feat(dashboard): add A2A audit page, stats bar on MCP audit, fix sidebar duplicates

- Add /dashboard/audit/a2a page with A2aAuditTab: lists tasks with skill/state
  filters, colored state badges, duration, events and artifacts counts
- Add "A2A Audit" item to Audit sidebar group (Monitoring section)
- Remove duplicate "MCP Audit" from MCP Server sidebar group — it stays
  only in the Audit group under Monitoring
- Improve McpAuditTab: fetch /api/mcp/audit/stats and show 4-card stat bar
  (calls 24h, success rate, avg duration, top tool) above the filters
- Add audit-a2a to HIDEABLE_SIDEBAR_ITEM_IDS
- Add i18n keys: auditA2a in sidebar + header sections, a2a* in compliance namespace
This commit is contained in:
diegosouzapw
2026-05-16 09:57:40 -03:00
parent 06bdc31a50
commit beb43a8bea
5 changed files with 321 additions and 6 deletions

View File

@@ -0,0 +1,217 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import type { A2ATask, TaskState } from "@/lib/a2a/taskManager";
type TaskListResponse = {
tasks: A2ATask[];
total: number;
limit: number;
offset: number;
};
const A2A_PAGE_SIZE = 25;
const STATE_STYLES: Record<TaskState, string> = {
submitted: "border-amber-500/30 bg-amber-500/10 text-amber-600",
working: "border-blue-500/30 bg-blue-500/10 text-blue-600",
completed: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600",
failed: "border-red-500/30 bg-red-500/10 text-red-600",
cancelled: "border-border bg-sidebar/40 text-text-muted",
};
function taskDuration(task: A2ATask): string {
const ms = new Date(task.updatedAt).getTime() - new Date(task.createdAt).getTime();
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
export default function A2aAuditTab() {
const t = useTranslations("compliance");
const [data, setData] = useState<TaskListResponse>({
tasks: [],
total: 0,
limit: A2A_PAGE_SIZE,
offset: 0,
});
const [loading, setLoading] = useState(true);
const [skillFilter, setSkillFilter] = useState("");
const [stateFilter, setStateFilter] = useState<TaskState | "all">("all");
const [offset, setOffset] = useState(0);
const fetchTasks = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set("limit", String(A2A_PAGE_SIZE));
params.set("offset", String(offset));
if (skillFilter) params.set("skill", skillFilter);
if (stateFilter !== "all") params.set("state", stateFilter);
const response = await fetch(`/api/a2a/tasks?${params.toString()}`);
const json = (await response.json().catch(() => ({}))) as Partial<TaskListResponse>;
setData({
tasks: Array.isArray(json.tasks) ? json.tasks : [],
total: Number(json.total || 0),
limit: Number(json.limit || A2A_PAGE_SIZE),
offset: Number(json.offset || offset),
});
} finally {
setLoading(false);
}
}, [offset, skillFilter, stateFilter]);
useEffect(() => {
void fetchTasks();
}, [fetchTasks]);
return (
<div className="space-y-5">
<Card className="p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-text-main">{t("a2aAudit")}</h2>
<p className="mt-1 text-sm text-text-muted">{t("a2aAuditDesc")}</p>
<p className="mt-2 text-xs text-text-muted">
{t("a2aShowingTasks", { count: data.tasks.length, total: data.total })}
</p>
</div>
<button
onClick={() => void fetchTasks()}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
<span
className={`material-symbols-outlined text-[16px] ${loading ? "animate-spin" : ""}`}
>
refresh
</span>
{t("refresh")}
</button>
</div>
</Card>
<Card className="p-4">
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("a2aSkill")}
</span>
<input
value={skillFilter}
onChange={(e) => {
setOffset(0);
setSkillFilter(e.target.value);
}}
placeholder={t("a2aSkillPlaceholder")}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("a2aState")}
</span>
<select
value={stateFilter}
onChange={(e) => {
setOffset(0);
setStateFilter(e.target.value as TaskState | "all");
}}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
>
<option value="all">{t("a2aAllStates")}</option>
<option value="submitted">{t("a2aStateSubmitted")}</option>
<option value="working">{t("a2aStateWorking")}</option>
<option value="completed">{t("a2aStateCompleted")}</option>
<option value="failed">{t("a2aStateFailed")}</option>
<option value="cancelled">{t("a2aStateCancelled")}</option>
</select>
</label>
<div className="flex items-end">
<button
onClick={() => {
setSkillFilter("");
setStateFilter("all");
setOffset(0);
}}
className="w-full rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar"
>
{t("clearFilters")}
</button>
</div>
</div>
</Card>
<Card className="overflow-hidden">
{loading ? (
<div className="p-8 text-center text-sm text-text-muted">{t("a2aLoadingTasks")}</div>
) : data.tasks.length === 0 ? (
<div className="p-10 text-center">
<span className="material-symbols-outlined text-[40px] text-text-muted">
device_hub
</span>
<p className="mt-3 text-sm text-text-muted">{t("a2aNoTasks")}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="border-b border-border bg-sidebar/40 text-xs uppercase tracking-wider text-text-muted">
<tr>
<th className="px-4 py-3 font-medium">{t("timestamp")}</th>
<th className="px-4 py-3 font-medium">{t("a2aTaskId")}</th>
<th className="px-4 py-3 font-medium">{t("a2aSkill")}</th>
<th className="px-4 py-3 font-medium">{t("a2aState")}</th>
<th className="px-4 py-3 font-medium">{t("duration")}</th>
<th className="px-4 py-3 font-medium">{t("a2aEvents")}</th>
<th className="px-4 py-3 font-medium">{t("a2aArtifacts")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{data.tasks.map((task) => (
<tr key={task.id} className="transition-colors hover:bg-sidebar/30">
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs text-text-muted">
{new Date(task.createdAt).toLocaleString()}
</td>
<td className="px-4 py-3 font-mono text-xs text-text-muted">
{task.id.slice(0, 8)}&hellip;
</td>
<td className="px-4 py-3 font-mono text-xs text-text-main">{task.skill}</td>
<td className="px-4 py-3">
<span
className={`rounded-full border px-2 py-1 text-xs font-medium ${STATE_STYLES[task.state]}`}
>
{task.state}
</span>
</td>
<td className="px-4 py-3 text-text-muted">{taskDuration(task)}</td>
<td className="px-4 py-3 text-text-muted">{task.events.length}</td>
<td className="px-4 py-3 text-text-muted">{task.artifacts.length}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setOffset((c) => Math.max(0, c - A2A_PAGE_SIZE))}
disabled={offset === 0 || loading}
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
{t("previous")}
</button>
<button
onClick={() => setOffset((c) => c + A2A_PAGE_SIZE)}
disabled={offset + A2A_PAGE_SIZE >= data.total || loading}
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
{t("next")}
</button>
</div>
</div>
);
}

View File

@@ -23,6 +23,13 @@ type McpAuditResponse = {
offset: number;
};
type McpAuditStats = {
totalCalls: number;
successRate: number;
avgDurationMs: number;
topTools: Array<{ tool: string; count: number }>;
};
const MCP_PAGE_SIZE = 25;
export default function McpAuditTab() {
@@ -33,11 +40,25 @@ export default function McpAuditTab() {
limit: MCP_PAGE_SIZE,
offset: 0,
});
const [stats, setStats] = useState<McpAuditStats | null>(null);
const [loading, setLoading] = useState(true);
const [toolFilter, setToolFilter] = useState("");
const [successFilter, setSuccessFilter] = useState<"all" | "true" | "false">("all");
const [offset, setOffset] = useState(0);
const fetchStats = useCallback(async () => {
try {
const res = await fetch("/api/mcp/audit/stats");
if (res.ok) setStats((await res.json()) as McpAuditStats);
} catch {
// non-fatal
}
}, []);
useEffect(() => {
void fetchStats();
}, [fetchStats]);
const fetchAudit = useCallback(async () => {
setLoading(true);
try {
@@ -80,7 +101,10 @@ export default function McpAuditTab() {
</p>
</div>
<button
onClick={() => void fetchAudit()}
onClick={() => {
void fetchAudit();
void fetchStats();
}}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
@@ -94,6 +118,54 @@ export default function McpAuditTab() {
</div>
</Card>
{stats && (
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{[
{
label: "Calls (24h)",
value: stats.totalCalls.toLocaleString(),
icon: "terminal",
},
{
label: "Success rate",
value: `${Math.round(stats.successRate * 100)}%`,
icon: "check_circle",
highlight: stats.successRate >= 0.9,
},
{
label: "Avg duration",
value: `${Math.round(stats.avgDurationMs)}ms`,
icon: "timer",
},
{
label: "Top tool",
value: stats.topTools[0]?.tool ?? "—",
icon: "star",
},
].map((item) => (
<Card key={item.label} className="px-4 py-3">
<div className="flex items-center gap-2 mb-1">
<span
className="material-symbols-outlined text-[14px]"
style={{ color: "var(--color-text-muted)" }}
>
{item.icon}
</span>
<span className="text-xs text-text-muted uppercase tracking-wider">
{item.label}
</span>
</div>
<p
className="text-lg font-semibold truncate"
style={{ color: item.highlight ? "rgb(34,197,94)" : "var(--color-text)" }}
>
{item.value}
</p>
</Card>
))}
</div>
)}
<Card className="p-4">
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1">

View File

@@ -0,0 +1,7 @@
"use client";
import A2aAuditTab from "../A2aAuditTab";
export default function AuditA2aPage() {
return <A2aAuditTab />;
}

View File

@@ -772,6 +772,7 @@
"logsConsole": "Console",
"logsActivity": "Activity",
"auditMcp": "MCP Audit",
"auditA2a": "A2A Audit",
"settingsGeneral": "General",
"settingsAppearance": "Appearance",
"settingsAi": "AI Settings",
@@ -890,7 +891,24 @@
"allResults": "All results",
"success": "Success",
"failure": "Failure",
"noMcpEvents": "No MCP audit events recorded."
"noMcpEvents": "No MCP audit events recorded.",
"a2aAudit": "A2A Audit",
"a2aAuditDesc": "Task execution audit trail recorded by the A2A server.",
"a2aShowingTasks": "Showing {count} of {total} tasks",
"a2aSkill": "Skill",
"a2aSkillPlaceholder": "Filter by skill name",
"a2aState": "State",
"a2aAllStates": "All states",
"a2aStateSubmitted": "Submitted",
"a2aStateWorking": "Working",
"a2aStateCompleted": "Completed",
"a2aStateFailed": "Failed",
"a2aStateCancelled": "Cancelled",
"a2aTaskId": "Task ID",
"a2aEvents": "Events",
"a2aArtifacts": "Artifacts",
"a2aNoTasks": "No A2A tasks recorded.",
"a2aLoadingTasks": "Loading A2A tasks..."
},
"themesPage": {
"title": "Themes",
@@ -965,6 +983,8 @@
"logsConsoleDescription": "Application console output and debug logs",
"logsActivityDescription": "Audit trail of user actions and system events",
"auditMcpDescription": "MCP tool invocation audit trail and compliance records",
"auditA2a": "A2A Audit",
"auditA2aDescription": "A2A task execution audit trail, state transitions, and skill invocation records",
"settingsGeneralDescription": "Storage, database, and general instance configuration",
"settingsAppearanceDescription": "Theme, branding, and visual customization",
"settingsAiDescription": "AI behaviors, thinking budgets, vision, and memory settings",

View File

@@ -43,6 +43,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
// Monitoring > Audit
"audit",
"audit-mcp",
"audit-a2a",
// Dev Tools
"translator",
"playground",
@@ -277,6 +278,7 @@ const AUDIT_GROUP: SidebarItemGroup = {
items: [
{ id: "audit", href: "/dashboard/audit", i18nKey: "auditLog", icon: "policy" },
{ id: "audit-mcp", href: "/dashboard/audit/mcp", i18nKey: "auditMcp", icon: "security" },
{ id: "audit-a2a", href: "/dashboard/audit/a2a", i18nKey: "auditA2a", icon: "device_hub" },
],
};
@@ -296,10 +298,7 @@ const MCP_GROUP: SidebarItemGroup = {
id: "mcp",
titleKey: "mcp",
titleFallback: "MCP Server",
items: [
{ id: "mcp", href: "/dashboard/mcp", i18nKey: "mcp", icon: "hub" },
{ id: "audit-mcp", href: "/dashboard/audit/mcp", i18nKey: "auditMcp", icon: "security" },
],
items: [{ id: "mcp", href: "/dashboard/mcp", i18nKey: "mcp", icon: "hub" }],
};
const AGENTIC_FEATURES_ITEMS: readonly SidebarSectionChild[] = [