feat: add LOG command for relay event introspection

Add an ephemeral event log system that tracks relay operations:

- EventLogService (src/services/event-log.ts):
  - Subscribes to PublishService for PUBLISH events with per-relay status
  - Monitors relay pool for CONNECT/DISCONNECT events
  - Tracks AUTH challenges and results
  - Captures NOTICE messages from relays
  - Uses RxJS BehaviorSubject for reactive updates
  - Circular buffer with configurable max entries (default 500)

- useEventLog hook (src/hooks/useEventLog.ts):
  - React hook for filtering and accessing log entries
  - Filter by type, relay, or limit
  - Retry failed relays directly from the hook

- EventLogViewer component (src/components/EventLogViewer.tsx):
  - Tab-based filtering (All/Publish/Connect/Auth/Notice)
  - Expandable PUBLISH entries showing per-relay status
  - Click to retry failed relays
  - Auto-scroll to new entries (pause on scroll)
  - Clear log button

- LOG command accessible via Cmd+K palette
This commit is contained in:
Claude
2026-01-23 22:25:18 +00:00
committed by Alejandro Gómez
parent 9a668bbdac
commit b89fd2c5ac
6 changed files with 1085 additions and 0 deletions

View File

@@ -0,0 +1,424 @@
/**
* Event Log Viewer
*
* Displays a log of relay operations for debugging and introspection:
* - PUBLISH events with per-relay status and retry functionality
* - CONNECT/DISCONNECT events
* - AUTH events
* - NOTICE events
*/
import { useState, useCallback, useRef, useEffect } from "react";
import {
Check,
X,
Loader2,
Wifi,
WifiOff,
Shield,
ShieldAlert,
MessageSquare,
Send,
RotateCcw,
Trash2,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { Button } from "./ui/button";
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import { RelayLink } from "./nostr/RelayLink";
import { useEventLog } from "@/hooks/useEventLog";
import {
type LogEntry,
type EventLogType,
type PublishLogEntry,
type ConnectLogEntry,
type AuthLogEntry,
type NoticeLogEntry,
} from "@/services/event-log";
import { formatTimestamp } from "@/hooks/useLocale";
import { cn } from "@/lib/utils";
// ============================================================================
// Tab Filter Types
// ============================================================================
type TabFilter = "all" | EventLogType;
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" },
];
// ============================================================================
// Entry Renderers
// ============================================================================
interface EntryProps {
entry: LogEntry;
onRetry?: (entryId: string) => void;
}
function PublishEntry({
entry,
onRetry,
}: EntryProps & { entry: PublishLogEntry }) {
const [expanded, setExpanded] = useState(false);
const successCount = Array.from(entry.relayStatus.values()).filter(
(s) => s.status === "success",
).length;
const errorCount = Array.from(entry.relayStatus.values()).filter(
(s) => s.status === "error",
).length;
const pendingCount = Array.from(entry.relayStatus.values()).filter(
(s) => s.status === "pending" || s.status === "publishing",
).length;
const hasFailures = errorCount > 0;
const isPending = pendingCount > 0;
// Truncate event content for preview
const contentPreview =
entry.event.content.length > 60
? entry.event.content.slice(0, 60) + "..."
: entry.event.content;
return (
<div className="border-b border-border last:border-b-0">
<button
className="w-full flex items-start gap-2 p-2 hover:bg-muted/50 text-left"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-4 w-4 mt-0.5 flex-shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 mt-0.5 flex-shrink-0 text-muted-foreground" />
)}
<Send className="h-4 w-4 mt-0.5 flex-shrink-0 text-blue-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatTimestamp(entry.timestamp / 1000, "time")}
</span>
<span className="font-medium">PUBLISH</span>
<span className="text-xs text-muted-foreground">
kind:{entry.event.kind}
</span>
{isPending && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
{!isPending && successCount > 0 && (
<span className="text-xs text-green-500">
{successCount}/{entry.relays.length}
</span>
)}
{!isPending && errorCount > 0 && (
<span className="text-xs text-red-500">{errorCount} failed</span>
)}
</div>
<div className="text-sm text-muted-foreground truncate">
{contentPreview || "(empty content)"}
</div>
</div>
</button>
{expanded && (
<div className="pl-10 pr-2 pb-2 space-y-2">
{/* Relay status list */}
<div className="space-y-1">
{Array.from(entry.relayStatus.entries()).map(([relay, status]) => (
<div key={relay} className="flex items-center gap-2 text-sm">
{status.status === "success" && (
<Check className="h-3 w-3 text-green-500" />
)}
{status.status === "error" && (
<X className="h-3 w-3 text-red-500" />
)}
{(status.status === "pending" ||
status.status === "publishing") && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
<RelayLink
url={relay}
write={true}
showInboxOutbox={false}
className="text-xs"
/>
{status.error && (
<span className="text-xs text-red-500 truncate">
{status.error}
</span>
)}
</div>
))}
</div>
{/* Retry button for failed relays */}
{hasFailures && onRetry && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onRetry(entry.id);
}}
>
<RotateCcw className="h-3 w-3 mr-1" />
Retry failed ({errorCount})
</Button>
)}
{/* Event ID */}
<div className="text-xs text-muted-foreground font-mono">
{entry.event.id.slice(0, 16)}...
</div>
</div>
)}
</div>
);
}
function ConnectEntry({ entry }: EntryProps & { entry: ConnectLogEntry }) {
const isConnect = entry.type === "CONNECT";
return (
<div className="flex items-center gap-2 p-2 border-b border-border last:border-b-0">
{isConnect ? (
<Wifi className="h-4 w-4 text-green-500" />
) : (
<WifiOff className="h-4 w-4 text-muted-foreground" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatTimestamp(entry.timestamp / 1000, "time")}
</span>
<span
className={cn(
"font-medium",
isConnect ? "text-green-500" : "text-muted-foreground",
)}
>
{entry.type}
</span>
</div>
<RelayLink
url={entry.relay}
write={true}
showInboxOutbox={false}
className="text-sm"
/>
</div>
</div>
);
}
function AuthEntry({ entry }: EntryProps & { entry: AuthLogEntry }) {
const statusColors = {
challenge: "text-yellow-500",
success: "text-green-500",
failed: "text-red-500",
rejected: "text-muted-foreground",
};
return (
<div className="flex items-center gap-2 p-2 border-b border-border last:border-b-0">
{entry.status === "success" ? (
<Shield className="h-4 w-4 text-green-500" />
) : (
<ShieldAlert className={cn("h-4 w-4", statusColors[entry.status])} />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatTimestamp(entry.timestamp / 1000, "time")}
</span>
<span className="font-medium">AUTH</span>
<span className={cn("text-xs", statusColors[entry.status])}>
{entry.status}
</span>
</div>
<RelayLink
url={entry.relay}
write={true}
showInboxOutbox={false}
className="text-sm"
/>
</div>
</div>
);
}
function NoticeEntry({ entry }: EntryProps & { entry: NoticeLogEntry }) {
return (
<div className="flex items-start gap-2 p-2 border-b border-border last:border-b-0">
<MessageSquare className="h-4 w-4 mt-0.5 text-amber-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatTimestamp(entry.timestamp / 1000, "time")}
</span>
<span className="font-medium text-amber-500">NOTICE</span>
</div>
<RelayLink
url={entry.relay}
write={true}
showInboxOutbox={false}
className="text-sm"
/>
<div className="text-sm text-muted-foreground mt-1 break-words">
{entry.message}
</div>
</div>
</div>
);
}
function LogEntryRenderer({ entry, onRetry }: EntryProps) {
switch (entry.type) {
case "PUBLISH":
return <PublishEntry entry={entry} onRetry={onRetry} />;
case "CONNECT":
case "DISCONNECT":
return <ConnectEntry entry={entry as ConnectLogEntry} />;
case "AUTH":
return <AuthEntry entry={entry as AuthLogEntry} />;
case "NOTICE":
return <NoticeEntry entry={entry as NoticeLogEntry} />;
default:
return null;
}
}
// ============================================================================
// Main Component
// ============================================================================
export function EventLogViewer() {
const [activeTab, setActiveTab] = useState<TabFilter>("all");
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const filterTypes = activeTab === "all" ? undefined : [activeTab];
const { entries, clear, retryFailedRelays, totalCount } = useEventLog({
types: filterTypes,
});
// Auto-scroll to top when new entries arrive
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
}, [entries.length, autoScroll]);
// Pause auto-scroll when user scrolls down
const handleScroll = useCallback(() => {
if (scrollRef.current) {
const { scrollTop } = scrollRef.current;
// If user scrolls down more than 50px, pause auto-scroll
setAutoScroll(scrollTop < 50);
}
}, []);
const handleRetry = useCallback(
async (entryId: string) => {
await retryFailedRelays(entryId);
},
[retryFailedRelays],
);
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-2 border-b border-border">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabFilter)}
>
<TabsList className="h-8">
{TAB_FILTERS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="text-xs px-2"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{entries.length}
{totalCount !== entries.length && ` / ${totalCount}`} entries
</span>
<Button
size="sm"
variant="ghost"
className="h-7"
onClick={clear}
title="Clear log"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* Log entries */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
{entries.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No events logged yet</p>
<p className="text-xs mt-1">
Events will appear here as you interact with relays
</p>
</div>
</div>
) : (
<div>
{entries.map((entry) => (
<LogEntryRenderer
key={entry.id}
entry={entry}
onRetry={handleRetry}
/>
))}
</div>
)}
</div>
{/* Auto-scroll indicator */}
{!autoScroll && entries.length > 0 && (
<div className="absolute bottom-4 right-4">
<Button
size="sm"
variant="secondary"
onClick={() => {
setAutoScroll(true);
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
}}
>
<ChevronDown className="h-3 w-3 mr-1" />
New events
</Button>
</div>
)}
</div>
);
}

View File

@@ -53,6 +53,9 @@ const PostViewer = lazy(() =>
const SettingsViewer = lazy(() =>
import("./SettingsViewer").then((m) => ({ default: m.SettingsViewer })),
);
const EventLogViewer = lazy(() =>
import("./EventLogViewer").then((m) => ({ default: m.EventLogViewer })),
);
// Loading fallback component
function ViewerLoading() {
@@ -257,6 +260,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
case "settings":
content = <SettingsViewer />;
break;
case "log":
content = <EventLogViewer />;
break;
default:
content = (
<div className="p-4 text-muted-foreground">

144
src/hooks/useEventLog.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* React hook for accessing the Event Log
*
* Provides reactive access to relay operation logs with filtering capabilities.
*/
import { useState, useEffect, useCallback, useMemo } from "react";
import eventLog, {
type LogEntry,
type EventLogType,
type PublishLogEntry,
} from "@/services/event-log";
export interface UseEventLogOptions {
/** Filter by event type(s) */
types?: EventLogType[];
/** Filter by relay URL */
relay?: string;
/** Maximum entries to return */
limit?: number;
}
export interface UseEventLogResult {
/** Filtered log entries */
entries: LogEntry[];
/** Publish entries with full status info */
publishEntries: PublishLogEntry[];
/** Clear all log entries */
clear: () => void;
/** Retry failed relays for a publish entry */
retryFailedRelays: (entryId: string) => Promise<void>;
/** Total count of all entries (before filtering) */
totalCount: number;
}
/**
* Hook to access and filter event log entries
*
* @example
* ```tsx
* // Get all entries
* const { entries } = useEventLog();
*
* // Filter by type
* const { entries } = useEventLog({ types: ["PUBLISH", "CONNECT"] });
*
* // Filter by relay
* const { entries } = useEventLog({ relay: "wss://relay.example.com/" });
*
* // Limit results
* const { entries } = useEventLog({ limit: 50 });
* ```
*/
export function useEventLog(
options: UseEventLogOptions = {},
): UseEventLogResult {
const { types, relay, limit } = options;
const [entries, setEntries] = useState<LogEntry[]>(() =>
eventLog.getEntries(),
);
// Subscribe to log updates
useEffect(() => {
const subscription = eventLog.entries$.subscribe((newEntries) => {
setEntries(newEntries);
});
return () => subscription.unsubscribe();
}, []);
// Filter entries based on options
const filteredEntries = useMemo(() => {
let result = entries;
// Filter by types
if (types && types.length > 0) {
result = result.filter((e) => types.includes(e.type));
}
// Filter by relay
if (relay) {
result = result.filter((e) => e.relay === relay);
}
// Apply limit
if (limit && limit > 0) {
result = result.slice(0, limit);
}
return result;
}, [entries, types, relay, limit]);
// Get publish entries
const publishEntries = useMemo(() => {
return filteredEntries.filter(
(e): e is PublishLogEntry => e.type === "PUBLISH",
);
}, [filteredEntries]);
// Clear all entries
const clear = useCallback(() => {
eventLog.clear();
}, []);
// Retry failed relays
const retryFailedRelays = useCallback(async (entryId: string) => {
await eventLog.retryFailedRelays(entryId);
}, []);
return {
entries: filteredEntries,
publishEntries,
clear,
retryFailedRelays,
totalCount: entries.length,
};
}
/**
* Hook to get the latest entry of a specific type
*/
export function useLatestLogEntry(type: EventLogType): LogEntry | undefined {
const { entries } = useEventLog({ types: [type], limit: 1 });
return entries[0];
}
/**
* Hook to subscribe to new log entries as they arrive
*/
export function useNewLogEntry(
callback: (entry: LogEntry) => void,
types?: EventLogType[],
): void {
useEffect(() => {
const subscription = eventLog.newEntry$.subscribe((entry) => {
if (!types || types.length === 0 || types.includes(entry.type)) {
callback(entry);
}
});
return () => subscription.unsubscribe();
}, [callback, types]);
}

498
src/services/event-log.ts Normal file
View File

@@ -0,0 +1,498 @@
/**
* Event Log Service
*
* Provides an ephemeral log of relay operations for introspection:
* - PUBLISH events with per-relay status
* - CONNECT/DISCONNECT events
* - AUTH events
* - NOTICE events
*
* Uses RxJS for reactive updates and maintains a circular buffer
* of recent events (configurable max size).
*/
import { BehaviorSubject, Subject, Subscription } from "rxjs";
import { startWith, pairwise, filter } from "rxjs/operators";
import type { NostrEvent } from "nostr-tools";
import publishService, {
type PublishEvent,
type RelayStatusUpdate,
} from "./publish-service";
import pool from "./relay-pool";
import type { IRelay } from "applesauce-relay";
// ============================================================================
// Types
// ============================================================================
/** Types of events tracked in the log */
export type EventLogType =
| "PUBLISH"
| "CONNECT"
| "DISCONNECT"
| "AUTH"
| "NOTICE";
/** Base interface for all log entries */
interface BaseLogEntry {
/** Unique ID for this log entry */
id: string;
/** Type of event */
type: EventLogType;
/** Timestamp when event occurred */
timestamp: number;
/** Relay URL (if applicable) */
relay?: string;
}
/** Publish event log entry */
export interface PublishLogEntry extends BaseLogEntry {
type: "PUBLISH";
/** The Nostr event being published */
event: NostrEvent;
/** Target relays */
relays: string[];
/** Per-relay status */
relayStatus: Map<string, { status: string; error?: string }>;
/** Overall status: pending, partial, success, failed */
status: "pending" | "partial" | "success" | "failed";
/** Publish ID from PublishService */
publishId: string;
}
/** Connection event log entry */
export interface ConnectLogEntry extends BaseLogEntry {
type: "CONNECT" | "DISCONNECT";
relay: string;
}
/** Auth event log entry */
export interface AuthLogEntry extends BaseLogEntry {
type: "AUTH";
relay: string;
/** Auth status: challenge, success, failed, rejected */
status: "challenge" | "success" | "failed" | "rejected";
/** Challenge string (for challenge events) */
challenge?: string;
}
/** Notice event log entry */
export interface NoticeLogEntry extends BaseLogEntry {
type: "NOTICE";
relay: string;
/** Notice message from relay */
message: string;
}
/** Union type for all log entries */
export type LogEntry =
| PublishLogEntry
| ConnectLogEntry
| AuthLogEntry
| NoticeLogEntry;
// ============================================================================
// EventLogService Class
// ============================================================================
class EventLogService {
/** Maximum number of entries to keep in the log */
private maxEntries: number;
/** Circular buffer of log entries */
private entries: LogEntry[] = [];
/** BehaviorSubject for reactive updates */
private entriesSubject = new BehaviorSubject<LogEntry[]>([]);
/** Subject for new entry notifications */
private newEntrySubject = new Subject<LogEntry>();
/** Active subscriptions */
private subscriptions: Subscription[] = [];
/** Relay subscriptions for connection/auth/notice tracking */
private relaySubscriptions = new Map<string, Subscription>();
/** Counter for generating unique IDs */
private idCounter = 0;
/** Map of publish IDs to log entry IDs */
private publishIdToEntryId = new Map<string, string>();
/** Polling interval for new relays */
private pollingIntervalId?: NodeJS.Timeout;
constructor(maxEntries = 500) {
this.maxEntries = maxEntries;
}
// --------------------------------------------------------------------------
// Public Observables
// --------------------------------------------------------------------------
/** Observable of all log entries (emits current state on subscribe) */
readonly entries$ = this.entriesSubject.asObservable();
/** Observable of new entries as they arrive */
readonly newEntry$ = this.newEntrySubject.asObservable();
// --------------------------------------------------------------------------
// Initialization
// --------------------------------------------------------------------------
/**
* Initialize the event log service
* Subscribes to PublishService and relay pool events
*/
initialize(): void {
// Subscribe to publish events
this.subscriptions.push(
publishService.publish$.subscribe((event) =>
this.handlePublishEvent(event),
),
);
// Subscribe to per-relay status updates
this.subscriptions.push(
publishService.status$.subscribe((update) =>
this.handleStatusUpdate(update),
),
);
// Monitor existing relays
pool.relays.forEach((relay) => this.monitorRelay(relay));
// Poll for new relays
this.pollingIntervalId = setInterval(() => {
pool.relays.forEach((relay) => {
if (!this.relaySubscriptions.has(relay.url)) {
this.monitorRelay(relay);
}
});
}, 1000);
}
/**
* Clean up subscriptions
*/
destroy(): void {
this.subscriptions.forEach((sub) => sub.unsubscribe());
this.subscriptions = [];
this.relaySubscriptions.forEach((sub) => sub.unsubscribe());
this.relaySubscriptions.clear();
if (this.pollingIntervalId) {
clearInterval(this.pollingIntervalId);
this.pollingIntervalId = undefined;
}
}
// --------------------------------------------------------------------------
// Relay Monitoring
// --------------------------------------------------------------------------
/**
* Monitor a relay for connection, auth, and notice events
*/
private monitorRelay(relay: IRelay): void {
const url = relay.url;
if (this.relaySubscriptions.has(url)) return;
const subscription = new Subscription();
// Track connection state changes
subscription.add(
relay.connected$
.pipe(
startWith(relay.connected),
pairwise(),
filter(([prev, curr]) => prev !== curr),
)
.subscribe(([, connected]) => {
this.addEntry({
type: connected ? "CONNECT" : "DISCONNECT",
relay: url,
});
}),
);
// Track authentication events
subscription.add(
relay.authenticated$
.pipe(
startWith(relay.authenticated),
pairwise(),
filter(([prev, curr]) => prev !== curr && curr === true),
)
.subscribe(() => {
this.addEntry({
type: "AUTH",
relay: url,
status: "success",
});
}),
);
// Track challenges
subscription.add(
relay.challenge$
.pipe(filter((challenge): challenge is string => !!challenge))
.subscribe((challenge) => {
this.addEntry({
type: "AUTH",
relay: url,
status: "challenge",
challenge,
});
}),
);
// Track notices
subscription.add(
relay.notice$.subscribe((notices) => {
// notices can be a single string or array
const noticeArray = Array.isArray(notices)
? notices
: notices
? [notices]
: [];
// Only log new notices (last one)
if (noticeArray.length > 0) {
const latestNotice = noticeArray[noticeArray.length - 1];
this.addEntry({
type: "NOTICE",
relay: url,
message: latestNotice,
});
}
}),
);
this.relaySubscriptions.set(url, subscription);
}
// --------------------------------------------------------------------------
// Publish Event Handling
// --------------------------------------------------------------------------
/**
* Handle a publish event from PublishService
*/
private handlePublishEvent(event: PublishEvent): void {
const entryId = this.generateId();
// Create initial publish entry
const entry: PublishLogEntry = {
id: entryId,
type: "PUBLISH",
timestamp: event.startedAt,
event: event.event,
relays: event.relays,
relayStatus: new Map(event.results),
status: this.calculatePublishStatus(event.results),
publishId: event.id,
};
// Map publish ID to entry ID for status updates
this.publishIdToEntryId.set(event.id, entryId);
this.addEntry(entry);
}
/**
* Handle a per-relay status update from PublishService
*/
private handleStatusUpdate(update: RelayStatusUpdate): void {
const entryId = this.publishIdToEntryId.get(update.publishId);
if (!entryId) return;
// Find and update the publish entry
const entryIndex = this.entries.findIndex(
(e) => e.id === entryId && e.type === "PUBLISH",
);
if (entryIndex === -1) return;
const entry = this.entries[entryIndex] as PublishLogEntry;
// Update relay status
entry.relayStatus.set(update.relay, {
status: update.status,
error: update.error,
});
// Recalculate overall status
entry.status = this.calculatePublishStatus(entry.relayStatus);
// Notify subscribers
this.entriesSubject.next([...this.entries]);
}
/**
* Calculate overall publish status from relay results
*/
private calculatePublishStatus(
results: Map<string, { status: string; error?: string }>,
): "pending" | "partial" | "success" | "failed" {
const statuses = Array.from(results.values()).map((r) => r.status);
if (statuses.every((s) => s === "pending" || s === "publishing")) {
return "pending";
}
const successCount = statuses.filter((s) => s === "success").length;
const errorCount = statuses.filter((s) => s === "error").length;
if (successCount === statuses.length) {
return "success";
} else if (errorCount === statuses.length) {
return "failed";
} else if (successCount > 0) {
return "partial";
}
return "pending";
}
// --------------------------------------------------------------------------
// Entry Management
// --------------------------------------------------------------------------
/**
* Generate a unique ID for a log entry
*/
private generateId(): string {
return `log_${Date.now()}_${++this.idCounter}`;
}
/**
* Add an entry to the log
* Accepts partial entries without id/timestamp (they will be generated)
*/
private addEntry(
entry:
| (Omit<PublishLogEntry, "id" | "timestamp"> & {
id?: string;
timestamp?: number;
})
| (Omit<ConnectLogEntry, "id" | "timestamp"> & {
id?: string;
timestamp?: number;
})
| (Omit<AuthLogEntry, "id" | "timestamp"> & {
id?: string;
timestamp?: number;
})
| (Omit<NoticeLogEntry, "id" | "timestamp"> & {
id?: string;
timestamp?: number;
}),
): void {
const fullEntry = {
id: entry.id || this.generateId(),
timestamp: entry.timestamp || Date.now(),
...entry,
} as LogEntry;
// Add to front (most recent first)
this.entries.unshift(fullEntry);
// Trim to max size
if (this.entries.length > this.maxEntries) {
const removed = this.entries.splice(this.maxEntries);
// Clean up publish ID mappings for removed entries
removed.forEach((e) => {
if (e.type === "PUBLISH") {
this.publishIdToEntryId.delete((e as PublishLogEntry).publishId);
}
});
}
// Notify subscribers
this.entriesSubject.next([...this.entries]);
this.newEntrySubject.next(fullEntry);
}
// --------------------------------------------------------------------------
// Public Methods
// --------------------------------------------------------------------------
/**
* Get all log entries
*/
getEntries(): LogEntry[] {
return [...this.entries];
}
/**
* Get entries filtered by type
*/
getEntriesByType(type: EventLogType): LogEntry[] {
return this.entries.filter((e) => e.type === type);
}
/**
* Get entries for a specific relay
*/
getEntriesByRelay(relay: string): LogEntry[] {
return this.entries.filter((e) => e.relay === relay);
}
/**
* Get publish entries only
*/
getPublishEntries(): PublishLogEntry[] {
return this.entries.filter(
(e): e is PublishLogEntry => e.type === "PUBLISH",
);
}
/**
* Clear all entries
*/
clear(): void {
this.entries = [];
this.publishIdToEntryId.clear();
this.entriesSubject.next([]);
}
/**
* Retry failed relays for a publish entry
*/
async retryFailedRelays(entryId: string): Promise<void> {
const entry = this.entries.find(
(e) => e.id === entryId && e.type === "PUBLISH",
) as PublishLogEntry | undefined;
if (!entry) return;
const failedRelays = Array.from(entry.relayStatus.entries())
.filter(([, status]) => status.status === "error")
.map(([relay]) => relay);
if (failedRelays.length === 0) return;
// Retry via PublishService
await publishService.retryRelays(
entry.event,
failedRelays,
entry.publishId,
);
}
}
// ============================================================================
// Singleton Export
// ============================================================================
const eventLog = new EventLogService();
// Initialize on module load
eventLog.initialize();
export default eventLog;
// Also export the class for testing
export { EventLogService };

View File

@@ -25,6 +25,7 @@ export type AppId =
| "zap"
| "post"
| "settings"
| "log"
| "win";
export interface WindowInstance {

View File

@@ -883,4 +883,16 @@ export const manPages: Record<string, ManPageEntry> = {
category: "System",
defaultProps: {},
},
log: {
name: "log",
section: "1",
synopsis: "log",
description:
"View ephemeral log of relay operations for debugging and introspection. Shows PUBLISH events with per-relay status (success/error/pending), CONNECT/DISCONNECT events, AUTH challenges and results, and relay NOTICE messages. Click on failed relays to retry publishing. Filter by event type using tabs. Log is ephemeral and stored in memory only.",
examples: ["log Open event log viewer"],
seeAlso: ["conn", "relay", "post"],
appId: "log",
category: "System",
defaultProps: {},
},
};