feat(web): add ? cheatsheet and / focus-search hotkeys
- New ShortcutsHelp modal enumerates global, nav G-chord and palette bindings; openable via ? (Shift+/) or the command palette. - / dispatches a global decnet:focus-search event; Attackers, Bounty and LiveLogs listen and focus their in-page search inputs (pages without a local search are skipped per plan). - Respects the existing editable-element guard and Alt+K palette toggle; no rebinds to prior shortcuts.
This commit is contained in:
@@ -15,6 +15,7 @@ import AgentEnrollment from './components/AgentEnrollment';
|
|||||||
import MazeNET from './components/MazeNET/MazeNET';
|
import MazeNET from './components/MazeNET/MazeNET';
|
||||||
import TopologyList from './components/TopologyList/TopologyList';
|
import TopologyList from './components/TopologyList/TopologyList';
|
||||||
import CommandPalette from './components/CommandPalette/CommandPalette';
|
import CommandPalette from './components/CommandPalette/CommandPalette';
|
||||||
|
import ShortcutsHelp from './components/ShortcutsHelp/ShortcutsHelp';
|
||||||
import { ToastProvider } from './components/Toasts/ToastProvider';
|
import { ToastProvider } from './components/Toasts/ToastProvider';
|
||||||
import { useToast } from './components/Toasts/useToast';
|
import { useToast } from './components/Toasts/useToast';
|
||||||
import { useGlobalHotkeys } from './hooks/useGlobalHotkeys';
|
import { useGlobalHotkeys } from './hooks/useGlobalHotkeys';
|
||||||
@@ -60,10 +61,12 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
const [cmdOpen, setCmdOpen] = useState(false);
|
const [cmdOpen, setCmdOpen] = useState(false);
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
||||||
useGlobalHotkeys({ cmdOpen, setCmdOpen });
|
useGlobalHotkeys({ cmdOpen, setCmdOpen, helpOpen, setHelpOpen });
|
||||||
|
|
||||||
const handleAction = (id: string) => {
|
const handleAction = (id: string) => {
|
||||||
|
if (id === 'shortcuts-help') { setHelpOpen(true); return; }
|
||||||
if (id === 'deploy') navigate('/fleet');
|
if (id === 'deploy') navigate('/fleet');
|
||||||
window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id } }));
|
window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id } }));
|
||||||
push({ text: ACTION_LABELS[id] ?? `${id.toUpperCase()} · QUEUED`, tone: 'violet', icon: 'terminal' });
|
push({ text: ACTION_LABELS[id] ?? `${id.toUpperCase()} · QUEUED`, tone: 'violet', icon: 'terminal' });
|
||||||
@@ -94,6 +97,7 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
|
|||||||
onNav={navigate}
|
onNav={navigate}
|
||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
/>
|
/>
|
||||||
|
<ShortcutsHelp open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { Search, ChevronLeft, ChevronRight, Users } from 'lucide-react';
|
import { Search, ChevronLeft, ChevronRight, Users } from 'lucide-react';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import EmptyState from './EmptyState/EmptyState';
|
import EmptyState from './EmptyState/EmptyState';
|
||||||
|
import { useFocusSearch } from '../hooks/useFocusSearch';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
import './Attackers.css';
|
import './Attackers.css';
|
||||||
|
|
||||||
@@ -63,6 +64,8 @@ const Attackers: React.FC = () => {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchInput, setSearchInput] = useState(query);
|
const [searchInput, setSearchInput] = useState(query);
|
||||||
|
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
useFocusSearch(searchRef);
|
||||||
|
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
|
|
||||||
@@ -122,6 +125,7 @@ const Attackers: React.FC = () => {
|
|||||||
<div className="search-container">
|
<div className="search-container">
|
||||||
<Search size={14} className="search-icon" />
|
<Search size={14} className="search-icon" />
|
||||||
<input
|
<input
|
||||||
|
ref={searchRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by IP..."
|
placeholder="Search by IP..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Archive, Search, ChevronLeft, ChevronRight, Filter, Key, Package, ChevronRight as ChevR,
|
Archive, Search, ChevronLeft, ChevronRight, Filter, Key, Package, ChevronRight as ChevR,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import BountyInspector from './BountyInspector';
|
import BountyInspector from './BountyInspector';
|
||||||
import EmptyState from './EmptyState/EmptyState';
|
import EmptyState from './EmptyState/EmptyState';
|
||||||
|
import { useFocusSearch } from '../hooks/useFocusSearch';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
import './Bounty.css';
|
import './Bounty.css';
|
||||||
|
|
||||||
@@ -57,6 +58,8 @@ const Bounty: React.FC = () => {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchInput, setSearchInput] = useState(query);
|
const [searchInput, setSearchInput] = useState(query);
|
||||||
|
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
useFocusSearch(searchRef);
|
||||||
const [selected, setSelected] = useState<BountyEntry | null>(null);
|
const [selected, setSelected] = useState<BountyEntry | null>(null);
|
||||||
|
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
@@ -118,6 +121,7 @@ const Bounty: React.FC = () => {
|
|||||||
<div className="search-container">
|
<div className="search-container">
|
||||||
<Search size={14} className="search-icon" />
|
<Search size={14} className="search-icon" />
|
||||||
<input
|
<input
|
||||||
|
ref={searchRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Filter by IP, decky, payload..."
|
placeholder="Filter by IP, decky, payload..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Server, Network, Terminal, Archive, Crosshair,
|
LayoutDashboard, Server, Network, Terminal, Archive, Crosshair,
|
||||||
PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, UserPlus, Settings,
|
PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, UserPlus, Settings,
|
||||||
SearchX,
|
SearchX, Keyboard,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import EmptyState from '../EmptyState/EmptyState';
|
import EmptyState from '../EmptyState/EmptyState';
|
||||||
import './CommandPalette.css';
|
import './CommandPalette.css';
|
||||||
@@ -33,6 +33,7 @@ const ITEMS: CmdItem[] = [
|
|||||||
{ section: 'ACTIONS', label: 'Pause live stream', icon: Pause, kind: 'action', payload: 'pause-logs' },
|
{ section: 'ACTIONS', label: 'Pause live stream', icon: Pause, kind: 'action', payload: 'pause-logs' },
|
||||||
{ section: 'ACTIONS', label: 'Force mutate all deckies', icon: RefreshCw, kind: 'action', payload: 'mutate-all' },
|
{ section: 'ACTIONS', label: 'Force mutate all deckies', icon: RefreshCw, kind: 'action', payload: 'mutate-all' },
|
||||||
{ section: 'ACTIONS', label: 'Export bounty to JSON', icon: Download, kind: 'action', payload: 'export-bounty' },
|
{ section: 'ACTIONS', label: 'Export bounty to JSON', icon: Download, kind: 'action', payload: 'export-bounty' },
|
||||||
|
{ section: 'ACTIONS', label: 'Show keyboard shortcuts', icon: Keyboard, kbd: '?', kind: 'action', payload: 'shortcuts-help' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||||
|
import { useFocusSearch } from '../hooks/useFocusSearch';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Terminal, Search, BarChart3, ChevronLeft, ChevronRight,
|
Terminal, Search, BarChart3, ChevronLeft, ChevronRight,
|
||||||
@@ -39,6 +40,8 @@ const LiveLogs: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [streaming, setStreaming] = useState(isLive);
|
const [streaming, setStreaming] = useState(isLive);
|
||||||
const [searchInput, setSearchInput] = useState(query);
|
const [searchInput, setSearchInput] = useState(query);
|
||||||
|
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
useFocusSearch(searchRef);
|
||||||
const [selectedHour, setSelectedHour] = useState<number | null>(null);
|
const [selectedHour, setSelectedHour] = useState<number | null>(null);
|
||||||
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
@@ -200,6 +203,7 @@ const LiveLogs: React.FC = () => {
|
|||||||
<div className="search-container">
|
<div className="search-container">
|
||||||
<Search size={14} className="search-icon" />
|
<Search size={14} className="search-icon" />
|
||||||
<input
|
<input
|
||||||
|
ref={searchRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Query (e.g. decky:decky-03 service:ssh attacker:89.248)"
|
placeholder="Query (e.g. decky:decky-03 service:ssh attacker:89.248)"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
|
|||||||
59
decnet_web/src/components/ShortcutsHelp/ShortcutsHelp.css
Normal file
59
decnet_web/src/components/ShortcutsHelp/ShortcutsHelp.css
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
.shk-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
padding: 18px 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shk-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shk-title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: var(--dim-color);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shk-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shk-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shk-label {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shk-keys {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shk-keys kbd {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
77
decnet_web/src/components/ShortcutsHelp/ShortcutsHelp.tsx
Normal file
77
decnet_web/src/components/ShortcutsHelp/ShortcutsHelp.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Keyboard } from 'lucide-react';
|
||||||
|
import Modal from '../Modal/Modal';
|
||||||
|
import './ShortcutsHelp.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Binding {
|
||||||
|
keys: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLOBAL: Binding[] = [
|
||||||
|
{ keys: 'Alt+K', label: 'Open command palette' },
|
||||||
|
{ keys: '?', label: 'Show this cheatsheet' },
|
||||||
|
{ keys: '/', label: 'Focus page search' },
|
||||||
|
{ keys: 'Esc', label: 'Close modal / palette' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NAV: Binding[] = [
|
||||||
|
{ keys: 'G D', label: 'Dashboard' },
|
||||||
|
{ keys: 'G F', label: 'Decoy Fleet' },
|
||||||
|
{ keys: 'G M', label: 'MazeNET' },
|
||||||
|
{ keys: 'G L', label: 'Live Logs' },
|
||||||
|
{ keys: 'G B', label: 'Bounty Vault' },
|
||||||
|
{ keys: 'G A', label: 'Attackers' },
|
||||||
|
{ keys: 'G S', label: 'SWARM Hosts' },
|
||||||
|
{ keys: 'G U', label: 'Remote Updates' },
|
||||||
|
{ keys: 'G E', label: 'Agent Enrollment' },
|
||||||
|
{ keys: 'G C', label: 'Config' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PALETTE: Binding[] = [
|
||||||
|
{ keys: '↑ ↓', label: 'Navigate entries' },
|
||||||
|
{ keys: '⏎', label: 'Run selected entry' },
|
||||||
|
{ keys: 'Esc', label: 'Dismiss palette' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Group: React.FC<{ title: string; rows: Binding[] }> = ({ title, rows }) => (
|
||||||
|
<section className="shk-group">
|
||||||
|
<h4 className="shk-title">{title}</h4>
|
||||||
|
<div className="shk-rows">
|
||||||
|
{rows.map(r => (
|
||||||
|
<div className="shk-row" key={r.keys}>
|
||||||
|
<span className="shk-keys">
|
||||||
|
{r.keys.split(' ').map((k, i) => (
|
||||||
|
<kbd key={i}>{k}</kbd>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="shk-label">{r.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ShortcutsHelp: React.FC<Props> = ({ open, onClose }) => (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="KEYBOARD SHORTCUTS"
|
||||||
|
icon={Keyboard}
|
||||||
|
accent="violet"
|
||||||
|
width="wide"
|
||||||
|
>
|
||||||
|
<div className="modal-body shk-body">
|
||||||
|
<Group title="GLOBAL" rows={GLOBAL} />
|
||||||
|
<Group title="NAVIGATION (G-CHORD)" rows={NAV} />
|
||||||
|
<Group title="COMMAND PALETTE" rows={PALETTE} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ShortcutsHelp;
|
||||||
18
decnet_web/src/hooks/useFocusSearch.ts
Normal file
18
decnet_web/src/hooks/useFocusSearch.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, 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]);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
interface Options {
|
interface Options {
|
||||||
cmdOpen: boolean;
|
cmdOpen: boolean;
|
||||||
setCmdOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
|
setCmdOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
|
helpOpen: boolean;
|
||||||
|
setHelpOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const G_NAV: Record<string, string> = {
|
const G_NAV: Record<string, string> = {
|
||||||
@@ -27,7 +29,7 @@ function isEditable(el: EventTarget | null): boolean {
|
|||||||
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
|
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGlobalHotkeys({ cmdOpen, setCmdOpen }: Options): void {
|
export function useGlobalHotkeys({ cmdOpen, setCmdOpen, helpOpen, setHelpOpen }: Options): void {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const pendingG = useRef(false);
|
const pendingG = useRef(false);
|
||||||
const gTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const gTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -52,10 +54,26 @@ export function useGlobalHotkeys({ cmdOpen, setCmdOpen }: Options): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdOpen) return;
|
if (cmdOpen || helpOpen) return;
|
||||||
if (isEditable(e.target)) return;
|
if (isEditable(e.target)) return;
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) 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();
|
const k = e.key.toLowerCase();
|
||||||
|
|
||||||
if (pendingG.current && G_NAV[k]) {
|
if (pendingG.current && G_NAV[k]) {
|
||||||
@@ -77,5 +95,5 @@ export function useGlobalHotkeys({ cmdOpen, setCmdOpen }: Options): void {
|
|||||||
window.removeEventListener('keydown', onKey);
|
window.removeEventListener('keydown', onKey);
|
||||||
if (gTimer.current) clearTimeout(gTimer.current);
|
if (gTimer.current) clearTimeout(gTimer.current);
|
||||||
};
|
};
|
||||||
}, [cmdOpen, setCmdOpen, navigate]);
|
}, [cmdOpen, setCmdOpen, helpOpen, setHelpOpen, navigate]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user