@aicloud360/360-aidrive 0.8.28

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.
@@ -0,0 +1,866 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import http from 'http';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import { homedir } from 'os';
8
+ import { execFileSync, spawn } from 'child_process';
9
+
10
+ const DEFAULT_HOST = '127.0.0.1';
11
+ const DEFAULT_PORT = 3210;
12
+
13
+ function getDefaultCliName() {
14
+ try {
15
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
16
+ const candidates = [
17
+ path.resolve(scriptDir, '../../package.json'),
18
+ path.resolve(scriptDir, '../../../package.json'),
19
+ path.resolve(process.cwd(), 'package.json'),
20
+ ];
21
+ for (const candidate of candidates) {
22
+ if (!fs.existsSync(candidate)) continue;
23
+ const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
24
+ if (pkg?.cli?.name) {
25
+ return pkg.cli.name;
26
+ }
27
+ }
28
+ } catch {
29
+ // ignore package lookup failures
30
+ }
31
+ return '360disk';
32
+ }
33
+
34
+ const DEFAULT_STATE_FILE_PATH = path.join(homedir(), `.${getDefaultCliName()}`, 'claw-auto-backup.json');
35
+
36
+ function parseArgs(argv) {
37
+ const options = {
38
+ eventLog: '',
39
+ stateFile: DEFAULT_STATE_FILE_PATH,
40
+ host: DEFAULT_HOST,
41
+ port: DEFAULT_PORT,
42
+ open: false,
43
+ };
44
+
45
+ for (let index = 0; index < argv.length; index += 1) {
46
+ const arg = argv[index];
47
+ switch (arg) {
48
+ case '--event-log':
49
+ options.eventLog = path.resolve(argv[++index] || '.');
50
+ break;
51
+ case '--state-file':
52
+ options.stateFile = path.resolve(argv[++index] || DEFAULT_STATE_FILE_PATH);
53
+ break;
54
+ case '--host':
55
+ options.host = String(argv[++index] || DEFAULT_HOST);
56
+ break;
57
+ case '--port': {
58
+ const port = Number(argv[++index] || DEFAULT_PORT);
59
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
60
+ throw new Error('--port 必须是 1-65535 的整数');
61
+ }
62
+ options.port = port;
63
+ break;
64
+ }
65
+ case '--open':
66
+ options.open = true;
67
+ break;
68
+ case '--help':
69
+ case '-h':
70
+ printHelp();
71
+ process.exit(0);
72
+ break;
73
+ default:
74
+ throw new Error(`未知参数: ${arg}`);
75
+ }
76
+ }
77
+
78
+ return options;
79
+ }
80
+
81
+ function printHelp() {
82
+ const cli = getDefaultCliName();
83
+ process.stdout.write(`Usage: node tools/auto-backup-monitor/server.mjs [options]
84
+
85
+ Options:
86
+ --event-log <path> 初始事件日志文件路径;不传时由网页输入框指定
87
+ --state-file <path> 自动备份状态文件路径,默认 ~/.${cli}/claw-auto-backup.json
88
+ --host <host> 监听地址,默认 127.0.0.1
89
+ --port <port> 监听端口,默认 3210
90
+ --open 启动后自动打开浏览器
91
+ -h, --help 显示帮助
92
+ `);
93
+ }
94
+
95
+ function htmlEscape(value) {
96
+ return String(value)
97
+ .replace(/&/g, '&amp;')
98
+ .replace(/</g, '&lt;')
99
+ .replace(/>/g, '&gt;');
100
+ }
101
+
102
+ function readJsonFile(filePath, fallback) {
103
+ try {
104
+ if (!fs.existsSync(filePath)) return fallback;
105
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
106
+ } catch {
107
+ return fallback;
108
+ }
109
+ }
110
+
111
+ function isProcessAlive(pid) {
112
+ if (!Number.isInteger(pid) || Number(pid) <= 0) {
113
+ return false;
114
+ }
115
+ try {
116
+ process.kill(Number(pid), 0);
117
+ return true;
118
+ } catch (error) {
119
+ if (error?.code === 'EPERM') return true;
120
+ return false;
121
+ }
122
+ }
123
+
124
+ function getProcessCommand(pid) {
125
+ if (!Number.isInteger(pid) || Number(pid) <= 0) return '';
126
+ try {
127
+ if (process.platform === 'win32') {
128
+ return execFileSync('powershell.exe', [
129
+ '-NoProfile',
130
+ '-Command',
131
+ `(Get-CimInstance Win32_Process -Filter "ProcessId = ${String(pid)}" | Select-Object -ExpandProperty CommandLine)`,
132
+ ], {
133
+ encoding: 'utf8',
134
+ stdio: ['ignore', 'pipe', 'ignore'],
135
+ }).trim();
136
+ }
137
+ return execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
138
+ encoding: 'utf8',
139
+ stdio: ['ignore', 'pipe', 'ignore'],
140
+ }).trim();
141
+ } catch {
142
+ return '';
143
+ }
144
+ }
145
+
146
+ function isMatchingWatcherProcess(state) {
147
+ if (!state?.pid || !state?.instance_id || !isProcessAlive(state.pid)) {
148
+ return false;
149
+ }
150
+ const command = getProcessCommand(state.pid);
151
+ if (!command) return false;
152
+ return command.includes('claw-auto-backup __watch')
153
+ && command.includes(`--instance-id ${state.instance_id}`);
154
+ }
155
+
156
+ function resolveEventLogPath(input) {
157
+ const raw = String(input || '').trim();
158
+ return raw ? path.resolve(raw) : '';
159
+ }
160
+
161
+ function buildStatus(stateFile, eventLog) {
162
+ const state = readJsonFile(stateFile, { enabled: false, watch_pairs: [] });
163
+ const running = state.enabled && isMatchingWatcherProcess(state);
164
+ return {
165
+ ...state,
166
+ enabled: state.enabled && running,
167
+ running,
168
+ event_log_path: eventLog || state.event_log_path || '',
169
+ };
170
+ }
171
+
172
+ function parseSinceOffset(input) {
173
+ const value = Number(input || 0);
174
+ if (!Number.isInteger(value) || value < 0) {
175
+ return 0;
176
+ }
177
+ return value;
178
+ }
179
+
180
+ function hasExplicitSince(input) {
181
+ return input !== null;
182
+ }
183
+
184
+ function getInitialOffset(filePath) {
185
+ if (!filePath) return 0;
186
+ try {
187
+ if (!fs.existsSync(filePath)) return 0;
188
+ const stat = fs.statSync(filePath);
189
+ if (!stat.isFile()) return 0;
190
+ return stat.size;
191
+ } catch {
192
+ return 0;
193
+ }
194
+ }
195
+
196
+ function readEventsSnapshot(filePath, limit = 300) {
197
+ if (!filePath) {
198
+ return { eventLogPath: '', events: [], snapshotOffset: 0, error: null };
199
+ }
200
+ try {
201
+ if (!fs.existsSync(filePath)) {
202
+ return { eventLogPath: filePath, events: [], snapshotOffset: 0, error: `日志文件不存在: ${filePath}` };
203
+ }
204
+ const stat = fs.statSync(filePath);
205
+ if (!stat.isFile()) {
206
+ return { eventLogPath: filePath, events: [], snapshotOffset: 0, error: `不是文件: ${filePath}` };
207
+ }
208
+ const fd = fs.openSync(filePath, 'r');
209
+ try {
210
+ const targetLines = limit > 0 ? limit : Number.MAX_SAFE_INTEGER;
211
+ const chunkSize = 64 * 1024;
212
+ let position = stat.size;
213
+ let startOffset = 0;
214
+ let linesFound = 0;
215
+ let skipTrailingNewline = false;
216
+
217
+ if (stat.size > 0) {
218
+ const lastByte = Buffer.alloc(1);
219
+ fs.readSync(fd, lastByte, 0, 1, stat.size - 1);
220
+ skipTrailingNewline = lastByte[0] === 0x0A;
221
+ }
222
+
223
+ while (position > 0 && linesFound < targetLines) {
224
+ const readSize = Math.min(chunkSize, position);
225
+ position -= readSize;
226
+ const buffer = Buffer.alloc(readSize);
227
+ fs.readSync(fd, buffer, 0, readSize, position);
228
+
229
+ for (let index = buffer.length - 1; index >= 0; index -= 1) {
230
+ if (buffer[index] !== 0x0A) continue;
231
+ const absoluteIndex = position + index;
232
+ if (skipTrailingNewline && absoluteIndex === stat.size - 1) {
233
+ skipTrailingNewline = false;
234
+ continue;
235
+ }
236
+ linesFound += 1;
237
+ if (linesFound === targetLines) {
238
+ startOffset = absoluteIndex + 1;
239
+ break;
240
+ }
241
+ }
242
+ }
243
+
244
+ const readLength = stat.size - startOffset;
245
+ const tailBuffer = Buffer.alloc(readLength);
246
+ if (readLength > 0) {
247
+ fs.readSync(fd, tailBuffer, 0, readLength, startOffset);
248
+ }
249
+ const lines = tailBuffer
250
+ .toString('utf8')
251
+ .split('\n')
252
+ .map((line) => line.trim())
253
+ .filter(Boolean);
254
+ const events = [];
255
+ for (const line of lines) {
256
+ try {
257
+ events.push(JSON.parse(line));
258
+ } catch {
259
+ // ignore malformed lines in temp event log
260
+ }
261
+ }
262
+ const trimmedEvents = limit > 0 ? events.slice(-limit) : events;
263
+ return {
264
+ eventLogPath: filePath,
265
+ events: trimmedEvents,
266
+ snapshotOffset: stat.size,
267
+ error: null,
268
+ };
269
+ } finally {
270
+ fs.closeSync(fd);
271
+ }
272
+ } catch (error) {
273
+ return {
274
+ eventLogPath: filePath,
275
+ events: [],
276
+ snapshotOffset: 0,
277
+ error: error?.message || String(error),
278
+ };
279
+ }
280
+ }
281
+
282
+ function renderPage(eventLogPath, stateFilePath) {
283
+ const embeddedEventPath = JSON.stringify(eventLogPath);
284
+ const embeddedStatePath = JSON.stringify(stateFilePath);
285
+ const displayEventPath = htmlEscape(eventLogPath);
286
+ const displayStatePath = htmlEscape(stateFilePath);
287
+ const displayCliName = htmlEscape(getDefaultCliName());
288
+
289
+ return `<!doctype html>
290
+ <html lang="zh-CN">
291
+ <head>
292
+ <meta charset="utf-8" />
293
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
294
+ <title>Auto Backup Monitor</title>
295
+ <style>
296
+ :root {
297
+ --bg: #f5efe6;
298
+ --panel: rgba(255, 250, 244, 0.9);
299
+ --ink: #1d1a17;
300
+ --muted: #6d655d;
301
+ --line: rgba(49, 34, 23, 0.12);
302
+ --ok: #0f715f;
303
+ --warn: #b3582d;
304
+ --shadow: 0 20px 40px rgba(57, 35, 18, 0.08);
305
+ }
306
+ * { box-sizing: border-box; }
307
+ body {
308
+ margin: 0;
309
+ color: var(--ink);
310
+ font-family: "Avenir Next", "Segoe UI", sans-serif;
311
+ background:
312
+ radial-gradient(circle at top left, rgba(15,113,95,0.12), transparent 28%),
313
+ radial-gradient(circle at top right, rgba(179,88,45,0.10), transparent 22%),
314
+ linear-gradient(180deg, #f8f4ed 0%, #efe4d7 100%);
315
+ }
316
+ .shell {
317
+ max-width: 1120px;
318
+ margin: 0 auto;
319
+ padding: 28px 20px 48px;
320
+ }
321
+ .hero h1 {
322
+ margin: 0;
323
+ font-size: clamp(28px, 4vw, 44px);
324
+ line-height: 1;
325
+ letter-spacing: -0.03em;
326
+ }
327
+ .hero p {
328
+ margin: 12px 0 0;
329
+ color: var(--muted);
330
+ max-width: 860px;
331
+ }
332
+ .mono {
333
+ font-family: "SFMono-Regular", Menlo, monospace;
334
+ font-size: 12px;
335
+ }
336
+ .grid {
337
+ display: grid;
338
+ gap: 14px;
339
+ grid-template-columns: repeat(4, minmax(0, 1fr));
340
+ margin: 22px 0 24px;
341
+ }
342
+ .card, .panel {
343
+ background: var(--panel);
344
+ border: 1px solid var(--line);
345
+ border-radius: 18px;
346
+ box-shadow: var(--shadow);
347
+ backdrop-filter: blur(12px);
348
+ }
349
+ .card {
350
+ padding: 16px 18px;
351
+ }
352
+ .label {
353
+ color: var(--muted);
354
+ font-size: 12px;
355
+ text-transform: uppercase;
356
+ letter-spacing: 0.08em;
357
+ }
358
+ .value {
359
+ margin-top: 8px;
360
+ font-size: 28px;
361
+ font-weight: 700;
362
+ letter-spacing: -0.03em;
363
+ }
364
+ .layout {
365
+ display: grid;
366
+ gap: 16px;
367
+ grid-template-columns: 1.1fr 1.9fr;
368
+ }
369
+ .panel {
370
+ padding: 18px;
371
+ }
372
+ .panel h2 {
373
+ margin: 0 0 14px;
374
+ font-size: 16px;
375
+ }
376
+ .meta {
377
+ display: grid;
378
+ gap: 10px;
379
+ }
380
+ .meta-row {
381
+ display: flex;
382
+ justify-content: space-between;
383
+ gap: 16px;
384
+ border-bottom: 1px solid var(--line);
385
+ padding-bottom: 10px;
386
+ font-size: 14px;
387
+ }
388
+ .meta-row:last-child { border-bottom: 0; padding-bottom: 0; }
389
+ .timeline {
390
+ display: grid;
391
+ gap: 10px;
392
+ max-height: 72vh;
393
+ overflow: auto;
394
+ }
395
+ .event {
396
+ border: 1px solid var(--line);
397
+ border-radius: 14px;
398
+ padding: 14px;
399
+ background: rgba(255,255,255,0.58);
400
+ }
401
+ .event-top {
402
+ display: flex;
403
+ justify-content: space-between;
404
+ gap: 16px;
405
+ margin-bottom: 8px;
406
+ align-items: center;
407
+ }
408
+ .chip {
409
+ display: inline-flex;
410
+ border-radius: 999px;
411
+ padding: 5px 10px;
412
+ font-size: 11px;
413
+ font-weight: 700;
414
+ letter-spacing: 0.06em;
415
+ text-transform: uppercase;
416
+ background: rgba(15,113,95,0.12);
417
+ color: var(--ok);
418
+ }
419
+ .chip.fail {
420
+ background: rgba(179,88,45,0.12);
421
+ color: var(--warn);
422
+ }
423
+ .event-sub {
424
+ color: var(--muted);
425
+ font-size: 13px;
426
+ line-height: 1.45;
427
+ word-break: break-all;
428
+ }
429
+ .empty {
430
+ padding: 24px 0;
431
+ text-align: center;
432
+ color: var(--muted);
433
+ }
434
+ @media (max-width: 900px) {
435
+ .grid, .layout { grid-template-columns: 1fr; }
436
+ }
437
+ </style>
438
+ </head>
439
+ <body>
440
+ <div class="shell">
441
+ <section class="hero">
442
+ <h1>Auto Backup Monitor</h1>
443
+ <p>独立监控页。状态文件 <span class="mono">${displayStatePath}</span> 用来展示 watcher 状态;事件日志路径由下方输入框指定,实时展示自动备份触发、开始、结束和耗时。</p>
444
+ </section>
445
+
446
+ <section class="panel" style="margin: 20px 0 16px;">
447
+ <h2>Event Log</h2>
448
+ <div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
449
+ <input id="eventLogInput" class="mono" placeholder="/path/to/events.ndjson" value="${displayEventPath}" style="flex:1 1 480px; padding:12px 14px; border-radius:12px; border:1px solid var(--line); background:rgba(255,255,255,0.72); color:var(--ink);" />
450
+ <button id="connectBtn" style="padding:12px 16px; border:0; border-radius:12px; background:#0f715f; color:#fff; font-weight:700; cursor:pointer;">Connect</button>
451
+ </div>
452
+ <p class="event-sub" style="margin:10px 0 0;">不指定日志路径时,页面不会读取任何事件。需要和 ${displayCliName} claw-auto-backup enable --event-log &lt;path&gt; 使用同一路径。</p>
453
+ </section>
454
+
455
+ <section class="grid">
456
+ <article class="card"><div class="label">Total Events</div><div class="value" id="totalEvents">0</div></article>
457
+ <article class="card"><div class="label">Backups OK</div><div class="value" id="okCount">0</div></article>
458
+ <article class="card"><div class="label">Backups Failed</div><div class="value" id="failCount">0</div></article>
459
+ <article class="card"><div class="label">Avg Duration</div><div class="value" id="avgDuration">-</div></article>
460
+ </section>
461
+
462
+ <section class="layout">
463
+ <article class="panel">
464
+ <h2>Status</h2>
465
+ <div class="meta" id="statusPanel"></div>
466
+ </article>
467
+ <article class="panel">
468
+ <h2>Timeline</h2>
469
+ <div class="timeline" id="timeline"></div>
470
+ </article>
471
+ </section>
472
+ </div>
473
+
474
+ <script>
475
+ const initialEventLogPath = ${embeddedEventPath};
476
+ const stateFilePath = ${embeddedStatePath};
477
+ const timeline = document.getElementById('timeline');
478
+ const statusPanel = document.getElementById('statusPanel');
479
+ const eventLogInput = document.getElementById('eventLogInput');
480
+ const connectBtn = document.getElementById('connectBtn');
481
+ const totalEvents = document.getElementById('totalEvents');
482
+ const okCount = document.getElementById('okCount');
483
+ const failCount = document.getElementById('failCount');
484
+ const avgDuration = document.getElementById('avgDuration');
485
+ const events = [];
486
+ let activeEventLogPath = initialEventLogPath || '';
487
+ let source = null;
488
+
489
+ function fmtTime(ts) {
490
+ if (!ts) return '-';
491
+ try { return new Date(ts).toLocaleString(); } catch { return ts; }
492
+ }
493
+
494
+ function fmtDuration(ms) {
495
+ if (typeof ms !== 'number') return '-';
496
+ if (ms < 1000) return ms + 'ms';
497
+ return (ms / 1000).toFixed(2) + 's';
498
+ }
499
+
500
+ function esc(value) {
501
+ return String(value ?? '')
502
+ .replace(/&/g, '&amp;')
503
+ .replace(/</g, '&lt;')
504
+ .replace(/>/g, '&gt;')
505
+ .replace(/"/g, '&quot;')
506
+ .replace(/'/g, '&#39;');
507
+ }
508
+
509
+ function eventTitle(evt) {
510
+ switch (evt.type) {
511
+ case 'watcher_started': return 'Watcher started';
512
+ case 'watcher_stopped': return 'Watcher stopped';
513
+ case 'watch_event': return 'Filesystem change detected';
514
+ case 'backup_scheduled': return 'Backup scheduled';
515
+ case 'backup_deferred': return 'Backup deferred';
516
+ case 'backup_started': return 'Backup started';
517
+ case 'backup_finished': return 'Backup finished';
518
+ case 'backup_failed': return 'Backup failed';
519
+ default: return evt.type || 'event';
520
+ }
521
+ }
522
+
523
+ function eventDetail(evt) {
524
+ const bits = [];
525
+ if (evt.source) bits.push('source: ' + evt.source);
526
+ if (evt.dest) bits.push('dest: ' + evt.dest);
527
+ if (evt.path) bits.push('path: ' + evt.path);
528
+ if (evt.fs_event) bits.push('fs_event: ' + evt.fs_event);
529
+ if (evt.run_id) bits.push('run_id: ' + evt.run_id);
530
+ if (typeof evt.duration_ms === 'number') bits.push('duration: ' + fmtDuration(evt.duration_ms));
531
+ if (evt.error) bits.push('error: ' + evt.error);
532
+ if (evt.message) bits.push('message: ' + evt.message);
533
+ return bits.join(' | ');
534
+ }
535
+
536
+ function renderStatus(status) {
537
+ const rows = [
538
+ ['Enabled', status.enabled ? 'true' : 'false'],
539
+ ['PID', status.pid || '-'],
540
+ ['Started', fmtTime(status.started_at)],
541
+ ['Event Log', activeEventLogPath || '-'],
542
+ ['State File', stateFilePath],
543
+ ['Last Event', fmtTime(status.last_event_at)],
544
+ ['Last Backup', fmtTime(status.last_backup_at)],
545
+ ['Last Result', status.last_backup_result ? ((status.last_backup_result.success ? 'success' : 'failed') + ' - ' + status.last_backup_result.message) : '-'],
546
+ ];
547
+ statusPanel.innerHTML = rows.map(([k, v]) => '<div class="meta-row"><strong>' + esc(k) + '</strong><span class="mono">' + esc(v) + '</span></div>').join('');
548
+ }
549
+
550
+ function renderSummary() {
551
+ totalEvents.textContent = String(events.length);
552
+ const ok = events.filter((evt) => evt.type === 'backup_finished' && evt.success !== false).length;
553
+ const fail = events.filter((evt) => evt.type === 'backup_failed' || (evt.type === 'backup_finished' && evt.success === false)).length;
554
+ const durations = events.filter((evt) => typeof evt.duration_ms === 'number').map((evt) => evt.duration_ms);
555
+ const avg = durations.length ? Math.round(durations.reduce((sum, value) => sum + value, 0) / durations.length) : null;
556
+ okCount.textContent = String(ok);
557
+ failCount.textContent = String(fail);
558
+ avgDuration.textContent = avg === null ? '-' : fmtDuration(avg);
559
+ }
560
+
561
+ function renderTimeline() {
562
+ if (!events.length) {
563
+ timeline.innerHTML = '<div class="empty">还没有事件,先触发一次自动备份。</div>';
564
+ return;
565
+ }
566
+ timeline.innerHTML = events.slice().reverse().map((evt) => {
567
+ const fail = evt.type === 'backup_failed' || evt.success === false;
568
+ return '<div class="event">'
569
+ + '<div class="event-top"><div><strong>' + esc(eventTitle(evt)) + '</strong></div><div class="chip ' + (fail ? 'fail' : '') + '">' + esc(evt.type) + '</div></div>'
570
+ + '<div class="event-sub">' + esc(fmtTime(evt.ts)) + '</div>'
571
+ + '<div class="event-sub">' + esc(eventDetail(evt) || 'no detail') + '</div>'
572
+ + '</div>';
573
+ }).join('');
574
+ }
575
+
576
+ function pushEvents(nextEvents) {
577
+ events.push(...nextEvents);
578
+ renderSummary();
579
+ renderTimeline();
580
+ }
581
+
582
+ function resetEvents() {
583
+ events.length = 0;
584
+ renderSummary();
585
+ renderTimeline();
586
+ }
587
+
588
+ async function refreshStatus() {
589
+ const res = await fetch('/api/status');
590
+ const data = await res.json();
591
+ renderStatus(data.result || {});
592
+ }
593
+
594
+ async function loadInitial(logPath) {
595
+ const [statusRes, eventsRes] = await Promise.all([
596
+ fetch('/api/status'),
597
+ fetch('/api/events?limit=300&eventLog=' + encodeURIComponent(logPath || '')),
598
+ ]);
599
+ const statusData = await statusRes.json();
600
+ const eventsData = await eventsRes.json();
601
+ renderStatus(statusData.result || {});
602
+ resetEvents();
603
+ pushEvents(eventsData.result?.events || []);
604
+ if (eventsData.result?.error) {
605
+ timeline.innerHTML = '<div class="empty">日志读取失败: ' + esc(eventsData.result.error) + '</div>';
606
+ }
607
+ return eventsData.result?.snapshotOffset || 0;
608
+ }
609
+
610
+ function connect(logPath, snapshotOffset) {
611
+ if (source) {
612
+ source.close();
613
+ }
614
+ source = new EventSource(
615
+ '/api/events/stream?eventLog=' + encodeURIComponent(logPath || '')
616
+ + '&since=' + encodeURIComponent(String(snapshotOffset || 0))
617
+ );
618
+ source.onmessage = (message) => {
619
+ try {
620
+ const evt = JSON.parse(message.data);
621
+ pushEvents([evt]);
622
+ refreshStatus().catch(() => {});
623
+ } catch {}
624
+ };
625
+ source.addEventListener('log-error', (message) => {
626
+ try {
627
+ const payload = JSON.parse(message.data);
628
+ timeline.innerHTML = '<div class="empty">日志读取失败: ' + esc(payload.error || 'unknown error') + '</div>';
629
+ } catch {}
630
+ });
631
+ }
632
+
633
+ async function connectToLogPath() {
634
+ const nextPath = String(eventLogInput.value || '').trim();
635
+ activeEventLogPath = nextPath;
636
+ const snapshotOffset = await loadInitial(nextPath);
637
+ connect(nextPath, snapshotOffset);
638
+ }
639
+
640
+ connectBtn.addEventListener('click', () => {
641
+ connectToLogPath().catch((error) => {
642
+ timeline.innerHTML = '<div class="empty">加载失败: ' + esc(error?.message || error) + '</div>';
643
+ });
644
+ });
645
+ eventLogInput.addEventListener('keydown', (event) => {
646
+ if (event.key === 'Enter') {
647
+ event.preventDefault();
648
+ connectBtn.click();
649
+ }
650
+ });
651
+
652
+ if (activeEventLogPath) {
653
+ connectToLogPath().catch((error) => {
654
+ timeline.innerHTML = '<div class="empty">加载失败: ' + esc(error?.message || error) + '</div>';
655
+ });
656
+ } else {
657
+ refreshStatus().catch(() => {});
658
+ renderSummary();
659
+ renderTimeline();
660
+ }
661
+ setInterval(() => { refreshStatus().catch(() => {}); }, 5000);
662
+ </script>
663
+ </body>
664
+ </html>`;
665
+ }
666
+
667
+ function json(res, statusCode, payload) {
668
+ res.writeHead(statusCode, {
669
+ 'Content-Type': 'application/json; charset=utf-8',
670
+ 'Cache-Control': 'no-cache',
671
+ });
672
+ res.end(JSON.stringify(payload));
673
+ }
674
+
675
+ function readNewEventsSince(filePath, offset, remainder) {
676
+ if (!filePath) {
677
+ return { offset: 0, remainder: '', events: [], error: null };
678
+ }
679
+ try {
680
+ if (!fs.existsSync(filePath)) {
681
+ return { offset: 0, remainder: '', events: [], error: `日志文件不存在: ${filePath}` };
682
+ }
683
+ const stat = fs.statSync(filePath);
684
+ if (!stat.isFile()) {
685
+ return { offset, remainder, events: [], error: `不是文件: ${filePath}` };
686
+ }
687
+ if (stat.size < offset) {
688
+ offset = 0;
689
+ remainder = '';
690
+ }
691
+ if (stat.size === offset) {
692
+ return { offset, remainder, events: [], error: null };
693
+ }
694
+
695
+ const fd = fs.openSync(filePath, 'r');
696
+ try {
697
+ const length = stat.size - offset;
698
+ const buffer = Buffer.alloc(length);
699
+ fs.readSync(fd, buffer, 0, length, offset);
700
+ const text = remainder + buffer.toString('utf8');
701
+ const lines = text.split('\n');
702
+ const nextRemainder = lines.pop() || '';
703
+ const events = lines
704
+ .map((line) => line.trim())
705
+ .filter(Boolean)
706
+ .flatMap((line) => {
707
+ try {
708
+ return [JSON.parse(line)];
709
+ } catch {
710
+ return [];
711
+ }
712
+ });
713
+ return { offset: stat.size, remainder: nextRemainder, events, error: null };
714
+ } finally {
715
+ fs.closeSync(fd);
716
+ }
717
+ } catch (error) {
718
+ return {
719
+ offset,
720
+ remainder,
721
+ events: [],
722
+ error: error?.message || String(error),
723
+ };
724
+ }
725
+ }
726
+
727
+ function maybeOpenBrowser(url) {
728
+ let command;
729
+ let args;
730
+ if (process.platform === 'darwin') {
731
+ command = 'open';
732
+ args = [url];
733
+ } else if (process.platform === 'win32') {
734
+ command = 'cmd';
735
+ args = ['/c', 'start', '', url];
736
+ } else {
737
+ command = 'xdg-open';
738
+ args = [url];
739
+ }
740
+ try {
741
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
742
+ child.unref();
743
+ } catch {
744
+ // ignore browser open failures
745
+ }
746
+ }
747
+
748
+ async function main() {
749
+ const options = parseArgs(process.argv.slice(2));
750
+ const clients = new Set();
751
+ const clientStates = new Map();
752
+
753
+ const server = http.createServer((req, res) => {
754
+ const url = new URL(req.url || '/', `http://${req.headers.host || `${options.host}:${options.port}`}`);
755
+
756
+ if (url.pathname === '/') {
757
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
758
+ res.end(renderPage(options.eventLog, options.stateFile));
759
+ return;
760
+ }
761
+
762
+ if (url.pathname === '/api/status') {
763
+ json(res, 200, {
764
+ success: true,
765
+ result: buildStatus(options.stateFile, ''),
766
+ });
767
+ return;
768
+ }
769
+
770
+ if (url.pathname === '/api/events') {
771
+ const limit = Number(url.searchParams.get('limit') || 300);
772
+ const eventLog = resolveEventLogPath(url.searchParams.get('eventLog') || options.eventLog);
773
+ const snapshot = readEventsSnapshot(eventLog, limit);
774
+ json(res, 200, {
775
+ success: true,
776
+ result: {
777
+ eventLogPath: snapshot.eventLogPath,
778
+ events: snapshot.events,
779
+ snapshotOffset: snapshot.snapshotOffset,
780
+ error: snapshot.error,
781
+ },
782
+ });
783
+ return;
784
+ }
785
+
786
+ if (url.pathname === '/api/events/stream') {
787
+ const eventLog = resolveEventLogPath(url.searchParams.get('eventLog') || options.eventLog);
788
+ const sinceInput = url.searchParams.get('since');
789
+ const since = parseSinceOffset(sinceInput);
790
+ res.writeHead(200, {
791
+ 'Content-Type': 'text/event-stream',
792
+ 'Cache-Control': 'no-cache',
793
+ Connection: 'keep-alive',
794
+ });
795
+ res.write(': connected\n\n');
796
+ clients.add(res);
797
+ clientStates.set(res, {
798
+ eventLog,
799
+ offset: hasExplicitSince(sinceInput) ? since : getInitialOffset(eventLog),
800
+ remainder: '',
801
+ });
802
+ const heartbeat = setInterval(() => {
803
+ res.write(': heartbeat\n\n');
804
+ }, 15000);
805
+ req.on('close', () => {
806
+ clearInterval(heartbeat);
807
+ clients.delete(res);
808
+ clientStates.delete(res);
809
+ });
810
+ return;
811
+ }
812
+
813
+ json(res, 404, {
814
+ success: false,
815
+ error: 'not found',
816
+ });
817
+ });
818
+
819
+ const poll = setInterval(() => {
820
+ for (const client of clients) {
821
+ const state = clientStates.get(client);
822
+ if (!state) continue;
823
+ const next = readNewEventsSince(state.eventLog, state.offset, state.remainder);
824
+ state.offset = next.offset;
825
+ state.remainder = next.remainder;
826
+ if (next.error && next.error !== state.lastError) {
827
+ client.write(`event: log-error\ndata: ${JSON.stringify({ error: next.error, eventLogPath: state.eventLog })}\n\n`);
828
+ state.lastError = next.error;
829
+ } else if (!next.error) {
830
+ state.lastError = null;
831
+ }
832
+ for (const event of next.events) {
833
+ client.write(`data: ${JSON.stringify(event)}\n\n`);
834
+ }
835
+ }
836
+ }, 1000);
837
+
838
+ await new Promise((resolve, reject) => {
839
+ server.listen(options.port, options.host, resolve);
840
+ server.on('error', reject);
841
+ });
842
+
843
+ const url = `http://${options.host}:${options.port}`;
844
+ process.stdout.write(`Auto backup monitor running at ${url}\n`);
845
+ process.stdout.write(`Initial event log: ${options.eventLog || '(not set)'}\n`);
846
+ process.stdout.write(`State file: ${options.stateFile}\n`);
847
+ if (options.open) {
848
+ maybeOpenBrowser(url);
849
+ }
850
+
851
+ const shutdown = () => {
852
+ clearInterval(poll);
853
+ for (const client of clients) {
854
+ client.end();
855
+ }
856
+ server.close(() => process.exit(0));
857
+ };
858
+
859
+ process.on('SIGINT', shutdown);
860
+ process.on('SIGTERM', shutdown);
861
+ }
862
+
863
+ main().catch((error) => {
864
+ process.stderr.write(`Auto backup monitor failed: ${error.message || error}\n`);
865
+ process.exit(1);
866
+ });