mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user