merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

@@ -0,0 +1,15 @@
import { useEffect } from 'react';
export function useEscapeKey(onEscape: () => void, active: boolean = true): void {
useEffect(() => {
if (!active) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onEscape();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onEscape, active]);
}

View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import type { RefObject } from 'react';
/**
* Focus the given input when the global `decnet:focus-search` event fires
* (dispatched by the `/` hotkey in useGlobalHotkeys).
*/
export function useFocusSearch(ref: RefObject<HTMLInputElement | null>): void {
useEffect(() => {
const handler = () => {
const el = ref.current;
if (!el) return;
el.focus();
try { el.select(); } catch { /* ignore */ }
};
window.addEventListener('decnet:focus-search', handler);
return () => window.removeEventListener('decnet:focus-search', handler);
}, [ref]);
}

View File

@@ -0,0 +1,49 @@
import { useEffect, type RefObject } from 'react';
const FOCUSABLE =
'input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
export function useFocusTrap(
ref: RefObject<HTMLElement | null>,
active: boolean,
): void {
useEffect(() => {
if (!active || !ref.current) return;
const root = ref.current;
const previouslyFocused = document.activeElement as HTMLElement | null;
const focusables = () =>
Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => !el.hasAttribute('aria-hidden') && el.offsetParent !== null,
);
const autoFocus =
root.querySelector<HTMLElement>('[data-autofocus]') ?? focusables()[0];
autoFocus?.focus();
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const items = focusables();
if (items.length === 0) {
e.preventDefault();
return;
}
const first = items[0];
const last = items[items.length - 1];
const current = document.activeElement as HTMLElement | null;
if (e.shiftKey && current === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && current === last) {
e.preventDefault();
first.focus();
}
};
root.addEventListener('keydown', handler);
return () => {
root.removeEventListener('keydown', handler);
previouslyFocused?.focus?.();
};
}, [ref, active]);
}

View File

@@ -0,0 +1,98 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
interface Options {
cmdOpen: boolean;
setCmdOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
helpOpen: boolean;
setHelpOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
}
const G_NAV: Record<string, string> = {
d: '/',
f: '/fleet',
m: '/mazenet',
l: '/live-logs',
b: '/bounty',
a: '/attackers',
c: '/config',
s: '/swarm/hosts',
u: '/swarm-updates',
};
const G_TIMEOUT_MS = 800;
function isEditable(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
}
export function useGlobalHotkeys({ cmdOpen, setCmdOpen, helpOpen, setHelpOpen }: Options): void {
const navigate = useNavigate();
const pendingG = useRef(false);
const gTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const clearG = () => {
pendingG.current = false;
if (gTimer.current) { clearTimeout(gTimer.current); gTimer.current = null; }
};
const onKey = (e: KeyboardEvent) => {
if (e.altKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
setCmdOpen(v => !v);
clearG();
return;
}
if (e.key === 'Escape' && cmdOpen) {
setCmdOpen(false);
clearG();
return;
}
if (cmdOpen || helpOpen) return;
if (isEditable(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
// `?` (Shift+/) — open shortcuts cheatsheet
if (e.key === '?') {
e.preventDefault();
setHelpOpen(true);
clearG();
return;
}
// `/` — focus page search (page listens for the event)
if (e.key === '/') {
e.preventDefault();
window.dispatchEvent(new CustomEvent('decnet:focus-search'));
clearG();
return;
}
const k = e.key.toLowerCase();
if (pendingG.current && G_NAV[k]) {
e.preventDefault();
navigate(G_NAV[k]);
clearG();
return;
}
if (k === 'g') {
pendingG.current = true;
if (gTimer.current) clearTimeout(gTimer.current);
gTimer.current = setTimeout(clearG, G_TIMEOUT_MS);
}
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
if (gTimer.current) clearTimeout(gTimer.current);
};
}, [cmdOpen, setCmdOpen, helpOpen, setHelpOpen, navigate]);
}

View File

@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from 'react';
import api from '../utils/api';
export interface SwarmHost {
uuid: string;
name: string;
address: string;
agent_port: number;
status: string;
last_heartbeat: string | null;
}
/**
* Lookup of enrolled swarm hosts. One-shot fetch on mount, with a manual
* refresh callback. Used to resolve `target_host_uuid` → display name in
* places where we don't already have a host name in hand (topology list,
* war-map header).
*
* Failure is treated as "no agents enrolled" — callers fall back to the
* uuid prefix or a generic label rather than blocking on this lookup.
*/
export function useSwarmHosts(): {
hosts: SwarmHost[];
byUuid: Map<string, SwarmHost>;
refresh: () => Promise<void>;
} {
const [hosts, setHosts] = useState<SwarmHost[]>([]);
const refresh = useCallback(async () => {
try {
const { data } = await api.get<SwarmHost[]>('/swarm/hosts');
setHosts(data ?? []);
} catch {
setHosts([]);
}
}, []);
useEffect(() => { refresh(); }, [refresh]);
const byUuid = new Map(hosts.map((h) => [h.uuid, h]));
return { hosts, byUuid, refresh };
}