feat: Fleet/Collector/엣지 배포 관련 누적 작업 일괄 커밋
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>
This commit is contained in:
chpark
2026-04-23 20:00:06 +09:00
parent 01625d9efd
commit 4c1dc4082e
77 changed files with 14639 additions and 205 deletions
@@ -299,8 +299,12 @@ export default function BatchCreatePage() {
null,
);
const [aiInputMessage, setAiInputMessage] = useState("");
const [aiNotifySystem, setAiNotifySystem] = useState(false);
const [aiNotifyMessenger, setAiNotifyMessenger] = useState(false);
const [aiMessengerRecipients, setAiMessengerRecipients] = useState<string[]>([]);
const [aiNotifyEmail, setAiNotifyEmail] = useState(false);
const [aiEmailAddresses, setAiEmailAddresses] = useState("");
const [aiWebhookUrl, setAiWebhookUrl] = useState("");
const [companyUsers, setCompanyUsers] = useState<any[]>([]);
// Step 3: Crawling state
const [crawlConfigs, setCrawlConfigs] = useState<any[]>([]);
@@ -670,8 +674,12 @@ export default function BatchCreatePage() {
ai_group_id: selectedAiGroupId,
ai_input_message: aiInputMessage || undefined,
notification: {
system_notice: aiNotifySystem,
webhook_url: aiWebhookUrl || undefined,
messenger: aiNotifyMessenger,
messenger_recipients: aiNotifyMessenger ? aiMessengerRecipients : undefined,
email: aiNotifyEmail && aiEmailAddresses
? aiEmailAddresses.split(",").map((e) => e.trim()).filter(Boolean)
: undefined,
webhook: aiWebhookUrl || undefined,
},
},
});
@@ -1532,18 +1540,87 @@ export default function BatchCreatePage() {
{/* Notification settings */}
<div className="space-y-4 rounded-xl border p-4">
<h3 className="text-xs font-bold"> </h3>
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium"> </p>
<p className="text-[10px] text-muted-foreground">
</p>
{/* 메신저 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium"> </p>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
<Switch
checked={aiNotifyMessenger}
onCheckedChange={async (v) => {
setAiNotifyMessenger(v);
if (v && companyUsers.length === 0) {
try {
const res = await fetch("/api/messenger/users", { credentials: "include" });
const data = await res.json();
setCompanyUsers(data.data || data || []);
} catch {}
}
}}
/>
</div>
<Switch
checked={aiNotifySystem}
onCheckedChange={setAiNotifySystem}
/>
{aiNotifyMessenger && (
<div className="space-y-1.5 pl-1">
<Label className="text-xs"> </Label>
<div className="max-h-32 overflow-y-auto rounded-lg border p-2 space-y-1">
{companyUsers.length === 0 ? (
<p className="text-[11px] text-muted-foreground"> ...</p>
) : (
companyUsers.map((u: any) => (
<label key={u.user_id} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-muted/50 rounded px-2 py-1">
<input
type="checkbox"
checked={aiMessengerRecipients.includes(u.user_id)}
onChange={(e) => {
if (e.target.checked) {
setAiMessengerRecipients([...aiMessengerRecipients, u.user_id]);
} else {
setAiMessengerRecipients(aiMessengerRecipients.filter((id) => id !== u.user_id));
}
}}
/>
<span>{u.user_name} <span className="text-muted-foreground">({u.user_id})</span></span>
</label>
))
)}
</div>
</div>
)}
</div>
{/* 이메일 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium"> </p>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
<Switch
checked={aiNotifyEmail}
onCheckedChange={setAiNotifyEmail}
/>
</div>
{aiNotifyEmail && (
<div className="space-y-1.5 pl-1">
<Label className="text-xs"> ( )</Label>
<Input
value={aiEmailAddresses}
onChange={(e) => setAiEmailAddresses(e.target.value)}
placeholder="user1@example.com, user2@example.com"
className="h-9 text-sm"
/>
</div>
)}
</div>
{/* 웹훅 */}
<div className="space-y-1.5">
<Label className="text-xs">
URL{" "}
@@ -739,9 +739,9 @@ export default function BatchEditPage() {
}
return (
<div className="mx-auto h-full max-w-[640px] space-y-7 overflow-y-auto p-4 sm:p-6">
<div className="mx-auto h-full w-full max-w-[1400px] overflow-y-auto p-4 sm:p-6">
{/* 헤더 */}
<div>
<div className="mb-5">
<button onClick={goBack} className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<ArrowLeft className="h-3.5 w-3.5" />
@@ -767,6 +767,9 @@ export default function BatchEditPage() {
</div>
</div>
{/* 2컬럼 레이아웃: 좌측 = 기본정보/스케줄, 우측 = 실행방식/상세설정 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="space-y-7">
{/* 기본 정보 */}
<div>
<h2 className="mb-3 text-sm font-bold"> </h2>
@@ -884,11 +887,15 @@ export default function BatchEditPage() {
</div>
</div>
</div>
</div>
{/* 우측 컬럼: 실행 방식 + 상세 설정 */}
<div className="space-y-7">
{/* 실행 타입 선택 */}
<div>
<h2 className="mb-3 text-sm font-bold"> </h2>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
<div className="grid grid-cols-2 gap-3">
{([
{ key: "mapping" as BatchExecutionType, label: "DB to DB", desc: "테이블 간 데이터를 옮겨요", icon: <Database className="h-5 w-5" /> },
{ key: "rest_api_sync" as BatchExecutionType, label: "REST API", desc: "외부 API 데이터를 DB에 저장해요", icon: <Globe className="h-5 w-5" /> },
@@ -979,10 +986,12 @@ export default function BatchEditPage() {
)}
</div>
)}
</div>
</div>
{/* FROM/TO 섹션 가로 배치 (매핑 타입일 때만) */}
{/* FROM/TO 섹션 + 매핑 (전체 너비) */}
{executionType === "mapping" && (
<>
<div className="mt-6 space-y-6">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* FROM 설정 */}
<div className="space-y-3 rounded-lg border border-emerald-500/20 p-4 sm:p-5">
@@ -1561,8 +1570,8 @@ export default function BatchEditPage() {
)}
</div>
{/* restapi-to-db 새로운 매핑 UI */}
{batchType === "restapi-to-db" && (
{/* restapi-to-db 새로운 매핑 UI (batchType이 restapi-to-db이거나, 아직 감지 안됐는데 mappingList 있으면 표시) */}
{(batchType === "restapi-to-db" || (!batchType && mappingList.length > 0)) && (
<>
{mappingList.length === 0 ? (
<div className="flex h-[360px] flex-col items-center justify-center rounded-lg border border-dashed text-center">
@@ -1825,11 +1834,11 @@ export default function BatchEditPage() {
</div>
</div>
</div>
</>
</div>
)}
{/* 하단 버튼 */}
<div className="flex justify-end gap-2 border-t pt-5">
<div className="mt-6 flex justify-end gap-2 border-t pt-5">
<Button variant="outline" size="sm" onClick={goBack} className="h-9 text-xs"></Button>
<Button
size="sm"
@@ -315,7 +315,6 @@ export default function BatchManagementPage() {
const [stats, setStats] = useState<BatchStats | null>(null);
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
const loadBatchConfigs = useCallback(async () => {
@@ -408,11 +407,6 @@ export default function BatchManagementPage() {
}
};
const handleBatchTypeSelect = (type: string) => {
setIsBatchTypeModalOpen(false);
openTab({ type: "admin", title: "새 배치 생성", adminUrl: "/admin/automaticMng/batchmngList/create" });
};
const filteredBatches = batchConfigs.filter((batch) => {
if (searchTerm && !batch.batch_name.toLowerCase().includes(searchTerm.toLowerCase()) && !(batch.description || "").toLowerCase().includes(searchTerm.toLowerCase())) return false;
if (statusFilter === "active" && batch.is_active !== "Y") return false;
@@ -439,7 +433,7 @@ export default function BatchManagementPage() {
<button onClick={loadBatchConfigs} disabled={loading} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button>
<Button size="sm" onClick={() => setIsBatchTypeModalOpen(true)} className="h-8 gap-1 text-xs">
<Button size="sm" onClick={() => openTab({ type: "admin", title: "새 배치 생성", adminUrl: "/admin/automaticMng/batchmngList/create" })} className="h-8 gap-1 text-xs">
<Plus className="h-3.5 w-3.5" />
</Button>
@@ -613,42 +607,6 @@ export default function BatchManagementPage() {
</div>
)}
{/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
<h2 className="mb-1 text-base font-bold"> ?</h2>
<p className="mb-5 text-xs text-muted-foreground"> </p>
<div className="space-y-2">
{[
{ type: "db-to-db", icon: Database, iconColor: "text-cyan-500", title: "DB to DB", desc: "테이블 간 데이터를 옮겨요" },
{ type: "restapi-to-db", icon: Globe, iconColor: "text-violet-500", title: "REST API", desc: "외부 API 데이터를 DB에 저장해요" },
{ type: "device", icon: Cpu, iconColor: "text-purple-500", title: "장비 수집", desc: "PLC/Modbus 장비에서 수집해요" },
{ type: "crawling", icon: Globe, iconColor: "text-rose-500", title: "크롤링", desc: "웹 페이지 데이터를 수집해요" },
{ type: "node-flow", icon: Workflow, iconColor: "text-indigo-500", title: "노드 플로우", desc: "만들어 둔 플로우를 실행해요" },
{ type: "ai-agent", icon: Bot, iconColor: "text-amber-500", title: "AI 에이전트", desc: "멀티 에이전트 그룹을 실행해요" },
].map((opt) => (
<button
key={opt.type}
className="flex w-full items-center gap-3.5 rounded-lg border p-4 text-left transition-all hover:border-primary/30 hover:bg-primary/5"
onClick={() => handleBatchTypeSelect(opt.type)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<opt.icon className={`h-[18px] w-[18px] ${opt.iconColor}`} />
</div>
<div>
<p className="text-sm font-semibold">{opt.title}</p>
<p className="text-[11px] text-muted-foreground">{opt.desc}</p>
</div>
</button>
))}
</div>
<button onClick={() => setIsBatchTypeModalOpen(false)} className="mt-4 w-full rounded-md border py-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
</button>
</div>
</div>
)}
</div>
<ScrollToTop />
</div>
@@ -0,0 +1,415 @@
"use client";
import React, { useEffect, useState } from "react";
import { Plus, Pencil, Trash2, Power, RefreshCw, Radio } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import {
CentralForwarderAPI,
CentralForwarderConfig,
ForwarderRuntimeStatus,
} from "@/lib/api/centralForwarder";
const emptyForm: CentralForwarderConfig = {
config_name: "",
company_code: "*",
company_id: "",
edge_id: "",
broker_host: "211.115.91.170",
broker_port: 31883,
username: "ingestion",
password: "",
use_tls: "N",
client_id_prefix: "pipeline-forwarder",
topic_pattern: "dt/v1/data/{company_id}/{edge_id}",
status_topic_pattern: "dt/v1/status/{company_id}/{edge_id}",
batch_size: 50,
batch_timeout_ms: 3000,
heartbeat_interval_sec: 60,
qos: 1,
is_enabled: "N",
description: "",
};
export default function CentralForwarderPage() {
const { toast } = useToast();
const [configs, setConfigs] = useState<CentralForwarderConfig[]>([]);
const [runtime, setRuntime] = useState<ForwarderRuntimeStatus[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState<CentralForwarderConfig>({ ...emptyForm });
const [editingId, setEditingId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const load = async () => {
setLoading(true);
try {
const [list, rt] = await Promise.all([
CentralForwarderAPI.list(),
CentralForwarderAPI.runtimeStatus(),
]);
setConfigs(list);
setRuntime(rt);
} catch (err) {
toast({
title: "조회 실패",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
const t = setInterval(load, 10000);
return () => clearInterval(t);
}, []);
const rtMap = new Map(runtime.map(r => [r.config_id, r]));
const openCreate = () => {
setEditingId(null);
setForm({ ...emptyForm });
setModalOpen(true);
};
const openEdit = async (id: number) => {
try {
const cfg = await CentralForwarderAPI.get(id);
setEditingId(id);
setForm({ ...cfg, password: "" }); // 비밀번호는 비움 (필요 시 새로 입력)
setModalOpen(true);
} catch (err) {
toast({
title: "조회 실패",
description: (err as Error).message,
variant: "destructive",
});
}
};
const save = async () => {
setSaving(true);
try {
if (editingId) {
const payload = { ...form };
if (!payload.password) delete (payload as { password?: string }).password;
await CentralForwarderAPI.update(editingId, payload);
} else {
await CentralForwarderAPI.create(form);
}
toast({ title: "저장 완료" });
setModalOpen(false);
await load();
} catch (err) {
toast({
title: "저장 실패",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setSaving(false);
}
};
const toggle = async (id: number, enabled: boolean) => {
try {
await CentralForwarderAPI.toggle(id, enabled);
toast({ title: enabled ? "포워더 시작" : "포워더 중지" });
await load();
} catch (err) {
toast({
title: "상태 변경 실패",
description: (err as Error).message,
variant: "destructive",
});
}
};
const remove = async (id: number) => {
if (!confirm("이 포워더 설정을 삭제하시겠습니까?")) return;
try {
await CentralForwarderAPI.delete(id);
toast({ title: "삭제 완료" });
await load();
} catch (err) {
toast({
title: "삭제 실패",
description: (err as Error).message,
variant: "destructive",
});
}
};
return (
<div className="h-full overflow-y-auto bg-background">
<div className="space-y-4 p-4 sm:p-5">
<div className="flex items-center justify-between border-b pb-3">
<div>
<h1 className="flex items-center gap-2 text-lg font-bold tracking-tight">
<Radio className="h-4 w-4" />
MQTT
</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
IDC EMQX로 (Pipeline = Edge )
</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={load}>
<RefreshCw className="h-3 w-3" />
</Button>
<Button size="sm" className="h-8 gap-1 text-xs" onClick={openCreate}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{loading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-44 animate-pulse rounded-lg border bg-muted/30" />
))}
</div>
) : configs.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-lg border border-dashed">
<Radio className="mb-2 h-5 w-5 text-muted-foreground" />
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{configs.map(cfg => {
const rt = rtMap.get(cfg.id!);
const enabled = cfg.is_enabled === "Y";
return (
<div key={cfg.id} className="flex flex-col rounded-lg border bg-card p-3.5">
<div className="mb-2 flex items-center justify-between">
<Badge variant={enabled ? "default" : "secondary"} className="h-5 text-[10px]">
{enabled ? "활성" : "비활성"}
</Badge>
{rt && (
<Badge
variant={rt.connected ? "default" : "destructive"}
className="h-5 text-[10px]"
>
{rt.connected ? "연결됨" : "연결끊김"}
</Badge>
)}
</div>
<h3 className="mb-0.5 truncate text-xs font-semibold">{cfg.config_name}</h3>
<p className="mb-2 truncate text-[11px] text-muted-foreground">
{cfg.company_code === "*" ? "공통" : cfg.company_code}
<span className="mx-1">·</span>
{cfg.edge_id}
</p>
<div className="mb-3 space-y-0.5 rounded-md bg-muted/50 px-2 py-1.5">
<p className="truncate font-mono text-[10px] text-muted-foreground">
{cfg.broker_host}:{cfg.broker_port}
</p>
<p className="truncate font-mono text-[10px]">{cfg.topic_pattern}</p>
{rt && (
<p className="text-[10px] text-muted-foreground">
{rt.messagesForwarded} · {rt.messagesFailed} · {rt.buffered}
</p>
)}
</div>
<div className="mt-auto flex items-center gap-1">
<Button
variant={enabled ? "secondary" : "default"}
size="sm"
onClick={() => toggle(cfg.id!, !enabled)}
className="h-6 flex-1 gap-1 text-[10px]"
>
<Power className="h-3 w-3" />
{enabled ? "중지" : "시작"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openEdit(cfg.id!)}
className="h-6 px-2"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => remove(cfg.id!)}
className="h-6 px-2 text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[640px]">
<DialogHeader>
<DialogTitle className="text-sm">
{editingId ? "포워더 설정 수정" : "새 포워더 설정"}
</DialogTitle>
<DialogDescription className="text-xs">
IDC MQTT(EMQX) .
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field
label="설정명"
value={form.config_name}
onChange={v => setForm({ ...form, config_name: v })}
/>
<Field
label="회사 코드"
value={form.company_code || "*"}
onChange={v => setForm({ ...form, company_code: v })}
/>
<Field
label="Company ID (MQTT 토픽)"
value={form.company_id}
onChange={v => setForm({ ...form, company_id: v })}
placeholder="예: 7f5c058c-ef65-45e3-..."
/>
<Field
label="Edge ID"
value={form.edge_id}
onChange={v => setForm({ ...form, edge_id: v })}
placeholder="예: edge-0f4d04ed"
/>
<Field
label="Broker Host"
value={form.broker_host}
onChange={v => setForm({ ...form, broker_host: v })}
/>
<Field
type="number"
label="Broker Port"
value={String(form.broker_port)}
onChange={v => setForm({ ...form, broker_port: Number(v) })}
/>
<Field
label="Username"
value={form.username || ""}
onChange={v => setForm({ ...form, username: v })}
/>
<Field
type="password"
label={editingId ? "Password (변경 시 입력)" : "Password"}
value={form.password || ""}
onChange={v => setForm({ ...form, password: v })}
/>
<Field
label="토픽 패턴"
value={form.topic_pattern || ""}
onChange={v => setForm({ ...form, topic_pattern: v })}
className="sm:col-span-2"
/>
<Field
type="number"
label="배치 크기"
value={String(form.batch_size || 50)}
onChange={v => setForm({ ...form, batch_size: Number(v) })}
/>
<Field
type="number"
label="배치 타임아웃 (ms)"
value={String(form.batch_timeout_ms || 3000)}
onChange={v => setForm({ ...form, batch_timeout_ms: Number(v) })}
/>
<Field
type="number"
label="하트비트 (초)"
value={String(form.heartbeat_interval_sec || 60)}
onChange={v => setForm({ ...form, heartbeat_interval_sec: Number(v) })}
/>
<Field
type="number"
label="QoS (0/1/2)"
value={String(form.qos ?? 1)}
onChange={v => setForm({ ...form, qos: Number(v) })}
/>
<div className="flex items-center gap-2 sm:col-span-2">
<Switch
checked={form.is_enabled === "Y"}
onCheckedChange={c => setForm({ ...form, is_enabled: c ? "Y" : "N" })}
/>
<Label className="text-xs"> ( )</Label>
</div>
<div className="sm:col-span-2">
<Label className="text-xs"></Label>
<Input
className="mt-1 h-8 text-xs"
value={form.description || ""}
onChange={e => setForm({ ...form, description: e.target.value })}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setModalOpen(false)}
className="h-8 text-xs"
>
</Button>
<Button size="sm" onClick={save} disabled={saving} className="h-8 text-xs">
{saving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function Field({
label,
value,
onChange,
type = "text",
placeholder,
className,
}: {
label: string;
value: string;
onChange: (v: string) => void;
type?: string;
placeholder?: string;
className?: string;
}) {
return (
<div className={className}>
<Label className="text-xs">{label}</Label>
<Input
className="mt-1 h-8 text-xs"
type={type}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
/>
</div>
);
}
@@ -0,0 +1,402 @@
"use client";
import { useEffect, useState } from "react";
import {
Clock,
Cpu,
Radio,
Activity,
CheckCircle2,
XCircle,
RefreshCw,
Zap,
Database as DatabaseIcon,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import {
AutomationDashboardAPI,
DashboardOverview,
} from "@/lib/api/automationDashboard";
function timeAgo(iso: string | null | undefined): string {
if (!iso) return "—";
const diff = Date.now() - new Date(iso).getTime();
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}초 전`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}분 전`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}시간 전`;
return `${Math.floor(hr / 24)}일 전`;
}
function cronToKo(c: string): string {
if (!c) return "—";
const p = c.split(" ");
if (p.length < 5) return c;
const [m, h] = p;
if (m.startsWith("*/")) return `${m.slice(2)}분마다`;
if (h.startsWith("*/")) return `${h.slice(2)}시간마다`;
if (h !== "*" && m !== "*") return `매일 ${h.padStart(2, "0")}:${m.padStart(2, "0")}`;
return c;
}
export default function AutomationDashboardPage() {
const { toast } = useToast();
const [data, setData] = useState<DashboardOverview | null>(null);
const [loading, setLoading] = useState(true);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const load = async () => {
setLoading(true);
try {
const d = await AutomationDashboardAPI.overview();
setData(d);
setLastRefresh(new Date());
} catch (err) {
toast({
title: "조회 실패",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
const t = setInterval(load, 15000);
return () => clearInterval(t);
}, []);
if (loading && !data) {
return (
<div className="flex h-full items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (!data) return null;
const { stats, batches, pollings, forwarders } = data;
return (
<div className="h-full overflow-y-auto bg-background">
<div className="space-y-5 p-4 sm:p-6">
{/* 헤더 */}
<div className="flex items-center justify-between border-b pb-3">
<div>
<h1 className="flex items-center gap-2 text-lg font-bold">
<Activity className="h-4 w-4" />
</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
+ + IDC (15 )
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">
{lastRefresh
? `마지막: ${lastRefresh.toLocaleTimeString("ko-KR")}`
: ""}
</span>
<Button
size="sm"
variant="outline"
onClick={load}
className="h-8 gap-1 text-xs"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatCard
icon={<Clock className="h-4 w-4 text-blue-600" />}
label="크론 배치"
value={`${stats.batches_active} / ${stats.batches_total}`}
sublabel="활성 / 전체"
/>
<StatCard
icon={<Cpu className="h-4 w-4 text-emerald-600" />}
label="장비 폴링"
value={`${stats.pollings_active} / ${stats.pollings_total}`}
sublabel={`연결됨 ${stats.pollings_connected}`}
/>
<StatCard
icon={<Zap className="h-4 w-4 text-amber-600" />}
label="수집 태그"
value={String(stats.total_tags)}
sublabel="전체 등록"
/>
<StatCard
icon={<Radio className="h-4 w-4 text-purple-600" />}
label="IDC 전송 누적"
value={stats.messages_forwarded_total.toLocaleString()}
sublabel={`포워더 ${stats.forwarders_enabled}/${stats.forwarders_total}`}
/>
</div>
{/* 3열 (데스크탑) / 세로 (모바일) */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* 크론 배치 */}
<Section title="크론 배치 (배치 관리)" icon={<Clock className="h-4 w-4" />} href="/admin/automaticMng/batchmngList">
{batches.length === 0 ? (
<Empty msg="등록된 배치 없음" />
) : (
<div className="space-y-1 overflow-hidden rounded-md border">
{batches.slice(0, 8).map((b) => (
<div
key={b.id}
className="flex items-center gap-2 border-b px-2 py-1.5 text-[11px] last:border-b-0 hover:bg-muted/40"
>
<Badge
variant={b.is_active === "Y" || b.is_active === true ? "default" : "secondary"}
className="h-4 text-[9px]"
>
{b.is_active === "Y" || b.is_active === true ? "ON" : "OFF"}
</Badge>
<span className="flex-1 truncate font-medium" title={b.batch_name}>
{b.batch_name}
</span>
<span className="font-mono text-[10px] text-muted-foreground">
{cronToKo(b.cron_schedule)}
</span>
<span className="text-[10px] text-muted-foreground">
{timeAgo(b.last_run_date)}
</span>
{b.last_run_result === "success" ? (
<CheckCircle2 className="h-3 w-3 text-emerald-600" />
) : b.last_run_result === "failure" ? (
<XCircle className="h-3 w-3 text-destructive" />
) : null}
</div>
))}
{batches.length > 8 && (
<div className="px-2 py-1 text-center text-[10px] text-muted-foreground">
+ {batches.length - 8}
</div>
)}
</div>
)}
</Section>
{/* 장비 폴링 */}
<Section title="장비 실시간 폴링 (장비 통신)" icon={<Cpu className="h-4 w-4" />} href="/admin/pipeline-device">
{pollings.length === 0 ? (
<Empty msg="등록된 장비 통신 없음" />
) : (
<div className="space-y-1 overflow-hidden rounded-md border">
{pollings.slice(0, 10).map((p) => {
const active = p.is_active === "Y";
const connected = p.status === "active";
const hasTarget = p.target_db_connection_id !== null && p.target_table_name;
return (
<div
key={p.id}
className="flex items-center gap-2 border-b px-2 py-1.5 text-[11px] last:border-b-0 hover:bg-muted/40"
>
<Badge
variant={active ? (connected ? "default" : "secondary") : "outline"}
className="h-4 text-[9px]"
>
{active ? (connected ? "정상" : "대기") : "OFF"}
</Badge>
<Badge variant="outline" className="h-4 text-[9px]">
{p.protocol}
</Badge>
<span className="flex-1 truncate font-medium" title={p.connection_name}>
{p.connection_name}
</span>
<span className="font-mono text-[10px] text-muted-foreground">
{p.polling_interval_ms}ms · {p.tag_count}
</span>
{hasTarget && (
<DatabaseIcon className="h-3 w-3 text-blue-500" aria-label="DB 저장 설정됨" />
)}
<span className="text-[10px] text-muted-foreground">
{timeAgo(p.last_collected_at)}
</span>
</div>
);
})}
{pollings.length > 10 && (
<div className="px-2 py-1 text-center text-[10px] text-muted-foreground">
+ {pollings.length - 10}
</div>
)}
</div>
)}
</Section>
{/* IDC 포워더 */}
<Section title="IDC MQTT 포워더" icon={<Radio className="h-4 w-4" />} href="/admin/automaticMng/centralForwarder" className="lg:col-span-2">
{forwarders.length === 0 ? (
<Empty msg="등록된 포워더 없음" />
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{forwarders.map((f) => {
const enabled = f.is_enabled === "Y";
const connected = f.is_connected === "Y";
return (
<div
key={f.id}
className="rounded-md border p-2.5 text-[11px]"
>
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Badge
variant={enabled ? "default" : "secondary"}
className="h-4 text-[9px]"
>
{enabled ? "활성" : "비활성"}
</Badge>
{enabled && (
<Badge
variant={connected ? "default" : "destructive"}
className="h-4 text-[9px]"
>
{connected ? "연결됨" : "끊김"}
</Badge>
)}
<span className="ml-1 truncate font-semibold">
{f.config_name}
</span>
</div>
</div>
<div className="truncate font-mono text-[10px] text-muted-foreground">
{f.broker_host}:{f.broker_port} · edge={f.edge_id}
</div>
<div className="mt-1 truncate font-mono text-[10px] text-muted-foreground">
{f.topic_pattern}
</div>
<div className="mt-2 grid grid-cols-4 gap-1 text-[10px]">
<Stat
label="전송"
value={(f.messages_forwarded || 0).toLocaleString()}
color="text-emerald-600"
/>
<Stat
label="실패"
value={(f.messages_failed || 0).toLocaleString()}
color="text-amber-600"
/>
<Stat
label="유실"
value={(f.messages_dropped || 0).toLocaleString()}
color="text-destructive"
/>
<Stat
label="배치"
value={(f.batches_sent || 0).toLocaleString()}
/>
</div>
<div className="mt-1 text-[10px] text-muted-foreground">
: {timeAgo(f.last_published_at)}
{f.last_error && (
<span className="ml-1 text-destructive">
· <AlertTriangle className="inline h-3 w-3" /> {f.last_error.slice(0, 40)}
</span>
)}
</div>
</div>
);
})}
</div>
)}
</Section>
</div>
</div>
</div>
);
}
function StatCard({
icon,
label,
value,
sublabel,
}: {
icon: React.ReactNode;
label: string;
value: string;
sublabel?: string;
}) {
return (
<div className="rounded-lg border bg-card p-3">
<div className="mb-1 flex items-center gap-1.5">
{icon}
<span className="text-[11px] font-medium text-muted-foreground">{label}</span>
</div>
<div className="text-xl font-bold">{value}</div>
{sublabel && <div className="text-[10px] text-muted-foreground">{sublabel}</div>}
</div>
);
}
function Section({
title,
icon,
children,
href,
className = "",
}: {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
href?: string;
className?: string;
}) {
return (
<div className={`rounded-lg border bg-card p-3 ${className}`}>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1.5">
{icon}
<h2 className="text-xs font-semibold">{title}</h2>
</div>
{href && (
<a
href={href}
className="text-[10px] text-blue-600 hover:underline"
>
</a>
)}
</div>
{children}
</div>
);
}
function Empty({ msg }: { msg: string }) {
return (
<div className="flex h-20 items-center justify-center rounded-md border border-dashed text-[11px] text-muted-foreground">
{msg}
</div>
);
}
function Stat({
label,
value,
color,
}: {
label: string;
value: string;
color?: string;
}) {
return (
<div className="rounded bg-muted/40 px-1.5 py-0.5 text-center">
<div className="text-[9px] text-muted-foreground">{label}</div>
<div className={`font-mono font-semibold ${color || ""}`}>{value}</div>
</div>
);
}
@@ -0,0 +1,214 @@
"use client";
import React, { useEffect, useState } from "react";
import { RefreshCw, Cpu, Activity, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import {
EquipmentStateAPI,
ConnectionStatusSummary,
EquipmentTagState,
} from "@/lib/api/equipmentState";
export default function EquipmentStatePage() {
const { toast } = useToast();
const [summary, setSummary] = useState<ConnectionStatusSummary[]>([]);
const [expanded, setExpanded] = useState<Record<number, EquipmentTagState[]>>({});
const [loadingId, setLoadingId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const load = async () => {
setLoading(true);
try {
const data = await EquipmentStateAPI.summary();
setSummary(data);
} catch (err) {
toast({
title: "조회 실패",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
const t = setInterval(load, 15000);
return () => clearInterval(t);
}, []);
const toggleExpand = async (connectionId: number) => {
if (expanded[connectionId]) {
const next = { ...expanded };
delete next[connectionId];
setExpanded(next);
return;
}
setLoadingId(connectionId);
try {
const tags = await EquipmentStateAPI.tagsByConnection(connectionId);
setExpanded(prev => ({ ...prev, [connectionId]: tags }));
} catch (err) {
toast({
title: "태그 조회 실패",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setLoadingId(null);
}
};
const filtered = summary.filter(
s =>
!search ||
s.connection_name?.toLowerCase().includes(search.toLowerCase()) ||
s.host?.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="h-full overflow-y-auto bg-background">
<div className="space-y-4 p-4 sm:p-5">
<div className="flex items-center justify-between border-b pb-3">
<div>
<h1 className="flex items-center gap-2 text-lg font-bold tracking-tight">
<Activity className="h-4 w-4" />
</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
(15 )
</p>
</div>
<div className="flex items-center gap-2">
<Input
className="h-8 w-48 text-xs"
placeholder="장비명/호스트 검색..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={load}>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
</div>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 animate-pulse rounded-lg border bg-muted/30" />
))}
</div>
) : filtered.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-lg border border-dashed">
<Cpu className="mb-2 h-5 w-5 text-muted-foreground" />
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2">
{filtered.map(s => {
const isOpen = !!expanded[s.connection_id];
const isHealthy = s.connection_status === "active" || s.connection_status === "connected";
return (
<div key={s.connection_id} className="rounded-lg border bg-card">
<button
onClick={() => toggleExpand(s.connection_id)}
className="flex w-full items-center gap-3 p-3 text-left hover:bg-muted/30"
>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Badge
variant={isHealthy ? "default" : "destructive"}
className="h-5 text-[10px]"
>
{s.connection_status || "unknown"}
</Badge>
<span className="text-xs font-semibold">{s.connection_name}</span>
<Badge variant="secondary" className="h-5 text-[10px]">
{s.protocol}
</Badge>
<span className="text-[11px] text-muted-foreground">
{s.host}:{s.port}
</span>
<div className="ml-auto flex items-center gap-3 text-[11px]">
<span>
<span className="font-medium">{s.tag_count}</span>
</span>
<span className="text-emerald-600">
<span className="font-medium">{s.good_tag_count}</span>
</span>
<span className="text-muted-foreground">
:{" "}
{s.last_collected_at
? new Date(s.last_collected_at).toLocaleString()
: "—"}
</span>
</div>
</button>
{isOpen && (
<div className="border-t bg-muted/10 p-3">
{loadingId === s.connection_id ? (
<p className="text-[11px] text-muted-foreground"> ...</p>
) : (expanded[s.connection_id] || []).length === 0 ? (
<p className="text-[11px] text-muted-foreground"> </p>
) : (
<table className="w-full text-[11px]">
<thead className="text-muted-foreground">
<tr className="border-b">
<th className="p-1 text-left font-normal">Tag</th>
<th className="p-1 text-right font-normal">Value</th>
<th className="p-1 text-left font-normal">Unit</th>
<th className="p-1 text-left font-normal">Quality</th>
<th className="p-1 text-left font-normal">Last Collected</th>
</tr>
</thead>
<tbody>
{(expanded[s.connection_id] || []).map(t => {
const v =
t.value_numeric ??
(t.value_boolean !== null
? String(t.value_boolean)
: t.value_text) ??
"—";
return (
<tr key={t.id} className="border-b last:border-b-0">
<td className="p-1 font-mono">{t.tag_name}</td>
<td className="p-1 text-right font-mono">{String(v)}</td>
<td className="p-1">{t.tag_unit || ""}</td>
<td className="p-1">
<Badge
variant={t.quality === "good" ? "default" : "destructive"}
className="h-4 text-[9px]"
>
{t.quality}
</Badge>
</td>
<td className="p-1 text-muted-foreground">
{new Date(t.last_collected_at).toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe, Cpu, FileText, Bug } from "lucide-react";
import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe, Cpu, FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -31,7 +31,7 @@ import dynamic from "next/dynamic";
const PipelineDevicePage = dynamic(() => import("@/app/(main)/admin/pipeline-device/page"), { ssr: false });
type ConnectionTabType = "database" | "rest-api" | "device" | "file-reader" | "crawling";
type ConnectionTabType = "database" | "rest-api" | "device" | "file-reader";
// DB 타입 매핑
const DB_TYPE_LABELS: Record<string, string> = {
@@ -200,16 +200,12 @@ export default function DataSourcePage() {
</TabsTrigger>
<TabsTrigger value="device" className="gap-1.5 px-3 text-xs">
<Cpu className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="file-reader" className="gap-1.5 px-3 text-xs">
<FileText className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="crawling" className="gap-1.5 px-3 text-xs">
<Bug className="h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 데이터베이스 탭 */}
@@ -396,11 +392,6 @@ export default function DataSourcePage() {
<TabsContent value="file-reader" className="mt-4">
<ComingSoon icon={FileText} title="파일 리더" desc="CSV, TXT, Excel 등 파일 데이터 연결 기능이 준비 중입니다" />
</TabsContent>
{/* 크롤링 탭 */}
<TabsContent value="crawling" className="mt-4">
<ComingSoon icon={Bug} title="크롤링" desc="웹 크롤링 데이터 수집 기능이 준비 중입니다" />
</TabsContent>
</Tabs>
</div>
<ScrollToTop />
@@ -0,0 +1,158 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi, FleetAlert } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RefreshCw, AlertTriangle, CheckCircle2, Circle, Loader2 } from "lucide-react";
import { toast } from "sonner";
const SEVERITY_COLORS: Record<string, string> = {
info: "bg-blue-500/10 text-blue-600 border-blue-500/20",
warning: "bg-amber-500/10 text-amber-600 border-amber-500/20",
critical: "bg-red-500/10 text-red-600 border-red-500/20",
};
export default function FleetAlertsPage() {
const [alerts, setAlerts] = useState<FleetAlert[]>([]);
const [rules, setRules] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<"open" | "acknowledged" | "resolved">("open");
const load = useCallback(async () => {
setLoading(true);
try {
const [a, r] = await Promise.all([
fleetApi.getAlerts(statusFilter),
fleetApi.getAlertRules(),
]);
setAlerts(a.data || []);
setRules(r.data || []);
} catch { toast.error("알림 조회 실패"); }
setLoading(false);
}, [statusFilter]);
useEffect(() => {
load();
const t = setInterval(load, 30000);
return () => clearInterval(t);
}, [load]);
const ackAlert = async (id: number) => {
try { await fleetApi.ackAlert(id); toast.success("확인 처리"); load(); }
catch { toast.error("실패"); }
};
const resolveAlert = async (id: number) => {
try { await fleetApi.resolveAlert(id); toast.success("해결 처리"); load(); }
catch { toast.error("실패"); }
};
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">Fleet </h1>
<p className="text-sm text-muted-foreground">
(CPU///)
</p>
</div>
<Button size="sm" variant="outline" onClick={load} disabled={loading} className="gap-1">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
{/* 필터 */}
<div className="mt-4 flex items-center gap-2">
<Select value={statusFilter} onValueChange={(v: any) => setStatusFilter(v)}>
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="open">🔴 </SelectItem>
<SelectItem value="acknowledged">🟡 </SelectItem>
<SelectItem value="resolved"> </SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground"> {alerts.length}</div>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : alerts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground rounded-xl border border-dashed">
<CheckCircle2 className="h-10 w-10 mb-3 text-green-500" />
<p className="text-sm"> </p>
</div>
) : (
<div className="space-y-2">
{alerts.map((a) => (
<div
key={a.id}
className={`rounded-lg border p-4 ${SEVERITY_COLORS[a.severity] || ""}`}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<AlertTriangle className="h-5 w-5 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold">{a.title}</p>
<Badge variant="outline" className="text-[10px]">
{a.severity.toUpperCase()}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">{a.message}</p>
<div className="flex items-center gap-3 mt-2 text-[10px] text-muted-foreground">
<span>📱 {a.device_id}</span>
<span>📏 {a.metric} = {a.value} ( {a.threshold})</span>
<span>🕒 {new Date(a.created_at).toLocaleString("ko-KR")}</span>
</div>
</div>
</div>
{a.status === "open" && (
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => ackAlert(a.id)}>
</Button>
<Button size="sm" onClick={() => resolveAlert(a.id)}>
</Button>
</div>
)}
{a.status === "acknowledged" && (
<Button size="sm" onClick={() => resolveAlert(a.id)}>
</Button>
)}
</div>
</div>
))}
</div>
)}
{/* 규칙 */}
<div className="mt-8">
<h2 className="text-sm font-bold mb-3"> ({rules.length})</h2>
<div className="space-y-1">
{rules.map((r) => (
<div key={r.id} className="flex items-center justify-between rounded-md border p-3 text-xs">
<div>
<span className="font-medium">{r.rule_name}</span>
<span className="text-muted-foreground ml-2">
{r.metric} {r.operator} {r.threshold}
</span>
</div>
<Badge variant="outline">{r.severity}</Badge>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,137 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ShieldCheck, RefreshCw, Loader2, Search } from "lucide-react";
import { toast } from "sonner";
export default function FleetAuditPage() {
const [logs, setLogs] = useState<any[]>([]);
const [stats, setStats] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<any>({ event_type: "", result: "", search: "" });
const load = useCallback(async () => {
setLoading(true);
try {
const f: any = { limit: 200 };
if (filter.event_type) f.event_type = filter.event_type;
if (filter.result) f.result = filter.result;
const [r, s] = await Promise.all([fleetApi.getAuditLogs(f), fleetApi.getAuditStats()]);
let data = r.data || [];
if (filter.search) {
const q = filter.search.toLowerCase();
data = data.filter((l: any) =>
(l.target_id || "").toLowerCase().includes(q) ||
(l.actor_id || "").toLowerCase().includes(q) ||
(l.action || "").toLowerCase().includes(q),
);
}
setLogs(data);
setStats(s.data);
} catch { toast.error("조회 실패"); }
setLoading(false);
}, [filter]);
useEffect(() => { load(); }, [load]);
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"><ShieldCheck className="h-5 w-5" /> </h1>
<p className="text-sm text-muted-foreground"> Fleet ( · )</p>
</div>
<Button variant="outline" size="sm" onClick={load}><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /></Button>
</div>
{/* 통계 */}
{stats && (
<div className="mt-4 grid grid-cols-3 gap-2">
<div className="rounded-lg border bg-card p-3">
<div className="text-[11px] text-muted-foreground"> </div>
<div className="text-lg font-bold">{(stats.byEvent || []).length}</div>
</div>
<div className="rounded-lg border bg-card p-3">
<div className="text-[11px] text-muted-foreground"></div>
<div className="text-lg font-bold">{(stats.byActor || []).length}</div>
</div>
<div className="rounded-lg border bg-card p-3">
<div className="text-[11px] text-muted-foreground"></div>
<div className="text-lg font-bold text-red-500">{stats.failures || 0}</div>
</div>
</div>
)}
{/* 필터 */}
<div className="mt-4 flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="검색" value={filter.search} onChange={(e) => setFilter({...filter, search: e.target.value})} className="pl-9" />
</div>
<Select value={filter.event_type || "all"} onValueChange={(v) => setFilter({...filter, event_type: v === "all" ? "" : v})}>
<SelectTrigger className="w-40"><SelectValue placeholder="이벤트 타입" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="device_register"> </SelectItem>
<SelectItem value="command_issue"> </SelectItem>
<SelectItem value="deploy"></SelectItem>
<SelectItem value="script_edit"> </SelectItem>
<SelectItem value="alert_ack"> </SelectItem>
</SelectContent>
</Select>
<Select value={filter.result || "all"} onValueChange={(v) => setFilter({...filter, result: v === "all" ? "" : v})}>
<SelectTrigger className="w-32"><SelectValue placeholder="결과" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="success"></SelectItem>
<SelectItem value="failed"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? <Loader2 className="mx-auto my-20 animate-spin" /> :
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr><td colSpan={6} className="text-center py-10 text-muted-foreground"> </td></tr>
) : logs.map((l) => (
<tr key={l.id} className="border-t hover:bg-muted/30">
<td className="p-2 whitespace-nowrap">{new Date(l.created_at).toLocaleString("ko-KR")}</td>
<td className="p-2"><Badge variant="outline" className="text-[10px]">{l.event_type}</Badge></td>
<td className="p-2">{l.actor_name || l.actor_id || "-"}</td>
<td className="p-2 font-mono">{l.target_type ? `${l.target_type}#${l.target_id}` : "-"}</td>
<td className="p-2">{l.action}</td>
<td className="p-2">
{l.result === "success"
? <Badge className="bg-green-500/10 text-green-600 text-[10px]"></Badge>
: <Badge className="bg-red-500/10 text-red-600 text-[10px]" title={l.error_message}></Badge>
}
</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
</div>
);
}
@@ -0,0 +1,115 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi, FleetCommand } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { RefreshCw, Terminal, Loader2 } from "lucide-react";
import { toast } from "sonner";
const STATUS_COLORS: Record<string, string> = {
pending: "bg-gray-500/10 text-gray-600",
sent: "bg-blue-500/10 text-blue-600",
executing: "bg-amber-500/10 text-amber-600",
success: "bg-green-500/10 text-green-600",
failed: "bg-red-500/10 text-red-600",
timeout: "bg-orange-500/10 text-orange-600",
};
export default function FleetCommandsPage() {
const [commands, setCommands] = useState<FleetCommand[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const r = await fleetApi.getCommands({ limit: 100 });
setCommands(r.data || []);
} catch { toast.error("커맨드 이력 조회 실패"); }
setLoading(false);
}, []);
useEffect(() => {
load();
const t = setInterval(load, 10000);
return () => clearInterval(t);
}, [load]);
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">Fleet </h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Button size="sm" variant="outline" onClick={load} disabled={loading} className="gap-1">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : commands.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground rounded-xl border border-dashed">
<Terminal className="h-10 w-10 mb-3" />
<p className="text-sm"> </p>
</div>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium">ID</th>
<th className="text-left p-3 font-medium"></th>
<th className="text-left p-3 font-medium"></th>
<th className="text-left p-3 font-medium"></th>
<th className="text-left p-3 font-medium"></th>
<th className="text-left p-3 font-medium"> </th>
<th className="text-left p-3 font-medium"></th>
</tr>
</thead>
<tbody>
{commands.map((c) => (
<tr key={c.id} className="border-t hover:bg-muted/30">
<td className="p-3 font-mono text-xs">#{c.id}</td>
<td className="p-3 font-mono text-xs">{c.device_id}</td>
<td className="p-3">{c.command_type}</td>
<td className="p-3">
<Badge
variant="outline"
className={`${STATUS_COLORS[c.status || ""]} text-[10px]`}
>
{c.status}
</Badge>
</td>
<td className="p-3 text-xs text-muted-foreground">{c.issued_by || "-"}</td>
<td className="p-3 text-xs text-muted-foreground">
{c.issued_at ? new Date(c.issued_at).toLocaleString("ko-KR") : "-"}
</td>
<td className="p-3 text-xs text-muted-foreground">
{c.error_message ? (
<span className="text-red-600">{c.error_message}</span>
) : c.responded_at ? (
new Date(c.responded_at).toLocaleString("ko-KR")
) : (
"-"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,263 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi, FleetDevice } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { RefreshCw, Activity, TrendingUp, Loader2, Database, Wifi } from "lucide-react";
import { toast } from "sonner";
export default function FleetDataPage() {
const [devices, setDevices] = useState<FleetDevice[]>([]);
const [selectedDevice, setSelectedDevice] = useState<string>("");
const [latestValues, setLatestValues] = useState<any[]>([]);
const [selectedTag, setSelectedTag] = useState<string>("");
const [timeseries, setTimeseries] = useState<any[]>([]);
const [stats, setStats] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [chartLoading, setChartLoading] = useState(false);
// 디바이스 목록 + 수집 통계
const loadDevices = useCallback(async () => {
try {
const [dev, st] = await Promise.all([
fleetApi.getDevices({ is_online: true } as any),
fleetApi.getDataStats(),
]);
const online = (dev.data || []).filter((d: any) => d.is_online);
setDevices(online);
setStats(st.data);
if (online.length > 0 && !selectedDevice) {
setSelectedDevice(online[0].device_id);
}
setLoading(false);
} catch { toast.error("디바이스 조회 실패"); setLoading(false); }
}, [selectedDevice]);
const loadLatestValues = useCallback(async () => {
if (!selectedDevice) return;
try {
const r = await fleetApi.getLatestValues(selectedDevice);
setLatestValues(r.data || []);
if (r.data?.length > 0 && !selectedTag) {
const firstNumeric = r.data.find((v: any) => v.value !== null);
if (firstNumeric) setSelectedTag(firstNumeric.tag_name);
}
} catch { /* ignore */ }
}, [selectedDevice, selectedTag]);
const loadTimeseries = useCallback(async () => {
if (!selectedDevice || !selectedTag) return;
setChartLoading(true);
try {
const r = await fleetApi.getTagTimeseries(selectedDevice, selectedTag, 200);
setTimeseries((r.data || []).reverse()); // 시간 오름차순
} catch { /* ignore */ }
setChartLoading(false);
}, [selectedDevice, selectedTag]);
useEffect(() => { loadDevices(); }, [loadDevices]);
useEffect(() => { loadLatestValues(); }, [loadLatestValues]);
useEffect(() => { loadTimeseries(); }, [loadTimeseries]);
// 3초마다 실시간 갱신
useEffect(() => {
const t = setInterval(() => {
loadLatestValues();
loadTimeseries();
}, 3000);
return () => clearInterval(t);
}, [loadLatestValues, loadTimeseries]);
// 간단한 SVG 차트 (라이브러리 없이)
const renderChart = () => {
if (timeseries.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
</div>
);
}
const values = timeseries.map((p) => p.value).filter((v) => v !== null);
if (values.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
(text: {timeseries[timeseries.length - 1]?.value_text})
</div>
);
}
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const w = 800;
const h = 280;
const pad = 30;
const points = timeseries
.map((p, i) => {
if (p.value === null) return null;
const x = pad + (i * (w - pad * 2)) / Math.max(timeseries.length - 1, 1);
const y = h - pad - ((p.value - min) / range) * (h - pad * 2);
return `${x},${y}`;
})
.filter(Boolean)
.join(" ");
return (
<svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} className="rounded">
<line x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} stroke="#e5e7eb" />
<line x1={pad} y1={pad} x2={pad} y2={h - pad} stroke="#e5e7eb" />
<text x={pad - 5} y={pad + 5} fontSize="10" textAnchor="end" fill="#6b7280">
{max.toFixed(2)}
</text>
<text x={pad - 5} y={h - pad} fontSize="10" textAnchor="end" fill="#6b7280">
{min.toFixed(2)}
</text>
<polyline points={points} fill="none" stroke="#3b82f6" strokeWidth="2" />
{timeseries.map((p, i) => {
if (p.value === null) return null;
const x = pad + (i * (w - pad * 2)) / Math.max(timeseries.length - 1, 1);
const y = h - pad - ((p.value - min) / range) * (h - pad * 2);
return <circle key={i} cx={x} cy={y} r="2" fill="#3b82f6" />;
})}
</svg>
);
};
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"> </h1>
<p className="text-sm text-muted-foreground">
Data Collector에서 PLC/ (3 )
</p>
</div>
<Button size="sm" variant="outline" onClick={loadDevices} disabled={loading} className="gap-1">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
{/* 통계 */}
{stats && (
<div className="mt-4 grid grid-cols-3 gap-3">
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Database className="h-4 w-4" /> 24
</div>
<p className="mt-1 text-2xl font-bold">
{parseInt(stats.total_records || 0).toLocaleString()}
</p>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Wifi className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold">{stats.device_count || 0}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Activity className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold">{stats.tag_count || 0}</p>
</div>
</div>
)}
{/* 디바이스 선택 */}
<div className="mt-4">
<Select value={selectedDevice} onValueChange={setSelectedDevice}>
<SelectTrigger className="w-full md:w-96">
<SelectValue placeholder="온라인 디바이스를 선택하세요" />
</SelectTrigger>
<SelectContent>
{devices.map((d) => (
<SelectItem key={d.device_id} value={d.device_id}>
{d.device_name || d.device_id} ({d.equipment_name || "장비 미연결"})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6 grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* 좌측: 태그 목록 */}
<div className="lg:col-span-1 space-y-2">
<h2 className="text-sm font-bold"> ({latestValues.length})</h2>
{latestValues.length === 0 ? (
<div className="rounded-lg border border-dashed p-6 text-center text-xs text-muted-foreground">
</div>
) : (
<div className="space-y-1 max-h-[calc(100vh-400px)] overflow-y-auto">
{latestValues.map((v) => (
<button
key={v.tag_name}
onClick={() => setSelectedTag(v.tag_name)}
className={`w-full text-left rounded-md border p-3 transition-colors ${
selectedTag === v.tag_name
? "bg-primary/10 border-primary"
: "hover:bg-muted/50"
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{v.tag_name}</span>
<Badge variant="outline" className="text-[10px]">
{v.quality || "good"}
</Badge>
</div>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-lg font-bold font-mono">
{v.value !== null
? typeof v.value === "number"
? v.value.toFixed(2)
: v.value
: v.value_text}
</span>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
{new Date(v.time).toLocaleString("ko-KR")}
</p>
</button>
))}
</div>
)}
</div>
{/* 우측: 차트 */}
<div className="lg:col-span-2 rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-sm font-bold flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
{selectedTag && `- ${selectedTag}`}
</h2>
<p className="text-[10px] text-muted-foreground">
200 · 3
</p>
</div>
{chartLoading && <Loader2 className="h-4 w-4 animate-spin" />}
</div>
<div className="h-[280px]">{renderChart()}</div>
{timeseries.length > 0 && (
<div className="mt-3 text-[11px] text-muted-foreground grid grid-cols-3 gap-2">
<div>: {timeseries[timeseries.length - 1]?.value?.toFixed(2) || "-"}</div>
<div>
: {Math.min(...timeseries.filter((p) => p.value !== null).map((p) => p.value)).toFixed(2)}
</div>
<div>
: {Math.max(...timeseries.filter((p) => p.value !== null).map((p) => p.value)).toFixed(2)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,262 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Rocket, RefreshCw, Play, Square, Undo2, Loader2, Package } from "lucide-react";
import { toast } from "sonner";
const STATUS_COLOR: Record<string, string> = {
pending: "bg-gray-500/10 text-gray-600",
running: "bg-blue-500/10 text-blue-600",
paused: "bg-amber-500/10 text-amber-600",
completed: "bg-green-500/10 text-green-600",
failed: "bg-red-500/10 text-red-600",
cancelled: "bg-gray-500/10 text-gray-400",
rolled_back: "bg-orange-500/10 text-orange-600",
};
export default function FleetDeploymentsPage() {
const [list, setList] = useState<any[]>([]);
const [releases, setReleases] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<any>({
release_id: "",
target_type: "all",
target_value: "",
rollout_strategy: "rolling",
batch_size: 10,
max_failures: 3,
description: "",
});
const [statusOpen, setStatusOpen] = useState<{id?: number; open: boolean}>({open: false});
const [statusList, setStatusList] = useState<any[]>([]);
const load = useCallback(async () => {
setLoading(true);
try {
const [d, r] = await Promise.all([fleetApi.getDeployments(), fleetApi.getReleases()]);
setList(d.data || []);
setReleases((r.data || []).filter((x: any) => x.status === "ready" || x.status === "released"));
} catch { toast.error("조회 실패"); }
setLoading(false);
}, []);
useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t); }, [load]);
const handleCreate = async () => {
if (!form.release_id) { toast.error("릴리즈 선택 필요"); return; }
try {
await fleetApi.createDeployment({ ...form, release_id: parseInt(form.release_id) });
toast.success("생성 완료");
setCreateOpen(false);
load();
} catch (e: any) { toast.error(e.response?.data?.message || "실패"); }
};
const start = async (id: number) => { try { await fleetApi.startDeployment(id); toast.success("시작"); load(); } catch (e: any) { toast.error(e.response?.data?.message || "실패"); } };
const cancel = async (id: number) => { if (!confirm("취소하시겠습니까?")) return; try { await fleetApi.cancelDeployment(id); toast.success("취소"); load(); } catch (e: any) { toast.error(e.response?.data?.message || "실패"); } };
const rollback = async (id: number) => { if (!confirm("롤백하시겠습니까?")) return; try { await fleetApi.rollbackDeployment(id); toast.success("롤백 시작"); load(); } catch (e: any) { toast.error(e.response?.data?.message || "실패"); } };
const showStatus = async (id: number) => {
try {
const r = await fleetApi.getDeploymentStatus(id);
setStatusList(r.data || []);
setStatusOpen({id, open: true});
} catch { toast.error("조회 실패"); }
};
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">
<Rocket 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} disabled={loading}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)} className="gap-1">
<Rocket className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 animate-spin" /></div>
) : list.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground rounded-xl border border-dashed">
<Rocket className="h-10 w-10 mb-3" />
<p className="text-sm"> </p>
</div>
) : (
<div className="space-y-2">
{list.map((d) => (
<div key={d.id} className="rounded-xl border bg-card p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-xs">#{d.id}</span>
<span className="font-semibold">{d.release_version || "릴리즈 미지정"}</span>
<Badge className={STATUS_COLOR[d.status]}>{d.status}</Badge>
<Badge variant="outline" className="text-[10px]">{d.rollout_strategy}</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
: {d.target_type}{d.target_value ? ` (${d.target_value})` : ""} · {d.total_devices}
</p>
<p className="text-[11px] text-muted-foreground">{d.description}</p>
<div className="flex gap-4 mt-2 text-[11px]">
<span className="text-green-600"> {d.success_count || 0}</span>
<span className="text-red-500"> {d.failed_count || 0}</span>
<span className="text-muted-foreground">
{d.created_at && new Date(d.created_at).toLocaleString("ko-KR")}
</span>
</div>
</div>
<div className="flex gap-1 ml-2">
<Button size="sm" variant="outline" onClick={() => showStatus(d.id)}></Button>
{["pending", "paused"].includes(d.status) && (
<Button size="sm" onClick={() => start(d.id)} className="gap-1">
<Play className="h-3 w-3" />
</Button>
)}
{["running", "pending"].includes(d.status) && (
<Button size="sm" variant="outline" onClick={() => cancel(d.id)} className="gap-1">
<Square className="h-3 w-3" />
</Button>
)}
{["completed", "failed", "paused"].includes(d.status) && (
<Button size="sm" variant="outline" onClick={() => rollback(d.id)} className="gap-1">
<Undo2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 생성 모달 */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle></DialogHeader>
<div className="space-y-3 pt-2">
<div>
<Label className="text-xs"> *</Label>
<Select value={form.release_id?.toString()} onValueChange={(v) => setForm({...form, release_id: v})}>
<SelectTrigger className="mt-1"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{releases.map((r) => (
<SelectItem key={r.id} value={r.id.toString()}>
v{r.version} ({r.release_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Select value={form.target_type} onValueChange={(v) => setForm({...form, target_type: v})}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
<SelectItem value="company"></SelectItem>
<SelectItem value="group"></SelectItem>
<SelectItem value="device_list"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={form.rollout_strategy} onValueChange={(v) => setForm({...form, rollout_strategy: v})}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate"></SelectItem>
<SelectItem value="rolling"></SelectItem>
<SelectItem value="canary"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{form.target_type !== "all" && (
<div>
<Label className="text-xs"> </Label>
<Input
value={form.target_value}
onChange={(e) => setForm({...form, target_value: e.target.value})}
placeholder={
form.target_type === "company" ? "예: spifox" :
form.target_type === "group" ? "예: production" :
"device_id1,device_id2,..."
}
className="mt-1"
/>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input type="number" value={form.batch_size} onChange={(e) => setForm({...form, batch_size: parseInt(e.target.value) || 10})} className="mt-1" />
</div>
<div>
<Label className="text-xs"> </Label>
<Input type="number" value={form.max_failures} onChange={(e) => setForm({...form, max_failures: parseInt(e.target.value) || 3})} className="mt-1" />
</div>
</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="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setCreateOpen(false)}></Button>
<Button onClick={handleCreate}></Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* 상태 모달 */}
<Dialog open={statusOpen.open} onOpenChange={(open) => setStatusOpen({open})}>
<DialogContent className="max-w-2xl">
<DialogHeader><DialogTitle> #{statusOpen.id} - </DialogTitle></DialogHeader>
<div className="max-h-[60vh] overflow-y-auto space-y-1 pt-2">
{statusList.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4"> </p>
) : (
statusList.map((s) => (
<div key={s.id} className="rounded-md border p-2 text-xs flex items-center justify-between">
<div>
<span className="font-mono">{s.device_id}</span>
{s.device_name && <span className="text-muted-foreground ml-2">{s.device_name}</span>}
</div>
<div className="flex items-center gap-2">
<Badge className={STATUS_COLOR[s.status]}>{s.status}</Badge>
{s.error_message && <span className="text-red-500 text-[10px]">{s.error_message}</span>}
</div>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,300 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import { fleetApi, FleetDevice } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
RefreshCw, Search, Wifi, WifiOff, Cpu, HardDrive, MemoryStick,
Terminal, Trash2, Send, Loader2, Circle, Activity,
} from "lucide-react";
import { toast } from "sonner";
export default function FleetDevicesPage() {
const [devices, setDevices] = useState<FleetDevice[]>([]);
const [commandTypes, setCommandTypes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [filterOnline, setFilterOnline] = useState<"all" | "online" | "offline">("all");
const [commandModalOpen, setCommandModalOpen] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<FleetDevice | null>(null);
const [commandForm, setCommandForm] = useState({
command_type: "health_check",
payload_text: "{}",
});
const [sending, setSending] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [dev, types] = await Promise.all([
fleetApi.getDevices(),
fleetApi.getCommandTypes(),
]);
setDevices(dev.data || []);
setCommandTypes(types.data || []);
} catch { toast.error("디바이스 목록 조회 실패"); }
setLoading(false);
}, []);
useEffect(() => {
load();
// 30초마다 자동 갱신
const t = setInterval(load, 30000);
return () => clearInterval(t);
}, [load]);
const filteredDevices = useMemo(() => {
return devices.filter((d) => {
if (filterOnline === "online" && !d.is_online) return false;
if (filterOnline === "offline" && d.is_online) return false;
if (searchTerm) {
const q = searchTerm.toLowerCase();
return (
(d.device_id || "").toLowerCase().includes(q) ||
(d.device_name || "").toLowerCase().includes(q) ||
(d.ip_address || "").toLowerCase().includes(q) ||
(d.equipment_name || "").toLowerCase().includes(q)
);
}
return true;
});
}, [devices, filterOnline, searchTerm]);
const openCommandModal = (device: FleetDevice) => {
setSelectedDevice(device);
setCommandForm({ command_type: "health_check", payload_text: "{}" });
setCommandModalOpen(true);
};
const sendCommand = async () => {
if (!selectedDevice) return;
let payload: any = {};
try { payload = JSON.parse(commandForm.payload_text || "{}"); }
catch { toast.error("Payload JSON 형식이 올바르지 않습니다."); return; }
setSending(true);
try {
await fleetApi.issueCommand({
device_id: selectedDevice.device_id,
command_type: commandForm.command_type,
payload,
});
toast.success(`커맨드 발행 완료: ${commandForm.command_type}`);
setCommandModalOpen(false);
} catch (e: any) {
toast.error(e.response?.data?.message || "커맨드 발행 실패");
}
setSending(false);
};
const deleteDevice = async (deviceId: string) => {
if (!confirm(`'${deviceId}' 디바이스를 삭제하시겠습니까?`)) return;
try {
await fleetApi.deleteDevice(deviceId);
toast.success("삭제 완료");
load();
} catch { toast.error("삭제 실패"); }
};
const onlineCount = devices.filter((d) => d.is_online).length;
const offlineCount = devices.length - onlineCount;
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"> </h1>
<p className="text-sm text-muted-foreground">
Fleet
</p>
</div>
<Button size="sm" variant="outline" onClick={load} disabled={loading} className="gap-1">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
{/* 통계 */}
<div className="mt-4 grid grid-cols-3 gap-3">
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Activity className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold">{devices.length}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-green-600">
<Wifi className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold text-green-600">{onlineCount}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-red-500">
<WifiOff className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold text-red-500">{offlineCount}</p>
</div>
</div>
{/* 검색 + 필터 */}
<div className="mt-4 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="디바이스 ID, 이름, IP, 장비로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<Select value={filterOnline} onValueChange={(v: any) => setFilterOnline(v)}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="online"></SelectItem>
<SelectItem value="offline"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 카드 그리드 */}
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : filteredDevices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground rounded-xl border border-dashed">
<WifiOff className="h-10 w-10 mb-3" />
<p className="text-sm"> </p>
<p className="text-xs mt-1"> MQTT로 </p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredDevices.map((d) => (
<div
key={d.device_id}
className="rounded-xl border bg-card p-4 hover:shadow-md transition-shadow"
>
{/* 상단 */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Circle
className={`h-3 w-3 ${d.is_online ? "fill-green-500 text-green-500" : "fill-gray-400 text-gray-400"}`}
/>
<Badge variant="outline" className="text-[10px]">
{d.device_type || "edge"}
</Badge>
</div>
<span className={`text-[10px] ${d.is_online ? "text-green-600" : "text-gray-400"}`}>
{d.is_online ? "온라인" : "오프라인"}
</span>
</div>
{/* 디바이스 정보 */}
<div className="mb-3">
<p className="text-sm font-semibold truncate" title={d.device_id}>
{d.device_name || d.device_id}
</p>
<p className="text-[11px] text-muted-foreground font-mono truncate">{d.device_id}</p>
{d.equipment_name && (
<p className="text-[11px] text-muted-foreground mt-1">
🔗 {d.equipment_name}
</p>
)}
</div>
{/* 상세 정보 */}
<div className="space-y-1 text-[11px] text-muted-foreground">
{d.ip_address && <div>📍 {d.ip_address}</div>}
{d.last_seen_at && (
<div> {new Date(d.last_seen_at).toLocaleString("ko-KR")}</div>
)}
{d.agent_version && <div> v{d.agent_version}</div>}
</div>
{/* 액션 버튼 */}
<div className="flex items-center gap-1 mt-3 pt-3 border-t">
<Button
variant="ghost"
size="sm"
className="h-8 flex-1 gap-1"
onClick={() => openCommandModal(d)}
disabled={!d.is_online}
>
<Terminal className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => deleteDevice(d.device_id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* 커맨드 모달 */}
<Dialog open={commandModalOpen} onOpenChange={setCommandModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle> - {selectedDevice?.device_id}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label className="text-xs"> </Label>
<Select
value={commandForm.command_type}
onValueChange={(v) => setCommandForm({ ...commandForm, command_type: v })}
>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{commandTypes.map((t) => (
<SelectItem key={t.command_type} value={t.command_type}>
{t.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground mt-1">
{commandTypes.find((t) => t.command_type === commandForm.command_type)?.description}
</p>
</div>
<div>
<Label className="text-xs">Payload (JSON)</Label>
<Textarea
value={commandForm.payload_text}
onChange={(e) => setCommandForm({ ...commandForm, payload_text: e.target.value })}
placeholder='{"container_name": "data-collector"}'
rows={5}
className="mt-1 font-mono text-xs"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCommandModalOpen(false)}></Button>
<Button onClick={sendCommand} disabled={sending} className="gap-1">
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,199 @@
"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 { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Package, Plus, Pencil, Trash2, RefreshCw, Loader2, Container } from "lucide-react";
import { toast } from "sonner";
export default function FleetReleasesPage() {
const [list, setList] = useState<any[]>([]);
const [projects, setProjects] = useState<any[]>([]);
const [repos, setRepos] = useState<any[]>([]);
const [tags, setTags] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form, setForm] = useState<any>({
version: "",
release_type: "minor",
backend_image: "",
frontend_image: "",
agent_image: "",
changelog: "",
is_canary: false,
status: "draft",
});
const load = useCallback(async () => {
setLoading(true);
try {
const r = await fleetApi.getReleases();
setList(r.data || []);
} catch { toast.error("조회 실패"); }
setLoading(false);
}, []);
useEffect(() => {
load();
fleetApi.getHarborProjects().then(r => setProjects(r.data || [])).catch(() => {});
}, [load]);
const open = (r?: any) => {
setEditing(r || null);
setForm(r ? {
version: r.version,
release_type: r.release_type,
backend_image: r.backend_image || "",
frontend_image: r.frontend_image || "",
agent_image: r.agent_image || "",
changelog: r.changelog || "",
is_canary: r.is_canary,
status: r.status,
} : {
version: "", release_type: "minor",
backend_image: "", frontend_image: "", agent_image: "",
changelog: "", is_canary: false, status: "draft",
});
setModalOpen(true);
};
const save = async () => {
try {
if (editing) await fleetApi.updateRelease(editing.id, form);
else await fleetApi.createRelease(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.deleteRelease(id); toast.success("삭제"); load(); } catch { toast.error("실패"); }
};
const transition = async (id: number, status: string) => {
try { await fleetApi.transitionRelease(id, status); toast.success(`${status} 전환`); load(); }
catch (e: any) { toast.error(e.response?.data?.message || "실패"); }
};
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"><Package className="h-5 w-5" /> </h1>
<p className="text-sm text-muted-foreground">Harbor </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()} className="gap-1"><Plus className="h-4 w-4" /> </Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="h-6 w-6 animate-spin" /></div>
) : list.length === 0 ? (
<div className="py-20 text-center text-muted-foreground text-sm rounded-xl border border-dashed">
<Package className="mx-auto h-10 w-10 mb-2" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{list.map((r) => (
<div key={r.id} className="rounded-xl border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<div>
<span className="text-lg font-bold">v{r.version}</span>
<Badge variant="outline" className="ml-2 text-[10px]">{r.release_type}</Badge>
{r.is_canary && <Badge className="ml-1 text-[10px] bg-amber-500/10 text-amber-600">Canary</Badge>}
</div>
<Badge className={
r.status === "released" ? "bg-green-500/10 text-green-600" :
r.status === "ready" ? "bg-blue-500/10 text-blue-600" :
r.status === "deprecated" ? "bg-gray-500/10 text-gray-500" :
"bg-amber-500/10 text-amber-600"
}>{r.status}</Badge>
</div>
{r.backend_image && <p className="text-[10px] text-muted-foreground font-mono truncate" title={r.backend_image}>BE: {r.backend_image.split("/").pop()}</p>}
{r.frontend_image && <p className="text-[10px] text-muted-foreground font-mono truncate" title={r.frontend_image}>FE: {r.frontend_image.split("/").pop()}</p>}
{r.agent_image && <p className="text-[10px] text-muted-foreground font-mono truncate" title={r.agent_image}>AGT: {r.agent_image.split("/").pop()}</p>}
<p className="text-[11px] text-muted-foreground mt-1"> {r.deploy_count || 0}</p>
{r.changelog && <p className="text-[11px] mt-2 line-clamp-2">{r.changelog}</p>}
<div className="flex gap-1 mt-3 pt-2 border-t">
{r.status === "draft" && (
<Button size="sm" variant="outline" className="flex-1" onClick={() => transition(r.id, "ready")}>Ready</Button>
)}
{r.status === "ready" && (
<Button size="sm" className="flex-1" onClick={() => transition(r.id, "released")}>Release</Button>
)}
<Button size="sm" variant="ghost" className="h-8 w-8 p-0" onClick={() => open(r)}><Pencil className="h-3.5 w-3.5" /></Button>
<Button size="sm" variant="ghost" className="h-8 w-8 p-0 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-lg">
<DialogHeader><DialogTitle>{editing ? "릴리즈 수정" : "새 릴리즈"}</DialogTitle></DialogHeader>
<div className="space-y-3 pt-2">
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> *</Label>
<Input value={form.version} onChange={(e) => setForm({...form, version: e.target.value})} placeholder="1.2.3" className="mt-1" />
</div>
<div>
<Label className="text-xs"></Label>
<Select value={form.release_type} onValueChange={(v) => setForm({...form, release_type: v})}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="major">major</SelectItem>
<SelectItem value="minor">minor</SelectItem>
<SelectItem value="patch">patch</SelectItem>
<SelectItem value="hotfix">hotfix</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs">Backend </Label>
<Input value={form.backend_image} onChange={(e) => setForm({...form, backend_image: e.target.value})} placeholder="harbor.wace.me/vexplor_fleet/data-collector:v1.2.3" className="mt-1 font-mono text-xs" />
</div>
<div>
<Label className="text-xs">Frontend </Label>
<Input value={form.frontend_image} onChange={(e) => setForm({...form, frontend_image: e.target.value})} className="mt-1 font-mono text-xs" />
</div>
<div>
<Label className="text-xs">Agent </Label>
<Input value={form.agent_image} onChange={(e) => setForm({...form, agent_image: e.target.value})} className="mt-1 font-mono text-xs" />
</div>
<div>
<Label className="text-xs">Changelog</Label>
<Textarea value={form.changelog} onChange={(e) => setForm({...form, changelog: e.target.value})} rows={3} className="mt-1" />
</div>
<div className="flex items-center justify-between rounded-lg border p-2">
<Label className="text-xs">Canary </Label>
<Switch checked={form.is_canary} onCheckedChange={(v) => setForm({...form, is_canary: v})} />
</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>
);
}
@@ -0,0 +1,206 @@
"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=">">&gt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value="<=">&lt;=</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>
);
}
@@ -0,0 +1,475 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import dynamic from "next/dynamic";
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 { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Plus, Save, Play, Trash2, Pencil, RefreshCw, Code2, Loader2,
History, RotateCcw, CheckCircle2, XCircle,
} from "lucide-react";
import { toast } from "sonner";
// Monaco Editor는 SSR 안됨
const MonacoEditor = dynamic(() => import("@monaco-editor/react").then((m) => m.default), {
ssr: false,
loading: () => <div className="flex items-center justify-center h-full"><Loader2 className="h-5 w-5 animate-spin" /></div>,
});
const SCOPE_LABELS: Record<string, string> = {
global: "전체 엣지",
equipment: "특정 장비",
connection: "특정 연결",
device: "특정 디바이스",
};
const HOOK_COLORS: Record<string, string> = {
transform: "bg-blue-500/10 text-blue-600 border-blue-500/30",
derived_tags: "bg-purple-500/10 text-purple-600 border-purple-500/30",
filter: "bg-amber-500/10 text-amber-600 border-amber-500/30",
alarm: "bg-red-500/10 text-red-600 border-red-500/30",
pre_send: "bg-green-500/10 text-green-600 border-green-500/30",
};
export default function FleetScriptsPage() {
const [scripts, setScripts] = useState<any[]>([]);
const [hookTypes, setHookTypes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [versionsModal, setVersionsModal] = useState<{open: boolean; scriptId?: number}>({open: false});
const [versions, setVersions] = useState<any[]>([]);
const [form, setForm] = useState<any>({
script_name: "",
description: "",
scope: "global",
hook_type: "transform",
code: "",
enabled: true,
priority: 100,
timeout_ms: 1000,
});
const [testInput, setTestInput] = useState<string>("{}");
const [testResult, setTestResult] = useState<any>(null);
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [s, h] = await Promise.all([fleetApi.listScripts(), fleetApi.getHookTypes()]);
setScripts(s.data || []);
setHookTypes(h.data || []);
} catch { toast.error("스크립트 조회 실패"); }
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
const defaultHook = hookTypes[0];
setEditing(null);
setForm({
script_name: "",
description: "",
scope: "global",
hook_type: defaultHook?.hook_type || "transform",
code: defaultHook?.example_code || "",
enabled: true,
priority: 100,
timeout_ms: 1000,
});
setTestInput(getDefaultTestInput(defaultHook?.hook_type || "transform"));
setTestResult(null);
setModalOpen(true);
};
const openEdit = (script: any) => {
setEditing(script);
setForm({
script_name: script.script_name,
description: script.description || "",
scope: script.scope,
hook_type: script.hook_type,
code: script.code,
enabled: script.enabled,
priority: script.priority,
timeout_ms: script.timeout_ms,
equipment_id: script.equipment_id,
connection_id: script.connection_id,
device_id: script.device_id,
});
setTestInput(getDefaultTestInput(script.hook_type));
setTestResult(null);
setModalOpen(true);
};
const getDefaultTestInput = (hookType: string): string => {
switch (hookType) {
case "transform": return JSON.stringify({ tag_name: "temperature", raw_value: 800, context: {} }, null, 2);
case "derived_tags": return JSON.stringify({ tags: { voltage: 220, current: 5 }, context: {} }, null, 2);
case "filter": return JSON.stringify({ tags: { running: true, temp: 25 }, context: {} }, null, 2);
case "alarm": return JSON.stringify({ tag_name: "temperature", value: 95, context: {} }, null, 2);
case "pre_send": return JSON.stringify({ payload: { tags: { temp: 25 } }, context: {} }, null, 2);
default: return "{}";
}
};
const handleHookChange = (hookType: string) => {
const hook = hookTypes.find((h) => h.hook_type === hookType);
setForm({ ...form, hook_type: hookType, code: form.code || hook?.example_code || "" });
setTestInput(getDefaultTestInput(hookType));
setTestResult(null);
};
const handleTestRun = async () => {
setTesting(true);
setTestResult(null);
try {
let parsed = {};
try { parsed = JSON.parse(testInput); } catch (e: any) {
toast.error("테스트 입력 JSON 파싱 실패: " + e.message);
setTesting(false);
return;
}
const r = await fleetApi.dryRunScript(form.code, form.hook_type, parsed, form.timeout_ms);
setTestResult(r);
if (r.success) toast.success(`실행 성공 (${r.duration_ms}ms)`);
else toast.error("실행 실패 - 결과 패널 참조");
} catch (e: any) {
toast.error(e.response?.data?.message || e.message);
}
setTesting(false);
};
const handleSave = async () => {
if (!form.script_name || !form.code) {
toast.error("이름과 코드를 입력하세요."); return;
}
setSaving(true);
try {
if (editing) {
await fleetApi.updateScript(editing.id, form);
toast.success("수정 완료");
} else {
await fleetApi.createScript(form);
toast.success("생성 완료");
}
setModalOpen(false);
load();
} catch (e: any) {
toast.error(e.response?.data?.message || "저장 실패");
}
setSaving(false);
};
const handleDelete = async (id: number) => {
if (!confirm("스크립트를 삭제하시겠습니까? 버전 이력도 함께 삭제됩니다.")) return;
try { await fleetApi.deleteScript(id); toast.success("삭제 완료"); load(); }
catch { toast.error("삭제 실패"); }
};
const toggleEnabled = async (script: any) => {
try {
await fleetApi.updateScript(script.id, { enabled: !script.enabled });
load();
} catch { toast.error("상태 변경 실패"); }
};
const openVersions = async (scriptId: number) => {
try {
const r = await fleetApi.getScriptVersions(scriptId);
setVersions(r.data || []);
setVersionsModal({open: true, scriptId});
} catch { toast.error("버전 조회 실패"); }
};
const rollback = async (scriptId: number, version: number) => {
if (!confirm(`v${version}으로 롤백하시겠습니까?`)) return;
try {
await fleetApi.rollbackScript(scriptId, version);
toast.success(`v${version}으로 롤백 완료`);
setVersionsModal({open: false});
load();
} catch (e: any) { toast.error(e.response?.data?.message || "롤백 실패"); }
};
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">
<Code2 className="h-5 w-5" /> Python Hook
</h1>
<p className="text-sm text-muted-foreground">
Data Collector가 Python ( /)
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={load} disabled={loading} className="gap-1">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button size="sm" onClick={openCreate} className="gap-1">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Hook 타입 설명 */}
<div className="shrink-0 px-6 pb-4">
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{hookTypes.map((h) => (
<div
key={h.hook_type}
className={`rounded-md border p-2 text-[11px] ${HOOK_COLORS[h.hook_type] || ""}`}
>
<div className="font-semibold">{h.display_name}</div>
<div className="text-[10px] opacity-80 mt-0.5 line-clamp-2">{h.description}</div>
</div>
))}
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : scripts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground rounded-xl border border-dashed">
<Code2 className="h-10 w-10 mb-3" />
<p className="text-sm"> Python Hook이 </p>
<Button variant="link" onClick={openCreate} className="mt-2"> </Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{scripts.map((s) => (
<div key={s.id} className="rounded-xl border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<Badge className={HOOK_COLORS[s.hook_type]}>{s.hook_type}</Badge>
<Switch checked={s.enabled} onCheckedChange={() => toggleEnabled(s)} />
</div>
<p className="text-sm font-semibold truncate">{s.script_name}</p>
<p className="text-[11px] text-muted-foreground truncate mt-0.5">
{s.description || "설명 없음"}
</p>
<div className="mt-2 text-[10px] text-muted-foreground space-y-0.5">
<div>📍 {SCOPE_LABELS[s.scope]}</div>
{s.equipment_name && <div>🔧 {s.equipment_name}</div>}
{s.connection_name && <div>🔌 {s.connection_name}</div>}
{s.device_id && <div>📱 {s.device_id}</div>}
<div>🏷 v{s.version} {s.timeout_ms}ms</div>
</div>
<div className="flex gap-1 mt-3 pt-3 border-t">
<Button variant="ghost" size="sm" className="flex-1 h-8 gap-1" onClick={() => openEdit(s)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openVersions(s.id)}>
<History className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => handleDelete(s.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* 편집 모달 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? `스크립트 수정: ${editing.script_name} (v${editing.version})` : "새 스크립트"}</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-2">
{/* 좌측: 메타 + 테스트 */}
<div className="space-y-3 lg:col-span-1">
<div>
<Label className="text-xs"> *</Label>
<Input
value={form.script_name}
onChange={(e) => setForm({ ...form, script_name: e.target.value })}
placeholder="예: 온도 센서 보정"
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">Hook *</Label>
<Select value={form.hook_type} onValueChange={handleHookChange}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{hookTypes.map((h) => (
<SelectItem key={h.hook_type} value={h.hook_type}>
{h.display_name} ({h.hook_type})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground mt-1">
{hookTypes.find((h) => h.hook_type === form.hook_type)?.signature}
</p>
</div>
<div>
<Label className="text-xs"> </Label>
<Select value={form.scope} onValueChange={(v) => setForm({ ...form, scope: v })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(SCOPE_LABELS).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={2}
className="mt-1 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"></Label>
<Input
type="number"
value={form.priority}
onChange={(e) => setForm({ ...form, priority: parseInt(e.target.value) || 100 })}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">(ms)</Label>
<Input
type="number"
value={form.timeout_ms}
onChange={(e) => setForm({ ...form, timeout_ms: parseInt(e.target.value) || 1000 })}
className="mt-1"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<span className="text-xs font-medium"></span>
<Switch
checked={form.enabled}
onCheckedChange={(v) => setForm({ ...form, enabled: v })}
/>
</div>
{/* 테스트 */}
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
<Label className="text-xs font-semibold">🧪 </Label>
<div>
<Label className="text-[10px]"> JSON</Label>
<Textarea
value={testInput}
onChange={(e) => setTestInput(e.target.value)}
rows={5}
className="mt-1 font-mono text-xs"
/>
</div>
<Button size="sm" variant="outline" className="w-full gap-1" onClick={handleTestRun} disabled={testing}>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
</Button>
{testResult && (
<div className={`rounded-md border p-2 text-xs ${testResult.success ? "bg-green-500/5 border-green-500/30" : "bg-red-500/5 border-red-500/30"}`}>
<div className="flex items-center gap-1 mb-1">
{testResult.success ? <CheckCircle2 className="h-3 w-3 text-green-600"/> : <XCircle className="h-3 w-3 text-red-600"/>}
<span className="font-semibold">{testResult.success ? "성공" : "실패"}</span>
<span className="text-muted-foreground text-[10px] ml-auto">{testResult.duration_ms}ms</span>
</div>
<pre className="whitespace-pre-wrap font-mono text-[10px] max-h-32 overflow-y-auto">
{testResult.success
? JSON.stringify(testResult.result, null, 2)
: testResult.error}
</pre>
</div>
)}
</div>
</div>
{/* 우측: Monaco Editor */}
<div className="lg:col-span-2 border rounded-lg overflow-hidden" style={{ height: "60vh" }}>
<MonacoEditor
height="100%"
language="python"
theme="vs-dark"
value={form.code}
onChange={(v) => setForm({ ...form, code: v || "" })}
options={{
fontSize: 13,
minimap: { enabled: false },
scrollBeyondLastLine: false,
tabSize: 4,
wordWrap: "on",
}}
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving} className="gap-1">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{editing ? "수정" : "생성"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 버전 이력 모달 */}
<Dialog open={versionsModal.open} onOpenChange={(open) => setVersionsModal({open})}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-2 pt-2 max-h-[60vh] overflow-y-auto">
{versions.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-6"> .</p>
) : (
versions.map((v) => (
<div key={v.id} className="rounded-md border p-3 text-xs">
<div className="flex items-center justify-between">
<div>
<span className="font-semibold">v{v.version}</span>
<span className="text-muted-foreground ml-2">{v.code_size}B</span>
</div>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 text-[10px]"
onClick={() => rollback(versionsModal.scriptId!, v.version)}
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
<div className="text-[10px] text-muted-foreground mt-1">
{new Date(v.changed_at).toLocaleString("ko-KR")} · {v.changed_by || "system"}
</div>
{v.description && (
<div className="text-[11px] mt-1">{v.description}</div>
)}
</div>
))
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
File diff suppressed because it is too large Load Diff