Files
pipeline/frontend/app/(main)/admin/automaticMng/dashboard/page.tsx
T
chpark 4c1dc4082e
Build and Push Images / build-and-push (push) Has been cancelled
feat: Fleet/Collector/엣지 배포 관련 누적 작업 일괄 커밋
이전 세션들에서 작업된 아래 범위를 모두 포함:

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>
2026-04-23 20:00:06 +09:00

403 lines
14 KiB
TypeScript

"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>
);
}