diff --git a/decnet_web/src/components/DeckyFleet.css b/decnet_web/src/components/DeckyFleet.css new file mode 100644 index 00000000..26d779e9 --- /dev/null +++ b/decnet_web/src/components/DeckyFleet.css @@ -0,0 +1,338 @@ +/* DeckyFleet — design-handoff port, scoped to the fleet view. */ + +.fleet-root { display: flex; flex-direction: column; gap: 24px; } + +.fleet-root .page-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + border-bottom: 1px solid var(--border); + padding-bottom: 16px; + gap: 24px; +} +.fleet-root .page-title-group { display: flex; flex-direction: column; gap: 6px; } +.fleet-root .page-header h1 { font-size: 1.3rem; letter-spacing: 4px; font-weight: 700; margin: 0; color: var(--matrix); } +.fleet-root .page-sub { font-size: 0.7rem; opacity: 0.5; letter-spacing: 1px; } +.fleet-root .page-header .actions { display: flex; gap: 10px; align-items: center; } + +.fleet-root .dim { opacity: 0.5; } +.fleet-root .violet-accent { color: var(--violet); } +.fleet-root .matrix-text { color: var(--matrix); } +.fleet-root .alert-text { color: var(--alert); } + +/* Filter tabs */ +.fleet-filter-group { display: flex; border: 1px solid var(--border); background: var(--panel); } +.fleet-filter-btn { + border: 0; + border-right: 1px solid var(--border); + padding: 8px 14px; + font-size: 0.68rem; + letter-spacing: 1px; + font-family: inherit; + cursor: pointer; + background: transparent; + color: rgba(0, 255, 65, 0.6); + text-transform: uppercase; +} +.fleet-filter-btn:last-child { border-right: none; } +.fleet-filter-btn.active { background: var(--matrix-tint-10); color: var(--matrix); } + +/* Buttons */ +.fleet-root .btn { + cursor: pointer; + background: transparent; + border: 1px solid var(--matrix); + color: var(--matrix); + padding: 7px 14px; + transition: all 0.3s; + font-family: inherit; + font-size: 0.78rem; + letter-spacing: 1.5px; + display: inline-flex; + align-items: center; + gap: 8px; +} +.fleet-root .btn:hover { background: var(--matrix); color: #000; box-shadow: var(--matrix-glow); } +.fleet-root .btn.violet { border-color: var(--violet); color: var(--violet); } +.fleet-root .btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); } +.fleet-root .btn.alert { border-color: var(--alert); color: var(--alert); } +.fleet-root .btn.alert:hover { background: var(--alert); color: #000; box-shadow: 0 0 10px rgba(255, 65, 65, 0.5); } +.fleet-root .btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; } +.fleet-root .btn.ghost:hover { color: var(--matrix); opacity: 1; border-color: var(--matrix); box-shadow: var(--matrix-glow); background: transparent; } +.fleet-root .btn.small { padding: 4px 10px; font-size: 0.68rem; } +.fleet-root .btn:disabled { opacity: 0.3; cursor: not-allowed; } + +/* Grid + decky card */ +.grid-fleet { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 16px; +} +.decky-card { + background: var(--panel); + border: 1px solid var(--border); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + transition: all 0.3s; + position: relative; +} +.decky-card:hover { border-color: var(--violet); box-shadow: var(--violet-glow); } +.decky-card.hot { border-color: var(--alert); } +.decky-card.hot::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + border: 1px solid var(--alert); + opacity: 0.4; + animation: dfleet-pulse 1s infinite alternate; +} +.decky-head { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); +} +.decky-name { + font-size: 1rem; + font-weight: 700; + color: var(--matrix); + letter-spacing: 1px; + display: flex; + align-items: center; +} +.decky-ip { + font-size: 0.7rem; + opacity: 0.6; + background: rgba(0, 255, 65, 0.08); + padding: 2px 7px; +} +.decky-meta { display: flex; flex-direction: column; gap: 6px; font-size: 0.75rem; } +.decky-meta .row { display: flex; gap: 8px; align-items: center; } +.decky-meta .label { opacity: 0.5; min-width: 82px; letter-spacing: 1px; font-size: 0.62rem; } +.decky-services { display: flex; flex-wrap: wrap; gap: 5px; } +.decky-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 10px; + border-top: 1px solid var(--border); + font-size: 0.68rem; + gap: 8px; + flex-wrap: wrap; +} +.decky-hits { font-variant-numeric: tabular-nums; } + +/* Status dots */ +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + flex-shrink: 0; +} +.status-dot.active { background: var(--matrix); box-shadow: 0 0 8px var(--matrix); } +.status-dot.idle { background: #30363d; } +.status-dot.hot { background: var(--alert); box-shadow: 0 0 8px var(--alert); animation: dfleet-pulse 1s infinite alternate; } +.status-dot.mutating { background: var(--violet); animation: dfleet-blink 1s infinite; } + +/* Service tag (page-scoped so we don't collide with MazeNET) */ +.fleet-root .service-tag { + padding: 3px 9px; + font-size: 0.68rem; + border: 1px solid var(--violet); + color: var(--violet); + border-radius: 2px; + letter-spacing: 1px; + white-space: nowrap; +} + +/* Swarm meta row inside the card */ +.decky-swarm-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + font-size: 0.65rem; + letter-spacing: 1px; +} +.decky-swarm-chip { + display: flex; + gap: 6px; + align-items: center; + border: 1px solid var(--border); + padding: 2px 8px; +} +.decky-swarm-state { + padding: 2px 8px; + border: 1px solid; + letter-spacing: 1px; +} + +/* Modal / wizard */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(1px); +} +.modal { + width: 640px; + max-width: 96vw; + max-height: 90vh; + background: var(--panel); + border: 1px solid var(--matrix); + box-shadow: 0 0 30px rgba(0, 255, 65, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; +} +.modal.violet { border-color: var(--violet); box-shadow: 0 0 30px rgba(238, 130, 238, 0.25); } +.modal.wide { width: 880px; } +.modal-head { + padding: 16px 22px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} +.modal-head h3 { font-size: 0.9rem; letter-spacing: 3px; margin: 0; display: flex; align-items: center; gap: 8px; } +.modal-body { padding: 20px 22px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; } +.modal-foot { + padding: 14px 22px; + border-top: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} +.close-btn { + background: transparent; + border: none; + color: var(--matrix); + cursor: pointer; + opacity: 0.6; + padding: 0; +} +.close-btn:hover { color: var(--violet); opacity: 1; } + +/* Wizard steps */ +.wizard-steps { display: flex; gap: 0; border-bottom: 1px solid var(--border); } +.wizard-step { + flex: 1; + padding: 12px 14px; + font-size: 0.65rem; + letter-spacing: 1.5px; + opacity: 0.4; + border-bottom: 2px solid transparent; + text-align: center; +} +.wizard-step.active { opacity: 1; border-bottom-color: var(--violet); color: var(--violet); } +.wizard-step.done { opacity: 0.8; color: var(--matrix); } + +/* Sub-tabs inside wizard step 1 */ +.wizard-subtabs { display: flex; border: 1px solid var(--border); } +.wizard-subtab { + flex: 1; + padding: 8px 12px; + background: transparent; + border: 0; + border-right: 1px solid var(--border); + color: rgba(0, 255, 65, 0.6); + font-family: inherit; + font-size: 0.7rem; + letter-spacing: 1.5px; + cursor: pointer; + text-transform: uppercase; +} +.wizard-subtab:last-child { border-right: none; } +.wizard-subtab.active { background: var(--violet-tint-10); color: var(--violet); } + +/* Pickable cards (archetype & service) */ +.pick-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } +.pick-grid.two { grid-template-columns: repeat(2, 1fr); } +.pick-card { + padding: 14px; + border: 1px solid var(--border); + background: var(--panel); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 8px; + transition: all 0.2s; + color: inherit; + font-family: inherit; + text-align: left; +} +.pick-card:hover { border-color: var(--violet); } +.pick-card.active { border-color: var(--violet); background: var(--violet-tint-10); } +.pick-card .pc-title { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; font-weight: 700; letter-spacing: 1px; } +.pick-card .pc-slug { font-size: 0.65rem; letter-spacing: 1px; opacity: 0.5; } +.pick-card .pc-services { display: flex; flex-wrap: wrap; gap: 4px; } +.pick-card .pc-services .service-tag { font-size: 0.6rem; padding: 2px 6px; } + +/* Type + tweak + input + code */ +.type-label { font-size: 0.7rem; letter-spacing: 1px; text-transform: uppercase; opacity: 0.6; } +.tweak-group { display: flex; flex-direction: column; gap: 6px; } +.tweak-group label { font-size: 0.62rem; opacity: 0.55; letter-spacing: 1.5px; } +.input { + width: 100%; + background: rgba(0, 0, 0, 0.5); + border: 1px solid var(--border); + color: var(--matrix); + padding: 8px 12px; + font-family: inherit; + font-size: 0.8rem; + letter-spacing: 1px; + outline: none; +} +.input:focus { border-color: var(--matrix); box-shadow: var(--matrix-glow); } +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } + +.code-block { + background: #000; + border: 1px solid var(--border); + padding: 14px 16px; + font-size: 0.75rem; + color: var(--matrix); + overflow-x: auto; + white-space: pre; + line-height: 1.6; + font-family: var(--font-mono); + min-height: 80px; +} +.code-block .comment { color: rgba(0, 255, 65, 0.4); } +.code-block .str { color: var(--violet); } +.code-block .key { color: rgba(0, 255, 65, 0.7); } +.replay-cursor { + display: inline-block; + width: 7px; + height: 14px; + background: var(--matrix); + animation: dfleet-blink 1s infinite; + vertical-align: middle; + margin-left: 2px; +} + +/* Empty state */ +.fleet-empty { + grid-column: 1 / -1; + padding: 48px 24px; + text-align: center; + opacity: 0.5; + border: 1px dashed var(--border); + background: var(--panel); +} + +/* Animations */ +@keyframes dfleet-pulse { from { opacity: 0.5; } to { opacity: 1; } } +@keyframes dfleet-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } +.fx-spin { animation: dfleet-spin 1.5s linear infinite; } +@keyframes dfleet-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index 41521a0a..2a505683 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -1,7 +1,13 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + Cpu, Database, Globe, Monitor, Network, PlusCircle, PowerOff, + RefreshCw, Server, Shield, Terminal, X, +} from 'lucide-react'; import api from '../utils/api'; -import './Dashboard.css'; // Re-use common dashboard styles -import { Server, Cpu, Globe, Database, Clock, RefreshCw, Upload, Network, PowerOff } from 'lucide-react'; +import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data'; +import './DeckyFleet.css'; + +// ─── Types ──────────────────────────────────────────────────────────────── interface SwarmMeta { host_uuid: string; @@ -20,15 +26,12 @@ interface Decky { distro: string; hostname: string; archetype: string | null; - service_config: Record>; + service_config: Record>; mutate_interval: number | null; last_mutated: number; swarm?: SwarmMeta; } -// Raw shape returned by /swarm/deckies (DeckyShardView on the backend). -// Pre-heartbeat rows have nullable metadata fields; we coerce to the -// shared Decky interface so the card grid renders uniformly either way. interface SwarmDeckyRaw { decky_name: string; decky_ip: string | null; @@ -43,42 +46,611 @@ interface SwarmDeckyRaw { hostname: string | null; distro: string | null; archetype: string | null; - service_config: Record>; + service_config: Record>; mutate_interval: number | null; last_mutated: number; } -const _stateColor = (state: string): string => { - switch (state) { - case 'running': return 'var(--accent-color)'; - case 'degraded': return '#f39c12'; - case 'tearing_down': return '#f39c12'; - case 'pending': return 'var(--dim-color)'; +interface Archetype { + slug: string; + name: string; + services: string[]; + icon: string; +} + +type FilterKey = 'all' | 'active' | 'hot' | 'idle'; +type DeckyStatus = 'active' | 'hot' | 'idle'; + +// ─── Helpers ────────────────────────────────────────────────────────────── + +const _archetypeIcon = (slug: string): string => { + const s = slug.toLowerCase(); + if (s.includes('windows') || s.includes('workstation')) return 'monitor'; + if (s.includes('domain')) return 'shield'; + if (s.includes('database') || s.includes('sql')) return 'database'; + if (s.includes('iot') || s.includes('ot')) return 'cpu'; + if (s.includes('web')) return 'globe'; + return 'server'; +}; + +// Compact icon resolver for lucide names we use in the wizard. +const PickIcon: React.FC<{ name: string; size?: number; className?: string }> = ( + { name, size = 16, className }, +) => { + const map: Record> = { + server: Server, monitor: Monitor, shield: Shield, database: Database, + cpu: Cpu, globe: Globe, terminal: Terminal, + }; + const Cmp = map[name] ?? Server; + return ; +}; + +// Map swarm state -> visual dot status. "active" with no recent hit is idle; +// we don't have per-decky hit counts here, so treat running = active. +const _dotFor = (d: Decky): DeckyStatus => { + if (!d.swarm) return 'active'; + switch (d.swarm.state) { + case 'running': return 'active'; case 'failed': - case 'teardown_failed': return '#e74c3c'; - default: return 'var(--dim-color)'; + case 'teardown_failed': return 'hot'; + case 'pending': + case 'tearing_down': + case 'degraded': return 'idle'; + default: return 'idle'; } }; +// Hits placeholder — backend doesn't expose per-decky 24h hit count yet. +const _hitsFor = (_d: Decky): number => 0; + +const _stateColor = (state: string): string => { + switch (state) { + case 'running': return 'var(--matrix)'; + case 'degraded': + case 'tearing_down': + case 'pending': return 'var(--violet)'; + case 'failed': + case 'teardown_failed': return 'var(--alert)'; + default: return 'var(--border)'; + } +}; + +// ─── Decky card ─────────────────────────────────────────────────────────── + +interface DeckyCardProps { + decky: Decky; + mutating: boolean; + isAdmin: boolean; + armed: string | null; + tdBusy: boolean; + onForce: (name: string) => void; + onTeardown: (d: Decky) => void; + onIntervalChange: (name: string, current: number | null) => void; +} + +const DeckyCard: React.FC = ({ + decky, mutating, isAdmin, armed, tdBusy, onForce, onTeardown, onIntervalChange, +}) => { + const dot = _dotFor(decky); + const hits = _hitsFor(decky); + const hot = dot === 'hot'; + const dotClass = mutating ? 'mutating' : dot; + const tdKey = decky.swarm ? `td:${decky.swarm.host_uuid}:${decky.name}` : ''; + + return ( +
+
+
+ + {decky.name} +
+ {decky.ip} +
+ + {decky.swarm && ( +
+ + + {decky.swarm.host_name} + @ {decky.swarm.host_address || '—'} + + + {decky.swarm.state.toUpperCase()} + + {decky.swarm.last_error && ( + + ⚠ {decky.swarm.last_error.slice(0, 48)} + {decky.swarm.last_error.length > 48 ? '…' : ''} + + )} +
+ )} + +
+
HOST{decky.hostname}
+
DISTRO{decky.distro}
+
+ ARCHETYPE + {decky.archetype || '—'} +
+
+ MUTATE + {!decky.swarm && isAdmin ? ( + onIntervalChange(decky.name, decky.mutate_interval)} + > + {decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'} + + ) : ( + + {decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'} + + )} +
+
+ +
+
EXPOSED
+
+ {decky.services.map((s) => {s})} +
+
+ +
+ + HITS 24h: + + {hits} + + +
+ {!decky.swarm && isAdmin && ( + + )} + {decky.swarm && isAdmin && ( + + )} +
+
+
+ ); +}; + +// ─── Deploy wizard ──────────────────────────────────────────────────────── + +interface DeployWizardProps { + open: boolean; + onClose: () => void; + onComplete: () => void; + archetypes: Archetype[]; + fleetSize: number; +} + +type PickMode = 'archetype' | 'services'; + +const PLACEHOLDER_LINES = ( + archetypeName: string, services: string[], count: number, fleetSize: number, +): string[] => [ + '[INIT] allocating MAC addresses...', + '[NET] binding macvlan interfaces...', + `[FP] spoofing OS fingerprint → ${archetypeName}`, + `[SVC] starting services: ${services.join(', ') || '—'}`, + '[TLS] provisioning self-signed certs...', + '[SENSE] attaching syslog sinks to logging stack...', + `[OK] ${count} deckies online — fleet size now ${fleetSize + count}`, +]; + +const _buildIni = ( + prefix: string, count: number, fleetSize: number, + mode: PickMode, archetype: Archetype | null, services: string[], + mutate: boolean, mutateEvery: number, +): string => { + const lines: string[] = []; + for (let i = 0; i < count; i++) { + const name = `${prefix}-${String(fleetSize + i + 1).padStart(2, '0')}`; + lines.push(`[${name}]`); + if (mode === 'archetype' && archetype) { + lines.push(`archetype=${archetype.slug}`); + } else if (mode === 'services' && services.length) { + lines.push(`services=${services.join(',')}`); + } + if (mutate) lines.push(`mutate_interval=${mutateEvery}`); + lines.push(''); + } + return lines.join('\n'); +}; + +const DeployWizard: React.FC = ({ + open, onClose, onComplete, archetypes, fleetSize, +}) => { + const [step, setStep] = useState(0); + const [pickMode, setPickMode] = useState('archetype'); + const [archetype, setArchetype] = useState(null); + const [selectedServices, setSelectedServices] = useState([]); + const [prefix, setPrefix] = useState('decky'); + const [count, setCount] = useState(3); + const [mutate, setMutate] = useState(true); + const [mutateEvery, setMutateEvery] = useState(30); + const [deploying, setDeploying] = useState(false); + const [log, setLog] = useState([]); + const [deployErr, setDeployErr] = useState(null); + + useEffect(() => { + if (!open) return; + setStep(0); + setPickMode('archetype'); + setArchetype(null); + setSelectedServices([]); + setPrefix('decky'); + setCount(3); + setMutate(true); + setMutateEvery(30); + setDeploying(false); + setLog([]); + setDeployErr(null); + }, [open]); + + const effectiveArchetypeName = archetype?.name + ?? (pickMode === 'services' && selectedServices.length ? 'custom services' : 'linux-server'); + const effectiveServices = pickMode === 'archetype' + ? (archetype?.services ?? []) + : selectedServices; + + // Preview lines, count-aware (shows up to 6, with "…and N more" footer). + const previewLines = useMemo(() => { + const out: string[] = []; + const cap = Math.min(count, 6); + for (let i = 0; i < cap; i++) { + const name = `${prefix}-${String(fleetSize + i + 1).padStart(2, '0')}`; + out.push(`${name} → ${effectiveArchetypeName} [${effectiveServices.join(', ') || '—'}]`); + } + if (count > 6) out.push(`...and ${count - 6} more`); + return out; + }, [count, prefix, fleetSize, effectiveArchetypeName, effectiveServices]); + + // Fake log stream during "deploying". + useEffect(() => { + if (step !== 3 || !deploying) return; + const msgs = PLACEHOLDER_LINES(effectiveArchetypeName, effectiveServices, count, fleetSize); + let i = 0; + const t = window.setInterval(() => { + setLog((prev) => [...prev, msgs[i]]); + i++; + if (i >= msgs.length) { + window.clearInterval(t); + window.setTimeout(() => onComplete(), 500); + } + }, 420); + return () => window.clearInterval(t); + }, [step, deploying, effectiveArchetypeName, effectiveServices, count, fleetSize, onComplete]); + + if (!open) return null; + + const canNext = step === 0 + ? (pickMode === 'archetype' ? !!archetype : selectedServices.length > 0) + : true; + + const startDeploy = async () => { + setDeployErr(null); + setLog([]); + setDeploying(true); + const ini = _buildIni( + prefix, count, fleetSize, pickMode, archetype, selectedServices, + mutate, mutateEvery, + ); + try { + await api.post('/deckies/deploy', { ini_content: ini }, { timeout: 180000 }); + } catch (e: unknown) { + const err = e as { response?: { data?: { detail?: string } }; message?: string }; + setDeployErr(err?.response?.data?.detail || err?.message || 'Deploy failed'); + setDeploying(false); + } + }; + + const toggleService = (slug: string) => { + setSelectedServices((prev) => + prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]); + }; + + return ( +
+
e.stopPropagation()}> +
+

+ + DEPLOY NEW DECKIES +

+ +
+ +
+ {['ARCHETYPE', 'CONFIGURATION', 'MUTATION', 'DEPLOY'].map((l, i) => ( +
+ {i + 1}. {l} +
+ ))} +
+ +
+ {step === 0 && ( + <> +
+ + +
+ {pickMode === 'archetype' ? ( + <> +
Pick the archetype the deckies should masquerade as.
+
+ {archetypes.map((a) => ( + + ))} +
+ + ) : ( + <> +
+ Pick individual services. Every selected decky will expose the same set. +
+
+ {DEFAULT_SERVICES.map((s) => { + const on = selectedServices.includes(s.slug); + return ( + + ); + })} +
+ + )} + + )} + + {step === 1 && ( + <> +
How many, and what to call them.
+
+
+ + setPrefix(e.target.value.replace(/\s+/g, '-'))} + /> +
+
+ + setCount(parseInt(e.target.value, 10))} + /> +
+
+ +
+ +