mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: add alert management functionality in NotificationSettingsModal and implement stacks API endpoint
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user