From 1d5b99e1ba95398352d3809e57ea1f551be75a0f Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 17 Sep 2026 10:49:45 +0200 Subject: [PATCH] feat(service): variables editor with masked-secret preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /services/{id}/variables replaced every row, and GET masks secrets as ******** — echoing the fetched list back would have overwritten real secrets with asterisks. The backend now keeps the stored value when a submitted secret still carries the mask. Service detail gains a Variables section: add/edit/remove rows, secret toggle, bulk .env paste (comments, export prefix, quoted values), and inline validation for empty/invalid/duplicate keys. Parser and validator live in variable-utils.ts with unit coverage. Generated OpenAPI types drive the new updateServiceVariables client call. ROADMAP: Variables UX marked done; per-environment values are already structural — each environment owns its service instances. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 8 +- app/backend/internal/api/variables.go | 35 ++- .../service/pages/ServiceDetailPage.tsx | 209 +++++++++++++++++- .../features/service/variable-utils.test.ts | 40 ++++ .../src/features/service/variable-utils.ts | 48 ++++ app/frontend/src/lib/api-client.ts | 27 ++- 6 files changed, 354 insertions(+), 13 deletions(-) create mode 100644 app/frontend/src/features/service/variable-utils.test.ts create mode 100644 app/frontend/src/features/service/variable-utils.ts diff --git a/ROADMAP.md b/ROADMAP.md index 5cf2063..fa361d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -219,8 +219,12 @@ pieces exist; this phase closes the gaps that block real usage. plan → real `POST /databases` provisioning, not a hollow service row), worker/cron types, and a Template entry that jumps to the catalog with the project preselected (`/templates?project=`). -- [ ] **Variables UX**: secret masking, per-environment values, bulk paste - (`KEY=VAL` lines), validation errors inline. +- [x] **Variables UX**: Variables section on the service detail page — add / + edit / remove rows, secret masking (masked `********` secrets preserved on + save; editing replaces), bulk `.env` paste (`KEY=VAL`, comments, `export` + prefix, quoted values), inline validation (required / invalid / duplicate + keys). Per-environment values are already covered by the schema: each + environment owns its service instances, and variables are per-service. - [ ] **Deploy lifecycle polish**: build → deploy status propagation to canvas node, failed-deploy surfacing with log tail link, rollback confirmation. - [ ] **Node/port collision handling**: two services claiming the same host diff --git a/app/backend/internal/api/variables.go b/app/backend/internal/api/variables.go index 32a169b..8a53dd9 100644 --- a/app/backend/internal/api/variables.go +++ b/app/backend/internal/api/variables.go @@ -9,6 +9,10 @@ import ( "github.com/google/uuid" ) +// maskedSecretValue is the placeholder returned for secret values; on update +// it means "keep the stored value" rather than overwriting with asterisks. +const maskedSecretValue = "********" + type EnvironmentVariable struct { ID uuid.UUID `json:"id" db:"id"` ServiceID uuid.UUID `json:"service_id" db:"service_id"` @@ -91,7 +95,7 @@ func handleGetVariables(c *gin.Context) { return } if v.IsSecret { - v.Value = "********" + v.Value = maskedSecretValue } variables = append(variables, v) } @@ -143,6 +147,25 @@ func handleUpdateVariables(c *gin.Context) { return } + // Snapshot stored secrets so a client echoing back the masked placeholder + // does not destroy the real value. + storedSecrets := map[string]string{} + secretRows, err := db.(*database.DB).Query( + `SELECT key, value FROM environment_variables WHERE service_id = $1 AND is_secret = TRUE`, + serviceID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"}) + return + } + for secretRows.Next() { + var k, v string + if err := secretRows.Scan(&k, &v); err == nil { + storedSecrets[k] = v + } + } + secretRows.Close() + tx, err := db.(*database.DB).Begin() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) @@ -158,11 +181,17 @@ func handleUpdateVariables(c *gin.Context) { now := time.Now() for _, v := range req.Variables { + value := v.Value + if v.IsSecret && value == maskedSecretValue { + if stored, ok := storedSecrets[v.Key]; ok { + value = stored + } + } varID := uuid.New() _, err = tx.Exec( `INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - varID, serviceID, v.Key, v.Value, v.IsSecret, now, now, + varID, serviceID, v.Key, value, v.IsSecret, now, now, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key}) @@ -198,7 +227,7 @@ func handleUpdateVariables(c *gin.Context) { continue } if v.IsSecret { - v.Value = "********" + v.Value = maskedSecretValue } variables = append(variables, v) } diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index f941f90..32322ac 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -10,9 +10,12 @@ import { getServiceMetrics, listDeployments, listServiceLogs, + listServiceVariables, rollbackDeployment, + updateServiceVariables, } from '@/lib/api-client'; import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data'; +import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils'; import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time'; import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components'; import { @@ -20,6 +23,11 @@ import { FileText, Settings, Sliders, + KeyRound, + Plus, + Trash2, + ClipboardPaste, + Save, Check, X, Loader2, @@ -34,12 +42,13 @@ import { Box, } from 'lucide-react'; -type ServiceSection = 'metrics' | 'logs' | 'config' | 'settings'; +type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'settings'; const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [ { key: 'metrics', label: 'Metrics', icon: Activity }, { key: 'logs', label: 'Logs', icon: FileText }, { key: 'config', label: 'Config', icon: Sliders }, + { key: 'variables', label: 'Variables', icon: KeyRound }, { key: 'settings', label: 'Settings', icon: Settings }, ]; @@ -84,6 +93,9 @@ export function ServiceDetailPage() { const [activeSection, setActiveSection] = useState('metrics'); const [logTail, setLogTail] = useState('100'); + const [varDrafts, setVarDrafts] = useState(null); + const [bulkOpen, setBulkOpen] = useState(false); + const [bulkText, setBulkText] = useState(''); const projectQuery = useQuery({ queryKey: ['project', projectId], @@ -104,6 +116,24 @@ export function ServiceDetailPage() { refetchInterval: 4000, }); + const variablesQuery = useQuery({ + queryKey: ['service-variables', serviceId], + queryFn: () => listServiceVariables(serviceId), + enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables', + }); + + const saveVariablesMutation = useMutation({ + mutationFn: (rows: VariableDraft[]) => + updateServiceVariables( + serviceId, + rows.map((row) => ({ key: row.key.trim(), value: row.value, is_secret: row.isSecret })), + ), + onSuccess: (data) => { + queryClient.setQueryData(['service-variables', serviceId], data); + setVarDrafts(null); + }, + }); + const serviceLogsQuery = useQuery({ queryKey: ['service-logs', serviceId, logTail], queryFn: () => listServiceLogs(serviceId, { tail: logTail }), @@ -157,6 +187,34 @@ export function ServiceDetailPage() { const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data; const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data; + const varRows: VariableDraft[] = useMemo( + () => + varDrafts ?? + (variablesQuery.data ?? []).map((v) => ({ key: v.key, value: v.value, isSecret: v.isSecret })), + [varDrafts, variablesQuery.data], + ); + const varErrors = useMemo(() => validateVariableRows(varRows), [varRows]); + const varDirty = varDrafts !== null; + const updateVarRow = (index: number, patch: Partial) => + setVarDrafts(varRows.map((row, i) => (i === index ? { ...row, ...patch } : row))); + const removeVarRow = (index: number) => setVarDrafts(varRows.filter((_, i) => i !== index)); + const applyBulkPaste = () => { + const parsed = parseDotenv(bulkText); + if (parsed.length === 0) return; + const merged = [...varRows]; + for (const row of parsed) { + const existing = merged.findIndex((r) => r.key === row.key); + if (existing >= 0) { + merged[existing] = { ...merged[existing], value: row.value }; + } else { + merged.push(row); + } + } + setVarDrafts(merged); + setBulkText(''); + setBulkOpen(false); + }; + const metricsQuery = useQuery({ queryKey: ['service-metrics', serviceId], queryFn: () => getServiceMetrics(serviceId), @@ -818,6 +876,155 @@ export function ServiceDetailPage() { )} + {activeSection === 'variables' && ( +
+
+
+
+ +
+
+

Environment Variables

+

Runtime values injected into the service container

+
+
+
+ + + +
+
+ + {bulkOpen && ( +
+ +