Files
HealthLog/src/components/admin/system-status-section.tsx
T
Marc-André Bombeck 07f9615dba feat(ops): name 1536 MB as the supported memory floor
The getting-started requirements now say a host needs 3 GB of RAM, 4 GB
recommended, and drop the advice to lower APP_MEMORY_LIMIT to 1g on a
2 GB host. The compose and .env example comments say the same.

At boot the app reads its cgroup memory limit (v2, then v1) and logs one
line when it is below MEMORY_FLOOR_MB. The support report adds the
container limit and a note below the floor, and Admin > System status
shows "below the supported floor" next to the existing low-heap flag.
The app keeps running either way.
2026-10-21 19:36:06 +02:00

314 lines
10 KiB
TypeScript

"use client";
import dynamic from "next/dynamic";
import {
Activity,
AlertTriangle,
Bell,
BellRing,
Clock,
Cog,
Database,
Globe,
Key,
Loader2,
Map,
MemoryStick,
Server,
Users,
} from "lucide-react";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTime } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
geoLookupState,
StatusItem,
useSystemStatus,
type FailingQueue,
} from "./_shared";
import { EmptyState } from "@/components/ui/empty-state";
// v1.4.16: Recharts is ~108 KiB Brotli — defer-load the host
// metrics chart so the system-status page only ships it when an admin
// actually opens this view. Splitting at the wrapper boundary (instead
// of per-primitive) keeps Recharts' `findAllByType` reconciliation
// working — same pattern the insights page uses for the scatter chart.
const HostMetricsChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.HostMetricsChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="bg-muted/40 h-[200px] w-full rounded-xl" />
),
},
);
/**
* The queues whose jobs gave up, named.
*
* Before this, a background job that threw on every single run showed up as one
* digit in an unnamed `errors` counter, and the module it fed rendered an empty
* screen as if empty were the answer. A broken queue has to say its own name,
* so this card is rendered whenever there is anything to report and stays
* silent otherwise — an operator should be able to trust that a quiet card
* means quiet queues, which is why the "cannot ask" case renders its own line
* rather than looking identical to health.
*/
export function FailingJobsCard({
failingJobs,
}: {
failingJobs: { windowHours: number; queues: FailingQueue[] } | null;
}) {
const { t, tCount } = useTranslations();
if (failingJobs === null) {
return (
<SettingsCard>
<SettingsCardHeader
icon={AlertTriangle}
title={t("admin.failingJobs.title")}
description={t("admin.failingJobs.description")}
/>
<p className="text-muted-foreground text-sm">
{t("admin.failingJobs.unavailable")}
</p>
</SettingsCard>
);
}
return (
<SettingsCard>
<SettingsCardHeader
icon={AlertTriangle}
title={t("admin.failingJobs.title")}
description={t("admin.failingJobs.description")}
/>
{failingJobs.queues.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("admin.failingJobs.none", {
hours: failingJobs.windowHours,
})}
data-testid="failing-jobs-none"
/>
) : (
<ul data-testid="failing-jobs-list" className="space-y-3">
{failingJobs.queues.map((queue) => (
<li key={queue.queue} className="text-sm">
<p className="text-foreground font-medium">
<span className="font-mono">{queue.queue}</span>{" "}
<span className="text-destructive font-normal">
{tCount("admin.failingJobs.count", queue.failures, {
count: queue.failures,
})}
</span>
</p>
<p className="text-muted-foreground text-xs">
{t("admin.failingJobs.lastFailed", {
when: formatDateTime(queue.lastFailedAt),
})}
</p>
{queue.lastError.length > 0 && (
<p className="text-muted-foreground mt-0.5 font-mono text-xs break-all">
{queue.lastError}
</p>
)}
</li>
))}
</ul>
)}
</SettingsCard>
);
}
export function SystemStatusSection() {
const { t } = useTranslations();
const fmt = useFormatters();
const { data: status, isError, error, refetch } = useSystemStatus();
return (
<div className="space-y-6">
<HostMetricsChart />
<SettingsCard>
<SettingsCardHeader
icon={Server}
title={t("admin.systemStatus")}
description={t("admin.systemStatusDescription")}
/>
{status ? (
<div
// `system-status-grid` names the answered snapshot. The two
// branches below it are a load error and a spinner, so the
// marker cannot stand for a section still waiting on the read.
data-slot="system-status-grid"
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
<StatusItem
icon={Database}
label={t("admin.database")}
value={
status.database === "connected"
? t("admin.databaseConnected")
: t("admin.databaseError")
}
tone={status.database === "connected" ? "success" : "destructive"}
/>
<StatusItem
icon={MemoryStick}
label={t("admin.heapLimitLabel")}
value={
status.memory.belowFloor && status.memory.containerLimitMb
? t("admin.memoryBelowFloor", {
mb: status.memory.containerLimitMb,
floor: status.memory.floorMb,
})
: status.memory.low
? t("admin.heapLimitLow", { mb: status.memory.heapLimitMb })
: t("admin.heapLimitValue", {
mb: status.memory.heapLimitMb,
})
}
tone={
status.memory.belowFloor || status.memory.low
? "warning"
: undefined
}
/>
<StatusItem
icon={Clock}
label={t("admin.startedAt")}
value={formatDateTime(status.startTime)}
/>
<StatusItem
icon={Users}
label={t("admin.users")}
value={String(status.counts.users)}
/>
<StatusItem
icon={Activity}
label={t("admin.measurementsCount")}
value={fmt.integer(status.counts.measurements)}
/>
<StatusItem
icon={Key}
label={t("admin.activeTokens")}
value={String(status.counts.activeTokens)}
/>
<StatusItem
icon={Globe}
label={t("admin.activeSessions")}
value={String(status.counts.activeSessions)}
/>
<StatusItem
icon={Cog}
label={t("admin.workerStatus")}
value={
status.worker.running
? t("admin.workerRunning")
: t("admin.workerStopped")
}
tone={status.worker.running ? "success" : "destructive"}
/>
{status.worker.lastReminderCheck && (
<StatusItem
icon={Bell}
label={t("admin.lastReminderCheck")}
value={formatDateTime(status.worker.lastReminderCheck)}
/>
)}
{status.integrations.umami && (
<StatusItem
icon={Activity}
label="Umami"
value={
status.integrations.umami.enabled
? t("common.active")
: t("common.disabled")
}
tone={
status.integrations.umami.enabled ? "success" : "destructive"
}
/>
)}
{status.integrations.glitchtip && (
<StatusItem
icon={AlertTriangle}
label="GlitchTip"
value={
status.integrations.glitchtip.enabled
? t("common.active")
: t("common.disabled")
}
tone={
status.integrations.glitchtip.enabled
? "success"
: "destructive"
}
/>
)}
{status.integrations.webPush && (
<StatusItem
icon={BellRing}
label={t("admin.integrationWebPush")}
value={t("admin.configured")}
tone="success"
/>
)}
{/* v1.4.27 — offline GeoLite2 availability, read from the
admin status rather than the public /api/version. */}
<StatusItem
icon={Map}
label={t("admin.offlineGeoLabel")}
value={
{
offline: t("admin.offlineGeoEnabled"),
off: t("admin.offlineGeoOff"),
chosen: t("admin.offlineGeoChosen", {
host: status.geo.providerHost,
}),
default: t("admin.offlineGeoFallback", {
host: status.geo.providerHost,
}),
}[geoLookupState(status.geo)]
}
tone={
geoLookupState(status.geo) === "default" ? "warning" : "success"
}
/>
</div>
) : isError ? (
// P19: surface load failures inline instead of leaving the
// section spinning forever. The hook throws on non-OK
// responses, so isError is true on 500s, network errors, etc.
// v1.4.16 Wave-C MED — pair the alert with a Retry button so a
// transient 500 (rolling deploy, DB blip) doesn't require a
// full page reload to recover.
<QueryErrorRow
inset
error={error}
message={t("admin.systemStatusLoadError")}
onRetry={() => void refetch()}
retryLabel={t("admin.systemStatusRetry")}
retrySlot="system-status-retry"
/>
) : (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.loadingStatus")}
</span>
</div>
)}
</SettingsCard>
{status && <FailingJobsCard failingJobs={status.failingJobs} />}
</div>
);
}