backlog-mcp 0.31.0 → 0.32.0
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/events/event-bus.d.mts +27 -0
- package/dist/events/event-bus.mjs +1 -0
- package/dist/events/index.d.mts +9 -0
- package/dist/events/index.mjs +9 -0
- package/dist/events/index.mjs.map +1 -0
- package/dist/events/local-event-bus.d.mts +16 -0
- package/dist/events/local-event-bus.mjs +39 -0
- package/dist/events/local-event-bus.mjs.map +1 -0
- package/dist/operations/middleware.d.mts +1 -1
- package/dist/operations/middleware.mjs +23 -2
- package/dist/operations/middleware.mjs.map +1 -1
- package/dist/server/viewer-routes.mjs +32 -0
- package/dist/server/viewer-routes.mjs.map +1 -1
- package/dist/viewer/main.js +50 -30
- package/package.json +1 -1
- package/viewer/components/activity-panel.ts +4 -31
- package/viewer/components/task-detail.ts +12 -1
- package/viewer/components/task-list.ts +33 -26
- package/viewer/main.ts +4 -0
- package/viewer/services/event-source-client.ts +62 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/events/event-bus.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* EventBus interface and event types for real-time viewer updates.
|
|
4
|
+
*
|
|
5
|
+
* The interface is designed to be pluggable: start with in-process
|
|
6
|
+
* EventEmitter (LocalEventBus), swap to Redis Pub/Sub or NATS
|
|
7
|
+
* for cloud deployment without changing consumers.
|
|
8
|
+
*/
|
|
9
|
+
type BacklogEventType = 'task_changed' | 'task_created' | 'task_deleted' | 'resource_changed';
|
|
10
|
+
interface BacklogEvent {
|
|
11
|
+
seq: number;
|
|
12
|
+
type: BacklogEventType;
|
|
13
|
+
id: string;
|
|
14
|
+
tool: string;
|
|
15
|
+
actor: string;
|
|
16
|
+
ts: string;
|
|
17
|
+
}
|
|
18
|
+
type BacklogEventCallback = (event: BacklogEvent) => void;
|
|
19
|
+
interface EventBus {
|
|
20
|
+
emit(event: Omit<BacklogEvent, 'seq'>): void;
|
|
21
|
+
subscribe(callback: BacklogEventCallback): void;
|
|
22
|
+
unsubscribe(callback: BacklogEventCallback): void;
|
|
23
|
+
replaySince(seq: number): BacklogEvent[];
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
export { BacklogEvent, BacklogEventCallback, BacklogEventType, EventBus };
|
|
27
|
+
//# sourceMappingURL=event-bus.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { BacklogEvent, BacklogEventCallback, BacklogEventType, EventBus } from "./event-bus.mjs";
|
|
2
|
+
import { LocalEventBus } from "./local-event-bus.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/events/index.d.ts
|
|
5
|
+
/** Singleton event bus instance. Swap implementation for cloud deployment. */
|
|
6
|
+
declare const eventBus: LocalEventBus;
|
|
7
|
+
//#endregion
|
|
8
|
+
export { type BacklogEvent, type BacklogEventCallback, type BacklogEventType, type EventBus, LocalEventBus, eventBus };
|
|
9
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { LocalEventBus } from "./local-event-bus.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/events/index.ts
|
|
4
|
+
/** Singleton event bus instance. Swap implementation for cloud deployment. */
|
|
5
|
+
const eventBus = new LocalEventBus();
|
|
6
|
+
|
|
7
|
+
//#endregion
|
|
8
|
+
export { LocalEventBus, eventBus };
|
|
9
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/events/index.ts"],"sourcesContent":["export type { BacklogEvent, BacklogEventType, BacklogEventCallback, EventBus } from './event-bus.js';\nexport { LocalEventBus } from './local-event-bus.js';\n\nimport { LocalEventBus } from './local-event-bus.js';\n\n/** Singleton event bus instance. Swap implementation for cloud deployment. */\nexport const eventBus = new LocalEventBus();\n"],"mappings":";;;;AAMA,MAAa,WAAW,IAAI,eAAe"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { BacklogEvent, BacklogEventCallback, EventBus } from "./event-bus.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/events/local-event-bus.d.ts
|
|
4
|
+
declare class LocalEventBus implements EventBus {
|
|
5
|
+
private emitter;
|
|
6
|
+
private seq;
|
|
7
|
+
private buffer;
|
|
8
|
+
constructor();
|
|
9
|
+
emit(event: Omit<BacklogEvent, 'seq'>): void;
|
|
10
|
+
subscribe(callback: BacklogEventCallback): void;
|
|
11
|
+
unsubscribe(callback: BacklogEventCallback): void;
|
|
12
|
+
replaySince(seq: number): BacklogEvent[];
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
export { LocalEventBus };
|
|
16
|
+
//# sourceMappingURL=local-event-bus.d.mts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
|
|
3
|
+
//#region src/events/local-event-bus.ts
|
|
4
|
+
/**
|
|
5
|
+
* In-process EventBus implementation using Node.js EventEmitter.
|
|
6
|
+
* Maintains a ring buffer for replay on SSE reconnect.
|
|
7
|
+
*/
|
|
8
|
+
const RING_BUFFER_SIZE = 1e3;
|
|
9
|
+
const EVENT_NAME = "backlog";
|
|
10
|
+
var LocalEventBus = class {
|
|
11
|
+
emitter = new EventEmitter();
|
|
12
|
+
seq = 0;
|
|
13
|
+
buffer = [];
|
|
14
|
+
constructor() {
|
|
15
|
+
this.emitter.setMaxListeners(100);
|
|
16
|
+
}
|
|
17
|
+
emit(event) {
|
|
18
|
+
const full = {
|
|
19
|
+
...event,
|
|
20
|
+
seq: ++this.seq
|
|
21
|
+
};
|
|
22
|
+
if (this.buffer.length >= RING_BUFFER_SIZE) this.buffer.shift();
|
|
23
|
+
this.buffer.push(full);
|
|
24
|
+
this.emitter.emit(EVENT_NAME, full);
|
|
25
|
+
}
|
|
26
|
+
subscribe(callback) {
|
|
27
|
+
this.emitter.on(EVENT_NAME, callback);
|
|
28
|
+
}
|
|
29
|
+
unsubscribe(callback) {
|
|
30
|
+
this.emitter.off(EVENT_NAME, callback);
|
|
31
|
+
}
|
|
32
|
+
replaySince(seq) {
|
|
33
|
+
return this.buffer.filter((e) => e.seq > seq);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
export { LocalEventBus };
|
|
39
|
+
//# sourceMappingURL=local-event-bus.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-event-bus.mjs","names":[],"sources":["../../src/events/local-event-bus.ts"],"sourcesContent":["/**\n * In-process EventBus implementation using Node.js EventEmitter.\n * Maintains a ring buffer for replay on SSE reconnect.\n */\n\nimport { EventEmitter } from 'node:events';\nimport type { BacklogEvent, BacklogEventCallback, EventBus } from './event-bus.js';\n\nconst RING_BUFFER_SIZE = 1000;\nconst EVENT_NAME = 'backlog';\n\nexport class LocalEventBus implements EventBus {\n private emitter = new EventEmitter();\n private seq = 0;\n private buffer: BacklogEvent[] = [];\n\n constructor() {\n this.emitter.setMaxListeners(100);\n }\n\n emit(event: Omit<BacklogEvent, 'seq'>): void {\n const full: BacklogEvent = { ...event, seq: ++this.seq };\n\n // Ring buffer: drop oldest when full\n if (this.buffer.length >= RING_BUFFER_SIZE) {\n this.buffer.shift();\n }\n this.buffer.push(full);\n\n this.emitter.emit(EVENT_NAME, full);\n }\n\n subscribe(callback: BacklogEventCallback): void {\n this.emitter.on(EVENT_NAME, callback);\n }\n\n unsubscribe(callback: BacklogEventCallback): void {\n this.emitter.off(EVENT_NAME, callback);\n }\n\n replaySince(seq: number): BacklogEvent[] {\n return this.buffer.filter(e => e.seq > seq);\n }\n}\n"],"mappings":";;;;;;;AAQA,MAAM,mBAAmB;AACzB,MAAM,aAAa;AAEnB,IAAa,gBAAb,MAA+C;CAC7C,AAAQ,UAAU,IAAI,cAAc;CACpC,AAAQ,MAAM;CACd,AAAQ,SAAyB,EAAE;CAEnC,cAAc;AACZ,OAAK,QAAQ,gBAAgB,IAAI;;CAGnC,KAAK,OAAwC;EAC3C,MAAM,OAAqB;GAAE,GAAG;GAAO,KAAK,EAAE,KAAK;GAAK;AAGxD,MAAI,KAAK,OAAO,UAAU,iBACxB,MAAK,OAAO,OAAO;AAErB,OAAK,OAAO,KAAK,KAAK;AAEtB,OAAK,QAAQ,KAAK,YAAY,KAAK;;CAGrC,UAAU,UAAsC;AAC9C,OAAK,QAAQ,GAAG,YAAY,SAAS;;CAGvC,YAAY,UAAsC;AAChD,OAAK,QAAQ,IAAI,YAAY,SAAS;;CAGxC,YAAY,KAA6B;AACvC,SAAO,KAAK,OAAO,QAAO,MAAK,EAAE,MAAM,IAAI"}
|
|
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
|
|
3
3
|
//#region src/operations/middleware.d.ts
|
|
4
4
|
/**
|
|
5
|
-
* Wrap an MCP server to log tool operations.
|
|
5
|
+
* Wrap an MCP server to log tool operations and emit real-time events.
|
|
6
6
|
* Returns a proxy that intercepts registerTool calls.
|
|
7
7
|
*/
|
|
8
8
|
declare function withOperationLogging(server: McpServer): McpServer;
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
import { extractResourceId } from "./resource-id.mjs";
|
|
2
|
+
import { WRITE_TOOLS } from "./types.mjs";
|
|
1
3
|
import { operationLogger } from "./logger.mjs";
|
|
4
|
+
import { eventBus } from "../events/index.mjs";
|
|
2
5
|
|
|
3
6
|
//#region src/operations/middleware.ts
|
|
7
|
+
/** Declarative mapping from write tool to event type. */
|
|
8
|
+
const TOOL_EVENT_MAP = {
|
|
9
|
+
backlog_create: "task_created",
|
|
10
|
+
backlog_update: "task_changed",
|
|
11
|
+
backlog_delete: "task_deleted",
|
|
12
|
+
write_resource: "resource_changed"
|
|
13
|
+
};
|
|
14
|
+
function isWriteTool(name) {
|
|
15
|
+
return WRITE_TOOLS.includes(name);
|
|
16
|
+
}
|
|
4
17
|
/**
|
|
5
|
-
* Wrap an MCP server to log tool operations.
|
|
18
|
+
* Wrap an MCP server to log tool operations and emit real-time events.
|
|
6
19
|
* Returns a proxy that intercepts registerTool calls.
|
|
7
20
|
*/
|
|
8
21
|
function withOperationLogging(server) {
|
|
@@ -10,7 +23,15 @@ function withOperationLogging(server) {
|
|
|
10
23
|
server.registerTool = function(name, config, callback) {
|
|
11
24
|
const wrappedCallback = async (...args) => {
|
|
12
25
|
const result = await callback(...args);
|
|
13
|
-
|
|
26
|
+
const params = args[0] || {};
|
|
27
|
+
operationLogger.log(name, params, result);
|
|
28
|
+
if (isWriteTool(name)) eventBus.emit({
|
|
29
|
+
type: TOOL_EVENT_MAP[name],
|
|
30
|
+
id: extractResourceId(name, params, result) || "",
|
|
31
|
+
tool: name,
|
|
32
|
+
actor: process.env.BACKLOG_ACTOR_NAME || process.env.USER || "unknown",
|
|
33
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
34
|
+
});
|
|
14
35
|
return result;
|
|
15
36
|
};
|
|
16
37
|
return originalRegisterTool(name, config, wrappedCallback);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.mjs","names":[],"sources":["../../src/operations/middleware.ts"],"sourcesContent":["import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { operationLogger } from './logger.js';\n\n/**\n * Wrap an MCP server to log tool operations.\n * Returns a proxy that intercepts registerTool calls.\n */\nexport function withOperationLogging(server: McpServer): McpServer {\n const originalRegisterTool = server.registerTool.bind(server);\n\n // Override registerTool to wrap callbacks with logging\n (server as any).registerTool = function(\n name: string,\n config: any,\n callback: (...args: any[]) => any\n ) {\n const wrappedCallback = async (...args: any[]) => {\n const result = await callback(...args);\n
|
|
1
|
+
{"version":3,"file":"middleware.mjs","names":[],"sources":["../../src/operations/middleware.ts"],"sourcesContent":["import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { operationLogger } from './logger.js';\nimport { eventBus } from '../events/index.js';\nimport type { BacklogEventType } from '../events/index.js';\nimport { extractResourceId } from './resource-id.js';\nimport { WRITE_TOOLS, type ToolName } from './types.js';\n\n/** Declarative mapping from write tool to event type. */\nconst TOOL_EVENT_MAP: Record<ToolName, BacklogEventType> = {\n backlog_create: 'task_created',\n backlog_update: 'task_changed',\n backlog_delete: 'task_deleted',\n write_resource: 'resource_changed',\n};\n\nfunction isWriteTool(name: string): name is ToolName {\n return WRITE_TOOLS.includes(name as ToolName);\n}\n\n/**\n * Wrap an MCP server to log tool operations and emit real-time events.\n * Returns a proxy that intercepts registerTool calls.\n */\nexport function withOperationLogging(server: McpServer): McpServer {\n const originalRegisterTool = server.registerTool.bind(server);\n\n // Override registerTool to wrap callbacks with logging\n (server as any).registerTool = function(\n name: string,\n config: any,\n callback: (...args: any[]) => any\n ) {\n const wrappedCallback = async (...args: any[]) => {\n const result = await callback(...args);\n const params = args[0] || {};\n\n // Log operation to disk\n operationLogger.log(name, params, result);\n\n // Emit real-time event for SSE consumers\n if (isWriteTool(name)) {\n eventBus.emit({\n type: TOOL_EVENT_MAP[name],\n id: extractResourceId(name, params, result) || '',\n tool: name,\n actor: process.env.BACKLOG_ACTOR_NAME || process.env.USER || 'unknown',\n ts: new Date().toISOString(),\n });\n }\n\n return result;\n };\n\n return originalRegisterTool(name, config, wrappedCallback as any);\n };\n\n return server;\n}\n"],"mappings":";;;;;;;AAQA,MAAM,iBAAqD;CACzD,gBAAgB;CAChB,gBAAgB;CAChB,gBAAgB;CAChB,gBAAgB;CACjB;AAED,SAAS,YAAY,MAAgC;AACnD,QAAO,YAAY,SAAS,KAAiB;;;;;;AAO/C,SAAgB,qBAAqB,QAA8B;CACjE,MAAM,uBAAuB,OAAO,aAAa,KAAK,OAAO;AAG7D,CAAC,OAAe,eAAe,SAC7B,MACA,QACA,UACA;EACA,MAAM,kBAAkB,OAAO,GAAG,SAAgB;GAChD,MAAM,SAAS,MAAM,SAAS,GAAG,KAAK;GACtC,MAAM,SAAS,KAAK,MAAM,EAAE;AAG5B,mBAAgB,IAAI,MAAM,QAAQ,OAAO;AAGzC,OAAI,YAAY,KAAK,CACnB,UAAS,KAAK;IACZ,MAAM,eAAe;IACrB,IAAI,kBAAkB,MAAM,QAAQ,OAAO,IAAI;IAC/C,MAAM;IACN,OAAO,QAAQ,IAAI,sBAAsB,QAAQ,IAAI,QAAQ;IAC7D,qBAAI,IAAI,MAAM,EAAC,aAAa;IAC7B,CAAC;AAGJ,UAAO;;AAGT,SAAO,qBAAqB,MAAM,QAAQ,gBAAuB;;AAGnE,QAAO"}
|
|
@@ -2,6 +2,7 @@ import { paths } from "../utils/paths.mjs";
|
|
|
2
2
|
import { resourceManager } from "../resources/manager.mjs";
|
|
3
3
|
import { storage } from "../storage/backlog-service.mjs";
|
|
4
4
|
import { operationLogger } from "../operations/logger.mjs";
|
|
5
|
+
import { eventBus } from "../events/index.mjs";
|
|
5
6
|
import "../operations/index.mjs";
|
|
6
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
8
|
import matter from "gray-matter";
|
|
@@ -9,6 +10,7 @@ import fastifyStatic from "@fastify/static";
|
|
|
9
10
|
import { exec } from "node:child_process";
|
|
10
11
|
|
|
11
12
|
//#region src/server/viewer-routes.ts
|
|
13
|
+
const SSE_HEARTBEAT_MS = 3e4;
|
|
12
14
|
function registerViewerRoutes(app) {
|
|
13
15
|
app.register(fastifyStatic, {
|
|
14
16
|
root: paths.viewerDist,
|
|
@@ -182,6 +184,36 @@ function registerViewerRoutes(app) {
|
|
|
182
184
|
const { taskId } = request.params;
|
|
183
185
|
return { count: operationLogger.countForTask(taskId) };
|
|
184
186
|
});
|
|
187
|
+
app.get("/events", (request, reply) => {
|
|
188
|
+
const lastEventId = request.headers["last-event-id"];
|
|
189
|
+
reply.hijack();
|
|
190
|
+
const raw = reply.raw;
|
|
191
|
+
raw.writeHead(200, {
|
|
192
|
+
"Content-Type": "text/event-stream",
|
|
193
|
+
"Cache-Control": "no-cache",
|
|
194
|
+
"Connection": "keep-alive",
|
|
195
|
+
"X-Accel-Buffering": "no"
|
|
196
|
+
});
|
|
197
|
+
if (lastEventId) {
|
|
198
|
+
const seq = parseInt(lastEventId, 10);
|
|
199
|
+
if (!isNaN(seq)) {
|
|
200
|
+
const missed = eventBus.replaySince(seq);
|
|
201
|
+
for (const event of missed) raw.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
raw.write(`: connected\n\n`);
|
|
205
|
+
const onEvent = (event) => {
|
|
206
|
+
raw.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
207
|
+
};
|
|
208
|
+
eventBus.subscribe(onEvent);
|
|
209
|
+
const heartbeat = setInterval(() => {
|
|
210
|
+
raw.write(`: heartbeat\n\n`);
|
|
211
|
+
}, SSE_HEARTBEAT_MS);
|
|
212
|
+
request.raw.on("close", () => {
|
|
213
|
+
clearInterval(heartbeat);
|
|
214
|
+
eventBus.unsubscribe(onEvent);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
185
217
|
}
|
|
186
218
|
|
|
187
219
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"viewer-routes.mjs","names":[],"sources":["../../src/server/viewer-routes.ts"],"sourcesContent":["import type { FastifyInstance } from 'fastify';\nimport fastifyStatic from '@fastify/static';\nimport { exec } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport matter from 'gray-matter';\nimport { storage } from '../storage/backlog-service.js';\nimport { resourceManager } from '../resources/manager.js';\nimport { paths } from '../utils/paths.js';\nimport { operationLogger } from '../operations/index.js';\n\nexport function registerViewerRoutes(app: FastifyInstance) {\n // Static files - serve from dist/viewer (built assets)\n app.register(fastifyStatic, {\n root: paths.viewerDist,\n prefix: '/',\n });\n\n // List tasks\n app.get('/tasks', async (request) => {\n const { filter, limit, q } = request.query as { filter?: string; limit?: string; q?: string };\n \n const statusMap: Record<string, any> = {\n active: { status: ['open', 'in_progress', 'blocked'] },\n completed: { status: ['done', 'cancelled'] },\n all: {},\n };\n \n const filterConfig = statusMap[filter || 'active'] || statusMap.active;\n const tasks = await storage.list({ \n ...filterConfig, \n query: q || undefined,\n limit: limit ? parseInt(limit) : 10000 \n });\n \n return tasks;\n });\n\n // Unified search API - returns proper SearchResult[] with item, score, type\n app.get('/search', async (request, reply) => {\n const { q, types, limit, sort } = request.query as { q?: string; types?: string; limit?: string; sort?: string };\n \n if (!q) {\n return reply.code(400).send({ error: 'Missing required query parameter: q' });\n }\n \n const typeFilter = types \n ? types.split(',').filter((t): t is 'task' | 'epic' | 'resource' => \n t === 'task' || t === 'epic' || t === 'resource')\n : undefined;\n \n const sortMode = sort === 'recent' ? 'recent' : 'relevant';\n \n const results = await storage.searchUnified(q, {\n types: typeFilter?.length ? typeFilter : undefined,\n limit: limit ? parseInt(limit) : 20,\n sort: sortMode,\n });\n \n return results;\n });\n\n // Get single task\n app.get('/tasks/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const task = storage.get(id);\n \n if (!task) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n // Include raw markdown for copy button\n const raw = storage.getMarkdown(id);\n \n return { ...task, raw };\n });\n\n // System status\n app.get('/api/status', async () => {\n const tasks = await storage.list({ limit: 10000 });\n const address = app.server.address();\n const port = typeof address === 'object' && address ? address.port : 3030;\n \n return {\n version: paths.getVersion(),\n port,\n dataDir: paths.backlogDataDir,\n taskCount: tasks.length,\n uptime: Math.floor(process.uptime())\n };\n });\n\n // Open task in editor\n app.get('/open/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const filePath = storage.getFilePath(id);\n \n if (!filePath) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n exec(`open \"${filePath}\"`);\n return { status: 'Opening...' };\n });\n\n // Resource proxy\n app.get('/resource', async (request, reply) => {\n const { path: filePath } = request.query as { path?: string };\n \n if (!filePath) {\n return reply.code(400).send({ error: 'Missing path parameter' });\n }\n \n if (!existsSync(filePath)) {\n return reply.code(404).send({ error: 'File not found', path: filePath });\n }\n \n try {\n const content = readFileSync(filePath, 'utf-8');\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n const mimeMap: Record<string, string> = {\n md: 'text/markdown',\n ts: 'text/typescript',\n js: 'text/javascript',\n json: 'application/json',\n txt: 'text/plain',\n };\n \n let frontmatter = {};\n let bodyContent = content;\n \n // Parse frontmatter for markdown files\n if (ext === 'md') {\n const parsed = matter(content);\n frontmatter = parsed.data;\n bodyContent = parsed.content;\n }\n \n return {\n content: bodyContent,\n frontmatter,\n type: mimeMap[ext] || 'text/plain',\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: resourceManager.toUri(filePath),\n ext\n };\n } catch (error: any) {\n return reply.code(500).send({ error: 'Failed to read file', message: error.message });\n }\n });\n\n // MCP resource proxy\n app.get('/mcp/resource', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri || !uri.startsWith('mcp://backlog/')) {\n return reply.code(400).send({ error: 'Invalid MCP URI' });\n }\n \n try {\n const resource = resourceManager.read(uri);\n const filePath = resourceManager.resolve(uri);\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n \n return {\n content: resource.content,\n frontmatter: resource.frontmatter || {},\n type: resource.mimeType,\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: uri,\n ext\n };\n } catch (error: any) {\n return reply.code(404).send({ error: 'Resource not found', uri, message: error.message });\n }\n });\n\n // Open resource in viewer\n app.get('/open', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri) {\n return reply.code(400).send({ error: 'Missing uri parameter' });\n }\n \n return reply.redirect(`/?resource=${encodeURIComponent(uri)}`);\n });\n\n // Operations API - recent activity (enriched with task titles and epic info)\n app.get('/operations', async (request) => {\n const { limit, task, date } = request.query as { limit?: string; task?: string; date?: string };\n \n const operations = operationLogger.read({\n limit: limit ? parseInt(limit) : (date ? 1000 : 50), // Higher limit when filtering by date\n taskId: task || undefined,\n date: date || undefined,\n });\n \n // Enrich operations with task titles and epic info\n // Use in-request cache to avoid duplicate storage lookups\n const taskCache = new Map<string, { title?: string; epicId?: string }>();\n const epicCache = new Map<string, string | undefined>();\n \n const enriched = operations.map(op => {\n if (op.resourceId) {\n if (!taskCache.has(op.resourceId)) {\n const taskData = storage.get(op.resourceId);\n taskCache.set(op.resourceId, {\n title: taskData?.title,\n epicId: taskData?.epic_id,\n });\n }\n const cached = taskCache.get(op.resourceId)!;\n \n // Resolve epic title if task has an epic\n let epicTitle: string | undefined;\n if (cached.epicId) {\n if (!epicCache.has(cached.epicId)) {\n const epicData = storage.get(cached.epicId);\n epicCache.set(cached.epicId, epicData?.title);\n }\n epicTitle = epicCache.get(cached.epicId);\n }\n \n return { ...op, resourceTitle: cached.title, epicId: cached.epicId, epicTitle };\n }\n return op;\n });\n \n return enriched;\n });\n\n // Operation count for a specific task (for badge)\n app.get('/operations/count/:taskId', async (request) => {\n const { taskId } = request.params as { taskId: string };\n return { count: operationLogger.countForTask(taskId) };\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"viewer-routes.mjs","names":[],"sources":["../../src/server/viewer-routes.ts"],"sourcesContent":["import type { FastifyInstance } from 'fastify';\nimport fastifyStatic from '@fastify/static';\nimport { exec } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport matter from 'gray-matter';\nimport { storage } from '../storage/backlog-service.js';\nimport { resourceManager } from '../resources/manager.js';\nimport { paths } from '../utils/paths.js';\nimport { operationLogger } from '../operations/index.js';\nimport { eventBus } from '../events/index.js';\nimport type { BacklogEvent } from '../events/index.js';\n\nconst SSE_HEARTBEAT_MS = 30_000;\n\nexport function registerViewerRoutes(app: FastifyInstance) {\n // Static files - serve from dist/viewer (built assets)\n app.register(fastifyStatic, {\n root: paths.viewerDist,\n prefix: '/',\n });\n\n // List tasks\n app.get('/tasks', async (request) => {\n const { filter, limit, q } = request.query as { filter?: string; limit?: string; q?: string };\n \n const statusMap: Record<string, any> = {\n active: { status: ['open', 'in_progress', 'blocked'] },\n completed: { status: ['done', 'cancelled'] },\n all: {},\n };\n \n const filterConfig = statusMap[filter || 'active'] || statusMap.active;\n const tasks = await storage.list({ \n ...filterConfig, \n query: q || undefined,\n limit: limit ? parseInt(limit) : 10000 \n });\n \n return tasks;\n });\n\n // Unified search API - returns proper SearchResult[] with item, score, type\n app.get('/search', async (request, reply) => {\n const { q, types, limit, sort } = request.query as { q?: string; types?: string; limit?: string; sort?: string };\n \n if (!q) {\n return reply.code(400).send({ error: 'Missing required query parameter: q' });\n }\n \n const typeFilter = types \n ? types.split(',').filter((t): t is 'task' | 'epic' | 'resource' => \n t === 'task' || t === 'epic' || t === 'resource')\n : undefined;\n \n const sortMode = sort === 'recent' ? 'recent' : 'relevant';\n \n const results = await storage.searchUnified(q, {\n types: typeFilter?.length ? typeFilter : undefined,\n limit: limit ? parseInt(limit) : 20,\n sort: sortMode,\n });\n \n return results;\n });\n\n // Get single task\n app.get('/tasks/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const task = storage.get(id);\n \n if (!task) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n // Include raw markdown for copy button\n const raw = storage.getMarkdown(id);\n \n return { ...task, raw };\n });\n\n // System status\n app.get('/api/status', async () => {\n const tasks = await storage.list({ limit: 10000 });\n const address = app.server.address();\n const port = typeof address === 'object' && address ? address.port : 3030;\n \n return {\n version: paths.getVersion(),\n port,\n dataDir: paths.backlogDataDir,\n taskCount: tasks.length,\n uptime: Math.floor(process.uptime())\n };\n });\n\n // Open task in editor\n app.get('/open/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const filePath = storage.getFilePath(id);\n \n if (!filePath) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n exec(`open \"${filePath}\"`);\n return { status: 'Opening...' };\n });\n\n // Resource proxy\n app.get('/resource', async (request, reply) => {\n const { path: filePath } = request.query as { path?: string };\n \n if (!filePath) {\n return reply.code(400).send({ error: 'Missing path parameter' });\n }\n \n if (!existsSync(filePath)) {\n return reply.code(404).send({ error: 'File not found', path: filePath });\n }\n \n try {\n const content = readFileSync(filePath, 'utf-8');\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n const mimeMap: Record<string, string> = {\n md: 'text/markdown',\n ts: 'text/typescript',\n js: 'text/javascript',\n json: 'application/json',\n txt: 'text/plain',\n };\n \n let frontmatter = {};\n let bodyContent = content;\n \n // Parse frontmatter for markdown files\n if (ext === 'md') {\n const parsed = matter(content);\n frontmatter = parsed.data;\n bodyContent = parsed.content;\n }\n \n return {\n content: bodyContent,\n frontmatter,\n type: mimeMap[ext] || 'text/plain',\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: resourceManager.toUri(filePath),\n ext\n };\n } catch (error: any) {\n return reply.code(500).send({ error: 'Failed to read file', message: error.message });\n }\n });\n\n // MCP resource proxy\n app.get('/mcp/resource', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri || !uri.startsWith('mcp://backlog/')) {\n return reply.code(400).send({ error: 'Invalid MCP URI' });\n }\n \n try {\n const resource = resourceManager.read(uri);\n const filePath = resourceManager.resolve(uri);\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n \n return {\n content: resource.content,\n frontmatter: resource.frontmatter || {},\n type: resource.mimeType,\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: uri,\n ext\n };\n } catch (error: any) {\n return reply.code(404).send({ error: 'Resource not found', uri, message: error.message });\n }\n });\n\n // Open resource in viewer\n app.get('/open', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri) {\n return reply.code(400).send({ error: 'Missing uri parameter' });\n }\n \n return reply.redirect(`/?resource=${encodeURIComponent(uri)}`);\n });\n\n // Operations API - recent activity (enriched with task titles and epic info)\n app.get('/operations', async (request) => {\n const { limit, task, date } = request.query as { limit?: string; task?: string; date?: string };\n \n const operations = operationLogger.read({\n limit: limit ? parseInt(limit) : (date ? 1000 : 50), // Higher limit when filtering by date\n taskId: task || undefined,\n date: date || undefined,\n });\n \n // Enrich operations with task titles and epic info\n // Use in-request cache to avoid duplicate storage lookups\n const taskCache = new Map<string, { title?: string; epicId?: string }>();\n const epicCache = new Map<string, string | undefined>();\n \n const enriched = operations.map(op => {\n if (op.resourceId) {\n if (!taskCache.has(op.resourceId)) {\n const taskData = storage.get(op.resourceId);\n taskCache.set(op.resourceId, {\n title: taskData?.title,\n epicId: taskData?.epic_id,\n });\n }\n const cached = taskCache.get(op.resourceId)!;\n \n // Resolve epic title if task has an epic\n let epicTitle: string | undefined;\n if (cached.epicId) {\n if (!epicCache.has(cached.epicId)) {\n const epicData = storage.get(cached.epicId);\n epicCache.set(cached.epicId, epicData?.title);\n }\n epicTitle = epicCache.get(cached.epicId);\n }\n \n return { ...op, resourceTitle: cached.title, epicId: cached.epicId, epicTitle };\n }\n return op;\n });\n \n return enriched;\n });\n\n // Operation count for a specific task (for badge)\n app.get('/operations/count/:taskId', async (request) => {\n const { taskId } = request.params as { taskId: string };\n return { count: operationLogger.countForTask(taskId) };\n });\n\n // SSE endpoint for real-time viewer updates\n app.get('/events', (request, reply) => {\n const lastEventId = request.headers['last-event-id'] as string | undefined;\n\n reply.hijack();\n const raw = reply.raw;\n\n raw.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n 'X-Accel-Buffering': 'no', // Disable nginx buffering\n });\n\n // Replay missed events if client reconnected with Last-Event-ID\n if (lastEventId) {\n const seq = parseInt(lastEventId, 10);\n if (!isNaN(seq)) {\n const missed = eventBus.replaySince(seq);\n for (const event of missed) {\n raw.write(`id: ${event.seq}\\ndata: ${JSON.stringify(event)}\\n\\n`);\n }\n }\n }\n\n // Send initial connected event\n raw.write(`: connected\\n\\n`);\n\n // Subscribe to new events\n const onEvent = (event: BacklogEvent) => {\n raw.write(`id: ${event.seq}\\ndata: ${JSON.stringify(event)}\\n\\n`);\n };\n eventBus.subscribe(onEvent);\n\n // Heartbeat to keep connection alive through proxies\n const heartbeat = setInterval(() => {\n raw.write(`: heartbeat\\n\\n`);\n }, SSE_HEARTBEAT_MS);\n\n // Cleanup on disconnect\n request.raw.on('close', () => {\n clearInterval(heartbeat);\n eventBus.unsubscribe(onEvent);\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;AAYA,MAAM,mBAAmB;AAEzB,SAAgB,qBAAqB,KAAsB;AAEzD,KAAI,SAAS,eAAe;EAC1B,MAAM,MAAM;EACZ,QAAQ;EACT,CAAC;AAGF,KAAI,IAAI,UAAU,OAAO,YAAY;EACnC,MAAM,EAAE,QAAQ,OAAO,MAAM,QAAQ;EAErC,MAAM,YAAiC;GACrC,QAAQ,EAAE,QAAQ;IAAC;IAAQ;IAAe;IAAU,EAAE;GACtD,WAAW,EAAE,QAAQ,CAAC,QAAQ,YAAY,EAAE;GAC5C,KAAK,EAAE;GACR;EAED,MAAM,eAAe,UAAU,UAAU,aAAa,UAAU;AAOhE,SANc,MAAM,QAAQ,KAAK;GAC/B,GAAG;GACH,OAAO,KAAK;GACZ,OAAO,QAAQ,SAAS,MAAM,GAAG;GAClC,CAAC;GAGF;AAGF,KAAI,IAAI,WAAW,OAAO,SAAS,UAAU;EAC3C,MAAM,EAAE,GAAG,OAAO,OAAO,SAAS,QAAQ;AAE1C,MAAI,CAAC,EACH,QAAO,MAAM,KAAK,IAAI,CAAC,KAAK,EAAE,OAAO,uCAAuC,CAAC;EAG/E,MAAM,aAAa,QACf,MAAM,MAAM,IAAI,CAAC,QAAQ,MACvB,MAAM,UAAU,MAAM,UAAU,MAAM,WAAW,GACnD;EAEJ,MAAM,WAAW,SAAS,WAAW,WAAW;AAQhD,SANgB,MAAM,QAAQ,cAAc,GAAG;GAC7C,OAAO,YAAY,SAAS,aAAa;GACzC,OAAO,QAAQ,SAAS,MAAM,GAAG;GACjC,MAAM;GACP,CAAC;GAGF;AAGF,KAAI,IAAI,cAAc,OAAO,SAAS,UAAU;EAC9C,MAAM,EAAE,OAAO,QAAQ;EACvB,MAAM,OAAO,QAAQ,IAAI,GAAG;AAE5B,MAAI,CAAC,KACH,QAAO,MAAM,KAAK,IAAI,CAAC,KAAK,EAAE,OAAO,kBAAkB,CAAC;EAI1D,MAAM,MAAM,QAAQ,YAAY,GAAG;AAEnC,SAAO;GAAE,GAAG;GAAM;GAAK;GACvB;AAGF,KAAI,IAAI,eAAe,YAAY;EACjC,MAAM,QAAQ,MAAM,QAAQ,KAAK,EAAE,OAAO,KAAO,CAAC;EAClD,MAAM,UAAU,IAAI,OAAO,SAAS;EACpC,MAAM,OAAO,OAAO,YAAY,YAAY,UAAU,QAAQ,OAAO;AAErE,SAAO;GACL,SAAS,MAAM,YAAY;GAC3B;GACA,SAAS,MAAM;GACf,WAAW,MAAM;GACjB,QAAQ,KAAK,MAAM,QAAQ,QAAQ,CAAC;GACrC;GACD;AAGF,KAAI,IAAI,aAAa,OAAO,SAAS,UAAU;EAC7C,MAAM,EAAE,OAAO,QAAQ;EACvB,MAAM,WAAW,QAAQ,YAAY,GAAG;AAExC,MAAI,CAAC,SACH,QAAO,MAAM,KAAK,IAAI,CAAC,KAAK,EAAE,OAAO,kBAAkB,CAAC;AAG1D,OAAK,SAAS,SAAS,GAAG;AAC1B,SAAO,EAAE,QAAQ,cAAc;GAC/B;AAGF,KAAI,IAAI,aAAa,OAAO,SAAS,UAAU;EAC7C,MAAM,EAAE,MAAM,aAAa,QAAQ;AAEnC,MAAI,CAAC,SACH,QAAO,MAAM,KAAK,IAAI,CAAC,KAAK,EAAE,OAAO,0BAA0B,CAAC;AAGlE,MAAI,CAAC,WAAW,SAAS,CACvB,QAAO,MAAM,KAAK,IAAI,CAAC,KAAK;GAAE,OAAO;GAAkB,MAAM;GAAU,CAAC;AAG1E,MAAI;GACF,MAAM,UAAU,aAAa,UAAU,QAAQ;GAC/C,MAAM,MAAM,SAAS,MAAM,IAAI,CAAC,KAAK,EAAE,aAAa,IAAI;GACxD,MAAM,UAAkC;IACtC,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,MAAM;IACN,KAAK;IACN;GAED,IAAI,cAAc,EAAE;GACpB,IAAI,cAAc;AAGlB,OAAI,QAAQ,MAAM;IAChB,MAAM,SAAS,OAAO,QAAQ;AAC9B,kBAAc,OAAO;AACrB,kBAAc,OAAO;;AAGvB,UAAO;IACL,SAAS;IACT;IACA,MAAM,QAAQ,QAAQ;IACtB,MAAM;IACN,SAAS,UAAU;IACnB,QAAQ,gBAAgB,MAAM,SAAS;IACvC;IACD;WACM,OAAY;AACnB,UAAO,MAAM,KAAK,IAAI,CAAC,KAAK;IAAE,OAAO;IAAuB,SAAS,MAAM;IAAS,CAAC;;GAEvF;AAGF,KAAI,IAAI,iBAAiB,OAAO,SAAS,UAAU;EACjD,MAAM,EAAE,QAAQ,QAAQ;AAExB,MAAI,CAAC,OAAO,CAAC,IAAI,WAAW,iBAAiB,CAC3C,QAAO,MAAM,KAAK,IAAI,CAAC,KAAK,EAAE,OAAO,mBAAmB,CAAC;AAG3D,MAAI;GACF,MAAM,WAAW,gBAAgB,KAAK,IAAI;GAC1C,MAAM,WAAW,gBAAgB,QAAQ,IAAI;GAC7C,MAAM,MAAM,SAAS,MAAM,IAAI,CAAC,KAAK,EAAE,aAAa,IAAI;AAExD,UAAO;IACL,SAAS,SAAS;IAClB,aAAa,SAAS,eAAe,EAAE;IACvC,MAAM,SAAS;IACf,MAAM;IACN,SAAS,UAAU;IACnB,QAAQ;IACR;IACD;WACM,OAAY;AACnB,UAAO,MAAM,KAAK,IAAI,CAAC,KAAK;IAAE,OAAO;IAAsB;IAAK,SAAS,MAAM;IAAS,CAAC;;GAE3F;AAGF,KAAI,IAAI,SAAS,OAAO,SAAS,UAAU;EACzC,MAAM,EAAE,QAAQ,QAAQ;AAExB,MAAI,CAAC,IACH,QAAO,MAAM,KAAK,IAAI,CAAC,KAAK,EAAE,OAAO,yBAAyB,CAAC;AAGjE,SAAO,MAAM,SAAS,cAAc,mBAAmB,IAAI,GAAG;GAC9D;AAGF,KAAI,IAAI,eAAe,OAAO,YAAY;EACxC,MAAM,EAAE,OAAO,MAAM,SAAS,QAAQ;EAEtC,MAAM,aAAa,gBAAgB,KAAK;GACtC,OAAO,QAAQ,SAAS,MAAM,GAAI,OAAO,MAAO;GAChD,QAAQ,QAAQ;GAChB,MAAM,QAAQ;GACf,CAAC;EAIF,MAAM,4BAAY,IAAI,KAAkD;EACxE,MAAM,4BAAY,IAAI,KAAiC;AA4BvD,SA1BiB,WAAW,KAAI,OAAM;AACpC,OAAI,GAAG,YAAY;AACjB,QAAI,CAAC,UAAU,IAAI,GAAG,WAAW,EAAE;KACjC,MAAM,WAAW,QAAQ,IAAI,GAAG,WAAW;AAC3C,eAAU,IAAI,GAAG,YAAY;MAC3B,OAAO,UAAU;MACjB,QAAQ,UAAU;MACnB,CAAC;;IAEJ,MAAM,SAAS,UAAU,IAAI,GAAG,WAAW;IAG3C,IAAI;AACJ,QAAI,OAAO,QAAQ;AACjB,SAAI,CAAC,UAAU,IAAI,OAAO,OAAO,EAAE;MACjC,MAAM,WAAW,QAAQ,IAAI,OAAO,OAAO;AAC3C,gBAAU,IAAI,OAAO,QAAQ,UAAU,MAAM;;AAE/C,iBAAY,UAAU,IAAI,OAAO,OAAO;;AAG1C,WAAO;KAAE,GAAG;KAAI,eAAe,OAAO;KAAO,QAAQ,OAAO;KAAQ;KAAW;;AAEjF,UAAO;IACP;GAGF;AAGF,KAAI,IAAI,6BAA6B,OAAO,YAAY;EACtD,MAAM,EAAE,WAAW,QAAQ;AAC3B,SAAO,EAAE,OAAO,gBAAgB,aAAa,OAAO,EAAE;GACtD;AAGF,KAAI,IAAI,YAAY,SAAS,UAAU;EACrC,MAAM,cAAc,QAAQ,QAAQ;AAEpC,QAAM,QAAQ;EACd,MAAM,MAAM,MAAM;AAElB,MAAI,UAAU,KAAK;GACjB,gBAAgB;GAChB,iBAAiB;GACjB,cAAc;GACd,qBAAqB;GACtB,CAAC;AAGF,MAAI,aAAa;GACf,MAAM,MAAM,SAAS,aAAa,GAAG;AACrC,OAAI,CAAC,MAAM,IAAI,EAAE;IACf,MAAM,SAAS,SAAS,YAAY,IAAI;AACxC,SAAK,MAAM,SAAS,OAClB,KAAI,MAAM,OAAO,MAAM,IAAI,UAAU,KAAK,UAAU,MAAM,CAAC,MAAM;;;AAMvE,MAAI,MAAM,kBAAkB;EAG5B,MAAM,WAAW,UAAwB;AACvC,OAAI,MAAM,OAAO,MAAM,IAAI,UAAU,KAAK,UAAU,MAAM,CAAC,MAAM;;AAEnE,WAAS,UAAU,QAAQ;EAG3B,MAAM,YAAY,kBAAkB;AAClC,OAAI,MAAM,kBAAkB;KAC3B,iBAAiB;AAGpB,UAAQ,IAAI,GAAG,eAAe;AAC5B,iBAAc,UAAU;AACxB,YAAS,YAAY,QAAQ;IAC7B;GACF"}
|
package/dist/viewer/main.js
CHANGED
|
@@ -3168,6 +3168,39 @@ async function fetchOperationCount(taskId) {
|
|
|
3168
3168
|
return data.count || 0;
|
|
3169
3169
|
}
|
|
3170
3170
|
|
|
3171
|
+
// viewer/services/event-source-client.ts
|
|
3172
|
+
var BacklogEvents = class {
|
|
3173
|
+
source = null;
|
|
3174
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3175
|
+
/** Start listening for server events. Call once on app init. */
|
|
3176
|
+
connect() {
|
|
3177
|
+
if (this.source) return;
|
|
3178
|
+
this.source = new EventSource(`${API_URL}/events`);
|
|
3179
|
+
this.source.onmessage = (e) => {
|
|
3180
|
+
try {
|
|
3181
|
+
const event = JSON.parse(e.data);
|
|
3182
|
+
for (const cb of this.listeners) {
|
|
3183
|
+
cb(event);
|
|
3184
|
+
}
|
|
3185
|
+
} catch {
|
|
3186
|
+
}
|
|
3187
|
+
};
|
|
3188
|
+
}
|
|
3189
|
+
/** Subscribe to all backlog change events. */
|
|
3190
|
+
onChange(callback) {
|
|
3191
|
+
this.listeners.add(callback);
|
|
3192
|
+
}
|
|
3193
|
+
/** Unsubscribe from change events. */
|
|
3194
|
+
offChange(callback) {
|
|
3195
|
+
this.listeners.delete(callback);
|
|
3196
|
+
}
|
|
3197
|
+
disconnect() {
|
|
3198
|
+
this.source?.close();
|
|
3199
|
+
this.source = null;
|
|
3200
|
+
}
|
|
3201
|
+
};
|
|
3202
|
+
var backlogEvents = new BacklogEvents();
|
|
3203
|
+
|
|
3171
3204
|
// viewer/components/breadcrumb.ts
|
|
3172
3205
|
var Breadcrumb = class extends HTMLElement {
|
|
3173
3206
|
currentEpicId = null;
|
|
@@ -3260,7 +3293,11 @@ var TaskList = class extends HTMLElement {
|
|
|
3260
3293
|
this.currentSort = savedSort;
|
|
3261
3294
|
}
|
|
3262
3295
|
this.loadTasks();
|
|
3263
|
-
|
|
3296
|
+
backlogEvents.onChange((event) => {
|
|
3297
|
+
if (event.type === "task_changed" || event.type === "task_created" || event.type === "task_deleted") {
|
|
3298
|
+
this.loadTasks();
|
|
3299
|
+
}
|
|
3300
|
+
});
|
|
3264
3301
|
document.addEventListener("filter-change", ((e) => {
|
|
3265
3302
|
this.currentFilter = e.detail.filter;
|
|
3266
3303
|
this.currentType = e.detail.type ?? "all";
|
|
@@ -3360,7 +3397,7 @@ var TaskList = class extends HTMLElement {
|
|
|
3360
3397
|
const childCount = (task.type ?? "task") === "epic" ? this.allTasks.filter((t) => t.epic_id === task.id).length : 0;
|
|
3361
3398
|
const isCurrentEpic = this.currentEpicId === task.id;
|
|
3362
3399
|
return `
|
|
3363
|
-
<task-item
|
|
3400
|
+
<task-item
|
|
3364
3401
|
data-id="${task.id}"
|
|
3365
3402
|
data-title="${escapeAttr(task.title)}"
|
|
3366
3403
|
data-status="${task.status}"
|
|
@@ -3448,8 +3485,14 @@ function linkify(input) {
|
|
|
3448
3485
|
return `<a href="${input.url}" target="_blank" rel="noopener">${input.title || input.url}</a>`;
|
|
3449
3486
|
}
|
|
3450
3487
|
var TaskDetail = class extends HTMLElement {
|
|
3488
|
+
currentTaskId = null;
|
|
3451
3489
|
connectedCallback() {
|
|
3452
3490
|
this.showEmpty();
|
|
3491
|
+
backlogEvents.onChange((event) => {
|
|
3492
|
+
if (this.currentTaskId && event.type === "task_changed" && event.id === this.currentTaskId) {
|
|
3493
|
+
this.loadTask(this.currentTaskId);
|
|
3494
|
+
}
|
|
3495
|
+
});
|
|
3453
3496
|
}
|
|
3454
3497
|
showEmpty() {
|
|
3455
3498
|
this.innerHTML = `
|
|
@@ -3460,6 +3503,7 @@ var TaskDetail = class extends HTMLElement {
|
|
|
3460
3503
|
`;
|
|
3461
3504
|
}
|
|
3462
3505
|
async loadTask(taskId) {
|
|
3506
|
+
this.currentTaskId = taskId;
|
|
3463
3507
|
try {
|
|
3464
3508
|
const task = await fetchTask(taskId);
|
|
3465
3509
|
this.updatePaneHeader(task);
|
|
@@ -7002,7 +7046,6 @@ function getToolIcon(tool) {
|
|
|
7002
7046
|
function createUnifiedDiff(oldStr, newStr, filename = "file") {
|
|
7003
7047
|
return createTwoFilesPatch(filename, filename, oldStr, newStr, "", "", { context: 5 });
|
|
7004
7048
|
}
|
|
7005
|
-
var POLL_INTERVAL = 3e4;
|
|
7006
7049
|
var MODE_STORAGE_KEY = "backlog:activity-mode";
|
|
7007
7050
|
var DEFAULT_VISIBLE_ITEMS = 2;
|
|
7008
7051
|
var ActivityPanel = class extends HTMLElement {
|
|
@@ -7010,11 +7053,10 @@ var ActivityPanel = class extends HTMLElement {
|
|
|
7010
7053
|
operations = [];
|
|
7011
7054
|
expandedIndex = null;
|
|
7012
7055
|
// Changed to timestamp-based ID
|
|
7013
|
-
pollTimer = null;
|
|
7014
|
-
visibilityHandler = null;
|
|
7015
7056
|
mode = "timeline";
|
|
7016
7057
|
selectedDate = getTodayKey();
|
|
7017
7058
|
expandedTaskGroups = /* @__PURE__ */ new Set();
|
|
7059
|
+
changeHandler = () => this.loadOperations();
|
|
7018
7060
|
connectedCallback() {
|
|
7019
7061
|
this.className = "activity-panel";
|
|
7020
7062
|
const savedMode = localStorage.getItem(MODE_STORAGE_KEY);
|
|
@@ -7022,33 +7064,10 @@ var ActivityPanel = class extends HTMLElement {
|
|
|
7022
7064
|
this.mode = savedMode;
|
|
7023
7065
|
}
|
|
7024
7066
|
this.render();
|
|
7025
|
-
this.
|
|
7067
|
+
backlogEvents.onChange(this.changeHandler);
|
|
7026
7068
|
}
|
|
7027
7069
|
disconnectedCallback() {
|
|
7028
|
-
this.
|
|
7029
|
-
}
|
|
7030
|
-
startPolling() {
|
|
7031
|
-
this.pollTimer = window.setInterval(() => {
|
|
7032
|
-
if (document.visibilityState === "visible") {
|
|
7033
|
-
this.loadOperations();
|
|
7034
|
-
}
|
|
7035
|
-
}, POLL_INTERVAL);
|
|
7036
|
-
this.visibilityHandler = () => {
|
|
7037
|
-
if (document.visibilityState === "visible") {
|
|
7038
|
-
this.loadOperations();
|
|
7039
|
-
}
|
|
7040
|
-
};
|
|
7041
|
-
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
7042
|
-
}
|
|
7043
|
-
stopPolling() {
|
|
7044
|
-
if (this.pollTimer !== null) {
|
|
7045
|
-
clearInterval(this.pollTimer);
|
|
7046
|
-
this.pollTimer = null;
|
|
7047
|
-
}
|
|
7048
|
-
if (this.visibilityHandler) {
|
|
7049
|
-
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
7050
|
-
this.visibilityHandler = null;
|
|
7051
|
-
}
|
|
7070
|
+
backlogEvents.offChange(this.changeHandler);
|
|
7052
7071
|
}
|
|
7053
7072
|
setTaskId(taskId) {
|
|
7054
7073
|
this.taskId = taskId;
|
|
@@ -7851,6 +7870,7 @@ var BacklogApp = class extends HTMLElement {
|
|
|
7851
7870
|
customElements.define("backlog-app", BacklogApp);
|
|
7852
7871
|
|
|
7853
7872
|
// viewer/main.ts
|
|
7873
|
+
backlogEvents.connect();
|
|
7854
7874
|
document.addEventListener("filter-change", ((e) => {
|
|
7855
7875
|
urlState.set({ filter: e.detail.filter, type: e.detail.type });
|
|
7856
7876
|
}));
|
package/package.json
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type JournalEntry,
|
|
24
24
|
type EpicGroup,
|
|
25
25
|
} from './activity-utils.js';
|
|
26
|
+
import { backlogEvents, type ChangeCallback } from '../services/event-source-client.js';
|
|
26
27
|
|
|
27
28
|
type ViewMode = 'timeline' | 'journal';
|
|
28
29
|
|
|
@@ -30,7 +31,6 @@ function createUnifiedDiff(oldStr: string, newStr: string, filename: string = 'f
|
|
|
30
31
|
return createTwoFilesPatch(filename, filename, oldStr, newStr, '', '', { context: 5 });
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
const POLL_INTERVAL = 30000;
|
|
34
34
|
const MODE_STORAGE_KEY = 'backlog:activity-mode';
|
|
35
35
|
const DEFAULT_VISIBLE_ITEMS = 2;
|
|
36
36
|
|
|
@@ -38,11 +38,10 @@ export class ActivityPanel extends HTMLElement {
|
|
|
38
38
|
private taskId: string | null = null;
|
|
39
39
|
private operations: OperationEntry[] = [];
|
|
40
40
|
private expandedIndex: string | null = null; // Changed to timestamp-based ID
|
|
41
|
-
private pollTimer: number | null = null;
|
|
42
|
-
private visibilityHandler: (() => void) | null = null;
|
|
43
41
|
private mode: ViewMode = 'timeline';
|
|
44
42
|
private selectedDate: string = getTodayKey();
|
|
45
43
|
private expandedTaskGroups = new Set<string>();
|
|
44
|
+
private changeHandler: ChangeCallback = () => this.loadOperations();
|
|
46
45
|
|
|
47
46
|
connectedCallback() {
|
|
48
47
|
this.className = 'activity-panel';
|
|
@@ -52,37 +51,11 @@ export class ActivityPanel extends HTMLElement {
|
|
|
52
51
|
this.mode = savedMode;
|
|
53
52
|
}
|
|
54
53
|
this.render();
|
|
55
|
-
this.
|
|
54
|
+
backlogEvents.onChange(this.changeHandler);
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
disconnectedCallback() {
|
|
59
|
-
this.
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
private startPolling() {
|
|
63
|
-
this.pollTimer = window.setInterval(() => {
|
|
64
|
-
if (document.visibilityState === 'visible') {
|
|
65
|
-
this.loadOperations();
|
|
66
|
-
}
|
|
67
|
-
}, POLL_INTERVAL);
|
|
68
|
-
|
|
69
|
-
this.visibilityHandler = () => {
|
|
70
|
-
if (document.visibilityState === 'visible') {
|
|
71
|
-
this.loadOperations();
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
document.addEventListener('visibilitychange', this.visibilityHandler);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
private stopPolling() {
|
|
78
|
-
if (this.pollTimer !== null) {
|
|
79
|
-
clearInterval(this.pollTimer);
|
|
80
|
-
this.pollTimer = null;
|
|
81
|
-
}
|
|
82
|
-
if (this.visibilityHandler) {
|
|
83
|
-
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
|
84
|
-
this.visibilityHandler = null;
|
|
85
|
-
}
|
|
58
|
+
backlogEvents.offChange(this.changeHandler);
|
|
86
59
|
}
|
|
87
60
|
|
|
88
61
|
setTaskId(taskId: string | null) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fetchTask, fetchOperationCount } from '../utils/api.js';
|
|
2
2
|
import type { Reference } from '../utils/api.js';
|
|
3
3
|
import { copyIcon, activityIcon } from '../icons/index.js';
|
|
4
|
+
import { backlogEvents } from '../services/event-source-client.js';
|
|
4
5
|
|
|
5
6
|
function linkify(input: string | Reference): string {
|
|
6
7
|
if (typeof input === 'string') {
|
|
@@ -11,10 +12,19 @@ function linkify(input: string | Reference): string {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export class TaskDetail extends HTMLElement {
|
|
15
|
+
private currentTaskId: string | null = null;
|
|
16
|
+
|
|
14
17
|
connectedCallback() {
|
|
15
18
|
this.showEmpty();
|
|
19
|
+
|
|
20
|
+
// Re-fetch displayed task when it changes via SSE
|
|
21
|
+
backlogEvents.onChange((event) => {
|
|
22
|
+
if (this.currentTaskId && event.type === 'task_changed' && event.id === this.currentTaskId) {
|
|
23
|
+
this.loadTask(this.currentTaskId);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
16
26
|
}
|
|
17
|
-
|
|
27
|
+
|
|
18
28
|
showEmpty() {
|
|
19
29
|
this.innerHTML = `
|
|
20
30
|
<div class="empty-state">
|
|
@@ -25,6 +35,7 @@ export class TaskDetail extends HTMLElement {
|
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
async loadTask(taskId: string) {
|
|
38
|
+
this.currentTaskId = taskId;
|
|
28
39
|
try {
|
|
29
40
|
const task = await fetchTask(taskId);
|
|
30
41
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetchTasks, type Task } from '../utils/api.js';
|
|
2
|
+
import { backlogEvents } from '../services/event-source-client.js';
|
|
2
3
|
import './breadcrumb.js';
|
|
3
4
|
import { ringIcon } from '../icons/index.js';
|
|
4
5
|
|
|
@@ -17,42 +18,48 @@ export class TaskList extends HTMLElement {
|
|
|
17
18
|
private selectedTaskId: string | null = null;
|
|
18
19
|
private currentQuery: string | null = null;
|
|
19
20
|
private allTasks: Task[] = [];
|
|
20
|
-
|
|
21
|
+
|
|
21
22
|
connectedCallback() {
|
|
22
23
|
const params = new URLSearchParams(window.location.search);
|
|
23
24
|
this.selectedTaskId = params.get('task');
|
|
24
25
|
this.currentEpicId = params.get('epic');
|
|
25
26
|
this.currentQuery = params.get('q');
|
|
26
|
-
|
|
27
|
+
|
|
27
28
|
// Restore sort from localStorage
|
|
28
29
|
const savedSort = localStorage.getItem(SORT_STORAGE_KEY);
|
|
29
30
|
if (savedSort) {
|
|
30
31
|
this.currentSort = savedSort;
|
|
31
32
|
}
|
|
32
|
-
|
|
33
|
+
|
|
33
34
|
this.loadTasks();
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
|
|
36
|
+
// Real-time updates via centralized event service
|
|
37
|
+
backlogEvents.onChange((event) => {
|
|
38
|
+
if (event.type === 'task_changed' || event.type === 'task_created' || event.type === 'task_deleted') {
|
|
39
|
+
this.loadTasks();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
36
43
|
document.addEventListener('filter-change', ((e: CustomEvent) => {
|
|
37
44
|
this.currentFilter = e.detail.filter;
|
|
38
45
|
this.currentType = e.detail.type ?? 'all';
|
|
39
46
|
this.loadTasks();
|
|
40
47
|
}) as EventListener);
|
|
41
|
-
|
|
48
|
+
|
|
42
49
|
document.addEventListener('sort-change', ((e: CustomEvent) => {
|
|
43
50
|
this.currentSort = e.detail.sort;
|
|
44
51
|
this.loadTasks();
|
|
45
52
|
}) as EventListener);
|
|
46
|
-
|
|
53
|
+
|
|
47
54
|
document.addEventListener('search-change', ((e: CustomEvent) => {
|
|
48
55
|
this.currentQuery = e.detail.query || null;
|
|
49
56
|
this.loadTasks();
|
|
50
57
|
}) as EventListener);
|
|
51
|
-
|
|
58
|
+
|
|
52
59
|
document.addEventListener('task-selected', ((e: CustomEvent) => {
|
|
53
60
|
this.setSelected(e.detail.taskId);
|
|
54
61
|
}) as EventListener);
|
|
55
|
-
|
|
62
|
+
|
|
56
63
|
document.addEventListener('epic-navigate', ((e: CustomEvent) => {
|
|
57
64
|
this.currentEpicId = e.detail.epicId;
|
|
58
65
|
if (e.detail.epicId) {
|
|
@@ -61,7 +68,7 @@ export class TaskList extends HTMLElement {
|
|
|
61
68
|
this.loadTasks();
|
|
62
69
|
}) as EventListener);
|
|
63
70
|
}
|
|
64
|
-
|
|
71
|
+
|
|
65
72
|
setState(filter: string, type: string, epicId: string | null, taskId: string | null, query: string | null) {
|
|
66
73
|
this.currentFilter = filter;
|
|
67
74
|
this.currentType = type;
|
|
@@ -70,7 +77,7 @@ export class TaskList extends HTMLElement {
|
|
|
70
77
|
this.currentQuery = query;
|
|
71
78
|
this.loadTasks();
|
|
72
79
|
}
|
|
73
|
-
|
|
80
|
+
|
|
74
81
|
private sortTasks(tasks: Task[]): Task[] {
|
|
75
82
|
const sorted = [...tasks];
|
|
76
83
|
switch (this.currentSort) {
|
|
@@ -83,20 +90,20 @@ export class TaskList extends HTMLElement {
|
|
|
83
90
|
return sorted.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
|
-
|
|
93
|
+
|
|
87
94
|
async loadTasks() {
|
|
88
95
|
try {
|
|
89
96
|
let tasks = await fetchTasks(this.currentFilter as any, this.currentQuery || undefined);
|
|
90
97
|
this.allTasks = tasks;
|
|
91
|
-
|
|
98
|
+
|
|
92
99
|
// Type filter
|
|
93
100
|
if (this.currentType !== 'all') {
|
|
94
101
|
tasks = tasks.filter(t => (t.type ?? 'task') === this.currentType);
|
|
95
102
|
}
|
|
96
|
-
|
|
103
|
+
|
|
97
104
|
// Apply sort
|
|
98
105
|
tasks = this.sortTasks(tasks);
|
|
99
|
-
|
|
106
|
+
|
|
100
107
|
// Epic navigation filter
|
|
101
108
|
if (this.currentEpicId) {
|
|
102
109
|
const currentEpic = tasks.find(t => t.id === this.currentEpicId);
|
|
@@ -108,9 +115,9 @@ export class TaskList extends HTMLElement {
|
|
|
108
115
|
const orphanTasks = tasks.filter(t => (t.type ?? 'task') === 'task' && !t.epic_id);
|
|
109
116
|
tasks = [...rootEpics, ...orphanTasks];
|
|
110
117
|
}
|
|
111
|
-
|
|
118
|
+
|
|
112
119
|
this.render(tasks);
|
|
113
|
-
|
|
120
|
+
|
|
114
121
|
const breadcrumb = this.querySelector('epic-breadcrumb');
|
|
115
122
|
if (breadcrumb) {
|
|
116
123
|
(breadcrumb as any).setData(this.currentEpicId, this.allTasks);
|
|
@@ -119,13 +126,13 @@ export class TaskList extends HTMLElement {
|
|
|
119
126
|
this.innerHTML = `<div class="error">Failed to load tasks: ${(error as Error).message}</div>`;
|
|
120
127
|
}
|
|
121
128
|
}
|
|
122
|
-
|
|
129
|
+
|
|
123
130
|
render(tasks: Task[]) {
|
|
124
131
|
const isEmpty = tasks.length === 0;
|
|
125
132
|
const isInsideEpic = !!this.currentEpicId;
|
|
126
133
|
const currentEpic = isInsideEpic ? tasks.find(t => t.id === this.currentEpicId) : null;
|
|
127
134
|
const hasOnlyEpic = isInsideEpic && tasks.length === 1 && currentEpic;
|
|
128
|
-
|
|
135
|
+
|
|
129
136
|
if (isEmpty) {
|
|
130
137
|
this.innerHTML = `
|
|
131
138
|
<epic-breadcrumb></epic-breadcrumb>
|
|
@@ -140,22 +147,22 @@ export class TaskList extends HTMLElement {
|
|
|
140
147
|
}
|
|
141
148
|
return;
|
|
142
149
|
}
|
|
143
|
-
|
|
150
|
+
|
|
144
151
|
// Group: epics first, then tasks
|
|
145
152
|
const epics = tasks.filter(t => (t.type ?? 'task') === 'epic');
|
|
146
153
|
const regularTasks = tasks.filter(t => (t.type ?? 'task') === 'task');
|
|
147
154
|
const grouped = [...epics, ...regularTasks];
|
|
148
|
-
|
|
155
|
+
|
|
149
156
|
this.innerHTML = `
|
|
150
157
|
<epic-breadcrumb></epic-breadcrumb>
|
|
151
158
|
<div class="task-list">
|
|
152
159
|
${grouped.map((task, index) => {
|
|
153
|
-
const childCount = (task.type ?? 'task') === 'epic'
|
|
154
|
-
? this.allTasks.filter(t => t.epic_id === task.id).length
|
|
160
|
+
const childCount = (task.type ?? 'task') === 'epic'
|
|
161
|
+
? this.allTasks.filter(t => t.epic_id === task.id).length
|
|
155
162
|
: 0;
|
|
156
163
|
const isCurrentEpic = this.currentEpicId === task.id;
|
|
157
164
|
return `
|
|
158
|
-
<task-item
|
|
165
|
+
<task-item
|
|
159
166
|
data-id="${task.id}"
|
|
160
167
|
data-title="${escapeAttr(task.title)}"
|
|
161
168
|
data-status="${task.status}"
|
|
@@ -170,13 +177,13 @@ export class TaskList extends HTMLElement {
|
|
|
170
177
|
${hasOnlyEpic ? '<div class="empty-state-inline"><div class="empty-state-icon">—</div><div>No tasks in this epic</div></div>' : ''}
|
|
171
178
|
</div>
|
|
172
179
|
`;
|
|
173
|
-
|
|
180
|
+
|
|
174
181
|
const breadcrumb = this.querySelector('epic-breadcrumb');
|
|
175
182
|
if (breadcrumb) {
|
|
176
183
|
(breadcrumb as any).setData(this.currentEpicId, this.allTasks);
|
|
177
184
|
}
|
|
178
185
|
}
|
|
179
|
-
|
|
186
|
+
|
|
180
187
|
setSelected(taskId: string) {
|
|
181
188
|
this.selectedTaskId = taskId;
|
|
182
189
|
}
|
package/viewer/main.ts
CHANGED
|
@@ -16,6 +16,10 @@ import './components/activity-panel.js';
|
|
|
16
16
|
import './components/backlog-app.js';
|
|
17
17
|
import { urlState } from './utils/url-state.js';
|
|
18
18
|
import { splitPane } from './utils/split-pane.js';
|
|
19
|
+
import { backlogEvents } from './services/event-source-client.js';
|
|
20
|
+
|
|
21
|
+
// Connect to SSE for real-time updates
|
|
22
|
+
backlogEvents.connect();
|
|
19
23
|
|
|
20
24
|
// Component events -> URL updates
|
|
21
25
|
document.addEventListener('filter-change', ((e: CustomEvent) => {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized real-time update service for the viewer.
|
|
3
|
+
*
|
|
4
|
+
* Owns the SSE connection and provides a simple subscribe/unsubscribe
|
|
5
|
+
* API for components. Components have zero knowledge of SSE, EventSource,
|
|
6
|
+
* or transport details — they just register callbacks.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { API_URL } from '../utils/api.js';
|
|
10
|
+
|
|
11
|
+
export type BacklogEventType = 'task_changed' | 'task_created' | 'task_deleted' | 'resource_changed';
|
|
12
|
+
|
|
13
|
+
export interface BacklogEvent {
|
|
14
|
+
seq: number;
|
|
15
|
+
type: BacklogEventType;
|
|
16
|
+
id: string;
|
|
17
|
+
tool: string;
|
|
18
|
+
actor: string;
|
|
19
|
+
ts: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ChangeCallback = (event: BacklogEvent) => void;
|
|
23
|
+
|
|
24
|
+
class BacklogEvents {
|
|
25
|
+
private source: EventSource | null = null;
|
|
26
|
+
private listeners = new Set<ChangeCallback>();
|
|
27
|
+
|
|
28
|
+
/** Start listening for server events. Call once on app init. */
|
|
29
|
+
connect(): void {
|
|
30
|
+
if (this.source) return;
|
|
31
|
+
|
|
32
|
+
this.source = new EventSource(`${API_URL}/events`);
|
|
33
|
+
|
|
34
|
+
this.source.onmessage = (e) => {
|
|
35
|
+
try {
|
|
36
|
+
const event: BacklogEvent = JSON.parse(e.data);
|
|
37
|
+
for (const cb of this.listeners) {
|
|
38
|
+
cb(event);
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Ignore malformed messages
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Subscribe to all backlog change events. */
|
|
47
|
+
onChange(callback: ChangeCallback): void {
|
|
48
|
+
this.listeners.add(callback);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Unsubscribe from change events. */
|
|
52
|
+
offChange(callback: ChangeCallback): void {
|
|
53
|
+
this.listeners.delete(callback);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
disconnect(): void {
|
|
57
|
+
this.source?.close();
|
|
58
|
+
this.source = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const backlogEvents = new BacklogEvents();
|