Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 103 additions & 1 deletion apps/netscli-gui/src-tauri/src/commands/operations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, (std::time::Instant, Option<&'static str>)>;

struct ProgressThrottle(std::sync::Mutex<Option<LastSent>>);

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<usize>) -> Ops {
Expand Down
1 change: 1 addition & 0 deletions apps/netscli-gui/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
70 changes: 60 additions & 10 deletions apps/netscli-gui/src/components/results/ResultTable.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,6 +13,7 @@ import {
startColumnResize,
updateOverflowState,
} from './resultTableInteractions';
import { DEFAULT_ROW_HEIGHT, rowWindow, scrollTopToShow } from './rowWindow';
import { StatusPill } from './StatusPill';

interface ResultTableProps {
Expand Down Expand Up @@ -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<HTMLTableRowElement>('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) {
Expand Down Expand Up @@ -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}
>
{/*
Expand All @@ -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) =>
Expand Down Expand Up @@ -171,12 +208,20 @@ export function ResultTable({
</tr>
</thead>
<tbody>
{rows.map((row, index) => {
{visible.before > 0 && (
<tr aria-hidden="true" className="row-spacer" style={{ height: visible.before }}>
<td colSpan={effectiveColumns.length} />
</tr>
)}
{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 (
<tr
aria-selected={selected}
aria-rowindex={index + 2}
data-row
className={[selected ? 'selected' : '', focused ? 'focused' : ''].filter(Boolean).join(' ')}
data-testid={`result-row-${row.data.port ?? row.id}`}
// Referenced by the grid's aria-activedescendant so arrow-key
Expand Down Expand Up @@ -210,6 +255,11 @@ export function ResultTable({
</tr>
);
})}
{visible.after > 0 && (
<tr aria-hidden="true" className="row-spacer" style={{ height: visible.after }}>
<td colSpan={effectiveColumns.length} />
</tr>
)}
</tbody>
</table>
{rows.length === 0 && (
Expand Down
46 changes: 46 additions & 0 deletions apps/netscli-gui/src/components/results/rowWindow.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
67 changes: 67 additions & 0 deletions apps/netscli-gui/src/components/results/rowWindow.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 9 additions & 0 deletions apps/netscli-gui/src/styles/result-window.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading