feat: implement enterprise sse global logs and developer mode

This commit is contained in:
SaelixCode
2026-03-09 14:02:10 -04:00
parent 7336ea896e
commit 448a64a10d
5 changed files with 236 additions and 19 deletions
+3
View File
@@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- **Added:** Developer Mode setting to enable true Real-Time (SSE) global log streaming and infinite scroll.
- **Added:** Configurable polling rates for standard global logs monitoring.
- **Added:** React Throttle Buffer to prevent UI freezing during heavy real-time log ingestion.
- **Fixed:** Global Logs aggressive auto-scrolling preventing users from reading log history.
- **Fixed:** Quiet stacks missing from the Global Logs filter dropdown by fetching the definitive stack list independently.
- **Fixed:** Global logs misclassifying INFO messages as errors due to naive string matching.
+82
View File
@@ -852,6 +852,88 @@ app.get('/api/logs/global', async (req: Request, res: Response) => {
}
});
app.get('/api/logs/global/stream', async (req: Request, res: Response) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const dockerController = DockerController.getInstance();
const streams: NodeJS.ReadableStream[] = [];
try {
const containers = await dockerController.getRunningContainers();
await Promise.all(containers.map(async (c) => {
const stackName = c.Labels?.['com.docker.compose.project'] || 'system';
let rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12);
let containerName = rawName;
if (rawName.startsWith(`${stackName}-`)) containerName = rawName.replace(`${stackName}-`, '').replace(/-1$/, '');
else if (rawName.startsWith(`${stackName}_`)) containerName = rawName.replace(`${stackName}_`, '').replace(/_1$/, '');
try {
const container = dockerController.getDocker().getContainer(c.Id);
const inspect = await container.inspect();
const isTty = inspect.Config.Tty;
// Dev mode gets a larger tail
const stream = await container.logs({ follow: true, stdout: true, stderr: true, tail: 500, timestamps: true });
streams.push(stream);
const processLine = (line: string, source: string) => {
if (!line.trim()) return;
const timeMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)/);
let cleanMessage = line;
let timestampMs = Date.now();
if (timeMatch) {
timestampMs = new Date(timeMatch[1]).getTime();
cleanMessage = timeMatch[2];
}
let level = source === 'STDERR' ? 'ERROR' : 'INFO';
if (/\[\s*(info|inf|debug|dbg)\s*\]/i.test(cleanMessage) || /^(info|debug)\b/i.test(cleanMessage)) level = 'INFO';
else if (/\b(warn|warning)\b/i.test(cleanMessage)) level = 'WARN';
else if (/\b(error|err|fatal|exception)\b/i.test(cleanMessage)) level = 'ERROR';
res.write(`data: ${JSON.stringify({ stackName, containerName, source, level, message: cleanMessage, timestampMs })}\n\n`);
};
stream.on('data', (chunk: Buffer) => {
if (isTty) {
const payload = chunk.toString('utf-8').replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, "");
payload.split('\n').forEach(line => processLine(line, 'STDOUT'));
} else {
let offset = 0;
while (offset < chunk.length) {
if (offset + 8 > chunk.length) break;
const streamType = chunk[offset];
const length = chunk.readUInt32BE(offset + 4);
offset += 8;
if (offset + length > chunk.length) break;
const payload = chunk.slice(offset, offset + length).toString('utf-8');
offset += length;
payload.split('\n').forEach(line => processLine(line, streamType === 2 ? 'STDERR' : 'STDOUT'));
}
}
});
} catch (err) { /* ignore */ }
}));
// Cleanup when client closes the tab or switches views
req.on('close', () => {
streams.forEach(s => {
try { (s as any).destroy(); } catch (e) { }
});
});
} catch (error) {
res.write(`data: ${JSON.stringify({ level: 'ERROR', message: '[Sencho] Failed to attach global log stream.', timestampMs: Date.now(), stackName: 'system', containerName: 'backend', source: 'STDERR' })}\n\n`);
res.end();
}
});
// Get host system stats
app.get('/api/system/stats', async (req: Request, res: Response) => {
try {
+2
View File
@@ -119,6 +119,8 @@ export class DatabaseService {
stmt.run('host_disk_limit', '90');
stmt.run('global_crash', '1');
stmt.run('docker_janitor_gb', '5');
stmt.run('global_logs_refresh', '5'); // Default 5 seconds
stmt.run('developer_mode', '0'); // Default off
}
private migrateJsonConfig(dataDir: string) {
@@ -4,6 +4,7 @@ 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 { apiFetch } from '@/lib/api';
interface LogEntry {
@@ -20,6 +21,10 @@ export function GlobalObservabilityView() {
const [loading, setLoading] = useState(true);
const [allStacks, setAllStacks] = useState<string[]>([]);
// Settings state
const [devMode, setDevMode] = useState(false);
const [pollRate, setPollRate] = useState(5);
// Filters
const [searchQuery, setSearchQuery] = useState('');
const [selectedStacks, setSelectedStacks] = useState<string[]>([]);
@@ -28,24 +33,24 @@ export function GlobalObservabilityView() {
const bottomRef = useRef<HTMLDivElement>(null);
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true);
const fetchData = async () => {
setLoading(true);
try {
const logsRes = await fetch('/api/logs/global');
if (logsRes.ok) {
setLogs(await logsRes.json());
}
} catch (error) {
console.error('Failed to fetch global logs:', error);
} finally {
setLoading(false);
}
};
// SSE throttle buffer
const bufferRef = useRef<LogEntry[]>([]);
// Fetch settings on mount
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 10000); // Poll every 10s
return () => clearInterval(interval);
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);
}
};
fetchSettings();
}, []);
// Fetch definitive stack list from the filesystem, independent of log data
@@ -64,6 +69,65 @@ export function GlobalObservabilityView() {
fetchStacks();
}, []);
// Data fetching: Polling (standard) vs SSE (dev mode)
useEffect(() => {
if (devMode) {
// SSE mode
const eventSource = new EventSource('/api/logs/global/stream');
eventSource.onmessage = (event) => {
try {
const entry: LogEntry = JSON.parse(event.data);
bufferRef.current.push(entry);
} catch (e) { /* ignore parse errors */ }
};
eventSource.onerror = () => {
// SSE will auto-reconnect, no action needed
};
// 500ms throttle: flush buffer into React state
const flushInterval = setInterval(() => {
if (bufferRef.current.length > 0) {
const batch = bufferRef.current.splice(0);
setLogs(prev => {
const merged = [...prev, ...batch];
// Infinite scroll: no slice limit in dev mode
merged.sort((a, b) => a.timestampMs - b.timestampMs);
return merged;
});
}
}, 500);
setLoading(false);
return () => {
eventSource.close();
clearInterval(flushInterval);
bufferRef.current = [];
};
} else {
// Standard polling mode
const fetchData = async () => {
setLoading(true);
try {
const logsRes = await fetch('/api/logs/global');
if (logsRes.ok) {
setLogs(await logsRes.json());
}
} catch (error) {
console.error('Failed to fetch global logs:', error);
} finally {
setLoading(false);
}
};
fetchData();
const interval = setInterval(fetchData, pollRate * 1000);
return () => clearInterval(interval);
}
}, [devMode, pollRate]);
const handleStackToggle = (stack: string) => {
setSelectedStacks(prev =>
prev.includes(stack) ? prev.filter(s => s !== stack) : [...prev, stack]
@@ -168,6 +232,12 @@ export function GlobalObservabilityView() {
<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" />
</Button>
{devMode && (
<div className="flex items-center px-2 text-xs text-emerald-400 font-mono animate-pulse">
LIVE
</div>
)}
</div>
{loading && logs.length === 0 && (
+63 -3
View File
@@ -11,7 +11,8 @@ import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { Shield, Activity, Bell, Palette, Moon, Sun } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Shield, Activity, Bell, Palette, Moon, Sun, Code } from 'lucide-react';
interface Agent {
type: 'discord' | 'slack' | 'webhook';
@@ -27,7 +28,7 @@ interface SettingsModalProps {
}
export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance'>('account');
const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer'>('account');
// Auth State
const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
@@ -45,7 +46,9 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
host_ram_limit: '90',
host_disk_limit: '90',
global_crash: '1',
docker_janitor_gb: '5'
docker_janitor_gb: '5',
global_logs_refresh: '5',
developer_mode: '0'
});
const [isLoading, setIsLoading] = useState(false);
@@ -255,6 +258,14 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
<Palette className="w-4 h-4 mr-2" />
Appearance
</Button>
<Button
variant={activeSection === 'developer' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('developer')}
>
<Code className="w-4 h-4 mr-2" />
Developer
</Button>
</nav>
</div>
@@ -418,6 +429,55 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
</div>
)}
{activeSection === 'developer' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold tracking-tight">Developer</h3>
<p className="text-sm text-muted-foreground">Power user settings for real-time observability and extended diagnostics.</p>
</div>
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics & Extended Logs</p>
</div>
<Switch
id="developer_mode"
checked={settings.developer_mode === '1'}
onCheckedChange={(c) => handleSettingChange('developer_mode', c ? '1' : '0')}
/>
</div>
<div className="space-y-2 pt-4 border-t border-border">
<Label className={`text-base ${settings.developer_mode === '1' ? 'text-muted-foreground' : ''}`}>Standard Log Polling Rate</Label>
<Select
value={settings.global_logs_refresh}
onValueChange={(val) => handleSettingChange('global_logs_refresh', val)}
disabled={settings.developer_mode === '1'}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="Select rate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 second</SelectItem>
<SelectItem value="3">3 seconds</SelectItem>
<SelectItem value="5">5 seconds</SelectItem>
<SelectItem value="10">10 seconds</SelectItem>
</SelectContent>
</Select>
{settings.developer_mode === '1' && (
<p className="text-xs text-amber-500">SSE streaming is active polling rate is overridden by real-time streaming.</p>
)}
</div>
</div>
<div className="flex justify-end mt-4">
<Button onClick={saveSettings} disabled={isLoading}>Save Developer Settings</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>