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.
This commit is contained in:
parent
10d1555e9d
commit
d4a3f2b869
59 changed files with 6962 additions and 894 deletions
4
bridge/.gitignore
vendored
Normal file
4
bridge/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.env.local
|
||||
26
bridge/package.json
Normal file
26
bridge/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
28
bridge/src/auth.ts
Normal file
28
bridge/src/auth.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
315
bridge/src/db.ts
Normal file
315
bridge/src/db.ts
Normal file
|
|
@ -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;
|
||||
113
bridge/src/index.ts
Normal file
113
bridge/src/index.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
75
bridge/src/routes/config.ts
Normal file
75
bridge/src/routes/config.ts
Normal file
|
|
@ -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;
|
||||
112
bridge/src/routes/dispatch.ts
Normal file
112
bridge/src/routes/dispatch.ts
Normal file
|
|
@ -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;
|
||||
36
bridge/src/routes/exec.ts
Normal file
36
bridge/src/routes/exec.ts
Normal file
|
|
@ -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;
|
||||
82
bridge/src/routes/files.ts
Normal file
82
bridge/src/routes/files.ts
Normal file
|
|
@ -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;
|
||||
127
bridge/src/routes/logs.ts
Normal file
127
bridge/src/routes/logs.ts
Normal file
|
|
@ -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: <json>\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 <container>
|
||||
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;
|
||||
91
bridge/src/routes/projects.ts
Normal file
91
bridge/src/routes/projects.ts
Normal file
|
|
@ -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;
|
||||
59
bridge/src/routes/restart.ts
Normal file
59
bridge/src/routes/restart.ts
Normal file
|
|
@ -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;
|
||||
24
bridge/src/routes/status.ts
Normal file
24
bridge/src/routes/status.ts
Normal file
|
|
@ -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;
|
||||
138
bridge/src/routes/tasks.ts
Normal file
138
bridge/src/routes/tasks.ts
Normal file
|
|
@ -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;
|
||||
293
bridge/src/tiger.ts
Normal file
293
bridge/src/tiger.ts
Normal file
|
|
@ -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 <container> kubectl exec -n <ns> <pod> -- <cmd>
|
||||
*/
|
||||
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<Record<string, any>> {
|
||||
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<string, any>): Promise<void> {
|
||||
// 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<string> {
|
||||
// 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
|
||||
]);
|
||||
}
|
||||
115
bridge/src/watcher.ts
Normal file
115
bridge/src/watcher.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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();
|
||||
17
bridge/tsconfig.json
Normal file
17
bridge/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
434
dashboard/package-lock.json
generated
434
dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
163
dashboard/src/app/api/cost/route.ts
Normal file
163
dashboard/src/app/api/cost/route.ts
Normal file
|
|
@ -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<string, { total: number; requests: number }>()
|
||||
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<string, ModelCost>()
|
||||
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 })
|
||||
}
|
||||
61
dashboard/src/app/api/tiger/config/route.ts
Normal file
61
dashboard/src/app/api/tiger/config/route.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
37
dashboard/src/app/api/tiger/dispatch/route.ts
Normal file
37
dashboard/src/app/api/tiger/dispatch/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
53
dashboard/src/app/api/tiger/exec/route.ts
Normal file
53
dashboard/src/app/api/tiger/exec/route.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
107
dashboard/src/app/api/tiger/logs/route.ts
Normal file
107
dashboard/src/app/api/tiger/logs/route.ts
Normal file
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
53
dashboard/src/app/api/tiger/projects/[id]/route.ts
Normal file
53
dashboard/src/app/api/tiger/projects/[id]/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
33
dashboard/src/app/api/tiger/projects/route.ts
Normal file
33
dashboard/src/app/api/tiger/projects/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
39
dashboard/src/app/api/tiger/restart/route.ts
Normal file
39
dashboard/src/app/api/tiger/restart/route.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
45
dashboard/src/app/api/tiger/status/route.ts
Normal file
45
dashboard/src/app/api/tiger/status/route.ts
Normal file
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
69
dashboard/src/app/api/tiger/tasks/[id]/route.ts
Normal file
69
dashboard/src/app/api/tiger/tasks/[id]/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
44
dashboard/src/app/api/tiger/tasks/route.ts
Normal file
44
dashboard/src/app/api/tiger/tasks/route.ts
Normal file
|
|
@ -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<string, string> = {};
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
55
dashboard/src/app/api/tiger/workspace/route.ts
Normal file
55
dashboard/src/app/api/tiger/workspace/route.ts
Normal file
|
|
@ -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<string, string> = {};
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
71
dashboard/src/app/cost/page.tsx
Normal file
71
dashboard/src/app/cost/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<DollarSign className="h-6 w-6 text-primary" />
|
||||
Cost Monitor
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Track API usage costs across all AI models and sessions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="bg-blue-500/10 border-blue-500/20">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-400 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-blue-400">Cost Tracking Active</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cost Monitor Component */}
|
||||
<CostMonitor />
|
||||
|
||||
{/* Additional Info */}
|
||||
<Card className="bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Supported Models
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">OpenAI</p>
|
||||
<p className="text-muted-foreground text-xs">GPT-4o, GPT-4o-mini</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Anthropic</p>
|
||||
<p className="text-muted-foreground text-xs">Claude 3 Opus, Claude 3.5 Sonnet</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Google</p>
|
||||
<p className="text-muted-foreground text-xs">Gemini Pro 1.5, Gemini Flash 1.5</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Others</p>
|
||||
<p className="text-muted-foreground text-xs">Kimi K2.5, and more via OpenRouter</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<LogEntry[]>([])
|
||||
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<HTMLDivElement>(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<string, unknown>
|
||||
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 (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col gap-3 p-4">
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Live Logs</h1>
|
||||
<p className="text-muted-foreground">Real-time gateway event stream.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Tiger Logs</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Live Docker container logs via Tiger Bridge
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPaused(!paused)}>
|
||||
{paused ? <Play className="h-4 w-4 mr-1" /> : <Pause className="h-4 w-4 mr-1" />}
|
||||
{paused ? "Resume" : "Pause"}
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-full border",
|
||||
connected
|
||||
? "bg-green-500/10 text-green-400 border-green-500/20"
|
||||
: "bg-gray-500/10 text-gray-400 border-gray-500/20"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full",
|
||||
connected ? "bg-green-400 animate-pulse" : "bg-gray-500"
|
||||
)}
|
||||
/>
|
||||
{connected ? "Streaming" : "Connecting..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Controls ───────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filter box */}
|
||||
<div className="flex items-center gap-1 flex-1 max-w-xs">
|
||||
<div className="relative flex-1">
|
||||
<Filter className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-7 h-8 text-xs"
|
||||
placeholder="Filter keyword..."
|
||||
value={filterInput}
|
||||
onChange={(e) => setFilterInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && applyFilter()}
|
||||
/>
|
||||
{filterInput && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={clearFilter}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={applyFilter}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleClear}>
|
||||
<RotateCcw className="h-4 w-4 mr-1" /> Clear
|
||||
</div>
|
||||
|
||||
{/* Active filter badge */}
|
||||
{activeFilter && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
Filter: "{activeFilter}"
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={paused ? resume : pause}
|
||||
>
|
||||
{paused ? (
|
||||
<><Play className="h-3.5 w-3.5 mr-1" /> Resume</>
|
||||
) : (
|
||||
<><Pause className="h-3.5 w-3.5 mr-1" /> Pause</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={clear}>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" /> Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 border rounded-lg overflow-hidden bg-black/90">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
{/* ── Log viewer ─────────────────────────────────────────────── */}
|
||||
<div className="flex-1 border rounded-lg overflow-hidden bg-black/90 min-h-0">
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-600">
|
||||
<ScrollText className="h-8 w-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">
|
||||
{connected
|
||||
? "Waiting for logs…"
|
||||
: "Connecting to Tiger Bridge…"}
|
||||
</p>
|
||||
{!connected && (
|
||||
<p className="text-xs mt-1 opacity-60">
|
||||
Make sure the Tiger Bridge is running on the VPS
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full" ref={scrollRef}>
|
||||
<div className="p-4 font-mono text-xs space-y-0.5">
|
||||
{logs.map(entry => (
|
||||
<div key={entry.id} className="flex gap-2 hover:bg-white/5 px-1 rounded">
|
||||
<span className="text-gray-600 flex-shrink-0 w-[80px]">
|
||||
{new Date(entry.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className={cn("flex-shrink-0 w-[50px]", levelColor(entry.level))}>
|
||||
{entry.level}
|
||||
</span>
|
||||
{entry.subsystem && (
|
||||
<span className="text-blue-400 flex-shrink-0">[{entry.subsystem}]</span>
|
||||
)}
|
||||
<span className="text-gray-300 break-all">{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<div className="text-gray-600 text-center py-8">
|
||||
<ScrollText className="mx-auto h-8 w-8 mb-2 opacity-50" />
|
||||
No log entries yet. Events will appear here in real-time.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
// ScrollArea component wraps a scrollable div
|
||||
// We give it a ref so we can programmatically scroll to bottom
|
||||
<div
|
||||
className="h-full overflow-y-auto p-3 font-mono text-xs space-y-0.5"
|
||||
ref={scrollRef}
|
||||
>
|
||||
{logs.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
"flex gap-2 px-1 py-0.5 rounded hover:bg-white/5",
|
||||
rowBg(entry.level)
|
||||
)}
|
||||
>
|
||||
{/* Timestamp — short format for readability */}
|
||||
<span className="text-gray-600 flex-shrink-0 w-[70px] tabular-nums">
|
||||
{new Date(entry.ts).toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* Level badge */}
|
||||
<span className={cn("flex-shrink-0 w-[45px] font-semibold", levelColor(entry.level))}>
|
||||
{entry.level}
|
||||
</span>
|
||||
|
||||
{/* Log text — break-all prevents long lines from overflowing */}
|
||||
<span className="text-gray-300 break-all leading-relaxed">
|
||||
{entry.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pause indicator at the bottom */}
|
||||
{paused && (
|
||||
<div className="text-center py-2 text-yellow-500/60 text-xs">
|
||||
── paused ──
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer info ────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{logs.length} lines</span>
|
||||
{activeFilter && <span>Filtered: "{activeFilter}"</span>}
|
||||
<span>Source: docker logs → Tiger Bridge SSE</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<number | null>(null)
|
||||
const [switchingModel, setSwitchingModel] = React.useState<string | null>(null)
|
||||
const [modelSearch, setModelSearch] = React.useState("")
|
||||
const { data: status, error: statusError, isLoading } = useSWR<TigerStatus>('/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<string, ModelInfo[]> = {}
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Crash Recovery Banner */}
|
||||
{isCrashed && (
|
||||
<div className="p-4 rounded-md bg-red-500/10 border border-red-500/20 text-red-400 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<span className="font-semibold">Tiger Crashed</span>
|
||||
<span className="text-sm text-red-400/80 ml-2">(exit code 255 — MiniMax API unreachable)</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{restartSuccess ? "Restarting..." : "Restart Container"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stat Cards Row */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Link href="/memory" className="contents">
|
||||
<StatCard
|
||||
title="Memory Files"
|
||||
value={memoryData?.count || "-"}
|
||||
description={memoryData?.latest ? `Latest: ${memoryData.latest.date}` : "Loading..."}
|
||||
icon={BrainCircuit}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</Link>
|
||||
<Link href="/cron" className="contents">
|
||||
<StatCard
|
||||
title="Cron Jobs"
|
||||
value={statusError ? "Err" : (statusData?.agent?.cronJobs ?? "-")}
|
||||
description={statusData?.agent?.cronTotal ? `${statusData.agent.cronTotal} total, ${statusData.agent.cronJobs} active` : "Active schedules"}
|
||||
icon={Clock}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</Link>
|
||||
<Link href="/skills" className="contents">
|
||||
<StatCard
|
||||
title="Skills"
|
||||
value={statusError ? "Err" : (statusData?.agent?.skills || "-")}
|
||||
description="Installed capabilities"
|
||||
icon={Bot}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</Link>
|
||||
<Link href="/sessions" className="contents">
|
||||
<StatCard
|
||||
title="Sessions"
|
||||
value={sessionCount ?? "-"}
|
||||
description="Active conversations"
|
||||
icon={Users}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</Link>
|
||||
<Card className={cn(
|
||||
"bg-card/50",
|
||||
status?.container?.status === "running" && "border-green-500/30",
|
||||
status?.container?.status !== "running" && "border-red-500/30"
|
||||
)}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Container</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold capitalize",
|
||||
status?.container?.status === "running" ? "text-green-400" : "text-red-400"
|
||||
)}>
|
||||
{isLoading ? "..." : status?.container?.status || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<Server className={cn(
|
||||
"h-5 w-5",
|
||||
status?.container?.status === "running" ? "text-green-400" : "text-red-400"
|
||||
)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">OpenClaw</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold",
|
||||
status?.openclaw?.running ? "text-green-400" : "text-red-400"
|
||||
)}>
|
||||
{isLoading ? "..." : status?.openclaw?.running ? "Running" : "Stopped"}
|
||||
</p>
|
||||
</div>
|
||||
<Terminal className={cn(
|
||||
"h-5 w-5",
|
||||
status?.openclaw?.running ? "text-green-400" : "text-red-400"
|
||||
)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Memory</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{isLoading ? "..." : `${status?.system?.memoryUsagePct || 0}%`}
|
||||
</p>
|
||||
</div>
|
||||
<MemoryStick className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Uptime</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{isLoading ? "..." : formatUptime(status?.container?.startedAt || "")}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
{/* Error State */}
|
||||
{isOffline && !isLoading && (
|
||||
<div className="p-4 rounded-md bg-destructive/10 text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>Failed to connect to agent backend. Ensure local server is running.</span>
|
||||
<span>Failed to connect to Tiger Bridge. Ensure the bridge server is running on the VPS.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
|
||||
{/* Recent Memory Widget */}
|
||||
{/* Container Health Card */}
|
||||
<Card className="col-span-4 bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
Recent Memory
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[300px] pr-4">
|
||||
<div className="space-y-2">
|
||||
{memoryData?.files?.length > 0 ? (
|
||||
memoryData.files.slice(0, 10).map((file: { name: string; date: string }, i: number) => (
|
||||
<Link key={i} href="/memory" className="block">
|
||||
<div className="flex items-center p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors">
|
||||
<div className="w-[100px] text-sm font-semibold text-primary">{file.date}</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{file.name.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground p-3">No memory files yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Info Widget */}
|
||||
<Card className="col-span-3 bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
System Info
|
||||
Tiger Health
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isGateway ? (
|
||||
{status?.status === "online" ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3 text-green-500" /> Gateway connected
|
||||
<Zap className="h-3 w-3 text-green-500" /> All systems operational
|
||||
</span>
|
||||
) : status?.status === "degraded" ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3 text-yellow-500" /> Degraded mode
|
||||
</span>
|
||||
) : (
|
||||
"Fallback mode"
|
||||
"Connection lost"
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{statusData?.agent?.name && (
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between">
|
||||
<span className="text-muted-foreground">Agent</span>
|
||||
<span>{statusData.agent.emoji} {statusData.agent.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<span className={statusData?.status === "online" ? "text-green-500" : "text-red-500"}>
|
||||
{statusData?.status === "online" ? "Online" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between">
|
||||
<span className="text-muted-foreground">Platform</span>
|
||||
<span>{statusData?.system?.platform || "..."}</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between">
|
||||
<span className="text-muted-foreground">Memory Usage</span>
|
||||
<span>{statusData?.system?.memoryUsage ? `${statusData.system.memoryUsage}%` : "..."}</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between">
|
||||
<span className="text-muted-foreground">Uptime</span>
|
||||
<span>{statusData?.system?.uptime ? `${(statusData.system.uptime / 3600).toFixed(1)}h` : "..."}</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm">
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-muted-foreground">Heartbeat</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{statusData?.agent?.lastHeartbeat ? new Date(statusData.agent.lastHeartbeat).toLocaleTimeString() : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{statusData?.agent?.heartbeatContent && (
|
||||
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
|
||||
{statusData.agent.heartbeatContent
|
||||
.split("\n")
|
||||
.filter((line: string) => line.startsWith("- ") || line.startsWith("# "))
|
||||
.map((line: string, i: number) => (
|
||||
<div key={i} className={line.startsWith("# ") ? "font-medium text-foreground" : ""}>
|
||||
{line.startsWith("# ") ? line.replace(/^#+\s*/, "") : line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Model Management */}
|
||||
{isGateway && (
|
||||
<Card className="bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-primary" />
|
||||
AI Model
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{currentModel ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn("text-[10px] font-semibold px-1.5 py-0.5 rounded border", providerColor(currentModelInfo?.provider || currentParsed.provider))}>
|
||||
{providerLabel(currentModelInfo?.provider || currentParsed.provider)}
|
||||
</span>
|
||||
<span>{currentModelInfo?.name || currentParsed.name}</span>
|
||||
</span>
|
||||
) : "No model configured"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-6">
|
||||
{/* Left: Current Model + Fallbacks */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Primary Model</div>
|
||||
<div className="p-4 rounded-lg border border-primary/30 bg-primary/5">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-base">{currentModelInfo?.name || currentParsed.name || "Not configured"}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono mb-2">{currentModel || "—"}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{(currentModelInfo?.provider || currentParsed.provider) && (
|
||||
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded border", providerColor(currentModelInfo?.provider || currentParsed.provider))}>
|
||||
{providerLabel(currentModelInfo?.provider || currentParsed.provider)}
|
||||
</span>
|
||||
)}
|
||||
{currentModelInfo?.contextWindow && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-muted text-muted-foreground flex items-center gap-1">
|
||||
<MessageSquare className="h-2.5 w-2.5" /> {formatContextWindow(currentModelInfo.contextWindow)} ctx
|
||||
</span>
|
||||
)}
|
||||
{currentModelInfo?.reasoning && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-yellow-500/15 text-yellow-400 border border-yellow-500/20 flex items-center gap-1">
|
||||
<Sparkles className="h-2.5 w-2.5" /> Reasoning
|
||||
</span>
|
||||
)}
|
||||
{currentModelInfo?.input?.includes("image") && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-cyan-500/15 text-cyan-400 border border-cyan-500/20 flex items-center gap-1">
|
||||
<ImageIcon className="h-2.5 w-2.5" /> Vision
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{/* Container Status */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Container Status</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
status?.container?.status === "running" ? "bg-green-500" : "bg-red-500"
|
||||
)} />
|
||||
<span className="capitalize">{status?.container?.status || "—"}</span>
|
||||
</div>
|
||||
|
||||
{/* Fallback Models */}
|
||||
{fallbackModels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Fallback Models</div>
|
||||
<div className="space-y-2">
|
||||
{fallbackModels.map((fb, i) => {
|
||||
const parsed = parseModelId(fb)
|
||||
const info = models.find(m => m.id === fb)
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className="w-full p-3 rounded-md border border-border bg-background/50 text-sm flex items-center justify-between hover:bg-muted/30 hover:border-primary/30 transition-colors cursor-pointer text-left"
|
||||
onClick={() => handleSwitchModel(fb)}
|
||||
disabled={switchingModel !== null}
|
||||
title="Click to set as primary model"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{info?.name || parsed.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate">{fb}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded border", providerColor(info?.provider || parsed.provider))}>
|
||||
{providerLabel(info?.provider || parsed.provider)}
|
||||
</span>
|
||||
{switchingModel === fb ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Available Models */}
|
||||
<div className="w-[380px] flex-none flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Available Models</div>
|
||||
<span className="text-[10px] text-muted-foreground">{models.length} models</span>
|
||||
{/* Exit Code */}
|
||||
{status?.container?.exitCode !== undefined && status?.container?.exitCode !== 0 && (
|
||||
<div className="p-3 rounded-md border border-red-500/30 bg-red-500/5 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Exit Code</span>
|
||||
<span className={cn(
|
||||
"font-mono",
|
||||
status?.container?.exitCode === 255 ? "text-red-400 font-bold" : "text-red-300"
|
||||
)}>
|
||||
{status?.container?.exitCode}
|
||||
{status?.container?.exitCode === 255 && " (API unreachable)"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
className="w-full px-3 py-1.5 text-xs bg-muted/50 rounded-md border border-border outline-none placeholder:text-muted-foreground focus:border-primary/50"
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto max-h-[400px] rounded-lg border border-border">
|
||||
{Object.keys(modelsByProvider).length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground text-center">No models found</div>
|
||||
)}
|
||||
|
||||
{/* OpenClaw Process */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">OpenClaw Process</span>
|
||||
<span className={cn(
|
||||
status?.openclaw?.running ? "text-green-400" : "text-red-400"
|
||||
)}>
|
||||
{status?.openclaw?.running ? "Running" : "Not running"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Memory Usage */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Memory Usage</span>
|
||||
<span>
|
||||
{status?.system?.memoryUsagePct || 0}% of {status?.system?.memoryTotalMb || 0}MB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Container Uptime</span>
|
||||
<span>{formatUptime(status?.container?.startedAt || "")}</span>
|
||||
</div>
|
||||
|
||||
{/* Restart Button */}
|
||||
<div className="pt-2 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
Object.entries(modelsByProvider).map(([provider, providerModels]) => (
|
||||
<div key={provider}>
|
||||
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 sticky top-0 z-10 border-b border-border/50">
|
||||
{providerLabel(provider)}
|
||||
</div>
|
||||
{providerModels.map((model) => {
|
||||
const isActive = model.id === currentModel
|
||||
const isFallback = fallbackModels.includes(model.id)
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left hover:bg-muted/30 flex items-center gap-2 transition-colors border-b border-border/30 last:border-0",
|
||||
isActive && "bg-primary/5 border-l-2 border-l-primary",
|
||||
isFallback && !isActive && "bg-amber-500/5"
|
||||
)}
|
||||
onClick={() => handleSwitchModel(model.id)}
|
||||
disabled={switchingModel !== null || isActive}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-xs truncate">{model.name || parseModelId(model.id).name}</span>
|
||||
{model.reasoning && <Sparkles className="h-3 w-3 text-yellow-400 shrink-0" />}
|
||||
{model.input?.includes("image") && <Eye className="h-3 w-3 text-cyan-400 shrink-0" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{model.contextWindow && (
|
||||
<span className="text-[10px] text-muted-foreground">{formatContextWindow(model.contextWindow)} ctx</span>
|
||||
)}
|
||||
{isActive && <span className="text-[10px] text-primary font-medium">Active</span>}
|
||||
{isFallback && !isActive && <span className="text-[10px] text-amber-400 font-medium">Fallback</span>}
|
||||
</div>
|
||||
</div>
|
||||
{switchingModel === model.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
|
||||
) : isActive ? (
|
||||
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
</div>
|
||||
Restart Container
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Command Center Row */}
|
||||
<div className="grid gap-4 md:grid-cols-1">
|
||||
<ChatInterface className="bg-card/40 border-primary/20" />
|
||||
{/* Agent Model Card */}
|
||||
<Card className="col-span-3 bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
Agent Model
|
||||
</CardTitle>
|
||||
<CardDescription>Current AI model configuration</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Primary Model */}
|
||||
<div className="p-4 rounded-lg border border-primary/30 bg-primary/5">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wider">Primary Model</div>
|
||||
<div className="font-semibold text-base">
|
||||
{isLoading ? "..." : status?.agent?.currentModel || "Not configured"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fallback Models */}
|
||||
{status?.agent?.fallbackModels && status.agent.fallbackModels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Fallback Models</div>
|
||||
<div className="space-y-1">
|
||||
{status.agent.fallbackModels.map((model, i) => (
|
||||
<div key={i} className="p-2 rounded-md border border-border bg-background/50 text-sm font-mono">
|
||||
{model}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heartbeat */}
|
||||
{status?.agent?.heartbeat && (
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-xs">
|
||||
<div className="text-muted-foreground mb-1">Last Heartbeat</div>
|
||||
<pre className="whitespace-pre-wrap text-muted-foreground/80 font-mono text-[10px] max-h-20 overflow-y-auto">
|
||||
{status.agent.heartbeat.slice(0, 500)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<a href="/logs" className="contents">
|
||||
<StatCard
|
||||
title="View Logs"
|
||||
value="→"
|
||||
description="Live container log stream"
|
||||
icon={Activity}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
<a href="/tasks" className="contents">
|
||||
<StatCard
|
||||
title="Task Board"
|
||||
value="→"
|
||||
description="Manage and track tasks"
|
||||
icon={Check}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
<a href="/settings" className="contents">
|
||||
<StatCard
|
||||
title="Settings"
|
||||
value="→"
|
||||
description="Configure Tiger agent"
|
||||
icon={Cpu}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
373
dashboard/src/app/projects/page.tsx
Normal file
373
dashboard/src/app/projects/page.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<Project[]>([])
|
||||
const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
|
||||
const [tasks, setTasks] = React.useState<Task[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadingTasks, setLoadingTasks] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(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<string, Task[]> = {
|
||||
backlog: [],
|
||||
ready: [],
|
||||
"in-progress": [],
|
||||
review: [],
|
||||
done: [],
|
||||
}
|
||||
tasks.forEach(task => {
|
||||
if (grouped[task.status]) {
|
||||
grouped[task.status].push(task)
|
||||
}
|
||||
})
|
||||
return grouped
|
||||
}, [tasks])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<FolderOpen className="h-6 w-6 text-primary" />
|
||||
Projects
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage projects and track tasks across your team
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Project
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Project</DialogTitle>
|
||||
<DialogDescription>Create a new project to organize tasks.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Name</label>
|
||||
<Input
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Input
|
||||
value={newProjectDesc}
|
||||
onChange={(e) => setNewProjectDesc(e.target.value)}
|
||||
placeholder="Project description"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Priority</label>
|
||||
<select
|
||||
value={newProjectPriority}
|
||||
onChange={(e) => setNewProjectPriority(e.target.value)}
|
||||
className="w-full mt-1 px-3 py-2 rounded-md border bg-background"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button onClick={handleCreateProject} disabled={creating || !newProjectName.trim()}>
|
||||
{creating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Create Project
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Projects Grid */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-center py-20 text-muted-foreground">
|
||||
<FolderOpen className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p>No projects yet. Create your first project to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<Card
|
||||
key={project.id}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50 transition-colors",
|
||||
selectedProject?.id === project.id && "ring-2 ring-primary"
|
||||
)}
|
||||
onClick={() => setSelectedProject(project)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg">{project.name}</CardTitle>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// TODO: Edit project
|
||||
}}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteProject(project.id)
|
||||
}} className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{project.description || "No description"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={cn("text-xs", PRIORITY_COLORS[project.priority])}>
|
||||
{project.priority}
|
||||
</Badge>
|
||||
<Badge className={cn("text-xs", STATUS_COLORS[project.status])}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Detail with Tasks */}
|
||||
{selectedProject && (
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Button variant="ghost" onClick={() => setSelectedProject(null)}>
|
||||
← Back
|
||||
</Button>
|
||||
<h2 className="text-xl font-bold">{selectedProject.name}</h2>
|
||||
<Badge className={cn(PRIORITY_COLORS[selectedProject.priority])}>
|
||||
{selectedProject.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Simple Kanban - Tasks by Status */}
|
||||
{loadingTasks ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
{(["backlog", "ready", "in-progress", "review", "done"] as const).map((status) => (
|
||||
<div key={status} className="space-y-2">
|
||||
<div className="text-sm font-medium text-muted-foreground uppercase tracking-wider px-2">
|
||||
{status.replace("-", " ")} ({tasksByStatus[status].length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{tasksByStatus[status].map((task) => (
|
||||
<Card key={task.id} className="p-3 cursor-pointer hover:bg-muted/50">
|
||||
<div className="font-medium text-sm">{task.title}</div>
|
||||
{task.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
{task.assigned_agent && (
|
||||
<Badge variant="outline" className="mt-2 text-xs">
|
||||
{task.assigned_agent}
|
||||
</Badge>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<ConfigSection[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [saving, setSaving] = React.useState<string | null>(null)
|
||||
const [savedKey, setSavedKey] = React.useState<string | null>(null)
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [saved, setSaved] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [showPasswords, setShowPasswords] = React.useState<Record<string, boolean>>({})
|
||||
|
||||
// Load config from Tiger Bridge
|
||||
const loadConfig = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await request("config.get", {}) as Record<string, ConfigValue>
|
||||
const data = await request("/api/tiger/config") as Record<string, ConfigValue>
|
||||
|
||||
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<string, ConfigValue> = {}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-4xl">
|
||||
|
|
@ -170,12 +159,22 @@ export default function SettingsPage() {
|
|||
<Settings2 className="h-6 w-6" />
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-muted-foreground">Gateway configuration. Changes are applied live.</p>
|
||||
<p className="text-muted-foreground">Tiger agent configuration. Changes are applied live.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadConfig} disabled={loading}>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||
Reload
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!hasChanges || saving}>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{saved ? "Saved!" : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadConfig} disabled={loading}>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
|
@ -258,20 +257,6 @@ export default function SettingsPage() {
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!hasChanges(section) || saving === section.key}
|
||||
onClick={() => handleSaveSection(section)}
|
||||
>
|
||||
{saving === section.key ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{savedKey === section.key ? "Saved!" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
|
|
@ -288,4 +273,4 @@ function getNestedValue(obj: Record<string, ConfigValue>, path: string): ConfigV
|
|||
current = (current as Record<string, ConfigValue>)[key]
|
||||
}
|
||||
return current ?? null
|
||||
}
|
||||
}
|
||||
85
dashboard/src/app/tasks/page.tsx
Normal file
85
dashboard/src/app/tasks/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<CheckSquare className="h-6 w-6 text-primary" />
|
||||
Task Progress
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage and track tasks across your team and AI sub-agents
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Active Tasks</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<Clock className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">In Progress</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<GitBranch className="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">AI Delegated</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<Bot className="h-5 w-5 text-violet-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Completed Today</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<CheckSquare className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Kanban Board */}
|
||||
<Card className="bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle>Task Board</CardTitle>
|
||||
<CardDescription>
|
||||
Drag and drop tasks between columns. Click a task to edit or assign to an AI agent.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<KanbanBoard />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
234
dashboard/src/app/workspace/page.tsx
Normal file
234
dashboard/src/app/workspace/page.tsx
Normal file
|
|
@ -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<WorkspaceFile[]>([])
|
||||
const [selectedFile, setSelectedFile] = React.useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = React.useState<FileContent | null>(null)
|
||||
const [loadingFiles, setLoadingFiles] = React.useState(false)
|
||||
const [loadingContent, setLoadingContent] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(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 (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col gap-4 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Workspace</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse files in Tiger's workspace
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateTo("")}
|
||||
disabled={!currentPath}
|
||||
>
|
||||
<Home className="h-4 w-4 mr-2" />
|
||||
Root
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0">
|
||||
{/* File List */}
|
||||
<Card className="col-span-1 bg-card/40 flex flex-col min-h-0">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Files</CardTitle>
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
|
||||
<button onClick={() => navigateTo("")} className="hover:text-foreground">root</button>
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<button onClick={() => navigateTo(breadcrumbs.slice(0, i + 1).join("/"))} className="hover:text-foreground">
|
||||
{crumb}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-y-auto">
|
||||
{loadingFiles ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">No files</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{/* Parent directory */}
|
||||
{currentPath && (
|
||||
<button
|
||||
onClick={() => navigateTo(breadcrumbs.slice(0, -1).join("/"))}
|
||||
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted text-left"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">..</span>
|
||||
</button>
|
||||
)}
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.name}
|
||||
onClick={() => file.type === "directory" ? navigateTo(currentPath ? `${currentPath}/${file.name}` : file.name) : loadFile(file.name)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted text-left",
|
||||
selectedFile === file.name && "bg-muted"
|
||||
)}
|
||||
>
|
||||
{file.type === "directory" ? (
|
||||
<Folder className="h-4 w-4 text-amber-400" />
|
||||
) : (
|
||||
<FileText className={cn(
|
||||
"h-4 w-4",
|
||||
getFileIcon(file.name) === "code" ? "text-blue-400" :
|
||||
getFileIcon(file.name) === "markdown" ? "text-purple-400" :
|
||||
"text-muted-foreground"
|
||||
)} />
|
||||
)}
|
||||
<span className="text-sm truncate flex-1">{file.name}</span>
|
||||
{file.size && <span className="text-xs text-muted-foreground">{formatSize(file.size)}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Viewer */}
|
||||
<Card className="col-span-2 bg-card/40 flex flex-col min-h-0">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">
|
||||
{selectedFile || "Select a file to view"}
|
||||
</CardTitle>
|
||||
{fileContent && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSize(fileContent.size)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-y-auto">
|
||||
{loadingContent ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !selectedFile ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
Click a file to view its contents
|
||||
</div>
|
||||
) : fileContent ? (
|
||||
<pre className={cn(
|
||||
"text-xs font-mono whitespace-pre-wrap break-all p-3 rounded-md border",
|
||||
fileContent.ok ? "bg-background/50" : "bg-destructive/10 border-destructive"
|
||||
)}>
|
||||
{fileContent.content}
|
||||
</pre>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<typeof Sidebar>) {
|
||||
const { connected } = useGatewayEvents(() => {}, [])
|
||||
// Use Tiger logs SSE for connection status
|
||||
// connected means the bridge is reachable
|
||||
const { connected } = useTigerLogs({ lines: 1, maxLines: 1 })
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
|
|
@ -90,7 +82,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
<Bot className="size-4" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className="text-xs text-muted-foreground">{connected ? "Live" : "Offline"}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
|
@ -129,4 +121,4 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
}
|
||||
275
dashboard/src/components/cost/cost-monitor.tsx
Normal file
275
dashboard/src/components/cost/cost-monitor.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 p-3 rounded-lg text-sm",
|
||||
percentage >= 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 ? <AlertTriangle className="h-4 w-4" /> : <CheckCircle2 className="h-4 w-4" />}
|
||||
<span>
|
||||
{percentage >= 90 ? "Budget critical: " :
|
||||
percentage >= 75 ? "Budget warning: " :
|
||||
"Budget notice: "}
|
||||
{formatCost(used)} of {formatCost(limit)} used ({percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CostMonitor() {
|
||||
const { data, error } = useSWR<CostData>("/api/cost", fetcher, { refreshInterval: 30000 })
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-red-400 text-sm">Failed to load cost data</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Today</p>
|
||||
<p className="text-2xl font-bold">{summary ? formatCost(summary.today) : "—"}</p>
|
||||
</div>
|
||||
<DollarSign className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">This Week</p>
|
||||
<p className="text-2xl font-bold">{summary ? formatCost(summary.thisWeek) : "—"}</p>
|
||||
</div>
|
||||
<Calendar className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">This Month</p>
|
||||
<p className="text-2xl font-bold">{summary ? formatCost(summary.thisMonth) : "—"}</p>
|
||||
</div>
|
||||
<TrendingUp className="h-5 w-5 text-violet-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Avg/Request</p>
|
||||
<p className="text-2xl font-bold">{summary ? formatCost(summary.averagePerRequest) : "—"}</p>
|
||||
</div>
|
||||
<BarChart3 className="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Budget Progress */}
|
||||
{summary && (
|
||||
<Card className="bg-card/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Monthly Budget
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{formatCost(summary.budgetUsed)} used</span>
|
||||
<span className="text-muted-foreground">{formatCost(summary.budgetLimit)} limit</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(budgetPercentage, 100)}
|
||||
className={cn(
|
||||
budgetPercentage >= 90 ? "bg-red-500/20" :
|
||||
budgetPercentage >= 75 ? "bg-yellow-500/20" :
|
||||
"bg-emerald-500/20"
|
||||
)}
|
||||
/>
|
||||
<BudgetAlert used={summary.budgetUsed} limit={summary.budgetLimit} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Daily Trend */}
|
||||
<Card className="bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Daily Cost Trend</CardTitle>
|
||||
<CardDescription>Last 30 days spending</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length > 0 ? (
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorCost" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="date" tick={{fontSize: 10}} stroke="#6b7280" />
|
||||
<YAxis tick={{fontSize: 10}} stroke="#6b7280" tickFormatter={(v) => `$${v.toFixed(2)}`} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: 'none', borderRadius: '6px' }}
|
||||
formatter={(value: number) => [`$${value.toFixed(4)}`, 'Cost']}
|
||||
/>
|
||||
<Area type="monotone" dataKey="cost" stroke="#10b981" fillOpacity={1} fill="url(#colorCost)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No cost data available yet
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* By Model */}
|
||||
<Card className="bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Top Models by Cost</CardTitle>
|
||||
<CardDescription>Highest spending models</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{modelChartData.length > 0 ? (
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={modelChartData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" horizontal={false} />
|
||||
<XAxis type="number" tick={{fontSize: 10}} stroke="#6b7280" tickFormatter={(v) => `$${v.toFixed(2)}`} />
|
||||
<YAxis type="category" dataKey="name" tick={{fontSize: 10}} stroke="#6b7280" width={100} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: 'none', borderRadius: '6px' }}
|
||||
formatter={(value: number, _name: string, props: any) => {
|
||||
const model = props?.payload?.fullModel || ''
|
||||
return [`$${value.toFixed(4)}`, model]
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="cost" fill="#8b5cf6" radius={[0, 4, 4, 0]}>
|
||||
{modelChartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={["#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#3b82f6"][index % 5]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No model data available yet
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Last Request */}
|
||||
{lastEntry && (
|
||||
<Card className="bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
Last Request
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Model</p>
|
||||
<p className="font-medium truncate">{lastEntry.model.split("/").pop()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Cost</p>
|
||||
<p className="font-medium text-emerald-400">{formatCost(lastEntry.totalCost)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Tokens</p>
|
||||
<p className="font-medium">{formatTokens(lastEntry.inputTokens + lastEntry.outputTokens)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Time</p>
|
||||
<p className="font-medium">{new Date(lastEntry.timestamp).toLocaleTimeString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
dashboard/src/components/cost/index.ts
Normal file
1
dashboard/src/components/cost/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { CostMonitor } from "./cost-monitor"
|
||||
267
dashboard/src/components/output-viewer.tsx
Normal file
267
dashboard/src/components/output-viewer.tsx
Normal file
|
|
@ -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 <span>{JSON.stringify(data)}</span>
|
||||
|
||||
if (data === null) return <span className="text-yellow-400">null</span>
|
||||
if (data === undefined) return <span className="text-gray-400">undefined</span>
|
||||
|
||||
if (typeof data === "boolean") {
|
||||
return <span className={data ? "text-green-400" : "text-red-400"}>{String(data)}</span>
|
||||
}
|
||||
|
||||
if (typeof data === "number") {
|
||||
return <span className="text-blue-400">{data}</span>
|
||||
}
|
||||
|
||||
if (typeof data === "string") {
|
||||
return <span className="text-green-300">"{data}"</span>
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) return <span>[]</span>
|
||||
return (
|
||||
<span>
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="hover:text-foreground">
|
||||
[{collapsed ? `...${data.length} items]` : ""}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<span className="ml-2">
|
||||
{data.map((item, i) => (
|
||||
<div key={i} className="ml-4">
|
||||
<JsonTree data={item} depth={depth + 1} />
|
||||
</div>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof data === "object") {
|
||||
const entries = Object.entries(data as Record<string, unknown>)
|
||||
if (entries.length === 0) return <span>{"{}"}</span>
|
||||
return (
|
||||
<span>
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="hover:text-foreground">
|
||||
{"{"}{collapsed ? `...${entries.length} keys}` : ""}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<span className="ml-2">
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key} className="ml-4">
|
||||
<span className="text-blue-300">{key}</span>
|
||||
<span className="text-muted-foreground">: </span>
|
||||
<JsonTree data={value} depth={depth + 1} />
|
||||
</div>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <span>{String(data)}</span>
|
||||
}
|
||||
|
||||
export function OutputViewer({ filename, fileType, content, filePath, size }: OutputViewerProps) {
|
||||
const category = getFileCategory(filename, fileType)
|
||||
|
||||
return (
|
||||
<Card className="bg-card/50">
|
||||
<CardContent className="p-4">
|
||||
{/* File header */}
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
{category === "markdown" && <FileText className="h-4 w-4 text-purple-400" />}
|
||||
{category === "code" && <Code className="h-4 w-4 text-blue-400" />}
|
||||
{category === "json" && <FileJson className="h-4 w-4 text-amber-400" />}
|
||||
{category === "html" && <FileText className="h-4 w-4 text-orange-400" />}
|
||||
{category === "image" && <ImageIcon className="h-4 w-4 text-green-400" />}
|
||||
{category === "text" && <FileText className="h-4 w-4 text-muted-foreground" />}
|
||||
{category === "unknown" && <File className="h-4 w-4 text-muted-foreground" />}
|
||||
|
||||
<span className="font-medium text-sm">{filename}</span>
|
||||
{size && <span className="text-xs text-muted-foreground">({formatSize(size)})</span>}
|
||||
</div>
|
||||
|
||||
{filePath && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={`/api/tiger/files?path=${encodeURIComponent(filePath)}`} download>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content based on category */}
|
||||
<div className="max-h-[500px] overflow-auto">
|
||||
{category === "json" && (
|
||||
<pre className="text-xs font-mono bg-muted/50 p-3 rounded-md overflow-x-auto">
|
||||
<JsonTree data={(() => { try { return JSON.parse(content) } catch { return content } })()} />
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{category === "code" && (
|
||||
<pre className="text-xs font-mono bg-muted/50 p-3 rounded-md overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{category === "text" && (
|
||||
<pre className="text-xs font-mono bg-muted/50 p-3 rounded-md whitespace-pre-wrap break-all">
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{category === "markdown" && (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<pre className="text-sm whitespace-pre-wrap">{content}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{category === "html" && (
|
||||
<iframe
|
||||
srcDoc={content}
|
||||
className="w-full h-[400px] border rounded-md"
|
||||
sandbox="allow-scripts"
|
||||
title={filename}
|
||||
/>
|
||||
)}
|
||||
|
||||
{category === "image" && (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={`data:${fileType};base64,${content}`}
|
||||
alt={filename}
|
||||
className="max-w-full h-auto rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{category === "unknown" && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<File className="h-12 w-12 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">Cannot preview this file type</p>
|
||||
<p className="text-xs">{fileType}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Multi-file viewer with button tabs
|
||||
interface MultiOutputViewerProps {
|
||||
outputs: Array<{
|
||||
id: string
|
||||
filename: string
|
||||
file_type: string
|
||||
file_path: string
|
||||
size_bytes: number
|
||||
content?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export function MultiOutputViewer({ outputs }: MultiOutputViewerProps) {
|
||||
const [selected, setSelected] = React.useState(0)
|
||||
|
||||
if (outputs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<File className="h-12 w-12 mx-auto mb-2 opacity-30" />
|
||||
<p>No outputs to display</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (outputs.length === 1) {
|
||||
return (
|
||||
<OutputViewer
|
||||
filename={outputs[0].filename}
|
||||
fileType={outputs[0].file_type}
|
||||
content={outputs[0].content || ""}
|
||||
filePath={outputs[0].file_path}
|
||||
size={outputs[0].size_bytes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Simple tab buttons */}
|
||||
<div className="flex gap-1 mb-2 flex-wrap">
|
||||
{outputs.map((output, i) => (
|
||||
<Button
|
||||
key={output.id}
|
||||
variant={selected === i ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelected(i)}
|
||||
className="text-xs"
|
||||
>
|
||||
{output.filename}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<OutputViewer
|
||||
filename={outputs[selected].filename}
|
||||
fileType={outputs[selected].file_type}
|
||||
content={outputs[selected].content || ""}
|
||||
filePath={outputs[selected].file_path}
|
||||
size={outputs[selected].size_bytes}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
215
dashboard/src/components/sub-agents.tsx
Normal file
215
dashboard/src/components/sub-agents.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* sub-agents.tsx — Sub-agent visibility panel
|
||||
*
|
||||
* Shows each of Tiger's sub-agents (Coder, Researcher, Writer, PM).
|
||||
* For each: last active time, current task (if any), workspace path.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Bot, Clock, FileText, Loader2, HardDrive } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useBridgeRequest } from "@/hooks/use-bridge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Inline badge component since shadcn doesn't have Badge
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors = {
|
||||
running: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||
idle: "bg-gray-500/10 text-gray-400 border-gray-500/20",
|
||||
completed: "bg-green-500/10 text-green-400 border-green-500/20",
|
||||
failed: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
}
|
||||
return (
|
||||
<span className={cn("text-xs px-2 py-0.5 rounded border", colors[status as keyof typeof colors] || colors.idle)}>
|
||||
{status === "running" && <span className="animate-pulse mr-1">●</span>}
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface SubAgent {
|
||||
name: string
|
||||
status: "idle" | "running" | "completed" | "failed"
|
||||
lastActive: string | null
|
||||
currentTask: string | null
|
||||
workspacePath: string
|
||||
}
|
||||
|
||||
const AGENT_CONFIGS = [
|
||||
{ name: "coder", label: "Coder", icon: "💻", color: "text-blue-400" },
|
||||
{ name: "researcher", label: "Researcher", icon: "🔬", color: "text-purple-400" },
|
||||
{ name: "writer", label: "Writer", icon: "📝", color: "text-amber-400" },
|
||||
{ name: "pm", label: "PM", icon: "📋", color: "text-green-400" },
|
||||
]
|
||||
|
||||
export function SubAgentsPanel() {
|
||||
const { request } = useBridgeRequest()
|
||||
const [agents, setAgents] = React.useState<SubAgent[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
|
||||
// Load sub-agent status by reading workspace directories
|
||||
React.useEffect(() => {
|
||||
const loadAgents = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Get workspace contents to find agent directories
|
||||
const workspace = await request("/api/tiger/workspace") as { ok: boolean; files?: Array<{ name: string; type: string }> }
|
||||
|
||||
const agentData: SubAgent[] = AGENT_CONFIGS.map(config => {
|
||||
// Check if agent has workspace directory
|
||||
const hasWorkspace = workspace?.ok && workspace?.files?.some(
|
||||
f => f.name === config.name && f.type === "directory"
|
||||
)
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
status: "idle" as const,
|
||||
lastActive: null,
|
||||
currentTask: null,
|
||||
workspacePath: hasWorkspace ? `/workspace/${config.name}` : "",
|
||||
}
|
||||
})
|
||||
|
||||
// Try to get more info by reading each agent's workspace
|
||||
for (const agent of agentData) {
|
||||
if (agent.workspacePath) {
|
||||
try {
|
||||
const agentFiles = await request("/api/tiger/workspace", "GET", { path: agent.name }) as {
|
||||
ok: boolean;
|
||||
files?: Array<{ name: string; modified?: string }>;
|
||||
}
|
||||
|
||||
if (agentFiles.ok && agentFiles.files && agentFiles.files.length > 0) {
|
||||
// Get most recent file as "last active"
|
||||
const recentFile = agentFiles.files.reduce((latest, file) => {
|
||||
if (!latest) return file
|
||||
const latestTime = new Date(latest.modified || 0).getTime()
|
||||
const fileTime = new Date(file.modified || 0).getTime()
|
||||
return fileTime > latestTime ? file : latest
|
||||
}, agentFiles.files[0])
|
||||
|
||||
agent.lastActive = recentFile.modified || null
|
||||
|
||||
// Check for current task file (task.json in progress)
|
||||
const taskFile = agentFiles.files.find(f => f.name === "task.json")
|
||||
if (taskFile) {
|
||||
agent.status = "running"
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore individual agent read errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAgents(agentData)
|
||||
} catch (e) {
|
||||
console.error("Failed to load sub-agents:", e)
|
||||
// Initialize with defaults
|
||||
setAgents(AGENT_CONFIGS.map(config => ({
|
||||
name: config.name,
|
||||
status: "idle" as const,
|
||||
lastActive: null,
|
||||
currentTask: null,
|
||||
workspacePath: "",
|
||||
})))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadAgents()
|
||||
}, [request])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-card/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Bot className="h-5 w-5" />
|
||||
Sub-Agents
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Bot className="h-5 w-5" />
|
||||
Sub-Agents
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{AGENT_CONFIGS.map(config => {
|
||||
const agent = agents.find(a => a.name === config.name) || {
|
||||
name: config.name,
|
||||
status: "idle" as const,
|
||||
lastActive: null,
|
||||
currentTask: null,
|
||||
workspacePath: "",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={config.name}
|
||||
className="p-3 rounded-lg border border-border bg-background/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{config.icon}</span>
|
||||
<span className={cn("font-medium", config.color)}>{config.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<StatusBadge status={agent.status} />
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
{/* Last active */}
|
||||
{agent.lastActive ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Last active: {new Date(agent.lastActive).toLocaleString()}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Never active</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current task */}
|
||||
{agent.currentTask && (
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>Working on: {agent.currentTask}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workspace path */}
|
||||
{agent.workspacePath && (
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
<span className="font-mono">{agent.workspacePath}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
4
dashboard/src/components/tasks/index.ts
Normal file
4
dashboard/src/components/tasks/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { KanbanBoard } from "./kanban-board"
|
||||
export { TaskCard } from "./task-card"
|
||||
export { TaskDialog } from "./task-dialog"
|
||||
export { KanbanColumn } from "./kanban-column"
|
||||
458
dashboard/src/components/tasks/kanban-board.tsx
Normal file
458
dashboard/src/components/tasks/kanban-board.tsx
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
closestCorners,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragStartEvent,
|
||||
DragOverEvent,
|
||||
DragEndEvent,
|
||||
defaultDropAnimationSideEffects,
|
||||
DropAnimation
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { TaskCard } from "./task-card"
|
||||
import { TaskDialog } from "./task-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Plus, Filter, Search, Loader2 } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useBridgeRequest } from "@/hooks/use-bridge"
|
||||
|
||||
type TaskStatus = "backlog" | "ready" | "in-progress" | "review" | "done"
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string | null
|
||||
title: string
|
||||
description: string
|
||||
status: TaskStatus
|
||||
priority: string
|
||||
assigned_agent: string | null
|
||||
progress: number
|
||||
tags: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const COLUMNS: TaskStatus[] = ["backlog", "ready", "in-progress", "review", "done"]
|
||||
|
||||
const STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
"backlog": "Backlog",
|
||||
"ready": "Ready",
|
||||
"in-progress": "In Progress",
|
||||
"review": "Review",
|
||||
"done": "Done",
|
||||
}
|
||||
|
||||
export function KanbanBoard() {
|
||||
const { request } = useBridgeRequest()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [filterPriority, setFilterPriority] = useState<Set<string>>(new Set())
|
||||
const [filterAgent, setFilterAgent] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Load tasks from API
|
||||
const loadTasks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await request("/api/tiger/tasks") as { ok: boolean; tasks?: Task[] }
|
||||
if (data.ok && data.tasks) {
|
||||
setTasks(data.tasks.map((t: Task) => ({
|
||||
...t,
|
||||
tags: typeof t.tags === "string" ? JSON.parse(t.tags || "[]") : t.tags || [],
|
||||
})))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load tasks:", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [request])
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks()
|
||||
}, [loadTasks])
|
||||
|
||||
// Update task status when dragged
|
||||
const updateTaskStatus = useCallback(async (taskId: string, newStatus: TaskStatus) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await request(`/api/tiger/tasks/${taskId}`, "POST", { status: newStatus })
|
||||
setTasks(prev => prev.map(t =>
|
||||
t.id === taskId ? { ...t, status: newStatus, updated_at: new Date().toISOString() } : t
|
||||
))
|
||||
} catch (e) {
|
||||
console.error("Failed to update task:", e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [request])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string)
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over) return
|
||||
|
||||
const activeId = active.id as string
|
||||
const overId = over.id as string
|
||||
|
||||
const activeTask = tasks.find(t => t.id === activeId)
|
||||
if (!activeTask) return
|
||||
|
||||
// If dragging over a column
|
||||
if (COLUMNS.includes(overId as TaskStatus)) {
|
||||
if (activeTask.status !== overId) {
|
||||
setTasks(prev => prev.map(t =>
|
||||
t.id === activeId ? { ...t, status: overId as TaskStatus } : t
|
||||
))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If dragging over another task
|
||||
const overTask = tasks.find(t => t.id === overId)
|
||||
if (overTask && activeTask.status !== overTask.status) {
|
||||
setTasks(prev => prev.map(t =>
|
||||
t.id === activeId ? { ...t, status: overTask.status } : t
|
||||
))
|
||||
}
|
||||
}, [tasks])
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
setActiveId(null)
|
||||
|
||||
if (!over) return
|
||||
|
||||
const activeId = active.id as string
|
||||
const overId = over.id as string
|
||||
|
||||
if (activeId === overId) return
|
||||
|
||||
// Find the task and its new status
|
||||
const activeTask = tasks.find(t => t.id === activeId)
|
||||
if (!activeTask) return
|
||||
|
||||
let newStatus: TaskStatus = activeTask.status
|
||||
|
||||
if (COLUMNS.includes(overId as TaskStatus)) {
|
||||
newStatus = overId as TaskStatus
|
||||
} else {
|
||||
const overTask = tasks.find(t => t.id === overId)
|
||||
if (overTask) {
|
||||
newStatus = overTask.status
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to API
|
||||
if (newStatus !== activeTask.status) {
|
||||
updateTaskStatus(activeId, newStatus)
|
||||
}
|
||||
|
||||
// Reorder within column
|
||||
setTasks(prev => {
|
||||
const oldIndex = prev.findIndex(t => t.id === activeId)
|
||||
const newIndex = prev.findIndex(t => t.id === overId)
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
return arrayMove(prev, oldIndex, newIndex)
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}, [tasks, updateTaskStatus])
|
||||
|
||||
const handleAddTask = useCallback(async (taskData: Partial<Task>) => {
|
||||
try {
|
||||
const result = await request("/api/tiger/tasks", "POST", {
|
||||
title: taskData.title,
|
||||
description: taskData.description,
|
||||
priority: taskData.priority || "medium",
|
||||
status: taskData.status || "backlog",
|
||||
assigned_agent: taskData.assigned_agent,
|
||||
}) as { ok: boolean; task?: Task }
|
||||
|
||||
if (result.ok && result.task) {
|
||||
setTasks(prev => [result.task!, ...prev])
|
||||
}
|
||||
setIsDialogOpen(false)
|
||||
} catch (e) {
|
||||
console.error("Failed to create task:", e)
|
||||
}
|
||||
}, [request])
|
||||
|
||||
const handleUpdateTask = useCallback(async (taskData: Partial<Task>) => {
|
||||
if (!editingTask) return
|
||||
|
||||
try {
|
||||
const result = await request(`/api/tiger/tasks/${editingTask.id}`, "POST", taskData) as { ok: boolean; task?: Task }
|
||||
|
||||
if (result.ok && result.task) {
|
||||
setTasks(prev => prev.map(t =>
|
||||
t.id === editingTask.id ? { ...result.task!, tags: typeof result.task!.tags === "string" ? JSON.parse(result.task!.tags || "[]") : result.task!.tags || [] } : t
|
||||
))
|
||||
}
|
||||
setIsDialogOpen(false)
|
||||
setEditingTask(null)
|
||||
} catch (e) {
|
||||
console.error("Failed to update task:", e)
|
||||
}
|
||||
}, [editingTask, request])
|
||||
|
||||
const handleDeleteTask = useCallback(async (taskId: string) => {
|
||||
try {
|
||||
await request(`/api/tiger/tasks/${taskId}`, "POST", { _method: "DELETE" })
|
||||
setTasks(prev => prev.filter(t => t.id !== taskId))
|
||||
setIsDialogOpen(false)
|
||||
setEditingTask(null)
|
||||
} catch (e) {
|
||||
console.error("Failed to delete task:", e)
|
||||
}
|
||||
}, [request])
|
||||
|
||||
const handleEditTask = useCallback((task: Task) => {
|
||||
setEditingTask(task)
|
||||
setIsDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleRunTask = useCallback(async (taskId: string) => {
|
||||
try {
|
||||
const result = await request(`/api/tiger/dispatch`, "POST", { taskId }) as { ok: boolean; message?: string }
|
||||
if (result.ok) {
|
||||
console.log("Task dispatched:", result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to run task:", e)
|
||||
}
|
||||
}, [request])
|
||||
|
||||
// Filter tasks
|
||||
const filteredTasks = tasks.filter(task => {
|
||||
if (searchQuery && !task.title.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
!task.description.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
if (filterPriority.size > 0 && !filterPriority.has(task.priority)) {
|
||||
return false
|
||||
}
|
||||
if (filterAgent.size > 0 && (!task.assigned_agent || !filterAgent.has(task.assigned_agent))) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const activeTask = activeId ? tasks.find(t => t.id === activeId) : null
|
||||
|
||||
const dropAnimation: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: "0.5",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => {
|
||||
setEditingTask(null)
|
||||
setIsDialogOpen(true)
|
||||
}}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Task
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tasks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Priority
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{["low", "medium", "high", "urgent"].map(priority => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={priority}
|
||||
checked={filterPriority.has(priority)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newSet = new Set(filterPriority)
|
||||
if (checked) newSet.add(priority)
|
||||
else newSet.delete(priority)
|
||||
setFilterPriority(newSet)
|
||||
}}
|
||||
>
|
||||
{priority.charAt(0).toUpperCase() + priority.slice(1)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Agent
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{["manual", "coder", "researcher", "writer", "pm"].map(agent => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={agent}
|
||||
checked={filterAgent.has(agent)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newSet = new Set(filterAgent)
|
||||
if (checked) newSet.add(agent)
|
||||
else newSet.delete(agent)
|
||||
setFilterAgent(newSet)
|
||||
}}
|
||||
>
|
||||
{agent.charAt(0).toUpperCase() + agent.slice(1)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Saving indicator */}
|
||||
{saving && (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Saving changes...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kanban Board */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[500px]">
|
||||
{COLUMNS.map(status => (
|
||||
<div key={status} className="min-w-[200px]">
|
||||
<div className="text-sm font-medium text-muted-foreground uppercase tracking-wider px-2 mb-2">
|
||||
{STATUS_LABELS[status]} ({filteredTasks.filter(t => t.status === status).length})
|
||||
</div>
|
||||
<SortableContext items={filteredTasks.filter(t => t.status === status).map(t => t.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2">
|
||||
{filteredTasks.filter(t => t.status === status).map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={{
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
assigned_agent: task.assigned_agent,
|
||||
progress: task.progress,
|
||||
tags: task.tags,
|
||||
}}
|
||||
onEdit={() => handleEditTask(task)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={dropAnimation}>
|
||||
{activeTask ? (
|
||||
<TaskCard
|
||||
task={{
|
||||
id: activeTask.id,
|
||||
title: activeTask.title,
|
||||
description: activeTask.description,
|
||||
status: activeTask.status,
|
||||
priority: activeTask.priority,
|
||||
assigned_agent: activeTask.assigned_agent,
|
||||
progress: activeTask.progress,
|
||||
tags: activeTask.tags,
|
||||
}}
|
||||
isOverlay
|
||||
onEdit={() => {}}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{/* Task Dialog */}
|
||||
<TaskDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
task={editingTask ? {
|
||||
id: editingTask.id,
|
||||
title: editingTask.title,
|
||||
description: editingTask.description,
|
||||
status: editingTask.status,
|
||||
priority: editingTask.priority,
|
||||
assigned_agent: editingTask.assigned_agent,
|
||||
progress: editingTask.progress,
|
||||
tags: editingTask.tags,
|
||||
} : null}
|
||||
onSubmit={editingTask ? handleUpdateTask : handleAddTask}
|
||||
onDelete={editingTask ? () => handleDeleteTask(editingTask.id) : undefined}
|
||||
onRun={editingTask ? () => handleRunTask(editingTask.id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
dashboard/src/components/tasks/kanban-column.tsx
Normal file
69
dashboard/src/components/tasks/kanban-column.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"use client"
|
||||
|
||||
import { useDroppable } from "@dnd-kit/core"
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy
|
||||
} from "@dnd-kit/sortable"
|
||||
import { Task, TaskStatus, STATUS_COLORS } from "@/lib/tasks"
|
||||
import { TaskCard } from "./task-card"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface KanbanColumnProps {
|
||||
status: TaskStatus
|
||||
title: string
|
||||
tasks: Task[]
|
||||
onEditTask: (task: Task) => void
|
||||
onSpawnSubAgent: (taskId: string, agentType: string) => void
|
||||
}
|
||||
|
||||
export function KanbanColumn({ status, title, tasks, onEditTask, onSpawnSubAgent }: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: status,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex-shrink-0 w-72 rounded-lg border transition-colors",
|
||||
isOver ? "bg-muted/50 border-primary/50" : "bg-card/30 border-border"
|
||||
)}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className={cn(
|
||||
"p-3 border-b rounded-t-lg flex items-center justify-between",
|
||||
STATUS_COLORS[status]
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
<span className="text-xs bg-background/50 px-2 py-0.5 rounded-full">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Content */}
|
||||
<SortableContext
|
||||
items={tasks.map(t => t.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="p-2 space-y-2 min-h-[100px]">
|
||||
{tasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onEdit={() => onEditTask(task)}
|
||||
onSpawn={() => onSpawnSubAgent(task.id, task.assignedAgent || "auto")}
|
||||
/>
|
||||
))}
|
||||
{tasks.length === 0 && (
|
||||
<div className="text-center py-8 text-xs text-muted-foreground">
|
||||
Drop tasks here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
188
dashboard/src/components/tasks/task-card.tsx
Normal file
188
dashboard/src/components/tasks/task-card.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"use client"
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import {
|
||||
Clock,
|
||||
MoreHorizontal,
|
||||
Tag
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface TaskCardProps {
|
||||
task: {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
status: string
|
||||
priority: string
|
||||
assigned_agent?: string | null
|
||||
progress?: number
|
||||
tags?: string[]
|
||||
dueDate?: string
|
||||
subAgentStatus?: string
|
||||
}
|
||||
isOverlay?: boolean
|
||||
onEdit: () => void
|
||||
onSpawn?: () => void
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
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 AGENT_ICONS: Record<string, string> = {
|
||||
"gemini-flash": "⚡",
|
||||
"claude-opus": "🧠",
|
||||
"claude-sonnet": "💭",
|
||||
"gpt-4o": "🤖",
|
||||
"kimi-k2.5": "🔮",
|
||||
"coder": "💻",
|
||||
"researcher": "🔬",
|
||||
"writer": "📝",
|
||||
"pm": "📋",
|
||||
"auto": "🎲",
|
||||
"manual": "👤"
|
||||
}
|
||||
|
||||
export function TaskCard({ task, isOverlay, onEdit, onSpawn }: TaskCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id: task.id, data: { task } })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
zIndex: isOverlay ? 50 : undefined,
|
||||
cursor: isDragging ? "grabbing" : "grab"
|
||||
}
|
||||
|
||||
const tags = typeof task.tags === "string" ? JSON.parse(task.tags || "[]") : (task.tags || [])
|
||||
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== "done"
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
"bg-card hover:bg-card/80 transition-colors border-border/50",
|
||||
isOverlay && "shadow-lg ring-2 ring-primary rotate-2 cursor-grabbing"
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-3 space-y-2">
|
||||
{/* Header: Title + Menu */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-medium leading-tight flex-1 line-clamp-2">
|
||||
{task.title}
|
||||
</h4>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0 -mr-1 -mt-1">
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onEdit() }}>
|
||||
Edit Task
|
||||
</DropdownMenuItem>
|
||||
{onSpawn && task.status !== "done" && task.assigned_agent && task.assigned_agent !== "manual" && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onSpawn() }}>
|
||||
Run with Tiger
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Description preview */}
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{task.status === "in-progress" && (
|
||||
<Progress value={task.progress || 0} className="h-1" />
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.slice(0, 3).map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px] px-1 py-0 h-4">
|
||||
<Tag className="h-2 w-2 mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
|
||||
+{tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: Priority + Due Date + Agent */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Priority Badge */}
|
||||
<Badge className={cn("text-[10px] px-1.5 py-0 h-5 capitalize", PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium)}>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
|
||||
{/* Sub-agent status */}
|
||||
{task.subAgentStatus && task.subAgentStatus !== "idle" && (
|
||||
<span className="text-xs">
|
||||
{task.subAgentStatus === "running" && <span className="text-blue-400">●</span>}
|
||||
{task.subAgentStatus === "completed" && <span className="text-emerald-400">✓</span>}
|
||||
{task.subAgentStatus === "failed" && <span className="text-red-400">✕</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Due date */}
|
||||
{task.dueDate && (
|
||||
<span className={cn(
|
||||
"text-[10px] flex items-center gap-0.5",
|
||||
isOverdue ? "text-red-400" : "text-muted-foreground"
|
||||
)}>
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(task.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Agent indicator */}
|
||||
{task.assigned_agent && (
|
||||
<span className="text-xs" title={task.assigned_agent}>
|
||||
{AGENT_ICONS[task.assigned_agent] || "🤖"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
327
dashboard/src/components/tasks/task-dialog.tsx
Normal file
327
dashboard/src/components/tasks/task-dialog.tsx
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { X, Plus, Trash2, Play } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface TaskDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
task: {
|
||||
id?: string
|
||||
title: string
|
||||
description?: string
|
||||
status: string
|
||||
priority: string
|
||||
assigned_agent?: string | null
|
||||
progress?: number
|
||||
tags?: string[]
|
||||
due_date?: string
|
||||
notes?: string
|
||||
} | null
|
||||
onSubmit: (data: {
|
||||
title: string
|
||||
description?: string
|
||||
status: string
|
||||
priority: string
|
||||
assigned_agent?: string
|
||||
progress?: number
|
||||
tags?: string[]
|
||||
due_date?: string
|
||||
notes?: string
|
||||
}) => void
|
||||
onDelete?: () => void
|
||||
onRun?: () => void
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "backlog", label: "Backlog" },
|
||||
{ value: "ready", label: "Ready" },
|
||||
{ value: "in-progress", label: "In Progress" },
|
||||
{ value: "review", label: "Review" },
|
||||
{ value: "done", label: "Done" },
|
||||
]
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "urgent", label: "Urgent" },
|
||||
]
|
||||
|
||||
const AGENT_OPTIONS = [
|
||||
{ value: "manual", label: "Manual (unassigned)" },
|
||||
{ value: "coder", label: "Coder" },
|
||||
{ value: "researcher", label: "Researcher" },
|
||||
{ value: "writer", label: "Writer" },
|
||||
{ value: "pm", label: "Project Manager" },
|
||||
]
|
||||
|
||||
export function TaskDialog({ open, onOpenChange, task, onSubmit, onDelete, onRun }: TaskDialogProps) {
|
||||
const isEditing = !!task?.id
|
||||
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [status, setStatus] = useState("backlog")
|
||||
const [priority, setPriority] = useState("medium")
|
||||
const [assignedAgent, setAssignedAgent] = useState("manual")
|
||||
const [dueDate, setDueDate] = useState("")
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [newTag, setNewTag] = useState("")
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [notes, setNotes] = useState("")
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (task) {
|
||||
setTitle(task.title || "")
|
||||
setDescription(task.description || "")
|
||||
setStatus(task.status || "backlog")
|
||||
setPriority(task.priority || "medium")
|
||||
setAssignedAgent(task.assigned_agent || "manual")
|
||||
setDueDate(task.due_date ? task.due_date.split("T")[0] : "")
|
||||
setTags(typeof task.tags === "string" ? JSON.parse(task.tags || "[]") : (task.tags || []))
|
||||
setProgress(task.progress || 0)
|
||||
setNotes(task.notes || "")
|
||||
} else {
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
setStatus("backlog")
|
||||
setPriority("medium")
|
||||
setAssignedAgent("manual")
|
||||
setDueDate("")
|
||||
setTags([])
|
||||
setNewTag("")
|
||||
setProgress(0)
|
||||
setNotes("")
|
||||
}
|
||||
}
|
||||
}, [open, task])
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!title.trim()) return
|
||||
|
||||
onSubmit({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
priority,
|
||||
assigned_agent: assignedAgent !== "manual" ? assignedAgent : undefined,
|
||||
progress,
|
||||
tags,
|
||||
due_date: dueDate || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
if (newTag.trim() && !tags.includes(newTag.trim())) {
|
||||
setTags([...tags, newTag.trim()])
|
||||
setNewTag("")
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setTags(tags.filter(t => t !== tag))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit Task" : "Create New Task"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing ? "Update task details and progress" : "Add a new task to your board"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Title */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="Task title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Describe the task..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status & Priority Row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Priority</Label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assigned Agent */}
|
||||
<div className="space-y-2">
|
||||
<Label>Assign To</Label>
|
||||
<Select value={assignedAgent} onValueChange={(v) => setAssignedAgent(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AGENT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select an AI agent to delegate this task to, or keep it manual.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Due Date & Progress */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dueDate">Due Date</Label>
|
||||
<Input
|
||||
id="dueDate"
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Progress</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={progress} className="flex-1" />
|
||||
<span className="text-sm text-muted-foreground w-10">{progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Add tag..."
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
addTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="icon" onClick={addTag}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1">
|
||||
{tag}
|
||||
<button onClick={() => removeTag(tag)} className="hover:text-red-400">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Additional notes, sub-tasks, etc."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
{isEditing && onDelete && (
|
||||
<Button variant="destructive" onClick={onDelete} className="mr-auto">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && onRun && (
|
||||
<Button variant="outline" onClick={onRun} className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10">
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Run with Tiger
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!title.trim()}>
|
||||
{isEditing ? "Save Changes" : "Create Task"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
48
dashboard/src/components/ui/badge.tsx
Normal file
48
dashboard/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
220
dashboard/src/hooks/use-bridge.ts
Normal file
220
dashboard/src/hooks/use-bridge.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* use-bridge.ts — React hooks for the Tiger Bridge API
|
||||
*
|
||||
* These are CLIENT-SIDE hooks. They call the Next.js /api/tiger/* routes,
|
||||
* which in turn call the Tiger Bridge on the VPS.
|
||||
*
|
||||
* Why use Next.js routes as a middleman?
|
||||
* - The TIGER_BRIDGE_TOKEN never reaches the browser
|
||||
* - The bridge can only be accessed server-side (safe)
|
||||
* - We avoid CORS config between browser and VPS
|
||||
*
|
||||
* Hooks exported:
|
||||
* useBridgeRequest() — one-shot fetch (status, exec, config, restart)
|
||||
* useTigerLogs() — SSE hook that streams log lines from the bridge
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BridgeStatus {
|
||||
status: "online" | "degraded" | "offline";
|
||||
container: {
|
||||
status: string;
|
||||
exitCode: number;
|
||||
startedAt: string;
|
||||
};
|
||||
openclaw: {
|
||||
running: boolean;
|
||||
processInfo: string;
|
||||
};
|
||||
system: {
|
||||
memoryUsagePct: number;
|
||||
memoryTotalMb: number;
|
||||
uptime: string;
|
||||
};
|
||||
agent: {
|
||||
currentModel: string;
|
||||
fallbackModels: string[];
|
||||
heartbeat: string | null;
|
||||
soul: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
ts: string;
|
||||
text: string;
|
||||
level: "INFO" | "WARN" | "ERROR" | "DEBUG";
|
||||
}
|
||||
|
||||
// ─── useBridgeRequest ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Low-level hook for making requests to /api/tiger/* endpoints.
|
||||
*
|
||||
* Usage:
|
||||
* const { request, loading } = useBridgeRequest()
|
||||
* const status = await request("/api/tiger/status")
|
||||
* await request("/api/tiger/restart", "POST", { reason: "dashboard" })
|
||||
* await request("/api/tiger/tasks/task_123", "PUT", { status: "done" })
|
||||
* await request("/api/tiger/projects/proj_123", "DELETE")
|
||||
*/
|
||||
export function useBridgeRequest() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const request = useCallback(
|
||||
async (
|
||||
apiPath: string,
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
|
||||
body?: Record<string, unknown>
|
||||
): Promise<unknown> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(apiPath, {
|
||||
method,
|
||||
headers: body || method === "DELETE" ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { request, loading };
|
||||
}
|
||||
|
||||
// ─── useTigerLogs ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook that opens an SSE connection to /api/tiger/logs and streams
|
||||
* log lines in real-time.
|
||||
*
|
||||
* How SSE works (simple mental model):
|
||||
* - Your browser opens a one-way "long-lived" HTTP connection
|
||||
* - The server keeps the connection open and sends events as they happen
|
||||
* - It's like a news ticker: the server pushes updates, you don't poll
|
||||
* - If the connection drops, EventSource reconnects automatically
|
||||
*
|
||||
* Usage:
|
||||
* const { logs, connected, clear, pause, paused } = useTigerLogs({ lines: 200 })
|
||||
*
|
||||
* @param lines - How many historical lines to fetch first (default 100)
|
||||
* @param filter - Optional keyword filter (passed to bridge)
|
||||
* @param maxLines - Max entries to keep in memory (default 500)
|
||||
*/
|
||||
export function useTigerLogs({
|
||||
lines = 100,
|
||||
filter = "",
|
||||
maxLines = 500,
|
||||
}: {
|
||||
lines?: number;
|
||||
filter?: string;
|
||||
maxLines?: number;
|
||||
} = {}) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
// We use a ref for paused so the event handler always sees the latest value
|
||||
// (closure would capture the initial value otherwise)
|
||||
const pausedRef = useRef(paused);
|
||||
pausedRef.current = paused;
|
||||
|
||||
// Build the URL with query params
|
||||
const url = filter
|
||||
? `/api/tiger/logs?lines=${lines}&filter=${encodeURIComponent(filter)}`
|
||||
: `/api/tiger/logs?lines=${lines}`;
|
||||
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null;
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const connect = () => {
|
||||
// EventSource is the browser's built-in SSE API
|
||||
// It reconnects automatically after errors
|
||||
eventSource = new EventSource(url);
|
||||
|
||||
// "connected" event — our custom event type from the bridge
|
||||
eventSource.addEventListener("connected", () => {
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
// "log" event — each log line from Docker
|
||||
eventSource.addEventListener("log", (e: MessageEvent) => {
|
||||
if (pausedRef.current) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(e.data) as { ts: string; text: string; level: string };
|
||||
const entry: LogEntry = {
|
||||
id: `log-${Date.now()}-${Math.random()}`,
|
||||
ts: data.ts,
|
||||
text: data.text,
|
||||
level: (data.level as LogEntry["level"]) || "INFO",
|
||||
};
|
||||
|
||||
// Keep only the last maxLines entries (prevent memory leak)
|
||||
setLogs((prev) => [...prev.slice(-(maxLines - 1)), entry]);
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
// "closed" event — stream ended (container stopped, etc.)
|
||||
eventSource.addEventListener("closed", () => {
|
||||
setConnected(false);
|
||||
});
|
||||
|
||||
// "error" event — bridge error
|
||||
eventSource.addEventListener("error", (e: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
setLogs((prev) => [
|
||||
...prev.slice(-(maxLines - 1)),
|
||||
{
|
||||
id: `err-${Date.now()}`,
|
||||
ts: new Date().toISOString(),
|
||||
text: `[BRIDGE ERROR] ${data.message}`,
|
||||
level: "ERROR",
|
||||
},
|
||||
]);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// onerror fires when EventSource loses connection
|
||||
eventSource.onerror = () => {
|
||||
setConnected(false);
|
||||
eventSource?.close();
|
||||
// Retry after 5 seconds
|
||||
retryTimeout = setTimeout(connect, 5000);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
// Cleanup when component unmounts
|
||||
return () => {
|
||||
eventSource?.close();
|
||||
if (retryTimeout) clearTimeout(retryTimeout);
|
||||
setConnected(false);
|
||||
};
|
||||
}, [url, maxLines]); // Reconnect if URL changes (filter/lines changed)
|
||||
|
||||
const clear = useCallback(() => setLogs([]), []);
|
||||
const pause = useCallback(() => setPaused(true), []);
|
||||
const resume = useCallback(() => setPaused(false), []);
|
||||
|
||||
return { logs, connected, paused, clear, pause, resume };
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
// Call a gateway method via the proxy API
|
||||
export function useGatewayRequest() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const request = useCallback(async (method: string, params: Record<string, unknown> = {}) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/gw/${method.replace(/\./g, "/")}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!json.ok) throw new Error(json.error || "Request failed")
|
||||
return json.data
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { request, loading }
|
||||
}
|
||||
|
||||
// Subscribe to gateway events via SSE
|
||||
export function useGatewayEvents(
|
||||
onEvent: (event: string, payload: unknown) => void,
|
||||
deps: unknown[] = []
|
||||
) {
|
||||
const onEventRef = useRef(onEvent)
|
||||
onEventRef.current = onEvent
|
||||
const [connected, setConnected] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const connect = () => {
|
||||
eventSource = new EventSource("/api/gw/stream")
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
if (data.event === "stream.connected") {
|
||||
setConnected(true)
|
||||
} else if (data.event === "stream.disconnected") {
|
||||
setConnected(false)
|
||||
} else {
|
||||
onEventRef.current(data.event, data.payload)
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setConnected(false)
|
||||
eventSource?.close()
|
||||
retryTimeout = setTimeout(connect, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
eventSource?.close()
|
||||
if (retryTimeout) clearTimeout(retryTimeout)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps)
|
||||
|
||||
return { connected }
|
||||
}
|
||||
160
dashboard/src/lib/bridge.ts
Normal file
160
dashboard/src/lib/bridge.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* bridge.ts — HTTP client for the Tiger Bridge API
|
||||
*
|
||||
* This module replaces the old WebSocket gateway client (gateway.ts) for
|
||||
* Tiger-specific operations. Instead of a persistent WebSocket connection,
|
||||
* we make simple HTTP fetch calls to the Bridge API running on the VPS.
|
||||
*
|
||||
* The Bridge URL is set via TIGER_BRIDGE_URL environment variable.
|
||||
* In production: https://agent.manohargupta.com/bridge
|
||||
* In development: http://localhost:3456
|
||||
*
|
||||
* How calls flow:
|
||||
* Dashboard component
|
||||
* → fetch("/api/tiger/status") [Next.js API route — server-side]
|
||||
* → bridgeGet("/tiger/status") [this file — makes authenticated call]
|
||||
* → Tiger Bridge (Express) [VPS]
|
||||
* → execOnHost / execInSandbox [docker exec...]
|
||||
*
|
||||
* Why go through Next.js API routes instead of calling the Bridge directly
|
||||
* from the browser?
|
||||
* 1. The Bridge token never leaves the server
|
||||
* 2. CORS is simpler (same-origin requests from the browser)
|
||||
* 3. We can add caching/rate limiting in one place
|
||||
*/
|
||||
|
||||
// ─── Configuration ──────────────────────────────────────────────────────────
|
||||
// These are read on the SERVER side (Next.js API routes), not the browser.
|
||||
// In .env.local:
|
||||
// TIGER_BRIDGE_URL=http://localhost:3456
|
||||
// TIGER_BRIDGE_TOKEN=your-secret-token-here
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
// ─── Request helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the Authorization header for Bridge API calls.
|
||||
* If no token is set, omit the header (dev mode).
|
||||
*/
|
||||
function authHeaders(): Record<string, string> {
|
||||
if (!BRIDGE_TOKEN) return {};
|
||||
return { Authorization: `Bearer ${BRIDGE_TOKEN}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GET request to the Tiger Bridge.
|
||||
*
|
||||
* @param path - Bridge API path, e.g. "/tiger/status"
|
||||
* @param query - Optional query string params, e.g. { lines: "100" }
|
||||
* @returns Parsed JSON response
|
||||
* @throws Error if the response is not ok
|
||||
*
|
||||
* Example:
|
||||
* const status = await bridgeGet("/tiger/status")
|
||||
* const files = await bridgeGet("/tiger/workspace", { path: "memory" })
|
||||
*/
|
||||
export async function bridgeGet(
|
||||
path: string,
|
||||
query: Record<string, string> = {}
|
||||
): Promise<unknown> {
|
||||
const url = new URL(`${BRIDGE_URL}${path}`);
|
||||
|
||||
// Append query params to the URL
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...authHeaders(),
|
||||
},
|
||||
// Don't cache — bridge data is always live
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Bridge GET ${path} failed: ${res.status} ${body}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request to the Tiger Bridge.
|
||||
*
|
||||
* @param path - Bridge API path, e.g. "/tiger/exec"
|
||||
* @param body - Request body (will be JSON-encoded)
|
||||
* @returns Parsed JSON response
|
||||
* @throws Error if the response is not ok
|
||||
*
|
||||
* Example:
|
||||
* const result = await bridgePost("/tiger/exec", { command: "ls /sandbox" })
|
||||
* const updated = await bridgePost("/tiger/config", { patch: { model: "..." } })
|
||||
*/
|
||||
export async function bridgePost(
|
||||
path: string,
|
||||
body: Record<string, unknown> = {}
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(`${BRIDGE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...authHeaders(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
throw new Error(`Bridge POST ${path} failed: ${res.status} ${errBody}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a DELETE request to the Tiger Bridge.
|
||||
*
|
||||
* @param path - Bridge API path, e.g. "/tiger/projects/proj_xxx"
|
||||
*/
|
||||
export async function bridgeDelete(path: string): Promise<unknown> {
|
||||
const res = await fetch(`${BRIDGE_URL}${path}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
throw new Error(`Bridge DELETE ${path} failed: ${res.status} ${errBody}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full URL for the Bridge's SSE logs endpoint.
|
||||
* This is used by the Next.js API route that proxies the SSE stream.
|
||||
*
|
||||
* @param lines - How many historical lines to tail
|
||||
* @param filter - Optional keyword filter
|
||||
*/
|
||||
export function bridgeLogsUrl(lines = 100, filter = ""): string {
|
||||
const url = new URL(`${BRIDGE_URL}/tiger/logs`);
|
||||
url.searchParams.set("lines", String(lines));
|
||||
if (filter) url.searchParams.set("filter", filter);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the auth headers so the SSE proxy route can use them.
|
||||
*/
|
||||
export { authHeaders };
|
||||
80
dashboard/src/lib/cost.ts
Normal file
80
dashboard/src/lib/cost.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// Cost tracking types and utilities
|
||||
export interface CostEntry {
|
||||
id: string
|
||||
timestamp: string
|
||||
model: string
|
||||
provider: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
inputCost: number
|
||||
outputCost: number
|
||||
totalCost: number
|
||||
sessionId?: string
|
||||
requestType?: string
|
||||
}
|
||||
|
||||
export interface DailyCost {
|
||||
date: string
|
||||
total: number
|
||||
requests: number
|
||||
}
|
||||
|
||||
export interface ModelCost {
|
||||
model: string
|
||||
provider: string
|
||||
totalCost: number
|
||||
requests: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
}
|
||||
|
||||
export interface CostSummary {
|
||||
today: number
|
||||
thisWeek: number
|
||||
thisMonth: number
|
||||
totalRequests: number
|
||||
averagePerRequest: number
|
||||
budgetUsed: number
|
||||
budgetLimit: number
|
||||
}
|
||||
|
||||
// Cost per 1M tokens (approximate rates)
|
||||
const MODEL_RATES: Record<string, { input: number; output: number }> = {
|
||||
"openrouter/moonshotai/kimi-k2.5": { input: 0.8, output: 2.0 },
|
||||
"openrouter/anthropic/claude-3.5-sonnet": { input: 3.0, output: 15.0 },
|
||||
"openrouter/anthropic/claude-3-opus": { input: 15.0, output: 75.0 },
|
||||
"openrouter/openai/gpt-4o": { input: 5.0, output: 15.0 },
|
||||
"openrouter/openai/gpt-4o-mini": { input: 0.15, output: 0.6 },
|
||||
"openrouter/google/gemini-flash-1.5": { input: 0.075, output: 0.3 },
|
||||
"openrouter/google/gemini-pro-1.5": { input: 1.25, output: 5.0 },
|
||||
"anthropic/claude-3.5-sonnet": { input: 3.0, output: 15.0 },
|
||||
"openai/gpt-4o": { input: 5.0, output: 15.0 },
|
||||
"gemini-flash": { input: 0.075, output: 0.3 },
|
||||
"gemini-pro": { input: 1.25, output: 5.0 },
|
||||
}
|
||||
|
||||
export function calculateCost(model: string, inputTokens: number, outputTokens: number): { inputCost: number; outputCost: number; totalCost: number } {
|
||||
const rates = MODEL_RATES[model] || { input: 1.0, output: 3.0 }
|
||||
const inputCost = (inputTokens / 1000000) * rates.input
|
||||
const outputCost = (outputTokens / 1000000) * rates.output
|
||||
return {
|
||||
inputCost,
|
||||
outputCost,
|
||||
totalCost: inputCost + outputCost
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultBudget(): number {
|
||||
return 50 // $50 default budget
|
||||
}
|
||||
|
||||
export function formatCost(cost: number): string {
|
||||
if (cost < 0.01) return `<$0.01`
|
||||
return `$${cost.toFixed(2)}`
|
||||
}
|
||||
|
||||
export function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
||||
if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`
|
||||
return String(tokens)
|
||||
}
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
import WebSocket from "ws"
|
||||
import { randomUUID } from "crypto"
|
||||
import { EventEmitter } from "events"
|
||||
|
||||
const GATEWAY_URL = process.env.CLAWDBOT_GATEWAY_URL || "ws://127.0.0.1:18789"
|
||||
const GATEWAY_TOKEN = process.env.CLAWDBOT_GATEWAY_TOKEN || ""
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason: unknown) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
class GatewayClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null
|
||||
private pending = new Map<string, PendingRequest>()
|
||||
private connected = false
|
||||
private connecting = false
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private seq = 0
|
||||
|
||||
isConnected() {
|
||||
return this.connected
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected || this.connecting) return
|
||||
this.connecting = true
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(GATEWAY_URL)
|
||||
|
||||
this.ws.on("open", () => {
|
||||
// Send connect handshake
|
||||
const connectMsg = {
|
||||
type: "req",
|
||||
id: randomUUID(),
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: "gateway-client",
|
||||
version: "1.0.0",
|
||||
platform: "node",
|
||||
mode: "ui",
|
||||
},
|
||||
auth: { token: GATEWAY_TOKEN },
|
||||
},
|
||||
}
|
||||
this.ws!.send(JSON.stringify(connectMsg))
|
||||
})
|
||||
|
||||
this.ws.on("message", (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString())
|
||||
this.handleMessage(msg, resolve)
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on("close", () => {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.emit("disconnected")
|
||||
this.rejectAllPending("Connection closed")
|
||||
this.scheduleReconnect()
|
||||
})
|
||||
|
||||
this.ws.on("error", (err) => {
|
||||
if (this.connecting && !this.connected) {
|
||||
this.connecting = false
|
||||
reject(err)
|
||||
}
|
||||
this.connected = false
|
||||
this.rejectAllPending("Connection error")
|
||||
})
|
||||
} catch (err) {
|
||||
this.connecting = false
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessage(msg: Record<string, unknown>, connectResolve?: (value: void) => void) {
|
||||
const type = msg.type as string
|
||||
|
||||
if (type === "event") {
|
||||
const event = msg.event as string
|
||||
const payload = msg.payload as Record<string, unknown>
|
||||
|
||||
// Handle connect challenge by ignoring (token auth doesn't need signing)
|
||||
if (event === "connect.challenge") return
|
||||
|
||||
this.seq = (msg.seq as number) || this.seq
|
||||
this.emit("gateway-event", { event, payload, seq: this.seq })
|
||||
this.emit(`event:${event}`, payload)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "res") {
|
||||
const id = msg.id as string
|
||||
const ok = msg.ok as boolean
|
||||
const payload = msg.payload as Record<string, unknown>
|
||||
|
||||
// Check if this is the connect handshake response
|
||||
if (payload && (payload as Record<string, unknown>).type === "hello-ok") {
|
||||
this.connected = true
|
||||
this.connecting = false
|
||||
this.emit("connected", payload)
|
||||
connectResolve?.()
|
||||
return
|
||||
}
|
||||
|
||||
const pending = this.pending.get(id)
|
||||
if (pending) {
|
||||
this.pending.delete(id)
|
||||
clearTimeout(pending.timer)
|
||||
if (ok) {
|
||||
pending.resolve(payload)
|
||||
} else {
|
||||
pending.reject(msg.error || payload || "Request failed")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async request(method: string, params: Record<string, unknown> = {}, timeoutMs = 30000): Promise<unknown> {
|
||||
if (!this.connected) {
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
const id = randomUUID()
|
||||
const msg = { type: "req", id, method, params }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
this.pending.set(id, { resolve, reject, timer })
|
||||
this.ws!.send(JSON.stringify(msg))
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.rejectAllPending("Disconnected")
|
||||
}
|
||||
|
||||
private rejectAllPending(reason: string) {
|
||||
for (const [id, pending] of this.pending) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(new Error(reason))
|
||||
this.pending.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect().catch(() => {
|
||||
// Will retry on next request
|
||||
})
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance - shared across all API routes
|
||||
let instance: GatewayClient | null = null
|
||||
|
||||
export function getGateway(): GatewayClient {
|
||||
if (!instance) {
|
||||
instance = new GatewayClient()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
export type { GatewayClient }
|
||||
118
dashboard/src/lib/tasks.ts
Normal file
118
dashboard/src/lib/tasks.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// Task management types
|
||||
|
||||
export type TaskStatus = "backlog" | "ready" | "in-progress" | "review" | "done"
|
||||
|
||||
export type TaskPriority = "low" | "medium" | "high" | "urgent"
|
||||
|
||||
export type AgentType = "gemini-flash" | "claude-opus" | "claude-sonnet" | "gpt-4o" | "kimi-k2.5" | "auto" | "manual"
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: TaskStatus
|
||||
priority: TaskPriority
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
dueDate?: string
|
||||
assignedAgent?: AgentType
|
||||
subAgentSessionId?: string
|
||||
subAgentStatus?: "idle" | "running" | "completed" | "failed"
|
||||
tags: string[]
|
||||
progress: number // 0-100
|
||||
parentTaskId?: string
|
||||
subTasks: string[]
|
||||
notes: string
|
||||
cost?: number // Tracked cost for this task
|
||||
estimatedHours?: number
|
||||
actualHours?: number
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
backlog: "Backlog",
|
||||
ready: "Ready",
|
||||
"in-progress": "In Progress",
|
||||
review: "Review",
|
||||
done: "Done"
|
||||
}
|
||||
|
||||
export const STATUS_COLORS: Record<TaskStatus, string> = {
|
||||
backlog: "bg-slate-500/20 text-slate-400 border-slate-500/30",
|
||||
ready: "bg-blue-500/20 text-blue-400 border-blue-500/30",
|
||||
"in-progress": "bg-amber-500/20 text-amber-400 border-amber-500/30",
|
||||
review: "bg-purple-500/20 text-purple-400 border-purple-500/30",
|
||||
done: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
|
||||
}
|
||||
|
||||
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
|
||||
low: "bg-slate-500/20 text-slate-400",
|
||||
medium: "bg-blue-500/20 text-blue-400",
|
||||
high: "bg-orange-500/20 text-orange-400",
|
||||
urgent: "bg-red-500/20 text-red-400"
|
||||
}
|
||||
|
||||
export const AGENT_OPTIONS: { value: AgentType; label: string }[] = [
|
||||
{ value: "manual", label: "Manual (Me)" },
|
||||
{ value: "auto", label: "Auto-select" },
|
||||
{ value: "gemini-flash", label: "Gemini Flash" },
|
||||
{ value: "claude-opus", label: "Claude 3 Opus" },
|
||||
{ value: "claude-sonnet", label: "Claude 3.5 Sonnet" },
|
||||
{ value: "gpt-4o", label: "GPT-4o" },
|
||||
{ value: "kimi-k2.5", label: "Kimi K2.5" }
|
||||
]
|
||||
|
||||
export function generateTaskId(): string {
|
||||
return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
export function createTask(partial: Partial<Task> = {}): Task {
|
||||
const now = new Date().toISOString()
|
||||
return {
|
||||
id: partial.id || generateTaskId(),
|
||||
title: partial.title || "New Task",
|
||||
description: partial.description || "",
|
||||
status: partial.status || "backlog",
|
||||
priority: partial.priority || "medium",
|
||||
createdAt: partial.createdAt || now,
|
||||
updatedAt: partial.updatedAt || now,
|
||||
dueDate: partial.dueDate,
|
||||
assignedAgent: partial.assignedAgent,
|
||||
subAgentSessionId: partial.subAgentSessionId,
|
||||
subAgentStatus: partial.subAgentStatus || "idle",
|
||||
tags: partial.tags || [],
|
||||
progress: partial.progress ?? 0,
|
||||
parentTaskId: partial.parentTaskId,
|
||||
subTasks: partial.subTasks || [],
|
||||
notes: partial.notes || "",
|
||||
cost: partial.cost,
|
||||
estimatedHours: partial.estimatedHours,
|
||||
actualHours: partial.actualHours
|
||||
}
|
||||
}
|
||||
|
||||
// Local storage key
|
||||
export const TASKS_STORAGE_KEY = "clawd-tasks"
|
||||
|
||||
// Helper to load tasks from localStorage (client-side only)
|
||||
export function loadTasks(): Task[] {
|
||||
if (typeof window === "undefined") return []
|
||||
try {
|
||||
const stored = localStorage.getItem(TASKS_STORAGE_KEY)
|
||||
if (stored) {
|
||||
return JSON.parse(stored)
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Helper to save tasks to localStorage
|
||||
export function saveTasks(tasks: Task[]) {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
localStorage.setItem(TASKS_STORAGE_KEY, JSON.stringify(tasks))
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
39
deploy/.env.example
Normal file
39
deploy/.env.example
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Tiger Control Plane — Environment Variables
|
||||
#
|
||||
# Copy this file:
|
||||
# For the dashboard: dashboard/.env.local
|
||||
# For the bridge: bridge/.env
|
||||
#
|
||||
# NEVER commit real secrets to git. .env.local is in .gitignore.
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Tiger Bridge (used by BOTH the bridge server AND the dashboard) ──
|
||||
#
|
||||
# The shared secret token — must match on both sides.
|
||||
# Generate a strong random value:
|
||||
# openssl rand -hex 32
|
||||
TIGER_BRIDGE_TOKEN=change-me-generate-with-openssl-rand-hex-32
|
||||
|
||||
# ── Bridge server config (bridge/.env only) ─────────────────────────
|
||||
#
|
||||
# Port the bridge listens on (localhost only — Caddy proxies HTTPS)
|
||||
TIGER_BRIDGE_PORT=3456
|
||||
|
||||
# Only accept connections from localhost (Caddy is the public face)
|
||||
TIGER_BRIDGE_HOST=127.0.0.1
|
||||
|
||||
# CORS: allow requests from the dashboard origin
|
||||
# In production: https://agent.manohargupta.com
|
||||
# In development: http://localhost:3000
|
||||
ALLOWED_ORIGIN=https://agent.manohargupta.com
|
||||
|
||||
# ── Dashboard config (dashboard/.env.local only) ────────────────────
|
||||
#
|
||||
# URL the Next.js server uses to call the bridge (server-to-server)
|
||||
# In production: http://localhost:3456 (same machine)
|
||||
# In development: http://localhost:3456
|
||||
TIGER_BRIDGE_URL=http://localhost:3456
|
||||
|
||||
# ── Next.js public URL (for absolute URLs in emails, etc.) ──────────
|
||||
NEXT_PUBLIC_APP_URL=https://agent.manohargupta.com
|
||||
110
deploy/Caddyfile
Normal file
110
deploy/Caddyfile
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Caddyfile — Tiger Control Plane
|
||||
# Deployed at: agent.manohargupta.com
|
||||
#
|
||||
# What Caddy does here:
|
||||
# 1. Automatically obtains and renews TLS certificates via Let's Encrypt
|
||||
# 2. Handles HTTPS → HTTP reverse proxy to the Next.js dashboard
|
||||
# 3. Protects certain routes with basic auth (exec, restart, config write)
|
||||
# 4. Sets security headers
|
||||
#
|
||||
# To deploy on your Hetzner VPS:
|
||||
# 1. Install Caddy: apt install caddy
|
||||
# 2. Copy this file: sudo cp Caddyfile /etc/caddy/Caddyfile
|
||||
# 3. Set the basic auth password hash (see instructions below)
|
||||
# 4. Reload: sudo systemctl reload caddy
|
||||
#
|
||||
# ── How to generate the basicauth password hash ──────────────────────────────
|
||||
# Run this on your VPS:
|
||||
# caddy hash-password --plaintext "your-password-here"
|
||||
# Then paste the output as the hash below (the $2a$... string).
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
agent.manohargupta.com {
|
||||
|
||||
# ── TLS ──────────────────────────────────────────────────────────────────
|
||||
# Caddy automatically fetches and renews TLS from Let's Encrypt.
|
||||
# Your VPS must have ports 80 and 443 open for this to work.
|
||||
tls {
|
||||
# Optional: use a specific email for Let's Encrypt notifications
|
||||
# email admin@manohargupta.com
|
||||
}
|
||||
|
||||
# ── Security headers ─────────────────────────────────────────────────────
|
||||
# These headers protect against common web attacks.
|
||||
# X-Frame-Options prevents your dashboard from being embedded in iframes.
|
||||
# X-Content-Type-Options stops browsers from guessing MIME types.
|
||||
header {
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
# Remove server info from responses
|
||||
-Server
|
||||
}
|
||||
|
||||
# ── Protected routes (require basic auth) ────────────────────────────────
|
||||
# These are the "dangerous" endpoints — exec, restart, config writes.
|
||||
# Basic auth adds a second layer of protection on top of the bridge token.
|
||||
#
|
||||
# Generate hash with: caddy hash-password --plaintext "YOUR_PASSWORD"
|
||||
# Replace "CHANGE_ME_HASH" with the output.
|
||||
|
||||
@protected path /api/tiger/exec /api/tiger/restart /api/tiger/config
|
||||
basicauth @protected {
|
||||
# Username: tiger
|
||||
# Hash: run `caddy hash-password --plaintext "your-password"` and paste here
|
||||
tiger $2a$14$CHANGE_ME_HASH_GENERATED_BY_CADDY_HASH_PASSWORD_COMMAND
|
||||
}
|
||||
|
||||
# ── Reverse proxy to Next.js dashboard ───────────────────────────────────
|
||||
# The dashboard runs on port 3000 (localhost only).
|
||||
# All requests go here by default.
|
||||
reverse_proxy localhost:3000 {
|
||||
# Forward the real client IP to Next.js so logs show the right IP
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
|
||||
# Health check — Caddy verifies the backend is alive before routing
|
||||
health_uri /api/tiger/status
|
||||
health_interval 30s
|
||||
health_timeout 10s
|
||||
}
|
||||
|
||||
# ── Handle SSE (Server-Sent Events) correctly ────────────────────────────
|
||||
# SSE is a streaming connection — we must disable buffering.
|
||||
# Without this, Caddy buffers the stream and logs appear with a delay.
|
||||
@sse path /api/tiger/logs
|
||||
handle @sse {
|
||||
reverse_proxy localhost:3000 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
# Flush immediately — critical for streaming
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────────────────
|
||||
log {
|
||||
output file /var/log/caddy/agent.manohargupta.com.log {
|
||||
roll_size 10MB
|
||||
roll_keep 5
|
||||
}
|
||||
format json
|
||||
}
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Tiger Bridge direct access — INTERNAL USE ONLY
|
||||
# The bridge listens on localhost:3456 and is NOT publicly exposed.
|
||||
# All bridge calls go through the Next.js dashboard routes.
|
||||
#
|
||||
# If you ever need to expose the bridge directly (debugging only),
|
||||
# uncomment and use the block below. Remember to set a strong token!
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# bridge.agent.manohargupta.com {
|
||||
# reverse_proxy localhost:3456
|
||||
# tls {
|
||||
# # email admin@manohargupta.com
|
||||
# }
|
||||
# }
|
||||
143
deploy/DEPLOY.md
Normal file
143
deploy/DEPLOY.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Tiger Bridge — Deployment Guide
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
│ HTTPS
|
||||
▼
|
||||
Caddy (agent.manohargupta.com)
|
||||
│ HTTP localhost:3000
|
||||
▼
|
||||
Next.js Dashboard ──── /api/tiger/* ────┐
|
||||
│ HTTP localhost:3456
|
||||
Tiger Bridge (Express)
|
||||
│ child_process.exec
|
||||
docker exec openshell-cluster-nemoclaw
|
||||
│
|
||||
kubectl exec -n openshell tiger
|
||||
│
|
||||
Tiger sandbox pod
|
||||
```
|
||||
|
||||
## Quick Start on the VPS
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
# Node.js 20+ (if not already installed)
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
|
||||
# Caddy
|
||||
sudo apt install -y caddy
|
||||
```
|
||||
|
||||
### 2. Clone and set up the project
|
||||
|
||||
```bash
|
||||
cd /root
|
||||
git clone <your-repo-url> clawd-dashboard
|
||||
cd clawd-dashboard
|
||||
```
|
||||
|
||||
### 3. Set up the Tiger Bridge
|
||||
|
||||
```bash
|
||||
cd bridge
|
||||
npm install
|
||||
|
||||
# Create bridge .env
|
||||
cp ../deploy/.env.example .env
|
||||
# Edit .env and set:
|
||||
# TIGER_BRIDGE_TOKEN=<run: openssl rand -hex 32>
|
||||
# ALLOWED_ORIGIN=https://agent.manohargupta.com
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 4. Set up the Dashboard
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
npm install
|
||||
|
||||
# Create dashboard .env.local
|
||||
cp ../deploy/.env.example .env.local
|
||||
# Edit .env.local and set:
|
||||
# TIGER_BRIDGE_TOKEN=<same token as bridge>
|
||||
# TIGER_BRIDGE_URL=http://localhost:3456
|
||||
nano .env.local
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 5. Configure Caddy
|
||||
|
||||
```bash
|
||||
# Generate the basicauth password hash
|
||||
caddy hash-password --plaintext "your-admin-password"
|
||||
# Copy the $2a$... output
|
||||
|
||||
# Edit the Caddyfile and paste your hash
|
||||
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
|
||||
sudo nano /etc/caddy/Caddyfile
|
||||
# Replace CHANGE_ME_HASH with your hash
|
||||
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
### 6. Start with PM2 (recommended)
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
|
||||
# Start the bridge
|
||||
cd /root/clawd-dashboard/bridge
|
||||
pm2 start "npm run dev" --name tiger-bridge
|
||||
|
||||
# Start the dashboard
|
||||
cd /root/clawd-dashboard/dashboard
|
||||
pm2 start "npm start" --name tiger-dashboard
|
||||
|
||||
# Save process list so they restart on reboot
|
||||
pm2 save
|
||||
pm2 startup # Follow the printed instructions
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# Check bridge is running
|
||||
curl http://localhost:3456/health
|
||||
# Should return: {"ok":true,"service":"tiger-bridge","ts":"..."}
|
||||
|
||||
# Check dashboard is running
|
||||
curl http://localhost:3000/api/tiger/status
|
||||
# Should return Tiger status JSON
|
||||
|
||||
# Check public HTTPS
|
||||
curl https://agent.manohargupta.com/api/tiger/status
|
||||
```
|
||||
|
||||
## Token Security
|
||||
|
||||
The `TIGER_BRIDGE_TOKEN` is the shared secret between the dashboard and bridge.
|
||||
|
||||
- Store it only in `.env.local` (dashboard) and `.env` (bridge)
|
||||
- Both files are in `.gitignore`
|
||||
- Never commit real tokens to git
|
||||
- Rotate with: `openssl rand -hex 32`
|
||||
|
||||
## Logs
|
||||
|
||||
```bash
|
||||
# Bridge logs
|
||||
pm2 logs tiger-bridge
|
||||
|
||||
# Dashboard logs
|
||||
pm2 logs tiger-dashboard
|
||||
|
||||
# Caddy access logs
|
||||
sudo tail -f /var/log/caddy/agent.manohargupta.com.log
|
||||
```
|
||||
Loading…
Add table
Reference in a new issue