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
+6
-2
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user