pi-mega-compact 0.11.2 → 0.11.3

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 (105) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +9 -1
  2. package/dist/extensions/dashboard-server/api-contracts/provider-cache.js +7 -0
  3. package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
  4. package/dist/extensions/dashboard-server/index-reader.js +22 -0
  5. package/dist/extensions/dashboard-server/routes-cache.js +80 -0
  6. package/dist/extensions/dashboard-server/routes-cache.test.js +248 -0
  7. package/dist/extensions/dashboard-server/routes-maintenance.js +7 -7
  8. package/dist/extensions/dashboard-server/routes-repo.js +24 -0
  9. package/dist/extensions/dashboard-server/routes.js +1 -0
  10. package/dist/extensions/dashboard-server/server.js +3 -1
  11. package/dist/extensions/mega-cache-replay.test.js +233 -24
  12. package/dist/extensions/mega-compact-s38.test.js +201 -6
  13. package/dist/extensions/mega-config.js +3 -0
  14. package/dist/extensions/mega-events/agent-handlers.js +23 -7
  15. package/dist/extensions/mega-events/context-handler.js +37 -19
  16. package/dist/extensions/mega-events/error-classifier.js +27 -16
  17. package/dist/extensions/mega-runtime/runtime-snapshot.js +14 -4
  18. package/dist/extensions/mega-runtime/snapshot.js +1 -1
  19. package/dist/extensions/mega-runtime/snapshot.test.js +127 -0
  20. package/dist/extensions/mega-runtime/widget.js +4 -4
  21. package/dist/extensions/mega-runtime/widget.test.js +103 -12
  22. package/dist/src/pricing.js +78 -0
  23. package/dist/src/store/sqlite/perf-samples.js +104 -0
  24. package/dist/src/store/sqlite/perf-samples.test.js +93 -1
  25. package/extensions/dashboard-client/dist/assets/{AchievementTiles-Cml6YC8w.js → AchievementTiles-vEo2MtE2.js} +2 -2
  26. package/extensions/dashboard-client/dist/assets/{AchievementTiles-Cml6YC8w.js.map → AchievementTiles-vEo2MtE2.js.map} +1 -1
  27. package/extensions/dashboard-client/dist/assets/AchievementsTab-DjxfYrDc.js +2 -0
  28. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B8NqDt7I.js.map → AchievementsTab-DjxfYrDc.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js +2 -0
  30. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js.map +1 -0
  31. package/extensions/dashboard-client/dist/assets/{ConfigTab-CBYvp2mV.js → ConfigTab-Cm6ymbmx.js} +2 -2
  32. package/extensions/dashboard-client/dist/assets/{ConfigTab-CBYvp2mV.js.map → ConfigTab-Cm6ymbmx.js.map} +1 -1
  33. package/extensions/dashboard-client/dist/assets/{EventsTab-DlTJqiDm.js → EventsTab-DXWl_YzI.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{EventsTab-DlTJqiDm.js.map → EventsTab-DXWl_YzI.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{GameTab-DkGTqKJW.js → GameTab-BLDudcO5.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{GameTab-DkGTqKJW.js.map → GameTab-BLDudcO5.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js +2 -0
  38. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js.map +1 -0
  39. package/extensions/dashboard-client/dist/assets/{MetricsTab-BFo7pKM-.js → MetricsTab-Cq4u-rZF.js} +2 -2
  40. package/extensions/dashboard-client/dist/assets/{MetricsTab-BFo7pKM-.js.map → MetricsTab-Cq4u-rZF.js.map} +1 -1
  41. package/extensions/dashboard-client/dist/assets/{OverviewTab-CClCUd3-.js → OverviewTab-D4kGvsiQ.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{OverviewTab-CClCUd3-.js.map → OverviewTab-D4kGvsiQ.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js +2 -0
  44. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js.map +1 -0
  45. package/extensions/dashboard-client/dist/assets/{SessionsTab-CCYd-kSz.js → SessionsTab-DZC2-GCn.js} +2 -2
  46. package/extensions/dashboard-client/dist/assets/{SessionsTab-CCYd-kSz.js.map → SessionsTab-DZC2-GCn.js.map} +1 -1
  47. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js +2 -0
  48. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js.map +1 -0
  49. package/extensions/dashboard-client/dist/assets/{TopicsTab-DRLJmVv0.js → TopicsTab-01G71hv1.js} +2 -2
  50. package/extensions/dashboard-client/dist/assets/{TopicsTab-DRLJmVv0.js.map → TopicsTab-01G71hv1.js.map} +1 -1
  51. package/extensions/dashboard-client/dist/assets/{TurnsTab-CLNM0T5R.js → TurnsTab-DLRg0LZT.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{TurnsTab-CLNM0T5R.js.map → TurnsTab-DLRg0LZT.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/index-BEdKlpAB.css +1 -0
  54. package/extensions/dashboard-client/dist/assets/{index-BcoSz-L4.js → index-BX0bcgLu.js} +4 -4
  55. package/extensions/dashboard-client/dist/assets/{index-BcoSz-L4.js.map → index-BX0bcgLu.js.map} +1 -1
  56. package/extensions/dashboard-client/dist/assets/{useSSE-Cezlih5V.js → useSSE-BBYt5UPB.js} +2 -2
  57. package/extensions/dashboard-client/dist/assets/{useSSE-Cezlih5V.js.map → useSSE-BBYt5UPB.js.map} +1 -1
  58. package/extensions/dashboard-client/dist/index.html +2 -2
  59. package/extensions/dashboard-client/src/api/client.ts +6 -0
  60. package/extensions/dashboard-client/src/components/ActiveReposTable.tsx +13 -1
  61. package/extensions/dashboard-client/src/components/ProviderCacheCard.tsx +141 -0
  62. package/extensions/dashboard-client/src/components/SavingsByModelTable.tsx +31 -3
  63. package/extensions/dashboard-client/src/main.tsx +1 -0
  64. package/extensions/dashboard-client/src/styles/maintenance.css +267 -0
  65. package/extensions/dashboard-client/src/styles/overview-extra.css +9 -0
  66. package/extensions/dashboard-client/src/tabs/CacheTab.tsx +83 -29
  67. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +301 -136
  68. package/extensions/dashboard-client/src/utils/format.ts +42 -7
  69. package/extensions/dashboard-server/api-contracts/endpoints.ts +31 -8
  70. package/extensions/dashboard-server/api-contracts/index.ts +4 -0
  71. package/extensions/dashboard-server/api-contracts/multi-repo.ts +6 -0
  72. package/extensions/dashboard-server/api-contracts/provider-cache.ts +30 -0
  73. package/extensions/dashboard-server/api-contracts.test.ts +3 -2
  74. package/extensions/dashboard-server/index-reader.ts +23 -0
  75. package/extensions/dashboard-server/routes-cache.test.ts +302 -0
  76. package/extensions/dashboard-server/routes-cache.ts +123 -0
  77. package/extensions/dashboard-server/routes-maintenance.ts +18 -11
  78. package/extensions/dashboard-server/routes-repo.ts +31 -4
  79. package/extensions/dashboard-server/routes.ts +1 -0
  80. package/extensions/dashboard-server/server.ts +2 -0
  81. package/extensions/mega-cache-replay.test.ts +481 -166
  82. package/extensions/mega-compact-s38.test.ts +210 -6
  83. package/extensions/mega-config.ts +6 -0
  84. package/extensions/mega-events/agent-handlers.ts +24 -6
  85. package/extensions/mega-events/context-handler.ts +59 -24
  86. package/extensions/mega-events/error-classifier.ts +30 -13
  87. package/extensions/mega-runtime/runtime-snapshot.ts +31 -9
  88. package/extensions/mega-runtime/snapshot.test.ts +139 -0
  89. package/extensions/mega-runtime/snapshot.ts +6 -4
  90. package/extensions/mega-runtime/widget.test.ts +290 -105
  91. package/extensions/mega-runtime/widget.ts +29 -6
  92. package/package.json +1 -1
  93. package/src/pricing.ts +95 -0
  94. package/src/store/sqlite/perf-samples.test.ts +107 -3
  95. package/src/store/sqlite/perf-samples.ts +142 -6
  96. package/extensions/dashboard-client/dist/assets/AchievementsTab-B8NqDt7I.js +0 -2
  97. package/extensions/dashboard-client/dist/assets/CacheTab-BCLzVL4S.js +0 -2
  98. package/extensions/dashboard-client/dist/assets/CacheTab-BCLzVL4S.js.map +0 -1
  99. package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js +0 -2
  100. package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js.map +0 -1
  101. package/extensions/dashboard-client/dist/assets/ReposTab-_3R4RSLZ.js +0 -2
  102. package/extensions/dashboard-client/dist/assets/ReposTab-_3R4RSLZ.js.map +0 -1
  103. package/extensions/dashboard-client/dist/assets/TimeSavedCard-0TqsrY6y.js +0 -2
  104. package/extensions/dashboard-client/dist/assets/TimeSavedCard-0TqsrY6y.js.map +0 -1
  105. package/extensions/dashboard-client/dist/assets/index-gn66lYD2.css +0 -1
@@ -1,8 +1,12 @@
1
1
  /**
2
- * dashboard-client/src/tabs/MaintenanceTab.tsx — Maintenance tab (S49B).
2
+ * dashboard-client/src/tabs/MaintenanceTab.tsx — Maintenance tab (v0.11.2 S49B).
3
3
  *
4
- * Schema health audit + DB stats + maintenance action triggers.
5
- * Polls schema-health every 10s and db-stats every 15s (stats are stable).
4
+ * Three cards:
5
+ * 1. Schema Health (schema version + integrity + column audit)
6
+ * 2. DB Stats (file sizes + table row counts)
7
+ * 3. Actions (one-time maintenance operations with results history)
8
+ *
9
+ * All three fetch independently; failures in one do not block the others.
6
10
  */
7
11
 
8
12
  import type React from "react";
@@ -12,82 +16,171 @@ import type {
12
16
  SchemaHealthResponse,
13
17
  MaintenanceAction,
14
18
  MaintenanceActionResult,
15
- TableStats,
16
- DbFiles,
17
- SchemaHealthRow,
18
19
  } from "@contracts";
19
20
  import { useApi } from "../hooks/useApi";
20
- import { fetchDbStats, fetchSchemaHealth, postMaintenanceAction } from "../api/client";
21
+ import {
22
+ fetchDbStats,
23
+ fetchSchemaHealth,
24
+ postMaintenanceAction,
25
+ } from "../api/client";
21
26
 
22
27
  // ---------------------------------------------------------------------------
23
- // DB Stats Card
28
+ // Action config
24
29
  // ---------------------------------------------------------------------------
25
30
 
26
- function DbStatsCard({ data, loading, error }: {
27
- data: DbStatsResponse | null;
28
- loading: boolean;
29
- error: Error | null;
30
- }) {
31
- return (
32
- <div className="card">
33
- <div className="card-header">DB Stats</div>
34
- <div className="card-body">
35
- {loading && !data && <span className="text-muted">Loading…</span>}
36
- {error && !data && <span className="text-error">{error.message}</span>}
37
- {data && (
38
- <>
39
- <FileSizes files={data.storage.files} pageSize={data.storage.pageSize} pageCount={data.storage.pageCount} freelistPages={data.storage.freelistPages} />
40
- <TableCounts tables={data.tables} />
41
- </>
42
- )}
43
- </div>
44
- </div>
45
- );
31
+ interface ActionDef {
32
+ key: string;
33
+ label: string;
34
+ desc: string;
35
+ dangerous: boolean;
36
+ confirm?: string;
37
+ daysOld?: boolean;
46
38
  }
47
39
 
40
+ const ACTIONS: ActionDef[] = [
41
+ {
42
+ key: "integrity-check",
43
+ label: "Integrity Check",
44
+ desc: "Run PRAGMA integrity_check",
45
+ dangerous: false,
46
+ },
47
+ {
48
+ key: "reconcile-dedup",
49
+ label: "Reconcile Dedup Mirror",
50
+ desc: "Fix orphan dedup rows + backfill refs",
51
+ dangerous: false,
52
+ confirm: "Reconcile dedup_mirror (safe read/write)?",
53
+ },
54
+ {
55
+ key: "checkpoint",
56
+ label: "WAL Checkpoint",
57
+ desc: "Merge WAL into main DB (read-only)",
58
+ dangerous: false,
59
+ },
60
+ {
61
+ key: "vacuum",
62
+ label: "VACUUM",
63
+ desc: "Rebuild DB reclaiming free pages",
64
+ dangerous: true,
65
+ confirm: "VACUUM rewrites the entire DB. Continue?",
66
+ },
67
+ {
68
+ key: "reindex",
69
+ label: "REINDEX",
70
+ desc: "Rebuild all indexes",
71
+ dangerous: true,
72
+ confirm: "REINDEX rebuilds indexes. Continue?",
73
+ },
74
+ {
75
+ key: "fts5-rebuild",
76
+ label: "FTS5 Rebuild",
77
+ desc: "Rebuild full-text search index",
78
+ dangerous: true,
79
+ confirm: "Rebuild FTS5 trigram index. Continue?",
80
+ },
81
+ {
82
+ key: "prune",
83
+ label: "Prune Old Rows",
84
+ desc: "Delete transcripts + epochs older than N days",
85
+ dangerous: true,
86
+ daysOld: true,
87
+ confirm: "Delete old raw_transcript rows. Continue?",
88
+ },
89
+ ];
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Format helpers (local — matches html.ts fmtBytes)
93
+ // ---------------------------------------------------------------------------
94
+
48
95
  function fmtBytes(b: number): string {
49
96
  if (b < 1024) return `${b} B`;
50
97
  if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
51
98
  return `${(b / (1024 * 1024)).toFixed(1)} MB`;
52
99
  }
53
100
 
54
- function FileSizes({ files, pageSize, pageCount, freelistPages }: {
55
- files: DbFiles;
101
+ // ---------------------------------------------------------------------------
102
+ // DB Stats card
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function FileSizesCard({
106
+ files,
107
+ pageSize,
108
+ pageCount,
109
+ freelistPages,
110
+ }: {
111
+ files: DbStatsResponse["storage"]["files"];
56
112
  pageSize: number;
57
113
  pageCount: number;
58
114
  freelistPages: number;
59
- }) {
115
+ }): React.ReactElement {
116
+ const total = files.dbBytes + files.walBytes + files.shmBytes;
60
117
  return (
61
118
  <div className="stat-section">
62
119
  <div className="stat-label">File Sizes</div>
63
120
  <table className="compact-table">
64
121
  <tbody>
65
- <tr><td>sqlite.db</td><td className="num">{fmtBytes(files.dbBytes)}</td></tr>
66
- {files.walBytes > 0 && <tr><td>.wal</td><td className="num">{fmtBytes(files.walBytes)}</td></tr>}
67
- {files.shmBytes > 0 && <tr><td>.shm</td><td className="num">{fmtBytes(files.shmBytes)}</td></tr>}
68
- <tr><td>Total</td><td className="num">{fmtBytes(files.dbBytes + files.walBytes + files.shmBytes)}</td></tr>
69
- <tr><td>Page size</td><td className="num">{fmtBytes(pageSize)}</td></tr>
70
- <tr><td>Pages</td><td className="num">{pageCount.toLocaleString()} ({freelistPages.toLocaleString()} free)</td></tr>
122
+ <tr>
123
+ <td>sqlite.db</td>
124
+ <td className="num">{fmtBytes(files.dbBytes)}</td>
125
+ </tr>
126
+ {files.walBytes > 0 && (
127
+ <tr>
128
+ <td>.wal</td>
129
+ <td className="num">{fmtBytes(files.walBytes)}</td>
130
+ </tr>
131
+ )}
132
+ {files.shmBytes > 0 && (
133
+ <tr>
134
+ <td>.shm</td>
135
+ <td className="num">{fmtBytes(files.shmBytes)}</td>
136
+ </tr>
137
+ )}
138
+ <tr>
139
+ <td>Total</td>
140
+ <td className="num">{fmtBytes(total)}</td>
141
+ </tr>
142
+ <tr>
143
+ <td>Page size</td>
144
+ <td className="num">{fmtBytes(pageSize)}</td>
145
+ </tr>
146
+ <tr>
147
+ <td>Pages</td>
148
+ <td className="num">
149
+ {pageCount.toLocaleString()} ({freelistPages.toLocaleString()}{" "}
150
+ free)
151
+ </td>
152
+ </tr>
71
153
  </tbody>
72
154
  </table>
73
155
  </div>
74
156
  );
75
157
  }
76
158
 
77
- function TableCounts({ tables }: { tables: TableStats[] }) {
78
- const total = tables.reduce((sum, t) => sum + Math.max(0, t.rowCount), 0);
159
+ function TableRowCountsCard({
160
+ tables,
161
+ }: {
162
+ tables: DbStatsResponse["tables"];
163
+ }): React.ReactElement {
164
+ const totalRows = tables.reduce((s, t) => s + Math.max(0, t.rowCount), 0);
79
165
  return (
80
166
  <div className="stat-section">
81
- <div className="stat-label">Table Row Counts ({total.toLocaleString()} total)</div>
167
+ <div className="stat-label">
168
+ Table Row Counts ({totalRows.toLocaleString()} total)
169
+ </div>
82
170
  <table className="compact-table">
83
171
  <thead>
84
- <tr><th>Table</th><th className="num">Rows</th></tr>
172
+ <tr>
173
+ <th>Table</th>
174
+ <th className="num">Rows</th>
175
+ </tr>
85
176
  </thead>
86
177
  <tbody>
87
178
  {tables.map((t) => (
88
179
  <tr key={t.table}>
89
180
  <td>{t.table}</td>
90
- <td className="num">{t.rowCount >= 0 ? t.rowCount.toLocaleString() : "—"}</td>
181
+ <td className="num">
182
+ {t.rowCount >= 0 ? t.rowCount.toLocaleString() : "—"}
183
+ </td>
91
184
  </tr>
92
185
  ))}
93
186
  </tbody>
@@ -96,39 +189,30 @@ function TableCounts({ tables }: { tables: TableStats[] }) {
96
189
  );
97
190
  }
98
191
 
99
- // ---------------------------------------------------------------------------
100
- // Schema Health Card
101
- // ---------------------------------------------------------------------------
102
-
103
- function SchemaHealthCard({ data, loading, error }: {
104
- data: SchemaHealthResponse | null;
192
+ function DbStatsCard({
193
+ data,
194
+ loading,
195
+ error,
196
+ }: {
197
+ data: DbStatsResponse | null;
105
198
  loading: boolean;
106
199
  error: Error | null;
107
- }) {
200
+ }): React.ReactElement {
108
201
  return (
109
202
  <div className="card">
110
- <div className="card-header">
111
- Schema Health
112
- {data && (
113
- <span className={`badge ${data.healthy ? "badge-ok" : "badge-bad"}`}>
114
- {data.healthy ? "HEALTHY" : "DEGRADED"}
115
- </span>
116
- )}
117
- </div>
203
+ <div className="card-header">DB Stats</div>
118
204
  <div className="card-body">
119
205
  {loading && !data && <span className="text-muted">Loading…</span>}
120
206
  {error && !data && <span className="text-error">{error.message}</span>}
121
207
  {data && (
122
208
  <>
123
- <div className="stat-row"><span className="stat-label">Schema version</span><span className="num">{data.schemaVersion}</span></div>
124
- <div className="stat-row"><span className="stat-label">Integrity</span><span className={data.integrity[0] === "ok" ? "text-ok" : "text-error"}>{data.integrity.join("; ")}</span></div>
125
- <ColumnAudit columns={data.columns} />
126
- {data.fkCheck.length > 0 && (
127
- <div className="stat-section">
128
- <div className="stat-label text-warn">FK Check ({data.fkCheck.length})</div>
129
- {data.fkCheck.map((fk, i) => <div key={i} className="text-warn text-mono">{fk}</div>)}
130
- </div>
131
- )}
209
+ <FileSizesCard
210
+ files={data.storage.files}
211
+ pageSize={data.storage.pageSize}
212
+ pageCount={data.storage.pageCount}
213
+ freelistPages={data.storage.freelistPages}
214
+ />
215
+ <TableRowCountsCard tables={data.tables} />
132
216
  </>
133
217
  )}
134
218
  </div>
@@ -136,14 +220,29 @@ function SchemaHealthCard({ data, loading, error }: {
136
220
  );
137
221
  }
138
222
 
139
- function ColumnAudit({ columns }: { columns: SchemaHealthRow[] }) {
223
+ // ---------------------------------------------------------------------------
224
+ // Schema Health card
225
+ // ---------------------------------------------------------------------------
226
+
227
+ function ColumnAuditCard({
228
+ columns,
229
+ }: {
230
+ columns: SchemaHealthResponse["columns"];
231
+ }): React.ReactElement {
140
232
  const missing = columns.filter((c) => !c.present);
141
233
  return (
142
234
  <div className="stat-section">
143
- <div className="stat-label">Column Audit ({columns.length} columns, {missing.length} missing)</div>
235
+ <div className="stat-label">
236
+ Column Audit ({columns.length} columns, {missing.length} missing)
237
+ </div>
144
238
  <table className="compact-table">
145
239
  <thead>
146
- <tr><th>Table</th><th>Column</th><th>Decl</th><th>Status</th></tr>
240
+ <tr>
241
+ <th>Table</th>
242
+ <th>Column</th>
243
+ <th>Decl</th>
244
+ <th>Status</th>
245
+ </tr>
147
246
  </thead>
148
247
  <tbody>
149
248
  {columns.map((c) => (
@@ -151,7 +250,9 @@ function ColumnAudit({ columns }: { columns: SchemaHealthRow[] }) {
151
250
  <td>{c.table}</td>
152
251
  <td>{c.column}</td>
153
252
  <td className="text-mono">{c.expectedDecl}</td>
154
- <td className={c.present ? "text-ok" : "text-error"}>{c.present ? "✓" : "MISSING"}</td>
253
+ <td className={c.present ? "text-ok" : "text-error"}>
254
+ {c.present ? "✓" : "MISSING"}
255
+ </td>
155
256
  </tr>
156
257
  ))}
157
258
  </tbody>
@@ -160,87 +261,138 @@ function ColumnAudit({ columns }: { columns: SchemaHealthRow[] }) {
160
261
  );
161
262
  }
162
263
 
264
+ function SchemaHealthCard({
265
+ data,
266
+ loading,
267
+ error,
268
+ }: {
269
+ data: SchemaHealthResponse | null;
270
+ loading: boolean;
271
+ error: Error | null;
272
+ }): React.ReactElement {
273
+ return (
274
+ <div className="card">
275
+ <div className="card-header">
276
+ Schema Health
277
+ {data && (
278
+ <span className={`badge ${data.healthy ? "badge-ok" : "badge-bad"}`}>
279
+ {data.healthy ? "HEALTHY" : "DEGRADED"}
280
+ </span>
281
+ )}
282
+ </div>
283
+ <div className="card-body">
284
+ {loading && !data && <span className="text-muted">Loading…</span>}
285
+ {error && !data && <span className="text-error">{error.message}</span>}
286
+ {data && (
287
+ <>
288
+ <div className="stat-row">
289
+ <span className="stat-label">Schema version</span>
290
+ <span className="num">{data.schemaVersion}</span>
291
+ </div>
292
+ <div className="stat-row">
293
+ <span className="stat-label">Integrity</span>
294
+ <span
295
+ className={
296
+ data.integrity[0] === "ok" ? "text-ok" : "text-error"
297
+ }
298
+ >
299
+ {data.integrity.join("; ")}
300
+ </span>
301
+ </div>
302
+ <ColumnAuditCard columns={data.columns} />
303
+ {data.fkCheck.length > 0 && (
304
+ <div className="stat-section">
305
+ <div className="stat-label text-warn">
306
+ FK Check ({data.fkCheck.length})
307
+ </div>
308
+ {data.fkCheck.map((line, i) => (
309
+ <div key={i} className="text-warn text-mono">
310
+ {line}
311
+ </div>
312
+ ))}
313
+ </div>
314
+ )}
315
+ </>
316
+ )}
317
+ </div>
318
+ </div>
319
+ );
320
+ }
321
+
163
322
  // ---------------------------------------------------------------------------
164
- // Action Card
323
+ // Actions card
165
324
  // ---------------------------------------------------------------------------
166
325
 
167
- const ACTIONS: Array<{
168
- key: string;
169
- label: string;
170
- desc: string;
171
- dangerous: boolean;
172
- confirm?: string;
173
- daysOld?: boolean;
174
- }> = [
175
- { key: "integrity-check", label: "Integrity Check", desc: "Run PRAGMA integrity_check", dangerous: false },
176
- { key: "reconcile-dedup", label: "Reconcile Dedup Mirror", desc: "Fix orphan dedup rows + backfill refs", dangerous: false, confirm: "Reconcile dedup_mirror (safe read/write)?" },
177
- { key: "checkpoint", label: "WAL Checkpoint", desc: "Merge WAL into main DB (read-only)", dangerous: false },
178
- { key: "vacuum", label: "VACUUM", desc: "Rebuild DB reclaiming free pages", dangerous: true, confirm: "VACUUM rewrites the entire DB. Continue?" },
179
- { key: "reindex", label: "REINDEX", desc: "Rebuild all indexes", dangerous: true, confirm: "REINDEX rebuilds indexes. Continue?" },
180
- { key: "fts5-rebuild", label: "FTS5 Rebuild", desc: "Rebuild full-text search index", dangerous: true, confirm: "Rebuild FTS5 trigram index. Continue?" },
181
- { key: "prune", label: "Prune Old Rows", desc: "Delete transcripts + epochs older than N days", dangerous: true, daysOld: true, confirm: "Delete old raw_transcript rows. Continue?" },
182
- ];
183
-
184
- interface ActionResult extends MaintenanceActionResult {
185
- timestamp: number;
186
- }
187
-
188
- function ActionsPanel() {
189
- const [results, setResults] = useState<ActionResult[]>([]);
326
+ function ActionsCard(): React.ReactElement {
327
+ const [results, setResults] = useState<MaintenanceActionResult[]>([]);
190
328
  const [running, setRunning] = useState<string | null>(null);
191
329
  const [days, setDays] = useState("30");
192
330
 
193
- const run = useCallback(async (def: typeof ACTIONS[number], daysOverride?: string) => {
194
- const payload: MaintenanceAction = (def.daysOld
195
- ? { action: "prune", daysOld: Number(daysOverride ?? days) }
196
- : { action: def.key }) as MaintenanceAction;
331
+ const handleAction = useCallback(
332
+ async (def: ActionDef) => {
333
+ const action: MaintenanceAction = def.daysOld
334
+ ? { action: "prune", daysOld: Number(days) }
335
+ : ({ action: def.key } as MaintenanceAction);
197
336
 
198
- setRunning(def.key);
199
- try {
200
- const res = await postMaintenanceAction(payload);
201
- setResults((prev) => [{ ...res, timestamp: Date.now() }, ...prev].slice(0, 20));
202
- } catch (err) {
203
- setResults((prev) => [{
204
- operation: def.key,
205
- success: false,
206
- affected: 0,
207
- reclaimedBytes: 0,
208
- summary: (err as Error).message,
209
- timestamp: Date.now(),
210
- }, ...prev].slice(0, 20));
211
- } finally {
212
- setRunning(null);
213
- }
214
- }, [days]);
337
+ setRunning(def.key);
338
+ try {
339
+ const res = await postMaintenanceAction(action);
340
+ setResults((prev) =>
341
+ [{ ...res, timestamp: Date.now() }, ...prev].slice(0, 20),
342
+ );
343
+ } catch (err) {
344
+ setResults((prev) =>
345
+ [
346
+ {
347
+ operation: def.key,
348
+ success: false,
349
+ affected: 0,
350
+ reclaimedBytes: 0,
351
+ summary: err instanceof Error ? err.message : String(err),
352
+ timestamp: Date.now(),
353
+ },
354
+ ...prev,
355
+ ].slice(0, 20),
356
+ );
357
+ } finally {
358
+ setRunning(null);
359
+ }
360
+ },
361
+ [days],
362
+ );
215
363
 
216
364
  return (
217
365
  <div className="card">
218
366
  <div className="card-header">Actions</div>
219
367
  <div className="card-body">
220
368
  <div className="action-grid">
221
- {ACTIONS.map((a) => {
222
- const isRunning = running === a.key;
223
- const lastResult = results.find((r) => r.operation === a.key);
369
+ {ACTIONS.map((def) => {
370
+ const isRunning = running === def.key;
371
+ const lastResult = results.find((r) => r.operation === def.key);
224
372
  return (
225
- <div key={a.key} className="action-item">
373
+ <div key={def.key} className="action-item">
226
374
  <div className="action-info">
227
375
  <div className="action-label">
228
- {a.label}
229
- {a.dangerous && <span className="badge badge-warn">DESTRUCTIVE</span>}
376
+ {def.label}
377
+ {def.dangerous && (
378
+ <span className="badge badge-warn">DESTRUCTIVE</span>
379
+ )}
230
380
  </div>
231
- <div className="action-desc">{a.desc}</div>
381
+ <div className="action-desc">{def.desc}</div>
232
382
  {lastResult && (
233
- <div className={`action-result ${lastResult.success ? "text-ok" : "text-error"}`}>
383
+ <div
384
+ className={`action-result ${lastResult.success ? "text-ok" : "text-error"}`}
385
+ >
234
386
  {lastResult.summary}
235
387
  </div>
236
388
  )}
237
389
  </div>
238
390
  <div className="action-controls">
239
- {a.daysOld && (
391
+ {def.daysOld && (
240
392
  <input
241
393
  type="number"
242
- min="1"
243
- max="365"
394
+ min={1}
395
+ max={365}
244
396
  value={days}
245
397
  onChange={(e) => setDays(e.target.value)}
246
398
  className="days-input"
@@ -248,11 +400,11 @@ function ActionsPanel() {
248
400
  />
249
401
  )}
250
402
  <button
251
- className={`btn ${a.dangerous ? "btn-danger" : "btn-primary"}`}
252
- disabled={isRunning !== null}
403
+ className={`btn ${def.dangerous ? "btn-danger" : "btn-primary"}`}
404
+ disabled={running !== null}
253
405
  onClick={() => {
254
- if (a.confirm && !window.confirm(a.confirm)) return;
255
- run(a);
406
+ if (def.confirm && !window.confirm(def.confirm)) return;
407
+ handleAction(def);
256
408
  }}
257
409
  >
258
410
  {isRunning ? "Running…" : "Run"}
@@ -268,15 +420,24 @@ function ActionsPanel() {
268
420
  }
269
421
 
270
422
  // ---------------------------------------------------------------------------
271
- // Tab root
423
+ // Main tab component
272
424
  // ---------------------------------------------------------------------------
273
425
 
274
426
  export default function MaintenanceTab(): React.ReactElement {
275
- const { data: stats, loading: statsLoading, error: statsError } = useApi<DbStatsResponse>(
427
+ const {
428
+ data: dbStats,
429
+ loading: statsLoading,
430
+ error: statsError,
431
+ } = useApi<DbStatsResponse>(
276
432
  useCallback(() => fetchDbStats(), []),
277
433
  { pollInterval: 15000 },
278
434
  );
279
- const { data: schema, loading: schemaLoading, error: schemaError } = useApi<SchemaHealthResponse>(
435
+
436
+ const {
437
+ data: schemaHealth,
438
+ loading: healthLoading,
439
+ error: healthError,
440
+ } = useApi<SchemaHealthResponse>(
280
441
  useCallback(() => fetchSchemaHealth(), []),
281
442
  { pollInterval: 10000 },
282
443
  );
@@ -284,10 +445,14 @@ export default function MaintenanceTab(): React.ReactElement {
284
445
  return (
285
446
  <div className="maintenance-tab">
286
447
  <div className="card-grid">
287
- <SchemaHealthCard data={schema} loading={schemaLoading} error={schemaError} />
288
- <DbStatsCard data={stats} loading={statsLoading} error={statsError} />
448
+ <SchemaHealthCard
449
+ data={schemaHealth}
450
+ loading={healthLoading}
451
+ error={healthError}
452
+ />
453
+ <DbStatsCard data={dbStats} loading={statsLoading} error={statsError} />
289
454
  </div>
290
- <ActionsPanel />
455
+ <ActionsCard />
291
456
  </div>
292
457
  );
293
458
  }
@@ -26,9 +26,7 @@ export function fmtSec(s: number | null | undefined): string {
26
26
  * Display a 0–1 fraction as a percentage string.
27
27
  * Uses one decimal place below 10%, rounded above (matches html.ts pct logic).
28
28
  */
29
- export function fmtPctFromFraction(
30
- v: number | null | undefined,
31
- ): string {
29
+ export function fmtPctFromFraction(v: number | null | undefined): string {
32
30
  const n = v ?? 0;
33
31
  const pct = n * 100;
34
32
  if (pct >= 10) return `${Math.round(pct)}%`;
@@ -48,10 +46,7 @@ export function fmtMs(v: number | null | undefined): string {
48
46
  }
49
47
 
50
48
  /** Format a number with fixed decimals or em-dash for null (matches fmtNum). */
51
- export function fmtNum(
52
- v: number | null | undefined,
53
- dec: number,
54
- ): string {
49
+ export function fmtNum(v: number | null | undefined, dec: number): string {
55
50
  if (v == null || typeof v !== "number") return "\u2014";
56
51
  return v.toFixed(dec);
57
52
  }
@@ -83,3 +78,43 @@ export function fmtDate(ts: string | number | null | undefined): string {
83
78
  return "\u2014";
84
79
  }
85
80
  }
81
+
82
+ /** Format tokens as human-readable (1.2M / 340K / 890 / —). */
83
+ export function fmtTokens(v: number | null | undefined): string {
84
+ if (v == null) return "\u2014";
85
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
86
+ if (v >= 1_000) return `${Math.round(v / 1000)}K`;
87
+ return v.toLocaleString();
88
+ }
89
+
90
+ /** Format USD savings (always 4 decimals, null → em-dash). */
91
+ export function fmtDollars(v: number | null | undefined): string {
92
+ if (v == null) return "\u2014";
93
+ return `$${v.toFixed(4)}`;
94
+ }
95
+
96
+ /**
97
+ * Format an ISO timestamp as relative time ("2m ago", "3h ago", "just now").
98
+ * Falls back to locale date for times >24h or unparseable input.
99
+ */
100
+ export function fmtRelativeTime(
101
+ ts: string | number | null | undefined,
102
+ ): string {
103
+ if (!ts) return "\u2014";
104
+ let date: Date;
105
+ try {
106
+ date = new Date(ts);
107
+ if (isNaN(date.getTime())) return "\u2014";
108
+ } catch {
109
+ return "\u2014";
110
+ }
111
+ const diffMs = Date.now() - date.getTime();
112
+ if (diffMs < 0) return "just now";
113
+ const sec = Math.floor(diffMs / 1000);
114
+ if (sec < 60) return "just now";
115
+ const min = Math.floor(sec / 60);
116
+ if (min < 60) return `${min}m ago`;
117
+ const hrs = Math.floor(min / 60);
118
+ if (hrs < 24) return `${hrs}h ago`;
119
+ return date.toLocaleDateString();
120
+ }