diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d948a2..2459c6a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Fixed:** `StackAlertSheet` now fetches notification agent status from the active node on open and displays a contextual banner: green checkmark with active channel names when agents are enabled, amber warning with a link to Settings → Notifications when none are configured, and a blue info callout on remote nodes explaining that alerts are evaluated and dispatched by that remote Sencho instance. +- **Fixed:** `SettingsModal` Notifications tab is no longer hidden when a remote node is active — users can now configure Discord/Slack/Webhook channels directly on any remote node. The section header shows the remote node name and a "Remote" badge with a tooltip explaining that channels are saved on the remote instance. +- **Fixed:** `StackAlertSheet` form error handling now surfaces the actual server error message (`err.error`) instead of a generic "Failed to add alert rule." string; console error logging added for all failure paths to aid debugging. +- **Fixed:** `POST /api/alerts` now validates the request body with a Zod schema — rejects unknown metric/operator values, negative thresholds, and missing required fields with a structured 400 response instead of passing raw input to SQLite. +- **Fixed:** WebSocket notification reconnect in `EditorLayout` upgraded to exponential backoff (1 s → 2 s → 4 s → 8 s → 16 s → 30 s max) instead of a flat 5-second retry; `ws.onerror` now logs the event rather than silently calling `close()`; cleanup correctly guards against closing a WebSocket that is already in CLOSING/CLOSED state, eliminating the "WebSocket is closed before the connection is established" console error on React StrictMode double-mount. - **Security:** Host Console and container exec WebSocket endpoints now reject `node_proxy` scoped JWT tokens with HTTP 403 — machine-to-machine proxy credentials can no longer be used to open interactive terminals. - **Security:** `stackParam` query parameter on `/api/system/host-console` is now validated against `path.resolve` + `startsWith(baseDir)` to prevent directory traversal when setting the PTY working directory. - **Security:** `HostTerminalService` no longer forwards the full `process.env` to spawned PTY shells; `JWT_SECRET`, `AUTH_PASSWORD`, `AUTH_PASSWORD_HASH`, and `DATABASE_URL` are stripped before the shell is spawned. diff --git a/backend/src/index.ts b/backend/src/index.ts index d381b8a1..1a96535f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1392,12 +1392,26 @@ app.get('/api/alerts', async (req: Request, res: Response) => { } }); +const AlertCreateSchema = z.object({ + stack_name: z.string().min(1).max(255), + metric: z.enum(['cpu_percent', 'memory_percent', 'memory_mb', 'net_rx', 'net_tx', 'restart_count']), + operator: z.enum(['>', '>=', '<', '<=', '==']), + threshold: z.number().min(0), + duration_mins: z.coerce.number().int().min(0).max(1440), + cooldown_mins: z.coerce.number().int().min(0).max(10080), +}); + app.post('/api/alerts', async (req: Request, res: Response) => { + const parsed = AlertCreateSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Invalid alert data', details: parsed.error.flatten().fieldErrors }); + return; + } try { - const alert = req.body; - DatabaseService.getInstance().addStackAlert(alert); + DatabaseService.getInstance().addStackAlert(parsed.data); res.json({ success: true }); } catch (error) { + console.error('Failed to add alert:', error); res.status(500).json({ error: 'Failed to add alert' }); } }); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 4fae0996..32ee7ecc 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -194,10 +194,22 @@ export default function EditorLayout() { 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; + } + retryCount = 0; // Reset backoff on successful connect + }; + ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); @@ -209,14 +221,18 @@ export default function EditorLayout() { } }; - ws.onclose = () => { - if (isMounted) { - reconnectTimer = setTimeout(connect, 5000); - } + ws.onclose = (event) => { + 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 = () => { - ws?.close(); + ws.onerror = (event) => { + // onerror always fires before onclose - log it and let onclose handle reconnect + console.warn('[WS notifications] error event', event); }; }; @@ -225,7 +241,12 @@ export default function EditorLayout() { return () => { isMounted = false; if (reconnectTimer) clearTimeout(reconnectTimer); - ws?.close(); + // 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 diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e6ea8320..c300e48d 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3,7 +3,10 @@ import { motion } from 'motion/react'; import { Dialog, DialogContent, + DialogTitle, + DialogDescription, } from '@/components/ui/dialog'; +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; @@ -123,7 +126,7 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa fetchAgents(); fetchSettings(); } - }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isOpen, activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps const fetchAgents = async () => { try { @@ -392,6 +395,8 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa return ( !open && onClose()}> + Settings Hub + Configure Sencho settings {/* Sidebar */}
Settings Hub
@@ -410,9 +415,7 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa label="System Limits" showDot={hasSystemChanges} /> - {!isRemote && ( - } label="Notifications" /> - )} + } label="Notifications" /> {!isRemote && ( } label="Appearance" /> )} @@ -573,9 +576,31 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa {activeSection === 'notifications' && (
-
-

Notifications & Alerts

-

Configure external integrations for crash alerts.

+
+
+

Notifications & Alerts

+

+ {isRemote + ? <>Configuring notification channels on {activeNode!.name}. Alerts from this remote node will dispatch via these channels. + : 'Configure external integrations for crash alerts.' + } +

+
+ {isRemote && ( + + + + + + Remote + + + + These channels are saved on the remote Sencho instance and used when it dispatches alerts. + + + + )}
setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full"> diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx index 7db64ae9..7835fdd2 100644 --- a/frontend/src/components/StackAlertSheet.tsx +++ b/frontend/src/components/StackAlertSheet.tsx @@ -11,9 +11,10 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { Trash2, HelpCircle } from 'lucide-react'; +import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; +import { useNodes } from '@/context/NodeContext'; interface StackAlert { id?: number; @@ -31,9 +32,23 @@ interface StackAlertSheetProps { stackName: string; } +interface AgentStatus { + loading: boolean; + hasEnabled: boolean; + enabledTypes: string[]; +} + export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetProps) { + const { activeNode } = useNodes(); + const isRemote = activeNode?.type === 'remote'; + const [alerts, setAlerts] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [agentStatus, setAgentStatus] = useState({ + loading: false, + hasEnabled: false, + enabledTypes: [], + }); // New Alert Form State const [metric, setMetric] = useState('cpu_percent'); @@ -45,18 +60,41 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP useEffect(() => { if (isOpen && stackName) { fetchAlerts(); + fetchAgentStatus(); } - }, [isOpen, stackName]); + }, [isOpen, stackName]); // eslint-disable-line react-hooks/exhaustive-deps const fetchAlerts = async () => { try { - const res = await apiFetch(`/alerts?stackName=${stackName}`); + const res = await apiFetch(`/alerts?stackName=${encodeURIComponent(stackName)}`); if (res.ok) { const data = await res.json(); setAlerts(data); } } catch (e) { - console.error('Failed to fetch alerts', e); + console.error('[StackAlertSheet] Failed to fetch alerts', e); + } + }; + + const fetchAgentStatus = async () => { + setAgentStatus(prev => ({ ...prev, loading: true })); + try { + // Always fetch agents from the active node (proxied via x-node-id for remote) + const res = await apiFetch('/agents'); + if (res.ok) { + const agents: Array<{ type: string; enabled: boolean }> = await res.json(); + const enabled = agents.filter(a => a.enabled); + setAgentStatus({ + loading: false, + hasEnabled: enabled.length > 0, + enabledTypes: enabled.map(a => a.type), + }); + } else { + setAgentStatus({ loading: false, hasEnabled: false, enabledTypes: [] }); + } + } catch (e) { + console.error('[StackAlertSheet] Failed to fetch agent status', e); + setAgentStatus({ loading: false, hasEnabled: false, enabledTypes: [] }); } }; @@ -73,23 +111,26 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP operator, threshold: parseFloat(threshold), duration_mins: parseInt(duration, 10), - cooldown_mins: parseInt(cooldown, 10) + cooldown_mins: parseInt(cooldown, 10), }; try { const res = await apiFetch('/alerts', { method: 'POST', - body: JSON.stringify(newAlert) + body: JSON.stringify(newAlert), }); if (res.ok) { toast.success('Alert rule added.'); setThreshold(''); fetchAlerts(); } else { - toast.error('Failed to add alert rule.'); + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to add alert rule.'); + console.error('[StackAlertSheet] addAlert failed:', err); } } catch (e) { - toast.error('Network error.'); + console.error('[StackAlertSheet] addAlert threw:', e); + toast.error('Network error. Could not reach the node.'); } finally { setIsLoading(false); } @@ -103,10 +144,11 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP toast.success('Alert rule deleted.'); fetchAlerts(); } else { - toast.error('Failed to delete alert rule.'); + const err = await res.json().catch(() => ({})); + toast.error(err?.error || 'Failed to delete alert rule.'); } } catch (e) { - toast.error('Network error.'); + toast.error('Network error. Could not reach the node.'); } finally { setIsLoading(false); } @@ -118,12 +160,81 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP memory_mb: 'Memory Usage (MB)', net_rx: 'Network In (MB)', net_tx: 'Network Out (MB)', - restart_count: 'Restart Count' + restart_count: 'Restart Count', + }; + + const agentTypeLabels: Record = { + discord: 'Discord', + slack: 'Slack', + webhook: 'Webhook', + }; + + const renderAgentStatusBanner = () => { + if (agentStatus.loading) { + return ( +
+ + Checking notification channels… +
+ ); + } + + if (isRemote) { + return ( +
+ +
+

+ Remote node: {activeNode?.name} +

+

+ Alert rules are stored and evaluated on this remote instance. Notifications are dispatched using that node's configured channels. +

+ {!agentStatus.hasEnabled && ( +

+ No notification channels are configured on this remote node. Open Settings → Notifications to configure them. +

+ )} + {agentStatus.hasEnabled && ( +

+ Active channels: {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')} +

+ )} +
+
+ ); + } + + if (!agentStatus.hasEnabled) { + return ( +
+ +
+

No notification channels configured

+

+ Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in{' '} + Settings → Notifications. +

+
+
+ ); + } + + return ( +
+ +
+

+ Notifications active via {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')} +

+
+
+ ); }; return ( !open && onClose()}> - + Stack Alerts: {stackName} @@ -132,7 +243,10 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP -
+
+ {/* Notification agent status banner */} + {renderAgentStatusBanner()} + {/* List Existing Alerts */}

Existing Rules

@@ -299,7 +413,11 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP