nilometer 0.1.0 → 0.2.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 +6 -1
- package/README.md +5 -3
- package/core/schema/032_stored_views.sql +233 -0
- package/dist/cli/main.cli.js +0 -0
- package/dist/cli/program.js +120 -40
- package/dist/core/ingest/derive.js +10 -3
- package/dist/core/ingest/stored-views.js +65 -0
- package/dist/core/view/server.js +142 -0
- package/dist/viewer/html.js +67 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.2.0 (2026-10-22)
|
|
4
|
+
|
|
5
|
+
- **`nilometer view`:** shows the report page in your browser with a **Refresh** button that reads new log lines and rebuilds the page in place, on the tab you're on. It runs until you press Ctrl+C, listening on 127.0.0.1 only, behind a random address made for each run; a refresh must come from that page. `report --save --html` still writes a page you can keep or share, with no button.
|
|
6
|
+
- **Reports in seconds:** on three months of real data, `report` went from 23 s to under 2 s, and `report --save --html` from 53 s to under 7 s, with the same output. Ingest now stores the two building blocks every report query read, instead of each query recomputing them. The first `ingest` after updating takes a few seconds longer, once.
|
|
7
|
+
|
|
8
|
+
## 0.1.0 (2026-10-22)
|
|
4
9
|
|
|
5
10
|
First public version.
|
|
6
11
|
|
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ Usage percentages exist only from the moment the collector is installed, and onl
|
|
|
104
104
|
|
|
105
105
|
## MVP scope
|
|
106
106
|
|
|
107
|
-
One screen. No daemon, no desktop app, no live updating. Every metric below is retrospective.
|
|
107
|
+
One screen. No daemon, no desktop app, no live updating: `nilometer view` serves the page from your own computer only while you run it, and its Refresh button rebuilds the page when you ask ([D-097](decisions.md)). Every metric below is retrospective.
|
|
108
108
|
|
|
109
109
|
### Observed: "Is my plan too small?"
|
|
110
110
|
|
|
@@ -200,9 +200,10 @@ There's no GUI, installer, or auto-updater. Configuration was always the hard pa
|
|
|
200
200
|
```sh
|
|
201
201
|
npm install -g nilometer
|
|
202
202
|
nilometer init
|
|
203
|
+
nilometer view
|
|
203
204
|
```
|
|
204
205
|
|
|
205
|
-
Installing compiles nothing: the database is the SQLite built into Node.js ([D-093](decisions.md)). To try a command without installing, use `npx nilometer <command>`.
|
|
206
|
+
`init` installs the hook and reads the session logs already on disk. `view` opens the report page in your browser; press **Refresh** on it whenever you want it brought up to date. Installing compiles nothing: the database is the SQLite built into Node.js ([D-093](decisions.md)). To try a command without installing, use `npx nilometer <command>`.
|
|
206
207
|
|
|
207
208
|
- **macOS:** tested.
|
|
208
209
|
- **Linux:** the test suite and an install from the package pass in CI.
|
|
@@ -230,6 +231,7 @@ On Node 24 before 24.15, `npm ci` prints `EBADENGINE` warnings for three lint pl
|
|
|
230
231
|
| `nilometer init` | Install the hook (wrapping any existing status line), then backfill from session logs |
|
|
231
232
|
| `nilometer ingest` | Read new session log lines and status line readings into the database. `--full` rereads everything; `--json` prints the outcome as JSON |
|
|
232
233
|
| `nilometer report` | Print what was observed, then projections in a separate section. `--json` prints unrounded rows and every label. `--save` also writes a dated copy (`report_YYYY-MM-DD_HHMMSS.txt` and `.json`) to `~/.local/share/nilometer/reports/`; `--save --html` adds a self-contained `.html` page with the same name, which works offline and makes no network requests. The page opens on an overview, has one tab per chart, and keeps the projections in their own labelled tabs. `--save --html --open` also opens the saved page in your browser; if it can't, the command says where the page is and still succeeds |
|
|
234
|
+
| `nilometer view` | Show the report page in your browser from your own computer, with a **Refresh** button that reads new log lines and rebuilds the page in place, on the tab you're on. Runs until you press Ctrl+C. It listens on 127.0.0.1 only, behind a random address made for each run. `--port <n>` picks the port; `--no-open` prints the address without opening a browser |
|
|
233
235
|
| `nilometer explain <metric>` | List the events behind a number in the report, with the file, line, and byte each came from, and add them back up. Metrics: `limit-hits`, `mid-task`, `lockout`, `by-plan`, `not-resumed`, `headroom`, `peak`, `crossings`, `unattributed`, `by-model`, `by-repo`, `burn-rate`, `api-list-price` |
|
|
234
236
|
| `nilometer plan-price set <YYYY-MM> <usd> --name <plan>` | Record your plan's USD list price per month from that month on, shown beside API list price. `plan-price list` shows what's entered |
|
|
235
237
|
| `nilometer alias set <old-path> <new-path>` | Count a renamed or moved project's old path under its new one, so it shows as one repository. The report lists every alias you entered under the repository table. `alias list` shows them; `alias remove <old-path>` undoes one ([D-092](decisions.md)) |
|
|
@@ -244,7 +246,7 @@ On Node 24 before 24.15, `npm ci` prints `EBADENGINE` warnings for three lint pl
|
|
|
244
246
|
|
|
245
247
|
### Privacy
|
|
246
248
|
|
|
247
|
-
- **Everything stays on your computer.** Nilometer reads Claude Code's session logs and status line payload, and copies them into a local SQLite database. It makes no network requests when it runs, and it sends no telemetry.
|
|
249
|
+
- **Everything stays on your computer.** Nilometer reads Claude Code's session logs and status line payload, and copies them into a local SQLite database. It makes no network requests when it runs, and it sends no telemetry. `nilometer view` listens on 127.0.0.1 for your own browser while it runs, and nothing else can reach it.
|
|
248
250
|
- **The database holds copies of your session logs:** prompts, code, and file paths. That's why the data directory is readable only by your account.
|
|
249
251
|
- **Reports contain spend figures and project names.** Think before sharing one. `nilometer explain` output also names session files and IDs.
|
|
250
252
|
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
-- 032_stored_views: requests_dedup and window_readings become tables that ingest fills from their views (D-096, step G6.1).
|
|
2
|
+
--
|
|
3
|
+
-- Every report statement recomputed both from the raw tables: window_readings pairs each of tens of thousands of
|
|
4
|
+
-- rate-limit readings with its API response through correlated lookups, and requests_dedup ranks every request line.
|
|
5
|
+
-- On real data the report and page ran about 20 statements built on them and took 45-50 s. Their inputs (requests,
|
|
6
|
+
-- parsed_lines, raw_lines, source_files, status_readings, rate_limit_windows) are written only by ingest, so ingest
|
|
7
|
+
-- stores their rows once (core/ingest/stored-views.ts) and everything downstream reads the stored rows. The
|
|
8
|
+
-- definitions are unchanged and keep running as views under *_computed, so the rows trace back exactly as before,
|
|
9
|
+
-- and a test fails if a stored table ever differs from its view.
|
|
10
|
+
--
|
|
11
|
+
-- Columns are ANY in a STRICT table: no type affinity, so every value is stored exactly as the view returned it.
|
|
12
|
+
|
|
13
|
+
DROP VIEW requests_dedup;
|
|
14
|
+
|
|
15
|
+
-- Observed: one row per distinct API response (winners) plus every unkeyed request line, as 019 computed it; ingest stores its rows in requests_dedup (D-096).
|
|
16
|
+
CREATE VIEW requests_dedup_computed AS
|
|
17
|
+
WITH ranked AS (
|
|
18
|
+
SELECT
|
|
19
|
+
r.raw_line_id,
|
|
20
|
+
r.dedup_key,
|
|
21
|
+
r.message_id,
|
|
22
|
+
r.request_id,
|
|
23
|
+
r.model,
|
|
24
|
+
r.input_tokens,
|
|
25
|
+
r.output_tokens,
|
|
26
|
+
r.cache_read_tokens,
|
|
27
|
+
r.cache_write_5m_tokens,
|
|
28
|
+
r.cache_write_1h_tokens,
|
|
29
|
+
r.cache_write_unsplit_tokens,
|
|
30
|
+
r.speed,
|
|
31
|
+
p.session_id,
|
|
32
|
+
p.timestamp_raw,
|
|
33
|
+
p.timestamp_utc,
|
|
34
|
+
p.cwd,
|
|
35
|
+
p.is_sidechain,
|
|
36
|
+
f.root,
|
|
37
|
+
f.relative_path,
|
|
38
|
+
l.first_run_id,
|
|
39
|
+
l.line_number,
|
|
40
|
+
ROW_NUMBER() OVER (
|
|
41
|
+
PARTITION BY r.dedup_key
|
|
42
|
+
ORDER BY r.output_tokens DESC, p.is_sidechain ASC,
|
|
43
|
+
-- Among lines tied on output, the earliest: when those numbers first existed
|
|
44
|
+
-- (D-065, the same rule D-045 applies to readings). NULLs last, so an unparsed
|
|
45
|
+
-- timestamp never wins.
|
|
46
|
+
(p.timestamp_utc IS NULL) ASC, p.timestamp_utc ASC,
|
|
47
|
+
f.root DESC, f.relative_path DESC, l.first_run_id DESC, l.line_number DESC
|
|
48
|
+
) AS candidate_rank
|
|
49
|
+
FROM requests r
|
|
50
|
+
JOIN parsed_lines p ON p.raw_line_id = r.raw_line_id
|
|
51
|
+
JOIN raw_lines l ON l.id = r.raw_line_id
|
|
52
|
+
JOIN source_files f ON f.id = l.source_file_id
|
|
53
|
+
)
|
|
54
|
+
SELECT
|
|
55
|
+
raw_line_id, dedup_key, message_id, request_id, model,
|
|
56
|
+
input_tokens, output_tokens, cache_read_tokens,
|
|
57
|
+
cache_write_5m_tokens, cache_write_1h_tokens, cache_write_unsplit_tokens, speed,
|
|
58
|
+
session_id, timestamp_raw, timestamp_utc, cwd, is_sidechain,
|
|
59
|
+
root, relative_path, first_run_id, line_number
|
|
60
|
+
FROM ranked
|
|
61
|
+
WHERE dedup_key IS NULL OR candidate_rank = 1;
|
|
62
|
+
|
|
63
|
+
CREATE TABLE requests_dedup (
|
|
64
|
+
raw_line_id ANY, -- derived: requests_dedup_computed.raw_line_id
|
|
65
|
+
dedup_key ANY, -- derived: requests_dedup_computed.dedup_key
|
|
66
|
+
message_id ANY, -- derived: requests_dedup_computed.message_id
|
|
67
|
+
request_id ANY, -- derived: requests_dedup_computed.request_id
|
|
68
|
+
model ANY, -- derived: requests_dedup_computed.model
|
|
69
|
+
input_tokens ANY, -- derived: requests_dedup_computed.input_tokens
|
|
70
|
+
output_tokens ANY, -- derived: requests_dedup_computed.output_tokens
|
|
71
|
+
cache_read_tokens ANY, -- derived: requests_dedup_computed.cache_read_tokens
|
|
72
|
+
cache_write_5m_tokens ANY, -- derived: requests_dedup_computed.cache_write_5m_tokens
|
|
73
|
+
cache_write_1h_tokens ANY, -- derived: requests_dedup_computed.cache_write_1h_tokens
|
|
74
|
+
cache_write_unsplit_tokens ANY, -- derived: requests_dedup_computed.cache_write_unsplit_tokens
|
|
75
|
+
speed ANY, -- derived: requests_dedup_computed.speed
|
|
76
|
+
session_id ANY, -- derived: requests_dedup_computed.session_id
|
|
77
|
+
timestamp_raw ANY, -- derived: requests_dedup_computed.timestamp_raw
|
|
78
|
+
timestamp_utc ANY, -- derived: requests_dedup_computed.timestamp_utc
|
|
79
|
+
cwd ANY, -- derived: requests_dedup_computed.cwd
|
|
80
|
+
is_sidechain ANY, -- derived: requests_dedup_computed.is_sidechain
|
|
81
|
+
root ANY, -- derived: requests_dedup_computed.root
|
|
82
|
+
relative_path ANY, -- derived: requests_dedup_computed.relative_path
|
|
83
|
+
first_run_id ANY, -- derived: requests_dedup_computed.first_run_id
|
|
84
|
+
line_number ANY -- derived: requests_dedup_computed.line_number
|
|
85
|
+
) STRICT;
|
|
86
|
+
|
|
87
|
+
CREATE INDEX requests_dedup_raw_line_id ON requests_dedup (raw_line_id);
|
|
88
|
+
CREATE INDEX requests_dedup_dedup_key ON requests_dedup (dedup_key);
|
|
89
|
+
CREATE INDEX requests_dedup_time ON requests_dedup (timestamp_utc);
|
|
90
|
+
CREATE INDEX requests_dedup_session_time ON requests_dedup (session_id, timestamp_utc);
|
|
91
|
+
CREATE INDEX requests_dedup_cwd ON requests_dedup (cwd);
|
|
92
|
+
|
|
93
|
+
INSERT INTO requests_dedup (raw_line_id, dedup_key, message_id, request_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_write_unsplit_tokens, speed, session_id, timestamp_raw, timestamp_utc, cwd, is_sidechain, root, relative_path, first_run_id, line_number)
|
|
94
|
+
SELECT raw_line_id, dedup_key, message_id, request_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_write_unsplit_tokens, speed, session_id, timestamp_raw, timestamp_utc, cwd, is_sidechain, root, relative_path, first_run_id, line_number FROM requests_dedup_computed;
|
|
95
|
+
|
|
96
|
+
DROP VIEW window_readings;
|
|
97
|
+
|
|
98
|
+
-- Observed: one row per observation of a plan window, as 028 computed it; ingest stores its rows in window_readings (D-096). As in 026 (D-044, D-045, D-086), with stale = 1 when a counted reading is more than 1 point below an earlier counted one in its window (D-089).
|
|
99
|
+
CREATE VIEW window_readings_computed AS
|
|
100
|
+
WITH readings AS (
|
|
101
|
+
SELECT
|
|
102
|
+
w.raw_line_id, w.window, w.resets_at, w.used_percentage, s.session_id, s.captured_at_s,
|
|
103
|
+
-- The response behind the latest request line in this session, inside this window, at or before
|
|
104
|
+
-- the capture (+1 s for whole-second captures). An unkeyed line is its own response.
|
|
105
|
+
(SELECT COALESCE(rq.dedup_key, 'line:' || rq.raw_line_id)
|
|
106
|
+
FROM parsed_lines p
|
|
107
|
+
JOIN requests rq ON rq.raw_line_id = p.raw_line_id
|
|
108
|
+
WHERE p.session_id = s.session_id AND p.class = 'request'
|
|
109
|
+
AND p.timestamp_utc > strftime('%Y-%m-%dT%H:%M:%fZ',
|
|
110
|
+
w.resets_at - CASE w.window WHEN 'five_hour' THEN 5 * 3600 ELSE 7 * 86400 END, 'unixepoch')
|
|
111
|
+
AND p.timestamp_utc < strftime('%Y-%m-%dT%H:%M:%fZ', s.captured_at_s + 1, 'unixepoch')
|
|
112
|
+
ORDER BY p.timestamp_utc DESC, p.raw_line_id DESC
|
|
113
|
+
LIMIT 1) AS response_key
|
|
114
|
+
FROM rate_limit_windows w
|
|
115
|
+
JOIN status_readings s ON s.raw_line_id = w.raw_line_id
|
|
116
|
+
WHERE s.status = 'ok' AND w.validity = 'valid' AND w.window IN ('five_hour', 'seven_day')
|
|
117
|
+
),
|
|
118
|
+
dated AS (
|
|
119
|
+
SELECT
|
|
120
|
+
r.*,
|
|
121
|
+
-- The response's first line: when its numbers first existed.
|
|
122
|
+
(SELECT MIN(p.timestamp_utc)
|
|
123
|
+
FROM requests rq
|
|
124
|
+
JOIN parsed_lines p ON p.raw_line_id = rq.raw_line_id
|
|
125
|
+
WHERE (r.response_key LIKE 'line:%' AND rq.raw_line_id = CAST(substr(r.response_key, 6) AS INTEGER))
|
|
126
|
+
OR rq.dedup_key = r.response_key) AS response_first_utc,
|
|
127
|
+
-- The previous capture of the same response, in capture order.
|
|
128
|
+
LAG(r.used_percentage) OVER captures AS previous_used_percentage
|
|
129
|
+
FROM readings r
|
|
130
|
+
-- A capture-time fallback is its own group: NULL response keys must not merge (D-044).
|
|
131
|
+
WINDOW captures AS (
|
|
132
|
+
PARTITION BY r.window, r.resets_at, r.session_id, r.response_key,
|
|
133
|
+
CASE WHEN r.response_key IS NULL THEN r.raw_line_id END
|
|
134
|
+
ORDER BY r.captured_at_s, r.raw_line_id
|
|
135
|
+
)
|
|
136
|
+
),
|
|
137
|
+
runs AS (
|
|
138
|
+
SELECT
|
|
139
|
+
d.*,
|
|
140
|
+
-- A new run starts at the first capture and at every change of more than 0.01 points: payloads
|
|
141
|
+
-- carry computed values such as 7.000000000000001, which are the same number (D-044, D-086).
|
|
142
|
+
SUM(CASE WHEN d.previous_used_percentage IS NULL
|
|
143
|
+
OR abs(d.used_percentage - d.previous_used_percentage) > 0.01 THEN 1 ELSE 0 END)
|
|
144
|
+
OVER (
|
|
145
|
+
PARTITION BY d.window, d.resets_at, d.session_id, d.response_key,
|
|
146
|
+
CASE WHEN d.response_key IS NULL THEN d.raw_line_id END
|
|
147
|
+
ORDER BY d.captured_at_s, d.raw_line_id
|
|
148
|
+
ROWS UNBOUNDED PRECEDING
|
|
149
|
+
) AS run
|
|
150
|
+
FROM dated d
|
|
151
|
+
),
|
|
152
|
+
observed AS (
|
|
153
|
+
SELECT
|
|
154
|
+
u.*,
|
|
155
|
+
-- A later run's value didn't come from the response, so it's dated when it was first seen.
|
|
156
|
+
CASE
|
|
157
|
+
WHEN u.response_first_utc IS NULL THEN strftime('%Y-%m-%dT%H:%M:%fZ', u.captured_at_s, 'unixepoch')
|
|
158
|
+
WHEN u.run = 1 THEN u.response_first_utc
|
|
159
|
+
ELSE strftime('%Y-%m-%dT%H:%M:%fZ', MIN(u.captured_at_s) OVER run_captures, 'unixepoch')
|
|
160
|
+
END AS observed_at_utc,
|
|
161
|
+
ROW_NUMBER() OVER run_captures_latest AS copy_rank
|
|
162
|
+
FROM runs u
|
|
163
|
+
WINDOW
|
|
164
|
+
run_captures AS (
|
|
165
|
+
PARTITION BY u.window, u.resets_at, u.session_id, u.response_key,
|
|
166
|
+
CASE WHEN u.response_key IS NULL THEN u.raw_line_id END, u.run
|
|
167
|
+
),
|
|
168
|
+
run_captures_latest AS (
|
|
169
|
+
PARTITION BY u.window, u.resets_at, u.session_id, u.response_key,
|
|
170
|
+
CASE WHEN u.response_key IS NULL THEN u.raw_line_id END, u.run
|
|
171
|
+
ORDER BY u.captured_at_s DESC, u.raw_line_id DESC
|
|
172
|
+
)
|
|
173
|
+
),
|
|
174
|
+
counted AS (
|
|
175
|
+
SELECT
|
|
176
|
+
raw_line_id,
|
|
177
|
+
window,
|
|
178
|
+
resets_at,
|
|
179
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', resets_at, 'unixepoch') AS reset_at_utc,
|
|
180
|
+
used_percentage,
|
|
181
|
+
session_id,
|
|
182
|
+
captured_at_s,
|
|
183
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', captured_at_s, 'unixepoch') AS captured_at_utc,
|
|
184
|
+
observed_at_utc,
|
|
185
|
+
unixepoch(observed_at_utc, 'subsec') AS observed_at_s,
|
|
186
|
+
CASE
|
|
187
|
+
WHEN response_key IS NULL THEN 'capture'
|
|
188
|
+
WHEN run = 1 THEN 'last_request'
|
|
189
|
+
ELSE 'changed_capture'
|
|
190
|
+
END AS observed_via,
|
|
191
|
+
unixepoch(observed_at_utc, 'subsec') > resets_at AS after_reset
|
|
192
|
+
FROM observed
|
|
193
|
+
WHERE copy_rank = 1
|
|
194
|
+
)
|
|
195
|
+
SELECT
|
|
196
|
+
c.*,
|
|
197
|
+
-- Usage within one window only rises, so a counted reading more than 1 point below an earlier one
|
|
198
|
+
-- in the same window, from any session, carries a number from before that earlier reading (D-089).
|
|
199
|
+
CASE
|
|
200
|
+
WHEN c.after_reset = 0 AND c.used_percentage < MAX(c.used_percentage) OVER (
|
|
201
|
+
PARTITION BY c.window, c.resets_at ORDER BY c.observed_at_s, c.raw_line_id
|
|
202
|
+
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
|
203
|
+
) - 1 THEN 1
|
|
204
|
+
ELSE 0
|
|
205
|
+
END AS stale
|
|
206
|
+
FROM counted c;
|
|
207
|
+
|
|
208
|
+
CREATE TABLE window_readings (
|
|
209
|
+
raw_line_id ANY, -- derived: window_readings_computed.raw_line_id
|
|
210
|
+
window ANY, -- derived: window_readings_computed.window
|
|
211
|
+
resets_at ANY, -- derived: window_readings_computed.resets_at
|
|
212
|
+
reset_at_utc ANY, -- derived: window_readings_computed.reset_at_utc
|
|
213
|
+
used_percentage ANY, -- derived: window_readings_computed.used_percentage
|
|
214
|
+
session_id ANY, -- derived: window_readings_computed.session_id
|
|
215
|
+
captured_at_s ANY, -- derived: window_readings_computed.captured_at_s
|
|
216
|
+
captured_at_utc ANY, -- derived: window_readings_computed.captured_at_utc
|
|
217
|
+
observed_at_utc ANY, -- derived: window_readings_computed.observed_at_utc
|
|
218
|
+
observed_at_s ANY, -- derived: window_readings_computed.observed_at_s
|
|
219
|
+
observed_via ANY, -- derived: window_readings_computed.observed_via
|
|
220
|
+
after_reset ANY, -- derived: window_readings_computed.after_reset
|
|
221
|
+
stale ANY -- derived: window_readings_computed.stale
|
|
222
|
+
) STRICT;
|
|
223
|
+
|
|
224
|
+
CREATE INDEX window_readings_raw_line ON window_readings (raw_line_id);
|
|
225
|
+
CREATE INDEX window_readings_instance ON window_readings (window, reset_at_utc, observed_at_s);
|
|
226
|
+
CREATE INDEX window_readings_session_time ON window_readings (session_id, observed_at_s);
|
|
227
|
+
CREATE INDEX window_readings_time ON window_readings (observed_at_utc);
|
|
228
|
+
|
|
229
|
+
INSERT INTO window_readings (raw_line_id, window, resets_at, reset_at_utc, used_percentage, session_id, captured_at_s, captured_at_utc, observed_at_utc, observed_at_s, observed_via, after_reset, stale)
|
|
230
|
+
SELECT raw_line_id, window, resets_at, reset_at_utc, used_percentage, session_id, captured_at_s, captured_at_utc, observed_at_utc, observed_at_s, observed_via, after_reset, stale FROM window_readings_computed;
|
|
231
|
+
|
|
232
|
+
-- Statistics for the query planner, so the first report after this migration is fast too (D-096).
|
|
233
|
+
ANALYZE;
|
package/dist/cli/main.cli.js
CHANGED
|
File without changes
|
package/dist/cli/program.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* calls it, and turns outcomes into words. All wording here follows CLAUDE.md "Wording is part of
|
|
8
8
|
* correctness": messages state what happened, never advice or judgment.
|
|
9
9
|
*/
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
10
11
|
import { dirname } from "node:path";
|
|
11
12
|
import { Command, CommanderError } from "commander";
|
|
12
13
|
import { transaction } from "../core/db/database.js";
|
|
@@ -21,6 +22,7 @@ import { runInit, runUninstall, } from "../core/install/install.js";
|
|
|
21
22
|
import { SettingsError, locateSettings } from "../core/settings/settings-file.js";
|
|
22
23
|
import { CCUSAGE_VERSION, runCcusage, runVerify, } from "../core/verify/verify.js";
|
|
23
24
|
import { openPage } from "../core/util/open-page.js";
|
|
25
|
+
import { listenView } from "../core/view/server.js";
|
|
24
26
|
import { RepositoryAliasError, listAliases, normalizeRepositoryPath, removeAlias, setAlias, validateAlias, } from "../core/plans/repository-aliases.js";
|
|
25
27
|
import { METRIC_NAMES, UnknownMetricError, explain, renderExplanation } from "../viewer/explain.js";
|
|
26
28
|
import { formatNumber } from "../viewer/format.js";
|
|
@@ -28,11 +30,25 @@ import { NoDatabaseError, readReport, summarizeStoredData, withReportDatabase, }
|
|
|
28
30
|
import { loadHtmlData } from "../viewer/html-data.js";
|
|
29
31
|
import { renderHtmlReport } from "../viewer/html.js";
|
|
30
32
|
import { saveReport } from "../viewer/save.js";
|
|
31
|
-
import { displayPath, plural, renderReport, reportJson } from "../viewer/render.js";
|
|
33
|
+
import { displayPath, plural, renderReport, reportJson, } from "../viewer/render.js";
|
|
32
34
|
/** The ccusage release `verify` compares against; pinned so two runs compare the same way. */
|
|
33
35
|
const CCUSAGE_PIN = CCUSAGE_VERSION;
|
|
34
36
|
/** Readings need a terminal session (D-029); said at install so empty usage windows aren't a surprise. */
|
|
35
37
|
export const TERMINAL_ONLY_NOTE = "Readings are recorded only when Claude Code runs in a terminal; the VS Code extension doesn't run the status line.";
|
|
38
|
+
/** Refusal for a `--port` that isn't a port number. */
|
|
39
|
+
export const BAD_PORT = "--port needs a whole number from 1 to 65535, or leave it out for any free port. Nothing was changed.";
|
|
40
|
+
/**
|
|
41
|
+
* Reads `--port`.
|
|
42
|
+
* @param value - The flag's text, if given.
|
|
43
|
+
* @returns The port, 0 for any free one, or undefined when the text isn't a port.
|
|
44
|
+
*/
|
|
45
|
+
export function parsePort(value) {
|
|
46
|
+
if (value === undefined) {
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const port = /^\d{1,5}$/.test(value) ? Number(value) : NaN;
|
|
50
|
+
return port >= 1 && port <= 65_535 ? port : undefined;
|
|
51
|
+
}
|
|
36
52
|
/**
|
|
37
53
|
* Refusal for `--open` without both `--save` and `--html`: only a saved page exists to open (D-082).
|
|
38
54
|
* Checked first, so it names everything `--open` needs.
|
|
@@ -491,6 +507,55 @@ function openSaved(deps, htmlPath) {
|
|
|
491
507
|
`Couldn't open the page (${opened.reason}). It's saved at ${displayPath(htmlPath, deps.home)}`,
|
|
492
508
|
];
|
|
493
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* Reads the report, and the page when asked, from one snapshot of the database: one read
|
|
512
|
+
* transaction, so an ingest finishing between the two reads can't make them disagree (D-070: "the
|
|
513
|
+
* same views, so the two can't disagree"). Shared by `report` and `view` (D-097).
|
|
514
|
+
* @param deps - Program dependencies.
|
|
515
|
+
* @param flags - The command's flags.
|
|
516
|
+
* @param flags.dataDir - `--data-dir`, if given.
|
|
517
|
+
* @param reportedAt - The report's time: a window is open only until its reset, as of now (D-083).
|
|
518
|
+
* @param withPage - Whether to render the page too.
|
|
519
|
+
* @param serving - How `view` serves the page; omitted for a saved page.
|
|
520
|
+
* @returns The text report's input, and the page when asked.
|
|
521
|
+
*/
|
|
522
|
+
function readSnapshot(deps, flags, reportedAt, withPage, serving) {
|
|
523
|
+
const reportOptions = {
|
|
524
|
+
...toPlanDatabaseOptions(flags, deps),
|
|
525
|
+
timeZone: deps.timeZone,
|
|
526
|
+
reportedAt,
|
|
527
|
+
// Where init would look, so the warning checks the same file it would fix (D-081).
|
|
528
|
+
settingsPath: locateSettings({
|
|
529
|
+
home: deps.home,
|
|
530
|
+
claudeConfigDir: deps.env["CLAUDE_CONFIG_DIR"],
|
|
531
|
+
}),
|
|
532
|
+
};
|
|
533
|
+
return withReportDatabase(reportOptions, (db, databasePath) => transaction(db, () => {
|
|
534
|
+
const read = readReport(db, databasePath, reportOptions);
|
|
535
|
+
return {
|
|
536
|
+
input: read,
|
|
537
|
+
html: withPage
|
|
538
|
+
? renderHtmlReport(
|
|
539
|
+
// Reuses what readReport just read from this snapshot, so the costliest views are
|
|
540
|
+
// computed once (D-070). The home directory shortens repository paths to ~/..., as
|
|
541
|
+
// in the text report.
|
|
542
|
+
loadHtmlData(db, {
|
|
543
|
+
timeZone: deps.timeZone,
|
|
544
|
+
generatedAt: reportedAt,
|
|
545
|
+
home: deps.home,
|
|
546
|
+
missingHookPath: read.missingHookPath,
|
|
547
|
+
}, read), serving)
|
|
548
|
+
: undefined,
|
|
549
|
+
};
|
|
550
|
+
}),
|
|
551
|
+
/**
|
|
552
|
+
* Says on stderr that data paths were made owner-only (D-043).
|
|
553
|
+
* @param tightened - Paths that were tightened.
|
|
554
|
+
*/
|
|
555
|
+
(tightened) => {
|
|
556
|
+
noteTightened(deps, tightened);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
494
559
|
/**
|
|
495
560
|
* Builds the command-line program.
|
|
496
561
|
* @param deps - Program dependencies.
|
|
@@ -579,49 +644,11 @@ export function buildProgram(deps, setExitCode) {
|
|
|
579
644
|
if (flags.html === true && flags.save !== true) {
|
|
580
645
|
return { exitCode: 1, lines: [], errors: [HTML_NEEDS_SAVE] };
|
|
581
646
|
}
|
|
582
|
-
/**
|
|
583
|
-
* Says on stderr that data paths were made owner-only.
|
|
584
|
-
* @param tightened - Paths that were tightened.
|
|
585
|
-
*/
|
|
586
|
-
const onTightened = (tightened) => {
|
|
587
|
-
noteTightened(deps, tightened);
|
|
588
|
-
};
|
|
589
647
|
// One instant is the report's time (D-083: a window is open only until its reset, as
|
|
590
648
|
// of now), names the saved files, and dates the page, so none of them can disagree.
|
|
591
649
|
const reportedAt = deps.now();
|
|
592
|
-
const reportOptions = {
|
|
593
|
-
...toPlanDatabaseOptions(flags, deps),
|
|
594
|
-
timeZone: deps.timeZone,
|
|
595
|
-
reportedAt,
|
|
596
|
-
// Where init would look, so the warning checks the same file it would fix (D-081).
|
|
597
|
-
settingsPath: locateSettings({
|
|
598
|
-
home: deps.home,
|
|
599
|
-
claudeConfigDir: deps.env["CLAUDE_CONFIG_DIR"],
|
|
600
|
-
}),
|
|
601
|
-
};
|
|
602
650
|
const at = flags.save === true ? reportedAt : undefined;
|
|
603
|
-
const { input, html } =
|
|
604
|
-
// One read transaction for the text and the page: they come from one snapshot of the
|
|
605
|
-
// database, so an ingest finishing between the two reads can't make them disagree
|
|
606
|
-
// (D-070: "the same views, so the two can't disagree").
|
|
607
|
-
transaction(db, () => {
|
|
608
|
-
const read = readReport(db, databasePath, reportOptions);
|
|
609
|
-
return {
|
|
610
|
-
input: read,
|
|
611
|
-
html: at !== undefined && flags.html === true
|
|
612
|
-
? renderHtmlReport(
|
|
613
|
-
// Reuses what readReport just read from this snapshot, so the costliest
|
|
614
|
-
// views are computed once (D-070).
|
|
615
|
-
// The home directory shortens repository paths to ~/..., as in the text report.
|
|
616
|
-
loadHtmlData(db, {
|
|
617
|
-
timeZone: deps.timeZone,
|
|
618
|
-
generatedAt: at,
|
|
619
|
-
home: deps.home,
|
|
620
|
-
missingHookPath: read.missingHookPath,
|
|
621
|
-
}, read))
|
|
622
|
-
: undefined,
|
|
623
|
-
};
|
|
624
|
-
}), onTightened);
|
|
651
|
+
const { input, html } = readSnapshot(deps, flags, reportedAt, at !== undefined && flags.html === true);
|
|
625
652
|
const printed = flags.json === true ? JSON.stringify(reportJson(input), null, 2) : renderReport(input);
|
|
626
653
|
if (at === undefined) {
|
|
627
654
|
return { exitCode: 0, lines: [printed] };
|
|
@@ -643,6 +670,59 @@ export function buildProgram(deps, setExitCode) {
|
|
|
643
670
|
return { exitCode: 0, lines: [printed, "", note], errors: notOpened };
|
|
644
671
|
}));
|
|
645
672
|
});
|
|
673
|
+
program
|
|
674
|
+
.command("view")
|
|
675
|
+
.description("Show the report page in your browser from your own computer (127.0.0.1), with a Refresh button that reads new log lines and rebuilds it. Runs until you press Ctrl+C.")
|
|
676
|
+
.option("--data-dir <path>", "data directory holding the database and spool")
|
|
677
|
+
.option("--port <number>", "port on 127.0.0.1 (default: any free port)")
|
|
678
|
+
.option("--no-open", "print the address without opening a browser")
|
|
679
|
+
.action((flags) => {
|
|
680
|
+
setExitCode(report(deps, () => {
|
|
681
|
+
const port = parsePort(flags.port);
|
|
682
|
+
if (port === undefined) {
|
|
683
|
+
return { exitCode: 1, lines: [], errors: [BAD_PORT] };
|
|
684
|
+
}
|
|
685
|
+
const token = (deps.viewToken ?? (() => randomBytes(24).toString("base64url")))();
|
|
686
|
+
const serving = { refreshPath: `/${token}/refresh` };
|
|
687
|
+
/**
|
|
688
|
+
* Ingests, then renders the page from what's stored now.
|
|
689
|
+
* @returns The page, and how many new lines the ingest stored.
|
|
690
|
+
*/
|
|
691
|
+
const build = () => {
|
|
692
|
+
const outcome = runIngestCommand(toIngestOptions(flags, deps));
|
|
693
|
+
const { html } = readSnapshot(deps, flags, deps.now(), true, serving);
|
|
694
|
+
return { page: html ?? "", stored: outcome.run.linesStored };
|
|
695
|
+
};
|
|
696
|
+
let current = build();
|
|
697
|
+
(deps.serveView ?? listenView)({
|
|
698
|
+
token,
|
|
699
|
+
port,
|
|
700
|
+
page: () => current.page,
|
|
701
|
+
refresh: () => {
|
|
702
|
+
current = build();
|
|
703
|
+
return {
|
|
704
|
+
ok: true,
|
|
705
|
+
message: `${plural(current.stored, "new line", "new lines")} read`,
|
|
706
|
+
};
|
|
707
|
+
},
|
|
708
|
+
onListening: (url) => {
|
|
709
|
+
deps.print(`Showing the report at ${url}`);
|
|
710
|
+
deps.print("Refresh on the page reads new log lines and rebuilds it. Press Ctrl+C here to stop.");
|
|
711
|
+
if (flags.open !== false) {
|
|
712
|
+
const opened = (deps.openPage ?? openPage)(url);
|
|
713
|
+
if (!opened.opened) {
|
|
714
|
+
deps.printError(`Couldn't open a browser (${opened.reason}). Open the address above.`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
onError: (message) => {
|
|
719
|
+
deps.printError(`nilometer view stopped: ${message}`);
|
|
720
|
+
process.exitCode = 1;
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
return { exitCode: 0, lines: [] };
|
|
724
|
+
}));
|
|
725
|
+
});
|
|
646
726
|
program
|
|
647
727
|
.command("explain <metric>")
|
|
648
728
|
.description(`List the events behind a number in the report, with where each was read from, and re-add them. Metrics: ${METRIC_NAMES.join(", ")}.`)
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* in SQL views over these tables.
|
|
7
7
|
*/
|
|
8
8
|
import { blobBytes, transaction } from "../db/database.js";
|
|
9
|
+
import { refreshStoredViews } from "./stored-views.js";
|
|
9
10
|
import { classifyLine, extractEvent, extractLinks, extractRequest, identityString, parseLine, parseTimestamp, } from "./classify.js";
|
|
10
11
|
import { resolveResetTime } from "./reset-time.js";
|
|
11
12
|
import { deriveNewSpoolLines } from "./spool.js";
|
|
@@ -198,15 +199,21 @@ export function rebuildDerived(db) {
|
|
|
198
199
|
}
|
|
199
200
|
/**
|
|
200
201
|
* Brings derived tables up to date: a full rebuild if the parser version changed, otherwise only
|
|
201
|
-
* the raw lines not yet derived.
|
|
202
|
+
* the raw lines not yet derived. Then refills the stored views when their inputs changed (D-096).
|
|
202
203
|
* @param db - Open, migrated database.
|
|
203
204
|
* @returns Number of raw lines derived and whether a rebuild happened.
|
|
204
205
|
*/
|
|
205
206
|
export function ensureDerived(db) {
|
|
206
207
|
const row = db.prepare("SELECT value FROM derive_meta WHERE key = 'parser_version'").get();
|
|
207
208
|
if (row?.value !== PARSER_VERSION) {
|
|
208
|
-
|
|
209
|
+
const derived = rebuildDerived(db);
|
|
210
|
+
// Every derived row was replaced, so the stored views are refilled even if the counts match (D-096).
|
|
211
|
+
refreshStoredViews(db, true);
|
|
212
|
+
return { derived, rebuilt: true };
|
|
209
213
|
}
|
|
210
|
-
|
|
214
|
+
const derived = deriveNewLines(db) + deriveNewSpoolLines(db);
|
|
215
|
+
// After deriving, so the report's stored rows include this run's lines (D-096).
|
|
216
|
+
refreshStoredViews(db);
|
|
217
|
+
return { derived, rebuilt: false };
|
|
211
218
|
}
|
|
212
219
|
//# sourceMappingURL=derive.js.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { transaction } from "../db/database.js";
|
|
2
|
+
/** Each stored table and the view whose rows it holds. */
|
|
3
|
+
export const STORED_VIEWS = [
|
|
4
|
+
{ table: "requests_dedup", view: "requests_dedup_computed" },
|
|
5
|
+
{ table: "window_readings", view: "window_readings_computed" },
|
|
6
|
+
];
|
|
7
|
+
/** Where the inputs' signature from the last refresh is kept, in `derive_meta`. */
|
|
8
|
+
const SIGNATURE_KEY = "stored_views_input";
|
|
9
|
+
/** Tables the computed views read, each written only by ingest. */
|
|
10
|
+
const INPUT_TABLES = [
|
|
11
|
+
"raw_lines",
|
|
12
|
+
"parsed_lines",
|
|
13
|
+
"requests",
|
|
14
|
+
"status_readings",
|
|
15
|
+
"rate_limit_windows",
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Describes the computed views' inputs cheaply enough to check on every ingest: each input table's
|
|
19
|
+
* row count and highest rowid, and every source file's root and path (requests_dedup carries them).
|
|
20
|
+
* Rows in those tables are only inserted, or all deleted and derived again, which a rebuild forces
|
|
21
|
+
* a refresh for anyway; a path changes only when Windows separators are normalized.
|
|
22
|
+
* @param db - Open, migrated database.
|
|
23
|
+
* @returns A string that changes whenever an input does.
|
|
24
|
+
*/
|
|
25
|
+
export function inputSignature(db) {
|
|
26
|
+
const counts = INPUT_TABLES.map((table) => {
|
|
27
|
+
const row = db
|
|
28
|
+
.prepare(`SELECT COUNT(*) AS n, COALESCE(MAX(rowid), 0) AS top FROM ${table}`)
|
|
29
|
+
.get();
|
|
30
|
+
return `${table}:${row.n}:${row.top}`;
|
|
31
|
+
});
|
|
32
|
+
const paths = db
|
|
33
|
+
.prepare("SELECT COUNT(*) AS n, COALESCE(group_concat(id || '|' || root || '|' || relative_path, '\n'), '') AS list FROM (SELECT id, root, relative_path FROM source_files ORDER BY id)")
|
|
34
|
+
.get();
|
|
35
|
+
return [...counts, `source_files:${paths.n}:${paths.list}`].join("\n");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Refills each stored table from its view when an input changed since the last refresh.
|
|
39
|
+
* @param db - Open, migrated database.
|
|
40
|
+
* @param force - Refill even if the inputs look unchanged (after a full rebuild of derived rows).
|
|
41
|
+
* @returns Whether the tables were refilled.
|
|
42
|
+
*/
|
|
43
|
+
export function refreshStoredViews(db, force = false) {
|
|
44
|
+
const signature = inputSignature(db);
|
|
45
|
+
const stored = db.prepare("SELECT value FROM derive_meta WHERE key = ?").get(SIGNATURE_KEY);
|
|
46
|
+
if (!force && stored?.value === signature) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
transaction(db, () => {
|
|
50
|
+
for (const { table, view } of STORED_VIEWS) {
|
|
51
|
+
// Named columns, in the table's order, so the copy can't depend on the view's column order.
|
|
52
|
+
const columns = db.prepare("SELECT name FROM pragma_table_info(?) ORDER BY cid").all(table)
|
|
53
|
+
.map((c) => c.name)
|
|
54
|
+
.join(", ");
|
|
55
|
+
db.exec(`DELETE FROM ${table}`);
|
|
56
|
+
db.exec(`INSERT INTO ${table} (${columns}) SELECT ${columns} FROM ${view}`);
|
|
57
|
+
}
|
|
58
|
+
db.prepare("INSERT INTO derive_meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(SIGNATURE_KEY, signature);
|
|
59
|
+
});
|
|
60
|
+
// Table statistics for the query planner: on real data they halved the page's queries again, for
|
|
61
|
+
// about 0.4 s here. They change how a query runs, never what it returns.
|
|
62
|
+
db.exec("ANALYZE");
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=stored-views.js.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The local server behind `nilometer view` (D-097, step G6.2).
|
|
3
|
+
*
|
|
4
|
+
* A saved page is a file, and a browser won't let a file run programs, so a Refresh button on it can't
|
|
5
|
+
* ingest. While `view` runs, this server shows the page on 127.0.0.1 and, when the page asks, ingests and
|
|
6
|
+
* rebuilds it. It's the only way in, so it accepts as little as it can:
|
|
7
|
+
* - it listens on the loopback address only, never on a network interface;
|
|
8
|
+
* - every path starts with a random token made for this run, so another page in the browser can't guess it;
|
|
9
|
+
* - the Host header must name this server, which defeats DNS rebinding, where a site re-points its own
|
|
10
|
+
* name at 127.0.0.1;
|
|
11
|
+
* - a refresh must be a POST from this server's own origin, which a page on another site can't send;
|
|
12
|
+
* - it serves one page and does one thing, refresh; anything else is a 404.
|
|
13
|
+
*/
|
|
14
|
+
import { timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { createServer } from "node:http";
|
|
16
|
+
/**
|
|
17
|
+
* The hosts this server answers to: the loopback address and `localhost`, on its own port.
|
|
18
|
+
* @param port - The port it listens on.
|
|
19
|
+
* @returns Host header values, without a scheme.
|
|
20
|
+
*/
|
|
21
|
+
export function allowedHosts(port) {
|
|
22
|
+
return [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Compares two strings in time that doesn't depend on where they differ.
|
|
26
|
+
* @param a - One string.
|
|
27
|
+
* @param b - The other.
|
|
28
|
+
* @returns Whether they're equal.
|
|
29
|
+
*/
|
|
30
|
+
function sameSecret(a, b) {
|
|
31
|
+
const x = Buffer.from(a);
|
|
32
|
+
const y = Buffer.from(b);
|
|
33
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Decides what a request may do.
|
|
37
|
+
* @param request - Method, target, Host and Origin.
|
|
38
|
+
* @param token - This run's token.
|
|
39
|
+
* @param port - The port this server listens on.
|
|
40
|
+
* @returns The page, a refresh, or a refusal with its status.
|
|
41
|
+
*/
|
|
42
|
+
export function route(request, token, port) {
|
|
43
|
+
const hosts = allowedHosts(port);
|
|
44
|
+
if (request.host === undefined || !hosts.includes(request.host.toLowerCase())) {
|
|
45
|
+
return { kind: "refuse", status: 403, reason: "unknown host" };
|
|
46
|
+
}
|
|
47
|
+
// Only the path counts; a query string never changes what's served.
|
|
48
|
+
const path = request.url.split("?")[0] ?? "";
|
|
49
|
+
const match = /^\/([^/]+)\/(refresh)?$/.exec(path);
|
|
50
|
+
if (match === null || !sameSecret(match[1] ?? "", token)) {
|
|
51
|
+
return { kind: "refuse", status: 404, reason: "not found" };
|
|
52
|
+
}
|
|
53
|
+
if (match[2] === undefined) {
|
|
54
|
+
return request.method === "GET" || request.method === "HEAD"
|
|
55
|
+
? { kind: "page" }
|
|
56
|
+
: { kind: "refuse", status: 405, reason: "the page is only read" };
|
|
57
|
+
}
|
|
58
|
+
if (request.method !== "POST") {
|
|
59
|
+
return { kind: "refuse", status: 405, reason: "a refresh is a POST" };
|
|
60
|
+
}
|
|
61
|
+
// A browser sends Origin with every POST from a page; none, or another one, isn't this page.
|
|
62
|
+
if (request.origin === undefined || !hosts.map((h) => `http://${h}`).includes(request.origin)) {
|
|
63
|
+
return { kind: "refuse", status: 403, reason: "a refresh must come from this page" };
|
|
64
|
+
}
|
|
65
|
+
return { kind: "refresh" };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Headers for every response. The policy lets the page run its own inline script and style and talk
|
|
69
|
+
* only to this server; the token stays out of any Referer.
|
|
70
|
+
*/
|
|
71
|
+
export const RESPONSE_HEADERS = {
|
|
72
|
+
"Cache-Control": "no-store",
|
|
73
|
+
"X-Content-Type-Options": "nosniff",
|
|
74
|
+
"Referrer-Policy": "no-referrer",
|
|
75
|
+
"Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Creates the server, not yet listening. Listen on 127.0.0.1 only.
|
|
79
|
+
* @param deps - Token, page, and refresh.
|
|
80
|
+
* @returns The server.
|
|
81
|
+
*/
|
|
82
|
+
export function createViewServer(deps) {
|
|
83
|
+
const server = createServer((request, response) => {
|
|
84
|
+
const address = server.address();
|
|
85
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
86
|
+
const decided = route({
|
|
87
|
+
method: request.method ?? "GET",
|
|
88
|
+
url: request.url ?? "/",
|
|
89
|
+
host: request.headers.host,
|
|
90
|
+
origin: request.headers.origin,
|
|
91
|
+
}, deps.token, port);
|
|
92
|
+
// The body is never read: no route takes one.
|
|
93
|
+
request.resume();
|
|
94
|
+
if (decided.kind === "refuse") {
|
|
95
|
+
response.writeHead(decided.status, {
|
|
96
|
+
...RESPONSE_HEADERS,
|
|
97
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
98
|
+
});
|
|
99
|
+
response.end(`${decided.reason}\n`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (decided.kind === "page") {
|
|
103
|
+
response.writeHead(200, { ...RESPONSE_HEADERS, "Content-Type": "text/html; charset=utf-8" });
|
|
104
|
+
response.end(request.method === "HEAD" ? undefined : deps.page());
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let result;
|
|
108
|
+
try {
|
|
109
|
+
result = deps.refresh();
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
result = { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
113
|
+
}
|
|
114
|
+
response.writeHead(result.ok ? 200 : 500, {
|
|
115
|
+
...RESPONSE_HEADERS,
|
|
116
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
117
|
+
});
|
|
118
|
+
response.end(JSON.stringify(result));
|
|
119
|
+
});
|
|
120
|
+
return server;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Starts the server on the loopback address only. It runs until the process ends (Ctrl+C).
|
|
124
|
+
* @param options - Token, page, refresh, port, and callbacks.
|
|
125
|
+
* @returns The listening server.
|
|
126
|
+
*/
|
|
127
|
+
export function listenView(options) {
|
|
128
|
+
const server = createViewServer(options);
|
|
129
|
+
server.on("error", (error) => {
|
|
130
|
+
options.onError(error.code === "EADDRINUSE"
|
|
131
|
+
? `port ${options.port} on 127.0.0.1 is in use; choose another with --port, or leave it out for any free port`
|
|
132
|
+
: error.message);
|
|
133
|
+
});
|
|
134
|
+
// 127.0.0.1 explicitly: listening on every interface would put the page on the network.
|
|
135
|
+
server.listen(options.port, "127.0.0.1", () => {
|
|
136
|
+
const address = server.address();
|
|
137
|
+
const port = typeof address === "object" && address !== null ? address.port : options.port;
|
|
138
|
+
options.onListening(`http://127.0.0.1:${port}/${options.token}/`);
|
|
139
|
+
});
|
|
140
|
+
return server;
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=server.js.map
|
package/dist/viewer/html.js
CHANGED
|
@@ -28,6 +28,13 @@ export const EVENT_LIST_LIMIT = 200;
|
|
|
28
28
|
*/
|
|
29
29
|
export const HTML_LABELS = {
|
|
30
30
|
privacy: "Privacy: this file holds session IDs and the paths of your session logs, like nilometer explain output, and it makes no network requests. Think before sharing it.",
|
|
31
|
+
// D-097 (step G6.2): the page as nilometer view serves it.
|
|
32
|
+
privacyServed: "Privacy: nilometer view shows this page from your own computer (127.0.0.1) while it runs. Refresh asks it to read new log lines and rebuild the page; nothing leaves this computer. The page holds session IDs and the paths of your session logs.",
|
|
33
|
+
refresh: "Refresh",
|
|
34
|
+
refreshHint: "Read new log lines and status line readings, then rebuild this page",
|
|
35
|
+
refreshing: "Reading new log lines and rebuilding the page",
|
|
36
|
+
refreshFailed: "Refresh failed",
|
|
37
|
+
refreshStopped: "Couldn't reach nilometer view. Is it still running in your terminal?",
|
|
31
38
|
// 2026-10-22 click-through: "What each number covers" with two bars didn't say why they differ.
|
|
32
39
|
coverage: "Where the numbers come from",
|
|
33
40
|
coverageWhy: "Session logs give tokens, models and limit hits. Usage percentages come only from status line readings, so they start later: when the collector was installed.",
|
|
@@ -353,9 +360,10 @@ function legend(entries) {
|
|
|
353
360
|
/**
|
|
354
361
|
* Renders the header: title, zone, last ingest, and the privacy note (README section Privacy).
|
|
355
362
|
* @param data - Report data.
|
|
363
|
+
* @param serving - How `nilometer view` serves the page; omitted for a saved page.
|
|
356
364
|
* @returns The header HTML.
|
|
357
365
|
*/
|
|
358
|
-
function renderHeader(data) {
|
|
366
|
+
function renderHeader(data, serving) {
|
|
359
367
|
const zone = data.timeZone;
|
|
360
368
|
const ingest = data.lastIngestAt === null ? "none yet" : formatInstant(data.lastIngestAt, zone);
|
|
361
369
|
const meta = `Times are in ${zone}. Last ingest: ${ingest}. Report produced: ${formatInstant(data.generatedAtUtc, zone)}.`;
|
|
@@ -368,12 +376,22 @@ function renderHeader(data) {
|
|
|
368
376
|
para(`${LABELS.hookMissingPath}: ${displayPath(data.missingHookPath, data.home)}`, "meta"),
|
|
369
377
|
"</div>",
|
|
370
378
|
];
|
|
379
|
+
// Served by nilometer view: the button and where its progress shows (D-097).
|
|
380
|
+
const refresh = serving === undefined
|
|
381
|
+
? []
|
|
382
|
+
: [
|
|
383
|
+
'<div class="nm-refresh">',
|
|
384
|
+
`<button type="button" id="nm-refresh" data-path="${escapeHtml(serving.refreshPath)}" title="${escapeHtml(HTML_LABELS.refreshHint)}">${escapeHtml(HTML_LABELS.refresh)}</button>`,
|
|
385
|
+
'<span id="nm-refresh-status" class="meta" role="status" aria-live="polite"></span>',
|
|
386
|
+
"</div>",
|
|
387
|
+
];
|
|
371
388
|
return [
|
|
372
389
|
'<header class="nm-head">',
|
|
373
390
|
`<h1>${escapeHtml(LABELS.title)}</h1>`,
|
|
374
391
|
para(meta, "meta"),
|
|
392
|
+
...refresh,
|
|
375
393
|
...hook,
|
|
376
|
-
para(HTML_LABELS.privacy, "privacy"),
|
|
394
|
+
para(serving === undefined ? HTML_LABELS.privacy : HTML_LABELS.privacyServed, "privacy"),
|
|
377
395
|
"</header>",
|
|
378
396
|
].join("\n");
|
|
379
397
|
}
|
|
@@ -1282,6 +1300,10 @@ p { margin: 0; }
|
|
|
1282
1300
|
.nm-head { display: grid; gap: 6px; }
|
|
1283
1301
|
.meta, .span, .note { font-family: var(--mono); font-size: 12px; color: var(--muted); }
|
|
1284
1302
|
.privacy { font-size: 13px; color: var(--ink-2); }
|
|
1303
|
+
.nm-refresh { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
|
1304
|
+
.nm-refresh button { font: inherit; font-size: 14px; font-weight: 600; color: var(--ink); background: var(--surface); border: 1px solid var(--ink-2); border-radius: 6px; padding: 6px 14px; cursor: pointer; }
|
|
1305
|
+
.nm-refresh button:disabled { cursor: progress; opacity: .6; }
|
|
1306
|
+
@media print { .nm-refresh { display: none; } }
|
|
1285
1307
|
.hook-warning { display: grid; gap: 4px; border-left: 3px solid var(--ink); padding: 8px 12px; background: var(--surface); font-weight: 600; }
|
|
1286
1308
|
.eyebrow { font-family: var(--mono); font-size: 12px; letter-spacing: 0.07em; text-transform: uppercase; color: var(--water); font-weight: 600; }
|
|
1287
1309
|
.eyebrow.projected { color: var(--silt); }
|
|
@@ -1595,15 +1617,54 @@ if (typeof document !== "undefined") {
|
|
|
1595
1617
|
})();
|
|
1596
1618
|
}
|
|
1597
1619
|
`;
|
|
1620
|
+
/**
|
|
1621
|
+
* The Refresh button's script, for a page `nilometer view` serves (D-097): it posts to the server,
|
|
1622
|
+
* counts the seconds while the server ingests and rebuilds, then reloads the same address, so the
|
|
1623
|
+
* tab in the address (#windows, #interruptions...) stays open.
|
|
1624
|
+
* @returns The script, with its wording from {@link HTML_LABELS}.
|
|
1625
|
+
*/
|
|
1626
|
+
function refreshScript() {
|
|
1627
|
+
const text = JSON.stringify({
|
|
1628
|
+
refreshing: HTML_LABELS.refreshing,
|
|
1629
|
+
failed: HTML_LABELS.refreshFailed,
|
|
1630
|
+
stopped: HTML_LABELS.refreshStopped,
|
|
1631
|
+
}).replace(/</g, "\\u003c");
|
|
1632
|
+
return `
|
|
1633
|
+
"use strict";
|
|
1634
|
+
(function () {
|
|
1635
|
+
var TEXT = ${text};
|
|
1636
|
+
var button = document.getElementById("nm-refresh");
|
|
1637
|
+
var status = document.getElementById("nm-refresh-status");
|
|
1638
|
+
if (button === null || status === null) { return; }
|
|
1639
|
+
button.addEventListener("click", function () {
|
|
1640
|
+
var started = Date.now();
|
|
1641
|
+
button.disabled = true;
|
|
1642
|
+
function tick() { status.textContent = TEXT.refreshing + " (" + Math.round((Date.now() - started) / 1000) + " s)"; }
|
|
1643
|
+
tick();
|
|
1644
|
+
var timer = setInterval(tick, 1000);
|
|
1645
|
+
function stop(message) { clearInterval(timer); button.disabled = false; status.textContent = message; }
|
|
1646
|
+
fetch(button.getAttribute("data-path"), { method: "POST" })
|
|
1647
|
+
.then(function (response) {
|
|
1648
|
+
return response.json().then(function (body) {
|
|
1649
|
+
if (response.ok && body.ok) { clearInterval(timer); location.reload(); return; }
|
|
1650
|
+
stop(TEXT.failed + ": " + body.message);
|
|
1651
|
+
});
|
|
1652
|
+
})
|
|
1653
|
+
.catch(function () { stop(TEXT.stopped); });
|
|
1654
|
+
});
|
|
1655
|
+
})();
|
|
1656
|
+
`;
|
|
1657
|
+
}
|
|
1598
1658
|
/**
|
|
1599
1659
|
* Renders the whole page (step G1.3, D-070): header, coverage, the observed sections, the seam,
|
|
1600
1660
|
* and the projected monthly section, in the text report's order, with the event table embedded for
|
|
1601
1661
|
* the script and a table view under every chart.
|
|
1602
1662
|
* @param data - Everything the report draws, from `loadHtmlData`.
|
|
1663
|
+
* @param serving - How `nilometer view` serves the page (D-097); omitted for a saved page.
|
|
1603
1664
|
* @returns One self-contained, ASCII-only HTML document; never throws for well-formed data.
|
|
1604
1665
|
* @see D-070
|
|
1605
1666
|
*/
|
|
1606
|
-
function renderPage(data) {
|
|
1667
|
+
function renderPage(data, serving) {
|
|
1607
1668
|
const options = { timeZone: data.timeZone, width: CHART_WIDTH };
|
|
1608
1669
|
const embedded = embedJson({ timeZone: data.timeZone, files: data.files, events: data.events });
|
|
1609
1670
|
return [
|
|
@@ -1617,7 +1678,7 @@ function renderPage(data) {
|
|
|
1617
1678
|
"</head>",
|
|
1618
1679
|
"<body>",
|
|
1619
1680
|
"<main>",
|
|
1620
|
-
renderHeader(data),
|
|
1681
|
+
renderHeader(data, serving),
|
|
1621
1682
|
// Navigation only: its labels name the tabs, so the projected group's label sits above the
|
|
1622
1683
|
// seam, while every projected panel stays below it in the document (principle 2, D-084).
|
|
1623
1684
|
renderTabBar(),
|
|
@@ -1639,6 +1700,8 @@ function renderPage(data) {
|
|
|
1639
1700
|
"</main>",
|
|
1640
1701
|
`<script type="application/json" id="nm-data">${embedded}</script>`,
|
|
1641
1702
|
`<script>${PAGE_SCRIPT}</script>`,
|
|
1703
|
+
// Only when served: a saved page makes no network requests (D-097).
|
|
1704
|
+
...(serving === undefined ? [] : [`<script>${refreshScript()}</script>`]),
|
|
1642
1705
|
"</body>",
|
|
1643
1706
|
"</html>",
|
|
1644
1707
|
"",
|
package/package.json
CHANGED