mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-18 00:02:59 +00:00
feat: add permission-aware operational monitoring to admin Overview and health with evidence clocks, backlog signals, coverage docs and typed permission regressions
This commit is contained in:
committed by
Devin AI
parent
9a09740c84
commit
2cf19ebedf
@@ -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() {
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Setup and health"
|
||||
description="What the backend thinks is wrong with this instance, and whether the services it depends on are answering."
|
||||
description="Configuration findings, live service probes and evidence-based operational measurements. Missing coverage is not an all-clear."
|
||||
>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
{canConfigure && <Button size="sm" variant="outline" asChild>
|
||||
<Link to="/configuration">Configuration</Link>
|
||||
</Button>
|
||||
</Button>}
|
||||
</PageHeader>
|
||||
|
||||
<PageTabs
|
||||
@@ -60,12 +60,13 @@ export default function HealthPage() {
|
||||
badge: findings > 0 ? findings : undefined,
|
||||
},
|
||||
{ id: "services", label: "Services", icon: Activity },
|
||||
{ id: "operations", label: "Operations", icon: Activity },
|
||||
]}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
|
||||
{tab === "findings" ? <FindingsTab /> : <ServicesTab />}
|
||||
{tab === "findings" ? <FindingsTab /> : tab === "services" ? <ServicesTab /> : <InstanceMonitoringPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Callout className="mb-4" tone={healthQ.isError ? "warning" : "info"} title={healthQ.isError ? "Configuration findings unavailable" : "No configuration errors or warnings reported"}>
|
||||
{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.`}
|
||||
{" "}<Link to="/health" className="font-medium hover:underline">Setup and health</Link>
|
||||
</Callout>
|
||||
);
|
||||
if (actionable.length === 0) return null;
|
||||
|
||||
const errors = actionable.filter((c) => c.severity === "error").length;
|
||||
const shown = actionable.slice(0, OVERVIEW_LIMIT);
|
||||
|
||||
@@ -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> = {}): 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> = {}): 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(<MemoryRouter><InstanceMonitoringPanel compact={compact} /></MemoryRouter>);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<span className="break-words">
|
||||
{label}: {at === null ? "unknown" : <time dateTime={value!} title={new Date(at).toISOString()}>{new Date(at).toLocaleString()} ({ageLabel(at, now)})</time>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<li className="min-w-0 space-y-2 px-4 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<h4 className="min-w-0 break-words text-[13px] font-medium">{m.title}</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusBadge tone={!measured ? "neutral" : metricTone(m, stale)}>{unavailable ? "Unavailable" : !measured ? "Unknown" : CONDITION_LABELS[m.condition] ?? "Unknown"}</StatusBadge>
|
||||
{!unavailable && stale && <StatusBadge tone="warning">Stale observation</StatusBadge>}
|
||||
{isBrokerQueue(m) && <StatusBadge tone="info">Queue measurement, not outage</StatusBadge>}
|
||||
</div>
|
||||
</div>
|
||||
{compact && <p className="text-xs text-muted-foreground">{MONITORING_SOURCES[source.id]?.title ?? "Unrecognized source"}</p>}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[13px] tabular-nums">
|
||||
<span className="font-medium">{measured ? `${m.count!.toLocaleString()} ${m.unit}` : "Not measured"}</span>
|
||||
{!unavailable && m.affected_mailboxes !== null && <span>{m.affected_mailboxes.toLocaleString()} affected mailboxes</span>}
|
||||
{!unavailable && m.affected_organizations !== null && <span>{m.affected_organizations.toLocaleString()} affected organizations</span>}
|
||||
{!unavailable && m.unknown_organization_rows !== null && m.unknown_organization_rows > 0 && <span>{m.unknown_organization_rows.toLocaleString()} rows with unknown organization</span>}
|
||||
</div>
|
||||
{m.reason && <p className="text-xs text-muted-foreground">{monitoringReason(m.reason)}</p>}
|
||||
{m.note && <p className="break-words text-xs leading-relaxed text-muted-foreground">{m.note}</p>}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<ObservationTime label="Observed" value={m.observed_at} now={now} />
|
||||
<ObservationTime label="Evidence since" value={m.evidence_at} now={now} />
|
||||
{m.latest_evidence_at && <ObservationTime label="Latest evidence" value={m.latest_evidence_at} now={now} />}
|
||||
{m.next_eligible_at && <ObservationTime label="Next eligible" value={m.next_eligible_at} now={now} />}
|
||||
</div>
|
||||
{!compact && <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{m.evidence_age_seconds !== null && <span>Evidence age reported by snapshot: {m.evidence_age_seconds.toLocaleString()}s</span>}
|
||||
{m.threshold_seconds !== null && <span>Monitoring threshold: {m.threshold_seconds.toLocaleString()}s (not a provider SLA)</span>}
|
||||
{m.window_start && <ObservationTime label="Window start" value={m.window_start} now={now} />}
|
||||
{m.window_end && <ObservationTime label="Window end" value={m.window_end} now={now} />}
|
||||
</div>}
|
||||
{path && <Link to={path} className="inline-flex text-xs font-medium text-[var(--admin-accent-strong)] hover:underline">Investigate {m.title}</Link>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="min-w-0 overflow-hidden rounded-lg border border-border bg-card" aria-label={MONITORING_SOURCES[source.id]?.title ?? "Unrecognized source"}>
|
||||
<div className="space-y-2 border-b border-border px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">{MONITORING_SOURCES[source.id]?.title ?? "Unrecognized source"}</h3>
|
||||
<StatusBadge tone={unavailable ? "neutral" : stale || source.coverage !== "complete" ? "warning" : "info"}>
|
||||
{unavailable ? "Unavailable" : stale ? "Stale" : "Fresh"} / {source.coverage === "complete" ? "complete" : "incomplete"} coverage
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<ObservationTime label="Source observed" value={source.observed_at} now={now} />
|
||||
<ObservationTime label="Collection attempted" value={source.checked_at} now={now} />
|
||||
{source.measured_scopes !== null && <span>{source.measured_scopes.toLocaleString()} / {source.expected_scopes?.toLocaleString() ?? "unknown"} scopes measured</span>}
|
||||
</div>
|
||||
{source.reason && <p className="text-xs text-muted-foreground">{monitoringReason(source.reason)}</p>}
|
||||
{source.coverage !== "complete" && !unavailable && <p className="text-xs text-muted-foreground">Incomplete coverage. Measured counts may be lower bounds; do not sum overlapping metrics.</p>}
|
||||
</div>
|
||||
{(unavailable || source.metrics.length === 0) && (
|
||||
<p className="px-4 py-4 text-[13px] text-muted-foreground">{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."}</p>
|
||||
)}
|
||||
{source.metrics.length > 0 && (
|
||||
<ul className="divide-y divide-border">{source.metrics.map((m, i) => <MetricRow key={`${m.id}:${i}`} metric={unavailable ? { ...m, availability: "unavailable", observed_at: null } : m} source={source} mask={mask} now={now} />)}</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className={compact ? "mb-8 space-y-3" : "space-y-4"} aria-label="Operational monitoring">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold"><Activity className="size-4" />Operational monitoring</h2>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{compact && allowed && <Link to="/health?tab=operations" className="text-xs font-medium text-[var(--admin-accent-strong)] hover:underline">All operational details</Link>}
|
||||
{allowed && <Button size="sm" variant="outline" onClick={() => query.refetch()} disabled={query.isFetching || waiting} title={waiting ? "The backend reuses the snapshot until its refresh time" : undefined}>
|
||||
<RefreshCw className={query.isFetching ? "size-3.5 animate-spin" : "size-3.5"} />{query.isFetching ? "Refreshing..." : "Refresh monitoring"}
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
{!allowed && <Callout tone="neutral" title="Monitoring permission required">Analytics access and a known permission mask are required. No operational measurements were requested.</Callout>}
|
||||
{allowed && query.isLoading && <div role="status" aria-label="Loading operational monitoring"><Skeleton className="h-24 w-full" /><span className="sr-only">Loading operational monitoring</span></div>}
|
||||
{allowed && query.isError && <Callout tone="warning" icon={AlertTriangle} title="Monitoring refresh failed">{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."}</Callout>}
|
||||
{unsupported && <Callout tone="warning" title="Unsupported monitoring version">This admin release cannot interpret this snapshot. Update the admin frontend; health remains unknown.</Callout>}
|
||||
{data && !unsupported && <>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<ObservationTime label="Snapshot checked" value={data.checked_at} now={now} />
|
||||
<ObservationTime label="Refresh eligible" value={data.refresh_after} now={now} />
|
||||
</div>
|
||||
{sources.length === 0 ? <EmptyState title="No monitoring sources returned" hint="Coverage is unknown. An empty response does not establish health or zero backlog." /> : <>
|
||||
<Callout tone={gaps.length || stale.length || missing || data.coverage !== "complete" ? "warning" : "info"} title={`${attention.length} recorded attention signals`}>
|
||||
{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."}
|
||||
</Callout>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">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.</p>
|
||||
{compact ? <>
|
||||
{queues.length > 0 && <section className="overflow-hidden rounded-lg border border-border bg-card" aria-label="Positive broker queue measurements">
|
||||
<h3 className="px-4 pt-3 text-sm font-medium">{queues.length} positive broker queue measurements</h3>
|
||||
<p className="px-4 py-2 text-xs text-muted-foreground">Observed pending or lag counts, not a proven outage or delivery rate. Separate scopes and metrics must not be added together.</p>
|
||||
<ul className="max-h-96 divide-y divide-border overflow-auto">{queues.map(({ source, metric }, i) => <MetricRow key={`${source.id}:${metric.id}:${i}`} metric={metric} source={source} mask={mask} now={now} compact />)}</ul>
|
||||
</section>}
|
||||
{issues.length > 0 && <div className="overflow-hidden rounded-lg border border-border bg-card"><ul className="divide-y divide-border">{issues.slice(0, 6).map(({ source, metric }, i) => <MetricRow key={`${source.id}:${metric.id}:${i}`} metric={metric} source={source} mask={mask} now={now} compact />)}</ul></div>}
|
||||
{issues.length > 6 && <p className="text-xs text-muted-foreground">{issues.length - 6} more signals in operational details.</p>}
|
||||
</> : <div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">{sources.map((source) => <SourcePanel key={source.id} source={source} mask={mask} now={now} />)}</div>}
|
||||
</>}
|
||||
</>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</PageHeader>
|
||||
|
||||
<InstanceProblemsPanel />
|
||||
<InstanceMonitoringPanel compact />
|
||||
|
||||
{!canView ? (
|
||||
<div className="rounded-lg border border-dashed border-border-strong">
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex items-center gap-2">
|
||||
{healthQ.dataUpdatedAt > 0 && (
|
||||
<span className="text-xs text-subtle-foreground tabular-nums">
|
||||
Last checked {new Date(healthQ.dataUpdatedAt).toLocaleTimeString()}
|
||||
Response received {new Date(healthQ.dataUpdatedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
@@ -70,7 +66,7 @@ export function FindingsTab() {
|
||||
|
||||
{healthQ.isError && (
|
||||
<ErrorState
|
||||
error={healthQ.error}
|
||||
error={new Error(healthQ.data ? "The refresh failed. Findings below are from the previous response, not a fresh verdict." : "Findings are unavailable. Verify access or retry; this is not an all-clear.")}
|
||||
title="Could not run the instance checks"
|
||||
onRetry={() => healthQ.refetch()}
|
||||
/>
|
||||
@@ -78,13 +74,12 @@ export function FindingsTab() {
|
||||
|
||||
{healthQ.data && (
|
||||
<>
|
||||
{problemCount === 0 && (
|
||||
<Callout tone="success" icon={CheckCircle2} title="No problems found">
|
||||
No errors or warnings were reported.
|
||||
<Callout tone={healthQ.isError ? "warning" : "info"} icon={Info} title={problemCount === 0 ? "No errors or warnings reported" : "Reported configuration findings"}>
|
||||
{healthQ.data.execution_coverage === "complete" ? "Configuration check coverage was reported complete, not operational send or sync coverage." : "Check execution coverage is not established. Absent findings do not prove every check ran or that the instance is healthy."}
|
||||
{" See Operations for measured activity, backlog and evidence freshness."}
|
||||
{checks.length > 0 &&
|
||||
" The informational notes below do not mean this instance is unhealthy."}
|
||||
</Callout>
|
||||
)}
|
||||
</Callout>
|
||||
{checks.length > 0 && (
|
||||
<div className={problemCount === 0 ? "mt-6" : undefined}>
|
||||
<SummaryStrip summary={summary} />
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InstanceHealthResult } from "@/lib/api/client/admin/instance";
|
||||
import type { SystemStatusResult } from "@/lib/api/client/admin/system";
|
||||
import { FindingsTab } from "./FindingsTab";
|
||||
import { ServicesTab } from "./ServicesTab";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
health: { data: undefined as InstanceHealthResult | undefined, isError: false, isLoading: false, isFetching: false, dataUpdatedAt: 0, error: new Error("secret-provider-detail"), refetch: vi.fn() },
|
||||
services: { data: undefined as SystemStatusResult | undefined, isError: false, isLoading: false, isFetching: false, error: new Error("secret-provider-detail"), refetch: vi.fn() },
|
||||
canConfigure: true,
|
||||
}));
|
||||
vi.mock("@/hooks/useInstanceHealth", () => ({ useInstanceHealth: () => state.health, findingCount: () => 0 }));
|
||||
vi.mock("@/hooks/useUpdateState", () => ({ useUpdateState: () => ({ data: undefined }) }));
|
||||
vi.mock("@/components/layout/UpdateDialog", () => ({ UpdateDialog: () => null }));
|
||||
vi.mock("../InstanceHealthPanel", () => ({ InstanceFindings: () => <div>Retained findings</div> }));
|
||||
vi.mock("@tanstack/react-query", () => ({ useQuery: () => state.services }));
|
||||
vi.mock("@/hooks/useAdminPerm", () => ({ useAdminPerm: () => state.canConfigure }));
|
||||
vi.mock("../MailStatusCard", () => ({ MailStatusCard: () => <span>Protected mail probe</span> }));
|
||||
|
||||
function render(page: "findings" | "services") {
|
||||
const html = renderToStaticMarkup(<MemoryRouter>{page === "findings" ? <FindingsTab /> : <ServicesTab />}</MemoryRouter>);
|
||||
return new DOMParser().parseFromString(html, "text/html").body.textContent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.canConfigure = true;
|
||||
Object.assign(state.health, { data: { checks: [], summary: { error: 0, warning: 0, info: 0 }, execution_coverage: "unreported" }, isError: false, dataUpdatedAt: Date.now() });
|
||||
Object.assign(state.services, { data: { data: [], checked_at: new Date().toISOString() }, isError: false });
|
||||
});
|
||||
|
||||
describe("configuration and service coverage", () => {
|
||||
it("does not mount the protected platform mail probe without manage-settings access", () => {
|
||||
state.canConfigure = false;
|
||||
expect(render("services")).toContain("Platform mail permission required");
|
||||
expect(render("services")).not.toContain("Protected mail probe");
|
||||
});
|
||||
it("does not equate absent findings or an older coverage-free response to healthy", () => {
|
||||
for (const coverage of [undefined, "unreported"]) {
|
||||
state.health.data!.execution_coverage = coverage;
|
||||
expect(render("findings")).toContain("Check execution coverage is not established");
|
||||
expect(render("findings")).not.toContain("No problems found");
|
||||
expect(render("findings")).toContain("Response received");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps failed refresh explicit and redacts raw query errors", () => {
|
||||
state.health.isError = true;
|
||||
state.services.isError = true;
|
||||
for (const page of ["findings", "services"] as const) {
|
||||
expect(render(page)).toContain("refresh");
|
||||
expect(render(page)).not.toContain("secret-provider-detail");
|
||||
expect(render(page)).not.toContain("All systems operational");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not call an empty service list operational", () => {
|
||||
expect(render("services")).toContain("Current service coverage unknown");
|
||||
expect(render("services")).toContain("No components were returned");
|
||||
});
|
||||
|
||||
it("scopes passed probes to reachability and hides connection errors", () => {
|
||||
state.services.data!.data = [{ name: "postgres", ok: true, latency_ms: 5 }, { name: "redis", ok: false, latency_ms: 20, error: "password=private secret-provider-detail" }];
|
||||
expect(render("services")).toContain("1 service probe failed");
|
||||
expect(render("services")).toContain("Probe passed");
|
||||
expect(render("services")).not.toContain("secret-provider-detail");
|
||||
state.services.data!.data = [{ name: "postgres", ok: true, latency_ms: 5 }];
|
||||
expect(render("services")).toContain("Reported service probes passed");
|
||||
expect(render("services")).toContain("does not prove send throughput");
|
||||
});
|
||||
|
||||
it("does not turn stale probes or cached successful probes into a fresh verdict", () => {
|
||||
state.services.data!.data = [{ name: "postgres", ok: true, latency_ms: 5 }];
|
||||
state.services.data!.checked_at = new Date(Date.now() - 300_000).toISOString();
|
||||
expect(render("services")).toContain("Current service coverage unknown");
|
||||
expect(render("services")).toContain("Cached probe");
|
||||
expect(render("services")).not.toContain("Probe passed");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,9 @@ import { Callout, StatusBadge, StatusDot } from "@/components/ui/kit";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MailStatusCard } from "../MailStatusCard";
|
||||
import { getSystemStatus, type SystemComponentStatus } from "@/lib/api/client/admin/system";
|
||||
import { observationIsOld } from "@/lib/monitoring";
|
||||
import { useAdminPerm } from "@/hooks/useAdminPerm";
|
||||
import { AdminPerm } from "@/lib/auth/permissions";
|
||||
|
||||
export const SYSTEM_STATUS_KEY = ["admin", "system", "status"] as const;
|
||||
|
||||
@@ -36,17 +39,20 @@ function titleCase(name: string): string {
|
||||
}
|
||||
|
||||
export function ServicesTab() {
|
||||
const canConfigure = useAdminPerm(AdminPerm.ManageSettings);
|
||||
// Probes have no realtime event, so this is a deliberate poll; the query
|
||||
// only lives while the tab is mounted.
|
||||
const statusQ = useQuery({
|
||||
queryKey: SYSTEM_STATUS_KEY,
|
||||
queryFn: getSystemStatus,
|
||||
refetchInterval: 15_000,
|
||||
refetchIntervalInBackground: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const components = statusQ.data?.data ?? [];
|
||||
const failing = components.filter((c) => !c.ok);
|
||||
const cached = statusQ.isError || observationIsOld(statusQ.data?.checked_at, Date.now());
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -85,7 +91,7 @@ export function ServicesTab() {
|
||||
|
||||
{statusQ.isError && (
|
||||
<ErrorState
|
||||
error={statusQ.error}
|
||||
error={new Error(statusQ.data ? "Refresh failed. The previous probe results are retained, not a fresh verdict." : "Service probe results are unavailable. An empty or failed request is not an all-clear.")}
|
||||
title="Could not run health checks"
|
||||
onRetry={() => statusQ.refetch()}
|
||||
/>
|
||||
@@ -93,8 +99,10 @@ export function ServicesTab() {
|
||||
|
||||
{statusQ.data && (
|
||||
<>
|
||||
{failing.length === 0 ? (
|
||||
<Callout tone="success" icon={CheckCircle2} title="All systems operational" className="mb-5" />
|
||||
{cached || components.length === 0 ? (
|
||||
<Callout tone="warning" title="Current service coverage unknown" className="mb-5">{components.length === 0 ? "No components were returned. Reachability has not been established." : "Cached probes cannot establish current reachability."}</Callout>
|
||||
) : failing.length === 0 ? (
|
||||
<Callout tone="info" icon={CheckCircle2} title="Reported service probes passed" className="mb-5">Reachability at the probe time only. This does not prove send throughput, mailbox sync or recovery.</Callout>
|
||||
) : (
|
||||
<Callout
|
||||
tone="danger"
|
||||
@@ -102,8 +110,8 @@ export function ServicesTab() {
|
||||
className="mb-5"
|
||||
title={
|
||||
failing.length === 1
|
||||
? "1 component is down"
|
||||
: `${failing.length} components are down`
|
||||
? "1 service probe failed"
|
||||
: `${failing.length} service probes failed`
|
||||
}
|
||||
>
|
||||
{failing.map((c) => titleCase(c.name)).join(", ")}
|
||||
@@ -123,13 +131,13 @@ export function ServicesTab() {
|
||||
) : (
|
||||
<ul className="divide-y divide-border/70">
|
||||
{components.map((c) => (
|
||||
<ComponentRow key={c.name} component={c} />
|
||||
<ComponentRow key={c.name} component={c} cached={cached} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="order-1 lg:order-2">
|
||||
<MailStatusCard />
|
||||
{canConfigure ? <MailStatusCard /> : <Callout tone="neutral" title="Platform mail permission required">Manage settings access is required to inspect or test platform mail.</Callout>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -138,12 +146,12 @@ export function ServicesTab() {
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentRow({ component: c }: { component: SystemComponentStatus }) {
|
||||
function ComponentRow({ component: c, cached }: { component: SystemComponentStatus; cached: boolean }) {
|
||||
return (
|
||||
<li className="px-4 py-3 transition-colors hover:bg-accent/50">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<StatusDot tone={c.ok ? "success" : "danger"} className="font-medium text-foreground">
|
||||
<StatusDot tone={cached ? "neutral" : c.ok ? "success" : "danger"} className="font-medium text-foreground">
|
||||
<span className="truncate">{titleCase(c.name)}</span>
|
||||
</StatusDot>
|
||||
<p className="mt-0.5 pl-3.5 text-xs leading-relaxed text-muted-foreground">
|
||||
@@ -154,14 +162,14 @@ function ComponentRow({ component: c }: { component: SystemComponentStatus }) {
|
||||
<span className="text-xs text-muted-foreground tabular-nums" title="Latency">
|
||||
{c.latency_ms} ms
|
||||
</span>
|
||||
<StatusBadge tone={c.ok ? "success" : "danger"} className="w-[5.75rem] justify-center">
|
||||
{c.ok ? "Operational" : "Down"}
|
||||
<StatusBadge tone={cached ? "neutral" : c.ok ? "success" : "danger"} className="w-[5.75rem] justify-center">
|
||||
{cached ? "Cached probe" : c.ok ? "Probe passed" : "Probe failed"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
{c.error && (
|
||||
<div className="mt-2 ml-3.5 break-words rounded-md border border-red-500/20 bg-red-500/[0.06] px-2.5 py-1.5 font-mono text-[11.5px] text-red-700 dark:text-red-400">
|
||||
{c.error}
|
||||
Probe failed. Inspect protected service logs for details; raw connection errors are not displayed here.
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AdminPerm } from "@/lib/auth/permissions";
|
||||
import { INSTANCE_MONITORING_KEY, useInstanceMonitoring } from "./useInstanceMonitoring";
|
||||
|
||||
const state = vi.hoisted(() => ({ mask: 0, id: "first-admin", fetch: vi.fn() }));
|
||||
vi.mock("@/hooks/useMe", () => ({ useMe: () => ({ data: { id: state.id, admin_permissions: state.mask } }) }));
|
||||
vi.mock("@/lib/api/client/admin/monitoring", () => ({ getInstanceMonitoring: state.fetch }));
|
||||
|
||||
let root: Root | undefined;
|
||||
let client: QueryClient | undefined;
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root!.unmount());
|
||||
root = undefined;
|
||||
client?.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function Reader() {
|
||||
const query = useInstanceMonitoring();
|
||||
return <span>{query.data?.checked_at ?? "No snapshot"}</span>;
|
||||
}
|
||||
|
||||
describe("shared monitoring query", () => {
|
||||
it("shares one scoped cache between readers, keeps original clocks and bounds polling", async () => {
|
||||
state.mask = AdminPerm.ViewAnalytics;
|
||||
state.id = "first-admin";
|
||||
state.fetch.mockResolvedValue({ version: "1", checked_at: "original-server-observation", sources: [] });
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const container = document.createElement("div");
|
||||
root = createRoot(container);
|
||||
await act(async () => root!.render(<QueryClientProvider client={client!}><Reader /><Reader /></QueryClientProvider>));
|
||||
await vi.waitFor(() => expect(state.fetch).toHaveBeenCalledTimes(1));
|
||||
const queries = client.getQueryCache().getAll();
|
||||
expect(queries).toHaveLength(1);
|
||||
expect(queries[0].queryKey).toEqual([...INSTANCE_MONITORING_KEY, "first-admin", AdminPerm.ViewAnalytics]);
|
||||
expect(queries[0].state.data).toMatchObject({ checked_at: "original-server-observation" });
|
||||
expect(queries[0].options).toMatchObject({ staleTime: 60_000, refetchInterval: 60_000, refetchIntervalInBackground: false, refetchOnWindowFocus: false, retry: false });
|
||||
});
|
||||
|
||||
it("does not fetch without analytics and does not reuse a higher-permission or different-user snapshot", async () => {
|
||||
client = new QueryClient();
|
||||
client.setQueryData([...INSTANCE_MONITORING_KEY, "first-admin", AdminPerm.ViewAnalytics | AdminPerm.ViewUsers], { checked_at: "private-old-observation" });
|
||||
client.setQueryData([...INSTANCE_MONITORING_KEY, "other-admin", AdminPerm.ViewAnalytics], { checked_at: "private-other-admin" });
|
||||
state.mask = 0;
|
||||
state.id = "first-admin";
|
||||
const container = document.createElement("div");
|
||||
root = createRoot(container);
|
||||
await act(async () => root!.render(<QueryClientProvider client={client!}><Reader /></QueryClientProvider>));
|
||||
expect(state.fetch).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toBe("No snapshot");
|
||||
state.mask = AdminPerm.ViewAnalytics;
|
||||
state.fetch.mockResolvedValue({ checked_at: "new-projection" });
|
||||
await act(async () => root!.render(<QueryClientProvider client={client!}><Reader /></QueryClientProvider>));
|
||||
await vi.waitFor(() => expect(state.fetch).toHaveBeenCalledTimes(1));
|
||||
expect(client.getQueryData([...INSTANCE_MONITORING_KEY, "first-admin", AdminPerm.ViewAnalytics])).toMatchObject({ checked_at: "new-projection" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMe } from "@/hooks/useMe";
|
||||
import { AdminPerm } from "@/lib/auth/permissions";
|
||||
import { canMonitor } from "@/lib/monitoring";
|
||||
import { getInstanceMonitoring } from "@/lib/api/client/admin/monitoring";
|
||||
|
||||
export const INSTANCE_MONITORING_KEY = ["admin", "instance", "monitoring"] as const;
|
||||
|
||||
export function useInstanceMonitoring() {
|
||||
const { data: me } = useMe();
|
||||
return useQuery({
|
||||
queryKey: [...INSTANCE_MONITORING_KEY, me?.id, me?.admin_permissions],
|
||||
queryFn: getInstanceMonitoring,
|
||||
enabled: canMonitor(me?.admin_permissions, AdminPerm.ViewAnalytics),
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 60_000,
|
||||
refetchIntervalInBackground: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
@@ -58,9 +58,11 @@ export interface InstanceHealthSummary {
|
||||
}
|
||||
|
||||
export interface InstanceHealthResult {
|
||||
// Only non-ok checks are returned, so an empty list means all clear.
|
||||
// Only findings are returned; absent findings do not prove check coverage.
|
||||
checks: InstanceCheck[];
|
||||
summary: InstanceHealthSummary;
|
||||
execution_coverage?: string;
|
||||
operational_monitoring_path?: string;
|
||||
}
|
||||
|
||||
export function getInstanceHealth(): Promise<InstanceHealthResult> {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Request } from "@/lib/api/client";
|
||||
|
||||
export type MonitoringAvailability = "fresh" | "stale" | "unavailable";
|
||||
export type MonitoringCondition = "healthy" | "unknown" | "no_recent_evidence" | "recent_failure" | "persistent_failure" | "recovery_unverified" | "safety_hold" | "backlog" | "in_flight" | "waiting_schedule" | "waiting_retry" | "budget" | "daily_limit" | "stale_telemetry";
|
||||
|
||||
export interface MonitoringMetric {
|
||||
id: string;
|
||||
title: string;
|
||||
unit: string;
|
||||
scope_id?: string;
|
||||
availability: MonitoringAvailability;
|
||||
condition: MonitoringCondition;
|
||||
severity: string;
|
||||
reason?: string;
|
||||
note: string;
|
||||
count: number | null;
|
||||
affected_mailboxes: number | null;
|
||||
affected_organizations: number | null;
|
||||
unknown_organization_rows: number | null;
|
||||
observed_at: string | null;
|
||||
evidence_at: string | null;
|
||||
latest_evidence_at: string | null;
|
||||
evidence_age_seconds: number | null;
|
||||
next_eligible_at: string | null;
|
||||
window_start: string | null;
|
||||
window_end: string | null;
|
||||
threshold_seconds: number | null;
|
||||
investigate?: string;
|
||||
}
|
||||
|
||||
export interface MonitoringSource {
|
||||
id: string;
|
||||
availability: MonitoringAvailability;
|
||||
coverage: string;
|
||||
reason?: string;
|
||||
observed_at: string | null;
|
||||
checked_at: string;
|
||||
measured_scopes: number | null;
|
||||
expected_scopes: number | null;
|
||||
metrics: MonitoringMetric[];
|
||||
}
|
||||
|
||||
export interface MonitoringSnapshot {
|
||||
version: string;
|
||||
checked_at: string;
|
||||
refresh_after: string;
|
||||
coverage: string;
|
||||
sources: MonitoringSource[];
|
||||
}
|
||||
|
||||
export function getInstanceMonitoring(): Promise<MonitoringSnapshot> {
|
||||
return Request({ method: "GET", url: "/admin/instance/monitoring", authorization: true, timeout: 12_000 });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AdminPerm } from "@/lib/auth/permissions";
|
||||
import type { MonitoringSource } from "@/lib/api/client/admin/monitoring";
|
||||
import { canMonitor, investigationPath, MONITORING_SOURCES, observationIsOld, permittedSource } from "./monitoring";
|
||||
|
||||
describe("monitoring permission and freshness boundaries", () => {
|
||||
it.each(Object.entries(MONITORING_SOURCES))("requires the native source bit for %s", (id, { permission }) => {
|
||||
const source = { id, availability: "fresh", metrics: [{ count: 42 }] } as MonitoringSource;
|
||||
expect(permittedSource(source, AdminPerm.ViewAnalytics | permission)).toBe(source);
|
||||
if (permission !== AdminPerm.ViewAnalytics) {
|
||||
expect(permittedSource(source, AdminPerm.ViewAnalytics)).toMatchObject({ availability: "unavailable", reason: "permission_denied", metrics: [], measured_scopes: null, expected_scopes: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed for unknown masks and unsupported destinations", () => {
|
||||
expect(canMonitor(undefined, AdminPerm.ViewAnalytics)).toBe(false);
|
||||
for (const path of ["https://external.invalid/", "//external.invalid/", "/workers/secret@example.com", "/sends?tab=failures&token=secret", "/jobs#payload"]) {
|
||||
expect(investigationPath(path, 0xffffff)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts known native destinations only with their separate page permission", () => {
|
||||
for (const [path, permission] of [["/sends?tab=dead-letters", AdminPerm.ViewCampaigns], ["/sync?state=backfilling", AdminPerm.ViewUsers], ["/mailboxes", AdminPerm.ViewUsers], ["/fleet", AdminPerm.ViewWorkers], ["/workers/1a64feee-dee2-41df-a7f5-a85816ba8585", AdminPerm.ViewWorkers], ["/organizations/1a64feee-dee2-41df-a7f5-a85816ba8585", AdminPerm.ViewOrganizations], ["/jobs", AdminPerm.ViewAnalytics], ["/configuration?tab=notifications", AdminPerm.ManageSettings]] as const) {
|
||||
expect(investigationPath(path, permission)).toBe(path);
|
||||
expect(investigationPath(path, 0)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not call missing, invalid, future or old observation clocks fresh", () => {
|
||||
const now = Date.now();
|
||||
for (const value of [null, "not-a-time", new Date(now - 121_000).toISOString(), new Date(now + 61_000).toISOString()]) expect(observationIsOld(value, now)).toBe(true);
|
||||
expect(observationIsOld(new Date(now - 60_000).toISOString(), now)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { AdminPerm } from "@/lib/auth/permissions";
|
||||
import type { MonitoringCondition, MonitoringMetric, MonitoringSource } from "@/lib/api/client/admin/monitoring";
|
||||
|
||||
export const MONITORING_SOURCES: Record<string, { title: string; permission: number }> = {
|
||||
send_safety: { title: "Send safety", permission: AdminPerm.ViewUsers },
|
||||
sends: { title: "Send confirmations", permission: AdminPerm.ViewCampaigns },
|
||||
campaign_reservations: { title: "Campaign reservations", permission: AdminPerm.ViewCampaigns },
|
||||
dispatch: { title: "Scheduled dispatch", permission: AdminPerm.ViewCampaigns },
|
||||
sync: { title: "Mailbox sync observations", permission: AdminPerm.ViewUsers },
|
||||
mailbox_errors: { title: "Recorded mailbox issues", permission: AdminPerm.ViewUsers },
|
||||
worker_samples: { title: "Worker telemetry", permission: AdminPerm.ViewWorkers },
|
||||
workers: { title: "Worker registration", permission: AdminPerm.ViewWorkers },
|
||||
jobs: { title: "Registered jobs", permission: AdminPerm.ViewAnalytics },
|
||||
dead_letters: { title: "Dead letters", permission: AdminPerm.ViewCampaigns },
|
||||
webhooks: { title: "Customer webhooks", permission: AdminPerm.ViewOrganizations },
|
||||
notifications: { title: "Customer notification rows", permission: AdminPerm.ManageSettings },
|
||||
result_effects: { title: "Send result effects", permission: AdminPerm.ViewCampaigns },
|
||||
arrivals: { title: "Optional arrival outbox", permission: AdminPerm.ViewUsers },
|
||||
domain_auth: { title: "Sending-domain authentication", permission: AdminPerm.ViewUsers },
|
||||
warmup_loading: { title: "Warmup mailbox loading", permission: AdminPerm.ViewCampaigns },
|
||||
backend_event_broker: { title: "Backend result broker", permission: AdminPerm.ViewAnalytics },
|
||||
worker_command_broker: { title: "Worker command broker", permission: AdminPerm.ViewWorkers },
|
||||
send_wait_attribution: { title: "Detailed send wait reasons", permission: AdminPerm.ViewCampaigns },
|
||||
tracking_broker: { title: "External tracking broker", permission: AdminPerm.ViewAnalytics },
|
||||
};
|
||||
|
||||
export const CONDITION_LABELS: Record<MonitoringCondition, string> = {
|
||||
healthy: "Confirmed scoped observation", unknown: "Unknown", no_recent_evidence: "No matching recent evidence",
|
||||
recent_failure: "Recent failure evidence", persistent_failure: "Persistent failure evidence",
|
||||
recovery_unverified: "Recovery unverified", safety_hold: "Safety hold", backlog: "Measured backlog",
|
||||
in_flight: "In flight", waiting_schedule: "Scheduled wait", waiting_retry: "Retry wait",
|
||||
budget: "Budget wait", daily_limit: "Daily-limit wait", stale_telemetry: "Stale telemetry",
|
||||
};
|
||||
|
||||
const REASONS: Record<string, string> = {
|
||||
permission_denied: "Permission required", unsupported: "Not supported by this source",
|
||||
dependency_missing: "Dependency unavailable", schema_absent: "Optional schema not installed",
|
||||
timeout: "Collection timed out", query_failed: "Measurement query failed", collection_failed: "Collection failed",
|
||||
sample_limit: "Bounded sample", scope_limit: "Bounded scope", no_recent_evidence: "No recent measurement evidence",
|
||||
no_registered_scopes: "No registered scopes to measure",
|
||||
};
|
||||
|
||||
export function monitoringReason(reason?: string): string {
|
||||
return REASONS[reason ?? ""] ?? "Measurement unavailable or incomplete";
|
||||
}
|
||||
|
||||
export function canMonitor(mask: number | undefined, permission: number): boolean {
|
||||
return typeof mask === "number" && (mask & permission) === permission;
|
||||
}
|
||||
|
||||
export function permittedSource(source: MonitoringSource, mask: number | undefined): MonitoringSource {
|
||||
const required = MONITORING_SOURCES[source.id]?.permission;
|
||||
if (required !== undefined && canMonitor(mask, required)) return source;
|
||||
return { ...source, availability: "unavailable", coverage: "unavailable", reason: required === undefined ? "unsupported" : "permission_denied", observed_at: null, measured_scopes: null, expected_scopes: null, metrics: [] };
|
||||
}
|
||||
|
||||
const ATTENTION = new Set<MonitoringCondition>(["recent_failure", "persistent_failure", "recovery_unverified", "safety_hold", "backlog", "stale_telemetry"]);
|
||||
|
||||
export function needsMonitoringAttention(metric: MonitoringMetric): boolean {
|
||||
return metric.availability !== "unavailable" && typeof metric.count === "number" && metric.count > 0 &&
|
||||
(ATTENTION.has(metric.condition) || metric.severity === "warning" || metric.severity === "error" || isBrokerQueue(metric));
|
||||
}
|
||||
|
||||
export function isBrokerQueue(metric: MonitoringMetric): boolean {
|
||||
return ["committed_lag", "pending", "ack_pending", "redelivered"].includes(metric.id);
|
||||
}
|
||||
|
||||
export function monitoringAttentionCount(sources: MonitoringSource[]): number {
|
||||
return sources.reduce((count, source) => count + source.metrics.filter(needsMonitoringAttention).length, 0);
|
||||
}
|
||||
|
||||
export function investigationPath(path: string | undefined, mask: number | undefined): string | null {
|
||||
if (!path) return null;
|
||||
let required: number | undefined;
|
||||
if (/^\/sends\?tab=(in-flight|failures|dead-letters)$/.test(path)) required = AdminPerm.ViewCampaigns;
|
||||
else if (path === "/sends?tab=webhooks") required = AdminPerm.ViewCampaigns | AdminPerm.ViewOrganizations;
|
||||
else if (/^\/(sync|mailboxes)(\?state=(throttled|backfilling|stalled|pending|complete))?$/.test(path)) required = AdminPerm.ViewUsers;
|
||||
else if (/^\/(workers|fleet)(\/[0-9a-f-]{36})?$/.test(path)) required = AdminPerm.ViewWorkers;
|
||||
else if (/^\/organizations\/[0-9a-f-]{36}$/.test(path)) required = AdminPerm.ViewOrganizations;
|
||||
else if (/^\/configuration(\?tab=notifications)?$/.test(path)) required = AdminPerm.ManageSettings;
|
||||
else if (/^\/(jobs|health)(\?tab=(findings|services|operations))?$/.test(path)) required = AdminPerm.ViewAnalytics;
|
||||
return required !== undefined && canMonitor(mask, required) ? path : null;
|
||||
}
|
||||
|
||||
export function timestamp(value: string | null | undefined): number | null {
|
||||
const parsed = value ? Date.parse(value) : NaN;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
export function observationIsOld(value: string | null | undefined, now: number): boolean {
|
||||
const at = timestamp(value);
|
||||
return at === null || at > now + 60_000 || now - at > 120_000;
|
||||
}
|
||||
|
||||
export function ageLabel(at: number, now: number): string {
|
||||
const seconds = Math.floor((now - at) / 1000);
|
||||
if (seconds < 0) return "future timestamp, age unknown";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
}
|
||||
@@ -610,6 +610,8 @@ Alongside them, `api_url` is this API's own public base (for a copyable example
|
||||
- All of `/auth/cli/*` except the two handshake routes below (`GET /auth/cli/codes/:code`, `POST /auth/cli/codes/:code/approve`, `POST /auth/cli/codes/:code/deny`: the browser half of `warmbly auth login`, where a signed-in member reviews the code a CLI is showing and authorizes it. Approving mints an ordinary API key, so it requires the `MANAGE_API_KEYS` organization permission and a fresh authentication, is session-only (an API key must not be able to mint another one this way), and caps the key to the scopes the approver's role allows. `GET /auth/cli/codes/:code?organization_id=` adds `granted_scopes` and `granted_scope_names`: what approving into that workspace would grant)
|
||||
- All of `/pool-link/*` and `/cloud-link/*` (the self-hosted warmup pool link: approving an instance's code, listing and unlinking instances, reading the pool plan's price and opening its checkout, and on a self-hosted instance the connect flow and mailbox enrollment). `/cloud-link/*` exists only on a self-hosted instance. Connections belong to the selected local workspace, but linking and unlinking them (`/connect`, `/connect/poll`, `DELETE /cloud-link`) and the linked cloud workspace's mailboxes (`/workspace-mailboxes`, `.../adopt`) take a platform admin holding `manage_settings` whose session presented a second factor (`admin_mfa_required` otherwise). Reading the link and putting a workspace's own mailboxes on it (`GET /cloud-link`, `/mailboxes`, enroll, pause, resume and the Google and Microsoft sign-in under `/oauth/*`) take the workspace's `MANAGE_EMAILS`. `GET /cloud-link` includes `legacy_connected`; mailbox rows include `legacy` when they still use the old connection. A normal `DELETE /cloud-link` only disconnects the selected workspace's scoped link. `DELETE /cloud-link?legacy=true` explicitly disconnects the legacy link across all workspaces and uses the same platform-admin permission. New connections and enrollments require workspace scope, and a Cloud workspace accepts only one active link (legacy links count). `POST /pool-link/codes` requires `remote_organization_id` and returns `workspace_scoped: true` for supported clients. `POST /pool-link/codes` and `POST /pool-link/poll` are public and per-IP rate limited: they are the device-code handshake an instance uses before it has a token, and `/pool-link/instance/*` accepts only an instance token. `/pool-link/instance/oauth/*`, `/pool-link/instance/mailboxes/:id/token`, `/pool-link/instance/workspace-mailboxes` and `/pool-link/instance/mailboxes/adopt` are the cloud-managed mailbox surface (Google and Microsoft sign-in on Warmbly's OAuth apps, brokered access tokens); their instance-side counterparts are `/cloud-link/oauth/*` and `/cloud-link/workspace-mailboxes/*`. `/pool-link/instance/redirects` (list), and `GET`, `PUT`, `DELETE` and `POST .../verify` on `/pool-link/instance/redirects/:domain`, are the root redirects Warmbly Cloud serves for the calling instance; the instance drives them through `PUT /emails/domains/:domain/redirect` with `served_by: "cloud"`, and `GET /pool-link/instance` carries `redirects` (`available`, `host`, `limit`, `used`)
|
||||
- All of `/admin/*`. `DELETE /admin/instance/invitations/expired` requires platform admin `manage_organizations` and an MFA-verified session. It removes only expired invitation records across all workspaces, leaves active invitations and membership unchanged, records an admin audit entry, and returns `{ "cleaned": true }`. The admin panel confirms before calling it.
|
||||
- `GET /admin/instance/health` requires `view_analytics` and an MFA-verified admin session. Returns configuration `checks`, `summary`, additive `execution_coverage` (currently `unreported`) and `operational_monitoring_path`. Empty findings do not establish execution coverage or operational health.
|
||||
- `GET /admin/instance/monitoring` requires `view_analytics` and an MFA-verified admin session, not an organization API-key scope. Returns the read-only version `"1"` snapshot with `checked_at`, `refresh_after`, `coverage` and permission-shaped `sources`; HTTP responses use `Cache-Control: no-store`, while the backend shares a bounded one-minute collection cache. Each source has availability, coverage, nullable observation/scope measurements and metrics with nullable counts, affected mailboxes/organizations, evidence clocks, conditions and optional investigation paths. Missing permissions return unavailable source placeholders with `permission_denied`, not private metrics. Source bits and interpretation are listed under [operational monitoring](/development/instance-health/#operational-monitoring). Unavailable is not zero or an outage; a positive broker lag/pending observation remains `unknown`/`info`, not proof of failed throughput. This route never subscribes, replays, reconnects, clears holds or changes broker offsets/assignments.
|
||||
- `GET /admin/analytics/overview` requires `view_analytics`. `total_emails_sent` and `emails_sent_today` count confirmed warmup and other outbound sends, not dispatches. Today uses the UTC calendar day. A send-count database error fails the request instead of returning zero counters. See [platform admin send counters](/guides/analytics/#platform-admin-send-counters).
|
||||
- `GET /admin/organizations` and `GET /admin/organizations/:id` require `view_organizations`. Their acquisition fields include the nullable `referrer_host` saved at signup, alongside UTM source, medium, campaign and landing path. The list accepts an exact `referrer_host` filter as well as its existing acquisition filters. An absent source is unknown, not proof of a direct visit. See [signup source attribution](/guides/analytics/#signup-source-attribution-platform-admins).
|
||||
- `GET /admin/warmup-content/overview` includes effective generation controls and requires `view_warmup_pool`; `PUT /admin/warmup-content/settings` writes effective controls with `manage_settings`. These are platform-admin routes, not organization API-key scopes. See [effective generation policy and stopping](/development/warmup-generation/).
|
||||
|
||||
@@ -48,6 +48,10 @@ The panel is dark by default. The account menu at the bottom of the sidebar (you
|
||||
|
||||
The landing page. Instance-wide counters and their trends, acquisition by channel, and a strip of open instance problems at the top, which is the same list as the Findings tab of [Setup and health](/development/instance-health/).
|
||||
|
||||
**Operational monitoring** adds recorded failure evidence, unverified recovery, safety restrictions, missing telemetry and measured backlogs. Positive Kafka lag or JetStream pending/ack-pending/redelivered counts stay visible separately, even when their backend condition is unknown. These are observations, not proof of an outage or provider delivery. Counts are per metric and can overlap; the attention count is a count of signals, not customers or failed messages.
|
||||
|
||||
The strip displays coverage gaps, stale sources and unknown/no-recent-evidence sources, rather than hiding a failed request behind an empty panel. **All operational details** opens Setup and health's Operations tab. The snapshot and each investigation link require their own admin permission; hidden sections cannot be recovered by changing tabs. See [monitoring permissions and evidence](/development/instance-health/#operational-monitoring).
|
||||
|
||||
## Operations
|
||||
|
||||
| Page | What it answers |
|
||||
@@ -123,7 +127,7 @@ Reading the tabs needs `view_organizations`; every action needs `manage_organiza
|
||||
|
||||
| Page | What it answers |
|
||||
|---|---|
|
||||
| **Setup and health** | Two tabs. **Findings** is the [instance health](/development/instance-health/) check list, and the sidebar badge counts its open findings. **Services** is the old System Status page: the per-service probes and the platform mail card with its connection check and send test |
|
||||
| **Setup and health** | Three tabs. **Findings** is the [instance health](/development/instance-health/) check list, and the sidebar badge counts its errors and warnings, not operational signals. **Services** holds scoped service reachability probes and the platform mail card. **Operations** holds read-only measurements, coverage, affected counts, evidence age, waits and permission-gated investigation links |
|
||||
| **Configuration** | Four tabs. **Settings** is the database-backed settings document; **Notifications** is the [operator notification](/development/operator-notifications/) channels; **Environment** is the read-only [configuration reference](/development/configuration/) view of what the backend resolved; **Effective limits** lists the compiled pacing constants |
|
||||
| **Transfers** | Workspace export and import for any organization, from the operator side, with the same archives as `warmblyctl org export` and `org import`. A whole-instance backup stays `warmblyctl backup` and `restore` |
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ title: Instance health
|
||||
description: The Setup and health page, what every check means, how to clear it, and how to run the same checks from a shell.
|
||||
---
|
||||
|
||||
Warmbly checks its own deployment and tells you what is wrong in the words of the thing that is wrong, with the command that fixes it. A silent instance and a healthy instance look identical from the outside, so the checks exist to make the difference visible.
|
||||
Warmbly reports configuration findings, service reachability and operational measurements separately. A silent instance and a healthy instance can look identical from the outside. The panel shows missing, partial and stale coverage explicitly instead of treating silence as proof of health.
|
||||
|
||||
## Setup and health
|
||||
|
||||
The page lives in the [admin panel](/development/admin-panel/) on `:5174`, under **Instance > Setup and health**, on its **Findings** tab; the **Services** tab next to it holds the per-service probes and the platform mail card. It is operator tooling, so it is not in the customer-facing dashboard, and every route behind it is gated on a platform admin permission.
|
||||
The page lives in the [admin panel](/development/admin-panel/) on `:5174`, under **Instance > Setup and health**. **Findings** lists configuration findings, **Services** holds per-service reachability probes and the platform mail card, and **Operations** holds read-only operational measurements. It is operator tooling, not a customer dashboard, and every route behind it is gated on a platform admin permission. Tabs support direct links such as `/health?tab=operations`.
|
||||
|
||||
Errors and warnings appear in the top panel of the admin overview, and the sidebar entry carries a badge with the number of those problems. Informational notes do not count toward the badge. If there are only informational notes, the Findings tab says **No problems found** and keeps the notes below it.
|
||||
Configuration errors and warnings appear on the admin Overview, and the sidebar badge counts those findings. Informational notes do not count toward the badge. If there are no errors or warnings, Findings says **No errors or warnings reported**, not that all checks ran or the instance is healthy. The separate Overview monitoring panel shows operational signals and positive broker queue measurements.
|
||||
|
||||
| Severity | Means |
|
||||
|---|---|
|
||||
@@ -17,11 +17,11 @@ Errors and warnings appear in the top panel of the admin overview, and the sideb
|
||||
| `warning` | Works today, will hurt you. Misconfiguration, a published secret, a concentration risk |
|
||||
| `info` | No action required, stated so you are not surprised later |
|
||||
|
||||
Only findings are listed. A check that passes returns nothing, so an empty page means the instance is clean.
|
||||
Only findings are listed. A passing check returns nothing, but a skipped or unavailable check may also have no finding. The health response currently reports `execution_coverage: "unreported"`; an empty list is not a confirmed all-clear. Older responses without this additive field are treated the same way. The receipt time shown on Findings is when the browser received the response, not a source-evidence timestamp.
|
||||
|
||||
Each row carries a title, the full message including the fix, and a link to the section of these docs that explains it. Every check that ships today is instance-wide, so no row is scoped to a single mailbox, worker or campaign.
|
||||
|
||||
Every check that runs is listed below. If a check is not on this page, it does not exist.
|
||||
The configuration checks are documented below. Operational evidence has its own coverage and limits.
|
||||
|
||||
## Running the checks from a shell
|
||||
|
||||
@@ -44,12 +44,14 @@ warmblyctl status # bare binary
|
||||
|
||||
## Health endpoints
|
||||
|
||||
There are two, and they answer different questions.
|
||||
These endpoints answer different questions.
|
||||
|
||||
| Endpoint | Auth | Use it for |
|
||||
|---|---|---|
|
||||
| `GET /health` | none | Liveness. Is the process up. This is the one a load balancer or an orchestrator probes |
|
||||
| `GET /admin/instance/health` | platform admin, `view_analytics` | Deep diagnostics: the finding list the panel renders |
|
||||
| `GET /admin/system/status` | platform admin, `view_analytics` | Scoped backing-service probes at `checked_at`, not send or sync throughput |
|
||||
| `GET /admin/instance/monitoring` | platform admin, `view_analytics` | Versioned operational snapshot with per-source permission filtering and explicit measurement coverage |
|
||||
|
||||
<Callout type="warn" title="Do not point a load balancer at the deep endpoint">
|
||||
The diagnostic endpoint reports warnings about configuration, and a warning is not a reason to take a node out of rotation. Use `GET /health` for liveness and leave the diagnostics to the panel and to `make doctor`.
|
||||
@@ -63,6 +65,42 @@ Two neighbouring endpoints round out the surface, both under **Instance** in the
|
||||
| `GET /admin/instance/limits` | `view_analytics` | The effective sending, warmup and rate limits this build compiles in |
|
||||
| `GET /admin/instance/update` | `view_analytics` | The running build, the newest release, and the updater's state. Backs the version pill at the foot of the sidebar; see [Updates](/development/updates/) |
|
||||
|
||||
## Operational monitoring
|
||||
|
||||
**Operations** renders the version `"1"` snapshot from `GET /admin/instance/monitoring`. It covers registered workers, worker sample receipt, confirmed sends, campaign reservations, dispatch, safety holds, sync state and fair-use waits, stored mailbox errors, jobs, dead letters, webhooks, customer notification rows, send-result effects, domain checks, warmup mailbox loading and broker queue observations. Unsupported sources remain visible as unavailable, including detailed send-wait attribution and external tracking-broker diagnostics. Optional arrival-outbox measurements are unavailable when their schema is absent; monitoring needs no arrival migration and does not require a new worker wire version.
|
||||
|
||||
The backend collects with an eight-second overall timeout, three seconds per source and at most four concurrent collectors. It shares a one-minute snapshot across admins, then filters source permissions for each response. HTTP responses are not cacheable. On collection failure it can retain a source's last good measurement for at most five minutes, marked stale and with its original observation/evidence clocks. After that it is unavailable. Source samples are bounded; measured/expected scope counts and incomplete coverage expose truncation. Counts in partial samples may be lower bounds, never extrapolated fleet totals.
|
||||
|
||||
### Reading a snapshot
|
||||
|
||||
- **Snapshot checked** and **Collection attempted** are collection clocks. They do not confirm delivery, sync or recovery. **Source observed** and **Observed** are the measurement clocks; **Evidence since**, **Latest evidence**, their ages and any window are distinct. The UI retains the original clocks after a failed refresh and marks retained measurements stale. A successful browser fetch of old data does not make it fresh.
|
||||
- `fresh`, `stale` and `unavailable` describe measurement availability. The UI also marks observations older than two minutes or implausibly future-dated as stale/unknown. Coverage can be complete only for the measured sources; it does not mean every customer operation succeeded.
|
||||
- A nullable count is **Not measured**, not zero. A successful zero means no matching rows in that metric's scope/window. Zero recent sends means **No matching recent evidence**, not healthy throughput. A completed task, heartbeat, low memory usage or sync tick does not prove delivery or recovery.
|
||||
- Recent/persistent failure evidence, recovery unverified, safety holds, backlog and stale telemetry remain separate conditions. An old failure without later confirmed send evidence is **Recovery unverified**, not proof that a new failure is happening now. Unknown warning observations such as missing assignment remain unknown, not fabricated provider outages.
|
||||
- Schedule waits, retry waits, budget waits, daily-limit waits and in-flight work are informational. **Next eligible** describes eligibility, not a promise of success. A safety hold is a restriction, not an outage; monitoring never clears it.
|
||||
- Positive broker lag/pending/ack-pending/redelivered counts appear prominently on Overview even though the backend deliberately labels them `unknown` with severity `info`. Kafka committed lag excludes uncommitted processing; JetStream pending and acknowledgement-pending are different measurements. Neither is provider-send throughput. Redelivered is not a lifetime retry rate; absent group members can occur during rebalancing.
|
||||
- Counts, affected mailboxes, affected organizations and rows with unknown organization are separate quantities. Metrics and per-worker scopes may overlap. The attention total counts recorded signals, not failed sends or unique customers; do not add metric counts together.
|
||||
- Query errors, permission denials, unsupported capability, missing dependencies/schema and timeouts do not produce an all-clear. No sources or no metrics means unknown coverage. Raw provider/connection errors, credentials, payloads and mailbox addresses are not rendered by the monitoring panel.
|
||||
|
||||
The Overview and Operations tab share the same frontend query cache, scoped to admin identity and permission mask. Monitoring polls at most once per minute while mounted in a visible tab, does not refetch on focus or retry automatically, and offers a refresh control that respects `refresh_after`. Service probes poll every 15 seconds while their tab is mounted; passed probes establish reachability at that time only. Failed or stale probes remain explicit and raw component connection errors are withheld. The existing platform mail card is shown only with `manage_settings`, which its inspection and send-test endpoints require.
|
||||
|
||||
This is observation-only tooling. It does not subscribe to brokers, create streams/groups, reconnect mailboxes, move assignments or offsets, reclaim tasks, replay results, clear holds, notify customers or deploy changes. Investigation links open existing admin pages; actions there retain their existing confirmations and permissions.
|
||||
|
||||
### Monitoring permissions
|
||||
|
||||
Every monitoring request requires an MFA-verified platform admin session and `view_analytics`, not a workspace API key. Source sections additionally require the bit below. Denied sources retain only an unavailable placeholder with `permission_denied`; their counts, scope measurements and private metrics are absent. The frontend also filters sections and checks destination permissions for every investigation link. Monitoring fails closed when an older identity response has no known admin mask.
|
||||
|
||||
| Source IDs | Additional platform admin bit |
|
||||
|---|---|
|
||||
| `send_safety`, `sync`, `mailbox_errors`, `arrivals`, `domain_auth` | `view_users` |
|
||||
| `sends`, `campaign_reservations`, `dispatch`, `dead_letters`, `result_effects`, `warmup_loading`, `send_wait_attribution` | `view_campaigns` |
|
||||
| `workers`, `worker_samples`, `worker_command_broker` | `view_workers` |
|
||||
| `jobs`, `backend_event_broker`, `tracking_broker` | `view_analytics` (already required) |
|
||||
| `webhooks` | `view_organizations` |
|
||||
| `notifications` | `manage_settings` |
|
||||
|
||||
For example, an admin may see webhook measurements with `view_organizations` but not a link into Sends without `view_campaigns`. Worker details, sync/mailboxes, organization details, jobs and configuration links likewise require their native page bit. Arbitrary/external investigation URLs are not accepted.
|
||||
|
||||
## Security and secrets
|
||||
|
||||
### secret_published_default
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -38,11 +37,16 @@ func TestMonitoringAdminHandlerPermissionsAndWireCompatibility(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != http.StatusOK || snapshot.Version != "1" || snapshot.Sources == nil || w.Header().Get("Cache-Control") != "no-store" {
|
||||
if w.Code != http.StatusOK || snapshot.Version != "1" || len(snapshot.Sources) != 1 || w.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
if !permissions.HasPermission(models.AdminPermViewUsers) && (strings.Contains(w.Body.String(), "42") || len(snapshot.Sources[0].Metrics) != 0 || snapshot.Sources[0].Reason != "permission_denied") {
|
||||
t.Fatal("permission leak", w.Body.String())
|
||||
source := snapshot.Sources[0]
|
||||
if !permissions.HasPermission(models.AdminPermViewUsers) {
|
||||
if len(source.Metrics) != 0 || source.Reason != "permission_denied" || source.Availability != models.MonitoringUnavailable || source.Coverage != "unavailable" || source.ObservedAt != nil || source.MeasuredScopes != nil || source.ExpectedScopes != nil {
|
||||
t.Fatal("permission leak", w.Body.String())
|
||||
}
|
||||
} else if len(source.Metrics) != 1 || source.Metrics[0].Count == nil || *source.Metrics[0].Count != n {
|
||||
t.Fatal("authorized measurement missing", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -52,7 +56,11 @@ func TestMonitoringAdminHandlerPermissionsAndWireCompatibility(t *testing.T) {
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/admin/instance/monitoring", nil)
|
||||
c.Set(middleware.AdminPermissionsKey, models.AdminPermViewAnalytics)
|
||||
h.AdminInstanceMonitoring(c)
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"coverage":"unavailable"`) {
|
||||
var snapshot models.MonitoringSnapshot
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != http.StatusOK || snapshot.Version != "1" || snapshot.Coverage != "unavailable" || len(snapshot.Sources) != 0 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user