feat: add alert management functionality in NotificationSettingsModal and implement stacks API endpoint

This commit is contained in:
SaelixCode
2026-02-27 09:52:54 -05:00
parent 0899fc0d64
commit 9cdf273b8c
4 changed files with 255 additions and 3 deletions
+4
View File
@@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
+2 -2
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "nodemon --exec ts-node src/index.ts",
"dev": "nodemon --watch src --ext ts,json --exec ts-node src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
@@ -42,4 +42,4 @@
"ws": "^8.19.0",
"yaml": "^2.8.2"
}
}
}
+9
View File
@@ -663,6 +663,15 @@ app.get('/api/system/stats', async (req: Request, res: Response) => {
// --- Notification & Alerting Routes ---
app.get('/api/stacks', async (req: Request, res: Response) => {
try {
const stacks = await fileSystemService.getStacks();
res.json(stacks);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch stacks' });
}
});
app.get('/api/agents', async (req: Request, res: Response) => {
try {
const agents = DatabaseService.getInstance().getAgents();
@@ -14,6 +14,18 @@ import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { Trash2 } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface StackAlert {
id?: number;
stack_name: string;
metric: string;
operator: string;
threshold: number;
duration_mins: number;
cooldown_mins: number;
}
interface Agent {
type: 'discord' | 'slack' | 'webhook';
@@ -43,10 +55,31 @@ export function NotificationSettingsModal({ isOpen, onClose }: NotificationSetti
const [isLoading, setIsLoading] = useState(false);
// Central Alerts State
const [alerts, setAlerts] = useState<StackAlert[]>([]);
const [stacks, setStacks] = useState<string[]>([]);
const [newAlertStack, setNewAlertStack] = useState('');
const [newAlertMetric, setNewAlertMetric] = useState('cpu_percent');
const [newAlertOperator, setNewAlertOperator] = useState('>');
const [newAlertThreshold, setNewAlertThreshold] = useState('');
const [newAlertDuration, setNewAlertDuration] = useState('5');
const [newAlertCooldown, setNewAlertCooldown] = useState('60');
const metricLabels: Record<string, string> = {
cpu_percent: 'CPU Usage (%)',
memory_percent: 'Memory Usage (%)',
memory_mb: 'Memory Usage (MB)',
net_rx: 'Network In (MB)',
net_tx: 'Network Out (MB)',
restart_count: 'Restart Count'
};
useEffect(() => {
if (isOpen) {
fetchAgents();
fetchSettings();
fetchAlerts();
fetchStacks();
}
}, [isOpen]);
@@ -78,6 +111,33 @@ export function NotificationSettingsModal({ isOpen, onClose }: NotificationSetti
}
};
const fetchAlerts = async () => {
try {
const res = await apiFetch('/alerts');
if (res.ok) {
const data = await res.json();
setAlerts(data);
}
} catch (e) {
console.error('Failed to fetch alerts', e);
}
};
const fetchStacks = async () => {
try {
const res = await apiFetch('/stacks');
if (res.ok) {
const data = await res.json();
setStacks(data);
if (data.length > 0 && !newAlertStack) {
setNewAlertStack(data[0]);
}
}
} catch (e) {
console.error('Failed to fetch stacks', e);
}
};
const handleAgentChange = (type: string, field: keyof Agent, value: any) => {
setAgents(prev => ({
...prev,
@@ -149,6 +209,62 @@ export function NotificationSettingsModal({ isOpen, onClose }: NotificationSetti
}
};
const addAlert = async () => {
if (!newAlertStack) {
toast.error('Please select a stack.');
return;
}
if (!newAlertThreshold) {
toast.error('Please enter a threshold.');
return;
}
setIsLoading(true);
const newAlert = {
stack_name: newAlertStack,
metric: newAlertMetric,
operator: newAlertOperator,
threshold: parseFloat(newAlertThreshold),
duration_mins: parseInt(newAlertDuration, 10),
cooldown_mins: parseInt(newAlertCooldown, 10)
};
try {
const res = await apiFetch('/alerts', {
method: 'POST',
body: JSON.stringify(newAlert)
});
if (res.ok) {
toast.success('Alert rule added.');
setNewAlertThreshold('');
fetchAlerts();
} else {
toast.error('Failed to add alert rule.');
}
} catch (e) {
toast.error('Network error.');
} finally {
setIsLoading(false);
}
};
const deleteAlert = async (id: number) => {
setIsLoading(true);
try {
const res = await apiFetch(`/alerts/${id}`, { method: 'DELETE' });
if (res.ok) {
toast.success('Alert rule deleted.');
fetchAlerts();
} else {
toast.error('Failed to delete alert rule.');
}
} catch (e) {
toast.error('Network error.');
} finally {
setIsLoading(false);
}
};
const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => (
<div className="space-y-4 py-4">
<div className="flex items-center justify-between">
@@ -186,11 +302,12 @@ export function NotificationSettingsModal({ isOpen, onClose }: NotificationSetti
</DialogHeader>
<Tabs defaultValue="global" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="global">Global</TabsTrigger>
<TabsTrigger value="discord">Discord</TabsTrigger>
<TabsTrigger value="slack">Slack</TabsTrigger>
<TabsTrigger value="webhook">Webhook</TabsTrigger>
<TabsTrigger value="rules">Alert Rules</TabsTrigger>
</TabsList>
<TabsContent value="global">
@@ -258,6 +375,128 @@ export function NotificationSettingsModal({ isOpen, onClose }: NotificationSetti
<TabsContent value="discord">{renderAgentTab('discord', 'Discord')}</TabsContent>
<TabsContent value="slack">{renderAgentTab('slack', 'Slack')}</TabsContent>
<TabsContent value="webhook">{renderAgentTab('webhook', 'Custom Webhook')}</TabsContent>
<TabsContent value="rules">
<div className="space-y-6 py-4">
<div className="space-y-3 max-h-[250px] overflow-y-auto pr-2">
<h4 className="text-sm font-semibold sticky top-0 bg-background pb-2 z-10">Existing Rules</h4>
{alerts.length === 0 ? (
<div className="text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg text-center">
No active alert rules across all stacks.
</div>
) : (
alerts.map(alert => (
<div key={alert.id} className="flex flex-col gap-2 p-3 bg-muted/50 rounded-lg border text-sm">
<div className="flex justify-between items-start">
<div>
<span className="font-semibold text-foreground">
[{alert.stack_name}] {metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold}
</span>
<div className="text-muted-foreground mt-1 text-xs">
Trigger after {alert.duration_mins}m Cooldown: {alert.cooldown_mins}m
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive shrink-0"
onClick={() => alert.id && deleteAlert(alert.id)}
disabled={isLoading}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
<hr />
<div className="space-y-4">
<h4 className="text-sm font-semibold">Add New Rule</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Target Stack</Label>
<Select value={newAlertStack} onValueChange={setNewAlertStack}>
<SelectTrigger>
<SelectValue placeholder="Select a stack" />
</SelectTrigger>
<SelectContent>
{stacks.map(stack => (
<SelectItem key={stack} value={stack}>{stack}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Metric</Label>
<Select value={newAlertMetric} onValueChange={setNewAlertMetric}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(metricLabels).map(([val, label]) => (
<SelectItem key={val} value={val}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Operator</Label>
<Select value={newAlertOperator} onValueChange={setNewAlertOperator}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value=">">Greater than</SelectItem>
<SelectItem value=">=">Greater or eq</SelectItem>
<SelectItem value="<">Less than</SelectItem>
<SelectItem value="<=">Less or eq</SelectItem>
<SelectItem value="==">Equals</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Threshold</Label>
<Input
type="number"
value={newAlertThreshold}
onChange={e => setNewAlertThreshold(e.target.value)}
placeholder="e.g. 90"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Duration (mins)</Label>
<Input
type="number"
value={newAlertDuration}
onChange={e => setNewAlertDuration(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Cooldown (mins)</Label>
<Input
type="number"
value={newAlertCooldown}
onChange={e => setNewAlertCooldown(e.target.value)}
/>
</div>
</div>
<Button className="w-full mt-2" onClick={addAlert} disabled={isLoading}>
Add Rule
</Button>
</div>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>