diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 376eb3b9..69006b0d 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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([]); - nodesRef.current = nodes; - // Tracks cleanup functions for per-remote-node notification WebSocket connections. - const remoteNotifWsRef = useRef 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>({}); - // 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>({}); const monacoEditorRef = useRef(null); const pendingStackLoadRef = useRef(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(() => { - 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([]); - 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(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 | 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), - 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 | 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, 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[]; - 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[]; - 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 = {}; - - (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).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 diff --git a/frontend/src/components/EditorLayout/__tests__/useEditorViewState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useEditorViewState.test.tsx index 74d60d60..e2f56736 100644 --- a/frontend/src/components/EditorLayout/__tests__/useEditorViewState.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/useEditorViewState.test.tsx @@ -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({}); }); diff --git a/frontend/src/components/EditorLayout/hooks/useContainerStats.test.ts b/frontend/src/components/EditorLayout/hooks/useContainerStats.test.ts new file mode 100644 index 00000000..ff5762e5 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useContainerStats.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useContainerStats.ts b/frontend/src/components/EditorLayout/hooks/useContainerStats.ts new file mode 100644 index 00000000..952e3b0a --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useContainerStats.ts @@ -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 { + const [containerStats, setContainerStats] = useState>({}); + + const pendingStatsRef = useRef>({}); + + const rawBytesRef = useRef>({}); + + useEffect(() => { + const wsMap: Record = {}; + + (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).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; +} diff --git a/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts b/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts index 16ddba09..9a5bee5e 100644 --- a/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts +++ b/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts @@ -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([]); const [selectedEnvFile, setSelectedEnvFile] = useState(''); const [containers, setContainers] = useState([]); - const [containerStats, setContainerStats] = useState>({}); const [activeTab, setActiveTab] = useState('compose'); const [logsMode, setLogsMode] = useState(readLogsMode); @@ -64,7 +63,6 @@ export function useEditorViewState() { envFiles, setEnvFiles, selectedEnvFile, setSelectedEnvFile, containers, setContainers, - containerStats, setContainerStats, activeTab, setActiveTab, logsMode, setLogsMode, gitSourceOpen, setGitSourceOpen, diff --git a/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts b/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts new file mode 100644 index 00000000..b1b8bf5b --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts @@ -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 => ({ + 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).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).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).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); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useNotifications.ts b/frontend/src/components/EditorLayout/hooks/useNotifications.ts new file mode 100644 index 00000000..0f421590 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useNotifications.ts @@ -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([]); + 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([]); + nodesRef.current = nodes; + const remoteNotifWsRef = useRef 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[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[]; + 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[]; + 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 | 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), + 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 | 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, 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[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[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[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; +} diff --git a/frontend/src/components/EditorLayout/hooks/useTheme.test.ts b/frontend/src/components/EditorLayout/hooks/useTheme.test.ts new file mode 100644 index 00000000..0a2213cb --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useTheme.test.ts @@ -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; + + 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); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useTheme.ts b/frontend/src/components/EditorLayout/hooks/useTheme.ts new file mode 100644 index 00000000..38a5ae2d --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useTheme.ts @@ -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(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; +}