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:
2026-04-22 17:25:32 -04:00
parent ecb813ad38
commit 4d1e6c0838
9 changed files with 196 additions and 7 deletions

View File

@@ -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 { Search, ChevronLeft, ChevronRight, Users } from 'lucide-react';
import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import { useFocusSearch } from '../hooks/useFocusSearch';
import './Dashboard.css';
import './Attackers.css';
@@ -63,6 +64,8 @@ const Attackers: React.FC = () => {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState(query);
const searchRef = useRef<HTMLInputElement | null>(null);
useFocusSearch(searchRef);
const limit = 50;
@@ -122,6 +125,7 @@ const Attackers: React.FC = () => {
<div className="search-container">
<Search size={14} className="search-icon" />
<input
ref={searchRef}
type="text"
placeholder="Search by IP..."
value={searchInput}

View File

@@ -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 {
Archive, Search, ChevronLeft, ChevronRight, Filter, Key, Package, ChevronRight as ChevR,
@@ -7,6 +7,7 @@ import {
import api from '../utils/api';
import BountyInspector from './BountyInspector';
import EmptyState from './EmptyState/EmptyState';
import { useFocusSearch } from '../hooks/useFocusSearch';
import './Dashboard.css';
import './Bounty.css';
@@ -57,6 +58,8 @@ const Bounty: React.FC = () => {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState(query);
const searchRef = useRef<HTMLInputElement | null>(null);
useFocusSearch(searchRef);
const [selected, setSelected] = useState<BountyEntry | null>(null);
const limit = 50;
@@ -118,6 +121,7 @@ const Bounty: React.FC = () => {
<div className="search-container">
<Search size={14} className="search-icon" />
<input
ref={searchRef}
type="text"
placeholder="Filter by IP, decky, payload..."
value={searchInput}

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
LayoutDashboard, Server, Network, Terminal, Archive, Crosshair,
PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, UserPlus, Settings,
SearchX,
SearchX, Keyboard,
} from 'lucide-react';
import EmptyState from '../EmptyState/EmptyState';
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: '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: 'Show keyboard shortcuts', icon: Keyboard, kbd: '?', kind: 'action', payload: 'shortcuts-help' },
];
interface Props {

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState, useRef, useMemo } from 'react';
import { useFocusSearch } from '../hooks/useFocusSearch';
import { useSearchParams } from 'react-router-dom';
import {
Terminal, Search, BarChart3, ChevronLeft, ChevronRight,
@@ -39,6 +40,8 @@ const LiveLogs: React.FC = () => {
const [loading, setLoading] = useState(true);
const [streaming, setStreaming] = useState(isLive);
const [searchInput, setSearchInput] = useState(query);
const searchRef = useRef<HTMLInputElement | null>(null);
useFocusSearch(searchRef);
const [selectedHour, setSelectedHour] = useState<number | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
@@ -200,6 +203,7 @@ const LiveLogs: React.FC = () => {
<div className="search-container">
<Search size={14} className="search-icon" />
<input
ref={searchRef}
type="text"
placeholder="Query (e.g. decky:decky-03 service:ssh attacker:89.248)"
value={searchInput}

View 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;
}

View 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;