-
-
Environments
-
-
);
};
diff --git a/frontend/src/base/components/environment/EnvironmentDrawer.jsx b/frontend/src/base/components/environment/EnvironmentDrawer.jsx
new file mode 100644
index 00000000..b4805823
--- /dev/null
+++ b/frontend/src/base/components/environment/EnvironmentDrawer.jsx
@@ -0,0 +1,1061 @@
+/* eslint-disable react/prop-types */
+import { useState, useEffect, useCallback, useMemo, useRef } from "react";
+import Cookies from "js-cookie";
+import isEqual from "lodash/isEqual.js";
+import {
+ Drawer,
+ Form,
+ Input,
+ Button,
+ Space,
+ Typography,
+ Alert,
+ Divider,
+ Tag,
+ Row,
+ Col,
+ Card,
+ Select,
+ Segmented,
+} from "antd";
+import {
+ DatabaseOutlined,
+ LinkOutlined,
+ InfoCircleOutlined,
+ ThunderboltOutlined,
+ CheckCircleFilled,
+ CloseCircleFilled,
+ ExclamationCircleFilled,
+ PlusOutlined,
+ EyeOutlined,
+ EyeInvisibleOutlined,
+ SafetyCertificateOutlined,
+} from "@ant-design/icons";
+import RjsfForm from "@rjsf/antd";
+import validator from "@rjsf/validator-ajv8";
+
+import { useAxiosPrivate } from "../../../service/axios-service";
+import { orgStore } from "../../../store/org-store";
+import encryptionService from "../../../service/encryption-service";
+import {
+ fetchAllConnections,
+ fetchSingleEnvironment,
+ fetchSingleConnection,
+ fetchDataSourceFields,
+ createEnvironmentApi,
+ updateEnvironmentApi,
+ testConnectionApi,
+ revealEnvironmentCredentials,
+ revealConnectionCredentials,
+} from "./environment-api-service";
+import { useNotificationService } from "../../../service/notification-service";
+import {
+ validateFormFieldName,
+ validateFormFieldDescription,
+ collapseSpaces,
+} from "./helper";
+import { SpinnerLoader } from "../../../widgets/spinner_loader";
+
+const { Text } = Typography;
+const { TextArea } = Input;
+
+/* ── Deployment type tile data ── */
+const DEPLOY_TYPES = [
+ {
+ value: "PROD",
+ label: "Production",
+ desc: "Live data, careful changes. Requires approvals on deploy.",
+ color: "#ef4444",
+ },
+ {
+ value: "STG",
+ label: "Staging",
+ desc: "Mirror of prod for pre-release testing.",
+ color: "#f59e0b",
+ },
+ {
+ value: "DEV",
+ label: "Development",
+ desc: "Personal or team sandbox. Freely editable.",
+ color: "#3b82f6",
+ },
+];
+
+/* ── Fields that render side-by-side ── */
+const HALF_WIDTH_FIELDS = new Set([
+ "host",
+ "port",
+ "user",
+ "passw",
+ "account",
+ "warehouse",
+ "catalog",
+ "schema",
+ "dbname",
+ "database",
+ "project_id",
+ "dataset_id",
+ "token",
+]);
+
+const GridObjectFieldTemplate = (props) => (
+
+ {props.properties.map((prop) => (
+
+ {prop.content}
+
+ ))}
+
+);
+
+/* ── Status tag ── */
+const StatusTag = ({ flag }) => {
+ if (flag === "GREEN")
+ return (
+
} color="success">
+ Healthy
+
+ );
+ if (flag === "YELLOW")
+ return (
+
} color="warning">
+ Stale
+
+ );
+ if (flag === "RED")
+ return (
+
} color="error">
+ Error
+
+ );
+ return null;
+};
+
+const EnvironmentDrawer = ({ open, onClose, envId, onSaved, getContainer }) => {
+ const axiosRef = useAxiosPrivate();
+ const { selectedOrgId } = orgStore();
+ const csrfToken = Cookies.get("csrftoken");
+ const { notify } = useNotificationService();
+ const [form] = Form.useForm();
+
+ // General
+ const [deployType, setDeployType] = useState("PROD");
+ const [connectionList, setConnectionList] = useState([]);
+ const [connListLoading, setConnListLoading] = useState(false);
+ const [selectedConnId, setSelectedConnId] = useState(null);
+ const [selectedConnInfo, setSelectedConnInfo] = useState(null);
+
+ // Credentials
+ const [connectionDataSource, setConnectionDataSource] = useState(null);
+ const [connectionSchema, setConnectionSchema] = useState({});
+ const [schema, setSchema] = useState(null);
+ const [uiSchema, setUiSchema] = useState({});
+ const [inputFields, setInputFields] = useState({});
+ const [connType, setConnType] = useState("host");
+ const [isCredentialsRevealed, setIsCredentialsRevealed] = useState(false);
+ const [isRevealLoading, setIsRevealLoading] = useState(false);
+
+ // Test & Save
+ const [isTestLoading, setIsTestLoading] = useState(false);
+ const [isTestSuccess, setIsTestSuccess] = useState(false);
+ const [testError, setTestError] = useState(null);
+ const [showErrorDetail, setShowErrorDetail] = useState(false);
+ const [isSaveLoading, setIsSaveLoading] = useState(false);
+ const [isEncryptionLoading, setIsEncryptionLoading] = useState(true);
+
+ // Change detection
+ const [initialData, setInitialData] = useState(null);
+ const [connectDetailBackup, setConnectDetailBackup] = useState({});
+ const hasCapturedRef = useRef(false);
+
+ const isEditing = Boolean(envId);
+
+ /* ── Init encryption ── */
+ useEffect(() => {
+ if (!open) return;
+ const init = async () => {
+ setIsEncryptionLoading(true);
+ try {
+ await encryptionService.initialize(selectedOrgId || "default_org");
+ } catch {
+ // proceed without
+ } finally {
+ setIsEncryptionLoading(false);
+ }
+ };
+ init();
+ }, [open, selectedOrgId]);
+
+ /* ── Fetch connection list ── */
+ useEffect(() => {
+ if (!open) return;
+ const load = async () => {
+ setConnListLoading(true);
+ try {
+ const data = await fetchAllConnections(axiosRef, selectedOrgId);
+ setConnectionList(data?.filter((el) => !el?.is_sample_project) || []);
+ } catch (error) {
+ notify({ error });
+ } finally {
+ setConnListLoading(false);
+ }
+ };
+ load();
+ }, [open, selectedOrgId]);
+
+ /* ── Fetch field schema when datasource changes ── */
+ useEffect(() => {
+ if (!connectionDataSource || !open) return;
+ const load = async () => {
+ try {
+ const details = await fetchDataSourceFields(
+ axiosRef,
+ selectedOrgId,
+ connectionDataSource
+ );
+ setConnectionSchema(details);
+ } catch (error) {
+ notify({ error });
+ }
+ };
+ load();
+ }, [connectionDataSource, selectedOrgId, open]);
+
+ /* ── Build RJSF schema ── */
+ useEffect(() => {
+ if (Object.keys(connectionSchema).length === 0) {
+ setSchema(null);
+ return;
+ }
+ if (["postgres", "snowflake"].includes(connectionDataSource)) {
+ const updatedProperties = { ...connectionSchema.properties };
+ delete updatedProperties["connection_type"];
+ const updatedRequired =
+ connType === "url"
+ ? ["connection_url"]
+ : connectionSchema?.required?.filter(
+ (el) =>
+ !["connection_url", "schema", "connection_type"].includes(el)
+ );
+ setSchema({
+ type: "object",
+ properties: updatedProperties,
+ required: updatedRequired,
+ });
+ const ui = {};
+ Object.keys(updatedProperties).forEach((key) => {
+ ui[key] = {
+ "ui:disabled":
+ connType === "url"
+ ? key !== "connection_url"
+ : key === "connection_url",
+ };
+ });
+ setUiSchema({ ...ui, schema: { "ui:disabled": false } });
+ } else {
+ setSchema(connectionSchema);
+ setUiSchema({});
+ }
+ }, [connectionSchema, connType, connectionDataSource]);
+
+ /* ── Load existing environment for edit ── */
+ useEffect(() => {
+ if (!envId || !open) return;
+ hasCapturedRef.current = false;
+ const load = async () => {
+ try {
+ const data = await fetchSingleEnvironment(
+ axiosRef,
+ selectedOrgId,
+ envId
+ );
+ const { connection, name, description, deployment_type } = data;
+ const connDetail = data.connection_details || {};
+ form.setFieldsValue({ name, description });
+ setDeployType(deployment_type);
+ setSelectedConnId(connection.id);
+ setConnectionDataSource(connection.datasource_name);
+ setInitialData({ name, description, deployment_type });
+ setConnectDetailBackup({ connection_details: connDetail });
+
+ const processed = { ...connDetail };
+ if (
+ processed.credentials &&
+ typeof processed.credentials === "object"
+ ) {
+ processed.credentials = JSON.stringify(
+ processed.credentials,
+ null,
+ 2
+ );
+ }
+ setInputFields(processed);
+ if (["postgres", "snowflake"].includes(connection.datasource_name)) {
+ setConnType(connDetail?.connection_type || "host");
+ }
+ setIsCredentialsRevealed(false);
+
+ // Find connection info for display
+ setSelectedConnInfo({
+ name: connection.name,
+ datasource_name: connection.datasource_name,
+ db_icon: connection.db_icon,
+ connection_flag: connection.connection_flag,
+ });
+ } catch (error) {
+ notify({ error });
+ }
+ };
+ load();
+ }, [envId, open]);
+
+ /* ── Handle connection selection ── */
+ const handleConnectionChange = useCallback(
+ async (connId) => {
+ if (connId === "__create__") {
+ // TODO: open nested connection drawer
+ return;
+ }
+ setSelectedConnId(connId);
+ setIsTestSuccess(false);
+ setTestError(null);
+ setIsCredentialsRevealed(false);
+ try {
+ const connData = await fetchSingleConnection(
+ axiosRef,
+ selectedOrgId,
+ connId
+ );
+ const { connection_details, datasource_name, name, db_icon } = connData;
+ setConnectionDataSource(datasource_name);
+ setSelectedConnInfo({
+ name,
+ datasource_name,
+ db_icon,
+ connection_flag: connData.connection_flag,
+ });
+ const processed = { ...connection_details };
+ if (
+ datasource_name === "bigquery" &&
+ processed.credentials &&
+ typeof processed.credentials === "object"
+ ) {
+ processed.credentials = JSON.stringify(
+ processed.credentials,
+ null,
+ 2
+ );
+ }
+ setInputFields(processed);
+ if (["postgres", "snowflake"].includes(datasource_name)) {
+ setConnType(connection_details?.connection_type || "host");
+ }
+ } catch (error) {
+ notify({ error });
+ }
+ },
+ [selectedOrgId]
+ );
+
+ /* ── Reveal credentials ── */
+ const handleReveal = useCallback(async () => {
+ if (isCredentialsRevealed) {
+ setIsCredentialsRevealed(false);
+ return;
+ }
+ setIsRevealLoading(true);
+ try {
+ const creds = envId
+ ? await revealEnvironmentCredentials(axiosRef, selectedOrgId, envId)
+ : await revealConnectionCredentials(
+ axiosRef,
+ selectedOrgId,
+ selectedConnId
+ );
+ const processed = { ...creds };
+ if (
+ connectionDataSource === "bigquery" &&
+ processed.credentials &&
+ typeof processed.credentials === "object"
+ ) {
+ processed.credentials = JSON.stringify(processed.credentials, null, 2);
+ }
+ setInputFields(processed);
+ setIsCredentialsRevealed(true);
+ } catch (error) {
+ notify({ error });
+ } finally {
+ setIsRevealLoading(false);
+ }
+ }, [
+ envId,
+ selectedConnId,
+ selectedOrgId,
+ isCredentialsRevealed,
+ connectionDataSource,
+ ]);
+
+ /* ── Test connection ── */
+ const handleTest = useCallback(async () => {
+ setIsTestLoading(true);
+ setIsTestSuccess(false);
+ setTestError(null);
+ setShowErrorDetail(false);
+ try {
+ const testData = {
+ ...inputFields,
+ ...(["postgres", "snowflake"].includes(connectionDataSource) && {
+ schema: inputFields.schema || "",
+ connection_type: connType,
+ }),
+ };
+ const data = encryptionService.isAvailable()
+ ? await encryptionService.encryptSensitiveFields(testData)
+ : testData;
+ await testConnectionApi(
+ axiosRef,
+ selectedOrgId,
+ csrfToken,
+ connectionDataSource,
+ data,
+ selectedConnId || null
+ );
+ setIsTestSuccess(true);
+ } catch (error) {
+ const errorData = error?.response?.data;
+ setTestError({
+ summary: error?.response?.status
+ ? `Error ${error.response.status}`
+ : "Connection test failed",
+ detail:
+ errorData?.error_message ||
+ errorData?.message ||
+ errorData?.error ||
+ error?.message ||
+ "Connection test failed",
+ });
+ } finally {
+ setIsTestLoading(false);
+ }
+ }, [
+ inputFields,
+ connectionDataSource,
+ connType,
+ selectedOrgId,
+ csrfToken,
+ selectedConnId,
+ ]);
+
+ /* ── Change detection ── */
+ const hasGeneralChanges = useMemo(() => {
+ if (!envId || !initialData) return false;
+ const formVals = form.getFieldsValue();
+ return (
+ formVals.name !== initialData.name ||
+ formVals.description !== initialData.description ||
+ deployType !== initialData.deployment_type
+ );
+ }, [envId, initialData, deployType, form]);
+
+ const hasCredChanges = useMemo(() => {
+ return !isEqual(connectDetailBackup, { connection_details: inputFields });
+ }, [inputFields, connectDetailBackup]);
+
+ /* ── Save ── */
+ const handleSave = useCallback(async () => {
+ try {
+ await form.validateFields();
+ } catch {
+ return;
+ }
+ if (!selectedConnId) {
+ notify({ type: "warning", message: "Please select a connection" });
+ return;
+ }
+ setIsSaveLoading(true);
+ try {
+ const { name, description } = form.getFieldsValue();
+ const payload = {
+ name,
+ description,
+ deployment_type: deployType,
+ connection: { id: selectedConnId },
+ connection_details: {
+ ...inputFields,
+ ...(["postgres", "snowflake"].includes(connectionDataSource) && {
+ connection_type: connType,
+ }),
+ },
+ };
+ if (encryptionService.isAvailable()) {
+ try {
+ const encrypted = await encryptionService.encryptSensitiveFields(
+ payload
+ );
+ Object.assign(payload, encrypted);
+ } catch {
+ // proceed unencrypted
+ }
+ }
+ if (!envId) {
+ const res = await createEnvironmentApi(
+ axiosRef,
+ selectedOrgId,
+ csrfToken,
+ payload
+ );
+ if (res.status === "success") {
+ notify({
+ type: "success",
+ message: "Environment created successfully",
+ });
+ onSaved?.();
+ onClose();
+ }
+ } else {
+ const res = await updateEnvironmentApi(
+ axiosRef,
+ selectedOrgId,
+ csrfToken,
+ envId,
+ payload
+ );
+ if (res.status === "success") {
+ notify({
+ type: "success",
+ message: "Environment updated successfully",
+ });
+ onSaved?.();
+ onClose();
+ }
+ }
+ } catch (error) {
+ notify({ error });
+ } finally {
+ setIsSaveLoading(false);
+ }
+ }, [
+ form,
+ deployType,
+ selectedConnId,
+ inputFields,
+ connectionDataSource,
+ connType,
+ envId,
+ selectedOrgId,
+ csrfToken,
+ ]);
+
+ /* ── Reset on close ── */
+ useEffect(() => {
+ if (!open) {
+ form.resetFields();
+ setDeployType("PROD");
+ setSelectedConnId(null);
+ setSelectedConnInfo(null);
+ setConnectionDataSource(null);
+ setConnectionSchema({});
+ setSchema(null);
+ setInputFields({});
+ setIsTestSuccess(false);
+ setTestError(null);
+ setInitialData(null);
+ setConnectDetailBackup({});
+ setIsCredentialsRevealed(false);
+ hasCapturedRef.current = false;
+ }
+ }, [open]);
+
+ /* ── RJSF handlers ── */
+ const handleFieldChange = ({ formData }) => {
+ setInputFields(formData);
+ if (isTestSuccess) setIsTestSuccess(false);
+ if (testError) {
+ setTestError(null);
+ setShowErrorDetail(false);
+ }
+ };
+
+ const handleConnTypeChange = (value) => {
+ setConnType(value);
+ };
+
+ const canSave = isEditing
+ ? (hasGeneralChanges && !hasCredChanges) ||
+ (hasCredChanges && isTestSuccess) ||
+ (hasGeneralChanges && hasCredChanges && isTestSuccess)
+ : isTestSuccess;
+
+ /* ── Connection dropdown options ── */
+ const connOptions = useMemo(() => {
+ const statusIcon = (flag) => {
+ if (flag === "GREEN")
+ return
;
+ if (flag === "YELLOW")
+ return
;
+ if (flag === "RED")
+ return
;
+ return null;
+ };
+ const options = connectionList.map((c) => ({
+ value: c.id,
+ label: (
+
+

+
{c.name}
+
{c.datasource_name}
+ {c.host && (
+
+ · {c.host}
+
+ )}
+
+ {statusIcon(c.connection_flag)}
+
+
+ ),
+ }));
+ options.push({
+ value: "__create__",
+ label: (
+
+ Create new connection
+
+ ),
+ });
+ return options;
+ }, [connectionList]);
+
+ return (
+
+
+
+ {isEditing ? "Edit Environment" : "New Environment"}
+
+
+ }
+ width={620}
+ open={open}
+ onClose={onClose}
+ destroyOnClose
+ keyboard={false}
+ maskClosable={false}
+ getContainer={getContainer}
+ className="conn-drawer"
+ footer={
+
+
+ Cancel
+
+
+
+ {isEditing ? "Save changes" : "Create environment"}
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ {/* ── Deployment Type ── */}
+ Deployment Type *
+
+ {DEPLOY_TYPES.map((dt) => (
+
setDeployType(dt.value)}
+ >
+
+
+ {dt.label}
+
+
{dt.desc}
+
+ ))}
+
+
+
+
+ {/* ── Connection ── */}
+ Connection *
+
+
+ Choose an existing connection or create one inline.
+
+
+ {/* Selected connection info card */}
+ {selectedConnInfo && (
+
+
+
+
+
+
+
+ {selectedConnInfo.name}
+
+
+ {selectedConnInfo.datasource_name}
+
+
+
+
+
+
+
+ )}
+
+ {/* ── Deployment Credentials (only when connection selected) ── */}
+ {selectedConnId && connectionDataSource && (
+ <>
+
+
+
+
+ Deployment Credentials
+
+ {connectionDataSource.toUpperCase()}
+
+
+
+
+
+ ) : (
+
+ )
+ }
+ loading={isRevealLoading}
+ onClick={handleReveal}
+ >
+ {isCredentialsRevealed ? "Hide" : "Reveal"}
+
+
+
+ }
+ message={
+
+ Pre-filled from {selectedConnInfo?.name}.
+ Override here to use different credentials for this
+ environment.
+
+ }
+ style={{ marginBottom: 14 }}
+ />
+
+ {/* URL vs Host toggle */}
+ {["postgres", "snowflake"].includes(connectionDataSource) && (
+
+
+ Individual fields
+
+ ),
+ },
+ {
+ value: "url",
+ label: (
+
+ Connection URL
+
+ ),
+ },
+ ]}
+ />
+
+ )}
+
+ {/* RJSF credential fields */}
+ {!schema ? (
+
+ ) : (
+ handleTest()}
+ uiSchema={uiSchema}
+ templates={{
+ ObjectFieldTemplate: GridObjectFieldTemplate,
+ ErrorListTemplate: () => null,
+ }}
+ transformErrors={(errors) =>
+ errors.map((e) => {
+ if (e.name === "required") {
+ const prop = e.params.missingProperty;
+ const title = schema?.properties?.[prop]?.title || prop;
+ return {
+ ...e,
+ message: `Please enter ${title}`,
+ };
+ }
+ return e;
+ })
+ }
+ omitExtraData
+ liveOmit
+ >
+ {/* Test connection card */}
+
+
+
+
+ Test this connection
+
+
+ Verify before saving. No data is read.
+
+
+
+ }
+ loading={isTestLoading}
+ style={{
+ background: "#f59e0b",
+ borderColor: "#f59e0b",
+ color: "white",
+ }}
+ >
+ Test connection
+
+
+
+ {isTestSuccess && (
+ }
+ style={{ marginTop: 10 }}
+ message={
+
+ Connection verified
+
+ }
+ />
+ )}
+ {testError && (
+
+ {testError.summary}
+
+ }
+ description={
+
+ {!showErrorDetail ? (
+
setShowErrorDetail(true)}
+ >
+ View details
+
+ ) : (
+ <>
+
setShowErrorDetail(false)}
+ >
+ Hide details
+
+
+ {testError.detail}
+
+ >
+ )}
+
+ }
+ />
+ )}
+
+
+ )}
+
+
+
+
+ setInputFields({
+ ...inputFields,
+ schema: e.target.value,
+ })
+ }
+ />
+
+ >
+ )}
+
+
+ );
+};
+
+EnvironmentDrawer.displayName = "EnvironmentDrawer";
+
+export { EnvironmentDrawer };
From bb482f79ac1ba06291f98cf3f5cfd04b423aac28 Mon Sep 17 00:00:00 2001
From: wicky <130177258+wicky-zipstack@users.noreply.github.com>
Date: Sun, 26 Apr 2026 12:31:54 +0530
Subject: [PATCH 03/12] feat: nested ConnectionDrawer from EnvironmentDrawer +
New connection link
- Click "Create new connection" in dropdown or "+ New connection" link
next to CONNECTION heading to open ConnectionDrawer as nested drawer
- On save, connection list refreshes and new connection is auto-selected
- Nested drawer scoped below topbar via getContainer passthrough
- Reuses same ConnectionDrawer component (zero code duplication)
---
.../environment/EnvironmentDrawer.jsx | 70 +++++++++++++++----
1 file changed, 56 insertions(+), 14 deletions(-)
diff --git a/frontend/src/base/components/environment/EnvironmentDrawer.jsx b/frontend/src/base/components/environment/EnvironmentDrawer.jsx
index b4805823..458ce62c 100644
--- a/frontend/src/base/components/environment/EnvironmentDrawer.jsx
+++ b/frontend/src/base/components/environment/EnvironmentDrawer.jsx
@@ -55,6 +55,7 @@ import {
collapseSpaces,
} from "./helper";
import { SpinnerLoader } from "../../../widgets/spinner_loader";
+import { ConnectionDrawer } from "../connection/ConnectionDrawer";
const { Text } = Typography;
const { TextArea } = Input;
@@ -151,6 +152,7 @@ const EnvironmentDrawer = ({ open, onClose, envId, onSaved, getContainer }) => {
const [connListLoading, setConnListLoading] = useState(false);
const [selectedConnId, setSelectedConnId] = useState(null);
const [selectedConnInfo, setSelectedConnInfo] = useState(null);
+ const [connDrawerOpen, setConnDrawerOpen] = useState(false);
// Credentials
const [connectionDataSource, setConnectionDataSource] = useState(null);
@@ -194,22 +196,36 @@ const EnvironmentDrawer = ({ open, onClose, envId, onSaved, getContainer }) => {
}, [open, selectedOrgId]);
/* ── Fetch connection list ── */
+ const loadConnections = useCallback(async () => {
+ setConnListLoading(true);
+ try {
+ const data = await fetchAllConnections(axiosRef, selectedOrgId);
+ return data?.filter((el) => !el?.is_sample_project) || [];
+ } catch (error) {
+ notify({ error });
+ return [];
+ } finally {
+ setConnListLoading(false);
+ }
+ }, [selectedOrgId]);
+
useEffect(() => {
if (!open) return;
- const load = async () => {
- setConnListLoading(true);
- try {
- const data = await fetchAllConnections(axiosRef, selectedOrgId);
- setConnectionList(data?.filter((el) => !el?.is_sample_project) || []);
- } catch (error) {
- notify({ error });
- } finally {
- setConnListLoading(false);
- }
- };
- load();
+ loadConnections().then(setConnectionList);
}, [open, selectedOrgId]);
+ /* ── Handle new connection created from nested drawer ── */
+ const handleConnectionCreated = useCallback(async () => {
+ const updated = await loadConnections();
+ setConnectionList(updated);
+ // Auto-select the newest connection (first in list, sorted by modified_at desc)
+ if (updated.length > 0) {
+ const newest = updated[0];
+ setSelectedConnId(newest.id);
+ handleConnectionChange(newest.id);
+ }
+ }, [loadConnections]);
+
/* ── Fetch field schema when datasource changes ── */
useEffect(() => {
if (!connectionDataSource || !open) return;
@@ -320,7 +336,7 @@ const EnvironmentDrawer = ({ open, onClose, envId, onSaved, getContainer }) => {
const handleConnectionChange = useCallback(
async (connId) => {
if (connId === "__create__") {
- // TODO: open nested connection drawer
+ setConnDrawerOpen(true);
return;
}
setSelectedConnId(connId);
@@ -755,7 +771,24 @@ const EnvironmentDrawer = ({ open, onClose, envId, onSaved, getContainer }) => {
{/* ── Connection ── */}
-
Connection *
+
+
+
+ Connection *
+
+
+
+ }
+ onClick={() => setConnDrawerOpen(true)}
+ style={{ padding: 0, fontSize: 12 }}
+ >
+ New connection
+
+
+
);
};
diff --git a/frontend/src/base/components/environment/EnvironmentDrawer.jsx b/frontend/src/base/components/environment/EnvironmentDrawer.jsx
index 458ce62c..46a8fbfa 100644
--- a/frontend/src/base/components/environment/EnvironmentDrawer.jsx
+++ b/frontend/src/base/components/environment/EnvironmentDrawer.jsx
@@ -224,7 +224,7 @@ const EnvironmentDrawer = ({ open, onClose, envId, onSaved, getContainer }) => {
setSelectedConnId(newest.id);
handleConnectionChange(newest.id);
}
- }, [loadConnections]);
+ }, [loadConnections, handleConnectionChange]);
/* ── Fetch field schema when datasource changes ── */
useEffect(() => {
From a0c5ec791af8be83806bfacf708986bc264793a6 Mon Sep 17 00:00:00 2001
From: wicky <130177258+wicky-zipstack@users.noreply.github.com>
Date: Tue, 28 Apr 2026 10:28:16 +0530
Subject: [PATCH 08/12] =?UTF-8?q?fix:=20address=20Tahier's=20PR=20#75=20re?=
=?UTF-8?q?view=20=E2=80=94=20blockers=20+=20code=20quality?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Blockers fixed:
- Connection list annotate: environmentmodels→environment_model,
projectdetails→project (correct related_name values)
- Env list annotate: projectdetails→project (correct related_name)
- EnvironmentDrawer TDZ: move handleConnectionCreated after
handleConnectionChange declaration
- EnvironmentDrawer hasGeneralChanges: add Form.useWatch for
name/description (was using stale form.getFieldsValue())
Code quality:
- Extract HALF_WIDTH_FIELDS, GridObjectFieldTemplate, StatusTag
to shared.jsx — imported by both drawers (no duplication)
- ConnectionDrawer: structuredClone instead of JSON.parse(stringify),
isEqual instead of JSON.stringify equality
- _get_host_display: add logger.warning on failure (was bare except pass)
---
.../application/session/connection_session.py | 11 ++-
.../application/session/env_session.py | 2 +-
.../connection/ConnectionDrawer.jsx | 41 +--------
.../src/base/components/connection/shared.jsx | 65 ++++++++++++++
.../environment/EnvironmentDrawer.jsx | 90 ++++---------------
5 files changed, 95 insertions(+), 114 deletions(-)
create mode 100644 frontend/src/base/components/connection/shared.jsx
diff --git a/backend/backend/application/session/connection_session.py b/backend/backend/application/session/connection_session.py
index f5ccaf44..41f82acc 100644
--- a/backend/backend/application/session/connection_session.py
+++ b/backend/backend/application/session/connection_session.py
@@ -1,8 +1,11 @@
+import logging
from typing import Any
from visitran.utils import import_file
from backend.application.utils import get_filter
+
+logger = logging.getLogger(__name__)
from backend.core.models.connection_models import ConnectionDetails
from backend.core.models.environment_models import EnvironmentModels
from backend.core.models.project_details import ProjectDetails
@@ -36,7 +39,7 @@ def _get_host_display(con_model):
if ds == "duckdb":
return details.get("file_path") or None
except Exception:
- pass
+ logger.warning("Failed to derive host display for connection %s", con_model.connection_id, exc_info=True)
return None
@@ -80,10 +83,10 @@ def get_all_connections(page: int, limit: int, filter_condition: dict[str, Any])
ConnectionDetails.objects.filter(**filter_condition)
.annotate(
env_count=Count(
- "environmentmodels",
- filter=Q(environmentmodels__is_deleted=False),
+ "environment_model",
+ filter=Q(environment_model__is_deleted=False),
),
- project_count=Count("projectdetails"),
+ project_count=Count("project"),
is_sample=Exists(
ProjectDetails.objects.filter(
connection_model_id=OuterRef("connection_id"),
diff --git a/backend/backend/application/session/env_session.py b/backend/backend/application/session/env_session.py
index 6da6ab0e..69a110ee 100644
--- a/backend/backend/application/session/env_session.py
+++ b/backend/backend/application/session/env_session.py
@@ -89,7 +89,7 @@ def get_all_environments(self, page: int, limit: int, filter_condition: dict[str
.select_related("connection_model")
.annotate(
job_count=Count("usertaskdetails"),
- project_count=Count("projectdetails"),
+ project_count=Count("project"),
)
.order_by("-modified_at")
)
diff --git a/frontend/src/base/components/connection/ConnectionDrawer.jsx b/frontend/src/base/components/connection/ConnectionDrawer.jsx
index 34b9a8b4..7614c402 100644
--- a/frontend/src/base/components/connection/ConnectionDrawer.jsx
+++ b/frontend/src/base/components/connection/ConnectionDrawer.jsx
@@ -48,46 +48,13 @@ import {
validateFormFieldDescription,
collapseSpaces,
} from "../environment/helper";
+import isEqual from "lodash/isEqual.js";
import { SpinnerLoader } from "../../../widgets/spinner_loader";
+import { GridObjectFieldTemplate } from "./shared";
const { Text } = Typography;
const { TextArea } = Input;
-/* ── Fields that should render half-width (side-by-side) ── */
-const HALF_WIDTH_FIELDS = new Set([
- "host",
- "port",
- "user",
- "passw",
- "account",
- "warehouse",
- "catalog",
- "schema",
- "dbname",
- "database",
- "project_id",
- "dataset_id",
- "token",
-]);
-
-/* ── Custom ObjectFieldTemplate for grid layout ── */
-const GridObjectFieldTemplate = (props) => (
- 0 &&
!hasCapturedOriginalRef.current
) {
- setOriginalConnectionData(JSON.parse(JSON.stringify(inputFields)));
+ setOriginalConnectionData(structuredClone(inputFields));
hasCapturedOriginalRef.current = true;
} else if (!connectionId) {
setOriginalConnectionData({});
@@ -325,7 +292,7 @@ const ConnectionDrawer = ({
const orig = { ...originalConnectionData };
delete curr.connection_type;
delete orig.connection_type;
- return JSON.stringify(curr) !== JSON.stringify(orig);
+ return !isEqual(curr, orig);
}, [connectionId, inputFields, originalConnectionData]);
const hasDetailsChanged = useMemo(() => {
diff --git a/frontend/src/base/components/connection/shared.jsx b/frontend/src/base/components/connection/shared.jsx
new file mode 100644
index 00000000..96e7457d
--- /dev/null
+++ b/frontend/src/base/components/connection/shared.jsx
@@ -0,0 +1,65 @@
+/* eslint-disable react/prop-types */
+import { Tag } from "antd";
+import {
+ CheckCircleFilled,
+ ExclamationCircleFilled,
+ CloseCircleFilled,
+} from "@ant-design/icons";
+
+/* ── Fields that render side-by-side in credential forms ── */
+export const HALF_WIDTH_FIELDS = new Set([
+ "host",
+ "port",
+ "user",
+ "passw",
+ "account",
+ "warehouse",
+ "catalog",
+ "schema",
+ "dbname",
+ "database",
+ "project_id",
+ "dataset_id",
+ "token",
+]);
+
+/* ── Custom RJSF ObjectFieldTemplate for grid layout ── */
+export const GridObjectFieldTemplate = (props) => (
+
+ {props.properties.map((prop) => (
+
+ {prop.content}
+
+ ))}
+
+);
+
+/* ── Status tag component ── */
+export const StatusTag = ({ flag }) => {
+ if (flag === "GREEN")
+ return (
+
} color="success">
+ Healthy
+
+ );
+ if (flag === "YELLOW")
+ return (
+
} color="warning">
+ Stale
+
+ );
+ if (flag === "RED")
+ return (
+
} color="error">
+ Error
+
+ );
+ return null;
+};
diff --git a/frontend/src/base/components/environment/EnvironmentDrawer.jsx b/frontend/src/base/components/environment/EnvironmentDrawer.jsx
index 46a8fbfa..3268bfe4 100644
--- a/frontend/src/base/components/environment/EnvironmentDrawer.jsx
+++ b/frontend/src/base/components/environment/EnvironmentDrawer.jsx
@@ -82,62 +82,8 @@ const DEPLOY_TYPES = [
},
];
-/* ── Fields that render side-by-side ── */
-const HALF_WIDTH_FIELDS = new Set([
- "host",
- "port",
- "user",
- "passw",
- "account",
- "warehouse",
- "catalog",
- "schema",
- "dbname",
- "database",
- "project_id",
- "dataset_id",
- "token",
-]);
-
-const GridObjectFieldTemplate = (props) => (
-
- {props.properties.map((prop) => (
-
- {prop.content}
-
- ))}
-
-);
-
-/* ── Status tag ── */
-const StatusTag = ({ flag }) => {
- if (flag === "GREEN")
- return (
-
} color="success">
- Healthy
-
- );
- if (flag === "YELLOW")
- return (
-
} color="warning">
- Stale
-
- );
- if (flag === "RED")
- return (
-
} color="error">
- Error
-
- );
- return null;
-};
+// Shared components from connection module
+import { GridObjectFieldTemplate, StatusTag } from "../connection/shared";
const EnvironmentDrawer = ({ open, onClose, envId, onSaved, getContainer }) => {
const axiosRef = useAxiosPrivate();
@@ -145,6 +91,8 @@ const EnvironmentDrawer = ({ open, onClose, envId, onSaved, getContainer }) => {
const csrfToken = Cookies.get("csrftoken");
const { notify } = useNotificationService();
const [form] = Form.useForm();
+ const watchedName = Form.useWatch("name", form);
+ const watchedDesc = Form.useWatch("description", form);
// General
const [deployType, setDeployType] = useState("PROD");
@@ -214,18 +162,6 @@ const EnvironmentDrawer = ({ open, onClose, envId, onSaved, getContainer }) => {
loadConnections().then(setConnectionList);
}, [open, selectedOrgId]);
- /* ── Handle new connection created from nested drawer ── */
- const handleConnectionCreated = useCallback(async () => {
- const updated = await loadConnections();
- setConnectionList(updated);
- // Auto-select the newest connection (first in list, sorted by modified_at desc)
- if (updated.length > 0) {
- const newest = updated[0];
- setSelectedConnId(newest.id);
- handleConnectionChange(newest.id);
- }
- }, [loadConnections, handleConnectionChange]);
-
/* ── Fetch field schema when datasource changes ── */
useEffect(() => {
if (!connectionDataSource || !open) return;
@@ -380,6 +316,17 @@ const EnvironmentDrawer = ({ open, onClose, envId, onSaved, getContainer }) => {
[selectedOrgId]
);
+ /* ── Handle new connection created from nested drawer ── */
+ const handleConnectionCreated = useCallback(async () => {
+ const updated = await loadConnections();
+ setConnectionList(updated);
+ if (updated.length > 0) {
+ const newest = updated[0];
+ setSelectedConnId(newest.id);
+ handleConnectionChange(newest.id);
+ }
+ }, [loadConnections, handleConnectionChange]);
+
/* ── Reveal credentials ── */
const handleReveal = useCallback(async () => {
if (isCredentialsRevealed) {
@@ -472,13 +419,12 @@ const EnvironmentDrawer = ({ open, onClose, envId, onSaved, getContainer }) => {
/* ── Change detection ── */
const hasGeneralChanges = useMemo(() => {
if (!envId || !initialData) return false;
- const formVals = form.getFieldsValue();
return (
- formVals.name !== initialData.name ||
- formVals.description !== initialData.description ||
+ watchedName !== initialData.name ||
+ watchedDesc !== initialData.description ||
deployType !== initialData.deployment_type
);
- }, [envId, initialData, deployType, form]);
+ }, [envId, initialData, deployType, watchedName, watchedDesc]);
const hasCredChanges = useMemo(() => {
return !isEqual(connectDetailBackup, { connection_details: inputFields });
From 2c784dd79377a865b609c246ec4c7ec6f45084d4 Mon Sep 17 00:00:00 2001
From: wicky <130177258+wicky-zipstack@users.noreply.github.com>
Date: Wed, 29 Apr 2026 09:27:40 +0530
Subject: [PATCH 09/12] fix: distinct=True on Count annotations + testingIds in
useMemo deps
Backend:
- Add distinct=True to all Count annotations in connection_session.py
and env_session.py to prevent cross-product inflation when multiple
multi-valued relations are joined in the same query
Frontend:
- Add testingIds to columns useMemo dependency array in EnvList.jsx
so the Test button spinner updates correctly
---
backend/backend/application/session/connection_session.py | 3 ++-
backend/backend/application/session/env_session.py | 4 ++--
frontend/src/base/components/environment/EnvList.jsx | 2 +-
3 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/backend/backend/application/session/connection_session.py b/backend/backend/application/session/connection_session.py
index 41f82acc..13a334e5 100644
--- a/backend/backend/application/session/connection_session.py
+++ b/backend/backend/application/session/connection_session.py
@@ -85,8 +85,9 @@ def get_all_connections(page: int, limit: int, filter_condition: dict[str, Any])
env_count=Count(
"environment_model",
filter=Q(environment_model__is_deleted=False),
+ distinct=True,
),
- project_count=Count("project"),
+ project_count=Count("project", distinct=True),
is_sample=Exists(
ProjectDetails.objects.filter(
connection_model_id=OuterRef("connection_id"),
diff --git a/backend/backend/application/session/env_session.py b/backend/backend/application/session/env_session.py
index 69a110ee..71185410 100644
--- a/backend/backend/application/session/env_session.py
+++ b/backend/backend/application/session/env_session.py
@@ -88,8 +88,8 @@ def get_all_environments(self, page: int, limit: int, filter_condition: dict[str
self.get_all_environment_models(filter_condition=filter_condition)
.select_related("connection_model")
.annotate(
- job_count=Count("usertaskdetails"),
- project_count=Count("project"),
+ job_count=Count("usertaskdetails", distinct=True),
+ project_count=Count("project", distinct=True),
)
.order_by("-modified_at")
)
diff --git a/frontend/src/base/components/environment/EnvList.jsx b/frontend/src/base/components/environment/EnvList.jsx
index d9b1a5d9..d618e5eb 100644
--- a/frontend/src/base/components/environment/EnvList.jsx
+++ b/frontend/src/base/components/environment/EnvList.jsx
@@ -378,7 +378,7 @@ const EnvList = () => {
),
},
],
- [canWrite, canDelete, token]
+ [canWrite, canDelete, token, testingIds]
);
const hasActiveFilters = searchQuery || filterType;
From 110abcf7f4dea62d3ea611e916064485dd4a17b4 Mon Sep 17 00:00:00 2001
From: wicky <130177258+wicky-zipstack@users.noreply.github.com>
Date: Wed, 29 Apr 2026 10:03:05 +0530
Subject: [PATCH 10/12] fix: drop explicit page args from test handlers to
avoid stale closure
handleTestEnv and handleTestConnection were passing currentPage/pageSize
explicitly to getEnvData/getConnectionData, but since they're not in the
columns useMemo deps, the captured values could be stale after pagination.
Now call with no args (defaults to current state).
---
frontend/src/base/components/connection/ConnectionList.jsx | 4 ++--
frontend/src/base/components/environment/EnvList.jsx | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/frontend/src/base/components/connection/ConnectionList.jsx b/frontend/src/base/components/connection/ConnectionList.jsx
index 25f4a466..9aba826b 100644
--- a/frontend/src/base/components/connection/ConnectionList.jsx
+++ b/frontend/src/base/components/connection/ConnectionList.jsx
@@ -195,10 +195,10 @@ const ConnectionList = () => {
}/connection/${connId}/test`,
});
notify({ type: "success", message: "Connection test passed" });
- getConnectionData(currentPage, pageSize);
+ getConnectionData();
} catch (error) {
notify({ error });
- getConnectionData(currentPage, pageSize);
+ getConnectionData();
} finally {
setTestingIds((prev) => {
// eslint-disable-next-line no-unused-vars
diff --git a/frontend/src/base/components/environment/EnvList.jsx b/frontend/src/base/components/environment/EnvList.jsx
index d618e5eb..6ec3cb8b 100644
--- a/frontend/src/base/components/environment/EnvList.jsx
+++ b/frontend/src/base/components/environment/EnvList.jsx
@@ -122,7 +122,7 @@ const EnvList = () => {
}/connection/${connId}/test`,
});
notify({ type: "success", message: "Connection test passed" });
- getEnvData(currentPage, pageSize);
+ getEnvData();
} catch (error) {
notify({ error });
} finally {
From a0344bedaa40744f27d91b318e86162b16c7120d Mon Sep 17 00:00:00 2001
From: wicky <130177258+wicky-zipstack@users.noreply.github.com>
Date: Wed, 29 Apr 2026 21:40:16 +0530
Subject: [PATCH 11/12] fix: button labels + env delete dependency warning
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ConnectionDrawer: "Save connection" → "Create connection" / "Update connection"
- EnvironmentDrawer: "Save changes" → "Update environment"
- EnvList delete Popconfirm: show job/project dependency warning
when environment has scheduled jobs (e.g., "used by 3 job(s)")
---
.../base/components/connection/ConnectionDrawer.jsx | 2 +-
frontend/src/base/components/environment/EnvList.jsx | 10 +++++++++-
.../base/components/environment/EnvironmentDrawer.jsx | 2 +-
3 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/frontend/src/base/components/connection/ConnectionDrawer.jsx b/frontend/src/base/components/connection/ConnectionDrawer.jsx
index 7614c402..58906002 100644
--- a/frontend/src/base/components/connection/ConnectionDrawer.jsx
+++ b/frontend/src/base/components/connection/ConnectionDrawer.jsx
@@ -532,7 +532,7 @@ const ConnectionDrawer = ({
loading={isSaveLoading || isEncryptionLoading}
disabled={!canSave || isEncryptionLoading}
>
- Save connection
+ {isEditing ? "Update connection" : "Create connection"}
diff --git a/frontend/src/base/components/environment/EnvList.jsx b/frontend/src/base/components/environment/EnvList.jsx
index 6ec3cb8b..91bb69b9 100644
--- a/frontend/src/base/components/environment/EnvList.jsx
+++ b/frontend/src/base/components/environment/EnvList.jsx
@@ -358,7 +358,15 @@ const EnvList = () => {
0
+ ? `"${record.name}" is used by ${record.job_count} job(s)${
+ record.project_count
+ ? ` and ${record.project_count} project(s)`
+ : ""
+ }. Deleting may break scheduled runs.`
+ : `"${record.name}" will be permanently removed.`
+ }
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => deleteEnv(record.id)}
diff --git a/frontend/src/base/components/environment/EnvironmentDrawer.jsx b/frontend/src/base/components/environment/EnvironmentDrawer.jsx
index 3268bfe4..5f1a6839 100644
--- a/frontend/src/base/components/environment/EnvironmentDrawer.jsx
+++ b/frontend/src/base/components/environment/EnvironmentDrawer.jsx
@@ -645,7 +645,7 @@ const EnvironmentDrawer = ({ open, onClose, envId, onSaved, getContainer }) => {
loading={isSaveLoading || isEncryptionLoading}
disabled={!canSave || isEncryptionLoading}
>
- {isEditing ? "Save changes" : "Create environment"}
+ {isEditing ? "Update environment" : "Create environment"}
From d91acc1a9581971b52c0a4e0ff4f75bc958b32f6 Mon Sep 17 00:00:00 2001
From: wicky <130177258+wicky-zipstack@users.noreply.github.com>
Date: Thu, 30 Apr 2026 14:02:47 +0530
Subject: [PATCH 12/12] fix: set is_tested=True on environment create and
update
The Save button is gated behind a successful test, so if the user
saved the environment, the credentials have been tested.
- Create: set is_tested=True in constructor
- Update: set is_tested=True before save
- FE: show "Tested" (green) instead of "Healthy" for clarity
Previously is_tested was never set to True, so all environments
showed "Untested" permanently.
---
backend/backend/application/session/env_session.py | 2 ++
frontend/src/base/components/environment/EnvList.jsx | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/backend/backend/application/session/env_session.py b/backend/backend/application/session/env_session.py
index 71185410..75b744a6 100644
--- a/backend/backend/application/session/env_session.py
+++ b/backend/backend/application/session/env_session.py
@@ -59,6 +59,7 @@ def create_environment(self, environment_details: dict[str, Any]) -> Environment
env_connection_data=env_connection_data,
env_custom_data=environment_details.get("custom_data", {}),
connection_model=connection_model,
+ is_tested=True,
)
env_model.save()
return env_model
@@ -154,6 +155,7 @@ def update_environment(self, environment_id: str, environment_details: dict[str,
)
env_model.env_connection_data = env_connection_data
env_model.env_custom_data = environment_details.get("custom_data", {})
+ env_model.is_tested = True
env_model.save()
return env_model
except KeyError as e:
diff --git a/frontend/src/base/components/environment/EnvList.jsx b/frontend/src/base/components/environment/EnvList.jsx
index 91bb69b9..80b5a86e 100644
--- a/frontend/src/base/components/environment/EnvList.jsx
+++ b/frontend/src/base/components/environment/EnvList.jsx
@@ -70,7 +70,7 @@ const StatusTag = ({ tested }) => {
if (tested) {
return (
} color="success">
- Healthy
+ Tested
);
}