From d4a3f2b86986aae4d48e6782a9b4e2790cf8475c Mon Sep 17 00:00:00 2001 From: Mannu Date: Sun, 12 Apr 2026 23:27:51 +0530 Subject: [PATCH] feat: complete Tiger dashboard implementation - Bridge: Express API server with SQLite (projects, tasks, executions, outputs) - Dashboard: Next.js app rewired from WebSocket gateway to Tiger Bridge HTTP API - Tasks: Kanban board with drag-drop, project management with CRUD - Dispatch: Task dispatch to sandbox with file watcher for status updates - UI: Container health panel, workspace browser, logs viewer, output viewer Critical fixes: - Use execInSandbox instead of execOnHost for container operations - Watch symlink path instead of container-internal path - URL-encoded params for GET requests instead of body - PUT/DELETE support added to useBridgeRequest Sprints 1-5 complete. Ready for VPS deployment. --- bridge/.gitignore | 4 + bridge/package.json | 26 + bridge/src/auth.ts | 28 + bridge/src/db.ts | 315 ++++++++ bridge/src/index.ts | 113 +++ bridge/src/routes/config.ts | 75 ++ bridge/src/routes/dispatch.ts | 112 +++ bridge/src/routes/exec.ts | 36 + bridge/src/routes/files.ts | 82 +++ bridge/src/routes/logs.ts | 127 ++++ bridge/src/routes/projects.ts | 91 +++ bridge/src/routes/restart.ts | 59 ++ bridge/src/routes/status.ts | 24 + bridge/src/routes/tasks.ts | 138 ++++ bridge/src/tiger.ts | 293 ++++++++ bridge/src/watcher.ts | 115 +++ bridge/tsconfig.json | 17 + dashboard/package-lock.json | 434 ++++++++++- dashboard/package.json | 4 + dashboard/src/app/api/cost/route.ts | 163 ++++ dashboard/src/app/api/tiger/config/route.ts | 61 ++ dashboard/src/app/api/tiger/dispatch/route.ts | 37 + .../tiger/dispatch/status/[taskId]/route.ts | 22 + dashboard/src/app/api/tiger/exec/route.ts | 53 ++ dashboard/src/app/api/tiger/logs/route.ts | 107 +++ .../src/app/api/tiger/projects/[id]/route.ts | 53 ++ dashboard/src/app/api/tiger/projects/route.ts | 33 + dashboard/src/app/api/tiger/restart/route.ts | 39 + dashboard/src/app/api/tiger/status/route.ts | 45 ++ .../src/app/api/tiger/tasks/[id]/route.ts | 69 ++ dashboard/src/app/api/tiger/tasks/route.ts | 44 ++ .../src/app/api/tiger/workspace/route.ts | 55 ++ dashboard/src/app/cost/page.tsx | 71 ++ dashboard/src/app/logs/page.tsx | 318 +++++--- dashboard/src/app/page.tsx | 693 ++++++++---------- dashboard/src/app/projects/page.tsx | 373 ++++++++++ dashboard/src/app/settings/page.tsx | 163 ++-- dashboard/src/app/tasks/page.tsx | 85 +++ dashboard/src/app/workspace/page.tsx | 234 ++++++ dashboard/src/components/app-sidebar.tsx | 54 +- .../src/components/cost/cost-monitor.tsx | 275 +++++++ dashboard/src/components/cost/index.ts | 1 + dashboard/src/components/output-viewer.tsx | 267 +++++++ dashboard/src/components/sub-agents.tsx | 215 ++++++ dashboard/src/components/tasks/index.ts | 4 + .../src/components/tasks/kanban-board.tsx | 458 ++++++++++++ .../src/components/tasks/kanban-column.tsx | 69 ++ dashboard/src/components/tasks/task-card.tsx | 188 +++++ .../src/components/tasks/task-dialog.tsx | 327 +++++++++ dashboard/src/components/ui/badge.tsx | 48 ++ dashboard/src/hooks/use-bridge.ts | 220 ++++++ dashboard/src/hooks/use-gateway.ts | 76 -- dashboard/src/lib/bridge.ts | 160 ++++ dashboard/src/lib/cost.ts | 80 ++ dashboard/src/lib/gateway.ts | 193 ----- dashboard/src/lib/tasks.ts | 118 +++ deploy/.env.example | 39 + deploy/Caddyfile | 110 +++ deploy/DEPLOY.md | 143 ++++ 59 files changed, 6962 insertions(+), 894 deletions(-) create mode 100644 bridge/.gitignore create mode 100644 bridge/package.json create mode 100644 bridge/src/auth.ts create mode 100644 bridge/src/db.ts create mode 100644 bridge/src/index.ts create mode 100644 bridge/src/routes/config.ts create mode 100644 bridge/src/routes/dispatch.ts create mode 100644 bridge/src/routes/exec.ts create mode 100644 bridge/src/routes/files.ts create mode 100644 bridge/src/routes/logs.ts create mode 100644 bridge/src/routes/projects.ts create mode 100644 bridge/src/routes/restart.ts create mode 100644 bridge/src/routes/status.ts create mode 100644 bridge/src/routes/tasks.ts create mode 100644 bridge/src/tiger.ts create mode 100644 bridge/src/watcher.ts create mode 100644 bridge/tsconfig.json create mode 100644 dashboard/src/app/api/cost/route.ts create mode 100644 dashboard/src/app/api/tiger/config/route.ts create mode 100644 dashboard/src/app/api/tiger/dispatch/route.ts create mode 100644 dashboard/src/app/api/tiger/dispatch/status/[taskId]/route.ts create mode 100644 dashboard/src/app/api/tiger/exec/route.ts create mode 100644 dashboard/src/app/api/tiger/logs/route.ts create mode 100644 dashboard/src/app/api/tiger/projects/[id]/route.ts create mode 100644 dashboard/src/app/api/tiger/projects/route.ts create mode 100644 dashboard/src/app/api/tiger/restart/route.ts create mode 100644 dashboard/src/app/api/tiger/status/route.ts create mode 100644 dashboard/src/app/api/tiger/tasks/[id]/route.ts create mode 100644 dashboard/src/app/api/tiger/tasks/route.ts create mode 100644 dashboard/src/app/api/tiger/workspace/route.ts create mode 100644 dashboard/src/app/cost/page.tsx create mode 100644 dashboard/src/app/projects/page.tsx create mode 100644 dashboard/src/app/tasks/page.tsx create mode 100644 dashboard/src/app/workspace/page.tsx create mode 100644 dashboard/src/components/cost/cost-monitor.tsx create mode 100644 dashboard/src/components/cost/index.ts create mode 100644 dashboard/src/components/output-viewer.tsx create mode 100644 dashboard/src/components/sub-agents.tsx create mode 100644 dashboard/src/components/tasks/index.ts create mode 100644 dashboard/src/components/tasks/kanban-board.tsx create mode 100644 dashboard/src/components/tasks/kanban-column.tsx create mode 100644 dashboard/src/components/tasks/task-card.tsx create mode 100644 dashboard/src/components/tasks/task-dialog.tsx create mode 100644 dashboard/src/components/ui/badge.tsx create mode 100644 dashboard/src/hooks/use-bridge.ts delete mode 100644 dashboard/src/hooks/use-gateway.ts create mode 100644 dashboard/src/lib/bridge.ts create mode 100644 dashboard/src/lib/cost.ts delete mode 100644 dashboard/src/lib/gateway.ts create mode 100644 dashboard/src/lib/tasks.ts create mode 100644 deploy/.env.example create mode 100644 deploy/Caddyfile create mode 100644 deploy/DEPLOY.md diff --git a/bridge/.gitignore b/bridge/.gitignore new file mode 100644 index 0000000..6d27ece --- /dev/null +++ b/bridge/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.env.local diff --git a/bridge/package.json b/bridge/package.json new file mode 100644 index 0000000..4df6d1d --- /dev/null +++ b/bridge/package.json @@ -0,0 +1,26 @@ +{ + "name": "tiger-bridge", + "version": "1.0.0", + "description": "Bridge API between dashboard and Tiger agent running inside Docker/k3s/sandbox", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "node --import tsx src/index.ts", + "build": "tsc", + "start:prod": "node dist/index.js" + }, + "dependencies": { + "express": "^4.21.0", + "cors": "^2.8.5", + "better-sqlite3": "^11.0.0", + "chokidar": "^3.6.0" + }, + "devDependencies": { + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/better-sqlite3": "^7.6.8", + "@types/node": "^22.0.0" + } +} diff --git a/bridge/src/auth.ts b/bridge/src/auth.ts new file mode 100644 index 0000000..75e79ee --- /dev/null +++ b/bridge/src/auth.ts @@ -0,0 +1,28 @@ +/** + * auth.ts — Simple bearer token auth for the Bridge API. + * + * The dashboard (running on the same machine) sends a shared secret. + * This prevents unauthorized access if the bridge port is accidentally exposed. + * Token is set via TIGER_BRIDGE_TOKEN env var. + */ + +import { Request, Response, NextFunction } from "express"; + +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""; + +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + // Skip auth if no token configured (local dev mode) + if (!BRIDGE_TOKEN) return next(); + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ error: "Missing authorization header" }); + } + + const token = authHeader.slice(7); + if (token !== BRIDGE_TOKEN) { + return res.status(403).json({ error: "Invalid token" }); + } + + next(); +} diff --git a/bridge/src/db.ts b/bridge/src/db.ts new file mode 100644 index 0000000..2457140 --- /dev/null +++ b/bridge/src/db.ts @@ -0,0 +1,315 @@ +/** + * db.ts — SQLite database for Tiger Bridge + * + * Manages persistent storage for projects, tasks, executions, and outputs. + * Database file: /root/clawd-dashboard/data/tiger.db + */ + +import Database from "better-sqlite3"; +import path from "path"; +import fs from "fs"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Data directory - use env var or fallback to local dev path +// VPS: /root/clawd-dashboard/data (or set TIGER_DB_DIR) +// Dev: ./data (relative to project root) +const DATA_DIR = process.env.TIGER_DB_DIR || path.join(__dirname, "../../data"); +if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); +} + +const DB_PATH = path.join(DATA_DIR, "tiger.db"); +const db = new Database(DB_PATH); + +// Enable WAL mode for better concurrency +db.pragma("journal_mode = WAL"); + +// ─── Schema ────────────────────────────────────────────────────────────────── + +db.exec(` + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT DEFAULT '', + status TEXT DEFAULT 'active', + priority TEXT DEFAULT 'medium', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + parent_task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, + title TEXT NOT NULL, + description TEXT DEFAULT '', + status TEXT DEFAULT 'backlog', + priority TEXT DEFAULT 'medium', + assigned_agent TEXT, + progress INTEGER DEFAULT 0, + tags TEXT DEFAULT '[]', + notes TEXT DEFAULT '', + due_date TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS executions ( + id TEXT PRIMARY KEY, + task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, + agent TEXT, + command TEXT, + stdout TEXT DEFAULT '', + stderr TEXT DEFAULT '', + exit_code INTEGER, + cost REAL DEFAULT 0, + tokens_used INTEGER DEFAULT 0, + started_at TEXT DEFAULT (datetime('now')), + completed_at TEXT + ); + + CREATE TABLE IF NOT EXISTS outputs ( + id TEXT PRIMARY KEY, + task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, + execution_id TEXT REFERENCES executions(id) ON DELETE SET NULL, + filename TEXT NOT NULL, + file_type TEXT DEFAULT 'text/plain', + file_path TEXT NOT NULL, + size_bytes INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id); + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); + CREATE INDEX IF NOT EXISTS idx_executions_task ON executions(task_id); + CREATE INDEX IF NOT EXISTS idx_outputs_task ON outputs(task_id); +`); + +// ─── Helper to generate IDs ───────────────────────────────────────────────── + +export function generateId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +// ─── Project Operations ──────────────────────────────────────────────────── + +export const projects = { + findAll(): unknown[] { + return db.prepare("SELECT * FROM projects ORDER BY updated_at DESC").all(); + }, + + findById(id: string): unknown | undefined { + return db.prepare("SELECT * FROM projects WHERE id = ?").get(id); + }, + + create(data: { name: string; description?: string; priority?: string }): unknown { + const id = generateId("proj"); + db.prepare(` + INSERT INTO projects (id, name, description, priority) + VALUES (?, ?, ?, ?) + `).run(id, data.name, data.description || "", data.priority || "medium"); + return projects.findById(id); + }, + + update(id: string, data: Partial<{ name: string; description: string; status: string; priority: string }>): unknown | undefined { + const updates: string[] = []; + const values: unknown[] = []; + + if (data.name !== undefined) { updates.push("name = ?"); values.push(data.name); } + if (data.description !== undefined) { updates.push("description = ?"); values.push(data.description); } + if (data.status !== undefined) { updates.push("status = ?"); values.push(data.status); } + if (data.priority !== undefined) { updates.push("priority = ?"); values.push(data.priority); } + + if (updates.length === 0) return projects.findById(id); + + updates.push("updated_at = datetime('now')"); + values.push(id); + + db.prepare(`UPDATE projects SET ${updates.join(", ")} WHERE id = ?`).run(...values); + return projects.findById(id); + }, + + delete(id: string): boolean { + const result = db.prepare("DELETE FROM projects WHERE id = ?").run(id); + return result.changes > 0; + }, + + getWithTasks(id: string): unknown { + const project = projects.findById(id); + if (!project) return null; + const tasks = db.prepare("SELECT * FROM tasks WHERE project_id = ? ORDER BY created_at DESC").all(id); + return { ...project, tasks }; + }, +}; + +// ─── Task Operations ──────────────────────────────────────────────────────── + +export const tasks = { + findAll(filters: { status?: string; project?: string; agent?: string } = {}): unknown[] { + let sql = "SELECT * FROM tasks WHERE 1=1"; + const params: unknown[] = []; + + if (filters.status) { sql += " AND status = ?"; params.push(filters.status); } + if (filters.project) { sql += " AND project_id = ?"; params.push(filters.project); } + if (filters.agent) { sql += " AND assigned_agent = ?"; params.push(filters.agent); } + + sql += " ORDER BY updated_at DESC"; + return db.prepare(sql).all(...params); + }, + + findById(id: string): unknown | undefined { + return db.prepare("SELECT * FROM tasks WHERE id = ?").get(id); + }, + + create(data: { + project_id: string; + title: string; + description?: string; + priority?: string; + assigned_agent?: string; + parent_task_id?: string; + }): unknown { + const id = generateId("task"); + db.prepare(` + INSERT INTO tasks (id, project_id, parent_task_id, title, description, priority, assigned_agent) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + id, + data.project_id, + data.parent_task_id || null, + data.title, + data.description || "", + data.priority || "medium", + data.assigned_agent || null + ); + return tasks.findById(id); + }, + + update(id: string, data: Partial<{ + title: string; + description: string; + status: string; + priority: string; + assigned_agent: string; + progress: number; + tags: string; + notes: string; + due_date: string; + }>): unknown | undefined { + const updates: string[] = []; + const values: unknown[] = []; + + if (data.title !== undefined) { updates.push("title = ?"); values.push(data.title); } + if (data.description !== undefined) { updates.push("description = ?"); values.push(data.description); } + if (data.status !== undefined) { updates.push("status = ?"); values.push(data.status); } + if (data.priority !== undefined) { updates.push("priority = ?"); values.push(data.priority); } + if (data.assigned_agent !== undefined) { updates.push("assigned_agent = ?"); values.push(data.assigned_agent); } + if (data.progress !== undefined) { updates.push("progress = ?"); values.push(data.progress); } + if (data.tags !== undefined) { updates.push("tags = ?"); values.push(data.tags); } + if (data.notes !== undefined) { updates.push("notes = ?"); values.push(data.notes); } + if (data.due_date !== undefined) { updates.push("due_date = ?"); values.push(data.due_date); } + + if (updates.length === 0) return tasks.findById(id); + + updates.push("updated_at = datetime('now')"); + values.push(id); + + db.prepare(`UPDATE tasks SET ${updates.join(", ")} WHERE id = ?`).run(...values); + return tasks.findById(id); + }, + + delete(id: string): boolean { + const result = db.prepare("DELETE FROM tasks WHERE id = ?").run(id); + return result.changes > 0; + }, + + getWithExecutions(id: string): unknown { + const task = tasks.findById(id); + if (!task) return null; + const executions = db.prepare("SELECT * FROM executions WHERE task_id = ? ORDER BY started_at DESC").all(id); + const outputs = db.prepare("SELECT * FROM outputs WHERE task_id = ? ORDER BY created_at DESC").all(id); + return { ...task, executions, outputs }; + }, +}; + +// ─── Execution Operations ─────────────────────────────────────────────────── + +export const executions = { + findByTaskId(taskId: string): unknown[] { + return db.prepare("SELECT * FROM executions WHERE task_id = ? ORDER BY started_at DESC").all(taskId); + }, + + create(data: { + task_id: string; + agent?: string; + command: string; + }): unknown { + const id = generateId("exec"); + db.prepare(` + INSERT INTO executions (id, task_id, agent, command) + VALUES (?, ?, ?, ?) + `).run(id, data.task_id, data.agent || null, data.command); + return db.prepare("SELECT * FROM executions WHERE id = ?").get(id); + }, + + complete(id: string, data: { + stdout?: string; + stderr?: string; + exit_code?: number; + cost?: number; + tokens_used?: number; + }): unknown | undefined { + const updates: string[] = []; + const values: unknown[] = []; + + if (data.stdout !== undefined) { updates.push("stdout = ?"); values.push(data.stdout); } + if (data.stderr !== undefined) { updates.push("stderr = ?"); values.push(data.stderr); } + if (data.exit_code !== undefined) { updates.push("exit_code = ?"); values.push(data.exit_code); } + if (data.cost !== undefined) { updates.push("cost = ?"); values.push(data.cost); } + if (data.tokens_used !== undefined) { updates.push("tokens_used = ?"); values.push(data.tokens_used); } + + if (updates.length === 0) return null; + + updates.push("completed_at = datetime('now')"); + values.push(id); + + db.prepare(`UPDATE executions SET ${updates.join(", ")} WHERE id = ?`).run(...values); + return db.prepare("SELECT * FROM executions WHERE id = ?").get(id); + }, +}; + +// ─── Output Operations ────────────────────────────────────────────────────── + +export const outputs = { + findByTaskId(taskId: string): unknown[] { + return db.prepare("SELECT * FROM outputs WHERE task_id = ? ORDER BY created_at DESC").all(taskId); + }, + + create(data: { + task_id: string; + execution_id?: string; + filename: string; + file_type?: string; + file_path: string; + size_bytes?: number; + }): unknown { + const id = generateId("out"); + db.prepare(` + INSERT INTO outputs (id, task_id, execution_id, filename, file_type, file_path, size_bytes) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + id, + data.task_id, + data.execution_id || null, + data.filename, + data.file_type || "text/plain", + data.file_path, + data.size_bytes || 0 + ); + return db.prepare("SELECT * FROM outputs WHERE id = ?").get(id); + }, +}; + +export default db; \ No newline at end of file diff --git a/bridge/src/index.ts b/bridge/src/index.ts new file mode 100644 index 0000000..17e98cb --- /dev/null +++ b/bridge/src/index.ts @@ -0,0 +1,113 @@ +/** + * index.ts — Tiger Bridge API Entry Point + * + * This is the main Express server that runs on the Hetzner VPS host. + * It wraps all the docker→k3s→sandbox commands into clean REST endpoints + * that the Next.js dashboard can call over HTTPS. + * + * Architecture: + * Dashboard (Next.js) → HTTPS → Caddy reverse proxy + * → Tiger Bridge (this server, port 3456) + * → docker exec openshell-cluster-nemoclaw + * → kubectl exec -n openshell tiger + * → sandbox pod (Tiger agent) + * + * Routes: + * GET /tiger/status — container health + process state + memory/CPU + * GET /tiger/logs — SSE stream of real-time container logs + * POST /tiger/exec — run a command inside the sandbox + * GET /tiger/config — read openclaw.json config + * POST /tiger/config — update config + auto-regen hash + * POST /tiger/restart — trigger container restart via watchdog + * GET /tiger/workspace — list workspace files + * GET /tiger/files/:path — read a workspace file + */ + +import express from "express"; +import cors from "cors"; +import { authMiddleware } from "./auth.js"; +import statusRouter from "./routes/status.js"; +import logsRouter from "./routes/logs.js"; +import execRouter from "./routes/exec.js"; +import configRouter from "./routes/config.js"; +import restartRouter from "./routes/restart.js"; +import filesRouter from "./routes/files.js"; +import projectsRouter from "./routes/projects.js"; +import tasksRouter from "./routes/tasks.js"; +import dispatchRouter from "./routes/dispatch.js"; +import { initWatcher } from "./watcher.js"; + +// Import db to ensure it's initialized +import "./db.js"; + +// ─── Configuration ───────────────────────────────────────────────────────── +const PORT = parseInt(process.env.TIGER_BRIDGE_PORT || "3456", 10); +const HOST = process.env.TIGER_BRIDGE_HOST || "127.0.0.1"; // Only localhost — Caddy handles HTTPS + +const app = express(); + +// ─── Middleware ───────────────────────────────────────────────────────────── + +// Parse JSON request bodies +app.use(express.json()); + +// CORS — only allow the dashboard origin +// In production, set ALLOWED_ORIGIN to https://agent.manohargupta.com +const allowedOrigin = process.env.ALLOWED_ORIGIN || "http://localhost:3000"; +app.use( + cors({ + origin: allowedOrigin, + credentials: true, + }) +); + +// All routes require a valid bearer token +// Set TIGER_BRIDGE_TOKEN in the environment — same value in dashboard .env +app.use(authMiddleware); + +// ─── Routes ───────────────────────────────────────────────────────────────── + +// Health check (no auth needed — Caddy health probes use this) +app.get("/health", (_req, res) => { + res.json({ ok: true, service: "tiger-bridge", ts: new Date().toISOString() }); +}); + +// Tiger endpoints — all scoped under /tiger +app.use("/tiger/status", statusRouter); +app.use("/tiger/logs", logsRouter); // SSE stream +app.use("/tiger/exec", execRouter); +app.use("/tiger/config", configRouter); +app.use("/tiger/restart", restartRouter); +app.use("/tiger/workspace", filesRouter); +app.use("/tiger/files", filesRouter); // Same router handles both /workspace and /files/:path + +// Project and Task management +app.use("/tiger/projects", projectsRouter); +app.use("/tiger/tasks", tasksRouter); +app.use("/tiger/dispatch", dispatchRouter); + +// ─── Error handling ───────────────────────────────────────────────────────── + +// Catch-all for unmatched routes +app.use((_req, res) => { + res.status(404).json({ error: "Not found" }); +}); + +// Global error handler (Express catches thrown errors here) +// The 4-arg signature is required by Express to recognise this as an error handler +// eslint-disable-next-line @typescript-eslint/no-explicit-any +app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error("[tiger-bridge] Unhandled error:", err); + res.status(500).json({ error: err.message || "Internal server error" }); +}); + +// ─── Start ─────────────────────────────────────────────────────────────────── + +app.listen(PORT, HOST, () => { + console.log(`[tiger-bridge] Listening on http://${HOST}:${PORT}`); + console.log(`[tiger-bridge] Auth: ${process.env.TIGER_BRIDGE_TOKEN ? "enabled" : "DISABLED (no token set)"}`); + console.log(`[tiger-bridge] CORS origin: ${allowedOrigin}`); + + // Initialize file watcher for task status updates + initWatcher(); +}); diff --git a/bridge/src/routes/config.ts b/bridge/src/routes/config.ts new file mode 100644 index 0000000..ac4a6ad --- /dev/null +++ b/bridge/src/routes/config.ts @@ -0,0 +1,75 @@ +/** + * config.ts — GET /tiger/config + POST /tiger/config + * + * Read and update the OpenClaw configuration file (openclaw.json). + * + * Why this is important: + * The Tiger agent reads openclaw.json on startup to know which model to use, + * what tools to load, API keys, etc. When you change it, you also MUST + * update the config hash — otherwise the gateway refuses to start with: + * "Config hash mismatch — refusing to boot" + * + * Previously this hash regeneration was a manual step that was constantly + * forgotten. This endpoint does it automatically. + * + * GET /tiger/config — returns the current parsed config + * POST /tiger/config — deep-merges a patch into the config + rehashes + * + * POST body example: + * { "model": { "primary": "openrouter/anthropic/claude-opus-4" } } + */ + +import { Router, Request, Response } from "express"; +import { getConfig, updateConfig } from "../tiger.js"; + +const router = Router(); + +// ─── GET /tiger/config ─────────────────────────────────────────────────────── +router.get("/", async (_req: Request, res: Response) => { + try { + const config = await getConfig(); + res.json({ ok: true, config }); + } catch (err: any) { + // Common failure: openclaw.json doesn't exist yet or wrong path + res.status(500).json({ + ok: false, + error: "Failed to read openclaw.json", + details: err.message, + }); + } +}); + +// ─── POST /tiger/config ────────────────────────────────────────────────────── +router.post("/", async (req: Request, res: Response) => { + const { patch } = req.body; + + // Validate: patch must be a plain object + if (!patch || typeof patch !== "object" || Array.isArray(patch)) { + return res.status(400).json({ + ok: false, + error: "Request body must be { patch: { ...fields } }", + }); + } + + try { + // updateConfig deep-merges, writes the file, AND regenerates the hash + await updateConfig(patch); + + // Read back the updated config to confirm + const updated = await getConfig(); + + res.json({ + ok: true, + message: "Config updated and hash regenerated", + config: updated, + }); + } catch (err: any) { + res.status(500).json({ + ok: false, + error: "Failed to update config", + details: err.message, + }); + } +}); + +export default router; diff --git a/bridge/src/routes/dispatch.ts b/bridge/src/routes/dispatch.ts new file mode 100644 index 0000000..8089c0d --- /dev/null +++ b/bridge/src/routes/dispatch.ts @@ -0,0 +1,112 @@ +/** + * routes/dispatch.ts — Task dispatch to Tiger sandbox + * + * POST /tiger/dispatch + * Body: { taskId, title, description, assignedAgent, context } + * + * Writes a JSON task file to the sandbox's task inbox: + * /sandbox/.openclaw-data/workspace/tasks/inbox/task_{id}.json + */ + +import { Router } from "express"; +import { tasks, executions } from "../db.js"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +router.post("/", async (req, res) => { + const { taskId, title, description, assignedAgent, context } = req.body; + + if (!taskId || !title) { + return res.status(400).json({ ok: false, error: "taskId and title are required" }); + } + + try { + // Find the task in SQLite + const task = tasks.findById(taskId) as { id: string; title: string; description: string; assigned_agent: string } | undefined; + + if (!task) { + return res.status(404).json({ ok: false, error: "Task not found" }); + } + + // Prepare task data + const taskData = { + id: taskId, + title: task.title, + description: task.description || description || "", + assignedAgent: assignedAgent || task.assigned_agent || "manual", + context: context || "", + createdAt: new Date().toISOString(), + status: "pending", + }; + + // Write task JSON to sandbox's inbox via kubectl exec + // The sandbox path: /sandbox/.openclaw-data/workspace/tasks/inbox/ + const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox"; + const taskFile = `task_${taskId}.json`; + + // First ensure the directory exists inside the container + await execInSandbox(`mkdir -p ${inboxPath}`); + + // Write the task file using printf (more reliable than echo with escaping) + const taskJson = JSON.stringify(taskData, null, 2); + // Escape single quotes for shell: ' -> '\'' + const escapedJson = taskJson.replace(/'/g, "'\\''"); + await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`); + + // Create execution record + const execution = executions.create({ + task_id: taskId, + agent: taskData.assignedAgent, + command: `dispatch task ${taskId} to ${taskData.assignedAgent}`, + }); + + // Update task status + tasks.update(taskId, { status: "in-progress" }); + + res.json({ + ok: true, + message: "Task dispatched to Tiger", + taskId, + executionId: (execution as { id: string }).id, + taskFile: `${inboxPath}/${taskFile}`, + }); + } catch (err) { + console.error("Dispatch error:", err); + res.status(500).json({ ok: false, error: "Failed to dispatch task" }); + } +}); + +// Get dispatch status (check task file status) +router.get("/status/:taskId", async (req, res) => { + const { taskId } = req.params; + + try { + // Check which directory the task is in (inbox, active, completed, failed) + const directories = ["inbox", "active", "completed", "failed"]; + + for (const dir of directories) { + const taskPath = `/sandbox/.openclaw-data/workspace/tasks/${dir}/task_${taskId}.json`; + try { + const content = await execInSandbox(`cat ${taskPath} 2>/dev/null || true`); + if (content && content.trim()) { + const taskData = JSON.parse(content); + return res.json({ + ok: true, + status: dir, + task: taskData, + }); + } + } catch { + // File doesn't exist in this directory, continue + } + } + + res.json({ ok: true, status: "unknown" }); + } catch (err) { + console.error("Status check error:", err); + res.status(500).json({ ok: false, error: "Failed to check task status" }); + } +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/routes/exec.ts b/bridge/src/routes/exec.ts new file mode 100644 index 0000000..a3f6e92 --- /dev/null +++ b/bridge/src/routes/exec.ts @@ -0,0 +1,36 @@ +/** + * Exec route — POST /api/exec + * Run arbitrary commands inside the Tiger sandbox. + * Use with care — this is the raw escape hatch. + * + * Body: { command: string, timeout?: number } + */ + +import { Router } from "express"; +import { execInSandbox, execOnHost } from "../tiger.js"; + +const router = Router(); + +router.post("/", async (req, res) => { + const { command, timeout, target = "sandbox" } = req.body; + + if (!command || typeof command !== "string") { + return res.status(400).json({ error: "Missing 'command' in request body" }); + } + + // Safety: block obviously destructive commands + const blocked = ["rm -rf /", "mkfs", "dd if=", ":(){ :|:& };:"]; + if (blocked.some((b) => command.includes(b))) { + return res.status(403).json({ error: "Command blocked for safety" }); + } + + try { + const exec = target === "host" ? execOnHost : execInSandbox; + const result = await exec(command, timeout || 30_000); + res.json(result); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/bridge/src/routes/files.ts b/bridge/src/routes/files.ts new file mode 100644 index 0000000..1b8e7d0 --- /dev/null +++ b/bridge/src/routes/files.ts @@ -0,0 +1,82 @@ +/** + * files.ts — Workspace file browser + * + * Exposes the Tiger agent's workspace files so the dashboard can + * display outputs, memory files, logs, etc. + * + * The workspace is accessed via a symlink on the host at: + * /root/tiger-workspace → (live pod filesystem) + * + * Routes: + * GET /tiger/workspace — list files in workspace root + * GET /tiger/workspace?path=sub — list files in a subdirectory + * GET /tiger/files/read?path=x — read a file's contents as text + * + * Security: + * - Path traversal (`../`) is stripped before use + * - Files outside the workspace symlink cannot be accessed + */ + +import { Router, Request, Response } from "express"; +import { listWorkspaceFiles, readWorkspaceFile } from "../tiger.js"; + +const router = Router(); + +// ─── GET /tiger/workspace (or /tiger/workspace?path=subdir) ───────────────── +// Lists files and directories in the workspace (or a subdirectory) +router.get("/", async (req: Request, res: Response) => { + try { + // ?path=some/subdir — optional subdirectory to list + const subpath = (req.query.path as string) || ""; + + const files = await listWorkspaceFiles(subpath); + + res.json({ + ok: true, + path: subpath || "/", + count: files.length, + files, + }); + } catch (err: any) { + res.status(500).json({ + ok: false, + error: "Failed to list workspace files", + details: err.message, + }); + } +}); + +// ─── GET /tiger/files/read?path=MEMORY.md ──────────────────────────────────── +// Read the text content of a single workspace file +router.get("/read", async (req: Request, res: Response) => { + const filepath = req.query.path as string; + + if (!filepath) { + return res.status(400).json({ + ok: false, + error: "Missing 'path' query parameter — e.g. ?path=MEMORY.md", + }); + } + + try { + const content = await readWorkspaceFile(filepath); + + // Return JSON by default; if the client wants raw text, they can check 'content' + res.json({ + ok: true, + path: filepath, + content, + size: content.length, + }); + } catch (err: any) { + // File not found is a 404, not a 500 + const isNotFound = err.message?.includes("not found") || err.message?.includes("No such file"); + res.status(isNotFound ? 404 : 500).json({ + ok: false, + error: isNotFound ? `File not found: ${filepath}` : "Failed to read file", + details: err.message, + }); + } +}); + +export default router; diff --git a/bridge/src/routes/logs.ts b/bridge/src/routes/logs.ts new file mode 100644 index 0000000..1ebaaa2 --- /dev/null +++ b/bridge/src/routes/logs.ts @@ -0,0 +1,127 @@ +/** + * logs.ts — GET /tiger/logs (Server-Sent Events) + * + * Streams real-time Docker container logs to the dashboard using SSE. + * + * SSE (Server-Sent Events) is simpler than WebSockets for one-way data + * (server → client). The browser's native EventSource API reconnects + * automatically if the connection drops. + * + * Query params: + * ?lines=N — how many historical lines to tail first (default: 100) + * ?filter=text — only forward lines containing this string (case-insensitive) + * + * SSE event format: + * data: {"ts":"ISO-string","text":"log line","level":"INFO|WARN|ERROR|DEBUG"}\n\n + */ + +import { Router, Request, Response } from "express"; +import { streamLogs } from "../tiger.js"; + +const router = Router(); + +router.get("/", (req: Request, res: Response) => { + // ─── Parse query params ────────────────────────────────────────────────── + const lines = parseInt((req.query.lines as string) || "100", 10); + const filter = ((req.query.filter as string) || "").toLowerCase(); + + // ─── Set SSE headers ───────────────────────────────────────────────────── + // These headers tell the browser this is an event stream + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); // Disable Nginx/Caddy buffering + + // Flush headers immediately so the client knows the stream started + res.flushHeaders(); + + // ─── Helper: send one SSE event ────────────────────────────────────────── + /** + * SSE format is simple: + * data: \n\n + * The double newline signals the end of one event. + * The 'event:' field is optional — we use it to distinguish log lines + * from control messages (like "connected" or "error"). + */ + const sendEvent = (eventType: string, payload: unknown) => { + // res.writable becomes false once the client disconnects + if (!res.writable) return; + res.write(`event: ${eventType}\n`); + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + + // ─── Keepalive comments ─────────────────────────────────────────────────── + // SSE comments (lines starting with `:`) are ignored by clients. + // We send them every 15s to keep proxies and firewalls from closing the conn. + const keepalive = setInterval(() => { + if (res.writable) res.write(": keepalive\n\n"); + }, 15_000); + + // ─── Start log stream ──────────────────────────────────────────────────── + // This spawns: docker logs --follow --tail N + const proc = streamLogs(lines); + + // Send a "connected" event so the client knows the stream is live + sendEvent("connected", { ts: new Date().toISOString(), tail: lines }); + + /** + * Docker logs interleaves stdout (normal logs) and stderr (error logs). + * We listen to both with the same handler. + */ + const handleData = (data: Buffer) => { + // Split on newlines — one buffer chunk may contain multiple log lines + const lines_arr = data.toString().split("\n").filter(Boolean); + + for (const line of lines_arr) { + // Apply keyword filter if set + if (filter && !line.toLowerCase().includes(filter)) continue; + + // Heuristic log level detection + const lower = line.toLowerCase(); + const level = lower.includes("error") + ? "ERROR" + : lower.includes("warn") + ? "WARN" + : lower.includes("debug") + ? "DEBUG" + : "INFO"; + + sendEvent("log", { + ts: new Date().toISOString(), + text: line, + level, + }); + } + }; + + proc.stdout.on("data", handleData); + proc.stderr.on("data", handleData); + + // If the docker process exits (container stopped, etc.), notify the client + proc.on("close", (code) => { + sendEvent("closed", { + ts: new Date().toISOString(), + message: `Log stream ended (exit code: ${code})`, + }); + clearInterval(keepalive); + res.end(); + }); + + proc.on("error", (err) => { + sendEvent("error", { + ts: new Date().toISOString(), + message: `Failed to start log stream: ${err.message}`, + }); + clearInterval(keepalive); + res.end(); + }); + + // ─── Cleanup on client disconnect ───────────────────────────────────────── + // When the user closes the Logs tab, clean up the docker logs process + req.on("close", () => { + proc.kill(); // Stop the docker logs process + clearInterval(keepalive); + }); +}); + +export default router; diff --git a/bridge/src/routes/projects.ts b/bridge/src/routes/projects.ts new file mode 100644 index 0000000..232d917 --- /dev/null +++ b/bridge/src/routes/projects.ts @@ -0,0 +1,91 @@ +/** + * routes/projects.ts — Project CRUD routes for Tiger Bridge + * + * Endpoints: + * GET /tiger/projects — list all projects + * POST /tiger/projects — create project + * GET /tiger/projects/:id — get project + its tasks + * PUT /tiger/projects/:id — update project + * DELETE /tiger/projects/:id — delete project + * GET /tiger/projects/:id/tasks — list tasks for project + * POST /tiger/projects/:id/tasks — create task in project + */ + +import { Router } from "express"; +import { projects, tasks } from "../db.js"; + +const router = Router(); + +// List all projects +router.get("/", (req, res) => { + const all = projects.findAll(); + res.json({ ok: true, projects: all }); +}); + +// Create project +router.post("/", (req, res) => { + const { name, description, priority } = req.body; + if (!name) { + return res.status(400).json({ ok: false, error: "name is required" }); + } + const created = projects.create({ name, description, priority }); + res.status(201).json({ ok: true, project: created }); +}); + +// Get project with tasks +router.get("/:id", (req, res) => { + const { id } = req.params; + const project = projects.getWithTasks(id); + if (!project) { + return res.status(404).json({ ok: false, error: "Project not found" }); + } + res.json({ ok: true, project }); +}); + +// Update project +router.put("/:id", (req, res) => { + const { id } = req.params; + const { name, description, status, priority } = req.body; + const updated = projects.update(id, { name, description, status, priority }); + if (!updated) { + return res.status(404).json({ ok: false, error: "Project not found" }); + } + res.json({ ok: true, project: updated }); +}); + +// Delete project +router.delete("/:id", (req, res) => { + const { id } = req.params; + const deleted = projects.delete(id); + if (!deleted) { + return res.status(404).json({ ok: false, error: "Project not found" }); + } + res.json({ ok: true }); +}); + +// List tasks for project +router.get("/:id/tasks", (req, res) => { + const { id } = req.params; + const projectTasks = tasks.findAll({ project: id }); + res.json({ ok: true, tasks: projectTasks }); +}); + +// Create task in project +router.post("/:id/tasks", (req, res) => { + const { id: project_id } = req.params; + const { title, description, priority, assigned_agent, parent_task_id } = req.body; + if (!title) { + return res.status(400).json({ ok: false, error: "title is required" }); + } + const created = tasks.create({ + project_id, + title, + description, + priority, + assigned_agent, + parent_task_id, + }); + res.status(201).json({ ok: true, task: created }); +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/routes/restart.ts b/bridge/src/routes/restart.ts new file mode 100644 index 0000000..c6c098d --- /dev/null +++ b/bridge/src/routes/restart.ts @@ -0,0 +1,59 @@ +/** + * restart.ts — POST /tiger/restart + * + * Trigger a Tiger container restart via the gateway watchdog script. + * + * Why a watchdog script? + * A simple `docker restart` would work, but the OpenClaw gateway needs + * specific flags (--allow-unconfigured) and the correct sequence to + * come back up cleanly. The watchdog handles all of that. + * + * The watchdog lives at /root/gateway-watchdog.sh on the VPS host. + * + * POST /tiger/restart + * Optional body: { "reason": "string" } — logged but not used operationally + * + * Response: + * { ok: true, message: "..." } — restart was triggered + * { ok: false, error: "..." } — something went wrong + */ + +import { Router, Request, Response } from "express"; +import { restartTiger } from "../tiger.js"; + +const router = Router(); + +router.post("/", async (req: Request, res: Response) => { + // Optional reason — useful for audit logs + const reason = (req.body?.reason as string) || "manual restart via dashboard"; + + console.log(`[tiger-bridge] Restart requested. Reason: ${reason}`); + + try { + const result = await restartTiger(); + + if (result.success) { + res.json({ + ok: true, + message: result.message || "Tiger restart triggered", + reason, + }); + } else { + // Watchdog ran but returned non-zero exit code + res.status(500).json({ + ok: false, + error: result.message || "Restart failed", + reason, + }); + } + } catch (err: any) { + res.status(500).json({ + ok: false, + error: "Failed to trigger restart", + details: err.message, + reason, + }); + } +}); + +export default router; diff --git a/bridge/src/routes/status.ts b/bridge/src/routes/status.ts new file mode 100644 index 0000000..597c346 --- /dev/null +++ b/bridge/src/routes/status.ts @@ -0,0 +1,24 @@ +/** + * Status route — GET /api/status + * Returns comprehensive Tiger health: container state, OpenClaw process, + * model info, memory usage, heartbeat content. + */ + +import { Router } from "express"; +import { getTigerStatus } from "../tiger.js"; + +const router = Router(); + +router.get("/", async (_req, res) => { + try { + const status = await getTigerStatus(); + res.json(status); + } catch (err: any) { + res.status(500).json({ + error: "Failed to get Tiger status", + details: err.message, + }); + } +}); + +export default router; diff --git a/bridge/src/routes/tasks.ts b/bridge/src/routes/tasks.ts new file mode 100644 index 0000000..94a5b56 --- /dev/null +++ b/bridge/src/routes/tasks.ts @@ -0,0 +1,138 @@ +/** + * routes/tasks.ts — Task CRUD routes for Tiger Bridge + * + * Endpoints: + * GET /tiger/tasks — list all tasks (with filters) + * GET /tiger/tasks/:id — get task + executions + outputs + * PUT /tiger/tasks/:id — update task + * DELETE /tiger/tasks/:id — delete task + * POST /tiger/tasks/:id/execute — trigger execution + */ + +import { Router } from "express"; +import { tasks, executions } from "../db.js"; + +const router = Router(); + +// List all tasks (with optional filters) +router.get("/", (req, res) => { + const { status, project, agent } = req.query; + const all = tasks.findAll({ + status: status as string, + project: project as string, + agent: agent as string, + }); + res.json({ ok: true, tasks: all }); +}); + +// Get task with executions and outputs +router.get("/:id", (req, res) => { + const { id } = req.params; + const task = tasks.getWithExecutions(id); + if (!task) { + return res.status(404).json({ ok: false, error: "Task not found" }); + } + res.json({ ok: true, task }); +}); + +// Update task +router.put("/:id", (req, res) => { + const { id } = req.params; + const { + title, + description, + status, + priority, + assigned_agent, + progress, + tags, + notes, + due_date, + } = req.body; + + const updated = tasks.update(id, { + title, + description, + status, + priority, + assigned_agent, + progress, + tags: tags ? JSON.stringify(tags) : undefined, + notes, + due_date, + }); + + if (!updated) { + return res.status(404).json({ ok: false, error: "Task not found" }); + } + res.json({ ok: true, task: updated }); +}); + +// Delete task +router.delete("/:id", (req, res) => { + const { id } = req.params; + const deleted = tasks.delete(id); + if (!deleted) { + return res.status(404).json({ ok: false, error: "Task not found" }); + } + res.json({ ok: true }); +}); + +// Trigger execution (writes task file to sandbox for Tiger to pick up) +router.post("/:id/execute", async (req, res) => { + const { id } = req.params; + const task = tasks.findById(id) as { id: string; title: string; description: string; assigned_agent: string } | undefined; + + if (!task) { + return res.status(404).json({ ok: false, error: "Task not found" }); + } + + // Prepare task data for dispatch + const taskData = { + id: id, + title: task.title, + description: task.description || "", + assignedAgent: task.assigned_agent || "manual", + context: "", + createdAt: new Date().toISOString(), + status: "pending", + }; + + // Write task JSON to sandbox's inbox via kubectl exec + const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox"; + const taskFile = `task_${id}.json`; + + // Import execInSandbox dynamically to avoid circular deps + const { execInSandbox } = await import("../tiger.js"); + + try { + // Create directories if needed + await execInSandbox(`mkdir -p ${inboxPath}`); + + // Write the task file + const taskJson = JSON.stringify(taskData, null, 2); + const escapedJson = taskJson.replace(/'/g, "'\\''"); + await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`); + + // Create execution record + const execution = executions.create({ + task_id: id, + agent: taskData.assignedAgent, + command: `dispatch task ${id} to ${taskData.assignedAgent}`, + }); + + // Update task status + tasks.update(id, { status: "in-progress" }); + + res.json({ + ok: true, + execution, + message: `Task ${id} dispatched to Tiger inbox`, + }); + } catch (err) { + console.error("Execute error:", err); + res.status(500).json({ ok: false, error: "Failed to dispatch task" }); + } +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/tiger.ts b/bridge/src/tiger.ts new file mode 100644 index 0000000..8ad4d36 --- /dev/null +++ b/bridge/src/tiger.ts @@ -0,0 +1,293 @@ +/** + * tiger.ts — Core executor for Tiger agent inside Docker→k3s→sandbox + * + * The key insight: Tiger lives 3 layers deep. Every command must traverse: + * Host → Docker (openshell-cluster-nemoclaw) → k3s (kubectl exec) → sandbox pod (tiger) + * + * This module wraps that complexity into clean async functions. + */ + +import { exec, execFile, spawn } from "child_process"; +import { promisify } from "util"; +import { readFile, writeFile } from "fs/promises"; +import { createHash } from "crypto"; + +const execAsync = promisify(exec); + +// ─── Configuration ─────────────────────────────────────────────── +// These match your known paths from the Tiger setup +const DOCKER_CONTAINER = "openshell-cluster-nemoclaw"; +const K8S_NAMESPACE = "openshell"; +const POD_NAME = "tiger"; +const OPENCLAW_CONFIG_HOST = "/root/.nemoclaw/openclaw.json"; +const CONFIG_HASH_PATH_SANDBOX = "/sandbox/.openclaw/.config-hash"; +const WORKSPACE_SYMLINK = "/root/tiger-workspace"; +const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh"; + +// Timeout for commands (30s default, some ops need longer) +const DEFAULT_TIMEOUT = 30_000; + +/** + * Execute a command inside the Tiger sandbox pod. + * This is the fundamental operation — everything else builds on it. + * + * The full command chain: + * docker exec kubectl exec -n -- + */ +export async function execInSandbox( + command: string, + timeoutMs = DEFAULT_TIMEOUT +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const fullCmd = `docker exec ${DOCKER_CONTAINER} kubectl exec -n ${K8S_NAMESPACE} ${POD_NAME} -- sh -c ${JSON.stringify(command)}`; + + try { + const { stdout, stderr } = await execAsync(fullCmd, { + timeout: timeoutMs, + maxBuffer: 5 * 1024 * 1024, // 5MB — agent outputs can be large + }); + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }; + } catch (err: any) { + // exec throws on non-zero exit codes — we still want the output + return { + stdout: (err.stdout || "").trim(), + stderr: (err.stderr || err.message || "").trim(), + exitCode: err.code ?? 1, + }; + } +} + +/** + * Execute a command on the Docker host (not inside the sandbox). + * Used for: reading host configs, container health, docker inspect, etc. + */ +export async function execOnHost( + command: string, + timeoutMs = DEFAULT_TIMEOUT +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + const { stdout, stderr } = await execAsync(command, { + timeout: timeoutMs, + maxBuffer: 5 * 1024 * 1024, + }); + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }; + } catch (err: any) { + return { + stdout: (err.stdout || "").trim(), + stderr: (err.stderr || err.message || "").trim(), + exitCode: err.code ?? 1, + }; + } +} + +/** + * Get comprehensive Tiger status — container health, process state, model info. + * This replaces the old clawdbot gateway "health" check. + */ +export async function getTigerStatus() { + // Run multiple checks in parallel for speed + const [containerState, openclawProc, systemInfo, heartbeat, soulMd] = + await Promise.allSettled([ + // 1. Is the Docker container running? + execOnHost(`docker inspect --format='{{.State.Status}}:{{.State.ExitCode}}:{{.State.StartedAt}}' ${DOCKER_CONTAINER}`), + + // 2. Is the OpenClaw process alive inside the sandbox? + execInSandbox("ps aux | grep -i openclaw | grep -v grep || echo 'NOT_RUNNING'"), + + // 3. System resources inside sandbox + execInSandbox("cat /proc/meminfo | head -5 && echo '---' && uptime"), + + // 4. Last heartbeat content + execInSandbox("cat /sandbox/.openclaw-data/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"), + + // 5. Agent identity from SOUL.md + execInSandbox("head -20 /sandbox/.openclaw-data/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"), + ]); + + // Parse container state + let container = { status: "unknown", exitCode: -1, startedAt: "" }; + if (containerState.status === "fulfilled" && containerState.value.exitCode === 0) { + const parts = containerState.value.stdout.split(":"); + container = { + status: parts[0] || "unknown", + exitCode: parseInt(parts[1] || "-1"), + startedAt: parts.slice(2).join(":") || "", + }; + } + + // Parse OpenClaw process state + const openclawRunning = + openclawProc.status === "fulfilled" && + !openclawProc.value.stdout.includes("NOT_RUNNING"); + + // Parse memory info + let memoryInfo = { totalKb: 0, freeKb: 0, availableKb: 0 }; + if (systemInfo.status === "fulfilled") { + const lines = systemInfo.value.stdout.split("\n"); + for (const line of lines) { + const match = line.match(/^(\w+):\s+(\d+)\s+kB/); + if (match) { + if (match[1] === "MemTotal") memoryInfo.totalKb = parseInt(match[2]); + if (match[1] === "MemFree") memoryInfo.freeKb = parseInt(match[2]); + if (match[1] === "MemAvailable") memoryInfo.availableKb = parseInt(match[2]); + } + } + } + + // Read host config for model info + let currentModel = "unknown"; + let fallbackModels: string[] = []; + try { + const configRaw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8"); + const config = JSON.parse(configRaw); + // Navigate the OpenClaw config structure for model info + currentModel = config?.model?.primary || config?.model || "unknown"; + fallbackModels = config?.model?.fallbacks || []; + } catch { /* config not readable */ } + + return { + status: container.status === "running" && openclawRunning ? "online" : "degraded", + container, + openclaw: { + running: openclawRunning, + processInfo: openclawProc.status === "fulfilled" ? openclawProc.value.stdout : "", + }, + system: { + memoryUsagePct: memoryInfo.totalKb > 0 + ? Math.round(((memoryInfo.totalKb - memoryInfo.availableKb) / memoryInfo.totalKb) * 100) + : 0, + memoryTotalMb: Math.round(memoryInfo.totalKb / 1024), + uptime: systemInfo.status === "fulfilled" + ? systemInfo.value.stdout.split("---")[1]?.trim() || "" + : "", + }, + agent: { + currentModel, + fallbackModels, + heartbeat: heartbeat.status === "fulfilled" ? heartbeat.value.stdout : null, + soul: soulMd.status === "fulfilled" ? soulMd.value.stdout : null, + }, + }; +} + +/** + * Read the OpenClaw config from the host. + * Config lives at /root/.nemoclaw/openclaw.json on the host, + * gets mounted into the sandbox at /sandbox/.openclaw/openclaw.json + */ +export async function getConfig(): Promise> { + const raw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8"); + return JSON.parse(raw); +} + +/** + * Update OpenClaw config AND regenerate the config hash. + * This is critical — the gateway refuses to start if the hash mismatches. + * Previously this was a manual step that caused repeated failures. + */ +export async function updateConfig(patch: Record): Promise { + // 1. Read current config + const current = await getConfig(); + + // 2. Deep merge the patch (shallow for now, can enhance later) + const merged = deepMerge(current, patch); + const configStr = JSON.stringify(merged, null, 2); + + // 3. Backup current config before writing + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} /root/.nemoclaw/backups/openclaw-${timestamp}.json`); + + // 4. Write updated config + await writeFile(OPENCLAW_CONFIG_HOST, configStr, "utf-8"); + + // 5. Regenerate config hash — the step that was always forgotten! + const hash = createHash("sha256").update(configStr).digest("hex"); + await execInSandbox(`echo '${hash}' > ${CONFIG_HASH_PATH_SANDBOX}`); +} + +/** Deep merge helper — second object wins on conflicts */ +function deepMerge(target: any, source: any): any { + const result = { ...target }; + for (const key of Object.keys(source)) { + if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) { + result[key] = deepMerge(result[key] || {}, source[key]); + } else { + result[key] = source[key]; + } + } + return result; +} + +/** + * List files in the Tiger workspace. + * Uses the symlink at /root/tiger-workspace that points into the live pod. + */ +export async function listWorkspaceFiles( + subpath = "" +): Promise<{ name: string; type: "file" | "dir"; size: number; modified: string }[]> { + const targetDir = subpath + ? `${WORKSPACE_SYMLINK}/${subpath}` + : WORKSPACE_SYMLINK; + + const { stdout } = await execOnHost( + `find ${targetDir} -maxdepth 1 -printf '%y|%s|%T@|%f\n' 2>/dev/null | sort` + ); + + return stdout + .split("\n") + .filter(Boolean) + .map((line) => { + const [type, size, mtime, name] = line.split("|"); + return { + name, + type: type === "d" ? "dir" as const : "file" as const, + size: parseInt(size) || 0, + modified: new Date(parseFloat(mtime) * 1000).toISOString(), + }; + }) + .filter((f) => f.name !== "." && f.name !== ".."); +} + +/** + * Read a file from the Tiger workspace. + */ +export async function readWorkspaceFile(filepath: string): Promise { + // Security: prevent path traversal + const sanitized = filepath.replace(/\.\./g, "").replace(/^\//, ""); + const { stdout, exitCode } = await execOnHost( + `cat "${WORKSPACE_SYMLINK}/${sanitized}" 2>/dev/null` + ); + if (exitCode !== 0) throw new Error(`File not found: ${sanitized}`); + return stdout; +} + +/** + * Restart Tiger — triggers the gateway watchdog script. + * The watchdog at /root/gateway-watchdog.sh handles the full restart cycle + * including the --allow-unconfigured flag. + */ +export async function restartTiger(): Promise<{ success: boolean; message: string }> { + try { + // First try the watchdog script + const { stdout, stderr, exitCode } = await execOnHost( + `bash ${GATEWAY_WATCHDOG} 2>&1`, + 60_000 // 60s timeout for restart + ); + return { + success: exitCode === 0, + message: stdout || stderr || "Restart triggered", + }; + } catch (err: any) { + return { success: false, message: err.message }; + } +} + +/** + * Stream container logs via a child process. + * Returns a readable stream that the route handler can pipe to SSE. + */ +export function streamLogs(lines = 100) { + // docker logs --follow gives us real-time output + return spawn("docker", [ + "logs", "--follow", "--tail", String(lines), DOCKER_CONTAINER + ]); +} diff --git a/bridge/src/watcher.ts b/bridge/src/watcher.ts new file mode 100644 index 0000000..3d5d395 --- /dev/null +++ b/bridge/src/watcher.ts @@ -0,0 +1,115 @@ +/** + * watcher.ts — File watcher for task status updates + * + * Watches the sandbox's task directories for file moves. + * When a task file moves between inbox → active → completed → failed, + * updates the corresponding task's status in SQLite. + */ + +import chokidar from "chokidar"; +import { tasks } from "./db.js"; +import { execInSandbox } from "./tiger.js"; + +// Watch through the workspace symlink that maps to container's /sandbox/ +const TASK_BASE_PATH = "/root/tiger-workspace/tasks"; +const DIRECTORIES = ["inbox", "active", "completed", "failed"]; + +// Map directory to task status +const STATUS_MAP: Record = { + inbox: "backlog", + active: "in-progress", + completed: "done", + failed: "failed", +}; + +// Extract task ID from filename (e.g., "task_task_123_abc.json" -> "task_123_abc") +function extractTaskId(filename: string): string | null { + const match = filename.match(/^task_(task_.*)\.json$/); + return match ? match[1] : null; +} + +// Update task status in SQLite based on file location +async function updateTaskStatus(filePath: string, newDirectory: string) { + const filename = filePath.split("/").pop() || ""; + const taskId = extractTaskId(filename); + + if (!taskId) { + console.log(`[watcher] Could not extract task ID from ${filename}`); + return; + } + + const newStatus = STATUS_MAP[newDirectory]; + if (!newStatus) { + console.log(`[watcher] Unknown directory: ${newDirectory}`); + return; + } + + console.log(`[watcher] Task ${taskId} moved to ${newDirectory}, updating status to ${newStatus}`); + + try { + tasks.update(taskId, { status: newStatus }); + } catch (err) { + console.error(`[watcher] Failed to update task ${taskId}:`, err); + } +} + +// Initialize watcher +export function initWatcher() { + console.log("[watcher] Initializing task directory watcher..."); + + // Create task directories inside the container + // The symlink at /root/tiger-workspace will expose them to the host watcher + execInSandbox(`mkdir -p /sandbox/.openclaw-data/workspace/tasks/{inbox,active,completed,failed}`).catch(err => { + console.error("[watcher] Failed to create task directories:", err); + }); + + // Watch each directory for changes + const watchers: chokidar.FSWatcher[] = []; + + for (const dir of DIRECTORIES) { + const watchPath = `${TASK_BASE_PATH}/${dir}`; + + const watcher = chokidar.watch(watchPath, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 1000, + pollInterval: 100, + }, + }); + + // File added (moved into this directory) + watcher.on("add", (filePath) => { + console.log(`[watcher] File added: ${filePath}`); + updateTaskStatus(filePath, dir); + }); + + // File changed + watcher.on("change", (filePath) => { + console.log(`[watcher] File changed: ${filePath}`); + }); + + // File removed (moved out of this directory) + watcher.on("unlink", (filePath) => { + console.log(`[watcher] File removed: ${filePath}`); + }); + + watcher.on("error", (err) => { + console.error(`[watcher] Error watching ${dir}:`, err); + }); + + watchers.push(watcher); + console.log(`[watcher] Watching ${watchPath}`); + } + + console.log("[watcher] Task directory watcher initialized"); + + return () => { + console.log("[watcher] Closing watchers..."); + watchers.forEach(w => w.close()); + }; +} + +// Run as standalone process +// Uncomment below to run: npx tsx src/watcher.ts +// initWatcher(); \ No newline at end of file diff --git a/bridge/tsconfig.json b/bridge/tsconfig.json new file mode 100644 index 0000000..adba852 --- /dev/null +++ b/bridge/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index c5be449..273152e 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -8,6 +8,9 @@ "name": "dashboard", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", @@ -17,6 +20,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-markdown": "^10.1.0", + "recharts": "^3.7.0", "swr": "^2.4.0", "tailwind-merge": "^3.4.0", "uuid": "^13.0.0", @@ -506,6 +510,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", @@ -3478,6 +3535,42 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3505,6 +3598,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3854,6 +3959,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3958,6 +4126,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -5555,6 +5729,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5643,6 +5938,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -6126,6 +6427,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6614,6 +6925,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -7531,6 +7848,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -7586,6 +7913,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", @@ -10654,7 +10990,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-markdown": { @@ -10684,6 +11019,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -10770,6 +11128,51 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -10867,6 +11270,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -11898,7 +12307,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, "license": "MIT" }, "node_modules/tinyexec": { @@ -12613,6 +13021,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index dcbee05..ffc88fb 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -9,6 +9,9 @@ "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", @@ -18,6 +21,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-markdown": "^10.1.0", + "recharts": "^3.7.0", "swr": "^2.4.0", "tailwind-merge": "^3.4.0", "uuid": "^13.0.0", diff --git a/dashboard/src/app/api/cost/route.ts b/dashboard/src/app/api/cost/route.ts new file mode 100644 index 0000000..81e7ffd --- /dev/null +++ b/dashboard/src/app/api/cost/route.ts @@ -0,0 +1,163 @@ +import { NextResponse } from "next/server" +import { calculateCost, CostEntry, DailyCost, ModelCost, CostSummary, getDefaultBudget } from "@/lib/cost" + +// In-memory storage for costs (in production, use a database) +let costEntries: CostEntry[] = [] + +// Load from localStorage on server start (simulated) +try { + if (typeof global !== "undefined" && (global as any).__COST_ENTRIES__) { + costEntries = (global as any).__COST_ENTRIES__ + } +} catch { + // Ignore +} + +// Helper to persist +function persist() { + if (typeof global !== "undefined") { + (global as any).__COST_ENTRIES__ = costEntries + } +} + +export async function GET() { + try { + // Calculate summary + const now = new Date() + const today = now.toISOString().split("T")[0] + const weekStart = new Date(now) + weekStart.setDate(weekStart.getDate() - weekStart.getDay()) + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) + + const todayCost = costEntries + .filter(e => e.timestamp.startsWith(today)) + .reduce((sum, e) => sum + e.totalCost, 0) + + const weekCost = costEntries + .filter(e => new Date(e.timestamp) >= weekStart) + .reduce((sum, e) => sum + e.totalCost, 0) + + const monthCost = costEntries + .filter(e => new Date(e.timestamp) >= monthStart) + .reduce((sum, e) => sum + e.totalCost, 0) + + // Daily breakdown (last 30 days) + const dailyMap = new Map() + const thirtyDaysAgo = new Date(now) + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30) + + costEntries + .filter(e => new Date(e.timestamp) >= thirtyDaysAgo) + .forEach(e => { + const date = e.timestamp.split("T")[0] + const existing = dailyMap.get(date) || { total: 0, requests: 0 } + dailyMap.set(date, { + total: existing.total + e.totalCost, + requests: existing.requests + 1 + }) + }) + + const daily: DailyCost[] = Array.from(dailyMap.entries()) + .map(([date, data]) => ({ date, ...data })) + .sort((a, b) => a.date.localeCompare(b.date)) + + // By model + const modelMap = new Map() + costEntries.forEach(e => { + const existing = modelMap.get(e.model) + if (existing) { + existing.totalCost += e.totalCost + existing.requests += 1 + existing.inputTokens += e.inputTokens + existing.outputTokens += e.outputTokens + } else { + modelMap.set(e.model, { + model: e.model, + provider: e.provider, + totalCost: e.totalCost, + requests: 1, + inputTokens: e.inputTokens, + outputTokens: e.outputTokens + }) + } + }) + + const byModel = Array.from(modelMap.values()).sort((a, b) => b.totalCost - a.totalCost) + + const summary: CostSummary = { + today: todayCost, + thisWeek: weekCost, + thisMonth: monthCost, + totalRequests: costEntries.length, + averagePerRequest: costEntries.length > 0 + ? costEntries.reduce((sum, e) => sum + e.totalCost, 0) / costEntries.length + : 0, + budgetUsed: monthCost, + budgetLimit: getDefaultBudget() + } + + // Last entry + const lastEntry = costEntries[costEntries.length - 1] + + return NextResponse.json({ + summary, + daily, + byModel, + lastEntry, + entries: costEntries.slice(-50).reverse() // Last 50 entries + }) + } catch (error) { + console.error("Cost API error:", error) + return NextResponse.json( + { error: "Failed to fetch cost data" }, + { status: 500 } + ) + } +} + +export async function POST(request: Request) { + try { + const body = await request.json() + const { model, provider, inputTokens, outputTokens, sessionId, requestType } = body + + if (!model || typeof inputTokens !== "number" || typeof outputTokens !== "number") { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ) + } + + const costs = calculateCost(model, inputTokens, outputTokens) + + const entry: CostEntry = { + id: `cost_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + timestamp: new Date().toISOString(), + model, + provider: provider || "unknown", + inputTokens, + outputTokens, + inputCost: costs.inputCost, + outputCost: costs.outputCost, + totalCost: costs.totalCost, + sessionId, + requestType + } + + costEntries.push(entry) + persist() + + return NextResponse.json({ success: true, entry }) + } catch (error) { + console.error("Cost tracking error:", error) + return NextResponse.json( + { error: "Failed to track cost" }, + { status: 500 } + ) + } +} + +export async function DELETE() { + costEntries = [] + persist() + return NextResponse.json({ success: true }) +} diff --git a/dashboard/src/app/api/tiger/config/route.ts b/dashboard/src/app/api/tiger/config/route.ts new file mode 100644 index 0000000..a1c472e --- /dev/null +++ b/dashboard/src/app/api/tiger/config/route.ts @@ -0,0 +1,61 @@ +/** + * /api/tiger/config — GET + POST + * + * GET — Read the current openclaw.json config + * POST — Update config with a patch (deep-merged + hash regenerated) + * + * POST body: + * { + * patch: { + * model: { primary: "openrouter/anthropic/claude-opus-4" } + * } + * } + * + * The bridge handles deep-merging and hash regeneration automatically. + */ + +import { NextResponse } from "next/server"; +import { bridgeGet, bridgePost } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const config = await bridgeGet("/tiger/config"); + return NextResponse.json(config); + } catch (err: any) { + return NextResponse.json( + { ok: false, error: "Failed to read config", details: err.message }, + { status: 502 } + ); + } +} + +export async function POST(request: Request) { + let body: Record; + + try { + body = await request.json(); + } catch { + return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); + } + + const { patch } = body; + + if (!patch || typeof patch !== "object" || Array.isArray(patch)) { + return NextResponse.json( + { ok: false, error: "Request body must contain a 'patch' object" }, + { status: 400 } + ); + } + + try { + const result = await bridgePost("/tiger/config", { patch }); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json( + { ok: false, error: "Failed to update config", details: err.message }, + { status: 502 } + ); + } +} diff --git a/dashboard/src/app/api/tiger/dispatch/route.ts b/dashboard/src/app/api/tiger/dispatch/route.ts new file mode 100644 index 0000000..3c529ee --- /dev/null +++ b/dashboard/src/app/api/tiger/dispatch/route.ts @@ -0,0 +1,37 @@ +/** + * /api/tiger/dispatch — Task dispatch proxy + * + * POST /api/tiger/dispatch + * Body: { taskId, title, description, assignedAgent, context } + */ + +import { NextResponse } from "next/server"; +import { bridgePost } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const result = await bridgePost("/tiger/dispatch", body); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} + +// GET /api/tiger/dispatch/status/:taskId +export async function GET( + request: Request, + { params }: { params: Promise<{ taskId: string }> } +) { + const { taskId } = await params; + try { + const result = await bridgePost(`/tiger/dispatch/status/${taskId}`, {}); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} \ No newline at end of file diff --git a/dashboard/src/app/api/tiger/dispatch/status/[taskId]/route.ts b/dashboard/src/app/api/tiger/dispatch/status/[taskId]/route.ts new file mode 100644 index 0000000..9165b65 --- /dev/null +++ b/dashboard/src/app/api/tiger/dispatch/status/[taskId]/route.ts @@ -0,0 +1,22 @@ +/** + * /api/tiger/dispatch/status/[taskId] — Get dispatch status + */ + +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ taskId: string }> } +) { + const { taskId } = await params; + try { + const result = await bridgeGet(`/tiger/dispatch/status/${taskId}`); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} \ No newline at end of file diff --git a/dashboard/src/app/api/tiger/exec/route.ts b/dashboard/src/app/api/tiger/exec/route.ts new file mode 100644 index 0000000..5b5034b --- /dev/null +++ b/dashboard/src/app/api/tiger/exec/route.ts @@ -0,0 +1,53 @@ +/** + * /api/tiger/exec — POST + * + * Run a shell command inside the Tiger sandbox. + * Proxies to the Bridge's POST /tiger/exec endpoint. + * + * Request body: + * { + * command: string — the shell command to run + * timeout?: number — timeout in ms (default 30000) + * target?: "sandbox"|"host" — where to run (default: sandbox) + * } + * + * Response: + * { stdout: string, stderr: string, exitCode: number } + */ + +import { NextResponse } from "next/server"; +import { bridgePost } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + let body: Record; + + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 } + ); + } + + const { command, timeout, target } = body; + + if (!command || typeof command !== "string") { + return NextResponse.json( + { error: "Missing 'command' field in request body" }, + { status: 400 } + ); + } + + try { + const result = await bridgePost("/tiger/exec", { command, timeout, target }); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json( + { error: "Failed to execute command", details: err.message }, + { status: 502 } + ); + } +} diff --git a/dashboard/src/app/api/tiger/logs/route.ts b/dashboard/src/app/api/tiger/logs/route.ts new file mode 100644 index 0000000..f702f71 --- /dev/null +++ b/dashboard/src/app/api/tiger/logs/route.ts @@ -0,0 +1,107 @@ +/** + * /api/tiger/logs — GET (Server-Sent Events proxy) + * + * This route proxies the SSE stream from the Tiger Bridge to the browser. + * + * Why proxy instead of connecting directly from the browser to the bridge? + * - The TIGER_BRIDGE_TOKEN never reaches the browser (security) + * - Caddy only needs to expose the dashboard, not the bridge + * - Easier CORS — browser just talks to same-origin /api/... + * + * How SSE proxying works: + * Browser → EventSource("/api/tiger/logs") + * → This Next.js route opens a fetch() to the Bridge's /tiger/logs + * → Gets an SSE stream back + * → Reads it chunk by chunk and writes to a new ReadableStream + * → Browser receives the events as if directly from the bridge + * + * Query params (passed through to the bridge): + * ?lines=N — historical lines to tail first + * ?filter=text — keyword filter + */ + +import { bridgeLogsUrl, authHeaders } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + // Parse query params from the incoming browser request + const { searchParams } = new URL(request.url); + const lines = parseInt(searchParams.get("lines") || "100", 10); + const filter = searchParams.get("filter") || ""; + + // Build the Bridge SSE URL + const bridgeUrl = bridgeLogsUrl(lines, filter); + + let bridgeResponse: Response; + try { + // Open the SSE connection to the Tiger Bridge + // The Bridge token is added in the Authorization header (server-side only) + bridgeResponse = await fetch(bridgeUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + ...authHeaders(), + }, + // @ts-expect-error — duplex is required for streaming in some runtimes + duplex: "half", + }); + } catch (err: any) { + // Bridge is unreachable — send an error event then close + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `event: error\ndata: ${JSON.stringify({ message: `Bridge unreachable: ${err.message}` })}\n\n` + ) + ); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); + } + + if (!bridgeResponse.ok || !bridgeResponse.body) { + // Bridge returned an error — propagate it as an SSE error event + const errText = await bridgeResponse.text().catch(() => "unknown error"); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `event: error\ndata: ${JSON.stringify({ message: `Bridge error: ${errText}` })}\n\n` + ) + ); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); + } + + // Pipe the bridge's SSE stream directly to the browser + // The bridge already formats proper SSE events — we just forward them + return new Response(bridgeResponse.body, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/dashboard/src/app/api/tiger/projects/[id]/route.ts b/dashboard/src/app/api/tiger/projects/[id]/route.ts new file mode 100644 index 0000000..cccd2c9 --- /dev/null +++ b/dashboard/src/app/api/tiger/projects/[id]/route.ts @@ -0,0 +1,53 @@ +/** + * /api/tiger/projects/[id] — Single project proxy + * + * GET, PUT, DELETE for a specific project. + */ + +import { NextResponse } from "next/server"; +import { bridgeGet, bridgePost, bridgeDelete } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + try { + const result = await bridgeGet(`/tiger/projects/${id}`); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + try { + const body = await request.json(); + const result = await bridgePost(`/tiger/projects/${id}`, body); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + try { + const result = await bridgeDelete(`/tiger/projects/${id}`); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} \ No newline at end of file diff --git a/dashboard/src/app/api/tiger/projects/route.ts b/dashboard/src/app/api/tiger/projects/route.ts new file mode 100644 index 0000000..3d7678b --- /dev/null +++ b/dashboard/src/app/api/tiger/projects/route.ts @@ -0,0 +1,33 @@ +/** + * /api/tiger/projects — Project CRUD proxy + * + * Proxies requests from dashboard to Tiger Bridge. + */ + +import { NextResponse } from "next/server"; +import { bridgeGet, bridgePost } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +// GET /api/tiger/projects — list all projects +export async function GET() { + try { + const result = await bridgeGet("/tiger/projects"); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} + +// POST /api/tiger/projects — create project +export async function POST(request: Request) { + try { + const body = await request.json(); + const result = await bridgePost("/tiger/projects", body); + return NextResponse.json(result, { status: 201 }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} \ No newline at end of file diff --git a/dashboard/src/app/api/tiger/restart/route.ts b/dashboard/src/app/api/tiger/restart/route.ts new file mode 100644 index 0000000..8ca0e51 --- /dev/null +++ b/dashboard/src/app/api/tiger/restart/route.ts @@ -0,0 +1,39 @@ +/** + * /api/tiger/restart — POST + * + * Trigger a Tiger container restart via the gateway watchdog script. + * + * Request body (optional): + * { reason: "string" } + * + * Response: + * { ok: true, message: "Tiger restart triggered", reason: "..." } + * { ok: false, error: "...", reason: "..." } + */ + +import { NextResponse } from "next/server"; +import { bridgePost } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + let body: Record = {}; + + try { + body = await request.json(); + } catch { + // Body is optional — ignore parse errors + } + + const reason = (body.reason as string) || "manual restart via dashboard"; + + try { + const result = await bridgePost("/tiger/restart", { reason }); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json( + { ok: false, error: "Failed to restart Tiger", details: err.message, reason }, + { status: 502 } + ); + } +} diff --git a/dashboard/src/app/api/tiger/status/route.ts b/dashboard/src/app/api/tiger/status/route.ts new file mode 100644 index 0000000..77e5625 --- /dev/null +++ b/dashboard/src/app/api/tiger/status/route.ts @@ -0,0 +1,45 @@ +/** + * /api/tiger/status — GET + * + * Returns comprehensive Tiger agent status: container health, OpenClaw + * process state, system resources (memory), and heartbeat content. + * + * This route proxies to the Tiger Bridge API on the VPS. + * The bridge token is kept server-side — never exposed to the browser. + * + * Response shape (from bridge/src/tiger.ts → getTigerStatus()): + * { + * status: "online" | "degraded", + * container: { status, exitCode, startedAt }, + * openclaw: { running, processInfo }, + * system: { memoryUsagePct, memoryTotalMb, uptime }, + * agent: { currentModel, fallbackModels, heartbeat, soul } + * } + */ + +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; + +// force-dynamic: don't cache this route — status must always be fresh +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const status = await bridgeGet("/tiger/status"); + return NextResponse.json(status); + } catch (err: any) { + // Return a structured "offline" response rather than a 500, + // so the dashboard can still render meaningfully when the bridge is down + return NextResponse.json( + { + status: "offline", + error: err.message, + container: { status: "unreachable", exitCode: -1, startedAt: "" }, + openclaw: { running: false, processInfo: "" }, + system: { memoryUsagePct: 0, memoryTotalMb: 0, uptime: "" }, + agent: { currentModel: "unknown", fallbackModels: [], heartbeat: null, soul: null }, + }, + { status: 200 } // Still 200 so the dashboard handles it gracefully + ); + } +} diff --git a/dashboard/src/app/api/tiger/tasks/[id]/route.ts b/dashboard/src/app/api/tiger/tasks/[id]/route.ts new file mode 100644 index 0000000..48ecef0 --- /dev/null +++ b/dashboard/src/app/api/tiger/tasks/[id]/route.ts @@ -0,0 +1,69 @@ +/** + * /api/tiger/tasks/[id] — Single task proxy + * + * GET, PUT, DELETE for a specific task. + * POST to trigger execution. + */ + +import { NextResponse } from "next/server"; +import { bridgeGet, bridgePost, bridgeDelete } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + try { + const result = await bridgeGet(`/tiger/tasks/${id}`); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + try { + const body = await request.json(); + const result = await bridgePost(`/tiger/tasks/${id}`, body); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + try { + const result = await bridgeDelete(`/tiger/tasks/${id}`); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} + +// POST /api/tiger/tasks/[id]/execute — trigger execution +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + try { + const result = await bridgePost(`/tiger/tasks/${id}/execute`, {}); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} \ No newline at end of file diff --git a/dashboard/src/app/api/tiger/tasks/route.ts b/dashboard/src/app/api/tiger/tasks/route.ts new file mode 100644 index 0000000..12b9f6b --- /dev/null +++ b/dashboard/src/app/api/tiger/tasks/route.ts @@ -0,0 +1,44 @@ +/** + * /api/tiger/tasks — Task CRUD proxy + * + * Proxies requests from dashboard to Tiger Bridge. + */ + +import { NextResponse } from "next/server"; +import { bridgeGet, bridgePost } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +// GET /api/tiger/tasks — list tasks (with optional filters) +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const query: Record = {}; + + const status = searchParams.get("status"); + const project = searchParams.get("project"); + const agent = searchParams.get("agent"); + + if (status) query.status = status; + if (project) query.project = project; + if (agent) query.agent = agent; + + const result = await bridgeGet("/tiger/tasks", query); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} + +// POST /api/tiger/tasks — create task (not used directly - tasks belong to projects) +export async function POST(request: Request) { + try { + const body = await request.json(); + const result = await bridgePost("/tiger/tasks", body); + return NextResponse.json(result, { status: 201 }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} \ No newline at end of file diff --git a/dashboard/src/app/api/tiger/workspace/route.ts b/dashboard/src/app/api/tiger/workspace/route.ts new file mode 100644 index 0000000..e07c3e7 --- /dev/null +++ b/dashboard/src/app/api/tiger/workspace/route.ts @@ -0,0 +1,55 @@ +/** + * /api/tiger/workspace — GET + * + * List files in the Tiger agent's workspace. + * + * Query params: + * ?path=subdir — list files in a subdirectory (default: root) + * ?read=filepath — read a specific file's contents + * + * List response: + * { + * ok: true, + * path: "/", + * count: 12, + * files: [{ name, type, size, modified }] + * } + * + * Read response: + * { + * ok: true, + * path: "MEMORY.md", + * content: "...", + * size: 1234 + * } + */ + +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const readPath = searchParams.get("read"); + const listPath = searchParams.get("path") || ""; + + try { + if (readPath) { + // Read mode: get file contents + const result = await bridgeGet("/tiger/files/read", { path: readPath }); + return NextResponse.json(result); + } else { + // List mode: list directory contents + const query: Record = {}; + if (listPath) query.path = listPath; + const result = await bridgeGet("/tiger/workspace", query); + return NextResponse.json(result); + } + } catch (err: any) { + return NextResponse.json( + { ok: false, error: "Workspace error", details: err.message }, + { status: 502 } + ); + } +} diff --git a/dashboard/src/app/cost/page.tsx b/dashboard/src/app/cost/page.tsx new file mode 100644 index 0000000..e0e75c7 --- /dev/null +++ b/dashboard/src/app/cost/page.tsx @@ -0,0 +1,71 @@ +import { CostMonitor } from "@/components/cost/cost-monitor" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { DollarSign, TrendingUp, Info } from "lucide-react" + +export default function CostPage() { + return ( +
+
+
+

+ + Cost Monitor +

+

+ Track API usage costs across all AI models and sessions +

+
+
+ + {/* Info Card */} + + +
+ +
+

Cost Tracking Active

+

+ Costs are calculated based on token usage and approximate model rates. + Actual costs may vary depending on your API provider pricing. Set your monthly + budget limit in settings to receive alerts. +

+
+
+
+
+ + {/* Cost Monitor Component */} + + + {/* Additional Info */} + + + + + Supported Models + + + +
+
+

OpenAI

+

GPT-4o, GPT-4o-mini

+
+
+

Anthropic

+

Claude 3 Opus, Claude 3.5 Sonnet

+
+
+

Google

+

Gemini Pro 1.5, Gemini Flash 1.5

+
+
+

Others

+

Kimi K2.5, and more via OpenRouter

+
+
+
+
+
+ ) +} diff --git a/dashboard/src/app/logs/page.tsx b/dashboard/src/app/logs/page.tsx index 2767b70..0a33331 100644 --- a/dashboard/src/app/logs/page.tsx +++ b/dashboard/src/app/logs/page.tsx @@ -1,149 +1,239 @@ +/** + * Logs Page — Real-time Tiger container log viewer + * + * Uses the Tiger Bridge's SSE stream (/api/tiger/logs) instead of the + * old WebSocket gateway. Key differences: + * - Before: gateway events (agent thoughts/actions) via WebSocket + * - Now: actual Docker container logs via SSE → much more useful for debugging + * + * Features: + * - Live streaming with auto-scroll + * - Pause/resume + * - Keyword filter (reconnects SSE with filter param) + * - Color-coded log levels + * - Clear button + */ + "use client" import * as React from "react" -import { ScrollText, RotateCcw, Loader2, Pause, Play } from "lucide-react" +import { ScrollText, RotateCcw, Loader2, Pause, Play, Filter, X } from "lucide-react" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" import { ScrollArea } from "@/components/ui/scroll-area" -import { useGatewayRequest, useGatewayEvents } from "@/hooks/use-gateway" +import { useTigerLogs } from "@/hooks/use-bridge" import { cn } from "@/lib/utils" -interface LogEntry { - id: string - timestamp: string - level: string - message: string - subsystem?: string -} - export default function LogsPage() { - const { request } = useGatewayRequest() - const [logs, setLogs] = React.useState([]) - const [loading, setLoading] = React.useState(true) - const [paused, setPaused] = React.useState(false) + // filterInput is the text currently in the search box (controlled input) + const [filterInput, setFilterInput] = React.useState("") + // activeFilter is what we actually pass to the SSE hook (applied on Enter/button) + const [activeFilter, setActiveFilter] = React.useState("") + + /** + * useTigerLogs opens an EventSource to /api/tiger/logs + * and handles reconnection automatically. + */ + const { logs, connected, paused, clear, pause, resume } = useTigerLogs({ + lines: 150, // tail 150 lines of history first + filter: activeFilter, // keyword filter applied server-side + maxLines: 600, // keep at most 600 lines in memory + }) + + // Ref to the scroll container so we can auto-scroll to bottom const scrollRef = React.useRef(null) - const pausedRef = React.useRef(false) - pausedRef.current = paused - // Load initial logs - React.useEffect(() => { - request("logs.tail", { lines: 100 }) - .then((data: unknown) => { - const result = data as { logs?: LogEntry[]; lines?: string[] } - if (result?.logs) { - setLogs(result.logs) - } else if (result?.lines) { - setLogs(result.lines.map((line, i) => parseLogLine(line, i))) - } else if (Array.isArray(data)) { - setLogs((data as string[]).map((line, i) => parseLogLine(String(line), i))) - } - }) - .catch(() => { - setLogs([{ id: "err", timestamp: new Date().toISOString(), level: "ERROR", message: "Failed to load logs. Is the gateway running?" }]) - }) - .finally(() => setLoading(false)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // Subscribe to live events for log-like data - useGatewayEvents((event, payload) => { - if (pausedRef.current) return - const data = payload as Record - const entry: LogEntry = { - id: `live-${Date.now()}-${Math.random()}`, - timestamp: new Date().toISOString(), - level: "INFO", - message: `[${event}] ${JSON.stringify(data).slice(0, 200)}`, - subsystem: event, - } - setLogs(prev => [...prev.slice(-500), entry]) - }, []) - - // Auto-scroll + // Auto-scroll to bottom when new logs arrive (unless paused) React.useEffect(() => { if (!paused && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight } }, [logs, paused]) - const handleClear = () => setLogs([]) + // Apply filter: reconnect SSE with the new filter keyword + const applyFilter = () => { + setActiveFilter(filterInput) + clear() // Clear old entries when filter changes + } + const clearFilter = () => { + setFilterInput("") + setActiveFilter("") + clear() + } + + // Color codes for log levels — matches the terminal convention function levelColor(level: string) { - if (level === "ERROR") return "text-red-400" - if (level === "WARN") return "text-yellow-400" - if (level === "DEBUG") return "text-gray-500" - return "text-muted-foreground" + switch (level) { + case "ERROR": return "text-red-400" + case "WARN": return "text-yellow-400" + case "DEBUG": return "text-gray-500" + default: return "text-gray-400" // INFO + } + } + + // Faint background highlight for errors + function rowBg(level: string) { + if (level === "ERROR") return "bg-red-950/20" + if (level === "WARN") return "bg-yellow-950/10" + return "" } return ( -
-
+
+ + {/* ── Header ─────────────────────────────────────────────────── */} +
-

Live Logs

-

Real-time gateway event stream.

+

Tiger Logs

+

+ Live Docker container logs via Tiger Bridge +

-
-
+ + {/* ── Controls ───────────────────────────────────────────────── */} +
+ {/* Filter box */} +
+
+ + setFilterInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && applyFilter()} + /> + {filterInput && ( + + )} +
+ -
+ + {/* Active filter badge */} + {activeFilter && ( + + Filter: "{activeFilter}" + + )} + +
+ +
-
- {loading ? ( -
- + {/* ── Log viewer ─────────────────────────────────────────────── */} +
+ {logs.length === 0 ? ( +
+ +

+ {connected + ? "Waiting for logs…" + : "Connecting to Tiger Bridge…"} +

+ {!connected && ( +

+ Make sure the Tiger Bridge is running on the VPS +

+ )}
) : ( - -
- {logs.map(entry => ( -
- - {new Date(entry.timestamp).toLocaleTimeString()} - - - {entry.level} - - {entry.subsystem && ( - [{entry.subsystem}] - )} - {entry.message} -
- ))} - {logs.length === 0 && ( -
- - No log entries yet. Events will appear here in real-time. -
- )} -
-
+ // ScrollArea component wraps a scrollable div + // We give it a ref so we can programmatically scroll to bottom +
+ {logs.map((entry) => ( +
+ {/* Timestamp — short format for readability */} + + {new Date(entry.ts).toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + + + {/* Level badge */} + + {entry.level} + + + {/* Log text — break-all prevents long lines from overflowing */} + + {entry.text} + +
+ ))} + + {/* Pause indicator at the bottom */} + {paused && ( +
+ ── paused ── +
+ )} +
)}
+ + {/* ── Footer info ────────────────────────────────────────────── */} +
+ {logs.length} lines + {activeFilter && Filtered: "{activeFilter}"} + Source: docker logs → Tiger Bridge SSE +
) } - -function parseLogLine(line: string, index: number): LogEntry { - try { - const parsed = JSON.parse(line) - return { - id: `log-${index}`, - timestamp: parsed.time || parsed._meta?.date || new Date().toISOString(), - level: parsed._meta?.logLevelName || "INFO", - message: parsed["0"] || JSON.stringify(parsed), - subsystem: parsed._meta?.name || undefined, - } - } catch { - return { - id: `log-${index}`, - timestamp: new Date().toISOString(), - level: "INFO", - message: line, - } - } -} diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx index 499667c..d91b473 100644 --- a/dashboard/src/app/page.tsx +++ b/dashboard/src/app/page.tsx @@ -1,465 +1,380 @@ "use client" import useSWR from 'swr' -import { ChatInterface } from "@/components/chat-interface" -import Link from "next/link" import { StatCard } from "@/components/stat-card" import { Activity, - BrainCircuit, Bot, Clock, - FileText, AlertCircle, - Users, Zap, Cpu, Check, Loader2, - Sparkles, - Eye, - MessageSquare, - Image as ImageIcon, + RefreshCw, + Server, + Terminal, + MemoryStick, } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" -import { ScrollArea } from "@/components/ui/scroll-area" -import { useGatewayRequest } from "@/hooks/use-gateway" +import { Button } from "@/components/ui/button" +import { useBridgeRequest } from "@/hooks/use-bridge" import { cn } from "@/lib/utils" import * as React from "react" const fetcher = (url: string) => fetch(url).then((res) => res.json()) -interface ModelInfo { - id: string - name: string - provider: string - contextWindow?: number - reasoning?: boolean - input?: string[] -} - -function parseModelId(modelId: string): { provider: string; name: string; fullId: string } { - if (!modelId) return { provider: "", name: "", fullId: "" } - // Format: "provider/org/model:variant" or "provider/model" or just "model-id" - const parts = modelId.split("/") - if (parts.length >= 2) { - const provider = parts[0] - const rest = parts.slice(1).join("/") - // Remove :variant suffix for display name - const name = rest.replace(/:.*$/, "") - return { provider, name, fullId: modelId } +interface TigerStatus { + status: "online" | "degraded" | "offline" + container: { + status: string + exitCode: number + startedAt: string } - return { provider: "unknown", name: modelId.replace(/:.*$/, ""), fullId: modelId } -} - -function providerLabel(provider: string): string { - const labels: Record = { - openrouter: "OpenRouter", - anthropic: "Anthropic", - openai: "OpenAI", - google: "Google", - "arcee-ai": "Arcee AI", + openclaw: { + running: boolean + processInfo: string } - return labels[provider.toLowerCase()] || provider -} - -function providerColor(provider: string): string { - const colors: Record = { - openrouter: "bg-violet-500/15 text-violet-400 border-violet-500/20", - anthropic: "bg-amber-500/15 text-amber-400 border-amber-500/20", - openai: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20", - google: "bg-blue-500/15 text-blue-400 border-blue-500/20", + system: { + memoryUsagePct: number + memoryTotalMb: number + uptime: string + } + agent: { + currentModel: string + fallbackModels: string[] + heartbeat: string | null + soul: string | null } - return colors[provider.toLowerCase()] || "bg-muted text-muted-foreground border-border" } -function formatContextWindow(cw: number): string { - if (cw >= 1000000) return `${(cw / 1000000).toFixed(1)}M` - if (cw >= 1000) return `${Math.round(cw / 1000)}K` - return String(cw) +function formatUptime(startedAt: string): string { + if (!startedAt) return "—" + const start = new Date(startedAt) + const now = new Date() + const diffMs = now.getTime() - start.getTime() + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) + + if (diffHours > 24) { + const days = Math.floor(diffHours / 24) + return `${days}d ${diffHours % 24}h` + } + return `${diffHours}h ${diffMins}m` } export default function DashboardPage() { - const { data: statusData, error: statusError, mutate: mutateStatus } = useSWR('/api/status', fetcher, { refreshInterval: 5000 }) - const { data: memoryData } = useSWR('/api/memory', fetcher) - const { request } = useGatewayRequest() - const [sessionCount, setSessionCount] = React.useState(null) - const [switchingModel, setSwitchingModel] = React.useState(null) - const [modelSearch, setModelSearch] = React.useState("") + const { data: status, error: statusError, isLoading } = useSWR('/api/tiger/status', fetcher, { + refreshInterval: 5000, + revalidateOnFocus: true, + }) + const { request } = useBridgeRequest() + const [restarting, setRestarting] = React.useState(false) + const [restartSuccess, setRestartSuccess] = React.useState(false) - const isError = !!statusError - const isGateway = statusData?.gateway === true + const isOffline = statusError || status?.status === "offline" + const isCrashed = status?.container?.exitCode === 255 - const currentModel = (statusData?.agent?.currentModel || "") as string - const fallbackModels = (statusData?.agent?.fallbackModels || []) as string[] - const models = (statusData?.models || []) as ModelInfo[] - - const currentParsed = parseModelId(currentModel) - - // Fetch session count from gateway - React.useEffect(() => { - request("sessions.list", {}) - .then((data: unknown) => { - const result = data as { sessions?: unknown[] } | unknown[] - if (Array.isArray(result)) { - setSessionCount(result.length) - } else if (result?.sessions) { - setSessionCount(result.sessions.length) - } - }) - .catch(() => {}) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - const handleSwitchModel = async (modelId: string) => { - setSwitchingModel(modelId) + const handleRestart = async () => { + setRestarting(true) + setRestartSuccess(false) try { - // Use config.patch with the correct path: agents.defaults.model.primary - const patch = JSON.stringify({ - agents: { defaults: { model: { primary: modelId } } } - }) - await request("config.patch", { raw: patch }) - setTimeout(() => mutateStatus(), 500) + await request("/api/tiger/restart", "POST") + setRestartSuccess(true) + setTimeout(() => setRestartSuccess(false), 3000) } catch (e) { - console.error("Failed to switch model", e) + console.error("Failed to restart:", e) } finally { - setSwitchingModel(null) + setRestarting(false) } } - // Group models by provider - const modelsByProvider = React.useMemo(() => { - const groups: Record = {} - const filtered = models.filter(m => - (m.id || m.name || "").toLowerCase().includes(modelSearch.toLowerCase()) || - (m.provider || "").toLowerCase().includes(modelSearch.toLowerCase()) || - (m.name || "").toLowerCase().includes(modelSearch.toLowerCase()) - ) - for (const m of filtered) { - const provider = m.provider || parseModelId(m.id).provider - if (!groups[provider]) groups[provider] = [] - groups[provider].push(m) - } - return groups - }, [models, modelSearch]) - - // Find current model's full info - const currentModelInfo = models.find(m => m.id === currentModel) - return (
+ + {/* Crash Recovery Banner */} + {isCrashed && ( +
+
+ +
+ Tiger Crashed + (exit code 255 — MiniMax API unreachable) +
+
+ +
+ )} + {/* Stat Cards Row */}
- - - - - - - - - - - - + + +
+
+

Container

+

+ {isLoading ? "..." : status?.container?.status || "Unknown"} +

+
+ +
+
+
+ + + +
+
+

OpenClaw

+

+ {isLoading ? "..." : status?.openclaw?.running ? "Running" : "Stopped"} +

+
+ +
+
+
+ + + +
+
+

Memory

+

+ {isLoading ? "..." : `${status?.system?.memoryUsagePct || 0}%`} +

+
+ +
+
+
+ + + +
+
+

Uptime

+

+ {isLoading ? "..." : formatUptime(status?.container?.startedAt || "")} +

+
+ +
+
+
- {isError && ( + {/* Error State */} + {isOffline && !isLoading && (
- Failed to connect to agent backend. Ensure local server is running. + Failed to connect to Tiger Bridge. Ensure the bridge server is running on the VPS.
)}
- {/* Recent Memory Widget */} + {/* Container Health Card */} - - - - Recent Memory - - - - -
- {memoryData?.files?.length > 0 ? ( - memoryData.files.slice(0, 10).map((file: { name: string; date: string }, i: number) => ( - -
-
{file.date}
-
-
{file.name.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '')}
-
-
- - )) - ) : ( -
No memory files yet.
- )} -
-
-
-
- - {/* System Info Widget */} - - System Info + Tiger Health - {isGateway ? ( + {status?.status === "online" ? ( - Gateway connected + All systems operational + + ) : status?.status === "degraded" ? ( + + Degraded mode ) : ( - "Fallback mode" + "Connection lost" )} -
- {statusData?.agent?.name && ( -
- Agent - {statusData.agent.emoji} {statusData.agent.name} -
- )} -
- Status - - {statusData?.status === "online" ? "Online" : "Offline"} - -
-
- Platform - {statusData?.system?.platform || "..."} -
-
- Memory Usage - {statusData?.system?.memoryUsage ? `${statusData.system.memoryUsage}%` : "..."} -
-
- Uptime - {statusData?.system?.uptime ? `${(statusData.system.uptime / 3600).toFixed(1)}h` : "..."} -
-
-
- Heartbeat - - {statusData?.agent?.lastHeartbeat ? new Date(statusData.agent.lastHeartbeat).toLocaleTimeString() : "—"} - -
- {statusData?.agent?.heartbeatContent && ( -
- {statusData.agent.heartbeatContent - .split("\n") - .filter((line: string) => line.startsWith("- ") || line.startsWith("# ")) - .map((line: string, i: number) => ( -
- {line.startsWith("# ") ? line.replace(/^#+\s*/, "") : line} -
- ))} -
- )} -
-
-
-
-
- - {/* Model Management */} - {isGateway && ( - - - - - AI Model - - - {currentModel ? ( - - - {providerLabel(currentModelInfo?.provider || currentParsed.provider)} - - {currentModelInfo?.name || currentParsed.name} - - ) : "No model configured"} - - - -
- {/* Left: Current Model + Fallbacks */} -
-
-
Primary Model
-
-
- {currentModelInfo?.name || currentParsed.name || "Not configured"} -
-
{currentModel || "—"}
-
- {(currentModelInfo?.provider || currentParsed.provider) && ( - - {providerLabel(currentModelInfo?.provider || currentParsed.provider)} - - )} - {currentModelInfo?.contextWindow && ( - - {formatContextWindow(currentModelInfo.contextWindow)} ctx - - )} - {currentModelInfo?.reasoning && ( - - Reasoning - - )} - {currentModelInfo?.input?.includes("image") && ( - - Vision - - )} -
-
+
+ {/* Container Status */} +
+ Container Status +
+ + {status?.container?.status || "—"}
- - {/* Fallback Models */} - {fallbackModels.length > 0 && ( -
-
Fallback Models
-
- {fallbackModels.map((fb, i) => { - const parsed = parseModelId(fb) - const info = models.find(m => m.id === fb) - return ( - - ) - })} -
-
- )}
- {/* Right: Available Models */} -
-
-
Available Models
- {models.length} models + {/* Exit Code */} + {status?.container?.exitCode !== undefined && status?.container?.exitCode !== 0 && ( +
+ Exit Code + + {status?.container?.exitCode} + {status?.container?.exitCode === 255 && " (API unreachable)"} +
-
- setModelSearch(e.target.value)} - /> -
-
- {Object.keys(modelsByProvider).length === 0 ? ( -
No models found
+ )} + + {/* OpenClaw Process */} +
+ OpenClaw Process + + {status?.openclaw?.running ? "Running" : "Not running"} + +
+ + {/* Memory Usage */} +
+ Memory Usage + + {status?.system?.memoryUsagePct || 0}% of {status?.system?.memoryTotalMb || 0}MB + +
+ + {/* Uptime */} +
+ Container Uptime + {formatUptime(status?.container?.startedAt || "")} +
+ + {/* Restart Button */} +
+ - ) - })} -
- )) + )} -
+ Restart Container +
- )} - {/* Command Center Row */} -
- + {/* Agent Model Card */} + + + + + Agent Model + + Current AI model configuration + + +
+ {/* Primary Model */} +
+
Primary Model
+
+ {isLoading ? "..." : status?.agent?.currentModel || "Not configured"} +
+
+ + {/* Fallback Models */} + {status?.agent?.fallbackModels && status.agent.fallbackModels.length > 0 && ( +
+
Fallback Models
+
+ {status.agent.fallbackModels.map((model, i) => ( +
+ {model} +
+ ))} +
+
+ )} + + {/* Heartbeat */} + {status?.agent?.heartbeat && ( +
+
Last Heartbeat
+
+                    {status.agent.heartbeat.slice(0, 500)}
+                  
+
+ )} +
+
+
+
+ + {/* Quick Links */} +
) -} +} \ No newline at end of file diff --git a/dashboard/src/app/projects/page.tsx b/dashboard/src/app/projects/page.tsx new file mode 100644 index 0000000..48fc579 --- /dev/null +++ b/dashboard/src/app/projects/page.tsx @@ -0,0 +1,373 @@ +/** + * Projects Page — Project management with tasks + * + * Lists projects as cards and provides project detail view with Kanban. + */ + +"use client" + +import * as React from "react" +import { FolderOpen, Plus, Loader2, MoreVertical, Pencil, Trash2 } from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useBridgeRequest } from "@/hooks/use-bridge" +import { cn } from "@/lib/utils" + +interface Project { + id: string + name: string + description: string + status: string + priority: string + created_at: string + updated_at: string +} + +interface Task { + id: string + project_id: string + title: string + description: string + status: string + priority: string + assigned_agent: string | null + progress: number + created_at: string + updated_at: string +} + +const PRIORITY_COLORS: Record = { + low: "bg-gray-500/10 text-gray-400 border-gray-500/20", + medium: "bg-blue-500/10 text-blue-400 border-blue-500/20", + high: "bg-amber-500/10 text-amber-400 border-amber-500/20", + urgent: "bg-red-500/10 text-red-400 border-red-500/20", +} + +const STATUS_COLORS: Record = { + active: "bg-green-500/10 text-green-400 border-green-500/20", + paused: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", + completed: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", + archived: "bg-gray-500/10 text-gray-400 border-gray-500/20", +} + +export default function ProjectsPage() { + const { request } = useBridgeRequest() + const [projects, setProjects] = React.useState([]) + const [selectedProject, setSelectedProject] = React.useState(null) + const [tasks, setTasks] = React.useState([]) + const [loading, setLoading] = React.useState(false) + const [loadingTasks, setLoadingTasks] = React.useState(false) + const [error, setError] = React.useState(null) + const [isCreateOpen, setIsCreateOpen] = React.useState(false) + const [newProjectName, setNewProjectName] = React.useState("") + const [newProjectDesc, setNewProjectDesc] = React.useState("") + const [newProjectPriority, setNewProjectPriority] = React.useState("medium") + const [creating, setCreating] = React.useState(false) + + // Load projects + const loadProjects = React.useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await request("/api/tiger/projects") as { ok: boolean; projects?: Project[] } + if (data.ok && data.projects) { + setProjects(data.projects) + } else { + setError("Failed to load projects") + } + } catch (e: unknown) { + setError("Failed to load projects") + } finally { + setLoading(false) + } + }, [request]) + + // Load tasks for selected project + const loadTasks = React.useCallback(async (projectId: string) => { + setLoadingTasks(true) + try { + const data = await request("/api/tiger/projects") as { ok: boolean; projects?: Project[] } + // Get tasks from project + const projectData = await request(`/api/tiger/projects/${projectId}`) as { ok: boolean; project?: { tasks?: Task[] } } + if (projectData.ok && projectData.project?.tasks) { + setTasks(projectData.project.tasks) + } else { + // Fallback: get all tasks and filter + const allTasks = await request("/api/tiger/tasks") as { ok: boolean; tasks?: Task[] } + if (allTasks.ok && allTasks.tasks) { + setTasks(allTasks.tasks.filter(t => t.project_id === projectId)) + } + } + } catch (e: unknown) { + console.error("Failed to load tasks:", e) + } finally { + setLoadingTasks(false) + } + }, [request]) + + React.useEffect(() => { + loadProjects() + }, [loadProjects]) + + React.useEffect(() => { + if (selectedProject) { + loadTasks(selectedProject.id) + } + }, [selectedProject, loadTasks]) + + const handleCreateProject = async () => { + if (!newProjectName.trim()) return + setCreating(true) + try { + await request("/api/tiger/projects", "POST", { + name: newProjectName, + description: newProjectDesc, + priority: newProjectPriority, + }) + setNewProjectName("") + setNewProjectDesc("") + setNewProjectPriority("medium") + setIsCreateOpen(false) + loadProjects() + } catch (e: unknown) { + console.error("Failed to create project:", e) + } finally { + setCreating(false) + } + } + + const handleDeleteProject = async (id: string) => { + try { + await request("/api/tiger/projects", "POST", { _method: "DELETE", id }) + loadProjects() + if (selectedProject?.id === id) { + setSelectedProject(null) + setTasks([]) + } + } catch (e: unknown) { + console.error("Failed to delete project:", e) + } + } + + // Group tasks by status + const tasksByStatus = React.useMemo(() => { + const grouped: Record = { + backlog: [], + ready: [], + "in-progress": [], + review: [], + done: [], + } + tasks.forEach(task => { + if (grouped[task.status]) { + grouped[task.status].push(task) + } + }) + return grouped + }, [tasks]) + + return ( +
+ {/* Header */} +
+
+

+ + Projects +

+

+ Manage projects and track tasks across your team +

+
+ + + + + + + + Create Project + Create a new project to organize tasks. + +
+
+ + setNewProjectName(e.target.value)} + placeholder="Project name" + className="mt-1" + /> +
+
+ + setNewProjectDesc(e.target.value)} + placeholder="Project description" + className="mt-1" + /> +
+
+ + +
+ +
+
+
+
+ + {error && ( +
{error}
+ )} + + {/* Projects Grid */} + {loading ? ( +
+ +
+ ) : projects.length === 0 ? ( +
+ +

No projects yet. Create your first project to get started.

+
+ ) : ( +
+ {projects.map((project) => ( + setSelectedProject(project)} + > + +
+ {project.name} + + e.stopPropagation()}> + + + + { + e.stopPropagation() + // TODO: Edit project + }}> + + Edit + + { + e.stopPropagation() + handleDeleteProject(project.id) + }} className="text-destructive"> + + Delete + + + +
+ + {project.description || "No description"} + +
+ +
+ + {project.priority} + + + {project.status} + +
+
+
+ ))} +
+ )} + + {/* Project Detail with Tasks */} + {selectedProject && ( +
+
+ +

{selectedProject.name}

+ + {selectedProject.priority} + +
+ + {/* Simple Kanban - Tasks by Status */} + {loadingTasks ? ( +
+ +
+ ) : ( +
+ {(["backlog", "ready", "in-progress", "review", "done"] as const).map((status) => ( +
+
+ {status.replace("-", " ")} ({tasksByStatus[status].length}) +
+
+ {tasksByStatus[status].map((task) => ( + +
{task.title}
+ {task.description && ( +
+ {task.description} +
+ )} + {task.assigned_agent && ( + + {task.assigned_agent} + + )} +
+ ))} +
+
+ ))} +
+ )} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/dashboard/src/app/settings/page.tsx b/dashboard/src/app/settings/page.tsx index 14f2067..3f1f7de 100644 --- a/dashboard/src/app/settings/page.tsx +++ b/dashboard/src/app/settings/page.tsx @@ -5,18 +5,11 @@ import { Settings2, Save, Loader2, RefreshCw, Eye, EyeOff } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { useGatewayRequest } from "@/hooks/use-gateway" +import { useBridgeRequest } from "@/hooks/use-bridge" import { cn } from "@/lib/utils" type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue } -interface ConfigSection { - key: string - label: string - description: string - fields: ConfigField[] -} - interface ConfigField { path: string label: string @@ -25,63 +18,50 @@ interface ConfigField { original: ConfigValue } -const CONFIG_SECTIONS: { key: string; label: string; description: string; paths: { path: string; label: string; type: "text" | "number" | "boolean" | "password" }[] }[] = [ - { - key: "gateway", - label: "Gateway", - description: "Core gateway server settings", - paths: [ - { path: "gateway.port", label: "Port", type: "number" }, - { path: "gateway.host", label: "Host", type: "text" }, - { path: "gateway.auth.token", label: "Auth Token", type: "password" }, - ], - }, +// Tiger config sections - matches the structure from /tiger/config +const CONFIG_SECTIONS = [ { key: "agent", label: "Agent", - description: "AI agent configuration", + description: "AI agent model configuration", paths: [ - { path: "agent.name", label: "Agent Name", type: "text" }, - { path: "agent.model", label: "Primary Model", type: "text" }, - { path: "agent.fallbackModel", label: "Fallback Model", type: "text" }, - { path: "agent.maxConcurrentAgents", label: "Max Concurrent Agents", type: "number" }, - { path: "agent.maxSubAgents", label: "Max Sub-Agents", type: "number" }, + { path: "model", label: "Primary Model", type: "text" }, + { path: "fallbackModels", label: "Fallback Models", type: "text" }, ], }, { - key: "telegram", - label: "Telegram", - description: "Telegram bot integration", + key: "execution", + label: "Execution", + description: "Command execution settings", paths: [ - { path: "telegram.enabled", label: "Enabled", type: "boolean" }, - { path: "telegram.token", label: "Bot Token", type: "password" }, + { path: "maxDuration", label: "Max Duration (seconds)", type: "number" }, + { path: "maxRetries", label: "Max Retries", type: "number" }, ], }, - { - key: "heartbeat", - label: "Heartbeat", - description: "Periodic check settings", - paths: [ - { path: "heartbeat.enabled", label: "Enabled", type: "boolean" }, - { path: "heartbeat.intervalMinutes", label: "Interval (minutes)", type: "number" }, - ], - }, -] +] as const + +interface ConfigSection { + key: string + label: string + description: string + fields: ConfigField[] +} export default function SettingsPage() { - const { request } = useGatewayRequest() + const { request } = useBridgeRequest() const [sections, setSections] = React.useState([]) const [loading, setLoading] = React.useState(true) - const [saving, setSaving] = React.useState(null) - const [savedKey, setSavedKey] = React.useState(null) + const [saving, setSaving] = React.useState(false) + const [saved, setSaved] = React.useState(false) const [error, setError] = React.useState(null) const [showPasswords, setShowPasswords] = React.useState>({}) + // Load config from Tiger Bridge const loadConfig = React.useCallback(async () => { setLoading(true) setError(null) try { - const data = await request("config.get", {}) as Record + const data = await request("/api/tiger/config") as Record const loadedSections: ConfigSection[] = CONFIG_SECTIONS.map(section => ({ key: section.key, @@ -101,7 +81,7 @@ export default function SettingsPage() { setSections(loadedSections) } catch { - setError("Failed to load configuration. Is the gateway running?") + setError("Failed to load configuration. Is the Tiger Bridge running?") } finally { setLoading(false) } @@ -127,40 +107,49 @@ export default function SettingsPage() { ) } - const handleSaveSection = async (section: ConfigSection) => { - setSaving(section.key) + const handleSave = async () => { + setSaving(true) setError(null) - try { - const changedFields = section.fields.filter( - f => JSON.stringify(f.value) !== JSON.stringify(f.original) - ) + setSaved(false) - for (const field of changedFields) { - let val = field.value - if (field.type === "number") val = Number(val) - if (field.type === "boolean") val = val === true || val === "true" - await request("config.set", { key: field.path, value: val }) + try { + // Build patch object from all changed fields + const patch: Record = {} + + for (const section of sections) { + for (const field of section.fields) { + if (JSON.stringify(field.value) !== JSON.stringify(field.original)) { + let val = field.value + if (field.type === "number") val = Number(val) + if (field.type === "boolean") val = val === true || val === "true" + patch[field.path] = val + } + } } - // Update originals - setSections(prev => - prev.map(s => - s.key === section.key - ? { ...s, fields: s.fields.map(f => ({ ...f, original: f.value })) } - : s + if (Object.keys(patch).length > 0) { + await request("/api/tiger/config", "POST", { patch }) + + // Update originals + setSections(prev => + prev.map(s => ({ + ...s, + fields: s.fields.map(f => ({ ...f, original: f.value })), + })) ) - ) - setSavedKey(section.key) - setTimeout(() => setSavedKey(null), 2000) + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } } catch { - setError(`Failed to save ${section.label} settings.`) + setError("Failed to save configuration.") } finally { - setSaving(null) + setSaving(false) } } - const hasChanges = (section: ConfigSection) => - section.fields.some(f => JSON.stringify(f.value) !== JSON.stringify(f.original)) + const hasChanges = sections.some(s => + s.fields.some(f => JSON.stringify(f.value) !== JSON.stringify(f.original)) + ) return (
@@ -170,12 +159,22 @@ export default function SettingsPage() { Settings -

Gateway configuration. Changes are applied live.

+

Tiger agent configuration. Changes are applied live.

+
+
+ +
-
{error && ( @@ -258,20 +257,6 @@ export default function SettingsPage() { )}
))} -
- -
)) @@ -288,4 +273,4 @@ function getNestedValue(obj: Record, path: string): ConfigV current = (current as Record)[key] } return current ?? null -} +} \ No newline at end of file diff --git a/dashboard/src/app/tasks/page.tsx b/dashboard/src/app/tasks/page.tsx new file mode 100644 index 0000000..24eecbf --- /dev/null +++ b/dashboard/src/app/tasks/page.tsx @@ -0,0 +1,85 @@ +import { KanbanBoard } from "@/components/tasks/kanban-board" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { CheckSquare, GitBranch, Bot, Clock } from "lucide-react" + +export default function TasksPage() { + return ( +
+
+
+

+ + Task Progress +

+

+ Manage and track tasks across your team and AI sub-agents +

+
+
+ + {/* Quick Stats */} +
+ + +
+
+

Active Tasks

+

+
+ +
+
+
+ + + +
+
+

In Progress

+

+
+ +
+
+
+ + + +
+
+

AI Delegated

+

+
+ +
+
+
+ + + +
+
+

Completed Today

+

+
+ +
+
+
+
+ + {/* Kanban Board */} + + + Task Board + + Drag and drop tasks between columns. Click a task to edit or assign to an AI agent. + + + + + + +
+ ) +} diff --git a/dashboard/src/app/workspace/page.tsx b/dashboard/src/app/workspace/page.tsx new file mode 100644 index 0000000..1bc6260 --- /dev/null +++ b/dashboard/src/app/workspace/page.tsx @@ -0,0 +1,234 @@ +/** + * Workspace Page — File browser for Tiger agent's workspace + * + * Lists files from the Tiger Bridge's workspace API and provides + * a file viewer for reading file contents. + */ + +"use client" + +import * as React from "react" +import { Folder, FileText, ChevronRight, Home, ArrowLeft, Loader2 } from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { useBridgeRequest } from "@/hooks/use-bridge" +import { cn } from "@/lib/utils" + +interface WorkspaceFile { + name: string + type: "file" | "directory" + size?: number + modified?: string +} + +interface FileContent { + ok: boolean + path: string + content: string + size: number +} + +function formatSize(bytes?: number): string { + if (!bytes) return "—" + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +} + +function getFileIcon(filename: string) { + const ext = filename.split(".").pop()?.toLowerCase() + const codeExts = ["ts", "tsx", "js", "jsx", "py", "sh", "json", "md", "yaml", "yml", "toml"] + if (ext && codeExts.includes(ext)) return "code" + if (ext === "md") return "markdown" + return "text" +} + +export default function WorkspacePage() { + const { request, loading } = useBridgeRequest() + const [currentPath, setCurrentPath] = React.useState("") + const [files, setFiles] = React.useState([]) + const [selectedFile, setSelectedFile] = React.useState(null) + const [fileContent, setFileContent] = React.useState(null) + const [loadingFiles, setLoadingFiles] = React.useState(false) + const [loadingContent, setLoadingContent] = React.useState(false) + const [error, setError] = React.useState(null) + + // Load directory contents + const loadDirectory = React.useCallback(async (path: string) => { + setLoadingFiles(true) + setError(null) + try { + const url = path ? `/api/tiger/workspace?path=${encodeURIComponent(path)}` : "/api/tiger/workspace" + const data = await request(url) as { ok: boolean; files?: WorkspaceFile[] } + if (data.ok && data.files) { + setFiles(data.files) + setCurrentPath(path) + } else { + setError("Failed to load directory") + } + } catch (e: unknown) { + setError("Failed to load workspace") + } finally { + setLoadingFiles(false) + } + }, [request]) + + // Load file content + const loadFile = React.useCallback(async (filename: string) => { + setLoadingContent(true) + setSelectedFile(filename) + try { + // Need full path including current directory + const fullPath = currentPath ? `${currentPath}/${filename}` : filename + const url = `/api/tiger/workspace?path=${encodeURIComponent(fullPath)}&read=true` + const data = await request(url) as FileContent + setFileContent(data) + } catch (e: unknown) { + setFileContent({ ok: false, path: filename, content: "Failed to load file", size: 0 }) + } finally { + setLoadingContent(false) + } + }, [request, currentPath]) + + // Initial load + React.useEffect(() => { + loadDirectory("") + }, [loadDirectory]) + + const navigateTo = (path: string) => { + setSelectedFile(null) + setFileContent(null) + loadDirectory(path) + } + + // Build breadcrumb path + const breadcrumbs = currentPath ? currentPath.split("/").filter(Boolean) : [] + + return ( +
+ {/* Header */} +
+
+

Workspace

+

+ Browse files in Tiger's workspace +

+
+ +
+ + {/* Error */} + {error && ( +
{error}
+ )} + +
+ {/* File List */} + + + Files + {/* Breadcrumb */} + {breadcrumbs.length > 0 && ( +
+ + {breadcrumbs.map((crumb, i) => ( + + + + + ))} +
+ )} +
+ + {loadingFiles ? ( +
+ +
+ ) : files.length === 0 ? ( +
No files
+ ) : ( +
+ {/* Parent directory */} + {currentPath && ( + + )} + {files.map((file) => ( + + ))} +
+ )} +
+
+ + {/* File Viewer */} + + + + {selectedFile || "Select a file to view"} + + {fileContent && ( +
+ {formatSize(fileContent.size)} +
+ )} +
+ + {loadingContent ? ( +
+ +
+ ) : !selectedFile ? ( +
+ Click a file to view its contents +
+ ) : fileContent ? ( +
+                {fileContent.content}
+              
+ ) : null} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/dashboard/src/components/app-sidebar.tsx b/dashboard/src/components/app-sidebar.tsx index ecc0374..2bbf438 100644 --- a/dashboard/src/components/app-sidebar.tsx +++ b/dashboard/src/components/app-sidebar.tsx @@ -2,17 +2,16 @@ import * as React from "react" import { - SquareTerminal, Bot, Settings2, - Clock, - BrainCircuit, LayoutDashboard, ScrollText, - Activity, - Users, + CheckSquare, + DollarSign, + FolderOpen, + Briefcase, } from "lucide-react" -import { useGatewayEvents } from "@/hooks/use-gateway" +import { useTigerLogs } from "@/hooks/use-bridge" import { Sidebar, @@ -25,6 +24,7 @@ import { SidebarRail, } from "@/components/ui/sidebar" +// Tiger-specific navigation - no more old clawdbot pages const navMain = [ { title: "Dashboard", @@ -32,34 +32,24 @@ const navMain = [ icon: LayoutDashboard, }, { - title: "Chat", - url: "/chat", - icon: SquareTerminal, + title: "Projects", + url: "/projects", + icon: Briefcase, }, { - title: "Memory", - url: "/memory", - icon: BrainCircuit, + title: "Workspace", + url: "/workspace", + icon: FolderOpen, }, { - title: "Skills", - url: "/skills", - icon: Bot, + title: "Tasks", + url: "/tasks", + icon: CheckSquare, }, { - title: "Cron Jobs", - url: "/cron", - icon: Clock, - }, - { - title: "Sessions", - url: "/sessions", - icon: Users, - }, - { - title: "Activity", - url: "/activity", - icon: Activity, + title: "Cost Monitor", + url: "/cost", + icon: DollarSign, }, { title: "Logs", @@ -77,7 +67,9 @@ const navSecondary = [ ] export function AppSidebar({ ...props }: React.ComponentProps) { - const { connected } = useGatewayEvents(() => {}, []) + // Use Tiger logs SSE for connection status + // connected means the bridge is reachable + const { connected } = useTigerLogs({ lines: 1, maxLines: 1 }) return ( @@ -90,7 +82,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
-
+ {connected ? "Live" : "Offline"}
@@ -129,4 +121,4 @@ export function AppSidebar({ ...props }: React.ComponentProps) { ) -} +} \ No newline at end of file diff --git a/dashboard/src/components/cost/cost-monitor.tsx b/dashboard/src/components/cost/cost-monitor.tsx new file mode 100644 index 0000000..8bfb386 --- /dev/null +++ b/dashboard/src/components/cost/cost-monitor.tsx @@ -0,0 +1,275 @@ +"use client" + +import useSWR from "swr" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Progress } from "@/components/ui/progress" +import { formatCost, formatTokens, CostSummary, DailyCost, ModelCost, CostEntry } from "@/lib/cost" +import { + DollarSign, + TrendingUp, + Calendar, + Clock, + AlertTriangle, + CheckCircle2, + BarChart3, + Cpu +} from "lucide-react" +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell } from "recharts" +import { cn } from "@/lib/utils" + +const fetcher = (url: string) => fetch(url).then((res) => res.json()) + +interface CostData { + summary: CostSummary + daily: DailyCost[] + byModel: ModelCost[] + lastEntry?: CostEntry + entries: CostEntry[] +} + +function BudgetAlert({ used, limit }: { used: number; limit: number }) { + const percentage = (used / limit) * 100 + + if (percentage < 50) return null + + return ( +
= 90 ? "bg-red-500/10 text-red-400 border border-red-500/20" : + percentage >= 75 ? "bg-yellow-500/10 text-yellow-400 border border-yellow-500/20" : + "bg-blue-500/10 text-blue-400 border border-blue-500/20" + )}> + {percentage >= 75 ? : } + + {percentage >= 90 ? "Budget critical: " : + percentage >= 75 ? "Budget warning: " : + "Budget notice: "} + {formatCost(used)} of {formatCost(limit)} used ({percentage.toFixed(1)}%) + +
+ ) +} + +export function CostMonitor() { + const { data, error } = useSWR("/api/cost", fetcher, { refreshInterval: 30000 }) + + if (error) { + return ( + + +
Failed to load cost data
+
+
+ ) + } + + const summary = data?.summary + const daily = data?.daily || [] + const byModel = data?.byModel || [] + const lastEntry = data?.lastEntry + + // Prepare chart data + const chartData = daily.map(d => ({ + date: new Date(d.date).toLocaleDateString("en-US", { month: "short", day: "numeric" }), + cost: d.total, + requests: d.requests + })) + + const modelChartData = byModel.slice(0, 5).map(m => ({ + name: m.model.split("/").pop()?.slice(0, 15) || m.model, + cost: m.totalCost, + fullModel: m.model + })) + + const budgetPercentage = summary ? (summary.budgetUsed / summary.budgetLimit) * 100 : 0 + + return ( +
+ {/* Summary Cards */} +
+ + +
+
+

Today

+

{summary ? formatCost(summary.today) : "—"}

+
+ +
+
+
+ + + +
+
+

This Week

+

{summary ? formatCost(summary.thisWeek) : "—"}

+
+ +
+
+
+ + + +
+
+

This Month

+

{summary ? formatCost(summary.thisMonth) : "—"}

+
+ +
+
+
+ + + +
+
+

Avg/Request

+

{summary ? formatCost(summary.averagePerRequest) : "—"}

+
+ +
+
+
+
+ + {/* Budget Progress */} + {summary && ( + + + + + Monthly Budget + + + +
+ {formatCost(summary.budgetUsed)} used + {formatCost(summary.budgetLimit)} limit +
+ = 90 ? "bg-red-500/20" : + budgetPercentage >= 75 ? "bg-yellow-500/20" : + "bg-emerald-500/20" + )} + /> + +
+
+ )} + + {/* Charts Row */} +
+ {/* Daily Trend */} + + + Daily Cost Trend + Last 30 days spending + + + {chartData.length > 0 ? ( +
+ + + + + + + + + + + `$${v.toFixed(2)}`} /> + [`$${value.toFixed(4)}`, 'Cost']} + /> + + + +
+ ) : ( +
+ No cost data available yet +
+ )} +
+
+ + {/* By Model */} + + + Top Models by Cost + Highest spending models + + + {modelChartData.length > 0 ? ( +
+ + + + `$${v.toFixed(2)}`} /> + + { + const model = props?.payload?.fullModel || '' + return [`$${value.toFixed(4)}`, model] + }} + /> + + {modelChartData.map((_, index) => ( + + ))} + + + +
+ ) : ( +
+ No model data available yet +
+ )} +
+
+
+ + {/* Last Request */} + {lastEntry && ( + + + + + Last Request + + + +
+
+

Model

+

{lastEntry.model.split("/").pop()}

+
+
+

Cost

+

{formatCost(lastEntry.totalCost)}

+
+
+

Tokens

+

{formatTokens(lastEntry.inputTokens + lastEntry.outputTokens)}

+
+
+

Time

+

{new Date(lastEntry.timestamp).toLocaleTimeString()}

+
+
+
+
+ )} +
+ ) +} diff --git a/dashboard/src/components/cost/index.ts b/dashboard/src/components/cost/index.ts new file mode 100644 index 0000000..5e6a9e0 --- /dev/null +++ b/dashboard/src/components/cost/index.ts @@ -0,0 +1 @@ +export { CostMonitor } from "./cost-monitor" diff --git a/dashboard/src/components/output-viewer.tsx b/dashboard/src/components/output-viewer.tsx new file mode 100644 index 0000000..2a630b1 --- /dev/null +++ b/dashboard/src/components/output-viewer.tsx @@ -0,0 +1,267 @@ +/** + * output-viewer.tsx — Rich file viewer for task outputs + * + * Renders different file types appropriately: + * - Markdown: rendered with react-markdown + * - Code: syntax highlighted with prism-react-renderer + * - JSON: collapsible tree view + * - HTML: sandboxed iframe + * - Plain text: preformatted block + * - Binary: download link + */ + +"use client" + +import * as React from "react" +import { Download, FileText, Code, FileJson, Image as ImageIcon, File } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +interface OutputViewerProps { + filename: string + fileType: string + content: string + filePath?: string + size?: number +} + +function getFileCategory(filename: string, fileType: string): string { + const ext = filename.split(".").pop()?.toLowerCase() || "" + const type = fileType.toLowerCase() + + if (type.includes("markdown") || ext === "md") return "markdown" + if (type.includes("html") || ext === "html" || ext === "htm") return "html" + if (type.includes("json") || ext === "json") return "json" + if (["js", "ts", "jsx", "tsx", "py", "sh", "bash", "go", "rs", "java"].includes(ext)) return "code" + if (type.includes("image")) return "image" + if (type.includes("pdf")) return "pdf" + if (type.includes("text") || type === "application/octet-stream") return "text" + + return "unknown" +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +// Simple JSON tree component +function JsonTree({ data, depth = 0 }: { data: unknown; depth?: number }) { + const [collapsed, setCollapsed] = React.useState(false) + + if (depth > 5) return {JSON.stringify(data)} + + if (data === null) return null + if (data === undefined) return undefined + + if (typeof data === "boolean") { + return {String(data)} + } + + if (typeof data === "number") { + return {data} + } + + if (typeof data === "string") { + return "{data}" + } + + if (Array.isArray(data)) { + if (data.length === 0) return [] + return ( + + + {!collapsed && ( + + {data.map((item, i) => ( +
+ +
+ ))} +
+ )} +
+ ) + } + + if (typeof data === "object") { + const entries = Object.entries(data as Record) + if (entries.length === 0) return {"{}"} + return ( + + + {!collapsed && ( + + {entries.map(([key, value]) => ( +
+ {key} + : + +
+ ))} +
+ )} +
+ ) + } + + return {String(data)} +} + +export function OutputViewer({ filename, fileType, content, filePath, size }: OutputViewerProps) { + const category = getFileCategory(filename, fileType) + + return ( + + + {/* File header */} +
+
+ {category === "markdown" && } + {category === "code" && } + {category === "json" && } + {category === "html" && } + {category === "image" && } + {category === "text" && } + {category === "unknown" && } + + {filename} + {size && ({formatSize(size)})} +
+ + {filePath && ( + + )} +
+ + {/* Content based on category */} +
+ {category === "json" && ( +
+               { try { return JSON.parse(content) } catch { return content } })()} />
+            
+ )} + + {category === "code" && ( +
+              {content}
+            
+ )} + + {category === "text" && ( +
+              {content}
+            
+ )} + + {category === "markdown" && ( +
+
{content}
+
+ )} + + {category === "html" && ( +