mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk (start ~670 to ~200 ms throttled). Guided follow-ups are personalised from the user's snapshot and an answer gets the coach's streamed reaction before the next question. Archived schedule eras can be corrected through a superseding revision that keeps the original as an audit trail, era writes serialise per medication, and duplicate dose times collapse in validation. A rolling medication whose next dose is tomorrow can no longer read as heavily overdue — the card status derives strictly from the served next-due — and the adherence bars keep a fixed geometry in every state. The server-wide assistant key gains its admin card, the one duplicated coach preference collapses onto a single owner, twenty-seven admin and settings card headers move onto the shared pattern with sixty containers normalised, and the remaining hydration warnings are gone.
232 lines
7.8 KiB
TypeScript
232 lines
7.8 KiB
TypeScript
"use client";
|
|
|
|
import dynamic from "next/dynamic";
|
|
import {
|
|
Activity,
|
|
AlertTriangle,
|
|
Bell,
|
|
BellRing,
|
|
Bug,
|
|
Clock,
|
|
Cog,
|
|
Database,
|
|
Globe,
|
|
Key,
|
|
Loader2,
|
|
Map,
|
|
RotateCw,
|
|
Server,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { formatDateTime } from "@/lib/format";
|
|
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
|
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
|
|
|
// v1.4.16 phase B3: 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("./host-metrics-chart").then((mod) => ({
|
|
default: mod.HostMetricsChart,
|
|
})),
|
|
{
|
|
ssr: false,
|
|
loading: () => (
|
|
<Skeleton className="bg-muted/40 h-[200px] w-full rounded-xl" />
|
|
),
|
|
},
|
|
);
|
|
|
|
export function SystemStatusSection() {
|
|
const { t } = useTranslations();
|
|
const fmt = useFormatters();
|
|
const { data: status, isError, refetch, isFetching } = useSystemStatus();
|
|
const { data: version } = usePublicVersion();
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<HostMetricsChart />
|
|
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
|
<SettingsCardHeader
|
|
icon={Server}
|
|
title={t("admin.systemStatus")}
|
|
description={t("admin.systemStatusDescription")}
|
|
/>
|
|
{status ? (
|
|
<div className="mt-4 grid gap-4 pl-7 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")
|
|
}
|
|
className={
|
|
status.database === "connected"
|
|
? "text-dracula-green"
|
|
: "text-destructive"
|
|
}
|
|
/>
|
|
<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")
|
|
}
|
|
className={
|
|
status.worker.running
|
|
? "text-dracula-green"
|
|
: "text-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")
|
|
}
|
|
className={
|
|
status.integrations.umami.enabled
|
|
? "text-dracula-green"
|
|
: "text-destructive"
|
|
}
|
|
/>
|
|
)}
|
|
{status.integrations.glitchtip && (
|
|
<StatusItem
|
|
icon={AlertTriangle}
|
|
label="GlitchTip"
|
|
value={
|
|
status.integrations.glitchtip.enabled
|
|
? t("common.active")
|
|
: t("common.disabled")
|
|
}
|
|
className={
|
|
status.integrations.glitchtip.enabled
|
|
? "text-dracula-green"
|
|
: "text-destructive"
|
|
}
|
|
/>
|
|
)}
|
|
{status.integrations.webPush && (
|
|
<StatusItem
|
|
icon={BellRing}
|
|
label={t("admin.integrationWebPush")}
|
|
value={t("admin.configured")}
|
|
className="text-dracula-green"
|
|
/>
|
|
)}
|
|
{status.integrations.bugReport && (
|
|
<StatusItem
|
|
icon={Bug}
|
|
label={t("admin.integrationBugReport")}
|
|
value={t("admin.configured")}
|
|
className="text-dracula-green"
|
|
/>
|
|
)}
|
|
{/* v1.4.27 R5 — offline GeoLite2 availability. Renders only
|
|
when /api/version returns the new field so legacy
|
|
responses do not produce a placeholder row. */}
|
|
{version?.offlineGeoEnabled !== undefined && (
|
|
<StatusItem
|
|
icon={Map}
|
|
label={t("admin.offlineGeoLabel")}
|
|
value={
|
|
version.offlineGeoEnabled
|
|
? t("admin.offlineGeoEnabled")
|
|
: t("admin.offlineGeoFallback")
|
|
}
|
|
className={
|
|
version.offlineGeoEnabled
|
|
? "text-dracula-green"
|
|
: "text-dracula-yellow"
|
|
}
|
|
/>
|
|
)}
|
|
</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.
|
|
<div
|
|
role="alert"
|
|
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<span>{t("admin.systemStatusLoadError")}</span>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={isFetching}
|
|
onClick={() => void refetch()}
|
|
className="min-h-9"
|
|
data-testid="system-status-retry"
|
|
>
|
|
{isFetching ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<RotateCw className="h-3.5 w-3.5" />
|
|
)}
|
|
{t("admin.systemStatusRetry")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="mt-4 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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|