@bli-cockpit/cli 0.2.193 → 0.2.194
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/dist/commands/local-args-tower-speed.js +31 -0
- package/dist/commands/local-args-tower.js +1 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help-commands-tower.js +18 -0
- package/dist/commands/local-help.js +2 -0
- package/dist/commands/local.js +2 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/speed-math.js +135 -0
- package/dist/commands/speed-pages.js +38 -0
- package/dist/commands/speed.js +130 -0
- package/package.json +2 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
2
|
+
import { SPEED_PAGE_NAMES } from "./speed-pages.js";
|
|
3
|
+
/** The most runs one page gets; a speed check is a handful of reads, not a load test. */
|
|
4
|
+
export const SPEED_MAX_RUNS = 50;
|
|
5
|
+
/** `cockpit speed [--page <name>]... [--runs <n>] [--json]` (BLI-13033). */
|
|
6
|
+
export function parseSpeedArgs(args) {
|
|
7
|
+
const values = parseNamedArgs(args, {
|
|
8
|
+
allowedFlags: ["--page", "--runs", "--json", "--home", "--dashboard-url"],
|
|
9
|
+
valueFlags: ["--page", "--runs", "--home", "--dashboard-url"],
|
|
10
|
+
});
|
|
11
|
+
if (values.positionals.length) {
|
|
12
|
+
throw new Error("speed takes --page <name>, not a subcommand. Pages: " + SPEED_PAGE_NAMES.join(", ") + ".");
|
|
13
|
+
}
|
|
14
|
+
const pages = (values.flagValues.get("--page") ?? []).map((name) => name.trim().toLowerCase());
|
|
15
|
+
const unknown = pages.filter((name) => !SPEED_PAGE_NAMES.includes(name));
|
|
16
|
+
if (unknown.length) {
|
|
17
|
+
throw new Error(`Unknown speed page: ${unknown.join(", ")}. Pages: ${SPEED_PAGE_NAMES.join(", ")}.`);
|
|
18
|
+
}
|
|
19
|
+
const runs = optionalPositiveInteger(values.flags.get("--runs"), "--runs");
|
|
20
|
+
if (runs !== undefined && runs > SPEED_MAX_RUNS) {
|
|
21
|
+
throw new Error(`--runs takes at most ${SPEED_MAX_RUNS}.`);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
kind: "speed",
|
|
25
|
+
pages: [...new Set(pages)],
|
|
26
|
+
runs,
|
|
27
|
+
json: values.booleans.has("--json"),
|
|
28
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
29
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -38,6 +38,7 @@ export { parseModelsArgs } from "./local-args-tower-models.js";
|
|
|
38
38
|
export { parseMailArgs } from "./local-args-tower-mail.js";
|
|
39
39
|
export { parseCalArgs } from "./local-args-tower-cal.js";
|
|
40
40
|
export { parseSetupArgs } from "./local-args-tower-setup.js";
|
|
41
|
+
export { parseSpeedArgs } from "./local-args-tower-speed.js";
|
|
41
42
|
export { parseUsageArgs } from "./local-args-tower-usage.js";
|
|
42
43
|
export { parseLogsArgs } from "./local-args-tower-logs.js";
|
|
43
44
|
export { parseBrainArgs } from "./local-args-tower-brain.js";
|
|
@@ -19,7 +19,7 @@ import { parseGlossaryArgs } from "./glossary.js";
|
|
|
19
19
|
import { parseRootsArgs } from "./roots.js";
|
|
20
20
|
import { SURFACE_ALIASES } from "./local-help.js";
|
|
21
21
|
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
22
|
-
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseLogsArgs, parseBrainArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
|
|
22
|
+
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
|
|
23
23
|
// `normalizeUrl` has always been part of this module's surface — `local.ts` and
|
|
24
24
|
// `local-auth.ts` import it from here — so it stays exported from this address
|
|
25
25
|
// even though it now lives next door. The same goes for the four names the
|
|
@@ -138,6 +138,8 @@ export function parseLocalArgs(argv) {
|
|
|
138
138
|
return parseSetupArgs(argv.slice(1));
|
|
139
139
|
case "usage":
|
|
140
140
|
return parseUsageArgs(argv.slice(1));
|
|
141
|
+
case "speed":
|
|
142
|
+
return parseSpeedArgs(argv.slice(1));
|
|
141
143
|
case "logs":
|
|
142
144
|
return parseLogsArgs(argv.slice(1));
|
|
143
145
|
case "brain":
|
|
@@ -175,6 +175,24 @@ export const TOWER_COMMAND_HELP = [
|
|
|
175
175
|
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
176
176
|
],
|
|
177
177
|
],
|
|
178
|
+
[
|
|
179
|
+
"speed",
|
|
180
|
+
[
|
|
181
|
+
"Usage: cockpit speed [--page <name>]... [--runs <n>] [--dashboard-url <url>] [--json]",
|
|
182
|
+
"",
|
|
183
|
+
"Times every Tower rail page's data door from this terminal, with this machine's device token.",
|
|
184
|
+
"Per page: the first run is reported as cold, the rest as warm; p50 and p95 are over the warm runs",
|
|
185
|
+
"(nearest rank). Also the HTTP status, the response size and the door's own Server-Timing total.",
|
|
186
|
+
"Slowest first. A page your account may not open (Careers for a member) reads \"not permitted\", not as a failure.",
|
|
187
|
+
"--page <name> limits the run to one page; repeat it for several. Names: today, meeting-notes, careers, scout,",
|
|
188
|
+
"messages, mail, docs, memory, tickets, setup, usage, calendar, workbook, settings.",
|
|
189
|
+
"--runs <n> sets the requests per page (default 5, at most 50).",
|
|
190
|
+
"--json writes one object to stdout ({ ok, runs, pages: [...] }); every log line stays on stderr.",
|
|
191
|
+
"It times the DATA DOOR the page opens on, not the page's HTML or paint, and it never uses the local read cache.",
|
|
192
|
+
"Exits 1 when any page failed; a page that is not permitted does not fail the run.",
|
|
193
|
+
"The same measurement is the speed_read MCP tool.",
|
|
194
|
+
],
|
|
195
|
+
],
|
|
178
196
|
[
|
|
179
197
|
"usage",
|
|
180
198
|
[
|
|
@@ -83,6 +83,7 @@ export const rootCommandNames = new Set([
|
|
|
83
83
|
"rename",
|
|
84
84
|
"setup",
|
|
85
85
|
"usage",
|
|
86
|
+
"speed",
|
|
86
87
|
"logs",
|
|
87
88
|
"brain",
|
|
88
89
|
...SURFACE_ALIASES.keys(),
|
|
@@ -153,6 +154,7 @@ export function localCommandHelp(command) {
|
|
|
153
154
|
" cockpit calendar [same verbs and flags as cockpit cal]",
|
|
154
155
|
" cockpit messages [same verbs and flags as cockpit msg]",
|
|
155
156
|
" cockpit meeting-notes [same verbs and flags as cockpit notes]",
|
|
157
|
+
" cockpit speed [--page <name>]... [--runs <n>] [--dashboard-url <url>] [--json]",
|
|
156
158
|
" cockpit usage people [--by-repo | --by-topic | --by-subject | --by-task] [--min-confidence high|medium|low] [--person <email|me>] [--all] [--detail] [--since <n>d|<n>h|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
|
|
157
159
|
"",
|
|
158
160
|
`Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
|
package/dist/commands/local.js
CHANGED
|
@@ -52,6 +52,7 @@ import { runCareers } from "./careers.js";
|
|
|
52
52
|
import { runRename } from "./rename-scope.js";
|
|
53
53
|
import { runSetup } from "./setup.js";
|
|
54
54
|
import { runUsage } from "./usage.js";
|
|
55
|
+
import { runSpeed } from "./speed.js";
|
|
55
56
|
import { runLogs } from "./logs.js";
|
|
56
57
|
import { runBrain } from "./brain.js";
|
|
57
58
|
import { parseLocalArgs } from "./local-args.js";
|
|
@@ -249,6 +250,7 @@ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorA
|
|
|
249
250
|
return await runRename(command, io);
|
|
250
251
|
case "setup": return await runSetup(command, io);
|
|
251
252
|
case "usage": return await runUsage(command, io);
|
|
253
|
+
case "speed": return await runSpeed(command, io);
|
|
252
254
|
case "logs": return await runLogs(command, io);
|
|
253
255
|
case "brain": return await runBrain(command, io);
|
|
254
256
|
}
|
|
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
24
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.194");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic and the layout behind `cockpit speed` (BLI-13033). Pure: no
|
|
3
|
+
* clock, no network, so the timing math, the ranking and the "not permitted"
|
|
4
|
+
* rule are all tested with plain numbers.
|
|
5
|
+
*/
|
|
6
|
+
/** 401 is a bad token (a failure to fix); 403 is a page this caller may not open. */
|
|
7
|
+
export function classifyStatus(status) {
|
|
8
|
+
if (status === 403)
|
|
9
|
+
return "not_permitted";
|
|
10
|
+
if (status !== null && status >= 200 && status < 300)
|
|
11
|
+
return "ok";
|
|
12
|
+
return "failed";
|
|
13
|
+
}
|
|
14
|
+
/** Nearest-rank percentile over unsorted values. 0 < p <= 100. Null on no values. */
|
|
15
|
+
export function percentile(values, p) {
|
|
16
|
+
if (values.length === 0)
|
|
17
|
+
return null;
|
|
18
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
19
|
+
const rank = Math.max(1, Math.ceil((p / 100) * sorted.length));
|
|
20
|
+
return sorted[Math.min(rank, sorted.length) - 1] ?? null;
|
|
21
|
+
}
|
|
22
|
+
/** `total;dur=123.4` out of a Server-Timing header. */
|
|
23
|
+
export function serverTotalMs(header) {
|
|
24
|
+
if (!header)
|
|
25
|
+
return null;
|
|
26
|
+
for (const part of header.split(",")) {
|
|
27
|
+
const [name, ...params] = part.trim().split(";");
|
|
28
|
+
if (name?.trim() !== "total")
|
|
29
|
+
continue;
|
|
30
|
+
for (const param of params) {
|
|
31
|
+
const [key, value] = param.trim().split("=");
|
|
32
|
+
if (key === "dur" && value !== undefined && Number.isFinite(Number(value)))
|
|
33
|
+
return Number(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const round1 = (value) => (value === null ? null : Math.round(value * 10) / 10);
|
|
39
|
+
export function summarizeSamples(page, samples) {
|
|
40
|
+
const base = { page: page.name, label: page.label, door: page.path, ...(page.note ? { note: page.note } : {}) };
|
|
41
|
+
const last = samples[samples.length - 1];
|
|
42
|
+
const good = samples.filter((sample) => classifyStatus(sample.status) === "ok");
|
|
43
|
+
const denied = samples.find((sample) => classifyStatus(sample.status) === "not_permitted");
|
|
44
|
+
if (good.length === 0) {
|
|
45
|
+
const first = samples[0];
|
|
46
|
+
const outcome = denied ? "not_permitted" : "failed";
|
|
47
|
+
return {
|
|
48
|
+
...base,
|
|
49
|
+
outcome,
|
|
50
|
+
cold_ms: null,
|
|
51
|
+
p50_ms: null,
|
|
52
|
+
p95_ms: null,
|
|
53
|
+
runs: samples.length,
|
|
54
|
+
failed_runs: denied ? 0 : samples.length,
|
|
55
|
+
http_status: (denied ?? first)?.status ?? null,
|
|
56
|
+
bytes: null,
|
|
57
|
+
server_timing: null,
|
|
58
|
+
server_total_ms: null,
|
|
59
|
+
reason: denied
|
|
60
|
+
? "not permitted for this account"
|
|
61
|
+
: (first?.failure ?? (first?.status === null || first === undefined ? "no_response" : `http_${first.status}`)),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const cold = samples[0] && classifyStatus(samples[0].status) === "ok" ? samples[0] : null;
|
|
65
|
+
const warm = good.filter((sample) => sample !== cold);
|
|
66
|
+
const timed = (warm.length > 0 ? warm : good).map((sample) => sample.ms);
|
|
67
|
+
const lastGood = good[good.length - 1] ?? last;
|
|
68
|
+
return {
|
|
69
|
+
...base,
|
|
70
|
+
outcome: "ok",
|
|
71
|
+
cold_ms: round1(cold ? cold.ms : null),
|
|
72
|
+
p50_ms: round1(percentile(timed, 50)),
|
|
73
|
+
p95_ms: round1(percentile(timed, 95)),
|
|
74
|
+
runs: samples.length,
|
|
75
|
+
failed_runs: samples.length - good.length,
|
|
76
|
+
http_status: lastGood?.status ?? null,
|
|
77
|
+
bytes: lastGood?.bytes ?? null,
|
|
78
|
+
server_timing: lastGood?.serverTiming ?? null,
|
|
79
|
+
server_total_ms: serverTotalMs(lastGood?.serverTiming ?? null),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Slowest first by median. Pages that answered come first, then pages that
|
|
84
|
+
* failed (a failure is something to fix, so it sits above the pages that were
|
|
85
|
+
* simply not open to this account), then pages not permitted.
|
|
86
|
+
*/
|
|
87
|
+
export function rankPages(pages) {
|
|
88
|
+
const tier = (page) => (page.outcome === "ok" ? 0 : page.outcome === "failed" ? 1 : 2);
|
|
89
|
+
return [...pages].sort((a, b) => {
|
|
90
|
+
if (tier(a) !== tier(b))
|
|
91
|
+
return tier(a) - tier(b);
|
|
92
|
+
return (b.p50_ms ?? 0) - (a.p50_ms ?? 0) || a.page.localeCompare(b.page);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function pad(value, width, right = false) {
|
|
96
|
+
return right ? value.padStart(width) : value.padEnd(width);
|
|
97
|
+
}
|
|
98
|
+
function ms(value) {
|
|
99
|
+
return value === null ? "-" : `${Math.round(value)} ms`;
|
|
100
|
+
}
|
|
101
|
+
function size(bytes) {
|
|
102
|
+
if (bytes === null)
|
|
103
|
+
return "-";
|
|
104
|
+
if (bytes < 1024)
|
|
105
|
+
return `${bytes} B`;
|
|
106
|
+
return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
107
|
+
}
|
|
108
|
+
/** The ranked table a person reads. Already ranked: this lays out, it does not sort. */
|
|
109
|
+
export function renderSpeedTable(ranked, runs) {
|
|
110
|
+
const rows = ranked.map((page, index) => {
|
|
111
|
+
if (page.outcome === "ok") {
|
|
112
|
+
return [
|
|
113
|
+
String(index + 1),
|
|
114
|
+
page.page,
|
|
115
|
+
ms(page.cold_ms),
|
|
116
|
+
ms(page.p50_ms),
|
|
117
|
+
ms(page.p95_ms),
|
|
118
|
+
String(page.http_status ?? "-"),
|
|
119
|
+
size(page.bytes),
|
|
120
|
+
page.server_total_ms === null ? "-" : ms(page.server_total_ms),
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
const word = page.outcome === "not_permitted" ? "not permitted" : `FAILED (${page.reason ?? "unknown"})`;
|
|
124
|
+
return [String(index + 1), page.page, word, "", "", String(page.http_status ?? "-"), "-", "-"];
|
|
125
|
+
});
|
|
126
|
+
const head = ["#", "PAGE", "COLD", "P50", "P95", "HTTP", "BYTES", "SERVER"];
|
|
127
|
+
const widths = head.map((title, column) => Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length)));
|
|
128
|
+
const line = (cells) => cells.map((cell, column) => pad(cell, widths[column] ?? 0, column >= 2 && column !== 5)).join(" ").trimEnd();
|
|
129
|
+
return [
|
|
130
|
+
line(head),
|
|
131
|
+
...rows.map(line),
|
|
132
|
+
"",
|
|
133
|
+
`${runs} run${runs === 1 ? "" : "s"} per page: the first is cold, p50 and p95 are over the warm runs${runs === 1 ? " (only the cold run was made)" : ""}. SERVER is the door's own Server-Timing total.`,
|
|
134
|
+
];
|
|
135
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which door each rail page opens on (BLI-13033).
|
|
3
|
+
*
|
|
4
|
+
* `cockpit speed` times the DATA DOOR of every rail page: the API read the
|
|
5
|
+
* page's first useful content comes from, or (where the page reads the
|
|
6
|
+
* database straight from the server) the nearest door that reads the same
|
|
7
|
+
* rows. It does not time the page's HTML, hydration or paint, so a number here
|
|
8
|
+
* is the floor of what a person waits, never more than that.
|
|
9
|
+
*
|
|
10
|
+
* Order is the rail's order (`ck-rail.tsx` `NAV_ITEMS`). The MCP twin
|
|
11
|
+
* (`packages/bli-cockpit-mcp/src/speed-tools.ts`) carries its own copy, and a
|
|
12
|
+
* test in that package fails when the two lists drift.
|
|
13
|
+
*/
|
|
14
|
+
/** The pages in rail order. `memoryContainer` is the tag `cockpit memory profile` would use here. */
|
|
15
|
+
export function speedPages(memoryContainer) {
|
|
16
|
+
return [
|
|
17
|
+
{ name: "today", label: "Today (JARVIS home)", path: "/api/jarvis/brief" },
|
|
18
|
+
{ name: "meeting-notes", label: "Meeting notes", path: "/api/notes/library" },
|
|
19
|
+
{ name: "careers", label: "Careers", path: "/api/careers/applications" },
|
|
20
|
+
{ name: "scout", label: "Scout", path: "/api/cockpit/scout" },
|
|
21
|
+
{ name: "messages", label: "Messages", path: "/api/msg/channels" },
|
|
22
|
+
{ name: "mail", label: "Mail", path: "/api/mail/inbox" },
|
|
23
|
+
{ name: "docs", label: "Docs", path: "/api/docs/tree" },
|
|
24
|
+
{
|
|
25
|
+
name: "memory",
|
|
26
|
+
label: "Memory",
|
|
27
|
+
path: `/api/memory/profile?container=${encodeURIComponent(memoryContainer)}`,
|
|
28
|
+
note: "the page reads agent_memories on the server; the profile door is the nearest read",
|
|
29
|
+
},
|
|
30
|
+
{ name: "tickets", label: "Tickets", path: "/api/work/issues" },
|
|
31
|
+
{ name: "setup", label: "Setup", path: "/api/setup" },
|
|
32
|
+
{ name: "usage", label: "Usage", path: "/api/usage/people?since=30d" },
|
|
33
|
+
{ name: "calendar", label: "Calendar", path: "/api/cal/week" },
|
|
34
|
+
{ name: "workbook", label: "Workbook", path: "/api/cockpit/workbook" },
|
|
35
|
+
{ name: "settings", label: "Settings", path: "/api/settings/personal" },
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
export const SPEED_PAGE_NAMES = speedPages("x").map((page) => page.name);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit speed` (BLI-13033) — how long each Tower rail page's data door takes,
|
|
3
|
+
* measured from this terminal with this machine's own device token.
|
|
4
|
+
*
|
|
5
|
+
* For every page: one request per run, the first reported as cold and the rest
|
|
6
|
+
* as warm, then p50, p95, the HTTP status, the response size and the door's
|
|
7
|
+
* own `Server-Timing` header. A page this account may not open (Careers for a
|
|
8
|
+
* member) reads "not permitted", which is an answer, not a failure.
|
|
9
|
+
*
|
|
10
|
+
* What a number is: wall-clock from sending the request to the last byte of the
|
|
11
|
+
* body, on THIS machine, so it includes the network between here and Tower. It
|
|
12
|
+
* is the data door, not the page's HTML or paint (`speed-pages.ts` says which
|
|
13
|
+
* door stands for which page). Reads only, and never the local read cache or
|
|
14
|
+
* the warm-connection route: a speed number that a cache answered would be a
|
|
15
|
+
* lie about Tower.
|
|
16
|
+
*
|
|
17
|
+
* stdout is `--json`'s alone; every log line goes to stderr, metadata only (a
|
|
18
|
+
* page name, a status, a millisecond count). The device token is attached by
|
|
19
|
+
* the shared Tower client and never printed.
|
|
20
|
+
*/
|
|
21
|
+
import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.js";
|
|
22
|
+
import { writeLine } from "./cli-io.js";
|
|
23
|
+
import { classifyStatus, rankPages, renderSpeedTable, summarizeSamples, } from "./speed-math.js";
|
|
24
|
+
import { speedPages } from "./speed-pages.js";
|
|
25
|
+
import { loadPairedSession, towerFailureDetail, towerRequest } from "../tower-client.js";
|
|
26
|
+
export const DEFAULT_SPEED_RUNS = 5;
|
|
27
|
+
const REQUEST_DEADLINE_MS = 60_000;
|
|
28
|
+
export async function runSpeed(command, io, deps = {}) {
|
|
29
|
+
const now = deps.now ?? (() => performance.now());
|
|
30
|
+
const log = (line) => writeLine(io.stderr, line);
|
|
31
|
+
const session = await loadPairedSession("speed", command.homeDir);
|
|
32
|
+
const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
|
|
33
|
+
const runs = command.runs ?? DEFAULT_SPEED_RUNS;
|
|
34
|
+
const everyPage = speedPages(resolveContainerTag({ cwd: process.cwd() }).containerTag);
|
|
35
|
+
const chosen = command.pages.length > 0
|
|
36
|
+
? everyPage.filter((page) => command.pages.includes(page.name))
|
|
37
|
+
: everyPage;
|
|
38
|
+
const results = [];
|
|
39
|
+
for (const page of chosen) {
|
|
40
|
+
const samples = [];
|
|
41
|
+
for (let run = 0; run < runs; run += 1) {
|
|
42
|
+
const sample = await timeOneRead(page, { dashboardUrl, deviceToken: session.device_token, io, now, log });
|
|
43
|
+
samples.push(sample);
|
|
44
|
+
// A page this account may not open answers the same way every time.
|
|
45
|
+
if (classifyStatus(sample.status) === "not_permitted")
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
const summary = summarizeSamples(page, samples);
|
|
49
|
+
results.push(summary);
|
|
50
|
+
log(`[speed cli] page measured ${JSON.stringify({
|
|
51
|
+
page: summary.page,
|
|
52
|
+
outcome: summary.outcome,
|
|
53
|
+
http_status: summary.http_status,
|
|
54
|
+
runs: samples.length,
|
|
55
|
+
failed_runs: summary.failed_runs,
|
|
56
|
+
p50_ms: summary.p50_ms,
|
|
57
|
+
...(summary.reason ? { reason: summary.reason } : {}),
|
|
58
|
+
})}`);
|
|
59
|
+
}
|
|
60
|
+
const ranked = rankPages(results);
|
|
61
|
+
const failed = ranked.filter((page) => page.outcome === "failed").length;
|
|
62
|
+
const notPermitted = ranked.filter((page) => page.outcome === "not_permitted").length;
|
|
63
|
+
log(`[speed cli] done ${JSON.stringify({
|
|
64
|
+
pages: ranked.length,
|
|
65
|
+
measured: ranked.length - failed - notPermitted,
|
|
66
|
+
failed,
|
|
67
|
+
not_permitted: notPermitted,
|
|
68
|
+
runs,
|
|
69
|
+
})}`);
|
|
70
|
+
if (command.json) {
|
|
71
|
+
writeLine(io.stdout, JSON.stringify({
|
|
72
|
+
ok: failed === 0,
|
|
73
|
+
measured_at: new Date().toISOString(),
|
|
74
|
+
dashboard_url: dashboardUrl,
|
|
75
|
+
runs,
|
|
76
|
+
pages: ranked,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
for (const line of renderSpeedTable(ranked, runs))
|
|
81
|
+
writeLine(io.stdout, line);
|
|
82
|
+
}
|
|
83
|
+
return failed === 0 ? 0 : 1;
|
|
84
|
+
}
|
|
85
|
+
async function timeOneRead(page, context) {
|
|
86
|
+
const startedAt = context.now();
|
|
87
|
+
const answer = await towerRequest({
|
|
88
|
+
dashboardUrl: context.dashboardUrl,
|
|
89
|
+
path: page.path,
|
|
90
|
+
method: "GET",
|
|
91
|
+
deviceToken: context.deviceToken,
|
|
92
|
+
fetch: context.io.fetch,
|
|
93
|
+
label: `speed-${page.name}`,
|
|
94
|
+
timeoutMs: REQUEST_DEADLINE_MS,
|
|
95
|
+
// The client's per-request success trace would print 70 lines here; the
|
|
96
|
+
// failure lines stay.
|
|
97
|
+
log: (line) => {
|
|
98
|
+
if (!line.startsWith("[tower client] responded"))
|
|
99
|
+
context.log(line);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
if (!answer.ok) {
|
|
103
|
+
return {
|
|
104
|
+
status: null,
|
|
105
|
+
ms: context.now() - startedAt,
|
|
106
|
+
bytes: 0,
|
|
107
|
+
serverTiming: null,
|
|
108
|
+
failure: `${answer.reason}: ${towerFailureDetail(answer.reason, answer.detail)}`.slice(0, 120),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
let bytes = 0;
|
|
112
|
+
try {
|
|
113
|
+
bytes = (await answer.response.arrayBuffer()).byteLength;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return {
|
|
117
|
+
status: answer.response.status,
|
|
118
|
+
ms: context.now() - startedAt,
|
|
119
|
+
bytes: 0,
|
|
120
|
+
serverTiming: null,
|
|
121
|
+
failure: "body_read_failed",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
status: answer.response.status,
|
|
126
|
+
ms: context.now() - startedAt,
|
|
127
|
+
bytes,
|
|
128
|
+
serverTiming: answer.response.headers.get("server-timing"),
|
|
129
|
+
};
|
|
130
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.194",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@bli-cockpit/memory-mcp": "0.1.57",
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.106",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.66"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|