diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index fb7198ce7..ab77c5c89 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -12,7 +12,7 @@ closes a phase; dated entries below provide the evidence, not a second status so | 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | | 1 — Foundation | Complete | The self-contained future root builds immutable browser/web/worker artifacts, protects project-local production state, installs exact Bun and systemd artifacts, migrates a database copy, atomically promotes the release/database pair, serves readiness/browser assets, writes project-local logs, and proves crash-safe rollback and shutdown in a disposable lifecycle. | | 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | -| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Monitoring ingestion plus report, incident, and notification server parity are implemented. Their browser workflows, schedules/jobs, overview, cache/metrics, and the real worker remain open. | +| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Monitoring ingestion plus report, incident, and notification server parity are implemented; report and incident browser readers are also complete. Notification browser state, schedules/jobs, overview, cache/metrics, and the real worker remain open. | | 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. | | 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. | | 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | @@ -868,3 +868,25 @@ full-browser parity, production rehearsal, cutover, and legacy deletion remain o reader rather than choosing one arbitrary observation report; greenfield stays inactive until the complete cutover stack lands. Browser workflows, schedules/jobs, overview, cache/metrics, and real worker execution remain open Phase 3 gates. + +### 2026-08-07 — Phase 3 report and incident browser readers + +- `/reports` now lists only bounded report summaries and loads the potentially large Markdown body + through an exact query after explicit selection or a validated UUIDv7 deep link. Status, free-form + kind, and source filters apply atomically; overlapping keyset pages are identity-deduplicated; + transient refresh failures preserve usable cached rows; and raw HTML remains disabled in the + existing shared Markdown renderer. +- Report deletion has an explicit confirmation boundary, removes the durable success from every + cached filtered page before refetch, and presents fixed `NOT_FOUND` and bounded + `PRECONDITION_FAILED` outcomes without exposing server text. Exact detail loading remains + independent of list availability, so a valid deep link can still render during a catalog-list + failure. +- Net-new `/incidents` is intentionally absent from the main navigation but linked from Reports. + It provides kind, monitor, lifecycle-state, and severity filters; a selectable TanStack Table + with the shared virtualizer; and exact detail deep links outside the currently loaded page. +- `monitoring.reports` and `monitoring.incidents` use the shared coalescing invalidation hook, + terminal-resync handling, and 30-second fallback refresh. Both routes retain the authenticated + boundary, validated lazy tRPC contract loading, cancellation signals, and separate query roots. +- Frontend parity now marks legacy `/reports` implemented. `/incidents` has no legacy route and is + tracked as a net-new reader. The notification center, schedules/jobs, overview, cache/metrics, + and the real worker remain open Phase 3 gates. diff --git a/greenfield/src/browser/api/trpcClient.test.ts b/greenfield/src/browser/api/trpcClient.test.ts index f6eb70207..878f82368 100644 --- a/greenfield/src/browser/api/trpcClient.test.ts +++ b/greenfield/src/browser/api/trpcClient.test.ts @@ -62,6 +62,30 @@ describe("Dashboard browser tRPC client", () => { expect(calls).toEqual([{ input: {}, kind: "mutation", path: "auth.logout" }]); }); + test("loads report and incident reader contracts on demand", async () => { + const reportCalls: TransportCall[] = []; + const incidentCalls: TransportCall[] = []; + const reportClient = createDashboardTrpcClient( + createRecordingTransport({ reports: [] }, reportCalls) + ); + const incidentClient = createDashboardTrpcClient( + createRecordingTransport({ incidents: [] }, incidentCalls) + ); + + expect(await reportClient.query("reports.list", { limit: 50 })).toEqual({ + reports: [], + }); + expect(await incidentClient.query("incidents.list", { limit: 50 })).toEqual({ + incidents: [], + }); + expect(reportCalls).toEqual([ + { input: { limit: 50 }, kind: "query", path: "reports.list" }, + ]); + expect(incidentCalls).toEqual([ + { input: { limit: 50 }, kind: "query", path: "incidents.list" }, + ]); + }); + test("rejects invalid input before transport access", async () => { const calls: TransportCall[] = []; const client = createDashboardTrpcClient( diff --git a/greenfield/src/browser/api/trpcClient.ts b/greenfield/src/browser/api/trpcClient.ts index 6b2030ad3..ab49eec5e 100644 --- a/greenfield/src/browser/api/trpcClient.ts +++ b/greenfield/src/browser/api/trpcClient.ts @@ -72,6 +72,10 @@ async function procedureContractsFor( const module = await import("../../contracts/agents.ts"); return module.agentProcedureContracts; } + case "incidents": { + const module = await import("../../contracts/incidents.ts"); + return module.incidentProcedureContracts; + } case "accountSecurity": { const module = await import("../../contracts/accountSecurity.ts"); return module.accountSecurityProcedureContracts; @@ -80,6 +84,10 @@ async function procedureContractsFor( const module = await import("../../contracts/auth.ts"); return module.authProcedureContracts; } + case "reports": { + const module = await import("../../contracts/reports.ts"); + return module.reportProcedureContracts; + } case "automationSecurity": { const module = await import("../../contracts/automationSecurity.ts"); return module.automationSecurityProcedureContracts; diff --git a/greenfield/src/browser/layout/DashboardShell.tsx b/greenfield/src/browser/layout/DashboardShell.tsx index e530a0bd5..8c42d6605 100644 --- a/greenfield/src/browser/layout/DashboardShell.tsx +++ b/greenfield/src/browser/layout/DashboardShell.tsx @@ -1,9 +1,21 @@ import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from "@headlessui/react"; import { Outlet, useLocation } from "@tanstack/react-router"; -import { Bot, Home, ListTodo, Menu, ShieldCheck, X, type LucideIcon } from "lucide-react"; +import { + Bot, + Home, + ListTodo, + Menu, + Newspaper, + ShieldCheck, + X, + type LucideIcon, +} from "lucide-react"; import { useState } from "react"; -import type { DashboardNavigationPath } from "../lib/dashboardRoutes.ts"; +import type { + DashboardAuthenticatedPath, + DashboardNavigationPath, +} from "../lib/dashboardRoutes.ts"; import { Icon } from "../ui/Icon.tsx"; import { IconOnlyButton } from "../ui/IconOnlyButton.tsx"; import { NavigationLink } from "../ui/NavigationLink.tsx"; @@ -18,8 +30,16 @@ const navigationItems: readonly NavigationItem[] = Object.freeze([ { icon: Home, label: "Dashboard", to: "/" }, { icon: Bot, label: "Agents", to: "/agents" }, { icon: ListTodo, label: "Tasks", to: "/tasks" }, + { icon: Newspaper, label: "Reports", to: "/reports" }, { icon: ShieldCheck, label: "Account security", to: "/account-security" }, ]); +const routeTitles: readonly Pick[] = Object.freeze([ + ...navigationItems, +]); +const authenticatedRouteTitles: readonly { + readonly label: string; + readonly to: DashboardAuthenticatedPath; +}[] = Object.freeze([...routeTitles, { label: "Incidents", to: "/incidents" }]); interface NavigationProps { readonly currentPath: string; @@ -99,7 +119,7 @@ export function DashboardShell() { } const currentTitle = - navigationItems.find((item) => item.to === location.pathname)?.label ?? + authenticatedRouteTitles.find((item) => item.to === location.pathname)?.label ?? "Mira Dashboard"; return (
diff --git a/greenfield/src/browser/lib/dashboardRoutes.ts b/greenfield/src/browser/lib/dashboardRoutes.ts index 6964a90c7..9e5aa7427 100644 --- a/greenfield/src/browser/lib/dashboardRoutes.ts +++ b/greenfield/src/browser/lib/dashboardRoutes.ts @@ -3,11 +3,16 @@ export const dashboardRoutePaths = Object.freeze([ "/", "/account-security", "/agents", + "/incidents", "/login", + "/reports", "/tasks", ] as const); export type DashboardRoutePath = (typeof dashboardRoutePaths)[number]; -/** Routes shown inside the authenticated application navigation. */ -export type DashboardNavigationPath = Exclude; +/** Routes rendered only inside an authenticated application shell. */ +export type DashboardAuthenticatedPath = Exclude; + +/** Authenticated routes shown in the main application navigation. */ +export type DashboardNavigationPath = Exclude; diff --git a/greenfield/src/browser/monitoring/IncidentBrowser.tsx b/greenfield/src/browser/monitoring/IncidentBrowser.tsx new file mode 100644 index 000000000..d9adbf24e --- /dev/null +++ b/greenfield/src/browser/monitoring/IncidentBrowser.tsx @@ -0,0 +1,303 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { Filter, RotateCcw, ShieldAlert } from "lucide-react"; +import { type FormEvent, type ReactNode, useState } from "react"; + +import type { ListIncidentsInput } from "../../contracts/incidents.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { dashboardBrowserFailureMessage } from "../api/trpcError.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Badge } from "../ui/Badge.tsx"; +import { Button } from "../ui/Button.tsx"; +import { Card } from "../ui/Card.tsx"; +import { FormField } from "../ui/FormField.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Input } from "../ui/Input.tsx"; +import { PageState } from "../ui/PageState.tsx"; +import { Select } from "../ui/Select.tsx"; +import { Text } from "../ui/Text.tsx"; +import { incidentSeverityVariant } from "./incidentPresentation.ts"; +import { IncidentTable } from "./IncidentTable.tsx"; +import { + incidentDetailQueryOptions, + incidentListQueryOptions, + uniqueMonitoringRows, +} from "./monitoringQueries.ts"; +import { parseIncidentsRouteSearch } from "./monitoringRouteSearch.ts"; + +const incidentFilters = Object.freeze([ + { label: "All", value: "all" }, + { label: "Active", value: "active" }, + { label: "Resolved", value: "resolved" }, +] as const); + +type IncidentFilter = (typeof incidentFilters)[number]["value"]; + +const severityFilters = Object.freeze([ + { label: "All severities", value: "all" }, + { label: "Critical", value: "critical" }, + { label: "Error", value: "error" }, + { label: "Warning", value: "warning" }, + { label: "Info", value: "info" }, +] as const); + +type SeverityFilter = (typeof severityFilters)[number]["value"]; + +function IncidentDetailPanel({ id }: { readonly id: string }) { + const client = useDashboardTrpcClient(); + const incident = useQuery(incidentDetailQueryOptions(client, id)); + + if (incident.isPending && incident.data === undefined) { + return ; + } + if (incident.data === undefined) { + return ( + void incident.refetch()} + retryBusy={incident.isFetching} + status="error" + title="Incident unavailable" + /> + ); + } + const detail = incident.data; + return ( + +
+ + {detail.severity} + + + {detail.state} + + generation {detail.generation} +
+ + {detail.title} + +
+ {[ + ["Monitor", detail.monitorKey], + ["Kind", detail.kind], + ["Occurrences", String(detail.occurrenceCount)], + ["First seen", formatDashboardDateTime(detail.firstSeenAtMs)], + ["Last seen", formatDashboardDateTime(detail.lastSeenAtMs)], + [ + "Resolved", + detail.state === "resolved" + ? formatDashboardDateTime(detail.resolvedAtMs) + : "Still active", + ], + ].map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+
+ + Incident details + +
+                    {JSON.stringify(detail.details, undefined, 2)}
+                
+
+
+ ); +} + +/** @returns Filtered incident lifecycle navigation and one exact incident record. */ +export function IncidentBrowser() { + const client = useDashboardTrpcClient(); + const navigate = useNavigate({ from: "/incidents" }); + const search = parseIncidentsRouteSearch( + useSearch({ from: "/incidents" }) as unknown + ); + const [kindDraft, setKindDraft] = useState(""); + const [monitorDraft, setMonitorDraft] = useState(""); + const [kind, setKind] = useState(""); + const [monitor, setMonitor] = useState(""); + const [stateDraft, setStateDraft] = useState("all"); + const [state, setState] = useState("all"); + const [severityDraft, setSeverityDraft] = useState("all"); + const [severity, setSeverity] = useState("all"); + const filters: ListIncidentsInput["filters"] = + kind === "" && monitor === "" && state === "all" && severity === "all" + ? undefined + : { + ...(kind === "" ? {} : { kinds: [kind] }), + ...(monitor === "" ? {} : { monitorKeys: [monitor] }), + ...(severity === "all" ? {} : { severities: [severity] }), + ...(state === "all" ? {} : { states: [state] }), + }; + const query = useInfiniteQuery(incidentListQueryOptions(client, filters)); + const incidents = uniqueMonitoringRows( + query.data?.pages.flatMap((page) => page.incidents) ?? [] + ); + const selectedId = search.incidentId; + const selectIncident = (incidentId: string) => { + void navigate({ replace: true, search: { incidentId } }); + }; + const applyFilters = (event: FormEvent) => { + event.preventDefault(); + setKind(kindDraft.trim()); + setMonitor(monitorDraft.trim()); + setState(stateDraft); + setSeverity(severityDraft); + }; + const resetFilters = () => { + setKindDraft(""); + setMonitorDraft(""); + setKind(""); + setMonitor(""); + setStateDraft("all"); + setState("all"); + setSeverityDraft("all"); + setSeverity("all"); + }; + let catalogContent: ReactNode; + if (query.isPending && query.data === undefined) { + catalogContent = ( +
+ +
+ ); + } else if (query.data === undefined) { + catalogContent = ( +
+ void query.refetch()} + retryBusy={query.isFetching} + status="error" + title="Incidents unavailable" + /> +
+ ); + } else if (incidents.length === 0) { + catalogContent = ( +
+ +
+ ); + } else { + catalogContent = ( + + ); + } + + return ( +
+
+ + setKindDraft(event.currentTarget.value)} + placeholder="e.g. filesystem" + value={kindDraft} + /> + + + setMonitorDraft(event.currentTarget.value)} + placeholder="e.g. ops-check" + value={monitorDraft} + /> + + + + +
+ + +
+
+ {query.error !== null && query.data !== undefined && ( + + )} +
+ +
+ + Incidents + +
+ {catalogContent} + {query.hasNextPage && ( +
+ +
+ )} +
+ {selectedId === undefined ? ( + + ) : ( + + )} +
+ + Incident state is independent of notification read state. + +
+ ); +} diff --git a/greenfield/src/browser/monitoring/IncidentTable.test.tsx b/greenfield/src/browser/monitoring/IncidentTable.test.tsx new file mode 100644 index 000000000..dd4b7bfc1 --- /dev/null +++ b/greenfield/src/browser/monitoring/IncidentTable.test.tsx @@ -0,0 +1,90 @@ +import { afterAll, beforeAll, describe, expect, jest, test } from "bun:test"; + +import type { IncidentSummary } from "../../contracts/monitoring.ts"; +import { IncidentTable } from "./IncidentTable.tsx"; + +const { render, screen } = await import("@testing-library/react"); + +const originalOffsetHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetHeight" +); +const originalOffsetWidth = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetWidth" +); +const hadOwnResizeObserver = Object.hasOwn(globalThis, "ResizeObserver"); +const originalResizeObserver = Reflect.get(globalThis, "ResizeObserver"); + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get: () => 480, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get: () => 960, + }); + Reflect.set(globalThis, "ResizeObserver", undefined); +}); + +afterAll(() => { + if (originalOffsetHeight === undefined) { + Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight"); + } else { + Object.defineProperty( + HTMLElement.prototype, + "offsetHeight", + originalOffsetHeight + ); + } + if (originalOffsetWidth === undefined) { + Reflect.deleteProperty(HTMLElement.prototype, "offsetWidth"); + } else { + Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth); + } + if (hadOwnResizeObserver) { + Reflect.set(globalThis, "ResizeObserver", originalResizeObserver); + } else { + Reflect.deleteProperty(globalThis, "ResizeObserver"); + } +}); + +const incidents: readonly IncidentSummary[] = Object.freeze( + Array.from({ length: 50 }, (_, index): IncidentSummary => ({ + fingerprint: index.toString(16).padStart(64, "0"), + firstSeenAtMs: 1_800_000_000_000 - index * 1000, + generation: 1, + id: `incident-${index}`, + kind: "filesystem", + lastSeenAtMs: 1_800_000_001_000 - index * 1000, + monitorKey: `monitor-${index}`, + occurrenceCount: 1, + severity: "warning", + state: "active", + title: `Incident ${index}`, + })) +); + +describe("incident table", () => { + test("uses a bounded virtual row window when the catalog reaches its threshold", () => { + const onSelect = jest.fn(); + const view = render( + + ); + + const table = screen.getByRole("table", { name: "Incidents" }); + expect( + screen.getByRole("button", { + name: "Incident 0; monitor-0; generation 1", + }) + ).toBeTruthy(); + expect(screen.queryByText("Incident 49")).toBeNull(); + expect(table.querySelector("td[height]")).toBeTruthy(); + view.unmount(); + }); +}); diff --git a/greenfield/src/browser/monitoring/IncidentTable.tsx b/greenfield/src/browser/monitoring/IncidentTable.tsx new file mode 100644 index 000000000..69be489f2 --- /dev/null +++ b/greenfield/src/browser/monitoring/IncidentTable.tsx @@ -0,0 +1,121 @@ +import { createColumnHelper, tableFeatures, useTable } from "@tanstack/react-table"; + +import type { IncidentSummary } from "../../contracts/monitoring.ts"; +import { cn } from "../lib/classNames.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Badge } from "../ui/Badge.tsx"; +import { DataTable } from "../ui/DataTable.tsx"; +import { Text } from "../ui/Text.tsx"; +import { Virtualizer, type VirtualizerRenderState } from "../ui/Virtualizer.tsx"; +import { incidentSeverityVariant } from "./incidentPresentation.ts"; + +const minimumVirtualizedRows = 50; +const incidentTableFeatures = tableFeatures({}); + +interface IncidentTableRow { + readonly incident: IncidentSummary; + readonly onSelect: (id: string) => void; + readonly selected: boolean; +} + +const incidentColumnHelper = createColumnHelper< + typeof incidentTableFeatures, + IncidentTableRow +>(); + +const incidentColumns = incidentColumnHelper.columns([ + incidentColumnHelper.accessor((row) => row.incident.title, { + cell: ({ getValue, row }) => ( + + ), + header: "Incident", + id: "title", + }), + incidentColumnHelper.accessor((row) => row.incident.state, { + cell: ({ getValue }) => ( + + {getValue()} + + ), + header: "State", + id: "state", + }), + incidentColumnHelper.accessor((row) => row.incident.severity, { + cell: ({ getValue }) => ( + {getValue()} + ), + header: "Severity", + id: "severity", + }), + incidentColumnHelper.accessor((row) => row.incident.monitorKey, { + cell: ({ getValue }) => {getValue()}, + header: "Monitor", + id: "monitorKey", + }), + incidentColumnHelper.accessor((row) => row.incident.kind, { + cell: ({ getValue }) => {getValue()}, + header: "Kind", + id: "kind", + }), + incidentColumnHelper.accessor((row) => row.incident.lastSeenAtMs, { + cell: ({ getValue }) => ( + + ), + header: "Last seen", + id: "lastSeenAtMs", + }), +]); + +interface IncidentTableProps { + readonly incidents: readonly IncidentSummary[]; + readonly onSelect: (id: string) => void; + readonly selectedId: string | undefined; +} + +/** @returns Selectable incident lifecycle table with bounded virtual rendering. */ +export function IncidentTable({ incidents, onSelect, selectedId }: IncidentTableProps) { + const table = useTable({ + columns: incidentColumns, + data: incidents.map((incident) => ({ + incident, + onSelect, + selected: incident.id === selectedId, + })), + features: incidentTableFeatures, + getRowId: ({ incident }) => incident.id, + }); + const rows = table.getRowModel().rows; + const tableElement = (rowWindow?: VirtualizerRenderState) => ( + + ); + + if (rows.length < minimumVirtualizedRows) return tableElement(); + return ( + + count={rows.length} + estimateSize={() => 72} + getItemKey={(index) => rows[index]?.id ?? `missing-incident-${index}`} + > + {(virtualization) => tableElement(virtualization)} + + ); +} diff --git a/greenfield/src/browser/monitoring/IncidentsRoute.tsx b/greenfield/src/browser/monitoring/IncidentsRoute.tsx new file mode 100644 index 000000000..5f613c076 --- /dev/null +++ b/greenfield/src/browser/monitoring/IncidentsRoute.tsx @@ -0,0 +1,55 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { Newspaper, RefreshCw } from "lucide-react"; + +import { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; +import { ActionLink } from "../ui/ActionLink.tsx"; +import { Alert } from "../ui/Alert.tsx"; +import { Button } from "../ui/Button.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { PageHeader } from "../ui/PageHeader.tsx"; +import { IncidentBrowser } from "./IncidentBrowser.tsx"; +import { refreshIncidentQueries } from "./monitoringQueries.ts"; +import { useIncidentRealtimeInvalidation } from "./useMonitoringRealtimeInvalidation.ts"; + +/** @returns Net-new incident generation reader linked from reports and notifications. */ +export function IncidentsRoute() { + useIncidentRealtimeInvalidation(); + const queryClient = useQueryClient(); + const refresh = useExclusiveDashboardAction(); + + return ( +
+ + + + Browse reports + + +
+ } + description="Active and resolved incident generations produced by complete monitoring snapshots." + eyebrow="Monitoring" + title="Incidents" + /> + +
+ +
+
+ ); +} diff --git a/greenfield/src/browser/monitoring/MonitoringRoutes.test.tsx b/greenfield/src/browser/monitoring/MonitoringRoutes.test.tsx new file mode 100644 index 000000000..7e0ab4fbc --- /dev/null +++ b/greenfield/src/browser/monitoring/MonitoringRoutes.test.tsx @@ -0,0 +1,501 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { createMemoryHistory } from "@tanstack/react-router"; + +import type { AuthStatus } from "../../contracts/auth.ts"; +import type { + IncidentRecord, + IncidentSummary, + ReportDetail, + ReportSummary, +} from "../../contracts/monitoring.ts"; +import { createDashboardQueryClient } from "../api/queryClient.ts"; +import { + createDashboardTrpcClient, + type DashboardTrpcTransport, +} from "../api/trpcClient.ts"; +import { DashboardBrowserApplication } from "../application.tsx"; +import { createDashboardBrowserCollections } from "../data/dashboardCollections.ts"; +import type { DashboardBrowserCollections } from "../data/dashboardCollections.ts"; +import { createDashboardRouter } from "../router.tsx"; +import type { DashboardWebAuthnClient } from "../security/webauthn/webauthnClient.ts"; +import { noOpDashboardRealtimeClient } from "../test/realtime.ts"; + +const { render, screen, waitFor, within } = await import("@testing-library/react"); +const userEventModule = await import("@testing-library/user-event"); +const userEvent = userEventModule.default; + +const reportId = "019fd974-54a2-74dd-a64b-d4186f8d8828"; +const secondReportId = "019fd975-54a2-74dd-a64b-d4186f8d8828"; +const incidentId = "019fd984-63e8-7404-a7da-80c6f243794f"; +const secondIncidentId = "019fd985-63e8-7404-a7da-80c6f243794f"; +const timestampMs = 1_800_000_000_000; + +function report( + id: string, + title: string, + occurredAtMs: number, + bodyMarkdown = "# Operations\n\nAll monitored systems responded." +): ReportDetail { + return { + bodyMarkdown, + id, + kind: "heartbeat", + metadata: { owner: "mira" }, + occurredAtMs, + source: "openclaw", + sourceJobId: "ops-check", + status: "warning", + summary: "One warning remains.", + title, + }; +} + +function incident(id: string, title: string, lastSeenAtMs: number): IncidentRecord { + return { + details: { path: "/srv/dashboard" }, + fingerprint: id === incidentId ? "a".repeat(64) : "b".repeat(64), + firstSeenAtMs: lastSeenAtMs - 1000, + generation: 1, + id, + kind: "filesystem", + lastSeenAtMs, + monitorKey: "ops-check", + occurrenceCount: 2, + severity: "warning", + state: "active", + title, + }; +} + +function reportSummary({ + bodyMarkdown: _bodyMarkdown, + metadata: _metadata, + ...summary +}: ReportDetail): ReportSummary { + return summary; +} + +function incidentSummary({ + details: _details, + ...summary +}: IncidentRecord): IncidentSummary { + return summary; +} + +function authenticatedStatus(): AuthStatus { + const now = Date.now(); + return { + session: { + authenticatedAtMs: now, + authMethod: "password", + createdAtMs: now, + expiresAtMs: now + 86_400_000, + id: "a".repeat(32), + isCurrent: true, + lastSeenAtMs: now, + userAgent: "Monitoring browser test", + }, + state: "authenticated", + user: { + id: "019fd974-54a2-74dd-a64b-d4186f8d8828", + username: "operator", + }, + }; +} + +function requestedId(input: unknown): string | undefined { + return typeof input === "object" && + input !== null && + "id" in input && + typeof input.id === "string" + ? input.id + : undefined; +} + +interface TransportCall { + readonly input: unknown; + readonly kind: "mutation" | "query"; + readonly path: string; +} + +class MonitoringTrpcError extends Error { + readonly data: { readonly code: "NOT_FOUND" | "PRECONDITION_FAILED" }; + + constructor(code: "NOT_FOUND" | "PRECONDITION_FAILED") { + super("Synthetic monitoring route failure"); + this.data = { code }; + } +} + +class MonitoringRouteTransport implements DashboardTrpcTransport { + authStatus: AuthStatus = authenticatedStatus(); + readonly calls: TransportCall[] = []; + deleteFailureCode: "NOT_FOUND" | "PRECONDITION_FAILED" | undefined; + failReportListAfterDelete = false; + incidentListFailuresRemaining = 0; + reportListFailuresRemaining = 0; + reportPages: readonly (readonly ReportSummary[])[] | undefined; + reports = [ + report(reportId, "Primary heartbeat", timestampMs), + report(secondReportId, "Secondary heartbeat", timestampMs - 1000), + ]; + reportListIds = [reportId, secondReportId]; + incidents = [ + incident(incidentId, "Primary disk warning", timestampMs), + incident(secondIncidentId, "Secondary disk warning", timestampMs - 1000), + ]; + incidentListIds = [incidentId, secondIncidentId]; + + mutation(path: string, input?: unknown): Promise { + this.calls.push({ input, kind: "mutation", path }); + if (path !== "reports.delete") { + return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + } + if (this.deleteFailureCode !== undefined) { + return Promise.reject(new MonitoringTrpcError(this.deleteFailureCode)); + } + const id = requestedId(input); + if (id === undefined) return Promise.reject(new TypeError("Missing report id")); + this.reports = this.reports.filter((candidate) => candidate.id !== id); + this.reportListIds = this.reportListIds.filter( + (candidateId) => candidateId !== id + ); + if (this.failReportListAfterDelete) this.reportListFailuresRemaining = 1; + return Promise.resolve({ deletedAtMs: Date.now(), id }); + } + + query(path: string, input?: unknown): Promise { + this.calls.push({ input, kind: "query", path }); + switch (path) { + case "auth.status": { + return Promise.resolve(this.authStatus); + } + case "reports.list": { + if (this.reportListFailuresRemaining > 0) { + this.reportListFailuresRemaining -= 1; + return Promise.reject(new TypeError("Report list unavailable")); + } + if (this.reportPages !== undefined) { + const hasCursor = + typeof input === "object" && input !== null && "cursor" in input; + const reports = this.reportPages[hasCursor ? 1 : 0] ?? []; + const last = reports.at(-1); + return Promise.resolve({ + ...(!hasCursor && + this.reportPages.length > 1 && + last !== undefined + ? { + nextCursor: { + id: last.id, + occurredAtMs: last.occurredAtMs, + }, + } + : {}), + reports, + }); + } + return Promise.resolve({ + reports: this.reportListIds.flatMap((id) => { + const detail = this.reports.find( + (candidate) => candidate.id === id + ); + return detail === undefined ? [] : [reportSummary(detail)]; + }), + }); + } + case "reports.get": { + const id = requestedId(input); + const detail = this.reports.find((candidate) => candidate.id === id); + return detail === undefined + ? Promise.reject(new MonitoringTrpcError("NOT_FOUND")) + : Promise.resolve(detail); + } + case "incidents.list": { + if (this.incidentListFailuresRemaining > 0) { + this.incidentListFailuresRemaining -= 1; + return Promise.reject(new TypeError("Incident list unavailable")); + } + return Promise.resolve({ + incidents: this.incidentListIds.flatMap((id) => { + const detail = this.incidents.find( + (candidate) => candidate.id === id + ); + return detail === undefined ? [] : [incidentSummary(detail)]; + }), + }); + } + case "incidents.get": { + const id = requestedId(input); + const detail = this.incidents.find((candidate) => candidate.id === id); + return detail === undefined + ? Promise.reject(new MonitoringTrpcError("NOT_FOUND")) + : Promise.resolve(detail); + } + default: { + return Promise.reject(new TypeError(`Unexpected query: ${path}`)); + } + } + } +} + +const unexpectedWebAuthnClient: DashboardWebAuthnClient = Object.freeze({ + authenticate: () => Promise.reject(new TypeError("Unexpected authentication")), + register: () => Promise.reject(new TypeError("Unexpected registration")), +}); +const queryClients: ReturnType[] = []; +const collectionRegistries: DashboardBrowserCollections[] = []; +const mountedViews: ReturnType[] = []; + +function renderMonitoringRoute(path: string, transport: MonitoringRouteTransport) { + const queryClient = createDashboardQueryClient(); + queryClient.setDefaultOptions({ + ...queryClient.getDefaultOptions(), + queries: { + ...queryClient.getDefaultOptions().queries, + retry: false, + }, + }); + queryClients.push(queryClient); + const trpcClient = createDashboardTrpcClient(transport); + const collections = createDashboardBrowserCollections(queryClient, trpcClient); + collectionRegistries.push(collections); + mountedViews.push( + render( + + ) + ); + return queryClient; +} + +afterEach(async () => { + for (const view of mountedViews.splice(0)) view.unmount(); + await Promise.all( + collectionRegistries.splice(0).map((collections) => collections.cleanup()) + ); + for (const queryClient of queryClients.splice(0)) queryClient.clear(); +}); + +describe("monitoring browser routes", () => { + test("loads an exact report deep link independently and keeps Markdown raw HTML inert", async () => { + const transport = new MonitoringRouteTransport(); + transport.reportListIds = [reportId]; + transport.reportListFailuresRemaining = 1; + transport.reports[1] = report( + secondReportId, + "Direct report", + timestampMs - 1000, + "# Direct report\n\n" + ); + renderMonitoringRoute(`/reports?reportId=${secondReportId}`, transport); + + expect( + await screen.findByRole("heading", { level: 2, name: "Direct report" }) + ).toBeTruthy(); + expect(await screen.findByText("Reports unavailable")).toBeTruthy(); + expect(document.querySelector("script")).toBeNull(); + expect(transport.calls.find(({ path }) => path === "reports.get")?.input).toEqual( + { id: secondReportId } + ); + }); + + test("drops an invalid report search value without issuing a detail query", async () => { + const transport = new MonitoringRouteTransport(); + renderMonitoringRoute("/reports?reportId=not-a-uuid", transport); + + expect(await screen.findByText("No report selected")).toBeTruthy(); + expect(await screen.findByText("Primary heartbeat")).toBeTruthy(); + expect(transport.calls.some(({ path }) => path === "reports.get")).toBeFalse(); + }); + + test("applies report text filters as one query transition", async () => { + const transport = new MonitoringRouteTransport(); + renderMonitoringRoute("/reports", transport); + const user = userEvent.setup(); + + await screen.findByText("Primary heartbeat"); + await user.type(screen.getByLabelText("Kind"), "heartbeat"); + await user.type(screen.getByLabelText("Source"), "openclaw"); + expect( + transport.calls.filter(({ path }) => path === "reports.list") + ).toHaveLength(1); + + await user.click(screen.getByRole("button", { name: "Apply" })); + await waitFor(() => + expect( + transport.calls.filter(({ path }) => path === "reports.list") + ).toHaveLength(2) + ); + expect( + transport.calls.findLast(({ path }) => path === "reports.list")?.input + ).toEqual({ + filters: { + kinds: ["heartbeat"], + sources: ["openclaw"], + }, + limit: 50, + }); + }); + + test("loads an overlapping report page without rendering duplicate identities", async () => { + const transport = new MonitoringRouteTransport(); + const first = reportSummary(transport.reports[0]!); + const second = reportSummary(transport.reports[1]!); + transport.reportPages = [[first], [first, second]]; + renderMonitoringRoute("/reports", transport); + const user = userEvent.setup(); + + await screen.findByText("Primary heartbeat"); + await user.click(screen.getByRole("button", { name: "Load older reports" })); + expect(await screen.findByText("Secondary heartbeat")).toBeTruthy(); + expect(screen.getAllByText("Primary heartbeat")).toHaveLength(1); + expect( + transport.calls.filter(({ path }) => path === "reports.list") + ).toHaveLength(2); + }); + + test("removes a deleted report from cached lists when the refresh fails", async () => { + const transport = new MonitoringRouteTransport(); + transport.failReportListAfterDelete = true; + const queryClient = renderMonitoringRoute( + `/reports?reportId=${reportId}`, + transport + ); + const user = userEvent.setup(); + + await screen.findByRole("heading", { level: 2, name: "Primary heartbeat" }); + await user.click(screen.getByRole("button", { name: "Delete" })); + await user.click(screen.getByRole("button", { name: "Delete report" })); + await waitFor(() => { + expect(queryClient.isFetching()).toBe(0); + expect(queryClient.isMutating()).toBe(0); + }); + expect(screen.queryByText("Primary heartbeat")).toBeNull(); + expect(screen.getByText("Secondary heartbeat")).toBeTruthy(); + expect(screen.getByRole("alert").textContent).toContain( + "The request could not be completed" + ); + }); + + test("presents a bounded-delete precondition and clears it for the next report", async () => { + const transport = new MonitoringRouteTransport(); + transport.deleteFailureCode = "PRECONDITION_FAILED"; + renderMonitoringRoute(`/reports?reportId=${reportId}`, transport); + const user = userEvent.setup(); + + await screen.findByRole("heading", { level: 2, name: "Primary heartbeat" }); + await user.click(screen.getByRole("button", { name: "Delete" })); + await user.click(screen.getByRole("button", { name: "Delete report" })); + expect( + await screen.findByText(/too many linked notifications to delete safely/u) + ).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: /Secondary heartbeat/u })); + expect( + await screen.findByRole("heading", { + level: 2, + name: "Secondary heartbeat", + }) + ).toBeTruthy(); + expect( + screen.queryByText(/too many linked notifications to delete safely/u) + ).toBeNull(); + }); + + test("presents a missing report without leaking a server message", async () => { + const transport = new MonitoringRouteTransport(); + transport.deleteFailureCode = "NOT_FOUND"; + renderMonitoringRoute(`/reports?reportId=${reportId}`, transport); + const user = userEvent.setup(); + + await screen.findByRole("heading", { level: 2, name: "Primary heartbeat" }); + await user.click(screen.getByRole("button", { name: "Delete" })); + await user.click(screen.getByRole("button", { name: "Delete report" })); + expect(await screen.findByText(/This report no longer exists/u)).toBeTruthy(); + }); + + test("renders the hidden incident table and an exact detail outside its first page", async () => { + const transport = new MonitoringRouteTransport(); + transport.incidentListIds = [incidentId]; + renderMonitoringRoute(`/incidents?incidentId=${secondIncidentId}`, transport); + + expect( + await screen.findByRole("heading", { + level: 2, + name: "Secondary disk warning", + }) + ).toBeTruthy(); + expect(screen.getByRole("table", { name: "Incidents" })).toBeTruthy(); + const navigation = screen.getByRole("navigation", { + name: "Main navigation", + }); + expect(within(navigation).queryByRole("link", { name: "Incidents" })).toBeNull(); + expect(screen.getByText("Incidents", { selector: "header p" })).toBeTruthy(); + expect( + transport.calls.find(({ path }) => path === "incidents.get")?.input + ).toEqual({ id: secondIncidentId }); + + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { + name: "Primary disk warning; ops-check; generation 1", + }) + ); + expect( + await screen.findByRole("heading", { + level: 2, + name: "Primary disk warning", + }) + ).toBeTruthy(); + }); + + test("loads an exact incident deep link independently of list availability", async () => { + const transport = new MonitoringRouteTransport(); + transport.incidentListFailuresRemaining = 1; + renderMonitoringRoute(`/incidents?incidentId=${secondIncidentId}`, transport); + + expect( + await screen.findByRole("heading", { + level: 2, + name: "Secondary disk warning", + }) + ).toBeTruthy(); + expect(await screen.findByText("Incidents unavailable")).toBeTruthy(); + expect( + transport.calls.find(({ path }) => path === "incidents.get")?.input + ).toEqual({ id: secondIncidentId }); + }); + + test("drops an invalid incident search value without issuing a detail query", async () => { + const transport = new MonitoringRouteTransport(); + renderMonitoringRoute("/incidents?incidentId=not-a-uuid", transport); + + expect(await screen.findByText("No incident selected")).toBeTruthy(); + expect(await screen.findByText("Primary disk warning")).toBeTruthy(); + expect(transport.calls.some(({ path }) => path === "incidents.get")).toBeFalse(); + }); + + test("keeps monitoring procedures behind the authenticated route boundary", async () => { + const transport = new MonitoringRouteTransport(); + transport.authStatus = { state: "anonymous" }; + renderMonitoringRoute("/reports", transport); + + expect( + await screen.findByRole("heading", { level: 1, name: "Sign in" }) + ).toBeTruthy(); + expect( + transport.calls.some( + ({ path }) => path === "reports.list" || path === "reports.get" + ) + ).toBeFalse(); + }); +}); diff --git a/greenfield/src/browser/monitoring/MonitoringSelectionList.tsx b/greenfield/src/browser/monitoring/MonitoringSelectionList.tsx new file mode 100644 index 000000000..f13bf0ddd --- /dev/null +++ b/greenfield/src/browser/monitoring/MonitoringSelectionList.tsx @@ -0,0 +1,80 @@ +import type { ReactNode } from "react"; + +import { cn } from "../lib/classNames.ts"; +import { Virtualizer } from "../ui/Virtualizer.tsx"; + +const minimumVirtualizedItems = 50; + +interface MonitoringSelectionListProps { + readonly className?: string; + readonly getKey: (item: TItem) => string; + readonly items: readonly TItem[]; + readonly label: string; + readonly renderItem: (item: TItem) => ReactNode; +} + +/** + * Renders bounded catalog pages directly and switches to TanStack Virtual as pages accumulate. + * @returns An accessible scrollable selection list. + */ +export function MonitoringSelectionList({ + className, + getKey, + items, + label, + renderItem, +}: MonitoringSelectionListProps) { + const listClassName = cn("max-h-128 space-y-2 overflow-auto p-2", className); + if (items.length < minimumVirtualizedItems) { + return ( +
    + {items.map((item) => ( +
  • {renderItem(item)}
  • + ))} +
+ ); + } + + return ( + + count={items.length} + estimateSize={() => 92} + getItemKey={(index) => { + const item = items[index]; + return item === undefined ? `missing-monitoring-${index}` : getKey(item); + }} + initialRect={{ height: 512, width: 384 }} + > + {({ measureElement, scrollContainerRef, totalSize, virtualItems }) => ( +
+
    + {virtualItems.map((virtualItem) => { + const item = items[virtualItem.index]; + if (item === undefined) return null; + return ( +
  • + {renderItem(item)} +
  • + ); + })} +
+
+ )} + + ); +} diff --git a/greenfield/src/browser/monitoring/ReportBrowser.tsx b/greenfield/src/browser/monitoring/ReportBrowser.tsx new file mode 100644 index 000000000..c2e7e1bd7 --- /dev/null +++ b/greenfield/src/browser/monitoring/ReportBrowser.tsx @@ -0,0 +1,394 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { FileText, Filter, RotateCcw, Trash2 } from "lucide-react"; +import { type FormEvent, type ReactNode, useState } from "react"; + +import type { ReportDetail, ReportSummary } from "../../contracts/monitoring.ts"; +import type { ListReportsInput } from "../../contracts/reports.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { + classifyDashboardBrowserFailure, + dashboardBrowserFailureMessage, +} from "../api/trpcError.ts"; +import { cn } from "../lib/classNames.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Badge } from "../ui/Badge.tsx"; +import { Button } from "../ui/Button.tsx"; +import { Card } from "../ui/Card.tsx"; +import { ConfirmModal } from "../ui/ConfirmModal.tsx"; +import { FormField } from "../ui/FormField.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Input } from "../ui/Input.tsx"; +import { Markdown } from "../ui/Markdown.tsx"; +import { PageState } from "../ui/PageState.tsx"; +import { Select } from "../ui/Select.tsx"; +import { Text } from "../ui/Text.tsx"; +import { useDeleteReportMutation } from "./monitoringMutations.ts"; +import { + reportDetailQueryOptions, + reportListQueryOptions, + uniqueMonitoringRows, +} from "./monitoringQueries.ts"; +import { parseReportsRouteSearch } from "./monitoringRouteSearch.ts"; +import { MonitoringSelectionList } from "./MonitoringSelectionList.tsx"; + +const reportStatusOptions = Object.freeze([ + { label: "All statuses", value: "all" }, + { label: "OK", value: "ok" }, + { label: "Warning", value: "warning" }, + { label: "Error", value: "error" }, +] as const); + +type ReportStatusFilter = (typeof reportStatusOptions)[number]["value"]; + +function reportStatusVariant(status: ReportSummary["status"]) { + if (status === "error") return "danger" as const; + if (status === "warning") return "warning" as const; + return "success" as const; +} + +function reportKindLabel(kind: string): string { + return kind.replaceAll(/[-_]+/gu, " "); +} + +function reportDeletionFailureMessage(error: unknown): string { + switch (classifyDashboardBrowserFailure(error)) { + case "not-found": { + return "This report no longer exists. Refresh the list and choose another report."; + } + case "conflict": { + return "This report has too many linked notifications to delete safely. Clear the linked notifications first and try again."; + } + default: { + return dashboardBrowserFailureMessage(error); + } + } +} + +interface ReportListItemProps { + readonly onSelect: (id: string) => void; + readonly report: ReportSummary; + readonly selected: boolean; +} + +function ReportListItem({ onSelect, report, selected }: ReportListItemProps) { + return ( + + ); +} + +interface ReportDetailPanelProps { + readonly id: string; + readonly onDeleted: () => void; +} + +function ReportDetailPanel({ id, onDeleted }: ReportDetailPanelProps) { + const client = useDashboardTrpcClient(); + const report = useQuery(reportDetailQueryOptions(client, id)); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const deletion = useDeleteReportMutation(onDeleted); + let errorMessage: string | undefined; + if (deletion.error !== null) { + errorMessage = reportDeletionFailureMessage(deletion.error); + } else if (report.error !== null) { + errorMessage = dashboardBrowserFailureMessage(report.error); + } + + if (report.isPending && report.data === undefined) { + return ; + } + if (report.data === undefined) { + return ( + void report.refetch()} + retryBusy={report.isFetching} + status="error" + title="Report unavailable" + /> + ); + } + + const detail: ReportDetail = report.data; + return ( + +
+
+
+ + {detail.status} + + {reportKindLabel(detail.kind)} +
+ + {detail.title} + + + {detail.source} + {detail.sourceJobId === undefined + ? "" + : ` · ${detail.sourceJobId}`}{" "} + · {formatDashboardDateTime(detail.occurredAtMs)} + +
+ +
+ + {detail.summary !== undefined && ( + + {detail.summary} + + )} + + {Object.keys(detail.metadata).length > 0 && ( +
+ + Report metadata + +
+                        {JSON.stringify(detail.metadata, undefined, 2)}
+                    
+
+ )} + setConfirmingDelete(false)} + onConfirm={() => + deletion.mutate( + { id: detail.id }, + { onError: () => setConfirmingDelete(false) } + ) + } + open={confirmingDelete} + title="Delete report" + /> +
+ ); +} + +/** @returns Filtered, paginated report navigation and one exact Markdown document. */ +export function ReportBrowser() { + const client = useDashboardTrpcClient(); + const navigate = useNavigate({ from: "/reports" }); + const search = parseReportsRouteSearch(useSearch({ from: "/reports" }) as unknown); + const [kindDraft, setKindDraft] = useState(""); + const [sourceDraft, setSourceDraft] = useState(""); + const [kind, setKind] = useState(""); + const [source, setSource] = useState(""); + const [statusDraft, setStatusDraft] = useState("all"); + const [status, setStatus] = useState("all"); + const filters: ListReportsInput["filters"] = + kind === "" && source === "" && status === "all" + ? undefined + : { + ...(kind === "" ? {} : { kinds: [kind] }), + ...(source === "" ? {} : { sources: [source] }), + ...(status === "all" ? {} : { statuses: [status] }), + }; + const query = useInfiniteQuery(reportListQueryOptions(client, filters)); + const reports = uniqueMonitoringRows( + query.data?.pages.flatMap((page) => page.reports) ?? [] + ); + const selectedId = search.reportId; + const selectReport = (reportId: string | undefined) => { + void navigate({ + replace: true, + search: reportId === undefined ? {} : { reportId }, + }); + }; + const applyFilters = (event: FormEvent) => { + event.preventDefault(); + setKind(kindDraft.trim()); + setSource(sourceDraft.trim()); + setStatus(statusDraft); + }; + const resetFilters = () => { + setKindDraft(""); + setSourceDraft(""); + setKind(""); + setSource(""); + setStatusDraft("all"); + setStatus("all"); + }; + let catalogContent: ReactNode; + if (query.isPending && query.data === undefined) { + catalogContent = ( +
+ +
+ ); + } else if (query.data === undefined) { + catalogContent = ( +
+ void query.refetch()} + retryBusy={query.isFetching} + status="error" + title="Reports unavailable" + /> +
+ ); + } else if (reports.length === 0) { + catalogContent = ( +
+ +
+ ); + } else { + catalogContent = ( + report.id} + items={reports} + label="Reports" + renderItem={(report) => ( + + )} + /> + ); + } + + return ( +
+
+ + setKindDraft(event.currentTarget.value)} + placeholder="e.g. heartbeat" + value={kindDraft} + /> + + + setSourceDraft(event.currentTarget.value)} + placeholder="e.g. openclaw" + value={sourceDraft} + /> + + +