diff --git a/admin/src/app/dashboard/HealthPage.tsx b/admin/src/app/dashboard/HealthPage.tsx index 724f98d63..d123f6a87 100644 --- a/admin/src/app/dashboard/HealthPage.tsx +++ b/admin/src/app/dashboard/HealthPage.tsx @@ -1,7 +1,3 @@ -// Setup and health: two views of "is this instance ok". Findings are the -// backend's own verdicts about configuration and state; services are live -// probes of what it runs on. ?tab= deep-links either one. - import { Link, useSearchParams } from "react-router-dom"; import { Activity, ListChecks } from "lucide-react"; import { PageHeader } from "@/components/layout/PageHeader"; @@ -10,8 +6,11 @@ import { Button } from "@/components/ui/button"; import { useInstanceHealth } from "@/hooks/useInstanceHealth"; import { FindingsTab } from "./health/FindingsTab"; import { ServicesTab } from "./health/ServicesTab"; +import { InstanceMonitoringPanel } from "./InstanceMonitoringPanel"; +import { useAdminPerm } from "@/hooks/useAdminPerm"; +import { AdminPerm } from "@/lib/auth/permissions"; -const TAB_IDS = ["findings", "services"] as const; +const TAB_IDS = ["findings", "services", "operations"] as const; type TabId = (typeof TAB_IDS)[number]; function isTabId(v: string | null): v is TabId { @@ -19,6 +18,7 @@ function isTabId(v: string | null): v is TabId { } export default function HealthPage() { + const canConfigure = useAdminPerm(AdminPerm.ManageSettings); const [params, setParams] = useSearchParams(); const raw = params.get("tab"); const tab: TabId = isTabId(raw) ? raw : "findings"; @@ -44,11 +44,11 @@ export default function HealthPage() {
- + } 0 ? findings : undefined, }, { id: "services", label: "Services", icon: Activity }, + { id: "operations", label: "Operations", icon: Activity }, ]} value={tab} onChange={setTab} /> - {tab === "findings" ? : } + {tab === "findings" ? : tab === "services" ? : }
); } diff --git a/admin/src/app/dashboard/InstanceHealthPanel.tsx b/admin/src/app/dashboard/InstanceHealthPanel.tsx index a47162a55..fd749e17b 100644 --- a/admin/src/app/dashboard/InstanceHealthPanel.tsx +++ b/admin/src/app/dashboard/InstanceHealthPanel.tsx @@ -4,7 +4,7 @@ import { Link } from "react-router-dom"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { AlertTriangle, ArrowRight, ExternalLink, Info, XCircle, type LucideIcon } from "lucide-react"; -import { StatusBadge } from "@/components/ui/kit"; +import { Callout, StatusBadge } from "@/components/ui/kit"; import { Button } from "@/components/ui/button"; import { useConfirm } from "@/components/ConfirmDialog"; import { docsUrl } from "@/lib/docs"; @@ -140,15 +140,21 @@ function ExpiredInvitationsCleanup() { const OVERVIEW_LIMIT = 4; -// Overview's problems strip. Silent when the instance is clean or when the -// caller cannot read the endpoint, so it never becomes noise on the page. export function InstanceProblemsPanel() { const canRead = useAdminPerm(AdminPerm.ViewAnalytics); const healthQ = useInstanceHealth({ enabled: canRead }); const checks = healthQ.data?.checks ?? []; const actionable = checks.filter((c) => c.severity === "error" || c.severity === "warning"); - if (healthQ.isError || actionable.length === 0) return null; + if (!canRead) return null; + if (healthQ.isError || (healthQ.data && actionable.length === 0)) return ( + + {healthQ.isError ? "Could not refresh findings. This is not an all-clear." : "Absent findings do not establish check coverage or operational health."} + {healthQ.isError && healthQ.data && ` Previous response listed ${actionable.length} errors or warnings; it is not a fresh verdict.`} + {" "}Setup and health + + ); + if (actionable.length === 0) return null; const errors = actionable.filter((c) => c.severity === "error").length; const shown = actionable.slice(0, OVERVIEW_LIMIT); diff --git a/admin/src/app/dashboard/InstanceMonitoringPanel.test.tsx b/admin/src/app/dashboard/InstanceMonitoringPanel.test.tsx new file mode 100644 index 000000000..3e24d20d5 --- /dev/null +++ b/admin/src/app/dashboard/InstanceMonitoringPanel.test.tsx @@ -0,0 +1,192 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MonitoringMetric, MonitoringSnapshot, MonitoringSource } from "@/lib/api/client/admin/monitoring"; +import { AdminPerm } from "@/lib/auth/permissions"; +import { InstanceMonitoringPanel } from "./InstanceMonitoringPanel"; + +const state = vi.hoisted(() => ({ + mask: 0 as number | undefined, + query: { data: undefined as MonitoringSnapshot | undefined, isError: false, isLoading: false, isFetching: false, error: new Error("provider-password-private"), refetch: vi.fn() }, +})); +vi.mock("@/hooks/useMe", () => ({ useMe: () => ({ data: { id: "admin", admin_permissions: state.mask } }) })); +vi.mock("@/hooks/useInstanceMonitoring", () => ({ useInstanceMonitoring: () => state.query })); + +const at = new Date().toISOString(); +const evidence = new Date(Date.now() - 3_600_000).toISOString(); +function metric(overrides: Partial = {}): MonitoringMetric { + return { + id: "failed_send", title: "Stored failure report", unit: "reports", availability: "fresh", + condition: "recent_failure", severity: "warning", note: "Only confirmed send evidence closes an incident.", + count: 7, affected_mailboxes: 3, affected_organizations: 2, unknown_organization_rows: 1, + observed_at: at, evidence_at: evidence, latest_evidence_at: at, evidence_age_seconds: 3600, + next_eligible_at: null, window_start: evidence, window_end: at, threshold_seconds: 3600, + investigate: "/sends?tab=failures", ...overrides, + }; +} +function source(overrides: Partial = {}): MonitoringSource { + return { id: "sends", availability: "fresh", coverage: "complete", observed_at: at, checked_at: at, + measured_scopes: null, expected_scopes: null, metrics: [metric()], ...overrides }; +} +function snapshot(sources: MonitoringSource[] = [source()]): MonitoringSnapshot { + return { version: "1", checked_at: at, refresh_after: at, coverage: "complete", sources }; +} +function render(compact = false) { + const html = renderToStaticMarkup(); + return new DOMParser().parseFromString(html, "text/html"); +} + +beforeEach(() => { + state.mask = Object.values(AdminPerm).reduce((mask, bit) => mask | bit, 0); + Object.assign(state.query, { data: snapshot(), isError: false, isLoading: false, isFetching: false }); +}); + +describe("operational monitoring evidence", () => { + it("keeps metric counts distinct from affected mailboxes, organizations and attention signals", () => { + const document = render(); + const text = document.body.textContent; + expect(text).toContain("1 recorded attention signals"); + expect(text).toContain("7 reports"); + expect(text).toContain("3 affected mailboxes"); + expect(text).toContain("2 affected organizations"); + expect(text).toContain("1 rows with unknown organization"); + expect(text).toContain("Observed:"); + expect(text).toContain("Evidence since:"); + expect(text).toContain("Latest evidence:"); + expect(document.querySelector(`time[datetime="${evidence}"]`)).not.toBeNull(); + expect(document.querySelector('a[href="/sends?tab=failures"]')).not.toBeNull(); + }); + + it("renders loading, failed and successful empty requests without a green verdict", () => { + state.query.data = undefined; + state.query.isLoading = true; + expect(render().querySelector('[role="status"]')?.textContent).toContain("Loading operational monitoring"); + state.query.isLoading = false; + state.query.isError = true; + expect(render().body.textContent).toContain("Monitoring refresh failed"); + expect(render().body.textContent).not.toContain("provider-password-private"); + state.query.isError = false; + state.query.data = snapshot([]); + expect(render().body.textContent).toContain("No monitoring sources returned"); + expect(render().body.textContent).not.toContain("recorded attention signals"); + }); + + it.each(["unsupported", "dependency_missing", "schema_absent", "timeout", "query_failed", "permission_denied"])("exposes %s coverage without inventing zero counts", (reason) => { + state.query.data = snapshot([source({ availability: "unavailable", coverage: "unavailable", reason, metrics: [] })]); + const text = render().body.textContent; + expect(text).toContain("1 sources with coverage gaps"); + expect(text).toContain("No current measurement available"); + expect(text).not.toContain("0 reports"); + }); + + it("keeps a missing measurement distinct from a measured zero", () => { + state.query.data = snapshot([source({ metrics: [metric({ count: null, affected_mailboxes: null, affected_organizations: null }), metric({ id: "sent", title: "Send confirmations", count: 0, condition: "no_recent_evidence" })] })]); + const text = render().body.textContent; + expect(text).toContain("Not measured"); + expect(text).toContain("0 reports"); + expect(text).toContain("No matching recent evidence"); + expect(text).not.toContain("Confirmed scoped observation"); + }); + + it("retains unsupported metric explanations without presenting their counts as current measurements", () => { + state.query.data = snapshot([source({ id: "send_wait_attribution", availability: "unavailable", coverage: "unavailable", reason: "unsupported", metrics: [metric({ title: "Detailed live send waits", condition: "budget", count: 123, note: "Scheduler does not persist per-task budget decisions." })] })]); + const text = render().body.textContent; + expect(text).toContain("Detailed live send waits"); + expect(text).toContain("Scheduler does not persist per-task budget decisions"); + expect(text).toContain("Not measured"); + expect(text).not.toContain("123 reports"); + expect(text).not.toContain("3 affected mailboxes"); + }); + + it("shows retained stale observations and partial samples with their original evidence clocks", () => { + state.query.data = snapshot([source({ availability: "stale", coverage: "partial", measured_scopes: 20, expected_scopes: 35, metrics: [metric({ availability: "stale" })] })]); + const document = render(); + expect(document.body.textContent).toContain("20 / 35 scopes measured"); + expect(document.body.textContent).toContain("lower bounds"); + expect(document.body.textContent).toContain("Stale observation"); + expect(document.body.textContent).toContain("historical, not proof of a current failure"); + expect(document.querySelector(`time[datetime="${evidence}"]`)).not.toBeNull(); + }); + + it("a failed refresh cannot make cached healthy data appear fresh", () => { + state.query.isError = true; + state.query.data = snapshot([source({ metrics: [metric({ condition: "healthy", severity: "info" })] })]); + const document = render(); + expect(document.body.textContent).toContain("previous snapshot with its original timestamps"); + expect(document.body.textContent).toContain("Stale observation"); + expect(document.body.textContent).not.toContain("Fresh / complete"); + expect(document.querySelector(`time[datetime="${at}"]`)).not.toBeNull(); + }); + + it("ages snapshots by observation time, not successful client fetch time", () => { + const old = new Date(Date.now() - 300_000).toISOString(); + state.query.data = snapshot([source({ observed_at: old, metrics: [metric({ observed_at: old })] })]); + expect(render().body.textContent).toContain("Stale observation"); + }); + + it("denies metrics and drill-downs when permission bits are absent or unknown", () => { + for (const mask of [undefined, 0]) { + state.mask = mask; + const document = render(true); + expect(document.body.textContent).toContain("Monitoring permission required"); + expect(document.body.textContent).not.toContain("Stored failure report"); + expect(document.querySelector("a")).toBeNull(); + } + state.mask = AdminPerm.ViewAnalytics; + expect(render().body.textContent).toContain("Permission required"); + expect(render().body.textContent).not.toContain("Stored failure report"); + expect(render().querySelector('a[href="/sends?tab=failures"]')).toBeNull(); + }); + + it("checks the link destination permission separately from the visible source", () => { + state.mask = AdminPerm.ViewAnalytics | AdminPerm.ViewOrganizations; + state.query.data = snapshot([source({ id: "webhooks", metrics: [metric({ investigate: "/sends?tab=webhooks" })] })]); + expect(render().body.textContent).toContain("7 reports"); + expect(render().querySelector('a[href="/sends?tab=webhooks"]')).toBeNull(); + state.mask |= AdminPerm.ViewCampaigns; + expect(render().querySelector('a[href="/sends?tab=webhooks"]')).not.toBeNull(); + }); + + it("rejects unrecognized source data, arbitrary links, raw reason and scope payloads", () => { + state.query.data = snapshot([source({ reason: "provider-password-private", metrics: [metric({ scope_id: "secret@example.com", reason: "provider-password-private", investigate: "https://evil.invalid/?token=secret" })] }), source({ id: "secret-source", metrics: [metric({ title: "secret-title" })] })]); + const text = render().body.innerHTML; + for (const secret of ["provider-password-private", "secret@example.com", "evil.invalid", "secret-title", "secret-source"]) expect(text).not.toContain(secret); + }); + + it.each(["committed_lag", "pending", "ack_pending", "redelivered"])("keeps positive broker %s prominent while preserving unknown/info", (id) => { + state.query.data = snapshot([source({ metrics: Array.from({ length: 8 }, (_, i) => metric({ id: `failure-${i}` })) }), source({ id: "backend_event_broker", metrics: [metric({ id, title: `Broker ${id}`, condition: "unknown", severity: "info", count: 45 })] })]); + const document = render(true); + expect(document.body.textContent).toContain("1 positive broker queue measurements"); + expect(document.body.textContent).toContain("45 reports"); + expect(document.body.textContent).toContain("Queue measurement, not outage"); + expect(document.body.textContent).toContain("Unknown"); + expect(document.body.textContent).not.toContain("Confirmed scoped observation"); + }); + + it("does not classify waits or a fresh zero queue as a failure", () => { + state.query.data = snapshot([source({ metrics: ["waiting_schedule", "waiting_retry", "budget", "daily_limit", "in_flight"].map((condition) => metric({ condition: condition as MonitoringMetric["condition"], severity: "info", next_eligible_at: at })) }), source({ id: "backend_event_broker", metrics: [metric({ id: "pending", condition: "unknown", severity: "info", count: 0 })] })]); + const text = render().body.textContent; + expect(text).toContain("0 recorded attention signals"); + expect(text).toContain("Scheduled wait"); + expect(text).toContain("Retry wait"); + expect(text).toContain("Budget wait"); + expect(text).toContain("Daily-limit wait"); + expect(text).toContain("Next eligible:"); + expect(text).toContain("not an instance-wide all-clear"); + }); + + it("shows safety holds as restrictions and counts unknown warning observations without outage claims", () => { + state.query.data = snapshot([source({ metrics: [metric({ condition: "safety_hold" }), metric({ id: "missing", condition: "unknown", severity: "warning" })] })]); + const text = render(true).body.textContent; + expect(text).toContain("2 recorded attention signals"); + expect(text).toContain("Safety hold"); + expect(text).toContain("Unknown"); + expect(text).toContain("restrictions, not provider outages"); + }); + + it("does not interpret an unsupported contract version", () => { + state.query.data = { ...snapshot(), version: "2" }; + expect(render().body.textContent).toContain("Unsupported monitoring version"); + expect(render().body.textContent).not.toContain("7 reports"); + }); +}); diff --git a/admin/src/app/dashboard/InstanceMonitoringPanel.tsx b/admin/src/app/dashboard/InstanceMonitoringPanel.tsx new file mode 100644 index 000000000..0da059928 --- /dev/null +++ b/admin/src/app/dashboard/InstanceMonitoringPanel.tsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Activity, AlertTriangle, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Callout, EmptyState, StatusBadge } from "@/components/ui/kit"; +import { useInstanceMonitoring } from "@/hooks/useInstanceMonitoring"; +import { useMe } from "@/hooks/useMe"; +import { AdminPerm } from "@/lib/auth/permissions"; +import type { MonitoringMetric, MonitoringSource } from "@/lib/api/client/admin/monitoring"; +import { + ageLabel, canMonitor, CONDITION_LABELS, investigationPath, isBrokerQueue, + MONITORING_SOURCES, monitoringReason, needsMonitoringAttention, observationIsOld, + permittedSource, timestamp, +} from "@/lib/monitoring"; +import type { Tone } from "@/lib/tones"; + +function ObservationTime({ label, value, now }: { label: string; value: string | null; now: number }) { + const at = timestamp(value); + return ( + + {label}: {at === null ? "unknown" : } + + ); +} + +function metricIsStale(metric: MonitoringMetric, source: MonitoringSource, now: number): boolean { + return metric.availability === "stale" || source.availability === "stale" || + observationIsOld(metric.observed_at, now) || observationIsOld(source.observed_at, now); +} + +function metricTone(metric: MonitoringMetric, stale: boolean): Tone { + if (metric.availability === "unavailable") return "neutral"; + if (stale) return "warning"; + if (needsMonitoringAttention(metric)) return isBrokerQueue(metric) ? "info" : metric.severity === "error" ? "danger" : "warning"; + if (metric.condition === "healthy") return "success"; + return "info"; +} + +function MetricRow({ metric: m, source, mask, now, compact }: { + metric: MonitoringMetric; source: MonitoringSource; mask: number | undefined; now: number; compact?: boolean; +}) { + const stale = metricIsStale(m, source, now); + const unavailable = m.availability === "unavailable"; + const path = investigationPath(m.investigate, mask); + const measured = !unavailable && typeof m.count === "number"; + return ( +
  • +
    +

    {m.title}

    +
    + {unavailable ? "Unavailable" : !measured ? "Unknown" : CONDITION_LABELS[m.condition] ?? "Unknown"} + {!unavailable && stale && Stale observation} + {isBrokerQueue(m) && Queue measurement, not outage} +
    +
    + {compact &&

    {MONITORING_SOURCES[source.id]?.title ?? "Unrecognized source"}

    } +
    + {measured ? `${m.count!.toLocaleString()} ${m.unit}` : "Not measured"} + {!unavailable && m.affected_mailboxes !== null && {m.affected_mailboxes.toLocaleString()} affected mailboxes} + {!unavailable && m.affected_organizations !== null && {m.affected_organizations.toLocaleString()} affected organizations} + {!unavailable && m.unknown_organization_rows !== null && m.unknown_organization_rows > 0 && {m.unknown_organization_rows.toLocaleString()} rows with unknown organization} +
    + {m.reason &&

    {monitoringReason(m.reason)}

    } + {m.note &&

    {m.note}

    } +
    + + + {m.latest_evidence_at && } + {m.next_eligible_at && } +
    + {!compact &&
    + {m.evidence_age_seconds !== null && Evidence age reported by snapshot: {m.evidence_age_seconds.toLocaleString()}s} + {m.threshold_seconds !== null && Monitoring threshold: {m.threshold_seconds.toLocaleString()}s (not a provider SLA)} + {m.window_start && } + {m.window_end && } +
    } + {path && Investigate {m.title}} +
  • + ); +} + +function SourcePanel({ source, mask, now }: { source: MonitoringSource; mask: number | undefined; now: number }) { + const unavailable = source.availability === "unavailable"; + const stale = !unavailable && (source.availability === "stale" || observationIsOld(source.observed_at, now)); + return ( +
    +
    +
    +

    {MONITORING_SOURCES[source.id]?.title ?? "Unrecognized source"}

    + + {unavailable ? "Unavailable" : stale ? "Stale" : "Fresh"} / {source.coverage === "complete" ? "complete" : "incomplete"} coverage + +
    +
    + + + {source.measured_scopes !== null && {source.measured_scopes.toLocaleString()} / {source.expected_scopes?.toLocaleString() ?? "unknown"} scopes measured} +
    + {source.reason &&

    {monitoringReason(source.reason)}

    } + {source.coverage !== "complete" && !unavailable &&

    Incomplete coverage. Measured counts may be lower bounds; do not sum overlapping metrics.

    } +
    + {(unavailable || source.metrics.length === 0) && ( +

    {unavailable ? "No current measurement available. This is not zero backlog or evidence of an outage." : "No metrics returned. Operational health is unknown for this source."}

    + )} + {source.metrics.length > 0 && ( +
      {source.metrics.map((m, i) => )}
    + )} +
    + ); +} + +export function InstanceMonitoringPanel({ compact = false }: { compact?: boolean }) { + const { data: me } = useMe(); + const query = useInstanceMonitoring(); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, []); + const mask = me?.admin_permissions; + const allowed = canMonitor(mask, AdminPerm.ViewAnalytics); + const data = allowed ? query.data : undefined; + const sources = data?.sources.map((source) => { + const permitted = permittedSource(source, mask); + return query.isError && permitted.availability === "fresh" ? { ...permitted, availability: "stale" as const } : permitted; + }) ?? []; + const attention = sources.flatMap((source) => source.availability === "unavailable" ? [] : source.metrics.filter(needsMonitoringAttention).map((metric) => ({ source, metric }))); + const queues = attention.filter(({ metric }) => isBrokerQueue(metric)); + const issues = attention.filter(({ metric }) => !isBrokerQueue(metric)); + const gaps = sources.filter((s) => s.availability === "unavailable" || s.coverage !== "complete" || s.metrics.length === 0 || s.metrics.some((m) => m.availability === "unavailable" || m.count === null)); + const stale = sources.filter((s) => s.availability === "stale" || s.availability !== "unavailable" && (observationIsOld(s.observed_at, now) || s.metrics.some((m) => metricIsStale(m, s, now)))); + const unknown = sources.filter((s) => s.metrics.some((m) => ["unknown", "no_recent_evidence"].includes(m.condition))); + const missing = data?.version === "1" ? Object.keys(MONITORING_SOURCES).filter((id) => !sources.some((s) => s.id === id)).length : 0; + const refreshAt = timestamp(data?.refresh_after); + const waiting = !query.isError && refreshAt !== null && refreshAt > now; + const unsupported = data && data.version !== "1"; + return ( +
    +
    +

    Operational monitoring

    +
    + {compact && allowed && All operational details} + {allowed && } +
    +
    + {!allowed && Analytics access and a known permission mask are required. No operational measurements were requested.} + {allowed && query.isLoading &&
    Loading operational monitoring
    } + {allowed && query.isError && {data ? "Showing the previous snapshot with its original timestamps, not a fresh health verdict." : "Operational measurements are unavailable. An empty or failed request is not an all-clear. Verify access or retry."}} + {unsupported && This admin release cannot interpret this snapshot. Update the admin frontend; health remains unknown.} + {data && !unsupported && <> +
    + + +
    + {sources.length === 0 ? : <> + + {gaps.length} sources with coverage gaps; {stale.length} stale; {unknown.length} with unknown or no recent evidence{missing > 0 ? `; ${missing} expected sources not returned` : ""}. Snapshot coverage: {data.coverage === "complete" ? "complete for collected sources" : "incomplete"}. + {attention.length === 0 && " No attention signals in measured sources. This is not an instance-wide all-clear."} + {stale.length > 0 && " Stale observations are historical, not proof of a current failure."} + +

    Counts are per metric and may overlap. Scheduled, retry, budget and daily-limit waits are informational; safety holds are restrictions, not provider outages. Heartbeats, completed tasks and sync ticks do not prove sends or recovery.

    + {compact ? <> + {queues.length > 0 &&
    +

    {queues.length} positive broker queue measurements

    +

    Observed pending or lag counts, not a proven outage or delivery rate. Separate scopes and metrics must not be added together.

    +
      {queues.map(({ source, metric }, i) => )}
    +
    } + {issues.length > 0 &&
      {issues.slice(0, 6).map(({ source, metric }, i) => )}
    } + {issues.length > 6 &&

    {issues.length - 6} more signals in operational details.

    } + :
    {sources.map((source) => )}
    } + } + } +
    + ); +} diff --git a/admin/src/app/dashboard/OverviewPage.tsx b/admin/src/app/dashboard/OverviewPage.tsx index 35e05739f..455bb6b7e 100644 --- a/admin/src/app/dashboard/OverviewPage.tsx +++ b/admin/src/app/dashboard/OverviewPage.tsx @@ -25,6 +25,7 @@ import { PageHeader } from "@/components/layout/PageHeader"; import { Skeleton } from "@/components/ui/skeleton"; import { EmptyState, Panel, Section, Segmented, Stat, StatGrid } from "@/components/ui/kit"; import { InstanceProblemsPanel } from "./InstanceHealthPanel"; +import { InstanceMonitoringPanel } from "./InstanceMonitoringPanel"; import { useAdminPerm } from "@/hooks/useAdminPerm"; import { AdminPerm } from "@/lib/auth/permissions"; import { TONE_DOT, TONE_TEXT } from "@/lib/tones"; @@ -83,6 +84,7 @@ export default function OverviewPage() { + {!canView ? (
    diff --git a/admin/src/app/dashboard/health/FindingsTab.tsx b/admin/src/app/dashboard/health/FindingsTab.tsx index 818f5afa7..2087864b3 100644 --- a/admin/src/app/dashboard/health/FindingsTab.tsx +++ b/admin/src/app/dashboard/health/FindingsTab.tsx @@ -1,7 +1,3 @@ -// Setup and health, findings: everything this deployment is currently -// getting wrong, as decided by the running backend. The endpoint returns -// only checks that are not ok, so an empty response is a real all-clear. - import { useState } from "react"; import { cn } from "@/lib/utils"; import { useSearchParams } from "react-router-dom"; @@ -43,7 +39,7 @@ export function FindingsTab() {
    {healthQ.dataUpdatedAt > 0 && ( - Last checked {new Date(healthQ.dataUpdatedAt).toLocaleTimeString()} + Response received {new Date(healthQ.dataUpdatedAt).toLocaleTimeString()} )}