Skip to content

Commit ef6db61

Browse files
committed
feat: add Memory Dashboard with kanban board, activity feed, and stats
- dashboard/server.ts: HTTP server using Node built-in http module - dashboard/index.html: self-contained dark-themed frontend with kanban board, activity sidebar, stats bar, and 30s auto-refresh - API endpoints: /api/tasks, /api/activity, /api/explorations, /api/notes/daily, /api/stats - Registered in plugin service lifecycle (start/stop) - Dashboard config (enabled, port) in plugin config schema - 8 passing tests with mocked BmClient - No new dependencies
1 parent 178372c commit ef6db61

File tree

6 files changed

+497
-0
lines changed

6 files changed

+497
-0
lines changed

config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ export type CloudConfig = {
66
api_key: string
77
}
88

9+
export type DashboardConfig = {
10+
enabled: boolean
11+
port: number
12+
}
13+
914
export type BasicMemoryConfig = {
1015
project: string
1116
bmPath: string
@@ -18,6 +23,7 @@ export type BasicMemoryConfig = {
1823
recallPrompt: string
1924
debug: boolean
2025
cloud?: CloudConfig
26+
dashboard: DashboardConfig
2127
}
2228

2329
const ALLOWED_KEYS = [
@@ -37,6 +43,7 @@ const ALLOWED_KEYS = [
3743
"recall_prompt",
3844
"debug",
3945
"cloud",
46+
"dashboard",
4047
]
4148

4249
function assertAllowedKeys(
@@ -106,6 +113,22 @@ export function parseConfig(raw: unknown): BasicMemoryConfig {
106113
}
107114
}
108115

116+
let dashboard: DashboardConfig = { enabled: false, port: 3838 }
117+
if (
118+
cfg.dashboard &&
119+
typeof cfg.dashboard === "object" &&
120+
!Array.isArray(cfg.dashboard)
121+
) {
122+
const d = cfg.dashboard as Record<string, unknown>
123+
dashboard = {
124+
enabled: typeof d.enabled === "boolean" ? d.enabled : false,
125+
port:
126+
typeof d.port === "number" && d.port > 0 && d.port < 65536
127+
? d.port
128+
: 3838,
129+
}
130+
}
131+
109132
return {
110133
project:
111134
typeof cfg.project === "string" && cfg.project.length > 0
@@ -144,6 +167,7 @@ export function parseConfig(raw: unknown): BasicMemoryConfig {
144167
: "Check for active tasks and recent activity. Summarize anything relevant to the current session.",
145168
debug: typeof cfg.debug === "boolean" ? cfg.debug : false,
146169
cloud,
170+
dashboard,
147171
}
148172
}
149173

dashboard/index.html

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Memory Dashboard</title>
7+
<style>
8+
* { margin: 0; padding: 0; box-sizing: border-box; }
9+
body { background: #0a0a0a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
10+
code, .mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
11+
12+
/* Stats Bar */
13+
.stats-bar {
14+
display: flex; gap: 24px; padding: 16px 24px;
15+
background: #111; border-bottom: 1px solid #222;
16+
}
17+
.stat { text-align: center; }
18+
.stat-value { font-size: 28px; font-weight: 700; }
19+
.stat-label { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; }
20+
.stat-active .stat-value { color: #3b82f6; }
21+
.stat-done .stat-value { color: #22c55e; }
22+
.stat-explore .stat-value { color: #a855f7; }
23+
.stat-total .stat-value { color: #e0e0e0; }
24+
25+
/* Layout */
26+
.main { display: flex; height: calc(100vh - 70px); }
27+
.kanban-area { flex: 1; overflow-x: auto; padding: 16px; }
28+
.sidebar { width: 320px; border-left: 1px solid #222; padding: 16px; overflow-y: auto; flex-shrink: 0; }
29+
30+
/* Kanban */
31+
.kanban { display: flex; gap: 12px; height: 100%; }
32+
.column { flex: 1; min-width: 220px; background: #111; border-radius: 8px; display: flex; flex-direction: column; }
33+
.column-header { padding: 12px; font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid; }
34+
.col-active .column-header { border-color: #3b82f6; color: #3b82f6; }
35+
.col-blocked .column-header { border-color: #ef4444; color: #ef4444; }
36+
.col-done .column-header { border-color: #22c55e; color: #22c55e; }
37+
.col-abandoned .column-header { border-color: #6b7280; color: #6b7280; }
38+
.column-body { padding: 8px; overflow-y: auto; flex: 1; }
39+
40+
/* Cards */
41+
.card {
42+
background: #1a1a1a; border: 1px solid #333; border-radius: 6px;
43+
padding: 10px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.15s;
44+
}
45+
.card:hover { border-color: #555; }
46+
.card-title { font-size: 13px; font-weight: 600; margin-bottom: 6px; word-break: break-word; }
47+
.card-meta { font-size: 11px; color: #888; font-family: 'SF Mono', monospace; }
48+
.card-meta span { margin-right: 10px; }
49+
.card-detail { display: none; margin-top: 8px; font-size: 12px; color: #aaa; border-top: 1px solid #333; padding-top: 8px; }
50+
.card.expanded .card-detail { display: block; }
51+
52+
/* Sidebar */
53+
.sidebar h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: #888; margin-bottom: 12px; }
54+
.activity-item {
55+
padding: 8px 0; border-bottom: 1px solid #1a1a1a; font-size: 13px;
56+
}
57+
.activity-title { font-weight: 500; }
58+
.activity-time { font-size: 11px; color: #666; font-family: 'SF Mono', monospace; }
59+
60+
/* Refresh indicator */
61+
.refresh { position: fixed; top: 8px; right: 8px; font-size: 11px; color: #444; }
62+
63+
@media (max-width: 900px) {
64+
.main { flex-direction: column; }
65+
.sidebar { width: 100%; border-left: none; border-top: 1px solid #222; max-height: 300px; }
66+
.kanban { flex-wrap: wrap; }
67+
.column { min-width: 180px; }
68+
}
69+
</style>
70+
</head>
71+
<body>
72+
73+
<div class="stats-bar" id="stats-bar">
74+
<div class="stat stat-total"><div class="stat-value" id="stat-total">-</div><div class="stat-label">Total Notes</div></div>
75+
<div class="stat stat-active"><div class="stat-value" id="stat-active">-</div><div class="stat-label">Active Tasks</div></div>
76+
<div class="stat stat-done"><div class="stat-value" id="stat-done">-</div><div class="stat-label">Completed</div></div>
77+
<div class="stat stat-explore"><div class="stat-value" id="stat-explore">-</div><div class="stat-label">Explorations</div></div>
78+
</div>
79+
80+
<div class="main">
81+
<div class="kanban-area">
82+
<div class="kanban">
83+
<div class="column col-active"><div class="column-header">Active <span class="col-count"></span></div><div class="column-body" id="col-active"></div></div>
84+
<div class="column col-blocked"><div class="column-header">Blocked <span class="col-count"></span></div><div class="column-body" id="col-blocked"></div></div>
85+
<div class="column col-done"><div class="column-header">Done <span class="col-count"></span></div><div class="column-body" id="col-done"></div></div>
86+
<div class="column col-abandoned"><div class="column-header">Abandoned <span class="col-count"></span></div><div class="column-body" id="col-abandoned"></div></div>
87+
</div>
88+
</div>
89+
<div class="sidebar">
90+
<h2>Activity Feed</h2>
91+
<div id="activity-feed"></div>
92+
</div>
93+
</div>
94+
95+
<div class="refresh" id="refresh"></div>
96+
97+
<script>
98+
const API = '';
99+
100+
function makeCard(task) {
101+
const fm = task.frontmatter || {};
102+
const status = (fm.status || 'active').toLowerCase();
103+
const step = fm.current_step || '?';
104+
const total = fm.total_steps || '?';
105+
const assigned = fm.assigned_to || '';
106+
const div = document.createElement('div');
107+
div.className = 'card';
108+
div.innerHTML = `
109+
<div class="card-title">${esc(task.title)}</div>
110+
<div class="card-meta">
111+
${assigned ? `<span>👤 ${esc(assigned)}</span>` : ''}
112+
<span>📊 ${esc(String(step))}/${esc(String(total))}</span>
113+
</div>
114+
<div class="card-detail mono">${esc(task.content || '').slice(0, 300)}</div>
115+
`;
116+
div.onclick = () => div.classList.toggle('expanded');
117+
return { el: div, status };
118+
}
119+
120+
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
121+
122+
async function fetchJson(url) {
123+
try { const r = await fetch(url); return r.ok ? r.json() : null; } catch { return null; }
124+
}
125+
126+
async function refresh() {
127+
document.getElementById('refresh').textContent = '🔄';
128+
129+
const [tasks, activity, stats] = await Promise.all([
130+
fetchJson(API + '/api/tasks'),
131+
fetchJson(API + '/api/activity'),
132+
fetchJson(API + '/api/stats'),
133+
]);
134+
135+
// Stats
136+
if (stats) {
137+
document.getElementById('stat-total').textContent = stats.totalNotes;
138+
document.getElementById('stat-active').textContent = stats.activeTasks;
139+
document.getElementById('stat-done').textContent = stats.completedTasks;
140+
document.getElementById('stat-explore').textContent = stats.explorations;
141+
}
142+
143+
// Kanban
144+
const cols = { active: [], blocked: [], done: [], abandoned: [] };
145+
if (tasks) {
146+
for (const t of tasks) {
147+
const { el, status } = makeCard(t);
148+
const bucket = cols[status] ? status : 'active';
149+
cols[bucket].push(el);
150+
}
151+
}
152+
for (const [key, items] of Object.entries(cols)) {
153+
const container = document.getElementById('col-' + key);
154+
container.innerHTML = '';
155+
for (const el of items) container.appendChild(el);
156+
container.parentElement.querySelector('.col-count').textContent = `(${items.length})`;
157+
}
158+
159+
// Activity
160+
const feed = document.getElementById('activity-feed');
161+
feed.innerHTML = '';
162+
if (activity && activity.length) {
163+
for (const a of activity.slice(0, 30)) {
164+
const div = document.createElement('div');
165+
div.className = 'activity-item';
166+
const time = a.created_at ? new Date(a.created_at).toLocaleTimeString() : '';
167+
div.innerHTML = `<div class="activity-title">${esc(a.title)}</div><div class="activity-time">${time}</div>`;
168+
feed.appendChild(div);
169+
}
170+
} else {
171+
feed.innerHTML = '<div style="color:#555">No recent activity</div>';
172+
}
173+
174+
document.getElementById('refresh').textContent = '✓';
175+
setTimeout(() => { document.getElementById('refresh').textContent = ''; }, 2000);
176+
}
177+
178+
refresh();
179+
setInterval(refresh, 30000);
180+
</script>
181+
</body>
182+
</html>

dashboard/server.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from "bun:test"
2+
import type { Server } from "node:http"
3+
import type { BmClient } from "../bm-client.ts"
4+
import { createDashboardServer } from "./server.ts"
5+
6+
describe("dashboard server", () => {
7+
let server: Server
8+
let mockClient: BmClient
9+
let baseUrl: string
10+
11+
beforeAll(async () => {
12+
mockClient = {
13+
search: jest.fn().mockResolvedValue([]),
14+
recentActivity: jest.fn().mockResolvedValue([]),
15+
readNote: jest.fn().mockResolvedValue({
16+
title: "Test",
17+
permalink: "test",
18+
content: "",
19+
file_path: "test.md",
20+
frontmatter: { status: "active", current_step: 1, total_steps: 3, assigned_to: "claw" },
21+
}),
22+
} as any
23+
24+
server = createDashboardServer({ port: 0, client: mockClient })
25+
await new Promise<void>((resolve) => {
26+
server.listen(0, () => resolve())
27+
})
28+
const addr = server.address()
29+
const port = typeof addr === "object" && addr ? addr.port : 0
30+
baseUrl = `http://localhost:${port}`
31+
})
32+
33+
afterAll(async () => {
34+
await new Promise<void>((resolve) => server.close(() => resolve()))
35+
})
36+
37+
beforeEach(() => {
38+
;(mockClient.search as any).mockClear()
39+
;(mockClient.recentActivity as any).mockClear()
40+
;(mockClient.readNote as any).mockClear()
41+
})
42+
43+
it("serves index.html at /", async () => {
44+
const res = await fetch(`${baseUrl}/`)
45+
expect(res.status).toBe(200)
46+
expect(res.headers.get("content-type")).toContain("text/html")
47+
const body = await res.text()
48+
expect(body).toContain("Memory Dashboard")
49+
})
50+
51+
it("returns 404 for unknown routes", async () => {
52+
const res = await fetch(`${baseUrl}/api/unknown`)
53+
expect(res.status).toBe(404)
54+
})
55+
56+
it("GET /api/tasks calls search with type:Task", async () => {
57+
;(mockClient.search as any).mockResolvedValue([
58+
{ title: "My Task", permalink: "tasks/my-task", content: "do stuff", file_path: "tasks/my-task.md" },
59+
])
60+
61+
const res = await fetch(`${baseUrl}/api/tasks`)
62+
expect(res.status).toBe(200)
63+
const data = await res.json()
64+
expect(Array.isArray(data)).toBe(true)
65+
expect(data.length).toBe(1)
66+
expect(data[0].title).toBe("My Task")
67+
expect(data[0].frontmatter).toBeDefined()
68+
69+
expect(mockClient.search).toHaveBeenCalledWith("type:Task", 50, undefined, {
70+
filters: { type: "Task" },
71+
})
72+
})
73+
74+
it("GET /api/activity calls recentActivity", async () => {
75+
;(mockClient.recentActivity as any).mockResolvedValue([
76+
{ title: "Daily Note", permalink: "2026-02-24", file_path: "memory/2026-02-24.md", created_at: "2026-02-24T12:00:00Z" },
77+
])
78+
79+
const res = await fetch(`${baseUrl}/api/activity`)
80+
expect(res.status).toBe(200)
81+
const data = await res.json()
82+
expect(data.length).toBe(1)
83+
expect(data[0].title).toBe("Daily Note")
84+
expect(mockClient.recentActivity).toHaveBeenCalledWith("24h")
85+
})
86+
87+
it("GET /api/explorations searches for type:Exploration", async () => {
88+
;(mockClient.search as any).mockResolvedValue([])
89+
90+
const res = await fetch(`${baseUrl}/api/explorations`)
91+
expect(res.status).toBe(200)
92+
expect(mockClient.search).toHaveBeenCalledWith("type:Exploration", 50, undefined, {
93+
filters: { type: "Exploration" },
94+
})
95+
})
96+
97+
it("GET /api/notes/daily searches for today's date", async () => {
98+
const today = new Date().toISOString().split("T")[0]
99+
;(mockClient.search as any).mockResolvedValue([
100+
{ title: today, permalink: today, content: "daily stuff", file_path: `memory/${today}.md` },
101+
])
102+
103+
const res = await fetch(`${baseUrl}/api/notes/daily`)
104+
expect(res.status).toBe(200)
105+
const data = await res.json()
106+
expect(data.length).toBe(1)
107+
expect(mockClient.search).toHaveBeenCalledWith(today, 5)
108+
})
109+
110+
it("GET /api/stats returns counts", async () => {
111+
;(mockClient.recentActivity as any).mockResolvedValue([{}, {}, {}])
112+
;(mockClient.search as any).mockImplementation(async (query: string) => {
113+
if (query === "type:Task") return [
114+
{ title: "T1", permalink: "t1", content: "", file_path: "t1.md" },
115+
{ title: "T2", permalink: "t2", content: "", file_path: "t2.md" },
116+
]
117+
return [{ title: "E1", permalink: "e1", content: "", file_path: "e1.md" }]
118+
})
119+
;(mockClient.readNote as any)
120+
.mockResolvedValueOnce({ title: "T1", permalink: "t1", content: "", file_path: "t1.md", frontmatter: { status: "active" } })
121+
.mockResolvedValueOnce({ title: "T2", permalink: "t2", content: "", file_path: "t2.md", frontmatter: { status: "done" } })
122+
123+
const res = await fetch(`${baseUrl}/api/stats`)
124+
expect(res.status).toBe(200)
125+
const data = await res.json()
126+
expect(data.totalNotes).toBe(3)
127+
expect(data.activeTasks).toBe(1)
128+
expect(data.completedTasks).toBe(1)
129+
expect(data.explorations).toBe(1)
130+
})
131+
132+
it("returns 500 on client error", async () => {
133+
;(mockClient.search as any).mockRejectedValue(new Error("MCP down"))
134+
135+
const res = await fetch(`${baseUrl}/api/tasks`)
136+
expect(res.status).toBe(500)
137+
const data = await res.json()
138+
expect(data.error).toBe("MCP down")
139+
})
140+
})

0 commit comments

Comments
 (0)