mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
fix(status): expose background failures
This commit is contained in:
1 parent
a689329e79
commit
8ece6f39cc
31 files changed
+854
-44
No files matched your search
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { FailingJobsCard } from "../system-status-section";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("FailingJobsCard", () => {
|
||||
it("renders named exhausted-retry failures from failingJobs", () => {
|
||||
const html = render(
|
||||
<FailingJobsCard
|
||||
failingJobs={{
|
||||
windowHours: 72,
|
||||
queues: [
|
||||
{
|
||||
queue: "environment-fetch",
|
||||
failures: 2,
|
||||
lastFailedAt: "2026-07-28T12:00:00.000Z",
|
||||
lastError: "upstream unavailable",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("environment-fetch");
|
||||
expect(html).toContain("2 failures");
|
||||
expect(html).toContain("upstream unavailable");
|
||||
expect(html).toContain('data-testid="failing-jobs-list"');
|
||||
});
|
||||
|
||||
it("distinguishes an unavailable queue ledger from an empty one", () => {
|
||||
const unavailable = render(<FailingJobsCard failingJobs={null} />);
|
||||
const empty = render(
|
||||
<FailingJobsCard failingJobs={{ windowHours: 72, queues: [] }} />,
|
||||
);
|
||||
|
||||
expect(unavailable).toContain("No job queue to read");
|
||||
expect(unavailable).not.toContain('data-testid="failing-jobs-none"');
|
||||
expect(empty).toContain('data-testid="failing-jobs-none"');
|
||||
expect(empty).toContain("No job has failed in the last 72 hours.");
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,6 @@ export interface WorkerStatus {
|
||||
lastWithingsSync: string | null;
|
||||
lastInsightsRun: string | null;
|
||||
jobsProcessed: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
/** One queue with exhausted-retry failures, as `/api/admin/status` reports it. */
|
||||
|
||||
@@ -59,7 +59,7 @@ const HostMetricsChart = dynamic(
|
||||
* means quiet queues, which is why the "cannot ask" case renders its own line
|
||||
* rather than looking identical to health.
|
||||
*/
|
||||
function FailingJobsCard({
|
||||
export function FailingJobsCard({
|
||||
failingJobs,
|
||||
}: {
|
||||
failingJobs: { windowHours: number; queues: FailingQueue[] } | null;
|
||||
|
||||
@@ -217,6 +217,33 @@ describe("IntegrationStatusPill — a failing provider states its age", () => {
|
||||
expect(html).toContain('data-state="warning"');
|
||||
});
|
||||
|
||||
it("renders the localized failure count against the server threshold", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="warning"
|
||||
lastSyncAt={new Date()}
|
||||
now={new Date()}
|
||||
failureCount={2}
|
||||
failureThreshold={3}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("2/3");
|
||||
});
|
||||
|
||||
it("omits a partial failure ratio", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="warning"
|
||||
lastSyncAt={new Date()}
|
||||
now={new Date()}
|
||||
failureCount={2}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("2/");
|
||||
});
|
||||
|
||||
it("keeps the German label free of a connection claim too", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
|
||||
@@ -69,6 +69,10 @@ interface IntegrationStatusPillProps {
|
||||
* suffix (used when the integration was just configured but has
|
||||
* never synced yet). */
|
||||
lastSyncAt: Date | string | null;
|
||||
/** Current consecutive-failure count from the active server bucket. */
|
||||
failureCount?: number;
|
||||
/** Server-owned alert threshold paired with `failureCount`. */
|
||||
failureThreshold?: number;
|
||||
/** Override "now" for deterministic testing. Defaults to `new Date()`. */
|
||||
now?: Date;
|
||||
/** Override the testid so a card can keep its own established e2e hook. */
|
||||
@@ -129,6 +133,8 @@ const RELATIVE_STATES = new Set<IntegrationPillState>([
|
||||
export function IntegrationStatusPill({
|
||||
state,
|
||||
lastSyncAt,
|
||||
failureCount,
|
||||
failureThreshold,
|
||||
now,
|
||||
testId,
|
||||
className,
|
||||
@@ -189,6 +195,15 @@ export function IntegrationStatusPill({
|
||||
t,
|
||||
)
|
||||
: null;
|
||||
const failureRatio =
|
||||
failureCount !== undefined &&
|
||||
failureCount > 0 &&
|
||||
failureThreshold !== undefined
|
||||
? t("settings.integrationPill.failureCount", {
|
||||
count: failureCount,
|
||||
threshold: failureThreshold,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
@@ -218,6 +233,7 @@ export function IntegrationStatusPill({
|
||||
{icon}
|
||||
<span className="truncate">
|
||||
{label}
|
||||
{failureRatio ? ` · ${failureRatio}` : null}
|
||||
{relative ? <span aria-hidden="true"> · {relative}</span> : null}
|
||||
</span>
|
||||
{relative ? <span className="sr-only">{relative}</span> : null}
|
||||
|
||||
@@ -13,7 +13,11 @@ import { join, sep } from "node:path";
|
||||
import { globSync } from "node:fs";
|
||||
|
||||
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
|
||||
import { pillStateForVerdict } from "../shared";
|
||||
import {
|
||||
pickStatus,
|
||||
pillFailurePropsFor,
|
||||
pillStateForVerdict,
|
||||
} from "../shared";
|
||||
|
||||
describe("pillStateForVerdict — the verdict→pill table", () => {
|
||||
const table: Array<[SyncVerdict | undefined, string]> = [
|
||||
@@ -50,6 +54,61 @@ describe("pillStateForVerdict — the verdict→pill table", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("pillFailurePropsFor", () => {
|
||||
it("projects the active bucket and envelope threshold onto the pill", () => {
|
||||
const status = pickStatus(
|
||||
{
|
||||
threshold: 3,
|
||||
integrations: [
|
||||
{
|
||||
integration: "withings",
|
||||
state: "error_transient",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: "2026-07-28T12:00:00.000Z",
|
||||
lastError: "upstream unavailable",
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 2,
|
||||
reauth_required: 0,
|
||||
persistent: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"withings",
|
||||
);
|
||||
|
||||
expect(pillFailurePropsFor(status)).toEqual({
|
||||
failureCount: 2,
|
||||
failureThreshold: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no ratio for a healthy zero-count status", () => {
|
||||
const status = pickStatus(
|
||||
{
|
||||
threshold: 3,
|
||||
integrations: [
|
||||
{
|
||||
integration: "withings",
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-07-28T12:00:00.000Z",
|
||||
lastAttemptAt: "2026-07-28T12:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"withings",
|
||||
);
|
||||
|
||||
expect(pillFailurePropsFor(status)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("structural guard — no second pill dialect", () => {
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
|
||||
@@ -185,6 +185,8 @@ function toOAuthStatus(
|
||||
lastAttemptAt: vm.lastAttemptAt,
|
||||
lastError: vm.lastError,
|
||||
legacyLastSyncedAt: vm.legacyLastSyncedAt,
|
||||
consecutiveFailuresByKind: vm.consecutiveFailuresByKind,
|
||||
failureThreshold: vm.failureThreshold,
|
||||
syncHealth: vm.syncHealth,
|
||||
metricFreshness: vm.metricFreshness,
|
||||
};
|
||||
@@ -310,11 +312,16 @@ export function ConnectionsPanel() {
|
||||
pickStatus(integrationStatus, "strava"),
|
||||
);
|
||||
const nightscoutViewModel = pickStatus(integrationStatus, "nightscout");
|
||||
const unclaimedIntegrations = (integrationStatus?.integrations ?? []).filter(
|
||||
(entry) =>
|
||||
!CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
|
||||
(entry.configured || entry.connected),
|
||||
);
|
||||
const unclaimedIntegrations = (integrationStatus?.integrations ?? [])
|
||||
.filter(
|
||||
(entry) =>
|
||||
!CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
|
||||
(entry.configured || entry.connected),
|
||||
)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
failureThreshold: integrationStatus?.threshold,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="connections-panel">
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillFailurePropsFor,
|
||||
pillStateFor,
|
||||
pillTimestampFor,
|
||||
type IntegrationStatusViewModel,
|
||||
@@ -237,6 +238,7 @@ export function FitbitCard({
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillLastSyncAt}
|
||||
{...pillFailurePropsFor(viewModel)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillFailurePropsFor,
|
||||
pillStateFor,
|
||||
pillTimestampFor,
|
||||
type IntegrationStatusViewModel,
|
||||
@@ -245,6 +246,7 @@ export function GoogleHealthCard({
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillLastSyncAt}
|
||||
{...pillFailurePropsFor(viewModel)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
pillFailurePropsFor,
|
||||
pillStateFor,
|
||||
pillTimestampFor,
|
||||
type IntegrationKey,
|
||||
@@ -89,6 +90,7 @@ export function IntegrationFallbackRow({
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillTimestampFor(status)}
|
||||
{...pillFailurePropsFor(status)}
|
||||
now={now}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
} from "./sync-outcome";
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillFailurePropsFor,
|
||||
pillStateForVerdict,
|
||||
pillTimestampFor,
|
||||
type IntegrationStatusViewModel,
|
||||
@@ -210,6 +211,7 @@ export function NightscoutCard({
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillTimestampFor(status)}
|
||||
{...pillFailurePropsFor(status)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -58,7 +58,11 @@ import {
|
||||
useSyncOutcomeMessage,
|
||||
type SyncOutcomeState,
|
||||
} from "./sync-outcome";
|
||||
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillFailurePropsFor,
|
||||
pillStateForVerdict,
|
||||
} from "./shared";
|
||||
import {
|
||||
IntegrationCardDescription,
|
||||
IntegrationRedirectGuide,
|
||||
@@ -81,6 +85,12 @@ export interface OAuthProviderStatus {
|
||||
lastAttemptAt?: string | null;
|
||||
lastError?: string | null;
|
||||
legacyLastSyncedAt?: string | null;
|
||||
consecutiveFailuresByKind?: {
|
||||
transient: number;
|
||||
reauth_required: number;
|
||||
persistent: number;
|
||||
} | null;
|
||||
failureThreshold?: number;
|
||||
/** The server-resolved liveness verdict; the pill is a projection of it. */
|
||||
syncHealth?: SyncHealth;
|
||||
metricFreshness?: MetricFreshnessEntry[];
|
||||
@@ -270,6 +280,7 @@ export function OAuthProviderCard({
|
||||
? (status?.syncHealth?.since ?? null)
|
||||
: (status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null)
|
||||
}
|
||||
{...pillFailurePropsFor(status)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface IntegrationStatusViewModel {
|
||||
reauth_required: number;
|
||||
persistent: number;
|
||||
} | null;
|
||||
/** Client-side copy of the envelope threshold, attached by `pickStatus`. */
|
||||
failureThreshold?: number;
|
||||
configured?: boolean;
|
||||
connected?: boolean;
|
||||
connectedAt?: string | null;
|
||||
@@ -110,7 +112,12 @@ export function pickStatus(
|
||||
envelope: IntegrationStatusEnvelope | undefined,
|
||||
integration: IntegrationKey,
|
||||
): IntegrationStatusViewModel | undefined {
|
||||
return envelope?.integrations.find((i) => i.integration === integration);
|
||||
const status = envelope?.integrations.find(
|
||||
(entry) => entry.integration === integration,
|
||||
);
|
||||
return status
|
||||
? { ...status, failureThreshold: envelope?.threshold }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +165,36 @@ export function pillStateFor(
|
||||
return pillStateForVerdict(status?.syncHealth?.verdict);
|
||||
}
|
||||
|
||||
export interface IntegrationPillFailureProps {
|
||||
failureCount?: number;
|
||||
failureThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair the largest per-kind streak with the server-owned alert threshold. The
|
||||
* alert ladder uses the same maximum, so the ratio cannot disagree with when a
|
||||
* notification becomes eligible.
|
||||
*/
|
||||
export function pillFailurePropsFor(
|
||||
status:
|
||||
| Pick<
|
||||
IntegrationStatusViewModel,
|
||||
"consecutiveFailuresByKind" | "failureThreshold"
|
||||
>
|
||||
| undefined,
|
||||
): IntegrationPillFailureProps {
|
||||
const buckets = status?.consecutiveFailuresByKind;
|
||||
const failureThreshold = status?.failureThreshold;
|
||||
if (!buckets || failureThreshold === undefined) return {};
|
||||
|
||||
const failureCount = Math.max(
|
||||
buckets.transient,
|
||||
buckets.reauth_required,
|
||||
buckets.persistent,
|
||||
);
|
||||
return failureCount > 0 ? { failureCount, failureThreshold } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The timestamp the pill shows inline. `connected` reads the last success (a
|
||||
* connection row's own `lastSyncedAt` wins when it is newer, which is what
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillFailurePropsFor,
|
||||
pillStateFor,
|
||||
pillTimestampFor,
|
||||
type IntegrationStatusViewModel,
|
||||
@@ -218,6 +219,7 @@ export function WhoopCard({
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillLastSyncAt}
|
||||
{...pillFailurePropsFor(viewModel)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillStateFor,
|
||||
pillFailurePropsFor,
|
||||
pillTimestampFor,
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
@@ -230,6 +231,7 @@ export function WithingsCard({
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillLastSyncAt}
|
||||
{...pillFailurePropsFor(viewModel)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user