Merge branch 'develop' into feature/app-store-categories

This commit is contained in:
Anso
2026-03-20 15:51:28 -04:00
committed by GitHub
5 changed files with 102 additions and 43 deletions
+55 -31
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import Editor from '@monaco-editor/react';
import TerminalComponent from './Terminal';
import ErrorBoundary from './ErrorBoundary';
@@ -67,6 +67,13 @@ export default function EditorLayout() {
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
const [containers, setContainers] = useState<ContainerInfo[]>([]);
const [containerStats, setContainerStats] = useState<Record<string, { cpu: string, ram: string, net: string, lastRx?: number, lastTx?: number }>>({});
// Incoming WebSocket stats are written here first (no re-render), then flushed
// to React state in one batched update every 1.5 s.
const pendingStatsRef = useRef<Record<string, { cpu: string; ram: string; net: string; lastRx: number; lastTx: number }>>({});
// Raw rx/tx byte totals used for rate calculation. Never cleared on flush so
// the delta is always computed against the most recent known value, avoiding
// the stale-closure bug that occurs when reading containerStats directly.
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -221,6 +228,7 @@ export default function EditorLayout() {
useEffect(() => {
const wsMap: Record<string, WebSocket> = {};
(containers || []).forEach(container => {
if (!container?.Id) return;
try {
@@ -238,6 +246,7 @@ export default function EditorLayout() {
const data = JSON.parse(event.data);
// Skip initial empty chunks where stats fields are missing
if (!data.cpu_stats?.cpu_usage || !data.precpu_stats?.cpu_usage || !data.memory_stats?.usage) return;
const cpuDelta = data.cpu_stats.cpu_usage.total_usage - data.precpu_stats.cpu_usage.total_usage;
const systemDelta = (data.cpu_stats.system_cpu_usage || 0) - (data.precpu_stats.system_cpu_usage || 0);
const onlineCpus = data.cpu_stats.online_cpus || 1;
@@ -253,31 +262,23 @@ export default function EditorLayout() {
});
}
setContainerStats(prev => {
const prevStat = prev[container.Id];
// Calculate rate if we have a previous value
const rxRate = prevStat?.lastRx !== undefined ? Math.max(0, currentRx - prevStat.lastRx) : 0;
const txRate = prevStat?.lastTx !== undefined ? Math.max(0, currentTx - prevStat.lastTx) : 0;
// Rate is derived from rawBytesRef which is never cleared on flush,
// so the delta is always accurate — no stale-closure risk.
const prevRaw = rawBytesRef.current[container.Id];
const rxRate = prevRaw ? Math.max(0, currentRx - prevRaw.lastRx) : 0;
const txRate = prevRaw ? Math.max(0, currentTx - prevRaw.lastTx) : 0;
rawBytesRef.current[container.Id] = { lastRx: currentRx, lastTx: currentTx };
const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`;
const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`;
// Check if values actually changed to prevent infinite re-renders
const newCpu = cpuPercent + '%';
if (prevStat && prevStat.cpu === newCpu && prevStat.ram === ramUsage && prevStat.lastRx === currentRx && prevStat.lastTx === currentTx) {
return prev;
}
return {
...prev,
[container.Id]: {
cpu: newCpu,
ram: ramUsage,
net: netIO,
lastRx: currentRx,
lastTx: currentTx
}
};
});
// Write into the buffer ref only — zero re-render cost.
pendingStatsRef.current[container.Id] = {
cpu: cpuPercent + '%',
ram: ramUsage,
net: netIO,
lastRx: currentRx,
lastTx: currentTx,
};
} catch {
// Ignore parse errors
}
@@ -286,16 +287,39 @@ export default function EditorLayout() {
// Ignore WebSocket errors
}
});
return () => {
Object.values(wsMap).forEach(ws => {
try {
ws.close();
} catch {
// Ignore close errors
// Flush buffered stats into React state once every 1.5 s.
// Snapshot + clear the buffer BEFORE calling setState so the updater
// function remains pure (no side-effects inside it).
const flushInterval = setInterval(() => {
const pending = pendingStatsRef.current;
if (Object.keys(pending).length === 0) return;
pendingStatsRef.current = {};
setContainerStats(prev => {
let hasChanges = false;
const next = { ...prev };
for (const [id, newStats] of Object.entries(pending)) {
const old = prev[id];
if (!old || old.cpu !== newStats.cpu || old.ram !== newStats.ram || old.net !== newStats.net) {
next[id] = newStats;
hasChanges = true;
}
}
return hasChanges ? next : prev;
});
}, 1500);
return () => {
clearInterval(flushInterval);
// Discard buffered stats for the old stack so stale entries don't
// briefly appear when a new stack is selected.
pendingStatsRef.current = {};
Object.values(wsMap).forEach(ws => {
try { ws.close(); } catch { /* ignore */ }
});
};
}, [containers]);
}, [containers]); // eslint-disable-line react-hooks/exhaustive-deps
const loadFile = async (filename: string) => {
if (!filename) return;
@@ -7,6 +7,12 @@ import { RefreshCw, Download, Trash2, Search, Filter } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
// Max entries held in React state. Bounds SSE-mode memory growth.
const MAX_LOG_ENTRIES = 2000;
// Max rows rendered as DOM nodes at once. Prevents the renderer from
// creating thousands of DOM nodes that OOM the browser on RAM-constrained hosts.
const MAX_DISPLAY_ROWS = 300;
interface LogEntry {
stackName: string;
@@ -15,6 +21,9 @@ interface LogEntry {
level: string;
message: string;
timestampMs: number;
// Assigned client-side at ingestion. Gives React a stable, collision-free
// key so the slice window can shift without touching existing DOM nodes.
_id: number;
}
export function GlobalObservabilityView() {
@@ -37,6 +46,9 @@ export function GlobalObservabilityView() {
// SSE throttle buffer
const bufferRef = useRef<LogEntry[]>([]);
// Monotonic counter for stable React keys. Incremented once per log entry
// at ingestion so duplicate-content lines never share a key.
const logIdRef = useRef(0);
// Fetch settings on mount
useEffect(() => {
@@ -81,6 +93,7 @@ export function GlobalObservabilityView() {
eventSource.onmessage = (event) => {
try {
const entry: LogEntry = JSON.parse(event.data);
entry._id = ++logIdRef.current;
bufferRef.current.push(entry);
} catch (e) { /* ignore parse errors */ }
};
@@ -96,7 +109,7 @@ export function GlobalObservabilityView() {
setLogs(prev => {
const merged = [...prev, ...batch];
merged.sort((a, b) => a.timestampMs - b.timestampMs);
return merged.slice(-10000);
return merged.slice(-MAX_LOG_ENTRIES);
});
}
}, 500);
@@ -115,7 +128,11 @@ export function GlobalObservabilityView() {
try {
const logsRes = await apiFetch('/logs/global');
if (logsRes.ok) {
setLogs(await logsRes.json());
const data: LogEntry[] = await logsRes.json();
// Stamp each entry with a monotonic _id at ingestion so React
// has a stable, collision-free key for every log line.
data.forEach(entry => { entry._id = ++logIdRef.current; });
setLogs(data);
}
} catch (error) {
console.error('Failed to fetch global logs:', error);
@@ -156,8 +173,10 @@ export function GlobalObservabilityView() {
}, [logs, selectedStacks, streamFilter, searchQuery, clearedAt]);
useEffect(() => {
if (isAutoScrollEnabled) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
if (isAutoScrollEnabled && bottomRef.current) {
// Use instant scroll to avoid stacking smooth-scroll animations on every
// 5-second poll cycle, which wastes layout work and renderer memory.
bottomRef.current.scrollIntoView({ behavior: 'instant' });
}
}, [filteredLogs, isAutoScrollEnabled]);
@@ -258,8 +277,13 @@ export function GlobalObservabilityView() {
<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">
{filteredLogs.length > MAX_DISPLAY_ROWS && (
<div className="text-gray-600 italic text-xs text-center mb-3 py-1 border-b border-gray-800">
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-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>
+10 -4
View File
@@ -174,23 +174,29 @@ export default function HomeDashboard() {
method: 'POST',
body: JSON.stringify({ stackName }),
});
if (!createResponse.ok) throw new Error('Failed to create stack');
if (!createResponse.ok) {
const err = await createResponse.json().catch(() => ({}));
throw new Error(err.error || 'Failed to create stack');
}
// Save the converted YAML content
const saveResponse = await apiFetch(`/stacks/${stackName}`, {
method: 'PUT',
body: JSON.stringify({ content: convertedYaml }),
});
if (!saveResponse.ok) throw new Error('Failed to save stack content');
if (!saveResponse.ok) {
const err = await saveResponse.json().catch(() => ({}));
throw new Error(err.error || 'Failed to save stack content');
}
setCreateDialogOpen(false);
setNewStackName('');
setConvertedYaml('');
setDockerRunInput('');
window.location.reload(); // Refresh to show new stack
} catch (error) {
} catch (error: any) {
console.error('Failed to create stack:', error);
toast.error('Failed to create stack');
toast.error(error?.message || error?.error || 'Failed to create stack');
}
};