diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index 6427d6b4..74c6adf2 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -3,7 +3,6 @@ import { Network, PlusCircle, PowerOff, RefreshCw, Server, Plus, X, } from '../icons'; -import { useEscapeKey } from '../hooks/useEscapeKey'; import api, { type ApiError } from '../utils/api'; import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data'; import { useToast } from './Toasts/useToast'; @@ -31,123 +30,7 @@ import { stateColor as _stateColor, } from './DeckyFleet/helpers'; -// ─── Decky inspect panel ───────────────────────────────────────────────── - -interface DeckyInspectPanelProps { - decky: Decky; - onClose: () => void; -} - -const DeckyInspectPanel: React.FC = ({ decky, onClose }) => { - useEscapeKey(onClose, true); - const status = _dotFor(decky); - - useEffect(() => { - const prev = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - return () => { document.body.style.overflow = prev; }; - }, []); - - const fmtDate = (ts: number | string | null | undefined) => { - if (!ts) return '—'; - const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts); - return isNaN(d.getTime()) ? String(ts) : d.toLocaleString(); - }; - - return ( -
-
e.stopPropagation()} - style={{ - width: 360, - background: 'var(--secondary-color)', - borderLeft: '1px solid var(--border)', - display: 'flex', flexDirection: 'column', - height: '100%', - overflowY: 'auto', - }} - > -
-
- - - {decky.name} - -
- -
- -
-
- {[ - ['IP', decky.ip], - ['HOSTNAME', decky.hostname], - ['DISTRO', decky.distro], - ['ARCHETYPE', decky.archetype], - ['LAST MUTATED', fmtDate(decky.last_mutated)], - ['MUTATE INTERVAL', decky.mutate_interval != null ? `${decky.mutate_interval}s` : '—'], - ].map(([label, val]) => val ? ( -
- {label} - {val} -
- ) : null)} -
- - {decky.services.length > 0 && ( -
-
SERVICES
-
- {decky.services.map(svc => ( - {svc} - ))} -
-
- )} - - {decky.swarm && ( -
-
SWARM
- {[ - ['HOST', decky.swarm.host_name], - ['ADDRESS', decky.swarm.host_address], - ['STATE', decky.swarm.state], - ['LAST SEEN', fmtDate(decky.swarm.last_seen)], - ['ERROR', decky.swarm.last_error], - ].map(([label, val]) => val ? ( -
- {label} - {val} -
- ) : null)} -
- )} -
-
-
- ); -}; +import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel'; // ─── Decky card ─────────────────────────────────────────────────────────── diff --git a/decnet_web/src/components/DeckyFleet/DeckyInspectPanel.test.tsx b/decnet_web/src/components/DeckyFleet/DeckyInspectPanel.test.tsx new file mode 100644 index 00000000..709fbd6f --- /dev/null +++ b/decnet_web/src/components/DeckyFleet/DeckyInspectPanel.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DeckyInspectPanel } from './DeckyInspectPanel'; +import { makeDecky } from '../../test/fixtures'; + +describe('DeckyInspectPanel', () => { + it('renders the decky name + identity rows from a fixture', () => { + render( + {}} + />, + ); + expect(screen.getByText('decoy-04')).toBeInTheDocument(); + expect(screen.getByText('10.0.0.4')).toBeInTheDocument(); + expect(screen.getByText('corp-fs-04')).toBeInTheDocument(); + expect(screen.getByText('workstation')).toBeInTheDocument(); + }); + + it('renders the SERVICES chips when services array is non-empty', () => { + render( + {}} + />, + ); + expect(screen.getByText('SERVICES')).toBeInTheDocument(); + expect(screen.getByText('ssh')).toBeInTheDocument(); + expect(screen.getByText('http')).toBeInTheDocument(); + }); + + it('renders the SWARM block only when decky.swarm is present', () => { + const { rerender } = render( + {}} />, + ); + expect(screen.queryByText('SWARM')).not.toBeInTheDocument(); + + rerender( + {}} + />, + ); + expect(screen.getByText('SWARM')).toBeInTheDocument(); + expect(screen.getByText('edge-01')).toBeInTheDocument(); + }); + + it('invokes onClose when the X button is clicked', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + const closeBtn = screen.getAllByRole('button')[0]; + await user.click(closeBtn); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/decnet_web/src/components/DeckyFleet/DeckyInspectPanel.tsx b/decnet_web/src/components/DeckyFleet/DeckyInspectPanel.tsx new file mode 100644 index 00000000..05b7f52a --- /dev/null +++ b/decnet_web/src/components/DeckyFleet/DeckyInspectPanel.tsx @@ -0,0 +1,124 @@ +import React, { useEffect } from 'react'; +import { X } from '../../icons'; +import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { dotFor, stateColor } from './helpers'; +import type { Decky } from './types'; + +interface Props { + decky: Decky; + onClose: () => void; +} + +/** Right-side slide-in inspect panel for a single Decky. Renders the + * rollup of identity / archetype / mutate scheduling fields plus + * the swarm placement metadata when present. */ +export const DeckyInspectPanel: React.FC = ({ decky, onClose }) => { + useEscapeKey(onClose, true); + const status = dotFor(decky); + + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, []); + + const fmtDate = (ts: number | string | null | undefined) => { + if (!ts) return '—'; + const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts); + return isNaN(d.getTime()) ? String(ts) : d.toLocaleString(); + }; + + return ( +
+
e.stopPropagation()} + style={{ + width: 360, + background: 'var(--secondary-color)', + borderLeft: '1px solid var(--border)', + display: 'flex', flexDirection: 'column', + height: '100%', + overflowY: 'auto', + }} + > +
+
+ + + {decky.name} + +
+ +
+ +
+
+ {[ + ['IP', decky.ip], + ['HOSTNAME', decky.hostname], + ['DISTRO', decky.distro], + ['ARCHETYPE', decky.archetype], + ['LAST MUTATED', fmtDate(decky.last_mutated)], + ['MUTATE INTERVAL', decky.mutate_interval != null ? `${decky.mutate_interval}s` : '—'], + ].map(([label, val]) => val ? ( +
+ {label} + {val} +
+ ) : null)} +
+ + {decky.services.length > 0 && ( +
+
SERVICES
+
+ {decky.services.map(svc => ( + {svc} + ))} +
+
+ )} + + {decky.swarm && ( +
+
SWARM
+ {[ + ['HOST', decky.swarm.host_name], + ['ADDRESS', decky.swarm.host_address], + ['STATE', decky.swarm.state], + ['LAST SEEN', fmtDate(decky.swarm.last_seen)], + ['ERROR', decky.swarm.last_error], + ].map(([label, val]) => val ? ( +
+ {label} + {val} +
+ ) : null)} +
+ )} +
+
+
+ ); +};