feat: Fleet/Collector/엣지 배포 관련 누적 작업 일괄 커밋
Build and Push Images / build-and-push (push) Has been cancelled
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:
@@ -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=">">></SelectItem>
|
||||
<SelectItem value=">=">>=</SelectItem>
|
||||
<SelectItem value="<"><</SelectItem>
|
||||
<SelectItem value="<="><=</SelectItem>
|
||||
<SelectItem value="==">==</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">임계값</Label>
|
||||
<Input type="number" value={form.threshold} onChange={(e) => setForm({...form, threshold: parseFloat(e.target.value)})} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">지속 시간(초)</Label>
|
||||
<Input type="number" value={form.duration_sec} onChange={(e) => setForm({...form, duration_sec: parseInt(e.target.value)})} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">심각도</Label>
|
||||
<Select value={form.severity} onValueChange={(v) => setForm({...form, severity: v})}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="info">info</SelectItem>
|
||||
<SelectItem value="warning">warning</SelectItem>
|
||||
<SelectItem value="critical">critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">알림 채널</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{["email", "messenger", "webhook", "sms"].map((ch) => (
|
||||
<label key={ch} className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input type="checkbox" checked={form.notify_channels.includes(ch)} onChange={() => toggleChannel(ch)} />
|
||||
{ch}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={save}>{editing ? "수정" : "생성"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user