feat(blueprints): add Fleet > Deployments tab UI, node labels, and docs (#861)

Implements the frontend layer for the Blueprint Model feature (backend
landed in PR #860). Fleet > Deployments tab is now live for Skipper+
users; Community users see the existing locked badge.

Key additions:
- blueprintsApi.ts: typed apiFetch wrappers (localOnly: true on all calls)
- BlueprintCatalog: featured hero, filter pills, classification-chipped tile grid
- BlueprintEditor: Monaco YAML editor with debounced live classification,
  label/node selector, three-mode drift radio cards, create/edit modes
- BlueprintDeploymentTable: per-node status rows with action buttons
  (Confirm deploy, Retry, Withdraw/Evict, DATA PINNED HERE for stateful)
- EvictionDialog: dual-affordance (Snapshot then evict / Evict and destroy)
- StateReviewDialog: fresh-deploy acceptance gate for stateful blueprints
- BlueprintClassificationBanner: real-time stateless/stateful/unknown banner
- DeploymentsTab: wires catalog, empty state, and create dialog
- BlueprintDetail: Sheet with Apply/Edit/overflow, themed delete dialog
- NodeLabelPicker + NodeLabelPill: label CRUD per node in NodeManager
- FleetView: gates Deployments tab behind isPaid; mounts DeploymentsTab
- NodeManager: Labels column with NodeLabelPicker (Skipper+ users)
- docs/features/blueprint-model.mdx + docs.json entry + 6 screenshots
This commit is contained in:
Anso
2026-05-01 19:47:05 -04:00
committed by GitHub
parent 685d5d729e
commit e5391e66cb
22 changed files with 2228 additions and 8 deletions
+15 -8
View File
@@ -32,6 +32,7 @@ import FleetSnapshots from './FleetSnapshots';
import { FleetConfiguration } from './fleet/FleetConfiguration';
import { FleetSoonPlaceholder, SoonBadge } from './fleet/FleetSoonPlaceholder';
import { RoutingTab } from './fleet/RoutingTab';
import { DeploymentsTab } from './blueprints/DeploymentsTab';
import { toast } from '@/components/ui/toast-store';
import { LabelDot } from './LabelPill';
import { type Label as StackLabel, type LabelColor } from './label-types';
@@ -1063,7 +1064,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<TabsHighlightItem value="deployments">
<TabsTrigger value="deployments">
<Send className="w-4 h-4 mr-1.5" />Deployments
<SoonBadge />
{!isPaid && <SoonBadge />}
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="federation">
@@ -1385,13 +1386,19 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<FleetConfiguration />
</TabsContent>
<TabsContent value="deployments">
<FleetSoonPlaceholder
icon={<Send className="h-4 w-4" />}
kicker="Deployments · Coming soon"
title="Push stacks across the fleet"
description="Push a stack from local to a remote in one move. Side-by-side compose diff, env reconciliation, volume migration plan."
plannedActions={['Push stack', 'Promote env', 'Diff config', 'Plan migration']}
/>
{isPaid ? (
<DeploymentsTab />
) : (
<PaidGate featureName="Blueprints (fleet-wide compose templates)">
<FleetSoonPlaceholder
icon={<Send className="h-4 w-4" />}
kicker="Deployments · Blueprints"
title="Declare once. Distribute everywhere."
description="Pick nodes by label, drop in a docker-compose, and Sencho keeps the matching nodes in sync. Drift detection always on; auto-fix optional."
plannedActions={['Author', 'Target', 'Reconcile', 'Snapshot+evict']}
/>
</PaidGate>
)}
</TabsContent>
<TabsContent value="federation">
<FleetSoonPlaceholder
+14
View File
@@ -19,6 +19,9 @@ import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Monitor, Globe, Copy, KeyRou
import { formatTimeUntil, formatTimeAgo } from '@/lib/relativeTime';
import { SettingsPrimaryButton } from './settings/SettingsActions';
import { useMastheadStats } from './settings/MastheadStatsContext';
import { NodeLabelPicker } from './blueprints/NodeLabelPicker';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
interface NodeSchedulingSummary {
active_tasks: number;
@@ -60,6 +63,9 @@ const defaultFormData: NodeFormData = {
};
export function NodeManager() {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const canEditLabels = isPaid && isAdmin;
const { nodes, refreshNodes } = useNodes();
useMastheadStats([
{ label: 'NODES', value: `${nodes.length}` },
@@ -497,6 +503,7 @@ export function NodeManager() {
<TableHead>Mode</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Status</TableHead>
<TableHead>Labels</TableHead>
<TableHead>Schedules</TableHead>
<TableHead>Updates</TableHead>
<TableHead className="text-right">Actions</TableHead>
@@ -551,6 +558,13 @@ export function NodeManager() {
: (node.api_url || '-')}
</TableCell>
<TableCell>{getStatusBadge(node.status)}</TableCell>
<TableCell>
{isPaid ? (
<NodeLabelPicker nodeId={node.id} canEdit={canEditLabels} />
) : (
<span className="text-[10px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Upgrade</span>
)}
</TableCell>
<TableCell>
{(() => {
const summary = nodeSummary[node.id];
@@ -0,0 +1,208 @@
import { useMemo, useState } from 'react';
import { Plus, Lock, Layers, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
type BlueprintListItem,
type BlueprintDeploymentStatus,
describeSelector,
} from '@/lib/blueprintsApi';
interface BlueprintCatalogProps {
blueprints: BlueprintListItem[];
onSelect: (id: number) => void;
onCreate: () => void;
}
type ModeFilter = 'all' | 'observe' | 'suggest' | 'enforce' | 'drifted';
const STATUS_PRIORITY: BlueprintDeploymentStatus[] = ['failed', 'name_conflict', 'evict_blocked', 'pending_state_review', 'drifted', 'correcting', 'deploying', 'pending', 'active', 'withdrawing', 'withdrawn'];
function dominantStatus(counts: Partial<Record<BlueprintDeploymentStatus, number>>): BlueprintDeploymentStatus | null {
for (const status of STATUS_PRIORITY) {
if ((counts[status] ?? 0) > 0) return status;
}
return null;
}
function statusDot(status: BlueprintDeploymentStatus | null): string {
if (!status) return 'bg-muted-foreground';
switch (status) {
case 'active': return 'bg-success';
case 'deploying':
case 'correcting': return 'bg-brand';
case 'failed':
case 'name_conflict': return 'bg-destructive';
case 'drifted':
case 'pending':
case 'pending_state_review':
case 'evict_blocked':
case 'withdrawing': return 'bg-warning';
default: return 'bg-muted-foreground';
}
}
export function BlueprintCatalog({ blueprints, onSelect, onCreate }: BlueprintCatalogProps) {
const [filter, setFilter] = useState<ModeFilter>('all');
const counts = useMemo(() => {
const c = { all: blueprints.length, observe: 0, suggest: 0, enforce: 0, drifted: 0 };
for (const b of blueprints) {
c[b.drift_mode] = (c[b.drift_mode] ?? 0) + 1;
if ((b.deploymentCounts.drifted ?? 0) > 0) c.drifted += 1;
}
return c;
}, [blueprints]);
const filtered = useMemo(() => {
switch (filter) {
case 'all': return blueprints;
case 'drifted': return blueprints.filter(b => (b.deploymentCounts.drifted ?? 0) > 0);
default: return blueprints.filter(b => b.drift_mode === filter);
}
}, [blueprints, filter]);
const featured = useMemo(() => {
if (blueprints.length === 0) return null;
const sorted = [...blueprints].sort((a, b) => {
const aActive = a.deploymentCounts.active ?? 0;
const bActive = b.deploymentCounts.active ?? 0;
if (aActive !== bActive) return bActive - aActive;
return b.updated_at - a.updated_at;
});
return sorted[0];
}, [blueprints]);
return (
<div className="space-y-5">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">
Deployments · Blueprints
</span>
</div>
<Button size="sm" onClick={onCreate} className="gap-2">
<Plus className="h-4 w-4" strokeWidth={1.5} />
New Blueprint
</Button>
</div>
{featured && (
<div className="rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
<div className="flex flex-col md:flex-row gap-4 p-5 border-l-2 border-brand">
<div className="flex-1 space-y-2 min-w-0">
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-brand">
Featured · most-deployed
</div>
<h3 className="font-serif italic text-xl tracking-[-0.01em] text-stat-value truncate">
{featured.name}
</h3>
{featured.description && (
<p className="text-xs text-stat-subtitle leading-relaxed line-clamp-2">{featured.description}</p>
)}
<div className="flex items-center gap-3 text-[10px] uppercase tracking-[0.18em] font-mono text-stat-icon">
<ClassificationChip classification={featured.classification} />
<span>·</span>
<span>{featured.deploymentCounts.active ?? 0}/{featured.deploymentTotal} active</span>
<span>·</span>
<span>drift {featured.drift_mode}</span>
</div>
</div>
<div className="self-end md:self-center">
<Button variant="outline" size="sm" onClick={() => onSelect(featured.id)}>Open</Button>
</div>
</div>
</div>
)}
<div className="flex items-center gap-1 flex-wrap">
<FilterChip active={filter === 'all'} count={counts.all} label="All" onClick={() => setFilter('all')} />
<FilterChip active={filter === 'drifted'} count={counts.drifted} label="Drifted" onClick={() => setFilter('drifted')} tone="warning" />
<span className="mx-2 text-stat-icon">·</span>
<FilterChip active={filter === 'observe'} count={counts.observe} label="Observe" onClick={() => setFilter('observe')} />
<FilterChip active={filter === 'suggest'} count={counts.suggest} label="Suggest" onClick={() => setFilter('suggest')} />
<FilterChip active={filter === 'enforce'} count={counts.enforce} label="Enforce" onClick={() => setFilter('enforce')} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{filtered.map(b => (
<BlueprintTile key={b.id} blueprint={b} onClick={() => onSelect(b.id)} />
))}
{filtered.length === 0 && (
<div className="md:col-span-2 xl:col-span-3 rounded-lg border border-dashed border-border p-8 text-center">
<p className="text-sm text-stat-subtitle">No blueprints match this filter.</p>
</div>
)}
</div>
</div>
);
}
function FilterChip({ active, count, label, onClick, tone }: { active: boolean; count: number; label: string; onClick: () => void; tone?: 'warning' }) {
const baseClass = active
? 'border-brand/40 bg-brand/10 text-brand'
: 'border-card-border bg-card text-stat-subtitle hover:text-stat-value';
const toneClass = tone === 'warning' && count > 0 && !active ? 'text-warning' : '';
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.18em] cursor-pointer ${baseClass} ${toneClass}`}
>
{label}
<span className="tabular-nums">{count}</span>
</button>
);
}
function BlueprintTile({ blueprint, onClick }: { blueprint: BlueprintListItem; onClick: () => void }) {
const dom = dominantStatus(blueprint.deploymentCounts);
const active = blueprint.deploymentCounts.active ?? 0;
return (
<button
type="button"
onClick={onClick}
className="text-left rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover cursor-pointer p-4 space-y-2"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className={`inline-block w-2 h-2 rounded-full shrink-0 ${statusDot(dom)}`} aria-hidden />
<span className="font-serif italic text-base tracking-[-0.01em] text-stat-value truncate">
{blueprint.name}
</span>
</div>
<ClassificationChip classification={blueprint.classification} />
</div>
{blueprint.description && (
<p className="text-xs text-stat-subtitle line-clamp-2 leading-relaxed">{blueprint.description}</p>
)}
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">
<span className="tabular-nums text-stat-value">{active}/{blueprint.deploymentTotal}</span>
<span>active</span>
<span>·</span>
<span className="truncate" title={describeSelector(blueprint.selector)}>{describeSelector(blueprint.selector)}</span>
</div>
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">
<span>drift</span>
<span className="text-stat-value">{blueprint.drift_mode}</span>
{!blueprint.enabled && (
<>
<span>·</span>
<span className="text-warning">disabled</span>
</>
)}
</div>
</button>
);
}
function ClassificationChip({ classification }: { classification: 'stateless' | 'stateful' | 'unknown' }) {
const Icon = classification === 'stateless' ? Layers : classification === 'unknown' ? AlertCircle : Lock;
const dot = classification === 'stateless' ? 'bg-success' : classification === 'unknown' ? 'bg-muted-foreground' : 'bg-warning';
return (
<span className="inline-flex items-center gap-1.5 rounded border border-card-border px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.2em] text-stat-icon">
<span className={`inline-block w-1.5 h-1.5 rounded-full ${dot}`} aria-hidden />
<Icon className="h-3 w-3" strokeWidth={1.5} />
{classification}
</span>
);
}
@@ -0,0 +1,86 @@
import { useState } from 'react';
import { ChevronDown, Lock, Layers, AlertCircle } from 'lucide-react';
import type { AnalyzerResult } from '@/lib/blueprintsApi';
interface BlueprintClassificationBannerProps {
analysis: AnalyzerResult | null;
/** When true, render an even more compact strip suitable for inline detail sheets. */
compact?: boolean;
}
const COPY: Record<'stateless' | 'stateful' | 'unknown', { kicker: string; message: string }> = {
stateless: {
kicker: 'Stateless · portable',
message: 'No persistent volumes detected. Sencho can deploy and evict freely across nodes.',
},
stateful: {
kicker: 'Stateful · pins to data',
message: 'Persistent volumes detected. Each node holds its own data; eviction requires explicit operator confirmation.',
},
unknown: {
kicker: 'State unknown',
message: 'External or unanalyzable volumes detected. Treated as stateful for safety.',
},
};
export function BlueprintClassificationBanner({ analysis, compact = false }: BlueprintClassificationBannerProps) {
const [showDetails, setShowDetails] = useState(false);
if (!analysis) {
return (
<div className="rounded-lg border border-card-border bg-card/40 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
Compose not analyzed yet
</div>
);
}
const { classification, reasons, parseError } = analysis;
const tone = classification === 'stateless' ? 'success' : classification === 'unknown' ? 'muted' : 'warning';
const Icon = classification === 'stateless' ? Layers : classification === 'unknown' ? AlertCircle : Lock;
const dotColor =
tone === 'success' ? 'bg-success'
: tone === 'warning' ? 'bg-warning'
: 'bg-muted-foreground';
return (
<div className={`rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel ${compact ? 'px-3 py-2' : 'p-3'}`}>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className={`inline-block w-2 h-2 rounded-full ${dotColor}`} aria-hidden />
<Icon className="w-3.5 h-3.5 text-stat-icon shrink-0" strokeWidth={1.5} />
<div className="flex flex-col min-w-0">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">
{COPY[classification].kicker}
</span>
{!compact && (
<span className="text-xs text-stat-subtitle leading-snug truncate">
{parseError ? `Could not parse compose: ${parseError}` : COPY[classification].message}
</span>
)}
</div>
</div>
{reasons.length > 0 && (
<button
type="button"
onClick={() => setShowDetails(s => !s)}
className="flex items-center gap-1 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon hover:text-stat-value cursor-pointer shrink-0"
>
{showDetails ? 'Hide' : `${reasons.length} signal${reasons.length === 1 ? '' : 's'}`}
<ChevronDown className={`w-3 h-3 transition-transform ${showDetails ? 'rotate-180' : ''}`} strokeWidth={1.5} />
</button>
)}
</div>
{showDetails && reasons.length > 0 && (
<ul className="mt-3 space-y-1 border-t border-border pt-3">
{reasons.map((reason, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-stat-subtitle leading-relaxed">
<span className="text-stat-icon font-mono">·</span>
<span>{reason}</span>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,166 @@
import { Lock, AlertTriangle, ShieldQuestion } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
type BlueprintDeployment,
type BlueprintClassification,
type BlueprintDeploymentStatus,
} from '@/lib/blueprintsApi';
import { useNodes } from '@/context/NodeContext';
import { formatTimeAgo } from '@/lib/relativeTime';
interface BlueprintDeploymentTableProps {
deployments: BlueprintDeployment[];
classification: BlueprintClassification;
canEdit: boolean;
busyNodeId: number | null;
onWithdraw: (nodeId: number) => void;
onAcceptStateReview: (nodeId: number) => void;
onRetry: (nodeId: number) => void;
}
const STATUS_LABEL: Record<BlueprintDeploymentStatus, string> = {
pending: 'Pending',
pending_state_review: 'Awaiting confirmation',
deploying: 'Deploying',
active: 'Active',
drifted: 'Drifted',
correcting: 'Correcting',
failed: 'Failed',
withdrawing: 'Withdrawing',
withdrawn: 'Withdrawn',
evict_blocked: 'Evict blocked',
name_conflict: 'Name conflict',
};
function statusDotClass(status: BlueprintDeploymentStatus): string {
switch (status) {
case 'active': return 'bg-success';
case 'deploying':
case 'correcting': return 'bg-brand';
case 'failed':
case 'name_conflict': return 'bg-destructive';
case 'drifted':
case 'pending':
case 'pending_state_review':
case 'evict_blocked':
case 'withdrawing': return 'bg-warning';
default: return 'bg-muted-foreground';
}
}
export function BlueprintDeploymentTable({
deployments, classification, canEdit, busyNodeId, onWithdraw, onAcceptStateReview, onRetry,
}: BlueprintDeploymentTableProps) {
const { nodes } = useNodes();
const nodesById = new Map(nodes.map(n => [n.id, n]));
if (deployments.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border p-6 text-center text-xs text-stat-subtitle">
No matching nodes yet. Add a label or pick a node ID, then click Apply now.
</div>
);
}
return (
<div className="rounded-lg border border-card-border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-32 font-mono text-[10px] uppercase tracking-[0.18em]">Node</TableHead>
<TableHead className="font-mono text-[10px] uppercase tracking-[0.18em]">Status</TableHead>
<TableHead className="font-mono text-[10px] uppercase tracking-[0.18em]">Last activity</TableHead>
<TableHead className="font-mono text-[10px] uppercase tracking-[0.18em]">Notes</TableHead>
<TableHead className="text-right font-mono text-[10px] uppercase tracking-[0.18em]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deployments.map(dep => {
const node = nodesById.get(dep.node_id);
const lastSeen = dep.last_drift_at ?? dep.last_deployed_at ?? dep.last_checked_at;
const isStateful = classification === 'stateful' || classification === 'unknown';
return (
<TableRow key={dep.id}>
<TableCell className="font-medium align-top">
<div className="flex items-center gap-2">
<span className="text-sm truncate">{node?.name ?? `node ${dep.node_id}`}</span>
{dep.status === 'active' && isStateful && (
<span title="Data pinned on this node" className="text-warning">
<Lock className="h-3 w-3" strokeWidth={1.5} />
</span>
)}
</div>
</TableCell>
<TableCell className="align-top">
<div className="flex items-center gap-2">
<span className={`inline-block w-2 h-2 rounded-full ${statusDotClass(dep.status)}`} aria-hidden />
<span className="text-xs">{STATUS_LABEL[dep.status]}</span>
</div>
</TableCell>
<TableCell className="text-xs text-stat-subtitle align-top">
{lastSeen ? formatTimeAgo(lastSeen) : '-'}
</TableCell>
<TableCell className="text-xs text-stat-subtitle align-top max-w-[280px]">
{dep.last_error ? (
<span className="text-destructive font-mono text-[10px] leading-relaxed">{dep.last_error}</span>
) : dep.drift_summary ? (
<span className="font-mono text-[10px] leading-relaxed">{dep.drift_summary}</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="align-top">
<div className="flex items-center justify-end gap-1">
{dep.status === 'pending_state_review' && canEdit && (
<Button
size="sm"
variant="default"
onClick={() => onAcceptStateReview(dep.node_id)}
disabled={busyNodeId === dep.node_id}
>
Confirm deploy
</Button>
)}
{dep.status === 'name_conflict' && (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-[0.18em] font-mono text-destructive">
<ShieldQuestion className="w-3 h-3" strokeWidth={1.5} />
Resolve manually
</span>
)}
{dep.status === 'failed' && canEdit && (
<Button
size="sm"
variant="outline"
onClick={() => onRetry(dep.node_id)}
disabled={busyNodeId === dep.node_id}
>
Retry
</Button>
)}
{(dep.status === 'active' || dep.status === 'drifted' || dep.status === 'evict_blocked' || dep.status === 'failed') && canEdit && (
<Button
size="sm"
variant="ghost"
onClick={() => onWithdraw(dep.node_id)}
disabled={busyNodeId === dep.node_id}
className="text-destructive hover:text-destructive"
>
{dep.status === 'evict_blocked' ? (
<span className="inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" strokeWidth={1.5} />
Evict
</span>
) : 'Withdraw'}
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,378 @@
import { useEffect, useState, useCallback } from 'react';
import { ChevronLeft, MoreHorizontal, Pencil, Play, Trash2 } from 'lucide-react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { toast } from '@/components/ui/toast-store';
import {
type BlueprintSummary,
type CreateBlueprintInput,
type UpdateBlueprintInput,
type WithdrawConfirm,
type AcceptMode,
getBlueprint,
applyBlueprint,
updateBlueprint,
deleteBlueprint,
withdrawDeployment,
acceptDeployment,
describeSelector,
} from '@/lib/blueprintsApi';
import { BlueprintEditor } from './BlueprintEditor';
import { BlueprintDeploymentTable } from './BlueprintDeploymentTable';
import { EvictionDialog } from './EvictionDialog';
import { StateReviewDialog } from './StateReviewDialog';
import { useNodes } from '@/context/NodeContext';
import { formatTimeAgo } from '@/lib/relativeTime';
interface BlueprintDetailProps {
blueprintId: number;
open: boolean;
onOpenChange: (open: boolean) => void;
onChanged: () => void;
canEdit: boolean;
distinctLabels: string[];
}
export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, canEdit, distinctLabels }: BlueprintDetailProps) {
const [summary, setSummary] = useState<BlueprintSummary | null>(null);
const [loading, setLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [busyNodeId, setBusyNodeId] = useState<number | null>(null);
const [evictTarget, setEvictTarget] = useState<{ nodeId: number; nodeName: string } | null>(null);
const [stateReviewTarget, setStateReviewTarget] = useState<{ nodeId: number; nodeName: string } | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const { nodes } = useNodes();
const refresh = useCallback(async () => {
setLoading(true);
try {
const result = await getBlueprint(blueprintId);
setSummary(result);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load blueprint');
onOpenChange(false);
} finally {
setLoading(false);
}
}, [blueprintId, onOpenChange]);
useEffect(() => {
if (open) {
void refresh();
setEditMode(false);
}
}, [open, refresh]);
if (!open) return null;
const blueprint = summary?.blueprint;
async function handleApply() {
if (!blueprint) return;
setSubmitting(true);
try {
await applyBlueprint(blueprint.id);
toast.success('Reconciliation triggered');
await refresh();
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to apply blueprint');
} finally {
setSubmitting(false);
}
}
async function handleSaveEdit(input: CreateBlueprintInput | UpdateBlueprintInput) {
if (!blueprint) return;
setSubmitting(true);
try {
await updateBlueprint(blueprint.id, input as UpdateBlueprintInput);
toast.success('Blueprint saved');
setEditMode(false);
await refresh();
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to save blueprint');
} finally {
setSubmitting(false);
}
}
const deleteTypedOk = !!blueprint && deleteConfirmText.trim() === blueprint.name;
async function performDelete() {
if (!blueprint) return;
setSubmitting(true);
try {
await deleteBlueprint(blueprint.id);
toast.success('Blueprint deleted');
setDeleteOpen(false);
setDeleteConfirmText('');
onChanged();
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete blueprint');
} finally {
setSubmitting(false);
}
}
async function handleToggleEnabled() {
if (!blueprint) return;
setSubmitting(true);
try {
await updateBlueprint(blueprint.id, { enabled: !blueprint.enabled });
toast.success(blueprint.enabled ? 'Reconciler disabled' : 'Reconciler enabled');
await refresh();
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update blueprint');
} finally {
setSubmitting(false);
}
}
async function performWithdraw(nodeId: number, confirm: WithdrawConfirm) {
if (!blueprint) return;
setBusyNodeId(nodeId);
try {
const result = await withdrawDeployment(blueprint.id, nodeId, confirm);
if (result.error) toast.error(result.error);
else toast.success(confirm === 'evict_and_destroy' ? 'Evicted and data removed' : 'Deployment withdrawn');
await refresh();
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to withdraw');
} finally {
setBusyNodeId(null);
setEvictTarget(null);
}
}
async function performAccept(nodeId: number, mode: AcceptMode) {
if (!blueprint) return;
setBusyNodeId(nodeId);
try {
await acceptDeployment(blueprint.id, nodeId, mode);
toast.success(mode === 'fresh' ? 'Deploying with fresh volumes' : 'Restoring from snapshot');
await refresh();
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to accept deployment');
} finally {
setBusyNodeId(null);
setStateReviewTarget(null);
}
}
/**
* Row-level retry isn't a backend primitive: the reconciler operates on the whole
* blueprint, not a single deployment. We surface the row's "Retry" button and
* trigger a full reconciliation tick, which retries failed deployments naturally.
* The nodeId argument satisfies BlueprintDeploymentTable.onRetry's signature.
*/
async function handleRetryRow(nodeId: number): Promise<void> {
void nodeId;
await handleApply();
}
function openWithdraw(nodeId: number) {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
setEvictTarget({ nodeId, nodeName: node.name });
}
function openAcceptStateReview(nodeId: number) {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
setStateReviewTarget({ nodeId, nodeName: node.name });
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 overflow-y-auto p-0">
<SheetHeader className="sticky top-0 z-10 bg-popover/95 backdrop-blur-md border-b border-border px-6 py-4">
<div className="flex items-center justify-between gap-3">
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} className="gap-1.5 -ml-2">
<ChevronLeft className="h-4 w-4" strokeWidth={1.5} />
Back
</Button>
{blueprint && (
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={handleApply} disabled={submitting || !blueprint.enabled} className="gap-1.5">
<Play className="h-3.5 w-3.5" strokeWidth={1.5} />
Apply now
</Button>
{canEdit && !editMode && (
<Button size="sm" variant="outline" onClick={() => setEditMode(true)} className="gap-1.5">
<Pencil className="h-3.5 w-3.5" strokeWidth={1.5} />
Edit
</Button>
)}
{canEdit && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8" disabled={submitting}>
<MoreHorizontal className="h-4 w-4" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleToggleEnabled} disabled={submitting}>
{blueprint.enabled ? 'Disable reconciler' : 'Enable reconciler'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setDeleteOpen(true)} className="text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-2" strokeWidth={1.5} />
Delete blueprint
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</div>
<div className="space-y-1.5">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">
Blueprint
</span>
<SheetTitle className="font-serif italic text-2xl tracking-[-0.01em] text-stat-value">
{blueprint?.name ?? <Skeleton className="h-7 w-40" />}
</SheetTitle>
{blueprint?.description && (
<p className="text-xs text-stat-subtitle">{blueprint.description}</p>
)}
</div>
</SheetHeader>
<div className="px-6 py-5 space-y-5">
{loading || !blueprint || !summary ? (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-40 w-full" />
</div>
) : editMode ? (
<BlueprintEditor
mode="edit"
initial={blueprint}
distinctLabels={distinctLabels}
onCancel={() => setEditMode(false)}
onSubmit={handleSaveEdit}
submitting={submitting}
/>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<Stat label="Selector" value={describeSelector(blueprint.selector)} />
<Stat label="Drift" value={blueprint.drift_mode} />
<Stat label="Revision" value={String(blueprint.revision)} />
<Stat label="Last updated" value={formatTimeAgo(blueprint.updated_at)} />
</div>
<BlueprintDeploymentTable
deployments={summary.deployments}
classification={blueprint.classification}
canEdit={canEdit}
busyNodeId={busyNodeId}
onWithdraw={openWithdraw}
onAcceptStateReview={openAcceptStateReview}
onRetry={handleRetryRow}
/>
<details className="rounded-lg border border-card-border bg-card">
<summary className="cursor-pointer px-3 py-2 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon hover:text-stat-value">
Compose
</summary>
<pre className="px-3 pb-3 font-mono text-[11px] text-stat-value overflow-x-auto whitespace-pre-wrap">
{blueprint.compose_content}
</pre>
</details>
</>
)}
</div>
{evictTarget && blueprint && (
<EvictionDialog
open={!!evictTarget}
onOpenChange={(o) => { if (!o) setEvictTarget(null); }}
blueprintName={blueprint.name}
nodeName={evictTarget.nodeName}
isStateful={blueprint.classification === 'stateful' || blueprint.classification === 'unknown'}
busy={busyNodeId === evictTarget.nodeId}
onConfirm={(mode) => performWithdraw(evictTarget.nodeId, mode)}
/>
)}
{stateReviewTarget && blueprint && (
<StateReviewDialog
open={!!stateReviewTarget}
onOpenChange={(o) => { if (!o) setStateReviewTarget(null); }}
blueprintName={blueprint.name}
nodeName={stateReviewTarget.nodeName}
busy={busyNodeId === stateReviewTarget.nodeId}
onAccept={(mode) => performAccept(stateReviewTarget.nodeId, mode)}
/>
)}
{blueprint && (
<Dialog open={deleteOpen} onOpenChange={(o) => { if (!o) { setDeleteOpen(false); setDeleteConfirmText(''); } }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-2 text-brand font-mono text-[10px] uppercase tracking-[0.2em]">
<span className="inline-block w-1 h-3 bg-destructive" />
Delete blueprint
</div>
<DialogTitle className="font-serif italic text-xl tracking-[-0.01em]">
Permanently delete {blueprint.name}?
</DialogTitle>
<DialogDescription>
Stateless deployments will be withdrawn first. Stateful deployments must be withdrawn explicitly through the deployment table before delete; the API will refuse otherwise.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<p className="text-xs text-stat-subtitle leading-relaxed">
Type <span className="font-mono text-stat-value">{blueprint.name}</span> to confirm.
</p>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder={blueprint.name}
className="font-mono text-xs"
disabled={submitting}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setDeleteOpen(false); setDeleteConfirmText(''); }} disabled={submitting}>
Cancel
</Button>
<Button
variant="outline"
className="text-destructive border-destructive/40 hover:bg-destructive/10"
disabled={!deleteTypedOk || submitting}
onClick={performDelete}
>
Delete blueprint
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</SheetContent>
</Sheet>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-0.5">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">{label}</span>
<p className="font-mono text-sm tabular-nums tracking-tight text-stat-value truncate">{value}</p>
</div>
);
}
@@ -0,0 +1,344 @@
import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, Save, Sparkles, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/toast-store';
import { Editor } from '@/lib/monacoLoader';
import { useNodes } from '@/context/NodeContext';
import {
type AnalyzerResult,
type Blueprint,
type BlueprintSelector,
type DriftMode,
type CreateBlueprintInput,
type UpdateBlueprintInput,
analyzeCompose,
} from '@/lib/blueprintsApi';
import { BlueprintClassificationBanner } from './BlueprintClassificationBanner';
interface BlueprintEditorProps {
initial?: Blueprint;
distinctLabels: string[];
onCancel: () => void;
onSubmit: (input: CreateBlueprintInput | UpdateBlueprintInput) => Promise<void>;
submitting: boolean;
mode: 'create' | 'edit';
}
const DEFAULT_COMPOSE = `# Blueprint compose. Sencho writes this file plus a .blueprint.json marker
# to <COMPOSE_DIR>/<blueprint-name>/ on every targeted node.
services:
app:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "8080:80"
`;
const DRIFT_MODES: Array<{ value: DriftMode; kicker: string; title: string; tagline: string }> = [
{ value: 'observe', kicker: 'Observe', title: 'Detect & display', tagline: 'no notifications' },
{ value: 'suggest', kicker: 'Suggest', title: 'Detect & notify', tagline: 'operator decides' },
{ value: 'enforce', kicker: 'Enforce', title: 'Detect & auto-fix', tagline: 'silent on success' },
];
export function BlueprintEditor({ initial, distinctLabels, onCancel, onSubmit, submitting, mode }: BlueprintEditorProps) {
const { nodes } = useNodes();
const [name, setName] = useState(initial?.name ?? '');
const [description, setDescription] = useState(initial?.description ?? '');
const [composeContent, setComposeContent] = useState(initial?.compose_content ?? DEFAULT_COMPOSE);
const [driftMode, setDriftMode] = useState<DriftMode>(initial?.drift_mode ?? 'suggest');
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
const initialSelector: BlueprintSelector = initial?.selector ?? { type: 'labels', any: [], all: [] };
const [selectorType, setSelectorType] = useState<'labels' | 'nodes'>(initialSelector.type);
const [labelsAny, setLabelsAny] = useState<string[]>(initialSelector.type === 'labels' ? initialSelector.any : []);
const [labelsAll, setLabelsAll] = useState<string[]>(initialSelector.type === 'labels' ? initialSelector.all : []);
const [nodeIds, setNodeIds] = useState<number[]>(initialSelector.type === 'nodes' ? initialSelector.ids : []);
const [analysis, setAnalysis] = useState<AnalyzerResult | null>(null);
const [analyzing, setAnalyzing] = useState(false);
// Debounced classification on compose change. Use a generation counter so
// out-of-order responses can't stamp a stale classification when the user
// types faster than the analyze endpoint responds.
const analyzeGen = useRef(0);
useEffect(() => {
const t = setTimeout(async () => {
if (!composeContent.trim()) {
setAnalysis(null);
return;
}
const gen = ++analyzeGen.current;
setAnalyzing(true);
try {
const result = await analyzeCompose(composeContent);
if (gen !== analyzeGen.current) return; // a newer request superseded us
setAnalysis(result);
} catch {
// Silent fail; banner shows "not analyzed yet" until next try
} finally {
if (gen === analyzeGen.current) setAnalyzing(false);
}
}, 600);
return () => clearTimeout(t);
}, [composeContent]);
const selector: BlueprintSelector = useMemo(() => {
if (selectorType === 'nodes') return { type: 'nodes', ids: nodeIds };
return { type: 'labels', any: labelsAny, all: labelsAll };
}, [selectorType, nodeIds, labelsAny, labelsAll]);
const isStatefulMulti = analysis?.classification === 'stateful' && (
(selectorType === 'labels' && (labelsAny.length > 0 || labelsAll.length > 0)) ||
(selectorType === 'nodes' && nodeIds.length > 1)
);
function toggleLabel(list: string[], setList: (v: string[]) => void, label: string) {
if (list.includes(label)) setList(list.filter(l => l !== label));
else setList([...list, label]);
}
function toggleNode(id: number) {
if (nodeIds.includes(id)) setNodeIds(nodeIds.filter(n => n !== id));
else setNodeIds([...nodeIds, id]);
}
function validate(): string | null {
if (mode === 'create') {
if (!name.trim()) return 'Blueprint needs a name';
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name.trim())) return 'Name must be lowercase letters, digits, hyphens, or underscores (must start with a letter or digit)';
}
if (!composeContent.trim()) return 'Compose content cannot be empty';
if (selectorType === 'labels' && labelsAny.length === 0 && labelsAll.length === 0) return 'Pick at least one label';
if (selectorType === 'nodes' && nodeIds.length === 0) return 'Pick at least one node';
return null;
}
async function handleSubmit() {
const err = validate();
if (err) { toast.error(err); return; }
const input = mode === 'create'
? {
name: name.trim(),
description: description.trim() || null,
compose_content: composeContent,
selector,
drift_mode: driftMode,
enabled,
} satisfies CreateBlueprintInput
: {
name: name.trim(),
description: description.trim() || null,
compose_content: composeContent,
selector,
drift_mode: driftMode,
enabled,
} satisfies UpdateBlueprintInput;
await onSubmit(input);
}
return (
<div className="space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">Name</Label>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder="caddy-edge"
className="font-mono"
disabled={mode === 'edit'}
/>
{mode === 'edit' && (
<p className="text-[10px] text-muted-foreground">Name is fixed once a blueprint exists.</p>
)}
</div>
<div className="space-y-1.5">
<Label className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">Description</Label>
<Input
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Reverse proxy across the production tier"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">
Compose
</Label>
{analyzing && (
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-[0.18em]">
Analyzing
</span>
)}
</div>
<BlueprintClassificationBanner analysis={analysis} />
<div className="rounded-lg border border-card-border overflow-hidden">
<Suspense fallback={<Skeleton className="h-[320px] w-full" />}>
<Editor
height="320px"
language="yaml"
value={composeContent}
onChange={(v) => setComposeContent(v ?? '')}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 12,
fontFamily: 'var(--font-mono)',
}}
theme="vs-dark"
/>
</Suspense>
</div>
{isStatefulMulti && (
<div className="flex items-start gap-2 rounded-lg border border-warning/30 bg-warning/5 p-3">
<AlertTriangle className="w-4 h-4 text-warning shrink-0 mt-0.5" strokeWidth={1.5} />
<p className="text-xs text-stat-subtitle leading-relaxed">
This blueprint is stateful and targets multiple nodes. Each node will hold its own data; Sencho does not replicate volumes between nodes.
</p>
</div>
)}
</div>
<div className="space-y-2">
<Label className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">Selector</Label>
<div className="flex gap-1">
<Button
size="sm"
variant={selectorType === 'labels' ? 'default' : 'outline'}
onClick={() => setSelectorType('labels')}
>
Labels
</Button>
<Button
size="sm"
variant={selectorType === 'nodes' ? 'default' : 'outline'}
onClick={() => setSelectorType('nodes')}
>
Specific nodes
</Button>
</div>
{selectorType === 'labels' ? (
<div className="space-y-3 rounded-lg border border-card-border bg-card p-3">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon mb-1.5">Match nodes with ANY of these labels</p>
<div className="flex flex-wrap gap-1.5">
{distinctLabels.length === 0 && (
<span className="text-[10px] text-muted-foreground">
No node labels yet. Add labels in Settings Nodes.
</span>
)}
{distinctLabels.map(l => (
<button
key={`any-${l}`}
type="button"
onClick={() => toggleLabel(labelsAny, setLabelsAny, l)}
className={`cursor-pointer rounded-md border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.18em] transition-colors ${labelsAny.includes(l)
? 'border-brand bg-brand/10 text-brand'
: 'border-card-border text-stat-subtitle hover:text-stat-value'
}`}
>
{l}
</button>
))}
</div>
</div>
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon mb-1.5">AND ALSO require ALL of these</p>
<div className="flex flex-wrap gap-1.5">
{distinctLabels.map(l => (
<button
key={`all-${l}`}
type="button"
onClick={() => toggleLabel(labelsAll, setLabelsAll, l)}
className={`cursor-pointer rounded-md border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.18em] transition-colors ${labelsAll.includes(l)
? 'border-brand bg-brand/10 text-brand'
: 'border-card-border text-stat-subtitle hover:text-stat-value'
}`}
>
{l}
</button>
))}
</div>
</div>
{(labelsAny.length > 0 || labelsAll.length > 0) && (
<p className="font-mono text-[10px] text-stat-icon">
Resolves to nodes labelled {[
labelsAll.length > 0 ? `all of [${labelsAll.join(', ')}]` : '',
labelsAny.length > 0 ? `any of [${labelsAny.join(', ')}]` : '',
].filter(Boolean).join(' AND ')}.
</p>
)}
</div>
) : (
<div className="space-y-2 rounded-lg border border-card-border bg-card p-3">
{nodes.map(n => (
<label key={n.id} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={nodeIds.includes(n.id)}
onChange={() => toggleNode(n.id)}
className="cursor-pointer"
/>
<span>{n.name}</span>
<span className="text-[10px] text-muted-foreground font-mono uppercase">{n.type}</span>
</label>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">Drift policy</Label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
{DRIFT_MODES.map(opt => {
const selected = driftMode === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setDriftMode(opt.value)}
className={`text-left rounded-lg border p-3 transition-colors cursor-pointer ${selected
? 'border-brand/50 bg-brand/5 border-l-2 border-l-brand'
: 'border-card-border bg-card hover:border-t-card-border-hover'
}`}
>
<div className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.2em] text-brand">
<span className={`inline-block w-2 h-2 rounded-full ${selected ? 'bg-brand' : 'border border-stat-icon'}`} />
{opt.kicker}
</div>
<p className="font-serif italic text-sm mt-1.5 text-stat-value">{opt.title}</p>
<p className="text-[10px] text-stat-subtitle mt-0.5">{opt.tagline}</p>
</button>
);
})}
</div>
</div>
<div className="flex items-center justify-between border-t border-border pt-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
<span className="text-xs text-stat-subtitle">Reconciler enabled</span>
</label>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onCancel} disabled={submitting}>Cancel</Button>
<Button size="sm" onClick={handleSubmit} disabled={submitting} className="gap-2">
{mode === 'create' ? <Sparkles className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{submitting ? 'Saving…' : mode === 'create' ? 'Create blueprint' : 'Save changes'}
</Button>
</div>
</div>
{!analysis?.parseError && analysis?.classification === 'stateful' && (
<div className="flex items-start gap-2 text-xs text-stat-subtitle">
<Zap className="h-3 w-3 text-warning shrink-0 mt-0.5" strokeWidth={1.5} />
<span>Stateful blueprints require explicit confirmation on first deploy and on eviction. The deployment table will surface those prompts.</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,51 @@
import { LayoutTemplate, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface BlueprintEmptyStateProps {
onCreate: () => void;
}
export function BlueprintEmptyState({ onCreate }: BlueprintEmptyStateProps) {
return (
<div className="mx-auto max-w-3xl rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
<div className="flex flex-col gap-5 p-8">
<div className="flex items-center justify-between gap-3">
<div className="inline-flex items-center gap-2 text-brand">
<LayoutTemplate className="h-4 w-4" strokeWidth={1.5} />
<span className="font-mono text-[10px] uppercase tracking-[0.2em]">Deployments · Blueprints</span>
</div>
</div>
<h3 className="font-serif text-2xl italic leading-tight tracking-[-0.01em] text-stat-value">
Declare once. Distribute everywhere.
</h3>
<p className="font-sans text-sm leading-relaxed text-stat-subtitle">
A Blueprint is a docker-compose.yml plus a node selector. Sencho ensures the matching nodes always run that stack at the latest revision and tells you when reality drifts from the plan.
</p>
<div className="grid gap-3 md:grid-cols-3 border-t border-border pt-4">
<Step kicker="01 · Author" title="Paste your compose" copy="Or build it from scratch in the YAML editor. We classify it as stateless or stateful so the right safety rails apply." />
<Step kicker="02 · Target" title="Pick nodes by label or by ID" copy="Selector matches `production` nodes today and any node tagged `production` you add tomorrow." />
<Step kicker="03 · Reconcile" title="Sencho keeps it in sync" copy="Choose Observe, Suggest, or Enforce. Sencho still always detects drift, never silently." />
</div>
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">
First blueprint, no fleet required
</span>
<Button size="sm" onClick={onCreate} className="gap-2">
<Sparkles className="h-4 w-4" strokeWidth={1.5} />
Create your first Blueprint
</Button>
</div>
</div>
</div>
);
}
function Step({ kicker, title, copy }: { kicker: string; title: string; copy: string }) {
return (
<div className="flex flex-col gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-brand">{kicker}</span>
<span className="font-serif italic text-base text-stat-value">{title}</span>
<span className="text-xs text-stat-subtitle leading-relaxed">{copy}</span>
</div>
);
}
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useState } from 'react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { toast } from '@/components/ui/toast-store';
import {
type BlueprintListItem,
type CreateBlueprintInput,
type UpdateBlueprintInput,
listBlueprints,
createBlueprint,
listDistinctLabels,
} from '@/lib/blueprintsApi';
import { BlueprintCatalog } from './BlueprintCatalog';
import { BlueprintEmptyState } from './BlueprintEmptyState';
import { BlueprintDetail } from './BlueprintDetail';
import { BlueprintEditor } from './BlueprintEditor';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
export function DeploymentsTab() {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const canEdit = isPaid && isAdmin;
const [blueprints, setBlueprints] = useState<BlueprintListItem[]>([]);
const [distinctLabels, setDistinctLabels] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
setLoadError(null);
try {
const [list, labels] = await Promise.all([
listBlueprints(),
listDistinctLabels().catch(() => [] as string[]),
]);
setBlueprints(list);
setDistinctLabels(labels);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load blueprints';
setLoadError(message);
toast.error(message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function handleCreate(input: CreateBlueprintInput | UpdateBlueprintInput) {
setSubmitting(true);
try {
const created = await createBlueprint(input as CreateBlueprintInput);
toast.success('Blueprint created');
setCreateOpen(false);
await refresh();
setSelectedId(created.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create blueprint');
} finally {
setSubmitting(false);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-xs text-stat-subtitle font-mono uppercase tracking-[0.18em]">
Loading blueprints
</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 blueprints
</div>
<p className="text-sm text-stat-subtitle leading-relaxed">{loadError}</p>
<button
type="button"
onClick={() => void refresh()}
className="inline-flex items-center gap-2 rounded border border-card-border px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.18em] hover:border-t-card-border-hover cursor-pointer"
>
Retry
</button>
</div>
);
}
return (
<div className="space-y-5">
{blueprints.length === 0 ? (
<BlueprintEmptyState onCreate={() => setCreateOpen(true)} />
) : (
<BlueprintCatalog
blueprints={blueprints}
onSelect={setSelectedId}
onCreate={() => setCreateOpen(true)}
/>
)}
{selectedId !== null && (
<BlueprintDetail
blueprintId={selectedId}
open={selectedId !== null}
onOpenChange={(o) => { if (!o) setSelectedId(null); }}
onChanged={refresh}
canEdit={canEdit}
distinctLabels={distinctLabels}
/>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">
New Blueprint
</span>
<DialogTitle className="font-serif italic text-xl tracking-[-0.01em]">
Declare a fleet-wide compose template
</DialogTitle>
</DialogHeader>
<BlueprintEditor
mode="create"
distinctLabels={distinctLabels}
onCancel={() => setCreateOpen(false)}
onSubmit={handleCreate}
submitting={submitting}
/>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,111 @@
import { useState } from 'react';
import { AlertTriangle, Camera } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface EvictionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
blueprintName: string;
nodeName: string;
isStateful: boolean;
busy: boolean;
onConfirm: (mode: 'standard' | 'snapshot_then_evict' | 'evict_and_destroy') => void;
}
export function EvictionDialog({
open, onOpenChange, blueprintName, nodeName, isStateful, busy, onConfirm,
}: EvictionDialogProps) {
const [confirmText, setConfirmText] = useState('');
const destructiveDisabled = isStateful && confirmText.trim() !== blueprintName;
function reset() {
setConfirmText('');
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2 text-brand font-mono text-[10px] uppercase tracking-[0.2em]">
<span className="inline-block w-1 h-3 bg-brand" />
Withdraw deployment
</div>
<DialogTitle className="font-serif italic text-xl tracking-[-0.01em]">
Stop {blueprintName} on {nodeName}
</DialogTitle>
<DialogDescription>
{isStateful
? 'This blueprint is stateful. Choose how to handle its data on this node.'
: 'Sencho will run docker compose down and remove the blueprint directory on this node.'}
</DialogDescription>
</DialogHeader>
{isStateful && (
<div className="space-y-3">
<div className="rounded-lg border border-warning/30 bg-warning/5 p-3 flex gap-2">
<AlertTriangle className="w-4 h-4 text-warning shrink-0 mt-0.5" strokeWidth={1.5} />
<p className="text-xs text-stat-subtitle leading-relaxed">
Named volumes or bind mounts were detected. Evicting destroys the named volumes managed by this stack on <span className="font-mono">{nodeName}</span>. Bind mounts on the host filesystem are left in place.
</p>
</div>
<button
type="button"
onClick={() => onConfirm('snapshot_then_evict')}
disabled={busy}
className="w-full text-left rounded-lg border border-card-border border-t-card-border-top bg-card hover:border-t-card-border-hover transition-colors p-3 cursor-pointer"
>
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.2em] text-brand">
<Camera className="w-3 h-3" strokeWidth={1.5} />
Snapshot, then evict (recommended)
</div>
<p className="text-xs text-stat-subtitle mt-1.5 leading-relaxed">
Captures the compose definition into the existing fleet-snapshot store, then runs the eviction. Note: volume bytes are not shipped; that ships in a future Volume Migration feature.
</p>
</button>
<div className="space-y-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-destructive">
Evict and destroy data
</div>
<p className="text-xs text-stat-subtitle leading-relaxed">
Type <span className="font-mono text-stat-value">{blueprintName}</span> to confirm.
</p>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={blueprintName}
className="font-mono text-xs"
disabled={busy}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
</Button>
{isStateful ? (
<Button
variant="outline"
className="text-destructive border-destructive/40 hover:bg-destructive/10"
disabled={destructiveDisabled || busy}
onClick={() => onConfirm('evict_and_destroy')}
>
Evict and destroy data
</Button>
) : (
<Button onClick={() => onConfirm('standard')} disabled={busy}>
Withdraw deployment
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,160 @@
import { useEffect, useState, useCallback } from 'react';
import { Plus, Tag } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui/toast-store';
import {
addNodeLabel,
removeNodeLabel,
getLabelsForNode,
listDistinctLabels,
} from '@/lib/blueprintsApi';
import { NodeLabelPill } from './NodeLabelPill';
interface NodeLabelPickerProps {
nodeId: number;
/** Whether the current operator is allowed to add/remove labels (admin + paid). */
canEdit?: boolean;
/** Called whenever the label set for this node changes; parents may use this
* to refresh their cached list. */
onChange?: (labels: string[]) => void;
}
export function NodeLabelPicker({ nodeId, canEdit = true, onChange }: NodeLabelPickerProps) {
const [labels, setLabels] = useState<string[]>([]);
const [allLabels, setAllLabels] = useState<string[]>([]);
const [open, setOpen] = useState(false);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const [mine, distinct] = await Promise.all([
getLabelsForNode(nodeId),
listDistinctLabels().catch(() => [] as string[]),
]);
setLabels(mine);
setAllLabels(distinct);
onChange?.(mine);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load node labels');
} finally {
setLoading(false);
}
}, [nodeId, onChange]);
useEffect(() => {
void refresh();
}, [refresh]);
async function handleAdd(label: string) {
const trimmed = label.trim();
if (!trimmed) return;
setBusy(true);
try {
await addNodeLabel(nodeId, trimmed);
setInput('');
await refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add label');
} finally {
setBusy(false);
}
}
async function handleRemove(label: string) {
setBusy(true);
try {
await removeNodeLabel(nodeId, label);
await refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to remove label');
} finally {
setBusy(false);
}
}
const suggestions = allLabels.filter(l => !labels.includes(l) && (input === '' || l.toLowerCase().includes(input.toLowerCase())));
return (
<div className="flex items-center flex-wrap gap-1">
{labels.map(label => (
<NodeLabelPill
key={label}
label={label}
size="sm"
onRemove={canEdit ? () => handleRemove(label) : undefined}
/>
))}
{labels.length === 0 && !loading && (
<span className="text-[10px] text-muted-foreground font-mono uppercase tracking-[0.18em]">No labels</span>
)}
{canEdit && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-5 w-5" aria-label="Add label">
<Plus className="w-3 h-3" strokeWidth={1.5} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3 space-y-2" align="start">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.18em] font-mono text-muted-foreground">
<Tag className="w-3 h-3" strokeWidth={1.5} />
Add label
</div>
<div className="flex items-center gap-1.5">
<Input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
void handleAdd(input);
}
}}
placeholder="prod"
className="h-7 text-xs font-mono"
disabled={busy}
autoFocus
/>
<Button
size="sm"
variant="default"
className="h-7"
onClick={() => void handleAdd(input)}
disabled={busy || input.trim().length === 0}
>
Add
</Button>
</div>
{suggestions.length > 0 && (
<div className="space-y-1">
<div className="text-[10px] uppercase tracking-[0.18em] font-mono text-muted-foreground">
Existing
</div>
<div className="flex flex-wrap gap-1">
{suggestions.slice(0, 12).map(s => (
<button
key={s}
type="button"
className="cursor-pointer"
onClick={() => void handleAdd(s)}
disabled={busy}
>
<NodeLabelPill label={s} size="sm" />
</button>
))}
</div>
</div>
)}
<p className="text-[10px] text-muted-foreground leading-relaxed">
Labels group nodes for fleet-wide blueprints. Use lowercase letters, digits, dot, dash, underscore.
</p>
</PopoverContent>
</Popover>
)}
</div>
);
}
@@ -0,0 +1,48 @@
const HUE_VARS = [
'teal', 'blue', 'purple', 'rose', 'amber',
'green', 'orange', 'pink', 'cyan', 'slate',
] as const;
type Hue = typeof HUE_VARS[number];
function hashLabel(label: string): Hue {
let h = 0;
for (let i = 0; i < label.length; i += 1) {
h = (h * 31 + label.charCodeAt(i)) | 0;
}
return HUE_VARS[Math.abs(h) % HUE_VARS.length];
}
interface NodeLabelPillProps {
label: string;
onRemove?: () => void;
size?: 'sm' | 'md';
}
export function NodeLabelPill({ label, onRemove, size = 'md' }: NodeLabelPillProps) {
const hue = hashLabel(label);
const sizeClasses = size === 'sm' ? 'text-[10px] px-1.5 py-0' : 'text-[11px] px-2 py-0.5';
return (
<span
className={`inline-flex items-center gap-1 rounded-md border font-mono ${sizeClasses}`}
style={{
backgroundColor: `var(--label-${hue}-bg)`,
color: `var(--label-${hue})`,
borderColor: `color-mix(in oklch, var(--label-${hue}) 30%, transparent)`,
}}
>
{label}
{onRemove && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="opacity-60 hover:opacity-100 ml-0.5 cursor-pointer"
aria-label={`Remove ${label}`}
>
×
</button>
)}
</span>
);
}
@@ -0,0 +1,74 @@
import { Database, Play } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface StateReviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
blueprintName: string;
nodeName: string;
busy: boolean;
onAccept: (mode: 'fresh' | 'restore_from_snapshot') => void;
}
export function StateReviewDialog({
open, onOpenChange, blueprintName, nodeName, busy, onAccept,
}: StateReviewDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2 text-brand font-mono text-[10px] uppercase tracking-[0.2em]">
<span className="inline-block w-1 h-3 bg-brand" />
Confirm first deploy
</div>
<DialogTitle className="font-serif italic text-xl tracking-[-0.01em]">
Deploy {blueprintName} to {nodeName}?
</DialogTitle>
<DialogDescription>
This blueprint is stateful and has never run on this node. Sencho will create empty volumes unless you choose to restore from a prior snapshot.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<button
type="button"
onClick={() => onAccept('fresh')}
disabled={busy}
className="w-full text-left rounded-lg border border-card-border border-t-card-border-top bg-card hover:border-t-card-border-hover transition-colors p-3 cursor-pointer"
>
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.2em] text-brand">
<Play className="w-3 h-3" strokeWidth={1.5} />
Deploy fresh
</div>
<p className="text-xs text-stat-subtitle mt-1.5 leading-relaxed">
Create empty named volumes on this node. The container starts with whatever default state its image carries.
</p>
</button>
<button
type="button"
disabled
className="w-full text-left rounded-lg border border-card-border bg-card/40 p-3 opacity-60 cursor-not-allowed"
>
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.2em] text-stat-icon">
<Database className="w-3 h-3" strokeWidth={1.5} />
Restore from snapshot
</div>
<p className="text-xs text-stat-subtitle mt-1.5 leading-relaxed">
Coming with the future Volume Migration feature. App-aware backup tooling (postgres pg_basebackup, mysql xtrabackup) will populate volumes from a prior deployment.
</p>
</button>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+280
View File
@@ -0,0 +1,280 @@
import { apiFetch } from './api';
export type DriftMode = 'observe' | 'suggest' | 'enforce';
export type BlueprintClassification = 'stateless' | 'stateful' | 'unknown';
export type BlueprintDeploymentStatus =
| 'pending'
| 'pending_state_review'
| 'deploying'
| 'active'
| 'drifted'
| 'correcting'
| 'failed'
| 'withdrawing'
| 'withdrawn'
| 'evict_blocked'
| 'name_conflict';
export type BlueprintSelector =
| { type: 'labels'; any: string[]; all: string[] }
| { type: 'nodes'; ids: number[] };
export interface Blueprint {
id: number;
name: string;
description: string | null;
compose_content: string;
selector: BlueprintSelector;
drift_mode: DriftMode;
classification: BlueprintClassification;
classification_reasons: string[];
enabled: boolean;
revision: number;
created_at: number;
updated_at: number;
created_by: string | null;
}
export interface BlueprintListItem extends Blueprint {
deploymentCounts: Partial<Record<BlueprintDeploymentStatus, number>>;
deploymentTotal: number;
}
export interface BlueprintDeployment {
id: number;
blueprint_id: number;
node_id: number;
status: BlueprintDeploymentStatus;
applied_revision: number | null;
last_deployed_at: number | null;
last_checked_at: number | null;
last_drift_at: number | null;
drift_summary: string | null;
last_error: string | null;
}
export interface BlueprintSummary {
blueprint: Blueprint;
deployments: BlueprintDeployment[];
statusCounts: Partial<Record<BlueprintDeploymentStatus, number>>;
}
export interface AnalyzerResult {
classification: BlueprintClassification;
reasons: string[];
hasNamedVolumes: boolean;
hasBindMounts: boolean;
hasExternalVolumes: boolean;
hasTmpfsOnly: boolean;
parseError?: string;
}
export interface BlueprintPreview {
blueprintId: number;
classification: BlueprintClassification;
matchedNodes: Array<{ id: number; name: string; type: 'local' | 'remote' }>;
plannedDeployments: Array<{ id: number; name: string }>;
plannedDriftChecks: Array<{ id: number; name: string }>;
plannedEvictions: number[];
}
export type WithdrawConfirm = 'standard' | 'snapshot_then_evict' | 'evict_and_destroy';
export type AcceptMode = 'fresh' | 'restore_from_snapshot';
async function expectJson<T>(res: Response, fallback: string): Promise<T> {
if (!res.ok) {
let detail = fallback;
try {
const body = await res.json();
if (body && typeof body === 'object' && typeof body.error === 'string') detail = body.error;
} catch {
// body not JSON
}
const err = new Error(detail) as Error & { status: number };
err.status = res.status;
throw err;
}
return res.json() as Promise<T>;
}
async function expectNoContent(res: Response, fallback: string): Promise<void> {
if (!res.ok) {
let detail = fallback;
try {
const body = await res.json();
if (body && typeof body === 'object' && typeof body.error === 'string') detail = body.error;
} catch {
// body not JSON
}
const err = new Error(detail) as Error & { status: number };
err.status = res.status;
throw err;
}
}
// ---- Blueprints CRUD ----
export async function listBlueprints(): Promise<BlueprintListItem[]> {
const res = await apiFetch('/blueprints', { localOnly: true });
return expectJson<BlueprintListItem[]>(res, 'Failed to load blueprints');
}
export async function getBlueprint(id: number): Promise<BlueprintSummary> {
const res = await apiFetch(`/blueprints/${id}`, { localOnly: true });
return expectJson<BlueprintSummary>(res, 'Failed to load blueprint');
}
export interface CreateBlueprintInput {
name: string;
description?: string | null;
compose_content: string;
selector: BlueprintSelector;
drift_mode?: DriftMode;
enabled?: boolean;
}
export async function createBlueprint(input: CreateBlueprintInput): Promise<Blueprint> {
const res = await apiFetch('/blueprints', {
method: 'POST',
body: JSON.stringify(input),
localOnly: true,
});
return expectJson<Blueprint>(res, 'Failed to create blueprint');
}
export interface UpdateBlueprintInput {
name?: string;
description?: string | null;
compose_content?: string;
selector?: BlueprintSelector;
drift_mode?: DriftMode;
enabled?: boolean;
}
export async function updateBlueprint(id: number, input: UpdateBlueprintInput): Promise<Blueprint> {
const res = await apiFetch(`/blueprints/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
localOnly: true,
});
return expectJson<Blueprint>(res, 'Failed to update blueprint');
}
export async function deleteBlueprint(id: number): Promise<void> {
const res = await apiFetch(`/blueprints/${id}`, { method: 'DELETE', localOnly: true });
return expectNoContent(res, 'Failed to delete blueprint');
}
// ---- Apply / Withdraw / Accept / Preview ----
export async function applyBlueprint(id: number): Promise<{ message: string }> {
const res = await apiFetch(`/blueprints/${id}/apply`, { method: 'POST', localOnly: true });
return expectJson(res, 'Failed to apply blueprint');
}
export async function withdrawDeployment(
blueprintId: number,
nodeId: number,
confirm: WithdrawConfirm,
): Promise<{ status: BlueprintDeploymentStatus; error: string | null; snapshotPolicy: WithdrawConfirm }> {
const res = await apiFetch(`/blueprints/${blueprintId}/withdraw/${nodeId}`, {
method: 'POST',
body: JSON.stringify({ confirm }),
localOnly: true,
});
return expectJson(res, 'Failed to withdraw deployment');
}
export async function acceptDeployment(
blueprintId: number,
nodeId: number,
mode: AcceptMode,
): Promise<{ status: string; mode: AcceptMode }> {
const res = await apiFetch(`/blueprints/${blueprintId}/accept/${nodeId}`, {
method: 'POST',
body: JSON.stringify({ mode }),
localOnly: true,
});
return expectJson(res, 'Failed to accept deployment');
}
export async function previewBlueprint(id: number): Promise<BlueprintPreview> {
const res = await apiFetch(`/blueprints/${id}/preview`, { localOnly: true });
return expectJson<BlueprintPreview>(res, 'Failed to preview blueprint');
}
export async function analyzeCompose(composeContent: string): Promise<AnalyzerResult> {
const res = await apiFetch('/blueprints/analyze', {
method: 'POST',
body: JSON.stringify({ compose_content: composeContent }),
localOnly: true,
});
return expectJson<AnalyzerResult>(res, 'Failed to analyze compose');
}
// ---- Node labels ----
export async function listAllNodeLabels(): Promise<Record<number, string[]>> {
const res = await apiFetch('/node-labels', { localOnly: true });
return expectJson<Record<number, string[]>>(res, 'Failed to load node labels');
}
export async function listDistinctLabels(): Promise<string[]> {
const res = await apiFetch('/node-labels/all', { localOnly: true });
const data = await expectJson<{ labels: string[] }>(res, 'Failed to load distinct labels');
return data.labels;
}
export async function getLabelsForNode(nodeId: number): Promise<string[]> {
const res = await apiFetch(`/node-labels/${nodeId}`, { localOnly: true });
const data = await expectJson<{ nodeId: number; labels: string[] }>(res, 'Failed to load labels for node');
return data.labels;
}
export async function addNodeLabel(nodeId: number, label: string): Promise<{ nodeId: number; label: string }> {
const res = await apiFetch(`/node-labels/${nodeId}`, {
method: 'POST',
body: JSON.stringify({ label }),
localOnly: true,
});
return expectJson(res, 'Failed to add label');
}
export async function removeNodeLabel(nodeId: number, label: string): Promise<void> {
const res = await apiFetch(`/node-labels/${nodeId}/${encodeURIComponent(label)}`, {
method: 'DELETE',
localOnly: true,
});
return expectNoContent(res, 'Failed to remove label');
}
// ---- helpers ----
export function describeSelector(selector: BlueprintSelector): string {
if (selector.type === 'nodes') {
return selector.ids.length === 0
? 'no nodes selected'
: `${selector.ids.length} node${selector.ids.length === 1 ? '' : 's'}`;
}
const parts: string[] = [];
if (selector.all.length > 0) parts.push(`all=[${selector.all.join(', ')}]`);
if (selector.any.length > 0) parts.push(`any=[${selector.any.join(', ')}]`);
return parts.length === 0 ? 'no labels selected' : parts.join(' · ');
}
export function statusTone(status: BlueprintDeploymentStatus): 'success' | 'brand' | 'warning' | 'destructive' | 'muted' {
switch (status) {
case 'active': return 'success';
case 'deploying':
case 'correcting': return 'brand';
case 'pending':
case 'pending_state_review':
case 'evict_blocked':
case 'drifted':
case 'withdrawing': return 'warning';
case 'failed':
case 'name_conflict': return 'destructive';
case 'withdrawn':
default: return 'muted';
}
}