diff --git a/Dockerfile.aggregator b/Dockerfile.aggregator new file mode 100644 index 0000000..d336ef6 --- /dev/null +++ b/Dockerfile.aggregator @@ -0,0 +1,29 @@ +# Backlog Aggregator Dockerfile +# Multi-project real-time task aggregation server + +FROM oven/bun:1 AS base +WORKDIR /app + +# Install dependencies +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lock* bunfig.toml /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile --ignore-scripts + +# Build stage +FROM base AS release +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +# Build CSS (needed for components) +RUN bun run build:css || true + +# Expose port +EXPOSE 6420 + +# Set environment +ENV NODE_ENV=production +ENV PORT=6420 + +# Run the aggregator server +CMD ["bun", "src/aggregator/index.ts", "--port", "6420", "--paths", "/projects"] diff --git a/docker-compose.aggregator.yml b/docker-compose.aggregator.yml new file mode 100644 index 0000000..be4766f --- /dev/null +++ b/docker-compose.aggregator.yml @@ -0,0 +1,33 @@ +# Backlog Aggregator - Multi-Project Real-time Task View +# Deploy at backlog.jeffemmett.com to see all project tasks in real-time + +services: + backlog-aggregator: + build: + context: . + dockerfile: Dockerfile.aggregator + container_name: backlog-aggregator + restart: unless-stopped + volumes: + # Mount all project directories that contain backlog folders + # The aggregator scans these paths for backlog/ subdirectories + - /opt/websites:/projects/websites:ro + - /opt/apps:/projects/apps:ro + # If you have repos in other locations, add them here: + # - /home/user/projects:/projects/home:ro + labels: + - "traefik.enable=true" + - "traefik.http.routers.backlog.rule=Host(`backlog.jeffemmett.com`)" + - "traefik.http.routers.backlog.entrypoints=web" + - "traefik.http.services.backlog.loadbalancer.server.port=6420" + - "traefik.docker.network=traefik-public" + networks: + - traefik-public + environment: + - PORT=6420 + - NODE_ENV=production + command: ["bun", "src/aggregator/index.ts", "--port", "6420", "--paths", "/projects/websites,/projects/apps"] + +networks: + traefik-public: + external: true diff --git a/src/aggregator/index.ts b/src/aggregator/index.ts new file mode 100644 index 0000000..26c139f --- /dev/null +++ b/src/aggregator/index.ts @@ -0,0 +1,466 @@ +/** + * Backlog Aggregator Server + * + * Real-time multi-project task aggregator that: + * - Scans directories for backlog projects + * - Watches task files for changes + * - Provides unified WebSocket-based real-time updates + * - Serves aggregated task data with project metadata + */ + +import { type Server, type ServerWebSocket, $ } from "bun"; +import { watch, type FSWatcher } from "node:fs"; +import { readdir, stat, readFile } from "node:fs/promises"; +import { join, basename, dirname, relative } from "node:path"; +import { parseTask } from "../markdown/parser.ts"; +import type { Task } from "../types/index.ts"; +import { sortByTaskId } from "../utils/task-sorting.ts"; + +// @ts-expect-error - Bun HTML import +import indexHtml from "./web/index.html"; +// @ts-expect-error - Bun file import +import favicon from "../web/favicon.png" with { type: "file" }; + +interface ProjectConfig { + path: string; + name: string; + color: string; +} + +interface AggregatedTask extends Task { + projectName: string; + projectColor: string; + projectPath: string; +} + +interface AggregatorConfig { + scanPaths: string[]; + port: number; + colors: string[]; +} + +const DEFAULT_COLORS = [ + "#8b5cf6", // purple - canvas-website + "#22c55e", // green - hyperindex + "#3b82f6", // blue - mycofi + "#f97316", // orange - decolonize-time + "#ef4444", // red - ai-orchestrator + "#ec4899", // pink + "#14b8a6", // teal + "#eab308", // yellow + "#6366f1", // indigo + "#84cc16", // lime +]; + +export class BacklogAggregator { + private server: Server | null = null; + private sockets = new Set>(); + private projects = new Map(); + private tasks = new Map(); + private watchers = new Map(); + private colorIndex = 0; + private config: AggregatorConfig; + private debounceTimers = new Map(); + + constructor(config: Partial = {}) { + this.config = { + scanPaths: config.scanPaths || ["/opt/websites", "/opt/apps"], + port: config.port || 6420, + colors: config.colors || DEFAULT_COLORS, + }; + } + + private getNextColor(): string { + const color = this.config.colors[this.colorIndex % this.config.colors.length]; + this.colorIndex++; + return color; + } + + async start(): Promise { + console.log("Starting Backlog Aggregator..."); + + // Initial scan for projects + await this.scanForProjects(); + + // Start the HTTP/WebSocket server + this.server = Bun.serve({ + port: this.config.port, + development: process.env.NODE_ENV === "development", + routes: { + "/": indexHtml, + "/api/projects": { + GET: async () => this.handleGetProjects(), + }, + "/api/tasks": { + GET: async (req: Request) => this.handleGetTasks(req), + }, + "/api/tasks/:project/:id": { + GET: async (req: Request & { params: { project: string; id: string } }) => + this.handleGetTask(req.params.project, req.params.id), + }, + "/api/statuses": { + GET: async () => this.handleGetStatuses(), + }, + "/api/health": { + GET: async () => Response.json({ status: "ok", projects: this.projects.size, tasks: this.tasks.size }), + }, + }, + fetch: async (req: Request, server: Server) => { + const url = new URL(req.url); + + // Handle WebSocket upgrade + if (req.headers.get("upgrade") === "websocket") { + const success = server.upgrade(req, { data: undefined }); + if (success) { + return new Response(null, { status: 101 }); + } + return new Response("WebSocket upgrade failed", { status: 400 }); + } + + // Serve favicon + if (url.pathname.startsWith("/favicon")) { + const faviconFile = Bun.file(favicon); + return new Response(faviconFile, { + headers: { "Content-Type": "image/png" }, + }); + } + + return new Response("Not Found", { status: 404 }); + }, + websocket: { + open: (ws: ServerWebSocket) => { + this.sockets.add(ws); + // Send initial state + ws.send( + JSON.stringify({ + type: "init", + projects: Array.from(this.projects.values()), + tasks: Array.from(this.tasks.values()), + }), + ); + }, + message: (ws: ServerWebSocket, message: string | Buffer) => { + const data = typeof message === "string" ? message : message.toString(); + if (data === "ping") { + ws.send("pong"); + } + }, + close: (ws: ServerWebSocket) => { + this.sockets.delete(ws); + }, + }, + }); + + const url = `http://localhost:${this.config.port}`; + console.log(`Backlog Aggregator running at ${url}`); + console.log(`Watching ${this.projects.size} projects with ${this.tasks.size} tasks`); + + // Set up periodic rescan for new projects + setInterval(() => this.scanForProjects(), 60000); // Every minute + } + + async stop(): Promise { + // Stop all watchers + for (const [path, watcher] of this.watchers) { + watcher.close(); + } + this.watchers.clear(); + + // Clear debounce timers + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); + + // Stop server + if (this.server) { + await this.server.stop(); + this.server = null; + } + + console.log("Aggregator stopped"); + } + + private async scanForProjects(): Promise { + const foundProjects = new Set(); + + for (const scanPath of this.config.scanPaths) { + try { + const entries = await readdir(scanPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const projectPath = join(scanPath, entry.name); + const backlogPath = join(projectPath, "backlog"); + + try { + const backlogStat = await stat(backlogPath); + if (backlogStat.isDirectory()) { + foundProjects.add(projectPath); + await this.addProject(projectPath); + } + } catch { + // No backlog directory + } + } + } catch (error) { + console.warn(`Could not scan ${scanPath}:`, error); + } + } + + // Remove projects that no longer exist + for (const [path] of this.projects) { + if (!foundProjects.has(path)) { + this.removeProject(path); + } + } + } + + private async addProject(projectPath: string): Promise { + if (this.projects.has(projectPath)) return; + + // Try to load project name from config + let projectName = basename(projectPath); + const configPath = join(projectPath, "backlog", "config.yml"); + try { + const configContent = await readFile(configPath, "utf-8"); + const nameMatch = configContent.match(/project_name:\s*["']?([^"'\n]+)["']?/); + if (nameMatch) { + projectName = nameMatch[1].trim(); + } + } catch { + // Use directory name + } + + const project: ProjectConfig = { + path: projectPath, + name: projectName, + color: this.getNextColor(), + }; + + this.projects.set(projectPath, project); + console.log(`Added project: ${projectName} (${projectPath})`); + + // Load initial tasks + await this.loadProjectTasks(projectPath); + + // Set up file watcher + this.watchProject(projectPath); + } + + private removeProject(projectPath: string): void { + const project = this.projects.get(projectPath); + if (!project) return; + + // Remove tasks + for (const [key] of this.tasks) { + if (key.startsWith(projectPath + ":")) { + this.tasks.delete(key); + } + } + + // Stop watcher + const watcher = this.watchers.get(projectPath); + if (watcher) { + watcher.close(); + this.watchers.delete(projectPath); + } + + this.projects.delete(projectPath); + console.log(`Removed project: ${project.name}`); + + this.broadcastUpdate(); + } + + private async loadProjectTasks(projectPath: string): Promise { + const project = this.projects.get(projectPath); + if (!project) return; + + const tasksPath = join(projectPath, "backlog", "tasks"); + + try { + const entries = await readdir(tasksPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".md")) continue; + + const taskPath = join(tasksPath, entry.name); + await this.loadTask(projectPath, taskPath); + } + } catch (error) { + console.warn(`Could not load tasks from ${tasksPath}:`, error); + } + } + + private async loadTask(projectPath: string, taskPath: string): Promise { + const project = this.projects.get(projectPath); + if (!project) return; + + try { + const content = await readFile(taskPath, "utf-8"); + const task = parseTask(content, taskPath); + if (task) { + const aggregatedTask: AggregatedTask = { + ...task, + projectName: project.name, + projectColor: project.color, + projectPath: projectPath, + }; + const key = `${projectPath}:${task.id}`; + this.tasks.set(key, aggregatedTask); + } + } catch (error) { + console.warn(`Could not load task ${taskPath}:`, error); + } + } + + private watchProject(projectPath: string): void { + const tasksPath = join(projectPath, "backlog", "tasks"); + + try { + const watcher = watch(tasksPath, { recursive: true }, (eventType, filename) => { + if (!filename || !filename.endsWith(".md")) return; + + // Debounce rapid changes + const key = `${projectPath}:${filename}`; + const existing = this.debounceTimers.get(key); + if (existing) { + clearTimeout(existing); + } + + this.debounceTimers.set( + key, + setTimeout(async () => { + this.debounceTimers.delete(key); + const taskPath = join(tasksPath, filename); + + try { + await stat(taskPath); + await this.loadTask(projectPath, taskPath); + console.log(`Task updated: ${filename} in ${this.projects.get(projectPath)?.name}`); + } catch { + // File deleted - remove from tasks + const taskId = basename(filename, ".md").replace(/^task-/, "task-"); + const taskKey = `${projectPath}:task-${taskId.replace(/^task-/, "")}`; + // Try various key formats + for (const [k] of this.tasks) { + if (k.startsWith(`${projectPath}:`) && k.includes(basename(filename, ".md"))) { + this.tasks.delete(k); + break; + } + } + console.log(`Task removed: ${filename} from ${this.projects.get(projectPath)?.name}`); + } + + this.broadcastUpdate(); + }, 100), + ); + }); + + watcher.on("error", (error) => { + console.warn(`Watcher error for ${projectPath}:`, error); + }); + + this.watchers.set(projectPath, watcher); + } catch (error) { + console.warn(`Could not watch ${tasksPath}:`, error); + } + } + + private broadcastUpdate(): void { + const message = JSON.stringify({ + type: "update", + projects: Array.from(this.projects.values()), + tasks: Array.from(this.tasks.values()), + timestamp: Date.now(), + }); + + for (const ws of this.sockets) { + try { + ws.send(message); + } catch { + // Socket closed + } + } + } + + // HTTP Handlers + private handleGetProjects(): Response { + return Response.json(Array.from(this.projects.values())); + } + + private handleGetTasks(req: Request): Response { + const url = new URL(req.url); + const project = url.searchParams.get("project"); + const status = url.searchParams.get("status"); + const priority = url.searchParams.get("priority"); + + let tasks = Array.from(this.tasks.values()); + + if (project) { + tasks = tasks.filter((t) => t.projectName === project || t.projectPath.includes(project)); + } + if (status) { + tasks = tasks.filter((t) => t.status.toLowerCase() === status.toLowerCase()); + } + if (priority) { + tasks = tasks.filter((t) => (t.priority ?? "").toLowerCase() === priority.toLowerCase()); + } + + // Sort by project then by task ID + tasks = tasks.sort((a, b) => { + const projectCompare = a.projectName.localeCompare(b.projectName); + if (projectCompare !== 0) return projectCompare; + return sortByTaskId([a, b])[0] === a ? -1 : 1; + }); + + return Response.json(tasks); + } + + private handleGetTask(project: string, id: string): Response { + for (const [key, task] of this.tasks) { + if ((task.projectName === project || task.projectPath.includes(project)) && task.id === id) { + return Response.json(task); + } + } + return Response.json({ error: "Task not found" }, { status: 404 }); + } + + private handleGetStatuses(): Response { + // Collect unique statuses from all projects + const statuses = new Set(); + for (const task of this.tasks.values()) { + statuses.add(task.status); + } + // Return common defaults plus any found + const common = ["To Do", "In Progress", "Done"]; + for (const s of common) statuses.add(s); + return Response.json(Array.from(statuses)); + } +} + +// CLI entry point +if (import.meta.main) { + const args = process.argv.slice(2); + const portIndex = args.indexOf("--port"); + const port = portIndex !== -1 ? Number.parseInt(args[portIndex + 1], 10) : 6420; + + const pathsIndex = args.indexOf("--paths"); + const paths = pathsIndex !== -1 ? args[pathsIndex + 1].split(",") : ["/opt/websites", "/opt/apps"]; + + const aggregator = new BacklogAggregator({ port, scanPaths: paths }); + + process.on("SIGINT", async () => { + await aggregator.stop(); + process.exit(0); + }); + + process.on("SIGTERM", async () => { + await aggregator.stop(); + process.exit(0); + }); + + aggregator.start().catch((error) => { + console.error("Failed to start aggregator:", error); + process.exit(1); + }); +} diff --git a/src/aggregator/web/app.tsx b/src/aggregator/web/app.tsx new file mode 100644 index 0000000..8511978 --- /dev/null +++ b/src/aggregator/web/app.tsx @@ -0,0 +1,380 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { createRoot } from "react-dom/client"; + +interface Project { + path: string; + name: string; + color: string; +} + +interface AggregatedTask { + id: string; + title: string; + status: string; + priority?: string; + description?: string; + assignee: string[]; + labels: string[]; + projectName: string; + projectColor: string; + projectPath: string; + createdDate?: string; + updatedDate?: string; +} + +interface WebSocketMessage { + type: "init" | "update"; + projects: Project[]; + tasks: AggregatedTask[]; + timestamp?: number; +} + +function App() { + const [projects, setProjects] = useState([]); + const [tasks, setTasks] = useState([]); + const [connected, setConnected] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); + const [filter, setFilter] = useState(""); + const [selectedProject, setSelectedProject] = useState(null); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef | null>(null); + + const connectWebSocket = useCallback(() => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${protocol}//${window.location.host}`); + + ws.onopen = () => { + setConnected(true); + console.log("WebSocket connected"); + }; + + ws.onmessage = (event) => { + try { + const data: WebSocketMessage = JSON.parse(event.data); + if (data.type === "init" || data.type === "update") { + setProjects(data.projects); + setTasks(data.tasks); + setLastUpdate(new Date()); + } + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }; + + ws.onclose = () => { + setConnected(false); + console.log("WebSocket disconnected, reconnecting..."); + reconnectTimeoutRef.current = setTimeout(connectWebSocket, 3000); + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + }; + + wsRef.current = ws; + }, []); + + useEffect(() => { + connectWebSocket(); + return () => { + wsRef.current?.close(); + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + }; + }, [connectWebSocket]); + + // Group tasks by status + const statuses = ["To Do", "In Progress", "Done"]; + const tasksByStatus = statuses.reduce( + (acc, status) => { + acc[status] = tasks.filter((t) => { + const statusMatch = t.status.toLowerCase() === status.toLowerCase(); + const projectMatch = !selectedProject || t.projectName === selectedProject; + const filterMatch = + !filter || + t.title.toLowerCase().includes(filter.toLowerCase()) || + t.projectName.toLowerCase().includes(filter.toLowerCase()); + return statusMatch && projectMatch && filterMatch; + }); + return acc; + }, + {} as Record, + ); + + return ( +
+ {/* Header */} +
+
+

Backlog Aggregator

+ + {connected ? "Live" : "Reconnecting..."} + +
+
+ + {projects.length} projects | {tasks.length} tasks + {lastUpdate && ` | Updated ${lastUpdate.toLocaleTimeString()}`} + +
+
+ + {/* Project Filter Bar */} +
+ setFilter(e.target.value)} + style={{ + padding: "0.5rem 1rem", + borderRadius: "0.375rem", + border: "1px solid #334155", + backgroundColor: "#0f172a", + color: "#e2e8f0", + minWidth: "200px", + }} + /> + + {projects.map((project) => ( + + ))} +
+ + {/* Kanban Board */} +
+ {statuses.map((status) => ( +
+ {/* Column Header */} +
+

{status}

+ + {tasksByStatus[status]?.length || 0} + +
+ + {/* Tasks */} +
+ {tasksByStatus[status]?.map((task) => ( + + ))} + {(!tasksByStatus[status] || tasksByStatus[status].length === 0) && ( +
+ No tasks +
+ )} +
+
+ ))} +
+
+ ); +} + +function TaskCard({ task }: { task: AggregatedTask }) { + const [expanded, setExpanded] = useState(false); + + const priorityColors: Record = { + high: "#ef4444", + medium: "#f59e0b", + low: "#22c55e", + }; + + return ( +
setExpanded(!expanded)} + style={{ + backgroundColor: "#0f172a", + borderRadius: "0.375rem", + padding: "0.75rem", + cursor: "pointer", + borderLeft: `4px solid ${task.projectColor}`, + transition: "transform 0.1s, box-shadow 0.1s", + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = "translateY(-1px)"; + e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.3)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = "translateY(0)"; + e.currentTarget.style.boxShadow = "none"; + }} + > + {/* Task Header */} +
+
+
{task.title}
+
+ {task.id} | {task.projectName} +
+
+ {task.priority && ( + + {task.priority} + + )} +
+ + {/* Labels */} + {task.labels && task.labels.length > 0 && ( +
+ {task.labels.map((label) => ( + + {label} + + ))} +
+ )} + + {/* Expanded Details */} + {expanded && task.description && ( +
+ {task.description.slice(0, 500)} + {task.description.length > 500 && "..."} +
+ )} + + {/* Assignees */} + {task.assignee && task.assignee.length > 0 && ( +
+ {task.assignee.join(", ")} +
+ )} +
+ ); +} + +// Mount the app +const root = createRoot(document.getElementById("root")!); +root.render(); diff --git a/src/aggregator/web/index.html b/src/aggregator/web/index.html new file mode 100644 index 0000000..f18111b --- /dev/null +++ b/src/aggregator/web/index.html @@ -0,0 +1,13 @@ + + + + + + Backlog Aggregator - Real-time Task View + + + + +
+ + diff --git a/src/cli.ts b/src/cli.ts index 813479c..bf86393 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3066,6 +3066,45 @@ program } }); +// Aggregator command for multi-project view +program + .command("aggregator") + .description("start the multi-project backlog aggregator with real-time updates") + .option("-p, --port ", "port to run server on", "6420") + .option("--paths ", "comma-separated list of directories to scan for backlog projects") + .action(async (options) => { + try { + const { BacklogAggregator } = await import("./aggregator/index.ts"); + + const port = Number.parseInt(options.port, 10) || 6420; + const scanPaths = options.paths + ? options.paths.split(",").map((p: string) => p.trim()) + : [process.cwd()]; + + const aggregator = new BacklogAggregator({ port, scanPaths }); + + const shutdown = async (signal: string) => { + console.log(`\n${signal} received, shutting down...`); + try { + const stopPromise = aggregator.stop(); + const timeout = new Promise((resolve) => setTimeout(resolve, 3000)); + await Promise.race([stopPromise, timeout]); + } finally { + process.exit(0); + } + }; + + process.once("SIGINT", () => void shutdown("SIGINT")); + process.once("SIGTERM", () => void shutdown("SIGTERM")); + process.once("SIGQUIT", () => void shutdown("SIGQUIT")); + + await aggregator.start(); + } catch (err) { + console.error("Failed to start aggregator", err); + process.exitCode = 1; + } + }); + // Completion command group registerCompletionCommand(program);