refactor(decnet_web/DeckyFleet): move DeckyInspectPanel out
Lift the right-side inspect drawer (~115 LOC) into its own file. This is a verbatim move — same JSX, same useEscapeKey + body overflow lock, same swarm-section gating. Underscore-prefixed helper calls (_dotFor, _stateColor) drop the leading underscore since they're now imported from helpers.tsx. - New DeckyFleet/DeckyInspectPanel.tsx - DeckyInspectPanel.test.tsx covers identity-row rendering, the SERVICES chip list, the conditional SWARM block, and the close button callback. - DeckyFleet.tsx loses the panel + the now-unused useEscapeKey import.
This commit is contained in:
@@ -3,7 +3,6 @@ import {
|
|||||||
Network, PlusCircle, PowerOff,
|
Network, PlusCircle, PowerOff,
|
||||||
RefreshCw, Server, Plus, X,
|
RefreshCw, Server, Plus, X,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import { useEscapeKey } from '../hooks/useEscapeKey';
|
|
||||||
import api, { type ApiError } from '../utils/api';
|
import api, { type ApiError } from '../utils/api';
|
||||||
import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data';
|
import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data';
|
||||||
import { useToast } from './Toasts/useToast';
|
import { useToast } from './Toasts/useToast';
|
||||||
@@ -31,123 +30,7 @@ import {
|
|||||||
stateColor as _stateColor,
|
stateColor as _stateColor,
|
||||||
} from './DeckyFleet/helpers';
|
} from './DeckyFleet/helpers';
|
||||||
|
|
||||||
// ─── Decky inspect panel ─────────────────────────────────────────────────
|
import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel';
|
||||||
|
|
||||||
interface DeckyInspectPanelProps {
|
|
||||||
decky: Decky;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeckyInspectPanel: React.FC<DeckyInspectPanelProps> = ({ 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 (
|
|
||||||
<div
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
position: 'fixed', inset: 0,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
|
||||||
display: 'flex', justifyContent: 'flex-end',
|
|
||||||
zIndex: 1200,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: 360,
|
|
||||||
background: 'var(--secondary-color)',
|
|
||||||
borderLeft: '1px solid var(--border)',
|
|
||||||
display: 'flex', flexDirection: 'column',
|
|
||||||
height: '100%',
|
|
||||||
overflowY: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
padding: '16px 20px',
|
|
||||||
borderBottom: '1px solid var(--border)',
|
|
||||||
gap: 12,
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<span className={`status-dot ${status}`} />
|
|
||||||
<span style={{ fontWeight: 700, letterSpacing: 3, fontSize: '0.95rem', color: 'var(--matrix)' }}>
|
|
||||||
{decky.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--dim-color)', padding: 4 }}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{[
|
|
||||||
['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 ? (
|
|
||||||
<div key={label} style={{ display: 'flex', gap: 10, fontSize: '0.78rem' }}>
|
|
||||||
<span style={{ minWidth: 130, opacity: 0.45, letterSpacing: 1 }}>{label}</span>
|
|
||||||
<span style={{ color: 'var(--matrix)', wordBreak: 'break-all' }}>{val}</span>
|
|
||||||
</div>
|
|
||||||
) : null)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{decky.services.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.45, letterSpacing: 1.5, marginBottom: 8 }}>SERVICES</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
||||||
{decky.services.map(svc => (
|
|
||||||
<span key={svc} className="chip violet" style={{ fontSize: '0.65rem' }}>{svc}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{decky.swarm && (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 8, borderTop: '1px solid var(--border)' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.45, letterSpacing: 1.5, marginBottom: 2 }}>SWARM</div>
|
|
||||||
{[
|
|
||||||
['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 ? (
|
|
||||||
<div key={label} style={{ display: 'flex', gap: 10, fontSize: '0.78rem' }}>
|
|
||||||
<span style={{ minWidth: 130, opacity: 0.45, letterSpacing: 1 }}>{label}</span>
|
|
||||||
<span style={{
|
|
||||||
color: label === 'STATE' ? _stateColor(val) : label === 'ERROR' ? 'var(--alert)' : 'var(--matrix)',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
}}>{val}</span>
|
|
||||||
</div>
|
|
||||||
) : null)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Decky card ───────────────────────────────────────────────────────────
|
// ─── Decky card ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<DeckyInspectPanel
|
||||||
|
decky={makeDecky({
|
||||||
|
name: 'decoy-04',
|
||||||
|
ip: '10.0.0.4',
|
||||||
|
hostname: 'corp-fs-04',
|
||||||
|
distro: 'debian-12',
|
||||||
|
archetype: 'workstation',
|
||||||
|
})}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<DeckyInspectPanel
|
||||||
|
decky={makeDecky({ services: ['ssh', 'http'] })}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<DeckyInspectPanel decky={makeDecky()} onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('SWARM')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DeckyInspectPanel
|
||||||
|
decky={makeDecky({
|
||||||
|
swarm: {
|
||||||
|
host_uuid: 'h1',
|
||||||
|
host_name: 'edge-01',
|
||||||
|
host_address: 'edge-01.example',
|
||||||
|
host_status: 'ok',
|
||||||
|
state: 'running',
|
||||||
|
last_error: null,
|
||||||
|
last_seen: '2026-05-09T11:00:00Z',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<DeckyInspectPanel decky={makeDecky()} onClose={onClose} />,
|
||||||
|
);
|
||||||
|
const closeBtn = screen.getAllByRole('button')[0];
|
||||||
|
await user.click(closeBtn);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
decnet_web/src/components/DeckyFleet/DeckyInspectPanel.tsx
Normal file
124
decnet_web/src/components/DeckyFleet/DeckyInspectPanel.tsx
Normal file
@@ -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<Props> = ({ 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 (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||||
|
display: 'flex', justifyContent: 'flex-end',
|
||||||
|
zIndex: 1200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: 360,
|
||||||
|
background: 'var(--secondary-color)',
|
||||||
|
borderLeft: '1px solid var(--border)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '16px 20px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span className={`status-dot ${status}`} />
|
||||||
|
<span style={{ fontWeight: 700, letterSpacing: 3, fontSize: '0.95rem', color: 'var(--matrix)' }}>
|
||||||
|
{decky.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--dim-color)', padding: 4 }}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{[
|
||||||
|
['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 ? (
|
||||||
|
<div key={label} style={{ display: 'flex', gap: 10, fontSize: '0.78rem' }}>
|
||||||
|
<span style={{ minWidth: 130, opacity: 0.45, letterSpacing: 1 }}>{label}</span>
|
||||||
|
<span style={{ color: 'var(--matrix)', wordBreak: 'break-all' }}>{val}</span>
|
||||||
|
</div>
|
||||||
|
) : null)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{decky.services.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.65rem', opacity: 0.45, letterSpacing: 1.5, marginBottom: 8 }}>SERVICES</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{decky.services.map(svc => (
|
||||||
|
<span key={svc} className="chip violet" style={{ fontSize: '0.65rem' }}>{svc}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{decky.swarm && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 8, borderTop: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', opacity: 0.45, letterSpacing: 1.5, marginBottom: 2 }}>SWARM</div>
|
||||||
|
{[
|
||||||
|
['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 ? (
|
||||||
|
<div key={label} style={{ display: 'flex', gap: 10, fontSize: '0.78rem' }}>
|
||||||
|
<span style={{ minWidth: 130, opacity: 0.45, letterSpacing: 1 }}>{label}</span>
|
||||||
|
<span style={{
|
||||||
|
color: label === 'STATE' ? stateColor(val) : label === 'ERROR' ? 'var(--alert)' : 'var(--matrix)',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}>{val}</span>
|
||||||
|
</div>
|
||||||
|
) : null)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user