mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-13 00:02:43 +00:00
feat: isolate admin sign-in caches, retain cached probe outcomes and clear recovered mailbox issues per measured source
This commit is contained in:
committed by
Devin AI
parent
2033fb8a36
commit
a8ddfc65ec
@@ -0,0 +1,81 @@
|
||||
import { act, type PropsWithChildren } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LoginPage from "./LoginPage";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
login: vi.fn(), confirm: vi.fn(), verify: vi.fn(), saveToken: vi.fn(),
|
||||
onToken: (_value: string): Promise<void> => Promise.resolve(),
|
||||
onCode: (_value: string): void => {},
|
||||
}));
|
||||
vi.mock("@/lib/api/client/auth", () => ({ getAuthConfig: () => Promise.resolve({ captcha: false }), login: state.login, loginConfirm: state.confirm, verifyTwoFA: state.verify }));
|
||||
vi.mock("@/lib/auth/storage", () => ({ setToken: state.saveToken }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("motion/react", () => ({ AnimatePresence: ({ children }: PropsWithChildren) => children, motion: { div: ({ children }: PropsWithChildren) => <div>{children}</div> } }));
|
||||
vi.mock("@/components/captcha/TurnstileModal", () => ({ TurnstileModal: ({ onToken }: { onToken: typeof state.onToken }) => { state.onToken = onToken; return null; } }));
|
||||
vi.mock("@/components/ui/input-otp", () => ({ InputOTP: ({ onChange }: { onChange: typeof state.onCode }) => { state.onCode = onChange; return null; }, InputOTPGroup: () => null, InputOTPSlot: () => null }));
|
||||
|
||||
const token = { access_token: "fixture-access", refresh_token: "fixture-refresh", access_token_expires_at: "2099-01-01T00:00:00Z", refresh_token_expires_at: "2099-01-01T00:00:00Z" };
|
||||
let root: Root;
|
||||
let client: QueryClient;
|
||||
let clear: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(["me"], { id: "previous-admin", admin_permissions: 0xffff });
|
||||
client.setQueryData(["system", "mail-status"], { private: "previous-session" });
|
||||
clear = vi.spyOn(client, "clear");
|
||||
root = createRoot(document.createElement("div"));
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter><LoginPage /></MemoryRouter></QueryClientProvider>));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
client.clear();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function expectClearedBeforeToken() {
|
||||
expect(client.getQueryCache().getAll()).toHaveLength(0);
|
||||
expect(state.saveToken).toHaveBeenCalledWith(token);
|
||||
expect(clear.mock.invocationCallOrder[0]).toBeLessThan(state.saveToken.mock.invocationCallOrder[0]);
|
||||
}
|
||||
|
||||
describe("admin sign-in session isolation", () => {
|
||||
it("clears cached identity and private data before a direct successful login", async () => {
|
||||
state.login.mockResolvedValue({ code_required: false, token });
|
||||
await act(async () => state.onToken("fixture-captcha"));
|
||||
expectClearedBeforeToken();
|
||||
});
|
||||
|
||||
it("keeps cache during an email-code challenge and clears it on confirmation", async () => {
|
||||
state.login.mockResolvedValue({ code_required: true, session: "fixture-session" });
|
||||
state.confirm.mockResolvedValue(token);
|
||||
await act(async () => state.onToken("fixture-captcha"));
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
await act(async () => state.onCode("123456"));
|
||||
expectClearedBeforeToken();
|
||||
});
|
||||
|
||||
it("keeps cache during a second-factor challenge and clears it on verification", async () => {
|
||||
state.login.mockResolvedValue({ code_required: false, two_fa_required: true, pending_token: "fixture-pending" });
|
||||
state.verify.mockResolvedValue(token);
|
||||
await act(async () => state.onToken("fixture-captcha"));
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
await act(async () => state.onCode("123456"));
|
||||
expectClearedBeforeToken();
|
||||
});
|
||||
|
||||
it("does not replace the session or clear caches on a failed login", async () => {
|
||||
state.login.mockRejectedValue(new Error("fixture-login-failure"));
|
||||
await act(async () => state.onToken("fixture-captcha"));
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
expect(state.saveToken).not.toHaveBeenCalled();
|
||||
expect(client.getQueryCache().getAll()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { AnimatePresence, motion, type Transition } from "motion/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Eye, EyeOff, Loader2, ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -40,6 +41,7 @@ function errorMessage(err: unknown): string {
|
||||
|
||||
export default function LoginPage() {
|
||||
const nav = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const loc = useLocation();
|
||||
const [phase, setPhase] = useState<"credentials" | "code" | "twofa">("credentials");
|
||||
const [pendingToken, setPendingToken] = useState("");
|
||||
@@ -118,6 +120,7 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
if (res.token) {
|
||||
queryClient.clear();
|
||||
setToken(res.token);
|
||||
const dest = (loc.state as { from?: string } | null)?.from ?? "/";
|
||||
nav(dest, { replace: true });
|
||||
@@ -166,6 +169,7 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
if (!res.access_token) throw new Error("The server returned an unexpected sign-in response.");
|
||||
queryClient.clear();
|
||||
setToken(res as LoginResponse);
|
||||
const dest = (loc.state as { from?: string } | null)?.from ?? "/";
|
||||
nav(dest, { replace: true });
|
||||
@@ -188,6 +192,7 @@ export default function LoginPage() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const tok = await verifyTwoFA({ pending_token: pendingToken, code: value });
|
||||
queryClient.clear();
|
||||
setToken(tok);
|
||||
const dest = (loc.state as { from?: string } | null)?.from ?? "/";
|
||||
nav(dest, { replace: true });
|
||||
|
||||
@@ -74,7 +74,15 @@ describe("configuration and service coverage", () => {
|
||||
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")).toContain("Cached: passed");
|
||||
expect(render("services")).not.toContain("Probe passed");
|
||||
});
|
||||
|
||||
it("retains a cached failure even when the earlier probe supplied no error details", () => {
|
||||
state.services.data!.data = [{ name: "postgres", ok: true, latency_ms: 5 }, { name: "redis", ok: false, latency_ms: 20 }];
|
||||
state.services.isError = true;
|
||||
expect(render("services")).toContain("Cached: passed");
|
||||
expect(render("services")).toContain("Cached: failed");
|
||||
expect(render("services")).not.toContain("Probe passed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,8 +162,8 @@ function ComponentRow({ component: c, cached }: { component: SystemComponentStat
|
||||
<span className="text-xs text-muted-foreground tabular-nums" title="Latency">
|
||||
{c.latency_ms} ms
|
||||
</span>
|
||||
<StatusBadge tone={cached ? "neutral" : c.ok ? "success" : "danger"} className="w-[5.75rem] justify-center">
|
||||
{cached ? "Cached probe" : c.ok ? "Probe passed" : "Probe failed"}
|
||||
<StatusBadge tone={cached ? "neutral" : c.ok ? "success" : "danger"} className="min-w-[5.75rem] justify-center">
|
||||
{cached ? c.ok ? "Cached: passed" : "Cached: failed" : c.ok ? "Probe passed" : "Probe failed"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ The admin panel is the operator's view of an instance: every worker, user, organ
|
||||
|
||||
The panel runs on `:5174` in the default stack (`http://localhost:5174` on a stock install, or the host you proxied to it). Sign in with the same account as the dashboard. The first owner holds every admin bit from the moment they claim the instance, and `warmblyctl user create --admin` creates a further account that already holds them. A page the account has no bit for says which bit it needs instead of rendering.
|
||||
|
||||
A completed admin sign-in discards previously cached operator data and permissions before storing the new session. An unsuccessful sign-in or a pending email-code/second-factor step does not replace the session.
|
||||
|
||||
<Callout type="warn" title="Two-factor authentication is required">
|
||||
Every admin route refuses a session that did not present a second factor. Turn on 2FA or add a passkey under **Settings > Security** in the dashboard, then sign in again; the panel says so rather than failing silently.
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ The backend collects with an eight-second overall timeout, three seconds per sou
|
||||
- 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.
|
||||
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, including whether a cached probe previously passed or failed, 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 when those pages cover the measured population; actions there retain their existing confirmations and permissions. Aggregate dispatch and send measurements have no direct link because the existing campaign-only detail tabs exclude some counted pending or warmup tasks. Keep the count and its evidence time in view while investigating through authorized backend diagnostics.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus";
|
||||
import AddressesPage from "./page";
|
||||
|
||||
const { tags, rows, statusState } = vi.hoisted(() => ({
|
||||
statusState: { data: [] as AccountStatus[], observations: [] as { status: AccountStatus; observedAt: number }[], isLoading: false, isFetching: false, isError: false, cloudLoading: false, cloudUnavailable: false },
|
||||
statusState: { data: [] as AccountStatus[], observations: [] as { status: AccountStatus; observedAt: number; current: boolean }[], isLoading: false, isFetching: false, isError: false, cloudLoading: false, cloudUnavailable: false, emailsLoading: false },
|
||||
tags: [
|
||||
{ id: "sending", title: "Sending account", color: "#0088cc", position: 0 },
|
||||
{ id: "serveblink", title: "Serveblink.com", color: "#008800", position: 1 },
|
||||
@@ -30,7 +30,7 @@ vi.mock("@/hooks/context/user", async (original) => ({
|
||||
vi.mock("@/hooks/context/confirm", () => ({ useConfirm: () => ({ show: vi.fn() }) }));
|
||||
vi.mock("@/hooks/usePermission", () => ({ usePermission: () => true }));
|
||||
vi.mock("@/lib/api/hooks/app/emails/useEmails", () => ({
|
||||
default: ({ tag }: { tag: string }) => ({ emails: tag ? rows.filter((row) => row.tags.includes(tag)) : rows }),
|
||||
default: ({ tag }: { tag: string }) => ({ emails: statusState.emailsLoading ? [] : tag ? rows.filter((row) => row.tags.includes(tag)) : rows, isLoading: statusState.emailsLoading, isPending: statusState.emailsLoading }),
|
||||
}));
|
||||
vi.mock("@/lib/api/hooks/app/analytics/useAccountStatuses", () => ({ default: () => statusState }));
|
||||
vi.mock("@/lib/api/hooks/app/subscription/useFeatureStatus", () => ({ default: () => ({ data: { can_use_warmup: true } }) }));
|
||||
@@ -142,14 +142,23 @@ describe("mailbox status while account and Cloud checks settle", () => {
|
||||
sessionStorage.clear();
|
||||
vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
statusState.data = [];
|
||||
statusState.observations = [];
|
||||
statusState.isLoading = false;
|
||||
statusState.isFetching = false;
|
||||
statusState.isError = false;
|
||||
statusState.cloudLoading = false;
|
||||
statusState.cloudUnavailable = false;
|
||||
statusState.emailsLoading = false;
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
|
||||
it("keeps summary measurements pending until the mailbox inventory loads", async () => {
|
||||
statusState.emailsLoading = true;
|
||||
await renderPage();
|
||||
expect(screen.getAllByText("Checking…").length).toBeGreaterThanOrEqual(4);
|
||||
expect(screen.queryByText("Healthy 100")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Idle or Healthy while Cloud and account status are loading, then shows the real error", async () => {
|
||||
statusState.cloudLoading = true;
|
||||
statusState.data = rows.map(measuredStatus);
|
||||
@@ -195,7 +204,7 @@ describe("mailbox status while account and Cloud checks settle", () => {
|
||||
it("retains a recently observed error as historical evidence while refreshing, then clears it on a fresh result", async () => {
|
||||
statusState.data = rows.map(measuredStatus);
|
||||
statusState.data[0] = { ...measuredStatus(rows[0]), health: { status: "error", score: 40 }, errors: [{ id: "error-1", error_code: "PROVIDER_UNAVAILABLE", severity: "critical", title: "Provider unavailable", message: "Provider refused the last attempt", created_at: new Date("2026-10-09T00:00:00Z") }] };
|
||||
statusState.observations = statusState.data.map((status) => ({ status, observedAt: Date.now() }));
|
||||
statusState.observations = statusState.data.map((status) => ({ status, observedAt: Date.now(), current: true }));
|
||||
await renderPage();
|
||||
expect(screen.getByText("Issue 40")).toBeInTheDocument();
|
||||
statusState.isFetching = true;
|
||||
@@ -212,7 +221,7 @@ describe("mailbox status while account and Cloud checks settle", () => {
|
||||
expect(screen.getAllByText("Last reported issue").length).toBeGreaterThan(0);
|
||||
|
||||
statusState.data = rows.map(measuredStatus);
|
||||
statusState.observations = statusState.data.map((status) => ({ status, observedAt: Date.now() }));
|
||||
statusState.observations = statusState.data.map((status) => ({ status, observedAt: Date.now(), current: true }));
|
||||
statusState.cloudLoading = false;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mailbox" }));
|
||||
await waitFor(() => expect(screen.queryAllByText("Last reported issue")).toHaveLength(0));
|
||||
@@ -223,7 +232,7 @@ describe("mailbox status while account and Cloud checks settle", () => {
|
||||
statusState.data = rows.map(measuredStatus);
|
||||
statusState.data[0] = { ...measuredStatus(rows[0]), health: { status: "error", score: 40 }, errors: [{ id: "error-1", error_code: "PROVIDER_UNAVAILABLE", severity: "critical", title: "Provider unavailable", message: "Provider refused the last attempt", created_at: new Date("2026-10-09T00:00:00Z") }] };
|
||||
const observedAt = Date.now();
|
||||
statusState.observations = statusState.data.map((status) => ({ status, observedAt }));
|
||||
statusState.observations = statusState.data.map((status) => ({ status, observedAt, current: true }));
|
||||
await renderPage();
|
||||
statusState.isFetching = true;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mailbox" }));
|
||||
@@ -233,4 +242,20 @@ describe("mailbox status while account and Cloud checks settle", () => {
|
||||
expect(screen.queryAllByText("Last reported issue")).toHaveLength(0);
|
||||
clock.mockRestore();
|
||||
});
|
||||
|
||||
it("does not resurrect a recovered mailbox's old issue when another mailbox is missing", async () => {
|
||||
statusState.data = rows.map(measuredStatus);
|
||||
statusState.data[0] = { ...measuredStatus(rows[0]), health: { status: "error", score: 40 }, errors: [{ id: "error-1", error_code: "PROVIDER_UNAVAILABLE", severity: "critical", title: "Provider unavailable", message: "Provider refused the last attempt", created_at: new Date("2026-10-09T00:00:00Z") }] };
|
||||
statusState.observations = statusState.data.map((status) => ({ status, observedAt: Date.now(), current: true }));
|
||||
await renderPage();
|
||||
statusState.data = [measuredStatus(rows[0])];
|
||||
statusState.observations = statusState.data.map((status) => ({ status, observedAt: Date.now(), current: true }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mailbox" }));
|
||||
await waitFor(() => expect(screen.getByText("Healthy 100")).toBeInTheDocument());
|
||||
statusState.isFetching = true;
|
||||
statusState.observations = statusState.observations.map((observation) => ({ ...observation, current: false }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mailbox" }));
|
||||
expect(screen.queryAllByText("Last reported issue")).toHaveLength(0);
|
||||
expect(screen.queryByText("Last observed: Issue 40")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,7 +170,7 @@ export default function AddressesPage() {
|
||||
// Self-hosted instances can hand warmup to the Warmbly pool; the banner,
|
||||
// row badges and menu items below key off this.
|
||||
const cloud = useCloudPool();
|
||||
const { connected: cloudConnected, rowFor: cloudRowFor } = cloud;
|
||||
const { connected: cloudConnected, rowFor: cloudRowFor, unavailable: cloudUnavailable } = cloud;
|
||||
const [cloudDialog, setCloudDialog] = React.useState(false);
|
||||
const authConfigLoading = useAuthConfig().isLoading;
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function AddressesPage() {
|
||||
for (const s of accountStatuses) m.set(s.id, s);
|
||||
return m;
|
||||
}, [accountStatuses]);
|
||||
const statusChecking = cloudChecking || statuses.isLoading || statuses.isFetching;
|
||||
const statusChecking = emailsData.isLoading || cloudChecking || statuses.isLoading || statuses.isFetching;
|
||||
const cloudStandingUnavailable = cloud.connected && (emailsData.emails ?? []).some((box) => {
|
||||
const row = cloud.rowFor(box.id);
|
||||
return row?.enrolled && !row.cloud?.health;
|
||||
@@ -213,24 +213,29 @@ export default function AddressesPage() {
|
||||
const next = new Map(previous);
|
||||
const now = Date.now();
|
||||
for (const [id, issue] of next) if (now - issue.observedAt > LAST_REPORTED_ISSUE_MS) next.delete(id);
|
||||
for (const { status, observedAt } of statuses.observations ?? []) {
|
||||
for (const { status, observedAt, current } of statuses.observations ?? []) {
|
||||
if (!observedAt || now - observedAt > LAST_REPORTED_ISSUE_MS || observedAt < (next.get(status.id)?.observedAt ?? 0)) continue;
|
||||
if (status.errors?.length || (status.health && status.health.status !== "healthy")) {
|
||||
next.set(status.id, { status, observedAt });
|
||||
} else if (!statusChecking && !statusUnavailable) {
|
||||
next.delete(status.id);
|
||||
} else if (current && !cloudChecking && !cloudUnavailable) {
|
||||
const linked = cloudConnected ? cloudRowFor(status.id) : undefined;
|
||||
if (!linked?.enrolled || linked.cloud?.health) next.delete(status.id);
|
||||
}
|
||||
}
|
||||
return next.size === previous.size && [...next].every(([id, value]) => previous.get(id)?.status === value.status && previous.get(id)?.observedAt === value.observedAt) ? previous : next;
|
||||
});
|
||||
}, [statuses.observations, statusChecking, statusUnavailable]);
|
||||
}, [statuses.observations, cloudChecking, cloudUnavailable, cloudConnected, cloudRowFor]);
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setLastReportedIssues((previous) => {
|
||||
const next = new Map([...previous].filter(([, issue]) => Date.now() - issue.observedAt <= LAST_REPORTED_ISSUE_MS));
|
||||
let expiresAt = Infinity;
|
||||
for (const issue of lastReportedIssues.values()) expiresAt = Math.min(expiresAt, issue.observedAt + LAST_REPORTED_ISSUE_MS);
|
||||
if (!Number.isFinite(expiresAt)) return;
|
||||
const timeout = setTimeout(() => setLastReportedIssues((previous) => {
|
||||
const now = Date.now();
|
||||
const next = new Map([...previous].filter(([, issue]) => now - issue.observedAt <= LAST_REPORTED_ISSUE_MS));
|
||||
return next.size === previous.size ? previous : next;
|
||||
}), 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}), Math.max(0, expiresAt - Date.now()) + 1);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [lastReportedIssues]);
|
||||
// Proactively notify the user when a mailbox's health drops.
|
||||
const prevHealth = useRef<Map<string, string>>(new Map());
|
||||
useEffect(() => {
|
||||
@@ -454,7 +459,7 @@ export default function AddressesPage() {
|
||||
</PageTopbar>
|
||||
|
||||
<StatStrip cols={4}>
|
||||
<Stat label="Total" value={<AnimatedNumber value={stats.total} />} sub="connected" />
|
||||
<Stat label="Total" value={emailsData.isLoading ? "Checking…" : <AnimatedNumber value={stats.total} />} sub="connected" />
|
||||
<Stat label="Healthy" value={statusChecking ? "Checking…" : statusUnavailable ? "Unavailable" : <AnimatedNumber value={stats.healthy} />} sub="no reported health issues" accent={!statusChecking && !statusUnavailable && stats.healthy > 0} />
|
||||
<Stat label="Warming" value={statusChecking ? "Checking…" : statusUnavailable ? "Unavailable" : <AnimatedNumber value={stats.warming} />} sub="ramping up" />
|
||||
<Stat label="Needs attention" value={statusChecking ? "Checking…" : statusUnavailable ? "Unavailable" : <AnimatedNumber value={stats.issues} />} sub="paused or failing" last />
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function useAccountStatuses(emailIds: string[], options?: { enabl
|
||||
// stable across renders until a chunk actually updates.
|
||||
combine: (results) => ({
|
||||
data: results.flatMap((r) => (r.data ?? []) as AccountStatus[]),
|
||||
observations: results.flatMap((r) => (r.data ?? []).map((status) => ({ status, observedAt: r.dataUpdatedAt }))),
|
||||
observations: results.flatMap((r) => (r.data ?? []).map((status) => ({ status, observedAt: r.dataUpdatedAt, current: r.isSuccess && !r.isFetching }))),
|
||||
isLoading: results.some((r) => r.isLoading),
|
||||
isFetching: results.some((r) => r.isFetching),
|
||||
isError: results.some((r) => r.isError),
|
||||
|
||||
Reference in New Issue
Block a user