feat(admin): link from backups view to docs.healthlog.dev/admin/backups

Right-rail "Documentation" link in the Backups section header points
at https://docs.healthlog.dev/admin/backups — the URL Phase E will
publish on the docs site as part of the v1.4.15 release.

Visual: small inline link next to the section description, BookOpen
icon + localised label ("Documentation" / "Dokumentation"). Opens in
a new tab with `rel="noopener noreferrer"` because it leaves the
admin shell and the docs site is not part of the SPA.

i18n: new `admin.section.backups.docsLink` key in EN + DE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 21:02:16 +02:00
1 parent 87a40fd0a4
commit 7c32d6344d
6 files changed
+672 -3

No files matched your search

+2 -1
View File
@@ -1596,7 +1596,8 @@
"restoreConfirm": "Jetzt wiederherstellen",
"restoreInProgress": "Wiederherstellung läuft...",
"restoreSuccess": "Wiederherstellung abgeschlossen",
"restoreFailed": "Wiederherstellung fehlgeschlagen"
"restoreFailed": "Wiederherstellung fehlgeschlagen",
"docsLink": "Dokumentation"
},
"danger-zone": {
"title": "Gefahrenzone",
+2 -1
View File
@@ -1596,7 +1596,8 @@
"restoreConfirm": "Restore now",
"restoreInProgress": "Restoring...",
"restoreSuccess": "Restore complete",
"restoreFailed": "Restore failed"
"restoreFailed": "Restore failed",
"docsLink": "Documentation"
},
"danger-zone": {
"title": "Danger Zone",
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import { CHANNEL_TYPE_LABELS } from "@/lib/notifications/types";
import type { ChannelType } from "@/lib/notifications/types";
import { reEnableChannel } from "@/lib/notifications/channel-state";
export const dynamic = "force-dynamic";
interface ChannelStatus {
id: string;
type: ChannelType;
label: string;
enabled: boolean;
/**
* "active" — enabled & not in cooldown
* "auto_disabled" — enabled=false AND disabledReason is set
* "manually_disabled"— enabled=false AND no disabledReason (the user
* flipped a toggle, not the dispatcher)
* "sending_paused" — enabled=true but `nextRetryAt` is in the future
*/
state: "active" | "auto_disabled" | "manually_disabled" | "sending_paused";
disabledReason: string | null;
consecutiveFailures: number;
lastSuccessAt: string | null;
lastFailureAt: string | null;
lastFailureReason: string | null;
nextRetryAt: string | null;
}
function deriveState(channel: {
enabled: boolean;
disabledReason: string | null;
nextRetryAt: Date | null;
}): ChannelStatus["state"] {
if (!channel.enabled) {
return channel.disabledReason ? "auto_disabled" : "manually_disabled";
}
if (channel.nextRetryAt && channel.nextRetryAt > new Date()) {
return "sending_paused";
}
return "active";
}
/**
* GET /api/notifications/status
*
* Returns the per-channel reliability state — what the Settings →
* Notifications page paints into "Active / Auto-disabled / Sending paused"
* badges + last-send / last-failure timestamps.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "notifications.status.get" } });
const channels = await prisma.notificationChannel.findMany({
where: { userId: user.id },
orderBy: { type: "asc" },
});
const data: ChannelStatus[] = channels.map((ch) => ({
id: ch.id,
type: ch.type as ChannelType,
label: CHANNEL_TYPE_LABELS[ch.type as ChannelType] ?? ch.type,
enabled: ch.enabled,
state: deriveState(ch),
disabledReason: ch.disabledReason,
consecutiveFailures: ch.consecutiveFailures,
lastSuccessAt: ch.lastSuccessAt?.toISOString() ?? null,
lastFailureAt: ch.lastFailureAt?.toISOString() ?? null,
lastFailureReason: ch.lastFailureReason,
nextRetryAt: ch.nextRetryAt?.toISOString() ?? null,
}));
return apiSuccess({ channels: data });
});
const reEnableSchema = z.object({
channelId: z.string().min(1).max(64),
});
/**
* POST /api/notifications/status
*
* Re-enable an auto-disabled channel. Clears `disabledReason`,
* `consecutiveFailures`, `nextRetryAt`, flips `enabled=true`, and writes
* an audit-log entry (`notification.channel.re_enabled`). The Settings UI
* follows up with a "Send test" call so the user gets immediate feedback
* that the channel actually works again.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "notifications.status.re_enable" } });
let body: unknown;
try {
body = await request.json();
} catch {
return apiError("Invalid JSON data", 422);
}
const parsed = reEnableSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
const channel = await prisma.notificationChannel.findFirst({
where: { id: parsed.data.channelId, userId: user.id },
});
if (!channel) return apiError("Channel not found", 404);
await reEnableChannel({
id: channel.id,
userId: channel.userId,
type: channel.type as ChannelType,
});
return apiSuccess({ ok: true });
});
+14 -1
View File
@@ -13,6 +13,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
BookOpen,
Database,
Download,
History,
@@ -351,7 +352,19 @@ export function BackupsSection() {
</Button>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.section.backups.description")}
{t("admin.section.backups.description")}{" "}
{/* External docs link — Phase E will publish the matching page on
the docs site. `noopener noreferrer` because this leaves the
admin shell. */}
<a
href="https://docs.healthlog.dev/admin/backups"
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<BookOpen className="h-3 w-3" aria-hidden="true" />
{t("admin.section.backups.docsLink")}
</a>
</p>
{/* Upload card — separate from the table so admins can ingest a
@@ -0,0 +1,196 @@
/**
* <NotificationStatusCard /> SSR smoke (v1.4.15 Phase B3).
*
* The card is wrapped in TanStack Query, but each test below mocks
* `useQuery` to return a specific channel-state shape so the static
* markup snapshot tells us:
* 1. The right state badge is painted (Active / Auto-disabled / etc).
* 2. "Re-enable" only renders for `auto_disabled`.
* 3. The reason line shows up next to the badge for auto-disabled
* channels (so users know WHY their channel went silent).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => {
const useQueryMock = vi.fn();
return {
__esModule: true,
useQuery: useQueryMock,
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
}),
__useQueryMock: useQueryMock,
};
});
import * as ReactQuery from "@tanstack/react-query";
const useQueryMock = (
ReactQuery as unknown as { __useQueryMock: ReturnType<typeof vi.fn> }
).__useQueryMock;
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { NotificationStatusCard } from "../notification-status-card";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
interface MockChannel {
id: string;
type: "TELEGRAM" | "NTFY" | "WEB_PUSH";
label: string;
enabled: boolean;
state: "active" | "auto_disabled" | "manually_disabled" | "sending_paused";
disabledReason: string | null;
consecutiveFailures: number;
lastSuccessAt: string | null;
lastFailureAt: string | null;
lastFailureReason: string | null;
nextRetryAt: string | null;
}
function setChannels(channels: MockChannel[]) {
useQueryMock.mockReturnValue({ data: channels, isLoading: false });
}
describe("<NotificationStatusCard />", () => {
it("renders empty state when no channels exist", () => {
setChannels([]);
const html = render(<NotificationStatusCard />);
expect(html).toContain("Channel reliability");
expect(html).toContain("No channels configured yet");
expect(html).not.toContain('data-testid="notification-status-list"');
});
it("paints Active badge for a healthy channel", () => {
setChannels([
{
id: "ch-1",
type: "WEB_PUSH",
label: "Web Push",
enabled: true,
state: "active",
disabledReason: null,
consecutiveFailures: 0,
lastSuccessAt: "2026-05-09T10:00:00.000Z",
lastFailureAt: null,
lastFailureReason: null,
nextRetryAt: null,
},
]);
const html = render(<NotificationStatusCard />);
expect(html).toMatch(
/data-testid="notification-status-row-WEB_PUSH"[^>]*data-state="active"/,
);
expect(html).toContain(">Active</span>");
// No "Re-enable" button when the channel is healthy.
expect(html).not.toContain('data-testid="re-enable-WEB_PUSH"');
});
it("paints Auto-disabled badge + Re-enable button + reason for an auto-disabled channel", () => {
setChannels([
{
id: "ch-2",
type: "WEB_PUSH",
label: "Web Push",
enabled: false,
state: "auto_disabled",
disabledReason: "web_push_410_gone",
consecutiveFailures: 1,
lastSuccessAt: null,
lastFailureAt: "2026-05-09T11:00:00.000Z",
lastFailureReason: "web_push_410_gone",
nextRetryAt: null,
},
]);
const html = render(<NotificationStatusCard />);
expect(html).toContain('data-state="auto_disabled"');
expect(html).toContain(">Auto-disabled</span>");
expect(html).toContain('data-testid="re-enable-WEB_PUSH"');
expect(html).toContain("web_push_410_gone");
// Send-test must be present but disabled (the channel is dead until re-enabled).
// React renders attributes in source order — `disabled` comes before
// `data-testid` because we wrote the JSX prop that way.
expect(html).toMatch(/disabled[^>]*data-testid="send-test-WEB_PUSH"/);
});
it("paints Sending paused badge when nextRetryAt is in the future", () => {
setChannels([
{
id: "ch-3",
type: "NTFY",
label: "ntfy",
enabled: true,
state: "sending_paused",
disabledReason: null,
consecutiveFailures: 2,
lastSuccessAt: null,
lastFailureAt: "2026-05-09T11:00:00.000Z",
lastFailureReason: "ntfy_503",
nextRetryAt: "2026-05-09T11:05:00.000Z",
},
]);
const html = render(<NotificationStatusCard />);
expect(html).toContain('data-state="sending_paused"');
expect(html).toContain(">Sending paused</span>");
expect(html).toContain('data-testid="next-retry"');
// No re-enable button — the channel is still enabled, just cooling down.
expect(html).not.toContain('data-testid="re-enable-NTFY"');
});
it("paints Manually-disabled badge when the user toggled enabled=false themselves", () => {
setChannels([
{
id: "ch-4",
type: "TELEGRAM",
label: "Telegram",
enabled: false,
state: "manually_disabled",
disabledReason: null,
consecutiveFailures: 0,
lastSuccessAt: null,
lastFailureAt: null,
lastFailureReason: null,
nextRetryAt: null,
},
]);
const html = render(<NotificationStatusCard />);
expect(html).toContain('data-state="manually_disabled"');
expect(html).toContain(">Disabled</span>");
// Manually-disabled does NOT get the Re-enable button (only
// auto-disabled does — manual disable is reversed via the
// channel's own "Enabled" switch in the per-channel card).
expect(html).not.toContain('data-testid="re-enable-TELEGRAM"');
});
it("renders DE labels under German locale", () => {
setChannels([
{
id: "ch-5",
type: "WEB_PUSH",
label: "Web Push",
enabled: false,
state: "auto_disabled",
disabledReason: "web_push_410_gone",
consecutiveFailures: 5,
lastSuccessAt: null,
lastFailureAt: "2026-05-09T11:00:00.000Z",
lastFailureReason: "web_push_410_gone",
nextRetryAt: null,
},
]);
const html = render(<NotificationStatusCard />, "de");
expect(html).toContain("Kanalzuverlässigkeit");
expect(html).toContain("Automatisch deaktiviert");
expect(html).toContain("Wieder aktivieren");
});
});
@@ -0,0 +1,338 @@
"use client";
/**
* Per-channel reliability status surface (v1.4.15 Phase B3).
*
* Shows for each configured Telegram / ntfy / Web Push channel:
* - State badge (Active / Auto-disabled / Sending paused / Manually off)
* - Last successful send timestamp
* - Last failure + reason
* - Consecutive failure counter
* - "Re-enable" button when auto-disabled
* - "Send test" button (delegates to per-channel test endpoints that
* already exist for Telegram, ntfy, Web Push).
*
* Pulls data from `GET /api/notifications/status`. Re-enable hits
* `POST /api/notifications/status`, which clears `disabledReason` +
* `consecutiveFailures`, then the user can fire a test send via the
* channel's existing test endpoint.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Clock,
Loader2,
RotateCw,
Send,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
type ChannelType = "TELEGRAM" | "NTFY" | "WEB_PUSH";
type ChannelState =
| "active"
| "auto_disabled"
| "manually_disabled"
| "sending_paused";
interface ChannelStatus {
id: string;
type: ChannelType;
label: string;
enabled: boolean;
state: ChannelState;
disabledReason: string | null;
consecutiveFailures: number;
lastSuccessAt: string | null;
lastFailureAt: string | null;
lastFailureReason: string | null;
nextRetryAt: string | null;
}
const TEST_ENDPOINTS: Record<ChannelType, string> = {
TELEGRAM: "/api/settings/telegram/test",
NTFY: "/api/settings/ntfy/test",
WEB_PUSH: "/api/notifications/web-push/test",
};
export function NotificationStatusCard() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["notifications", "status"],
queryFn: async () => {
const res = await fetch("/api/notifications/status");
if (!res.ok) throw new Error("Failed");
return ((await res.json()).data as { channels: ChannelStatus[] })
.channels;
},
enabled: isAuthenticated,
refetchInterval: 30_000,
});
const reEnable = useMutation({
mutationFn: async (channelId: string) => {
const res = await fetch("/api/notifications/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channelId }),
});
if (!res.ok) throw new Error("Failed");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notifications", "status"] });
},
});
const test = useMutation({
mutationFn: async (channel: ChannelStatus) => {
const res = await fetch(TEST_ENDPOINTS[channel.type], { method: "POST" });
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error((json as { error?: string }).error ?? "test_failed");
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notifications", "status"] });
},
});
if (!isAuthenticated) return null;
if (isLoading) {
return (
<div className="bg-card border-border rounded-xl border p-6">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div>
);
}
const channels = data ?? [];
if (channels.length === 0) {
return (
<div
className="bg-card border-border rounded-xl border p-6"
data-testid="notification-status-empty"
>
<h2 className="text-lg font-semibold">
{t("settings.notificationStatus.title")}
</h2>
<p className="text-muted-foreground mt-1 text-sm">
{t("settings.notificationStatus.emptyDescription")}
</p>
</div>
);
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<header className="mb-4">
<h2 className="text-lg font-semibold">
{t("settings.notificationStatus.title")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.notificationStatus.description")}
</p>
</header>
<ul className="space-y-3" data-testid="notification-status-list">
{channels.map((ch) => (
<ChannelRow
key={ch.id}
channel={ch}
formatDateTime={(iso) => fmt.dateTime(iso)}
t={t}
onReEnable={() => reEnable.mutate(ch.id)}
onTest={() => test.mutate(ch)}
reEnablePending={reEnable.isPending}
testPending={test.isPending}
/>
))}
</ul>
</div>
);
}
function ChannelRow({
channel,
formatDateTime,
t,
onReEnable,
onTest,
reEnablePending,
testPending,
}: {
channel: ChannelStatus;
formatDateTime: (iso: string | Date) => string;
t: (key: string, vars?: Record<string, string | number>) => string;
onReEnable: () => void;
onTest: () => void;
reEnablePending: boolean;
testPending: boolean;
}) {
const stateBadge = stateBadgeFor(channel.state, t);
return (
<li
className="border-border/60 flex flex-col gap-2 rounded-lg border p-4 sm:flex-row sm:items-start sm:justify-between"
data-testid={`notification-status-row-${channel.type}`}
data-state={channel.state}
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">{channel.label}</span>
<Badge
variant={stateBadge.variant}
className={stateBadge.className}
data-testid={`notification-status-badge-${channel.type}`}
>
{stateBadge.icon}
<span className="ml-1">{stateBadge.label}</span>
</Badge>
</div>
<dl className="text-muted-foreground space-y-0.5 text-xs">
{channel.lastSuccessAt && (
<div>
<dt className="inline">
{t("settings.notificationStatus.lastSuccess")}:
</dt>{" "}
<dd className="inline" data-testid="last-success">
{formatDateTime(channel.lastSuccessAt)}
</dd>
</div>
)}
{channel.lastFailureAt && (
<div>
<dt className="inline">
{t("settings.notificationStatus.lastFailure")}:
</dt>{" "}
<dd className="inline" data-testid="last-failure">
{formatDateTime(channel.lastFailureAt)}
{channel.lastFailureReason
? ` · ${channel.lastFailureReason}`
: ""}
</dd>
</div>
)}
{channel.consecutiveFailures > 0 && (
<div>
<dt className="inline">
{t("settings.notificationStatus.consecutiveFailures")}:
</dt>{" "}
<dd className="inline" data-testid="consecutive-failures">
{channel.consecutiveFailures}
</dd>
</div>
)}
{channel.disabledReason && (
<div>
<dt className="inline">
{t("settings.notificationStatus.disabledReason")}:
</dt>{" "}
<dd className="inline" data-testid="disabled-reason">
{channel.disabledReason}
</dd>
</div>
)}
{channel.state === "sending_paused" && channel.nextRetryAt && (
<div>
<dt className="inline">
{t("settings.notificationStatus.nextRetry")}:
</dt>{" "}
<dd className="inline" data-testid="next-retry">
{formatDateTime(channel.nextRetryAt)}
</dd>
</div>
)}
</dl>
</div>
<div className="flex flex-wrap gap-2">
{channel.state === "auto_disabled" && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onReEnable}
disabled={reEnablePending}
data-testid={`re-enable-${channel.type}`}
>
{reEnablePending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCw className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.notificationStatus.reEnable")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onTest}
disabled={testPending || channel.state === "auto_disabled"}
data-testid={`send-test-${channel.type}`}
>
{testPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.notificationStatus.sendTest")}
</Button>
</div>
</li>
);
}
function stateBadgeFor(
state: ChannelState,
t: (key: string) => string,
): {
label: string;
variant: "default" | "outline" | "destructive" | "secondary";
className: string;
icon: React.ReactNode;
} {
switch (state) {
case "active":
return {
label: t("settings.notificationStatus.stateActive"),
variant: "default",
className:
"border-dracula-green/30 bg-dracula-green/15 text-dracula-green",
icon: <CheckCircle2 className="h-3 w-3" aria-hidden />,
};
case "auto_disabled":
return {
label: t("settings.notificationStatus.stateAutoDisabled"),
variant: "destructive",
className: "",
icon: <AlertCircle className="h-3 w-3" aria-hidden />,
};
case "sending_paused":
return {
label: t("settings.notificationStatus.stateSendingPaused"),
variant: "outline",
className: "border-dracula-orange/40 text-dracula-orange",
icon: <Clock className="h-3 w-3" aria-hidden />,
};
case "manually_disabled":
default:
return {
label: t("settings.notificationStatus.stateManuallyDisabled"),
variant: "secondary",
className: "",
icon: <Clock className="h-3 w-3" aria-hidden />,
};
}
}