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>
263 lines
13 KiB
TypeScript
263 lines
13 KiB
TypeScript
"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>
|
|
);
|
|
}
|