From fcf11199af57f6da28936c1bdc8cc18ddf35c351 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Tue, 6 Oct 2026 01:37:37 +0100 Subject: [PATCH 1/2] Desktop: draw only the result rows on screen, and throttle progress events The results table rendered every row and re-rendered all of them on every selection change and scroll (the scroll handler set overflow state even when it had not changed). Production build in headless Chrome, 2 runs: rows arrow key (median) scroll step tab switch 4,096 107-117 ms -> 33 ms 59-65 -> 33 0.9 s -> 33 ms 20,000 620-660 ms -> 50-66 313-321 -> 33 4.7-5.5 s -> 100 ms 33 ms is the measurement floor (two frames). Above 200 rows the table now renders the rows in view plus 20 either side between two spacer rows (rowWindow.ts), scrolls a focused row into view itself since an unrendered row cannot scrollIntoView, and sets aria-rowcount and aria-rowindex so assistive tech still counts every row. Overflow state is only set when an edge actually changes. Checked on 4,096 rows: the middle of the result renders without gaps, and End lands on the last row, on screen, with aria-activedescendant pointing at it. Desktop progress events: one per port or host was one IPC event and one React update each, 4,096 for a full port scan. The backend now sends at most one per operation per 100 ms, plus the first, the last and each phase change. --- CHANGELOG.md | 9 ++ .../src-tauri/src/commands/operations/mod.rs | 104 +++++++++++++++++- .../src/components/results/ResultTable.tsx | 70 ++++++++++-- .../src/components/results/rowWindow.test.ts | 46 ++++++++ .../src/components/results/rowWindow.ts | 67 +++++++++++ apps/netscli-gui/src/styles/results.css | 9 ++ 6 files changed, 294 insertions(+), 11 deletions(-) create mode 100644 apps/netscli-gui/src/components/results/rowWindow.test.ts create mode 100644 apps/netscli-gui/src/components/results/rowWindow.ts 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/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/results.css b/apps/netscli-gui/src/styles/results.css index 32c22b61..554422ec 100644 --- a/apps/netscli-gui/src/styles/results.css +++ b/apps/netscli-gui/src/styles/results.css @@ -200,6 +200,15 @@ text-align: left; } +/* Stand-ins for the rows not rendered in a large result (rowWindow.ts). + Their height comes from an inline style; the cell rules above would + otherwise give them a fixed height and a border. */ +.result-table tr.row-spacer td { + height: auto; + padding: 0; + border: 0; +} + .result-table th { position: relative; color: var(--text-secondary); From 85e6bec29977f9d577b17b3efb5bd016bb2fb4ec Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Tue, 6 Oct 2026 01:38:08 +0100 Subject: [PATCH 2/2] Spacer-row CSS in its own file; results.css is at its size cap --- apps/netscli-gui/src/App.css | 1 + apps/netscli-gui/src/styles/result-window.css | 9 +++++++++ apps/netscli-gui/src/styles/results.css | 9 --------- 3 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 apps/netscli-gui/src/styles/result-window.css 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/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; +} diff --git a/apps/netscli-gui/src/styles/results.css b/apps/netscli-gui/src/styles/results.css index 554422ec..32c22b61 100644 --- a/apps/netscli-gui/src/styles/results.css +++ b/apps/netscli-gui/src/styles/results.css @@ -200,15 +200,6 @@ text-align: left; } -/* Stand-ins for the rows not rendered in a large result (rowWindow.ts). - Their height comes from an inline style; the cell rules above would - otherwise give them a fixed height and a border. */ -.result-table tr.row-spacer td { - height: auto; - padding: 0; - border: 0; -} - .result-table th { position: relative; color: var(--text-secondary);