mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +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": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"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"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@@ -42,4 +42,4 @@
|
|||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"yaml": "^2.8.2"
|
"yaml": "^2.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -663,6 +663,15 @@ app.get('/api/system/stats', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
// --- Notification & Alerting Routes ---
|
// --- 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) => {
|
app.get('/api/agents', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const agents = DatabaseService.getInstance().getAgents();
|
const agents = DatabaseService.getInstance().getAgents();
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { apiFetch } from '@/lib/api';
|
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 {
|
interface Agent {
|
||||||
type: 'discord' | 'slack' | 'webhook';
|
type: 'discord' | 'slack' | 'webhook';
|
||||||
@@ -43,10 +55,31 @@ export function NotificationSettingsModal({ isOpen, onClose }: NotificationSetti
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
fetchAgents();
|
fetchAgents();
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
|
fetchAlerts();
|
||||||
|
fetchStacks();
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [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) => {
|
const handleAgentChange = (type: string, field: keyof Agent, value: any) => {
|
||||||
setAgents(prev => ({
|
setAgents(prev => ({
|
||||||
...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) => (
|
const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => (
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -186,11 +302,12 @@ export function NotificationSettingsModal({ isOpen, onClose }: NotificationSetti
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs defaultValue="global" className="w-full">
|
<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="global">Global</TabsTrigger>
|
||||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||||
<TabsTrigger value="slack">Slack</TabsTrigger>
|
<TabsTrigger value="slack">Slack</TabsTrigger>
|
||||||
<TabsTrigger value="webhook">Webhook</TabsTrigger>
|
<TabsTrigger value="webhook">Webhook</TabsTrigger>
|
||||||
|
<TabsTrigger value="rules">Alert Rules</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="global">
|
<TabsContent value="global">
|
||||||
@@ -258,6 +375,128 @@ export function NotificationSettingsModal({ isOpen, onClose }: NotificationSetti
|
|||||||
<TabsContent value="discord">{renderAgentTab('discord', 'Discord')}</TabsContent>
|
<TabsContent value="discord">{renderAgentTab('discord', 'Discord')}</TabsContent>
|
||||||
<TabsContent value="slack">{renderAgentTab('slack', 'Slack')}</TabsContent>
|
<TabsContent value="slack">{renderAgentTab('slack', 'Slack')}</TabsContent>
|
||||||
<TabsContent value="webhook">{renderAgentTab('webhook', 'Custom Webhook')}</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>
|
</Tabs>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user