-
-
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..5f1a6839
--- /dev/null
+++ b/frontend/src/base/components/environment/EnvironmentDrawer.jsx
@@ -0,0 +1,1049 @@
+/* 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";
+import { ConnectionDrawer } from "../connection/ConnectionDrawer";
+
+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",
+ },
+];
+
+// Shared components from connection module
+import { GridObjectFieldTemplate, StatusTag } from "../connection/shared";
+
+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();
+ const watchedName = Form.useWatch("name", form);
+ const watchedDesc = Form.useWatch("description", form);
+
+ // 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);
+ const [connDrawerOpen, setConnDrawerOpen] = useState(false);
+
+ // 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 ── */
+ 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;
+ loadConnections().then(setConnectionList);
+ }, [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__") {
+ setConnDrawerOpen(true);
+ 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]
+ );
+
+ /* ── 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) {
+ 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;
+ return (
+ watchedName !== initialData.name ||
+ watchedDesc !== initialData.description ||
+ deployType !== initialData.deployment_type
+ );
+ }, [envId, initialData, deployType, watchedName, watchedDesc]);
+
+ 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 ? "Update environment" : "Create environment"}
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ {/* ── Deployment Type ── */}
+ Deployment Type *
+
+ {DEPLOY_TYPES.map((dt) => (
+
setDeployType(dt.value)}
+ >
+
+
+ {dt.label}
+
+
{dt.desc}
+
+ ))}
+
+
+
+
+ {/* ── Connection ── */}
+
+
+
+ Connection *
+
+
+
+ }
+ onClick={() => setConnDrawerOpen(true)}
+ style={{ padding: 0, fontSize: 12 }}
+ >
+ New 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,
+ })
+ }
+ />
+
+ >
+ )}
+
+
+ {/* Nested Connection Drawer */}
+ setConnDrawerOpen(false)}
+ connectionId=""
+ onSaved={handleConnectionCreated}
+ getContainer={getContainer}
+ />
+
+ );
+};
+
+EnvironmentDrawer.displayName = "EnvironmentDrawer";
+
+export { EnvironmentDrawer };