diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index 0b97317f..21d9dd39 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -1,12 +1,13 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Cpu, Database, Globe, Monitor, Network, PlusCircle, PowerOff, - RefreshCw, Server, Shield, Terminal, + RefreshCw, Server, Shield, Terminal, Plus, X, } from '../icons'; import api from '../utils/api'; import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data'; import { useToast } from './Toasts/useToast'; import Modal from './Modal/Modal'; +import { useServiceRegistry } from '../hooks/useServiceRegistry'; import './DeckyFleet.css'; // ─── Types ──────────────────────────────────────────────────────────────── @@ -130,10 +131,16 @@ interface DeckyCardProps { onIntervalChange: (name: string, current: number | null) => void; onInspect: (d: Decky) => void; innerRef?: React.Ref; + /** Per-decky-eligible service slugs from useServiceRegistry. */ + availableServices: string[]; + /** Called after a successful live add/remove so the parent can + * optimistically apply the response's services list. */ + onServicesChanged: (deckyName: string, services: string[]) => void; } const DeckyCard: React.FC = ({ - decky, mutating, isAdmin, armed, tdBusy, onForce, onTeardown, onIntervalChange, onInspect, innerRef, + decky, mutating, isAdmin, armed, tdBusy, onForce, onTeardown, onIntervalChange, onInspect, + innerRef, availableServices, onServicesChanged, }) => { const dot = _dotFor(decky); const hits = _hitsFor(decky); @@ -141,6 +148,54 @@ const DeckyCard: React.FC = ({ const dotClass = mutating ? 'mutating' : dot; const tdKey = decky.swarm ? `td:${decky.swarm.host_uuid}:${decky.name}` : ''; + // Live service mutation is local-only (admin, non-swarm). Swarm + // deckies live on a remote agent — the W3 path runs docker compose + // locally and won't reach the agent's containers (same gap as the + // canary planter has for agent-pinned topologies; out of scope here). + const liveServicesEnabled = isAdmin && !decky.swarm; + const [addOpen, setAddOpen] = useState(false); + const [addSlug, setAddSlug] = useState(''); + const [busy, setBusy] = useState(null); + const [opError, setOpError] = useState(null); + + const removeService = async (slug: string) => { + setOpError(null); + setBusy(slug); + try { + const { data } = await api.delete<{ services: string[] }>( + `/deckies/${encodeURIComponent(decky.name)}/services/${encodeURIComponent(slug)}`, + ); + onServicesChanged(decky.name, data.services); + } catch (err) { + const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail + ?? 'Remove failed.'; + setOpError(msg); + } finally { + setBusy(null); + } + }; + + const addService = async () => { + if (!addSlug) return; + setOpError(null); + setBusy(addSlug); + try { + const { data } = await api.post<{ services: string[] }>( + `/deckies/${encodeURIComponent(decky.name)}/services`, + { name: addSlug }, + ); + onServicesChanged(decky.name, data.services); + setAddOpen(false); + setAddSlug(''); + } catch (err) { + const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail + ?? 'Add failed.'; + setOpError(msg); + } finally { + setBusy(null); + } + }; + return (
= ({
EXPOSED
- {decky.services.map((s) => {s})} + {decky.services.map((s) => ( + + {s} + {liveServicesEnabled && ( + + )} + + ))} + {liveServicesEnabled && !addOpen && ( + + )}
+ {liveServicesEnabled && addOpen && ( +
e.stopPropagation()} + style={{ display: 'flex', gap: 6, marginTop: 6, alignItems: 'center' }} + > + + + +
+ )} + {opError && ( +
{opError}
+ )}
@@ -744,6 +869,7 @@ interface FleetProps { const DeckyFleet: React.FC = ({ searchQuery = '' }) => { const { push } = useToast(); + const serviceRegistry = useServiceRegistry(); const [deckies, setDeckies] = useState([]); const [loading, setLoading] = useState(true); const [mutating, setMutating] = useState(null); @@ -1083,6 +1209,12 @@ const DeckyFleet: React.FC = ({ searchQuery = '' }) => { if (el) cardRefs.current.set(d.name, el); else cardRefs.current.delete(d.name); }} + availableServices={serviceRegistry.perDecky} + onServicesChanged={(name, services) => { + setDeckies((prev) => prev.map((row) => + row.name === name ? { ...row, services } : row, + )); + }} /> )) )}