@nredd/pi-session-kit 0.6.0 → 0.8.0
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/CHANGELOG.md +28 -0
- package/README.md +35 -8
- package/docs/mon-dashboard.md +123 -0
- package/docs/stats-snapshots.md +19 -2
- package/package.json +8 -3
- package/src/config.ts +73 -2
- package/src/index.ts +4 -1
- package/src/mon/component.ts +486 -0
- package/src/mon/index.ts +105 -0
- package/src/mon/model.ts +153 -0
- package/src/mon/poll.ts +80 -0
- package/src/mon/source.ts +121 -0
- package/src/monitor/index.ts +16 -3
- package/src/monitor/manager.ts +5 -0
- package/src/monitor/types.ts +2 -0
- package/src/monitor/widget.ts +209 -26
- package/src/stats/component.ts +23 -5
- package/src/stats/freshness.ts +46 -0
- package/src/stats/index.ts +5 -2
- package/src/stats/snapshot.ts +24 -1
package/src/monitor/widget.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Above-editor monitors panel, laid out like the Todos and subagents widgets: a disclosure heading
|
|
3
|
+
* (clickable, plus a configurable shortcut), one bulleted row per monitor with flush-right stats,
|
|
4
|
+
* and a fixed-height scroll window. It is display only: passive monitor output still stays in the
|
|
5
|
+
* ring buffer and never enters the transcript or model context.
|
|
6
|
+
*/
|
|
2
7
|
|
|
3
8
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { type TUI, type TuiMouseEvent, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { COLLAPSE_KEY_OFF } from "../config.ts";
|
|
5
11
|
import { formatDuration } from "./messages.ts";
|
|
6
12
|
import type { MonitorInfo } from "./types.ts";
|
|
7
13
|
|
|
@@ -9,54 +15,202 @@ export const WIDGET_KEY = "session-kit:monitors";
|
|
|
9
15
|
const TICK_MS = 1_000;
|
|
10
16
|
|
|
11
17
|
type WidgetTheme = Pick<Theme, "fg">;
|
|
18
|
+
type Color = Parameters<Theme["fg"]>[0];
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
export type MonitorState = "running" | "matched" | "failed" | "exited";
|
|
21
|
+
|
|
22
|
+
const STATE_COLOR: Record<MonitorState, Color> = {
|
|
23
|
+
running: "success",
|
|
24
|
+
matched: "warning",
|
|
25
|
+
failed: "error",
|
|
26
|
+
exited: "dim",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Bullet state: a running monitor that has matched, a non-clean end, or a clean/requested end. */
|
|
30
|
+
export function monitorState(info: MonitorInfo): MonitorState {
|
|
31
|
+
if (info.status === "running") return info.matches > 0 ? "matched" : "running";
|
|
32
|
+
if (info.status === "timeout") return "failed";
|
|
33
|
+
if (info.status === "stopped") return "exited";
|
|
34
|
+
return info.exitCode === 0 && info.signal === null ? "exited" : "failed";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A slice `[start, end)` of the rows plus which edge hints take a row. */
|
|
38
|
+
export interface MonitorWindow {
|
|
39
|
+
start: number;
|
|
40
|
+
end: number;
|
|
41
|
+
above: boolean;
|
|
42
|
+
below: boolean;
|
|
43
|
+
maxOffset: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Window of exactly `budget` rows over `total` monitors from `offset`: each visible edge hint costs
|
|
48
|
+
* a row, so the panel height never changes while scrolling (budget >= 3 keeps every position
|
|
49
|
+
* showing at least one monitor).
|
|
50
|
+
*/
|
|
51
|
+
export function windowFor(total: number, budget: number, offset: number): MonitorWindow {
|
|
52
|
+
if (total <= budget) return { start: 0, end: total, above: false, below: false, maxOffset: 0 };
|
|
53
|
+
const maxOffset = total - (budget - 1);
|
|
54
|
+
const start = Math.max(0, Math.min(maxOffset, offset));
|
|
55
|
+
if (start === 0) return { start, end: budget - 1, above: false, below: true, maxOffset };
|
|
56
|
+
if (start === maxOffset) return { start, end: total, above: true, below: false, maxOffset };
|
|
57
|
+
return { start, end: start + budget - 2, above: true, below: true, maxOffset };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `left` truncated first so `right` survives; never wider than `width` (a wrap desyncs pi's diff). */
|
|
61
|
+
function rightAlign(left: string, right: string, width: number): string {
|
|
62
|
+
const rightWidth = visibleWidth(right);
|
|
63
|
+
const clamped = truncateToWidth(left, Math.max(0, width - rightWidth - 1), "…");
|
|
64
|
+
const gap = Math.max(1, width - visibleWidth(clamped) - rightWidth);
|
|
65
|
+
return truncateToWidth(clamped + " ".repeat(gap) + right, width, "…");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Keep the full heading ahead of an optional hint; a narrow panel omits the hint. */
|
|
69
|
+
function headingLine(heading: string, hint: string | undefined, width: number): string {
|
|
70
|
+
if (hint && visibleWidth(heading) + visibleWidth(hint) + 1 <= width) return rightAlign(heading, hint, width);
|
|
71
|
+
return truncateToWidth(heading, width, "…");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface WidgetView {
|
|
75
|
+
collapsed: boolean;
|
|
76
|
+
offset: number;
|
|
77
|
+
/** Row budget including the heading. */
|
|
78
|
+
maxLines: number;
|
|
79
|
+
/** Shortcut shown in the collapsed hint, or `"off"`. */
|
|
80
|
+
collapseKey: string;
|
|
81
|
+
/** Show every row (pi's tool-output expansion) instead of the budget. */
|
|
82
|
+
showAll?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface RenderedWidget {
|
|
86
|
+
lines: string[];
|
|
87
|
+
/** The offset actually used after clamping to the window. */
|
|
88
|
+
offset: number;
|
|
89
|
+
/** Largest scroll offset (0 when everything fits). */
|
|
90
|
+
maxOffset: number;
|
|
15
91
|
}
|
|
16
92
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
93
|
+
function headingText(infos: MonitorInfo[]): string {
|
|
94
|
+
const running = infos.filter((i) => i.status === "running").length;
|
|
95
|
+
const failed = infos.filter((i) => monitorState(i) === "failed").length;
|
|
96
|
+
const done = infos.length - running - failed;
|
|
97
|
+
const parts = [
|
|
98
|
+
running ? `${running} running` : "",
|
|
99
|
+
failed ? `${failed} failed` : "",
|
|
100
|
+
done ? `${done} done` : "",
|
|
101
|
+
].filter(Boolean);
|
|
102
|
+
return `Monitors (${parts.join(" · ")})`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function rowLine(info: MonitorInfo, now: number, width: number, theme: WidgetTheme): string {
|
|
106
|
+
const bullet = theme.fg(STATE_COLOR[monitorState(info)], "●");
|
|
107
|
+
const last = info.lastLine === undefined || info.lastLine === "" ? "(no output)" : info.lastLine;
|
|
108
|
+
const left = `${bullet} ${info.name} ${theme.fg("dim", last)}`;
|
|
109
|
+
const elapsed = formatDuration((info.endedAt ?? now) - info.startedAt);
|
|
110
|
+
const stats = theme.fg("dim", `${elapsed} · ${info.lines} ${info.lines === 1 ? "line" : "lines"}`);
|
|
111
|
+
return rightAlign(left, stats, width);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Pure render: collapsed is heading plus spacer, expanded is a constant-height window. */
|
|
115
|
+
export function renderWidget(
|
|
116
|
+
infos: MonitorInfo[],
|
|
117
|
+
now: number,
|
|
118
|
+
width: number,
|
|
119
|
+
theme: WidgetTheme,
|
|
120
|
+
view: WidgetView,
|
|
121
|
+
): RenderedWidget {
|
|
122
|
+
if (infos.length === 0) return { lines: [], offset: 0, maxOffset: 0 };
|
|
123
|
+
const anyRunning = infos.some((i) => i.status === "running");
|
|
124
|
+
const color: Color = anyRunning ? "accent" : "dim";
|
|
125
|
+
const disclosure = theme.fg("dim", view.collapsed ? "▸" : "▾");
|
|
126
|
+
const head = `${disclosure} ${theme.fg(color, anyRunning ? "●" : "○")} ${theme.fg(color, headingText(infos))}`;
|
|
127
|
+
|
|
128
|
+
if (view.collapsed) {
|
|
129
|
+
const hint = view.collapseKey === COLLAPSE_KEY_OFF ? "collapsed" : `${view.collapseKey} to expand`;
|
|
130
|
+
return { lines: [headingLine(head, theme.fg("dim", hint), width), ""], offset: 0, maxOffset: 0 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const budget = view.showAll ? infos.length : view.maxLines - 1;
|
|
134
|
+
const win = windowFor(infos.length, budget, view.offset);
|
|
135
|
+
const rows: string[] = [];
|
|
136
|
+
if (win.above) rows.push(theme.fg("dim", `↑ ${win.start} more`));
|
|
137
|
+
for (const info of infos.slice(win.start, win.end)) rows.push(rowLine(info, now, Math.max(0, width - 3), theme));
|
|
138
|
+
if (win.below) rows.push(theme.fg("dim", `↓ ${infos.length - win.end} more`));
|
|
139
|
+
|
|
140
|
+
const lines = [headingLine(head, undefined, width)];
|
|
141
|
+
rows.forEach((row, i) => {
|
|
142
|
+
const connector = theme.fg("dim", i === rows.length - 1 ? "└─" : "├─");
|
|
143
|
+
lines.push(truncateToWidth(`${connector} ${row}`, width, "…"));
|
|
144
|
+
});
|
|
145
|
+
lines.push("");
|
|
146
|
+
return { lines, offset: win.start, maxOffset: win.maxOffset };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface WidgetOptions {
|
|
150
|
+
maxLines: number;
|
|
151
|
+
collapseKey: string;
|
|
152
|
+
now?: () => number;
|
|
26
153
|
}
|
|
27
154
|
|
|
28
155
|
export class MonitorWidget {
|
|
29
156
|
private ctx: ExtensionContext | undefined;
|
|
30
157
|
private visible = false;
|
|
31
|
-
private
|
|
158
|
+
private tui: TUI | undefined;
|
|
32
159
|
private timer: NodeJS.Timeout | undefined;
|
|
160
|
+
private collapsed = false;
|
|
161
|
+
private offset = 0;
|
|
162
|
+
private maxOffset = 0;
|
|
33
163
|
|
|
34
|
-
|
|
164
|
+
/** `list` is every retained monitor; the panel shows while at least one is running. */
|
|
165
|
+
constructor(
|
|
166
|
+
private readonly list: () => MonitorInfo[],
|
|
167
|
+
private readonly options: WidgetOptions,
|
|
168
|
+
) {}
|
|
35
169
|
|
|
36
170
|
/** Bind to the session's UI; a no-op outside the TUI. */
|
|
37
171
|
attach(ctx: ExtensionContext): void {
|
|
38
172
|
if (ctx.mode === "tui") this.ctx = ctx;
|
|
39
173
|
}
|
|
40
174
|
|
|
175
|
+
isVisible(): boolean {
|
|
176
|
+
return this.visible;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
isCollapsed(): boolean {
|
|
180
|
+
return this.collapsed;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Collapse or expand, resetting the scroll position; a full redraw covers the height step. */
|
|
184
|
+
toggle(): void {
|
|
185
|
+
this.collapsed = !this.collapsed;
|
|
186
|
+
this.offset = 0;
|
|
187
|
+
this.tui?.requestRender(true);
|
|
188
|
+
}
|
|
189
|
+
|
|
41
190
|
/** Show or hide the widget to match the manager, and repaint. */
|
|
42
191
|
refresh(): void {
|
|
43
192
|
const ctx = this.ctx;
|
|
44
193
|
if (!ctx) return;
|
|
45
|
-
const any = this.
|
|
194
|
+
const any = this.list().some((i) => i.status === "running");
|
|
46
195
|
if (any && !this.visible) {
|
|
47
|
-
ctx.ui.setWidget(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
196
|
+
ctx.ui.setWidget(
|
|
197
|
+
WIDGET_KEY,
|
|
198
|
+
(tui, theme) => {
|
|
199
|
+
this.tui = tui;
|
|
200
|
+
return {
|
|
201
|
+
render: (width: number) => this.render(width, theme),
|
|
202
|
+
handleMouse: (event: TuiMouseEvent) => this.handleMouse(event),
|
|
203
|
+
invalidate() {},
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
{ placement: "aboveEditor" },
|
|
207
|
+
);
|
|
54
208
|
this.visible = true;
|
|
55
209
|
this.startTicking();
|
|
56
210
|
} else if (!any && this.visible) {
|
|
57
211
|
this.hide();
|
|
58
212
|
}
|
|
59
|
-
this.requestRender
|
|
213
|
+
this.tui?.requestRender();
|
|
60
214
|
}
|
|
61
215
|
|
|
62
216
|
/** Remove the widget and stop the timer. */
|
|
@@ -65,16 +219,45 @@ export class MonitorWidget {
|
|
|
65
219
|
this.ctx = undefined;
|
|
66
220
|
}
|
|
67
221
|
|
|
222
|
+
private render(width: number, theme: WidgetTheme): string[] {
|
|
223
|
+
const out = renderWidget(this.list(), (this.options.now ?? Date.now)(), width, theme, {
|
|
224
|
+
collapsed: this.collapsed,
|
|
225
|
+
offset: this.offset,
|
|
226
|
+
maxLines: this.options.maxLines,
|
|
227
|
+
collapseKey: this.options.collapseKey,
|
|
228
|
+
showAll: this.ctx?.ui.getToolsExpanded?.() === true,
|
|
229
|
+
});
|
|
230
|
+
this.offset = out.offset;
|
|
231
|
+
this.maxOffset = out.maxOffset;
|
|
232
|
+
return out.lines;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private handleMouse(event: TuiMouseEvent): { handled: true } | undefined {
|
|
236
|
+
if (event.type === "click" && event.button === "left" && event.y === 0) {
|
|
237
|
+
this.toggle();
|
|
238
|
+
return { handled: true };
|
|
239
|
+
}
|
|
240
|
+
if (event.type !== "wheel" || this.collapsed) return undefined;
|
|
241
|
+
const next = Math.max(0, Math.min(this.maxOffset, this.offset + Math.trunc(event.wheelDelta ?? 0)));
|
|
242
|
+
if (next === this.offset) return undefined;
|
|
243
|
+
this.offset = next;
|
|
244
|
+
this.tui?.requestRender();
|
|
245
|
+
return { handled: true };
|
|
246
|
+
}
|
|
247
|
+
|
|
68
248
|
private hide(): void {
|
|
69
249
|
if (this.timer) clearInterval(this.timer);
|
|
70
250
|
this.timer = undefined;
|
|
71
251
|
if (this.visible) this.ctx?.ui.setWidget(WIDGET_KEY, undefined);
|
|
72
252
|
this.visible = false;
|
|
73
|
-
this.
|
|
253
|
+
this.tui = undefined;
|
|
74
254
|
}
|
|
75
255
|
|
|
76
256
|
private startTicking(): void {
|
|
77
|
-
|
|
257
|
+
// Collapsed rows show no elapsed time; counts repaint through `refresh` on state changes.
|
|
258
|
+
this.timer = setInterval(() => {
|
|
259
|
+
if (!this.collapsed) this.tui?.requestRender();
|
|
260
|
+
}, TICK_MS);
|
|
78
261
|
this.timer.unref();
|
|
79
262
|
}
|
|
80
263
|
}
|
package/src/stats/component.ts
CHANGED
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type Color,
|
|
10
|
+
type Component,
|
|
11
|
+
Key,
|
|
12
|
+
matchesKey,
|
|
13
|
+
truncateToWidth,
|
|
14
|
+
visibleWidth,
|
|
15
|
+
wrapTextWithAnsi,
|
|
16
|
+
} from "@earendil-works/pi-tui";
|
|
9
17
|
import { sanitize } from "../diff/view.ts";
|
|
10
18
|
import {
|
|
11
19
|
chartSeries,
|
|
@@ -39,8 +47,8 @@ import { seriesPalette } from "./palette.ts";
|
|
|
39
47
|
import { timeAxis } from "./ticks.ts";
|
|
40
48
|
|
|
41
49
|
type ThemeLike = Pick<Theme, "fg" | "bg" | "bold" | "style" | "colors">;
|
|
42
|
-
export type Tab = "Overview" | "Models";
|
|
43
|
-
const TABS: readonly Tab[] = ["Overview", "Models"];
|
|
50
|
+
export type Tab = "Overview" | "Models" | "Machines";
|
|
51
|
+
const TABS: readonly Tab[] = ["Overview", "Models", "Machines"];
|
|
44
52
|
/** Chart lines, at most; their colours are shared by the chart, its legend and the model list. */
|
|
45
53
|
const SERIES = 3;
|
|
46
54
|
/** Narrowest plot `plot` can draw; only panes too narrow for a 6-week heatmap hit it. */
|
|
@@ -76,6 +84,8 @@ export interface StatsViewOptions {
|
|
|
76
84
|
today?: string;
|
|
77
85
|
now?: number;
|
|
78
86
|
coverage?: string;
|
|
87
|
+
/** Offline per-source provenance, rendered as a scrollable Machines tab. */
|
|
88
|
+
freshness?: readonly string[];
|
|
79
89
|
tab?: Tab;
|
|
80
90
|
}
|
|
81
91
|
|
|
@@ -126,7 +136,8 @@ export class StatsView implements Component {
|
|
|
126
136
|
return;
|
|
127
137
|
}
|
|
128
138
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) {
|
|
129
|
-
|
|
139
|
+
const step = matchesKey(data, Key.shift("tab")) ? -1 : 1;
|
|
140
|
+
this.tab = TABS[(TABS.indexOf(this.tab) + step + TABS.length) % TABS.length] ?? "Overview";
|
|
130
141
|
this.scroll = 0;
|
|
131
142
|
} else if (data === "r") {
|
|
132
143
|
this.range = RANGES[(RANGES.indexOf(this.range) + 1) % RANGES.length] ?? "all";
|
|
@@ -155,6 +166,13 @@ export class StatsView implements Component {
|
|
|
155
166
|
let body: string[];
|
|
156
167
|
let hint = "";
|
|
157
168
|
if (this.tab === "Overview") body = this.scrolled(this.overviewBody(w, room), room);
|
|
169
|
+
else if (this.tab === "Machines")
|
|
170
|
+
body = this.scrolled(
|
|
171
|
+
(this.o.freshness ?? ["Per-machine freshness unknown (no source metadata)."]).flatMap((line) =>
|
|
172
|
+
wrapTextWithAnsi(this.paint("muted", line), contentWidth(w)),
|
|
173
|
+
),
|
|
174
|
+
room,
|
|
175
|
+
);
|
|
158
176
|
else {
|
|
159
177
|
const models = this.modelsBody(w, room);
|
|
160
178
|
body = this.scrolled(models.lines, room);
|
|
@@ -175,7 +193,7 @@ export class StatsView implements Component {
|
|
|
175
193
|
const joined = spread(tabs, coverage, inner);
|
|
176
194
|
const lines = [
|
|
177
195
|
joined ?? tabs,
|
|
178
|
-
...this.selectors(inner),
|
|
196
|
+
...(this.tab === "Machines" ? [] : this.selectors(inner)),
|
|
179
197
|
...(joined ? [] : [coverage]),
|
|
180
198
|
this.paint("dim", "─".repeat(inner)),
|
|
181
199
|
];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Offline provenance display. Export time is not last activity or proof of fleet completeness. */
|
|
2
|
+
import { sanitize } from "../diff/view.ts";
|
|
3
|
+
import type { Coverage } from "./snapshot.ts";
|
|
4
|
+
|
|
5
|
+
/** Invalid/absent clocks never become fresh; future clocks are explicitly untrustworthy. */
|
|
6
|
+
export function timestampLabel(ms: number | undefined, now: number): string {
|
|
7
|
+
if (ms === undefined || !Number.isFinite(ms) || ms <= 0 || ms > 8640000000000000) return "unknown";
|
|
8
|
+
const timestamp = new Date(ms).toISOString();
|
|
9
|
+
if (ms > now) return `${timestamp} (future; clock skew)`;
|
|
10
|
+
const minutes = Math.floor((now - ms) / 60000);
|
|
11
|
+
const age =
|
|
12
|
+
minutes < 1
|
|
13
|
+
? "<1m"
|
|
14
|
+
: minutes < 60
|
|
15
|
+
? `${minutes}m`
|
|
16
|
+
: minutes < 1440
|
|
17
|
+
? `${Math.floor(minutes / 60)}h`
|
|
18
|
+
: `${Math.floor(minutes / 1440)}d`;
|
|
19
|
+
return `${timestamp} (${age} ago)`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Shared print/TUI lines. UUIDs disambiguate default or duplicate labels without exposing hostnames. */
|
|
23
|
+
export function freshnessLines(coverage: Coverage, now = Date.now()): string[] {
|
|
24
|
+
const lines = [
|
|
25
|
+
`Viewer last import: ${timestampLabel(coverage.importedAt, now)}`,
|
|
26
|
+
"Known sources only; other machines cannot be discovered here.",
|
|
27
|
+
"Export age is not last activity. Local history is scanned on opening.",
|
|
28
|
+
];
|
|
29
|
+
if (!coverage.sources?.length) return [...lines, "Per-machine freshness unknown (no source metadata)."];
|
|
30
|
+
for (const source of [...coverage.sources].sort(
|
|
31
|
+
(a, b) => Number(b.local) - Number(a.local) || a.machineId.localeCompare(b.machineId),
|
|
32
|
+
)) {
|
|
33
|
+
lines.push("", `${sanitize(source.label)} [${source.machineId}]${source.local ? " (local)" : ""}`);
|
|
34
|
+
if (source.local) lines.push(source.incomplete ? "Local scan incomplete" : "Local scan complete at opening");
|
|
35
|
+
else {
|
|
36
|
+
lines.push(`Producer exported: ${timestampLabel(source.exportedAt, now)}`);
|
|
37
|
+
if (source.incomplete)
|
|
38
|
+
lines.push(
|
|
39
|
+
source.exportedAt === undefined
|
|
40
|
+
? "Incomplete import; no last-good snapshot"
|
|
41
|
+
: "Incomplete import; retained last-good snapshot",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return lines;
|
|
46
|
+
}
|
package/src/stats/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { type Clock, realClock } from "../clock.ts";
|
|
|
8
8
|
import type { KitConfig } from "../config.ts";
|
|
9
9
|
import { say } from "../output.ts";
|
|
10
10
|
import { StatsView, summary } from "./component.ts";
|
|
11
|
+
import { freshnessLines } from "./freshness.ts";
|
|
11
12
|
import type { Loaded } from "./load.ts";
|
|
12
13
|
import { type Combined, coverageLabel, loadCombined } from "./snapshot.ts";
|
|
13
14
|
|
|
@@ -52,19 +53,20 @@ export function register(
|
|
|
52
53
|
|
|
53
54
|
const damage = [...problems(loaded), ...loaded.coverage.problems.map((p) => `/stats: ${p}`)];
|
|
54
55
|
const coverage = coverageLabel(loaded.coverage);
|
|
56
|
+
const freshness = freshnessLines(loaded.coverage, clock.now());
|
|
55
57
|
if (!tui) {
|
|
56
58
|
say(
|
|
57
59
|
pi,
|
|
58
60
|
ctx,
|
|
59
61
|
MESSAGE_TYPE,
|
|
60
|
-
[summary(loaded.calls.filter((c) => c.ms <= clock.now())), coverage, ...damage].join("\n"),
|
|
62
|
+
[summary(loaded.calls.filter((c) => c.ms <= clock.now())), coverage, ...freshness, ...damage].join("\n"),
|
|
61
63
|
"info",
|
|
62
64
|
);
|
|
63
65
|
return;
|
|
64
66
|
}
|
|
65
67
|
for (const line of damage) ctx.ui.notify(line, "warning");
|
|
66
68
|
if (loaded.calls.length === 0) {
|
|
67
|
-
ctx.ui.notify(`/stats: no usage recorded yet\n${coverage}`, "info");
|
|
69
|
+
ctx.ui.notify(`/stats: no usage recorded yet\n${coverage}\n${freshness.join("\n")}`, "info");
|
|
68
70
|
return;
|
|
69
71
|
}
|
|
70
72
|
const { calls, sessions, badLines } = loaded;
|
|
@@ -75,6 +77,7 @@ export function register(
|
|
|
75
77
|
sessions,
|
|
76
78
|
badLines,
|
|
77
79
|
coverage,
|
|
80
|
+
freshness,
|
|
78
81
|
now: clock.now(),
|
|
79
82
|
theme,
|
|
80
83
|
rows: () => tuiHost.terminal.rows,
|
package/src/stats/snapshot.ts
CHANGED
|
@@ -63,7 +63,14 @@ export interface GlobalCache {
|
|
|
63
63
|
machines: Record<string, MachineSnapshot>;
|
|
64
64
|
problems: string[];
|
|
65
65
|
}
|
|
66
|
+
/** Known sources only, including failed imports with no last-good snapshot. */
|
|
67
|
+
export interface MachineCoverage extends Identity {
|
|
68
|
+
local: boolean;
|
|
69
|
+
exportedAt?: number;
|
|
70
|
+
incomplete: boolean;
|
|
71
|
+
}
|
|
66
72
|
export interface Coverage {
|
|
73
|
+
sources?: MachineCoverage[];
|
|
67
74
|
machines: number;
|
|
68
75
|
remoteMachines: number;
|
|
69
76
|
importedAt?: number;
|
|
@@ -484,6 +491,22 @@ export async function loadCombined(
|
|
|
484
491
|
sessionMetadata: [...sessions.values()],
|
|
485
492
|
sessions: [...sessions.values()].filter((s) => !s.child && owners.has(s.path)),
|
|
486
493
|
coverage: {
|
|
494
|
+
sources: [
|
|
495
|
+
{ ...identity, local: true, incomplete: local.errors > 0 || local.badLines > 0 },
|
|
496
|
+
...remotes.map(({ manifest }) => ({
|
|
497
|
+
machineId: manifest.machineId,
|
|
498
|
+
label: manifest.label,
|
|
499
|
+
local: false,
|
|
500
|
+
exportedAt: manifest.exportedAt,
|
|
501
|
+
incomplete: global.problems.some((p) => p.endsWith(` ${manifest.machineId}`)),
|
|
502
|
+
})),
|
|
503
|
+
...global.problems.flatMap((p) => {
|
|
504
|
+
const id = /^(?:Incomplete|Missing) snapshot (.+)$/.exec(p)?.[1];
|
|
505
|
+
return id && UUID.test(id) && id !== identity.machineId && !global.machines[id]
|
|
506
|
+
? [{ machineId: id, label: "Pi machine", local: false, incomplete: true }]
|
|
507
|
+
: [];
|
|
508
|
+
}),
|
|
509
|
+
],
|
|
487
510
|
machines: remotes.length + 1,
|
|
488
511
|
remoteMachines: remotes.length,
|
|
489
512
|
importedAt: global.importedAt || undefined,
|
|
@@ -493,5 +516,5 @@ export async function loadCombined(
|
|
|
493
516
|
};
|
|
494
517
|
}
|
|
495
518
|
export function coverageLabel(c: Coverage): string {
|
|
496
|
-
return `${c.incomplete ? "incomplete coverage · " : ""}${c.machines} machine${c.machines === 1 ? "" : "s"} · ${c.remoteMachines ? "cached" : "local-only"} · ${c.importedAt ? `last
|
|
519
|
+
return `${c.incomplete ? "incomplete coverage · " : ""}${c.machines} machine${c.machines === 1 ? "" : "s"} · ${c.remoteMachines ? "cached" : "local-only"} · ${c.importedAt && positive(c.importedAt) ? `viewer last import ${new Date(c.importedAt).toISOString()}` : "viewer never imported"}`;
|
|
497
520
|
}
|