diff --git a/src/app/HomeClient.tsx b/src/app/HomeClient.tsx index 010e99b..8a4cb89 100644 --- a/src/app/HomeClient.tsx +++ b/src/app/HomeClient.tsx @@ -88,7 +88,47 @@ export default function HomeClient({ Installed agents grouped by team workspace

- {appVersion}-beta +
+ + Create team + + + Recipes + + + Tickets + + + + Kitchen Sink + + + Channels / Bindings + + + Settings + + + {appVersion}-beta +
diff --git a/src/app/overview/page.tsx b/src/app/overview/page.tsx new file mode 100644 index 0000000..6686ee4 --- /dev/null +++ b/src/app/overview/page.tsx @@ -0,0 +1,327 @@ +import Link from "next/link"; +import { unstable_noStore as noStore } from "next/cache"; + +import { execFileAsync } from "@/lib/exec"; +import { inferTeamIdFromWorkspace, getActiveSessions, getAgents } from "@/lib/overview/overview-data"; +import { runOpenClaw } from "@/lib/openclaw"; +import { listRecipes } from "@/lib/recipes"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +async function getTeamsFromRecipes(): Promise<{ teamNames: Record }> { + const items = await listRecipes(); + const teamNames: Record = {}; + for (const r of items) { + if (r.kind !== "team") continue; + const name = String(r.name ?? "").trim(); + if (!name) continue; + teamNames[r.id] = name; + } + return { teamNames }; +} + +type GatewayStatusSummary = { + ok: boolean; + statusLabel: string; + configOk: boolean; + rpcOk: boolean; + portStatus?: string; +}; + + +type GatewayStatusJson = { + service?: { + runtime?: { status?: string; state?: string }; + configAudit?: { ok?: boolean }; + }; + rpc?: { ok?: boolean }; + port?: { status?: string }; +}; + +async function getGatewayStatusSummary(): Promise { + const res = await runOpenClaw(["gateway", "status", "--json"]); + if (!res.ok) { + return { + ok: false, + statusLabel: "unknown", + configOk: false, + rpcOk: false, + }; + } + + const parsed = JSON.parse(res.stdout) as GatewayStatusJson; + const runtimeStatus = String(parsed?.service?.runtime?.status ?? "unknown"); + const runtimeState = String(parsed?.service?.runtime?.state ?? "unknown"); + const configOk = Boolean(parsed?.service?.configAudit?.ok); + const rpcOk = Boolean(parsed?.rpc?.ok); + const portStatus = String(parsed?.port?.status ?? "unknown"); + + const running = runtimeStatus === "running" && runtimeState === "active"; + const ok = running && configOk && rpcOk; + + return { + ok, + statusLabel: running ? "running" : runtimeStatus, + configOk, + rpcOk, + portStatus, + }; +} + +let cachedGatewayErrCount: { ts: number; value: number | null } = { ts: 0, value: null }; + +async function getGatewayErrorsLast24h(): Promise { + // journalctl can be expensive; cache for 60s per server instance. + const now = Date.now(); + if (now - cachedGatewayErrCount.ts < 60_000) return cachedGatewayErrCount.value; + + try { + const { stdout } = await execFileAsync( + "bash", + [ + "-lc", + "journalctl --user -u openclaw-gateway --since '24 hours ago' -p err --no-pager 2>/dev/null | wc -l", + ], + { encoding: "utf8", timeout: 5_000 }, + ); + const n = Number(String(stdout).trim()); + const value = Number.isFinite(n) ? n : null; + cachedGatewayErrCount = { ts: now, value }; + return value; + } catch { + cachedGatewayErrCount = { ts: now, value: null }; + return null; + } +} + +function formatAgeMs(ms: number | undefined) { + if (ms == null) return ""; + const s = Math.round(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.round(s / 60); + if (m < 60) return `${m}m`; + const h = Math.round(m / 60); + return `${h}h`; +} + +function displayNameForTeam(teamId: string, teamNames: Record) { + const fallback = teamId.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim(); + return teamNames[teamId] || fallback || teamId; +} + +export default async function OverviewPage({ + searchParams, +}: { + searchParams?: Promise>; +}) { + noStore(); + + const sp = (await searchParams) ?? {}; + const teamRaw = sp.team; + const team = (Array.isArray(teamRaw) ? teamRaw[0] : teamRaw) || ""; + const teamFilter = team.trim(); + + const [agents, sessions60, sessions5, { teamNames }, gateway, gatewayErr24h] = await Promise.all([ + getAgents(), + getActiveSessions(60), + getActiveSessions(5), + getTeamsFromRecipes(), + getGatewayStatusSummary(), + getGatewayErrorsLast24h(), + ]); + + const agentsById = new Map(agents.map((a) => [a.id, a] as const)); + + const sessionsFiltered = sessions60.filter((s) => { + if (!teamFilter) return true; + const a = agentsById.get(s.agentId); + const t = inferTeamIdFromWorkspace(a?.workspace ?? null); + return t === teamFilter; + }); + + const sessions5Filtered = sessions5.filter((s) => { + if (!teamFilter) return true; + const a = agentsById.get(s.agentId); + const t = inferTeamIdFromWorkspace(a?.workspace ?? null); + return t === teamFilter; + }); + + const installedAgents = teamFilter + ? agents.filter((a) => inferTeamIdFromWorkspace(a.workspace ?? null) === teamFilter) + : agents; + + const activeSessions = sessionsFiltered.length; + const tasksRunning = sessions5Filtered.length; + + const tasksHref = teamFilter + ? `/overview/tasks?team=${encodeURIComponent(teamFilter)}` + : `/overview/tasks`; + + const kpi: Array<{ label: string; value: string; note: string; href?: string }> = [ + { + label: "Active Sessions (last 60m)", + value: String(activeSessions), + note: teamFilter ? `team=${teamFilter}` : "all teams", + }, + { + label: "Agents (installed)", + value: String(installedAgents.length), + note: teamFilter ? displayNameForTeam(teamFilter, teamNames) : "all teams", + }, + { + label: "Tasks Running", + value: String(tasksRunning), + note: "active sessions (last 5m)", + href: tasksHref, + }, + { + label: "Errors (24h)", + value: gatewayErr24h == null ? "—" : String(gatewayErr24h), + note: "gateway journalctl -p err", + }, + ]; + + const sessionsPreview = sessionsFiltered.slice().sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 8); + + return ( +
+
+
+

Kitchen Sink

+

+ A live overview of your OpenClaw system. Data is real when available; otherwise we show explicit unknowns. +

+
+ Tip: filter by team via ?team=<teamId>. +
+
+ +
+ + {teamFilter ? "Team →" : "Home →"} + +
+
+ +
+ {kpi.map((t) => ( +
+
{t.label}
+
+ {t.href ? ( + + {t.value} + + ) : ( + t.value + )} +
+
{t.note}
+
+ ))} +
+ +
+
+
System Health
+
+
+
Gateway
+
+ {gateway.statusLabel} + {!gateway.ok ? ( + (degraded) + ) : null} +
+
+
+
RPC
+
{gateway.rpcOk ? "ok" : "not ok"}
+
+
+
Config audit
+
+ {gateway.configOk ? "ok" : "issues"} +
+
+
+
Port
+
{gateway.portStatus ?? "—"}
+
+
+
+ Source: openclaw gateway status --json +
+
+ +
+
Security & Audit
+
No audit feed wired yet.
+
Empty state (intentional): no fake data.
+
+ +
+
Backup & Pipelines
+
No backup pipeline status wired yet.
+
Empty state (intentional): no fake data.
+
+ +
+
+
+
Recent Sessions
+
+ Showing last 60 minutes{teamFilter ? ` for team ${teamFilter}` : ""}. +
+
+
+ + {sessionsPreview.length ? ( +
+ + + + + + + + + + + + {sessionsPreview.map((s) => ( + + + + + + + + ))} + +
AgentModelAgeTotal tokensContext
{s.agentId}{s.model || "—"}{formatAgeMs(s.ageMs)}{s.totalTokens ?? "—"}{s.contextTokens ?? "—"}
+
+ ) : ( +
No recent sessions.
+ )} +
+ +
+
Recent Logs
+
+ Not wired yet. This will likely surface the last N lines of gateway + worker logs with filters. +
+
Empty state (intentional): no fake data.
+
+
+
+ ); +} diff --git a/src/app/overview/tasks/page.tsx b/src/app/overview/tasks/page.tsx new file mode 100644 index 0000000..8893eb4 --- /dev/null +++ b/src/app/overview/tasks/page.tsx @@ -0,0 +1,125 @@ +import Link from "next/link"; +import { unstable_noStore as noStore } from "next/cache"; + +import { getActiveSessions, getAgents, inferTeamIdFromWorkspace } from "@/lib/overview/overview-data"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +function fmtIso(tsMs: number | null) { + if (!tsMs) return "—"; + try { + return new Date(tsMs).toISOString().replace(".000Z", "Z"); + } catch { + return "—"; + } +} + +export default async function TasksRunningPage({ + searchParams, +}: { + searchParams?: Promise>; +}) { + noStore(); + + const sp = (await searchParams) ?? {}; + const teamRaw = sp.team; + const team = (Array.isArray(teamRaw) ? teamRaw[0] : teamRaw) || ""; + const teamFilter = team.trim(); + + const [agents, sessions5] = await Promise.all([getAgents(), getActiveSessions(5)]); + const agentsById = new Map(agents.map((a) => [a.id, a] as const)); + + const sessionsFiltered = sessions5.filter((s) => { + if (!teamFilter) return true; + const a = agentsById.get(s.agentId); + const t = inferTeamIdFromWorkspace(a?.workspace ?? null); + return t === teamFilter; + }); + + const rows = sessionsFiltered + .slice() + .sort((a, b) => b.updatedAt - a.updatedAt) + .map((s) => { + const a = agentsById.get(s.agentId); + const teamId = inferTeamIdFromWorkspace(a?.workspace ?? null); + const startedAt = s.ageMs != null ? s.updatedAt - s.ageMs : null; + const lastActivity = s.updatedAt; + return { + key: s.key, + agentId: s.agentId, + teamId: teamId ?? "—", + model: s.model ?? "—", + startedAt, + lastActivity, + }; + }); + + const backHref = teamFilter ? `/overview?team=${encodeURIComponent(teamFilter)}` : `/overview`; + + return ( +
+
+
+

Tasks Running

+

+ This is a best-effort view: we treat “tasks” as OpenClaw sessions active in the last 5 minutes. +

+
+ Filter: {teamFilter ? team={teamFilter} : all teams} +
+
+ +
+ + Overview → + +
+
+ +
+
+ Active sessions (last 5m) — {rows.length} +
+ + {rows.length ? ( +
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
sessionKeyagentIdteamIdmodelstartlast activity
{r.key}{r.agentId}{r.teamId}{r.model}{fmtIso(r.startedAt)}{fmtIso(r.lastActivity)}
+
+ ) : ( +
No active sessions in the last 5 minutes.
+ )} + +
+ Source: openclaw sessions --active 5 --all-agents --json +
+
+
+ ); +} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index aed09fa..b0d8d5c 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -202,6 +202,21 @@ export function AppShell({ children }: { children: React.ReactNode }) { }, []); const globalNav = [ + { + href: navHref(`/overview`), + label: "Kitchen Sink", + icon: ( + + + + + + + + + + ), + }, { href: `/`, label: "Agents", diff --git a/src/lib/openclaw.ts b/src/lib/openclaw.ts index b9f9ef8..2aaabf5 100644 --- a/src/lib/openclaw.ts +++ b/src/lib/openclaw.ts @@ -178,7 +178,15 @@ export async function runOpenClaw(args: string[]): Promise { return runOpenClawLocal(args); } - const api = getKitchenApi(); + let api: ReturnType; + try { + api = getKitchenApi(); + } catch { + // When Kitchen is started outside the gateway runtime (e.g. standalone Next.js server), + // the plugin API isn't injected. Fall back to local `openclaw` exec so pages can render. + return runOpenClawLocal(args); + } + try { const res = (await api.runtime.system.runCommandWithTimeout(["openclaw", ...args], { timeoutMs: 120000 })) as { stdout?: unknown; diff --git a/src/lib/overview/overview-data.ts b/src/lib/overview/overview-data.ts new file mode 100644 index 0000000..46240ac --- /dev/null +++ b/src/lib/overview/overview-data.ts @@ -0,0 +1,42 @@ +import { runOpenClaw } from "@/lib/openclaw"; + +export type AgentListItem = { + id: string; + identityName?: string | null; + workspace?: string | null; +}; + +export type SessionListItem = { + key: string; + updatedAt: number; + ageMs?: number; + agentId: string; + kind?: string; + model?: string; + contextTokens?: number; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; +}; + +export function inferTeamIdFromWorkspace(workspace: string | null | undefined) { + if (!workspace) return null; + const parts = workspace.split("/").filter(Boolean); + const wsPart = parts.find((p) => p.startsWith("workspace-")) ?? ""; + if (!wsPart) return null; + const team = wsPart.slice("workspace-".length); + return team || null; +} + +export async function getAgents(): Promise { + const res = await runOpenClaw(["agents", "list", "--json"]); + if (!res.ok) return []; + return JSON.parse(res.stdout) as AgentListItem[]; +} + +export async function getActiveSessions(minutes: number): Promise { + const res = await runOpenClaw(["sessions", "--active", String(minutes), "--all-agents", "--json"]); + if (!res.ok) return []; + const parsed = JSON.parse(res.stdout) as { sessions?: SessionListItem[] }; + return Array.isArray(parsed.sessions) ? parsed.sessions : []; +} diff --git a/src/lib/tickets.ts b/src/lib/tickets.ts index 3370ae9..5e21d20 100644 --- a/src/lib/tickets.ts +++ b/src/lib/tickets.ts @@ -38,11 +38,18 @@ export function teamWorkspace(teamId: string) { */ export function getTeamWorkspaceDir(teamId?: string): string { if (teamId) return teamWorkspace(teamId); - const envTeam = process.env.CK_TEAM_ID; - if (!envTeam && !process.env.CK_TEAM_WORKSPACE_DIR) { + + // Prefer explicit env vars when running outside a team-scoped UI context. + // CK_TEAM_WORKSPACE_DIR wins (it fully specifies the workspace path). + const envDir = process.env.CK_TEAM_WORKSPACE_DIR?.trim(); + if (envDir) return envDir; + + const envTeam = process.env.CK_TEAM_ID?.trim(); + if (!envTeam) { throw new Error("No team specified. Pass a teamId or set CK_TEAM_ID / CK_TEAM_WORKSPACE_DIR."); } - return process.env.CK_TEAM_WORKSPACE_DIR ?? teamWorkspace(envTeam!); + + return teamWorkspace(envTeam); } export function stageDir(stage: TicketStage, teamIdOrDir: string) {