diff --git a/CHANGELOG.md b/CHANGELOG.md index 383e78c4..e0b2c8e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,15 @@ its heading and collects entries. The date and the link go on with the tag. ### Changed +- **The desktop app stays responsive with large results.** It drew every + row of a result and redrew all of them on every keypress or scroll, so a + full 4,096-port scan took about a tenth of a second per arrow key, and a + 20,000-row result such as a long packet capture over half a second. It now + draws only the rows on screen, so 4,096 rows respond as fast as 100, and + 20,000 in about a twentieth of a second. Progress updates + from a running scan are also sent ten times a second instead of once per + port. + - **Windows lists the desktop app's publisher as Felix Stubner.** Installed apps showed "netscli", a default taken from the app's internal identifier, while the code signature, winget and the Microsoft Store listing all say diff --git a/apps/netscli-gui/src-tauri/src/commands/operations/mod.rs b/apps/netscli-gui/src-tauri/src/commands/operations/mod.rs index a0f60b98..2663b53b 100644 --- a/apps/netscli-gui/src-tauri/src/commands/operations/mod.rs +++ b/apps/netscli-gui/src-tauri/src/commands/operations/mod.rs @@ -38,7 +38,109 @@ pub(super) struct OperationProgressPayload { } pub(super) fn emit_operation_progress(app: &tauri::AppHandle, payload: OperationProgressPayload) { - let _ = app.emit(OPERATION_PROGRESS_EVENT, payload); + if PROGRESS_THROTTLE.should_send(&payload, std::time::Instant::now()) { + let _ = app.emit(OPERATION_PROGRESS_EVENT, payload); + } +} + +/// At most one progress event per operation every this often, plus the +/// first, the last, and every change of phase. +/// +/// The core reports every port and every host, and each report was one IPC +/// event and one React state update: 4,096 of them for a full port scan, most +/// arriving inside a second. Ten a second is as fast as a progress bar can be +/// read. +const PROGRESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100); + +static PROGRESS_THROTTLE: ProgressThrottle = ProgressThrottle::new(); + +/// When each operation last sent an event, and in which phase. +type LastSent = HashMap)>; + +struct ProgressThrottle(std::sync::Mutex>); + +impl ProgressThrottle { + const fn new() -> Self { + Self(std::sync::Mutex::new(None)) + } + + fn should_send(&self, payload: &OperationProgressPayload, now: std::time::Instant) -> bool { + let mut guard = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let last = guard.get_or_insert_with(HashMap::new); + let finished = payload.total > 0 && payload.completed >= payload.total; + let send = match last.get(&payload.op_id) { + None => true, + Some((at, phase)) => { + finished || *phase != payload.phase || now.duration_since(*at) >= PROGRESS_INTERVAL + } + }; + if finished { + // The operation is done, or this phase of it is; a later phase + // starts a new entry with its own first event. + last.remove(&payload.op_id); + } else if send { + last.insert(payload.op_id.clone(), (now, payload.phase)); + } + send + } +} + +#[cfg(test)] +mod progress_throttle_tests { + use super::*; + use std::time::{Duration, Instant}; + + fn event( + op: &str, + phase: &'static str, + completed: usize, + total: usize, + ) -> OperationProgressPayload { + OperationProgressPayload { + op_id: op.into(), + kind: "scan", + phase: Some(phase), + completed, + total, + found: 0, + target: None, + detail: None, + } + } + + #[test] + fn a_full_port_scan_inside_one_interval_sends_the_first_and_last_event_only() { + let throttle = ProgressThrottle::new(); + let start = Instant::now(); + let sent = (1..=4096) + .filter(|&n| throttle.should_send(&event("op", "scanning", n, 4096), start)) + .count(); + assert_eq!(sent, 2); + } + + #[test] + fn events_resume_after_the_interval_and_on_a_phase_change() { + let throttle = ProgressThrottle::new(); + let t = Instant::now(); + assert!(throttle.should_send(&event("op", "ping", 1, 254), t)); + assert!(!throttle.should_send(&event("op", "ping", 2, 254), t + Duration::from_millis(50))); + assert!(throttle.should_send(&event("op", "ping", 3, 254), t + Duration::from_millis(120))); + assert!(throttle.should_send( + &event("op", "resolve", 1, 10), + t + Duration::from_millis(121) + )); + } + + #[test] + fn operations_are_throttled_separately() { + let throttle = ProgressThrottle::new(); + let t = Instant::now(); + assert!(throttle.should_send(&event("a", "scanning", 1, 10), t)); + assert!(throttle.should_send(&event("b", "scanning", 1, 10), t)); + } } pub(super) fn ops_with_concurrency(max_concurrent: Option) -> Ops { diff --git a/apps/netscli-gui/src/App.css b/apps/netscli-gui/src/App.css index 471c0102..b28dc552 100644 --- a/apps/netscli-gui/src/App.css +++ b/apps/netscli-gui/src/App.css @@ -2,6 +2,7 @@ @import './styles/shell.css'; @import './styles/forms.css'; @import './styles/results.css'; +@import './styles/result-window.css'; @import './styles/error-boundary.css'; @import './styles/pcap.css'; @import './styles/details.css'; diff --git a/apps/netscli-gui/src/components/results/ResultTable.tsx b/apps/netscli-gui/src/components/results/ResultTable.tsx index f9e36094..816da335 100644 --- a/apps/netscli-gui/src/components/results/ResultTable.tsx +++ b/apps/netscli-gui/src/components/results/ResultTable.tsx @@ -1,5 +1,5 @@ import { Terminal } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'; import { renderValue } from '../../tools/presentation'; import type { ResultCellContext, ResultColumn, ResultRow, RowSelectionMode, WorkspaceTab } from '../../tools/types'; @@ -13,6 +13,7 @@ import { startColumnResize, updateOverflowState, } from './resultTableInteractions'; +import { DEFAULT_ROW_HEIGHT, rowWindow, scrollTopToShow } from './rowWindow'; import { StatusPill } from './StatusPill'; interface ResultTableProps { @@ -59,23 +60,58 @@ export function ResultTable({ [activeTab.selectedIndex, activeTab.selectedIndices], ); + // Only rows in view are rendered once a result is large; see rowWindow.ts. + const [viewport, setViewport] = useState({ scrollTop: 0, height: 600 }); + const [rowHeight, setRowHeight] = useState(DEFAULT_ROW_HEIGHT); + const visible = rowWindow(rows.length, viewport.scrollTop, viewport.height, rowHeight); + + // A scroll used to re-render the whole table through this state even when + // nothing about the overflow edges had changed. + const setOverflowIfChanged = useCallback((next: typeof overflow) => { + setOverflow((prev) => + (Object.keys(next) as (keyof typeof overflow)[]).every((key) => prev[key] === next[key]) ? prev : next, + ); + }, []); + + const onShellScroll = () => { + const shell = tableShellRef.current; + updateOverflowState(shell, setOverflowIfChanged); + if (shell) setViewport({ scrollTop: shell.scrollTop, height: shell.clientHeight }); + }; + useEffect(() => { - tableShellRef.current - ?.querySelector('tr.focused') - ?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + const shell = tableShellRef.current; + if (!shell) return; + const header = shell.querySelector('thead'); + const row = shell.querySelector('tbody tr[data-row]'); + const measured = row?.getBoundingClientRect().height; + if (measured && Math.abs(measured - rowHeight) > 0.5) setRowHeight(measured); + const target = scrollTopToShow( + activeTab.selectedIndex, + shell.scrollTop, + shell.clientHeight, + measured ?? rowHeight, + header?.getBoundingClientRect().height ?? 0, + ); + if (target !== null) shell.scrollTop = target; + // eslint-disable-next-line react-hooks/exhaustive-deps -- rowHeight is an output here, not an input }, [activeTab.selectedIndex, rows.length]); useEffect(() => { const shell = tableShellRef.current; - updateOverflowState(shell, setOverflow); - const frame = window.requestAnimationFrame(() => updateOverflowState(shell, setOverflow)); - const resizeObserver = shell ? new ResizeObserver(() => updateOverflowState(shell, setOverflow)) : null; + const measure = () => { + updateOverflowState(shell, setOverflowIfChanged); + if (shell) setViewport({ scrollTop: shell.scrollTop, height: shell.clientHeight }); + }; + measure(); + const frame = window.requestAnimationFrame(measure); + const resizeObserver = shell ? new ResizeObserver(measure) : null; if (shell) resizeObserver?.observe(shell); return () => { window.cancelAnimationFrame(frame); resizeObserver?.disconnect(); }; - }, [rows.length, activeTab.result]); + }, [rows.length, activeTab.result, setOverflowIfChanged]); if (!activeTab.result) { if (activeTab.kind === 'pcap' && !activeTab.result && pcapCapability && !pcapCapability.available) { @@ -104,7 +140,7 @@ export function ResultTable({ ].filter(Boolean).join(' ')} data-testid="result-table" ref={tableShellRef} - onScroll={() => updateOverflowState(tableShellRef.current, setOverflow)} + onScroll={onShellScroll} onContextMenu={onContentContextMenu} > {/* @@ -118,6 +154,7 @@ export function ResultTable({ className="result-table" role="grid" aria-multiselectable="true" + aria-rowcount={rows.length + 1} aria-activedescendant={rows.length > 0 ? `result-row-${activeTab.selectedIndex}` : undefined} tabIndex={0} onKeyDown={(event) => @@ -171,12 +208,20 @@ export function ResultTable({ - {rows.map((row, index) => { + {visible.before > 0 && ( + + + + )} + {rows.slice(visible.start, visible.end).map((row, offset) => { + const index = visible.start + offset; const selected = selectedIndices.has(index); const focused = index === activeTab.selectedIndex; return ( ); })} + {visible.after > 0 && ( + + + + )} {rows.length === 0 && ( diff --git a/apps/netscli-gui/src/components/results/rowWindow.test.ts b/apps/netscli-gui/src/components/results/rowWindow.test.ts new file mode 100644 index 00000000..531f294c --- /dev/null +++ b/apps/netscli-gui/src/components/results/rowWindow.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { OVERSCAN, rowWindow, scrollTopToShow, WINDOW_THRESHOLD } from './rowWindow'; + +describe('rowWindow', () => { + it('renders a small result whole', () => { + expect(rowWindow(WINDOW_THRESHOLD, 5000, 600, 31)).toEqual({ + start: 0, + end: WINDOW_THRESHOLD, + before: 0, + after: 0, + }); + }); + + it('renders only the rows in view plus the margin for a large result', () => { + const w = rowWindow(4096, 31 * 1000, 620, 31); + // Row 1000 is at the top of the view, 21 rows fit. + expect(w.start).toBe(1000 - OVERSCAN); + expect(w.end).toBe(1000 + 21 + OVERSCAN); + expect(w.end - w.start).toBeLessThan(80); + // The spacers keep the scrollbar the size of the whole result. + expect(w.before + (w.end - w.start) * 31 + w.after).toBe(4096 * 31); + }); + + it('clamps at both ends', () => { + expect(rowWindow(4096, 0, 620, 31).start).toBe(0); + const last = rowWindow(4096, 31 * 4096, 620, 31); + expect(last.end).toBe(4096); + expect(last.after).toBe(0); + }); +}); + +describe('scrollTopToShow', () => { + it('leaves a visible row alone', () => { + expect(scrollTopToShow(10, 0, 620, 31, 31)).toBeNull(); + }); + + it('scrolls up to a row above the view', () => { + expect(scrollTopToShow(5, 31 * 100, 620, 31, 31)).toBe(31 * 5); + }); + + it('scrolls down so a row below the view sits just above the bottom edge', () => { + // 589 px of body under a 31 px header; row 50 ends at 1581 px. + expect(scrollTopToShow(50, 0, 620, 31, 31)).toBe(31 * 51 - (620 - 31)); + }); +}); diff --git a/apps/netscli-gui/src/components/results/rowWindow.ts b/apps/netscli-gui/src/components/results/rowWindow.ts new file mode 100644 index 00000000..963d0945 --- /dev/null +++ b/apps/netscli-gui/src/components/results/rowWindow.ts @@ -0,0 +1,67 @@ +/** + * Which result rows to put in the DOM. + * + * The table rendered every row, and every selection change or scroll + * re-rendered all of them. Measured on the production build in headless + * Chrome (2026-10-06): one arrow-key press took a median 107 ms to paint at + * 4,096 rows (a full port scan) and 620 ms at 20,000 (a packet capture), + * and switching back to the tab took 0.9 s and 5 s. Rendering only the rows + * in view, plus a margin, makes that cost independent of the row count. + * + * Small results are rendered whole: below the threshold the saving is + * nothing and every row stays findable with the browser's own search. + */ + +export const WINDOW_THRESHOLD = 200; +/** Rows rendered beyond each edge of the viewport, so a fast scroll does not + * show blank space before the next render. */ +export const OVERSCAN = 20; +/** `.result-table td { height: 30px }` plus the 1px border. Measured from a + * rendered row where one exists; this is only the first guess. */ +export const DEFAULT_ROW_HEIGHT = 31; + +export interface RowWindow { + start: number; + end: number; + /** Height of the empty space standing in for the rows above `start`. */ + before: number; + /** Height of the empty space standing in for the rows from `end` on. */ + after: number; +} + +export function rowWindow( + count: number, + scrollTop: number, + viewportHeight: number, + rowHeight: number, + overscan = OVERSCAN, +): RowWindow { + if (count <= WINDOW_THRESHOLD || rowHeight <= 0) { + return { start: 0, end: count, before: 0, after: 0 }; + } + const first = Math.floor(scrollTop / rowHeight); + const visible = Math.ceil(viewportHeight / rowHeight) + 1; + const start = Math.max(0, first - overscan); + const end = Math.min(count, first + visible + overscan); + return { start, end, before: start * rowHeight, after: (count - end) * rowHeight }; +} + +/** + * Where to scroll so row `index` is fully visible below the sticky header, + * or `null` if it already is. Needed because a row outside the window is not + * in the DOM, so `scrollIntoView` has nothing to scroll to. + */ +export function scrollTopToShow( + index: number, + scrollTop: number, + viewportHeight: number, + rowHeight: number, + headerHeight: number, +): number | null { + const top = index * rowHeight; + const bottom = top + rowHeight; + const visibleBody = viewportHeight - headerHeight; + if (top < scrollTop) return top; + if (bottom > scrollTop + visibleBody) return bottom - visibleBody; + return null; +} diff --git a/apps/netscli-gui/src/styles/result-window.css b/apps/netscli-gui/src/styles/result-window.css new file mode 100644 index 00000000..cf745c70 --- /dev/null +++ b/apps/netscli-gui/src/styles/result-window.css @@ -0,0 +1,9 @@ +/* Stand-ins for the rows not rendered in a large result (rowWindow.ts). + Their height comes from an inline style; the cell rules in results.css + would otherwise give them a fixed height and a border. Its own file + because results.css is at its size cap. */ +.result-table tr.row-spacer td { + height: auto; + padding: 0; + border: 0; +}