mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -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",
|
||||
|
||||
@@ -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**.
|
||||
|
||||
<Note>
|
||||
Blueprints are a Skipper feature. Configuring blueprints requires an admin role; viewers see the catalog read-only.
|
||||
</Note>
|
||||
|
||||
## 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 (`<COMPOSE_DIR>/<blueprint-name>/`). 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 <volume>:/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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
@@ -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">
|
||||
{isPaid ? (
|
||||
<DeploymentsTab />
|
||||
) : (
|
||||
<PaidGate featureName="Blueprints (fleet-wide compose templates)">
|
||||
<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']}
|
||||
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