feat(webui): error-log bell with unread badge and clear-all

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-07-31 19:32:57 +08:00
parent a67f16651d
commit 47420514bd
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { errorToast } from "@/lib/errorReporter";
import { formatBadgeCount } from "@/lib/errorLogUnread";
import { formatFullTime, formatTime } from "@/lib/time";
import { useErrorLogs, type ErrorLogEntry } from "@/app/hooks/useErrorLogs";
function ErrorLogRow({ entry }: { entry: ErrorLogEntry }) {
const created = new Date(entry.createdAt);
return (
<li className="border-b border-border px-3 py-2 last:border-b-0">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{entry.source}
</span>
<time
className="shrink-0 text-[11px] text-muted-foreground"
title={formatFullTime(created)}
>
{formatTime(created)}
</time>
</div>
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">
{entry.message}
</p>
{entry.details && (
<p className="mt-0.5 line-clamp-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">
{entry.details}
</p>
)}
</li>
);
}
export function ErrorLogBell() {
const { logs, unread, loading, markAllRead, clearAll } = useErrorLogs();
const [open, setOpen] = useState(false);
const [confirmingClear, setConfirmingClear] = useState(false);
const [clearing, setClearing] = useState(false);
const badge = formatBadgeCount(unread);
const handleOpenChange = (next: boolean) => {
setOpen(next);
setConfirmingClear(false);
if (next) markAllRead();
};
const handleClear = async () => {
if (!confirmingClear) {
setConfirmingClear(true);
return;
}
setClearing(true);
try {
await clearAll();
setConfirmingClear(false);
} catch (error) {
errorToast(
"errors.clear",
error instanceof Error ? error.message : "Couldn't clear error logs."
);
} finally {
setClearing(false);
}
};
return (
<DropdownMenu modal={false} open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Error log"
title="Error log"
className="relative size-8"
>
<Bell className="size-5" aria-hidden="true" />
{badge && (
<span className="absolute right-0 top-0 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] text-destructive-foreground">
{badge}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-96 p-0">
<div className="border-b border-border px-3 py-2 text-sm font-medium">
Error log
</div>
{loading && logs.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Loading...
</p>
) : logs.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
No errors logged.
</p>
) : (
<ScrollArea className="max-h-96">
<ul>
{logs.map((entry) => (
<ErrorLogRow key={entry.id} entry={entry} />
))}
</ul>
</ScrollArea>
)}
{logs.length > 0 && (
<div className="border-t border-border p-2">
<Button
variant={confirmingClear ? "destructive" : "ghost"}
size="sm"
className="w-full"
disabled={clearing}
onClick={() => void handleClear()}
>
{clearing
? "Clearing..."
: confirmingClear
? "Click again to clear all"
: "Clear all"}
</Button>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}