mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
Merge branch 'develop' into feature/app-store-categories
This commit is contained in:
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Added:** App Store category filter — LSIO templates are now grouped into categories (Automation, Downloaders, Media, Monitoring, Networking, Security, Development, Productivity, Utilities, Other) via a static lookup map in `TemplateService`. A horizontal pill bar below the search field lets users filter by category; clicking a category badge on a template card also activates the filter. Category badges highlight when their category is the active filter. App count updates reactively.
|
||||
- **Added:** App Store registry settings — new "App Store" section in Settings lets users supply a custom Portainer v2 JSON template URL to override the default LinuxServer.io registry. "Save & Refresh" persists the URL and immediately busts the 24-hour template cache via `POST /api/templates/refresh-cache`. Portainer v2 registries pass their native `categories` field through unchanged.
|
||||
- **Added:** `source` field on the `Template` interface — set to `'linuxserver'` for LSIO apps and `'custom'` for Portainer v2 registries, enabling future per-source filtering.
|
||||
- **Fixed:** Container stats WebSocket flooding React with up to 20+ `setState` calls per second in `EditorLayout` — each container's `onmessage` handler called `setContainerStats` independently, causing React to schedule a separate reconciliation pass per container per second. Replaced with a ref-buffer + 1.5 s flush interval pattern: incoming stats are written to `pendingStatsRef` (no re-render cost), snapshotted and cleared before a single batched `setContainerStats` call every 1.5 s. A separate `rawBytesRef` (never cleared) tracks raw rx/tx bytes for accurate rate calculation, avoiding the stale-closure bug that would have produced 0 B/s net I/O after every flush cycle. Buffer is also cleared on cleanup so stale entries from the previous stack don't flash in on stack switch.
|
||||
- **Fixed:** Browser Out of Memory crash in `GlobalObservabilityView` (Logs view) — the component rendered all log entries (up to 1,859+) as real DOM nodes with no virtualization, causing ~9,600 DOM nodes and ~25 MB of GC pressure every 5-second poll cycle. On RAM-constrained hosts (host system was at 97% usage) the browser renderer process OOMed within minutes. Fixed by capping DOM rendering to the last 300 entries (`MAX_DISPLAY_ROWS`), reducing the in-memory SSE log cap from 10,000 to 2,000 entries (`MAX_LOG_ENTRIES`), switching auto-scroll from `behavior: 'smooth'` (stacked layout animations) to `behavior: 'instant'`, and reducing the backend polling response limit from 2,000 to 500 lines. Also replaced `key={idx}` (array index) with a monotonic `_id` counter stamped at ingestion time so the slice window shifting no longer forces O(n) DOM mutations per new log line — React now only reconciles the one entry that actually changed.
|
||||
- **Fixed:** `HomeDashboard` create-stack error handling — HTTP error responses were thrown as hardcoded strings, discarding the server's actual error message (e.g. "Stack already exists", "Invalid stack name"). Now reads the JSON error body before throwing, and uses the defensive `error?.message || error?.error || fallback` toast pattern.
|
||||
- **Fixed:** LogViewer (container SSE log stream) returning 404 on remote nodes — the `?nodeId=` query param was forwarded by `remoteNodeProxy` to the remote server, where `nodeContextMiddleware` rejected it with `Node X not found` because the gateway's node IDs don't exist on the remote instance. Fixed by stripping `nodeId` from `proxyReq.path` in `onProxyReq`, mirroring the existing `x-node-id` header removal.
|
||||
- **Fixed:** Terminal logs and container stats WebSockets failing with "HTTP Authentication failed" on remote nodes — the WebSocket upgrade proxy was forwarding the browser's `cookie` header to the remote Sencho instance. The remote's `authMiddleware` picks `cookieToken` before `bearerToken`, and the cookie (signed with the local JWT secret) fails verification on the remote, returning 401. Fixed by deleting the `cookie` header before `wsProxyServer.ws()`, mirroring the `proxyReq.removeHeader('cookie')` already present in the HTTP proxy. Also strips the gateway's `nodeId` query param from the forwarded URL so the remote defaults cleanly to its own local node.
|
||||
- **Fixed:** `streamStats` Docker stats stream leaking after WebSocket client disconnect — the Docker daemon stream was never destroyed when the WS closed, causing orphaned streams to poll the daemon indefinitely. Fixed by adding a `ws.on('close')` handler that calls `stats.destroy()`. Also guards all `ws.send()` calls with a `readyState === OPEN` check to prevent silent errors on a closed socket.
|
||||
|
||||
@@ -1072,9 +1072,11 @@ app.get('/api/logs/global', async (req: Request, res: Response) => {
|
||||
}
|
||||
}));
|
||||
|
||||
// Sort globally by timestamp ascending (newest bottom) and limit to 2000 lines
|
||||
// Sort globally by timestamp ascending (newest bottom).
|
||||
// Limit to 500 lines — the client renders at most 300 rows at once, so
|
||||
// sending 2000 lines was wasting bandwidth and inflating JSON parse time.
|
||||
allLogs.sort((a, b) => a.timestampMs - b.timestampMs);
|
||||
res.json(allLogs.slice(-2000));
|
||||
res.json(allLogs.slice(-500));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch global logs' });
|
||||
}
|
||||
|
||||
@@ -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