mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
1 parent
87a40fd0a4
commit
7c32d6344d
6 files changed
+672
-3
No files matched your search
+2
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 />,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user