refactor(decnet_web/DeckyFleet): wire hook + extract filter UI
Final integration step. The page shell is now a thin composition
of the hook + the previously-extracted children:
- DeckyFleet.tsx: 1,674 -> 274 LOC. Page owns only the
pure-UI state (filter, search, armed-confirm, modal visibility,
selected-card-for-inspect) and the toast-wrapping handlers that
translate hook results into toast tone. Polling, REST plumbing,
role lookup, and archetype catalog all moved to useDeckyFleet
in the prior commit.
- New DeckyFilters.tsx (header pill row + DEPLOY shortcut) +
DeckyGridEmpty.tsx (fleet-empty vs. filter-empty copy).
- DeckyFilters.test.tsx + DeckyGridEmpty.test.tsx cover count
rendering, filter-click callbacks, and admin-gated DEPLOY
visibility.
Two-step teardown arming logic stays in the page (it's pure UI).
Toast tone branching on { ok, reason } from useDeckyFleet
results moves the policy decision out of the data layer.
This commit is contained in:
@@ -1,26 +1,17 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { PlusCircle, Server } from '../icons';
|
import { Server } from '../icons';
|
||||||
import api, { type ApiError } from '../utils/api';
|
|
||||||
import { ARCHETYPES as FALLBACK_ARCHETYPES } from './MazeNET/data';
|
|
||||||
import { useToast } from './Toasts/useToast';
|
import { useToast } from './Toasts/useToast';
|
||||||
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
||||||
import './DeckyFleet.css';
|
import './DeckyFleet.css';
|
||||||
import type {
|
import type { Decky, FilterKey } from './DeckyFleet/types';
|
||||||
Decky,
|
import { dotFor } from './DeckyFleet/helpers';
|
||||||
SwarmDeckyRaw,
|
import { useDeckyFleet } from './DeckyFleet/useDeckyFleet';
|
||||||
Archetype,
|
|
||||||
FilterKey,
|
|
||||||
} from './DeckyFleet/types';
|
|
||||||
import {
|
|
||||||
archetypeIcon as _archetypeIcon,
|
|
||||||
dotFor as _dotFor,
|
|
||||||
} from './DeckyFleet/helpers';
|
|
||||||
import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel';
|
import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel';
|
||||||
import { DeckyCard } from './DeckyFleet/DeckyCard';
|
import { DeckyCard } from './DeckyFleet/DeckyCard';
|
||||||
import { DeployWizard } from './DeckyFleet/DeployWizard';
|
import { DeployWizard } from './DeckyFleet/DeployWizard';
|
||||||
import { IntervalEditor } from './DeckyFleet/IntervalEditor';
|
import { IntervalEditor } from './DeckyFleet/IntervalEditor';
|
||||||
|
import { DeckyFilters } from './DeckyFleet/DeckyFilters';
|
||||||
// ─── Fleet page ──────────────────────────────────────────────────────────
|
import { DeckyGridEmpty } from './DeckyFleet/DeckyGridEmpty';
|
||||||
|
|
||||||
interface FleetProps {
|
interface FleetProps {
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
@@ -29,26 +20,27 @@ interface FleetProps {
|
|||||||
const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
const serviceRegistry = useServiceRegistry();
|
const serviceRegistry = useServiceRegistry();
|
||||||
const [deckies, setDeckies] = useState<Decky[]>([]);
|
const fleet = useDeckyFleet();
|
||||||
const [loading, setLoading] = useState(true);
|
const {
|
||||||
const [mutating, setMutating] = useState<string | null>(null);
|
deckies, loading, isAdmin, deployMode, archetypes, isSwarm,
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
mutating, tearingDown,
|
||||||
const [deployMode, setDeployMode] = useState<{ mode: string; swarm_host_count: number } | null>(null);
|
mutate, setMutateInterval, teardown, applyServicesChange, refresh,
|
||||||
|
} = fleet;
|
||||||
|
|
||||||
|
// Pure UI state (no data lifecycle).
|
||||||
const [filter, setFilter] = useState<FilterKey>('all');
|
const [filter, setFilter] = useState<FilterKey>('all');
|
||||||
const [showDeploy, setShowDeploy] = useState(false);
|
const [showDeploy, setShowDeploy] = useState(false);
|
||||||
const [armed, setArmed] = useState<string | null>(null);
|
const [armed, setArmed] = useState<string | null>(null);
|
||||||
const [tearingDown, setTearingDown] = useState<Set<string>>(new Set());
|
|
||||||
const [archetypes, setArchetypes] = useState<Archetype[]>(FALLBACK_ARCHETYPES);
|
|
||||||
const [localSearch, setLocalSearch] = useState<string>('');
|
const [localSearch, setLocalSearch] = useState<string>('');
|
||||||
const [intervalEditor, setIntervalEditor] = useState<{ name: string; current: number | null } | null>(null);
|
const [intervalEditor, setIntervalEditor] = useState<{ name: string; current: number | null } | null>(null);
|
||||||
const [selectedDecky, setSelectedDecky] = useState<Decky | null>(null);
|
const [selectedDecky, setSelectedDecky] = useState<Decky | null>(null);
|
||||||
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
|
||||||
|
// Mirror the topbar search prop into local state; filter-decky events
|
||||||
|
// can override it in-session.
|
||||||
const lastSearchPropRef = useRef<string>(searchQuery);
|
const lastSearchPropRef = useRef<string>(searchQuery);
|
||||||
if (lastSearchPropRef.current !== searchQuery) {
|
if (lastSearchPropRef.current !== searchQuery) {
|
||||||
lastSearchPropRef.current = searchQuery;
|
lastSearchPropRef.current = searchQuery;
|
||||||
// Mirror the topbar search into local state; filter-decky events can
|
|
||||||
// override it in-session.
|
|
||||||
if (localSearch !== searchQuery) setLocalSearch(searchQuery);
|
if (localSearch !== searchQuery) setLocalSearch(searchQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,97 +49,19 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
|||||||
window.setTimeout(() => setArmed((p) => (p === key ? null : p)), 4000);
|
window.setTimeout(() => setArmed((p) => (p === key ? null : p)), 4000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchDeckies = async (mode?: string) => {
|
// Toast-wrapping handlers — the hook returns discriminated results,
|
||||||
try {
|
// and the page decides how to surface them in the toast lane.
|
||||||
if (mode === 'swarm') {
|
|
||||||
const res = await api.get<SwarmDeckyRaw[]>('/swarm/deckies');
|
|
||||||
const normalized: Decky[] = res.data.map((s) => ({
|
|
||||||
name: s.decky_name,
|
|
||||||
ip: s.decky_ip || '—',
|
|
||||||
services: s.services || [],
|
|
||||||
distro: s.distro || 'unknown',
|
|
||||||
hostname: s.hostname || '—',
|
|
||||||
archetype: s.archetype,
|
|
||||||
service_config: s.service_config || {},
|
|
||||||
mutate_interval: s.mutate_interval,
|
|
||||||
last_mutated: s.last_mutated || 0,
|
|
||||||
swarm: {
|
|
||||||
host_uuid: s.host_uuid,
|
|
||||||
host_name: s.host_name,
|
|
||||||
host_address: s.host_address,
|
|
||||||
host_status: s.host_status,
|
|
||||||
state: s.state,
|
|
||||||
last_error: s.last_error,
|
|
||||||
last_seen: s.last_seen,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
setDeckies(normalized);
|
|
||||||
} else {
|
|
||||||
const res = await api.get<Decky[]>('/deckies');
|
|
||||||
setDeckies(res.data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch decky fleet', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchRole = async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get('/config');
|
|
||||||
setIsAdmin(res.data.role === 'admin');
|
|
||||||
} catch {
|
|
||||||
setIsAdmin(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchDeployMode = async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get('/system/deployment-mode');
|
|
||||||
setDeployMode({ mode: res.data.mode, swarm_host_count: res.data.swarm_host_count });
|
|
||||||
return res.data.mode as string;
|
|
||||||
} catch {
|
|
||||||
setDeployMode(null);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchArchetypes = async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get<{ archetypes: { slug: string; display_name: string; services: string[] }[] }>(
|
|
||||||
'/topologies/archetypes',
|
|
||||||
);
|
|
||||||
const list: Archetype[] = res.data.archetypes.map((a) => ({
|
|
||||||
slug: a.slug,
|
|
||||||
name: a.display_name,
|
|
||||||
services: a.services,
|
|
||||||
icon: _archetypeIcon(a.slug),
|
|
||||||
}));
|
|
||||||
if (list.length) setArchetypes(list);
|
|
||||||
} catch {
|
|
||||||
// fall back to bundled list
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMutate = async (name: string): Promise<boolean> => {
|
const handleMutate = async (name: string): Promise<boolean> => {
|
||||||
setMutating(name);
|
const r = await mutate(name);
|
||||||
try {
|
if (r.ok) {
|
||||||
await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 });
|
|
||||||
await fetchDeckies(deployMode?.mode);
|
|
||||||
push({ text: `MUTATED · ${name.toUpperCase()}`, tone: 'matrix', icon: 'refresh-cw' });
|
push({ text: `MUTATED · ${name.toUpperCase()}`, tone: 'matrix', icon: 'refresh-cw' });
|
||||||
return true;
|
return true;
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error('Failed to mutate', err);
|
|
||||||
const e = err as { code?: string };
|
|
||||||
const msg = e.code === 'ECONNABORTED'
|
|
||||||
? `MUTATION TIMED OUT · ${name.toUpperCase()}`
|
|
||||||
: `MUTATION FAILED · ${name.toUpperCase()}`;
|
|
||||||
push({ text: msg, tone: 'alert', icon: 'alert-triangle' });
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setMutating(null);
|
|
||||||
}
|
}
|
||||||
|
const msg = r.reason === 'timeout'
|
||||||
|
? `MUTATION TIMED OUT · ${name.toUpperCase()}`
|
||||||
|
: `MUTATION FAILED · ${name.toUpperCase()}`;
|
||||||
|
push({ text: msg, tone: 'alert', icon: 'alert-triangle' });
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMutateAll = async () => {
|
const handleMutateAll = async () => {
|
||||||
@@ -180,10 +94,9 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
|||||||
const handleIntervalSave = async (minutes: number | null) => {
|
const handleIntervalSave = async (minutes: number | null) => {
|
||||||
if (!intervalEditor) return;
|
if (!intervalEditor) return;
|
||||||
const { name } = intervalEditor;
|
const { name } = intervalEditor;
|
||||||
try {
|
const ok = await setMutateInterval(name, minutes);
|
||||||
await api.put(`/deckies/${name}/mutate-interval`, { mutate_interval: minutes });
|
if (ok) {
|
||||||
setIntervalEditor(null);
|
setIntervalEditor(null);
|
||||||
fetchDeckies(deployMode?.mode);
|
|
||||||
push({
|
push({
|
||||||
text: minutes === null
|
text: minutes === null
|
||||||
? `INTERVAL · ${name.toUpperCase()} · DISABLED`
|
? `INTERVAL · ${name.toUpperCase()} · DISABLED`
|
||||||
@@ -191,57 +104,28 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
|||||||
tone: 'matrix',
|
tone: 'matrix',
|
||||||
icon: 'refresh-cw',
|
icon: 'refresh-cw',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} else {
|
||||||
console.error('Failed to update interval', err);
|
|
||||||
push({ text: `INTERVAL UPDATE FAILED · ${name.toUpperCase()}`, tone: 'alert', icon: 'alert-triangle' });
|
push({ text: `INTERVAL UPDATE FAILED · ${name.toUpperCase()}`, tone: 'alert', icon: 'alert-triangle' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Two-step teardown: first click arms the button, second click within
|
||||||
|
// 4s actually fires the POST. Keeps swarm hosts safe from misclicks.
|
||||||
const handleTeardown = async (d: Decky) => {
|
const handleTeardown = async (d: Decky) => {
|
||||||
if (!d.swarm) return;
|
if (!d.swarm) return;
|
||||||
const key = `td:${d.swarm.host_uuid}:${d.name}`;
|
const key = `td:${d.swarm.host_uuid}:${d.name}`;
|
||||||
if (armed !== key) { arm(key); return; }
|
if (armed !== key) { arm(key); return; }
|
||||||
setArmed(null);
|
setArmed(null);
|
||||||
setTearingDown((prev) => new Set(prev).add(d.name));
|
const r = await teardown(d);
|
||||||
try {
|
if (r.ok) {
|
||||||
await api.post(`/swarm/hosts/${d.swarm.host_uuid}/teardown`, { decky_id: d.name });
|
|
||||||
await fetchDeckies(deployMode?.mode);
|
|
||||||
push({ text: `TORN DOWN · ${d.name.toUpperCase()}`, tone: 'matrix', icon: 'check-circle' });
|
push({ text: `TORN DOWN · ${d.name.toUpperCase()}`, tone: 'matrix', icon: 'check-circle' });
|
||||||
} catch (err: unknown) {
|
} else {
|
||||||
const e = err as ApiError;
|
push({ text: `TEARDOWN FAILED · ${r.reason}`, tone: 'alert', icon: 'alert-triangle' });
|
||||||
push({
|
|
||||||
text: `TEARDOWN FAILED · ${e?.response?.data?.detail || d.name}`,
|
|
||||||
tone: 'alert',
|
|
||||||
icon: 'alert-triangle',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setTearingDown((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(d.name);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInspect = (d: Decky) => {
|
// decnet:cmd bus: deploy + mutate-all are wired here because they
|
||||||
setSelectedDecky(d);
|
// dispatch UI state and a toast-wrapped operation respectively.
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
const mode = await fetchDeployMode();
|
|
||||||
if (cancelled) return;
|
|
||||||
await Promise.all([fetchDeckies(mode), fetchRole(), fetchArchetypes()]);
|
|
||||||
})();
|
|
||||||
const interval = window.setInterval(() => {
|
|
||||||
fetchDeployMode().then((m) => fetchDeckies(m));
|
|
||||||
}, 10000);
|
|
||||||
return () => { cancelled = true; window.clearInterval(interval); };
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Phase-2 decnet:cmd bus: deploy, mutate-all, filter-decky
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onCmd = (e: Event) => {
|
const onCmd = (e: Event) => {
|
||||||
const detail = (e as CustomEvent).detail as { id?: string; payload?: string };
|
const detail = (e as CustomEvent).detail as { id?: string; payload?: string };
|
||||||
@@ -263,14 +147,14 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
|||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const c = { all: deckies.length, active: 0, hot: 0, idle: 0 } as Record<FilterKey, number>;
|
const c = { all: deckies.length, active: 0, hot: 0, idle: 0 } as Record<FilterKey, number>;
|
||||||
for (const d of deckies) {
|
for (const d of deckies) {
|
||||||
const s = _dotFor(d);
|
const s = dotFor(d);
|
||||||
c[s] += 1;
|
c[s] += 1;
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
}, [deckies]);
|
}, [deckies]);
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
const base = filter === 'all' ? deckies : deckies.filter((d) => _dotFor(d) === filter);
|
const base = filter === 'all' ? deckies : deckies.filter((d) => dotFor(d) === filter);
|
||||||
const q = localSearch.trim().toLowerCase();
|
const q = localSearch.trim().toLowerCase();
|
||||||
if (!q) return base;
|
if (!q) return base;
|
||||||
return base.filter((d) =>
|
return base.filter((d) =>
|
||||||
@@ -279,7 +163,6 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
|||||||
|| (d.hostname || '').toLowerCase().includes(q),
|
|| (d.hostname || '').toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
}, [deckies, filter, localSearch]);
|
}, [deckies, filter, localSearch]);
|
||||||
const isSwarm = deployMode?.mode === 'swarm';
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -306,43 +189,22 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<DeckyFilters
|
||||||
<div className="fleet-filter-group">
|
filter={filter}
|
||||||
{([['all', 'ALL'], ['active', 'ACTIVE'], ['hot', 'HOT'], ['idle', 'IDLE']] as [FilterKey, string][]).map(
|
setFilter={setFilter}
|
||||||
([v, l]) => (
|
counts={counts}
|
||||||
<button
|
isAdmin={isAdmin}
|
||||||
key={v}
|
onDeploy={() => setShowDeploy(true)}
|
||||||
onClick={() => setFilter(v)}
|
/>
|
||||||
className={`fleet-filter-btn ${filter === v ? 'active' : ''}`}
|
|
||||||
>
|
|
||||||
{l} {counts[v]}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isAdmin && (
|
|
||||||
<button className="btn violet" onClick={() => setShowDeploy(true)}>
|
|
||||||
<PlusCircle size={12} /> DEPLOY DECKIES
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid-fleet">
|
<div className="grid-fleet">
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<div className="fleet-empty">
|
<DeckyGridEmpty
|
||||||
<Server size={32} className="dim" />
|
fleetEmpty={deckies.length === 0}
|
||||||
<span className="dim">
|
isAdmin={isAdmin}
|
||||||
{deckies.length === 0
|
onDeploy={() => setShowDeploy(true)}
|
||||||
? 'NO DECOYS DEPLOYED IN THIS SECTOR'
|
/>
|
||||||
: 'NO DECOYS MATCH CURRENT FILTER'}
|
|
||||||
</span>
|
|
||||||
{isAdmin && deckies.length === 0 && (
|
|
||||||
<button className="btn violet" onClick={() => setShowDeploy(true)}>
|
|
||||||
<PlusCircle size={12} /> DEPLOY DECKIES
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
visible.map((d) => (
|
visible.map((d) => (
|
||||||
<DeckyCard
|
<DeckyCard
|
||||||
@@ -355,17 +217,13 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
|||||||
onForce={(name) => { void handleMutate(name); }}
|
onForce={(name) => { void handleMutate(name); }}
|
||||||
onTeardown={handleTeardown}
|
onTeardown={handleTeardown}
|
||||||
onIntervalChange={handleIntervalChange}
|
onIntervalChange={handleIntervalChange}
|
||||||
onInspect={handleInspect}
|
onInspect={(decky) => setSelectedDecky(decky)}
|
||||||
innerRef={(el: HTMLDivElement | null) => {
|
innerRef={(el: HTMLDivElement | null) => {
|
||||||
if (el) cardRefs.current.set(d.name, el);
|
if (el) cardRefs.current.set(d.name, el);
|
||||||
else cardRefs.current.delete(d.name);
|
else cardRefs.current.delete(d.name);
|
||||||
}}
|
}}
|
||||||
availableServices={serviceRegistry.perDecky}
|
availableServices={serviceRegistry.perDecky}
|
||||||
onServicesChanged={(name, services) => {
|
onServicesChanged={applyServicesChange}
|
||||||
setDeckies((prev) => prev.map((row) =>
|
|
||||||
row.name === name ? { ...row, services } : row,
|
|
||||||
));
|
|
||||||
}}
|
|
||||||
onTarpitResult={(_name, ok, message) => {
|
onTarpitResult={(_name, ok, message) => {
|
||||||
push({
|
push({
|
||||||
text: message,
|
text: message,
|
||||||
@@ -385,7 +243,7 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
|||||||
onClose={() => setShowDeploy(false)}
|
onClose={() => setShowDeploy(false)}
|
||||||
onComplete={(count) => {
|
onComplete={(count) => {
|
||||||
setShowDeploy(false);
|
setShowDeploy(false);
|
||||||
fetchDeckies(deployMode?.mode);
|
void refresh();
|
||||||
push({
|
push({
|
||||||
text: `DEPLOYED · ${count} DECK${count === 1 ? 'Y' : 'IES'}`,
|
text: `DEPLOYED · ${count} DECK${count === 1 ? 'Y' : 'IES'}`,
|
||||||
tone: 'matrix',
|
tone: 'matrix',
|
||||||
|
|||||||
67
decnet_web/src/components/DeckyFleet/DeckyFilters.test.tsx
Normal file
67
decnet_web/src/components/DeckyFleet/DeckyFilters.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { DeckyFilters } from './DeckyFilters';
|
||||||
|
|
||||||
|
const counts = { all: 7, active: 4, hot: 2, idle: 1 } as const;
|
||||||
|
|
||||||
|
describe('DeckyFilters', () => {
|
||||||
|
it('renders one button per filter with the matching count', () => {
|
||||||
|
render(
|
||||||
|
<DeckyFilters
|
||||||
|
filter="all"
|
||||||
|
setFilter={() => {}}
|
||||||
|
counts={counts}
|
||||||
|
isAdmin={false}
|
||||||
|
onDeploy={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('ALL 7')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ACTIVE 4')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('HOT 2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('IDLE 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a filter button invokes setFilter with its key', async () => {
|
||||||
|
const setFilter = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<DeckyFilters
|
||||||
|
filter="all"
|
||||||
|
setFilter={setFilter}
|
||||||
|
counts={counts}
|
||||||
|
isAdmin={false}
|
||||||
|
onDeploy={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByText('HOT 2'));
|
||||||
|
expect(setFilter).toHaveBeenCalledWith('hot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides DEPLOY DECKIES for non-admins, shows it for admins', async () => {
|
||||||
|
const onDeploy = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<DeckyFilters
|
||||||
|
filter="all"
|
||||||
|
setFilter={() => {}}
|
||||||
|
counts={counts}
|
||||||
|
isAdmin={false}
|
||||||
|
onDeploy={onDeploy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/DEPLOY DECKIES/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DeckyFilters
|
||||||
|
filter="all"
|
||||||
|
setFilter={() => {}}
|
||||||
|
counts={counts}
|
||||||
|
isAdmin
|
||||||
|
onDeploy={onDeploy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByText(/DEPLOY DECKIES/));
|
||||||
|
expect(onDeploy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
decnet_web/src/components/DeckyFleet/DeckyFilters.tsx
Normal file
44
decnet_web/src/components/DeckyFleet/DeckyFilters.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { PlusCircle } from '../../icons';
|
||||||
|
import type { FilterKey } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
filter: FilterKey;
|
||||||
|
setFilter: (k: FilterKey) => void;
|
||||||
|
counts: Record<FilterKey, number>;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onDeploy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_BUTTONS: ReadonlyArray<readonly [FilterKey, string]> = [
|
||||||
|
['all', 'ALL'],
|
||||||
|
['active', 'ACTIVE'],
|
||||||
|
['hot', 'HOT'],
|
||||||
|
['idle', 'IDLE'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Filter pill row + DEPLOY DECKIES action used in the page header.
|
||||||
|
* Counts feed badge text inside each pill so users can see fleet
|
||||||
|
* health without filtering first. */
|
||||||
|
export const DeckyFilters: React.FC<Props> = ({
|
||||||
|
filter, setFilter, counts, isAdmin, onDeploy,
|
||||||
|
}) => (
|
||||||
|
<div className="actions">
|
||||||
|
<div className="fleet-filter-group">
|
||||||
|
{FILTER_BUTTONS.map(([v, l]) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
onClick={() => setFilter(v)}
|
||||||
|
className={`fleet-filter-btn ${filter === v ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
{l} {counts[v]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<button className="btn violet" onClick={onDeploy}>
|
||||||
|
<PlusCircle size={12} /> DEPLOY DECKIES
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
41
decnet_web/src/components/DeckyFleet/DeckyGridEmpty.test.tsx
Normal file
41
decnet_web/src/components/DeckyFleet/DeckyGridEmpty.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { DeckyGridEmpty } from './DeckyGridEmpty';
|
||||||
|
|
||||||
|
describe('DeckyGridEmpty', () => {
|
||||||
|
it('shows the fleet-empty copy when fleetEmpty is true', () => {
|
||||||
|
render(
|
||||||
|
<DeckyGridEmpty fleetEmpty isAdmin={false} onDeploy={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('NO DECOYS DEPLOYED IN THIS SECTOR')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the filtered-empty copy when fleetEmpty is false', () => {
|
||||||
|
render(
|
||||||
|
<DeckyGridEmpty fleetEmpty={false} isAdmin={false} onDeploy={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('NO DECOYS MATCH CURRENT FILTER')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only renders the DEPLOY shortcut for admins on a truly empty fleet', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<DeckyGridEmpty fleetEmpty isAdmin={false} onDeploy={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/DEPLOY DECKIES/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DeckyGridEmpty fleetEmpty={false} isAdmin onDeploy={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/DEPLOY DECKIES/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const onDeploy = vi.fn();
|
||||||
|
rerender(
|
||||||
|
<DeckyGridEmpty fleetEmpty isAdmin onDeploy={onDeploy} />,
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
return user.click(screen.getByText(/DEPLOY DECKIES/)).then(() => {
|
||||||
|
expect(onDeploy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
28
decnet_web/src/components/DeckyFleet/DeckyGridEmpty.tsx
Normal file
28
decnet_web/src/components/DeckyFleet/DeckyGridEmpty.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { PlusCircle, Server } from '../../icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** True when the underlying fleet itself is empty (vs. just filtered down). */
|
||||||
|
fleetEmpty: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onDeploy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty-state shown inside the grid when no cards match. Distinguishes
|
||||||
|
* a genuinely empty fleet (offers a DEPLOY shortcut for admins) from a
|
||||||
|
* filter that hid everything (just nudges the user to widen). */
|
||||||
|
export const DeckyGridEmpty: React.FC<Props> = ({ fleetEmpty, isAdmin, onDeploy }) => (
|
||||||
|
<div className="fleet-empty">
|
||||||
|
<Server size={32} className="dim" />
|
||||||
|
<span className="dim">
|
||||||
|
{fleetEmpty
|
||||||
|
? 'NO DECOYS DEPLOYED IN THIS SECTOR'
|
||||||
|
: 'NO DECOYS MATCH CURRENT FILTER'}
|
||||||
|
</span>
|
||||||
|
{isAdmin && fleetEmpty && (
|
||||||
|
<button className="btn violet" onClick={onDeploy}>
|
||||||
|
<PlusCircle size={12} /> DEPLOY DECKIES
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user