@3minapi/cli 0.0.0-stage → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/dist/bin.js +54 -0
- package/dist/cli.js +241 -0
- package/dist/commands/auth.js +573 -0
- package/dist/commands/common.js +48 -0
- package/dist/commands/dashboard.js +298 -0
- package/dist/commands/delete.js +167 -0
- package/dist/commands/doctor.js +326 -0
- package/dist/commands/guide.js +84 -0
- package/dist/commands/index.js +28 -0
- package/dist/commands/init.js +248 -0
- package/dist/commands/logs.js +154 -0
- package/dist/commands/new.js +109 -0
- package/dist/commands/paths.js +64 -0
- package/dist/commands/reload.js +32 -0
- package/dist/commands/results.js +445 -0
- package/dist/commands/start.js +443 -0
- package/dist/commands/stop.js +68 -0
- package/dist/commands/tasks.js +93 -0
- package/dist/commands/worker.js +178 -0
- package/dist/core/api.js +132 -0
- package/dist/core/config.js +188 -0
- package/dist/core/control-db.js +462 -0
- package/dist/core/credentials.js +75 -0
- package/dist/core/domain/constants.js +121 -0
- package/dist/core/domain/contract.js +102 -0
- package/dist/core/domain/errors.js +57 -0
- package/dist/core/domain/index.js +3 -0
- package/dist/core/domain/types.js +1 -0
- package/dist/core/error-report.js +103 -0
- package/dist/core/host/detach.js +49 -0
- package/dist/core/host/live-connection.js +270 -0
- package/dist/core/host/live-receiver.js +264 -0
- package/dist/core/host/module.js +1 -0
- package/dist/core/host/poller.js +113 -0
- package/dist/core/host/pull-step.js +54 -0
- package/dist/core/host/receiver.js +1 -0
- package/dist/core/host/reload.js +34 -0
- package/dist/core/host/runner-lock.js +245 -0
- package/dist/core/host/session.js +84 -0
- package/dist/core/host/usage.js +20 -0
- package/dist/core/log-read.js +59 -0
- package/dist/core/logger.js +206 -0
- package/dist/core/names.js +27 -0
- package/dist/core/network.js +10 -0
- package/dist/core/open-browser.js +26 -0
- package/dist/core/output.js +68 -0
- package/dist/core/paths.js +26 -0
- package/dist/core/project.js +85 -0
- package/dist/core/prompt.js +140 -0
- package/dist/core/redact.js +45 -0
- package/dist/core/secure-fs.js +50 -0
- package/dist/core/text-file.js +12 -0
- package/dist/core/text.js +27 -0
- package/dist/core/time.js +84 -0
- package/dist/core/update-notice.js +142 -0
- package/dist/core/version.js +16 -0
- package/dist/core/warnings.js +10 -0
- package/dist/help.js +144 -0
- package/dist/modules/dashboard/assets.js +411 -0
- package/dist/modules/dashboard/data.js +565 -0
- package/dist/modules/dashboard/guard.js +75 -0
- package/dist/modules/dashboard/lock.js +93 -0
- package/dist/modules/dashboard/server.js +295 -0
- package/dist/modules/dashboard/views/html.js +114 -0
- package/dist/modules/dashboard/views/layout.js +59 -0
- package/dist/modules/dashboard/views/logs.js +122 -0
- package/dist/modules/dashboard/views/overview.js +316 -0
- package/dist/modules/dashboard/views/runs.js +486 -0
- package/dist/modules/dashboard/views/workers.js +157 -0
- package/dist/modules/workers/builtin/host-identity.js +59 -0
- package/dist/modules/workers/builtin/results-worker.js +37 -0
- package/dist/modules/workers/local-server.js +244 -0
- package/dist/modules/workers/module.js +98 -0
- package/dist/modules/workers/reload-result.js +16 -0
- package/dist/modules/workers/repeat-scheduler.js +165 -0
- package/dist/modules/workers/results/missing-paths.js +66 -0
- package/dist/modules/workers/results/query.js +175 -0
- package/dist/modules/workers/results/reader-input.js +134 -0
- package/dist/modules/workers/results/reader.js +430 -0
- package/dist/modules/workers/results/results-child.js +64 -0
- package/dist/modules/workers/results/shape.js +212 -0
- package/dist/modules/workers/results/store.js +336 -0
- package/dist/modules/workers/results/substitute.js +30 -0
- package/dist/modules/workers/runtime/env.js +43 -0
- package/dist/modules/workers/runtime/error-chain.js +109 -0
- package/dist/modules/workers/runtime/executor.js +289 -0
- package/dist/modules/workers/runtime/inspect.js +53 -0
- package/dist/modules/workers/runtime/protocol.js +1 -0
- package/dist/modules/workers/runtime/resolve.js +30 -0
- package/dist/modules/workers/runtime/schema.js +85 -0
- package/dist/modules/workers/runtime/thread.js +117 -0
- package/dist/modules/workers/start.js +265 -0
- package/dist/modules/workers/sync.js +241 -0
- package/dist/modules/workers/task-runner.js +892 -0
- package/dist/modules/workers/template.js +100 -0
- package/dist/modules/workers/worker-set.js +122 -0
- package/dist/modules/workers/workers.js +66 -0
- package/package.json +48 -5
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { WORKER_LOG_DIR, builtinResultsName } from "../../core/domain/index.js";
|
|
5
|
+
import { ControlDb } from "../../core/control-db.js";
|
|
6
|
+
import { readRunner } from "../../core/host/runner-lock.js";
|
|
7
|
+
import { hostUsage } from "../../core/host/usage.js";
|
|
8
|
+
import { findLastRecord, parseRecord, readIfExists, tailLines } from "../../core/log-read.js";
|
|
9
|
+
import { channelDir, listAllLogFiles, listChannelFiles } from "../../core/logger.js";
|
|
10
|
+
import { utcDate } from "../../core/time.js";
|
|
11
|
+
import { HOST_META } from "../workers/builtin/host-identity.js";
|
|
12
|
+
import { ResultStore, resultsDbBytes, resultsDbPath } from "../workers/results/store.js";
|
|
13
|
+
import { parseSettings } from "../workers/repeat-scheduler.js";
|
|
14
|
+
import { workerFiles } from "../workers/workers.js";
|
|
15
|
+
export const RANGES = ['today', '7d', '30d'];
|
|
16
|
+
export const RUN_RANGES = [...RANGES, 'all'];
|
|
17
|
+
export const RUN_STATES = ['completed', 'failed', 'cancelled', 'running', 'superseded'];
|
|
18
|
+
export function rangeStart(range, now) {
|
|
19
|
+
if (range === 'all')
|
|
20
|
+
return null;
|
|
21
|
+
const d = new Date(now);
|
|
22
|
+
const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
23
|
+
if (range === 'today')
|
|
24
|
+
return midnight;
|
|
25
|
+
const days = range === '7d' ? 6 : 29;
|
|
26
|
+
const start = new Date(midnight);
|
|
27
|
+
start.setDate(start.getDate() - days);
|
|
28
|
+
return start.getTime();
|
|
29
|
+
}
|
|
30
|
+
function runState(state) {
|
|
31
|
+
return state === 'received' ? 'running' : state;
|
|
32
|
+
}
|
|
33
|
+
function withControl(src, fn, fallback) {
|
|
34
|
+
const db = ControlDb.openReadOnly(src.dataDir);
|
|
35
|
+
if (!db)
|
|
36
|
+
return fallback;
|
|
37
|
+
try {
|
|
38
|
+
return fn(db);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
db.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function withResults(src, fn, fallback) {
|
|
45
|
+
if (!src.projectRoot)
|
|
46
|
+
return fallback;
|
|
47
|
+
const store = ResultStore.openReadOnly(src.projectRoot);
|
|
48
|
+
if (!store)
|
|
49
|
+
return fallback;
|
|
50
|
+
try {
|
|
51
|
+
return fn(store);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
store.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function encodeCursor(c) {
|
|
58
|
+
return `${c.at}.${c.taskId}`;
|
|
59
|
+
}
|
|
60
|
+
export function decodeCursor(raw) {
|
|
61
|
+
if (!raw)
|
|
62
|
+
return null;
|
|
63
|
+
const m = /^(\d{1,16})\.(.+)$/.exec(raw);
|
|
64
|
+
return m ? { at: Number(m[1]), taskId: m[2] } : null;
|
|
65
|
+
}
|
|
66
|
+
function toRun(task, result, savedRuns = null) {
|
|
67
|
+
const t = task ?? null;
|
|
68
|
+
const r = result ?? null;
|
|
69
|
+
const repeat = parseSettings(t?.repeat);
|
|
70
|
+
return {
|
|
71
|
+
task_id: (t?.task_id ?? r?.task_id),
|
|
72
|
+
worker: (t?.worker ?? r?.worker),
|
|
73
|
+
state: runState(t?.state ?? r.status),
|
|
74
|
+
at: t?.received_at ?? Date.parse(r.created_at),
|
|
75
|
+
started_at: t?.started_at ?? null,
|
|
76
|
+
finished_at: t?.finished_at ?? null,
|
|
77
|
+
duration_ms: t?.started_at != null && t.finished_at != null ? t.finished_at - t.started_at : null,
|
|
78
|
+
delivery_attempt: t?.delivery_attempt ?? null,
|
|
79
|
+
run_count: t?.run_count ?? null,
|
|
80
|
+
last_error: t?.last_error ?? null,
|
|
81
|
+
bytes: r?.bytes ?? null,
|
|
82
|
+
reply: r?.reply ?? null,
|
|
83
|
+
source: r?.source ?? null,
|
|
84
|
+
in: t && r ? 'both' : t ? 'control' : 'results',
|
|
85
|
+
repeat: repeat
|
|
86
|
+
? { interval_seconds: repeat.interval_seconds, max_runs: repeat.max_runs ?? null }
|
|
87
|
+
: null,
|
|
88
|
+
runs: t?.runs ?? null,
|
|
89
|
+
failed: t?.failed ?? null,
|
|
90
|
+
last_run_at: t?.last_run_at ?? null,
|
|
91
|
+
next_at: t?.next_at ?? null,
|
|
92
|
+
saved_runs: savedRuns
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function isRepeat(run) {
|
|
96
|
+
return run.repeat !== null || (run.saved_runs ?? 0) > 1;
|
|
97
|
+
}
|
|
98
|
+
const RESULT_ONLY_BATCH = 500;
|
|
99
|
+
export function listRuns(src, filter, limit, after = null) {
|
|
100
|
+
const since = rangeStart(filter.range, src.now());
|
|
101
|
+
const states = filter.state === undefined
|
|
102
|
+
? undefined
|
|
103
|
+
: filter.state === 'running'
|
|
104
|
+
? ['received', 'running']
|
|
105
|
+
: [filter.state];
|
|
106
|
+
const fetch = limit + 1;
|
|
107
|
+
const tasks = withControl(src, (db) => db.listTasks({
|
|
108
|
+
...(since !== null ? { since } : {}),
|
|
109
|
+
...(filter.worker !== undefined ? { worker: filter.worker } : {}),
|
|
110
|
+
...(states ? { states } : {}),
|
|
111
|
+
...(after ? { after } : {})
|
|
112
|
+
}, fetch), []);
|
|
113
|
+
const boundary = tasks.length === fetch ? tasks[tasks.length - 1].received_at : null;
|
|
114
|
+
const fromResults = filter.state === undefined || filter.state === 'completed' || filter.state === 'failed';
|
|
115
|
+
const resultOnly = fromResults ? findResultOnly(src, filter, since, after, boundary, fetch) : [];
|
|
116
|
+
const { paired, counts } = withResults(src, (store) => ({
|
|
117
|
+
paired: store.rowsByIds(tasks.map((t) => t.task_id)),
|
|
118
|
+
counts: store.runCounts([...tasks.map((t) => t.task_id), ...resultOnly.map((r) => r.task_id)])
|
|
119
|
+
}), { paired: [], counts: new Map() });
|
|
120
|
+
const resultById = new Map(paired.map((r) => [r.task_id, r]));
|
|
121
|
+
const saved = (id) => counts.get(id)?.runs ?? null;
|
|
122
|
+
const merged = [
|
|
123
|
+
...tasks.map((t) => toRun(t, resultById.get(t.task_id), saved(t.task_id))),
|
|
124
|
+
...resultOnly.map((r) => toRun(undefined, r, saved(r.task_id)))
|
|
125
|
+
]
|
|
126
|
+
.filter((run) => filter.state === undefined || run.state === filter.state)
|
|
127
|
+
.filter((run) => !after || before(run, after))
|
|
128
|
+
.sort((a, b) => b.at - a.at || a.task_id.localeCompare(b.task_id));
|
|
129
|
+
const runs = merged.slice(0, limit);
|
|
130
|
+
const last = runs.at(-1);
|
|
131
|
+
return {
|
|
132
|
+
runs,
|
|
133
|
+
next: merged.length > limit && last ? { at: last.at, taskId: last.task_id } : null
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function findResultOnly(src, filter, since, after, boundary, max) {
|
|
137
|
+
const store = src.projectRoot ? ResultStore.openReadOnly(src.projectRoot) : null;
|
|
138
|
+
if (!store)
|
|
139
|
+
return [];
|
|
140
|
+
const db = ControlDb.openReadOnly(src.dataDir);
|
|
141
|
+
try {
|
|
142
|
+
const out = [];
|
|
143
|
+
let cursor = after
|
|
144
|
+
? { createdAt: new Date(after.at).toISOString(), taskId: after.taskId }
|
|
145
|
+
: undefined;
|
|
146
|
+
for (;;) {
|
|
147
|
+
const batch = store.list({
|
|
148
|
+
...(since !== null ? { since: new Date(since).toISOString() } : {}),
|
|
149
|
+
...(filter.worker !== undefined ? { worker: filter.worker } : {}),
|
|
150
|
+
...(filter.state === 'completed' || filter.state === 'failed'
|
|
151
|
+
? { status: filter.state }
|
|
152
|
+
: {}),
|
|
153
|
+
...(cursor ? { after: cursor } : {}),
|
|
154
|
+
latestRunOnly: true
|
|
155
|
+
}, RESULT_ONLY_BATCH);
|
|
156
|
+
const known = new Set(db ? db.tasksByIds(batch.map((r) => r.task_id)).map((t) => t.task_id) : []);
|
|
157
|
+
for (const r of batch) {
|
|
158
|
+
if (boundary !== null && Date.parse(r.created_at) < boundary)
|
|
159
|
+
return out;
|
|
160
|
+
if (known.has(r.task_id))
|
|
161
|
+
continue;
|
|
162
|
+
out.push(r);
|
|
163
|
+
if (out.length >= max)
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
if (batch.length < RESULT_ONLY_BATCH)
|
|
167
|
+
return out;
|
|
168
|
+
const last = batch[batch.length - 1];
|
|
169
|
+
cursor = { createdAt: last.created_at, taskId: last.task_id };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
db?.close();
|
|
174
|
+
store.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function before(run, c) {
|
|
178
|
+
return run.at < c.at || (run.at === c.at && run.task_id > c.taskId);
|
|
179
|
+
}
|
|
180
|
+
export function runDetail(src, taskId) {
|
|
181
|
+
const task = withControl(src, (db) => db.getTask(taskId), null);
|
|
182
|
+
const found = withResults(src, (store) => store.snapshot(() => {
|
|
183
|
+
const latest = store.rowsByIds([taskId])[0] ?? null;
|
|
184
|
+
const saved = store.runCounts([taskId]).get(taskId)?.runs ?? 0;
|
|
185
|
+
if (!task && !latest)
|
|
186
|
+
return null;
|
|
187
|
+
const run = toRun(task ?? undefined, latest ?? undefined, saved || null);
|
|
188
|
+
return { run, result: latest && !isRepeat(run) ? store.get(taskId) : null };
|
|
189
|
+
}), undefined);
|
|
190
|
+
if (found === null)
|
|
191
|
+
return null;
|
|
192
|
+
if (found === undefined)
|
|
193
|
+
return task ? { run: toRun(task, undefined, null), result: null } : null;
|
|
194
|
+
return found;
|
|
195
|
+
}
|
|
196
|
+
export const RUNS_DETAIL_PAGE = 50;
|
|
197
|
+
export const PREVIEW_MAX_BYTES = 64 * 1024;
|
|
198
|
+
export const PREVIEW_CHARS = 120;
|
|
199
|
+
export function repeatRuns(src, taskId, runsBefore) {
|
|
200
|
+
const found = withResults(src, (store) => store.snapshot(() => ({
|
|
201
|
+
page: store.runPreviews(taskId, RUNS_DETAIL_PAGE + 1, runsBefore, PREVIEW_MAX_BYTES),
|
|
202
|
+
total: store.runCounts([taskId]).get(taskId)?.runs ?? 0
|
|
203
|
+
})), { page: [], total: 0 });
|
|
204
|
+
const runs = found.page.slice(0, RUNS_DETAIL_PAGE).map(({ payload, ...row }) => ({
|
|
205
|
+
...row,
|
|
206
|
+
preview: payload === null ? null : previewOf(row.status, payload),
|
|
207
|
+
large: payload === null
|
|
208
|
+
}));
|
|
209
|
+
return {
|
|
210
|
+
runs,
|
|
211
|
+
runsNext: found.page.length > RUNS_DETAIL_PAGE ? (runs.at(-1)?.run_no ?? null) : null,
|
|
212
|
+
total: found.total
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
export function previewOf(status, payload) {
|
|
216
|
+
let text = payload;
|
|
217
|
+
if (status === 'failed') {
|
|
218
|
+
try {
|
|
219
|
+
const v = JSON.parse(payload);
|
|
220
|
+
const message = v !== null && typeof v === 'object' ? v.message : undefined;
|
|
221
|
+
if (typeof message === 'string')
|
|
222
|
+
text = message.split('\n')[0];
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const cut = text.length > PREVIEW_CHARS * 4;
|
|
228
|
+
text = (cut ? text.slice(0, PREVIEW_CHARS * 4) : text).replace(/\s+/g, ' ').trim();
|
|
229
|
+
if (text.length <= PREVIEW_CHARS)
|
|
230
|
+
return cut ? `${text}…` : text;
|
|
231
|
+
const end = /[\uD800-\uDBFF]/.test(text[PREVIEW_CHARS - 1]) ? PREVIEW_CHARS - 1 : PREVIEW_CHARS;
|
|
232
|
+
return `${text.slice(0, end)}…`;
|
|
233
|
+
}
|
|
234
|
+
export function runOfTask(src, run, runNo) {
|
|
235
|
+
const found = withResults(src, (store) => store.snapshot(() => ({
|
|
236
|
+
result: store.get(run.task_id, runNo),
|
|
237
|
+
prevRow: store.runs(run.task_id, 1, runNo)[0] ?? null,
|
|
238
|
+
next: store.nextRunNo(run.task_id, runNo)
|
|
239
|
+
})), {
|
|
240
|
+
result: null,
|
|
241
|
+
prevRow: null,
|
|
242
|
+
next: null
|
|
243
|
+
});
|
|
244
|
+
const savedAt = found.result ? Date.parse(found.result.created_at) : src.now();
|
|
245
|
+
return {
|
|
246
|
+
result: found.result,
|
|
247
|
+
prev: found.prevRow?.run_no ?? null,
|
|
248
|
+
next: found.next,
|
|
249
|
+
from: runLogStart(run, runNo, savedAt, found.prevRow)
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function runLogStart(run, runNo, savedAt, prev) {
|
|
253
|
+
let start;
|
|
254
|
+
if (prev)
|
|
255
|
+
start = Date.parse(prev.created_at);
|
|
256
|
+
else if (runNo === 1 && run.in !== 'results')
|
|
257
|
+
start = run.at - RUN_LOG_MARGIN_MS;
|
|
258
|
+
else if (run.repeat)
|
|
259
|
+
start = savedAt - run.repeat.interval_seconds * 1000;
|
|
260
|
+
else
|
|
261
|
+
start = savedAt - TASK_LOG_MARGIN_MS;
|
|
262
|
+
return Math.max(start, savedAt - TASK_LOG_MAX_SPAN_MS);
|
|
263
|
+
}
|
|
264
|
+
const HISTORY_WORKERS_TTL_MS = 60_000;
|
|
265
|
+
const historyWorkers = new Map();
|
|
266
|
+
export function knownWorkers(src) {
|
|
267
|
+
const names = new Set();
|
|
268
|
+
const now = src.now();
|
|
269
|
+
let cached = historyWorkers.get(src.dataDir);
|
|
270
|
+
if (!cached || now - cached.at >= HISTORY_WORKERS_TTL_MS) {
|
|
271
|
+
cached = { at: now, names: withControl(src, (db) => db.distinctWorkers(), []) };
|
|
272
|
+
if (cached.names.length > 0)
|
|
273
|
+
historyWorkers.set(src.dataDir, cached);
|
|
274
|
+
}
|
|
275
|
+
for (const name of cached.names)
|
|
276
|
+
names.add(name);
|
|
277
|
+
for (const name of workerFilesIfAny(src.workersDir)?.keys() ?? [])
|
|
278
|
+
names.add(name);
|
|
279
|
+
return [...names].sort();
|
|
280
|
+
}
|
|
281
|
+
const HOST_NAME_TTL_MS = 60_000;
|
|
282
|
+
const hostNames = new Map();
|
|
283
|
+
export function hostName(src) {
|
|
284
|
+
const now = src.now();
|
|
285
|
+
const cached = hostNames.get(src.dataDir);
|
|
286
|
+
if (cached && now - cached.at < HOST_NAME_TTL_MS)
|
|
287
|
+
return cached.name;
|
|
288
|
+
let name = null;
|
|
289
|
+
try {
|
|
290
|
+
name = withControl(src, (db) => db.getMeta(HOST_META.name), null);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
}
|
|
294
|
+
if (name) {
|
|
295
|
+
hostNames.set(src.dataDir, { at: now, name });
|
|
296
|
+
return name;
|
|
297
|
+
}
|
|
298
|
+
return hostname();
|
|
299
|
+
}
|
|
300
|
+
function emptyCounts() {
|
|
301
|
+
return { total: 0, completed: 0, failed: 0, cancelled: 0, running: 0, superseded: 0 };
|
|
302
|
+
}
|
|
303
|
+
function add(c, state, n) {
|
|
304
|
+
c.total += n;
|
|
305
|
+
c[runState(state)] += n;
|
|
306
|
+
}
|
|
307
|
+
export function overview(src, range) {
|
|
308
|
+
const now = src.now();
|
|
309
|
+
const since = rangeStart(range, now);
|
|
310
|
+
const unit = range === 'today' ? 'hour' : 'day';
|
|
311
|
+
const totals = emptyCounts();
|
|
312
|
+
const workers = new Map();
|
|
313
|
+
const buckets = new Map(bucketKeys(since, now, unit).map((k) => [k, emptyCounts()]));
|
|
314
|
+
const fromDb = withControl(src, (db) => {
|
|
315
|
+
for (const r of db.countGrouped(since, unit)) {
|
|
316
|
+
add(totals, r.state, r.n);
|
|
317
|
+
if (!workers.has(r.worker))
|
|
318
|
+
workers.set(r.worker, emptyCounts());
|
|
319
|
+
add(workers.get(r.worker), r.state, r.n);
|
|
320
|
+
if (!buckets.has(r.bucket))
|
|
321
|
+
buckets.set(r.bucket, emptyCounts());
|
|
322
|
+
add(buckets.get(r.bucket), r.state, r.n);
|
|
323
|
+
}
|
|
324
|
+
const usage = hostUsage(db, src.logsDir);
|
|
325
|
+
return {
|
|
326
|
+
recentFailures: db.listTasks({ since, states: ['failed'] }, 8),
|
|
327
|
+
lastResponse: db.getQuota(),
|
|
328
|
+
stop: db.getStop(),
|
|
329
|
+
end: db.getEnd(),
|
|
330
|
+
host: { id: db.getMeta(HOST_META.id), name: db.getMeta(HOST_META.name) },
|
|
331
|
+
usage
|
|
332
|
+
};
|
|
333
|
+
}, null);
|
|
334
|
+
const logs = logUsage(src.logsDir);
|
|
335
|
+
const dataBytes = src.projectRoot ? resultsDbBytes(src.projectRoot) : null;
|
|
336
|
+
const results = withResults(src, (store) => store.usage().count, 0);
|
|
337
|
+
return {
|
|
338
|
+
range,
|
|
339
|
+
since,
|
|
340
|
+
totals,
|
|
341
|
+
byWorker: [...workers]
|
|
342
|
+
.map(([worker, c]) => ({ worker, ...c }))
|
|
343
|
+
.sort((a, b) => b.total - a.total || a.worker.localeCompare(b.worker)),
|
|
344
|
+
trend: {
|
|
345
|
+
unit,
|
|
346
|
+
buckets: [...buckets]
|
|
347
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
348
|
+
.map(([bucket, c]) => ({ bucket, ...c }))
|
|
349
|
+
},
|
|
350
|
+
recentFailures: fromDb?.recentFailures ?? [],
|
|
351
|
+
runner: readRunner(src.dataDir),
|
|
352
|
+
lastResponse: fromDb?.lastResponse ?? null,
|
|
353
|
+
stop: fromDb?.stop ?? null,
|
|
354
|
+
end: fromDb?.end ?? null,
|
|
355
|
+
host: fromDb?.host ?? { id: null, name: null },
|
|
356
|
+
disk: {
|
|
357
|
+
controlDb: fromDb?.usage.controlDb
|
|
358
|
+
? {
|
|
359
|
+
file: fromDb.usage.controlDb.file,
|
|
360
|
+
bytes: fromDb.usage.controlDb.bytes,
|
|
361
|
+
tasks: fromDb.usage.controlDb.tasks
|
|
362
|
+
}
|
|
363
|
+
: null,
|
|
364
|
+
dataDb: src.projectRoot && dataBytes !== null
|
|
365
|
+
? { file: resultsDbPath(src.projectRoot), bytes: dataBytes, results }
|
|
366
|
+
: null,
|
|
367
|
+
logs
|
|
368
|
+
},
|
|
369
|
+
hasControlDb: fromDb !== null
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function bucketKeys(since, now, unit) {
|
|
373
|
+
const keys = [];
|
|
374
|
+
const d = new Date(since);
|
|
375
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
376
|
+
while (d.getTime() <= now) {
|
|
377
|
+
const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
378
|
+
keys.push(unit === 'day' ? day : `${day} ${pad(d.getHours())}`);
|
|
379
|
+
if (unit === 'day')
|
|
380
|
+
d.setDate(d.getDate() + 1);
|
|
381
|
+
else
|
|
382
|
+
d.setHours(d.getHours() + 1);
|
|
383
|
+
}
|
|
384
|
+
return keys;
|
|
385
|
+
}
|
|
386
|
+
function logUsage(logsDir) {
|
|
387
|
+
const files = listAllLogFiles(logsDir);
|
|
388
|
+
return { dir: logsDir, bytes: files.reduce((n, f) => n + f.size, 0), files: files.length };
|
|
389
|
+
}
|
|
390
|
+
export function workersView(src) {
|
|
391
|
+
const lastSync = readLastSync(src.logsDir);
|
|
392
|
+
const hostId = withControl(src, (db) => db.getMeta(HOST_META.id), null);
|
|
393
|
+
const builtin = hostId ? builtinResultsName(hostId) : null;
|
|
394
|
+
if (!src.workersDir)
|
|
395
|
+
return { dir: null, dirMissing: true, error: null, files: [], lastSync, builtin };
|
|
396
|
+
let map;
|
|
397
|
+
try {
|
|
398
|
+
map = workerFilesIfAny(src.workersDir);
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
return {
|
|
402
|
+
dir: src.workersDir,
|
|
403
|
+
dirMissing: false,
|
|
404
|
+
error: err.message,
|
|
405
|
+
files: [],
|
|
406
|
+
lastSync,
|
|
407
|
+
builtin
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if (!map)
|
|
411
|
+
return { dir: src.workersDir, dirMissing: true, error: null, files: [], lastSync, builtin };
|
|
412
|
+
const files = [...map].map(([name, list]) => ({
|
|
413
|
+
name,
|
|
414
|
+
files: list.map((file) => {
|
|
415
|
+
const st = statSync(file, { throwIfNoEntry: false });
|
|
416
|
+
return { file, bytes: st?.size ?? 0, modifiedAt: st?.mtimeMs ?? 0 };
|
|
417
|
+
})
|
|
418
|
+
}));
|
|
419
|
+
return { dir: src.workersDir, dirMissing: false, error: null, files, lastSync, builtin };
|
|
420
|
+
}
|
|
421
|
+
export const SYNC_SCAN_MAX_BYTES = 64 * 1024 * 1024;
|
|
422
|
+
function readLastSync(logsDir) {
|
|
423
|
+
const record = findLastRecord(channelDir(logsDir, null), (r) => r.body === 'workers synced' || r.body === 'workers resynced', { maxBytes: SYNC_SCAN_MAX_BYTES, contains: 'workers ' });
|
|
424
|
+
if (!record)
|
|
425
|
+
return null;
|
|
426
|
+
const a = record.attributes ?? {};
|
|
427
|
+
const pairs = (value, key) => new Map((Array.isArray(value) ? value : [])
|
|
428
|
+
.filter((v) => v !== null && typeof v === 'object')
|
|
429
|
+
.map((v) => [String(v.name), String(v[key])]));
|
|
430
|
+
return {
|
|
431
|
+
at: Date.parse(record.timestamp),
|
|
432
|
+
event: record.body,
|
|
433
|
+
results: pairs(a.workers, 'result'),
|
|
434
|
+
rejected: pairs(a.rejected, 'code'),
|
|
435
|
+
local: Array.isArray(a.local)
|
|
436
|
+
? new Map(a.local
|
|
437
|
+
.filter((w) => w !== null && typeof w === 'object')
|
|
438
|
+
.map((w) => [String(w.name), w]))
|
|
439
|
+
: null
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
function workerFilesIfAny(dir) {
|
|
443
|
+
if (!dir || !existsSync(dir))
|
|
444
|
+
return null;
|
|
445
|
+
return workerFiles(dir);
|
|
446
|
+
}
|
|
447
|
+
export const CODE_MAX_BYTES = 512 * 1024;
|
|
448
|
+
export function workerSource(src, name) {
|
|
449
|
+
const files = workerFilesIfAny(src.workersDir)?.get(name);
|
|
450
|
+
if (!files)
|
|
451
|
+
return null;
|
|
452
|
+
return files.map((file) => {
|
|
453
|
+
const buf = readFileHead(file, CODE_MAX_BYTES + 1);
|
|
454
|
+
const truncated = buf.length > CODE_MAX_BYTES;
|
|
455
|
+
return {
|
|
456
|
+
file,
|
|
457
|
+
text: buf.subarray(0, CODE_MAX_BYTES).toString('utf8'),
|
|
458
|
+
truncated,
|
|
459
|
+
bytes: statSync(file, { throwIfNoEntry: false })?.size ?? buf.length
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
function readFileHead(file, max) {
|
|
464
|
+
const fd = openSync(file, 'r');
|
|
465
|
+
try {
|
|
466
|
+
const buf = Buffer.alloc(max);
|
|
467
|
+
const n = readSync(fd, buf, 0, max, 0);
|
|
468
|
+
return buf.subarray(0, n);
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
closeSync(fd);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
export const LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
|
|
475
|
+
export function logChannels(src) {
|
|
476
|
+
const names = new Set();
|
|
477
|
+
const workerLogs = path.join(src.logsDir, WORKER_LOG_DIR);
|
|
478
|
+
if (existsSync(workerLogs)) {
|
|
479
|
+
for (const e of readdirSync(workerLogs, { withFileTypes: true })) {
|
|
480
|
+
if (e.isDirectory())
|
|
481
|
+
names.add(e.name);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
for (const name of workerFilesIfAny(src.workersDir)?.keys() ?? [])
|
|
485
|
+
names.add(name);
|
|
486
|
+
return [...names].sort();
|
|
487
|
+
}
|
|
488
|
+
export const LOG_SCAN_MAX_BYTES = 32 * 1024 * 1024;
|
|
489
|
+
export function readLog(src, channel, opts) {
|
|
490
|
+
const dir = channelDir(src.logsDir, channel);
|
|
491
|
+
const min = opts.minLevel ? LOG_LEVELS.indexOf(opts.minLevel) : 0;
|
|
492
|
+
if (min <= 0) {
|
|
493
|
+
return { lines: tailLines(dir, opts.lines).map((l) => toLine(l, channel)), truncated: false };
|
|
494
|
+
}
|
|
495
|
+
const out = [];
|
|
496
|
+
let scanned = 0;
|
|
497
|
+
for (const f of listChannelFiles(dir).reverse()) {
|
|
498
|
+
if (scanned >= LOG_SCAN_MAX_BYTES)
|
|
499
|
+
return { lines: out.reverse(), truncated: true };
|
|
500
|
+
const text = readIfExists(f.file);
|
|
501
|
+
if (text === null)
|
|
502
|
+
continue;
|
|
503
|
+
scanned += f.size;
|
|
504
|
+
const lines = text.split('\n');
|
|
505
|
+
for (let i = lines.length - 1; i >= 0 && out.length < opts.lines; i--) {
|
|
506
|
+
const record = parseRecord(lines[i]);
|
|
507
|
+
if (record && levelOf(record) >= min)
|
|
508
|
+
out.push({ record, raw: null, channel });
|
|
509
|
+
}
|
|
510
|
+
if (out.length >= opts.lines)
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
return { lines: out.reverse(), truncated: false };
|
|
514
|
+
}
|
|
515
|
+
function levelOf(record) {
|
|
516
|
+
const level = LOG_LEVELS.indexOf(record.severity_text);
|
|
517
|
+
return level === -1 ? LOG_LEVELS.length : level;
|
|
518
|
+
}
|
|
519
|
+
function toLine(line, channel) {
|
|
520
|
+
const record = parseRecord(line);
|
|
521
|
+
return record ? { record, raw: null, channel } : { record: null, raw: line, channel };
|
|
522
|
+
}
|
|
523
|
+
const TASK_LOG_MARGIN_MS = 10 * 60_000;
|
|
524
|
+
const TASK_LOG_MAX_SPAN_MS = 24 * 60 * 60 * 1000;
|
|
525
|
+
export function taskLog(src, run) {
|
|
526
|
+
const to = (run.finished_at ?? (run.state === 'running' ? src.now() : run.at)) + TASK_LOG_MARGIN_MS;
|
|
527
|
+
const from = Math.max(run.at - TASK_LOG_MARGIN_MS, to - TASK_LOG_MAX_SPAN_MS);
|
|
528
|
+
return scanTaskLines(src, run, from, to, () => true);
|
|
529
|
+
}
|
|
530
|
+
export const RUN_LOG_MARGIN_MS = 5_000;
|
|
531
|
+
export function runLog(src, run, runNo, from, savedAt) {
|
|
532
|
+
const to = savedAt + RUN_LOG_MARGIN_MS;
|
|
533
|
+
return scanTaskLines(src, run, from, to, (record) => {
|
|
534
|
+
const n = record.attributes?.run_no;
|
|
535
|
+
if (typeof n === 'number')
|
|
536
|
+
return n === runNo;
|
|
537
|
+
const at = Date.parse(record.timestamp);
|
|
538
|
+
return at > from && at <= to;
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
function scanTaskLines(src, run, from, to, keep) {
|
|
542
|
+
const first = utcDate(from);
|
|
543
|
+
const last = utcDate(to);
|
|
544
|
+
const lines = [];
|
|
545
|
+
let files = 0;
|
|
546
|
+
for (const channel of [null, run.worker]) {
|
|
547
|
+
for (const f of listChannelFiles(channelDir(src.logsDir, channel))) {
|
|
548
|
+
if (f.date < first || f.date > last)
|
|
549
|
+
continue;
|
|
550
|
+
const text = readIfExists(f.file);
|
|
551
|
+
if (text === null)
|
|
552
|
+
continue;
|
|
553
|
+
files++;
|
|
554
|
+
for (const line of text.split('\n')) {
|
|
555
|
+
if (!line.includes(run.task_id))
|
|
556
|
+
continue;
|
|
557
|
+
const record = parseRecord(line);
|
|
558
|
+
if (record?.attributes?.task_id === run.task_id && keep(record))
|
|
559
|
+
lines.push({ record, raw: null, channel });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
lines.sort((a, b) => (a.record?.timestamp ?? '').localeCompare(b.record?.timestamp ?? ''));
|
|
564
|
+
return { lines, deleted: files === 0 };
|
|
565
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { hostname, networkInterfaces } from 'node:os';
|
|
3
|
+
const LOOPBACK_NAMES = ['127.0.0.1', 'localhost', '[::1]'];
|
|
4
|
+
export function allowedHosts(port, scope = {}) {
|
|
5
|
+
const names = scope.lan ? [...LOOPBACK_NAMES, ...(scope.lanNames ?? lanNames)()] : LOOPBACK_NAMES;
|
|
6
|
+
return names.map((name) => `${name.toLowerCase()}:${port}`);
|
|
7
|
+
}
|
|
8
|
+
export function isRemoteViewer(req, port) {
|
|
9
|
+
if (!allowedHosts(port).includes((req.host ?? '').toLowerCase()))
|
|
10
|
+
return true;
|
|
11
|
+
return !isLoopbackAddress(req.remoteAddress);
|
|
12
|
+
}
|
|
13
|
+
function isLoopbackAddress(address) {
|
|
14
|
+
if (!address)
|
|
15
|
+
return false;
|
|
16
|
+
const v4 = address.toLowerCase().replace(/^::ffff:/, '');
|
|
17
|
+
return v4 === '::1' || /^127\.\d+\.\d+\.\d+$/.test(v4);
|
|
18
|
+
}
|
|
19
|
+
export function lanNames() {
|
|
20
|
+
const names = new Set();
|
|
21
|
+
for (const addrs of Object.values(networkInterfaces())) {
|
|
22
|
+
for (const a of addrs ?? [])
|
|
23
|
+
if (a.family === 'IPv4' && !a.internal)
|
|
24
|
+
names.add(a.address);
|
|
25
|
+
}
|
|
26
|
+
const host = hostname().toLowerCase();
|
|
27
|
+
if (host) {
|
|
28
|
+
names.add(host);
|
|
29
|
+
names.add(host.endsWith('.local') ? host : `${host}.local`);
|
|
30
|
+
}
|
|
31
|
+
return [...names];
|
|
32
|
+
}
|
|
33
|
+
export function checkRequest(req, port, scope = {}) {
|
|
34
|
+
const host = (req.host ?? '').toLowerCase();
|
|
35
|
+
if (!allowedHosts(port, scope).includes(host)) {
|
|
36
|
+
return {
|
|
37
|
+
status: 403,
|
|
38
|
+
message: scope.lan
|
|
39
|
+
? "Host header must be this PC's address or name"
|
|
40
|
+
: 'Host header must be 127.0.0.1 or localhost'
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (req.origin !== undefined && req.origin.toLowerCase() !== `http://${host}`) {
|
|
44
|
+
return { status: 403, message: 'requests from other web pages are not accepted' };
|
|
45
|
+
}
|
|
46
|
+
const site = req.secFetchSite;
|
|
47
|
+
if (site !== undefined && site !== 'none' && site !== 'same-origin') {
|
|
48
|
+
return { status: 403, message: 'requests from other web pages are not accepted' };
|
|
49
|
+
}
|
|
50
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
51
|
+
return { status: 405, message: 'the dashboard is read-only — use GET' };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
export function cookieName(port) {
|
|
56
|
+
return `tm_dashboard_${port}`;
|
|
57
|
+
}
|
|
58
|
+
export function readCookie(header, name) {
|
|
59
|
+
if (!header)
|
|
60
|
+
return undefined;
|
|
61
|
+
for (const part of header.split(';')) {
|
|
62
|
+
const i = part.indexOf('=');
|
|
63
|
+
if (i !== -1 && part.slice(0, i).trim() === name)
|
|
64
|
+
return part.slice(i + 1).trim();
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
export function tokenMatches(given, token) {
|
|
69
|
+
if (!given)
|
|
70
|
+
return false;
|
|
71
|
+
return timingSafeEqual(sha256(given), sha256(token));
|
|
72
|
+
}
|
|
73
|
+
function sha256(value) {
|
|
74
|
+
return createHash('sha256').update(value).digest();
|
|
75
|
+
}
|