pi-mega-compact 0.11.1 → 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 (109) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +28 -1
  2. package/dist/extensions/dashboard-server/api-contracts/maintenance.js +7 -0
  3. package/dist/extensions/dashboard-server/api-contracts/provider-cache.js +7 -0
  4. package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
  5. package/dist/extensions/dashboard-server/index-reader.js +22 -0
  6. package/dist/extensions/dashboard-server/routes-cache.js +80 -0
  7. package/dist/extensions/dashboard-server/routes-cache.test.js +248 -0
  8. package/dist/extensions/dashboard-server/routes-maintenance.js +293 -0
  9. package/dist/extensions/dashboard-server/routes-repo.js +24 -0
  10. package/dist/extensions/dashboard-server/routes.js +2 -0
  11. package/dist/extensions/dashboard-server/server.js +5 -1
  12. package/dist/extensions/mega-cache-replay.test.js +233 -24
  13. package/dist/extensions/mega-compact-s38.test.js +201 -6
  14. package/dist/extensions/mega-config.js +3 -0
  15. package/dist/extensions/mega-events/agent-handlers.js +23 -7
  16. package/dist/extensions/mega-events/context-handler.js +37 -19
  17. package/dist/extensions/mega-events/error-classifier.js +27 -16
  18. package/dist/extensions/mega-runtime/runtime-snapshot.js +14 -4
  19. package/dist/extensions/mega-runtime/snapshot.js +1 -1
  20. package/dist/extensions/mega-runtime/snapshot.test.js +127 -0
  21. package/dist/extensions/mega-runtime/widget.js +4 -4
  22. package/dist/extensions/mega-runtime/widget.test.js +103 -12
  23. package/dist/src/pricing.js +78 -0
  24. package/dist/src/store/sqlite/maintenance.js +20 -1
  25. package/dist/src/store/sqlite/perf-samples.js +104 -0
  26. package/dist/src/store/sqlite/perf-samples.test.js +93 -1
  27. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DZp_xQOW.js → AchievementTiles-vEo2MtE2.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DZp_xQOW.js.map → AchievementTiles-vEo2MtE2.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/AchievementsTab-DjxfYrDc.js +2 -0
  30. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BzFHpAHM.js.map → AchievementsTab-DjxfYrDc.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js +2 -0
  32. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js.map +1 -0
  33. package/extensions/dashboard-client/dist/assets/{ConfigTab-DQ4FG33J.js → ConfigTab-Cm6ymbmx.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{ConfigTab-DQ4FG33J.js.map → ConfigTab-Cm6ymbmx.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{EventsTab-D5HoEVYo.js → EventsTab-DXWl_YzI.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{EventsTab-D5HoEVYo.js.map → EventsTab-DXWl_YzI.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{GameTab-Jzsc4yeY.js → GameTab-BLDudcO5.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{GameTab-Jzsc4yeY.js.map → GameTab-BLDudcO5.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js +2 -0
  40. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js.map +1 -0
  41. package/extensions/dashboard-client/dist/assets/{MetricsTab-D_XnsEYX.js → MetricsTab-Cq4u-rZF.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{MetricsTab-D_XnsEYX.js.map → MetricsTab-Cq4u-rZF.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/{OverviewTab-BMREvAth.js → OverviewTab-D4kGvsiQ.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{OverviewTab-BMREvAth.js.map → OverviewTab-D4kGvsiQ.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js +2 -0
  46. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js.map +1 -0
  47. package/extensions/dashboard-client/dist/assets/{SessionsTab-mz_vz8i2.js → SessionsTab-DZC2-GCn.js} +2 -2
  48. package/extensions/dashboard-client/dist/assets/{SessionsTab-mz_vz8i2.js.map → SessionsTab-DZC2-GCn.js.map} +1 -1
  49. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js +2 -0
  50. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js.map +1 -0
  51. package/extensions/dashboard-client/dist/assets/{TopicsTab-DKuct4oD.js → TopicsTab-01G71hv1.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{TopicsTab-DKuct4oD.js.map → TopicsTab-01G71hv1.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{TurnsTab-11MGLxY8.js → TurnsTab-DLRg0LZT.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{TurnsTab-11MGLxY8.js.map → TurnsTab-DLRg0LZT.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/index-BEdKlpAB.css +1 -0
  56. package/extensions/dashboard-client/dist/assets/{index-CqLN7STl.js → index-BX0bcgLu.js} +10 -10
  57. package/extensions/dashboard-client/dist/assets/{index-CqLN7STl.js.map → index-BX0bcgLu.js.map} +1 -1
  58. package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js → useSSE-BBYt5UPB.js} +2 -2
  59. package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js.map → useSSE-BBYt5UPB.js.map} +1 -1
  60. package/extensions/dashboard-client/dist/index.html +2 -2
  61. package/extensions/dashboard-client/src/App.tsx +5 -1
  62. package/extensions/dashboard-client/src/api/client.ts +29 -0
  63. package/extensions/dashboard-client/src/components/ActiveReposTable.tsx +13 -1
  64. package/extensions/dashboard-client/src/components/ProviderCacheCard.tsx +141 -0
  65. package/extensions/dashboard-client/src/components/SavingsByModelTable.tsx +31 -3
  66. package/extensions/dashboard-client/src/main.tsx +1 -0
  67. package/extensions/dashboard-client/src/styles/maintenance.css +267 -0
  68. package/extensions/dashboard-client/src/styles/overview-extra.css +9 -0
  69. package/extensions/dashboard-client/src/tabs/CacheTab.tsx +83 -29
  70. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +458 -0
  71. package/extensions/dashboard-client/src/utils/format.ts +42 -7
  72. package/extensions/dashboard-server/api-contracts/endpoints.ts +54 -1
  73. package/extensions/dashboard-server/api-contracts/index.ts +16 -0
  74. package/extensions/dashboard-server/api-contracts/maintenance.ts +84 -0
  75. package/extensions/dashboard-server/api-contracts/multi-repo.ts +6 -0
  76. package/extensions/dashboard-server/api-contracts/provider-cache.ts +30 -0
  77. package/extensions/dashboard-server/api-contracts.test.ts +3 -2
  78. package/extensions/dashboard-server/api-contracts.ts +9 -0
  79. package/extensions/dashboard-server/index-reader.ts +23 -0
  80. package/extensions/dashboard-server/routes-cache.test.ts +302 -0
  81. package/extensions/dashboard-server/routes-cache.ts +123 -0
  82. package/extensions/dashboard-server/routes-maintenance.ts +352 -0
  83. package/extensions/dashboard-server/routes-repo.ts +31 -4
  84. package/extensions/dashboard-server/routes.ts +2 -0
  85. package/extensions/dashboard-server/server.ts +4 -0
  86. package/extensions/mega-cache-replay.test.ts +481 -166
  87. package/extensions/mega-compact-s38.test.ts +210 -6
  88. package/extensions/mega-config.ts +6 -0
  89. package/extensions/mega-events/agent-handlers.ts +24 -6
  90. package/extensions/mega-events/context-handler.ts +59 -24
  91. package/extensions/mega-events/error-classifier.ts +30 -13
  92. package/extensions/mega-runtime/runtime-snapshot.ts +31 -9
  93. package/extensions/mega-runtime/snapshot.test.ts +139 -0
  94. package/extensions/mega-runtime/snapshot.ts +6 -4
  95. package/extensions/mega-runtime/widget.test.ts +290 -105
  96. package/extensions/mega-runtime/widget.ts +29 -6
  97. package/package.json +1 -1
  98. package/src/pricing.ts +95 -0
  99. package/src/store/sqlite/maintenance.ts +20 -1
  100. package/src/store/sqlite/perf-samples.test.ts +107 -3
  101. package/src/store/sqlite/perf-samples.ts +142 -6
  102. package/extensions/dashboard-client/dist/assets/AchievementsTab-BzFHpAHM.js +0 -2
  103. package/extensions/dashboard-client/dist/assets/CacheTab-DEQbTq2T.js +0 -2
  104. package/extensions/dashboard-client/dist/assets/CacheTab-DEQbTq2T.js.map +0 -1
  105. package/extensions/dashboard-client/dist/assets/ReposTab-DuyhOktG.js +0 -2
  106. package/extensions/dashboard-client/dist/assets/ReposTab-DuyhOktG.js.map +0 -1
  107. package/extensions/dashboard-client/dist/assets/TimeSavedCard-B8AIjNTY.js +0 -2
  108. package/extensions/dashboard-client/dist/assets/TimeSavedCard-B8AIjNTY.js.map +0 -1
  109. package/extensions/dashboard-client/dist/assets/index-gn66lYD2.css +0 -1
@@ -1,50 +1,104 @@
1
1
  /**
2
- * dashboard-client/src/tabs/CacheTab.tsx — Cache tab (NEW).
2
+ * dashboard-client/src/tabs/CacheTab.tsx — Cache tab (B.3).
3
3
  *
4
- * Renders CacheHitsCard + TimeSavedCard from /api/snapshot
5
- * via the useApi hook with 5s polling.
4
+ * Two sections, each with its own header:
5
+ * 1. Provider Prompt Cache → ProviderCacheCard (/api/provider-cache)
6
+ * 2. Mega-Compact Dedup Cache → CacheHitsCard + TimeSavedCard (/api/snapshot)
7
+ *
8
+ * Both fetch at 5s polling (same interval). Loading / error shown per-section
9
+ * only when its own fetch fails; the other section keeps rendering.
6
10
  */
7
11
 
8
12
  import type React from "react";
9
13
  import { useCallback } from "react";
10
- import type { SnapshotResponse } from "@contracts";
14
+ import type { SnapshotResponse, ProviderCacheResponse } from "@contracts";
11
15
  import { useApi } from "../hooks/useApi";
12
- import { fetchSnapshot } from "../api/client";
16
+ import { fetchSnapshot, fetchProviderCache } from "../api/client";
13
17
  import { CacheHitsCard } from "../components/CacheHitsCard";
14
18
  import { TimeSavedCard } from "../components/TimeSavedCard";
19
+ import { ProviderCacheCard } from "../components/ProviderCacheCard";
15
20
 
16
21
  export default function CacheTab(): React.ReactElement {
17
- const { data: snapshot, loading, error } = useApi<SnapshotResponse>(
18
- useCallback(() => fetchSnapshot(), []),
22
+ /* --- Provider prompt cache (5s poll) --- */
23
+ const {
24
+ data: providerCache,
25
+ loading: pcLoading,
26
+ error: pcError,
27
+ } = useApi<ProviderCacheResponse>(
28
+ useCallback(() => fetchProviderCache(), []),
19
29
  { pollInterval: 5000 },
20
30
  );
21
31
 
22
- if (loading && !snapshot)
23
- return <div className="tab-stub">Loading snapshot…</div>;
24
- if (error && !snapshot)
25
- return <div className="tab-stub">Error: {error.message}</div>;
26
- if (!snapshot) return <div className="tab-stub">No snapshot data.</div>;
27
-
28
- const { cacheHits, compacts, timeSaved } = snapshot;
32
+ /* --- Dedup cache snapshot (5s poll) --- */
33
+ const {
34
+ data: snapshot,
35
+ loading: snapLoading,
36
+ error: snapError,
37
+ } = useApi<SnapshotResponse>(
38
+ useCallback(() => fetchSnapshot(), []),
39
+ { pollInterval: 5000 },
40
+ );
29
41
 
30
42
  return (
31
43
  <div className="cache-tab">
32
- <div className="card-grid overview-card-grid">
33
- <CacheHitsCard
34
- cacheHitsSession={cacheHits.session}
35
- cacheHitsTotal={cacheHits.total}
36
- tokensSavedSession={cacheHits.sessionTokensSaved}
37
- tokensSavedTotal={cacheHits.totalTokensSaved}
38
- compactionsSession={compacts.session}
39
- compactionsTotal={compacts.total}
44
+ {/* ==============================================================
45
+ Section 1 — Provider Prompt Cache
46
+ ============================================================== */}
47
+ <h2>Provider Prompt Cache</h2>
48
+ {pcLoading ? (
49
+ <p className="tab-stub">Loading…</p>
50
+ ) : pcError ? (
51
+ <p className="tab-stub">
52
+ Provider cache unavailable: {pcError.message}
53
+ </p>
54
+ ) : providerCache ? (
55
+ <ProviderCacheCard
56
+ hitPct={providerCache.cache.avgHitPct}
57
+ turnCount={providerCache.cache.turnCount}
58
+ totalCacheRead={providerCache.cache.totalCacheRead}
59
+ totalCacheWrite={providerCache.cache.totalCacheWrite}
60
+ totalInput={providerCache.cache.totalInput}
61
+ cacheReadSaved={providerCache.savings?.cacheReadSaved ?? null}
62
+ cacheWriteCost={providerCache.savings?.cacheWriteCost ?? null}
63
+ netSaved={providerCache.savings?.netSaved ?? null}
64
+ modelLabel={providerCache.savings?.model ?? null}
65
+ firstTurnAt={providerCache.cache.firstTurnAt}
66
+ latestTurnAt={providerCache.cache.latestTurnAt}
40
67
  />
41
- <TimeSavedCard
42
- compactSessionSec={timeSaved.compact.sessionSec}
43
- compactTotalSec={timeSaved.compact.totalSec}
44
- cacheHitSessionSec={timeSaved.cacheHit.sessionSec}
45
- cacheHitTotalSec={timeSaved.cacheHit.totalSec}
46
- />
47
- </div>
68
+ ) : null}
69
+
70
+ {/* ==============================================================
71
+ Section 2 — Mega-Compact Dedup Cache
72
+ ============================================================== */}
73
+ <h2>Mega-Compact Dedup Cache</h2>
74
+ {snapLoading ? (
75
+ <p className="tab-stub">Loading…</p>
76
+ ) : snapError ? (
77
+ <p className="tab-stub">Snapshot unavailable: {snapError.message}</p>
78
+ ) : (
79
+ snapshot &&
80
+ (() => {
81
+ const { cacheHits, compacts, timeSaved } = snapshot;
82
+ return (
83
+ <div className="card-grid overview-card-grid">
84
+ <CacheHitsCard
85
+ cacheHitsSession={cacheHits.session}
86
+ cacheHitsTotal={cacheHits.total}
87
+ tokensSavedSession={cacheHits.sessionTokensSaved}
88
+ tokensSavedTotal={cacheHits.totalTokensSaved}
89
+ compactionsSession={compacts.session}
90
+ compactionsTotal={compacts.total}
91
+ />
92
+ <TimeSavedCard
93
+ compactSessionSec={timeSaved.compact.sessionSec}
94
+ compactTotalSec={timeSaved.compact.totalSec}
95
+ cacheHitSessionSec={timeSaved.cacheHit.sessionSec}
96
+ cacheHitTotalSec={timeSaved.cacheHit.totalSec}
97
+ />
98
+ </div>
99
+ );
100
+ })()
101
+ )}
48
102
  </div>
49
103
  );
50
104
  }
@@ -0,0 +1,458 @@
1
+ /**
2
+ * dashboard-client/src/tabs/MaintenanceTab.tsx — Maintenance tab (v0.11.2 S49B).
3
+ *
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.
10
+ */
11
+
12
+ import type React from "react";
13
+ import { useState, useCallback } from "react";
14
+ import type {
15
+ DbStatsResponse,
16
+ SchemaHealthResponse,
17
+ MaintenanceAction,
18
+ MaintenanceActionResult,
19
+ } from "@contracts";
20
+ import { useApi } from "../hooks/useApi";
21
+ import {
22
+ fetchDbStats,
23
+ fetchSchemaHealth,
24
+ postMaintenanceAction,
25
+ } from "../api/client";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Action config
29
+ // ---------------------------------------------------------------------------
30
+
31
+ interface ActionDef {
32
+ key: string;
33
+ label: string;
34
+ desc: string;
35
+ dangerous: boolean;
36
+ confirm?: string;
37
+ daysOld?: boolean;
38
+ }
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
+
95
+ function fmtBytes(b: number): string {
96
+ if (b < 1024) return `${b} B`;
97
+ if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
98
+ return `${(b / (1024 * 1024)).toFixed(1)} MB`;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // DB Stats card
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function FileSizesCard({
106
+ files,
107
+ pageSize,
108
+ pageCount,
109
+ freelistPages,
110
+ }: {
111
+ files: DbStatsResponse["storage"]["files"];
112
+ pageSize: number;
113
+ pageCount: number;
114
+ freelistPages: number;
115
+ }): React.ReactElement {
116
+ const total = files.dbBytes + files.walBytes + files.shmBytes;
117
+ return (
118
+ <div className="stat-section">
119
+ <div className="stat-label">File Sizes</div>
120
+ <table className="compact-table">
121
+ <tbody>
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>
153
+ </tbody>
154
+ </table>
155
+ </div>
156
+ );
157
+ }
158
+
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);
165
+ return (
166
+ <div className="stat-section">
167
+ <div className="stat-label">
168
+ Table Row Counts ({totalRows.toLocaleString()} total)
169
+ </div>
170
+ <table className="compact-table">
171
+ <thead>
172
+ <tr>
173
+ <th>Table</th>
174
+ <th className="num">Rows</th>
175
+ </tr>
176
+ </thead>
177
+ <tbody>
178
+ {tables.map((t) => (
179
+ <tr key={t.table}>
180
+ <td>{t.table}</td>
181
+ <td className="num">
182
+ {t.rowCount >= 0 ? t.rowCount.toLocaleString() : "—"}
183
+ </td>
184
+ </tr>
185
+ ))}
186
+ </tbody>
187
+ </table>
188
+ </div>
189
+ );
190
+ }
191
+
192
+ function DbStatsCard({
193
+ data,
194
+ loading,
195
+ error,
196
+ }: {
197
+ data: DbStatsResponse | null;
198
+ loading: boolean;
199
+ error: Error | null;
200
+ }): React.ReactElement {
201
+ return (
202
+ <div className="card">
203
+ <div className="card-header">DB Stats</div>
204
+ <div className="card-body">
205
+ {loading && !data && <span className="text-muted">Loading…</span>}
206
+ {error && !data && <span className="text-error">{error.message}</span>}
207
+ {data && (
208
+ <>
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} />
216
+ </>
217
+ )}
218
+ </div>
219
+ </div>
220
+ );
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Schema Health card
225
+ // ---------------------------------------------------------------------------
226
+
227
+ function ColumnAuditCard({
228
+ columns,
229
+ }: {
230
+ columns: SchemaHealthResponse["columns"];
231
+ }): React.ReactElement {
232
+ const missing = columns.filter((c) => !c.present);
233
+ return (
234
+ <div className="stat-section">
235
+ <div className="stat-label">
236
+ Column Audit ({columns.length} columns, {missing.length} missing)
237
+ </div>
238
+ <table className="compact-table">
239
+ <thead>
240
+ <tr>
241
+ <th>Table</th>
242
+ <th>Column</th>
243
+ <th>Decl</th>
244
+ <th>Status</th>
245
+ </tr>
246
+ </thead>
247
+ <tbody>
248
+ {columns.map((c) => (
249
+ <tr key={`${c.table}.${c.column}`}>
250
+ <td>{c.table}</td>
251
+ <td>{c.column}</td>
252
+ <td className="text-mono">{c.expectedDecl}</td>
253
+ <td className={c.present ? "text-ok" : "text-error"}>
254
+ {c.present ? "✓" : "MISSING"}
255
+ </td>
256
+ </tr>
257
+ ))}
258
+ </tbody>
259
+ </table>
260
+ </div>
261
+ );
262
+ }
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
+
322
+ // ---------------------------------------------------------------------------
323
+ // Actions card
324
+ // ---------------------------------------------------------------------------
325
+
326
+ function ActionsCard(): React.ReactElement {
327
+ const [results, setResults] = useState<MaintenanceActionResult[]>([]);
328
+ const [running, setRunning] = useState<string | null>(null);
329
+ const [days, setDays] = useState("30");
330
+
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);
336
+
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
+ );
363
+
364
+ return (
365
+ <div className="card">
366
+ <div className="card-header">Actions</div>
367
+ <div className="card-body">
368
+ <div className="action-grid">
369
+ {ACTIONS.map((def) => {
370
+ const isRunning = running === def.key;
371
+ const lastResult = results.find((r) => r.operation === def.key);
372
+ return (
373
+ <div key={def.key} className="action-item">
374
+ <div className="action-info">
375
+ <div className="action-label">
376
+ {def.label}
377
+ {def.dangerous && (
378
+ <span className="badge badge-warn">DESTRUCTIVE</span>
379
+ )}
380
+ </div>
381
+ <div className="action-desc">{def.desc}</div>
382
+ {lastResult && (
383
+ <div
384
+ className={`action-result ${lastResult.success ? "text-ok" : "text-error"}`}
385
+ >
386
+ {lastResult.summary}
387
+ </div>
388
+ )}
389
+ </div>
390
+ <div className="action-controls">
391
+ {def.daysOld && (
392
+ <input
393
+ type="number"
394
+ min={1}
395
+ max={365}
396
+ value={days}
397
+ onChange={(e) => setDays(e.target.value)}
398
+ className="days-input"
399
+ disabled={isRunning}
400
+ />
401
+ )}
402
+ <button
403
+ className={`btn ${def.dangerous ? "btn-danger" : "btn-primary"}`}
404
+ disabled={running !== null}
405
+ onClick={() => {
406
+ if (def.confirm && !window.confirm(def.confirm)) return;
407
+ handleAction(def);
408
+ }}
409
+ >
410
+ {isRunning ? "Running…" : "Run"}
411
+ </button>
412
+ </div>
413
+ </div>
414
+ );
415
+ })}
416
+ </div>
417
+ </div>
418
+ </div>
419
+ );
420
+ }
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // Main tab component
424
+ // ---------------------------------------------------------------------------
425
+
426
+ export default function MaintenanceTab(): React.ReactElement {
427
+ const {
428
+ data: dbStats,
429
+ loading: statsLoading,
430
+ error: statsError,
431
+ } = useApi<DbStatsResponse>(
432
+ useCallback(() => fetchDbStats(), []),
433
+ { pollInterval: 15000 },
434
+ );
435
+
436
+ const {
437
+ data: schemaHealth,
438
+ loading: healthLoading,
439
+ error: healthError,
440
+ } = useApi<SchemaHealthResponse>(
441
+ useCallback(() => fetchSchemaHealth(), []),
442
+ { pollInterval: 10000 },
443
+ );
444
+
445
+ return (
446
+ <div className="maintenance-tab">
447
+ <div className="card-grid">
448
+ <SchemaHealthCard
449
+ data={schemaHealth}
450
+ loading={healthLoading}
451
+ error={healthError}
452
+ />
453
+ <DbStatsCard data={dbStats} loading={statsLoading} error={statsError} />
454
+ </div>
455
+ <ActionsCard />
456
+ </div>
457
+ );
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
+ }