Files
pipeline/frontend/components/layout/AdminPageRenderer.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

338 lines
15 KiB
TypeScript

"use client";
import React, { useMemo, useState, useEffect } from "react";
import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react";
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
const LoadingFallback = () => (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
function ScreenCodeResolver({ screenCode }: { screenCode: string }) {
const [screenId, setScreenId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const numericId = parseInt(screenCode);
if (!isNaN(numericId)) {
setScreenId(numericId);
setLoading(false);
return;
}
const resolve = async () => {
try {
const res = await apiClient.get("/screen-management/screens", {
params: { searchTerm: screenCode, size: 50 },
});
const items = res.data?.data?.data || res.data?.data || [];
const arr = Array.isArray(items) ? items : [];
const exact = arr.find((s: any) => s.screenCode === screenCode);
const target = exact || arr[0];
if (target) setScreenId(target.screenId || target.screen_id);
} catch {
console.error("스크린 코드 변환 실패:", screenCode);
} finally {
setLoading(false);
}
};
resolve();
}, [screenCode]);
if (loading) return <LoadingFallback />;
if (!screenId) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground"> (: {screenCode})</p>
</div>
);
}
return <ScreenViewPageWrapper screenIdProp={screenId} />;
}
const DashboardViewPage = dynamic(
() => import("@/app/(main)/dashboard/[dashboardId]/page"),
{ ssr: false, loading: LoadingFallback },
);
// 별칭 매핑: URL과 실제 파일 경로가 다른 경우만 등록
// 나머지는 DynamicAdminLoader의 자동 import로 처리 (@/app/(main)/${url}/page)
const ADMIN_PAGE_ALIASES: Record<string, string> = {
"/admin/systemMng/cascading-managementList": "/admin/cascading-management",
"/admin/systemMng/dataflow/node-editorList": "/admin/systemMng/dataflow",
"/admin/cascading-relations": "/admin/cascading-management",
"/admin/auto-fill": "/admin/cascading-management",
};
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {};
// 페이지 등록: URL → lazy import 매핑
const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
"/admin": () => import("@/app/(main)/admin/page"),
"/admin/menu": () => import("@/app/(main)/admin/menu/page"),
"/admin/userMng/userMngList": () => import("@/app/(main)/admin/userMng/userMngList/page"),
"/admin/userMng/rolesList": () => import("@/app/(main)/admin/userMng/rolesList/page"),
"/admin/userMng/userAuthList": () => import("@/app/(main)/admin/userMng/userAuthList/page"),
"/admin/userMng/companyList": () => import("@/app/(main)/admin/userMng/companyList/page"),
"/admin/screenMng/dashboardList": () => import("@/app/(main)/admin/screenMng/dashboardList/page"),
"/admin/screenMng/reportList": () => import("@/app/(main)/admin/screenMng/reportList/page"),
"/admin/systemMng/commonCodeList": () => import("@/app/(main)/admin/systemMng/commonCodeList/page"),
"/admin/systemMng/tableMngList": () => import("@/app/(main)/admin/systemMng/tableMngList/page"),
"/admin/systemMng/i18nList": () => import("@/app/(main)/admin/systemMng/i18nList/page"),
"/admin/systemMng/collection-managementList": () => import("@/app/(main)/admin/systemMng/collection-managementList/page"),
"/admin/systemMng/dataflow": () => import("@/app/(main)/admin/systemMng/dataflow/page"),
"/admin/systemMng/dataflow/node-editorList": () => import("@/app/(main)/admin/systemMng/dataflow/page"),
"/admin/automaticMng/flowMgmtList": () => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"),
"/admin/automaticMng/batchmngList": () => import("@/app/(main)/admin/automaticMng/batchmngList/page"),
"/admin/automaticMng/batchmngList/create": () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"),
"/admin/automaticMng/crawlingList": () => import("@/app/(main)/admin/automaticMng/crawlingList/page"),
"/admin/automaticMng/exconList": () => import("@/app/(main)/admin/automaticMng/exconList/page"),
"/admin/automaticMng/exCallConfList": () => import("@/app/(main)/admin/automaticMng/exCallConfList/page"),
"/admin/automaticMng/mail/send": () => import("@/app/(main)/admin/automaticMng/mail/send/page"),
"/admin/automaticMng/mail/receive": () => import("@/app/(main)/admin/automaticMng/mail/receive/page"),
"/admin/automaticMng/mail/sent": () => import("@/app/(main)/admin/automaticMng/mail/sent/page"),
"/admin/automaticMng/mail/drafts": () => import("@/app/(main)/admin/automaticMng/mail/drafts/page"),
"/admin/automaticMng/mail/trash": () => import("@/app/(main)/admin/automaticMng/mail/trash/page"),
"/admin/automaticMng/mail/accounts": () => import("@/app/(main)/admin/automaticMng/mail/accounts/page"),
"/admin/automaticMng/mail/templates": () => import("@/app/(main)/admin/automaticMng/mail/templates/page"),
"/admin/automaticMng/mail/dashboardList": () => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"),
"/admin/automaticMng/mail/bulk-send": () => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"),
"/mail/imap": () => import("@/app/(main)/mail/imap/page"),
"/admin/approvalTemplate": () => import("@/app/(main)/admin/approvalTemplate/page"),
"/admin/approvalBox": () => import("@/app/(main)/admin/approvalBox/page"),
"/admin/approvalMng": () => import("@/app/(main)/admin/approvalMng/page"),
"/admin/audit-log": () => import("@/app/(main)/admin/audit-log/page"),
"/admin/system-notices": () => import("@/app/(main)/admin/system-notices/page"),
"/admin/aiAssistant": () => import("@/app/(main)/admin/aiAssistant/page"),
"/admin/aiAssistant/agents": () => import("@/app/(main)/admin/aiAssistant/agents/page"),
"/admin/aiAssistant/providers": () => import("@/app/(main)/admin/aiAssistant/providers/page"),
"/admin/aiAssistant/workspace": () => import("@/app/(main)/admin/aiAssistant/workspace/page"),
"/admin/aiAssistant/conversations": () => import("@/app/(main)/admin/aiAssistant/conversations/page"),
"/admin/aiAssistant/api-keys-manage": () => import("@/app/(main)/admin/aiAssistant/api-keys-manage/page"),
"/admin/aiAssistant/knowledge": () => import("@/app/(main)/admin/aiAssistant/knowledge/page"),
"/admin/cascading-management": () => import("@/app/(main)/admin/cascading-management/page"),
"/admin/layouts": () => import("@/app/(main)/admin/layouts/page"),
"/admin/templates": () => import("@/app/(main)/admin/templates/page"),
"/admin/monitoring": () => import("@/app/(main)/admin/monitoring/page"),
"/admin/standards": () => import("@/app/(main)/admin/standards/page"),
"/admin/standards/new": () => import("@/app/(main)/admin/standards/new/page"),
"/admin/flow-external-db": () => import("@/app/(main)/admin/flow-external-db/page"),
// 장비 연결 관리
"/admin/pipeline-device": () => import("@/app/(main)/admin/pipeline-device/page"),
// Fleet 관리
"/admin/fleet/devices": () => import("@/app/(main)/admin/fleet/devices/page"),
"/admin/fleet/commands": () => import("@/app/(main)/admin/fleet/commands/page"),
"/admin/fleet/alerts": () => import("@/app/(main)/admin/fleet/alerts/page"),
"/admin/fleet/data": () => import("@/app/(main)/admin/fleet/data/page"),
"/admin/fleet/scripts": () => import("@/app/(main)/admin/fleet/scripts/page"),
"/admin/fleet/deployments": () => import("@/app/(main)/admin/fleet/deployments/page"),
"/admin/fleet/releases": () => import("@/app/(main)/admin/fleet/releases/page"),
"/admin/fleet/rules": () => import("@/app/(main)/admin/fleet/rules/page"),
"/admin/fleet/audit": () => import("@/app/(main)/admin/fleet/audit/page"),
};
const DYNAMIC_ADMIN_PATTERNS: Array<{
pattern: RegExp;
getImport: (match: RegExpMatchArray) => Promise<any>;
extractParams: (match: RegExpMatchArray) => Record<string, string>;
}> = [
{
pattern: /^\/admin\/userMng\/rolesList\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"),
extractParams: (m) => ({ id: m[1] }),
},
{
pattern: /^\/admin\/screenMng\/dashboardList\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"),
extractParams: (m) => ({ id: m[1] }),
},
{
pattern: /^\/admin\/automaticMng\/flowMgmtList\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/automaticMng/flowMgmtList/[id]/page"),
extractParams: (m) => ({ id: m[1] }),
},
{
pattern: /^\/admin\/automaticMng\/batchmngList\/edit\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page"),
extractParams: (m) => ({ id: m[1] }),
},
{
pattern: /^\/admin\/screenMng\/reportList\/designer\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/screenMng/reportList/designer/[reportId]/page"),
extractParams: (m) => ({ reportId: m[1] }),
},
{
pattern: /^\/admin\/systemMng\/dataflow\/edit\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"),
extractParams: (m) => ({ diagramId: m[1] }),
},
{
pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/,
getImport: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"),
extractParams: (m) => ({ companyCode: m[1] }),
},
{
pattern: /^\/admin\/standards\/([^/]+)\/edit$/,
getImport: () => import("@/app/(main)/admin/standards/[webType]/edit/page"),
extractParams: (m) => ({ webType: m[1] }),
},
{
pattern: /^\/admin\/standards\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/standards/[webType]/page"),
extractParams: (m) => ({ webType: m[1] }),
},
];
function DynamicAdminLoader({ url, params }: { url: string; params?: Record<string, string> }) {
const [Component, setComponent] = useState<React.ComponentType<any> | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
const tryLoad = async () => {
// 1) 정적 import 목록
const staticImport = DYNAMIC_ADMIN_IMPORTS[url];
if (staticImport) {
try {
const mod = await staticImport();
if (!cancelled) setComponent(() => mod.default);
} catch {
if (!cancelled) setFailed(true);
}
return;
}
// 2) 동적 라우트 패턴 매칭
for (const { pattern, getImport } of DYNAMIC_ADMIN_PATTERNS) {
const match = url.match(pattern);
if (match) {
try {
const mod = await getImport();
if (!cancelled) setComponent(() => mod.default);
} catch {
if (!cancelled) setFailed(true);
}
return;
}
}
// 3) 매칭되지 않은 경우 실패 처리
// (페이지는 DYNAMIC_ADMIN_IMPORTS 또는 DYNAMIC_ADMIN_PATTERNS에 등록 필요)
console.warn("[DynamicAdminLoader] 미등록 페이지:", url);
if (!cancelled) setFailed(true);
};
tryLoad();
return () => { cancelled = true; };
}, [url]);
if (failed) return <AdminPageFallback url={url} />;
if (!Component) return <LoadingFallback />;
if (params) return <Component params={Promise.resolve(params)} adminParams={params} />;
return <Component />;
}
function AdminPageFallback({ url }: { url: string }) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-lg font-semibold text-foreground"> </p>
<p className="mt-1 text-sm text-muted-foreground">: {url}</p>
<p className="mt-2 text-xs text-muted-foreground"> .</p>
</div>
</div>
);
}
interface AdminPageRendererProps {
url: string;
}
// 회사별 커스텀 페이지 경로 prefix 목록
// 이 prefix로 시작하는 URL은 회사코드 폴더에서 로드
const COMPANY_PAGE_PREFIXES = [
"/sales/",
"/master-data/",
"/production/",
"/equipment/",
"/logistics/",
"/outsourcing/",
"/design/",
"/purchase/",
"/quality/",
"/mold/",
"/monitoring/",
];
function isCompanyPage(url: string): boolean {
return COMPANY_PAGE_PREFIXES.some((prefix) => url.startsWith(prefix));
}
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
const { user } = useAuth();
const companyCode = user?.companyCode || user?.company_code;
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
// 회사별 커스텀 페이지: companyCode를 prefix로 붙여 경로 변환
// 예: /sales/order → /COMPANY_7/sales/order
const resolvedUrl = (companyCode && isCompanyPage(cleanUrl))
? `/${companyCode}${cleanUrl}`
: cleanUrl;
console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl, resolvedUrl, companyCode });
// 화면 할당: /screens/[id]
const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/);
if (screensIdMatch) {
console.log("[AdminPageRenderer] → /screens/[id] 매칭:", screensIdMatch[1]);
return <ScreenViewPageWrapper screenIdProp={parseInt(screensIdMatch[1])} />;
}
// 화면 할당: /screen/[code] (구 형식)
const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/);
if (screenCodeMatch) {
console.log("[AdminPageRenderer] → /screen/[code] 매칭:", screenCodeMatch[1]);
return <ScreenCodeResolver screenCode={screenCodeMatch[1]} />;
}
// 대시보드 할당: /dashboard/[id]
const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/);
if (dashboardMatch) {
console.log("[AdminPageRenderer] → /dashboard/[id] 매칭:", dashboardMatch[1]);
return <DashboardViewPage params={Promise.resolve({ dashboardId: dashboardMatch[1] })} />;
}
// 별칭 매핑 적용
const aliasedUrl = ADMIN_PAGE_ALIASES[resolvedUrl] || ADMIN_PAGE_ALIASES[cleanUrl] || resolvedUrl;
// URL 직접 입력: 레지스트리 매칭 (resolvedUrl 우선, cleanUrl 폴백)
const PageComponent = useMemo(() => {
return ADMIN_PAGE_REGISTRY[aliasedUrl] || ADMIN_PAGE_REGISTRY[resolvedUrl] || ADMIN_PAGE_REGISTRY[cleanUrl] || null;
}, [aliasedUrl, resolvedUrl, cleanUrl]);
if (PageComponent) {
console.log("[AdminPageRenderer] → 레지스트리 매칭:", aliasedUrl || resolvedUrl || cleanUrl);
return <PageComponent />;
}
// 레지스트리에 없으면 동적 import 시도
// 동적 라우트 패턴 매칭 (params 추출)
for (const { pattern, extractParams } of DYNAMIC_ADMIN_PATTERNS) {
const match = cleanUrl.match(pattern);
if (match) {
const params = extractParams(match);
console.log("[AdminPageRenderer] → 동적 라우트 매칭:", cleanUrl, params);
return <DynamicAdminLoader url={cleanUrl} params={params} />;
}
}
// 레지스트리/패턴에 없으면 DynamicAdminLoader가 자동 import 시도
console.log("[AdminPageRenderer] → 자동 import 시도:", aliasedUrl);
return <DynamicAdminLoader url={aliasedUrl} />;
}