feat(service): variables editor with masked-secret preservation

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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 10:49:45 +02:00
1 parent e6e76c1084
commit 1d5b99e1ba
6 files changed
+354 -13

No files matched your search

+6 -2
View File
@@ -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), plan → real `POST /databases` provisioning, not a hollow service row),
worker/cron types, and a Template entry that jumps to the catalog with worker/cron types, and a Template entry that jumps to the catalog with
the project preselected (`/templates?project=`). the project preselected (`/templates?project=`).
- [ ] **Variables UX**: secret masking, per-environment values, bulk paste - [x] **Variables UX**: Variables section on the service detail page — add /
(`KEY=VAL` lines), validation errors inline. 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 - [ ] **Deploy lifecycle polish**: build → deploy status propagation to canvas
node, failed-deploy surfacing with log tail link, rollback confirmation. node, failed-deploy surfacing with log tail link, rollback confirmation.
- [ ] **Node/port collision handling**: two services claiming the same host - [ ] **Node/port collision handling**: two services claiming the same host
+32 -3
View File
@@ -9,6 +9,10 @@ import (
"github.com/google/uuid" "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 { type EnvironmentVariable struct {
ID uuid.UUID `json:"id" db:"id"` ID uuid.UUID `json:"id" db:"id"`
ServiceID uuid.UUID `json:"service_id" db:"service_id"` ServiceID uuid.UUID `json:"service_id" db:"service_id"`
@@ -91,7 +95,7 @@ func handleGetVariables(c *gin.Context) {
return return
} }
if v.IsSecret { if v.IsSecret {
v.Value = "********" v.Value = maskedSecretValue
} }
variables = append(variables, v) variables = append(variables, v)
} }
@@ -143,6 +147,25 @@ func handleUpdateVariables(c *gin.Context) {
return 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() tx, err := db.(*database.DB).Begin()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
@@ -158,11 +181,17 @@ func handleUpdateVariables(c *gin.Context) {
now := time.Now() now := time.Now()
for _, v := range req.Variables { 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() varID := uuid.New()
_, err = tx.Exec( _, err = tx.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at) `INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`, 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
@@ -198,7 +227,7 @@ func handleUpdateVariables(c *gin.Context) {
continue continue
} }
if v.IsSecret { if v.IsSecret {
v.Value = "********" v.Value = maskedSecretValue
} }
variables = append(variables, v) variables = append(variables, v)
} }
@@ -10,9 +10,12 @@ import {
getServiceMetrics, getServiceMetrics,
listDeployments, listDeployments,
listServiceLogs, listServiceLogs,
listServiceVariables,
rollbackDeployment, rollbackDeployment,
updateServiceVariables,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data'; import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data';
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time'; import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components'; import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
import { import {
@@ -20,6 +23,11 @@ import {
FileText, FileText,
Settings, Settings,
Sliders, Sliders,
KeyRound,
Plus,
Trash2,
ClipboardPaste,
Save,
Check, Check,
X, X,
Loader2, Loader2,
@@ -34,12 +42,13 @@ import {
Box, Box,
} from 'lucide-react'; } 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 }> = [ const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
{ key: 'metrics', label: 'Metrics', icon: Activity }, { key: 'metrics', label: 'Metrics', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText }, { key: 'logs', label: 'Logs', icon: FileText },
{ key: 'config', label: 'Config', icon: Sliders }, { key: 'config', label: 'Config', icon: Sliders },
{ key: 'variables', label: 'Variables', icon: KeyRound },
{ key: 'settings', label: 'Settings', icon: Settings }, { key: 'settings', label: 'Settings', icon: Settings },
]; ];
@@ -84,6 +93,9 @@ export function ServiceDetailPage() {
const [activeSection, setActiveSection] = useState<ServiceSection>('metrics'); const [activeSection, setActiveSection] = useState<ServiceSection>('metrics');
const [logTail, setLogTail] = useState('100'); const [logTail, setLogTail] = useState('100');
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null);
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkText, setBulkText] = useState('');
const projectQuery = useQuery({ const projectQuery = useQuery({
queryKey: ['project', projectId], queryKey: ['project', projectId],
@@ -104,6 +116,24 @@ export function ServiceDetailPage() {
refetchInterval: 4000, 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({ const serviceLogsQuery = useQuery({
queryKey: ['service-logs', serviceId, logTail], queryKey: ['service-logs', serviceId, logTail],
queryFn: () => listServiceLogs(serviceId, { tail: logTail }), queryFn: () => listServiceLogs(serviceId, { tail: logTail }),
@@ -157,6 +187,34 @@ export function ServiceDetailPage() {
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data; const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.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<VariableDraft>) =>
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({ const metricsQuery = useQuery({
queryKey: ['service-metrics', serviceId], queryKey: ['service-metrics', serviceId],
queryFn: () => getServiceMetrics(serviceId), queryFn: () => getServiceMetrics(serviceId),
@@ -818,6 +876,155 @@ export function ServiceDetailPage() {
</div> </div>
)} )}
{activeSection === 'variables' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<KeyRound size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
<p className="text-sm text-[var(--text-secondary)]">Runtime values injected into the service container</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setBulkOpen((open) => !open)}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<ClipboardPaste size={12} />
Paste .env
</button>
<button
onClick={() => setVarDrafts([...varRows, { key: '', value: '', isSecret: false }])}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<Plus size={12} />
Add variable
</button>
<button
onClick={() => saveVariablesMutation.mutate(varRows)}
disabled={!varDirty || Object.keys(varErrors).length > 0 || saveVariablesMutation.isPending}
className="flex items-center gap-2 px-4 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] shadow transition-all disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
>
{saveVariablesMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
Save
</button>
</div>
</div>
{bulkOpen && (
<div className="mb-4 panel-soft p-4">
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Paste .env contents
</label>
<textarea
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
rows={6}
placeholder={'DATABASE_URL=postgres://...\nAPI_KEY=secret\n# comments are ignored'}
className="w-full px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
<div className="mt-3 flex justify-end gap-2">
<button
onClick={() => { setBulkOpen(false); setBulkText(''); }}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
Cancel
</button>
<button
onClick={applyBulkPaste}
className="px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] transition-all"
style={{ background: 'var(--accent-primary)' }}
>
Import {parseDotenv(bulkText).length > 0 ? `${parseDotenv(bulkText).length} variables` : ''}
</button>
</div>
</div>
)}
{variablesQuery.isLoading ? (
<div className="flex items-center justify-center py-12 text-[var(--text-muted)]">
<Loader2 size={20} className="animate-spin" />
</div>
) : varRows.length === 0 ? (
<div className="panel-soft p-8 text-center">
<p className="text-sm text-[var(--text-secondary)]">No variables configured.</p>
<p className="mt-1 text-xs text-[var(--text-muted)]">Add rows or paste a .env file to get started.</p>
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 px-1">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Key</span>
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Value</span>
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Secret</span>
<span />
</div>
{varRows.map((row, i) => (
<div key={i}>
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
<input
value={row.key}
onChange={(e) => updateVarRow(i, { key: e.target.value })}
placeholder="KEY"
className={`h-10 px-3 rounded-[var(--radius-md)] border bg-[var(--surface-muted)] mono text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:ring-1 transition-all ${
varErrors[i]
? 'border-[var(--error)] focus:border-[var(--error)] focus:ring-[var(--error)]'
: 'border-[var(--border-subtle)] focus:border-[var(--accent-primary)] focus:ring-[var(--accent-primary)]'
}`}
/>
<input
value={row.value}
onChange={(e) => updateVarRow(i, { value: e.target.value })}
type={row.isSecret ? 'password' : 'text'}
placeholder="value"
autoComplete="off"
className="h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
<button
type="button"
title={row.isSecret ? 'Secret — value masked on save' : 'Mark as secret'}
onClick={() => updateVarRow(i, { isSecret: !row.isSecret })}
className={`w-10 h-10 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${
row.isSecret
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
}`}
>
<KeyRound size={14} />
</button>
<button
type="button"
onClick={() => removeVarRow(i)}
className="w-10 h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
>
<Trash2 size={14} />
</button>
</div>
{varErrors[i] && (
<p className="mt-1 px-1 text-xs text-[var(--error)]">{varErrors[i]}</p>
)}
</div>
))}
</div>
)}
<p className="mt-4 text-xs text-[var(--text-muted)]">
Secret values are stored encrypted and masked as ******** in responses. Leaving a masked
value unchanged keeps the stored secret; editing replaces it. Saving replaces the full set.
</p>
{saveVariablesMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">
{saveVariablesMutation.error instanceof Error
? saveVariablesMutation.error.message
: 'Failed to save variables'}
</p>
)}
</div>
)}
{activeSection === 'settings' && ( {activeSection === 'settings' && (
<div className="panel p-6"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { parseDotenv, validateVariableRows } from './variable-utils';
describe('parseDotenv', () => {
it('parses KEY=VAL lines and skips blanks/comments', () => {
const rows = parseDotenv('A=1\n\n# comment\nB=two\n');
expect(rows).toEqual([
{ key: 'A', value: '1', isSecret: false },
{ key: 'B', value: 'two', isSecret: false },
]);
});
it('strips export prefix and quotes, keeps = inside values', () => {
const rows = parseDotenv('export A="x=y"\nB=\' spaced \'\nC=plain\n');
expect(rows.map((r) => [r.key, r.value])).toEqual([
['A', 'x=y'],
['B', ' spaced '],
['C', 'plain'],
]);
});
it('ignores malformed lines without a key', () => {
expect(parseDotenv('=novalue\njusttext\n')).toEqual([]);
});
});
describe('validateVariableRows', () => {
it('flags empty, invalid, and duplicate keys', () => {
const errors = validateVariableRows([
{ key: 'OK', value: '1', isSecret: false },
{ key: '', value: 'x', isSecret: false },
{ key: '9BAD', value: 'x', isSecret: false },
{ key: 'OK', value: '2', isSecret: false },
]);
expect(errors[0]).toBeUndefined();
expect(errors[1]).toBe('Key is required');
expect(errors[2]).toContain('Letters, digits');
expect(errors[3]).toBe('Duplicate key');
});
});
@@ -0,0 +1,48 @@
export type VariableDraft = { key: string; value: string; isSecret: boolean };
export const VARIABLE_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
// parseDotenv handles KEY=VAL lines, '#' comments, 'export ' prefixes and
// single/double-quoted values — enough for standard .env pastes.
export function parseDotenv(text: string): VariableDraft[] {
const rows: VariableDraft[] = [];
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const stripped = line.startsWith('export ') ? line.slice(7).trim() : line;
const eq = stripped.indexOf('=');
if (eq <= 0) {
continue;
}
const key = stripped.slice(0, eq).trim();
let value = stripped.slice(eq + 1).trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
rows.push({ key, value, isSecret: false });
}
return rows;
}
export function validateVariableRows(rows: VariableDraft[]): Record<number, string> {
const errors: Record<number, string> = {};
const seen = new Set<string>();
rows.forEach((row, i) => {
const key = row.key.trim();
if (!key) {
errors[i] = 'Key is required';
} else if (!VARIABLE_KEY_RE.test(key)) {
errors[i] = 'Letters, digits, underscore; must not start with a digit';
} else if (seen.has(key)) {
errors[i] = 'Duplicate key';
} else {
seen.add(key);
}
});
return errors;
}
+20 -7
View File
@@ -936,16 +936,12 @@ export async function deleteService(serviceId: string): Promise<void> {
}); });
} }
export async function listServiceVariables(serviceId: string): Promise<ServiceVariable[]> { function normalizeServiceVariables(rows: RawServiceVariable[] | undefined): ServiceVariable[] {
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(`/services/${serviceId}/variables`);
const rows = payload.variables ?? [];
const result: ServiceVariable[] = []; const result: ServiceVariable[] = [];
for (const row of rows ?? []) {
for (const row of rows) {
if (!row.id || !row.service_id || !row.key) { if (!row.id || !row.service_id || !row.key) {
continue; continue;
} }
result.push({ result.push({
id: row.id, id: row.id,
serviceId: row.service_id, serviceId: row.service_id,
@@ -956,10 +952,27 @@ export async function listServiceVariables(serviceId: string): Promise<ServiceVa
updatedAt: row.updated_at, updatedAt: row.updated_at,
}); });
} }
return result; return result;
} }
export async function listServiceVariables(serviceId: string): Promise<ServiceVariable[]> {
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(`/services/${serviceId}/variables`);
return normalizeServiceVariables(payload.variables);
}
export type UpdateServiceVariableInput = components['schemas']['VariableInput'];
export async function updateServiceVariables(
serviceId: string,
variables: UpdateServiceVariableInput[],
): Promise<ServiceVariable[]> {
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(
`/services/${serviceId}/variables`,
{ method: 'PUT', body: JSON.stringify({ variables }) },
);
return normalizeServiceVariables(payload.variables);
}
export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> { export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (input.resource) { if (input.resource) {