4c1dc4082e
Build and Push Images / build-and-push (push) Has been cancelled
이전 세션들에서 작업된 아래 범위를 모두 포함: Fleet 서브시스템 (src/fleet/) - fleetDeviceService / fleetCommandService / fleetDeploymentService / fleetReleaseService - fleetMetricsService, fleetScriptService, fleetEdgeConfigService - Edge 디바이스 관리, 커맨드 발행, 배포/릴리스, 스크립트 동기화 Collector 확장 - centralMqttForwarder / centralForwarderConfigService - equipmentStateService, pythonHookRunner, scriptCache - Modbus/OPC-UA/S7/XGT 프로토콜 클라이언트 - targetDbIntrospection (저장 DB 조회) Routes / API - automationDashboardRoutes, centralForwarderRoutes, equipmentStateRoutes DB - importEdgeConfig (Python cached config → Pipeline DB) - seedDataSources (external_db_connections 초기 시드) 엣지 배포 리소스 - docker/edge/Dockerfile.backend.prod, Dockerfile.frontend.prod - docker/edge/docker-compose.edge.yml 프론트엔드 - admin/automaticMng (centralForwarder, dashboard, equipmentState) - admin/fleet (commands, devices, deployments, releases, scripts, alerts) - admin/pipeline-device 개선 (저장 DB 드롭다운, 태그 매핑 등) - ExternalDbConnectionModal, ScriptsManagerDialog 등 신규 컴포넌트 - lib/api: automationDashboard, centralForwarder, equipmentState, fleet docs/ - EDGE_SERVER_STRUCTURE, FLEET_COMPLETE, FLEET_EDGE_INTEGRATION, FLEET_HOOK_INTEGRATION Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
207 lines
9.8 KiB
TypeScript
207 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { fleetApi } from "@/lib/api/fleet";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Bell, Plus, Pencil, Trash2, RefreshCw, Loader2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
export default function FleetRulesPage() {
|
|
const [rules, setRules] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editing, setEditing] = useState<any>(null);
|
|
const [form, setForm] = useState<any>({
|
|
rule_name: "", description: "",
|
|
metric: "cpu_percent", operator: ">", threshold: 80,
|
|
duration_sec: 60, severity: "warning",
|
|
enabled: true, notify_channels: [],
|
|
});
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try { const r = await fleetApi.getAlertRules(); setRules(r.data || []); }
|
|
catch { toast.error("조회 실패"); }
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const open = (r?: any) => {
|
|
setEditing(r || null);
|
|
setForm(r ? {
|
|
rule_name: r.rule_name, description: r.description || "",
|
|
metric: r.metric, operator: r.operator, threshold: r.threshold,
|
|
duration_sec: r.duration_sec, severity: r.severity,
|
|
enabled: r.enabled,
|
|
notify_channels: r.notify_channels || [],
|
|
} : {
|
|
rule_name: "", description: "",
|
|
metric: "cpu_percent", operator: ">", threshold: 80,
|
|
duration_sec: 60, severity: "warning",
|
|
enabled: true, notify_channels: [],
|
|
});
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const save = async () => {
|
|
try {
|
|
if (editing) await fleetApi.updateAlertRule(editing.id, form);
|
|
else await fleetApi.createAlertRule(form);
|
|
toast.success("저장");
|
|
setModalOpen(false);
|
|
load();
|
|
} catch (e: any) { toast.error(e.response?.data?.message || "실패"); }
|
|
};
|
|
|
|
const del = async (id: number) => { if (!confirm("삭제?")) return; try { await fleetApi.deleteAlertRule(id); load(); } catch { toast.error("실패"); } };
|
|
const toggle = async (id: number) => { try { await fleetApi.toggleAlertRule(id); load(); } catch { toast.error("실패"); } };
|
|
|
|
const toggleChannel = (ch: string) => {
|
|
const chs = form.notify_channels.includes(ch) ? form.notify_channels.filter((c: string) => c !== ch) : [...form.notify_channels, ch];
|
|
setForm({ ...form, notify_channels: chs });
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full flex-col bg-background">
|
|
<div className="shrink-0 p-6 pb-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold flex items-center gap-2"><Bell className="h-5 w-5" /> 알림 규칙</h1>
|
|
<p className="text-sm text-muted-foreground">임계값 기반 자동 알림 규칙 관리</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={load}><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /></Button>
|
|
<Button size="sm" onClick={() => open()}><Plus className="h-4 w-4 mr-1" />규칙 추가</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto px-6 pb-6 space-y-2">
|
|
{loading ? <Loader2 className="mx-auto my-20 animate-spin" /> :
|
|
rules.map((r) => (
|
|
<div key={r.id} className="rounded-xl border bg-card p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-semibold">{r.rule_name}</span>
|
|
<Badge className={
|
|
r.severity === "critical" ? "bg-red-500/10 text-red-600" :
|
|
r.severity === "warning" ? "bg-amber-500/10 text-amber-600" :
|
|
"bg-blue-500/10 text-blue-600"
|
|
}>{r.severity}</Badge>
|
|
<span className="text-[10px] text-muted-foreground">#{r.id}</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
<code>{r.metric} {r.operator} {r.threshold}</code> · {r.duration_sec}초 유지
|
|
</p>
|
|
{r.description && <p className="text-[11px] mt-1">{r.description}</p>}
|
|
<div className="flex gap-2 mt-2">
|
|
{(r.notify_channels || []).map((ch: string) => (
|
|
<Badge key={ch} variant="outline" className="text-[10px]">{ch}</Badge>
|
|
))}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground mt-1">
|
|
총 알림 {r.alert_count || 0}건 · 미처리 {r.open_count || 0}건
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-1 items-center">
|
|
<Switch checked={r.enabled} onCheckedChange={() => toggle(r.id)} />
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => open(r)}><Pencil className="h-3.5 w-3.5" /></Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => del(r.id)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
}
|
|
</div>
|
|
|
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader><DialogTitle>{editing ? "규칙 수정" : "새 규칙"}</DialogTitle></DialogHeader>
|
|
<div className="space-y-3 pt-2">
|
|
<div>
|
|
<Label className="text-xs">이름 *</Label>
|
|
<Input value={form.rule_name} onChange={(e) => setForm({...form, rule_name: e.target.value})} className="mt-1" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">설명</Label>
|
|
<Textarea value={form.description} onChange={(e) => setForm({...form, description: e.target.value})} rows={2} className="mt-1" />
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div>
|
|
<Label className="text-xs">메트릭</Label>
|
|
<Select value={form.metric} onValueChange={(v) => setForm({...form, metric: v})}>
|
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="cpu_percent">CPU %</SelectItem>
|
|
<SelectItem value="memory_percent">메모리 %</SelectItem>
|
|
<SelectItem value="disk_percent">디스크 %</SelectItem>
|
|
<SelectItem value="offline_duration">오프라인 초</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">연산자</Label>
|
|
<Select value={form.operator} onValueChange={(v) => setForm({...form, operator: v})}>
|
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value=">">></SelectItem>
|
|
<SelectItem value=">=">>=</SelectItem>
|
|
<SelectItem value="<"><</SelectItem>
|
|
<SelectItem value="<="><=</SelectItem>
|
|
<SelectItem value="==">==</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">임계값</Label>
|
|
<Input type="number" value={form.threshold} onChange={(e) => setForm({...form, threshold: parseFloat(e.target.value)})} className="mt-1" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-xs">지속 시간(초)</Label>
|
|
<Input type="number" value={form.duration_sec} onChange={(e) => setForm({...form, duration_sec: parseInt(e.target.value)})} className="mt-1" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">심각도</Label>
|
|
<Select value={form.severity} onValueChange={(v) => setForm({...form, severity: v})}>
|
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="info">info</SelectItem>
|
|
<SelectItem value="warning">warning</SelectItem>
|
|
<SelectItem value="critical">critical</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">알림 채널</Label>
|
|
<div className="flex gap-2 mt-1">
|
|
{["email", "messenger", "webhook", "sms"].map((ch) => (
|
|
<label key={ch} className="flex items-center gap-1 text-xs cursor-pointer">
|
|
<input type="checkbox" checked={form.notify_channels.includes(ch)} onChange={() => toggleChannel(ch)} />
|
|
{ch}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
|
<Button onClick={save}>{editing ? "수정" : "생성"}</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|