import { useState, useEffect, useCallback, useRef } from 'react'; import { useNodes } from '@/context/NodeContext'; import { apiFetch } from '@/lib/api'; import { visibilityInterval } from '@/lib/utils'; import { normalizeConfigurationAgents, type AgentStatus } from '@/lib/configurationStatus'; // Trailing-edge debounce window for filtered settings-event refetches, // matching the precedent in useNextAutoUpdateRun. const INVALIDATE_DEBOUNCE_MS = 250; export interface ConfigurationStatus { tier: 'community' | 'paid'; notifications: { agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus; ntfy: AgentStatus }; alertRules: number; routingRules: { count: number; enabledCount: number; locked: boolean }; suppressionRules: { total: number; enabledCount: number }; }; automation: { autoHeal: { total: number; enabled: number }; autoUpdate: { enabled: number; total: number }; scheduledTasks: { total: number; enabled: number; locked: boolean }; webhooks: { total: number; enabled: number; locked: boolean }; }; security: { mfaEnabled: boolean | null; ssoEnabled: boolean; ssoProvider: string | null; trivyInstalled: boolean; scanPolicies: { total: number; enabled: number; locked: boolean }; }; thresholds: { cpuLimit: number; ramLimit: number; diskLimit: number; dockerJanitorGb: number; globalCrash: boolean; hostAlertsEnabled: boolean; }; backup: { provider: 'disabled' | 'sencho' | 'custom'; autoUpload: boolean; locked: boolean; }; } /** Wire payload may omit `apprise` from older remotes. */ type WireConfigurationStatus = Omit & { notifications: Omit & { agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise?: AgentStatus; ntfy?: AgentStatus; }; }; }; function normalizeConfigurationStatus(raw: WireConfigurationStatus): ConfigurationStatus { return { ...raw, notifications: { ...raw.notifications, agents: normalizeConfigurationAgents(raw.notifications.agents), }, }; } export function useConfigurationStatus() { const { activeNode } = useNodes(); const nodeId = activeNode?.id; const nodeIdRef = useRef(nodeId); useEffect(() => { nodeIdRef.current = nodeId; }, [nodeId]); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const fetchStatus = useCallback(async () => { try { const res = await apiFetch('/dashboard/configuration'); if (!res.ok) return; const data = await res.json() as WireConfigurationStatus; setStatus(normalizeConfigurationStatus(data)); } catch { // Silent; stale data stays visible } finally { setLoading(false); } }, []); // Configuration data is derived from settings/policy tables (agents, // alert rules, auto-heal policies, scheduled tasks, scan policies, cloud // backup config). The 60 s poll catches settings drift on its own. useEffect(() => { setStatus(null); setLoading(true); const currentNodeId = nodeId; const guard = () => { if (nodeIdRef.current === currentNodeId) void fetchStatus(); }; guard(); return visibilityInterval(guard, 60_000); }, [nodeId, fetchStatus]); // Refetch the configuration card when a scheduled-tasks mutation fires // an invalidate. High-frequency `scope: 'stack'` and `scope: 'image-updates'` // events are ignored; they don't change any tile on this card. Debounced // so a burst of edits coalesces into a single fetch. useEffect(() => { let invalidateTimer: ReturnType | null = null; const onInvalidate = (e: Event) => { const detail = (e as CustomEvent<{ scope?: string }>).detail; if (detail?.scope !== 'scheduled-tasks') return; if (invalidateTimer) clearTimeout(invalidateTimer); invalidateTimer = setTimeout(() => { invalidateTimer = null; void fetchStatus(); }, INVALIDATE_DEBOUNCE_MS); }; window.addEventListener('sencho:state-invalidate', onInvalidate); return () => { window.removeEventListener('sencho:state-invalidate', onInvalidate); if (invalidateTimer) clearTimeout(invalidateTimer); }; }, [fetchStatus]); return { status, loading }; }