mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
e6e76c1084
commit
1d5b99e1ba
6 files changed
+354
-13
No files matched your search
@@ -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<ServiceSection>('metrics');
|
||||
const [logTail, setLogTail] = useState('100');
|
||||
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(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<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({
|
||||
queryKey: ['service-metrics', serviceId],
|
||||
queryFn: () => getServiceMetrics(serviceId),
|
||||
@@ -818,6 +876,155 @@ export function ServiceDetailPage() {
|
||||
</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' && (
|
||||
<div className="panel p-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;
|
||||
}
|
||||
Reference in new issue
Block a user