mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(dashboard): Conductor panel — fleet live view, task detail and cancel over /api/conductor proxy
This commit is contained in:
1
changelog.d/features/conductor-panel.md
Normal file
1
changelog.d/features/conductor-panel.md
Normal file
@@ -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
|
||||||
233
src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx
Normal file
233
src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx
Normal file
@@ -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<FleetSnapshot | null>(null);
|
||||||
|
const [detail, setDetail] = useState<TaskDetail | null>(null);
|
||||||
|
const [cancelTarget, setCancelTarget] = useState<string | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||||
|
<p className="text-sm text-text-muted">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <Badge variant="error">{err}</Badge>}
|
||||||
|
|
||||||
|
{snapshot?.offline ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState icon="cloud_off" title={t("hubOffline")} />
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card title={t("runners")}>
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: "name", label: t("colName") },
|
||||||
|
{ key: "clis", label: t("colClis") },
|
||||||
|
{ key: "status", label: t("colStatus") },
|
||||||
|
]}
|
||||||
|
data={runners.map((r) => ({ ...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 <span className="font-medium">{r.name}</span>;
|
||||||
|
if (column.key === "clis") return r.clis.join(" / ");
|
||||||
|
if (r.draining) return <Badge variant="warning" dot>{t("draining")}</Badge>;
|
||||||
|
return r.online ? (
|
||||||
|
<Badge variant="success" dot>{t("online")}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="error" dot>{t("offline")}</Badge>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t("tasks")}>
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: "id", label: t("colTask") },
|
||||||
|
{ key: "status", label: t("colStatus") },
|
||||||
|
{ key: "mode", label: t("colMode") },
|
||||||
|
{ key: "runner", label: t("colRunner") },
|
||||||
|
{ key: "summary", label: t("colSummary"), maxWidth: "28rem" },
|
||||||
|
]}
|
||||||
|
data={tasks.map((task) => ({ ...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 <Badge variant={statusVariant(task.status)} dot>{task.status}</Badge>;
|
||||||
|
if (column.key === "id") return <code className="text-xs">{task.id}</code>;
|
||||||
|
if (column.key === "summary") return task.summary ?? task.error ?? "—";
|
||||||
|
return (task as unknown as Record<string, unknown>)[column.key]?.toString() ?? "—";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal isOpen={detail !== null} onClose={() => setDetail(null)} title={t("detailTitle")} size="lg">
|
||||||
|
{detail && (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs">{detail.id}</code>
|
||||||
|
<Badge variant={statusVariant(detail.status)} dot>{detail.status}</Badge>
|
||||||
|
<Badge>{detail.mode}</Badge>
|
||||||
|
{detail.runner && <Badge variant="info">{detail.runner}</Badge>}
|
||||||
|
</div>
|
||||||
|
{detail.prompt && (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{t("prompt")}</div>
|
||||||
|
<pre className="whitespace-pre-wrap text-xs bg-black/5 dark:bg-white/5 rounded p-2">{detail.prompt}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.summary && <p>{detail.summary}</p>}
|
||||||
|
{detail.error && <Badge variant="error">{detail.error}</Badge>}
|
||||||
|
{detail.branch && (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{t("branch")}</div>
|
||||||
|
<code className="text-xs">{detail.branch}</code>
|
||||||
|
<p className="text-xs text-text-muted">{t("fetchHint", { branch: detail.branch })}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.mode.startsWith("council") && detail.council?.candidate_task_ids && (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{t("council")}</div>
|
||||||
|
<p className="text-xs">
|
||||||
|
{t("candidates")}: {detail.council.candidate_task_ids.join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!TERMINAL.has(detail.status) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm text-red-600 dark:text-red-400 underline"
|
||||||
|
onClick={() => setCancelTarget(detail.id)}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={cancelTarget !== null}
|
||||||
|
onClose={() => setCancelTarget(null)}
|
||||||
|
onConfirm={confirmCancel}
|
||||||
|
title={t("cancelConfirmTitle")}
|
||||||
|
message={t("cancelConfirmMessage")}
|
||||||
|
confirmText={t("cancel")}
|
||||||
|
loading={canceling}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/app/(dashboard)/dashboard/conductor/page.tsx
Normal file
10
src/app/(dashboard)/dashboard/conductor/page.tsx
Normal file
@@ -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 <ConductorPageClient />;
|
||||||
|
}
|
||||||
@@ -1219,7 +1219,9 @@
|
|||||||
"alwaysVisible": "Always visible",
|
"alwaysVisible": "Always visible",
|
||||||
"groupSeparatorLabel": "Separator",
|
"groupSeparatorLabel": "Separator",
|
||||||
"discovery": "Discovery",
|
"discovery": "Discovery",
|
||||||
"discoverySubtitle": "Scan providers for free access"
|
"discoverySubtitle": "Scan providers for free access",
|
||||||
|
"conductor": "Conductor",
|
||||||
|
"conductorSubtitle": "CLI-agent fleet"
|
||||||
},
|
},
|
||||||
"webhooks": {
|
"webhooks": {
|
||||||
"title": "Webhooks",
|
"title": "Webhooks",
|
||||||
@@ -12084,5 +12086,36 @@
|
|||||||
"cta": "Get Kimi Code",
|
"cta": "Get Kimi Code",
|
||||||
"partnerLinkNote": "Partner link",
|
"partnerLinkNote": "Partner link",
|
||||||
"dismissAriaLabel": "Dismiss"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1219,7 +1219,9 @@
|
|||||||
"alwaysVisible": "__MISSING__:Always visible",
|
"alwaysVisible": "__MISSING__:Always visible",
|
||||||
"groupSeparatorLabel": "__MISSING__:Separator",
|
"groupSeparatorLabel": "__MISSING__:Separator",
|
||||||
"discovery": "Descoberta",
|
"discovery": "Descoberta",
|
||||||
"discoverySubtitle": "Buscar acesso gratuito em provedores"
|
"discoverySubtitle": "Buscar acesso gratuito em provedores",
|
||||||
|
"conductor": "Conductor",
|
||||||
|
"conductorSubtitle": "Frota de agentes CLI"
|
||||||
},
|
},
|
||||||
"webhooks": {
|
"webhooks": {
|
||||||
"title": "Webhooks",
|
"title": "Webhooks",
|
||||||
@@ -12084,5 +12086,36 @@
|
|||||||
"cta": "Obter Kimi Code",
|
"cta": "Obter Kimi Code",
|
||||||
"partnerLinkNote": "Link de parceria",
|
"partnerLinkNote": "Link de parceria",
|
||||||
"dismissAriaLabel": "Dispensar"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,6 +234,15 @@ const TOOLS_GROUP: SidebarItemGroup = {
|
|||||||
subtitleKey: "cloudAgentsSubtitle",
|
subtitleKey: "cloudAgentsSubtitle",
|
||||||
icon: "cloud",
|
icon: "cloud",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "conductor",
|
||||||
|
href: "/dashboard/conductor",
|
||||||
|
i18nKey: "conductor",
|
||||||
|
subtitleKey: "conductorSubtitle",
|
||||||
|
icon: "account_tree",
|
||||||
|
labelFallback: "Conductor",
|
||||||
|
subtitleFallback: "CLI-agent fleet",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "agent-bridge",
|
id: "agent-bridge",
|
||||||
href: "/dashboard/tools/agent-bridge",
|
href: "/dashboard/tools/agent-bridge",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
|||||||
"cli-agents",
|
"cli-agents",
|
||||||
"acp-agents",
|
"acp-agents",
|
||||||
"cloud-agents",
|
"cloud-agents",
|
||||||
|
"conductor",
|
||||||
"agent-bridge",
|
"agent-bridge",
|
||||||
"traffic-inspector",
|
"traffic-inspector",
|
||||||
"discovery",
|
"discovery",
|
||||||
|
|||||||
35
tests/unit/conductor-panel-client.test.ts
Normal file
35
tests/unit/conductor-panel-client.test.ts
Normal file
@@ -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("<ConfirmModal");
|
||||||
|
const cancelPost = src.indexOf("/cancel");
|
||||||
|
assert.ok(confirmAt > 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");
|
||||||
|
});
|
||||||
@@ -62,6 +62,7 @@ test("primary sidebar items place limits after cache", () => {
|
|||||||
"cli-agents",
|
"cli-agents",
|
||||||
"acp-agents",
|
"acp-agents",
|
||||||
"cloud-agents",
|
"cloud-agents",
|
||||||
|
"conductor",
|
||||||
"agent-bridge",
|
"agent-bridge",
|
||||||
"traffic-inspector",
|
"traffic-inspector",
|
||||||
"discovery",
|
"discovery",
|
||||||
|
|||||||
Reference in New Issue
Block a user