From b1864e34bbfba3993bbff2f23f8378c9b8bef980 Mon Sep 17 00:00:00 2001 From: Slava Trofimov Date: Wed, 3 Jun 2026 08:48:54 -0400 Subject: [PATCH] add dashboard skills page --- webapp/src/api/dashboardClient.ts | 164 +++++++++-- webapp/src/pages/SkillsPage.tsx | 449 ++++++++++++++++++++++++++++++ webapp/src/routes.tsx | 5 + webapp/src/ui/AppShell.test.tsx | 1 + webapp/src/ui/AppShell.tsx | 3 +- 5 files changed, 596 insertions(+), 26 deletions(-) create mode 100644 webapp/src/pages/SkillsPage.tsx diff --git a/webapp/src/api/dashboardClient.ts b/webapp/src/api/dashboardClient.ts index 50ad47f0..c58c74d1 100644 --- a/webapp/src/api/dashboardClient.ts +++ b/webapp/src/api/dashboardClient.ts @@ -134,10 +134,10 @@ export type DashboardProviderOption = { base_url_label: string; }; -export type WorkerTemplate = { - id: string; - name: string; - description: string; +export type WorkerTemplate = { + id: string; + name: string; + description: string; system_prompt: string; available_tools: string[]; required_permissions: string[]; @@ -147,9 +147,80 @@ export type WorkerTemplate = { can_spawn_children: boolean; allowed_child_templates: string[]; created_at?: string; - updated_at?: string; -}; - + updated_at?: string; +}; + +export type DashboardSkill = { + id: string; + name: string; + description: string; + scope: string; + enabled: boolean; + ready: boolean; + status: string; + reasons: string[]; + origin: string; + source: { + kind: string; + label: string; + path: string; + installer_managed: boolean; + auto_discovered: boolean; + }; + trust: { + trusted: boolean; + has_scripts: boolean; + scan_status: string; + scan_findings_count: number; + }; + runtime: { + kind: string; + required: boolean; + recommended: boolean; + prepared: boolean; + next_step: string; + }; + requirements: { + missing_bins: string[]; + missing_env: string[]; + missing_config: string[]; + }; + actions: { + can_enable: boolean; + can_disable: boolean; + can_remove: boolean; + can_install: boolean; + }; +}; + +export type DashboardSkillsResponse = { + contract_version: string; + count: number; + registry_path: string; + skills: DashboardSkill[]; + install: { + supported_sources: string[]; + default_clawhub_site: string; + }; +}; + +export type DashboardSkillInstallPayload = { + source: string; + clawhub_site?: string; +}; + +export type DashboardSkillMutationResponse = { + status: string; + skill_id: string; + skill: DashboardSkill; +}; + +export type DashboardSkillDeleteResponse = { + status: string; + skill_id: string; + skills: DashboardSkillsResponse; +}; + export type DashboardQueryParams = { windowMinutes: 15 | 60 | 240 | 1440; service: "all" | "gateway" | "octo" | "telegram" | "exec_run" | "mcp" | "workers"; @@ -186,17 +257,29 @@ async function mutateJson(url: string, method: "POST" | "PUT" | "DELETE", tok const headers: HeadersInit = token ? { ...defaultHeaders, "x-octopal-token": token } : defaultHeaders; - const response = await fetch(url, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - }); - if (!response.ok) { - const detail = await response.text(); - throw new Error(detail || `Request failed: ${response.status}`); - } - return (await response.json()) as T; -} + const response = await fetch(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok) { + const detail = await response.text(); + let parsedDetail = ""; + try { + const parsed = JSON.parse(detail) as { detail?: unknown }; + if (typeof parsed.detail === "string" && parsed.detail.trim()) { + parsedDetail = parsed.detail; + } + } catch { + // Plain-text errors are common for failed local gateway requests. + } + if (parsedDetail) { + throw new Error(parsedDetail); + } + throw new Error(detail || `Request failed: ${response.status}`); + } + return (await response.json()) as T; +} export async function fetchOverview(params: DashboardQueryParams): Promise { return fetchJson(withQuery("/api/dashboard/v2/overview", params), params.token); @@ -268,10 +351,41 @@ export async function updateWorkerTemplate(payload: WorkerTemplate, token?: stri return response.template; } -export async function deleteWorkerTemplate(templateId: string, token?: string): Promise { - await mutateJson<{ status: string }>( - `/api/dashboard/worker-templates/${encodeURIComponent(templateId)}`, - "DELETE", - token, - ); -} +export async function deleteWorkerTemplate(templateId: string, token?: string): Promise { + await mutateJson<{ status: string }>( + `/api/dashboard/worker-templates/${encodeURIComponent(templateId)}`, + "DELETE", + token, + ); +} + +export async function fetchSkills(token?: string): Promise { + return fetchJson("/api/dashboard/skills", token); +} + +export async function installSkill( + payload: DashboardSkillInstallPayload, + token?: string, +): Promise { + return mutateJson("/api/dashboard/skills/install", "POST", token, payload); +} + +export async function setSkillEnabled( + skillId: string, + enabled: boolean, + token?: string, +): Promise { + return mutateJson( + `/api/dashboard/skills/${encodeURIComponent(skillId)}/${enabled ? "enable" : "disable"}`, + "POST", + token, + ); +} + +export async function deleteSkill(skillId: string, token?: string): Promise { + return mutateJson( + `/api/dashboard/skills/${encodeURIComponent(skillId)}`, + "DELETE", + token, + ); +} diff --git a/webapp/src/pages/SkillsPage.tsx b/webapp/src/pages/SkillsPage.tsx new file mode 100644 index 00000000..183171ab --- /dev/null +++ b/webapp/src/pages/SkillsPage.tsx @@ -0,0 +1,449 @@ +import { useEffect, useMemo, useState } from "react"; +import { useOutletContext } from "react-router-dom"; +import { + CheckCircle2, + Download, + Power, + PowerOff, + RefreshCw, + Trash2, + TriangleAlert, +} from "lucide-react"; + +import { + deleteSkill, + fetchSkills, + installSkill, + setSkillEnabled, + type DashboardSkill, + type DashboardSkillsResponse, +} from "../api/dashboardClient"; +import type { AppShellOutletContext } from "../ui/AppShell"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +const inputClass = "rounded-[18px] border-white/8 bg-[var(--surface-panel-strong)] px-3 text-white"; + +function sortSkills(skills: DashboardSkill[]): DashboardSkill[] { + return [...skills].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); +} + +function statusTone(skill: DashboardSkill): string { + if (!skill.enabled) { + return "border-slate-400/20 bg-slate-400/10 text-slate-200"; + } + if (skill.ready) { + return "border-emerald-400/25 bg-emerald-500/12 text-emerald-200"; + } + return "border-amber-400/25 bg-amber-500/12 text-amber-200"; +} + +function statusLabel(skill: DashboardSkill): string { + if (!skill.enabled) { + return "Disabled"; + } + if (skill.ready) { + return "Ready"; + } + return skill.status || "Needs setup"; +} + +function sourceLabel(skill: DashboardSkill): string { + const label = skill.source.label || skill.source.path || skill.origin; + return label || "local"; +} + +function RequirementList({ title, values }: { title: string; values: string[] }) { + if (values.length === 0) { + return null; + } + return ( +
+
{title}
+
+ {values.map((value) => ( + + {value} + + ))} +
+
+ ); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value || "n/a"} +
+
+ ); +} + +function replaceSkill(skills: DashboardSkill[], skill: DashboardSkill): DashboardSkill[] { + const next = skills.some((item) => item.id === skill.id) + ? skills.map((item) => (item.id === skill.id ? skill : item)) + : [...skills, skill]; + return sortSkills(next); +} + +export function SkillsPage() { + const { filters } = useOutletContext(); + const [payload, setPayload] = useState(null); + const [selectedId, setSelectedId] = useState(""); + const [source, setSource] = useState(""); + const [clawhubSite, setClawhubSite] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + + async function reload(nextSelectedId?: string): Promise { + setLoading(true); + setError(""); + try { + const nextPayload = await fetchSkills(filters.token || undefined); + const sorted = sortSkills(nextPayload.skills ?? []); + const selected = nextSelectedId && sorted.some((item) => item.id === nextSelectedId) + ? nextSelectedId + : sorted[0]?.id ?? ""; + setPayload({ ...nextPayload, skills: sorted }); + setSelectedId(selected); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to load skills"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void reload(); + }, [filters.token]); + + const skills = payload?.skills ?? []; + const selectedSkill = useMemo( + () => skills.find((item) => item.id === selectedId) ?? null, + [selectedId, skills], + ); + const enabledCount = skills.filter((skill) => skill.enabled).length; + const readyCount = skills.filter((skill) => skill.enabled && skill.ready).length; + const needsSetupCount = skills.filter((skill) => skill.enabled && !skill.ready).length; + const defaultClawhubSite = payload?.install.default_clawhub_site ?? "https://clawhub.ai"; + + async function handleInstall(): Promise { + const trimmedSource = source.trim(); + if (!trimmedSource) { + setError("Skill source is required"); + return; + } + setSaving(true); + setError(""); + setNotice(""); + try { + const response = await installSkill( + { + source: trimmedSource, + clawhub_site: clawhubSite.trim() || undefined, + }, + filters.token || undefined, + ); + setPayload((current) => current ? { ...current, skills: replaceSkill(current.skills, response.skill) } : current); + setSelectedId(response.skill.id); + setSource(""); + setNotice(`Skill "${response.skill.name}" installed.`); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to install skill"); + } finally { + setSaving(false); + } + } + + async function handleToggle(skill: DashboardSkill, enabled: boolean): Promise { + setSaving(true); + setError(""); + setNotice(""); + try { + const response = await setSkillEnabled(skill.id, enabled, filters.token || undefined); + setPayload((current) => current ? { ...current, skills: replaceSkill(current.skills, response.skill) } : current); + setSelectedId(response.skill.id); + setNotice(`Skill "${response.skill.name}" ${enabled ? "enabled" : "disabled"}.`); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : `Failed to ${enabled ? "enable" : "disable"} skill`); + } finally { + setSaving(false); + } + } + + async function handleDelete(skill: DashboardSkill): Promise { + const confirmed = window.confirm(`Delete skill "${skill.name}" (${skill.id})?`); + if (!confirmed) { + return; + } + setSaving(true); + setError(""); + setNotice(""); + try { + const response = await deleteSkill(skill.id, filters.token || undefined); + const sorted = sortSkills(response.skills.skills ?? []); + setPayload({ ...response.skills, skills: sorted }); + setSelectedId(sorted[0]?.id ?? ""); + setNotice(`Skill "${skill.name}" deleted.`); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to delete skill"); + } finally { + setSaving(false); + } + } + + if (loading) { + return ( +
+

Skills

+

Loading installed skills...

+
+ ); + } + + return ( +
+
+
+
+

Skill library

+

Installed skills

+

+ Skills are loaded from workspace/skills and the installer registry. +

+
+
+
+
Total
+
{skills.length}
+
+
+
Enabled
+
{enabledCount}
+
+
+
Ready
+
{readyCount}
+
+
+
+
+ + {error ? ( +
+ {error} +
+ ) : null} + {notice ? ( +
+ {notice} +
+ ) : null} + +
+
+ + +
+ + +
+
+
+ +
+ + +
+ {selectedSkill ? ( + <> +
+
+

Skill detail

+

{selectedSkill.name}

+

+ {selectedSkill.description || "No description"} +

+
+
+ + +
+
+ +
+ + {selectedSkill.ready ? : } + {statusLabel(selectedSkill)} + + + {selectedSkill.scope} + + + {selectedSkill.origin} + + {selectedSkill.trust.has_scripts ? ( + + Scripts + + ) : null} +
+ + {selectedSkill.reasons.length > 0 ? ( +
+
+ + Attention +
+
    + {selectedSkill.reasons.map((reason) => ( +
  • {reason}
  • + ))} +
+
+ ) : null} + +
+ + + + +
+ +
+ + + +
+ + {selectedSkill.runtime.next_step ? ( +
+ Runtime: {selectedSkill.runtime.next_step} +
+ ) : null} + + ) : ( +
+ Select a skill after installing one. +
+ )} +
+
+
+ ); +} diff --git a/webapp/src/routes.tsx b/webapp/src/routes.tsx index 16af28b4..9ab2ba9e 100644 --- a/webapp/src/routes.tsx +++ b/webapp/src/routes.tsx @@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate } from "react-router-dom"; import { ControlCenterPage } from "./pages/ControlCenterPage"; import { IncidentsPage } from "./pages/IncidentsPage"; +import { SkillsPage } from "./pages/SkillsPage"; import { SystemPage } from "./pages/SystemPage"; import { WorkersPage } from "./pages/WorkersPage"; import { AppShell } from "./ui/AppShell"; @@ -25,6 +26,10 @@ export const appRouter = createBrowserRouter([ path: "workers", element: , }, + { + path: "skills", + element: , + }, { path: "system", element: , diff --git a/webapp/src/ui/AppShell.test.tsx b/webapp/src/ui/AppShell.test.tsx index 65794a29..7dd57e9a 100644 --- a/webapp/src/ui/AppShell.test.tsx +++ b/webapp/src/ui/AppShell.test.tsx @@ -15,6 +15,7 @@ describe("AppShell", () => { expect(screen.getByRole("heading", { name: "Control" })).toBeInTheDocument(); expect(screen.getAllByText("Live operating surface").length).toBeGreaterThan(0); expect(screen.getByText("Dashboard token")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Skills Installed skill library/i })).toBeInTheDocument(); expect(screen.getByText("Loading live operations view...")).toBeInTheDocument(); }); }); diff --git a/webapp/src/ui/AppShell.tsx b/webapp/src/ui/AppShell.tsx index be43916a..13286a8d 100644 --- a/webapp/src/ui/AppShell.tsx +++ b/webapp/src/ui/AppShell.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { NavLink, Outlet, useLocation } from "react-router-dom"; -import { Activity, Settings2, Siren, Wrench } from "lucide-react"; +import { Activity, Puzzle, Settings2, Siren, Wrench } from "lucide-react"; import octopalLogo from "../assets/octopal-logo.png"; import { Button } from "@/components/ui/button"; @@ -42,6 +42,7 @@ const navGroups: { title: string; items: NavItem[] }[] = [ title: "Workspace", items: [ { to: "/workers", label: "Workers", description: "Templates and worker setup", icon: Wrench }, + { to: "/skills", label: "Skills", description: "Installed skill library", icon: Puzzle }, { to: "/system", label: "System", description: "Host, queues, and stability", icon: Settings2 }, ], },