mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 07:13:05 +00:00
feat(fleet): Fleet Secrets tab with env-var bundles (v1 MVP) (#965)
* feat(fleet): add Fleet Secrets tab with versioned env-var bundles (Skipper+) Centralized, encrypted-at-rest secret bundles that can be pushed to labeled nodes' stacks. Each save bumps a monotonic version; each push records a per-node-per-version row in `secret_pushes` plus an entry in `audit_log`. Conflict detection shows added/changed/unchanged/removed (informational) diffs before write. Overlay merge preserves keys missing from the bundle. - Adds `secrets`, `secret_versions`, `secret_pushes` tables. - New `SecretsService` reuses CryptoService for AES-256-GCM, NodeLabelService for selectors, and direct fetch + Bearer for outbound calls to remote nodes. - New `secretsRouter` with 9 endpoints under `/api/secrets`, gated by `requirePaid`. Mounted after the auth gate. - Audit summary patterns added for the new routes. - New Fleet › Secrets tab with bundle list, editor sheet (key=value rows, versions tab), and push wizard (selector, target stack, env file picker, per-node diff preview, results pills). - Documentation: docs/features/fleet-secrets.mdx + docs.json nav entry. - 26 Vitest cases cover parser, encryption, versioning, push aggregation, tier gating. * fix(fleet): use const for rawValue in env parser ESLint prefer-const flagged the let declaration as a CI-blocking error; the variable is never reassigned.
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { useExperimental } from '@/hooks/useExperimental';
|
||||
import {
|
||||
RefreshCw, Search, Camera,
|
||||
Network, SlidersHorizontal,
|
||||
@@ -20,11 +19,11 @@ import { useLicense } from '@/context/LicenseContext';
|
||||
import { AdmiralGate } from './AdmiralGate';
|
||||
import FleetSnapshots from './FleetSnapshots';
|
||||
import { FleetConfiguration } from './fleet/FleetConfiguration';
|
||||
import { FleetSoonPlaceholder } from './fleet/FleetSoonPlaceholder';
|
||||
import { RoutingTab } from './fleet/RoutingTab';
|
||||
import { FederationTab } from './fleet/FederationTab';
|
||||
import { DeploymentsTab } from './blueprints/DeploymentsTab';
|
||||
import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
|
||||
import { SecretsTab } from './fleet/secrets/SecretsTab';
|
||||
|
||||
interface FleetViewProps {
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
@@ -33,7 +32,6 @@ interface FleetViewProps {
|
||||
export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
const { isPaid, license } = useLicense();
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const experimental = useExperimental();
|
||||
|
||||
const { prefs, updatePrefs } = useFleetPreferences();
|
||||
const updateStatus = useFleetUpdateStatus();
|
||||
@@ -107,15 +105,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<Wrench className="w-4 h-4 mr-1.5" />Fleet Actions
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{experimental && (
|
||||
<>
|
||||
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
||||
<TabsHighlightItem value="secrets">
|
||||
<TabsTrigger value="secrets">
|
||||
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</>
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="secrets">
|
||||
<TabsTrigger value="secrets">
|
||||
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
@@ -198,15 +193,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<TabsContent value="actions">
|
||||
<FleetActionsTab nodes={overview.allNodes} />
|
||||
</TabsContent>
|
||||
{experimental && (
|
||||
{isPaid && (
|
||||
<TabsContent value="secrets">
|
||||
<FleetSoonPlaceholder
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
kicker="Secrets"
|
||||
title="One source of truth for env, creds and certs"
|
||||
description="Push to selected nodes, rotate centrally, audit who-saw-what. Solves silent drift across copies."
|
||||
plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']}
|
||||
/>
|
||||
<SecretsTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Eye, EyeOff, Plus, Trash2, Copy, Loader2 } from 'lucide-react';
|
||||
import { SystemSheet, SheetSection, type SystemSheetTab } from '@/components/ui/system-sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import {
|
||||
type SecretSummary,
|
||||
type SecretVersionSummary,
|
||||
createSecret,
|
||||
updateSecret,
|
||||
getSecret,
|
||||
listSecretVersions,
|
||||
} from '@/lib/secretsApi';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
|
||||
interface KvRow { id: string; key: string; value: string; reveal: boolean }
|
||||
type Mode = 'create' | 'edit';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** When provided, edit existing bundle; when null, create new. */
|
||||
secret: SecretSummary | null;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _.-]{0,62}[a-zA-Z0-9]$/;
|
||||
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function makeRowId(): string {
|
||||
return `r-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function kvToRows(kv: Record<string, string>): KvRow[] {
|
||||
return Object.keys(kv)
|
||||
.sort()
|
||||
.map((key) => ({ id: makeRowId(), key, value: kv[key], reveal: false }));
|
||||
}
|
||||
|
||||
function rowsToKv(rows: KvRow[]): { kv: Record<string, string> } | { error: string } {
|
||||
const kv: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
for (const r of rows) {
|
||||
const k = r.key.trim();
|
||||
if (!k) continue;
|
||||
if (!KEY_PATTERN.test(k)) {
|
||||
return { error: `Invalid key: ${k}` };
|
||||
}
|
||||
if (seen.has(k)) {
|
||||
return { error: `Duplicate key: ${k}` };
|
||||
}
|
||||
seen.add(k);
|
||||
kv[k] = r.value;
|
||||
}
|
||||
return { kv };
|
||||
}
|
||||
|
||||
export function SecretBundleSheet({ open, onOpenChange, secret, onSaved }: Props) {
|
||||
const mode: Mode = secret ? 'edit' : 'create';
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [rows, setRows] = useState<KvRow[]>([]);
|
||||
const [note, setNote] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('keys');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [versions, setVersions] = useState<SecretVersionSummary[]>([]);
|
||||
const [versionsLoading, setVersionsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
if (secret) {
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const fresh = await getSecret(secret.id);
|
||||
if (cancelled) return;
|
||||
setName(fresh.name);
|
||||
setDescription(fresh.description);
|
||||
setRows(kvToRows(fresh.kv));
|
||||
setNote('');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load secret');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setRows([{ id: makeRowId(), key: '', value: '', reveal: true }]);
|
||||
setNote('');
|
||||
setActiveTab('keys');
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
}, [open, secret]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !secret || activeTab !== 'versions') return;
|
||||
let cancelled = false;
|
||||
setVersionsLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const list = await listSecretVersions(secret.id);
|
||||
if (!cancelled) setVersions(list);
|
||||
} catch (err) {
|
||||
if (!cancelled) toast.error(err instanceof Error ? err.message : 'Failed to load versions');
|
||||
} finally {
|
||||
if (!cancelled) setVersionsLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, secret, activeTab]);
|
||||
|
||||
const tabs: SystemSheetTab[] = mode === 'edit'
|
||||
? [{ id: 'keys', label: 'Keys', count: rows.filter(r => r.key.trim().length > 0).length }, { id: 'versions', label: 'Versions' }]
|
||||
: [{ id: 'keys', label: 'Keys', count: rows.filter(r => r.key.trim().length > 0).length }];
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!NAME_PATTERN.test(name)) return false;
|
||||
if (rows.some(r => r.key.trim().length === 0 && r.value.length > 0)) return false;
|
||||
return true;
|
||||
}, [name, rows]);
|
||||
|
||||
async function handleSave() {
|
||||
if (mode === 'create' && !NAME_PATTERN.test(name)) {
|
||||
toast.error('Name must be 2-64 characters (letters, digits, dot, dash, underscore)');
|
||||
return;
|
||||
}
|
||||
const built = rowsToKv(rows);
|
||||
if ('error' in built) {
|
||||
toast.error(built.error);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
const result = await createSecret({ name, description: description || undefined, kv: built.kv, note: note || undefined });
|
||||
toast.success(`Bundle saved (v${result.version})`);
|
||||
} else if (secret) {
|
||||
const result = await updateSecret(secret.id, { description, kv: built.kv, note: note || undefined });
|
||||
toast.success(`Bundle saved (v${result.version})`);
|
||||
}
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save bundle');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
setRows((prev) => [...prev, { id: makeRowId(), key: '', value: '', reveal: true }]);
|
||||
}
|
||||
|
||||
function removeRow(id: string) {
|
||||
setRows((prev) => prev.filter(r => r.id !== id));
|
||||
}
|
||||
|
||||
function updateRow(id: string, patch: Partial<KvRow>) {
|
||||
setRows((prev) => prev.map(r => r.id === id ? { ...r, ...patch } : r));
|
||||
}
|
||||
|
||||
async function handleCopyValue(value: string) {
|
||||
try {
|
||||
await copyToClipboard(value);
|
||||
toast.success('Copied');
|
||||
} catch {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Secrets', mode === 'create' ? 'New bundle' : (secret?.name ?? 'Bundle')]}
|
||||
name={mode === 'create' ? 'New secret bundle' : (secret?.name ?? 'Bundle')}
|
||||
meta={mode === 'edit' && secret ? `v${secret.currentVersion} · ${secret.keyCount} keys` : undefined}
|
||||
primaryAction={{
|
||||
label: submitting ? 'Saving…' : 'Save',
|
||||
onClick: handleSave,
|
||||
disabled: submitting || loading || !canSubmit,
|
||||
}}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
size="lg"
|
||||
>
|
||||
{activeTab === 'keys' && (
|
||||
<div className="space-y-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-stat-subtitle"><Loader2 className="w-4 h-4 animate-spin" /> Loading bundle…</div>
|
||||
) : (
|
||||
<>
|
||||
<SheetSection title="Identity">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="secret-name" className="block text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Name</label>
|
||||
<Input
|
||||
id="secret-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="app-secrets"
|
||||
disabled={mode === 'edit'}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="secret-description" className="block text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Description</label>
|
||||
<Input
|
||||
id="secret-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Production database and API credentials"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title={`Key/value pairs · ${rows.filter(r => r.key.trim().length > 0).length}`}>
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="flex items-start gap-2">
|
||||
<Input
|
||||
value={row.key}
|
||||
onChange={(e) => updateRow(row.id, { key: e.target.value })}
|
||||
placeholder="KEY_NAME"
|
||||
className="font-mono w-[200px]"
|
||||
/>
|
||||
<span className="text-stat-subtitle pt-2">=</span>
|
||||
<Input
|
||||
type={row.reveal ? 'text' : 'password'}
|
||||
value={row.value}
|
||||
onChange={(e) => updateRow(row.id, { value: e.target.value })}
|
||||
placeholder="value"
|
||||
className="font-mono flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => updateRow(row.id, { reveal: !row.reveal })}
|
||||
aria-label={row.reveal ? 'Hide value' : 'Show value'}
|
||||
>
|
||||
{row.reveal ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => void handleCopyValue(row.value)}
|
||||
aria-label="Copy value"
|
||||
disabled={!row.value}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive/70" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={addRow} className="gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" /> Add key
|
||||
</Button>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title="Change note (optional)">
|
||||
<Input
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={mode === 'create' ? 'initial bundle' : 'Why this change?'}
|
||||
/>
|
||||
</SheetSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'versions' && (
|
||||
<div className="space-y-2">
|
||||
{versionsLoading && (
|
||||
<div className="flex items-center gap-2 text-sm text-stat-subtitle"><Loader2 className="w-4 h-4 animate-spin" /> Loading…</div>
|
||||
)}
|
||||
{!versionsLoading && versions.length === 0 && (
|
||||
<div className="text-sm text-stat-subtitle">No version history yet.</div>
|
||||
)}
|
||||
{!versionsLoading && versions.map((v) => (
|
||||
<div key={v.version} className="rounded border border-card-border/60 bg-popover/40 px-3 py-2 flex items-baseline gap-3">
|
||||
<div className="font-mono text-sm tabular-nums">v{v.version}</div>
|
||||
<div className="text-xs text-stat-subtitle">{v.keyCount} keys</div>
|
||||
<div className="text-xs text-stat-subtitle flex-1 truncate">{v.note || '(no note)'}</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">{v.createdBy}</div>
|
||||
<div className="text-[10px] font-mono text-stat-subtitle tabular-nums">{new Date(v.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Loader2, Send, ChevronDown, ChevronRight, CheckCircle2, AlertCircle, MinusCircle, type LucideIcon } from 'lucide-react';
|
||||
import { SystemSheet, SheetSection, type SystemSheetTab } from '@/components/ui/system-sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MultiSelectCombobox, type MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { listDistinctLabels, type BlueprintSelector } from '@/lib/blueprintsApi';
|
||||
import {
|
||||
type SecretSummary,
|
||||
type SecretPushPlanEntry,
|
||||
type SecretPushResultEntry,
|
||||
type SecretPushStatus,
|
||||
type DiffStatus,
|
||||
previewPush,
|
||||
executePush,
|
||||
} from '@/lib/secretsApi';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
secret: SecretSummary | null;
|
||||
}
|
||||
|
||||
type Stage = 'target' | 'preview' | 'results';
|
||||
|
||||
const DIFF_STATUS_COLOR: Record<DiffStatus, string> = {
|
||||
added: 'text-emerald-500',
|
||||
changed: 'text-amber-500',
|
||||
removed: 'text-stat-subtitle',
|
||||
unchanged: 'text-stat-subtitle/60',
|
||||
};
|
||||
|
||||
const RESULT_ICON: Record<SecretPushStatus, LucideIcon> = {
|
||||
ok: CheckCircle2,
|
||||
failed: AlertCircle,
|
||||
skipped: MinusCircle,
|
||||
};
|
||||
|
||||
const RESULT_ICON_CLASS: Record<SecretPushStatus, string> = {
|
||||
ok: 'text-emerald-500',
|
||||
failed: 'text-destructive',
|
||||
skipped: 'text-stat-subtitle',
|
||||
};
|
||||
|
||||
export function SecretPushSheet({ open, onOpenChange, secret }: Props) {
|
||||
const { nodes } = useNodes();
|
||||
const [stage, setStage] = useState<Stage>('target');
|
||||
const [labelMode, setLabelMode] = useState<'any' | 'all'>('any');
|
||||
const [selectedLabels, setSelectedLabels] = useState<Set<string>>(new Set());
|
||||
const [allLabels, setAllLabels] = useState<string[]>([]);
|
||||
const [stackName, setStackName] = useState('');
|
||||
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
||||
const [envFile, setEnvFile] = useState('.env');
|
||||
const [envFilesLoading, setEnvFilesLoading] = useState(false);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [pushLoading, setPushLoading] = useState(false);
|
||||
const [plan, setPlan] = useState<SecretPushPlanEntry[]>([]);
|
||||
const [results, setResults] = useState<SecretPushResultEntry[]>([]);
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStage('target');
|
||||
setSelectedLabels(new Set());
|
||||
setStackName('');
|
||||
setEnvFiles([]);
|
||||
setEnvFile('.env');
|
||||
setPlan([]);
|
||||
setResults([]);
|
||||
setExpanded(new Set());
|
||||
listDistinctLabels()
|
||||
.then(setAllLabels)
|
||||
.catch(() => setAllLabels([]));
|
||||
}, [open]);
|
||||
|
||||
const labelOptions: MultiSelectOption[] = useMemo(
|
||||
() => allLabels.map((l) => ({ value: l, label: l })),
|
||||
[allLabels],
|
||||
);
|
||||
|
||||
function buildSelector(): BlueprintSelector {
|
||||
const labels = Array.from(selectedLabels);
|
||||
return { type: 'labels', any: labelMode === 'any' ? labels : [], all: labelMode === 'all' ? labels : [] };
|
||||
}
|
||||
|
||||
async function loadEnvFiles() {
|
||||
if (!stackName.trim()) return;
|
||||
const ref = nodes[0];
|
||||
if (!ref) return;
|
||||
setEnvFilesLoading(true);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName.trim())}/envs`, {
|
||||
headers: { 'x-node-id': String(ref.id) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
setEnvFiles([]);
|
||||
setEnvFile('.env');
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { envFiles?: string[] };
|
||||
const basenames = (body.envFiles ?? []).map((p) => p.split(/[\\/]/).pop() ?? '');
|
||||
const unique = Array.from(new Set(basenames.filter(Boolean)));
|
||||
if (!unique.includes('.env')) unique.unshift('.env');
|
||||
setEnvFiles(unique);
|
||||
if (!unique.includes(envFile)) setEnvFile(unique[0] ?? '.env');
|
||||
} catch {
|
||||
setEnvFiles([]);
|
||||
} finally {
|
||||
setEnvFilesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (stage !== 'target') return;
|
||||
if (!stackName.trim()) {
|
||||
setEnvFiles([]);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => { void loadEnvFiles(); }, 250);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stage, stackName, nodes.length]);
|
||||
|
||||
async function handlePreview() {
|
||||
if (!secret) return;
|
||||
if (selectedLabels.size === 0) {
|
||||
toast.error('Pick at least one label');
|
||||
return;
|
||||
}
|
||||
if (!stackName.trim()) {
|
||||
toast.error('Stack name is required');
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const result = await previewPush(secret.id, {
|
||||
selector: buildSelector(),
|
||||
stackName: stackName.trim(),
|
||||
envFileBasename: envFile,
|
||||
});
|
||||
if (result.length === 0) {
|
||||
toast.error('No nodes match this selector');
|
||||
return;
|
||||
}
|
||||
setPlan(result);
|
||||
setStage('preview');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to preview push');
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePush() {
|
||||
if (!secret) return;
|
||||
setPushLoading(true);
|
||||
try {
|
||||
const result = await executePush(secret.id, {
|
||||
selector: buildSelector(),
|
||||
stackName: stackName.trim(),
|
||||
envFileBasename: envFile,
|
||||
});
|
||||
setResults(result.results);
|
||||
setStage('results');
|
||||
const okCount = result.results.filter(r => r.status === 'ok').length;
|
||||
const failCount = result.results.length - okCount;
|
||||
if (failCount === 0) toast.success(`Pushed to ${okCount} ${okCount === 1 ? 'node' : 'nodes'}`);
|
||||
else toast.error(`${okCount} ok · ${failCount} failed`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to push secret');
|
||||
} finally {
|
||||
setPushLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpanded(nodeId: number) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) next.delete(nodeId);
|
||||
else next.add(nodeId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const tabs: SystemSheetTab[] = [
|
||||
{ id: 'target', label: 'Target' },
|
||||
{ id: 'preview', label: 'Preview' },
|
||||
{ id: 'results', label: 'Results' },
|
||||
];
|
||||
|
||||
const primaryAction = (() => {
|
||||
if (stage === 'target') return {
|
||||
label: previewLoading ? 'Previewing…' : 'Preview',
|
||||
onClick: handlePreview,
|
||||
disabled: previewLoading || selectedLabels.size === 0 || !stackName.trim(),
|
||||
icon: previewLoading ? Loader2 : undefined,
|
||||
};
|
||||
if (stage === 'preview') return {
|
||||
label: pushLoading ? 'Pushing…' : `Push to ${plan.length} ${plan.length === 1 ? 'node' : 'nodes'}`,
|
||||
onClick: handlePush,
|
||||
disabled: pushLoading,
|
||||
icon: Send,
|
||||
};
|
||||
return {
|
||||
label: 'Done',
|
||||
onClick: () => onOpenChange(false),
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Secrets', secret?.name ?? '', 'Push']}
|
||||
name={secret ? `Push '${secret.name}'` : 'Push secret'}
|
||||
meta={secret ? `v${secret.currentVersion} · ${secret.keyCount} keys` : undefined}
|
||||
primaryAction={primaryAction}
|
||||
tabs={tabs}
|
||||
activeTab={stage}
|
||||
onTabChange={(id) => {
|
||||
if (id === 'target') setStage('target');
|
||||
else if (id === 'preview' && plan.length > 0) setStage('preview');
|
||||
else if (id === 'results' && results.length > 0) setStage('results');
|
||||
}}
|
||||
size="xl"
|
||||
>
|
||||
{stage === 'target' && (
|
||||
<div className="space-y-5">
|
||||
<SheetSection title="Target nodes">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">
|
||||
<span>Match</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-0.5 rounded border ${labelMode === 'any' ? 'border-brand text-stat-value' : 'border-card-border text-stat-subtitle'}`}
|
||||
onClick={() => setLabelMode('any')}
|
||||
>
|
||||
any
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-0.5 rounded border ${labelMode === 'all' ? 'border-brand text-stat-value' : 'border-card-border text-stat-subtitle'}`}
|
||||
onClick={() => setLabelMode('all')}
|
||||
>
|
||||
all
|
||||
</button>
|
||||
<span>of these labels</span>
|
||||
</div>
|
||||
<MultiSelectCombobox
|
||||
options={labelOptions}
|
||||
selected={selectedLabels}
|
||||
onSelectionChange={setSelectedLabels}
|
||||
placeholder="Pick labels…"
|
||||
emptyText={allLabels.length === 0 ? 'No node labels yet. Add them via Fleet › Overview.' : 'No matches'}
|
||||
/>
|
||||
{selectedLabels.size > 0 && (
|
||||
<p className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">
|
||||
{nodes.length} node{nodes.length === 1 ? '' : 's'} known to fleet · preview will resolve exact matches
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title="Target stack">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="push-stack" className="block text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Stack name</label>
|
||||
<Input
|
||||
id="push-stack"
|
||||
value={stackName}
|
||||
onChange={(e) => setStackName(e.target.value)}
|
||||
placeholder="my-app"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="push-envfile" className="block text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Env file</label>
|
||||
<select
|
||||
id="push-envfile"
|
||||
value={envFile}
|
||||
onChange={(e) => setEnvFile(e.target.value)}
|
||||
disabled={envFilesLoading || envFiles.length === 0}
|
||||
className="font-mono text-sm rounded border border-card-border bg-popover/40 px-2 py-1.5 min-w-[200px]"
|
||||
>
|
||||
{envFiles.length === 0 ? <option value=".env">.env</option> : envFiles.map((f) => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
<p className="text-[10px] text-stat-subtitle leading-relaxed">
|
||||
Lists files declared by the stack's compose on a representative target. Per-node compose can differ; nodes that don't declare the chosen file are reported as failed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === 'preview' && (
|
||||
<div className="space-y-3">
|
||||
{plan.length === 0 && (
|
||||
<div className="text-sm text-stat-subtitle">Run preview from the Target tab.</div>
|
||||
)}
|
||||
{plan.map((entry) => (
|
||||
<div key={entry.nodeId} className="rounded border border-card-border/60 bg-popover/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(entry.nodeId)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left"
|
||||
>
|
||||
{expanded.has(entry.nodeId) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
<span className="font-mono text-sm flex-1 truncate">{entry.nodeName}</span>
|
||||
{!entry.reachable || !entry.stackExists ? (
|
||||
<span className="text-xs text-destructive">{entry.error ?? 'unreachable'}</span>
|
||||
) : (
|
||||
<span className="font-mono text-xs tabular-nums flex items-center gap-2">
|
||||
<span className="text-emerald-500">+{entry.added}</span>
|
||||
<span className="text-amber-500">~{entry.changed}</span>
|
||||
<span className="text-stat-subtitle">·{entry.unchanged}</span>
|
||||
{entry.removedInformational > 0 && <span className="text-stat-subtitle">drift {entry.removedInformational}</span>}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded.has(entry.nodeId) && entry.diff.length > 0 && (
|
||||
<div className="border-t border-card-border/40 px-3 py-2 space-y-1">
|
||||
{entry.diff.map((d) => (
|
||||
<div key={d.key} className="flex items-baseline gap-2 font-mono text-xs">
|
||||
<span className={`w-16 uppercase tracking-[0.18em] text-[10px] ${DIFF_STATUS_COLOR[d.status]}`}>{d.status}</span>
|
||||
<span className="flex-1 truncate">{d.key}</span>
|
||||
{d.status === 'changed' && <span className="text-stat-subtitle">old to new</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === 'results' && (
|
||||
<div className="space-y-2">
|
||||
{results.map((r) => {
|
||||
const Icon = RESULT_ICON[r.status];
|
||||
return (
|
||||
<div key={r.nodeId} className="rounded border border-card-border/60 bg-popover/40 px-3 py-2 flex items-center gap-3">
|
||||
<Icon className={`w-4 h-4 ${RESULT_ICON_CLASS[r.status]}`} />
|
||||
<span className="font-mono text-sm flex-1 truncate">{r.nodeName}</span>
|
||||
{r.status === 'ok' ? (
|
||||
<span className="font-mono text-xs tabular-nums flex items-center gap-2">
|
||||
<span className="text-emerald-500">+{r.added}</span>
|
||||
<span className="text-amber-500">~{r.changed}</span>
|
||||
<span className="text-stat-subtitle">·{r.unchanged}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-destructive truncate max-w-[300px]" title={r.error}>{r.error ?? r.status}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { KeyRound, Plus, Pencil, Send, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { listSecrets, deleteSecret, type SecretSummary } from '@/lib/secretsApi';
|
||||
import { SecretBundleSheet } from './SecretBundleSheet';
|
||||
import { SecretPushSheet } from './SecretPushSheet';
|
||||
|
||||
export function SecretsTab() {
|
||||
const [items, setItems] = useState<SecretSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<SecretSummary | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [pushing, setPushing] = useState<SecretSummary | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const list = await listSecrets();
|
||||
setItems(list);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load secrets';
|
||||
setLoadError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function handleDelete(secret: SecretSummary) {
|
||||
if (!confirm(`Delete bundle '${secret.name}'? This removes all versions and push history.`)) return;
|
||||
setDeletingId(secret.id);
|
||||
try {
|
||||
await deleteSecret(secret.id);
|
||||
toast.success('Bundle deleted');
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete bundle');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-xs text-stat-subtitle font-mono uppercase tracking-[0.18em]">
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Loading secrets…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl rounded-xl border border-destructive/30 bg-destructive/5 p-6 space-y-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-destructive">Could not load secrets</div>
|
||||
<p className="text-sm text-stat-subtitle leading-relaxed">{loadError}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void refresh()}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-display italic text-[1.5rem] leading-tight text-stat-value">Secret bundles</h2>
|
||||
<p className="text-sm text-stat-subtitle">Centralized env-var bundles, encrypted at rest, versioned, pushed to labeled nodes.</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setCreating(true)} className="gap-1.5">
|
||||
<Plus className="w-4 h-4" /> New bundle
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState onCreate={() => setCreating(true)} />
|
||||
) : (
|
||||
<div className="rounded-xl border border-card-border/60 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-popover/40 border-b border-card-border/60">
|
||||
<tr className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">
|
||||
<th className="px-4 py-2 text-left font-normal">Name</th>
|
||||
<th className="px-4 py-2 text-left font-normal">Description</th>
|
||||
<th className="px-4 py-2 text-right font-normal tabular-nums">Version</th>
|
||||
<th className="px-4 py-2 text-right font-normal tabular-nums">Keys</th>
|
||||
<th className="px-4 py-2 text-left font-normal">Updated</th>
|
||||
<th className="px-4 py-2 text-right font-normal" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className="border-b border-card-border/30 last:border-b-0 hover:bg-popover/20">
|
||||
<td className="px-4 py-2 font-mono">{s.name}</td>
|
||||
<td className="px-4 py-2 text-stat-subtitle truncate max-w-[300px]">{s.description || <span className="text-stat-subtitle/50">·</span>}</td>
|
||||
<td className="px-4 py-2 text-right font-mono tabular-nums">v{s.currentVersion}</td>
|
||||
<td className="px-4 py-2 text-right font-mono tabular-nums">{s.keyCount}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-stat-subtitle tabular-nums">{new Date(s.updatedAt).toLocaleString()}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setEditing(s)} aria-label="Edit">
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setPushing(s)} aria-label="Push">
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => void handleDelete(s)}
|
||||
disabled={deletingId === s.id}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive/70" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SecretBundleSheet
|
||||
open={creating || editing !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
secret={editing}
|
||||
onSaved={() => void refresh()}
|
||||
/>
|
||||
|
||||
<SecretPushSheet
|
||||
open={pushing !== null}
|
||||
onOpenChange={(o) => { if (!o) setPushing(null); }}
|
||||
secret={pushing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl rounded-xl border border-card-border/60 bg-popover/30 p-8 text-center space-y-4">
|
||||
<KeyRound className="mx-auto w-8 h-8 text-stat-subtitle" />
|
||||
<div>
|
||||
<h3 className="font-display italic text-[1.25rem] text-stat-value">One source of truth for env</h3>
|
||||
<p className="text-sm text-stat-subtitle leading-relaxed mt-1">
|
||||
Build a bundle of key=value pairs, push it to nodes by label, see exactly what changed before you write.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={onCreate} className="gap-1.5">
|
||||
<Plus className="w-4 h-4" /> Create your first bundle
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user