pi-mega-compact 0.8.8 → 0.8.10

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 (88) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/core.js +7 -0
  2. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +104 -0
  3. package/dist/extensions/dashboard-server/api-contracts/game.js +9 -0
  4. package/dist/extensions/dashboard-server/api-contracts/index.js +9 -0
  5. package/dist/extensions/dashboard-server/api-contracts/infrastructure.js +10 -0
  6. package/dist/extensions/dashboard-server/api-contracts/multi-repo.js +9 -0
  7. package/dist/extensions/dashboard-server/api-contracts/snapshot.js +8 -0
  8. package/dist/extensions/dashboard-server/api-contracts.js +8 -0
  9. package/dist/extensions/dashboard-server/api-contracts.test.js +869 -0
  10. package/dist/extensions/dashboard-server/auth.js +18 -0
  11. package/dist/extensions/dashboard-server/helpers.js +37 -0
  12. package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
  13. package/dist/extensions/dashboard-server/html/body-open.js +23 -0
  14. package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
  15. package/dist/extensions/dashboard-server/html/head-open.js +16 -0
  16. package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
  17. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
  18. package/dist/extensions/dashboard-server/html/script.js +259 -0
  19. package/dist/extensions/dashboard-server/html/styles.js +103 -0
  20. package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
  21. package/dist/extensions/dashboard-server/html-template.js +41 -0
  22. package/dist/extensions/dashboard-server/server.js +191 -39
  23. package/dist/extensions/dashboard-server/tailscale.js +8 -0
  24. package/dist/extensions/dashboard-server/types.js +4 -0
  25. package/dist/src/store/sqlite/connection.js +35 -0
  26. package/dist/src/store/sqlite/index-store.js +167 -0
  27. package/dist/src/store/sqlite/memory.js +54 -0
  28. package/dist/src/store/sqlite/minhash-lsh.js +47 -0
  29. package/dist/src/store/sqlite/sessions.js +39 -0
  30. package/dist/src/store/sqlite/transaction.js +19 -0
  31. package/dist/src/vectorStore/add.js +260 -0
  32. package/dist/src/vectorStore/dedup.js +52 -0
  33. package/dist/src/vectorStore/index.js +10 -0
  34. package/dist/src/vectorStore/queries.js +83 -0
  35. package/dist/src/vectorStore/search.js +95 -0
  36. package/dist/src/vectorStore/session.js +19 -0
  37. package/dist/src/vectorStore/store.js +105 -0
  38. package/dist/src/vectorStore/types.js +6 -0
  39. package/dist/src/vectorStore/utils.js +23 -0
  40. package/extensions/dashboard-client/package-lock.json +1771 -0
  41. package/extensions/dashboard-client/package.json +27 -0
  42. package/extensions/dashboard-client/src/App.tsx +82 -0
  43. package/extensions/dashboard-client/src/api/client.ts +145 -0
  44. package/extensions/dashboard-client/src/components/CacheStatusPerModel.tsx +44 -0
  45. package/extensions/dashboard-client/src/components/CompressionCard.tsx +61 -0
  46. package/extensions/dashboard-client/src/components/ContextGauge.tsx +59 -0
  47. package/extensions/dashboard-client/src/components/DataSafetyCard.tsx +49 -0
  48. package/extensions/dashboard-client/src/components/ErrorBoundary.tsx +53 -0
  49. package/extensions/dashboard-client/src/components/EventCategoryFilter.tsx +16 -0
  50. package/extensions/dashboard-client/src/components/EventStream.tsx +152 -0
  51. package/extensions/dashboard-client/src/components/LoadingSpinner.tsx +7 -0
  52. package/extensions/dashboard-client/src/components/MemoryStatusCard.tsx +24 -0
  53. package/extensions/dashboard-client/src/components/ModelBadge.tsx +41 -0
  54. package/extensions/dashboard-client/src/components/PerfChart.tsx +160 -0
  55. package/extensions/dashboard-client/src/components/RepoDetailModal.tsx +126 -0
  56. package/extensions/dashboard-client/src/components/RepoTable.tsx +150 -0
  57. package/extensions/dashboard-client/src/components/SessionInfo.tsx +65 -0
  58. package/extensions/dashboard-client/src/components/SummaryTiles.tsx +52 -0
  59. package/extensions/dashboard-client/src/components/TabBar.tsx +34 -0
  60. package/extensions/dashboard-client/src/components/TriggerStatus.tsx +62 -0
  61. package/extensions/dashboard-client/src/hooks/useApi.ts +77 -0
  62. package/extensions/dashboard-client/src/hooks/useSSE.ts +87 -0
  63. package/extensions/dashboard-client/src/index.html +12 -0
  64. package/extensions/dashboard-client/src/main.tsx +23 -0
  65. package/extensions/dashboard-client/src/styles/base.css +121 -0
  66. package/extensions/dashboard-client/src/styles/overview-events.css +318 -0
  67. package/extensions/dashboard-client/src/styles/repos-metrics.css +307 -0
  68. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +16 -0
  69. package/extensions/dashboard-client/src/tabs/EventsTab.tsx +37 -0
  70. package/extensions/dashboard-client/src/tabs/MetricsTab.tsx +49 -0
  71. package/extensions/dashboard-client/src/tabs/OverviewTab.tsx +80 -0
  72. package/extensions/dashboard-client/src/tabs/ReposTab.tsx +59 -0
  73. package/extensions/dashboard-client/tsconfig.json +28 -0
  74. package/extensions/dashboard-client/vite.config.ts +38 -0
  75. package/extensions/dashboard-server/api-contracts/core.ts +302 -0
  76. package/extensions/dashboard-server/api-contracts/endpoints.ts +476 -0
  77. package/extensions/dashboard-server/api-contracts/game.ts +145 -0
  78. package/extensions/dashboard-server/api-contracts/index.ts +159 -0
  79. package/extensions/dashboard-server/api-contracts/infrastructure.ts +465 -0
  80. package/extensions/dashboard-server/api-contracts/multi-repo.ts +328 -0
  81. package/extensions/dashboard-server/api-contracts/snapshot.ts +386 -0
  82. package/extensions/dashboard-server/api-contracts.test.ts +1050 -0
  83. package/extensions/dashboard-server/api-contracts.ts +96 -0
  84. package/extensions/dashboard-server/auth.ts +20 -0
  85. package/extensions/dashboard-server/server.ts +862 -582
  86. package/extensions/dashboard-server/tailscale.ts +7 -0
  87. package/extensions/dashboard-server/types.ts +23 -114
  88. package/package.json +2 -1
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "pi-mega-compact-dashboard-client",
3
+ "version": "0.8.9",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "React + Vite frontend for the pi-mega-compact dashboard server (Sprint B1 scaffold).",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "vite build",
10
+ "preview": "vite preview",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "react": "^18.3.1",
15
+ "react-dom": "^18.3.1"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^18.3.12",
19
+ "@types/react-dom": "^18.3.1",
20
+ "@vitejs/plugin-react": "^4.3.4",
21
+ "typescript": "^5.6.3",
22
+ "vite": "^5.4.11"
23
+ },
24
+ "allowScripts": {
25
+ "esbuild@0.21.5": true
26
+ }
27
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * dashboard-client/src/App.tsx — Dashboard shell layout.
3
+ *
4
+ * SPRINT-B1: React scaffold with tab routing, header, error boundary.
5
+ * SPRINT-C1+: tabs wired progressively with real content.
6
+ */
7
+
8
+ import React, { useState, useCallback } from "react";
9
+ import { ErrorBoundary } from "./components/ErrorBoundary";
10
+ import { TabBar } from "./components/TabBar";
11
+ import { LoadingSpinner } from "./components/LoadingSpinner";
12
+ import { useApi } from "./hooks/useApi";
13
+ import { fetchSnapshot } from "./api/client";
14
+ import type { SnapshotResponse } from "@contracts";
15
+
16
+ // Tab components — lazy-loaded. C1 fills Overview + Events; C2/C3 fill the rest.
17
+ const OverviewTab = React.lazy(() => import("./tabs/OverviewTab"));
18
+ const ReposTab = React.lazy(() => import("./tabs/ReposTab"));
19
+ const EventsTab = React.lazy(() => import("./tabs/EventsTab"));
20
+ const ConfigTab = React.lazy(() => import("./tabs/ConfigTab"));
21
+ const MetricsTab = React.lazy(() => import("./tabs/MetricsTab"));
22
+
23
+ // SPRINT-C2/C3-REMAINING: GameTab, DiagnosticsPanel lazy imports.
24
+
25
+ export type TabId = "overview" | "repos" | "events" | "config" | "metrics";
26
+
27
+ const TABS: Array<{ id: TabId; label: string }> = [
28
+ { id: "overview", label: "Overview" },
29
+ { id: "repos", label: "Repos" },
30
+ { id: "events", label: "Events" },
31
+ { id: "config", label: "Config" },
32
+ { id: "metrics", label: "Metrics" },
33
+ ];
34
+
35
+ export default function App(): React.ReactElement {
36
+ const [activeTab, setActiveTab] = useState<TabId>("overview");
37
+ const {
38
+ data: snapshot,
39
+ loading,
40
+ error,
41
+ } = useApi<SnapshotResponse>(
42
+ useCallback(() => fetchSnapshot(), []),
43
+ {
44
+ // Poll every 5s so Overview stays live without SSE. D1 will add retry/stale.
45
+ pollInterval: 5000,
46
+ },
47
+ );
48
+
49
+ const tier = snapshot?.tier ?? "unknown";
50
+ const version = snapshot?.model?.name ?? "";
51
+
52
+ return (
53
+ <ErrorBoundary>
54
+ <div className="dashboard-app">
55
+ <header className="dashboard-header">
56
+ <h1>
57
+ mega-compact dashboard
58
+ <span className="tier">{tier}</span>
59
+ {version && <span className="version-pill">{version}</span>}
60
+ </h1>
61
+ </header>
62
+ <TabBar tabs={TABS} active={activeTab} onTabChange={setActiveTab} />
63
+ <main className="dashboard-content">
64
+ <React.Suspense fallback={<LoadingSpinner />}>
65
+ {activeTab === "overview" && (
66
+ <OverviewTab
67
+ snapshot={snapshot}
68
+ loading={loading}
69
+ error={error}
70
+ />
71
+ )}
72
+ {activeTab === "repos" && <ReposTab />}
73
+ {activeTab === "events" && <EventsTab />}
74
+ {activeTab === "config" && <ConfigTab />}
75
+ {activeTab === "metrics" && <MetricsTab />}
76
+ {/* SPRINT-C2/C3-REMAINING: Game tab, diagnostics panel */}
77
+ </React.Suspense>
78
+ </main>
79
+ </div>
80
+ </ErrorBoundary>
81
+ );
82
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * dashboard-client/src/api/client.ts — typed fetch wrappers using A1 contracts.
3
+ *
4
+ * PREVENT-PI-004: every request targets a relative path (loopback-only —
5
+ * the dashboard server is the same origin that serves this static bundle).
6
+ * No absolute URLs, no external hosts.
7
+ *
8
+ * Uses the ENDPOINTS registry from A1 as the single source of truth for
9
+ * paths + methods. Response types come from the api-contracts domain modules.
10
+ */
11
+
12
+ import { ENDPOINTS } from "@contracts";
13
+ import type {
14
+ SnapshotResponse,
15
+ VersionResponse,
16
+ IndexesSummaryResponse,
17
+ IndexFallbackResponse,
18
+ ReposResponse,
19
+ SummaryResponse,
20
+ DriftReportResponse,
21
+ ServersResponse,
22
+ GameStateResponse,
23
+ GameStatePatch,
24
+ GameScoreRow,
25
+ GameScoresQuery,
26
+ PerfResponse,
27
+ PerfQuery,
28
+ AchievementRow,
29
+ } from "@contracts";
30
+
31
+ /** Error thrown when a dashboard API response is not 2xx. */
32
+ export class ApiError extends Error {
33
+ readonly status: number;
34
+ constructor(status: number, message: string) {
35
+ super(`dashboard API ${status}: ${message}`);
36
+ this.name = "ApiError";
37
+ this.status = status;
38
+ }
39
+ }
40
+
41
+ /** Internal: typed GET that throws ApiError on non-2xx. */
42
+ async function getJson<T>(path: string): Promise<T> {
43
+ // guardrails-allow PREVENT-PI-004: relative-path fetch to same-origin dashboard server (loopback-only, static bundle served by the same Node HTTP server).
44
+ const res = await fetch(path);
45
+ if (!res.ok) {
46
+ throw new ApiError(
47
+ res.status,
48
+ await res.text().catch(() => res.statusText),
49
+ );
50
+ }
51
+ return res.json() as Promise<T>;
52
+ }
53
+
54
+ /** Internal: typed PUT that throws ApiError on non-2xx. */
55
+ async function putJson<T>(path: string, body: unknown): Promise<T> {
56
+ // guardrails-allow PREVENT-PI-004: relative-path fetch to same-origin dashboard server (loopback-only).
57
+ const res = await fetch(path, {
58
+ method: "PUT",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify(body),
61
+ });
62
+ if (!res.ok) {
63
+ throw new ApiError(
64
+ res.status,
65
+ await res.text().catch(() => res.statusText),
66
+ );
67
+ }
68
+ return res.json() as Promise<T>;
69
+ }
70
+
71
+ /** Build a query string from a record, skipping undefined/null values. */
72
+ function query(
73
+ params: Record<string, string | number | undefined | null>,
74
+ ): string {
75
+ const sp = new URLSearchParams();
76
+ for (const [k, v] of Object.entries(params)) {
77
+ if (v !== undefined && v !== null) sp.set(k, String(v));
78
+ }
79
+ const qs = sp.toString();
80
+ return qs ? `?${qs}` : "";
81
+ }
82
+
83
+ // ─── Endpoint wrappers ──────────────────────────────────────────────────────
84
+
85
+ export function fetchSnapshot(): Promise<SnapshotResponse> {
86
+ return getJson<SnapshotResponse>(ENDPOINTS.snapshot.path);
87
+ }
88
+
89
+ export function fetchVersion(): Promise<VersionResponse> {
90
+ return getJson<VersionResponse>(ENDPOINTS.version.path);
91
+ }
92
+
93
+ export function fetchIndex(): Promise<
94
+ IndexesSummaryResponse | IndexFallbackResponse
95
+ > {
96
+ return getJson<IndexesSummaryResponse | IndexFallbackResponse>(
97
+ ENDPOINTS.index.path,
98
+ );
99
+ }
100
+
101
+ export function fetchRepos(activeHours?: number): Promise<ReposResponse> {
102
+ return getJson<ReposResponse>(
103
+ `${ENDPOINTS.repos.path}${query({ active: activeHours ? `${activeHours}h` : undefined })}`,
104
+ );
105
+ }
106
+
107
+ export function fetchSummary(): Promise<SummaryResponse> {
108
+ return getJson<SummaryResponse>(ENDPOINTS.summary.path);
109
+ }
110
+
111
+ export function fetchDrift(): Promise<DriftReportResponse> {
112
+ return getJson<DriftReportResponse>(ENDPOINTS.drift.path);
113
+ }
114
+
115
+ export function fetchServers(): Promise<ServersResponse> {
116
+ return getJson<ServersResponse>(ENDPOINTS.servers.path);
117
+ }
118
+
119
+ export function fetchGameState(): Promise<GameStateResponse> {
120
+ return getJson<GameStateResponse>(ENDPOINTS.getGameState.path);
121
+ }
122
+
123
+ export function putGameState(
124
+ patch: GameStatePatch,
125
+ ): Promise<GameStateResponse> {
126
+ return putJson<GameStateResponse>(ENDPOINTS.putGameState.path, patch);
127
+ }
128
+
129
+ export function fetchGameScores(
130
+ params: GameScoresQuery = {},
131
+ ): Promise<GameScoreRow[]> {
132
+ return getJson<GameScoreRow[]>(
133
+ `${ENDPOINTS.gameScores.path}${query({ metric: params.metric, limit: params.limit })}`,
134
+ );
135
+ }
136
+
137
+ export function fetchPerf(params: PerfQuery = {}): Promise<PerfResponse> {
138
+ return getJson<PerfResponse>(
139
+ `${ENDPOINTS.perf.path}${query({ minutes: params.minutes })}`,
140
+ );
141
+ }
142
+
143
+ export function fetchAchievements(): Promise<AchievementRow[]> {
144
+ return getJson<AchievementRow[]>(ENDPOINTS.achievements.path);
145
+ }
@@ -0,0 +1,44 @@
1
+ import type React from "react";
2
+ export interface CacheRow {
3
+ model: string;
4
+ provider: string;
5
+ hits: number;
6
+ total: number;
7
+ saved: string;
8
+ pct: number;
9
+ compactions: number;
10
+ }
11
+ export function CacheStatusPerModel({
12
+ rows,
13
+ }: {
14
+ rows: CacheRow[];
15
+ }): React.ReactElement {
16
+ return (
17
+ <div className="cache-per-model">
18
+ <table>
19
+ <thead>
20
+ <tr>
21
+ <th>Model</th>
22
+ <th>Provider</th>
23
+ <th>Hits</th>
24
+ <th>Saved</th>
25
+ <th>Pct</th>
26
+ <th>Comp</th>
27
+ </tr>
28
+ </thead>
29
+ <tbody>
30
+ {rows.map((r) => (
31
+ <tr key={r.model}>
32
+ <td>{r.model}</td>
33
+ <td>{r.provider}</td>
34
+ <td>{r.hits}</td>
35
+ <td>{r.saved}</td>
36
+ <td>{r.pct}%</td>
37
+ <td>{r.compactions}</td>
38
+ </tr>
39
+ ))}
40
+ </tbody>
41
+ </table>
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * dashboard-client/src/components/CompressionCard.tsx — compression stats.
3
+ *
4
+ * Stat grid: tokens in → out, freed amount highlighted, compression %,
5
+ * dedup contribution %. Consumes snapshot.compression.repo.
6
+ */
7
+
8
+ import type React from "react";
9
+
10
+ export interface CompressionCardProps {
11
+ /** Repo-level compression stats (tokens in/out/freed + percentages). */
12
+ tokensIn: number;
13
+ tokensOut: number;
14
+ tokensFreed: number;
15
+ /** Compression ratio (percent, 0–100). */
16
+ compressionPct: number;
17
+ /** Dedup contribution (percent, 0–100). */
18
+ dedupPct: number;
19
+ }
20
+
21
+ function fmt(n: number): string {
22
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
23
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
24
+ return String(n);
25
+ }
26
+
27
+ export function CompressionCard({
28
+ tokensIn,
29
+ tokensOut,
30
+ tokensFreed,
31
+ compressionPct,
32
+ dedupPct,
33
+ }: CompressionCardProps): React.ReactElement {
34
+ return (
35
+ <div className="card compression-card">
36
+ <h3>Compression (repo)</h3>
37
+ <div className="stat-grid">
38
+ <div className="stat">
39
+ <span className="stat-label">In</span>
40
+ <span className="stat-value">{fmt(tokensIn)}</span>
41
+ </div>
42
+ <div className="stat">
43
+ <span className="stat-label">Out</span>
44
+ <span className="stat-value">{fmt(tokensOut)}</span>
45
+ </div>
46
+ <div className="stat stat-highlight">
47
+ <span className="stat-label">Freed</span>
48
+ <span className="stat-value">{fmt(tokensFreed)}</span>
49
+ </div>
50
+ <div className="stat">
51
+ <span className="stat-label">Ratio</span>
52
+ <span className="stat-value">{compressionPct.toFixed(1)}%</span>
53
+ </div>
54
+ <div className="stat">
55
+ <span className="stat-label">Dedup</span>
56
+ <span className="stat-value">{dedupPct.toFixed(1)}%</span>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * dashboard-client/src/components/ContextGauge.tsx — token usage meter.
3
+ *
4
+ * Color-coded percent fill bar: green <60%, yellow 60–80%, red >80%.
5
+ * Sublabel: "{tokens} / {contextWindow} tokens ({percent}%)".
6
+ */
7
+
8
+ import type React from "react";
9
+
10
+ export interface ContextGaugeProps {
11
+ /** Current token count in the context window, or null if unknown. */
12
+ tokens: number | null;
13
+ /** Context window usage percent (0–100), or null if unknown. */
14
+ percent: number | null;
15
+ /** Maximum context window size for the active model. */
16
+ contextWindow: number;
17
+ }
18
+
19
+ function severityClass(percent: number): string {
20
+ if (percent >= 80) return "gauge-red";
21
+ if (percent >= 60) return "gauge-yellow";
22
+ return "gauge-green";
23
+ }
24
+
25
+ function formatTokens(n: number): string {
26
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
27
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
28
+ return String(n);
29
+ }
30
+
31
+ export function ContextGauge({
32
+ tokens,
33
+ percent,
34
+ contextWindow,
35
+ }: ContextGaugeProps): React.ReactElement {
36
+ const pct = percent ?? 0;
37
+ const fillWidth = Math.max(0, Math.min(100, pct));
38
+ const cls = severityClass(pct);
39
+ const label =
40
+ tokens !== null && tokens !== undefined
41
+ ? `${formatTokens(tokens)} / ${formatTokens(contextWindow)} tokens (${pct.toFixed(1)}%)`
42
+ : `${formatTokens(contextWindow)} window (usage unknown)`;
43
+
44
+ return (
45
+ <div className="card context-gauge">
46
+ <h3>Context</h3>
47
+ <div
48
+ className={`gauge-bar ${cls}`}
49
+ role="meter"
50
+ aria-valuenow={pct}
51
+ aria-valuemin={0}
52
+ aria-valuemax={100}
53
+ >
54
+ <div className="gauge-fill" style={{ width: `${fillWidth}%` }} />
55
+ </div>
56
+ <p className="gauge-label">{label}</p>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * dashboard-client/src/components/DataSafetyCard.tsx — Data Safety / Shield.
3
+ *
4
+ * Shows regions retained, compressed-original size, storage dedup %,
5
+ * and permanently deleted count. Read-only (no mutations).
6
+ */
7
+ import type React from "react";
8
+
9
+ export interface DataSafetyCardProps {
10
+ regionsRetained: number;
11
+ compressedOriginalKiB: number;
12
+ storageDedupPct: number;
13
+ permanentlyDeleted: number;
14
+ }
15
+
16
+ export function DataSafetyCard({
17
+ regionsRetained,
18
+ compressedOriginalKiB,
19
+ storageDedupPct,
20
+ permanentlyDeleted,
21
+ }: DataSafetyCardProps): React.ReactElement {
22
+ return (
23
+ <div className="data-safety-card">
24
+ <h4>Data Safety</h4>
25
+ <div className="safety-grid">
26
+ <div className="safety-row">
27
+ <span className="label">Regions Retained</span>
28
+ <span className="value">{regionsRetained}</span>
29
+ </div>
30
+ <div className="safety-row">
31
+ <span className="label">Compressed-Original</span>
32
+ <span className="value">{compressedOriginalKiB.toFixed(1)} KiB</span>
33
+ </div>
34
+ <div className="safety-row">
35
+ <span className="label">Storage Dedup</span>
36
+ <span className="value">{storageDedupPct}%</span>
37
+ </div>
38
+ <div className="safety-row">
39
+ <span className="label">Permanently Deleted</span>
40
+ <span className="value">{permanentlyDeleted}</span>
41
+ </div>
42
+ </div>
43
+ <div className="safety-note">
44
+ Every compacted region is kept verbatim (compressed). "Drop" = removed
45
+ from the live window only. We never delete your data.
46
+ </div>
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * dashboard-client/src/components/ErrorBoundary.tsx — React error boundary.
3
+ *
4
+ * Catches render errors anywhere in the child tree and shows a fallback
5
+ * with a reload button. Re-throws are prevented; recovery is via reload.
6
+ */
7
+
8
+ import React, { type ReactNode } from "react";
9
+
10
+ interface ErrorBoundaryProps {
11
+ children: ReactNode;
12
+ }
13
+
14
+ interface ErrorBoundaryState {
15
+ hasError: boolean;
16
+ error: Error | null;
17
+ }
18
+
19
+ export class ErrorBoundary extends React.Component<
20
+ ErrorBoundaryProps,
21
+ ErrorBoundaryState
22
+ > {
23
+ override state: ErrorBoundaryState = { hasError: false, error: null };
24
+
25
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
26
+ return { hasError: true, error };
27
+ }
28
+
29
+ override componentDidCatch(error: Error, info: React.ErrorInfo): void {
30
+ // Keep the error visible in the console for debugging; no network reporting
31
+ // (PREVENT-PI-004 — dashboard is fully local).
32
+ console.error("[dashboard] render error:", error, info.componentStack);
33
+ }
34
+
35
+ private handleReload = (): void => {
36
+ window.location.reload();
37
+ };
38
+
39
+ override render(): ReactNode {
40
+ if (this.state.hasError) {
41
+ return (
42
+ <div className="error-fallback">
43
+ <h2>Something went wrong</h2>
44
+ <p>{this.state.error?.message ?? "Unknown render error"}</p>
45
+ <button type="button" onClick={this.handleReload}>
46
+ Reload dashboard
47
+ </button>
48
+ </div>
49
+ );
50
+ }
51
+ return this.props.children;
52
+ }
53
+ }
@@ -0,0 +1,16 @@
1
+ import type React from "react";
2
+ export function EventCategoryFilter({
3
+ onFilter,
4
+ }: {
5
+ onFilter: (f: string) => void;
6
+ }): React.ReactElement {
7
+ return (
8
+ <div className="event-filter">
9
+ <button onClick={() => onFilter("all")}>All</button>
10
+ <button onClick={() => onFilter("agent_start")}>Agent Start</button>
11
+ <button onClick={() => onFilter("agent_end")}>Agent End</button>
12
+ <button onClick={() => onFilter("turn_start")}>Turn Start</button>
13
+ <button onClick={() => onFilter("turn_end")}>Turn End</button>
14
+ </div>
15
+ );
16
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * dashboard-client/src/components/EventStream.tsx — SSE event list with filters.
3
+ *
4
+ * Type badge colored by event type. Timestamp + expandable detail row.
5
+ * Type filter chips (all + per-type). Virtualization: manual windowing for
6
+ * 500+ events (render last N visible).
7
+ */
8
+
9
+ import type React from "react";
10
+ import { useState, useMemo } from "react";
11
+ import type { SseEvent } from "@contracts";
12
+
13
+ export interface EventStreamProps {
14
+ events: SseEvent[];
15
+ }
16
+
17
+ const TYPE_COLORS: Record<string, string> = {
18
+ compact_start: "ev-compact",
19
+ compact_end: "ev-compact",
20
+ compact_trigger: "ev-trigger",
21
+ compact_skip: "ev-trigger",
22
+ tier_changed: "ev-tier",
23
+ model_changed: "ev-tier",
24
+ pressure_lifted: "ev-tier",
25
+ checkpoint_persisted: "ev-checkpoint",
26
+ recall_inject: "ev-recall",
27
+ anchors_updated: "ev-recall",
28
+ config_updated: "ev-config",
29
+ config_preset: "ev-config",
30
+ crew_presence_changed: "ev-crew",
31
+ crew_turn_changed: "ev-crew",
32
+ crew_bandit_chosen: "ev-crew",
33
+ game_ritual_start: "ev-game",
34
+ game_ritual_stage: "ev-game",
35
+ game_ritual_end: "ev-game",
36
+ game_mode_changed: "ev-game",
37
+ game_render: "ev-game",
38
+ };
39
+
40
+ // Filterable event types (spec: compact_start/end, recall_inject, checkpoint_persisted + all).
41
+ const FILTER_TYPES = [
42
+ "all",
43
+ "compact_start",
44
+ "compact_end",
45
+ "recall_inject",
46
+ "checkpoint_persisted",
47
+ ] as const;
48
+ type FilterType = (typeof FILTER_TYPES)[number];
49
+
50
+ const RENDER_WINDOW = 200; // render last 200 to keep DOM light under 500+ events
51
+
52
+ function summarize(ev: SseEvent): string {
53
+ switch (ev.type) {
54
+ case "compact_start":
55
+ return `trigger=${ev.trigger} session=${ev.sessionId.slice(0, 8)}`;
56
+ case "compact_end":
57
+ return `freed=${ev.tokensFreed} ok=${ev.success} cp=${ev.checkpointId.slice(0, 8)}`;
58
+ case "compact_trigger":
59
+ return `pressure=${ev.pressure}% threshold=${ev.threshold}% armed=${ev.armed}`;
60
+ case "compact_skip":
61
+ return `reason=${ev.reason}`;
62
+ case "tier_changed":
63
+ return `${ev.from} → ${ev.to} ctx=${ev.contextPct}%`;
64
+ case "model_changed":
65
+ return `${ev.providerName}/${ev.model}`;
66
+ case "pressure_lifted":
67
+ return `${ev.beforePct}% → ${ev.afterPct}%`;
68
+ case "checkpoint_persisted":
69
+ return `cp=${ev.checkpointId.slice(0, 8)} sessionTok=${ev.sessionTokens}`;
70
+ case "recall_inject":
71
+ return `q="${ev.query.slice(0, 40)}" chunks=${ev.chunks} tok=${ev.tokens}`;
72
+ case "anchors_updated":
73
+ return `count=${ev.count} pinned=${ev.pinned}`;
74
+ case "config_updated":
75
+ return `key=${ev.key}`;
76
+ case "config_preset":
77
+ return `preset=${ev.preset}`;
78
+ case "crew_presence_changed":
79
+ return `agents=${ev.activeAgents} turn=${ev.currentTurn}`;
80
+ case "crew_turn_changed":
81
+ return `turn=${ev.turnIndex} agent=${ev.agentName}`;
82
+ case "crew_bandit_chosen":
83
+ return `agent=${ev.chosenAgent} score=${ev.score} regret=${ev.regret}`;
84
+ case "game_ritual_start":
85
+ case "game_ritual_stage":
86
+ case "game_ritual_end":
87
+ case "game_mode_changed":
88
+ case "game_render":
89
+ return `stage=${ev.type}`;
90
+ }
91
+ }
92
+
93
+ function formatTs(ts: string): string {
94
+ try {
95
+ const d = new Date(ts);
96
+ return d.toLocaleTimeString();
97
+ } catch {
98
+ return ts;
99
+ }
100
+ }
101
+
102
+ export function EventStream({ events }: EventStreamProps): React.ReactElement {
103
+ const [filter, setFilter] = useState<FilterType>("all");
104
+ const [expanded, setExpanded] = useState<number | null>(null);
105
+
106
+ const filtered = useMemo(() => {
107
+ const list =
108
+ filter === "all" ? events : events.filter((e) => e.type === filter);
109
+ // Newest first; render only the last RENDER_WINDOW to keep DOM light.
110
+ return list.slice(-RENDER_WINDOW).reverse();
111
+ }, [events, filter]);
112
+
113
+ return (
114
+ <div className="event-stream">
115
+ <div className="event-filters" role="toolbar">
116
+ {FILTER_TYPES.map((t) => (
117
+ <button
118
+ key={t}
119
+ type="button"
120
+ className={`filter-chip ${filter === t ? "active" : ""}`}
121
+ onClick={() => setFilter(t)}
122
+ >
123
+ {t === "all" ? "all" : t.replace(/_/g, " ")}
124
+ </button>
125
+ ))}
126
+ <span className="event-count">{events.length} buffered</span>
127
+ </div>
128
+ <ul className="event-list">
129
+ {filtered.length === 0 && (
130
+ <li className="event-empty">No events yet.</li>
131
+ )}
132
+ {filtered.map((ev, idx) => {
133
+ const cls = TYPE_COLORS[ev.type] ?? "ev-default";
134
+ return (
135
+ <li
136
+ key={`${ev.ts}-${idx}`}
137
+ className={`event-row ${cls}`}
138
+ onClick={() => setExpanded(expanded === idx ? null : idx)}
139
+ >
140
+ <span className="ev-time">{formatTs(ev.ts)}</span>
141
+ <span className="ev-type">{ev.type}</span>
142
+ <span className="ev-summary">{summarize(ev)}</span>
143
+ {expanded === idx && (
144
+ <pre className="ev-detail">{JSON.stringify(ev, null, 2)}</pre>
145
+ )}
146
+ </li>
147
+ );
148
+ })}
149
+ </ul>
150
+ </div>
151
+ );
152
+ }