mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge the AA-contrast status rows on the admin page
This commit is contained in:
commit
17671d9dc1
5 files changed
+168
-39
No files matched your search
@@ -78,7 +78,10 @@ const PINNED_AFFORDANCES: Record<
|
||||
string,
|
||||
Partial<Record<SuccessMarker, number>>
|
||||
> = {
|
||||
"src/components/admin/_shared.tsx": { "text-success": 2, "toast.success": 1 },
|
||||
// The third `text-success` is the success arm of StatusItem's tone table:
|
||||
// the two status grids used to spell the tint at every call site and now
|
||||
// name a tone instead, so the marker lives here once.
|
||||
"src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 },
|
||||
"src/components/admin/ai-server-key-section.tsx": { "toast.success": 1 },
|
||||
"src/components/admin/api-token-overview-section.tsx": { "text-success": 1 },
|
||||
"src/components/admin/app-log-preview-section.tsx": {
|
||||
@@ -115,8 +118,6 @@ const PINNED_AFFORDANCES: Record<
|
||||
"text-success": 4,
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/admin/system-status-section.tsx": { "text-success": 6 },
|
||||
"src/components/admin/system-status-summary.tsx": { "text-success": 3 },
|
||||
"src/components/admin/umami-section.tsx": { "toast.success": 1 },
|
||||
"src/components/admin/user-management-section.tsx": {
|
||||
"text-success": 2,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* The `/admin` snapshot renders its worker row red whenever the process
|
||||
* serving the page is not the worker — a split deployment where the web
|
||||
* container runs with `HEALTHLOG_PROCESS_TYPE=web`, or a worker that died.
|
||||
* `text-destructive` on the tile's `bg-muted/50` wash measured 3.97:1, under
|
||||
* the 4.5:1 floor, and `e2e/a11y.spec.ts` went red in exactly that state.
|
||||
*
|
||||
* jsdom cannot compute a contrast ratio, so this pins the token choice
|
||||
* instead: the destructive token paints the indicator, the wording stays in
|
||||
* `text-foreground`, and the running state keeps its `text-success` tint.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin",
|
||||
}));
|
||||
|
||||
const workerRunning = { current: false };
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
|
||||
if (queryKey[0] === "admin" && queryKey[1] === "status") {
|
||||
return {
|
||||
data: {
|
||||
version: "1.38.14",
|
||||
nodeVersion: "v22.0.0",
|
||||
gitCommit: "abc1234",
|
||||
buildTime: "2026-09-10T00:00:00.000Z",
|
||||
startTime: "2026-09-10T00:00:00.000Z",
|
||||
database: "connected",
|
||||
worker: {
|
||||
running: workerRunning.current,
|
||||
startedAt: null,
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
},
|
||||
failingJobs: null,
|
||||
counts: {
|
||||
users: 1,
|
||||
measurements: 0,
|
||||
medications: 0,
|
||||
intakeEvents: 0,
|
||||
activeTokens: 0,
|
||||
activeSessions: 1,
|
||||
},
|
||||
integrations: { umami: null, glitchtip: null, webPush: null },
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SystemStatusSummary } from "../system-status-summary";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SystemStatusSummary />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin snapshot worker row", () => {
|
||||
it("states an absent worker in the foreground token with a destructive indicator", () => {
|
||||
workerRunning.current = false;
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain("Stopped");
|
||||
// The state reads in the foreground token, which is AA on the tile wash.
|
||||
expect(html).toContain("text-foreground");
|
||||
// The destructive token survives as the indicator, not as the wording.
|
||||
expect(html).toContain('data-slot="status-item-indicator"');
|
||||
expect(html).toContain("bg-destructive");
|
||||
expect(html).not.toContain("text-destructive");
|
||||
});
|
||||
|
||||
it("keeps the success tint when the worker is running", () => {
|
||||
workerRunning.current = true;
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain("Running");
|
||||
expect(html).toContain("text-success");
|
||||
expect(html).not.toContain('data-slot="status-item-indicator"');
|
||||
});
|
||||
});
|
||||
@@ -150,29 +150,67 @@ export function ConfiguredBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The meaning a status tile's value carries. */
|
||||
export type StatusTone = "success" | "warning" | "destructive";
|
||||
|
||||
/**
|
||||
* Tone → classes for a status tile's value line.
|
||||
*
|
||||
* `--success` and `--warning` clear AA as text on the tile's `bg-muted/50`
|
||||
* surface, so they tint the wording directly. `--destructive` does not: it is
|
||||
* AA on the card itself (4.85:1 in the light theme) but the tile paints a
|
||||
* muted wash over that card, and the tinted value measures 3.97:1 there —
|
||||
* under the 4.5:1 floor. That is what axe reports on `/admin` whenever the
|
||||
* process serving the page is not the worker and the worker row goes red.
|
||||
*
|
||||
* So the destructive tone takes the shape the other status rows already use
|
||||
* (the medication detail status row, the invite-token chips): the token
|
||||
* paints a small indicator and the wording stays in `text-foreground`. The
|
||||
* word itself ("Stopped", "Error") states the condition, so nothing hangs on
|
||||
* the colour alone.
|
||||
*/
|
||||
const STATUS_TONE_CLASS: Record<StatusTone, string> = {
|
||||
success: "text-success",
|
||||
warning: "text-warning",
|
||||
destructive: "text-foreground",
|
||||
};
|
||||
|
||||
/**
|
||||
* The console's label/value tile. Four hand-rolled variants of this box used
|
||||
* to exist side by side; `StatTile` is the one, and this is the thin adapter
|
||||
* the status grids call (the `className` they pass tints the VALUE, which is
|
||||
* why it cannot just be `StatTile` under a different name).
|
||||
* the status grids call (the `tone` they pass decides how the VALUE reads,
|
||||
* which is why it cannot just be `StatTile` under a different name).
|
||||
*/
|
||||
export function StatusItem({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
tone,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
tone?: StatusTone;
|
||||
}) {
|
||||
return (
|
||||
<StatTile
|
||||
icon={icon}
|
||||
label={label}
|
||||
value={value}
|
||||
valueClassName={className}
|
||||
value={
|
||||
tone === "destructive" ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-slot="status-item-indicator"
|
||||
className="bg-destructive size-2 shrink-0 rounded-full"
|
||||
/>
|
||||
{value}
|
||||
</span>
|
||||
) : (
|
||||
value
|
||||
)
|
||||
}
|
||||
valueClassName={tone ? STATUS_TONE_CLASS[tone] : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,11 +155,7 @@ export function SystemStatusSection() {
|
||||
? t("admin.databaseConnected")
|
||||
: t("admin.databaseError")
|
||||
}
|
||||
className={
|
||||
status.database === "connected"
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
}
|
||||
tone={status.database === "connected" ? "success" : "destructive"}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Clock}
|
||||
@@ -194,9 +190,7 @@ export function SystemStatusSection() {
|
||||
? t("admin.workerRunning")
|
||||
: t("admin.workerStopped")
|
||||
}
|
||||
className={
|
||||
status.worker.running ? "text-success" : "text-destructive"
|
||||
}
|
||||
tone={status.worker.running ? "success" : "destructive"}
|
||||
/>
|
||||
{status.worker.lastReminderCheck && (
|
||||
<StatusItem
|
||||
@@ -214,10 +208,8 @@ export function SystemStatusSection() {
|
||||
? t("common.active")
|
||||
: t("common.disabled")
|
||||
}
|
||||
className={
|
||||
status.integrations.umami.enabled
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
tone={
|
||||
status.integrations.umami.enabled ? "success" : "destructive"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -230,10 +222,10 @@ export function SystemStatusSection() {
|
||||
? t("common.active")
|
||||
: t("common.disabled")
|
||||
}
|
||||
className={
|
||||
tone={
|
||||
status.integrations.glitchtip.enabled
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -242,7 +234,7 @@ export function SystemStatusSection() {
|
||||
icon={BellRing}
|
||||
label={t("admin.integrationWebPush")}
|
||||
value={t("admin.configured")}
|
||||
className="text-success"
|
||||
tone="success"
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.27 R5 — offline GeoLite2 availability. Renders only
|
||||
@@ -259,9 +251,7 @@ export function SystemStatusSection() {
|
||||
host: version.geoProviderHost ?? "ipwho.is",
|
||||
})
|
||||
}
|
||||
className={
|
||||
version.offlineGeoEnabled ? "text-success" : "text-warning"
|
||||
}
|
||||
tone={version.offlineGeoEnabled ? "success" : "warning"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,11 +46,7 @@ export function SystemStatusSummary() {
|
||||
? t("admin.databaseConnected")
|
||||
: t("admin.databaseError")
|
||||
}
|
||||
className={
|
||||
status.database === "connected"
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
}
|
||||
tone={status.database === "connected" ? "success" : "destructive"}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Cog}
|
||||
@@ -60,9 +56,7 @@ export function SystemStatusSummary() {
|
||||
? t("admin.workerRunning")
|
||||
: t("admin.workerStopped")
|
||||
}
|
||||
className={
|
||||
status.worker.running ? "text-success" : "text-destructive"
|
||||
}
|
||||
tone={status.worker.running ? "success" : "destructive"}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Clock}
|
||||
@@ -84,9 +78,7 @@ export function SystemStatusSummary() {
|
||||
host: version.geoProviderHost ?? "ipwho.is",
|
||||
})
|
||||
}
|
||||
className={
|
||||
version.offlineGeoEnabled ? "text-success" : "text-warning"
|
||||
}
|
||||
tone={version.offlineGeoEnabled ? "success" : "warning"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user