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.
Files changed (53) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +19 -0
  2. package/dist/extensions/dashboard-server/api-contracts/maintenance.js +7 -0
  3. package/dist/extensions/dashboard-server/api-contracts.test.js +1 -1
  4. package/dist/extensions/dashboard-server/routes-maintenance.js +293 -0
  5. package/dist/extensions/dashboard-server/routes.js +1 -0
  6. package/dist/extensions/dashboard-server/server.js +3 -1
  7. package/dist/src/store/sqlite/maintenance.js +20 -1
  8. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DZp_xQOW.js → AchievementTiles-Cml6YC8w.js} +2 -2
  9. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DZp_xQOW.js.map → AchievementTiles-Cml6YC8w.js.map} +1 -1
  10. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BzFHpAHM.js → AchievementsTab-B8NqDt7I.js} +2 -2
  11. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BzFHpAHM.js.map → AchievementsTab-B8NqDt7I.js.map} +1 -1
  12. package/extensions/dashboard-client/dist/assets/{CacheTab-DEQbTq2T.js → CacheTab-BCLzVL4S.js} +2 -2
  13. package/extensions/dashboard-client/dist/assets/{CacheTab-DEQbTq2T.js.map → CacheTab-BCLzVL4S.js.map} +1 -1
  14. package/extensions/dashboard-client/dist/assets/{ConfigTab-DQ4FG33J.js → ConfigTab-CBYvp2mV.js} +2 -2
  15. package/extensions/dashboard-client/dist/assets/{ConfigTab-DQ4FG33J.js.map → ConfigTab-CBYvp2mV.js.map} +1 -1
  16. package/extensions/dashboard-client/dist/assets/{EventsTab-D5HoEVYo.js → EventsTab-DlTJqiDm.js} +2 -2
  17. package/extensions/dashboard-client/dist/assets/{EventsTab-D5HoEVYo.js.map → EventsTab-DlTJqiDm.js.map} +1 -1
  18. package/extensions/dashboard-client/dist/assets/{GameTab-Jzsc4yeY.js → GameTab-DkGTqKJW.js} +2 -2
  19. package/extensions/dashboard-client/dist/assets/{GameTab-Jzsc4yeY.js.map → GameTab-DkGTqKJW.js.map} +1 -1
  20. package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js +2 -0
  21. package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js.map +1 -0
  22. package/extensions/dashboard-client/dist/assets/{MetricsTab-D_XnsEYX.js → MetricsTab-BFo7pKM-.js} +2 -2
  23. package/extensions/dashboard-client/dist/assets/{MetricsTab-D_XnsEYX.js.map → MetricsTab-BFo7pKM-.js.map} +1 -1
  24. package/extensions/dashboard-client/dist/assets/{OverviewTab-BMREvAth.js → OverviewTab-CClCUd3-.js} +2 -2
  25. package/extensions/dashboard-client/dist/assets/{OverviewTab-BMREvAth.js.map → OverviewTab-CClCUd3-.js.map} +1 -1
  26. package/extensions/dashboard-client/dist/assets/{ReposTab-DuyhOktG.js → ReposTab-_3R4RSLZ.js} +2 -2
  27. package/extensions/dashboard-client/dist/assets/{ReposTab-DuyhOktG.js.map → ReposTab-_3R4RSLZ.js.map} +1 -1
  28. package/extensions/dashboard-client/dist/assets/{SessionsTab-mz_vz8i2.js → SessionsTab-CCYd-kSz.js} +2 -2
  29. package/extensions/dashboard-client/dist/assets/{SessionsTab-mz_vz8i2.js.map → SessionsTab-CCYd-kSz.js.map} +1 -1
  30. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B8AIjNTY.js → TimeSavedCard-0TqsrY6y.js} +2 -2
  31. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B8AIjNTY.js.map → TimeSavedCard-0TqsrY6y.js.map} +1 -1
  32. package/extensions/dashboard-client/dist/assets/{TopicsTab-DKuct4oD.js → TopicsTab-DRLJmVv0.js} +2 -2
  33. package/extensions/dashboard-client/dist/assets/{TopicsTab-DKuct4oD.js.map → TopicsTab-DRLJmVv0.js.map} +1 -1
  34. package/extensions/dashboard-client/dist/assets/{TurnsTab-11MGLxY8.js → TurnsTab-CLNM0T5R.js} +2 -2
  35. package/extensions/dashboard-client/dist/assets/{TurnsTab-11MGLxY8.js.map → TurnsTab-CLNM0T5R.js.map} +1 -1
  36. package/extensions/dashboard-client/dist/assets/{index-CqLN7STl.js → index-BcoSz-L4.js} +10 -10
  37. package/extensions/dashboard-client/dist/assets/{index-CqLN7STl.js.map → index-BcoSz-L4.js.map} +1 -1
  38. package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js → useSSE-Cezlih5V.js} +2 -2
  39. package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js.map → useSSE-Cezlih5V.js.map} +1 -1
  40. package/extensions/dashboard-client/dist/index.html +1 -1
  41. package/extensions/dashboard-client/src/App.tsx +5 -1
  42. package/extensions/dashboard-client/src/api/client.ts +23 -0
  43. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +293 -0
  44. package/extensions/dashboard-server/api-contracts/endpoints.ts +30 -0
  45. package/extensions/dashboard-server/api-contracts/index.ts +12 -0
  46. package/extensions/dashboard-server/api-contracts/maintenance.ts +84 -0
  47. package/extensions/dashboard-server/api-contracts.test.ts +1 -1
  48. package/extensions/dashboard-server/api-contracts.ts +9 -0
  49. package/extensions/dashboard-server/routes-maintenance.ts +345 -0
  50. package/extensions/dashboard-server/routes.ts +1 -0
  51. package/extensions/dashboard-server/server.ts +2 -0
  52. package/package.json +1 -1
  53. package/src/store/sqlite/maintenance.ts +20 -1
@@ -1,2 +1,2 @@
1
- import{r as e}from"./index-CqLN7STl.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-CXrQSbVY.js.map
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
@@ -1 +1 @@
1
- {"version":3,"file":"useSSE-CXrQSbVY.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"}
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-CqLN7STl.js"></script>
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,