[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

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