diff --git a/messages/de.json b/messages/de.json
index ab727c410..91e6def45 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index 1a872b0eb..9e218c427 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/src/app/api/notifications/status/route.ts b/src/app/api/notifications/status/route.ts
new file mode 100644
index 000000000..998f028d6
--- /dev/null
+++ b/src/app/api/notifications/status/route.ts
@@ -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 });
+});
diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx
index f9554c387..c1b2e3bdb 100644
--- a/src/components/admin/backups-section.tsx
+++ b/src/components/admin/backups-section.tsx
@@ -13,6 +13,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
+ BookOpen,
Database,
Download,
History,
@@ -351,7 +352,19 @@ export function BackupsSection() {
- {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. */}
+
+
+ {t("admin.section.backups.docsLink")}
+
{/* Upload card — separate from the table so admins can ingest a
diff --git a/src/components/settings/__tests__/notification-status-card.test.tsx b/src/components/settings/__tests__/notification-status-card.test.tsx
new file mode 100644
index 000000000..b4630efd4
--- /dev/null
+++ b/src/components/settings/__tests__/notification-status-card.test.tsx
@@ -0,0 +1,196 @@
+/**
+ * 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 }
+).__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(
+ {node},
+ );
+}
+
+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("", () => {
+ it("renders empty state when no channels exist", () => {
+ setChannels([]);
+ const html = render();
+ 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();
+ expect(html).toMatch(
+ /data-testid="notification-status-row-WEB_PUSH"[^>]*data-state="active"/,
+ );
+ expect(html).toContain(">Active");
+ // 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();
+ expect(html).toContain('data-state="auto_disabled"');
+ expect(html).toContain(">Auto-disabled");
+ 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();
+ expect(html).toContain('data-state="sending_paused"');
+ expect(html).toContain(">Sending paused");
+ 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();
+ expect(html).toContain('data-state="manually_disabled"');
+ expect(html).toContain(">Disabled");
+ // 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(, "de");
+ expect(html).toContain("Kanalzuverlässigkeit");
+ expect(html).toContain("Automatisch deaktiviert");
+ expect(html).toContain("Wieder aktivieren");
+ });
+});
diff --git a/src/components/settings/notification-status-card.tsx b/src/components/settings/notification-status-card.tsx
new file mode 100644
index 000000000..1fbceda47
--- /dev/null
+++ b/src/components/settings/notification-status-card.tsx
@@ -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 = {
+ 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 (
+