mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +00:00
e5391e66cb
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
379 lines
18 KiB
TypeScript
379 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|