mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-17 00:02:47 +00:00
feat: keep mailbox health and usage unknown until Cloud and account status checks finish with refresh and incomplete-data regressions
This commit is contained in:
committed by
Devin AI
parent
2cf19ebedf
commit
cdb102ca56
@@ -5,9 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
import type Tag from "@/lib/api/models/app/Tag";
|
||||
import type * as UserModule from "@/hooks/context/user";
|
||||
import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus";
|
||||
import AddressesPage from "./page";
|
||||
|
||||
const { tags, rows } = vi.hoisted(() => ({
|
||||
const { tags, rows, statusState } = vi.hoisted(() => ({
|
||||
statusState: { data: [] as AccountStatus[], isLoading: false, isFetching: false, isError: false, cloudLoading: false, cloudUnavailable: false },
|
||||
tags: [
|
||||
{ id: "sending", title: "Sending account", color: "#0088cc", position: 0 },
|
||||
{ id: "serveblink", title: "Serveblink.com", color: "#008800", position: 1 },
|
||||
@@ -30,12 +32,12 @@ 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 }),
|
||||
}));
|
||||
vi.mock("@/lib/api/hooks/app/analytics/useAccountStatuses", () => ({ default: () => ({ data: [] }) }));
|
||||
vi.mock("@/lib/api/hooks/app/analytics/useAccountStatuses", () => ({ default: () => statusState }));
|
||||
vi.mock("@/lib/api/hooks/app/subscription/useFeatureStatus", () => ({ default: () => ({ data: { can_use_warmup: true } }) }));
|
||||
vi.mock("@/lib/api/hooks/auth/useAuthConfig", () => ({ default: () => ({ data: {}, isLoading: false }) }));
|
||||
vi.mock("@/lib/api/hooks/app/emails/useMailboxGrants", () => ({ useSigninMigration: () => ({ data: { data: [] } }) }));
|
||||
vi.mock("@/lib/api/hooks/app/advisor/useAdvisor", () => ({ useAdvisorEntityIndex: () => ({ get: () => [] }) }));
|
||||
vi.mock("@/hooks/useCloudPool", () => ({ default: () => ({ selfHosted: false, connected: false }) }));
|
||||
vi.mock("@/hooks/useCloudPool", () => ({ default: () => ({ selfHosted: false, connected: false, loading: statusState.cloudLoading, unavailable: statusState.cloudUnavailable, observedAt: 0 }) }));
|
||||
vi.mock("@/lib/api/hooks/app/cloudlink/useCloudLink", () => ({
|
||||
useEnrollCloudLinkMailbox: () => ({}), useUnenrollCloudLinkMailbox: () => ({}), useCloudLinkMailboxLifecycle: () => ({}),
|
||||
}));
|
||||
@@ -70,9 +72,19 @@ function orderedEmails() {
|
||||
return screen.getAllByRole("row").slice(1).map((row) => within(row).getByText(/@/).textContent);
|
||||
}
|
||||
|
||||
function measuredStatus(row: Inbox): AccountStatus {
|
||||
return { id: row.id, email: row.email, provider: "gmail", status: "active", last_synced_at: null, health: { status: "healthy", score: 100 }, errors: [], daily_usage: { date: "2026-10-09", campaign_sent: 0, campaign_limit: 50 }, in_campaign: false };
|
||||
}
|
||||
|
||||
describe("mailbox ordering with a tag filter", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
statusState.data = [];
|
||||
statusState.isLoading = false;
|
||||
statusState.isFetching = false;
|
||||
statusState.isError = false;
|
||||
statusState.cloudLoading = false;
|
||||
statusState.cloudUnavailable = false;
|
||||
vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -123,3 +135,48 @@ describe("mailbox ordering with a tag filter", () => {
|
||||
expect(screen.getByRole("columnheader", { name: "Mailbox" })).toHaveAttribute("aria-sort", "descending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mailbox status while account and Cloud checks settle", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
statusState.data = [];
|
||||
statusState.isLoading = false;
|
||||
statusState.isFetching = false;
|
||||
statusState.isError = false;
|
||||
statusState.cloudLoading = false;
|
||||
statusState.cloudUnavailable = false;
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
|
||||
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);
|
||||
await renderPage();
|
||||
expect(screen.getAllByText("Checking…").length).toBeGreaterThan(4);
|
||||
expect(screen.queryByText("Idle")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Healthy 100")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle("0 of 50 campaign emails sent today")).not.toBeInTheDocument();
|
||||
|
||||
statusState.cloudLoading = false;
|
||||
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") }] };
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mailbox" }));
|
||||
const sara = screen.getAllByRole("row").find((row) => row.textContent?.includes(rows[0].email));
|
||||
expect(sara).toBeDefined();
|
||||
expect(within(sara!).getByText("Issue 40")).toBeInTheDocument();
|
||||
expect(within(sara!).getAllByText("Error").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("leaves partial, stale and failed status checks unknown instead of counting them healthy", async () => {
|
||||
statusState.data = [measuredStatus(rows[0])];
|
||||
statusState.isFetching = true;
|
||||
await renderPage();
|
||||
expect(screen.queryByText("Healthy 100")).not.toBeInTheDocument();
|
||||
statusState.isFetching = false;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mailbox" }));
|
||||
expect(screen.getAllByText("Unavailable").length).toBeGreaterThan(0);
|
||||
statusState.isError = true;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mailbox" }));
|
||||
expect(screen.queryByText("Healthy 100")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,7 +50,6 @@ import { useSigninMigration } from "@/lib/api/hooks/app/emails/useMailboxGrants"
|
||||
import { mailboxBrand, mailboxSource } from "@/lib/mailboxSource";
|
||||
import type Tag from "@/lib/api/models/app/Tag";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
import mailboxDisplayStatus from "@/lib/mailboxStatus";
|
||||
import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus";
|
||||
import {
|
||||
ActivityIcon,
|
||||
@@ -184,18 +183,28 @@ export default function AddressesPage() {
|
||||
() => (emailsData.emails ?? []).map((e) => e.id),
|
||||
[emailsData.emails],
|
||||
);
|
||||
const statuses = useAccountStatuses(visibleEmailIds);
|
||||
const cloudChecking = authConfigLoading || cloud.loading || cloud.refreshing;
|
||||
const statuses = useAccountStatuses(visibleEmailIds, {
|
||||
enabled: !cloudChecking,
|
||||
sourceRevision: cloud.observedAt,
|
||||
});
|
||||
// Coerce to an array defensively: a wrong-shape (non-array) response must
|
||||
// never reach a `for…of`, which would throw "{} is not iterable".
|
||||
const accountStatuses = useMemo(
|
||||
() => (Array.isArray(statuses.data) ? statuses.data : []),
|
||||
[statuses.data],
|
||||
() => (!cloudChecking && !statuses.isError && !statuses.isFetching && Array.isArray(statuses.data) ? statuses.data : []),
|
||||
[cloudChecking, statuses.data, statuses.isError, statuses.isFetching],
|
||||
);
|
||||
const statusById = useMemo(() => {
|
||||
const m = new Map<string, AccountStatus>();
|
||||
for (const s of accountStatuses) m.set(s.id, s);
|
||||
return m;
|
||||
}, [accountStatuses]);
|
||||
const statusChecking = 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;
|
||||
});
|
||||
const statusUnavailable = cloud.unavailable || cloudStandingUnavailable || statuses.isError || (!statusChecking && visibleEmailIds.some((id) => !statusById.has(id)));
|
||||
|
||||
// Proactively notify the user when a mailbox's health drops.
|
||||
const prevHealth = useRef<Map<string, string>>(new Map());
|
||||
@@ -344,13 +353,21 @@ export default function AddressesPage() {
|
||||
const s = { total: 0, healthy: 0, warming: 0, issues: 0 };
|
||||
for (const e of emailsData.emails ?? []) {
|
||||
s.total++;
|
||||
const st = mailboxDisplayStatus(e);
|
||||
if (st === "healthy") s.healthy++;
|
||||
else if (st === "warming") s.warming++;
|
||||
else s.issues++;
|
||||
const current = statusById.get(e.id);
|
||||
if (!current) continue;
|
||||
if (e.status !== "active" || e.auth_state === "failing" || current.health?.status !== "healthy" || current.errors?.length) {
|
||||
s.issues++;
|
||||
continue;
|
||||
}
|
||||
const linked = cloud.connected ? cloud.rowFor(e.id) : undefined;
|
||||
if (linked?.enrolled ? linked.cloud?.warmup?.enabled && !cloudWarmupPaused(linked.cloud) : diagnosticWarmupActive(e)) {
|
||||
s.warming++;
|
||||
} else {
|
||||
s.healthy++;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}, [emailsData.emails]);
|
||||
}, [emailsData.emails, statusById, cloud.connected, cloud.rowFor]);
|
||||
|
||||
// Mailboxes actively warming (enabled and not paused). Warmup pairs mailboxes
|
||||
// with each other, so too few starves it; the notice below warns on that.
|
||||
@@ -413,9 +430,9 @@ export default function AddressesPage() {
|
||||
|
||||
<StatStrip cols={4}>
|
||||
<Stat label="Total" value={<AnimatedNumber value={stats.total} />} sub="connected" />
|
||||
<Stat label="Healthy" value={<AnimatedNumber value={stats.healthy} />} sub="sending now" accent={stats.healthy > 0} />
|
||||
<Stat label="Warming" value={<AnimatedNumber value={stats.warming} />} sub="ramping up" />
|
||||
<Stat label="Needs attention" value={<AnimatedNumber value={stats.issues} />} sub="paused or failing" last />
|
||||
<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 />
|
||||
</StatStrip>
|
||||
|
||||
<SectionBar label="Mailboxes" count={emailsData.emails?.length ?? 0}>
|
||||
@@ -547,7 +564,9 @@ export default function AddressesPage() {
|
||||
key={box.id}
|
||||
box={box}
|
||||
tags={p?.user.tags ?? []}
|
||||
status={statusById.get(box.id)}
|
||||
status={(cloud.unavailable || cloudStandingUnavailable) && cloud.connected && statusById.get(box.id)?.health?.status === "healthy" && !statusById.get(box.id)?.errors?.length ? undefined : statusById.get(box.id)}
|
||||
statusPending={statusChecking && !statusUnavailable}
|
||||
cloudStatusPending={cloudChecking || cloud.unavailable || cloudStandingUnavailable}
|
||||
findings={advisor.get(box.id)}
|
||||
canWarmup={canWarmup}
|
||||
cloud={cloud.connected ? cloud.rowFor(box.id) : undefined}
|
||||
@@ -723,6 +742,8 @@ function MailboxRow({
|
||||
box,
|
||||
tags,
|
||||
status,
|
||||
statusPending,
|
||||
cloudStatusPending,
|
||||
findings,
|
||||
canWarmup,
|
||||
cloud,
|
||||
@@ -736,6 +757,8 @@ function MailboxRow({
|
||||
box: Inbox;
|
||||
tags: Tag[];
|
||||
status?: AccountStatus;
|
||||
statusPending: boolean;
|
||||
cloudStatusPending: boolean;
|
||||
findings: AdvisorFinding[];
|
||||
canWarmup: boolean;
|
||||
cloud?: CloudLinkMailboxRow;
|
||||
@@ -1008,7 +1031,7 @@ function MailboxRow({
|
||||
</div>
|
||||
{/* Too narrow for the metric columns: the same readings, labelled, under the address. */}
|
||||
<div className="@2xl:hidden mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0 leading-tight">
|
||||
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : warming} compact />
|
||||
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : warming} compact pending={statusPending} />
|
||||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span className="text-[10.5px] text-slate-400">Warmup</span>
|
||||
<span className={`font-mono text-[11.5px] tabular-nums ${warmupTone}`}>{warmupCell}</span>
|
||||
@@ -1032,7 +1055,7 @@ function MailboxRow({
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-3 overflow-hidden ${colShow("status")}`}>
|
||||
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : warming} />
|
||||
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : warming} pending={statusPending} />
|
||||
</td>
|
||||
<td className={`px-3 overflow-hidden ${colShow("sent")}`}>
|
||||
{status?.daily_usage ? (
|
||||
@@ -1051,7 +1074,7 @@ function MailboxRow({
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-3 overflow-hidden font-mono text-[12px] tabular-nums ${warmupTone} ${colShow("warmup")}`}>
|
||||
{warmupCell}
|
||||
{cloudStatusPending && !inCloud ? <span className="text-slate-500">{statusPending ? "Checking…" : "Unavailable"}</span> : warmupCell}
|
||||
</td>
|
||||
<td className={`px-3 ${colShow("inbox")}`}>
|
||||
<button
|
||||
@@ -1337,7 +1360,7 @@ function MailboxTh({ col, sort, onSort }: { col: MailboxColumn; sort: MailboxSor
|
||||
// What the mailbox is doing right now. Cold sending and warmup run side by
|
||||
// side, so both show when both are on; a problem that stops it wins.
|
||||
// compact spells a problem out, since it is the one reading a phone must not miss.
|
||||
function MailboxStatusPill({ box, status, warming, compact = false }: { box: Inbox; status?: AccountStatus; warming: boolean; compact?: boolean }) {
|
||||
function MailboxStatusPill({ box, status, warming, compact = false, pending = false }: { box: Inbox; status?: AccountStatus; warming: boolean; compact?: boolean; pending?: boolean }) {
|
||||
const error = status?.errors?.[0];
|
||||
const lifecycle = status?.send_lifecycle;
|
||||
const inCampaign = !!status?.in_campaign;
|
||||
@@ -1359,6 +1382,8 @@ function MailboxStatusPill({ box, status, warming, compact = false }: { box: Inb
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) return <span className="text-[10.5px] text-slate-500">{pending ? "Checking…" : "Unavailable"}</span>;
|
||||
|
||||
// One word; the icons beside it say which activities are on.
|
||||
const label = sending && warming ? "Active" : sending ? "Sending" : resting ? (lifecycle?.state === "reserve" ? "Reserve" : "Resting") : warming ? "Warming" : "Idle";
|
||||
const title = [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// warms. Members who manage mailboxes see the link and use it for their own
|
||||
// mailboxes (`manageable`); linking it is the instance administrator's.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
|
||||
import { useInstanceAdmin, usePermission } from "@/hooks/usePermission";
|
||||
import { useCloudLinkMailboxes, useCloudLinkStatus } from "@/lib/api/hooks/app/cloudlink/useCloudLink";
|
||||
@@ -27,6 +27,7 @@ export default function useCloudPool() {
|
||||
|
||||
const enrolledCount = useMemo(() => (mailboxes.data ?? []).filter((r) => r.enrolled).length, [mailboxes.data]);
|
||||
const plan: PoolLinkPlan | undefined = status.data?.info?.plan;
|
||||
const rowFor = useCallback((id: string) => byId.get(id), [byId]);
|
||||
|
||||
return {
|
||||
selfHosted,
|
||||
@@ -37,8 +38,11 @@ export default function useCloudPool() {
|
||||
orgName: status.data?.link?.organization_name ?? "",
|
||||
plan,
|
||||
enrolledCount,
|
||||
rowFor: (id: string) => byId.get(id),
|
||||
rowFor,
|
||||
isEnrolled: (id: string) => byId.get(id)?.enrolled === true,
|
||||
loading: manageable && (status.isLoading || (connected && mailboxes.isLoading)),
|
||||
loading: manageable && (status.isPending || (connected && mailboxes.isPending)),
|
||||
refreshing: manageable && (status.isFetching || (connected && mailboxes.isFetching)),
|
||||
unavailable: manageable && (status.isError || (connected && mailboxes.isError)),
|
||||
observedAt: connected ? mailboxes.dataUpdatedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ function chunk(ids: string[]): string[][] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function useAccountStatuses(emailIds: string[]) {
|
||||
export default function useAccountStatuses(emailIds: string[], options?: { enabled?: boolean; sourceRevision?: number }) {
|
||||
// Sort so a chunk's id set (and its query key) is stable regardless of the
|
||||
// order the mailbox list arrived in; adding a mailbox only refetches the
|
||||
// chunk it lands in.
|
||||
@@ -26,15 +26,16 @@ export default function useAccountStatuses(emailIds: string[]) {
|
||||
|
||||
return useQueries({
|
||||
queries: chunks.map((ids) => ({
|
||||
queryKey: ["analytics", "accounts", "list", ids],
|
||||
queryKey: ["analytics", "accounts", "list", ids, options?.sourceRevision ?? 0],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) => getAccountStatuses(ids, signal),
|
||||
enabled: ids.length > 0,
|
||||
enabled: ids.length > 0 && (options?.enabled ?? true),
|
||||
})),
|
||||
// react-query memoizes this by result reference, so the merged array is
|
||||
// stable across renders until a chunk actually updates.
|
||||
combine: (results) => ({
|
||||
data: results.flatMap((r) => (r.data ?? []) as AccountStatus[]),
|
||||
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