@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.
Files changed (123) hide show
  1. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
  2. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
  4. package/bizar-dash/dist/assets/{mobile-lbH6szyX.js → mobile-BK8-ythT.js} +18 -18
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js.map → mobile-Chvf9u_B.js.map} +1 -1
  8. package/bizar-dash/dist/index.html +3 -3
  9. package/bizar-dash/dist/mobile.html +2 -2
  10. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  11. package/bizar-dash/skills/publishing/SKILL.md +146 -0
  12. package/bizar-dash/src/server/api.mjs +8 -0
  13. package/bizar-dash/src/server/backup-store.mjs +525 -0
  14. package/bizar-dash/src/server/digest-store.mjs +558 -0
  15. package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
  16. package/bizar-dash/src/server/logger.mjs +71 -0
  17. package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
  18. package/bizar-dash/src/server/metrics.mjs +193 -0
  19. package/bizar-dash/src/server/routes/backup.mjs +112 -0
  20. package/bizar-dash/src/server/routes/chat.mjs +20 -3
  21. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  22. package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
  23. package/bizar-dash/src/server/routes/memory.mjs +5 -4
  24. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  25. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  26. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  27. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  28. package/bizar-dash/src/server/server.mjs +79 -0
  29. package/bizar-dash/src/web/App.tsx +8 -1
  30. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  31. package/bizar-dash/src/web/components/SearchModal.tsx +6 -3
  32. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  33. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  34. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  35. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  36. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  37. package/bizar-dash/src/web/locales/en.json +52 -0
  38. package/bizar-dash/src/web/main.tsx +5 -0
  39. package/bizar-dash/src/web/styles/main.css +70 -8
  40. package/bizar-dash/src/web/views/Activity.tsx +35 -18
  41. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  42. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  43. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  44. package/bizar-dash/src/web/views/History.tsx +94 -76
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  46. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  47. package/bizar-dash/src/web/views/Overview.tsx +19 -9
  48. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  49. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  50. package/bizar-dash/src/web/views/Settings.tsx +97 -1745
  51. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  52. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  53. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  54. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  55. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  56. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  57. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  58. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  59. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  60. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  61. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  62. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  63. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  64. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  65. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  66. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  67. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  68. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  69. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  70. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  71. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  72. package/bizar-dash/tests/a11y.test.tsx +206 -0
  73. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  74. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  75. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  76. package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
  77. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  78. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  79. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  80. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  81. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  82. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  83. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  84. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  85. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  86. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  87. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  88. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  89. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  90. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  91. package/bizar-dash/tests/logger.test.mjs +207 -0
  92. package/bizar-dash/tests/metrics.test.mjs +183 -0
  93. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  94. package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
  95. package/bizar-dash/tests/setup.ts +7 -0
  96. package/bizar-dash/vitest.config.ts +13 -0
  97. package/cli/artifact-cli.mjs +605 -0
  98. package/cli/artifact-render.mjs +621 -0
  99. package/cli/artifact-server.mjs +847 -0
  100. package/cli/artifact.mjs +38 -2096
  101. package/cli/bg.mjs +5 -13
  102. package/cli/bin.mjs +221 -1350
  103. package/cli/commands/artifact.mjs +20 -0
  104. package/cli/commands/dash.mjs +160 -0
  105. package/cli/commands/headroom.mjs +204 -0
  106. package/cli/commands/install.mjs +169 -0
  107. package/cli/commands/memory.mjs +25 -0
  108. package/cli/commands/minimax.mjs +285 -0
  109. package/cli/commands/mod.mjs +185 -0
  110. package/cli/commands/service.mjs +65 -0
  111. package/cli/commands/usage.mjs +109 -0
  112. package/cli/commands/util.mjs +459 -0
  113. package/cli/digest.mjs +149 -0
  114. package/cli/doctor.mjs +1 -13
  115. package/cli/provision.mjs +2 -13
  116. package/cli/service-controller.mjs +1 -11
  117. package/cli/service.mjs +1 -11
  118. package/cli/utils.mjs +41 -1
  119. package/package.json +6 -1
  120. package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
  121. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
  122. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  123. 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
- console.error('[dashboard] restart spawn failed:', err.message);
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 &amp; 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
  >