@polderlabs/bizar 4.5.2 → 4.7.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/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
- package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
- package/bizar-dash/dist/assets/{mobile-lbH6szyX.js → mobile-BK8-ythT.js} +18 -18
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-Chvf9u_B.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js.map → mobile-Chvf9u_B.js.map} +1 -1
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/bizar-dash/skills/publishing/SKILL.md +146 -0
- package/bizar-dash/src/server/api.mjs +8 -0
- package/bizar-dash/src/server/backup-store.mjs +525 -0
- package/bizar-dash/src/server/digest-store.mjs +558 -0
- package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
- package/bizar-dash/src/server/logger.mjs +71 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
- package/bizar-dash/src/server/metrics.mjs +193 -0
- package/bizar-dash/src/server/routes/backup.mjs +112 -0
- package/bizar-dash/src/server/routes/chat.mjs +20 -3
- package/bizar-dash/src/server/routes/digests.mjs +82 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
- package/bizar-dash/src/server/routes/memory.mjs +5 -4
- package/bizar-dash/src/server/routes/misc.mjs +2 -1
- package/bizar-dash/src/server/routes/overview.mjs +2 -1
- package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
- package/bizar-dash/src/server/schedules-runner.mjs +126 -0
- package/bizar-dash/src/server/server.mjs +79 -0
- package/bizar-dash/src/web/App.tsx +8 -1
- package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
- package/bizar-dash/src/web/components/SearchModal.tsx +6 -3
- package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
- package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
- package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
- package/bizar-dash/src/web/lib/i18n.ts +25 -0
- package/bizar-dash/src/web/locales/en.json +52 -0
- package/bizar-dash/src/web/main.tsx +5 -0
- package/bizar-dash/src/web/styles/main.css +70 -8
- package/bizar-dash/src/web/views/Activity.tsx +35 -18
- package/bizar-dash/src/web/views/Agents.tsx +57 -42
- package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
- package/bizar-dash/src/web/views/Chat.tsx +8 -0
- package/bizar-dash/src/web/views/History.tsx +94 -76
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
- package/bizar-dash/src/web/views/Mods.tsx +30 -17
- package/bizar-dash/src/web/views/Overview.tsx +19 -9
- package/bizar-dash/src/web/views/Providers.tsx +16 -16
- package/bizar-dash/src/web/views/Schedules.tsx +33 -15
- package/bizar-dash/src/web/views/Settings.tsx +97 -1745
- package/bizar-dash/src/web/views/Skills.tsx +4 -1
- package/bizar-dash/src/web/views/Tasks.tsx +11 -2
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
- package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
- package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
- package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
- package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
- package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
- package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
- package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
- package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
- package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
- package/bizar-dash/tests/a11y.test.tsx +206 -0
- package/bizar-dash/tests/backup-restore.test.mjs +217 -0
- package/bizar-dash/tests/backup-restore.test.tsx +123 -0
- package/bizar-dash/tests/backup-store.test.mjs +300 -0
- package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
- package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
- package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
- package/bizar-dash/tests/components/Button.test.tsx +41 -0
- package/bizar-dash/tests/components/Card.test.tsx +42 -0
- package/bizar-dash/tests/components/Modal.test.tsx +104 -0
- package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
- package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
- package/bizar-dash/tests/components/Toast.test.tsx +108 -0
- package/bizar-dash/tests/digest-generation.test.mjs +191 -0
- package/bizar-dash/tests/digest-store.test.mjs +264 -0
- package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
- package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
- package/bizar-dash/tests/lib/i18n.test.ts +46 -0
- package/bizar-dash/tests/lib/utils.test.ts +194 -0
- package/bizar-dash/tests/logger.test.mjs +207 -0
- package/bizar-dash/tests/metrics.test.mjs +183 -0
- package/bizar-dash/tests/rate-limit.test.mjs +298 -0
- package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
- package/bizar-dash/tests/setup.ts +7 -0
- package/bizar-dash/vitest.config.ts +13 -0
- package/cli/artifact-cli.mjs +605 -0
- package/cli/artifact-render.mjs +621 -0
- package/cli/artifact-server.mjs +847 -0
- package/cli/artifact.mjs +38 -2096
- package/cli/bg.mjs +5 -13
- package/cli/bin.mjs +221 -1350
- package/cli/commands/artifact.mjs +20 -0
- package/cli/commands/dash.mjs +160 -0
- package/cli/commands/headroom.mjs +204 -0
- package/cli/commands/install.mjs +169 -0
- package/cli/commands/memory.mjs +25 -0
- package/cli/commands/minimax.mjs +285 -0
- package/cli/commands/mod.mjs +185 -0
- package/cli/commands/service.mjs +65 -0
- package/cli/commands/usage.mjs +109 -0
- package/cli/commands/util.mjs +459 -0
- package/cli/digest.mjs +149 -0
- package/cli/doctor.mjs +1 -13
- package/cli/provision.mjs +2 -13
- package/cli/service-controller.mjs +1 -11
- package/cli/service.mjs +1 -11
- package/cli/utils.mjs +41 -1
- package/package.json +6 -1
- package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
- package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
- package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
- package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +0 -1
|
@@ -15,6 +15,7 @@ import { agentsStore } from '../agents-store.mjs';
|
|
|
15
15
|
import { schedulesStore } from '../schedules-store.mjs';
|
|
16
16
|
import { providersStore, mcpsStore } from '../providers-store.mjs';
|
|
17
17
|
import { modsLoader } from '../mods-loader.mjs';
|
|
18
|
+
import { error as logError } from '../logger.mjs';
|
|
18
19
|
import {
|
|
19
20
|
OPENCODE_JSON,
|
|
20
21
|
readSettings,
|
|
@@ -56,7 +57,7 @@ export function createOverviewRouter({ state }) {
|
|
|
56
57
|
},
|
|
57
58
|
);
|
|
58
59
|
child.on('error', (err) => {
|
|
59
|
-
|
|
60
|
+
logError('restart spawn failed', { module: 'overview', err: err.message });
|
|
60
61
|
});
|
|
61
62
|
child.unref();
|
|
62
63
|
process.exit(0);
|
|
@@ -14,10 +14,24 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import express from 'express';
|
|
17
|
+
import { createRateLimiter } from '../lib/rate-limit.mjs';
|
|
17
18
|
|
|
18
19
|
export function createV2EventsRouter({ eventBus }) {
|
|
19
20
|
const router = express.Router();
|
|
20
21
|
|
|
22
|
+
// v4.8.0 — Per-IP token bucket. The v2 event endpoint accepts
|
|
23
|
+
// arbitrary publishes from the opencode plugin, so we cap it
|
|
24
|
+
// higher than chat to keep automation pipelines flowing under
|
|
25
|
+
// burst. Defaults: 120 requests / minute / IP, 2 tokens/sec refill.
|
|
26
|
+
// Operators can tune via BIZAR_RATE_LIMIT_EVENT_CAPACITY /
|
|
27
|
+
// BIZAR_RATE_LIMIT_EVENT_REFILL.
|
|
28
|
+
const eventLimiter = createRateLimiter({
|
|
29
|
+
capacity: parseInt(process.env.BIZAR_RATE_LIMIT_EVENT_CAPACITY || '120', 10),
|
|
30
|
+
refillPerSecond: parseFloat(process.env.BIZAR_RATE_LIMIT_EVENT_REFILL || '2'),
|
|
31
|
+
scope: 'event',
|
|
32
|
+
});
|
|
33
|
+
router.use(eventLimiter);
|
|
34
|
+
|
|
21
35
|
router.get('/event', (req, res) => {
|
|
22
36
|
res.status(200);
|
|
23
37
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
@@ -23,6 +23,7 @@ import { homedir } from 'node:os';
|
|
|
23
23
|
import { isIP } from 'node:net';
|
|
24
24
|
import { projectsStore } from './projects-store.mjs';
|
|
25
25
|
import { schedulesStore } from './schedules-store.mjs';
|
|
26
|
+
import { Cron } from 'croner';
|
|
26
27
|
|
|
27
28
|
const HOME = homedir();
|
|
28
29
|
const LOG_DIR = join(HOME, '.config', 'bizar');
|
|
@@ -30,6 +31,97 @@ const LOG_FILE = join(LOG_DIR, 'service.log');
|
|
|
30
31
|
const ALLOW_PRIVATE_WEBHOOKS = process.env.BIZAR_DASHBOARD_ALLOW_PRIVATE_WEBHOOKS === '1';
|
|
31
32
|
const SHELL_META = /[;&|`$<>\n\r]/;
|
|
32
33
|
|
|
34
|
+
// ── Internal schedules ─────────────────────────────────────────────────────
|
|
35
|
+
//
|
|
36
|
+
// v4.8.0 — Builtin schedules registered at module load. These are NOT
|
|
37
|
+
// persisted in the schedules-store; they live in-memory here and are
|
|
38
|
+
// checked alongside the project schedules during tick(). When a builtin
|
|
39
|
+
// schedule fires, the runner dispatches directly to the handler without
|
|
40
|
+
// going through the task delegator or action runner.
|
|
41
|
+
|
|
42
|
+
/** @type {Array<{ id: string, name: string, schedule: string, type: string, timezone: string, enabled: boolean, builtin: boolean, handler?: Function, lastRun: string|null, nextRun: string|null }>} */
|
|
43
|
+
const internalSchedules = [];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Register an internal schedule that is evaluated during tick().
|
|
47
|
+
*
|
|
48
|
+
* @param {object} spec
|
|
49
|
+
* @param {string} spec.id
|
|
50
|
+
* @param {string} spec.name
|
|
51
|
+
* @param {string} spec.cron — cron expression
|
|
52
|
+
* @param {string} [spec.timezone='UTC']
|
|
53
|
+
* @param {Function} [spec.handler] — optional direct-call handler for builtin schedules
|
|
54
|
+
*/
|
|
55
|
+
export function registerInternalSchedule(spec) {
|
|
56
|
+
const tz = spec.timezone || 'UTC';
|
|
57
|
+
let nextRun = null;
|
|
58
|
+
if (spec.cron) {
|
|
59
|
+
try {
|
|
60
|
+
const job = new Cron(spec.cron, { timezone: tz });
|
|
61
|
+
const n = job.nextRun();
|
|
62
|
+
nextRun = n ? n.toISOString() : null;
|
|
63
|
+
} catch { /* invalid cron */ }
|
|
64
|
+
}
|
|
65
|
+
// Remove existing entry with same id (for hot-reload)
|
|
66
|
+
const existing = internalSchedules.findIndex((s) => s.id === spec.id);
|
|
67
|
+
const entry = {
|
|
68
|
+
id: spec.id,
|
|
69
|
+
name: spec.name || spec.id,
|
|
70
|
+
schedule: spec.cron || '',
|
|
71
|
+
type: 'cron',
|
|
72
|
+
timezone: tz,
|
|
73
|
+
enabled: true,
|
|
74
|
+
builtin: true,
|
|
75
|
+
handler: spec.handler || null,
|
|
76
|
+
lastRun: null,
|
|
77
|
+
nextRun,
|
|
78
|
+
};
|
|
79
|
+
if (existing >= 0) {
|
|
80
|
+
internalSchedules[existing] = { ...internalSchedules[existing], ...entry };
|
|
81
|
+
} else {
|
|
82
|
+
internalSchedules.push(entry);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Recompute nextRun for all internal schedules (called on system clock
|
|
88
|
+
* changes or after midnight).
|
|
89
|
+
*/
|
|
90
|
+
export function recomputeInternalSchedules() {
|
|
91
|
+
for (const s of internalSchedules) {
|
|
92
|
+
if (s.type === 'cron' && s.schedule) {
|
|
93
|
+
try {
|
|
94
|
+
const job = new Cron(s.schedule, { timezone: s.timezone || 'UTC' });
|
|
95
|
+
const n = job.nextRun();
|
|
96
|
+
s.nextRun = n ? n.toISOString() : null;
|
|
97
|
+
} catch {
|
|
98
|
+
s.nextRun = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// v4.8.0 — Auto-register the weekly digest cron at module load.
|
|
105
|
+
registerInternalSchedule({
|
|
106
|
+
id: 'bizar-internal-weekly-digest',
|
|
107
|
+
name: 'Weekly digest',
|
|
108
|
+
cron: '0 0 * * 0',
|
|
109
|
+
timezone: 'UTC',
|
|
110
|
+
handler: async () => {
|
|
111
|
+
logLine('[digest] Weekly digest cron fired — generating…');
|
|
112
|
+
try {
|
|
113
|
+
const { generateAndSave } = await import('./digest-store.mjs');
|
|
114
|
+
const projectRoot = process.env.BIZAR_PROJECT_ROOT || process.cwd();
|
|
115
|
+
const result = await generateAndSave({ projectRoot });
|
|
116
|
+
logLine(`[digest] Weekly digest generated: ${result.weekStart} (${result.saveResult.paths.length} file(s))`);
|
|
117
|
+
return { ok: true, result };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
logLine(`[digest] Weekly digest generation failed: ${err.message}`);
|
|
120
|
+
return { ok: false, error: err.message };
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
33
125
|
function logLine(line) {
|
|
34
126
|
try {
|
|
35
127
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
@@ -292,6 +384,8 @@ export const schedulesRunner = {
|
|
|
292
384
|
console.warn('swallowed in schedules due-list:', err.message);
|
|
293
385
|
}
|
|
294
386
|
let skipped = 0;
|
|
387
|
+
|
|
388
|
+
// Project schedules
|
|
295
389
|
for (const projectId of projectIds) {
|
|
296
390
|
const due = schedulesStore.due(projectId, Date.now());
|
|
297
391
|
for (const sched of due) {
|
|
@@ -300,6 +394,38 @@ export const schedulesRunner = {
|
|
|
300
394
|
fired.push({ projectId, scheduleId: sched.id, ...result });
|
|
301
395
|
}
|
|
302
396
|
}
|
|
397
|
+
|
|
398
|
+
// v4.8.0 — Internal (builtin) schedules
|
|
399
|
+
const now = Date.now();
|
|
400
|
+
for (const sched of internalSchedules) {
|
|
401
|
+
if (!sched.enabled) continue;
|
|
402
|
+
if (!sched.nextRun) continue;
|
|
403
|
+
if (new Date(sched.nextRun).getTime() > now) continue;
|
|
404
|
+
const startedAt = new Date().toISOString();
|
|
405
|
+
logLine(`[${startedAt}] run internal ${sched.id} (${sched.name})`);
|
|
406
|
+
try {
|
|
407
|
+
if (typeof sched.handler === 'function') {
|
|
408
|
+
const res = await sched.handler();
|
|
409
|
+
sched.lastRun = new Date().toISOString();
|
|
410
|
+
// Recompute nextRun
|
|
411
|
+
if (sched.schedule) {
|
|
412
|
+
try {
|
|
413
|
+
const job = new Cron(sched.schedule, { timezone: sched.timezone || 'UTC' });
|
|
414
|
+
const n = job.nextRun();
|
|
415
|
+
sched.nextRun = n ? n.toISOString() : null;
|
|
416
|
+
} catch {
|
|
417
|
+
sched.nextRun = null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
logLine(`[${new Date().toISOString()}] done internal ${sched.id} → ${res.ok ? 'success' : 'error'}`);
|
|
421
|
+
fired.push({ projectId: 'internal', scheduleId: sched.id, ok: res.ok, runResult: res });
|
|
422
|
+
}
|
|
423
|
+
} catch (err) {
|
|
424
|
+
logLine(`[${new Date().toISOString()}] failed internal ${sched.id}: ${err.message}`);
|
|
425
|
+
fired.push({ projectId: 'internal', scheduleId: sched.id, ok: false, runResult: { error: err.message } });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
303
429
|
if (skipped > 0) {
|
|
304
430
|
logLine(`[schedule] tick: ${skipped} skipped by budget pre-flight`);
|
|
305
431
|
}
|
|
@@ -39,6 +39,22 @@ import { buildAllowedRootsFromSettings, resolveSafePath } from './lib/path-safe.
|
|
|
39
39
|
import { V2EventBus } from './v2-event-bus.mjs';
|
|
40
40
|
import { loadOrCreateAuth, V2_DEFAULT_PORT } from './v2-auth-file.mjs';
|
|
41
41
|
import { createV2Router } from './routes-v2/index.mjs';
|
|
42
|
+
import { counter, gauge, render as renderMetrics } from './metrics.mjs';
|
|
43
|
+
import { warn } from './logger.mjs';
|
|
44
|
+
|
|
45
|
+
// v4.7.0 — Prometheus-style HTTP metrics. Bound to the server-wide
|
|
46
|
+
// registry; render() emits the text exposition format consumed by
|
|
47
|
+
// `GET /metrics`. Counter is keyed by {method, route, status} where
|
|
48
|
+
// `route` is the matched Express pattern (e.g. `/api/tasks/:id`) to
|
|
49
|
+
// keep cardinality bounded; unmatched 404s collapse to `unmatched`.
|
|
50
|
+
const httpRequestsTotal = counter(
|
|
51
|
+
'http_requests_total',
|
|
52
|
+
'Count of HTTP requests handled by the dashboard server.',
|
|
53
|
+
);
|
|
54
|
+
const wsClientsGauge = gauge(
|
|
55
|
+
'ws_clients',
|
|
56
|
+
'Number of currently-connected WebSocket clients (snapshot + /ws/logs).',
|
|
57
|
+
);
|
|
42
58
|
|
|
43
59
|
let processHandlersInstalled = false;
|
|
44
60
|
let v2Bus = null;
|
|
@@ -161,6 +177,62 @@ export async function createServer({
|
|
|
161
177
|
next();
|
|
162
178
|
});
|
|
163
179
|
|
|
180
|
+
// v4.7.0 — Prometheus scrape endpoint. Mounted BEFORE the auth
|
|
181
|
+
// middleware and OUTSIDE /api/* so scrapers don't need a bearer
|
|
182
|
+
// token. Returns text/plain in the standard 0.0.4 exposition
|
|
183
|
+
// format so both Prometheus and `curl http://host/metrics` work.
|
|
184
|
+
app.get('/metrics', (_req, res) => {
|
|
185
|
+
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
186
|
+
res.send(renderMetrics());
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// v4.7.0 — Per-request counter. Uses the matched Express route
|
|
190
|
+
// pattern (e.g. `/api/tasks/:id`) when available; falls back to
|
|
191
|
+
// `unmatched` for 404s so the cardinality stays bounded. Hooked off
|
|
192
|
+
// `finish` so the final status code is captured even after error
|
|
193
|
+
// handlers run.
|
|
194
|
+
//
|
|
195
|
+
// v4.8.0 — When the status is 429, also emit a structured warning
|
|
196
|
+
// so operators can see when an IP is being throttled. The scope
|
|
197
|
+
// comes from the X-RateLimit-Scope header set by the per-route
|
|
198
|
+
// limiter middleware (chat / event); if no scope header is
|
|
199
|
+
// present, log the 429 with scope=unknown (it may be an upstream
|
|
200
|
+
// 429 proxied from the opencode plugin).
|
|
201
|
+
app.use((req, res, next) => {
|
|
202
|
+
res.on('finish', () => {
|
|
203
|
+
const route = req.route?.path ? `${req.baseUrl || ''}${req.route.path}` : 'unmatched';
|
|
204
|
+
httpRequestsTotal.inc({
|
|
205
|
+
method: req.method,
|
|
206
|
+
route,
|
|
207
|
+
status: String(res.statusCode),
|
|
208
|
+
});
|
|
209
|
+
if (res.statusCode === 429) {
|
|
210
|
+
const scope = res.getHeader('X-RateLimit-Scope') || 'unknown';
|
|
211
|
+
warn('rate_limit_exceeded', {
|
|
212
|
+
ip: req.ip || (req.socket && req.socket.remoteAddress) || 'unknown',
|
|
213
|
+
scope,
|
|
214
|
+
method: req.method,
|
|
215
|
+
route,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
next();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// v4.7.0 — Cache-Control headers for live JSON endpoints. The
|
|
223
|
+
// frontend pulls these every few seconds, so we use `no-cache` (which
|
|
224
|
+
// allows conditional revalidation) rather than `no-store` (which
|
|
225
|
+
// forbids it). Static assets already get `max-age=1y` via
|
|
226
|
+
// express.static above; this only fills the gap for the live API.
|
|
227
|
+
app.use('/api/settings', (req, res, next) => {
|
|
228
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
229
|
+
next();
|
|
230
|
+
});
|
|
231
|
+
app.use('/api/snapshot', (req, res, next) => {
|
|
232
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
233
|
+
next();
|
|
234
|
+
});
|
|
235
|
+
|
|
164
236
|
app.use(
|
|
165
237
|
(
|
|
166
238
|
err,
|
|
@@ -519,9 +591,16 @@ export async function createServer({
|
|
|
519
591
|
|
|
520
592
|
wss.on('connection', (ws, req) => {
|
|
521
593
|
ws.isAlive = true;
|
|
594
|
+
wsClientsGauge.set(wss.clients.size);
|
|
522
595
|
ws.on('pong', () => {
|
|
523
596
|
ws.isAlive = true;
|
|
524
597
|
});
|
|
598
|
+
ws.on('close', () => {
|
|
599
|
+
wsClientsGauge.set(wss.clients.size);
|
|
600
|
+
});
|
|
601
|
+
ws.on('error', () => {
|
|
602
|
+
wsClientsGauge.set(wss.clients.size);
|
|
603
|
+
});
|
|
525
604
|
const path = req.url
|
|
526
605
|
? (() => {
|
|
527
606
|
try {
|
|
@@ -715,6 +715,13 @@ function Shell() {
|
|
|
715
715
|
|
|
716
716
|
return (
|
|
717
717
|
<div className="app" data-layout={layout} data-active-tab={activeTab}>
|
|
718
|
+
{/* v4.8.0 — WCAG 2.2 AA: skip-to-main-content link. Hidden visually
|
|
719
|
+
until focused; clicking moves keyboard focus past the nav into
|
|
720
|
+
the active view. Screen readers and keyboard users can jump
|
|
721
|
+
past the topbar + sidebar without tabbing through every tab. */}
|
|
722
|
+
<a className="skip-to-main" href="#main-content">
|
|
723
|
+
Skip to main content
|
|
724
|
+
</a>
|
|
718
725
|
{showHeader && (
|
|
719
726
|
<Topbar
|
|
720
727
|
activeTab={activeTab}
|
|
@@ -769,7 +776,7 @@ function Shell() {
|
|
|
769
776
|
onTabChange={setActiveTab}
|
|
770
777
|
/>
|
|
771
778
|
)}
|
|
772
|
-
<main className="content">
|
|
779
|
+
<main className="content" id="main-content" tabIndex={-1}>
|
|
773
780
|
{bootError && (
|
|
774
781
|
<div className="boot-error">
|
|
775
782
|
<h2>Dashboard unavailable</h2>
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// src/web/components/BackupRestore.tsx — v4.8.0 Backup/restore card for Settings.
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState, useCallback } from 'react';
|
|
4
|
+
import { Download, Upload, Trash2, RefreshCw, CheckCircle, AlertCircle, Archive } from 'lucide-react';
|
|
5
|
+
import { Button } from './Button';
|
|
6
|
+
import { Card, CardTitle, CardMeta } from './Card';
|
|
7
|
+
import { useModal } from './Modal';
|
|
8
|
+
import { useToast } from './Toast';
|
|
9
|
+
import { api } from '../lib/api';
|
|
10
|
+
import { cn } from '../lib/utils';
|
|
11
|
+
|
|
12
|
+
type BackupEntry = {
|
|
13
|
+
path: string;
|
|
14
|
+
name: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
sizeBytes: number;
|
|
17
|
+
sizeFormatted: string;
|
|
18
|
+
manifest: {
|
|
19
|
+
version?: string;
|
|
20
|
+
createdAt?: string;
|
|
21
|
+
label?: string;
|
|
22
|
+
paths?: string[];
|
|
23
|
+
} | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type RestoreResult = {
|
|
27
|
+
ok: boolean;
|
|
28
|
+
restored: string[];
|
|
29
|
+
skipped: string[];
|
|
30
|
+
errors: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function RestoreDialog({
|
|
34
|
+
backup,
|
|
35
|
+
onClose,
|
|
36
|
+
}: {
|
|
37
|
+
backup: BackupEntry;
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
}) {
|
|
40
|
+
const toast = useToast();
|
|
41
|
+
const [dryRun, setDryRun] = useState(true);
|
|
42
|
+
const [strategy, setStrategy] = useState<'overwrite' | 'merge' | 'skip'>('merge');
|
|
43
|
+
const [loading, setLoading] = useState(false);
|
|
44
|
+
const [result, setResult] = useState<RestoreResult | null>(null);
|
|
45
|
+
|
|
46
|
+
const doRestore = async () => {
|
|
47
|
+
setLoading(true);
|
|
48
|
+
setResult(null);
|
|
49
|
+
try {
|
|
50
|
+
const r = await api.post<RestoreResult>('/backup/restore', {
|
|
51
|
+
backupPath: backup.path,
|
|
52
|
+
dryRun,
|
|
53
|
+
conflictStrategy: strategy,
|
|
54
|
+
});
|
|
55
|
+
setResult(r);
|
|
56
|
+
if (!r.ok && r.errors.length > 0) {
|
|
57
|
+
toast.error(`Restore completed with errors.`);
|
|
58
|
+
} else if (r.ok && !dryRun) {
|
|
59
|
+
toast.success('Restore complete.');
|
|
60
|
+
onClose();
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
toast.error(`Restore failed: ${(err as Error).message}`);
|
|
64
|
+
} finally {
|
|
65
|
+
setLoading(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
71
|
+
{backup.manifest && (
|
|
72
|
+
<div style={{ fontSize: 12, color: 'var(--text-muted, #8b949e)' }}>
|
|
73
|
+
<p><strong>Created:</strong> {new Date(backup.createdAt).toLocaleString()}</p>
|
|
74
|
+
<p><strong>Version:</strong> {backup.manifest.version || 'unknown'}</p>
|
|
75
|
+
<p><strong>Contents:</strong> {backup.manifest.paths?.join(', ') || 'unknown'}</p>
|
|
76
|
+
{backup.manifest.label && <p><strong>Label:</strong> {backup.manifest.label}</p>}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
<label className="checkbox-row">
|
|
81
|
+
<input
|
|
82
|
+
type="checkbox"
|
|
83
|
+
checked={dryRun}
|
|
84
|
+
onChange={(e) => setDryRun(e.target.checked)}
|
|
85
|
+
/>
|
|
86
|
+
<span>Dry-run (preview only — no files will be modified)</span>
|
|
87
|
+
</label>
|
|
88
|
+
|
|
89
|
+
<div className="field">
|
|
90
|
+
<label className="field-label">Conflict strategy</label>
|
|
91
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6 }}>
|
|
92
|
+
{(['overwrite', 'merge', 'skip'] as const).map((s) => (
|
|
93
|
+
<button
|
|
94
|
+
key={s}
|
|
95
|
+
type="button"
|
|
96
|
+
className={cn('theme-card', strategy === s && 'theme-card-active')}
|
|
97
|
+
onClick={() => setStrategy(s)}
|
|
98
|
+
>
|
|
99
|
+
<span className="theme-card-label" style={{ fontSize: 12 }}>
|
|
100
|
+
{s === 'overwrite' ? 'Replace all' : s === 'merge' ? 'Merge (newer wins)' : 'Skip existing'}
|
|
101
|
+
</span>
|
|
102
|
+
</button>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
<p className="field-help">
|
|
106
|
+
{strategy === 'overwrite' && 'Replace every file in the backup — existing files will be overwritten.'}
|
|
107
|
+
{strategy === 'merge' && 'Copy newer files from backup over existing files. Existing files are kept if they are newer.'}
|
|
108
|
+
{strategy === 'skip' && 'Only create files that don\'t already exist. Existing files are never modified.'}
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{result && (
|
|
113
|
+
<div style={{ fontSize: 12, padding: 12, background: 'var(--surface-2, #161b22)', borderRadius: 8 }}>
|
|
114
|
+
{result.restored.length > 0 && (
|
|
115
|
+
<p style={{ color: 'var(--success, #3fb950)' }}>
|
|
116
|
+
<CheckCircle size={12} style={{ display: 'inline', marginRight: 4 }} />
|
|
117
|
+
Restored: {result.restored.join(', ')}
|
|
118
|
+
</p>
|
|
119
|
+
)}
|
|
120
|
+
{result.skipped.length > 0 && (
|
|
121
|
+
<p style={{ color: 'var(--text-muted, #8b949e)' }}>
|
|
122
|
+
Skipped: {result.skipped.join(', ')}
|
|
123
|
+
</p>
|
|
124
|
+
)}
|
|
125
|
+
{result.errors.length > 0 && (
|
|
126
|
+
<p style={{ color: 'var(--error, #f85149)' }}>
|
|
127
|
+
<AlertCircle size={12} style={{ display: 'inline', marginRight: 4 }} />
|
|
128
|
+
Errors: {result.errors.join(', ')}
|
|
129
|
+
</p>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
<div className="modal-footer-actions">
|
|
135
|
+
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
|
136
|
+
<Button
|
|
137
|
+
variant="primary"
|
|
138
|
+
disabled={loading}
|
|
139
|
+
onClick={doRestore}
|
|
140
|
+
>
|
|
141
|
+
{loading ? <span className="btn-spinner" /> : null}
|
|
142
|
+
{dryRun ? 'Preview restore' : 'Restore'}
|
|
143
|
+
</Button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function BackupRestoreCard() {
|
|
150
|
+
const toast = useToast();
|
|
151
|
+
const modal = useModal();
|
|
152
|
+
const [backups, setBackups] = useState<BackupEntry[]>([]);
|
|
153
|
+
const [loading, setLoading] = useState(true);
|
|
154
|
+
const [creating, setCreating] = useState(false);
|
|
155
|
+
const [label, setLabel] = useState('');
|
|
156
|
+
|
|
157
|
+
const loadBackups = useCallback(async () => {
|
|
158
|
+
setLoading(true);
|
|
159
|
+
try {
|
|
160
|
+
const r = await api.get<{ ok: boolean; backups: BackupEntry[] }>('/backup/list');
|
|
161
|
+
setBackups(r.backups || []);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
toast.error(`Load failed: ${(err as Error).message}`);
|
|
164
|
+
} finally {
|
|
165
|
+
setLoading(false);
|
|
166
|
+
}
|
|
167
|
+
}, [toast]);
|
|
168
|
+
|
|
169
|
+
useEffect(() => { loadBackups(); }, [loadBackups]);
|
|
170
|
+
|
|
171
|
+
const onCreate = async () => {
|
|
172
|
+
setCreating(true);
|
|
173
|
+
try {
|
|
174
|
+
const r = await api.post<{ ok: boolean; path: string; sizeBytes: number; durationMs: number }>('/backup/create', {
|
|
175
|
+
label: label.trim() || null,
|
|
176
|
+
});
|
|
177
|
+
if (r.ok) {
|
|
178
|
+
toast.success('Backup created.');
|
|
179
|
+
setLabel('');
|
|
180
|
+
await loadBackups();
|
|
181
|
+
} else {
|
|
182
|
+
toast.error('Backup failed.');
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
toast.error(`Create failed: ${(err as Error).message}`);
|
|
186
|
+
} finally {
|
|
187
|
+
setCreating(false);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const onVerify = async (backup: BackupEntry) => {
|
|
192
|
+
try {
|
|
193
|
+
const r = await api.post<{ ok: boolean; issues: string[] }>('/backup/verify', {
|
|
194
|
+
backupPath: backup.path,
|
|
195
|
+
});
|
|
196
|
+
if (r.ok) {
|
|
197
|
+
toast.success('Backup is valid.');
|
|
198
|
+
} else {
|
|
199
|
+
toast.error(`Backup has issues: ${r.issues.join(', ')}`);
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
toast.error(`Verify failed: ${(err as Error).message}`);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const onDelete = async (backup: BackupEntry) => {
|
|
207
|
+
if (!confirm(`Delete backup?\n\n${backup.path}\n\nThis cannot be undone.`)) return;
|
|
208
|
+
try {
|
|
209
|
+
const encoded = encodeURIComponent(backup.path);
|
|
210
|
+
await api.del(`/backup/${encoded}`);
|
|
211
|
+
toast.success('Backup deleted.');
|
|
212
|
+
await loadBackups();
|
|
213
|
+
} catch (err) {
|
|
214
|
+
toast.error(`Delete failed: ${(err as Error).message}`);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const onRestore = (backup: BackupEntry) => {
|
|
219
|
+
modal.open({
|
|
220
|
+
title: `Restore backup`,
|
|
221
|
+
children: <RestoreDialog backup={backup} onClose={() => modal.close()} />,
|
|
222
|
+
width: 520,
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const recent = backups.slice(0, 10);
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<Card id="settings-backup" data-section="backup">
|
|
230
|
+
<CardTitle><Archive size={14} /> Backup & Restore</CardTitle>
|
|
231
|
+
<CardMeta>
|
|
232
|
+
Snapshot your BizarHarness config, memory, and usage logs.
|
|
233
|
+
Backups are stored under <code>~/.local/share/bizar/backups/</code>.
|
|
234
|
+
</CardMeta>
|
|
235
|
+
|
|
236
|
+
{/* Create backup */}
|
|
237
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 12, marginBottom: 16, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|
238
|
+
<div className="field" style={{ flex: '1 1 200px', margin: 0 }}>
|
|
239
|
+
<label className="field-label" htmlFor="backup-label" style={{ fontSize: 12 }}>
|
|
240
|
+
Label <span className="muted">(optional)</span>
|
|
241
|
+
</label>
|
|
242
|
+
<input
|
|
243
|
+
id="backup-label"
|
|
244
|
+
type="text"
|
|
245
|
+
className="input"
|
|
246
|
+
placeholder="e.g. before-upgrade"
|
|
247
|
+
value={label}
|
|
248
|
+
onChange={(e) => setLabel(e.target.value)}
|
|
249
|
+
onKeyDown={(e) => { if (e.key === 'Enter') onCreate(); }}
|
|
250
|
+
style={{ fontSize: 13 }}
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
<Button variant="primary" onClick={onCreate} disabled={creating}>
|
|
254
|
+
{creating ? <span className="btn-spinner" /> : <Download size={14} />}
|
|
255
|
+
{creating ? 'Creating…' : 'Create backup'}
|
|
256
|
+
</Button>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{/* Backup list */}
|
|
260
|
+
{loading ? (
|
|
261
|
+
<div className="muted" style={{ fontSize: 12, padding: '12px 0' }}>Loading backups…</div>
|
|
262
|
+
) : recent.length === 0 ? (
|
|
263
|
+
<div className="muted" style={{ fontSize: 12, padding: '12px 0' }}>
|
|
264
|
+
No backups yet. Create one to protect your configuration.
|
|
265
|
+
</div>
|
|
266
|
+
) : (
|
|
267
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
268
|
+
{recent.map((b) => (
|
|
269
|
+
<div
|
|
270
|
+
key={b.path}
|
|
271
|
+
style={{
|
|
272
|
+
display: 'flex',
|
|
273
|
+
alignItems: 'center',
|
|
274
|
+
gap: 8,
|
|
275
|
+
padding: '8px 10px',
|
|
276
|
+
borderRadius: 6,
|
|
277
|
+
border: '1px solid var(--border, #30363d)',
|
|
278
|
+
background: 'var(--surface-2, #161b22)',
|
|
279
|
+
flexWrap: 'wrap',
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
<div style={{ flex: '1 1 200px', minWidth: 0 }}>
|
|
283
|
+
<div style={{ fontSize: 12, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
284
|
+
{b.name}
|
|
285
|
+
</div>
|
|
286
|
+
<div style={{ fontSize: 11, color: 'var(--text-muted, #8b949e)' }}>
|
|
287
|
+
{new Date(b.createdAt).toLocaleString()} · {b.sizeFormatted}
|
|
288
|
+
{b.manifest?.label && <> · <em>{b.manifest.label}</em></>}
|
|
289
|
+
{b.manifest?.version && <> · v{b.manifest.version}</>}
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
|
293
|
+
<Button
|
|
294
|
+
variant="secondary"
|
|
295
|
+
size="sm"
|
|
296
|
+
onClick={() => onRestore(b)}
|
|
297
|
+
title="Restore from this backup"
|
|
298
|
+
>
|
|
299
|
+
<Upload size={12} /> Restore
|
|
300
|
+
</Button>
|
|
301
|
+
<Button
|
|
302
|
+
variant="ghost"
|
|
303
|
+
size="sm"
|
|
304
|
+
onClick={() => onVerify(b)}
|
|
305
|
+
title="Verify backup integrity"
|
|
306
|
+
>
|
|
307
|
+
<RefreshCw size={12} />
|
|
308
|
+
</Button>
|
|
309
|
+
<Button
|
|
310
|
+
variant="ghost"
|
|
311
|
+
size="sm"
|
|
312
|
+
onClick={() => onDelete(b)}
|
|
313
|
+
title="Delete this backup"
|
|
314
|
+
style={{ color: 'var(--error, #f85149)' }}
|
|
315
|
+
>
|
|
316
|
+
<Trash2 size={12} />
|
|
317
|
+
</Button>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
))}
|
|
321
|
+
{backups.length > 10 && (
|
|
322
|
+
<div style={{ fontSize: 11, color: 'var(--text-muted, #8b949e)', textAlign: 'center', padding: '4px 0' }}>
|
|
323
|
+
+ {backups.length - 10} older backup{backups.length - 10 === 1 ? '' : 's'}
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
</Card>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
@@ -128,8 +128,8 @@ export function SearchModal({ open, onClose, onSelect }: Props) {
|
|
|
128
128
|
return (
|
|
129
129
|
<div className="search-modal-backdrop" onClick={onClose}>
|
|
130
130
|
<div className="search-modal" role="dialog" aria-modal="true" aria-labelledby="search-modal-title" onClick={(e) => e.stopPropagation()}>
|
|
131
|
-
<div className="search-modal-head">
|
|
132
|
-
<Search size={14} />
|
|
131
|
+
<div className="search-modal-head" role="search">
|
|
132
|
+
<Search size={14} aria-hidden />
|
|
133
133
|
<span id="search-modal-title" className="sr-only">Search</span>
|
|
134
134
|
<input
|
|
135
135
|
ref={inputRef}
|
|
@@ -138,6 +138,7 @@ export function SearchModal({ open, onClose, onSelect }: Props) {
|
|
|
138
138
|
value={q}
|
|
139
139
|
onChange={(e) => setQ(e.target.value)}
|
|
140
140
|
onKeyDown={onKey}
|
|
141
|
+
aria-label="Search"
|
|
141
142
|
/>
|
|
142
143
|
<button
|
|
143
144
|
type="button"
|
|
@@ -148,11 +149,13 @@ export function SearchModal({ open, onClose, onSelect }: Props) {
|
|
|
148
149
|
<X size={14} />
|
|
149
150
|
</button>
|
|
150
151
|
</div>
|
|
151
|
-
<div className="search-modal-scopes">
|
|
152
|
+
<div className="search-modal-scopes" role="tablist" aria-label="Search scope">
|
|
152
153
|
{SCOPES.map((s) => (
|
|
153
154
|
<button
|
|
154
155
|
key={s.id}
|
|
155
156
|
type="button"
|
|
157
|
+
role="tab"
|
|
158
|
+
aria-selected={scope === s.id}
|
|
156
159
|
className={cn('search-scope', scope === s.id && 'search-scope-active')}
|
|
157
160
|
onClick={() => setScope(s.id)}
|
|
158
161
|
>
|