@tikoci/rosetta 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/db.ts +21 -6
- package/src/mcp-http.test.ts +4 -4
- package/src/mcp.ts +112 -0
- package/src/query.test.ts +109 -3
- package/src/query.ts +111 -12
package/package.json
CHANGED
package/src/db.ts
CHANGED
|
@@ -345,10 +345,6 @@ export function initDb() {
|
|
|
345
345
|
export function getDbStats() {
|
|
346
346
|
const count = (sql: string) =>
|
|
347
347
|
Number((db.prepare(sql).get() as { c: number }).c ?? 0);
|
|
348
|
-
const scalar = (sql: string) => {
|
|
349
|
-
const row = db.prepare(sql).get() as { v: string | null } | null;
|
|
350
|
-
return row?.v ?? null;
|
|
351
|
-
};
|
|
352
348
|
return {
|
|
353
349
|
db_path: DB_PATH,
|
|
354
350
|
pages: count("SELECT COUNT(*) AS c FROM pages"),
|
|
@@ -363,8 +359,27 @@ export function getDbStats() {
|
|
|
363
359
|
changelogs: count("SELECT COUNT(*) AS c FROM changelogs"),
|
|
364
360
|
changelog_versions: count("SELECT COUNT(DISTINCT version) AS c FROM changelogs"),
|
|
365
361
|
ros_versions: count("SELECT COUNT(*) AS c FROM ros_versions"),
|
|
366
|
-
|
|
367
|
-
|
|
362
|
+
...(() => {
|
|
363
|
+
// Semantic version sort — SQL MIN/MAX is lexicographic ("7.10" < "7.9")
|
|
364
|
+
const versions = (db.prepare("SELECT version FROM ros_versions").all() as Array<{ version: string }>).map((r) => r.version);
|
|
365
|
+
if (versions.length === 0) return { ros_version_min: null, ros_version_max: null };
|
|
366
|
+
const norm = (v: string) => {
|
|
367
|
+
const clean = v.replace(/beta\d*/, "").replace(/rc\d*/, "");
|
|
368
|
+
const parts = clean.split(".").map(Number);
|
|
369
|
+
const suffix = v.includes("beta") ? 0 : v.includes("rc") ? 1 : 2;
|
|
370
|
+
return { parts, suffix };
|
|
371
|
+
};
|
|
372
|
+
const cmp = (a: string, b: string) => {
|
|
373
|
+
const na = norm(a), nb = norm(b);
|
|
374
|
+
for (let i = 0; i < Math.max(na.parts.length, nb.parts.length); i++) {
|
|
375
|
+
const d = (na.parts[i] ?? 0) - (nb.parts[i] ?? 0);
|
|
376
|
+
if (d !== 0) return d;
|
|
377
|
+
}
|
|
378
|
+
return na.suffix - nb.suffix;
|
|
379
|
+
};
|
|
380
|
+
versions.sort(cmp);
|
|
381
|
+
return { ros_version_min: versions[0], ros_version_max: versions[versions.length - 1] };
|
|
382
|
+
})(),
|
|
368
383
|
doc_export: "2026-03-25 (Confluence HTML)",
|
|
369
384
|
};
|
|
370
385
|
}
|
package/src/mcp-http.test.ts
CHANGED
|
@@ -212,7 +212,7 @@ describe("HTTP transport: session lifecycle", () => {
|
|
|
212
212
|
|
|
213
213
|
const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
|
|
214
214
|
const tools = result.tools as Array<{ name: string }>;
|
|
215
|
-
expect(tools.length).toBe(
|
|
215
|
+
expect(tools.length).toBe(12);
|
|
216
216
|
|
|
217
217
|
const toolNames = tools.map((t) => t.name).sort();
|
|
218
218
|
expect(toolNames).toContain("routeros_search");
|
|
@@ -402,8 +402,8 @@ describe("HTTP transport: multi-session", () => {
|
|
|
402
402
|
const tools1 = ((msgs1[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
|
|
403
403
|
const tools2 = ((msgs2[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
|
|
404
404
|
|
|
405
|
-
expect(tools1.length).toBe(
|
|
406
|
-
expect(tools2.length).toBe(
|
|
405
|
+
expect(tools1.length).toBe(12);
|
|
406
|
+
expect(tools2.length).toBe(12);
|
|
407
407
|
});
|
|
408
408
|
|
|
409
409
|
test("deleting one session does not affect another", async () => {
|
|
@@ -425,7 +425,7 @@ describe("HTTP transport: multi-session", () => {
|
|
|
425
425
|
// Client2 still works
|
|
426
426
|
const msgs = await mcpRequest(server.url, client2.sessionId, "tools/list", 2);
|
|
427
427
|
const tools = ((msgs[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
|
|
428
|
-
expect(tools.length).toBe(
|
|
428
|
+
expect(tools.length).toBe(12);
|
|
429
429
|
|
|
430
430
|
// Client1 is gone
|
|
431
431
|
const resp = await fetch(server.url, {
|
package/src/mcp.ts
CHANGED
|
@@ -138,6 +138,8 @@ const {
|
|
|
138
138
|
searchCallouts,
|
|
139
139
|
searchChangelogs,
|
|
140
140
|
searchDevices,
|
|
141
|
+
searchDeviceTests,
|
|
142
|
+
getTestResultMeta,
|
|
141
143
|
searchPages,
|
|
142
144
|
searchProperties,
|
|
143
145
|
} = await import("./query.ts");
|
|
@@ -672,6 +674,7 @@ Shows bus topology and per-port bandwidth limits — useful for understanding So
|
|
|
672
674
|
- SMIPS: lowest-end (hAP lite)
|
|
673
675
|
|
|
674
676
|
Workflow — combine with other tools:
|
|
677
|
+
→ routeros_search_tests: cross-device performance ranking (all 125 devices at once, e.g., 512B routing benchmark)
|
|
675
678
|
→ routeros_search: find documentation for features relevant to a device
|
|
676
679
|
→ routeros_command_tree: check commands available for a feature
|
|
677
680
|
→ routeros_current_versions: check latest firmware for the device
|
|
@@ -757,6 +760,115 @@ Data: 144 products, March 2026 snapshot. Not all MikroTik products ever made —
|
|
|
757
760
|
},
|
|
758
761
|
);
|
|
759
762
|
|
|
763
|
+
// ---- routeros_search_tests ----
|
|
764
|
+
|
|
765
|
+
server.registerTool(
|
|
766
|
+
"routeros_search_tests",
|
|
767
|
+
{
|
|
768
|
+
description: `Query device performance test results across all devices.
|
|
769
|
+
|
|
770
|
+
Returns throughput benchmarks from mikrotik.com product pages — one call replaces
|
|
771
|
+
what would otherwise require 125+ individual device lookups.
|
|
772
|
+
|
|
773
|
+
**Data:** 2,874 test results across 125 devices (March 2026).
|
|
774
|
+
- Ethernet: bridging/routing throughput at 64/512/1518 byte packets
|
|
775
|
+
- IPSec: tunnel throughput with AES/SHA cipher configurations
|
|
776
|
+
- Results include kpps (packets/sec) and Mbps
|
|
777
|
+
|
|
778
|
+
**Common queries:**
|
|
779
|
+
- Routing performance ranking: test_type="ethernet", mode="Routing", configuration="25 ip filter rules", packet_size=512
|
|
780
|
+
- Bridge performance: test_type="ethernet", mode="Bridging", configuration="25 bridge filter"
|
|
781
|
+
- IPSec throughput: test_type="ipsec", mode="Single tunnel", configuration="AES-128-CBC"
|
|
782
|
+
|
|
783
|
+
**Configuration matching:** Uses LIKE (substring) — "25 ip filter" matches "25 ip filter rules".
|
|
784
|
+
Note: some devices use slightly different names (e.g., "25 bridge filter" vs "25 bridge filter rules").
|
|
785
|
+
|
|
786
|
+
**Tip:** Call with no filters first to see available test_types, modes, configurations, and packet_sizes via the metadata field.
|
|
787
|
+
|
|
788
|
+
Workflow:
|
|
789
|
+
→ routeros_device_lookup: get full specs + block diagram for a specific device from results
|
|
790
|
+
→ routeros_search: find documentation about features relevant to the test type`,
|
|
791
|
+
inputSchema: {
|
|
792
|
+
test_type: z
|
|
793
|
+
.string()
|
|
794
|
+
.optional()
|
|
795
|
+
.describe("Filter: 'ethernet' or 'ipsec'"),
|
|
796
|
+
mode: z
|
|
797
|
+
.string()
|
|
798
|
+
.optional()
|
|
799
|
+
.describe("Filter: e.g., 'Routing', 'Bridging', 'Single tunnel', '256 tunnels'"),
|
|
800
|
+
configuration: z
|
|
801
|
+
.string()
|
|
802
|
+
.optional()
|
|
803
|
+
.describe("Filter (substring match): e.g., '25 ip filter rules', 'AES-128-CBC + SHA1', 'none (fast path)'"),
|
|
804
|
+
packet_size: z
|
|
805
|
+
.number()
|
|
806
|
+
.int()
|
|
807
|
+
.optional()
|
|
808
|
+
.describe("Filter: packet size in bytes (64, 512, 1400, 1518)"),
|
|
809
|
+
sort_by: z
|
|
810
|
+
.enum(["mbps", "kpps"])
|
|
811
|
+
.optional()
|
|
812
|
+
.default("mbps")
|
|
813
|
+
.describe("Sort results by throughput metric (default: mbps)"),
|
|
814
|
+
limit: z
|
|
815
|
+
.number()
|
|
816
|
+
.int()
|
|
817
|
+
.min(1)
|
|
818
|
+
.max(200)
|
|
819
|
+
.optional()
|
|
820
|
+
.default(50)
|
|
821
|
+
.describe("Max results (default 50, max 200)"),
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
async ({ test_type, mode, configuration, packet_size, sort_by, limit }) => {
|
|
825
|
+
const hasFilters = test_type || mode || configuration || packet_size;
|
|
826
|
+
|
|
827
|
+
if (!hasFilters) {
|
|
828
|
+
// Discovery mode: return available filter values
|
|
829
|
+
const meta = getTestResultMeta();
|
|
830
|
+
return {
|
|
831
|
+
content: [{
|
|
832
|
+
type: "text",
|
|
833
|
+
text: JSON.stringify({
|
|
834
|
+
message: "No filters provided. Here are the available values — use these to build your query:",
|
|
835
|
+
...meta,
|
|
836
|
+
hint: "Common query: test_type='ethernet', mode='Routing', configuration='25 ip filter rules', packet_size=512",
|
|
837
|
+
}, null, 2),
|
|
838
|
+
}],
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const result = searchDeviceTests(
|
|
843
|
+
{ test_type, mode, configuration, packet_size, sort_by },
|
|
844
|
+
limit,
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
if (result.results.length === 0) {
|
|
848
|
+
const hints = [
|
|
849
|
+
"Call with no filters to see available test types, modes, and configurations",
|
|
850
|
+
configuration ? `Try a shorter configuration substring (e.g., "25 ip filter" instead of the full string)` : null,
|
|
851
|
+
].filter(Boolean);
|
|
852
|
+
return {
|
|
853
|
+
content: [{
|
|
854
|
+
type: "text",
|
|
855
|
+
text: `No test results matched the filters.\n\nTry:\n${hints.map((h) => `- ${h}`).join("\n")}`,
|
|
856
|
+
}],
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return {
|
|
861
|
+
content: [{
|
|
862
|
+
type: "text",
|
|
863
|
+
text: JSON.stringify({
|
|
864
|
+
...result,
|
|
865
|
+
has_more: result.total > result.results.length,
|
|
866
|
+
}, null, 2),
|
|
867
|
+
}],
|
|
868
|
+
};
|
|
869
|
+
},
|
|
870
|
+
);
|
|
871
|
+
|
|
760
872
|
// ---- routeros_current_versions ----
|
|
761
873
|
|
|
762
874
|
server.registerTool(
|
package/src/query.test.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { beforeAll, describe, expect, test } from "bun:test";
|
|
|
13
13
|
process.env.DB_PATH = ":memory:";
|
|
14
14
|
|
|
15
15
|
// Dynamic imports so the env-var assignment above is visible to db.ts
|
|
16
|
-
const { db, initDb } = await import("./db.ts");
|
|
16
|
+
const { db, initDb, getDbStats } = await import("./db.ts");
|
|
17
17
|
const {
|
|
18
18
|
extractTerms,
|
|
19
19
|
buildFtsQuery,
|
|
@@ -25,6 +25,8 @@ const {
|
|
|
25
25
|
searchChangelogs,
|
|
26
26
|
checkCommandVersions,
|
|
27
27
|
searchDevices,
|
|
28
|
+
searchDeviceTests,
|
|
29
|
+
getTestResultMeta,
|
|
28
30
|
} = await import("./query.ts");
|
|
29
31
|
const { parseChangelog } = await import("./extract-changelogs.ts");
|
|
30
32
|
|
|
@@ -69,6 +71,10 @@ beforeAll(() => {
|
|
|
69
71
|
|
|
70
72
|
db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
|
|
71
73
|
VALUES ('7.22', 'stable', 0, '2024-01-01T00:00:00Z')`);
|
|
74
|
+
db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
|
|
75
|
+
VALUES ('7.9', 'stable', 0, '2023-01-01T00:00:00Z')`);
|
|
76
|
+
db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
|
|
77
|
+
VALUES ('7.10.2', 'stable', 0, '2023-06-01T00:00:00Z')`);
|
|
72
78
|
|
|
73
79
|
db.run(`INSERT INTO commands
|
|
74
80
|
(id, path, name, type, parent_path, page_id, description, ros_version)
|
|
@@ -80,6 +86,8 @@ beforeAll(() => {
|
|
|
80
86
|
|
|
81
87
|
db.run(`INSERT INTO command_versions (command_path, ros_version)
|
|
82
88
|
VALUES ('/ip/dhcp-server', '7.22')`);
|
|
89
|
+
db.run(`INSERT INTO command_versions (command_path, ros_version)
|
|
90
|
+
VALUES ('/ip/dhcp-server', '7.9')`);
|
|
83
91
|
|
|
84
92
|
// Device fixtures for searchDevices tests
|
|
85
93
|
db.run(`INSERT INTO devices
|
|
@@ -635,8 +643,8 @@ describe("searchCallouts", () => {
|
|
|
635
643
|
describe("checkCommandVersions", () => {
|
|
636
644
|
test("returns versions for known command path", () => {
|
|
637
645
|
const res = checkCommandVersions("/ip/dhcp-server");
|
|
638
|
-
expect(res.versions).toEqual(["7.22"]);
|
|
639
|
-
expect(res.first_seen).toBe("7.
|
|
646
|
+
expect(res.versions).toEqual(["7.9", "7.22"]);
|
|
647
|
+
expect(res.first_seen).toBe("7.9");
|
|
640
648
|
expect(res.last_seen).toBe("7.22");
|
|
641
649
|
});
|
|
642
650
|
|
|
@@ -827,6 +835,91 @@ describe("searchDevices", () => {
|
|
|
827
835
|
expect(res.results[0].product_url).toBeNull();
|
|
828
836
|
expect(res.results[0].block_diagram_url).toBeNull();
|
|
829
837
|
});
|
|
838
|
+
|
|
839
|
+
test("has_more is false when all results fit", () => {
|
|
840
|
+
const res = searchDevices("", { architecture: "ARM 64bit" }, 50);
|
|
841
|
+
expect(res.has_more).toBe(false);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test("has_more is true when results are truncated", () => {
|
|
845
|
+
// We have multiple devices; limit to 1 with a broad filter
|
|
846
|
+
const res = searchDevices("", { architecture: "ARM 64bit" }, 1);
|
|
847
|
+
expect(res.results).toHaveLength(1);
|
|
848
|
+
expect(res.has_more).toBe(true);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test("single FTS match attaches test_results", () => {
|
|
852
|
+
// "hAP ax3" as FTS should find exactly one match and attach test results
|
|
853
|
+
const res = searchDevices("hAP ax3");
|
|
854
|
+
if (res.mode === "exact" || res.results.length === 1) {
|
|
855
|
+
expect(res.results[0].test_results).toBeDefined();
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// ---------------------------------------------------------------------------
|
|
861
|
+
// searchDeviceTests: cross-device test queries
|
|
862
|
+
// ---------------------------------------------------------------------------
|
|
863
|
+
|
|
864
|
+
describe("searchDeviceTests", () => {
|
|
865
|
+
test("returns all test results with no filters", () => {
|
|
866
|
+
const res = searchDeviceTests({});
|
|
867
|
+
expect(res.results.length).toBe(3);
|
|
868
|
+
expect(res.total).toBe(3);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
test("filters by test_type", () => {
|
|
872
|
+
const res = searchDeviceTests({ test_type: "ethernet" });
|
|
873
|
+
expect(res.results.every((r) => r.test_type === "ethernet")).toBe(true);
|
|
874
|
+
expect(res.results.length).toBe(2);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test("filters by test_type and mode", () => {
|
|
878
|
+
const res = searchDeviceTests({ test_type: "ipsec", mode: "Single tunnel" });
|
|
879
|
+
expect(res.results).toHaveLength(1);
|
|
880
|
+
expect(res.results[0].configuration).toBe("AES-128-CBC + SHA1");
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test("configuration uses LIKE matching", () => {
|
|
884
|
+
const res = searchDeviceTests({ configuration: "25 ip filter" });
|
|
885
|
+
expect(res.results).toHaveLength(1);
|
|
886
|
+
expect(res.results[0].configuration).toBe("25 ip filter rules");
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("filters by packet_size", () => {
|
|
890
|
+
const res = searchDeviceTests({ packet_size: 512 });
|
|
891
|
+
expect(res.results.every((r) => r.packet_size === 512)).toBe(true);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test("sorts by mbps descending by default", () => {
|
|
895
|
+
const res = searchDeviceTests({ test_type: "ethernet" });
|
|
896
|
+
if (res.results.length >= 2) {
|
|
897
|
+
const mbps = res.results.map((r) => r.throughput_mbps ?? 0);
|
|
898
|
+
expect(mbps[0]).toBeGreaterThanOrEqual(mbps[1]);
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test("includes device info in results", () => {
|
|
903
|
+
const res = searchDeviceTests({ test_type: "ethernet" });
|
|
904
|
+
expect(res.results[0].product_name).toBeDefined();
|
|
905
|
+
expect(res.results[0].architecture).toBeDefined();
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("respects limit", () => {
|
|
909
|
+
const res = searchDeviceTests({}, 1);
|
|
910
|
+
expect(res.results).toHaveLength(1);
|
|
911
|
+
expect(res.total).toBe(3);
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
describe("getTestResultMeta", () => {
|
|
916
|
+
test("returns distinct values", () => {
|
|
917
|
+
const meta = getTestResultMeta();
|
|
918
|
+
expect(meta.test_types).toContain("ethernet");
|
|
919
|
+
expect(meta.test_types).toContain("ipsec");
|
|
920
|
+
expect(meta.modes).toContain("Routing");
|
|
921
|
+
expect(meta.packet_sizes).toContain(512);
|
|
922
|
+
});
|
|
830
923
|
});
|
|
831
924
|
|
|
832
925
|
// ---------------------------------------------------------------------------
|
|
@@ -1037,3 +1130,16 @@ describe("schema", () => {
|
|
|
1037
1130
|
expect(triggers).toContain("changelogs_au");
|
|
1038
1131
|
});
|
|
1039
1132
|
});
|
|
1133
|
+
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
// getDbStats: version range uses semantic sort (not lexicographic)
|
|
1136
|
+
// ---------------------------------------------------------------------------
|
|
1137
|
+
|
|
1138
|
+
describe("getDbStats", () => {
|
|
1139
|
+
test("version range is semantically sorted (7.9 < 7.10.2 < 7.22)", () => {
|
|
1140
|
+
const stats = getDbStats();
|
|
1141
|
+
// Fixtures have 7.9, 7.10.2, 7.22 — lexicographic MIN would give "7.10.2", not "7.9"
|
|
1142
|
+
expect(stats.ros_version_min).toBe("7.9");
|
|
1143
|
+
expect(stats.ros_version_max).toBe("7.22");
|
|
1144
|
+
});
|
|
1145
|
+
});
|
package/src/query.ts
CHANGED
|
@@ -764,14 +764,14 @@ export function searchDevices(
|
|
|
764
764
|
query: string,
|
|
765
765
|
filters: DeviceFilters = {},
|
|
766
766
|
limit = 10,
|
|
767
|
-
): { results: DeviceResult[]; mode: "exact" | "fts" | "like" | "filter" | "fts+or"; total: number } {
|
|
767
|
+
): { results: DeviceResult[]; mode: "exact" | "fts" | "like" | "filter" | "fts+or"; total: number; has_more: boolean } {
|
|
768
768
|
// 1. Try exact match on product_name or product_code
|
|
769
769
|
if (query) {
|
|
770
770
|
const exact = db
|
|
771
771
|
.prepare(`${DEVICE_SELECT} WHERE product_name = ? COLLATE NOCASE OR product_code = ? COLLATE NOCASE`)
|
|
772
772
|
.all(query, query) as DeviceResult[];
|
|
773
773
|
if (exact.length > 0) {
|
|
774
|
-
return { results: attachTestResults(exact), mode: "exact", total: exact.length };
|
|
774
|
+
return { results: attachTestResults(exact), mode: "exact", total: exact.length, has_more: false };
|
|
775
775
|
}
|
|
776
776
|
}
|
|
777
777
|
|
|
@@ -789,12 +789,17 @@ export function searchDevices(
|
|
|
789
789
|
() => "(d.product_name LIKE ? COLLATE NOCASE OR d.product_code LIKE ? COLLATE NOCASE)",
|
|
790
790
|
);
|
|
791
791
|
const likeParams = likeTerms.flatMap((t) => [t, t]);
|
|
792
|
+
// Fetch limit+1 to detect truncation
|
|
792
793
|
const likeSql = `${DEVICE_SELECT} d WHERE ${likeConditions.join(" AND ")} ORDER BY d.product_name LIMIT ?`;
|
|
793
|
-
const likeResults = db.prepare(likeSql).all(...likeParams, limit) as DeviceResult[];
|
|
794
|
+
const likeResults = db.prepare(likeSql).all(...likeParams, limit + 1) as DeviceResult[];
|
|
794
795
|
if (likeResults.length > 0) {
|
|
796
|
+
const hasMore = likeResults.length > limit;
|
|
797
|
+
const trimmed = hasMore ? likeResults.slice(0, limit) : likeResults;
|
|
795
798
|
// Attach test results for small result sets (likely specific device lookups)
|
|
796
|
-
if (
|
|
797
|
-
|
|
799
|
+
if (trimmed.length <= 5) attachTestResults(trimmed);
|
|
800
|
+
// Single match in any mode gets test results
|
|
801
|
+
else if (trimmed.length === 1) attachTestResults(trimmed);
|
|
802
|
+
return { results: trimmed, mode: "like", total: trimmed.length, has_more: hasMore };
|
|
798
803
|
}
|
|
799
804
|
}
|
|
800
805
|
}
|
|
@@ -848,9 +853,13 @@ export function searchDevices(
|
|
|
848
853
|
WHERE devices_fts MATCH ?${filterWhere}
|
|
849
854
|
ORDER BY rank LIMIT ?`;
|
|
850
855
|
try {
|
|
851
|
-
const results = db.prepare(sql).all(ftsQuery, ...params, limit) as DeviceResult[];
|
|
856
|
+
const results = db.prepare(sql).all(ftsQuery, ...params, limit + 1) as DeviceResult[];
|
|
852
857
|
if (results.length > 0) {
|
|
853
|
-
|
|
858
|
+
const hasMore = results.length > limit;
|
|
859
|
+
const trimmed = hasMore ? results.slice(0, limit) : results;
|
|
860
|
+
// Single FTS match gets test results (same behavior as exact)
|
|
861
|
+
if (trimmed.length === 1) attachTestResults(trimmed);
|
|
862
|
+
return { results: trimmed, mode: "fts", total: trimmed.length, has_more: hasMore };
|
|
854
863
|
}
|
|
855
864
|
} catch { /* fall through to OR */ }
|
|
856
865
|
|
|
@@ -858,9 +867,12 @@ export function searchDevices(
|
|
|
858
867
|
if (terms.length > 1) {
|
|
859
868
|
const orQuery = buildDeviceFtsQuery(terms, "OR");
|
|
860
869
|
try {
|
|
861
|
-
const results = db.prepare(sql).all(orQuery, ...params, limit) as DeviceResult[];
|
|
870
|
+
const results = db.prepare(sql).all(orQuery, ...params, limit + 1) as DeviceResult[];
|
|
862
871
|
if (results.length > 0) {
|
|
863
|
-
|
|
872
|
+
const hasMore = results.length > limit;
|
|
873
|
+
const trimmed = hasMore ? results.slice(0, limit) : results;
|
|
874
|
+
if (trimmed.length === 1) attachTestResults(trimmed);
|
|
875
|
+
return { results: trimmed, mode: "fts+or", total: trimmed.length, has_more: hasMore };
|
|
864
876
|
}
|
|
865
877
|
} catch { /* fall through */ }
|
|
866
878
|
}
|
|
@@ -870,11 +882,98 @@ export function searchDevices(
|
|
|
870
882
|
// 4. Filter-only (no FTS query)
|
|
871
883
|
if (whereClauses.length > 0) {
|
|
872
884
|
const sql = `${DEVICE_SELECT} d WHERE ${whereClauses.join(" AND ")} ORDER BY d.product_name LIMIT ?`;
|
|
873
|
-
const results = db.prepare(sql).all(...params, limit) as DeviceResult[];
|
|
874
|
-
|
|
885
|
+
const results = db.prepare(sql).all(...params, limit + 1) as DeviceResult[];
|
|
886
|
+
const hasMore = results.length > limit;
|
|
887
|
+
const trimmed = hasMore ? results.slice(0, limit) : results;
|
|
888
|
+
return { results: trimmed, mode: "filter", total: trimmed.length, has_more: hasMore };
|
|
875
889
|
}
|
|
876
890
|
|
|
877
|
-
return { results: [], mode: "fts", total: 0 };
|
|
891
|
+
return { results: [], mode: "fts", total: 0, has_more: false };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ── Cross-device test result queries ──
|
|
895
|
+
|
|
896
|
+
export type DeviceTestRow = {
|
|
897
|
+
product_name: string;
|
|
898
|
+
product_code: string | null;
|
|
899
|
+
architecture: string;
|
|
900
|
+
cpu: string | null;
|
|
901
|
+
cpu_cores: number | null;
|
|
902
|
+
cpu_frequency: string | null;
|
|
903
|
+
test_type: string;
|
|
904
|
+
mode: string;
|
|
905
|
+
configuration: string;
|
|
906
|
+
packet_size: number;
|
|
907
|
+
throughput_kpps: number | null;
|
|
908
|
+
throughput_mbps: number | null;
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
export function searchDeviceTests(
|
|
912
|
+
filters: {
|
|
913
|
+
test_type?: string;
|
|
914
|
+
mode?: string;
|
|
915
|
+
configuration?: string;
|
|
916
|
+
packet_size?: number;
|
|
917
|
+
sort_by?: "mbps" | "kpps";
|
|
918
|
+
},
|
|
919
|
+
limit = 50,
|
|
920
|
+
): { results: DeviceTestRow[]; total: number } {
|
|
921
|
+
const where: string[] = [];
|
|
922
|
+
const params: (string | number)[] = [];
|
|
923
|
+
|
|
924
|
+
if (filters.test_type) {
|
|
925
|
+
where.push("t.test_type = ?");
|
|
926
|
+
params.push(filters.test_type);
|
|
927
|
+
}
|
|
928
|
+
if (filters.mode) {
|
|
929
|
+
where.push("t.mode = ?");
|
|
930
|
+
params.push(filters.mode);
|
|
931
|
+
}
|
|
932
|
+
if (filters.configuration) {
|
|
933
|
+
where.push("t.configuration LIKE ?");
|
|
934
|
+
params.push(`%${filters.configuration}%`);
|
|
935
|
+
}
|
|
936
|
+
if (filters.packet_size) {
|
|
937
|
+
where.push("t.packet_size = ?");
|
|
938
|
+
params.push(filters.packet_size);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
942
|
+
const orderCol = filters.sort_by === "kpps" ? "t.throughput_kpps" : "t.throughput_mbps";
|
|
943
|
+
|
|
944
|
+
// Total count (before limit)
|
|
945
|
+
const totalSql = `SELECT COUNT(*) AS c FROM device_test_results t
|
|
946
|
+
JOIN devices d ON d.id = t.device_id ${whereClause}`;
|
|
947
|
+
const total = Number((db.prepare(totalSql).get(...params) as { c: number }).c);
|
|
948
|
+
|
|
949
|
+
const sql = `SELECT d.product_name, d.product_code, d.architecture, d.cpu,
|
|
950
|
+
d.cpu_cores, d.cpu_frequency,
|
|
951
|
+
t.test_type, t.mode, t.configuration, t.packet_size,
|
|
952
|
+
t.throughput_kpps, t.throughput_mbps
|
|
953
|
+
FROM device_test_results t
|
|
954
|
+
JOIN devices d ON d.id = t.device_id
|
|
955
|
+
${whereClause}
|
|
956
|
+
ORDER BY ${orderCol} DESC NULLS LAST
|
|
957
|
+
LIMIT ?`;
|
|
958
|
+
|
|
959
|
+
const results = db.prepare(sql).all(...params, limit) as DeviceTestRow[];
|
|
960
|
+
return { results, total };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Get distinct values for test result fields (for discovery). */
|
|
964
|
+
export function getTestResultMeta(): {
|
|
965
|
+
test_types: string[];
|
|
966
|
+
modes: string[];
|
|
967
|
+
configurations: string[];
|
|
968
|
+
packet_sizes: number[];
|
|
969
|
+
} {
|
|
970
|
+
const col = (sql: string) => (db.prepare(sql).all() as Array<{ v: string }>).map((r) => r.v);
|
|
971
|
+
return {
|
|
972
|
+
test_types: col("SELECT DISTINCT test_type AS v FROM device_test_results ORDER BY v"),
|
|
973
|
+
modes: col("SELECT DISTINCT mode AS v FROM device_test_results ORDER BY v"),
|
|
974
|
+
configurations: col("SELECT DISTINCT configuration AS v FROM device_test_results ORDER BY v"),
|
|
975
|
+
packet_sizes: (db.prepare("SELECT DISTINCT packet_size AS v FROM device_test_results ORDER BY v DESC").all() as Array<{ v: number }>).map((r) => r.v),
|
|
976
|
+
};
|
|
878
977
|
}
|
|
879
978
|
|
|
880
979
|
const VERSION_CHANNELS = ["stable", "long-term", "testing", "development"] as const;
|