mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
Merge branch 'develop' into feature/app-store-categories
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user