mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
refactor(frontend): extract useTheme, useNotifications, useContainerStats hooks from EditorLayout (#903)
* refactor(frontend): add useViewNavigationState hook with tests * refactor(frontend): wire useViewNavigationState into EditorLayout * test(frontend): add skipper tier and handleOpenSettings no-arg coverage * refactor(frontend): extract useTheme hook from EditorLayout * refactor(frontend): extract useNotifications hook from EditorLayout * refactor(frontend): extract useContainerStats hook, remove containerStats from useEditorViewState * refactor(frontend): wire useTheme, useNotifications, useContainerStats into EditorLayout Removes all extracted effect clusters from EditorLayout.tsx and replaces them with calls to the three new focused hooks. Extracted code removed: - theme useState + 2 effects (system dark-mode listener, DOM class sync) - fetchNotifications, fetchNotificationsRef, markAllRead, deleteNotification, clearAllNotifications, and 5 notification effects (local WS, nodes refetch, remote per-node WS, remote cleanup, 60s safety-net poll) - container stats useEffect with 1.5s flush interval - formatBytes utility (moved to useContainerStats) - fetchForNode import (no longer used at this level) EditorLayout now at 17 useState / 4 useEffect, both under the <20 / <10 acceptance criteria required before the B4-7 shell PR.
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'auto';
|
||||
import type { NotificationItem } from './dashboard/types';
|
||||
import BashExecModal from './BashExecModal';
|
||||
import LazyBoundary from './LazyBoundary';
|
||||
@@ -9,7 +8,7 @@ import { Plus } from 'lucide-react';
|
||||
import { type Label as StackLabel, type LabelColor } from './label-types';
|
||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||
import { NotificationPanel } from './NotificationPanel';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { PolicyBlockDialog, type PolicyBlockPayload } from './stack/PolicyBlockDialog';
|
||||
import { TopBar } from './TopBar';
|
||||
@@ -21,6 +20,9 @@ import { EditorView, type StackAction } from './EditorLayout/EditorView';
|
||||
import { useEditorViewState } from './EditorLayout/hooks/useEditorViewState';
|
||||
import { useStackListState } from './EditorLayout/hooks/useStackListState';
|
||||
import { useViewNavigationState } from './EditorLayout/hooks/useViewNavigationState';
|
||||
import { useTheme } from './EditorLayout/hooks/useTheme';
|
||||
import { useNotifications } from './EditorLayout/hooks/useNotifications';
|
||||
import { useContainerStats } from './EditorLayout/hooks/useContainerStats';
|
||||
import { StackAlertSheet } from './StackAlertSheet';
|
||||
import { StackAutoHealSheet } from '@/components/StackAutoHealSheet';
|
||||
import { GitSourcePanel } from './stack/GitSourcePanel';
|
||||
@@ -54,14 +56,6 @@ import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { ComposeDiffPreviewDialog } from '@/components/ComposeDiffPreviewDialog';
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export default function EditorLayout() {
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { isPaid, license } = useLicense();
|
||||
@@ -79,7 +73,6 @@ export default function EditorLayout() {
|
||||
envFiles, setEnvFiles,
|
||||
selectedEnvFile, setSelectedEnvFile,
|
||||
containers, setContainers,
|
||||
containerStats, setContainerStats,
|
||||
activeTab, setActiveTab,
|
||||
logsMode, setLogsMode,
|
||||
gitSourceOpen, setGitSourceOpen,
|
||||
@@ -124,29 +117,6 @@ export default function EditorLayout() {
|
||||
const [policyBlock, setPolicyBlock] = useState<{ stackName: string; payload: PolicyBlockPayload } | null>(null);
|
||||
const [policyBypassing, setPolicyBypassing] = useState(false);
|
||||
const { nodes, activeNode, setActiveNode } = useNodes();
|
||||
// Stable ref so notification callbacks always read the latest nodes list
|
||||
// without needing nodes in their dependency arrays (which would cause loops).
|
||||
const nodesRef = useRef<Node[]>([]);
|
||||
nodesRef.current = nodes;
|
||||
// Tracks cleanup functions for per-remote-node notification WebSocket connections.
|
||||
const remoteNotifWsRef = useRef<Map<number, () => void>>(new Map());
|
||||
// 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;
|
||||
cpuNum: number;
|
||||
memNum: number;
|
||||
netInNum: number;
|
||||
netOutNum: 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 monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||||
const pendingStackLoadRef = useRef<string | null>(null);
|
||||
const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null);
|
||||
@@ -176,15 +146,7 @@ export default function EditorLayout() {
|
||||
|
||||
const loadingAction = selectedFile ? (stackActions[selectedFile] ?? null) : null;
|
||||
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('sencho-theme') as Theme | null;
|
||||
if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved;
|
||||
return 'dark'; // Default to dark mode
|
||||
});
|
||||
const [systemDark, setSystemDark] = useState(() =>
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
);
|
||||
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
|
||||
const { theme, setTheme, isDarkMode } = useTheme();
|
||||
const [diffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
||||
const [diffPreview, setDiffPreview] = useState<{
|
||||
mode: 'save' | 'save-and-deploy';
|
||||
@@ -231,9 +193,19 @@ export default function EditorLayout() {
|
||||
|
||||
const isAdmiral = license?.variant === 'admiral';
|
||||
|
||||
// Notifications state
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [tickerConnected, setTickerConnected] = useState(false);
|
||||
const {
|
||||
notifications,
|
||||
tickerConnected,
|
||||
markAllRead,
|
||||
deleteNotification,
|
||||
clearAllNotifications,
|
||||
} = useNotifications({
|
||||
nodes,
|
||||
onStateInvalidate: scheduleStateInvalidateRefresh,
|
||||
onAutoUpdateChange: fetchAutoUpdateSettings,
|
||||
});
|
||||
|
||||
const containerStats = useContainerStats(containers);
|
||||
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
|
||||
const [alertSheetStack, setAlertSheetStack] = useState('');
|
||||
const [autoHealStackName, setAutoHealStackName] = useState<string | null>(null);
|
||||
@@ -243,20 +215,6 @@ export default function EditorLayout() {
|
||||
setAlertSheetOpen(true);
|
||||
};
|
||||
|
||||
// Listen for system dark mode changes (for 'auto' theme)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Apply dark class and persist theme preference
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', isDarkMode);
|
||||
localStorage.setItem('sencho-theme', theme);
|
||||
}, [isDarkMode, theme]);
|
||||
|
||||
// Force Monaco to re-measure its container after the tab switch DOM settles.
|
||||
// Monaco's internal child is position:static with an explicit pixel height that
|
||||
// creates a circular CSS dependency (Monaco drives card height → grid height → Monaco).
|
||||
@@ -292,173 +250,6 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
// Notification WS push - subscribe to local real-time alerts.
|
||||
// Initial history load is handled by the [nodes] effect below.
|
||||
useEffect(() => {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsBase = `${wsProtocol}//${window.location.host}`;
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let isMounted = true;
|
||||
let retryCount = 0;
|
||||
const MAX_RETRY_DELAY_MS = 30000;
|
||||
|
||||
const connect = () => {
|
||||
if (!isMounted) return;
|
||||
ws = new WebSocket(`${wsBase}/ws/notifications`);
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!isMounted) {
|
||||
// Component unmounted while the handshake was in-flight (React StrictMode double-mount)
|
||||
ws?.close();
|
||||
return;
|
||||
}
|
||||
setTickerConnected(true);
|
||||
retryCount = 0; // Reset backoff on successful connect
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
if (msg.type === 'notification' && msg.payload) {
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
const tagged: NotificationItem = {
|
||||
...(msg.payload as Omit<NotificationItem, 'nodeId' | 'nodeName'>),
|
||||
nodeId: localNode?.id ?? -1,
|
||||
nodeName: localNode?.name ?? 'Local',
|
||||
};
|
||||
setNotifications(prev => [tagged, ...prev].sort((a, b) => b.timestamp - a.timestamp));
|
||||
} else if (msg.type === 'state-invalidate') {
|
||||
// Lightweight signal that a container/stack event happened.
|
||||
// Re-broadcast on the window bus so other hooks (dashboard data,
|
||||
// sidebar, etc.) can refetch on the same trigger without prop
|
||||
// drilling. Refresh stack statuses on this layer too.
|
||||
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: msg }));
|
||||
if (msg.action === 'auto-update-settings-changed') {
|
||||
fetchAutoUpdateSettings();
|
||||
} else {
|
||||
scheduleStateInvalidateRefresh();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[WS notifications] parse error', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setTickerConnected(false);
|
||||
if (!isMounted) return;
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), MAX_RETRY_DELAY_MS);
|
||||
retryCount++;
|
||||
console.debug(`[WS notifications] closed (code=${event.code}), reconnecting in ${delay}ms (attempt ${retryCount})`);
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = (event) => {
|
||||
// onerror always fires before onclose - log it and let onclose handle reconnect
|
||||
console.warn('[WS notifications] error event', event);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
// Only close an already-open connection. If still CONNECTING, let onopen
|
||||
// detect isMounted=false and close then - avoids the browser warning
|
||||
// "WebSocket is closed before the connection is established".
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-fetch all notifications when the nodes list changes (e.g. remote node added/removed).
|
||||
// nodesRef ensures fetchNotifications always reads the latest nodes at call time.
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [nodes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Open / close per-remote-node notification WebSocket connections as the nodes list changes.
|
||||
// Uses remoteNotifWsRef to avoid tearing down existing connections on unrelated node updates.
|
||||
useEffect(() => {
|
||||
const remoteNodes = nodes.filter(n => n.type === 'remote');
|
||||
const currentIds = new Set(remoteNotifWsRef.current.keys());
|
||||
const newIds = new Set(remoteNodes.map(n => n.id));
|
||||
|
||||
// Close connections for nodes that are no longer registered as remote
|
||||
for (const id of currentIds) {
|
||||
if (!newIds.has(id)) {
|
||||
remoteNotifWsRef.current.get(id)?.();
|
||||
remoteNotifWsRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Open connections for newly-added remote nodes
|
||||
for (const rn of remoteNodes) {
|
||||
if (remoteNotifWsRef.current.has(rn.id)) continue;
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let active = true;
|
||||
let retryCount = 0;
|
||||
|
||||
const connect = () => {
|
||||
if (!active) return;
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws/notifications?nodeId=${rn.id}`);
|
||||
|
||||
ws.onopen = () => { if (!active) { ws?.close(); } else { retryCount = 0; } };
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
if (msg.type === 'notification' && msg.payload) {
|
||||
// Read node name from ref so it stays fresh even if the node was renamed
|
||||
const current = nodesRef.current.find(n => n.id === rn.id);
|
||||
setNotifications(prev =>
|
||||
[{ ...msg.payload as Omit<NotificationItem, 'nodeId' | 'nodeName'>, nodeId: rn.id, nodeName: current?.name ?? rn.name }, ...prev]
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
);
|
||||
} else if (msg.type === 'state-invalidate') {
|
||||
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: { ...msg, nodeId: rn.id } }));
|
||||
scheduleStateInvalidateRefresh();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[WS notifications:${rn.name}] parse error`, e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!active) return;
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
|
||||
retryCount++;
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = (e) => console.warn(`[WS notifications:${rn.name}] error`, e);
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
remoteNotifWsRef.current.set(rn.id, () => {
|
||||
active = false;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.close();
|
||||
});
|
||||
}
|
||||
}, [nodes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Cleanup all remote notification WebSocket connections on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const cleanup of remoteNotifWsRef.current.values()) cleanup();
|
||||
remoteNotifWsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-fetch stacks whenever the active node changes (or becomes available on mount).
|
||||
// Also clears any stale editor/container state that belonged to the previous node.
|
||||
useEffect(() => {
|
||||
@@ -487,207 +278,6 @@ export default function EditorLayout() {
|
||||
refreshGitSourcePending();
|
||||
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const currentNodes = nodesRef.current;
|
||||
const localNode = currentNodes.find(n => n.type === 'local');
|
||||
const remoteNodes = currentNodes.filter(n => n.type === 'remote');
|
||||
|
||||
const [localResult, ...remoteNodeResults] = await Promise.allSettled([
|
||||
apiFetch('/notifications', { localOnly: true }),
|
||||
...remoteNodes.map(n => fetchForNode('/notifications', n.id)),
|
||||
]);
|
||||
|
||||
const all: NotificationItem[] = [];
|
||||
|
||||
if (localResult.status === 'fulfilled' && localResult.value.ok) {
|
||||
const data = await localResult.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
|
||||
data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' }));
|
||||
}
|
||||
|
||||
for (let i = 0; i < remoteNodes.length; i++) {
|
||||
const result = remoteNodeResults[i];
|
||||
if (result?.status === 'fulfilled' && result.value.ok) {
|
||||
const data = await result.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
|
||||
const rn = remoteNodes[i];
|
||||
data.forEach(n => all.push({ ...n, nodeId: rn.id, nodeName: rn.name }));
|
||||
}
|
||||
}
|
||||
|
||||
all.sort((a, b) => b.timestamp - a.timestamp);
|
||||
setNotifications(all);
|
||||
} catch (e) {
|
||||
console.error('[Notifications] fetch error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Safety-net poll: reconciles the list every 60s so events missed during a
|
||||
// WebSocket reconnect backoff still appear without a manual refresh. The ref
|
||||
// indirection keeps the interval pinned to the latest closure even though
|
||||
// fetchNotifications is redefined on every render.
|
||||
const fetchNotificationsRef = useRef(fetchNotifications);
|
||||
fetchNotificationsRef.current = fetchNotifications;
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => { fetchNotificationsRef.current(); }, 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
const unreadNodeIds = [...new Set(notifications.filter(n => !n.is_read && n.nodeId != null).map(n => n.nodeId as number))];
|
||||
await Promise.allSettled(unreadNodeIds.map(nodeId =>
|
||||
nodeId === localNode?.id
|
||||
? apiFetch('/notifications/read', { method: 'POST', localOnly: true })
|
||||
: fetchForNode('/notifications/read', nodeId, { method: 'POST' })
|
||||
));
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: 1 })));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; error?: string };
|
||||
toast.error(err?.message || err?.error || 'Failed to mark notifications as read');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNotification = async (notif: NotificationItem) => {
|
||||
try {
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
if (notif.nodeId === localNode?.id) {
|
||||
await apiFetch(`/notifications/${notif.id}`, { method: 'DELETE', localOnly: true });
|
||||
} else if (notif.nodeId != null) {
|
||||
await fetchForNode(`/notifications/${notif.id}`, notif.nodeId, { method: 'DELETE' });
|
||||
}
|
||||
setNotifications(prev => prev.filter(n => !(n.id === notif.id && n.nodeId === notif.nodeId)));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; error?: string };
|
||||
toast.error(err?.message || err?.error || 'Failed to delete notification');
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllNotifications = async () => {
|
||||
try {
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
const uniqueNodeIds = [...new Set(notifications.filter(n => n.nodeId != null).map(n => n.nodeId as number))];
|
||||
await Promise.allSettled(uniqueNodeIds.map(nodeId =>
|
||||
nodeId === localNode?.id
|
||||
? apiFetch('/notifications', { method: 'DELETE', localOnly: true })
|
||||
: fetchForNode('/notifications', nodeId, { method: 'DELETE' })
|
||||
));
|
||||
setNotifications([]);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; error?: string };
|
||||
toast.error(err?.message || err?.error || 'Failed to clear notifications');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const wsMap: Record<string, WebSocket> = {};
|
||||
|
||||
(containers || []).forEach(container => {
|
||||
if (!container?.Id) return;
|
||||
try {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
||||
const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`);
|
||||
wsMap[container.Id] = ws;
|
||||
ws.onopen = () => ws.send(JSON.stringify({
|
||||
action: 'streamStats',
|
||||
containerId: container.Id,
|
||||
nodeId: activeNodeId || undefined
|
||||
}));
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
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;
|
||||
const cpuPercent = systemDelta > 0 ? ((cpuDelta / systemDelta) * onlineCpus * 100).toFixed(2) : '0.00';
|
||||
const ramUsage = (data.memory_stats.usage / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
|
||||
let currentRx = 0;
|
||||
let currentTx = 0;
|
||||
if (data.networks) {
|
||||
Object.values(data.networks as Record<string, { rx_bytes?: number; tx_bytes?: number }>).forEach((net) => {
|
||||
currentRx += net.rx_bytes || 0;
|
||||
currentTx += net.tx_bytes || 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 ↑`;
|
||||
|
||||
// Write into the buffer ref only - zero re-render cost.
|
||||
pendingStatsRef.current[container.Id] = {
|
||||
cpu: cpuPercent + '%',
|
||||
ram: ramUsage,
|
||||
net: netIO,
|
||||
lastRx: currentRx,
|
||||
lastTx: currentTx,
|
||||
cpuNum: parseFloat(cpuPercent) || 0,
|
||||
memNum: data.memory_stats.usage / (1024 * 1024),
|
||||
netInNum: rxRate,
|
||||
netOutNum: txRate,
|
||||
};
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
// Ignore WebSocket 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 => {
|
||||
const next = { ...prev };
|
||||
const HISTORY_CAP = 60;
|
||||
for (const [id, newStats] of Object.entries(pending)) {
|
||||
const prior = prev[id]?.history ?? { cpu: [], mem: [], netIn: [], netOut: [] };
|
||||
const history = {
|
||||
cpu: [...prior.cpu, newStats.cpuNum].slice(-HISTORY_CAP),
|
||||
mem: [...prior.mem, newStats.memNum].slice(-HISTORY_CAP),
|
||||
netIn: [...prior.netIn, newStats.netInNum].slice(-HISTORY_CAP),
|
||||
netOut: [...prior.netOut, newStats.netOutNum].slice(-HISTORY_CAP),
|
||||
};
|
||||
next[id] = {
|
||||
cpu: newStats.cpu,
|
||||
ram: newStats.ram,
|
||||
net: newStats.net,
|
||||
lastRx: newStats.lastRx,
|
||||
lastTx: newStats.lastTx,
|
||||
history,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 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]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Resolve a pending container name (from notification click) to a live
|
||||
// container id once the target stack's container list loads, then dispatch
|
||||
// the logs event. Only consume when the current stack matches the pending
|
||||
|
||||
@@ -32,7 +32,6 @@ describe('useEditorViewState', () => {
|
||||
const { result } = renderHook(() => useEditorViewState());
|
||||
expect(result.current.envFiles).toEqual([]);
|
||||
expect(result.current.containers).toEqual([]);
|
||||
expect(result.current.containerStats).toEqual({});
|
||||
expect(result.current.gitSourcePendingMap).toEqual({});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { useContainerStats } from './useContainerStats';
|
||||
import type { ContainerInfo } from '../EditorView';
|
||||
|
||||
class MockWS {
|
||||
static instances: MockWS[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
readyState = 1;
|
||||
send = vi.fn();
|
||||
close = vi.fn();
|
||||
constructor(_url: string) { MockWS.instances.push(this); }
|
||||
static reset() { MockWS.instances = []; }
|
||||
}
|
||||
|
||||
const makeContainer = (id: string): ContainerInfo =>
|
||||
({ Id: id, Names: [`/${id}`], State: 'running', Status: 'Up 1 minute', Image: 'img' } as ContainerInfo);
|
||||
|
||||
beforeEach(() => {
|
||||
MockWS.reset();
|
||||
vi.stubGlobal('WebSocket', MockWS);
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('localStorage', { getItem: vi.fn(() => ''), setItem: vi.fn(), clear: vi.fn() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useContainerStats', () => {
|
||||
it('returns empty stats for empty containers', () => {
|
||||
const { result } = renderHook(() => useContainerStats([]));
|
||||
expect(result.current).toEqual({});
|
||||
});
|
||||
|
||||
it('opens one WebSocket per container', () => {
|
||||
renderHook(() => useContainerStats([makeContainer('abc'), makeContainer('def')]));
|
||||
expect(MockWS.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('sends streamStats action on WS open', () => {
|
||||
renderHook(() => useContainerStats([makeContainer('abc')]));
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
expect(MockWS.instances[0].send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"action":"streamStats"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('flushes buffered stats into state after 1500ms', () => {
|
||||
const { result } = renderHook(() => useContainerStats([makeContainer('c1')]));
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
|
||||
const statsMsg = {
|
||||
cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 1 },
|
||||
precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 },
|
||||
memory_stats: { usage: 1048576 },
|
||||
networks: { eth0: { rx_bytes: 1024, tx_bytes: 512 } },
|
||||
};
|
||||
act(() => { MockWS.instances[0]?.onmessage?.({ data: JSON.stringify(statsMsg) }); });
|
||||
|
||||
expect(result.current['c1']).toBeUndefined();
|
||||
|
||||
act(() => { vi.advanceTimersByTime(1500); });
|
||||
expect(result.current['c1']).toBeDefined();
|
||||
expect(result.current['c1'].cpu).toContain('%');
|
||||
expect(result.current['c1'].ram).toContain('MB');
|
||||
});
|
||||
|
||||
it('closes all WebSockets when containers change', () => {
|
||||
const { rerender } = renderHook(
|
||||
(containers: ContainerInfo[]) => useContainerStats(containers),
|
||||
{ initialProps: [makeContainer('old')] },
|
||||
);
|
||||
const firstWs = MockWS.instances[0];
|
||||
rerender([makeContainer('new')]);
|
||||
expect(firstWs.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ContainerInfo, ContainerStatsEntry } from '../EditorView';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function useContainerStats(containers: ContainerInfo[]): Record<string, ContainerStatsEntry> {
|
||||
const [containerStats, setContainerStats] = useState<Record<string, ContainerStatsEntry>>({});
|
||||
|
||||
const pendingStatsRef = useRef<Record<string, {
|
||||
cpu: string; ram: string; net: string;
|
||||
lastRx: number; lastTx: number;
|
||||
cpuNum: number; memNum: number; netInNum: number; netOutNum: number;
|
||||
}>>({});
|
||||
|
||||
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const wsMap: Record<string, WebSocket> = {};
|
||||
|
||||
(containers || []).forEach(container => {
|
||||
if (!container?.Id) return;
|
||||
try {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
||||
const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`);
|
||||
wsMap[container.Id] = ws;
|
||||
|
||||
ws.onopen = () => ws.send(JSON.stringify({
|
||||
action: 'streamStats',
|
||||
containerId: container.Id,
|
||||
nodeId: activeNodeId || undefined,
|
||||
}));
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
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;
|
||||
const cpuPercent = systemDelta > 0 ? ((cpuDelta / systemDelta) * onlineCpus * 100).toFixed(2) : '0.00';
|
||||
const ramUsage = (data.memory_stats.usage / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
|
||||
let currentRx = 0;
|
||||
let currentTx = 0;
|
||||
if (data.networks) {
|
||||
Object.values(data.networks as Record<string, { rx_bytes?: number; tx_bytes?: number }>).forEach(net => {
|
||||
currentRx += net.rx_bytes || 0;
|
||||
currentTx += net.tx_bytes || 0;
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
pendingStatsRef.current[container.Id] = {
|
||||
cpu: cpuPercent + '%',
|
||||
ram: ramUsage,
|
||||
net: `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`,
|
||||
lastRx: currentRx,
|
||||
lastTx: currentTx,
|
||||
cpuNum: parseFloat(cpuPercent) || 0,
|
||||
memNum: data.memory_stats.usage / (1024 * 1024),
|
||||
netInNum: rxRate,
|
||||
netOutNum: txRate,
|
||||
};
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
// Ignore WebSocket errors
|
||||
}
|
||||
});
|
||||
|
||||
const flushInterval = setInterval(() => {
|
||||
const pending = pendingStatsRef.current;
|
||||
if (Object.keys(pending).length === 0) return;
|
||||
pendingStatsRef.current = {};
|
||||
|
||||
setContainerStats(prev => {
|
||||
const next = { ...prev };
|
||||
const HISTORY_CAP = 60;
|
||||
for (const [id, newStats] of Object.entries(pending)) {
|
||||
const prior = prev[id]?.history ?? { cpu: [], mem: [], netIn: [], netOut: [] };
|
||||
const history = {
|
||||
cpu: [...prior.cpu, newStats.cpuNum].slice(-HISTORY_CAP),
|
||||
mem: [...prior.mem, newStats.memNum].slice(-HISTORY_CAP),
|
||||
netIn: [...prior.netIn, newStats.netInNum].slice(-HISTORY_CAP),
|
||||
netOut: [...prior.netOut, newStats.netOutNum].slice(-HISTORY_CAP),
|
||||
};
|
||||
next[id] = {
|
||||
cpu: newStats.cpu, ram: newStats.ram, net: newStats.net,
|
||||
lastRx: newStats.lastRx, lastTx: newStats.lastTx,
|
||||
history,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 1500);
|
||||
|
||||
return () => {
|
||||
clearInterval(flushInterval);
|
||||
pendingStatsRef.current = {};
|
||||
Object.values(wsMap).forEach(ws => { try { ws.close(); } catch { /* ignore */ } });
|
||||
};
|
||||
}, [containers]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return containerStats;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ContainerInfo, ContainerStatsEntry } from '../EditorView';
|
||||
import type { ContainerInfo } from '../EditorView';
|
||||
|
||||
export const LOGS_MODE_STORAGE_KEY = 'sencho.stackView.logsMode';
|
||||
|
||||
@@ -37,7 +37,6 @@ export function useEditorViewState() {
|
||||
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
||||
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
|
||||
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
||||
const [containerStats, setContainerStats] = useState<Record<string, ContainerStatsEntry>>({});
|
||||
|
||||
const [activeTab, setActiveTab] = useState<EditorTab>('compose');
|
||||
const [logsMode, setLogsMode] = useState<LogsMode>(readLogsMode);
|
||||
@@ -64,7 +63,6 @@ export function useEditorViewState() {
|
||||
envFiles, setEnvFiles,
|
||||
selectedEnvFile, setSelectedEnvFile,
|
||||
containers, setContainers,
|
||||
containerStats, setContainerStats,
|
||||
activeTab, setActiveTab,
|
||||
logsMode, setLogsMode,
|
||||
gitSourceOpen, setGitSourceOpen,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { useNotifications } from './useNotifications';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { NotificationItem } from '../../dashboard/types';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
fetchForNode: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn() } }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
const localNode: Node = { id: 1, name: 'Local', type: 'local', api_url: '', api_token: '', compose_dir: '', is_default: true, status: 'online', created_at: 0 };
|
||||
|
||||
const makeNotif = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
||||
id: 1, level: 'info', message: 'test', timestamp: 1000, is_read: 0, ...overrides,
|
||||
});
|
||||
|
||||
class MockWS {
|
||||
static instances: MockWS[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onclose: ((e: { code: number }) => void) | null = null;
|
||||
onerror: ((e: unknown) => void) | null = null;
|
||||
readyState = 1; // OPEN
|
||||
send = vi.fn();
|
||||
close = vi.fn();
|
||||
constructor(_url: string) { MockWS.instances.push(this); }
|
||||
static reset() { MockWS.instances = []; }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
MockWS.reset();
|
||||
vi.stubGlobal('WebSocket', MockWS);
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
|
||||
});
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); });
|
||||
|
||||
describe('useNotifications', () => {
|
||||
it('starts with empty notifications and disconnected state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn() }),
|
||||
);
|
||||
expect(result.current.notifications).toEqual([]);
|
||||
expect(result.current.tickerConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('opens a local notification WebSocket on mount', () => {
|
||||
renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn() }),
|
||||
);
|
||||
expect(MockWS.instances.length).toBeGreaterThanOrEqual(1);
|
||||
expect(MockWS.instances[0]).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets tickerConnected true when local WS opens', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn() }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
expect(result.current.tickerConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('adds notification when local WS receives notification message', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn() }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
act(() => {
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({ type: 'notification', payload: makeNotif({ id: 42, message: 'hello' }) }),
|
||||
});
|
||||
});
|
||||
expect(result.current.notifications).toHaveLength(1);
|
||||
expect(result.current.notifications[0].message).toBe('hello');
|
||||
});
|
||||
|
||||
it('clearAllNotifications empties the local state', async () => {
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn() }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
act(() => {
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({ type: 'notification', payload: makeNotif({ id: 1 }) }),
|
||||
});
|
||||
});
|
||||
expect(result.current.notifications).toHaveLength(1);
|
||||
await act(async () => { await result.current.clearAllNotifications(); });
|
||||
expect(result.current.notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deleteNotification removes the matching item', async () => {
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn() }),
|
||||
);
|
||||
act(() => { MockWS.instances[0]?.onopen?.(); });
|
||||
const notif = makeNotif({ id: 5, nodeId: localNode.id });
|
||||
act(() => {
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({ type: 'notification', payload: notif }),
|
||||
});
|
||||
});
|
||||
await act(async () => { await result.current.deleteNotification({ ...notif, nodeId: localNode.id }); });
|
||||
expect(result.current.notifications).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { NotificationItem } from '../../dashboard/types';
|
||||
|
||||
interface UseNotificationsOptions {
|
||||
nodes: Node[];
|
||||
onStateInvalidate: () => void;
|
||||
onAutoUpdateChange: () => void;
|
||||
}
|
||||
|
||||
export function useNotifications({ nodes, onStateInvalidate, onAutoUpdateChange }: UseNotificationsOptions) {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [tickerConnected, setTickerConnected] = useState(false);
|
||||
|
||||
// Stable refs so long-lived WS callbacks always read the latest values
|
||||
// without needing them in the zero-dep effect dependency arrays.
|
||||
const nodesRef = useRef<Node[]>([]);
|
||||
nodesRef.current = nodes;
|
||||
const remoteNotifWsRef = useRef<Map<number, () => void>>(new Map());
|
||||
const onStateInvalidateRef = useRef(onStateInvalidate);
|
||||
onStateInvalidateRef.current = onStateInvalidate;
|
||||
const onAutoUpdateChangeRef = useRef(onAutoUpdateChange);
|
||||
onAutoUpdateChangeRef.current = onAutoUpdateChange;
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const currentNodes = nodesRef.current;
|
||||
const localNode = currentNodes.find(n => n.type === 'local');
|
||||
const remoteNodes = currentNodes.filter(n => n.type === 'remote');
|
||||
|
||||
const [localResult, ...remoteNodeResults] = await Promise.allSettled([
|
||||
apiFetch('/notifications', { localOnly: true } as Parameters<typeof apiFetch>[1]),
|
||||
...remoteNodes.map(n => fetchForNode('/notifications', n.id)),
|
||||
]);
|
||||
|
||||
const all: NotificationItem[] = [];
|
||||
|
||||
if (localResult.status === 'fulfilled' && localResult.value.ok) {
|
||||
const data = await localResult.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
|
||||
data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' }));
|
||||
}
|
||||
|
||||
for (let i = 0; i < remoteNodes.length; i++) {
|
||||
const result = remoteNodeResults[i];
|
||||
if (result?.status === 'fulfilled' && result.value.ok) {
|
||||
const data = await result.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
|
||||
const rn = remoteNodes[i];
|
||||
data.forEach(n => all.push({ ...n, nodeId: rn.id, nodeName: rn.name }));
|
||||
}
|
||||
}
|
||||
|
||||
all.sort((a, b) => b.timestamp - a.timestamp);
|
||||
setNotifications(all);
|
||||
} catch (e) {
|
||||
console.error('[Notifications] fetch error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNotificationsRef = useRef(fetchNotifications);
|
||||
fetchNotificationsRef.current = fetchNotifications;
|
||||
|
||||
// Local notification WebSocket with exponential-backoff reconnect.
|
||||
useEffect(() => {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsBase = `${wsProtocol}//${window.location.host}`;
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let isMounted = true;
|
||||
let retryCount = 0;
|
||||
const MAX_RETRY_DELAY_MS = 30000;
|
||||
|
||||
const connect = () => {
|
||||
if (!isMounted) return;
|
||||
ws = new WebSocket(`${wsBase}/ws/notifications`);
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!isMounted) { ws?.close(); return; }
|
||||
setTickerConnected(true);
|
||||
retryCount = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
if (msg.type === 'notification' && msg.payload) {
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
const tagged: NotificationItem = {
|
||||
...(msg.payload as Omit<NotificationItem, 'nodeId' | 'nodeName'>),
|
||||
nodeId: localNode?.id ?? -1,
|
||||
nodeName: localNode?.name ?? 'Local',
|
||||
};
|
||||
setNotifications(prev => [tagged, ...prev].sort((a, b) => b.timestamp - a.timestamp));
|
||||
} else if (msg.type === 'state-invalidate') {
|
||||
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: msg }));
|
||||
if (msg.action === 'auto-update-settings-changed') {
|
||||
onAutoUpdateChangeRef.current();
|
||||
} else {
|
||||
onStateInvalidateRef.current();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[WS notifications] parse error', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setTickerConnected(false);
|
||||
if (!isMounted) return;
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), MAX_RETRY_DELAY_MS);
|
||||
retryCount++;
|
||||
console.debug(`[WS notifications] closed (code=${event.code}), reconnecting in ${delay}ms`);
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = (event) => { console.warn('[WS notifications] error event', event); };
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.close();
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-fetch all notifications when the nodes list changes.
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [nodes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Open/close per-remote-node WebSocket connections as the nodes list changes.
|
||||
useEffect(() => {
|
||||
const remoteNodes = nodes.filter(n => n.type === 'remote');
|
||||
const currentIds = new Set(remoteNotifWsRef.current.keys());
|
||||
const newIds = new Set(remoteNodes.map(n => n.id));
|
||||
|
||||
for (const id of currentIds) {
|
||||
if (!newIds.has(id)) {
|
||||
remoteNotifWsRef.current.get(id)?.();
|
||||
remoteNotifWsRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rn of remoteNodes) {
|
||||
if (remoteNotifWsRef.current.has(rn.id)) continue;
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let active = true;
|
||||
let retryCount = 0;
|
||||
|
||||
const connect = () => {
|
||||
if (!active) return;
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws/notifications?nodeId=${rn.id}`);
|
||||
|
||||
ws.onopen = () => { if (!active) { ws?.close(); } else { retryCount = 0; } };
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
if (msg.type === 'notification' && msg.payload) {
|
||||
const current = nodesRef.current.find(n => n.id === rn.id);
|
||||
setNotifications(prev =>
|
||||
[{ ...msg.payload as Omit<NotificationItem, 'nodeId' | 'nodeName'>, nodeId: rn.id, nodeName: current?.name ?? rn.name }, ...prev]
|
||||
.sort((a, b) => b.timestamp - a.timestamp),
|
||||
);
|
||||
} else if (msg.type === 'state-invalidate') {
|
||||
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: { ...msg, nodeId: rn.id } }));
|
||||
onStateInvalidateRef.current();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[WS notifications:${rn.name}] parse error`, e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!active) return;
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
|
||||
retryCount++;
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = (e) => console.warn(`[WS notifications:${rn.name}] error`, e);
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
remoteNotifWsRef.current.set(rn.id, () => {
|
||||
active = false;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.close();
|
||||
});
|
||||
}
|
||||
}, [nodes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Cleanup all remote notification WebSocket connections on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const cleanup of remoteNotifWsRef.current.values()) cleanup();
|
||||
remoteNotifWsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Safety-net poll: reconciles the list every 60 s.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => { fetchNotificationsRef.current(); }, 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
const unreadNodeIds = [...new Set(notifications.filter(n => !n.is_read && n.nodeId != null).map(n => n.nodeId as number))];
|
||||
await Promise.allSettled(unreadNodeIds.map(nodeId =>
|
||||
nodeId === localNode?.id
|
||||
? apiFetch('/notifications/read', { method: 'POST', localOnly: true } as Parameters<typeof apiFetch>[1])
|
||||
: fetchForNode('/notifications/read', nodeId, { method: 'POST' }),
|
||||
));
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: 1 })));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; error?: string };
|
||||
toast.error(err?.message || err?.error || 'Failed to mark notifications as read');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNotification = async (notif: NotificationItem) => {
|
||||
try {
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
if (notif.nodeId === localNode?.id) {
|
||||
await apiFetch(`/notifications/${notif.id}`, { method: 'DELETE', localOnly: true } as Parameters<typeof apiFetch>[1]);
|
||||
} else if (notif.nodeId != null) {
|
||||
await fetchForNode(`/notifications/${notif.id}`, notif.nodeId, { method: 'DELETE' });
|
||||
}
|
||||
setNotifications(prev => prev.filter(n => !(n.id === notif.id && n.nodeId === notif.nodeId)));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; error?: string };
|
||||
toast.error(err?.message || err?.error || 'Failed to delete notification');
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllNotifications = async () => {
|
||||
try {
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
const uniqueNodeIds = [...new Set(notifications.filter(n => n.nodeId != null).map(n => n.nodeId as number))];
|
||||
await Promise.allSettled(uniqueNodeIds.map(nodeId =>
|
||||
nodeId === localNode?.id
|
||||
? apiFetch('/notifications', { method: 'DELETE', localOnly: true } as Parameters<typeof apiFetch>[1])
|
||||
: fetchForNode('/notifications', nodeId, { method: 'DELETE' }),
|
||||
));
|
||||
setNotifications([]);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; error?: string };
|
||||
toast.error(err?.message || err?.error || 'Failed to clear notifications');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
notifications,
|
||||
tickerConnected,
|
||||
markAllRead,
|
||||
deleteNotification,
|
||||
clearAllNotifications,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { useTheme } from './useTheme';
|
||||
|
||||
const mockMatchMedia = (matches: boolean) => {
|
||||
const listeners: Array<(e: MediaQueryListEvent) => void> = [];
|
||||
return {
|
||||
matches,
|
||||
addEventListener: vi.fn((_: string, cb: (e: MediaQueryListEvent) => void) => { listeners.push(cb); }),
|
||||
removeEventListener: vi.fn(),
|
||||
_trigger: (m: boolean) => listeners.forEach(cb => cb({ matches: m } as MediaQueryListEvent)),
|
||||
};
|
||||
};
|
||||
|
||||
describe('useTheme', () => {
|
||||
let mq: ReturnType<typeof mockMatchMedia>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mq = mockMatchMedia(false);
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => mq));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
document.documentElement.classList.remove('dark');
|
||||
});
|
||||
|
||||
it('defaults to dark theme when no localStorage entry', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe('dark');
|
||||
expect(result.current.isDarkMode).toBe(true);
|
||||
});
|
||||
|
||||
it('reads persisted theme from localStorage', () => {
|
||||
localStorage.setItem('sencho-theme', 'light');
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe('light');
|
||||
expect(result.current.isDarkMode).toBe(false);
|
||||
});
|
||||
|
||||
it('setTheme persists to localStorage and updates isDarkMode', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setTheme('light'));
|
||||
expect(result.current.theme).toBe('light');
|
||||
expect(result.current.isDarkMode).toBe(false);
|
||||
expect(localStorage.getItem('sencho-theme')).toBe('light');
|
||||
});
|
||||
|
||||
it('auto theme tracks system preference', () => {
|
||||
mq = mockMatchMedia(true);
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => mq));
|
||||
localStorage.setItem('sencho-theme', 'auto');
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.isDarkMode).toBe(true);
|
||||
});
|
||||
|
||||
it('applies dark class to documentElement when isDarkMode', () => {
|
||||
renderHook(() => useTheme());
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('removes dark class when switching to light', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setTheme('light'));
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'auto';
|
||||
|
||||
function readTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
const saved = localStorage.getItem('sencho-theme') as Theme | null;
|
||||
if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved;
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(readTheme);
|
||||
const [systemDark, setSystemDark] = useState(() =>
|
||||
typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false,
|
||||
);
|
||||
|
||||
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', isDarkMode);
|
||||
try { localStorage.setItem('sencho-theme', theme); } catch { /* ignore */ }
|
||||
}, [isDarkMode, theme]);
|
||||
|
||||
const setTheme = (next: Theme) => setThemeState(next);
|
||||
|
||||
return { theme, setTheme, isDarkMode } as const;
|
||||
}
|
||||
Reference in New Issue
Block a user