pi-mega-compact 0.11.1 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +19 -0
- package/dist/extensions/dashboard-server/api-contracts/maintenance.js +7 -0
- package/dist/extensions/dashboard-server/api-contracts.test.js +1 -1
- package/dist/extensions/dashboard-server/routes-maintenance.js +293 -0
- package/dist/extensions/dashboard-server/routes.js +1 -0
- package/dist/extensions/dashboard-server/server.js +3 -1
- package/dist/src/store/sqlite/maintenance.js +20 -1
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-DZp_xQOW.js → AchievementTiles-Cml6YC8w.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-DZp_xQOW.js.map → AchievementTiles-Cml6YC8w.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BzFHpAHM.js → AchievementsTab-B8NqDt7I.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BzFHpAHM.js.map → AchievementsTab-B8NqDt7I.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-DEQbTq2T.js → CacheTab-BCLzVL4S.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-DEQbTq2T.js.map → CacheTab-BCLzVL4S.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-DQ4FG33J.js → ConfigTab-CBYvp2mV.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-DQ4FG33J.js.map → ConfigTab-CBYvp2mV.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-D5HoEVYo.js → EventsTab-DlTJqiDm.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-D5HoEVYo.js.map → EventsTab-DlTJqiDm.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{GameTab-Jzsc4yeY.js → GameTab-DkGTqKJW.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-Jzsc4yeY.js.map → GameTab-DkGTqKJW.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js +2 -0
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{MetricsTab-D_XnsEYX.js → MetricsTab-BFo7pKM-.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-D_XnsEYX.js.map → MetricsTab-BFo7pKM-.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-BMREvAth.js → OverviewTab-CClCUd3-.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-BMREvAth.js.map → OverviewTab-CClCUd3-.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-DuyhOktG.js → ReposTab-_3R4RSLZ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-DuyhOktG.js.map → ReposTab-_3R4RSLZ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-mz_vz8i2.js → SessionsTab-CCYd-kSz.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-mz_vz8i2.js.map → SessionsTab-CCYd-kSz.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B8AIjNTY.js → TimeSavedCard-0TqsrY6y.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B8AIjNTY.js.map → TimeSavedCard-0TqsrY6y.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TopicsTab-DKuct4oD.js → TopicsTab-DRLJmVv0.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TopicsTab-DKuct4oD.js.map → TopicsTab-DRLJmVv0.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TurnsTab-11MGLxY8.js → TurnsTab-CLNM0T5R.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TurnsTab-11MGLxY8.js.map → TurnsTab-CLNM0T5R.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-CqLN7STl.js → index-BcoSz-L4.js} +10 -10
- package/extensions/dashboard-client/dist/assets/{index-CqLN7STl.js.map → index-BcoSz-L4.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js → useSSE-Cezlih5V.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js.map → useSSE-Cezlih5V.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +1 -1
- package/extensions/dashboard-client/src/App.tsx +5 -1
- package/extensions/dashboard-client/src/api/client.ts +23 -0
- package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +293 -0
- package/extensions/dashboard-server/api-contracts/endpoints.ts +30 -0
- package/extensions/dashboard-server/api-contracts/index.ts +12 -0
- package/extensions/dashboard-server/api-contracts/maintenance.ts +84 -0
- package/extensions/dashboard-server/api-contracts.test.ts +1 -1
- package/extensions/dashboard-server/api-contracts.ts +9 -0
- package/extensions/dashboard-server/routes-maintenance.ts +345 -0
- package/extensions/dashboard-server/routes.ts +1 -0
- package/extensions/dashboard-server/server.ts +2 -0
- package/package.json +1 -1
- package/src/store/sqlite/maintenance.ts +20 -1
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{r as e}from"./index-
|
|
2
|
-
//# sourceMappingURL=useSSE-
|
|
1
|
+
import{r as e}from"./index-BcoSz-L4.js";function R(i={}){const{maxEvents:c=500,maxReconnects:l=5}=i,[f,v]=e.useState([]),[E,n]=e.useState("connecting"),[S,p]=e.useState(0),[m,d]=e.useState(null),o=e.useRef(0),s=e.useRef(null),r=e.useCallback(()=>{s.current&&s.current.close(),n("connecting");const t=new EventSource("/api/events");s.current=t,t.onopen=()=>{n("connected"),o.current=0},t.onmessage=g=>{try{const x=JSON.parse(g.data);v(u=>{const a=[...u,x];return a.length>c?a.slice(-c):a}),p(u=>u+1),d(Date.now())}catch{}},t.onerror=()=>{t.close(),o.current<l?(o.current+=1,n("connecting"),setTimeout(r,1e3)):n("disconnected")}},[c,l]);return e.useEffect(()=>(r(),()=>{s.current?.close()}),[r]),{events:f,status:E,eventCount:S,lastEventAt:m}}export{R as u};
|
|
2
|
+
//# sourceMappingURL=useSSE-Cezlih5V.js.map
|
package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js.map → useSSE-Cezlih5V.js.map}
RENAMED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSSE-
|
|
1
|
+
{"version":3,"file":"useSSE-Cezlih5V.js","sources":["../../src/hooks/useSSE.ts"],"sourcesContent":["/**\n * dashboard-client/src/hooks/useSSE.ts — Server-Sent Events hook.\n *\n * Connects to /api/events, provides real-time event stream.\n * SPRINT-B1: basic connection. SPRINT-D1: reconnect with backoff.\n */\n\nimport { useState, useEffect, useRef, useCallback } from 'react';\nimport type { SseEvent } from '../../../dashboard-server/api-contracts';\n\nexport type SseStatus = 'connecting' | 'connected' | 'disconnected' | 'error';\n\nexport interface UseSSEResult {\n events: SseEvent[];\n status: SseStatus;\n eventCount: number;\n lastEventAt: number | null;\n}\n\nexport interface UseSSEOptions {\n /** Max events to keep in memory (ring buffer). */\n maxEvents?: number;\n /** Max reconnect attempts before giving up. */\n maxReconnects?: number;\n}\n\n// SPRINT-D1-REMAINING: exponential backoff (1s → 2s → 4s → max 30s).\n// SPRINT-D1-REMAINING: after 5 failures, set status='disconnected'.\n\nexport function useSSE(options: UseSSEOptions = {}): UseSSEResult {\n const { maxEvents = 500, maxReconnects = 5 } = options;\n const [events, setEvents] = useState<SseEvent[]>([]);\n const [status, setStatus] = useState<SseStatus>('connecting');\n const [eventCount, setEventCount] = useState(0);\n const [lastEventAt, setLastEventAt] = useState<number | null>(null);\n const reconnectCount = useRef(0);\n const sourceRef = useRef<EventSource | null>(null);\n\n const connect = useCallback(() => {\n if (sourceRef.current) {\n sourceRef.current.close();\n }\n setStatus('connecting');\n const source = new EventSource('/api/events');\n sourceRef.current = source;\n\n source.onopen = () => {\n setStatus('connected');\n reconnectCount.current = 0;\n };\n\n source.onmessage = (event) => {\n try {\n const parsed = JSON.parse(event.data) as SseEvent;\n setEvents(prev => {\n const next = [...prev, parsed];\n return next.length > maxEvents ? next.slice(-maxEvents) : next;\n });\n setEventCount(c => c + 1);\n setLastEventAt(Date.now());\n } catch {\n // non-fatal: skip malformed events\n }\n };\n\n source.onerror = () => {\n source.close();\n if (reconnectCount.current < maxReconnects) {\n reconnectCount.current += 1;\n setStatus('connecting');\n // SPRINT-D1: use exponential backoff instead of fixed delay\n setTimeout(connect, 1000);\n } else {\n setStatus('disconnected');\n }\n };\n }, [maxEvents, maxReconnects]);\n\n useEffect(() => {\n connect();\n return () => {\n sourceRef.current?.close();\n };\n }, [connect]);\n\n return { events, status, eventCount, lastEventAt };\n}\n"],"names":["useSSE","options","maxEvents","maxReconnects","events","setEvents","useState","status","setStatus","eventCount","setEventCount","lastEventAt","setLastEventAt","reconnectCount","useRef","sourceRef","connect","useCallback","source","event","parsed","prev","next","c","useEffect"],"mappings":"wCA6BO,SAASA,EAAOC,EAAyB,GAAkB,CAChE,KAAM,CAAE,UAAAC,EAAY,IAAK,cAAAC,EAAgB,GAAMF,EACzC,CAACG,EAAQC,CAAS,EAAIC,EAAAA,SAAqB,CAAA,CAAE,EAC7C,CAACC,EAAQC,CAAS,EAAIF,EAAAA,SAAoB,YAAY,EACtD,CAACG,EAAYC,CAAa,EAAIJ,EAAAA,SAAS,CAAC,EACxC,CAACK,EAAaC,CAAc,EAAIN,EAAAA,SAAwB,IAAI,EAC5DO,EAAiBC,EAAAA,OAAO,CAAC,EACzBC,EAAYD,EAAAA,OAA2B,IAAI,EAE3CE,EAAUC,EAAAA,YAAY,IAAM,CAC5BF,EAAU,SACZA,EAAU,QAAQ,MAAA,EAEpBP,EAAU,YAAY,EACtB,MAAMU,EAAS,IAAI,YAAY,aAAa,EAC5CH,EAAU,QAAUG,EAEpBA,EAAO,OAAS,IAAM,CACpBV,EAAU,WAAW,EACrBK,EAAe,QAAU,CAC3B,EAEAK,EAAO,UAAaC,GAAU,CAC5B,GAAI,CACF,MAAMC,EAAS,KAAK,MAAMD,EAAM,IAAI,EACpCd,EAAUgB,GAAQ,CAChB,MAAMC,EAAO,CAAC,GAAGD,EAAMD,CAAM,EAC7B,OAAOE,EAAK,OAASpB,EAAYoB,EAAK,MAAM,CAACpB,CAAS,EAAIoB,CAC5D,CAAC,EACDZ,EAAca,GAAKA,EAAI,CAAC,EACxBX,EAAe,KAAK,KAAK,CAC3B,MAAQ,CAER,CACF,EAEAM,EAAO,QAAU,IAAM,CACrBA,EAAO,MAAA,EACHL,EAAe,QAAUV,GAC3BU,EAAe,SAAW,EAC1BL,EAAU,YAAY,EAEtB,WAAWQ,EAAS,GAAI,GAExBR,EAAU,cAAc,CAE5B,CACF,EAAG,CAACN,EAAWC,CAAa,CAAC,EAE7BqB,OAAAA,EAAAA,UAAU,KACRR,EAAA,EACO,IAAM,CACXD,EAAU,SAAS,MAAA,CACrB,GACC,CAACC,CAAO,CAAC,EAEL,CAAE,OAAAZ,EAAQ,OAAAG,EAAQ,WAAAE,EAAY,YAAAE,CAAA,CACvC"}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>mega-compact dashboard</title>
|
|
7
|
-
<script type="module" crossorigin src="./assets/index-
|
|
7
|
+
<script type="module" crossorigin src="./assets/index-BcoSz-L4.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="./assets/index-gn66lYD2.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -25,6 +25,7 @@ const AchievementsTab = React.lazy(() => import("./tabs/AchievementsTab"));
|
|
|
25
25
|
const SessionsTab = React.lazy(() => import("./tabs/SessionsTab"));
|
|
26
26
|
const TopicsTab = React.lazy(() => import("./tabs/TopicsTab"));
|
|
27
27
|
const TurnsTab = React.lazy(() => import("./tabs/TurnsTab"));
|
|
28
|
+
const MaintenanceTab = React.lazy(() => import("./tabs/MaintenanceTab"));
|
|
28
29
|
|
|
29
30
|
export type TabId =
|
|
30
31
|
| "overview"
|
|
@@ -37,7 +38,8 @@ export type TabId =
|
|
|
37
38
|
| "achievements"
|
|
38
39
|
| "sessions"
|
|
39
40
|
| "topics"
|
|
40
|
-
| "turns"
|
|
41
|
+
| "turns"
|
|
42
|
+
| "maintenance";
|
|
41
43
|
|
|
42
44
|
const TABS: Array<{ id: TabId; label: string }> = [
|
|
43
45
|
{ id: "overview", label: "Overview" },
|
|
@@ -51,6 +53,7 @@ const TABS: Array<{ id: TabId; label: string }> = [
|
|
|
51
53
|
{ id: "sessions", label: "Sessions" },
|
|
52
54
|
{ id: "topics", label: "Topics" },
|
|
53
55
|
{ id: "turns", label: "Turns" },
|
|
56
|
+
{ id: "maintenance", label: "Maintenance" },
|
|
54
57
|
];
|
|
55
58
|
|
|
56
59
|
export default function App(): React.ReactElement {
|
|
@@ -100,6 +103,7 @@ export default function App(): React.ReactElement {
|
|
|
100
103
|
{activeTab === "sessions" && <SessionsTab />}
|
|
101
104
|
{activeTab === "topics" && <TopicsTab />}
|
|
102
105
|
{activeTab === "turns" && <TurnsTab />}
|
|
106
|
+
{activeTab === "maintenance" && <MaintenanceTab />}
|
|
103
107
|
</React.Suspense>
|
|
104
108
|
</main>
|
|
105
109
|
</div>
|
|
@@ -35,6 +35,10 @@ import type {
|
|
|
35
35
|
ForkResponse,
|
|
36
36
|
PruneTurnsResponse,
|
|
37
37
|
TopicMemoriesResponse,
|
|
38
|
+
DbStatsResponse,
|
|
39
|
+
SchemaHealthResponse,
|
|
40
|
+
MaintenanceAction,
|
|
41
|
+
MaintenanceActionResult,
|
|
38
42
|
} from "@contracts";
|
|
39
43
|
|
|
40
44
|
/** Error thrown when a dashboard API response is not 2xx. */
|
|
@@ -244,3 +248,22 @@ export function fetchTopicMemories(
|
|
|
244
248
|
),
|
|
245
249
|
);
|
|
246
250
|
}
|
|
251
|
+
|
|
252
|
+
// ─── Maintenance (S49B) ─────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
export function fetchDbStats(): Promise<DbStatsResponse> {
|
|
255
|
+
return getJson<DbStatsResponse>(ENDPOINTS.maintenanceStats.path);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function fetchSchemaHealth(): Promise<SchemaHealthResponse> {
|
|
259
|
+
return getJson<SchemaHealthResponse>(ENDPOINTS.schemaHealth.path);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function postMaintenanceAction(
|
|
263
|
+
action: MaintenanceAction,
|
|
264
|
+
): Promise<MaintenanceActionResult> {
|
|
265
|
+
return postJson<MaintenanceActionResult>(
|
|
266
|
+
ENDPOINTS.maintenanceAction.path,
|
|
267
|
+
action,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-client/src/tabs/MaintenanceTab.tsx — Maintenance tab (S49B).
|
|
3
|
+
*
|
|
4
|
+
* Schema health audit + DB stats + maintenance action triggers.
|
|
5
|
+
* Polls schema-health every 10s and db-stats every 15s (stats are stable).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type React from "react";
|
|
9
|
+
import { useState, useCallback } from "react";
|
|
10
|
+
import type {
|
|
11
|
+
DbStatsResponse,
|
|
12
|
+
SchemaHealthResponse,
|
|
13
|
+
MaintenanceAction,
|
|
14
|
+
MaintenanceActionResult,
|
|
15
|
+
TableStats,
|
|
16
|
+
DbFiles,
|
|
17
|
+
SchemaHealthRow,
|
|
18
|
+
} from "@contracts";
|
|
19
|
+
import { useApi } from "../hooks/useApi";
|
|
20
|
+
import { fetchDbStats, fetchSchemaHealth, postMaintenanceAction } from "../api/client";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// DB Stats Card
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
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
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fmtBytes(b: number): string {
|
|
49
|
+
if (b < 1024) return `${b} B`;
|
|
50
|
+
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
|
51
|
+
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function FileSizes({ files, pageSize, pageCount, freelistPages }: {
|
|
55
|
+
files: DbFiles;
|
|
56
|
+
pageSize: number;
|
|
57
|
+
pageCount: number;
|
|
58
|
+
freelistPages: number;
|
|
59
|
+
}) {
|
|
60
|
+
return (
|
|
61
|
+
<div className="stat-section">
|
|
62
|
+
<div className="stat-label">File Sizes</div>
|
|
63
|
+
<table className="compact-table">
|
|
64
|
+
<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>
|
|
71
|
+
</tbody>
|
|
72
|
+
</table>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function TableCounts({ tables }: { tables: TableStats[] }) {
|
|
78
|
+
const total = tables.reduce((sum, t) => sum + Math.max(0, t.rowCount), 0);
|
|
79
|
+
return (
|
|
80
|
+
<div className="stat-section">
|
|
81
|
+
<div className="stat-label">Table Row Counts ({total.toLocaleString()} total)</div>
|
|
82
|
+
<table className="compact-table">
|
|
83
|
+
<thead>
|
|
84
|
+
<tr><th>Table</th><th className="num">Rows</th></tr>
|
|
85
|
+
</thead>
|
|
86
|
+
<tbody>
|
|
87
|
+
{tables.map((t) => (
|
|
88
|
+
<tr key={t.table}>
|
|
89
|
+
<td>{t.table}</td>
|
|
90
|
+
<td className="num">{t.rowCount >= 0 ? t.rowCount.toLocaleString() : "—"}</td>
|
|
91
|
+
</tr>
|
|
92
|
+
))}
|
|
93
|
+
</tbody>
|
|
94
|
+
</table>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Schema Health Card
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function SchemaHealthCard({ data, loading, error }: {
|
|
104
|
+
data: SchemaHealthResponse | null;
|
|
105
|
+
loading: boolean;
|
|
106
|
+
error: Error | null;
|
|
107
|
+
}) {
|
|
108
|
+
return (
|
|
109
|
+
<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>
|
|
118
|
+
<div className="card-body">
|
|
119
|
+
{loading && !data && <span className="text-muted">Loading…</span>}
|
|
120
|
+
{error && !data && <span className="text-error">{error.message}</span>}
|
|
121
|
+
{data && (
|
|
122
|
+
<>
|
|
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
|
+
)}
|
|
132
|
+
</>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ColumnAudit({ columns }: { columns: SchemaHealthRow[] }) {
|
|
140
|
+
const missing = columns.filter((c) => !c.present);
|
|
141
|
+
return (
|
|
142
|
+
<div className="stat-section">
|
|
143
|
+
<div className="stat-label">Column Audit ({columns.length} columns, {missing.length} missing)</div>
|
|
144
|
+
<table className="compact-table">
|
|
145
|
+
<thead>
|
|
146
|
+
<tr><th>Table</th><th>Column</th><th>Decl</th><th>Status</th></tr>
|
|
147
|
+
</thead>
|
|
148
|
+
<tbody>
|
|
149
|
+
{columns.map((c) => (
|
|
150
|
+
<tr key={`${c.table}.${c.column}`}>
|
|
151
|
+
<td>{c.table}</td>
|
|
152
|
+
<td>{c.column}</td>
|
|
153
|
+
<td className="text-mono">{c.expectedDecl}</td>
|
|
154
|
+
<td className={c.present ? "text-ok" : "text-error"}>{c.present ? "✓" : "MISSING"}</td>
|
|
155
|
+
</tr>
|
|
156
|
+
))}
|
|
157
|
+
</tbody>
|
|
158
|
+
</table>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Action Card
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
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[]>([]);
|
|
190
|
+
const [running, setRunning] = useState<string | null>(null);
|
|
191
|
+
const [days, setDays] = useState("30");
|
|
192
|
+
|
|
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;
|
|
197
|
+
|
|
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]);
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className="card">
|
|
218
|
+
<div className="card-header">Actions</div>
|
|
219
|
+
<div className="card-body">
|
|
220
|
+
<div className="action-grid">
|
|
221
|
+
{ACTIONS.map((a) => {
|
|
222
|
+
const isRunning = running === a.key;
|
|
223
|
+
const lastResult = results.find((r) => r.operation === a.key);
|
|
224
|
+
return (
|
|
225
|
+
<div key={a.key} className="action-item">
|
|
226
|
+
<div className="action-info">
|
|
227
|
+
<div className="action-label">
|
|
228
|
+
{a.label}
|
|
229
|
+
{a.dangerous && <span className="badge badge-warn">DESTRUCTIVE</span>}
|
|
230
|
+
</div>
|
|
231
|
+
<div className="action-desc">{a.desc}</div>
|
|
232
|
+
{lastResult && (
|
|
233
|
+
<div className={`action-result ${lastResult.success ? "text-ok" : "text-error"}`}>
|
|
234
|
+
{lastResult.summary}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
<div className="action-controls">
|
|
239
|
+
{a.daysOld && (
|
|
240
|
+
<input
|
|
241
|
+
type="number"
|
|
242
|
+
min="1"
|
|
243
|
+
max="365"
|
|
244
|
+
value={days}
|
|
245
|
+
onChange={(e) => setDays(e.target.value)}
|
|
246
|
+
className="days-input"
|
|
247
|
+
disabled={isRunning}
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
250
|
+
<button
|
|
251
|
+
className={`btn ${a.dangerous ? "btn-danger" : "btn-primary"}`}
|
|
252
|
+
disabled={isRunning !== null}
|
|
253
|
+
onClick={() => {
|
|
254
|
+
if (a.confirm && !window.confirm(a.confirm)) return;
|
|
255
|
+
run(a);
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
{isRunning ? "Running…" : "Run"}
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
})}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Tab root
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
export default function MaintenanceTab(): React.ReactElement {
|
|
275
|
+
const { data: stats, loading: statsLoading, error: statsError } = useApi<DbStatsResponse>(
|
|
276
|
+
useCallback(() => fetchDbStats(), []),
|
|
277
|
+
{ pollInterval: 15000 },
|
|
278
|
+
);
|
|
279
|
+
const { data: schema, loading: schemaLoading, error: schemaError } = useApi<SchemaHealthResponse>(
|
|
280
|
+
useCallback(() => fetchSchemaHealth(), []),
|
|
281
|
+
{ pollInterval: 10000 },
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<div className="maintenance-tab">
|
|
286
|
+
<div className="card-grid">
|
|
287
|
+
<SchemaHealthCard data={schema} loading={schemaLoading} error={schemaError} />
|
|
288
|
+
<DbStatsCard data={stats} loading={statsLoading} error={statsError} />
|
|
289
|
+
</div>
|
|
290
|
+
<ActionsPanel />
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
@@ -56,6 +56,13 @@ export type {
|
|
|
56
56
|
TopicDetailResponse,
|
|
57
57
|
} from "./game-types.js";
|
|
58
58
|
|
|
59
|
+
import type {
|
|
60
|
+
DbStatsResponse,
|
|
61
|
+
MaintenanceActionResult,
|
|
62
|
+
MaintenanceAction,
|
|
63
|
+
SchemaHealthResponse,
|
|
64
|
+
} from "./maintenance.js";
|
|
65
|
+
|
|
59
66
|
// ─── New Response Types (inline) ───────────────────────────────────────────
|
|
60
67
|
|
|
61
68
|
/**
|
|
@@ -553,4 +560,27 @@ export const ENDPOINTS = {
|
|
|
553
560
|
path: "/api/topics/:topicId/memories",
|
|
554
561
|
description: "Member memories assigned to a wiki topic (drill-down).",
|
|
555
562
|
} as const satisfies EndpointDef<"GET", undefined, TopicMemoriesResponse>,
|
|
563
|
+
|
|
564
|
+
// ─── S49B Maintenance Tab ─────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
/** GET /api/maintenance — DB stats (table row counts + storage). */
|
|
567
|
+
maintenanceStats: {
|
|
568
|
+
method: "GET",
|
|
569
|
+
path: "/api/maintenance",
|
|
570
|
+
description: "SQLite table row counts, storage stats, and DB file sizes.",
|
|
571
|
+
} as const satisfies EndpointDef<"GET", undefined, DbStatsResponse>,
|
|
572
|
+
|
|
573
|
+
/** GET /api/maintenance/schema-health — Schema version + integrity audit. */
|
|
574
|
+
schemaHealth: {
|
|
575
|
+
method: "GET",
|
|
576
|
+
path: "/api/maintenance/schema-health",
|
|
577
|
+
description: "SCHEMA_VERSION, PRAGMA integrity_check, FK check, and per-column audit.",
|
|
578
|
+
} as const satisfies EndpointDef<"GET", undefined, SchemaHealthResponse>,
|
|
579
|
+
|
|
580
|
+
/** POST /api/maintenance/action — Trigger a maintenance action. */
|
|
581
|
+
maintenanceAction: {
|
|
582
|
+
method: "POST",
|
|
583
|
+
path: "/api/maintenance/action",
|
|
584
|
+
description: "Run vacuum, checkpoint, reindex, fts5-rebuild, reconcile-dedup, prune, or integrity-check.",
|
|
585
|
+
} as const satisfies EndpointDef<"POST", MaintenanceAction, MaintenanceActionResult>,
|
|
556
586
|
} as const;
|
|
@@ -171,6 +171,18 @@ export type SseEvent =
|
|
|
171
171
|
| SseGameRender
|
|
172
172
|
| SseSessionSample;
|
|
173
173
|
|
|
174
|
+
// S49B — maintenance tab API
|
|
175
|
+
export type {
|
|
176
|
+
TableStats,
|
|
177
|
+
DbFiles,
|
|
178
|
+
DbStorageStats,
|
|
179
|
+
DbStatsResponse,
|
|
180
|
+
MaintenanceActionResult,
|
|
181
|
+
SchemaHealthRow,
|
|
182
|
+
SchemaHealthResponse,
|
|
183
|
+
MaintenanceAction,
|
|
184
|
+
} from "./maintenance.js";
|
|
185
|
+
|
|
174
186
|
// S52 — turn-by-turn memory tracking + recall + rewind
|
|
175
187
|
export type {
|
|
176
188
|
TurnRow,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api-contracts/maintenance.ts — Maintenance tab API contract (S49B).
|
|
3
|
+
*
|
|
4
|
+
* Types for the /api/maintenance and /api/maintenance/action endpoints.
|
|
5
|
+
* All pi-agnostic, all PREVENT-PI-004 compliant (type definitions only).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Row count + size stats per SQLite table. */
|
|
9
|
+
export interface TableStats {
|
|
10
|
+
/** Table name. */
|
|
11
|
+
table: string;
|
|
12
|
+
/** Row count (or -1 if FTS virtual table). */
|
|
13
|
+
rowCount: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** DB file sizes on disk in bytes. */
|
|
17
|
+
export interface DbFiles {
|
|
18
|
+
/** Main sqlite.db size. */
|
|
19
|
+
dbBytes: number;
|
|
20
|
+
/** WAL sidecar size (0 if absent). */
|
|
21
|
+
walBytes: number;
|
|
22
|
+
/** SHM sidecar size (0 if absent). */
|
|
23
|
+
shmBytes: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** SQLite storage-level stats. */
|
|
27
|
+
export interface DbStorageStats {
|
|
28
|
+
files: DbFiles;
|
|
29
|
+
pageSize: number;
|
|
30
|
+
pageCount: number;
|
|
31
|
+
freelistPages: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Full table + storage overview (GET /api/maintenance result). */
|
|
35
|
+
export interface DbStatsResponse {
|
|
36
|
+
type: "db-stats";
|
|
37
|
+
/** Per-table row counts. */
|
|
38
|
+
tables: TableStats[];
|
|
39
|
+
/** Storage stats. */
|
|
40
|
+
storage: DbStorageStats;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Result of a single maintenance action. */
|
|
44
|
+
export interface MaintenanceActionResult {
|
|
45
|
+
operation: string;
|
|
46
|
+
success: boolean;
|
|
47
|
+
affected: number;
|
|
48
|
+
reclaimedBytes: number;
|
|
49
|
+
summary: string;
|
|
50
|
+
detail?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Schema health check — per-column audit result. */
|
|
54
|
+
export interface SchemaHealthRow {
|
|
55
|
+
table: string;
|
|
56
|
+
column: string;
|
|
57
|
+
present: boolean;
|
|
58
|
+
expectedDecl: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Schema health audit (GET /api/maintenance/schema-health). */
|
|
62
|
+
export interface SchemaHealthResponse {
|
|
63
|
+
type: "schema-health";
|
|
64
|
+
/** SCHEMA_VERSION constant compiled in. */
|
|
65
|
+
schemaVersion: number;
|
|
66
|
+
/** PRAGMA integrity_check result lines. */
|
|
67
|
+
integrity: string[];
|
|
68
|
+
/** PRAGMA foreign_key_check result lines. */
|
|
69
|
+
fkCheck: string[];
|
|
70
|
+
/** Per-column audit of every ensureColumn migration column. */
|
|
71
|
+
columns: SchemaHealthRow[];
|
|
72
|
+
/** All present = healthy. */
|
|
73
|
+
healthy: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Maintenance actions the client can trigger (POST /api/maintenance/action). */
|
|
77
|
+
export type MaintenanceAction =
|
|
78
|
+
| { action: "vacuum" }
|
|
79
|
+
| { action: "checkpoint" }
|
|
80
|
+
| { action: "reindex" }
|
|
81
|
+
| { action: "fts5-rebuild" }
|
|
82
|
+
| { action: "reconcile-dedup" }
|
|
83
|
+
| { action: "prune"; daysOld: number }
|
|
84
|
+
| { action: "integrity-check" };
|
|
@@ -162,7 +162,7 @@ void _c_achievements;
|
|
|
162
162
|
// (Sprint 39 added /api/sessions + /api/sessions/timeseries.)
|
|
163
163
|
|
|
164
164
|
const ENDPOINT_KEYS = Object.keys(ENDPOINTS) as (keyof typeof ENDPOINTS)[];
|
|
165
|
-
const EXPECTED_ENDPOINT_COUNT = 23
|
|
165
|
+
const EXPECTED_ENDPOINT_COUNT = 26; // 23 base + 3 S49B maintenance
|
|
166
166
|
|
|
167
167
|
/** All `/api/*` paths served by server.ts (extracted from the route handlers). */
|
|
168
168
|
const SERVER_TS_PATHS: string[] = [
|
|
@@ -66,6 +66,15 @@ export type {
|
|
|
66
66
|
MinHashBandState,
|
|
67
67
|
// Composite
|
|
68
68
|
SseEvent,
|
|
69
|
+
// S49B — maintenance tab
|
|
70
|
+
TableStats,
|
|
71
|
+
DbFiles,
|
|
72
|
+
DbStorageStats,
|
|
73
|
+
DbStatsResponse,
|
|
74
|
+
MaintenanceActionResult,
|
|
75
|
+
SchemaHealthRow,
|
|
76
|
+
SchemaHealthResponse,
|
|
77
|
+
MaintenanceAction,
|
|
69
78
|
// Endpoints registry (Sprint A1)
|
|
70
79
|
VersionResponse,
|
|
71
80
|
IndexSummary,
|