@tikoci/rosetta 0.3.1 → 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/README.md +135 -197
- package/package.json +1 -1
- package/src/db.ts +52 -7
- package/src/extract-test-results.ts +359 -0
- package/src/mcp-http.test.ts +4 -4
- package/src/mcp.ts +137 -7
- package/src/query.test.ts +156 -6
- package/src/query.ts +147 -13
- package/src/release.test.ts +1 -0
- package/src/setup.ts +22 -0
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
|
|
@@ -87,13 +95,15 @@ beforeAll(() => {
|
|
|
87
95
|
license_level, operating_system, ram, ram_mb, storage, storage_mb,
|
|
88
96
|
poe_in, poe_out, wireless_24_chains, wireless_5_chains,
|
|
89
97
|
eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
|
|
90
|
-
eth_multigig, usb_ports, sim_slots, msrp_usd
|
|
98
|
+
eth_multigig, usb_ports, sim_slots, msrp_usd,
|
|
99
|
+
product_url, block_diagram_url)
|
|
91
100
|
VALUES
|
|
92
101
|
('hAP ax3', 'C53UiG+5HPaxD2HPaxD', 'ARM 64bit', 'IPQ-6010', 4, 'auto (864 - 1800) MHz',
|
|
93
102
|
4, 'RouterOS v7', '1 GB', 1024, '128 MB', 128,
|
|
94
103
|
'802.3af/at', NULL, 2, 2,
|
|
95
104
|
NULL, 4, 1, NULL, NULL,
|
|
96
|
-
NULL, 1, NULL, 139.00
|
|
105
|
+
NULL, 1, NULL, 139.00,
|
|
106
|
+
'https://mikrotik.com/product/hap_ax3', 'https://cdn.mikrotik.com/web-assets/product_files/hap_ax3_123.png')`);
|
|
97
107
|
|
|
98
108
|
db.run(`INSERT INTO devices
|
|
99
109
|
(product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
|
|
@@ -161,6 +171,17 @@ beforeAll(() => {
|
|
|
161
171
|
NULL, 13, NULL, NULL, NULL,
|
|
162
172
|
NULL, NULL, NULL, 369.00)`);
|
|
163
173
|
|
|
174
|
+
// Device test results fixtures (hAP ax3 = id 1)
|
|
175
|
+
db.run(`INSERT INTO device_test_results
|
|
176
|
+
(device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
|
|
177
|
+
VALUES (1, 'ethernet', 'Routing', '25 ip filter rules', 512, 755.9, 3096.2)`);
|
|
178
|
+
db.run(`INSERT INTO device_test_results
|
|
179
|
+
(device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
|
|
180
|
+
VALUES (1, 'ethernet', 'Routing', 'none (fast path)', 512, 2332.0, 9551.9)`);
|
|
181
|
+
db.run(`INSERT INTO device_test_results
|
|
182
|
+
(device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
|
|
183
|
+
VALUES (1, 'ipsec', 'Single tunnel', 'AES-128-CBC + SHA1', 1400, 120.9, 1354.1)`);
|
|
184
|
+
|
|
164
185
|
// Page 3: a "large" page with sections for TOC testing
|
|
165
186
|
// Text is ~200 chars to keep fixture small, but we'll use max_length=50 to trigger truncation
|
|
166
187
|
db.run(`INSERT INTO pages
|
|
@@ -622,8 +643,8 @@ describe("searchCallouts", () => {
|
|
|
622
643
|
describe("checkCommandVersions", () => {
|
|
623
644
|
test("returns versions for known command path", () => {
|
|
624
645
|
const res = checkCommandVersions("/ip/dhcp-server");
|
|
625
|
-
expect(res.versions).toEqual(["7.22"]);
|
|
626
|
-
expect(res.first_seen).toBe("7.
|
|
646
|
+
expect(res.versions).toEqual(["7.9", "7.22"]);
|
|
647
|
+
expect(res.first_seen).toBe("7.9");
|
|
627
648
|
expect(res.last_seen).toBe("7.22");
|
|
628
649
|
});
|
|
629
650
|
|
|
@@ -783,6 +804,122 @@ describe("searchDevices", () => {
|
|
|
783
804
|
const res = searchDevices("");
|
|
784
805
|
expect(res.results).toHaveLength(0);
|
|
785
806
|
});
|
|
807
|
+
|
|
808
|
+
test("exact match includes test_results", () => {
|
|
809
|
+
const res = searchDevices("hAP ax3");
|
|
810
|
+
expect(res.mode).toBe("exact");
|
|
811
|
+
expect(res.results).toHaveLength(1);
|
|
812
|
+
const dev = res.results[0];
|
|
813
|
+
expect(dev.test_results).toBeDefined();
|
|
814
|
+
expect(dev.test_results!.length).toBe(3);
|
|
815
|
+
expect(dev.test_results!.some((t) => t.test_type === "ethernet")).toBe(true);
|
|
816
|
+
expect(dev.test_results!.some((t) => t.test_type === "ipsec")).toBe(true);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("LIKE match with ≤5 results includes test_results", () => {
|
|
820
|
+
const res = searchDevices("RB1100");
|
|
821
|
+
expect(res.mode).toBe("like");
|
|
822
|
+
expect(res.results.length).toBeLessThanOrEqual(5);
|
|
823
|
+
// RB1100 devices have no test results, but the field should still be populated (empty array)
|
|
824
|
+
expect(res.results.every((d) => Array.isArray(d.test_results))).toBe(true);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("exact match includes product_url and block_diagram_url", () => {
|
|
828
|
+
const res = searchDevices("hAP ax3");
|
|
829
|
+
expect(res.results[0].product_url).toBe("https://mikrotik.com/product/hap_ax3");
|
|
830
|
+
expect(res.results[0].block_diagram_url).toContain("cdn.mikrotik.com");
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test("devices without product_url return null", () => {
|
|
834
|
+
const res = searchDevices("CCR2216-1G-12XS-2XQ");
|
|
835
|
+
expect(res.results[0].product_url).toBeNull();
|
|
836
|
+
expect(res.results[0].block_diagram_url).toBeNull();
|
|
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
|
+
});
|
|
786
923
|
});
|
|
787
924
|
|
|
788
925
|
// ---------------------------------------------------------------------------
|
|
@@ -943,7 +1080,7 @@ describe("schema", () => {
|
|
|
943
1080
|
const expected = [
|
|
944
1081
|
"pages", "properties", "callouts", "sections",
|
|
945
1082
|
"commands", "command_versions", "ros_versions",
|
|
946
|
-
"devices", "changelogs", "schema_migrations",
|
|
1083
|
+
"devices", "device_test_results", "changelogs", "schema_migrations",
|
|
947
1084
|
];
|
|
948
1085
|
for (const table of expected) {
|
|
949
1086
|
expect(names).toContain(table);
|
|
@@ -993,3 +1130,16 @@ describe("schema", () => {
|
|
|
993
1130
|
expect(triggers).toContain("changelogs_au");
|
|
994
1131
|
});
|
|
995
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
|
@@ -669,6 +669,15 @@ export function browseCommandsAtVersion(
|
|
|
669
669
|
|
|
670
670
|
// ── Device lookup and search ──
|
|
671
671
|
|
|
672
|
+
export type DeviceTestResult = {
|
|
673
|
+
test_type: string;
|
|
674
|
+
mode: string;
|
|
675
|
+
configuration: string;
|
|
676
|
+
packet_size: number;
|
|
677
|
+
throughput_kpps: number | null;
|
|
678
|
+
throughput_mbps: number | null;
|
|
679
|
+
};
|
|
680
|
+
|
|
672
681
|
export type DeviceResult = {
|
|
673
682
|
id: number;
|
|
674
683
|
product_name: string;
|
|
@@ -698,6 +707,9 @@ export type DeviceResult = {
|
|
|
698
707
|
usb_ports: number | null;
|
|
699
708
|
sim_slots: number | null;
|
|
700
709
|
msrp_usd: number | null;
|
|
710
|
+
product_url: string | null;
|
|
711
|
+
block_diagram_url: string | null;
|
|
712
|
+
test_results?: DeviceTestResult[];
|
|
701
713
|
};
|
|
702
714
|
|
|
703
715
|
export type DeviceFilters = {
|
|
@@ -715,9 +727,29 @@ const DEVICE_SELECT = `SELECT id, product_name, product_code, architecture, cpu,
|
|
|
715
727
|
ram, ram_mb, storage, storage_mb, dimensions, poe_in, poe_out,
|
|
716
728
|
max_power_w, wireless_24_chains, wireless_5_chains,
|
|
717
729
|
eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
|
|
718
|
-
eth_multigig, usb_ports, sim_slots, msrp_usd
|
|
730
|
+
eth_multigig, usb_ports, sim_slots, msrp_usd,
|
|
731
|
+
product_url, block_diagram_url
|
|
719
732
|
FROM devices`;
|
|
720
733
|
|
|
734
|
+
/** Get test results for a device by ID. */
|
|
735
|
+
function getDeviceTestResults(deviceId: number): DeviceTestResult[] {
|
|
736
|
+
return db.prepare(
|
|
737
|
+
`SELECT test_type, mode, configuration, packet_size,
|
|
738
|
+
throughput_kpps, throughput_mbps
|
|
739
|
+
FROM device_test_results
|
|
740
|
+
WHERE device_id = ?
|
|
741
|
+
ORDER BY test_type, mode, configuration, packet_size DESC`
|
|
742
|
+
).all(deviceId) as DeviceTestResult[];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Attach test results to device results (for single/exact lookups). */
|
|
746
|
+
function attachTestResults(devices: DeviceResult[]): DeviceResult[] {
|
|
747
|
+
for (const dev of devices) {
|
|
748
|
+
dev.test_results = getDeviceTestResults(dev.id);
|
|
749
|
+
}
|
|
750
|
+
return devices;
|
|
751
|
+
}
|
|
752
|
+
|
|
721
753
|
/** Build FTS5 query for devices — appends prefix '*' to every term.
|
|
722
754
|
* Model numbers like "RB1100" need prefix matching to find "RB1100AHx4".
|
|
723
755
|
* No compound term handling (not relevant for device names). */
|
|
@@ -732,14 +764,14 @@ export function searchDevices(
|
|
|
732
764
|
query: string,
|
|
733
765
|
filters: DeviceFilters = {},
|
|
734
766
|
limit = 10,
|
|
735
|
-
): { 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 } {
|
|
736
768
|
// 1. Try exact match on product_name or product_code
|
|
737
769
|
if (query) {
|
|
738
770
|
const exact = db
|
|
739
771
|
.prepare(`${DEVICE_SELECT} WHERE product_name = ? COLLATE NOCASE OR product_code = ? COLLATE NOCASE`)
|
|
740
772
|
.all(query, query) as DeviceResult[];
|
|
741
773
|
if (exact.length > 0) {
|
|
742
|
-
return { results: exact, mode: "exact", total: exact.length };
|
|
774
|
+
return { results: attachTestResults(exact), mode: "exact", total: exact.length, has_more: false };
|
|
743
775
|
}
|
|
744
776
|
}
|
|
745
777
|
|
|
@@ -757,10 +789,17 @@ export function searchDevices(
|
|
|
757
789
|
() => "(d.product_name LIKE ? COLLATE NOCASE OR d.product_code LIKE ? COLLATE NOCASE)",
|
|
758
790
|
);
|
|
759
791
|
const likeParams = likeTerms.flatMap((t) => [t, t]);
|
|
792
|
+
// Fetch limit+1 to detect truncation
|
|
760
793
|
const likeSql = `${DEVICE_SELECT} d WHERE ${likeConditions.join(" AND ")} ORDER BY d.product_name LIMIT ?`;
|
|
761
|
-
const likeResults = db.prepare(likeSql).all(...likeParams, limit) as DeviceResult[];
|
|
794
|
+
const likeResults = db.prepare(likeSql).all(...likeParams, limit + 1) as DeviceResult[];
|
|
762
795
|
if (likeResults.length > 0) {
|
|
763
|
-
|
|
796
|
+
const hasMore = likeResults.length > limit;
|
|
797
|
+
const trimmed = hasMore ? likeResults.slice(0, limit) : likeResults;
|
|
798
|
+
// Attach test results for small result sets (likely specific device lookups)
|
|
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 };
|
|
764
803
|
}
|
|
765
804
|
}
|
|
766
805
|
}
|
|
@@ -807,15 +846,20 @@ export function searchDevices(
|
|
|
807
846
|
d.ram, d.ram_mb, d.storage, d.storage_mb, d.dimensions, d.poe_in, d.poe_out,
|
|
808
847
|
d.max_power_w, d.wireless_24_chains, d.wireless_5_chains,
|
|
809
848
|
d.eth_fast, d.eth_gigabit, d.eth_2500, d.sfp_ports, d.sfp_plus_ports,
|
|
810
|
-
d.eth_multigig, d.usb_ports, d.sim_slots, d.msrp_usd
|
|
849
|
+
d.eth_multigig, d.usb_ports, d.sim_slots, d.msrp_usd,
|
|
850
|
+
d.product_url, d.block_diagram_url
|
|
811
851
|
FROM devices_fts fts
|
|
812
852
|
JOIN devices d ON d.id = fts.rowid
|
|
813
853
|
WHERE devices_fts MATCH ?${filterWhere}
|
|
814
854
|
ORDER BY rank LIMIT ?`;
|
|
815
855
|
try {
|
|
816
|
-
const results = db.prepare(sql).all(ftsQuery, ...params, limit) as DeviceResult[];
|
|
856
|
+
const results = db.prepare(sql).all(ftsQuery, ...params, limit + 1) as DeviceResult[];
|
|
817
857
|
if (results.length > 0) {
|
|
818
|
-
|
|
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 };
|
|
819
863
|
}
|
|
820
864
|
} catch { /* fall through to OR */ }
|
|
821
865
|
|
|
@@ -823,9 +867,12 @@ export function searchDevices(
|
|
|
823
867
|
if (terms.length > 1) {
|
|
824
868
|
const orQuery = buildDeviceFtsQuery(terms, "OR");
|
|
825
869
|
try {
|
|
826
|
-
const results = db.prepare(sql).all(orQuery, ...params, limit) as DeviceResult[];
|
|
870
|
+
const results = db.prepare(sql).all(orQuery, ...params, limit + 1) as DeviceResult[];
|
|
827
871
|
if (results.length > 0) {
|
|
828
|
-
|
|
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 };
|
|
829
876
|
}
|
|
830
877
|
} catch { /* fall through */ }
|
|
831
878
|
}
|
|
@@ -835,11 +882,98 @@ export function searchDevices(
|
|
|
835
882
|
// 4. Filter-only (no FTS query)
|
|
836
883
|
if (whereClauses.length > 0) {
|
|
837
884
|
const sql = `${DEVICE_SELECT} d WHERE ${whereClauses.join(" AND ")} ORDER BY d.product_name LIMIT ?`;
|
|
838
|
-
const results = db.prepare(sql).all(...params, limit) as DeviceResult[];
|
|
839
|
-
|
|
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 };
|
|
889
|
+
}
|
|
890
|
+
|
|
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}%`);
|
|
840
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
|
+
}
|
|
841
962
|
|
|
842
|
-
|
|
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
|
+
};
|
|
843
977
|
}
|
|
844
978
|
|
|
845
979
|
const VERSION_CHANNELS = ["stable", "long-term", "testing", "development"] as const;
|
package/src/release.test.ts
CHANGED
|
@@ -238,6 +238,7 @@ describe("release.yml", () => {
|
|
|
238
238
|
expect(src).toContain("extract-properties.ts");
|
|
239
239
|
expect(src).toContain("extract-commands.ts");
|
|
240
240
|
expect(src).toContain("extract-devices.ts");
|
|
241
|
+
expect(src).toContain("extract-test-results.ts");
|
|
241
242
|
expect(src).toContain("extract-changelogs.ts");
|
|
242
243
|
expect(src).toContain("link-commands.ts");
|
|
243
244
|
});
|
package/src/setup.ts
CHANGED
|
@@ -65,6 +65,7 @@ export async function runSetup(force = false) {
|
|
|
65
65
|
const dbPath = resolveDbPath(import.meta.dirname);
|
|
66
66
|
|
|
67
67
|
console.log(`rosetta ${RELEASE_VERSION}`);
|
|
68
|
+
console.log(` ${link("https://github.com/tikoci/rosetta")}`);
|
|
68
69
|
console.log();
|
|
69
70
|
|
|
70
71
|
// ── Download DB if needed ──
|
|
@@ -301,8 +302,29 @@ function printHttpConfig(startCmd: string) {
|
|
|
301
302
|
console.log(" For LAN access, replace localhost with the server's IP address.");
|
|
302
303
|
console.log(" Use a reverse proxy (nginx, caddy) for production HTTPS.");
|
|
303
304
|
console.log();
|
|
305
|
+
|
|
306
|
+
printMikroTikConfig();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Format a clickable terminal hyperlink using OSC 8 escape sequences. */
|
|
310
|
+
function link(url: string, display?: string): string {
|
|
311
|
+
return `\x1b]8;;${url}\x07${display ?? url}\x1b]8;;\x07`;
|
|
304
312
|
}
|
|
305
313
|
|
|
314
|
+
function printMikroTikConfig() {
|
|
315
|
+
console.log("─".repeat(60));
|
|
316
|
+
console.log("MikroTik /app container (RouterOS 7.22+, x86 or ARM64):");
|
|
317
|
+
console.log("─".repeat(60));
|
|
318
|
+
console.log();
|
|
319
|
+
console.log(" Run directly on your MikroTik router — any MCP client on");
|
|
320
|
+
console.log(" the network can connect to the URL shown in the router UI.");
|
|
321
|
+
console.log();
|
|
322
|
+
console.log(" Requires: container package + device-mode enabled.");
|
|
323
|
+
console.log(` See: ${link("https://github.com/tikoci/rosetta#install-on-mikrotik-app", "README — Install on MikroTik")}`);
|
|
324
|
+
console.log();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
306
328
|
// Run directly
|
|
307
329
|
if (import.meta.main) {
|
|
308
330
|
const force = process.argv.includes("--force");
|