[taskboard] migrate fleet console to nextjs
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
data/*.db
|
||||
data/*.db-wal
|
||||
data/*.db-shm
|
||||
|
||||
20
Dockerfile
20
Dockerfile
@@ -1,15 +1,23 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
FROM node:20-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8395
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
EXPOSE 8395
|
||||
|
||||
CMD ["npm", "start"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
200
README.md
200
README.md
@@ -1,138 +1,92 @@
|
||||
# OpenClaw Agent Fleet Dashboard
|
||||
# Claw Fleet Console
|
||||
|
||||
A real-time task coordination board for the OpenClaw agent fleet.
|
||||
`openclaw-taskboard` is now a `Next.js + React + Tailwind + shadcn-style` dashboard for the deployed Claw fleet.
|
||||
|
||||
## Features
|
||||
It tracks and visualizes:
|
||||
|
||||
- **Kanban Board**: Backlog → Todo → In Progress → Review → Done
|
||||
- **Agent Assignment**: Assign tasks to specific OpenClaw agents
|
||||
- **Priority Levels**: High, Medium, Low
|
||||
- **Tags**: Categorize tasks with tags
|
||||
- **Wiki Auto-Generation**: Completed tasks generate wiki documentation
|
||||
- **Real-time Updates**: WebSocket-powered live updates
|
||||
- **REST API**: For agent heartbeat integration
|
||||
- OpenClaw swarm agents on `ubuntu`
|
||||
- ZeroClaw host runtimes on `grizzley` and `ice`
|
||||
- shared task assignment across both families
|
||||
- wiki pages and architecture documentation rendered in the UI
|
||||
|
||||
## Quick Start
|
||||
## Stack
|
||||
|
||||
- Next.js App Router
|
||||
- React 19
|
||||
- Tailwind CSS
|
||||
- shadcn-style UI components under `components/ui`
|
||||
- SQLite task storage
|
||||
|
||||
## Key Pages
|
||||
|
||||
- `/tasks` - unified Kanban board
|
||||
- `/agents` - configured OpenClaw and ZeroClaw runtimes
|
||||
- `/architecture` - deployed architecture documentation with ASCII topology
|
||||
- `/wiki` - markdown-backed runbooks and generated docs
|
||||
- `/usage` - usage aggregates from the local tracking table
|
||||
|
||||
## Fleet Model
|
||||
|
||||
### OpenClaw
|
||||
|
||||
- Host: `ubuntu`
|
||||
- Service: `openclaw.service`
|
||||
- Runtime: `/srv/state/openclaw/current`
|
||||
- Config: `~/.openclaw/openclaw.json`
|
||||
- Channels:
|
||||
- Telegram DM allowlist
|
||||
- Homelab HQ forum topics
|
||||
- local gateway on `:18789`
|
||||
|
||||
### ZeroClaw
|
||||
|
||||
- Primary runtime: `grizzley`
|
||||
- Control-plane runtime: `ice`
|
||||
- Runtime roots:
|
||||
- `/srv/state/zeroclaw/current`
|
||||
- `/home/bear/.zeroclaw-admin`
|
||||
- Channels:
|
||||
- paired HTTP gateway access
|
||||
- Homelab-Ice forum topics
|
||||
- remote gateway routing from `ice`
|
||||
|
||||
## Important Environment Variables
|
||||
|
||||
- `DB_PATH`
|
||||
- `WIKI_DIR`
|
||||
- `AGENTS_DIR`
|
||||
- `SESSIONS_DIR`
|
||||
- `OPENCLAW_CONFIG`
|
||||
- `ZEROCLAW_PRIMARY_DIR`
|
||||
- `ZEROCLAW_CONTROL_DIR`
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd /home/bear/homelab/ubuntu/taskboard
|
||||
docker compose up -d --build
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Access at: https://agentdash.local.tophermayor.com
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Tasks
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/tasks` | List all tasks (filter: `?assignee=ubuntu&status=todo`) |
|
||||
| GET | `/api/tasks/:id` | Get single task |
|
||||
| POST | `/api/tasks` | Create task |
|
||||
| PATCH | `/api/tasks/:id` | Update task |
|
||||
| POST | `/api/tasks/:id/complete` | Complete task (creates wiki) |
|
||||
| DELETE | `/api/tasks/:id` | Delete task |
|
||||
|
||||
### Wiki
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/wiki` | List wiki pages |
|
||||
| GET | `/api/wiki/:filename` | Get wiki page content |
|
||||
|
||||
### Agent Heartbeat
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/heartbeat/:agent` | Get pending tasks for agent |
|
||||
|
||||
## Agent Integration
|
||||
|
||||
Add to agent's HEARTBEAT.md:
|
||||
## Production Build
|
||||
|
||||
```bash
|
||||
# Check for assigned tasks
|
||||
TASKS=$(curl -s http://192.168.50.61:8395/api/heartbeat/ubuntu)
|
||||
|
||||
# If tasks pending, process them
|
||||
if echo "$TASKS" | jq -e '.pending_tasks > 0' > /dev/null; then
|
||||
echo "Processing assigned tasks..."
|
||||
# Process tasks...
|
||||
fi
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Example: Create Task via API
|
||||
## Deployment Shape On Ubuntu
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8395/api/tasks \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Restart PostgreSQL container",
|
||||
"description": "The postgres-shared container needs a restart for config changes",
|
||||
"assignee": "ubuntu",
|
||||
"priority": "high",
|
||||
"tags": ["docker", "database"]
|
||||
}'
|
||||
```
|
||||
- app source checkout: `/srv/apps/openclaw-taskboard/current`
|
||||
- taskboard data: `/srv/state/openclaw-taskboard/data`
|
||||
- OpenClaw mounts:
|
||||
- `/home/bear/.openclaw/agents`
|
||||
- `/home/bear/.openclaw/openclaw.json`
|
||||
- `/home/bear/.openclaw/workspace/wiki`
|
||||
- ZeroClaw mounts:
|
||||
- `/srv/state/zeroclaw/current`
|
||||
- `/home/bear/.zeroclaw-admin`
|
||||
|
||||
## Example: Complete Task with Wiki
|
||||
## Notes
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8395/api/tasks/TASK_ID/complete \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"implementation_details": "Restarted the container using docker restart postgres-shared. Verified connections working.",
|
||||
"files_changed": ["/home/bear/homelab/ubuntu/postgres/docker-compose.yml"]
|
||||
}'
|
||||
```
|
||||
|
||||
## Task Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "string",
|
||||
"description": "string",
|
||||
"assignee": "ubuntu|pve|truenas|grizzley|ice|panda|zeroclaw|docs",
|
||||
"status": "backlog|todo|in_progress|review|done",
|
||||
"priority": "high|medium|low",
|
||||
"tags": ["array", "of", "tags"],
|
||||
"created_at": "ISO timestamp",
|
||||
"updated_at": "ISO timestamp",
|
||||
"completed_at": "ISO timestamp or null",
|
||||
"wiki_path": "filename.md or null"
|
||||
}
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
taskboard/
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── README.md
|
||||
├── package.json
|
||||
├── server.js
|
||||
├── client/
|
||||
│ └── index.html
|
||||
├── public/
|
||||
│ ├── index.html
|
||||
│ └── app.js
|
||||
├── data/
|
||||
│ └── tasks.db (SQLite)
|
||||
└── wiki/
|
||||
└── (auto-generated wiki pages)
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
The taskboard is deployed on the ubuntu host at:
|
||||
- **URL**: https://agentdash.local.tophermayor.com
|
||||
- **Port**: 8395
|
||||
- **Container**: openclaw-taskboard
|
||||
- **Traefik Route**: /home/bear/homelab/ubuntu/traefik/config/dynamic/taskboard.yml
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
- The UI intentionally treats OpenClaw and ZeroClaw as separate families with different runtime and channel models.
|
||||
- `ice` ZeroClaw remains tied to host-local secret/encryption state; the dashboard reads that runtime but does not attempt to rewrite it.
|
||||
|
||||
9
app/agents/page.tsx
Normal file
9
app/agents/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AgentsClient } from "@/components/agents-client";
|
||||
import { listFleetAgents } from "@/lib/agents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AgentsPage() {
|
||||
const agents = await listFleetAgents();
|
||||
return <AgentsClient agents={agents} />;
|
||||
}
|
||||
29
app/api/agents/[slug]/assign/route.ts
Normal file
29
app/api/agents/[slug]/assign/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { listFleetAgents } from "@/lib/agents";
|
||||
import { findTask, updateTask } from "@/lib/tasks";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
const { slug } = await params;
|
||||
const payload = (await request.json()) as { taskId?: number };
|
||||
if (!payload.taskId) {
|
||||
return NextResponse.json({ error: "taskId_is_required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const agents = await listFleetAgents();
|
||||
const agent = agents.find((entry) => entry.slug === slug);
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: "agent_not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const existing = await findTask(payload.taskId);
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "task_not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updated = await updateTask(existing.id, { assignee: agent.assignmentKey });
|
||||
return NextResponse.json({ success: true, task: updated });
|
||||
}
|
||||
7
app/api/agents/route.ts
Normal file
7
app/api/agents/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { listFleetAgents } from "@/lib/agents";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await listFleetAgents());
|
||||
}
|
||||
7
app/api/architecture/route.ts
Normal file
7
app/api/architecture/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { listArchitecture } from "@/lib/agents";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await listArchitecture());
|
||||
}
|
||||
35
app/api/tasks/[id]/route.ts
Normal file
35
app/api/tasks/[id]/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { updateTask, validateTaskPayload } from "@/lib/tasks";
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return NextResponse.json({ error: "invalid_task_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = (await request.json()) as Record<string, unknown>;
|
||||
const errors = validateTaskPayload(payload as never, true);
|
||||
if (errors.length > 0) {
|
||||
return NextResponse.json({ error: "validation_error", details: errors }, { status: 400 });
|
||||
}
|
||||
|
||||
const task = await updateTask(numericId, {
|
||||
title: typeof payload.title === "string" ? payload.title : undefined,
|
||||
description: typeof payload.description === "string" ? payload.description : undefined,
|
||||
assignee: typeof payload.assignee === "string" ? payload.assignee : undefined,
|
||||
priority: payload.priority as never,
|
||||
status: payload.status as never,
|
||||
tags: Array.isArray(payload.tags) ? payload.tags.filter((tag) => typeof tag === "string") : undefined,
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json({ error: "task_not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(task);
|
||||
}
|
||||
26
app/api/tasks/route.ts
Normal file
26
app/api/tasks/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createTask, listTasks, validateTaskPayload } from "@/lib/tasks";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await listTasks());
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = (await request.json()) as Record<string, unknown>;
|
||||
const errors = validateTaskPayload(payload as never, false);
|
||||
if (errors.length > 0) {
|
||||
return NextResponse.json({ error: "validation_error", details: errors }, { status: 400 });
|
||||
}
|
||||
|
||||
const task = await createTask({
|
||||
title: String(payload.title),
|
||||
description: typeof payload.description === "string" ? payload.description : "",
|
||||
assignee: typeof payload.assignee === "string" ? payload.assignee : "",
|
||||
priority: payload.priority as never,
|
||||
status: (payload.status as never) || "Backlog",
|
||||
tags: Array.isArray(payload.tags) ? payload.tags.filter((tag) => typeof tag === "string") : [],
|
||||
});
|
||||
|
||||
return NextResponse.json(task, { status: 201 });
|
||||
}
|
||||
37
app/api/usage/stats/route.ts
Normal file
37
app/api/usage/stats/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { all } from "@/lib/db";
|
||||
|
||||
type UsageRow = {
|
||||
agent: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
tokens_used: number;
|
||||
cost_estimate: number;
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const rows = await all<UsageRow>("SELECT * FROM usage_tracking ORDER BY timestamp DESC");
|
||||
const result = rows.reduce(
|
||||
(accumulator, row) => {
|
||||
accumulator.totalRequests += 1;
|
||||
accumulator.totalTokens += row.tokens_used || 0;
|
||||
accumulator.totalCost += row.cost_estimate || 0;
|
||||
if (!accumulator.byAgent[row.agent]) {
|
||||
accumulator.byAgent[row.agent] = { requests: 0, tokens: 0, cost: 0 };
|
||||
}
|
||||
accumulator.byAgent[row.agent].requests += 1;
|
||||
accumulator.byAgent[row.agent].tokens += row.tokens_used || 0;
|
||||
accumulator.byAgent[row.agent].cost += row.cost_estimate || 0;
|
||||
return accumulator;
|
||||
},
|
||||
{
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
byAgent: {} as Record<string, { requests: number; tokens: number; cost: number }>,
|
||||
},
|
||||
);
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
38
app/api/wiki/[filename]/route.ts
Normal file
38
app/api/wiki/[filename]/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { deleteWikiPage, readWikiPage, updateWikiPage } from "@/lib/wiki";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ filename: string }> },
|
||||
) {
|
||||
const { filename } = await params;
|
||||
const page = readWikiPage(filename);
|
||||
if (!page) {
|
||||
return NextResponse.json({ error: "wiki_page_not_found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(page);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ filename: string }> },
|
||||
) {
|
||||
const { filename } = await params;
|
||||
const payload = (await request.json()) as { content?: string };
|
||||
if (typeof payload.content !== "string") {
|
||||
return NextResponse.json({ error: "content_is_required" }, { status: 400 });
|
||||
}
|
||||
|
||||
updateWikiPage(filename, payload.content);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ filename: string }> },
|
||||
) {
|
||||
const { filename } = await params;
|
||||
deleteWikiPage(filename);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
17
app/api/wiki/route.ts
Normal file
17
app/api/wiki/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createWikiPage, listWikiPages } from "@/lib/wiki";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(listWikiPages());
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = (await request.json()) as { title?: string };
|
||||
if (!payload.title) {
|
||||
return NextResponse.json({ error: "title_is_required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filename = createWikiPage(payload.title);
|
||||
return NextResponse.json({ filename, success: true }, { status: 201 });
|
||||
}
|
||||
9
app/architecture/page.tsx
Normal file
9
app/architecture/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ArchitectureView } from "@/components/architecture-view";
|
||||
import { listArchitecture } from "@/lib/agents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ArchitecturePage() {
|
||||
const architecture = await listArchitecture();
|
||||
return <ArchitectureView architecture={architecture} />;
|
||||
}
|
||||
21
app/gitea/page.tsx
Normal file
21
app/gitea/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function GiteaPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gitea Integration</CardTitle>
|
||||
<CardDescription>
|
||||
The Next.js migration keeps the fleet UI focused on operations. Existing Gitea automation can be reattached through dedicated API routes or direct repo links.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-slate-300">
|
||||
<p>Primary repo: `TopherMayor/openclaw-taskboard`</p>
|
||||
<p>Infra wrapper: `TopherMayor/homelabagentroot` under `homelab/ubuntu/taskboard/`</p>
|
||||
<p>Use the architecture and agents pages to verify deployed fleet state before issuing repo automation from the host agents.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
38
app/globals.css
Normal file
38
app/globals.css
Normal file
@@ -0,0 +1,38 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: 220 44% 10%;
|
||||
--foreground: 210 40% 96%;
|
||||
--card: 222 47% 13%;
|
||||
--card-foreground: 210 40% 96%;
|
||||
--primary: 188 95% 48%;
|
||||
--primary-foreground: 221 39% 11%;
|
||||
--secondary: 221 28% 20%;
|
||||
--secondary-foreground: 210 40% 96%;
|
||||
--muted: 223 27% 18%;
|
||||
--muted-foreground: 215 20% 74%;
|
||||
--accent: 35 94% 56%;
|
||||
--accent-foreground: 221 39% 11%;
|
||||
--border: 218 22% 26%;
|
||||
--input: 218 22% 26%;
|
||||
--ring: 188 95% 48%;
|
||||
--radius: 1rem;
|
||||
--font-sans: "Space Grotesk", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
}
|
||||
35
app/layout.tsx
Normal file
35
app/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import "@/app/globals.css";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Claw Fleet Console",
|
||||
description: "OpenClaw and ZeroClaw fleet dashboard",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const headerList = await headers();
|
||||
const pathname = headerList.get("x-pathname") || "/tasks";
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<AppShell pathname={pathname}>{children}</AppShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/tasks");
|
||||
}
|
||||
10
app/tasks/page.tsx
Normal file
10
app/tasks/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { TasksClient } from "@/components/tasks-client";
|
||||
import { listFleetAgents } from "@/lib/agents";
|
||||
import { listTasks } from "@/lib/tasks";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TasksPage() {
|
||||
const [tasks, agents] = await Promise.all([listTasks(), listFleetAgents()]);
|
||||
return <TasksClient initialTasks={tasks} agents={agents} />;
|
||||
}
|
||||
39
app/usage/page.tsx
Normal file
39
app/usage/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { all } from "@/lib/db";
|
||||
import { UsageView } from "@/components/usage-view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type UsageRow = {
|
||||
agent: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
tokens_used: number;
|
||||
cost_estimate: number;
|
||||
};
|
||||
|
||||
export default async function UsagePage() {
|
||||
const rows = await all<UsageRow>("SELECT * FROM usage_tracking ORDER BY timestamp DESC");
|
||||
|
||||
const stats = rows.reduce(
|
||||
(accumulator, row) => {
|
||||
accumulator.totalRequests += 1;
|
||||
accumulator.totalTokens += row.tokens_used || 0;
|
||||
accumulator.totalCost += row.cost_estimate || 0;
|
||||
if (!accumulator.byAgent[row.agent]) {
|
||||
accumulator.byAgent[row.agent] = { requests: 0, tokens: 0, cost: 0 };
|
||||
}
|
||||
accumulator.byAgent[row.agent].requests += 1;
|
||||
accumulator.byAgent[row.agent].tokens += row.tokens_used || 0;
|
||||
accumulator.byAgent[row.agent].cost += row.cost_estimate || 0;
|
||||
return accumulator;
|
||||
},
|
||||
{
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
byAgent: {} as Record<string, { requests: number; tokens: number; cost: number }>,
|
||||
},
|
||||
);
|
||||
|
||||
return <UsageView stats={stats} />;
|
||||
}
|
||||
23
app/wiki/page.tsx
Normal file
23
app/wiki/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { WikiView } from "@/components/wiki-view";
|
||||
import { listWikiPages, readWikiPage } from "@/lib/wiki";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function WikiPage() {
|
||||
const pages = listWikiPages();
|
||||
const firstPage = pages[0] ? readWikiPage(pages[0].filename) : null;
|
||||
|
||||
return (
|
||||
<WikiView
|
||||
pages={pages}
|
||||
initialPageContent={
|
||||
firstPage
|
||||
? {
|
||||
title: firstPage.metadata.title,
|
||||
content: firstPage.content,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
components.json
Normal file
16
components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
111
components/agents-client.tsx
Normal file
111
components/agents-client.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import type { FleetAgent } from "@/lib/types";
|
||||
|
||||
export function AgentsClient({ agents }: { agents: FleetAgent[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [family, setFamily] = useState("");
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
return agents.filter((agent) => {
|
||||
const matchesQuery =
|
||||
query.length === 0 ||
|
||||
agent.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
agent.host.toLowerCase().includes(query.toLowerCase()) ||
|
||||
agent.role.toLowerCase().includes(query.toLowerCase());
|
||||
const matchesFamily = family.length === 0 || agent.family === family;
|
||||
return matchesQuery && matchesFamily;
|
||||
});
|
||||
}, [agents, family, query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configured Agent Runtimes</CardTitle>
|
||||
<CardDescription>
|
||||
OpenClaw swarm members and ZeroClaw host runtimes are shown from the deployed fleet model.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-[1fr_220px]">
|
||||
<Input placeholder="Search by name, host, or role" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<Select value={family} onChange={(event) => setFamily(event.target.value)}>
|
||||
<option value="">All families</option>
|
||||
<option value="openclaw">OpenClaw</option>
|
||||
<option value="zeroclaw">ZeroClaw</option>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{filteredAgents.map((agent) => (
|
||||
<Card key={agent.slug}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>{agent.emoji}</span>
|
||||
<span>{agent.name}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>{agent.role}</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant={agent.family === "openclaw" ? "default" : "success"}>{agent.family}</Badge>
|
||||
<Badge variant="secondary">{agent.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<dl className="grid gap-2 text-sm text-slate-300 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Host</dt>
|
||||
<dd>{agent.host}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Model</dt>
|
||||
<dd>{agent.model || "Host-local/runtime-defined"}</dd>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Runtime</dt>
|
||||
<dd className="font-mono text-xs text-cyan-100">{agent.runtimePath}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Workload</dt>
|
||||
<dd>{agent.workload} active</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Current task</dt>
|
||||
<dd>{agent.currentTask || "No heartbeat task"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Channels</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agent.channels.map((channel) => (
|
||||
<Badge key={`${agent.slug}-${channel.label}`} variant="outline">
|
||||
{channel.label}: {channel.value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Tools</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agent.tools.length ? agent.tools.map((tool) => <Badge key={tool} variant="secondary">{tool}</Badge>) : <span className="text-sm text-slate-400">No parsed tools.</span>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
components/app-shell.tsx
Normal file
67
components/app-shell.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import Link from "next/link";
|
||||
import { Network, NotebookTabs, PanelsTopLeft, ScrollText, Settings2, UsersRound } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/tasks", label: "Tasks", icon: PanelsTopLeft },
|
||||
{ href: "/agents", label: "Agents", icon: UsersRound },
|
||||
{ href: "/architecture", label: "Architecture", icon: Network },
|
||||
{ href: "/wiki", label: "Wiki", icon: NotebookTabs },
|
||||
{ href: "/usage", label: "Usage", icon: ScrollText },
|
||||
{ href: "/gitea", label: "Gitea", icon: Settings2 },
|
||||
];
|
||||
|
||||
export function AppShell({
|
||||
pathname,
|
||||
children,
|
||||
}: {
|
||||
pathname: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(34,211,238,0.18),_transparent_30%),radial-gradient(circle_at_top_right,_rgba(245,158,11,0.14),_transparent_28%),linear-gradient(180deg,#07111f_0%,#091321_44%,#0f172a_100%)] text-foreground">
|
||||
<header className="border-b border-white/10 bg-slate-950/60 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
|
||||
<div>
|
||||
<p className="font-mono text-xs uppercase tracking-[0.3em] text-cyan-300/80">
|
||||
OpenClaw Taskboard
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-white">
|
||||
Claw Fleet Console
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-slate-300">
|
||||
Unified operations view for OpenClaw orchestration, ZeroClaw host runtimes, and deployed architecture.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto grid max-w-7xl gap-6 px-6 py-8 lg:grid-cols-[240px_minmax(0,1fr)]">
|
||||
<nav className="space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl border px-4 py-3 text-sm transition",
|
||||
isActive
|
||||
? "border-cyan-300/30 bg-cyan-300/10 text-cyan-100 shadow-panel"
|
||||
: "border-white/10 bg-slate-950/35 text-slate-300 hover:border-white/20 hover:bg-white/5 hover:text-white",
|
||||
)}
|
||||
href={item.href}
|
||||
key={item.href}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
components/architecture-view.tsx
Normal file
84
components/architecture-view.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { ArchitectureDocument } from "@/lib/types";
|
||||
|
||||
export function ArchitectureView({ architecture }: { architecture: ArchitectureDocument }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{architecture.title}</CardTitle>
|
||||
<CardDescription>Generated from the deployed fleet model and tracked channel/runtime definitions.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{architecture.overview.map((line) => (
|
||||
<Badge key={line} variant="secondary">
|
||||
{line}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<pre className="rounded-xl border border-white/10 bg-slate-950/60 p-4 font-mono text-xs text-cyan-100">
|
||||
{architecture.topologyDiagram}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{architecture.sections.map((section) => (
|
||||
<Card key={section.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{section.title}</CardTitle>
|
||||
<CardDescription>{section.summary}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="space-y-4">
|
||||
<pre className="rounded-xl border border-white/10 bg-slate-950/60 p-4 font-mono text-xs text-cyan-100">
|
||||
{section.diagram}
|
||||
</pre>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{section.configuredAgents.map((agentName) => (
|
||||
<Badge key={agentName}>{agentName}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Runtime</p>
|
||||
<div className="space-y-2">
|
||||
{section.runtime.map((entry) => (
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 p-3 text-sm" key={`${section.id}-${entry.label}`}>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">{entry.label}</p>
|
||||
<p>{entry.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Channels</p>
|
||||
<div className="space-y-2">
|
||||
{section.channels.map((entry) => (
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 p-3 text-sm" key={`${section.id}-${entry.label}`}>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">{entry.label}</p>
|
||||
<p>{entry.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Notes</p>
|
||||
<ul className="space-y-2 text-sm text-slate-300">
|
||||
{section.notes.map((note) => (
|
||||
<li key={note}>- {note}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
components/tasks-client.tsx
Normal file
178
components/tasks-client.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { FleetAgent, TaskPriority, TaskRecord, TaskStatus } from "@/lib/types";
|
||||
|
||||
const COLUMNS: TaskStatus[] = ["Backlog", "Todo", "In Progress", "Review", "Done"];
|
||||
const PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"];
|
||||
|
||||
export function TasksClient({
|
||||
initialTasks,
|
||||
agents,
|
||||
}: {
|
||||
initialTasks: TaskRecord[];
|
||||
agents: FleetAgent[];
|
||||
}) {
|
||||
const [tasks, setTasks] = useState(initialTasks);
|
||||
const [formState, setFormState] = useState({
|
||||
title: "",
|
||||
description: "",
|
||||
assignee: "",
|
||||
priority: "Medium" as TaskPriority,
|
||||
tags: "",
|
||||
});
|
||||
|
||||
async function refreshTasks() {
|
||||
const response = await fetch("/api/tasks");
|
||||
const nextTasks = (await response.json()) as TaskRecord[];
|
||||
setTasks(nextTasks);
|
||||
}
|
||||
|
||||
async function createTask(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
await fetch("/api/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: formState.title,
|
||||
description: formState.description,
|
||||
assignee: formState.assignee,
|
||||
priority: formState.priority,
|
||||
tags: formState.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
});
|
||||
|
||||
setFormState({
|
||||
title: "",
|
||||
description: "",
|
||||
assignee: "",
|
||||
priority: "Medium",
|
||||
tags: "",
|
||||
});
|
||||
await refreshTasks();
|
||||
}
|
||||
|
||||
async function moveToDone(taskId: number) {
|
||||
await fetch(`/api/tasks/${taskId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "Done" }),
|
||||
});
|
||||
await refreshTasks();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Unified Task Intake</CardTitle>
|
||||
<CardDescription>
|
||||
Assign work to OpenClaw swarm agents or ZeroClaw host runtimes from a single board.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-3 md:grid-cols-2" onSubmit={createTask}>
|
||||
<Input
|
||||
placeholder="Task title"
|
||||
required
|
||||
value={formState.title}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))}
|
||||
/>
|
||||
<Select
|
||||
value={formState.assignee}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, assignee: event.target.value }))}
|
||||
>
|
||||
<option value="">Select agent</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.slug} value={agent.assignmentKey}>
|
||||
{agent.name} • {agent.family} • {agent.host}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={formState.priority}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({ ...current, priority: event.target.value as TaskPriority }))
|
||||
}
|
||||
>
|
||||
{PRIORITIES.map((priority) => (
|
||||
<option key={priority} value={priority}>
|
||||
{priority}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="Tags (comma-separated)"
|
||||
value={formState.tags}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, tags: event.target.value }))}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<Textarea
|
||||
placeholder="Describe the task, host target, and expected outcome"
|
||||
value={formState.description}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({ ...current, description: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit">Create Task</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-5">
|
||||
{COLUMNS.map((column) => {
|
||||
const columnTasks = tasks.filter((task) => task.status === column);
|
||||
return (
|
||||
<Card className="min-h-[420px]" key={column}>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>{column}</span>
|
||||
<Badge variant="secondary">{columnTasks.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{columnTasks.map((task) => (
|
||||
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4" key={task.id}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="font-medium text-white">{task.title}</h3>
|
||||
<Badge variant={task.priority === "Critical" ? "warning" : "outline"}>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-300">{task.description || "No description"}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
<Badge variant="secondary">{task.assignee || "Unassigned"}</Badge>
|
||||
{task.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{task.status !== "Done" ? (
|
||||
<Button className="mt-4 w-full" size="sm" variant="outline" onClick={() => moveToDone(task.id)}>
|
||||
Mark Done
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
components/ui/badge.tsx
Normal file
29
components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold tracking-wide",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary/15 text-primary",
|
||||
secondary: "border-border bg-secondary/70 text-secondary-foreground",
|
||||
outline: "border-border/70 text-foreground",
|
||||
success: "border-emerald-400/30 bg-emerald-400/10 text-emerald-300",
|
||||
warning: "border-amber-400/30 bg-amber-400/10 text-amber-300",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
41
components/ui/button.tsx
Normal file
41
components/ui/button.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "border border-border bg-transparent hover:bg-secondary/40",
|
||||
ghost: "hover:bg-secondary/40",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
46
components/ui/card.tsx
Normal file
46
components/ui/card.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border/70 bg-card/90 text-card-foreground shadow-panel backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-2 p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn("text-lg font-semibold tracking-tight", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
17
components/ui/input.tsx
Normal file
17
components/ui/input.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<input
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background/70 px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
20
components/ui/select.tsx
Normal file
20
components/ui/select.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Select = React.forwardRef<
|
||||
HTMLSelectElement,
|
||||
React.SelectHTMLAttributes<HTMLSelectElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<select
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background/70 px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
));
|
||||
Select.displayName = "Select";
|
||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[100px] w-full rounded-md border border-input bg-background/70 px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Textarea.displayName = "Textarea";
|
||||
58
components/usage-view.tsx
Normal file
58
components/usage-view.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function UsageView({
|
||||
stats,
|
||||
}: {
|
||||
stats: {
|
||||
totalRequests: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
byAgent: Record<string, { requests: number; tokens: number; cost: number }>;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Total Requests</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-semibold">{stats.totalRequests}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Total Tokens</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-semibold">{stats.totalTokens}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Estimated Cost</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-semibold">${stats.totalCost.toFixed(2)}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>By Agent</CardTitle>
|
||||
<CardDescription>Aggregated from the taskboard usage tracking table.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.entries(stats.byAgent).map(([agent, value]) => (
|
||||
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-slate-950/40 p-4" key={agent}>
|
||||
<div>
|
||||
<p className="font-medium">{agent}</p>
|
||||
<p className="text-sm text-slate-400">{value.tokens} tokens</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="secondary">{value.requests} req</Badge>
|
||||
<Badge variant="outline">${value.cost.toFixed(2)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
components/wiki-view.tsx
Normal file
60
components/wiki-view.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { WikiPageSummary } from "@/lib/types";
|
||||
|
||||
export function WikiView({
|
||||
pages,
|
||||
initialPageContent,
|
||||
}: {
|
||||
pages: WikiPageSummary[];
|
||||
initialPageContent: { title: string; content: string } | null;
|
||||
}) {
|
||||
const [activeTitle, setActiveTitle] = useState(initialPageContent?.title || "Select a page");
|
||||
const [activeContent, setActiveContent] = useState(initialPageContent?.content || "");
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const filteredPages = pages.filter((page) =>
|
||||
filter.length === 0 ? true : page.title.toLowerCase().includes(filter.toLowerCase()),
|
||||
);
|
||||
|
||||
async function openPage(filename: string) {
|
||||
const response = await fetch(`/api/wiki/${filename}`);
|
||||
const page = await response.json();
|
||||
setActiveTitle(page.metadata.title);
|
||||
setActiveContent(page.content);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Wiki Pages</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input placeholder="Search wiki" value={filter} onChange={(event) => setFilter(event.target.value)} />
|
||||
<div className="space-y-2">
|
||||
{filteredPages.map((page) => (
|
||||
<Button className="w-full justify-start" key={page.filename} variant="ghost" onClick={() => openPage(page.filename)}>
|
||||
{page.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{activeTitle}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-invert max-w-none prose-pre:rounded-xl prose-pre:border prose-pre:border-white/10 prose-pre:bg-slate-950/70">
|
||||
{activeContent ? <ReactMarkdown>{activeContent}</ReactMarkdown> : <p className="text-slate-400">Select a wiki page to view it.</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
268
lib/agents.ts
Normal file
268
lib/agents.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
ARCHITECTURE_DOCUMENT,
|
||||
OPENCLAW_AGENTS_DIR,
|
||||
OPENCLAW_CONFIG_PATH,
|
||||
ZEROCLAW_CONTROL_DIR,
|
||||
ZEROCLAW_PRIMARY_DIR,
|
||||
} from "@/lib/fleet-config";
|
||||
import { all } from "@/lib/db";
|
||||
import { normalizeTask } from "@/lib/tasks";
|
||||
import type { AgentStatus, FleetAgent, TaskRecord } from "@/lib/types";
|
||||
|
||||
type OpenClawAgentConfig = {
|
||||
id: string;
|
||||
name?: string;
|
||||
model?: { primary?: string };
|
||||
identity?: { name?: string; emoji?: string; theme?: string };
|
||||
subagents?: { allowAgents?: string[] };
|
||||
};
|
||||
|
||||
type OpenClawConfigShape = {
|
||||
agents?: { list?: OpenClawAgentConfig[] };
|
||||
channels?: {
|
||||
telegram?: {
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
topics?: Record<string, { systemPrompt?: string }>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function readTextFile(filePath: string) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function parseBulletValues(content: string) {
|
||||
return content
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("- "))
|
||||
.map((line) => line.replace(/^- /, "").replace(/`/g, "").trim());
|
||||
}
|
||||
|
||||
function parseRoleFromAgentsMd(content: string) {
|
||||
const identityMatch = content.match(/- Scope:\s*(.+)/);
|
||||
if (identityMatch) {
|
||||
return identityMatch[1].trim();
|
||||
}
|
||||
return "Host-scoped agent";
|
||||
}
|
||||
|
||||
function parseResponsibilities(content: string) {
|
||||
const sectionMatch = content.match(/## Responsibilities([\s\S]*?)(##|$)/);
|
||||
return sectionMatch ? parseBulletValues(sectionMatch[1]) : [];
|
||||
}
|
||||
|
||||
function readWorkspaceAgent(agentRoot: string, fallbackName: string) {
|
||||
const workspaceRoot = path.join(agentRoot, "workspace");
|
||||
const agentsMd = readTextFile(path.join(workspaceRoot, "AGENTS.md"));
|
||||
const toolsMd = readTextFile(path.join(workspaceRoot, "TOOLS.md"));
|
||||
const identityMd = readTextFile(path.join(workspaceRoot, "IDENTITY.md"));
|
||||
const heartbeatMd = readTextFile(path.join(workspaceRoot, "HEARTBEAT.md"));
|
||||
|
||||
const tools = parseBulletValues(toolsMd);
|
||||
const capabilities = parseResponsibilities(agentsMd);
|
||||
const currentTaskMatch = heartbeatMd.match(/Current Task:\s*(.+)/i);
|
||||
|
||||
return {
|
||||
files: ["AGENTS.md", "TOOLS.md", "IDENTITY.md"].filter((fileName) =>
|
||||
fs.existsSync(path.join(workspaceRoot, fileName)),
|
||||
),
|
||||
tools,
|
||||
capabilities,
|
||||
currentTask: currentTaskMatch ? currentTaskMatch[1].trim() : null,
|
||||
role: parseRoleFromAgentsMd(agentsMd),
|
||||
noteValues: parseBulletValues(identityMd),
|
||||
workspaceRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function readOpenClawConfig(): OpenClawConfigShape {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf8")) as OpenClawConfigShape;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenClawChannels(agentId: string, config: OpenClawConfigShape) {
|
||||
const summaries: { label: string; value: string }[] = [
|
||||
{ label: "Family", value: "OpenClaw telegram + gateway" },
|
||||
];
|
||||
|
||||
const topicGroups = config.channels?.telegram?.groups?.["-1003809447066"]?.topics;
|
||||
if (!topicGroups) {
|
||||
return summaries;
|
||||
}
|
||||
|
||||
const topicEntries = Object.entries(topicGroups).filter(([, topic]) => {
|
||||
const prompt = topic.systemPrompt || "";
|
||||
return prompt.toLowerCase().includes(agentId.toLowerCase()) || agentId === "main";
|
||||
});
|
||||
|
||||
if (topicEntries.length === 0 && agentId === "main") {
|
||||
summaries.push({ label: "Forum", value: "Homelab HQ default route" });
|
||||
return summaries;
|
||||
}
|
||||
|
||||
topicEntries.forEach(([topicId]) => {
|
||||
summaries.push({ label: "Topic", value: `Homelab HQ topic ${topicId}` });
|
||||
});
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async function fetchTaskBuckets(aliases: string[]) {
|
||||
const placeholders = aliases.map(() => "?").join(", ");
|
||||
const activeRows = await all<Omit<TaskRecord, "tags"> & { tags: string }>(
|
||||
`SELECT * FROM tasks WHERE assignee IN (${placeholders}) AND status IN ('Todo', 'In Progress', 'Review')
|
||||
ORDER BY priority DESC, created_at ASC`,
|
||||
aliases,
|
||||
);
|
||||
const completedRows = await all<Omit<TaskRecord, "tags"> & { tags: string }>(
|
||||
`SELECT * FROM tasks WHERE assignee IN (${placeholders}) AND status = 'Done'
|
||||
ORDER BY completed_at DESC LIMIT 5`,
|
||||
aliases,
|
||||
);
|
||||
|
||||
return {
|
||||
activeTasks: activeRows.map(normalizeTask),
|
||||
completedTasks: completedRows.map(normalizeTask),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildOpenClawAgents() {
|
||||
const config = readOpenClawConfig();
|
||||
const agents = config.agents?.list || [];
|
||||
|
||||
return Promise.all(
|
||||
agents.map(async (agentConfig) => {
|
||||
const agentRoot = path.join(OPENCLAW_AGENTS_DIR, agentConfig.id);
|
||||
const workspace = readWorkspaceAgent(agentRoot, agentConfig.identity?.name || agentConfig.id);
|
||||
const aliases = [
|
||||
agentConfig.id,
|
||||
agentConfig.identity?.name || agentConfig.name || agentConfig.id,
|
||||
];
|
||||
const taskBuckets = await fetchTaskBuckets(aliases);
|
||||
|
||||
return {
|
||||
slug: agentConfig.id,
|
||||
assignmentKey: agentConfig.id,
|
||||
aliases,
|
||||
family: "openclaw",
|
||||
name: agentConfig.identity?.name || agentConfig.name || agentConfig.id,
|
||||
host: "ubuntu",
|
||||
role: agentConfig.identity?.theme || workspace.role,
|
||||
runtimePath: workspace.workspaceRoot || OPENCLAW_AGENTS_DIR,
|
||||
configPath: OPENCLAW_CONFIG_PATH,
|
||||
model: agentConfig.model?.primary || null,
|
||||
emoji: agentConfig.identity?.emoji || "🦞",
|
||||
channels: getOpenClawChannels(agentConfig.id, config),
|
||||
tools: workspace.tools,
|
||||
capabilities: workspace.capabilities,
|
||||
files: workspace.files,
|
||||
status: deriveStatus(taskBuckets.activeTasks.length),
|
||||
workload: taskBuckets.activeTasks.length,
|
||||
activeTasks: taskBuckets.activeTasks,
|
||||
completedTasks: taskBuckets.completedTasks,
|
||||
currentTask: workspace.currentTask,
|
||||
notes: workspace.noteValues,
|
||||
} satisfies FleetAgent;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function buildZeroClawAgents() {
|
||||
const configuredAgents = [
|
||||
{
|
||||
slug: "grizzley-zeroclaw",
|
||||
assignmentKey: "grizzley-zeroclaw",
|
||||
aliases: ["grizzley-zeroclaw", "ZeroClaw Grizzley", "grizzley"],
|
||||
name: "ZeroClaw Grizzley",
|
||||
host: "grizzley",
|
||||
role: "Edge host operator for grizzley",
|
||||
runtimePath: ZEROCLAW_PRIMARY_DIR,
|
||||
configPath: path.join(ZEROCLAW_PRIMARY_DIR, "config.toml"),
|
||||
model: "glm-4.7",
|
||||
emoji: "🛰️",
|
||||
channels: [
|
||||
{ label: "Gateway", value: "HTTP gateway :3000" },
|
||||
{ label: "Access", value: "paired remote gateway via ice" },
|
||||
],
|
||||
notes: ["Host-scoped runtime for Traefik, OpenCode, and local services."],
|
||||
},
|
||||
{
|
||||
slug: "ice-zeroclaw",
|
||||
assignmentKey: "ice-zeroclaw",
|
||||
aliases: ["ice-zeroclaw", "ZeroClaw Ice", "ZeroClaw Admin", "ice"],
|
||||
name: "ZeroClaw Ice",
|
||||
host: "ice",
|
||||
role: "Control-plane operator for ice",
|
||||
runtimePath: ZEROCLAW_CONTROL_DIR,
|
||||
configPath: path.join(ZEROCLAW_CONTROL_DIR, "config.toml"),
|
||||
model: "glm-5",
|
||||
emoji: "🧊",
|
||||
channels: [
|
||||
{ label: "Telegram", value: "Homelab-Ice topics 11-15" },
|
||||
{ label: "Gateway", value: "paired webhook + status routing" },
|
||||
],
|
||||
notes: ["Control-plane runtime and topic router for remote host delegation."],
|
||||
},
|
||||
];
|
||||
|
||||
return Promise.all(
|
||||
configuredAgents.map(async (configuredAgent) => {
|
||||
const workspace = readWorkspaceAgent(configuredAgent.runtimePath, configuredAgent.name);
|
||||
const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases);
|
||||
return {
|
||||
...configuredAgent,
|
||||
family: "zeroclaw" as const,
|
||||
tools: workspace.tools,
|
||||
capabilities: workspace.capabilities,
|
||||
files: workspace.files,
|
||||
status: deriveStatus(taskBuckets.activeTasks.length),
|
||||
workload: taskBuckets.activeTasks.length,
|
||||
activeTasks: taskBuckets.activeTasks,
|
||||
completedTasks: taskBuckets.completedTasks,
|
||||
currentTask: workspace.currentTask,
|
||||
notes: [...configuredAgent.notes, ...workspace.noteValues],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listFleetAgents() {
|
||||
const [openclawAgents, zeroclawAgents] = await Promise.all([
|
||||
buildOpenClawAgents(),
|
||||
buildZeroClawAgents(),
|
||||
]);
|
||||
|
||||
return [...openclawAgents, ...zeroclawAgents];
|
||||
}
|
||||
|
||||
export async function listArchitecture() {
|
||||
const agents = await listFleetAgents();
|
||||
return {
|
||||
...ARCHITECTURE_DOCUMENT,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sections: ARCHITECTURE_DOCUMENT.sections.map((section) => ({
|
||||
...section,
|
||||
configuredAgents: agents
|
||||
.filter((agent) => agent.family === section.id)
|
||||
.map((agent) => `${agent.name} (${agent.host})`),
|
||||
})),
|
||||
};
|
||||
}
|
||||
function deriveStatus(activeTaskCount: number): AgentStatus {
|
||||
return activeTaskCount > 0 ? "busy" : "active";
|
||||
}
|
||||
86
lib/db.ts
Normal file
86
lib/db.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import sqlite3 from "sqlite3";
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "tasks.db");
|
||||
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
|
||||
let database: sqlite3.Database | null = null;
|
||||
|
||||
function getDatabase() {
|
||||
if (database) {
|
||||
return database;
|
||||
}
|
||||
|
||||
database = new sqlite3.Database(DB_PATH);
|
||||
database.serialize(() => {
|
||||
database?.run(`
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
assignee TEXT DEFAULT '',
|
||||
priority TEXT NOT NULL DEFAULT 'Medium',
|
||||
status TEXT NOT NULL DEFAULT 'Backlog',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
)
|
||||
`);
|
||||
database?.run(`
|
||||
CREATE TABLE IF NOT EXISTS usage_tracking (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_type TEXT DEFAULT 'chat',
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
cost_estimate REAL DEFAULT 0,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
return database;
|
||||
}
|
||||
|
||||
export function all<T>(sql: string, params: unknown[] = []) {
|
||||
const db = getDatabase();
|
||||
return new Promise<T[]>((resolve, reject) => {
|
||||
db.all(sql, params, (error, rows) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(rows as T[]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function get<T>(sql: string, params: unknown[] = []) {
|
||||
const db = getDatabase();
|
||||
return new Promise<T | undefined>((resolve, reject) => {
|
||||
db.get(sql, params, (error, row) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(row as T | undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function run(sql: string, params: unknown[] = []) {
|
||||
const db = getDatabase();
|
||||
return new Promise<{ lastID: number; changes: number }>((resolve, reject) => {
|
||||
db.run(sql, params, function onRun(error) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ lastID: this.lastID, changes: this.changes });
|
||||
});
|
||||
});
|
||||
}
|
||||
124
lib/fleet-config.ts
Normal file
124
lib/fleet-config.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { ArchitectureDocument } from "@/lib/types";
|
||||
|
||||
export const OPENCLAW_RUNTIME_ROOT =
|
||||
process.env.OPENCLAW_RUNTIME_ROOT || "/home/bear/.openclaw";
|
||||
export const OPENCLAW_AGENTS_DIR =
|
||||
process.env.AGENTS_DIR || "/home/bear/.openclaw/agents";
|
||||
export const OPENCLAW_CONFIG_PATH =
|
||||
process.env.OPENCLAW_CONFIG || "/home/bear/.openclaw/openclaw.json";
|
||||
export const ZEROCLAW_PRIMARY_DIR =
|
||||
process.env.ZEROCLAW_PRIMARY_DIR || "/app/zeroclaw/grizzley";
|
||||
export const ZEROCLAW_CONTROL_DIR =
|
||||
process.env.ZEROCLAW_CONTROL_DIR || "/app/zeroclaw/ice";
|
||||
|
||||
export const ARCHITECTURE_DOCUMENT: ArchitectureDocument = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
title: "Claw Fleet Architecture",
|
||||
overview: [
|
||||
"OpenClaw is the ubuntu-local orchestration layer and Telegram HQ entrypoint.",
|
||||
"ZeroClaw provides host-scoped remote administration on grizzley and ice.",
|
||||
"Task assignment is shared across both families in a single board.",
|
||||
],
|
||||
topologyDiagram: String.raw`
|
||||
Telegram / Forum Topics
|
||||
|
|
||||
+----------------+----------------+
|
||||
| |
|
||||
v v
|
||||
OpenClaw gateway ZeroClaw control
|
||||
ubuntu :18789 ice zeroclaw-admin
|
||||
local swarm topic router / paired gateway
|
||||
| |
|
||||
+------------+--------------------+
|
||||
|
|
||||
v
|
||||
shared taskboard UI
|
||||
|
|
||||
+-----------+-----------+
|
||||
| |
|
||||
v v
|
||||
OpenClaw agents ZeroClaw runtimes
|
||||
ubuntu-local swarm grizzley / ice
|
||||
`,
|
||||
sections: [
|
||||
{
|
||||
id: "openclaw",
|
||||
title: "OpenClaw",
|
||||
summary:
|
||||
"Primary orchestration family on ubuntu. Owns local swarm execution, HQ Telegram bindings, and ubuntu-host workflows.",
|
||||
runtime: [
|
||||
{ label: "Host", value: "ubuntu (192.168.50.61)" },
|
||||
{ label: "Service", value: "openclaw.service" },
|
||||
{ label: "Runtime", value: "/srv/state/openclaw/current" },
|
||||
{ label: "Config", value: "/home/bear/.openclaw/openclaw.json" },
|
||||
],
|
||||
channels: [
|
||||
{ label: "Telegram DM", value: "allowlist: tg:5512934365" },
|
||||
{ label: "Forum Group", value: "Homelab HQ (-1003809447066)" },
|
||||
{ label: "Gateway", value: "LAN bind :18789 with token auth" },
|
||||
],
|
||||
configuredAgents: [
|
||||
"main",
|
||||
"ubuntu",
|
||||
"docs",
|
||||
"gitea-admin",
|
||||
"planner",
|
||||
"builder",
|
||||
"reviewer",
|
||||
],
|
||||
diagram: String.raw`
|
||||
OpenClaw HQ topics
|
||||
topic 2 -> ubuntu
|
||||
topic 3 -> docs
|
||||
topic 4 -> gitea-admin
|
||||
topics 5-9 -> main, then delegate to host-scoped ZeroClaw paths
|
||||
|
||||
main
|
||||
|- ubuntu
|
||||
|- docs
|
||||
|- gitea-admin
|
||||
|- planner
|
||||
|- builder
|
||||
\- reviewer
|
||||
`,
|
||||
notes: [
|
||||
"Remote host personas were removed from OpenClaw.",
|
||||
"OpenClaw remains gateway-only on ubuntu.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "zeroclaw",
|
||||
title: "ZeroClaw",
|
||||
summary:
|
||||
"Host-scoped runtime family for remote administration. Grizzley is the primary active gateway. Ice is the control-plane runtime and topic router.",
|
||||
runtime: [
|
||||
{ label: "Primary", value: "/srv/state/zeroclaw/current on grizzley" },
|
||||
{ label: "Control", value: "/home/bear/.zeroclaw-admin on ice" },
|
||||
{ label: "Primary Service", value: "zeroclaw.service" },
|
||||
{ label: "Control Service", value: "zeroclaw-admin.service" },
|
||||
],
|
||||
channels: [
|
||||
{ label: "Grizzley Gateway", value: "HTTP gateway :3000, pairing required" },
|
||||
{ label: "Ice Telegram", value: "Homelab-Ice (-1003728617160)" },
|
||||
{ label: "Remote Routing", value: "paired status/webhook to grizzley and pve" },
|
||||
],
|
||||
configuredAgents: ["grizzley-zeroclaw", "ice-zeroclaw"],
|
||||
diagram: String.raw`
|
||||
Homelab-Ice topics
|
||||
11 -> local ice operations
|
||||
12 -> grizzley paired gateway
|
||||
13 -> pve paired gateway
|
||||
14 -> truenas blocker message
|
||||
15 -> panda rollout pending
|
||||
|
||||
ice zeroclaw-admin
|
||||
-> zeroclaw-remote-gateway.sh status grizzley|pve
|
||||
-> zeroclaw-remote-gateway.sh webhook grizzley|pve "<message>"
|
||||
`,
|
||||
notes: [
|
||||
"Grizzley is host-scoped and should not proxy other hosts directly.",
|
||||
"Ice still uses host-local secret/encryption state under /home/bear/.zeroclaw-admin.",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
162
lib/tasks.ts
Normal file
162
lib/tasks.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { all, get, run } from "@/lib/db";
|
||||
import type { TaskPriority, TaskRecord, TaskStatus } from "@/lib/types";
|
||||
|
||||
const VALID_STATUSES: TaskStatus[] = [
|
||||
"Backlog",
|
||||
"Todo",
|
||||
"In Progress",
|
||||
"Review",
|
||||
"Done",
|
||||
];
|
||||
|
||||
const VALID_PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"];
|
||||
const WIKI_DIR = process.env.WIKI_DIR || path.join(process.cwd(), "wiki");
|
||||
|
||||
fs.mkdirSync(WIKI_DIR, { recursive: true });
|
||||
|
||||
type DatabaseTaskRow = Omit<TaskRecord, "tags"> & { tags: string };
|
||||
|
||||
export function normalizeTask(row: DatabaseTaskRow): TaskRecord {
|
||||
let tags: string[] = [];
|
||||
try {
|
||||
tags = JSON.parse(row.tags || "[]");
|
||||
} catch {
|
||||
tags = [];
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listTasks() {
|
||||
const rows = await all<DatabaseTaskRow>("SELECT * FROM tasks ORDER BY id DESC");
|
||||
return rows.map(normalizeTask);
|
||||
}
|
||||
|
||||
export async function findTask(id: number) {
|
||||
const row = await get<DatabaseTaskRow>("SELECT * FROM tasks WHERE id = ?", [id]);
|
||||
return row ? normalizeTask(row) : null;
|
||||
}
|
||||
|
||||
export function validateTaskPayload(
|
||||
payload: Partial<TaskRecord> & { tags?: unknown },
|
||||
partial = false,
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!partial || payload.title !== undefined) {
|
||||
if (typeof payload.title !== "string" || payload.title.trim().length === 0) {
|
||||
errors.push("title is required");
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.status !== undefined && !VALID_STATUSES.includes(payload.status)) {
|
||||
errors.push(`status must be one of: ${VALID_STATUSES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (payload.priority !== undefined && !VALID_PRIORITIES.includes(payload.priority)) {
|
||||
errors.push(`priority must be one of: ${VALID_PRIORITIES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (payload.tags !== undefined && !Array.isArray(payload.tags)) {
|
||||
errors.push("tags must be an array of strings");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function buildWikiMarkdown(task: TaskRecord) {
|
||||
const renderedTags = task.tags.length ? task.tags.join(", ") : "None";
|
||||
return `# ${task.title}
|
||||
|
||||
- Task ID: ${task.id}
|
||||
- Assignee: ${task.assignee || "Unassigned"}
|
||||
- Priority: ${task.priority}
|
||||
- Status: ${task.status}
|
||||
- Tags: ${renderedTags}
|
||||
- Created: ${task.created_at}
|
||||
- Completed: ${task.completed_at || new Date().toISOString()}
|
||||
|
||||
## Description
|
||||
|
||||
${task.description || "No description provided."}
|
||||
`;
|
||||
}
|
||||
|
||||
async function writeWikiForTask(task: TaskRecord) {
|
||||
const safeTitle = task.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
const fileName = `${new Date().toISOString().slice(0, 10)}-task-${task.id}-${safeTitle || `task-${task.id}`}.md`;
|
||||
fs.writeFileSync(path.join(WIKI_DIR, fileName), buildWikiMarkdown(task), "utf8");
|
||||
}
|
||||
|
||||
export async function createTask(input: Partial<TaskRecord>) {
|
||||
const result = await run(
|
||||
`INSERT INTO tasks (title, description, assignee, priority, status, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
input.title?.trim() || "",
|
||||
input.description || "",
|
||||
input.assignee || "",
|
||||
input.priority || "Medium",
|
||||
input.status || "Backlog",
|
||||
JSON.stringify(Array.isArray(input.tags) ? input.tags.filter((tag) => typeof tag === "string") : []),
|
||||
],
|
||||
);
|
||||
|
||||
const task = await findTask(result.lastID);
|
||||
if (!task) {
|
||||
throw new Error("failed_to_fetch_created_task");
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function updateTask(id: number, input: Partial<TaskRecord>) {
|
||||
const existing = await findTask(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextStatus = input.status ?? existing.status;
|
||||
const completedAt =
|
||||
nextStatus === "Done"
|
||||
? existing.completed_at || new Date().toISOString()
|
||||
: null;
|
||||
|
||||
await run(
|
||||
`UPDATE tasks
|
||||
SET title = ?, description = ?, assignee = ?, priority = ?, status = ?, tags = ?,
|
||||
completed_at = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
[
|
||||
input.title?.trim() || existing.title,
|
||||
input.description ?? existing.description,
|
||||
input.assignee ?? existing.assignee,
|
||||
input.priority ?? existing.priority,
|
||||
nextStatus,
|
||||
JSON.stringify(input.tags ?? existing.tags),
|
||||
completedAt,
|
||||
id,
|
||||
],
|
||||
);
|
||||
|
||||
const updated = await findTask(id);
|
||||
if (!updated) {
|
||||
throw new Error("failed_to_fetch_updated_task");
|
||||
}
|
||||
|
||||
if (nextStatus === "Done" && existing.status !== "Done") {
|
||||
await writeWikiForTask(updated);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
84
lib/types.ts
Normal file
84
lib/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export type TaskStatus = "Backlog" | "Todo" | "In Progress" | "Review" | "Done";
|
||||
export type TaskPriority = "Low" | "Medium" | "High" | "Critical";
|
||||
export type AgentFamily = "openclaw" | "zeroclaw";
|
||||
export type AgentStatus = "active" | "busy" | "idle";
|
||||
|
||||
export type TaskRecord = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
assignee: string;
|
||||
priority: TaskPriority;
|
||||
status: TaskStatus;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
};
|
||||
|
||||
export type WikiPageSummary = {
|
||||
filename: string;
|
||||
title: string;
|
||||
created: string;
|
||||
modified: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type WikiPage = {
|
||||
filename: string;
|
||||
content: string;
|
||||
metadata: {
|
||||
title: string;
|
||||
created: string;
|
||||
modified: string;
|
||||
tags: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentRouteSummary = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type FleetAgent = {
|
||||
slug: string;
|
||||
assignmentKey: string;
|
||||
aliases: string[];
|
||||
family: AgentFamily;
|
||||
name: string;
|
||||
host: string;
|
||||
role: string;
|
||||
runtimePath: string;
|
||||
configPath: string | null;
|
||||
model: string | null;
|
||||
emoji: string;
|
||||
channels: AgentRouteSummary[];
|
||||
tools: string[];
|
||||
capabilities: string[];
|
||||
files: string[];
|
||||
status: AgentStatus;
|
||||
workload: number;
|
||||
activeTasks: TaskRecord[];
|
||||
completedTasks: TaskRecord[];
|
||||
currentTask: string | null;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export type FleetSection = {
|
||||
id: AgentFamily;
|
||||
title: string;
|
||||
summary: string;
|
||||
runtime: AgentRouteSummary[];
|
||||
channels: AgentRouteSummary[];
|
||||
configuredAgents: string[];
|
||||
diagram: string;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export type ArchitectureDocument = {
|
||||
generatedAt: string;
|
||||
title: string;
|
||||
overview: string[];
|
||||
sections: FleetSection[];
|
||||
topologyDiagram: string;
|
||||
};
|
||||
29
lib/utils.ts
Normal file
29
lib/utils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function slugify(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
130
lib/wiki.ts
Normal file
130
lib/wiki.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { WikiPage, WikiPageSummary } from "@/lib/types";
|
||||
|
||||
const WIKI_DIR = process.env.WIKI_DIR || path.join(process.cwd(), "wiki");
|
||||
|
||||
fs.mkdirSync(WIKI_DIR, { recursive: true });
|
||||
|
||||
function assertSafeFilename(filename: string) {
|
||||
if (filename.includes("..") || filename.includes("/") || filename.includes("\\")) {
|
||||
throw new Error("invalid_filename");
|
||||
}
|
||||
}
|
||||
|
||||
function extractMetadata(content: string) {
|
||||
const metadata = {
|
||||
title: "",
|
||||
created: "",
|
||||
modified: "",
|
||||
tags: [] as string[],
|
||||
};
|
||||
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (frontmatterMatch) {
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const titleMatch = frontmatter.match(/title:\s*(.+)/i);
|
||||
const tagsMatch = frontmatter.match(/tags:\s*\[(.+)\]/i);
|
||||
if (titleMatch) {
|
||||
metadata.title = titleMatch[1].trim();
|
||||
}
|
||||
if (tagsMatch) {
|
||||
metadata.tags = tagsMatch[1].split(",").map((tag) => tag.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadata.title) {
|
||||
const headingMatch = content.match(/^#\s+(.+)$/m);
|
||||
if (headingMatch) {
|
||||
metadata.title = headingMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function listWikiPages(): WikiPageSummary[] {
|
||||
if (!fs.existsSync(WIKI_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(WIKI_DIR)
|
||||
.filter((fileName) => fileName.endsWith(".md"))
|
||||
.map((filename) => {
|
||||
const filePath = path.join(WIKI_DIR, filename);
|
||||
const stats = fs.statSync(filePath);
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const metadata = extractMetadata(content);
|
||||
return {
|
||||
filename,
|
||||
title: metadata.title || filename.replace(".md", "").replace(/-/g, " "),
|
||||
created: stats.birthtime.toISOString(),
|
||||
modified: stats.mtime.toISOString(),
|
||||
tags: metadata.tags,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => new Date(right.modified).getTime() - new Date(left.modified).getTime());
|
||||
}
|
||||
|
||||
export function readWikiPage(filename: string): WikiPage | null {
|
||||
assertSafeFilename(filename);
|
||||
const filePath = path.join(WIKI_DIR, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const stats = fs.statSync(filePath);
|
||||
const metadata = extractMetadata(content);
|
||||
|
||||
return {
|
||||
filename,
|
||||
content,
|
||||
metadata: {
|
||||
title: metadata.title || filename.replace(".md", ""),
|
||||
created: stats.birthtime.toISOString(),
|
||||
modified: stats.mtime.toISOString(),
|
||||
tags: metadata.tags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createWikiPage(title: string) {
|
||||
const safeTitle = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
let filename = `${timestamp}-${safeTitle}.md`;
|
||||
let counter = 1;
|
||||
|
||||
while (fs.existsSync(path.join(WIKI_DIR, filename))) {
|
||||
filename = `${timestamp}-${safeTitle}-${counter}.md`;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
const content = `# ${title}
|
||||
|
||||
## Summary
|
||||
|
||||
Document the architecture, deployment notes, or runbook here.
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(WIKI_DIR, filename), content, "utf8");
|
||||
return filename;
|
||||
}
|
||||
|
||||
export function updateWikiPage(filename: string, content: string) {
|
||||
assertSafeFilename(filename);
|
||||
fs.writeFileSync(path.join(WIKI_DIR, filename), content, "utf8");
|
||||
}
|
||||
|
||||
export function deleteWikiPage(filename: string) {
|
||||
assertSafeFilename(filename);
|
||||
fs.unlinkSync(path.join(WIKI_DIR, filename));
|
||||
}
|
||||
17
middleware.ts
Normal file
17
middleware.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set("x-pathname", request.nextUrl.pathname);
|
||||
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
9
next.config.ts
Normal file
9
next.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
typedRoutes: false,
|
||||
serverExternalPackages: ["sqlite3"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
3872
package-lock.json
generated
3872
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -1,14 +1,32 @@
|
||||
{
|
||||
"name": "openclaw-taskboard",
|
||||
"version": "1.0.0",
|
||||
"description": "OpenClaw agent fleet task tracking dashboard",
|
||||
"main": "server.js",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "Next.js fleet dashboard for OpenClaw and ZeroClaw runtimes",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.511.0",
|
||||
"next": "^15.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"sqlite3": "^5.1.7",
|
||||
"ws": "^8.19.0"
|
||||
"tailwind-merge": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
56
tailwind.config.ts
Normal file
56
tailwind.config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./lib/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
boxShadow: {
|
||||
panel: "0 24px 60px rgba(15, 23, 42, 0.28)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)"],
|
||||
mono: ["var(--font-mono)"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es2022"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user