mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
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:
@@ -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,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,3 +6,9 @@ export interface SenchoOpenLogsDetail {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
}
|
||||
|
||||
export const SENCHO_SETTINGS_CHANGED = 'sencho-settings-changed';
|
||||
|
||||
export interface SenchoSettingsChangedDetail {
|
||||
changedKeys: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user