Skip to content

Commit 5db9554

Browse files
committed
Add Fan-out page (#19)
New /fan-out route with kind selector, multi-host checkbox grid (seeded to all online agents), and a live results table that polls /api/fan-out/{id} every 2s. Sidebar gets a Fan-out shortcut. Destructive apt-upgrade fan-outs route through the confirm modal.
1 parent 059b8ed commit 5db9554

3 files changed

Lines changed: 360 additions & 0 deletions

File tree

src/app/fan-out/page.tsx

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useState } from 'react';
4+
import { useRouter } from 'next/navigation';
5+
import { useSession } from '@/components/providers/SessionProvider';
6+
import { useWebSocket } from '@/components/providers/WebSocketProvider';
7+
import { useUi } from '@/components/providers/UiProvider';
8+
import type { FanOutKind, FanOutRunDetail } from '@/lib/types';
9+
import {
10+
ArrowLeftIcon,
11+
RefreshCwIcon,
12+
Loader2Icon,
13+
CheckCircleIcon,
14+
AlertCircleIcon,
15+
CircleDashedIcon,
16+
WifiOffIcon,
17+
RocketIcon,
18+
} from 'lucide-react';
19+
20+
const KIND_LABELS: Record<FanOutKind, string> = {
21+
'apt-status': 'apt status (list upgradable)',
22+
'apt-upgrade': 'apt upgrade',
23+
'docker-list': 'docker list',
24+
};
25+
26+
function fmtTs(secs: number | null | undefined) {
27+
if (!secs) return '—';
28+
return new Date(secs * 1000).toLocaleString();
29+
}
30+
31+
export default function FanOutPage() {
32+
const router = useRouter();
33+
const ui = useUi();
34+
const { status } = useSession();
35+
const { agents } = useWebSocket();
36+
const [kind, setKind] = useState<FanOutKind>('docker-list');
37+
const [selected, setSelected] = useState<Record<string, boolean>>({});
38+
const [pkg, setPkg] = useState('');
39+
const [submitting, setSubmitting] = useState(false);
40+
const [run, setRun] = useState<FanOutRunDetail | null>(null);
41+
42+
useEffect(() => {
43+
if (status === 'guest') router.replace('/login');
44+
}, [status, router]);
45+
46+
// Default: select all online agents.
47+
useEffect(() => {
48+
setSelected((prev) => {
49+
if (Object.keys(prev).length > 0) return prev;
50+
const init: Record<string, boolean> = {};
51+
for (const a of agents) init[a] = true;
52+
return init;
53+
});
54+
}, [agents]);
55+
56+
const refresh = useCallback(async () => {
57+
if (!run) return;
58+
try {
59+
const res = await fetch(`/api/fan-out/${run.run.id}`, { credentials: 'include' });
60+
if (!res.ok) return;
61+
const data: FanOutRunDetail = await res.json();
62+
setRun(data);
63+
} catch {
64+
/* swallow */
65+
}
66+
}, [run]);
67+
68+
useEffect(() => {
69+
if (!run) return;
70+
const t = setInterval(refresh, 2_000);
71+
return () => clearInterval(t);
72+
}, [run, refresh]);
73+
74+
const toggleAll = (val: boolean) => {
75+
const next: Record<string, boolean> = {};
76+
for (const a of agents) next[a] = val;
77+
setSelected(next);
78+
};
79+
80+
const submit = async () => {
81+
const ids = agents.filter((a) => selected[a]);
82+
if (ids.length === 0) {
83+
ui.toast('error', 'Pick at least one host');
84+
return;
85+
}
86+
if (kind === 'apt-upgrade') {
87+
const ok = await ui.confirm({
88+
title: `Run apt upgrade on ${ids.length} host${ids.length === 1 ? '' : 's'}?`,
89+
description: pkg
90+
? `Package: ${pkg}`
91+
: 'This runs apt-get -y upgrade across every selected host.',
92+
destructive: true,
93+
confirmLabel: 'Run',
94+
});
95+
if (!ok) return;
96+
}
97+
setSubmitting(true);
98+
try {
99+
const res = await fetch('/api/fan-out', {
100+
method: 'POST',
101+
credentials: 'include',
102+
headers: { 'Content-Type': 'application/json' },
103+
body: JSON.stringify({
104+
kind,
105+
agent_ids: ids,
106+
package: kind === 'apt-upgrade' && pkg ? pkg : null,
107+
}),
108+
});
109+
if (!res.ok) {
110+
const txt = await res.text();
111+
throw new Error(txt || `HTTP ${res.status}`);
112+
}
113+
const data: FanOutRunDetail = await res.json();
114+
setRun(data);
115+
ui.toast('success', `Fan-out run #${data.run.id} dispatched`);
116+
} catch (e) {
117+
ui.toast('error', `Submit failed: ${(e as Error).message}`);
118+
} finally {
119+
setSubmitting(false);
120+
}
121+
};
122+
123+
if (status === 'loading' || status === 'guest') {
124+
return (
125+
<div className="flex h-screen items-center justify-center text-slate-500 bg-slate-950">
126+
<Loader2Icon className="w-6 h-6 animate-spin" />
127+
</div>
128+
);
129+
}
130+
131+
return (
132+
<div className="min-h-screen bg-slate-950 text-slate-100">
133+
<header className="border-b border-slate-800 bg-slate-900">
134+
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
135+
<div className="flex items-center gap-3">
136+
<button
137+
type="button"
138+
onClick={() => router.push('/')}
139+
className="text-slate-400 hover:text-slate-100"
140+
aria-label="Back"
141+
>
142+
<ArrowLeftIcon className="w-5 h-5" />
143+
</button>
144+
<RocketIcon className="w-5 h-5 text-slate-400" />
145+
<h1 className="text-lg font-semibold">Fan-out</h1>
146+
</div>
147+
<button
148+
type="button"
149+
onClick={refresh}
150+
className="text-xs flex items-center gap-1.5 px-2.5 py-1.5 border border-slate-700 rounded-md text-slate-300 hover:bg-slate-800"
151+
>
152+
<RefreshCwIcon className="w-3.5 h-3.5" />
153+
Refresh
154+
</button>
155+
</div>
156+
</header>
157+
158+
<main className="max-w-5xl mx-auto px-6 py-6 space-y-6">
159+
<section className="space-y-3">
160+
<h2 className="text-sm uppercase tracking-wide text-slate-500">
161+
Dispatch a command
162+
</h2>
163+
<div className="rounded-md border border-slate-800 bg-slate-900/40 p-4 space-y-3">
164+
<div className="grid grid-cols-2 gap-3">
165+
<label className="text-xs text-slate-400 flex flex-col gap-1">
166+
Kind
167+
<select
168+
value={kind}
169+
onChange={(e) => setKind(e.target.value as FanOutKind)}
170+
className="bg-slate-950 border border-slate-700 rounded-md px-2 py-1.5 text-sm text-slate-100"
171+
>
172+
{(Object.keys(KIND_LABELS) as FanOutKind[]).map((k) => (
173+
<option key={k} value={k}>
174+
{KIND_LABELS[k]}
175+
</option>
176+
))}
177+
</select>
178+
</label>
179+
{kind === 'apt-upgrade' && (
180+
<label className="text-xs text-slate-400 flex flex-col gap-1">
181+
Package (optional)
182+
<input
183+
type="text"
184+
value={pkg}
185+
onChange={(e) => setPkg(e.target.value)}
186+
placeholder="leave blank for full upgrade"
187+
className="bg-slate-950 border border-slate-700 rounded-md px-2 py-1.5 text-sm font-mono text-slate-100"
188+
/>
189+
</label>
190+
)}
191+
</div>
192+
193+
<div>
194+
<div className="flex items-center justify-between mb-1">
195+
<span className="text-xs text-slate-400">Targets ({agents.length} online)</span>
196+
<div className="flex gap-2 text-[11px]">
197+
<button
198+
type="button"
199+
onClick={() => toggleAll(true)}
200+
className="text-slate-400 hover:text-slate-100"
201+
>
202+
select all
203+
</button>
204+
<button
205+
type="button"
206+
onClick={() => toggleAll(false)}
207+
className="text-slate-400 hover:text-slate-100"
208+
>
209+
clear
210+
</button>
211+
</div>
212+
</div>
213+
<div className="grid grid-cols-2 md:grid-cols-3 gap-1">
214+
{agents.map((a) => (
215+
<label
216+
key={a}
217+
className="flex items-center gap-2 px-2 py-1 rounded text-sm bg-slate-950 border border-slate-800 hover:border-slate-700"
218+
>
219+
<input
220+
type="checkbox"
221+
checked={!!selected[a]}
222+
onChange={(e) =>
223+
setSelected((prev) => ({ ...prev, [a]: e.target.checked }))
224+
}
225+
className="accent-blue-600"
226+
/>
227+
<span className="truncate">{a.replace(/-id$/, '')}</span>
228+
</label>
229+
))}
230+
</div>
231+
</div>
232+
233+
<div className="flex justify-end">
234+
<button
235+
type="button"
236+
onClick={submit}
237+
disabled={submitting}
238+
className="text-sm flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 text-white rounded-md"
239+
>
240+
{submitting ? (
241+
<Loader2Icon className="w-4 h-4 animate-spin" />
242+
) : (
243+
<RocketIcon className="w-4 h-4" />
244+
)}
245+
Dispatch
246+
</button>
247+
</div>
248+
</div>
249+
</section>
250+
251+
{run && (
252+
<section className="space-y-3">
253+
<h2 className="text-sm uppercase tracking-wide text-slate-500 flex items-center gap-2">
254+
Run #{run.run.id}{run.run.kind}
255+
<span className="text-slate-600 normal-case tracking-normal text-xs">
256+
started {fmtTs(run.run.started_at)}
257+
</span>
258+
</h2>
259+
<div className="rounded-md border border-slate-800 overflow-hidden">
260+
<table className="w-full text-sm">
261+
<thead className="bg-slate-900/60 text-[11px] uppercase tracking-wide text-slate-500">
262+
<tr>
263+
<th className="text-left px-3 py-2 font-medium">Host</th>
264+
<th className="text-left px-3 py-2 font-medium">Status</th>
265+
<th className="text-left px-3 py-2 font-medium">Detail</th>
266+
<th className="text-left px-3 py-2 font-medium">Finished</th>
267+
</tr>
268+
</thead>
269+
<tbody className="divide-y divide-slate-800">
270+
{run.results.map((r) => (
271+
<tr key={r.agent_id} className="bg-slate-900/30">
272+
<td className="px-3 py-2 font-mono text-slate-200">
273+
{r.agent_id.replace(/-id$/, '')}
274+
</td>
275+
<td className="px-3 py-2">
276+
<StatusBadge status={r.status} />
277+
</td>
278+
<td className="px-3 py-2 text-slate-400 truncate max-w-md" title={r.detail ?? ''}>
279+
{r.detail ?? '—'}
280+
</td>
281+
<td className="px-3 py-2 text-slate-500 text-xs">
282+
{fmtTs(r.finished_at)}
283+
</td>
284+
</tr>
285+
))}
286+
</tbody>
287+
</table>
288+
</div>
289+
</section>
290+
)}
291+
</main>
292+
</div>
293+
);
294+
}
295+
296+
function StatusBadge({ status }: { status: string }) {
297+
switch (status) {
298+
case 'success':
299+
return (
300+
<span className="inline-flex items-center gap-1 text-xs text-emerald-300">
301+
<CheckCircleIcon className="w-3.5 h-3.5" /> success
302+
</span>
303+
);
304+
case 'failed':
305+
return (
306+
<span className="inline-flex items-center gap-1 text-xs text-red-300">
307+
<AlertCircleIcon className="w-3.5 h-3.5" /> failed
308+
</span>
309+
);
310+
case 'pending':
311+
return (
312+
<span className="inline-flex items-center gap-1 text-xs text-amber-300">
313+
<Loader2Icon className="w-3.5 h-3.5 animate-spin" /> pending
314+
</span>
315+
);
316+
case 'offline':
317+
return (
318+
<span className="inline-flex items-center gap-1 text-xs text-slate-400">
319+
<WifiOffIcon className="w-3.5 h-3.5" /> offline
320+
</span>
321+
);
322+
default:
323+
return (
324+
<span className="inline-flex items-center gap-1 text-xs text-slate-400">
325+
<CircleDashedIcon className="w-3.5 h-3.5" /> {status}
326+
</span>
327+
);
328+
}
329+
}

src/app/page.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,14 @@ function HomeBody() {
183183
<ActivityIcon className="w-3.5 h-3.5" />
184184
Activity
185185
</button>
186+
<button
187+
type="button"
188+
onClick={() => router.push('/fan-out')}
189+
className="mt-2 w-full inline-flex items-center justify-center gap-1.5 text-xs font-medium py-1.5 px-3 rounded-md border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
190+
>
191+
<RocketIcon className="w-3.5 h-3.5" />
192+
Fan-out
193+
</button>
186194
</div>
187195

188196
<div className="px-4 py-3 border-b border-slate-800 text-xs uppercase tracking-wide text-slate-500 flex items-center justify-between">

src/lib/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,29 @@ export type SwarmStackDeployResponse = {
211211
error: string | null;
212212
};
213213

214+
export type FanOutKind = 'apt-status' | 'apt-upgrade' | 'docker-list';
215+
216+
export type FanOutRun = {
217+
id: number;
218+
kind: string;
219+
payload: string | null;
220+
started_at: number;
221+
actor: string | null;
222+
};
223+
224+
export type FanOutResult = {
225+
run_id: number;
226+
agent_id: string;
227+
status: 'pending' | 'success' | 'failed' | 'offline' | string;
228+
detail: string | null;
229+
finished_at: number | null;
230+
};
231+
232+
export type FanOutRunDetail = {
233+
run: FanOutRun;
234+
results: FanOutResult[];
235+
};
236+
214237
export type HealthProbeKind = 'http' | 'tcp';
215238
export type HealthProbeState = 'green' | 'red';
216239

0 commit comments

Comments
 (0)