bunqueue-dashboard 0.0.14
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 +315 -0
- package/LICENSE +21 -0
- package/README.md +287 -0
- package/agent/db.ts +491 -0
- package/agent/dbQueryWorker.ts +21 -0
- package/agent/index.ts +47 -0
- package/agent/logger.ts +23 -0
- package/agent/manager.ts +262 -0
- package/agent/server.ts +213 -0
- package/dist/.vite/manifest.json +578 -0
- package/dist/assets/AddJob-DOtZrwc-.js +3 -0
- package/dist/assets/AreaChart-DzUXW8u3.js +1 -0
- package/dist/assets/Benchmark-BKVE509y.js +1 -0
- package/dist/assets/CopilotPanel-Bn8gPZx_.js +152 -0
- package/dist/assets/CopyButton-CSjndiY7.js +1 -0
- package/dist/assets/Cron-DzeoQTpD.js +1 -0
- package/dist/assets/CronManager-CI6wTFbe.js +1 -0
- package/dist/assets/Database-Bjkl_O9n.js +3 -0
- package/dist/assets/Diagnostics-BUeDbexv.js +1 -0
- package/dist/assets/Dlq-BzwhitJI.js +1 -0
- package/dist/assets/DlqControl-BUCut0SS.js +1 -0
- package/dist/assets/DlqPro-HoeGdD-B.js +1 -0
- package/dist/assets/Flows-D9sVq4vd.js +1 -0
- package/dist/assets/JobInspector-dB68adRz.js +2 -0
- package/dist/assets/Jobs-CAHFY5qa.js +1 -0
- package/dist/assets/JobsPro-BqIHonV6.js +1 -0
- package/dist/assets/Logs-DjNLXK_r.js +1 -0
- package/dist/assets/LogsPro-BPWf8AAv.js +1 -0
- package/dist/assets/McpServer-BVxK5LwU.js +22 -0
- package/dist/assets/Metrics-Bm_pwxzy.js +1 -0
- package/dist/assets/MetricsPro-DTsnXQVd.js +1 -0
- package/dist/assets/Overview-O67Y-R5x.js +1 -0
- package/dist/assets/Pagination-B-nfZjQZ.js +1 -0
- package/dist/assets/QueueControl-BrHXeXPc.js +1 -0
- package/dist/assets/QueueDetail-D-C5SXxB.js +1 -0
- package/dist/assets/Queues-5tq1o_1I.js +1 -0
- package/dist/assets/QueuesOverview-ClZM34rT.js +1 -0
- package/dist/assets/S3Backup-BZfckRKX.js +1 -0
- package/dist/assets/S3BackupPro-CteU25_J.js +1 -0
- package/dist/assets/ServerControl-BSLX2WeV.js +3 -0
- package/dist/assets/Settings-BAELEgud.js +1 -0
- package/dist/assets/Usage-B_8T34Sp.js +1 -0
- package/dist/assets/UsagePro-BhhM0H05.js +1 -0
- package/dist/assets/Webhooks-B_qJ0bx1.js +1 -0
- package/dist/assets/Workers-N6qIdxg1.js +1 -0
- package/dist/assets/WorkersPro-DzjKVhCO.js +1 -0
- package/dist/assets/cn-CMFYpumL.js +1 -0
- package/dist/assets/connectionStore-DI-LYcct.js +1 -0
- package/dist/assets/form-PtDLk33Z.js +1 -0
- package/dist/assets/index-ChJpSdwV.js +8 -0
- package/dist/assets/index-DNaP_bi8.css +1 -0
- package/dist/assets/install-BvB40Zk0.js +3 -0
- package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/assets/jobActions-CuOdMglt.js +1 -0
- package/dist/assets/react-vendor-CHIHtdAC.js +11 -0
- package/dist/favicon.svg +27 -0
- package/dist/index.html +30 -0
- package/package.json +90 -0
- package/scripts/embedded.gen.ts +122 -0
- package/scripts/serve.ts +92 -0
package/agent/manager.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProcessManager — supervises a bunqueue server child process so the dashboard
|
|
3
|
+
* can start / stop / restart it. Runs under Bun (uses Bun.spawn).
|
|
4
|
+
*
|
|
5
|
+
* It does NOT import or touch bunqueue source — it just launches a configurable
|
|
6
|
+
* command (default `bunqueue start`) with the ports/data-path passed as env.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type Status = 'running' | 'stopped' | 'starting' | 'stopping';
|
|
10
|
+
|
|
11
|
+
export interface ServerConfig {
|
|
12
|
+
/** Command to launch the server, e.g. "bunqueue start" or "bun run src/main.ts". */
|
|
13
|
+
command: string;
|
|
14
|
+
httpPort: number;
|
|
15
|
+
tcpPort: number;
|
|
16
|
+
dataPath: string;
|
|
17
|
+
extraEnv: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LogLine {
|
|
21
|
+
seq: number;
|
|
22
|
+
ts: number;
|
|
23
|
+
stream: 'stdout' | 'stderr' | 'sys';
|
|
24
|
+
line: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** On-disk footprint of the configured SQLite database (main + WAL + SHM). */
|
|
28
|
+
export interface DbStats {
|
|
29
|
+
path: string;
|
|
30
|
+
exists: boolean;
|
|
31
|
+
size: number;
|
|
32
|
+
walSize: number;
|
|
33
|
+
shmSize: number;
|
|
34
|
+
totalSize: number;
|
|
35
|
+
mtimeMs: number | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StatusSnapshot {
|
|
39
|
+
status: Status;
|
|
40
|
+
pid: number | null;
|
|
41
|
+
startedAt: number | null;
|
|
42
|
+
exitCode: number | null;
|
|
43
|
+
/** Editable/desired config — takes effect on the next start/restart. */
|
|
44
|
+
config: ServerConfig;
|
|
45
|
+
/** Config the live process was actually launched with (null when stopped). */
|
|
46
|
+
runningConfig: ServerConfig | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MAX_LOGS = 800;
|
|
50
|
+
const STOP_TIMEOUT_MS = 8000;
|
|
51
|
+
|
|
52
|
+
function defaultConfig(): ServerConfig {
|
|
53
|
+
return {
|
|
54
|
+
command: process.env.BUNQUEUE_START_CMD || 'bunqueue start',
|
|
55
|
+
httpPort: Number(process.env.HTTP_PORT) || 6790,
|
|
56
|
+
tcpPort: Number(process.env.TCP_PORT) || 6789,
|
|
57
|
+
dataPath: process.env.BUNQUEUE_DATA_PATH || './data/bunq.db',
|
|
58
|
+
extraEnv: {},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class ProcessManager {
|
|
63
|
+
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
|
64
|
+
private status: Status = 'stopped';
|
|
65
|
+
private startedAt: number | null = null;
|
|
66
|
+
private exitCode: number | null = null;
|
|
67
|
+
private config: ServerConfig = defaultConfig();
|
|
68
|
+
private runningConfig: ServerConfig | null = null;
|
|
69
|
+
private logs: LogLine[] = [];
|
|
70
|
+
private seq = 0;
|
|
71
|
+
/**
|
|
72
|
+
* Monotonic id for the current process generation. Every start() bumps it;
|
|
73
|
+
* onExit and stop() only mutate shared state when their captured token is
|
|
74
|
+
* still current, so a stop() awaiting an old process can't clobber a process
|
|
75
|
+
* a concurrent start() brought up (and vice-versa).
|
|
76
|
+
*/
|
|
77
|
+
private procToken = 0;
|
|
78
|
+
|
|
79
|
+
getConfig(): ServerConfig {
|
|
80
|
+
return this.config;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Update the desired config. Editing while the server runs is allowed; the
|
|
85
|
+
* change applies on the next start/restart (the live process keeps using
|
|
86
|
+
* `runningConfig` until then). Ports/data-path of a live process cannot be
|
|
87
|
+
* changed in place.
|
|
88
|
+
*/
|
|
89
|
+
setConfig(patch: Partial<ServerConfig>): ServerConfig {
|
|
90
|
+
this.config = { ...this.config, ...patch, extraEnv: patch.extraEnv ?? this.config.extraEnv };
|
|
91
|
+
return this.config;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getStatus(): StatusSnapshot {
|
|
95
|
+
return {
|
|
96
|
+
status: this.status,
|
|
97
|
+
pid: this.proc?.pid ?? null,
|
|
98
|
+
startedAt: this.startedAt,
|
|
99
|
+
exitCode: this.exitCode,
|
|
100
|
+
config: this.config,
|
|
101
|
+
runningConfig: this.runningConfig,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Stat the configured SQLite db file plus its WAL/SHM sidecars. */
|
|
106
|
+
async dbStats(): Promise<DbStats> {
|
|
107
|
+
const path = this.config.dataPath;
|
|
108
|
+
const one = async (p: string): Promise<{ size: number; mtimeMs: number | null }> => {
|
|
109
|
+
try {
|
|
110
|
+
const f = Bun.file(p);
|
|
111
|
+
if (!(await f.exists())) return { size: 0, mtimeMs: null };
|
|
112
|
+
return { size: f.size, mtimeMs: f.lastModified };
|
|
113
|
+
} catch {
|
|
114
|
+
return { size: 0, mtimeMs: null };
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const [main, wal, shm] = await Promise.all([
|
|
118
|
+
one(path),
|
|
119
|
+
one(`${path}-wal`),
|
|
120
|
+
one(`${path}-shm`),
|
|
121
|
+
]);
|
|
122
|
+
return {
|
|
123
|
+
path,
|
|
124
|
+
exists: main.mtimeMs !== null,
|
|
125
|
+
size: main.size,
|
|
126
|
+
walSize: wal.size,
|
|
127
|
+
shmSize: shm.size,
|
|
128
|
+
totalSize: main.size + wal.size + shm.size,
|
|
129
|
+
mtimeMs: main.mtimeMs,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
getLogs(): LogLine[] {
|
|
134
|
+
return this.logs;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private push(stream: LogLine['stream'], line: string): void {
|
|
138
|
+
this.logs.push({ seq: this.seq++, ts: Date.now(), stream, line });
|
|
139
|
+
// Amortized trim: only splice once the buffer overshoots by a slack margin, so
|
|
140
|
+
// steady-state logging isn't an O(n) array shift on every single line.
|
|
141
|
+
if (this.logs.length > MAX_LOGS + 256) this.logs.splice(0, this.logs.length - MAX_LOGS);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async start(): Promise<StatusSnapshot> {
|
|
145
|
+
if (this.status === 'running' || this.status === 'starting') return this.getStatus();
|
|
146
|
+
this.status = 'starting';
|
|
147
|
+
this.exitCode = null;
|
|
148
|
+
const token = ++this.procToken;
|
|
149
|
+
|
|
150
|
+
const parts = this.config.command.trim().split(/\s+/);
|
|
151
|
+
const [cmd, ...args] = parts;
|
|
152
|
+
if (!cmd) {
|
|
153
|
+
this.status = 'stopped';
|
|
154
|
+
throw new Error('Empty command');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const env: Record<string, string> = {
|
|
158
|
+
...(process.env as Record<string, string>),
|
|
159
|
+
HTTP_PORT: String(this.config.httpPort),
|
|
160
|
+
TCP_PORT: String(this.config.tcpPort),
|
|
161
|
+
BUNQUEUE_DATA_PATH: this.config.dataPath,
|
|
162
|
+
...this.config.extraEnv,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
this.proc = Bun.spawn([cmd, ...args], {
|
|
167
|
+
env,
|
|
168
|
+
stdout: 'pipe',
|
|
169
|
+
stderr: 'pipe',
|
|
170
|
+
onExit: (_p, code) => {
|
|
171
|
+
// Ignore the exit of a process a newer start() has already replaced.
|
|
172
|
+
if (this.procToken !== token) return;
|
|
173
|
+
this.exitCode = code ?? null;
|
|
174
|
+
this.status = 'stopped';
|
|
175
|
+
this.proc = null;
|
|
176
|
+
this.runningConfig = null;
|
|
177
|
+
this.push('sys', `process exited (code ${code ?? '?'})`);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
} catch (e) {
|
|
181
|
+
this.status = 'stopped';
|
|
182
|
+
// Leave a consistent stopped snapshot: without this, a spawn failure
|
|
183
|
+
// racing an in-flight stop() (whose token is already stale, so its
|
|
184
|
+
// finalizer returns early) leaves getStatus() reporting the previous
|
|
185
|
+
// generation's dead pid and a non-null runningConfig.
|
|
186
|
+
this.proc = null;
|
|
187
|
+
this.runningConfig = null;
|
|
188
|
+
throw new Error(`Failed to spawn "${cmd}": ${(e as Error).message}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.startedAt = Date.now();
|
|
192
|
+
this.status = 'running';
|
|
193
|
+
this.runningConfig = { ...this.config, extraEnv: { ...this.config.extraEnv } };
|
|
194
|
+
this.push('sys', `started: ${this.config.command} (pid ${this.proc.pid})`);
|
|
195
|
+
void this.pipe(this.proc.stdout as ReadableStream<Uint8Array>, 'stdout');
|
|
196
|
+
void this.pipe(this.proc.stderr as ReadableStream<Uint8Array>, 'stderr');
|
|
197
|
+
return this.getStatus();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private async pipe(stream: ReadableStream<Uint8Array>, name: 'stdout' | 'stderr'): Promise<void> {
|
|
201
|
+
const reader = stream.getReader();
|
|
202
|
+
const decoder = new TextDecoder();
|
|
203
|
+
let buffer = '';
|
|
204
|
+
try {
|
|
205
|
+
while (true) {
|
|
206
|
+
const { done, value } = await reader.read();
|
|
207
|
+
if (done) break;
|
|
208
|
+
buffer += decoder.decode(value, { stream: true });
|
|
209
|
+
let i: number;
|
|
210
|
+
while ((i = buffer.indexOf('\n')) !== -1) {
|
|
211
|
+
const line = buffer.slice(0, i);
|
|
212
|
+
buffer = buffer.slice(i + 1);
|
|
213
|
+
if (line.trim()) this.push(name, line);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
/* stream closed */
|
|
218
|
+
}
|
|
219
|
+
// Flush the final chunk when the stream ends without a trailing newline
|
|
220
|
+
// (e.g. a crash cause written via a bare write()) — otherwise the last,
|
|
221
|
+
// often most important, line never reaches the log buffer.
|
|
222
|
+
const tail = buffer + decoder.decode();
|
|
223
|
+
if (tail.trim()) this.push(name, tail);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async stop(): Promise<StatusSnapshot> {
|
|
227
|
+
if (!this.proc) {
|
|
228
|
+
this.status = 'stopped';
|
|
229
|
+
this.runningConfig = null;
|
|
230
|
+
return this.getStatus();
|
|
231
|
+
}
|
|
232
|
+
this.status = 'stopping';
|
|
233
|
+
this.push('sys', 'stopping (SIGTERM)…');
|
|
234
|
+
const proc = this.proc;
|
|
235
|
+
const token = this.procToken;
|
|
236
|
+
proc.kill();
|
|
237
|
+
const timedOut = await Promise.race([
|
|
238
|
+
proc.exited.then(() => false),
|
|
239
|
+
new Promise<boolean>((r) => setTimeout(() => r(true), STOP_TIMEOUT_MS)),
|
|
240
|
+
]);
|
|
241
|
+
// Always escalate to SIGKILL on the CAPTURED proc (never `this.proc`) so a
|
|
242
|
+
// child that ignores SIGTERM is still reaped even if a concurrent start()
|
|
243
|
+
// has since swapped in a new process — `proc` targets this generation only.
|
|
244
|
+
if (timedOut) {
|
|
245
|
+
this.push('sys', 'SIGTERM timed out, sending SIGKILL');
|
|
246
|
+
proc.kill(9);
|
|
247
|
+
await proc.exited;
|
|
248
|
+
}
|
|
249
|
+
// Only finalize shared state if no newer start() replaced this generation
|
|
250
|
+
// while we awaited — otherwise we'd null out the wrong (live) process.
|
|
251
|
+
if (this.procToken !== token) return this.getStatus();
|
|
252
|
+
this.status = 'stopped';
|
|
253
|
+
this.proc = null;
|
|
254
|
+
this.runningConfig = null;
|
|
255
|
+
return this.getStatus();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async restart(): Promise<StatusSnapshot> {
|
|
259
|
+
await this.stop();
|
|
260
|
+
return this.start();
|
|
261
|
+
}
|
|
262
|
+
}
|
package/agent/server.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request handling + origin/auth policy for the control agent, factored out of
|
|
3
|
+
* index.ts so it can be unit-tested without binding a port.
|
|
4
|
+
*
|
|
5
|
+
* Threat model: the agent can spawn arbitrary processes (`/control/config` sets
|
|
6
|
+
* the launch command, `/control/start` runs it). It binds 127.0.0.1, but that
|
|
7
|
+
* does NOT stop a malicious web page the user is visiting from issuing a
|
|
8
|
+
* cross-origin request to http://127.0.0.1:6800 (CSRF → RCE). Defenses:
|
|
9
|
+
*
|
|
10
|
+
* 1. CORS is locked to an explicit allowlist (never `*`); the browser is only
|
|
11
|
+
* told a cross-origin response is readable for known dashboard origins.
|
|
12
|
+
* 2. Any request carrying a disallowed `Origin` header is rejected (403)
|
|
13
|
+
* before it can reach the ProcessManager — a cross-origin browser request
|
|
14
|
+
* always sends `Origin`, so a drive-by page cannot start/stop/reconfigure
|
|
15
|
+
* the server. Non-browser callers (curl, same-process) send no Origin and
|
|
16
|
+
* still work for local use.
|
|
17
|
+
* 3. Optional bearer token (AGENT_TOKEN): when set, state-changing requests
|
|
18
|
+
* must present it (`Authorization: Bearer <t>` or `x-agent-token: <t>`).
|
|
19
|
+
*/
|
|
20
|
+
import { type DbFilter, dbCell, dbInfo, dbRows, dbSchema, dbTables, MissingDbError, queryWithTimeout } from './db';
|
|
21
|
+
import type { ProcessManager, ServerConfig } from './manager';
|
|
22
|
+
|
|
23
|
+
export interface AgentOptions {
|
|
24
|
+
allowedOrigins: string[];
|
|
25
|
+
/** When set, POST/PUT requests must present this token. */
|
|
26
|
+
token?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_ORIGINS = ['http://localhost:5273', 'http://127.0.0.1:5273'];
|
|
30
|
+
|
|
31
|
+
/** Parse AGENT_ALLOWED_ORIGINS (comma-separated) merged with sane dev defaults. */
|
|
32
|
+
export function resolveAllowedOrigins(env = process.env): string[] {
|
|
33
|
+
const extra = (env.AGENT_ALLOWED_ORIGINS ?? '')
|
|
34
|
+
.split(',')
|
|
35
|
+
.map((s) => s.trim().replace(/\/$/, ''))
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
return Array.from(new Set([...DEFAULT_ORIGINS, ...extra]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isOriginAllowed(origin: string | null, allowed: string[]): boolean {
|
|
41
|
+
if (!origin) return true; // non-browser caller (curl / same process) — no Origin header
|
|
42
|
+
return allowed.includes(origin.replace(/\/$/, ''));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** CORS headers. ACAO is reflected only for an allowed origin (never `*`). */
|
|
46
|
+
export function corsHeaders(origin: string | null, allowed: string[]): Record<string, string> {
|
|
47
|
+
const h: Record<string, string> = {
|
|
48
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
|
|
49
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Agent-Token',
|
|
50
|
+
Vary: 'Origin',
|
|
51
|
+
};
|
|
52
|
+
if (origin && allowed.includes(origin.replace(/\/$/, ''))) {
|
|
53
|
+
h['Access-Control-Allow-Origin'] = origin;
|
|
54
|
+
}
|
|
55
|
+
return h;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tokenOk(req: Request, token?: string): boolean {
|
|
59
|
+
if (!token) return true;
|
|
60
|
+
const auth = req.headers.get('authorization') ?? '';
|
|
61
|
+
const bearer = auth.startsWith('Bearer ') ? auth.slice(7) : '';
|
|
62
|
+
return bearer === token || req.headers.get('x-agent-token') === token;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the fetch handler for the agent. Pure w.r.t. the network — takes a
|
|
67
|
+
* ProcessManager and policy, returns an async (req) => Response.
|
|
68
|
+
*/
|
|
69
|
+
export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
70
|
+
const { allowedOrigins, token } = opts;
|
|
71
|
+
|
|
72
|
+
const json = (data: unknown, status: number, origin: string | null): Response =>
|
|
73
|
+
new Response(JSON.stringify(data), {
|
|
74
|
+
status,
|
|
75
|
+
headers: { 'Content-Type': 'application/json', ...corsHeaders(origin, allowedOrigins) },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
async function statusWithHealth() {
|
|
79
|
+
const snap = mgr.getStatus();
|
|
80
|
+
let healthy = false;
|
|
81
|
+
let version: string | undefined;
|
|
82
|
+
if (snap.status === 'running') {
|
|
83
|
+
const port = snap.runningConfig?.httpPort ?? snap.config.httpPort;
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
86
|
+
signal: AbortSignal.timeout(1500),
|
|
87
|
+
});
|
|
88
|
+
if (res.ok) {
|
|
89
|
+
const h = (await res.json()) as { ok?: boolean; version?: string };
|
|
90
|
+
healthy = h.ok !== false;
|
|
91
|
+
version = h.version;
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
/* not up yet */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const db = await mgr.dbStats().catch(() => null);
|
|
98
|
+
return { ...snap, healthy, version, db };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return async function handle(req: Request): Promise<Response> {
|
|
102
|
+
const { pathname } = new URL(req.url);
|
|
103
|
+
const method = req.method;
|
|
104
|
+
const origin = req.headers.get('origin');
|
|
105
|
+
|
|
106
|
+
if (method === 'OPTIONS') {
|
|
107
|
+
return new Response(null, { status: 204, headers: corsHeaders(origin, allowedOrigins) });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Block any request from a disallowed browser origin before it can act.
|
|
111
|
+
if (!isOriginAllowed(origin, allowedOrigins)) {
|
|
112
|
+
return json({ ok: false, error: 'Origin not allowed' }, 403, origin);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The optional AGENT_TOKEN gates STATE-CHANGING requests only. Read-only
|
|
116
|
+
// GETs (including the /db/* inspector, which can expose job payloads) are
|
|
117
|
+
// protected by the Origin allowlist alone — a drive-by cross-origin page
|
|
118
|
+
// can't read the response (no ACAO for its origin), which is the real
|
|
119
|
+
// threat. Gating reads behind the token would break the dashboard itself:
|
|
120
|
+
// the browser client sends no agent token (see bq.ts `agent()`), so the
|
|
121
|
+
// whole point of loopback + allowlisted CORS is to make browser reads work
|
|
122
|
+
// without one. Non-browser local callers on loopback are trusted for reads.
|
|
123
|
+
const mutating = method === 'POST' || method === 'PUT';
|
|
124
|
+
if (mutating && !tokenOk(req, token)) {
|
|
125
|
+
return json({ ok: false, error: 'Unauthorized' }, 401, origin);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
if (pathname === '/control/status') return json(await statusWithHealth(), 200, origin);
|
|
130
|
+
if (pathname === '/control/logs') return json({ lines: mgr.getLogs() }, 200, origin);
|
|
131
|
+
|
|
132
|
+
if (pathname === '/control/start' && method === 'POST') {
|
|
133
|
+
await mgr.start();
|
|
134
|
+
return json(await statusWithHealth(), 200, origin);
|
|
135
|
+
}
|
|
136
|
+
if (pathname === '/control/stop' && method === 'POST') {
|
|
137
|
+
await mgr.stop();
|
|
138
|
+
return json(await statusWithHealth(), 200, origin);
|
|
139
|
+
}
|
|
140
|
+
if (pathname === '/control/restart' && method === 'POST') {
|
|
141
|
+
await mgr.restart();
|
|
142
|
+
return json(await statusWithHealth(), 200, origin);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (pathname === '/control/config' && method === 'GET') return json(mgr.getConfig(), 200, origin);
|
|
146
|
+
if (pathname === '/control/config' && method === 'PUT') {
|
|
147
|
+
const patch = (await req.json()) as Partial<ServerConfig>;
|
|
148
|
+
return json(mgr.setConfig(patch), 200, origin);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Read-only SQLite inspector (agent/db.ts opens every connection
|
|
152
|
+
// readonly, so none of these can mutate the store). The POST query
|
|
153
|
+
// endpoint rides the same token gate as other mutating methods.
|
|
154
|
+
if (pathname === '/db/info' && method === 'GET') {
|
|
155
|
+
return json({ ok: true, ...dbInfo(mgr.getConfig().dataPath) }, 200, origin);
|
|
156
|
+
}
|
|
157
|
+
if (pathname === '/db/tables' && method === 'GET') {
|
|
158
|
+
return json({ ok: true, tables: dbTables(mgr.getConfig().dataPath) }, 200, origin);
|
|
159
|
+
}
|
|
160
|
+
// Segment-based routing (the client percent-encodes the table name, so it
|
|
161
|
+
// never contains a literal '/'): a single trailing segment is the rows
|
|
162
|
+
// route; `<table>/schema` and `<table>/cell` are sub-resources. This is
|
|
163
|
+
// unambiguous even for a table literally named "schema" or "cell".
|
|
164
|
+
if (pathname.startsWith('/db/tables/') && method === 'GET') {
|
|
165
|
+
const segs = pathname.slice('/db/tables/'.length).split('/');
|
|
166
|
+
const table = decodeURIComponent(segs[0] ?? '');
|
|
167
|
+
const sub = segs[1];
|
|
168
|
+
if (segs.length === 2 && sub === 'schema') {
|
|
169
|
+
return json({ ok: true, ...dbSchema(mgr.getConfig().dataPath, table) }, 200, origin);
|
|
170
|
+
}
|
|
171
|
+
if (segs.length === 2 && sub === 'cell') {
|
|
172
|
+
const sp = new URL(req.url).searchParams;
|
|
173
|
+
const rowid = Number(sp.get('rowid'));
|
|
174
|
+
const column = sp.get('column') ?? '';
|
|
175
|
+
return json(
|
|
176
|
+
{ ok: true, ...dbCell(mgr.getConfig().dataPath, table, rowid, column) },
|
|
177
|
+
200,
|
|
178
|
+
origin
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (segs.length !== 1) return json({ ok: false, error: 'Not found' }, 404, origin);
|
|
182
|
+
const sp = new URL(req.url).searchParams;
|
|
183
|
+
const limit = Number(sp.get('limit')) || 50;
|
|
184
|
+
const offset = Number(sp.get('offset')) || 0;
|
|
185
|
+
const orderBy = sp.get('orderBy') || undefined;
|
|
186
|
+
const dir = sp.get('dir') === 'desc' ? 'desc' : 'asc';
|
|
187
|
+
const fCol = sp.get('fcol');
|
|
188
|
+
const fOp = sp.get('fop');
|
|
189
|
+
const fVal = sp.get('fval');
|
|
190
|
+
const filter: DbFilter | undefined =
|
|
191
|
+
fCol && fVal
|
|
192
|
+
? { column: fCol, op: fOp === 'eq' ? 'eq' : fOp === 'ne' ? 'ne' : 'contains', value: fVal }
|
|
193
|
+
: undefined;
|
|
194
|
+
return json(
|
|
195
|
+
{ ok: true, ...dbRows(mgr.getConfig().dataPath, table, limit, offset, orderBy, dir, filter) },
|
|
196
|
+
200,
|
|
197
|
+
origin
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (pathname === '/db/query' && method === 'POST') {
|
|
201
|
+
const { sql } = (await req.json()) as { sql?: string };
|
|
202
|
+
return json({ ok: true, ...(await queryWithTimeout(mgr.getConfig().dataPath, sql ?? '')) }, 200, origin);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return json({ ok: false, error: 'Not found' }, 404, origin);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
// A missing database file is an expected pre-first-start condition — 404
|
|
208
|
+
// so the UI can show "no database yet" distinctly from a real read error.
|
|
209
|
+
const status = e instanceof MissingDbError ? 404 : 400;
|
|
210
|
+
return json({ ok: false, error: (e as Error).message ?? String(e) }, status, origin);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|