[taskboard] migrate fleet console to nextjs

This commit is contained in:
2026-03-06 14:44:27 -08:00
parent 94e54dc144
commit a765b3d22f
48 changed files with 5483 additions and 790 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,7 @@
node_modules/
.next/
out/
*.tsbuildinfo
data/*.db
data/*.db-wal
data/*.db-shm

View File

@@ -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
View File

@@ -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
View 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} />;
}

View 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
View File

@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { listFleetAgents } from "@/lib/agents";
export async function GET() {
return NextResponse.json(await listFleetAgents());
}

View File

@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { listArchitecture } from "@/lib/agents";
export async function GET() {
return NextResponse.json(await listArchitecture());
}

View 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
View 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 });
}

View 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);
}

View 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
View 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 });
}

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/tasks");
}

10
app/tasks/page.tsx Normal file
View 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
View 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
View 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
View 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"
}
}

View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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";

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

56
tailwind.config.ts Normal file
View 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
View 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"
]
}