diff --git a/docs/docs.json b/docs/docs.json index a11ddf10..98e7ec3a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -111,6 +111,7 @@ "features/sencho-mesh", "features/fleet-view", "features/fleet-sync", + "features/blueprint-model", "features/remote-updates", "features/stack-labels", "features/alerts-notifications", diff --git a/docs/features/blueprint-model.mdx b/docs/features/blueprint-model.mdx new file mode 100644 index 00000000..0372921c --- /dev/null +++ b/docs/features/blueprint-model.mdx @@ -0,0 +1,151 @@ +--- +title: "Blueprints" +description: "Fleet-wide compose templates that Sencho keeps in sync across the nodes you choose." +--- + +A **Blueprint** is a docker-compose.yml plus a node selector. Sencho ensures that every node matching the selector runs that stack at the latest revision and reports back when reality drifts from the plan. You declare a stack once; Sencho handles the distribution. + +Blueprints live under **Fleet → Deployments**. + + +Blueprints are a Skipper feature. Configuring blueprints requires an admin role; viewers see the catalog read-only. + + +## What problem this solves + +Without Blueprints, running the same stack on multiple nodes means SSHing or clicking through each node's stack manager and keeping them in sync by hand. When something drifts (someone restarts a container, edits a compose, or a node forgets to pull a new image) you find out when it breaks. + +With Blueprints you get: + +- **One declaration covers many nodes.** Pick nodes by label (`production`) or by ID. The set is recomputed every reconciliation tick — adding a node with the right label deploys the stack automatically. +- **Drift detection always on.** Every tick, Sencho compares each target node's actual state to the desired one. You choose what happens when drift is found. +- **Safety rails for stateful workloads.** Blueprints are classified as stateless or stateful at author time; stateful blueprints get explicit confirmation prompts before first deploy and before eviction. + +## Anatomy of a Blueprint + +| Field | Purpose | +|---|---| +| **Name** | Used as the stack directory on every targeted node (`//`). Lowercase letters, digits, hyphens, and underscores. | +| **Description** | Short prose for the catalog tile and detail header. | +| **Compose** | Standard `docker-compose.yml`. The same file ships to every targeted node. | +| **Selector** | Either `labels` (any/all expressions) or a list of node IDs. | +| **Drift policy** | Observe, Suggest, or Enforce. See below. | +| **Reconciler enabled** | Toggle the reconciliation loop without deleting the blueprint. | + +Sencho writes a `.blueprint.json` marker into each targeted node's stack directory. The marker carries the blueprint ID, revision, and the timestamp of the last apply. The reconciler refuses to touch any directory that does not carry a matching marker — so a Blueprint named `nginx` will never overwrite an existing user-authored `nginx` stack on any node. + +## Selectors + +A **labels** selector matches any node whose labels satisfy the expression: + +``` +all = [docker] +any = [production, staging] +``` + +This resolves to nodes that have *every* label in `all` AND *at least one* label in `any`. Either side may be empty. An entirely empty labels selector matches nothing — choose at least one label. + +A **nodes** selector picks specific node IDs by hand. Useful when you want a one-off blueprint that runs only on a known node. + +Add labels to nodes from **Settings → Nodes** — each node row has a Labels column with a `+` button. + +## Drift policy + +Drift detection runs every minute regardless of the policy. Only the response differs: + +| Mode | What happens on drift | +|---|---| +| **Observe** | Drift surfaces in the deployment table; no notification, no auto-fix. | +| **Suggest** (default) | Sencho dispatches a `blueprint_drift_detected` notification through your notification routes, if any. | +| **Enforce** | Sencho re-deploys the blueprint silently when drift is detected. A notification fires only when an auto-fix attempt fails. | + +Even **Observe** keeps Sencho honest about what it found — the deployment row shows "drifted 3h ago: service caddy exited code 1". Silence would forfeit Sencho's authority over your fleet. + +For **stateful** blueprints under Enforce, Sencho declines auto-fixes that would destroy named volumes (for example, when you rename a volume in the compose). The drift downgrades to Suggest semantics for that event with the reason `auto-fix declined: would destroy volume data`. + +## Stateless vs Stateful Blueprints + +Sencho classifies your compose at author time: + +- **Stateless** — no persistent volumes detected, or only `tmpfs`. Sencho can deploy and evict freely. +- **Stateful** — named volumes or bind mounts detected. Each node holds its own data; Sencho does not replicate volumes between nodes. +- **State unknown** — `external: true` volumes detected. Sencho cannot prove portability and treats the blueprint as stateful for safety. + +The classification appears as a chip on the catalog tile and as a banner above the YAML editor. Click the banner to see exactly what made Sencho classify the way it did. + +### Safety rails on stateful blueprints + +| Trigger | What Sencho does | +|---|---| +| Selector matches a node that has never run this blueprint | Deployment enters `pending_state_review`. The reconciler refuses to deploy until you click **Confirm deploy** in the deployment table. | +| A node leaves the selector while a deployment is active | Deployment enters `evict_blocked`. The reconciler refuses to evict until you choose **Snapshot, then evict** or **Evict and destroy data**. | +| You target more than one node | The editor warns: "Each node will hold its own data — Sencho does not replicate volumes between nodes." | + +Stateless blueprints flow through these states automatically. + +## Working with Blueprints + +### Create + +1. Go to **Fleet → Deployments**. +2. Click **New Blueprint**. +3. Fill in the name, description, compose YAML, selector, and drift policy. +4. Watch the classification banner update as you type — it tells you whether the blueprint is portable or pinned. +5. Click **Create blueprint**. Sencho immediately runs one reconciliation tick. + +### Apply on demand + +The reconciler runs every minute. To trigger it now (for example, after editing the selector or compose), click **Apply now** on the detail sheet. + +### Edit + +Click **Edit** on the detail sheet. Editing the compose bumps the revision; the reconciler will redeploy on every targeted node on the next tick. Stateful blueprints follow the volume-destroying drift rule under Enforce. + +### Withdraw a single deployment + +In the deployment table, click **Withdraw** on the node's row. For stateless blueprints, Sencho runs `docker compose down` and removes the directory. For stateful blueprints, you choose between **Snapshot, then evict** (records the compose definition to fleet snapshots, then evicts) and **Evict and destroy data** (typed-confirm, destroys named volumes). + +### Delete the blueprint + +Stateless blueprints withdraw all deployments and then delete. Stateful blueprints with active deployments refuse to delete — withdraw each deployment explicitly first. + +## Migrating stateful data between nodes (manual) + +Sencho's compose-native lane does not include automatic volume shipping. When you move a stateful Blueprint's data from node A to node B, do it by hand: + +1. Stop the Blueprint deployment on node A from the deployment table. +2. Use your existing host tooling (`docker run --rm -v :/data busybox tar -czf - /data > snapshot.tar.gz`, or app-aware tooling such as `pg_basebackup`/`mysqldump`/`mongodump`) to capture the volume. +3. Transfer the artifact to node B and restore it into the named volume. +4. Update the Blueprint's selector to include node B; click **Apply now**. + +A future Volume Migration feature will automate this with app-aware backup tooling. + +## Troubleshooting + +### "Name conflict" on a deployment row + +A directory by the blueprint's name already exists on that node and does not carry our `.blueprint.json` marker. Most likely cause: a manually created stack with the same name. Resolution: rename either the existing stack or the blueprint, then click **Apply now**. + +### Stateful blueprint stuck in "Awaiting confirmation" + +Click **Confirm deploy** on the row, then choose **Deploy fresh**. Sencho will create empty named volumes and start the stack. + +### Drift never gets corrected + +Confirm the drift policy is `enforce` and the blueprint is enabled. Open the detail sheet to see the deployment row's status and the most recent drift summary. If the drift was caused by a compose change that would destroy named volumes, Enforce intentionally downgrades — change the compose to one that preserves volumes, or withdraw and re-deploy with explicit operator confirmation. + +### Cannot disable a blueprint + +Blueprints with active or drifted deployments refuse to disable; you would orphan them silently. Withdraw the deployments first, then disable. + +## What's not in scope + +By design, Blueprints do not include: + +- A distributed storage layer (no CSI, no Longhorn-style replication) +- Automatic volume migration between nodes +- Per-node parameter overrides or templating (one compose, all nodes) +- Staged or canary rollouts +- Versioning history with one-click rollback (re-paste the prior compose to revert) + +These omissions keep Blueprints honest: a compose-native fleet primitive that distributes the file you already have to the nodes you choose. diff --git a/docs/images/blueprint-model/catalog.png b/docs/images/blueprint-model/catalog.png new file mode 100644 index 00000000..45e00762 Binary files /dev/null and b/docs/images/blueprint-model/catalog.png differ diff --git a/docs/images/blueprint-model/detail-sheet.png b/docs/images/blueprint-model/detail-sheet.png new file mode 100644 index 00000000..7daada3f Binary files /dev/null and b/docs/images/blueprint-model/detail-sheet.png differ diff --git a/docs/images/blueprint-model/detail-state-review.png b/docs/images/blueprint-model/detail-state-review.png new file mode 100644 index 00000000..63b04d54 Binary files /dev/null and b/docs/images/blueprint-model/detail-state-review.png differ diff --git a/docs/images/blueprint-model/editor-dialog.png b/docs/images/blueprint-model/editor-dialog.png new file mode 100644 index 00000000..cba5e35e Binary files /dev/null and b/docs/images/blueprint-model/editor-dialog.png differ diff --git a/docs/images/blueprint-model/empty-state.png b/docs/images/blueprint-model/empty-state.png new file mode 100644 index 00000000..28fd2ea0 Binary files /dev/null and b/docs/images/blueprint-model/empty-state.png differ diff --git a/docs/images/blueprint-model/node-labels.png b/docs/images/blueprint-model/node-labels.png new file mode 100644 index 00000000..91280d14 Binary files /dev/null and b/docs/images/blueprint-model/node-labels.png differ diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 279b0aac..4895b8e1 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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) { Deployments - + {!isPaid && } @@ -1385,13 +1386,19 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { - } - 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 ? ( + + ) : ( + + } + 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']} + /> + + )} Mode Endpoint Status + Labels Schedules Updates Actions @@ -551,6 +558,13 @@ export function NodeManager() { : (node.api_url || '-')} {getStatusBadge(node.status)} + + {isPaid ? ( + + ) : ( + Upgrade + )} + {(() => { const summary = nodeSummary[node.id]; diff --git a/frontend/src/components/blueprints/BlueprintCatalog.tsx b/frontend/src/components/blueprints/BlueprintCatalog.tsx new file mode 100644 index 00000000..e0670b61 --- /dev/null +++ b/frontend/src/components/blueprints/BlueprintCatalog.tsx @@ -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>): 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('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 ( +
+
+
+ + Deployments · Blueprints + +
+ +
+ + {featured && ( +
+
+
+
+ ★ Featured · most-deployed +
+

+ {featured.name} +

+ {featured.description && ( +

{featured.description}

+ )} +
+ + · + {featured.deploymentCounts.active ?? 0}/{featured.deploymentTotal} active + · + drift {featured.drift_mode} +
+
+
+ +
+
+
+ )} + +
+ setFilter('all')} /> + setFilter('drifted')} tone="warning" /> + · + setFilter('observe')} /> + setFilter('suggest')} /> + setFilter('enforce')} /> +
+ +
+ {filtered.map(b => ( + onSelect(b.id)} /> + ))} + {filtered.length === 0 && ( +
+

No blueprints match this filter.

+
+ )} +
+
+ ); +} + +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 ( + + ); +} + +function BlueprintTile({ blueprint, onClick }: { blueprint: BlueprintListItem; onClick: () => void }) { + const dom = dominantStatus(blueprint.deploymentCounts); + const active = blueprint.deploymentCounts.active ?? 0; + return ( + + ); +} + +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 ( + + + + {classification} + + ); +} diff --git a/frontend/src/components/blueprints/BlueprintClassificationBanner.tsx b/frontend/src/components/blueprints/BlueprintClassificationBanner.tsx new file mode 100644 index 00000000..eae5f8b6 --- /dev/null +++ b/frontend/src/components/blueprints/BlueprintClassificationBanner.tsx @@ -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 ( +
+ Compose not analyzed yet +
+ ); + } + + 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 ( +
+
+
+ + +
+ + {COPY[classification].kicker} + + {!compact && ( + + {parseError ? `Could not parse compose: ${parseError}` : COPY[classification].message} + + )} +
+
+ {reasons.length > 0 && ( + + )} +
+ {showDetails && reasons.length > 0 && ( +
    + {reasons.map((reason, i) => ( +
  • + · + {reason} +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/blueprints/BlueprintDeploymentTable.tsx b/frontend/src/components/blueprints/BlueprintDeploymentTable.tsx new file mode 100644 index 00000000..79b8e12b --- /dev/null +++ b/frontend/src/components/blueprints/BlueprintDeploymentTable.tsx @@ -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 = { + 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 ( +
+ No matching nodes yet. Add a label or pick a node ID, then click Apply now. +
+ ); + } + + return ( +
+ + + + Node + Status + Last activity + Notes + Actions + + + + {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 ( + + +
+ {node?.name ?? `node ${dep.node_id}`} + {dep.status === 'active' && isStateful && ( + + + + )} +
+
+ +
+ + {STATUS_LABEL[dep.status]} +
+
+ + {lastSeen ? formatTimeAgo(lastSeen) : '-'} + + + {dep.last_error ? ( + {dep.last_error} + ) : dep.drift_summary ? ( + {dep.drift_summary} + ) : ( + - + )} + + +
+ {dep.status === 'pending_state_review' && canEdit && ( + + )} + {dep.status === 'name_conflict' && ( + + + Resolve manually + + )} + {dep.status === 'failed' && canEdit && ( + + )} + {(dep.status === 'active' || dep.status === 'drifted' || dep.status === 'evict_blocked' || dep.status === 'failed') && canEdit && ( + + )} +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/blueprints/BlueprintDetail.tsx b/frontend/src/components/blueprints/BlueprintDetail.tsx new file mode 100644 index 00000000..7977330b --- /dev/null +++ b/frontend/src/components/blueprints/BlueprintDetail.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [editMode, setEditMode] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [busyNodeId, setBusyNodeId] = useState(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 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 ( + + + +
+ + {blueprint && ( +
+ + {canEdit && !editMode && ( + + )} + {canEdit && ( + + + + + + + {blueprint.enabled ? 'Disable reconciler' : 'Enable reconciler'} + + + setDeleteOpen(true)} className="text-destructive"> + + Delete blueprint + + + + )} +
+ )} +
+
+ + Blueprint + + + {blueprint?.name ?? } + + {blueprint?.description && ( +

{blueprint.description}

+ )} +
+
+ +
+ {loading || !blueprint || !summary ? ( +
+ + + +
+ ) : editMode ? ( + setEditMode(false)} + onSubmit={handleSaveEdit} + submitting={submitting} + /> + ) : ( + <> +
+ + + + +
+ + + +
+ + Compose + +
+                                    {blueprint.compose_content}
+                                
+
+ + )} +
+ + {evictTarget && blueprint && ( + { 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 && ( + { if (!o) setStateReviewTarget(null); }} + blueprintName={blueprint.name} + nodeName={stateReviewTarget.nodeName} + busy={busyNodeId === stateReviewTarget.nodeId} + onAccept={(mode) => performAccept(stateReviewTarget.nodeId, mode)} + /> + )} + {blueprint && ( + { if (!o) { setDeleteOpen(false); setDeleteConfirmText(''); } }}> + + +
+ + Delete blueprint +
+ + Permanently delete {blueprint.name}? + + + Stateless deployments will be withdrawn first. Stateful deployments must be withdrawn explicitly through the deployment table before delete; the API will refuse otherwise. + +
+
+

+ Type {blueprint.name} to confirm. +

+ setDeleteConfirmText(e.target.value)} + placeholder={blueprint.name} + className="font-mono text-xs" + disabled={submitting} + /> +
+ + + + +
+
+ )} +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+ {label} +

{value}

+
+ ); +} diff --git a/frontend/src/components/blueprints/BlueprintEditor.tsx b/frontend/src/components/blueprints/BlueprintEditor.tsx new file mode 100644 index 00000000..fb56f6d1 --- /dev/null +++ b/frontend/src/components/blueprints/BlueprintEditor.tsx @@ -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; + submitting: boolean; + mode: 'create' | 'edit'; +} + +const DEFAULT_COMPOSE = `# Blueprint compose. Sencho writes this file plus a .blueprint.json marker +# to // 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(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(initialSelector.type === 'labels' ? initialSelector.any : []); + const [labelsAll, setLabelsAll] = useState(initialSelector.type === 'labels' ? initialSelector.all : []); + const [nodeIds, setNodeIds] = useState(initialSelector.type === 'nodes' ? initialSelector.ids : []); + + const [analysis, setAnalysis] = useState(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 ( +
+
+
+ + setName(e.target.value)} + placeholder="caddy-edge" + className="font-mono" + disabled={mode === 'edit'} + /> + {mode === 'edit' && ( +

Name is fixed once a blueprint exists.

+ )} +
+
+ + setDescription(e.target.value)} + placeholder="Reverse proxy across the production tier" + /> +
+
+ +
+
+ + {analyzing && ( + + Analyzing… + + )} +
+ +
+ }> + setComposeContent(v ?? '')} + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 12, + fontFamily: 'var(--font-mono)', + }} + theme="vs-dark" + /> + +
+ {isStatefulMulti && ( +
+ +

+ This blueprint is stateful and targets multiple nodes. Each node will hold its own data; Sencho does not replicate volumes between nodes. +

+
+ )} +
+ +
+ +
+ + +
+ {selectorType === 'labels' ? ( +
+
+

Match nodes with ANY of these labels

+
+ {distinctLabels.length === 0 && ( + + No node labels yet. Add labels in Settings → Nodes. + + )} + {distinctLabels.map(l => ( + + ))} +
+
+
+

AND ALSO require ALL of these

+
+ {distinctLabels.map(l => ( + + ))} +
+
+ {(labelsAny.length > 0 || labelsAll.length > 0) && ( +

+ Resolves to nodes labelled {[ + labelsAll.length > 0 ? `all of [${labelsAll.join(', ')}]` : '', + labelsAny.length > 0 ? `any of [${labelsAny.join(', ')}]` : '', + ].filter(Boolean).join(' AND ')}. +

+ )} +
+ ) : ( +
+ {nodes.map(n => ( + + ))} +
+ )} +
+ +
+ +
+ {DRIFT_MODES.map(opt => { + const selected = driftMode === opt.value; + return ( + + ); + })} +
+
+ +
+ +
+ + +
+
+ + {!analysis?.parseError && analysis?.classification === 'stateful' && ( +
+ + Stateful blueprints require explicit confirmation on first deploy and on eviction. The deployment table will surface those prompts. +
+ )} +
+ ); +} diff --git a/frontend/src/components/blueprints/BlueprintEmptyState.tsx b/frontend/src/components/blueprints/BlueprintEmptyState.tsx new file mode 100644 index 00000000..e1e05ccd --- /dev/null +++ b/frontend/src/components/blueprints/BlueprintEmptyState.tsx @@ -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 ( +
+
+
+
+ + Deployments · Blueprints +
+
+

+ Declare once. Distribute everywhere. +

+

+ 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. +

+
+ + + +
+
+ + First blueprint, no fleet required + + +
+
+
+ ); +} + +function Step({ kicker, title, copy }: { kicker: string; title: string; copy: string }) { + return ( +
+ {kicker} + {title} + {copy} +
+ ); +} diff --git a/frontend/src/components/blueprints/DeploymentsTab.tsx b/frontend/src/components/blueprints/DeploymentsTab.tsx new file mode 100644 index 00000000..3738241a --- /dev/null +++ b/frontend/src/components/blueprints/DeploymentsTab.tsx @@ -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([]); + const [distinctLabels, setDistinctLabels] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [selectedId, setSelectedId] = useState(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 ( +
+ Loading blueprints… +
+ ); + } + + if (loadError) { + return ( +
+
+ Could not load blueprints +
+

{loadError}

+ +
+ ); + } + + return ( +
+ {blueprints.length === 0 ? ( + setCreateOpen(true)} /> + ) : ( + setCreateOpen(true)} + /> + )} + + {selectedId !== null && ( + { if (!o) setSelectedId(null); }} + onChanged={refresh} + canEdit={canEdit} + distinctLabels={distinctLabels} + /> + )} + + + + + + New Blueprint + + + Declare a fleet-wide compose template + + + setCreateOpen(false)} + onSubmit={handleCreate} + submitting={submitting} + /> + + +
+ ); +} diff --git a/frontend/src/components/blueprints/EvictionDialog.tsx b/frontend/src/components/blueprints/EvictionDialog.tsx new file mode 100644 index 00000000..0e52ccd3 --- /dev/null +++ b/frontend/src/components/blueprints/EvictionDialog.tsx @@ -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 ( + { if (!o) reset(); onOpenChange(o); }}> + + +
+ + Withdraw deployment +
+ + Stop {blueprintName} on {nodeName} + + + {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.'} + +
+ + {isStateful && ( +
+
+ +

+ Named volumes or bind mounts were detected. Evicting destroys the named volumes managed by this stack on {nodeName}. Bind mounts on the host filesystem are left in place. +

+
+ + + +
+
+ Evict and destroy data +
+

+ Type {blueprintName} to confirm. +

+ setConfirmText(e.target.value)} + placeholder={blueprintName} + className="font-mono text-xs" + disabled={busy} + /> +
+
+ )} + + + + {isStateful ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/frontend/src/components/blueprints/NodeLabelPicker.tsx b/frontend/src/components/blueprints/NodeLabelPicker.tsx new file mode 100644 index 00000000..390b6eff --- /dev/null +++ b/frontend/src/components/blueprints/NodeLabelPicker.tsx @@ -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([]); + const [allLabels, setAllLabels] = useState([]); + 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 ( +
+ {labels.map(label => ( + handleRemove(label) : undefined} + /> + ))} + {labels.length === 0 && !loading && ( + No labels + )} + {canEdit && ( + + + + + +
+ + Add label +
+
+ 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 + /> + +
+ {suggestions.length > 0 && ( +
+
+ Existing +
+
+ {suggestions.slice(0, 12).map(s => ( + + ))} +
+
+ )} +

+ Labels group nodes for fleet-wide blueprints. Use lowercase letters, digits, dot, dash, underscore. +

+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/blueprints/NodeLabelPill.tsx b/frontend/src/components/blueprints/NodeLabelPill.tsx new file mode 100644 index 00000000..87bd125f --- /dev/null +++ b/frontend/src/components/blueprints/NodeLabelPill.tsx @@ -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 ( + + {label} + {onRemove && ( + + )} + + ); +} + diff --git a/frontend/src/components/blueprints/StateReviewDialog.tsx b/frontend/src/components/blueprints/StateReviewDialog.tsx new file mode 100644 index 00000000..2c20cb1a --- /dev/null +++ b/frontend/src/components/blueprints/StateReviewDialog.tsx @@ -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 ( + + + +
+ + Confirm first deploy +
+ + Deploy {blueprintName} to {nodeName}? + + + 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. + +
+ +
+ + + +
+ + + + +
+
+ ); +} diff --git a/frontend/src/lib/blueprintsApi.ts b/frontend/src/lib/blueprintsApi.ts new file mode 100644 index 00000000..028310fc --- /dev/null +++ b/frontend/src/lib/blueprintsApi.ts @@ -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>; + 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>; +} + +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(res: Response, fallback: string): Promise { + 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; +} + +async function expectNoContent(res: Response, fallback: string): Promise { + 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 { + const res = await apiFetch('/blueprints', { localOnly: true }); + return expectJson(res, 'Failed to load blueprints'); +} + +export async function getBlueprint(id: number): Promise { + const res = await apiFetch(`/blueprints/${id}`, { localOnly: true }); + return expectJson(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 { + const res = await apiFetch('/blueprints', { + method: 'POST', + body: JSON.stringify(input), + localOnly: true, + }); + return expectJson(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 { + const res = await apiFetch(`/blueprints/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + localOnly: true, + }); + return expectJson(res, 'Failed to update blueprint'); +} + +export async function deleteBlueprint(id: number): Promise { + 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 { + const res = await apiFetch(`/blueprints/${id}/preview`, { localOnly: true }); + return expectJson(res, 'Failed to preview blueprint'); +} + +export async function analyzeCompose(composeContent: string): Promise { + const res = await apiFetch('/blueprints/analyze', { + method: 'POST', + body: JSON.stringify({ compose_content: composeContent }), + localOnly: true, + }); + return expectJson(res, 'Failed to analyze compose'); +} + +// ---- Node labels ---- + +export async function listAllNodeLabels(): Promise> { + const res = await apiFetch('/node-labels', { localOnly: true }); + return expectJson>(res, 'Failed to load node labels'); +} + +export async function listDistinctLabels(): Promise { + 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 { + 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 { + 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'; + } +}