fix(alerts): overhaul alerts & notifications system for local and remote nodes

- StackAlertSheet: fetch agent status on open and show contextual banner
  (amber warning when no channels configured, green when active, blue info
  callout on remote nodes explaining remote-instance evaluation semantics)
- StackAlertSheet: surface actual server error message on addAlert failure
  instead of generic string; add console logging for all failure paths
- SettingsModal: expose Notifications tab for remote nodes so agents can be
  configured directly on any Sencho instance; section header shows node name
  and Remote badge with tooltip when proxying to a remote node
- SettingsModal: re-fetch agents/settings when active node changes
- SettingsModal: add hidden DialogTitle/DialogDescription to satisfy Radix
  UI accessibility requirements (eliminates console errors)
- backend POST /api/alerts: add Zod validation schema; rejects unknown
  metric/operator values, negative thresholds, and missing fields with 400
- EditorLayout WS: upgrade reconnect from flat 5s retry to exponential
  backoff (1s→2s→4s→8s→16s→30s max); onerror now logs the event; cleanup
  only closes OPEN sockets — CONNECTING sockets are closed in onopen after
  isMounted check, eliminating "closed before established" StrictMode error
This commit is contained in:
SaelixCode
2026-03-21 01:19:29 -04:00
parent bfef6a7979
commit e190f3ad8a
5 changed files with 213 additions and 30 deletions
+5
View File
@@ -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.
+16 -2
View File
@@ -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' });
}
});
+28 -7
View File
@@ -194,10 +194,22 @@ export default function EditorLayout() {
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;
}
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
+32 -7
View File
@@ -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 (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[900px] h-[650px] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
<VisuallyHidden><DialogTitle>Settings Hub</DialogTitle></VisuallyHidden>
<VisuallyHidden><DialogDescription>Configure Sencho settings</DialogDescription></VisuallyHidden>
{/* Sidebar */}
<div className="w-[200px] bg-muted/20 border-r border-border flex flex-col p-4 shrink-0">
<div className="font-semibold text-lg mb-1 text-foreground tracking-tight">Settings Hub</div>
@@ -410,9 +415,7 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa
label="System Limits"
showDot={hasSystemChanges}
/>
{!isRemote && (
<NavButton section="notifications" icon={<Bell className="w-4 h-4 mr-2" />} label="Notifications" />
)}
<NavButton section="notifications" icon={<Bell className="w-4 h-4 mr-2" />} label="Notifications" />
{!isRemote && (
<NavButton section="appearance" icon={<Palette className="w-4 h-4 mr-2" />} label="Appearance" />
)}
@@ -573,9 +576,31 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa
{activeSection === 'notifications' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold tracking-tight">Notifications & Alerts</h3>
<p className="text-sm text-muted-foreground">Configure external integrations for crash alerts.</p>
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-semibold tracking-tight">Notifications & Alerts</h3>
<p className="text-sm text-muted-foreground">
{isRemote
? <>Configuring notification channels on <span className="font-semibold text-foreground">{activeNode!.name}</span>. Alerts from this remote node will dispatch via these channels.</>
: 'Configure external integrations for crash alerts.'
}
</p>
</div>
{isRemote && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="text-xs shrink-0 ml-2 mt-0.5 cursor-help">
<Info className="w-3 h-3 mr-1" />
Remote
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[240px] text-center">
These channels are saved on the remote Sencho instance and used when it dispatches alerts.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<Tabs value={notifTab} onValueChange={(v) => setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full">
<TabsList className="w-full mb-4 grid grid-cols-3">
+132 -14
View File
@@ -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<StackAlert[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [agentStatus, setAgentStatus] = useState<AgentStatus>({
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<string, string> = {
discord: 'Discord',
slack: 'Slack',
webhook: 'Webhook',
};
const renderAgentStatusBanner = () => {
if (agentStatus.loading) {
return (
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50 border text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
<span>Checking notification channels</span>
</div>
);
}
if (isRemote) {
return (
<div className="flex items-start gap-2 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20 text-sm">
<Info className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
<div className="space-y-0.5">
<p className="font-medium text-blue-700 dark:text-blue-400">
Remote node: <span className="font-semibold">{activeNode?.name}</span>
</p>
<p className="text-muted-foreground">
Alert rules are stored and evaluated on this remote instance. Notifications are dispatched using that node's configured channels.
</p>
{!agentStatus.hasEnabled && (
<p className="text-amber-600 dark:text-amber-400 font-medium mt-1">
No notification channels are configured on this remote node. Open Settings → Notifications to configure them.
</p>
)}
{agentStatus.hasEnabled && (
<p className="text-green-600 dark:text-green-400 font-medium mt-1">
Active channels: {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')}
</p>
)}
</div>
</div>
);
}
if (!agentStatus.hasEnabled) {
return (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-sm">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-700 dark:text-amber-400">No notification channels configured</p>
<p className="text-muted-foreground mt-0.5">
Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in{' '}
<span className="font-medium">Settings → Notifications</span>.
</p>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
<div>
<p className="font-medium text-green-700 dark:text-green-400">
Notifications active via {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')}
</p>
</div>
</div>
);
};
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
<SheetContent className="overflow-y-auto sm:max-w-[400px]">
<SheetContent className="overflow-y-auto sm:max-w-[420px]">
<SheetHeader>
<SheetTitle>Stack Alerts: {stackName}</SheetTitle>
<SheetDescription>
@@ -132,7 +243,10 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
</SheetHeader>
<TooltipProvider>
<div className="mt-6 space-y-6">
<div className="mt-4 space-y-5">
{/* Notification agent status banner */}
{renderAgentStatusBanner()}
{/* List Existing Alerts */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Existing Rules</h4>
@@ -299,7 +413,11 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
</div>
<Button className="w-full mt-2" onClick={addAlert} disabled={isLoading}>
Add Rule
{isLoading ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving…</>
) : (
'Add Rule'
)}
</Button>
</div>
</div>