From 12b957a2df66d3eddd4a98664e50402849a06c62 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:55:19 -0300 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20Conductor=20panel=20?= =?UTF-8?q?=E2=80=94=20fleet=20live=20view,=20task=20detail=20and=20cancel?= =?UTF-8?q?=20over=20/api/conductor=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/features/conductor-panel.md | 1 + .../conductor/ConductorPageClient.tsx | 233 ++++++++++++++++++ .../(dashboard)/dashboard/conductor/page.tsx | 10 + src/i18n/messages/en.json | 35 ++- src/i18n/messages/pt-BR.json | 35 ++- .../constants/sidebarVisibility/sections.ts | 9 + .../constants/sidebarVisibility/types.ts | 1 + tests/unit/conductor-panel-client.test.ts | 35 +++ tests/unit/sidebar-visibility.test.ts | 1 + 9 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 changelog.d/features/conductor-panel.md create mode 100644 src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx create mode 100644 src/app/(dashboard)/dashboard/conductor/page.tsx create mode 100644 tests/unit/conductor-panel-client.test.ts diff --git a/changelog.d/features/conductor-panel.md b/changelog.d/features/conductor-panel.md new file mode 100644 index 0000000000..ca6366a7cf --- /dev/null +++ b/changelog.d/features/conductor-panel.md @@ -0,0 +1 @@ +- feat(dashboard): "Conductor" panel — OmniConductor fleet (runners + task queue) live via server-side proxy routes (`/api/conductor/*`, management auth, hub token never reaches the browser), task detail with manifest/council and cancel-with-confirmation; sidebar entry under Tools diff --git a/src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx b/src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx new file mode 100644 index 0000000000..1bf1899bb6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx @@ -0,0 +1,233 @@ +"use client"; + +/** + * Conductor panel (PRD Conductor RF3): fleet + task queue live view over the + * /api/conductor proxy routes. The browser never talks to the hub — everything + * goes through the server-side proxy (hub token stays in server env). + */ + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; + +import { Badge, Card, ConfirmModal, DataTable, EmptyState, Modal } from "@/shared/components"; + +interface FleetRunner { + id: string; + name: string; + clis: string[]; + online: boolean; + draining: boolean; +} + +interface FleetTask { + id: string; + status: string; + mode: string; + repo: string | null; + runner: string | null; + summary: string | null; + branch: string | null; + error: string | null; + updated_at: string | null; +} + +interface FleetSnapshot { + offline: boolean; + runners: FleetRunner[]; + tasks: FleetTask[]; +} + +interface TaskDetail extends FleetTask { + prompt: string | null; + base_ref: string | null; + council: { candidate_task_ids?: string[] } | null; +} + +const REFRESH_MS = 5000; +const TERMINAL = new Set(["completed", "failed", "canceled"]); + +function statusVariant(status: string): "success" | "error" | "warning" | "info" | "default" { + if (status === "completed") return "success"; + if (status === "failed") return "error"; + if (status === "canceled" || status === "input_required") return "warning"; + if (status === "working") return "info"; + return "default"; +} + +export default function ConductorPageClient() { + const t = useTranslations("conductor"); + const [snapshot, setSnapshot] = useState(null); + const [detail, setDetail] = useState(null); + const [cancelTarget, setCancelTarget] = useState(null); + const [canceling, setCanceling] = useState(false); + const [err, setErr] = useState(""); + + const load = useCallback(async () => { + try { + const res = await fetch("/api/conductor/fleet"); + if (res.ok) setSnapshot(await res.json()); + } catch { + // rede local instável: mantém o último snapshot; o banner offline vem do servidor + } + }, []); + + useEffect(() => { + void load(); + const timer = setInterval(() => void load(), REFRESH_MS); + return () => clearInterval(timer); + }, [load]); + + const openDetail = async (taskId: string) => { + setErr(""); + try { + const res = await fetch(`/api/conductor/tasks/${encodeURIComponent(taskId)}`); + if (res.ok) setDetail(await res.json()); + else setErr(`${t("error")}: HTTP ${res.status}`); + } catch { + setErr(t("error")); + } + }; + + const confirmCancel = async () => { + if (!cancelTarget) return; + setCanceling(true); + setErr(""); + try { + const res = await fetch(`/api/conductor/tasks/${encodeURIComponent(cancelTarget)}/cancel`, { method: "POST" }); + if (!res.ok) setErr(`${t("cancelFailed")} (HTTP ${res.status})`); + else { + setDetail(null); + await load(); + } + } catch { + setErr(t("cancelFailed")); + } finally { + setCanceling(false); + setCancelTarget(null); + } + }; + + const runners = snapshot?.runners ?? []; + const tasks = snapshot?.tasks ?? []; + + return ( +
+
+

{t("title")}

+

{t("subtitle")}

+
+ + {err && {err}} + + {snapshot?.offline ? ( + + + + ) : ( + <> + + ({ ...r, id: r.id }))} + emptyMessage={t("noRunners")} + loading={snapshot === null} + renderCell={(row, column) => { + const r = row as unknown as FleetRunner; + if (column.key === "name") return {r.name}; + if (column.key === "clis") return r.clis.join(" / "); + if (r.draining) return {t("draining")}; + return r.online ? ( + {t("online")} + ) : ( + {t("offline")} + ); + }} + /> + + + + ({ ...task, id: task.id }))} + emptyMessage={t("noTasks")} + loading={snapshot === null} + onRowClick={(row) => void openDetail(String(row.id))} + renderCell={(row, column) => { + const task = row as unknown as FleetTask; + if (column.key === "status") return {task.status}; + if (column.key === "id") return {task.id}; + if (column.key === "summary") return task.summary ?? task.error ?? "—"; + return (task as unknown as Record)[column.key]?.toString() ?? "—"; + }} + /> + + + )} + + setDetail(null)} title={t("detailTitle")} size="lg"> + {detail && ( +
+
+ {detail.id} + {detail.status} + {detail.mode} + {detail.runner && {detail.runner}} +
+ {detail.prompt && ( +
+
{t("prompt")}
+
{detail.prompt}
+
+ )} + {detail.summary &&

{detail.summary}

} + {detail.error && {detail.error}} + {detail.branch && ( +
+
{t("branch")}
+ {detail.branch} +

{t("fetchHint", { branch: detail.branch })}

+
+ )} + {detail.mode.startsWith("council") && detail.council?.candidate_task_ids && ( +
+
{t("council")}
+

+ {t("candidates")}: {detail.council.candidate_task_ids.join(", ")} +

+
+ )} + {!TERMINAL.has(detail.status) && ( + + )} +
+ )} +
+ + setCancelTarget(null)} + onConfirm={confirmCancel} + title={t("cancelConfirmTitle")} + message={t("cancelConfirmMessage")} + confirmText={t("cancel")} + loading={canceling} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/conductor/page.tsx b/src/app/(dashboard)/dashboard/conductor/page.tsx new file mode 100644 index 0000000000..ab28f3d8d9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/conductor/page.tsx @@ -0,0 +1,10 @@ +import ConductorPageClient from "./ConductorPageClient"; + +export const metadata = { + title: "Conductor — OmniRoute", + description: "OmniConductor CLI-agent fleet: runners, task queue and councils, live.", +}; + +export default function ConductorPage() { + return ; +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 8e19bfd593..facf291e6c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1219,7 +1219,9 @@ "alwaysVisible": "Always visible", "groupSeparatorLabel": "Separator", "discovery": "Discovery", - "discoverySubtitle": "Scan providers for free access" + "discoverySubtitle": "Scan providers for free access", + "conductor": "Conductor", + "conductorSubtitle": "CLI-agent fleet" }, "webhooks": { "title": "Webhooks", @@ -12084,5 +12086,36 @@ "cta": "Get Kimi Code", "partnerLinkNote": "Partner link", "dismissAriaLabel": "Dismiss" + }, + "conductor": { + "title": "Conductor — CLI-agent fleet", + "subtitle": "OmniConductor hub: runners, task queue and councils, live", + "runners": "Runners", + "tasks": "Tasks", + "colName": "Name", + "colClis": "CLIs", + "colStatus": "Status", + "colTask": "Task", + "colMode": "Mode", + "colRunner": "Runner", + "colSummary": "Summary", + "online": "online", + "offline": "offline", + "draining": "draining", + "hubOffline": "Conductor hub offline or not configured (CONDUCTOR_HUB_URL) — showing nothing rather than stale data.", + "noRunners": "No runners registered", + "noTasks": "No tasks yet", + "detailTitle": "Task detail", + "prompt": "Prompt", + "branch": "Branch", + "fetchHint": "Fetch the result with: git fetch origin {branch}", + "council": "Council", + "candidates": "Candidates", + "cancel": "Cancel task", + "cancelConfirmTitle": "Cancel this task?", + "cancelConfirmMessage": "The hub will abort the execution on the runner. This cannot be undone.", + "cancelFailed": "The hub refused the cancellation", + "close": "Close", + "error": "Error" } } diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index ddc32eb949..80e39669b8 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -1219,7 +1219,9 @@ "alwaysVisible": "__MISSING__:Always visible", "groupSeparatorLabel": "__MISSING__:Separator", "discovery": "Descoberta", - "discoverySubtitle": "Buscar acesso gratuito em provedores" + "discoverySubtitle": "Buscar acesso gratuito em provedores", + "conductor": "Conductor", + "conductorSubtitle": "Frota de agentes CLI" }, "webhooks": { "title": "Webhooks", @@ -12084,5 +12086,36 @@ "cta": "Obter Kimi Code", "partnerLinkNote": "Link de parceria", "dismissAriaLabel": "Dispensar" + }, + "conductor": { + "title": "Conductor — frota de agentes CLI", + "subtitle": "Hub OmniConductor: runners, fila de tasks e councils, ao vivo", + "runners": "Runners", + "tasks": "Tasks", + "colName": "Nome", + "colClis": "CLIs", + "colStatus": "Status", + "colTask": "Task", + "colMode": "Modo", + "colRunner": "Runner", + "colSummary": "Resumo", + "online": "online", + "offline": "offline", + "draining": "drenando", + "hubOffline": "Hub do Conductor fora do ar ou não configurado (CONDUCTOR_HUB_URL) — melhor nada do que dado velho.", + "noRunners": "Nenhum runner registrado", + "noTasks": "Nenhuma task ainda", + "detailTitle": "Detalhe da task", + "prompt": "Prompt", + "branch": "Branch", + "fetchHint": "Busque o resultado com: git fetch origin {branch}", + "council": "Council", + "candidates": "Candidatos", + "cancel": "Cancelar task", + "cancelConfirmTitle": "Cancelar esta task?", + "cancelConfirmMessage": "O hub aborta a execução no runner. Não dá para desfazer.", + "cancelFailed": "O hub recusou o cancelamento", + "close": "Fechar", + "error": "Erro" } } diff --git a/src/shared/constants/sidebarVisibility/sections.ts b/src/shared/constants/sidebarVisibility/sections.ts index 3667842a05..ddf486a169 100644 --- a/src/shared/constants/sidebarVisibility/sections.ts +++ b/src/shared/constants/sidebarVisibility/sections.ts @@ -234,6 +234,15 @@ const TOOLS_GROUP: SidebarItemGroup = { subtitleKey: "cloudAgentsSubtitle", icon: "cloud", }, + { + id: "conductor", + href: "/dashboard/conductor", + i18nKey: "conductor", + subtitleKey: "conductorSubtitle", + icon: "account_tree", + labelFallback: "Conductor", + subtitleFallback: "CLI-agent fleet", + }, { id: "agent-bridge", href: "/dashboard/tools/agent-bridge", diff --git a/src/shared/constants/sidebarVisibility/types.ts b/src/shared/constants/sidebarVisibility/types.ts index c5172189d5..63e21c04a1 100644 --- a/src/shared/constants/sidebarVisibility/types.ts +++ b/src/shared/constants/sidebarVisibility/types.ts @@ -28,6 +28,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "cli-agents", "acp-agents", "cloud-agents", + "conductor", "agent-bridge", "traffic-inspector", "discovery", diff --git a/tests/unit/conductor-panel-client.test.ts b/tests/unit/conductor-panel-client.test.ts new file mode 100644 index 0000000000..ddc382cf2a --- /dev/null +++ b/tests/unit/conductor-panel-client.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +// Componentes React são cobertos pelo vitest-ui (advisory) + dashboard-typecheck; +// este source-scan trava os invariantes de segurança/UX que revisão nenhuma pode perder. +const CLIENT = "src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx"; +const PAGE = "src/app/(dashboard)/dashboard/conductor/page.tsx"; + +test("client: poll com setInterval + clearInterval; fala SÓ com /api/conductor (nunca com o hub)", () => { + const src = fs.readFileSync(path.join(process.cwd(), CLIENT), "utf8"); + assert.match(src, /"use client"/); + assert.match(src, /setInterval\(/); + assert.match(src, /clearInterval\(/); + assert.match(src, /\/api\/conductor\/fleet/); + assert.ok(!src.includes("CONDUCTOR_HUB"), "nenhuma env do hub no client"); + assert.ok(!src.includes(":7910"), "nenhum endereço de hub hardcoded no client"); +}); + +test("client: cancelar é destrutivo → ConfirmModal antes do POST", () => { + const src = fs.readFileSync(path.join(process.cwd(), CLIENT), "utf8"); + assert.match(src, /ConfirmModal/); + const confirmAt = src.indexOf(" 0 && cancelPost > 0, "ConfirmModal e POST cancel presentes"); + assert.match(src, /useTranslations\("conductor"\)/, "strings via i18n, namespace conductor"); +}); + +test("page: wrapper fino de servidor com metadata (padrão relay)", () => { + const src = fs.readFileSync(path.join(process.cwd(), PAGE), "utf8"); + assert.match(src, /export const metadata/); + assert.match(src, /ConductorPageClient/); + assert.ok(!src.includes('"use client"'), "page.tsx é server component fino"); +}); diff --git a/tests/unit/sidebar-visibility.test.ts b/tests/unit/sidebar-visibility.test.ts index 721e00e29b..ba170b4d9b 100644 --- a/tests/unit/sidebar-visibility.test.ts +++ b/tests/unit/sidebar-visibility.test.ts @@ -62,6 +62,7 @@ test("primary sidebar items place limits after cache", () => { "cli-agents", "acp-agents", "cloud-agents", + "conductor", "agent-bridge", "traffic-inspector", "discovery",