4c1dc4082e
Build and Push Images / build-and-push (push) Has been cancelled
이전 세션들에서 작업된 아래 범위를 모두 포함: Fleet 서브시스템 (src/fleet/) - fleetDeviceService / fleetCommandService / fleetDeploymentService / fleetReleaseService - fleetMetricsService, fleetScriptService, fleetEdgeConfigService - Edge 디바이스 관리, 커맨드 발행, 배포/릴리스, 스크립트 동기화 Collector 확장 - centralMqttForwarder / centralForwarderConfigService - equipmentStateService, pythonHookRunner, scriptCache - Modbus/OPC-UA/S7/XGT 프로토콜 클라이언트 - targetDbIntrospection (저장 DB 조회) Routes / API - automationDashboardRoutes, centralForwarderRoutes, equipmentStateRoutes DB - importEdgeConfig (Python cached config → Pipeline DB) - seedDataSources (external_db_connections 초기 시드) 엣지 배포 리소스 - docker/edge/Dockerfile.backend.prod, Dockerfile.frontend.prod - docker/edge/docker-compose.edge.yml 프론트엔드 - admin/automaticMng (centralForwarder, dashboard, equipmentState) - admin/fleet (commands, devices, deployments, releases, scripts, alerts) - admin/pipeline-device 개선 (저장 DB 드롭다운, 태그 매핑 등) - ExternalDbConnectionModal, ScriptsManagerDialog 등 신규 컴포넌트 - lib/api: automationDashboard, centralForwarder, equipmentState, fleet docs/ - EDGE_SERVER_STRUCTURE, FLEET_COMPLETE, FLEET_EDGE_INTEGRATION, FLEET_HOOK_INTEGRATION Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
403 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|