@3minapi/cli 0.0.0-stage → 0.22.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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +82 -2
  3. package/dist/bin.js +54 -0
  4. package/dist/cli.js +241 -0
  5. package/dist/commands/auth.js +573 -0
  6. package/dist/commands/common.js +48 -0
  7. package/dist/commands/dashboard.js +298 -0
  8. package/dist/commands/delete.js +167 -0
  9. package/dist/commands/doctor.js +326 -0
  10. package/dist/commands/guide.js +84 -0
  11. package/dist/commands/index.js +28 -0
  12. package/dist/commands/init.js +248 -0
  13. package/dist/commands/logs.js +154 -0
  14. package/dist/commands/new.js +109 -0
  15. package/dist/commands/paths.js +64 -0
  16. package/dist/commands/reload.js +32 -0
  17. package/dist/commands/results.js +445 -0
  18. package/dist/commands/start.js +443 -0
  19. package/dist/commands/stop.js +68 -0
  20. package/dist/commands/tasks.js +93 -0
  21. package/dist/commands/worker.js +178 -0
  22. package/dist/core/api.js +132 -0
  23. package/dist/core/config.js +188 -0
  24. package/dist/core/control-db.js +462 -0
  25. package/dist/core/credentials.js +75 -0
  26. package/dist/core/domain/constants.js +121 -0
  27. package/dist/core/domain/contract.js +102 -0
  28. package/dist/core/domain/errors.js +57 -0
  29. package/dist/core/domain/index.js +3 -0
  30. package/dist/core/domain/types.js +1 -0
  31. package/dist/core/error-report.js +103 -0
  32. package/dist/core/host/detach.js +49 -0
  33. package/dist/core/host/live-connection.js +270 -0
  34. package/dist/core/host/live-receiver.js +264 -0
  35. package/dist/core/host/module.js +1 -0
  36. package/dist/core/host/poller.js +113 -0
  37. package/dist/core/host/pull-step.js +54 -0
  38. package/dist/core/host/receiver.js +1 -0
  39. package/dist/core/host/reload.js +34 -0
  40. package/dist/core/host/runner-lock.js +245 -0
  41. package/dist/core/host/session.js +84 -0
  42. package/dist/core/host/usage.js +20 -0
  43. package/dist/core/log-read.js +59 -0
  44. package/dist/core/logger.js +206 -0
  45. package/dist/core/names.js +27 -0
  46. package/dist/core/network.js +10 -0
  47. package/dist/core/open-browser.js +26 -0
  48. package/dist/core/output.js +68 -0
  49. package/dist/core/paths.js +26 -0
  50. package/dist/core/project.js +85 -0
  51. package/dist/core/prompt.js +140 -0
  52. package/dist/core/redact.js +45 -0
  53. package/dist/core/secure-fs.js +50 -0
  54. package/dist/core/text-file.js +12 -0
  55. package/dist/core/text.js +27 -0
  56. package/dist/core/time.js +84 -0
  57. package/dist/core/update-notice.js +142 -0
  58. package/dist/core/version.js +16 -0
  59. package/dist/core/warnings.js +10 -0
  60. package/dist/help.js +144 -0
  61. package/dist/modules/dashboard/assets.js +411 -0
  62. package/dist/modules/dashboard/data.js +565 -0
  63. package/dist/modules/dashboard/guard.js +75 -0
  64. package/dist/modules/dashboard/lock.js +93 -0
  65. package/dist/modules/dashboard/server.js +295 -0
  66. package/dist/modules/dashboard/views/html.js +114 -0
  67. package/dist/modules/dashboard/views/layout.js +59 -0
  68. package/dist/modules/dashboard/views/logs.js +122 -0
  69. package/dist/modules/dashboard/views/overview.js +316 -0
  70. package/dist/modules/dashboard/views/runs.js +486 -0
  71. package/dist/modules/dashboard/views/workers.js +157 -0
  72. package/dist/modules/workers/builtin/host-identity.js +59 -0
  73. package/dist/modules/workers/builtin/results-worker.js +37 -0
  74. package/dist/modules/workers/local-server.js +244 -0
  75. package/dist/modules/workers/module.js +98 -0
  76. package/dist/modules/workers/reload-result.js +16 -0
  77. package/dist/modules/workers/repeat-scheduler.js +165 -0
  78. package/dist/modules/workers/results/missing-paths.js +66 -0
  79. package/dist/modules/workers/results/query.js +175 -0
  80. package/dist/modules/workers/results/reader-input.js +134 -0
  81. package/dist/modules/workers/results/reader.js +430 -0
  82. package/dist/modules/workers/results/results-child.js +64 -0
  83. package/dist/modules/workers/results/shape.js +212 -0
  84. package/dist/modules/workers/results/store.js +336 -0
  85. package/dist/modules/workers/results/substitute.js +30 -0
  86. package/dist/modules/workers/runtime/env.js +43 -0
  87. package/dist/modules/workers/runtime/error-chain.js +109 -0
  88. package/dist/modules/workers/runtime/executor.js +289 -0
  89. package/dist/modules/workers/runtime/inspect.js +53 -0
  90. package/dist/modules/workers/runtime/protocol.js +1 -0
  91. package/dist/modules/workers/runtime/resolve.js +30 -0
  92. package/dist/modules/workers/runtime/schema.js +85 -0
  93. package/dist/modules/workers/runtime/thread.js +117 -0
  94. package/dist/modules/workers/start.js +265 -0
  95. package/dist/modules/workers/sync.js +241 -0
  96. package/dist/modules/workers/task-runner.js +892 -0
  97. package/dist/modules/workers/template.js +100 -0
  98. package/dist/modules/workers/worker-set.js +122 -0
  99. package/dist/modules/workers/workers.js +66 -0
  100. package/package.json +48 -5
@@ -0,0 +1,93 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { BIN, CliError, DASHBOARD_LINK_FILE, DASHBOARD_LOCK_FILE, DASHBOARD_STOP_FILE, SECRET_FILE_MODE } from "../../core/domain/index.js";
5
+ import { claimLockFile, heartbeater, readLockFile, readSettled, releaseLockFile, stopFileMatches } from "../../core/host/runner-lock.js";
6
+ import { writeSecretFile } from "../../core/secure-fs.js";
7
+ export function dashboardLockPath(dataDir) {
8
+ return path.join(dataDir, DASHBOARD_LOCK_FILE);
9
+ }
10
+ function stopPath(dataDir) {
11
+ return path.join(dataDir, DASHBOARD_STOP_FILE);
12
+ }
13
+ function linkPath(dataDir) {
14
+ return path.join(dataDir, DASHBOARD_LINK_FILE);
15
+ }
16
+ export function readDashboard(dataDir) {
17
+ return readLockFile(dashboardLockPath(dataDir));
18
+ }
19
+ export function readDashboardSettled(dataDir) {
20
+ return readSettled(() => readDashboard(dataDir));
21
+ }
22
+ export function acquireDashboardLock(dataDir, fields, now) {
23
+ const file = dashboardLockPath(dataDir);
24
+ let info = {
25
+ pid: process.pid,
26
+ nonce: randomBytes(8).toString('hex'),
27
+ startedAt: now,
28
+ heartbeat: true,
29
+ port: null,
30
+ origins: [],
31
+ ...fields
32
+ };
33
+ claimLockFile(file, info, (cause) => alreadyRunning(dataDir, cause));
34
+ const beat = heartbeater(file);
35
+ rmSync(stopPath(dataDir), { force: true });
36
+ rmSync(linkPath(dataDir), { force: true });
37
+ let released = false;
38
+ return {
39
+ get info() {
40
+ return info;
41
+ },
42
+ listening({ url, lanUrls, port, origins }) {
43
+ const record = { nonce: info.nonce, url, lanUrls };
44
+ writeSecretFile(linkPath(dataDir), JSON.stringify(record));
45
+ info = { ...info, port, origins };
46
+ writeSecretFile(file, JSON.stringify(info));
47
+ },
48
+ stopRequested() {
49
+ return stopFileMatches(stopPath(dataDir), info.nonce);
50
+ },
51
+ heartbeat: beat,
52
+ release() {
53
+ if (released)
54
+ return;
55
+ released = true;
56
+ releaseLockFile(file, info.nonce);
57
+ rmSync(stopPath(dataDir), { force: true });
58
+ try {
59
+ const record = JSON.parse(readFileSync(linkPath(dataDir), 'utf8'));
60
+ if (record.nonce === info.nonce)
61
+ rmSync(linkPath(dataDir), { force: true });
62
+ }
63
+ catch {
64
+ }
65
+ }
66
+ };
67
+ }
68
+ export function readDashboardLinks(dataDir, info) {
69
+ try {
70
+ const record = JSON.parse(readFileSync(linkPath(dataDir), 'utf8'));
71
+ if (record.nonce !== info.nonce || typeof record.url !== 'string')
72
+ return null;
73
+ return { url: record.url, lanUrls: Array.isArray(record.lanUrls) ? record.lanUrls : [] };
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ export function requestDashboardStop(dataDir, info) {
80
+ writeFileSync(stopPath(dataDir), info.nonce, { mode: SECRET_FILE_MODE });
81
+ }
82
+ function alreadyRunning(dataDir, cause) {
83
+ const current = readDashboard(dataDir);
84
+ if (current.state === 'running') {
85
+ const where = current.info.detached ? 'in the background' : 'in another terminal';
86
+ return new CliError(`the dashboard is already running ${where} (pid ${current.info.pid}). Only one can run per host.\n` +
87
+ `Open it with '${BIN} dashboard url', or stop it with '${BIN} dashboard stop' first.`);
88
+ }
89
+ if (current.state === 'starting') {
90
+ return new CliError(`another '${BIN} dashboard' is starting right now.`);
91
+ }
92
+ return new CliError(`cannot create ${dashboardLockPath(dataDir)}: ${cause.message}`);
93
+ }
@@ -0,0 +1,295 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { CliError, UsageError } from "../../core/domain/index.js";
4
+ import { APP_CSS, APP_JS } from "./assets.js";
5
+ import { LOG_LEVELS, RANGES, RUN_RANGES, RUN_STATES, decodeCursor, hostName, knownWorkers, listRuns, logChannels, overview, isRepeat, readLog, repeatRuns, runDetail, runLog, runOfTask, taskLog, workerSource, workersView } from "./data.js";
6
+ import { checkRequest, cookieName, isRemoteViewer, lanNames, readCookie, tokenMatches } from "./guard.js";
7
+ import { shortTaskId } from "./views/html.js";
8
+ import { layout, message } from "./views/layout.js";
9
+ import { LOG_LINE_CHOICES, logsPage } from "./views/logs.js";
10
+ import { overviewPage } from "./views/overview.js";
11
+ import { repeatPage, runOfTaskPage, runPage, runsPage } from "./views/runs.js";
12
+ import { workerCodePage, workersPage } from "./views/workers.js";
13
+ export const RUNS_PAGE_LIMIT = 100;
14
+ export const REFRESH_MS = 5_000;
15
+ export const OVERVIEW_REFRESH_MS = 15_000;
16
+ const SECURITY_HEADERS = {
17
+ 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
18
+ 'X-Content-Type-Options': 'nosniff',
19
+ 'X-Frame-Options': 'DENY',
20
+ 'Referrer-Policy': 'no-referrer',
21
+ 'Cross-Origin-Opener-Policy': 'same-origin',
22
+ 'Cross-Origin-Resource-Policy': 'same-origin',
23
+ 'Cache-Control': 'no-store'
24
+ };
25
+ export async function startDashboard(opts) {
26
+ const token = opts.token ?? randomBytes(32).toString('hex');
27
+ const bindAddress = opts.listenHost ?? (opts.lan ? '0.0.0.0' : '127.0.0.1');
28
+ let port = 0;
29
+ const server = createServer((req, res) => {
30
+ let reply;
31
+ const ctx = { host: null, remote: true, lan: opts.lan === true };
32
+ try {
33
+ reply = handle(opts, token, port, req, ctx);
34
+ }
35
+ catch (err) {
36
+ opts.onError?.(err, req.url ?? '/');
37
+ reply = page(ctx, opts, 500, 'Error', null, err instanceof CliError
38
+ ? message('Something went wrong', err.message)
39
+ : message('Unexpected error', `This is a bug in the CLI — the details are in the terminal running 3minapi dashboard${terminalWhere(ctx)}.`));
40
+ }
41
+ send(req, res, reply);
42
+ });
43
+ await new Promise((resolve, reject) => {
44
+ server.once('error', reject);
45
+ server.listen(opts.port, bindAddress, () => {
46
+ server.off('error', reject);
47
+ resolve();
48
+ });
49
+ }).catch((err) => {
50
+ if (err.code === 'EADDRINUSE') {
51
+ throw new UsageError(`port ${opts.port} is already in use — pick another with --port`);
52
+ }
53
+ if (err.code === 'EACCES') {
54
+ throw new UsageError(`cannot listen on port ${opts.port}: permission denied — pick a port above 1023 with --port`);
55
+ }
56
+ throw new CliError(`cannot start the dashboard on ${bindAddress}:${opts.port}: ${err.message}`);
57
+ });
58
+ port = server.address().port;
59
+ const origin = `http://127.0.0.1:${port}`;
60
+ const lanOrigins = opts.lan
61
+ ? (opts.lanNames ?? lanNames)()
62
+ .filter((name) => /^\d+\.\d+\.\d+\.\d+$/.test(name) || name.endsWith('.local'))
63
+ .map((name) => `http://${name}:${port}`)
64
+ : [];
65
+ return {
66
+ url: `${origin}/?token=${token}`,
67
+ origin,
68
+ lanUrls: lanOrigins.map((o) => `${o}/?token=${token}`),
69
+ lanOrigins,
70
+ port,
71
+ close: () => new Promise((resolve) => {
72
+ server.closeAllConnections();
73
+ server.close(() => resolve());
74
+ })
75
+ };
76
+ }
77
+ function handle(opts, token, port, req, pageCtx) {
78
+ const access = (kind, status) => {
79
+ let path = '/';
80
+ try {
81
+ path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname;
82
+ }
83
+ catch {
84
+ }
85
+ opts.onAccess?.({
86
+ kind,
87
+ status,
88
+ remote: req.socket.remoteAddress,
89
+ host: req.headers.host,
90
+ path
91
+ });
92
+ };
93
+ const denied = checkRequest({
94
+ method: req.method,
95
+ host: req.headers.host,
96
+ origin: req.headers.origin,
97
+ secFetchSite: header(req, 'sec-fetch-site')
98
+ }, port, { lan: opts.lan, lanNames: opts.lanNames });
99
+ if (denied) {
100
+ if (denied.status === 403)
101
+ access('denied', 403);
102
+ return text(denied.status, denied.message);
103
+ }
104
+ pageCtx.remote = isRemoteViewer({ host: req.headers.host, remoteAddress: req.socket.remoteAddress }, port);
105
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
106
+ const cookie = cookieName(port);
107
+ if (url.pathname === '/favicon.ico')
108
+ return { status: 204, type: 'text/plain; charset=utf-8', body: '' };
109
+ const given = url.searchParams.get('token');
110
+ if (given !== null) {
111
+ if (!tokenMatches(given, token)) {
112
+ access('denied', 401);
113
+ return unauthorized(pageCtx, opts);
114
+ }
115
+ access('granted', 303);
116
+ url.searchParams.delete('token');
117
+ return {
118
+ status: 303,
119
+ type: 'text/plain; charset=utf-8',
120
+ body: '',
121
+ headers: {
122
+ Location: `${url.pathname}${url.search}`,
123
+ 'Set-Cookie': `${cookie}=${token}; Path=/; HttpOnly; SameSite=Strict`
124
+ }
125
+ };
126
+ }
127
+ if (!tokenMatches(readCookie(req.headers.cookie, cookie), token)) {
128
+ access('denied', 401);
129
+ return unauthorized(pageCtx, opts);
130
+ }
131
+ const path = url.pathname;
132
+ const q = url.searchParams;
133
+ if (path === '/assets/app.css')
134
+ return { status: 200, type: 'text/css; charset=utf-8', body: APP_CSS };
135
+ if (path === '/assets/app.js')
136
+ return { status: 200, type: 'text/javascript; charset=utf-8', body: APP_JS };
137
+ const ctx = { ...pageCtx, host: hostName(opts) };
138
+ pageCtx.host = ctx.host;
139
+ if (path === '/') {
140
+ const range = pick(q.get('range'), RANGES, '7d');
141
+ return page(ctx, opts, 200, 'Overview', 'overview', overviewPage(overview(opts, range), opts.now(), ctx), true);
142
+ }
143
+ if (path === '/runs') {
144
+ const range = pick(q.get('range'), RUN_RANGES, '7d');
145
+ const state = pick(q.get('state'), RUN_STATES, undefined);
146
+ const worker = q.get('worker') || undefined;
147
+ const after = decodeCursor(q.get('after'));
148
+ const filter = { range, ...(state ? { state } : {}), ...(worker ? { worker } : {}) };
149
+ const { runs, next } = listRuns(opts, filter, RUNS_PAGE_LIMIT, after);
150
+ const workers = knownWorkers(opts);
151
+ if (worker && !workers.includes(worker))
152
+ workers.push(worker);
153
+ return page(ctx, opts, 200, 'Runs', 'runs', runsPage({ runs, next, paged: after !== null, firstNo: after ? firstNo(q.get('no')) : 1 }, filter, workers, ctx), after === null);
154
+ }
155
+ const run = /^\/runs\/([^/]+)(?:\/(\d+))?$/.exec(path);
156
+ if (run && decodePart(run[1]) !== null) {
157
+ const taskId = decodePart(run[1]);
158
+ const base = `/runs/${encodeURIComponent(taskId)}`;
159
+ const runNo = run[2] !== undefined ? positive(run[2]) : undefined;
160
+ if (run[2] !== undefined) {
161
+ if (runNo === undefined)
162
+ return notFound(ctx, opts);
163
+ if (run[2] !== String(runNo))
164
+ return redirect(`${base}/${runNo}${q.size > 0 ? `?${q}` : ''}`);
165
+ }
166
+ const legacy = runNo === undefined ? positive(q.get('run')) : undefined;
167
+ if (legacy !== undefined) {
168
+ q.delete('run');
169
+ q.delete('runs_before');
170
+ return redirect(`${base}/${legacy}${q.size > 0 ? `?${q}` : ''}`);
171
+ }
172
+ const detail = runDetail(opts, taskId);
173
+ if (!detail) {
174
+ return page(ctx, opts, 404, 'Not found', 'runs', message('Run not found', `No task ${taskId} in the history or saved results on host ${ctx.host}.`));
175
+ }
176
+ const offset = Number(q.get('offset') ?? 0);
177
+ const view = {
178
+ path: q.get('path') || '$',
179
+ offset: Number.isInteger(offset) && offset >= 0 ? offset : 0
180
+ };
181
+ const repeat = isRepeat(detail.run);
182
+ if (!repeat) {
183
+ if (runNo !== undefined)
184
+ return redirect(base);
185
+ return page(ctx, opts, 200, `Run ${shortTaskId(taskId)}`, 'runs', runPage(detail, view, taskLogSection(opts, detail.run, () => taskLog(opts, detail.run)), ctx));
186
+ }
187
+ if (runNo !== undefined) {
188
+ const one = runOfTask(opts, detail.run, runNo);
189
+ const log = one.result
190
+ ? taskLogSection(opts, detail.run, () => runLog(opts, detail.run, runNo, one.from, Date.parse(one.result.created_at)))
191
+ : null;
192
+ return page(ctx, opts, 200, `Run ${shortTaskId(taskId)} · ${runNo}`, 'runs', runOfTaskPage(detail.run, runNo, one, view, log, ctx));
193
+ }
194
+ const runsBefore = positive(q.get('runs_before'));
195
+ return page(ctx, opts, 200, `Run ${shortTaskId(taskId)}`, 'runs', repeatPage(detail.run, repeatRuns(opts, taskId, runsBefore), runsBefore !== undefined, ctx), runsBefore === undefined && detail.run.state === 'running');
196
+ }
197
+ if (path === '/workers') {
198
+ return page(ctx, opts, 200, 'Workers', 'workers', workersPage(workersView(opts), ctx), true);
199
+ }
200
+ const worker = /^\/workers\/([^/]+)$/.exec(path);
201
+ if (worker && decodePart(worker[1]) !== null) {
202
+ const name = decodePart(worker[1]);
203
+ const files = workerSource(opts, name);
204
+ if (!files) {
205
+ return page(ctx, opts, 404, 'Not found', 'workers', message('Worker not found', `No worker file named ${name} in the workers folder.`));
206
+ }
207
+ return page(ctx, opts, 200, name, 'workers', workerCodePage(name, files, ctx));
208
+ }
209
+ if (path === '/logs') {
210
+ const channels = logChannels(opts);
211
+ const raw = q.get('channel') || null;
212
+ const unknown = raw !== null && !channels.includes(raw) ? raw : null;
213
+ const channel = unknown === null ? raw : null;
214
+ const level = pick(q.get('level'), LOG_LEVELS, 'debug');
215
+ const count = pick(Number(q.get('n')), LOG_LINE_CHOICES, LOG_LINE_CHOICES[0]);
216
+ const read = readLog(opts, channel, { lines: count, minLevel: level });
217
+ return page(ctx, opts, 200, 'Logs', 'logs', unknown === null
218
+ ? logsPage(read, { channel, channels, level, count }, ctx)
219
+ : message('No such log', `No log for worker ${unknown} on host ${ctx.host}.`), true);
220
+ }
221
+ return notFound(ctx, opts);
222
+ }
223
+ function notFound(ctx, opts) {
224
+ return page(ctx, opts, 404, 'Not found', null, message('Not found', 'There is no page here.'));
225
+ }
226
+ function redirect(location) {
227
+ return {
228
+ status: 303,
229
+ type: 'text/plain; charset=utf-8',
230
+ body: '',
231
+ headers: { Location: location }
232
+ };
233
+ }
234
+ function decodePart(raw) {
235
+ try {
236
+ return decodeURIComponent(raw);
237
+ }
238
+ catch {
239
+ return null;
240
+ }
241
+ }
242
+ function positive(raw) {
243
+ if (raw === null || raw === '')
244
+ return undefined;
245
+ const n = Number(raw);
246
+ return Number.isSafeInteger(n) && n >= 1 ? n : undefined;
247
+ }
248
+ function firstNo(raw) {
249
+ const n = Number(raw);
250
+ return Number.isInteger(n) && n >= 1 ? n : 1;
251
+ }
252
+ function taskLogSection(opts, run, read) {
253
+ try {
254
+ return read();
255
+ }
256
+ catch (err) {
257
+ opts.onError?.(err, `/runs/${run.task_id} (log)`);
258
+ return { lines: [], deleted: false, error: err?.message ?? String(err) };
259
+ }
260
+ }
261
+ function pick(value, choices, fallback) {
262
+ return choices.includes(value) ? value : fallback;
263
+ }
264
+ function header(req, name) {
265
+ const v = req.headers[name];
266
+ return Array.isArray(v) ? v[0] : v;
267
+ }
268
+ function page(ctx, opts, status, title, section, body, refresh = false) {
269
+ const refreshMs = section === 'overview' ? OVERVIEW_REFRESH_MS : REFRESH_MS;
270
+ return {
271
+ status,
272
+ type: 'text/html; charset=utf-8',
273
+ body: layout({ title, section, version: opts.version, refresh, refreshMs, ctx }, body).value
274
+ };
275
+ }
276
+ function terminalWhere(ctx) {
277
+ if (!ctx.remote)
278
+ return '';
279
+ return ctx.host !== null ? ` on host ${ctx.host}` : ' on the computer running the dashboard';
280
+ }
281
+ function unauthorized(ctx, opts) {
282
+ return page(ctx, opts, 401, 'Sign in', null, message('Open the link from the terminal', `This dashboard needs the link that 3minapi dashboard printed — it works for that run only. Each run makes a new link; ${ctx.remote ? 'on the computer running the dashboard, ' : ''}3minapi dashboard url prints it again.`));
283
+ }
284
+ function text(status, body) {
285
+ return { status, type: 'text/plain; charset=utf-8', body };
286
+ }
287
+ function send(req, res, reply) {
288
+ res.writeHead(reply.status, {
289
+ ...SECURITY_HEADERS,
290
+ 'Content-Type': reply.type,
291
+ 'Content-Length': Buffer.byteLength(reply.body),
292
+ ...reply.headers
293
+ });
294
+ res.end(req.method === 'HEAD' ? undefined : reply.body);
295
+ }
@@ -0,0 +1,114 @@
1
+ import { BUILTIN_RESULTS_PREFIX } from "../../../core/domain/index.js";
2
+ import { formatLocal } from "../../../core/time.js";
3
+ export class Html {
4
+ value;
5
+ constructor(value) {
6
+ this.value = value;
7
+ }
8
+ toString() {
9
+ return this.value;
10
+ }
11
+ }
12
+ export function escapeHtml(text) {
13
+ return text
14
+ .replace(/&/g, '&')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;')
18
+ .replace(/'/g, '&#39;');
19
+ }
20
+ function render(part) {
21
+ if (part instanceof Html)
22
+ return part.value;
23
+ if (Array.isArray(part))
24
+ return part.map(render).join('');
25
+ if (part === null || part === undefined || part === false)
26
+ return '';
27
+ return escapeHtml(String(part));
28
+ }
29
+ export function html(strings, ...values) {
30
+ let out = strings[0];
31
+ for (let i = 0; i < values.length; i++)
32
+ out += render(values[i]) + strings[i + 1];
33
+ return new Html(out);
34
+ }
35
+ export function qs(params) {
36
+ const p = new URLSearchParams();
37
+ for (const [k, v] of Object.entries(params))
38
+ if (v !== undefined && v !== null && v !== '')
39
+ p.set(k, String(v));
40
+ const s = p.toString();
41
+ return s ? `?${s}` : '';
42
+ }
43
+ export function time(ms) {
44
+ if (ms === null || ms === undefined || Number.isNaN(ms))
45
+ return html `<span class="muted">—</span>`;
46
+ const text = formatLocal(ms);
47
+ return html `<time datetime="${new Date(ms).toISOString()}" title="${text}"
48
+ >${text.slice(0, 19)}</time
49
+ >`;
50
+ }
51
+ export function duration(ms) {
52
+ if (ms === null)
53
+ return '—';
54
+ if (ms < 1000)
55
+ return `${ms} ms`;
56
+ if (ms < 60_000)
57
+ return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`;
58
+ const m = Math.floor(ms / 60_000);
59
+ return `${m}m ${Math.round((ms % 60_000) / 1000)}s`;
60
+ }
61
+ export function stateTone(state) {
62
+ switch (state) {
63
+ case 'completed':
64
+ return 'ok';
65
+ case 'failed':
66
+ return 'fail';
67
+ case 'running':
68
+ case 'received':
69
+ return 'progress';
70
+ default:
71
+ return 'muted';
72
+ }
73
+ }
74
+ export function badge(text, tone = stateTone(text)) {
75
+ return html `<span class="badge badge-${tone}">${text}</span>`;
76
+ }
77
+ export function tag(text, title) {
78
+ return title
79
+ ? html `<span class="tag" title="${title}">${text}</span>`
80
+ : html `<span class="tag">${text}</span>`;
81
+ }
82
+ export function smallButton(label, extra, attrs = html ``) {
83
+ return html `<button type="button" class="btn-sm ${extra}" ${attrs}>${label}</button>`;
84
+ }
85
+ export function copyButton(text, label = 'Copy') {
86
+ return smallButton(label, 'copy', html `data-copy="${text}"`);
87
+ }
88
+ export function command(text, ctx) {
89
+ return html `<span class="cmd"
90
+ >${ctx.remote
91
+ ? html `<span class="muted">Run on ${ctx.host}:</span> `
92
+ : ''}<code>${text}</code>${copyButton(text)}</span
93
+ >`;
94
+ }
95
+ export function hostPath(file, ctx) {
96
+ return ctx.remote
97
+ ? html `<code>${file}</code> <span class="muted">on host ${ctx.host}</span>`
98
+ : html `<code>${file}</code> ${copyButton(file, 'Copy path')}`;
99
+ }
100
+ export function isBuiltinWorker(name) {
101
+ return name.startsWith(BUILTIN_RESULTS_PREFIX);
102
+ }
103
+ export const BUILTIN_WORKER_LABEL = 'Results reader';
104
+ export function workerName(name) {
105
+ return isBuiltinWorker(name)
106
+ ? html `<span title="${name}">${BUILTIN_WORKER_LABEL} ${tag('built-in')}</span>`
107
+ : html `${name}`;
108
+ }
109
+ export function workerText(name) {
110
+ return isBuiltinWorker(name) ? `${BUILTIN_WORKER_LABEL} (built-in)` : name;
111
+ }
112
+ export function shortTaskId(taskId) {
113
+ return taskId.length <= 8 ? taskId : `…${taskId.slice(-8)}`;
114
+ }
@@ -0,0 +1,59 @@
1
+ import { html } from "./html.js";
2
+ const NAV = [
3
+ ['overview', 'Overview', '/'],
4
+ ['runs', 'Runs', '/runs'],
5
+ ['workers', 'Workers', '/workers'],
6
+ ['logs', 'Logs', '/logs']
7
+ ];
8
+ export function layout(opts, body) {
9
+ return html `<!doctype html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="utf-8" />
13
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
14
+ <meta name="color-scheme" content="light dark" />
15
+ <meta name="referrer" content="no-referrer" />
16
+ <title>${opts.title} · 3minapi dashboard</title>
17
+ <link rel="stylesheet" href="/assets/app.css" />
18
+ <script src="/assets/app.js" defer></script>
19
+ </head>
20
+ <body
21
+ ${opts.ctx.host !== null ? html ` data-host="${opts.ctx.host}"` : ''}${opts.ctx.remote
22
+ ? html ` data-remote`
23
+ : ''}
24
+ >
25
+ <header class="top">
26
+ <a class="brand" href="/">3minapi <span>dashboard</span></a>
27
+ <nav aria-label="Sections">
28
+ ${NAV.map(([key, label, href]) => html `<a href="${href}" ${key === opts.section ? html ` aria-current="page"` : ''}
29
+ >${label}</a
30
+ >`)}
31
+ </nav>
32
+ ${opts.refresh
33
+ ? html `<label class="auto"
34
+ ><input type="checkbox" id="auto-refresh" checked /> Auto refresh</label
35
+ >`
36
+ : ''}
37
+ </header>
38
+ ${opts.refresh ? html `<p id="stale" class="stale" role="status" hidden></p>` : ''}
39
+ <main id="main" ${opts.refresh ? html ` data-refresh="${opts.refreshMs}"` : ''}>
40
+ ${body}
41
+ </main>
42
+ <footer class="foot">
43
+ <span>3minapi ${opts.version}</span>
44
+ <span
45
+ >${opts.ctx.lan
46
+ ? 'Read-only. LAN mode is on — other devices on the network see these pages over plain HTTP.'
47
+ : 'Local and read-only — data never leaves this PC.'}
48
+ Change things with the CLI.</span
49
+ >
50
+ </footer>
51
+ </body>
52
+ </html> `;
53
+ }
54
+ export function message(title, text) {
55
+ return html `<section class="empty">
56
+ <h1>${title}</h1>
57
+ <p>${text}</p>
58
+ </section>`;
59
+ }
@@ -0,0 +1,122 @@
1
+ import { BIN } from "../../../core/domain/index.js";
2
+ import { stripControl } from "../../../core/log-read.js";
3
+ import { LOG_LEVELS, LOG_SCAN_MAX_BYTES } from "../data.js";
4
+ import { command, html, shortTaskId, smallButton, time, workerName, workerText } from "./html.js";
5
+ export const LOG_LINE_CHOICES = [100, 300, 1000];
6
+ export function logsPage(read, opts, ctx) {
7
+ return html `
8
+ <div class="head"><h1>Logs</h1></div>
9
+ <form class="filters" method="get" action="/logs" data-autosubmit>
10
+ <label
11
+ >Log
12
+ <select name="channel">
13
+ <option value="" ${opts.channel === null ? html ` selected` : ''}>CLI (app)</option>
14
+ ${opts.channels.map((c) => html `<option value="${c}" ${c === opts.channel ? html ` selected` : ''}>
15
+ worker: ${workerText(c)}
16
+ </option>`)}
17
+ </select>
18
+ </label>
19
+ <label
20
+ >Level
21
+ <select name="level">
22
+ ${LOG_LEVELS.map((l) => html `<option value="${l}" ${l === opts.level ? html ` selected` : ''}>
23
+ ${l === 'debug' ? 'all' : `${l} and above`}
24
+ </option>`)}
25
+ </select>
26
+ </label>
27
+ <label
28
+ >Lines
29
+ <select name="n">
30
+ ${LOG_LINE_CHOICES.map((n) => html `<option value="${n}" ${n === opts.count ? html ` selected` : ''}>${n}</option>`)}
31
+ </select>
32
+ </label>
33
+ <label
34
+ >Find
35
+ <input
36
+ type="search"
37
+ id="log-find"
38
+ placeholder="Text in the message"
39
+ autocomplete="off"
40
+ spellcheck="false"
41
+ />
42
+ </label>
43
+ <noscript><button type="submit">Apply</button></noscript>
44
+ </form>
45
+ <p class="note">
46
+ Follow it in a terminal:
47
+ ${command(opts.channel === null ? `${BIN} logs -f` : `${BIN} worker logs ${opts.channel} -f`, ctx)}
48
+ </p>
49
+ ${read.lines.length === 0
50
+ ? html `<p class="muted">No log lines.</p>`
51
+ : html `<p class="muted" id="log-find-count" hidden></p>
52
+ ${logTable([...read.lines].reverse(), { channel: false, task: true })}`}
53
+ ${read.truncated
54
+ ? html `<p class="muted">
55
+ Searched the most recent ${LOG_SCAN_MAX_BYTES / 1024 / 1024} MB of this log. For older
56
+ lines use
57
+ ${command(opts.channel === null
58
+ ? `${BIN} logs -n 10000`
59
+ : `${BIN} worker logs ${opts.channel} -n 10000`, ctx)}
60
+ </p>`
61
+ : ''}
62
+ `;
63
+ }
64
+ export function logTable(lines, cols) {
65
+ return html `<div class="scroll">
66
+ <table class="table log">
67
+ <thead>
68
+ <tr>
69
+ <th>Time</th>
70
+ ${cols.channel ? html `<th>Log</th>` : ''}
71
+ <th>Level</th>
72
+ ${cols.task ? html `<th>Task</th>` : ''}
73
+ <th>Message</th>
74
+ </tr>
75
+ </thead>
76
+ <tbody>
77
+ ${lines.map((l) => {
78
+ if (!l.record) {
79
+ return html `<tr>
80
+ <td></td>
81
+ ${cols.channel ? html `<td></td>` : ''}
82
+ <td></td>
83
+ ${cols.task ? html `<td></td>` : ''}
84
+ <td class="msg-cell">${clamp(html `<code>${stripControl(l.raw ?? '')}</code>`)}</td>
85
+ </tr>`;
86
+ }
87
+ const r = l.record;
88
+ const taskId = typeof r.attributes?.task_id === 'string' ? r.attributes.task_id : null;
89
+ const attrs = Object.entries(r.attributes ?? {})
90
+ .filter(([k]) => !(cols.task && taskId !== null && k === 'task_id'))
91
+ .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
92
+ .join(' ');
93
+ return html `<tr class="lvl-${r.severity_text}">
94
+ <td>${time(Date.parse(r.timestamp))}</td>
95
+ ${cols.channel
96
+ ? html `<td>${l.channel === null ? 'cli' : workerName(l.channel)}</td>`
97
+ : ''}
98
+ <td><span class="level">${String(r.severity_text)}</span></td>
99
+ ${cols.task
100
+ ? html `<td class="task">
101
+ ${taskId
102
+ ? html `<a href="/runs/${encodeURIComponent(taskId)}" title="${taskId}"
103
+ ><code>${shortTaskId(taskId)}</code></a
104
+ >`
105
+ : ''}
106
+ </td>`
107
+ : ''}
108
+ <td class="msg-cell">
109
+ ${clamp(html `<span class="msg">${stripControl(String(r.body))}</span>${attrs
110
+ ? html ` <span class="attrs">${stripControl(attrs)}</span>`
111
+ : ''}`)}
112
+ </td>
113
+ </tr>`;
114
+ })}
115
+ </tbody>
116
+ </table>
117
+ </div>`;
118
+ }
119
+ function clamp(content) {
120
+ return html `<div class="clamp">${content}</div>
121
+ ${smallButton('Show all', 'more', html `hidden`)}`;
122
+ }