mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
- Virtualize log list with react-virtuoso for 500-entry buffer performance - Add ErrorEntry renderer for new ERROR log type (AlertTriangle icon) - Show per-relay response time (e.g. "142ms", "2.3s") in publish details - Make all entry types expandable (connect/disconnect now have details) - Show absolute timestamp in all expanded detail views - Group ERROR events under Connect tab filter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
578 lines
17 KiB
TypeScript
578 lines
17 KiB
TypeScript
/**
|
|
* Event Log Viewer
|
|
*
|
|
* Compact log of relay operations for debugging and introspection.
|
|
*/
|
|
|
|
import { useState, useMemo, useCallback } from "react";
|
|
import {
|
|
Check,
|
|
X,
|
|
Loader2,
|
|
Wifi,
|
|
WifiOff,
|
|
Shield,
|
|
ShieldAlert,
|
|
MessageSquare,
|
|
Send,
|
|
RotateCcw,
|
|
Trash2,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
AlertTriangle,
|
|
} from "lucide-react";
|
|
import { Virtuoso } from "react-virtuoso";
|
|
import { Button } from "./ui/button";
|
|
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
import { RelayLink } from "./nostr/RelayLink";
|
|
import { useEventLog } from "@/hooks/useEventLog";
|
|
import {
|
|
type LogEntry,
|
|
type EventLogType,
|
|
type PublishLogEntry,
|
|
type ConnectLogEntry,
|
|
type ErrorLogEntry,
|
|
type AuthLogEntry,
|
|
type NoticeLogEntry,
|
|
type RelayStatusEntry,
|
|
} from "@/services/event-log";
|
|
import { formatTimestamp } from "@/hooks/useLocale";
|
|
import { cn } from "@/lib/utils";
|
|
import { KindBadge } from "./KindBadge";
|
|
import { KindRenderer } from "./nostr/kinds";
|
|
import { EventErrorBoundary } from "./EventErrorBoundary";
|
|
|
|
// ============================================================================
|
|
// Tab Filters
|
|
// ============================================================================
|
|
|
|
type TabFilter = "all" | "publish" | "connect" | "auth" | "notice";
|
|
|
|
const TAB_TYPE_MAP: Record<TabFilter, EventLogType[] | undefined> = {
|
|
all: undefined,
|
|
publish: ["PUBLISH"],
|
|
connect: ["CONNECT", "DISCONNECT", "ERROR"],
|
|
auth: ["AUTH"],
|
|
notice: ["NOTICE"],
|
|
};
|
|
|
|
const TAB_FILTERS: { value: TabFilter; label: string }[] = [
|
|
{ value: "all", label: "All" },
|
|
{ value: "publish", label: "Publish" },
|
|
{ value: "connect", label: "Connect" },
|
|
{ value: "auth", label: "Auth" },
|
|
{ value: "notice", label: "Notice" },
|
|
];
|
|
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
|
|
const AUTH_STATUS_TOOLTIP: Record<string, string> = {
|
|
challenge: "Auth challenge",
|
|
success: "Auth success",
|
|
failed: "Auth failed",
|
|
rejected: "Auth rejected",
|
|
};
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
/** Format relay response time relative to publish start */
|
|
function formatRelayTime(
|
|
publishTimestamp: number,
|
|
relayUpdatedAt: number,
|
|
): string {
|
|
const ms = relayUpdatedAt - publishTimestamp;
|
|
if (ms < 1000) return `${ms}ms`;
|
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Shared row layout
|
|
// ============================================================================
|
|
|
|
function EntryRow({
|
|
icon,
|
|
tooltip,
|
|
children,
|
|
timestamp,
|
|
className,
|
|
expanded,
|
|
onToggle,
|
|
details,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
tooltip: string;
|
|
children: React.ReactNode;
|
|
timestamp: number;
|
|
className?: string;
|
|
expanded?: boolean;
|
|
onToggle?: () => void;
|
|
details?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-2 px-2 py-1 border-b border-border min-w-0",
|
|
onToggle && "cursor-pointer hover:bg-muted/50",
|
|
className,
|
|
)}
|
|
onClick={onToggle}
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="flex-shrink-0">{icon}</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right">{tooltip}</TooltipContent>
|
|
</Tooltip>
|
|
<div className="flex-1 min-w-0 flex items-center gap-1.5 text-xs">
|
|
{children}
|
|
</div>
|
|
<span className="flex-shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
|
{formatTimestamp(timestamp / 1000, "relative")}
|
|
</span>
|
|
{onToggle && (
|
|
<div className="flex-shrink-0 text-muted-foreground">
|
|
{expanded ? (
|
|
<ChevronDown className="size-3" />
|
|
) : (
|
|
<ChevronRight className="size-3" />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{expanded && details && (
|
|
<div className="pl-7 pr-2 py-2 space-y-2 bg-muted/30 border-b border-border text-xs">
|
|
{details}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Entry Renderers
|
|
// ============================================================================
|
|
|
|
function PublishRelayRow({
|
|
relay,
|
|
status,
|
|
publishTimestamp,
|
|
onRetry,
|
|
}: {
|
|
relay: string;
|
|
status: RelayStatusEntry;
|
|
publishTimestamp: number;
|
|
onRetry?: () => void;
|
|
}) {
|
|
const isTerminal = status.status === "success" || status.status === "error";
|
|
|
|
return (
|
|
<div className="space-y-0.5">
|
|
<div className="flex items-center gap-1.5">
|
|
{status.status === "success" && (
|
|
<Check className="size-3 text-success flex-shrink-0" />
|
|
)}
|
|
{status.status === "error" && (
|
|
<X className="size-3 text-destructive flex-shrink-0" />
|
|
)}
|
|
{(status.status === "pending" || status.status === "publishing") && (
|
|
<Loader2 className="size-3 animate-spin text-muted-foreground flex-shrink-0" />
|
|
)}
|
|
<RelayLink
|
|
url={relay}
|
|
showInboxOutbox={false}
|
|
className="flex-1 min-w-0"
|
|
/>
|
|
{isTerminal && (
|
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
|
{formatRelayTime(publishTimestamp, status.updatedAt)}
|
|
</span>
|
|
)}
|
|
{status.status === "error" && onRetry && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-5 text-[11px] px-1.5 text-muted-foreground hover:text-foreground"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRetry();
|
|
}}
|
|
>
|
|
<RotateCcw className="size-2.5 mr-0.5" />
|
|
Retry
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{status.error && (
|
|
<div className="pl-[18px] text-[10px] text-destructive/80 break-words">
|
|
{status.error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PublishEntry({
|
|
entry,
|
|
onRetry,
|
|
onRetryRelay,
|
|
}: {
|
|
entry: PublishLogEntry;
|
|
onRetry?: (entryId: string) => void;
|
|
onRetryRelay?: (entryId: string, relay: string) => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
const statuses = Array.from(entry.relayStatus.values());
|
|
const successCount = statuses.filter((s) => s.status === "success").length;
|
|
const errorCount = statuses.filter((s) => s.status === "error").length;
|
|
const isPending = statuses.some(
|
|
(s) => s.status === "pending" || s.status === "publishing",
|
|
);
|
|
|
|
return (
|
|
<EntryRow
|
|
icon={<Send className="size-3.5 text-info" />}
|
|
tooltip="Publish"
|
|
timestamp={entry.timestamp}
|
|
expanded={expanded}
|
|
onToggle={() => setExpanded(!expanded)}
|
|
details={
|
|
<>
|
|
<div className="space-y-1">
|
|
{Array.from(entry.relayStatus.entries()).map(([relay, status]) => (
|
|
<PublishRelayRow
|
|
key={relay}
|
|
relay={relay}
|
|
status={status}
|
|
publishTimestamp={entry.timestamp}
|
|
onRetry={
|
|
onRetryRelay ? () => onRetryRelay(entry.id, relay) : undefined
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="rounded border border-border overflow-hidden">
|
|
<EventErrorBoundary event={entry.event}>
|
|
<KindRenderer event={entry.event} />
|
|
</EventErrorBoundary>
|
|
</div>
|
|
{errorCount > 0 && onRetry && (
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 text-xs px-2"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRetry(entry.id);
|
|
}}
|
|
>
|
|
<RotateCcw className="size-3 mr-1" />
|
|
Retry all ({errorCount})
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
<KindBadge
|
|
kind={entry.event.kind}
|
|
className="text-xs gap-1"
|
|
iconClassname="size-3 text-muted-foreground"
|
|
/>
|
|
{isPending && (
|
|
<Loader2 className="size-3 animate-spin text-muted-foreground" />
|
|
)}
|
|
{!isPending && successCount > 0 && (
|
|
<span className="text-success tabular-nums">{successCount} ok</span>
|
|
)}
|
|
{!isPending && errorCount > 0 && (
|
|
<span className="text-destructive tabular-nums">{errorCount} fail</span>
|
|
)}
|
|
</EntryRow>
|
|
);
|
|
}
|
|
|
|
function ConnectEntry({ entry }: { entry: ConnectLogEntry }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const isConnect = entry.type === "CONNECT";
|
|
|
|
return (
|
|
<EntryRow
|
|
icon={
|
|
isConnect ? (
|
|
<Wifi className="size-3.5 text-success" />
|
|
) : (
|
|
<WifiOff className="size-3.5 text-destructive/70" />
|
|
)
|
|
}
|
|
tooltip={isConnect ? "Connected" : "Disconnected"}
|
|
timestamp={entry.timestamp}
|
|
expanded={expanded}
|
|
onToggle={() => setExpanded(!expanded)}
|
|
details={
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-muted-foreground">Event:</span>
|
|
<span>{isConnect ? "Connected" : "Disconnected"}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-muted-foreground">Time:</span>
|
|
<span className="tabular-nums">
|
|
{formatTimestamp(entry.timestamp / 1000, "absolute")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<RelayLink url={entry.relay} showInboxOutbox={false} />
|
|
</EntryRow>
|
|
);
|
|
}
|
|
|
|
function ErrorEntry({ entry }: { entry: ErrorLogEntry }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
return (
|
|
<EntryRow
|
|
icon={<AlertTriangle className="size-3.5 text-destructive" />}
|
|
tooltip="Connection error"
|
|
timestamp={entry.timestamp}
|
|
expanded={expanded}
|
|
onToggle={() => setExpanded(!expanded)}
|
|
details={
|
|
<div className="space-y-1">
|
|
<div className="text-destructive/80 break-words font-mono">
|
|
{entry.message}
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-muted-foreground">Time:</span>
|
|
<span className="tabular-nums">
|
|
{formatTimestamp(entry.timestamp / 1000, "absolute")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<RelayLink url={entry.relay} showInboxOutbox={false} />
|
|
</EntryRow>
|
|
);
|
|
}
|
|
|
|
function AuthEntry({ entry }: { entry: AuthLogEntry }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
return (
|
|
<EntryRow
|
|
icon={
|
|
entry.status === "success" ? (
|
|
<Shield className="size-3.5 text-success" />
|
|
) : entry.status === "failed" ? (
|
|
<ShieldAlert className="size-3.5 text-destructive" />
|
|
) : entry.status === "challenge" ? (
|
|
<ShieldAlert className="size-3.5 text-warning" />
|
|
) : (
|
|
<ShieldAlert className="size-3.5 text-muted-foreground" />
|
|
)
|
|
}
|
|
tooltip={AUTH_STATUS_TOOLTIP[entry.status] ?? "Auth"}
|
|
timestamp={entry.timestamp}
|
|
expanded={expanded}
|
|
onToggle={() => setExpanded(!expanded)}
|
|
details={
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-muted-foreground">Status:</span>
|
|
<span
|
|
className={cn(
|
|
entry.status === "success" && "text-success",
|
|
entry.status === "failed" && "text-destructive",
|
|
entry.status === "challenge" && "text-warning",
|
|
entry.status === "rejected" && "text-muted-foreground",
|
|
)}
|
|
>
|
|
{entry.status}
|
|
</span>
|
|
</div>
|
|
{entry.challenge && (
|
|
<div className="text-[10px] text-muted-foreground font-mono truncate">
|
|
challenge: {entry.challenge}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-muted-foreground">Time:</span>
|
|
<span className="tabular-nums">
|
|
{formatTimestamp(entry.timestamp / 1000, "absolute")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<RelayLink url={entry.relay} showInboxOutbox={false} />
|
|
</EntryRow>
|
|
);
|
|
}
|
|
|
|
function NoticeEntry({ entry }: { entry: NoticeLogEntry }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
return (
|
|
<EntryRow
|
|
icon={<MessageSquare className="size-3.5 text-warning" />}
|
|
tooltip="Notice"
|
|
timestamp={entry.timestamp}
|
|
expanded={expanded}
|
|
onToggle={() => setExpanded(!expanded)}
|
|
details={
|
|
<div className="space-y-1">
|
|
<div className="text-muted-foreground break-words">
|
|
{entry.message}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
<span>Time:</span>
|
|
<span className="tabular-nums">
|
|
{formatTimestamp(entry.timestamp / 1000, "absolute")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<RelayLink url={entry.relay} showInboxOutbox={false} />
|
|
</EntryRow>
|
|
);
|
|
}
|
|
|
|
function LogEntryRenderer({
|
|
entry,
|
|
onRetry,
|
|
onRetryRelay,
|
|
}: {
|
|
entry: LogEntry;
|
|
onRetry?: (entryId: string) => void;
|
|
onRetryRelay?: (entryId: string, relay: string) => void;
|
|
}) {
|
|
switch (entry.type) {
|
|
case "PUBLISH":
|
|
return (
|
|
<PublishEntry
|
|
entry={entry}
|
|
onRetry={onRetry}
|
|
onRetryRelay={onRetryRelay}
|
|
/>
|
|
);
|
|
case "CONNECT":
|
|
case "DISCONNECT":
|
|
return <ConnectEntry entry={entry as ConnectLogEntry} />;
|
|
case "ERROR":
|
|
return <ErrorEntry entry={entry as ErrorLogEntry} />;
|
|
case "AUTH":
|
|
return <AuthEntry entry={entry as AuthLogEntry} />;
|
|
case "NOTICE":
|
|
return <NoticeEntry entry={entry as NoticeLogEntry} />;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main Component
|
|
// ============================================================================
|
|
|
|
function getTabCount(
|
|
tab: TabFilter,
|
|
totalCount: number,
|
|
typeCounts: Record<string, number>,
|
|
): number {
|
|
const types = TAB_TYPE_MAP[tab];
|
|
if (!types) return totalCount;
|
|
return types.reduce((sum, t) => sum + (typeCounts[t] || 0), 0);
|
|
}
|
|
|
|
export function EventLogViewer() {
|
|
const [activeTab, setActiveTab] = useState<TabFilter>("all");
|
|
|
|
const filterTypes = useMemo(() => TAB_TYPE_MAP[activeTab], [activeTab]);
|
|
const {
|
|
entries,
|
|
clear,
|
|
retryFailedRelays,
|
|
retryRelay,
|
|
totalCount,
|
|
typeCounts,
|
|
} = useEventLog({ types: filterTypes });
|
|
|
|
const renderItem = useCallback(
|
|
(_index: number, entry: LogEntry) => (
|
|
<LogEntryRenderer
|
|
entry={entry}
|
|
onRetry={retryFailedRelays}
|
|
onRetryRelay={retryRelay}
|
|
/>
|
|
),
|
|
[retryFailedRelays, retryRelay],
|
|
);
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-2 py-1 border-b border-border gap-2">
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(v) => setActiveTab(v as TabFilter)}
|
|
>
|
|
<TabsList className="h-7">
|
|
{TAB_FILTERS.map((tab) => {
|
|
const count = getTabCount(tab.value, totalCount, typeCounts);
|
|
return (
|
|
<TabsTrigger
|
|
key={tab.value}
|
|
value={tab.value}
|
|
className="text-xs px-1.5 h-5 gap-1"
|
|
>
|
|
{tab.label}
|
|
{count > 0 && (
|
|
<span className="text-[10px] tabular-nums text-muted-foreground">
|
|
{count}
|
|
</span>
|
|
)}
|
|
</TabsTrigger>
|
|
);
|
|
})}
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="size-6 p-0 text-destructive/70 hover:text-destructive hover:bg-destructive/10"
|
|
onClick={clear}
|
|
title="Clear log"
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Log entries */}
|
|
<div className="flex-1">
|
|
{entries.length === 0 ? (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
<p className="text-xs">No events logged yet</p>
|
|
</div>
|
|
) : (
|
|
<Virtuoso
|
|
data={entries}
|
|
itemContent={renderItem}
|
|
style={{ height: "100%" }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|