Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion dashboard/src/main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
formatRuntimeUsageSeconds,
groupSessionEvents,
groupTranscriptEntries,
hasActionableAutoupdate,
isKnowledgePath,
isMcpPath,
isRetryableStatus,
Expand Down Expand Up @@ -116,9 +117,10 @@ describe("dashboard routing and API query helpers", () => {

it("shows a knowledge badge when proposed rules need moderation", () => {
const onNavigate = vi.fn();
const { rerender } = render(<SectionNav isDashboardRoute={true} isSystemRoute={false} isKnowledgeRoute={false} isMcpRoute={false} knowledgeBadgeCount={2} />);
const { rerender } = render(<SectionNav isDashboardRoute={true} isSystemRoute={false} isKnowledgeRoute={false} isMcpRoute={false} knowledgeBadgeCount={2} systemUpdateAvailable />);

expect(screen.getByRole("link", { name: /Knowledge/i })).toContainElement(screen.getByLabelText("2 proposed knowledge items"));
expect(screen.getByRole("link", { name: /System/i })).toContainElement(screen.getByLabelText("System update available"));
expect(screen.getByRole("link", { name: /Jobs/i })).toHaveClass("bg-primary");
expect(screen.getByRole("link", { name: /System/i })).not.toHaveClass("bg-primary");
expect(screen.getByRole("link", { name: /MCP/i })).not.toHaveClass("bg-primary");
Expand Down Expand Up @@ -1112,6 +1114,64 @@ describe("autoupdate notice", () => {
warnings: [],
};

it("treats only non-noop releases as actionable", () => {
expect(hasActionableAutoupdate(updateState)).toBe(true);
expect(hasActionableAutoupdate({ ...updateState, decision: "noop" })).toBe(false);
expect(hasActionableAutoupdate({ ...updateState, target: undefined })).toBe(false);
});

it("keeps update attention on System and renders update controls only there", async () => {
window.history.replaceState({}, "", "/");
class ResizeObserverMock {
observe() {}
disconnect() {}
unobserve() {}
}
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
const jsonResponse = (body: unknown) => Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }));
const emptyMetrics = {
db_exists: true,
status_counts: {},
by_repo: {},
by_action: {},
by_intent: {},
by_created_day: {},
runtime_usage: { day: [], month: [] },
runtime_seconds: { median: null, p90: null, p99: null },
queue_wait_seconds: { median: null, p90: null, p99: null },
};
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const path = String(input);
if (path.startsWith("/api/metrics/summary")) return jsonResponse({ metrics: emptyMetrics });
if (path === "/api/status") return jsonResponse({ service: "github-agent-bridge-dashboard", read_only: false, admin_actions: [], autoupdate: updateState });
if (path === "/api/me") return jsonResponse({ user: { login: "operator", avatar_url: "", html_url: "", is_admin: true } });
if (path === "/api/about") return jsonResponse({ service: "github-agent-bridge", version: "0.67.0", repository_url: "https://github.com/gisce/github-agent-bridge" });
if (path === "/api/web-push/config") return jsonResponse({ configured: false, public_key: "", status: { enabled: false, subscriptions: [] } });
if (path === "/api/jobs/actors") return jsonResponse({ actors: [] });
if (path.startsWith("/api/jobs?")) return jsonResponse({ jobs: [] });
if (path === "/api/processes") return jsonResponse({ running_jobs: [], executor: { service: "bridge", pid: null, children: [] }, signals: { live_process: { state: "idle", child_count: 0 }, process_activity: { state: "idle", idle_seconds: null, sample_ts: null }, semantic_progress: [], visible_progress: [] }, alerts: [], samples: [], detail: "" });
if (path === "/api/systemd") return jsonResponse({ available: true, units: [], errors: [] });
if (path === "/api/alerts") return jsonResponse({ alerts: [] });
throw new Error(`Unexpected fetch: ${path}`);
});
vi.stubGlobal("fetch", fetchMock);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const user = userEvent.setup();
render(<QueryClientProvider client={client}><App /></QueryClientProvider>);

const systemLink = await screen.findByRole("link", { name: /System/ });
expect(systemLink).toContainElement(await screen.findByLabelText("System update available"));
expect(screen.queryByLabelText("Update available")).not.toBeInTheDocument();

await user.click(systemLink);

expect(await screen.findByLabelText("Update available")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /apply update/i })).toBeInTheDocument();

window.history.replaceState({}, "", "/");
vi.unstubAllGlobals();
});

it("shows release impact only to admins", () => {
const { rerender } = render(<AutoupdateNotice state={updateState} isAdmin={false} />);
expect(screen.queryByLabelText("Update available")).not.toBeInTheDocument();
Expand Down
63 changes: 42 additions & 21 deletions dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,7 @@ function App() {

const counts = metrics.data?.metrics.status_counts ?? {};
const jobRows = jobs.data?.jobs ?? [];
const systemUpdateAvailable = Boolean(me.data?.user?.is_admin && hasActionableAutoupdate(dashboardStatus.data?.autoupdate));
const applyFilters = React.useCallback((nextFilters: JobFilters) => {
setFilters(nextFilters);
setJobLimit(initialJobLimit);
Expand Down Expand Up @@ -1306,7 +1307,7 @@ function App() {
</header>

<main className="mx-auto grid w-full max-w-[1440px] gap-4 px-3 py-4 sm:px-4 md:px-6 md:py-5">
<SectionNav isDashboardRoute={isDashboardRoute} isSystemRoute={isSystemRoute} isKnowledgeRoute={isKnowledgeRoute} isMcpRoute={isMcpRoute} isWebhooksRoute={isWebhooksRoute} showWebhooks={Boolean(me.data?.user?.is_admin && dashboardStatus.data?.webhook_configured)} knowledgeBadgeCount={dashboardStatus.data?.metrics?.knowledge?.proposed ?? 0} onNavigate={navigateDashboard} />
<SectionNav isDashboardRoute={isDashboardRoute} isSystemRoute={isSystemRoute} isKnowledgeRoute={isKnowledgeRoute} isMcpRoute={isMcpRoute} isWebhooksRoute={isWebhooksRoute} showWebhooks={Boolean(me.data?.user?.is_admin && dashboardStatus.data?.webhook_configured)} knowledgeBadgeCount={dashboardStatus.data?.metrics?.knowledge?.proposed ?? 0} systemUpdateAvailable={systemUpdateAvailable} onNavigate={navigateDashboard} />
<WebPushToast notification={inAppPush} onDismiss={() => setInAppPush(null)} onNavigate={navigateDashboard} />
{jobRouteId !== null ? (
<JobDetailPage
Expand Down Expand Up @@ -1392,25 +1393,7 @@ function App() {
/>
)
) : isSystemRoute ? (
<SystemPage
processes={processes.data}
processesLoading={processes.isLoading}
processesError={processes.error}
systemd={systemd.data}
systemdLoading={systemd.isLoading}
systemdError={systemd.error}
alerts={alerts.data?.alerts}
alertsLoading={alerts.isLoading}
alertsError={alerts.error}
now={now}
onRefreshProcesses={() => processes.refetch()}
onRefreshSystemd={() => systemd.refetch()}
onRefreshAlerts={() => alerts.refetch()}
/>
) : (
<>
{metrics.error ? <Banner tone="error" text={metrics.error.message} /> : null}
{dashboardStatus.error ? <Banner tone="error" text={dashboardStatus.error.message} /> : null}
<AutoupdateNotice
state={dashboardStatus.data?.autoupdate}
isAdmin={Boolean(me.data?.user?.is_admin)}
Expand All @@ -1420,6 +1403,26 @@ function App() {
onApply={() => runAutoupdateAction("apply")}
onCompletePending={() => runAutoupdateAction("complete")}
/>
<SystemPage
processes={processes.data}
processesLoading={processes.isLoading}
processesError={processes.error}
systemd={systemd.data}
systemdLoading={systemd.isLoading}
systemdError={systemd.error}
alerts={alerts.data?.alerts}
alertsLoading={alerts.isLoading}
alertsError={alerts.error}
now={now}
onRefreshProcesses={() => processes.refetch()}
onRefreshSystemd={() => systemd.refetch()}
onRefreshAlerts={() => alerts.refetch()}
/>
</>
) : (
<>
{metrics.error ? <Banner tone="error" text={metrics.error.message} /> : null}
{dashboardStatus.error ? <Banner tone="error" text={dashboardStatus.error.message} /> : null}
<section className="grid grid-cols-2 gap-3 xl:grid-cols-4" aria-label="Summary metrics">
<Metric title="Pending" value={counts.pending ?? 0} icon={<Clock3 className="h-5 w-5" />} />
<Metric title="Running" value={counts.running ?? 0} icon={<Activity className="h-5 w-5" />} />
Expand Down Expand Up @@ -1485,6 +1488,10 @@ function ProductMeta({ about }: { about: About | undefined }) {
);
}

function hasActionableAutoupdate(state: AutoupdateState | undefined) {
return Boolean(state?.target?.tag_name?.trim() && state.decision !== "noop");
}

function AutoupdateNotice({
state,
isAdmin,
Expand All @@ -1502,9 +1509,9 @@ function AutoupdateNotice({
onApply?: () => Promise<void> | void;
onCompletePending?: () => Promise<void> | void;
}) {
if (!state) return null;
if (!state || !hasActionableAutoupdate(state)) return null;
const targetTag = state?.target?.tag_name?.trim();
if (!isAdmin || !targetTag || state?.decision === "noop") return null;
if (!isAdmin || !targetTag) return null;
const decision = autoupdateDecisionLabel(state.decision);
const activeTotal = state.queue?.active_total ?? 0;
const risk = autoupdateRiskLabel(state.classification?.risk);
Expand Down Expand Up @@ -1669,6 +1676,7 @@ function SectionNav({
isWebhooksRoute = false,
showWebhooks = false,
knowledgeBadgeCount = 0,
systemUpdateAvailable = false,
onNavigate,
}: {
isDashboardRoute: boolean;
Expand All @@ -1678,6 +1686,7 @@ function SectionNav({
isWebhooksRoute?: boolean;
showWebhooks?: boolean;
knowledgeBadgeCount?: number;
systemUpdateAvailable?: boolean;
onNavigate?: (path: string) => void;
}) {
return (
Expand All @@ -1689,6 +1698,17 @@ function SectionNav({
<SectionLink href="/system" active={isSystemRoute} onNavigate={onNavigate}>
<Gauge className="h-4 w-4" aria-hidden />
<span>System</span>
{systemUpdateAvailable ? (
<span
className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-full border px-1 font-mono text-[11px] leading-none",
isSystemRoute ? "border-white/40 bg-white/15 text-white" : "border-amber-200 bg-amber-100 text-amber-800",
)}
aria-label="System update available"
>
!
</span>
) : null}
</SectionLink>
{showWebhooks ? (
<SectionLink href="/webhooks" active={isWebhooksRoute} onNavigate={onNavigate}>
Expand Down Expand Up @@ -4437,6 +4457,7 @@ export {
buildJobQuery,
buildKnowledgeQuery,
changelogMarkdown,
hasActionableAutoupdate,
isKnowledgePath,
isMcpPath,
isSystemPath,
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/github_agent_bridge/dashboard_static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitHub Agent Bridge Dashboard</title>
<script type="module" crossorigin src="/assets/index-DzCW97RR.js"></script>
<script type="module" crossorigin src="/assets/index-E8xF6mP2.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-SqBiqy9C.js">
<link rel="stylesheet" crossorigin href="/assets/index-BDe9F9HC.css">
</head>
Expand Down
Loading