diff --git a/.gitignore b/.gitignore index 757c58e..d6dc79f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ node_modules/ +.next/ +out/ +*.tsbuildinfo data/*.db data/*.db-wal data/*.db-shm diff --git a/Dockerfile b/Dockerfile index b8ba9f5..72180df 100644 --- a/Dockerfile +++ b/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"] diff --git a/README.md b/README.md index 8c963e5..b8e0ce8 100644 --- a/README.md +++ b/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. diff --git a/app/agents/page.tsx b/app/agents/page.tsx new file mode 100644 index 0000000..c4bdf25 --- /dev/null +++ b/app/agents/page.tsx @@ -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 ; +} diff --git a/app/api/agents/[slug]/assign/route.ts b/app/api/agents/[slug]/assign/route.ts new file mode 100644 index 0000000..d697ef8 --- /dev/null +++ b/app/api/agents/[slug]/assign/route.ts @@ -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 }); +} diff --git a/app/api/agents/route.ts b/app/api/agents/route.ts new file mode 100644 index 0000000..bc45fea --- /dev/null +++ b/app/api/agents/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; + +import { listFleetAgents } from "@/lib/agents"; + +export async function GET() { + return NextResponse.json(await listFleetAgents()); +} diff --git a/app/api/architecture/route.ts b/app/api/architecture/route.ts new file mode 100644 index 0000000..3a37556 --- /dev/null +++ b/app/api/architecture/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; + +import { listArchitecture } from "@/lib/agents"; + +export async function GET() { + return NextResponse.json(await listArchitecture()); +} diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..5ac25b3 --- /dev/null +++ b/app/api/tasks/[id]/route.ts @@ -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; + 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); +} diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts new file mode 100644 index 0000000..5320a96 --- /dev/null +++ b/app/api/tasks/route.ts @@ -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; + 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 }); +} diff --git a/app/api/usage/stats/route.ts b/app/api/usage/stats/route.ts new file mode 100644 index 0000000..af2c555 --- /dev/null +++ b/app/api/usage/stats/route.ts @@ -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("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, + }, + ); + + return NextResponse.json(result); +} diff --git a/app/api/wiki/[filename]/route.ts b/app/api/wiki/[filename]/route.ts new file mode 100644 index 0000000..3e32bf7 --- /dev/null +++ b/app/api/wiki/[filename]/route.ts @@ -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 }); +} diff --git a/app/api/wiki/route.ts b/app/api/wiki/route.ts new file mode 100644 index 0000000..8cf9c06 --- /dev/null +++ b/app/api/wiki/route.ts @@ -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 }); +} diff --git a/app/architecture/page.tsx b/app/architecture/page.tsx new file mode 100644 index 0000000..f3cc2ca --- /dev/null +++ b/app/architecture/page.tsx @@ -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 ; +} diff --git a/app/gitea/page.tsx b/app/gitea/page.tsx new file mode 100644 index 0000000..1f5d9f6 --- /dev/null +++ b/app/gitea/page.tsx @@ -0,0 +1,21 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export const dynamic = "force-dynamic"; + +export default function GiteaPage() { + return ( + + + Gitea Integration + + 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. + + + +

Primary repo: `TopherMayor/openclaw-taskboard`

+

Infra wrapper: `TopherMayor/homelabagentroot` under `homelab/ubuntu/taskboard/`

+

Use the architecture and agents pages to verify deployed fleet state before issuing repo automation from the host agents.

+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..c422c68 --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..9afd173 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..ddb82a7 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function HomePage() { + redirect("/tasks"); +} diff --git a/app/tasks/page.tsx b/app/tasks/page.tsx new file mode 100644 index 0000000..24b58ce --- /dev/null +++ b/app/tasks/page.tsx @@ -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 ; +} diff --git a/app/usage/page.tsx b/app/usage/page.tsx new file mode 100644 index 0000000..f37bc11 --- /dev/null +++ b/app/usage/page.tsx @@ -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("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, + }, + ); + + return ; +} diff --git a/app/wiki/page.tsx b/app/wiki/page.tsx new file mode 100644 index 0000000..80e457b --- /dev/null +++ b/app/wiki/page.tsx @@ -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 ( + + ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..f335e28 --- /dev/null +++ b/components.json @@ -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" + } +} diff --git a/components/agents-client.tsx b/components/agents-client.tsx new file mode 100644 index 0000000..43d4c8d --- /dev/null +++ b/components/agents-client.tsx @@ -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 ( +
+ + + Configured Agent Runtimes + + OpenClaw swarm members and ZeroClaw host runtimes are shown from the deployed fleet model. + + + + setQuery(event.target.value)} /> + + + + +
+ {filteredAgents.map((agent) => ( + + +
+
+ + {agent.emoji} + {agent.name} + + {agent.role} +
+
+ {agent.family} + {agent.status} +
+
+
+ +
+
+
Host
+
{agent.host}
+
+
+
Model
+
{agent.model || "Host-local/runtime-defined"}
+
+
+
Runtime
+
{agent.runtimePath}
+
+
+
Workload
+
{agent.workload} active
+
+
+
Current task
+
{agent.currentTask || "No heartbeat task"}
+
+
+ +
+

Channels

+
+ {agent.channels.map((channel) => ( + + {channel.label}: {channel.value} + + ))} +
+
+ +
+

Tools

+
+ {agent.tools.length ? agent.tools.map((tool) => {tool}) : No parsed tools.} +
+
+
+
+ ))} +
+
+ ); +} diff --git a/components/app-shell.tsx b/components/app-shell.tsx new file mode 100644 index 0000000..fa1f67a --- /dev/null +++ b/components/app-shell.tsx @@ -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 ( +
+
+
+
+

+ OpenClaw Taskboard +

+

+ Claw Fleet Console +

+

+ Unified operations view for OpenClaw orchestration, ZeroClaw host runtimes, and deployed architecture. +

+
+
+
+ +
+ + +
{children}
+
+
+ ); +} diff --git a/components/architecture-view.tsx b/components/architecture-view.tsx new file mode 100644 index 0000000..36a86bc --- /dev/null +++ b/components/architecture-view.tsx @@ -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 ( +
+ + + {architecture.title} + Generated from the deployed fleet model and tracked channel/runtime definitions. + + +
+ {architecture.overview.map((line) => ( + + {line} + + ))} +
+
+            {architecture.topologyDiagram}
+          
+
+
+ +
+ {architecture.sections.map((section) => ( + + + {section.title} + {section.summary} + + +
+
+                  {section.diagram}
+                
+
+ {section.configuredAgents.map((agentName) => ( + {agentName} + ))} +
+
+ +
+
+

Runtime

+
+ {section.runtime.map((entry) => ( +
+

{entry.label}

+

{entry.value}

+
+ ))} +
+
+
+

Channels

+
+ {section.channels.map((entry) => ( +
+

{entry.label}

+

{entry.value}

+
+ ))} +
+
+
+

Notes

+
    + {section.notes.map((note) => ( +
  • - {note}
  • + ))} +
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/components/tasks-client.tsx b/components/tasks-client.tsx new file mode 100644 index 0000000..3dcc8df --- /dev/null +++ b/components/tasks-client.tsx @@ -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) { + 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 ( +
+ + + Unified Task Intake + + Assign work to OpenClaw swarm agents or ZeroClaw host runtimes from a single board. + + + +
+ setFormState((current) => ({ ...current, title: event.target.value }))} + /> + + + setFormState((current) => ({ ...current, tags: event.target.value }))} + /> +
+