mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
04c770c198
Task 1 - Network layer fixes: - AppStoreView: replace raw fetch() with apiFetch() on /templates and /templates/deploy so x-node-id header is injected for proxy routing - GlobalObservabilityView: replace raw fetch() with apiFetch() on /stacks and /logs/global - HostConsole: append ?nodeId= to WebSocket URL so the upgrade handler correctly routes the PTY session to the active remote node Task 2 - Scoped context two-tier UX (Option A / Portainer-style): - EditorLayout: add a context pill in the top header bar showing the active node name (pulsing blue for remote, green for local) - HostConsole: header now reads "Host Console - [Node Name]" - ResourcesView: title now reads "Resources Hub - [Node Name]" for remote - GlobalObservabilityView: floating node badge in top-left corner for remote - AppStoreView: deploy sheet shows "Deploying to: [Node Name]" badge - SettingsModal: remote nodes only see System Limits + Developer tabs; global-only tabs (Account, Appearance, Notifications, Nodes) are hidden; header subtitle shows remote node name
280 lines
12 KiB
TypeScript
280 lines
12 KiB
TypeScript
import { useEffect, useState, useMemo, useRef, useCallback } 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 { apiFetch } from '@/lib/api';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
|
|
|
|
interface LogEntry {
|
|
stackName: string;
|
|
containerName: string;
|
|
source: string;
|
|
level: string;
|
|
message: string;
|
|
timestampMs: number;
|
|
}
|
|
|
|
export function GlobalObservabilityView() {
|
|
const { activeNode } = useNodes();
|
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
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[]>([]);
|
|
const [streamFilter, setStreamFilter] = useState<'ALL' | 'STDOUT' | 'STDERR'>('ALL');
|
|
const [clearedAt, setClearedAt] = useState<number>(0);
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true);
|
|
|
|
// SSE throttle buffer
|
|
const bufferRef = useRef<LogEntry[]>([]);
|
|
|
|
// Fetch settings on mount
|
|
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);
|
|
}
|
|
};
|
|
fetchSettings();
|
|
}, []);
|
|
|
|
// Fetch definitive stack list from the filesystem, independent of log data
|
|
useEffect(() => {
|
|
const fetchStacks = async () => {
|
|
try {
|
|
const res = await apiFetch('/stacks');
|
|
if (res.ok) {
|
|
const stacks: string[] = await res.json();
|
|
setAllStacks(stacks.sort());
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch stacks:', err);
|
|
}
|
|
};
|
|
fetchStacks();
|
|
}, []);
|
|
|
|
// Data fetching: Polling (standard) vs SSE (dev mode)
|
|
useEffect(() => {
|
|
if (devMode) {
|
|
// SSE mode
|
|
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
|
const eventSource = new EventSource(`/api/logs/global/stream?nodeId=${activeNodeId}`);
|
|
|
|
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];
|
|
merged.sort((a, b) => a.timestampMs - b.timestampMs);
|
|
return merged.slice(-10000);
|
|
});
|
|
}
|
|
}, 500);
|
|
|
|
setLoading(false);
|
|
|
|
return () => {
|
|
eventSource.close();
|
|
clearInterval(flushInterval);
|
|
bufferRef.current = [];
|
|
};
|
|
} else {
|
|
// Standard polling mode
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const logsRes = await apiFetch('/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]
|
|
);
|
|
};
|
|
|
|
const handleClearLogs = () => {
|
|
setClearedAt(Date.now());
|
|
};
|
|
|
|
const filteredLogs = useMemo(() => {
|
|
return logs.filter(log => {
|
|
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 (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
return log.message.toLowerCase().includes(query) ||
|
|
log.containerName.toLowerCase().includes(query) ||
|
|
log.stackName.toLowerCase().includes(query);
|
|
}
|
|
return true;
|
|
});
|
|
}, [logs, selectedStacks, streamFilter, searchQuery, clearedAt]);
|
|
|
|
useEffect(() => {
|
|
if (isAutoScrollEnabled) {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
}, [filteredLogs, isAutoScrollEnabled]);
|
|
|
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
const target = e.currentTarget;
|
|
const isAtBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 50;
|
|
setIsAutoScrollEnabled(isAtBottom);
|
|
}, []);
|
|
|
|
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 url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `sencho-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full w-full relative group bg-[#0A0A0A] text-gray-300">
|
|
{/* Node Context Indicator */}
|
|
{activeNode?.type === 'remote' && (
|
|
<div className="absolute top-2 left-4 z-10 flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md px-2.5 py-1 text-xs text-muted-foreground">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
|
{activeNode.name}
|
|
</div>
|
|
)}
|
|
{/* Floating Action Bar */}
|
|
<div className="absolute top-2 right-6 z-10 flex gap-2 transition-opacity duration-200 opacity-0 group-hover:opacity-100 focus-within:opacity-100 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md p-1 pr-1">
|
|
<div className="relative flex items-center">
|
|
<Search className="absolute left-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search logs..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-8 h-8 text-sm w-48 bg-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" className="h-8 text-sm">
|
|
<Filter className="w-3.5 h-3.5 mr-2" />
|
|
Stacks ({selectedStacks.length === 0 ? 'All' : selectedStacks.length})
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
{allStacks.map(stack => (
|
|
<DropdownMenuCheckboxItem
|
|
key={stack}
|
|
checked={selectedStacks.includes(stack)}
|
|
onCheckedChange={() => handleStackToggle(stack)}
|
|
>
|
|
{stack}
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
{allStacks.length === 0 && (
|
|
<div className="px-2 py-1.5 text-sm text-muted-foreground">No stacks found</div>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Select value={streamFilter} onValueChange={(val: any) => setStreamFilter(val)}>
|
|
<SelectTrigger className="w-[110px] h-8 text-sm">
|
|
<SelectValue placeholder="Stream" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="ALL">All Streams</SelectItem>
|
|
<SelectItem value="STDOUT">STDOUT</SelectItem>
|
|
<SelectItem value="STDERR">STDERR</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button variant="outline" size="sm" onClick={handleClearLogs} className="h-8 text-sm px-2">
|
|
<Trash2 className="w-3.5 h-3.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" />
|
|
</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 && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-20">
|
|
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent" onScroll={handleScroll}>
|
|
{filteredLogs.length > 0 ? (
|
|
<>
|
|
{filteredLogs.map((log, idx) => (
|
|
<div key={idx} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-white/5 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
|
|
<span className="text-gray-500 mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
|
|
<span className="text-blue-400 font-semibold mr-2">[{log.containerName}]</span>
|
|
<span className={`mr-2 font-bold ${log.level === 'ERROR' ? 'text-red-500' : log.level === 'WARN' ? 'text-yellow-500' : 'text-green-500'}`}>{log.level}:</span>
|
|
<span className={log.source === 'STDERR' ? 'text-red-300' : 'text-gray-300'}>{log.message}</span>
|
|
</div>
|
|
))}
|
|
<div ref={bottomRef} />
|
|
</>
|
|
) : (
|
|
<div className="text-gray-500 italic p-4 text-center mt-10">
|
|
{logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|