fix(logs): harden global logs with shared parsing, SSE fixes, and level filter (#539)

- Extract log parsing utilities (normalizeContainerName, parseLogTimestamp,
  detectLogLevel, stripControlChars, demuxDockerLog) to shared module,
  eliminating duplication between polling and SSE endpoints
- Fix timestamp regex to accept timezone offsets (+HH:MM/-HH:MM), not just Z
- Replace any[] with typed GlobalLogEntry interface
- Add SSE heartbeat (30s) to prevent reverse proxy timeouts
- Add X-Accel-Buffering: no header for nginx SSE compatibility
- Replace swallowed catch blocks with console.warn diagnostics
- Fix SSE not reconnecting on node switch (missing dep in effect array)
- Add settings change event so devMode/pollRate updates apply without remount
- Add log level filter (ALL/ERROR/WARN/INFO) to toolbar
- Replace overflow-auto div with ScrollArea for design system compliance
- Add strokeWidth={1.5} to all toolbar icons
- Include stack name in download format
- Surface fetch errors with inline banner
- Add 39 unit tests for all log parsing utilities
- Update docs with level filter documentation and refreshed screenshot
This commit is contained in:
Anso
2026-04-12 21:19:08 -04:00
committed by GitHub
parent c1fb0caba6
commit a74a516850
9 changed files with 572 additions and 188 deletions
@@ -1,11 +1,13 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useEffect, useState, useMemo, useRef, useCallback, useLayoutEffect } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { RefreshCw, Download, Trash2, Search, Filter } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { RefreshCw, Download, Trash2, Search, Filter, AlertCircle } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
// Max entries held in React state. Bounds SSE-mode memory growth.
const MAX_LOG_ENTRIES = 2000;
@@ -17,8 +19,8 @@ const MAX_DISPLAY_ROWS = 300;
interface LogEntry {
stackName: string;
containerName: string;
source: string;
level: string;
source: 'STDOUT' | 'STDERR';
level: 'INFO' | 'WARN' | 'ERROR';
message: string;
timestampMs: number;
// Assigned client-side at ingestion. Gives React a stable, collision-free
@@ -31,6 +33,7 @@ export function GlobalObservabilityView() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [allStacks, setAllStacks] = useState<string[]>([]);
const [fetchError, setFetchError] = useState(false);
// Settings state
const [devMode, setDevMode] = useState(false);
@@ -40,9 +43,11 @@ export function GlobalObservabilityView() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedStacks, setSelectedStacks] = useState<string[]>([]);
const [streamFilter, setStreamFilter] = useState<'ALL' | 'STDOUT' | 'STDERR'>('ALL');
const [levelFilter, setLevelFilter] = useState<'ALL' | 'ERROR' | 'WARN' | 'INFO'>('ALL');
const [clearedAt, setClearedAt] = useState<number>(0);
const bottomRef = useRef<HTMLDivElement>(null);
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true);
const viewportRef = useRef<HTMLDivElement>(null);
// SSE throttle buffer
const bufferRef = useRef<LogEntry[]>([]);
@@ -51,21 +56,32 @@ export function GlobalObservabilityView() {
const logIdRef = useRef(0);
// Fetch settings on mount
const fetchSettings = useCallback(async () => {
try {
const res = await apiFetch('/settings');
if (res.ok) {
const data = await res.json();
setDevMode(data.developer_mode === '1');
setPollRate(parseInt(data.global_logs_refresh || '5', 10));
}
} catch (e) {
console.error('Failed to fetch settings:', e);
}
}, []);
useEffect(() => { fetchSettings(); }, [fetchSettings]);
// Re-fetch settings when they change from the settings modal
useEffect(() => {
const fetchSettings = async () => {
try {
const res = await apiFetch('/settings');
if (res.ok) {
const data = await res.json();
setDevMode(data.developer_mode === '1');
setPollRate(parseInt(data.global_logs_refresh || '5', 10));
}
} catch (e) {
console.error('Failed to fetch settings:', e);
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ changedKeys: string[] }>).detail;
if (detail.changedKeys.some(k => k === 'developer_mode' || k === 'global_logs_refresh')) {
fetchSettings();
}
};
fetchSettings();
}, []);
window.addEventListener(SENCHO_SETTINGS_CHANGED, handler);
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, handler);
}, [fetchSettings]);
// Fetch definitive stack list from the filesystem, independent of log data
useEffect(() => {
@@ -83,12 +99,14 @@ export function GlobalObservabilityView() {
fetchStacks();
}, []);
// Data fetching: Polling (standard) vs SSE (dev mode)
// Data fetching: Polling (standard) vs SSE (dev mode).
// Depends on activeNode?.id so the stream reconnects on node switch.
const activeNodeId = activeNode?.id;
useEffect(() => {
if (devMode) {
// SSE mode
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
const eventSource = new EventSource(`/api/logs/global/stream?nodeId=${activeNodeId}`);
// SSE mode: use the node ID from context (not localStorage)
const nodeParam = activeNodeId != null ? String(activeNodeId) : '';
const eventSource = new EventSource(`/api/logs/global/stream?nodeId=${nodeParam}`);
eventSource.onmessage = (event) => {
try {
@@ -99,7 +117,9 @@ export function GlobalObservabilityView() {
};
eventSource.onerror = () => {
// SSE will auto-reconnect, no action needed
if (eventSource.readyState === EventSource.CLOSED) {
setFetchError(true);
}
};
// 500ms throttle: flush buffer into React state
@@ -115,6 +135,7 @@ export function GlobalObservabilityView() {
}, 500);
setLoading(false);
setFetchError(false);
return () => {
eventSource.close();
@@ -133,9 +154,13 @@ export function GlobalObservabilityView() {
// has a stable, collision-free key for every log line.
data.forEach(entry => { entry._id = ++logIdRef.current; });
setLogs(data);
setFetchError(false);
} else {
setFetchError(true);
}
} catch (error) {
console.error('Failed to fetch global logs:', error);
setFetchError(true);
} finally {
setLoading(false);
}
@@ -145,7 +170,7 @@ export function GlobalObservabilityView() {
const interval = setInterval(fetchData, pollRate * 1000);
return () => clearInterval(interval);
}
}, [devMode, pollRate]);
}, [devMode, pollRate, activeNodeId]);
const handleStackToggle = (stack: string) => {
setSelectedStacks(prev =>
@@ -162,6 +187,7 @@ export function GlobalObservabilityView() {
if (log.timestampMs < clearedAt) return false;
if (selectedStacks.length > 0 && !selectedStacks.includes(log.stackName)) return false;
if (streamFilter !== 'ALL' && log.source !== streamFilter) return false;
if (levelFilter !== 'ALL' && log.level !== levelFilter) return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
return log.message.toLowerCase().includes(query) ||
@@ -170,7 +196,7 @@ export function GlobalObservabilityView() {
}
return true;
});
}, [logs, selectedStacks, streamFilter, searchQuery, clearedAt]);
}, [logs, selectedStacks, streamFilter, levelFilter, searchQuery, clearedAt]);
useEffect(() => {
if (isAutoScrollEnabled && bottomRef.current) {
@@ -180,15 +206,25 @@ export function GlobalObservabilityView() {
}
}, [filteredLogs, isAutoScrollEnabled]);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const isAtBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 50;
// Wire scroll detection via the Radix viewport ref (ScrollArea does not
// forward onScroll natively).
const handleScroll = useCallback(() => {
const el = viewportRef.current;
if (!el) return;
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50;
setIsAutoScrollEnabled(isAtBottom);
}, []);
useLayoutEffect(() => {
const el = viewportRef.current;
if (!el) return;
el.addEventListener('scroll', handleScroll);
return () => el.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
const handleDownload = () => {
if (filteredLogs.length === 0) return;
const blob = new Blob([filteredLogs.map(l => `[${new Date(l.timestampMs).toLocaleTimeString([], { hour12: true })}] [${l.containerName}] ${l.level}: ${l.message}`).join('\n')], { type: 'text/plain;charset=utf-8' });
const blob = new Blob([filteredLogs.map(l => `[${new Date(l.timestampMs).toLocaleTimeString([], { hour12: true })}] [${l.stackName}/${l.containerName}] ${l.level}: ${l.message}`).join('\n')], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -211,7 +247,7 @@ export function GlobalObservabilityView() {
)}
<div className="relative flex items-center">
<Search className="absolute left-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Search className="absolute left-2.5 h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.5} />
<Input
placeholder="Search logs..."
value={searchQuery}
@@ -223,7 +259,7 @@ export function GlobalObservabilityView() {
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-sm">
<Filter className="w-3.5 h-3.5 mr-2" />
<Filter className="w-3.5 h-3.5 mr-2" strokeWidth={1.5} />
Stacks ({selectedStacks.length === 0 ? 'All' : selectedStacks.length})
</Button>
</DropdownMenuTrigger>
@@ -254,6 +290,18 @@ export function GlobalObservabilityView() {
</SelectContent>
</Select>
<Select value={levelFilter} onValueChange={(val) => setLevelFilter(val as 'ALL' | 'ERROR' | 'WARN' | 'INFO')}>
<SelectTrigger className="w-[100px] h-8 text-sm">
<SelectValue placeholder="Level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Levels</SelectItem>
<SelectItem value="ERROR">ERROR</SelectItem>
<SelectItem value="WARN">WARN</SelectItem>
<SelectItem value="INFO">INFO</SelectItem>
</SelectContent>
</Select>
<div className="flex-1" />
{devMode && (
@@ -263,42 +311,51 @@ export function GlobalObservabilityView() {
)}
<Button variant="outline" size="sm" onClick={handleClearLogs} className="h-8 text-sm px-2">
<Trash2 className="w-3.5 h-3.5" />
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
<Button variant="outline" size="sm" onClick={handleDownload} disabled={filteredLogs.length === 0} className="h-8 text-sm px-2">
<Download className="w-3.5 h-3.5" />
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
<div className="flex-1 min-h-0 overflow-auto p-4 relative bg-background" onScroll={handleScroll}>
{loading && logs.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
</div>
)}
{filteredLogs.length > 0 ? (
<>
{filteredLogs.length > MAX_DISPLAY_ROWS && (
<div className="text-muted-foreground italic text-xs text-center mb-3 py-1 border-b border-border">
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
</div>
)}
{filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => (
<div key={log._id} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-accent/50 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
<span className="text-muted-foreground mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
<span className="text-info font-semibold mr-2">[{log.containerName}]</span>
<span className={`mr-2 font-medium ${log.level === 'ERROR' ? 'text-destructive' : log.level === 'WARN' ? 'text-warning' : 'text-success'}`}>{log.level}:</span>
<span className={log.source === 'STDERR' ? 'text-destructive/80' : 'text-foreground/80'}>{log.message}</span>
</div>
))}
<div ref={bottomRef} />
</>
) : (
<div className="text-muted-foreground italic p-4 text-center mt-10">
{logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
</div>
)}
</div>
{fetchError && (
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b border-border bg-destructive/5 text-destructive text-xs">
<AlertCircle className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
Failed to fetch logs. Retrying...
</div>
)}
<ScrollArea type="hover" className="flex-1 min-h-0" viewportRef={viewportRef}>
<div className="p-4 relative">
{loading && logs.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
</div>
)}
{filteredLogs.length > 0 ? (
<>
{filteredLogs.length > MAX_DISPLAY_ROWS && (
<div className="text-muted-foreground italic text-xs text-center mb-3 py-1 border-b border-border">
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
</div>
)}
{filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => (
<div key={log._id} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-accent/50 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
<span className="text-muted-foreground mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
<span className="text-info font-semibold mr-2">[{log.containerName}]</span>
<span className={`mr-2 font-medium ${log.level === 'ERROR' ? 'text-destructive' : log.level === 'WARN' ? 'text-warning' : 'text-success'}`}>{log.level}:</span>
<span className={log.source === 'STDERR' ? 'text-destructive/80' : 'text-foreground/80'}>{log.message}</span>
</div>
))}
<div ref={bottomRef} />
</>
) : (
<div className="text-muted-foreground italic p-4 text-center mt-10">
{logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
</div>
)}
</div>
</ScrollArea>
</div>
);
}
+11 -3
View File
@@ -11,6 +11,8 @@ import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import type { SenchoSettingsChangedDetail } from '@/lib/events';
import {
Shield, Activity, Bell, Code, Server, Package,
Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, GitBranch,
@@ -185,14 +187,20 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
};
const saveDeveloperSettings = async () => {
const ok = await patchSettings({
const payload = {
developer_mode: settings.developer_mode,
global_logs_refresh: settings.global_logs_refresh,
metrics_retention_hours: settings.metrics_retention_hours,
log_retention_days: settings.log_retention_days,
audit_retention_days: settings.audit_retention_days,
}, setIsSavingDeveloper, true);
if (ok) toast.success('Developer settings saved.');
};
const ok = await patchSettings(payload, setIsSavingDeveloper, true);
if (ok) {
toast.success('Developer settings saved.');
window.dispatchEvent(new CustomEvent<SenchoSettingsChangedDetail>(SENCHO_SETTINGS_CHANGED, {
detail: { changedKeys: Object.keys(payload) },
}));
}
};
const handlePasswordChange = async () => {
+6
View File
@@ -6,3 +6,9 @@ export interface SenchoOpenLogsDetail {
containerId: string;
containerName: string;
}
export const SENCHO_SETTINGS_CHANGED = 'sencho-settings-changed';
export interface SenchoSettingsChangedDetail {
changedKeys: string[];
}