mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1531e46640 | ||
|
|
c161eb6b5f |
No files matched your search
@@ -0,0 +1,31 @@
|
||||
# Vault host search performance
|
||||
|
||||
Issue #3720 covers a large-library input stall already present in v1.1.83. It is separate from the snippet argument-completion repair in #3717. The recent IP sort is not the cause: A–Z sorting reproduces the same delay.
|
||||
|
||||
## Cause and scope
|
||||
|
||||
`getHostSearchMatch` evaluates matching/ranking against host label, group, tags and hostname. After literal and compact comparisons fail, human fields may also use pinyin. Printable ASCII fields cannot gain another match from that fallback, but repeated queries still performed its conversion/cache operations for thousands of hosts.
|
||||
|
||||
The repair only skips that redundant host-field fallback. Literal and compact matching remain ahead of it; source preference, phases, scores, Chinese/mixed-language matching, IP isolation, usernames/notes fallback, sorting and rendered fields are unchanged. The generic search and snippet matchers are not modified.
|
||||
|
||||
## Reproduction and verification
|
||||
|
||||
```sh
|
||||
node_modules/.bin/electron scripts/vault-host-search-performance.live.test.cjs
|
||||
```
|
||||
|
||||
The isolated Electron fixture renders production `useVaultHostCollections` and `VirtualizedHostCollection` with 5,000 synthetic hosts across 100 groups. It changes the actual controlled input through React `onChange`, repeats 63 input events over three rounds, measures render/input-task delay and compares result counts/order fingerprints. Only 15 host rows are mounted. It does not access user hosts, SSH servers or settings.
|
||||
|
||||
The real input regression was observed failing before the source change: warmed `Region 99` events delayed the next input turn by 100.2 and 89.6 ms. After the repair they took 24.2 and 23.2 ms. The standalone measurement uses a 50 ms warmed-input gate; slower test machines can set `NETCATTY_VAULT_SEARCH_MAX_INPUT_MS` explicitly. It is skipped under ordinary Node tests, where real renderer timings are unavailable.
|
||||
|
||||
| Query | Before render task | After render task | Matches |
|
||||
| --- | --- | --- | --- |
|
||||
| `production web` | 59.4–62.8 ms | 17.8–21.2 ms | 5,000 |
|
||||
| `Region 99` | 89.1–99.5 ms | 20.2–23.6 ms | 95 |
|
||||
| `no-match-query` | 36.4–90.4 ms | 13.8–17.1 ms | 0 |
|
||||
|
||||
All 63 before/after input events retained the same result count and complete-order fingerprint. `Region 99` returning 95 hosts is existing multi-segment matching behavior; this repair does not reinterpret it as a group-only filter.
|
||||
|
||||
Separate four-round diagnosis compared current source, v1.1.83 source and A–Z/IP sorting. Release source reproduced 56.4–59.2 ms for `production web` and 90.5–101.4 ms for `Region 99`, confirming an existing bottleneck rather than a newly introduced regression. These are representative local renderer measurements, not a guarantee for every host library or device.
|
||||
|
||||
Semantic tests cover ASCII separator compaction and punctuation, fullwidth normalization, direct IP matches and cross-field isolation, strict source preference, Chinese/mixed-language pinyin, and complete matching phase/method/score details. They also pass against the original implementation.
|
||||
@@ -276,6 +276,53 @@ test("pinyin cache stays hard-bounded by retained characters, not just entries",
|
||||
resetPinyinCacheForTests();
|
||||
});
|
||||
|
||||
test("host ASCII fallback preserves compact matches and their complete ranking details", () => {
|
||||
const separators = ["-", "_", " ", "/", "\\", "|", ".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "<", ">", '"', "'", "`", "~"];
|
||||
for (const separator of separators) {
|
||||
assert.deepEqual(getHostSearchMatch("prefixsuffix", { label: `prefix${separator}suffix` }), {
|
||||
matched: true, phase: "loose", score: 100525,
|
||||
details: [{ segment: "prefixsuffix", field: "label", method: "compact", phase: "loose" }],
|
||||
}, JSON.stringify(separator));
|
||||
}
|
||||
for (const separator of ["+", "&", "@", "=", "%", "$", "#", "^"]) {
|
||||
assert.deepEqual(getHostSearchMatch("prefixsuffix", { label: `prefix${separator}suffix` }), {
|
||||
matched: false, phase: "none", score: 0, details: [],
|
||||
}, JSON.stringify(separator));
|
||||
}
|
||||
assert.deepEqual(getHostSearchMatch("PRODAPI", { label: "PROD-API" }), {
|
||||
matched: true, phase: "loose", score: 100525,
|
||||
details: [{ segment: "prodapi", field: "label", method: "compact", phase: "loose" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("host ASCII bypass preserves strict source selection, IP isolation, and pinyin scoring", () => {
|
||||
const host = { label: "Production API-01", group: "Region 99", hostname: "10.6.1.88", tags: ["production", "web", "核心"] };
|
||||
assert.deepEqual(getHostSearchMatch("production web", host), {
|
||||
matched: true, phase: "strict", score: 1000820,
|
||||
details: [
|
||||
{ segment: "production", field: "label", method: "literal", phase: "strict" },
|
||||
{ segment: "web", field: "tag", method: "literal", phase: "strict" },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(getHostSearchMatch("10.6.1.88", host), {
|
||||
matched: true, phase: "strict", score: 1000430,
|
||||
details: [{ segment: "10.6.1.88", field: "hostname", method: "literal", phase: "strict" }],
|
||||
});
|
||||
for (const query of ["61", "production 10.6"]) {
|
||||
assert.deepEqual(getHostSearchMatch(query, host), { matched: false, phase: "none", score: 0, details: [] });
|
||||
}
|
||||
for (const [query, method, score] of [["hexin", "pinyinFull", 100232], ["hx", "pinyinInitials", 100228]] as const) {
|
||||
assert.deepEqual(getHostSearchMatch(query, host), {
|
||||
matched: true, phase: "loose", score,
|
||||
details: [{ segment: query, field: "tag", method, phase: "loose" }],
|
||||
});
|
||||
}
|
||||
assert.deepEqual(getHostSearchMatch("apibu", { label: "API部署" }), {
|
||||
matched: true, phase: "loose", score: 100512,
|
||||
details: [{ segment: "apibu", field: "label", method: "pinyinFull", phase: "loose" }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("word prefixes retain literal, normalized, and non-ASCII matching", () => {
|
||||
assert.equal(matchesWordPrefixQuery("", "deploy"), true);
|
||||
|
||||
@@ -454,6 +454,9 @@ function matchSegmentAgainstSource(
|
||||
}
|
||||
|
||||
if (!source.allowPinyin || !/[a-z]/i.test(segment)) return null;
|
||||
// Literal and compact checks already cover printable ASCII host fields.
|
||||
// Keep their ranking above; skip only redundant transliteration/cache work.
|
||||
if (/^[\x20-\x7E]+$/.test(source.text)) return null;
|
||||
|
||||
const { full, initials } = getPinyinVariants(source.text);
|
||||
if (full && full.includes(compactSegment)) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useVaultHostCollections } from "../../components/vault/useVaultHostCollections";
|
||||
import { VirtualizedHostCollection } from "../../components/vault/VirtualizedHostCollection";
|
||||
import type { GroupConfig, Host, KnownHost } from "../../types";
|
||||
|
||||
const hosts: Host[] = Array.from({ length: 5000 }, (_, index) => ({
|
||||
id: `host-${index}`,
|
||||
label: `Production Host ${index}`,
|
||||
hostname: `10.${(index >> 16) % 256}.${(index >> 8) % 256}.${index % 256}`,
|
||||
username: "fixture",
|
||||
notes: "Deployment host",
|
||||
tags: ["production", "web"],
|
||||
group: `Region ${index % 100}`,
|
||||
port: 22,
|
||||
os: "linux",
|
||||
createdAt: 1,
|
||||
}));
|
||||
const groups = Array.from({ length: 100 }, (_, index) => `Region ${index}`);
|
||||
const groupConfigs: GroupConfig[] = [];
|
||||
const knownHosts: KnownHost[] = [];
|
||||
const selectedTags: string[] = [];
|
||||
const noop = () => {};
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let current: ReturnType<typeof useVaultHostCollections>;
|
||||
|
||||
function VaultSearchFixture() {
|
||||
const [search, setSearch] = useState("");
|
||||
current = useVaultHostCollections({
|
||||
hosts, customGroups: groups, groupConfigs, knownHosts,
|
||||
onConvertKnownHost: noop, onUpdateHosts: noop, onUpdateKnownHosts: noop,
|
||||
search, selectedGroupPath: null, selectedTags,
|
||||
showOnlyUngroupedHostsInRoot: false, showRecentHosts: false,
|
||||
sortMode: "ip", viewMode: "list",
|
||||
});
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
return <>
|
||||
<label>Search hosts <input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
|
||||
<output>{search}</output>
|
||||
<div ref={scrollRef} style={{ height: 650, width: 1200, overflow: "auto" }}>
|
||||
<VirtualizedHostCollection
|
||||
items={current.displayedHosts}
|
||||
itemKey={(host) => host.id}
|
||||
renderItem={(host) => <button data-host-id={host.id}>{host.label}</button>}
|
||||
scrollRef={scrollRef}
|
||||
viewMode="list"
|
||||
/>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
export async function runVaultSearchPerformance() {
|
||||
const rootNode = document.getElementById("root");
|
||||
if (!rootNode) throw new Error("Missing fixture root");
|
||||
const root = createRoot(rootNode);
|
||||
flushSync(() => root.render(<VaultSearchFixture />));
|
||||
await wait(100);
|
||||
const input = rootNode.querySelector("input");
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
if (!input || !setter) throw new Error("Missing search input");
|
||||
const rows = [];
|
||||
const queries = ["", "P", "Pr", "Pro", "Prod", "production", "production w", "production we", "production web", "", "R", "Re", "Reg", "Region", "Region 9", "Region 99", "", "no", "no-m", "no-match", "no-match-query"];
|
||||
for (let round = 0; round < 3; round++) {
|
||||
for (const query of queries) {
|
||||
let resolveInput!: (ms: number) => void;
|
||||
const started = performance.now();
|
||||
const nextInputTurn = new Promise<number>((resolve) => { resolveInput = resolve; });
|
||||
setTimeout(() => resolveInput(performance.now() - started), 0);
|
||||
flushSync(() => {
|
||||
setter.call(input, query);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
const renderMs = performance.now() - started;
|
||||
const inputDelayMs = await nextInputTurn;
|
||||
const ids = current.displayedHosts.map((host) => host.id).join(",");
|
||||
// A compact fingerprint lets before/after runs compare complete order,
|
||||
// without recording thousands of host IDs for every input event.
|
||||
let orderHash = 0;
|
||||
for (const char of ids) orderHash = ((orderHash << 5) - orderHash + char.charCodeAt(0)) | 0;
|
||||
rows.push({ round, query, renderMs, inputDelayMs, orderHash,
|
||||
matches: current.displayedHosts.length,
|
||||
actualInput: rootNode.querySelector("output")?.textContent,
|
||||
mountedHosts: rootNode.querySelectorAll("[data-host-id]").length });
|
||||
await wait(30);
|
||||
}
|
||||
}
|
||||
root.unmount();
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
/* global process, __dirname, console */
|
||||
|
||||
if (!process.versions.electron) {
|
||||
require("node:test")("large Vault searches keep input responsive", {
|
||||
skip: "run with Electron for actual React input and virtualized host rendering",
|
||||
}, () => {});
|
||||
} else {
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const esbuild = require("esbuild");
|
||||
const temp = require("../electron/bridges/tempDirBridge.cjs");
|
||||
const userData = fs.mkdtempSync(`${temp.getTempFilePath("vault-host-search-perf")}-`);
|
||||
const maximumInputMs = Number(process.env.NETCATTY_VAULT_SEARCH_MAX_INPUT_MS || "50");
|
||||
app.setPath("userData", userData);
|
||||
app.on("window-all-closed", () => {});
|
||||
let window;
|
||||
const cleanup = (code) => {
|
||||
window?.destroy();
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
app.exit(code);
|
||||
};
|
||||
void app.whenReady().then(async () => {
|
||||
const bundle = esbuild.buildSync({
|
||||
entryPoints: [path.join(__dirname, "fixtures/vault-search-performance.tsx")],
|
||||
bundle: true, format: "cjs", platform: "browser", write: false,
|
||||
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}", "process.env.NODE_ENV": '"production"' },
|
||||
}).outputFiles[0].text;
|
||||
window = new BrowserWindow({
|
||||
show: process.env.NETCATTY_VAULT_SEARCH_SHOW_WINDOW === "1",
|
||||
width: 1280, height: 800,
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false },
|
||||
});
|
||||
await window.loadURL("data:text/html,<body><div id='root'></div>");
|
||||
const rows = await window.webContents.executeJavaScript(`(async () => {
|
||||
const loaded = { exports: {} };
|
||||
((module, exports) => { ${bundle} })(loaded, loaded.exports);
|
||||
return loaded.exports.runVaultSearchPerformance();
|
||||
})()`);
|
||||
console.log("VAULT_HOST_SEARCH_MEASUREMENTS", JSON.stringify(rows));
|
||||
assert.ok(rows.every(row => row.actualInput === row.query), "drive actual onChange updates");
|
||||
assert.ok(rows.every(row => row.mountedHosts <= 20), "retain a bounded rendered list");
|
||||
const expectedMatches = { "production web": 5000, "Region 99": 95, "no-match-query": 0 };
|
||||
for (const [query, count] of Object.entries(expectedMatches)) {
|
||||
const samples = rows.filter(row => row.query === query);
|
||||
assert.ok(samples.every(row => row.matches === count), query);
|
||||
assert.equal(new Set(samples.map(row => row.orderHash)).size, 1, "stable matching order");
|
||||
}
|
||||
const warmRegion = rows.filter(row => row.round > 0 && row.query === "Region 99");
|
||||
assert.ok(warmRegion.every(row => row.inputDelayMs < maximumInputMs),
|
||||
`Vault search input exceeded ${maximumInputMs}ms: ${JSON.stringify(warmRegion)}`);
|
||||
console.log("VAULT_HOST_SEARCH_PERFORMANCE_OK");
|
||||
cleanup(0);
|
||||
}).catch(error => { console.error(error); cleanup(1); });
|
||||
}
|
||||
Reference in new issue
Block a user