/** * Instances Management Page * Display and manage all Helm instances across clusters */ import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Package, RefreshCw, Boxes, Server } from "lucide-react"; import { listClusters, listInstances, deleteInstance, updateInstance } from "@/api"; import type { ClusterResponse, InstanceResponse, UpdateInstanceRequest } from "@/api"; import type { Cluster, Instance } from "@/core/types"; import { PageHeader, DropdownSelect, Button, LoadingState, ErrorState, EmptyState, } from "@/shared/components"; import { useToast } from "@/shared"; import { InstanceErrors, SuccessMessages, formatApiError } from "@/shared/utils"; import { InstanceCard } from "../components/InstanceCard"; import { ModifyModal } from "../components/ModifyModal"; import { EntriesModal } from "../components/EntriesModal"; import { DiagnosticsModal } from "../components/DiagnosticsModal"; import { globalCache } from "@/shared/services/artifact-cache"; const AUTO_REFRESH_INTERVAL_MS = 30000; type LoadDataMode = "initial" | "manual" | "auto"; interface LoadDataOptions { skipCache?: boolean; mode?: LoadDataMode; } const InstancesManagementPage: React.FC = () => { const { success, error: toastError, info: toastInfo } = useToast(); const [clusters, setClusters] = useState([]); const [instancesByCluster, setInstancesByCluster] = useState< Map >(new Map()); const [instanceTotals, setInstanceTotals] = useState>(new Map()); const [selectedCluster, setSelectedCluster] = useState("all"); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); // Modals const [modifyInstance, setModifyInstance] = useState(null); const [entriesInstance, setEntriesInstance] = useState(null); const [diagnosticsInstance, setDiagnosticsInstance] = useState(null); // 核心数据加载函数 - 使用全局缓存 const loadDataCore = useCallback(async (options: LoadDataOptions = {}) => { const { skipCache = false, mode = "initial" } = options; const shouldShowLoading = mode === "initial"; const shouldShowRefreshing = mode === "manual"; if (shouldShowRefreshing) { setRefreshing(true); } else if (shouldShowLoading) { setLoading(true); } setError(null); try { // Load clusters with cache let clustersData: ClusterResponse[]; if (!skipCache) { const cachedClusters = globalCache.get('clusters'); if (cachedClusters) { console.log('[InstancesManagementPage] Using cached clusters'); clustersData = cachedClusters; } else { clustersData = await listClusters(); globalCache.set('clusters', clustersData); } } else { clustersData = await listClusters(); globalCache.set('clusters', clustersData); } const validClusters = clustersData.filter( (cluster): cluster is ClusterResponse & { id: string } => typeof cluster.id === "string" && cluster.id.length > 0 ); setClusters(validClusters); // Load instances for each cluster with cache const instancesMap = new Map(); const totalsMap = new Map(); for (const cluster of validClusters) { const clusterId = cluster.id; try { let normalized: NormalizedInstanceList; if (!skipCache) { const cachedInstances = globalCache.get('instances', clusterId); if (cachedInstances) { normalized = normalizeInstanceList(cachedInstances); console.log(`[InstancesManagementPage] Using cached instances for ${cluster.name}`); if (!isNormalizedInstanceList(cachedInstances)) { globalCache.set('instances', normalized, clusterId); } } else { normalized = await fetchClusterInstances(clusterId); } } else { normalized = await fetchClusterInstances(clusterId); } instancesMap.set(clusterId, normalized.instances); totalsMap.set(clusterId, normalized.total); } catch (err) { console.error(`Failed to load instances for cluster ${cluster.name}:`, err); instancesMap.set(clusterId, []); totalsMap.set(clusterId, 0); } } setInstancesByCluster(instancesMap); setInstanceTotals(totalsMap); return null; // 成功返回 null } catch (err: unknown) { const errorMsg = (err as Error).message || "Failed to load data"; setError(errorMsg); return errorMsg; // 返回错误消息 } finally { if (shouldShowRefreshing) { setRefreshing(false); } else if (shouldShowLoading) { setLoading(false); } } }, []); // 没有任何依赖! // 初始加载 - 只执行一次 useEffect(() => { let mounted = true; const init = async () => { const error = await loadDataCore({ skipCache: false, mode: "initial" }); if (mounted && error) { toastError(error); } }; init(); return () => { mounted = false; }; }, [loadDataCore, toastError]); // loadDataCore 永远不会变化 // 手动刷新函数 - 带 toast 提示,清除缓存 const loadData = useCallback(async () => { toastInfo("Refreshing instances...", { title: "Instances Refresh", durationMs: 1800, mergeKey: "instances-refresh", }); globalCache.clearType("instances"); const error = await loadDataCore({ skipCache: true, mode: "manual" }); // manual refresh mode if (error) { toastError(error); } else { success(SuccessMessages.DATA_REFRESHED); } }, [loadDataCore, toastError, toastInfo, success]); const autoRefreshInFlight = useRef(false); const autoRefresh = useCallback(async () => { if (loading || refreshing || autoRefreshInFlight.current) { return; } autoRefreshInFlight.current = true; const error = await loadDataCore({ skipCache: true, mode: "auto" }); if (error) { console.warn("[InstancesManagementPage] Auto refresh failed:", error); } autoRefreshInFlight.current = false; }, [loading, refreshing, loadDataCore]); useEffect(() => { const intervalId = window.setInterval(() => { void autoRefresh(); }, AUTO_REFRESH_INTERVAL_MS); return () => { window.clearInterval(intervalId); }; }, [autoRefresh]); const handleScale = useCallback((updatedInstance: InstanceResponse) => { setInstancesByCluster((prev) => { const next = new Map(prev); for (const [clusterId, insts] of next) { const idx = insts.findIndex((i) => i.id === updatedInstance.id); if (idx !== -1) { const updated = [...insts]; updated[idx] = updatedInstance; next.set(clusterId, updated); break; } } return next; }); }, []); const handleModify = useCallback((instance: Instance) => { setModifyInstance(instance); }, []); const handleViewEntries = useCallback((instance: Instance) => { setEntriesInstance(instance); }, []); const handleViewDiagnostics = useCallback((instance: Instance) => { setDiagnosticsInstance(instance); }, []); const handleModifyConfirm = useCallback(async ( clusterId: string, instanceId: string, data: UpdateInstanceRequest ) => { toastInfo("Applying instance update...", { title: "Update Instance", durationMs: 1800, mergeKey: `instance-update-${instanceId}`, }); try { await updateInstance({ clusterId, instanceId }, data); success(SuccessMessages.INSTANCE_UPGRADED); await loadData(); } catch (err: unknown) { const errorMsg = formatApiError(err) || InstanceErrors.UPDATE_FAILED; toastError(errorMsg); throw new Error(errorMsg); } }, [toastInfo, success, loadData, toastError]); const handleTerminate = useCallback(async (instance: Instance) => { if ( !confirm( `Are you sure you want to terminate instance "${instance.name}"? This action cannot be undone.` ) ) { return; } if (!instance.clusterId || !instance.id) { toastError("Instance ID or Cluster ID is missing"); return; } try { toastInfo(`Terminating "${instance.name || "instance"}"...`, { title: "Terminate Instance", durationMs: 1800, mergeKey: `instance-terminate-${instance.id || instance.clusterId}`, }); await deleteInstance({ clusterId: instance.clusterId, instanceId: instance.id }); success(SuccessMessages.INSTANCE_DELETED); await loadData(); } catch (err: unknown) { toastError(formatApiError(err) || InstanceErrors.DELETE_FAILED); } }, [success, toastError, loadData, toastInfo]); // Get filtered instances - memoized to avoid recalculation on every render const filteredInstances = useMemo((): Array<{ cluster: Cluster; instance: Instance }> => { const result: Array<{ cluster: Cluster; instance: Instance }> = []; clusters.forEach((cluster) => { const clusterId = cluster.id; if (!clusterId) { return; } if (selectedCluster !== "all" && clusterId !== selectedCluster) { return; } const instances = instancesByCluster.get(clusterId) || []; instances.forEach((instance) => { result.push({ cluster, instance }); }); }); return result; }, [clusters, selectedCluster, instancesByCluster]); // Calculate total instances - memoized const totalInstances = useMemo(() => { if (instanceTotals.size > 0) { return Array.from(instanceTotals.values()).reduce((sum, total) => sum + total, 0); } return Array.from(instancesByCluster.values()).reduce( (sum, instances) => sum + instances.length, 0 ); }, [instanceTotals, instancesByCluster]); return (
{/* Header */} Refresh } /> {/* Enhanced Stats with gradient cards */} {!loading && clusters.length > 0 && (
1 ? 'md:grid-cols-3' : 'md:grid-cols-2' }`}>

Total Instances

{totalInstances}

Clusters

{clusters.length}

{/* Only show Filtered when there are multiple clusters */} {clusters.length > 1 && (

Showing

{filteredInstances.length}

)}
)} {/* Enhanced Filters */} {clusters.length > 1 && (
setSelectedCluster(value)} options={[ { value: "all", label: "All Clusters" }, ...clusters .filter((cluster): cluster is ClusterResponse & { id: string } => Boolean(cluster.id)) .map((cluster) => { const instanceCount = instancesByCluster.get(cluster.id)?.length || 0; return { value: cluster.id, label: `${cluster.name || 'Unknown'} (${instanceCount} instances)`, }; }), ]} />
)} {/* Content */}
{loading ? ( ) : error ? ( ) : filteredInstances.length === 0 ? ( ) : (
{/* Group by cluster if showing all */} {selectedCluster === "all" ? ( clusters.map((cluster, index) => { const clusterId = cluster.id; if (!clusterId) return null; const instances = instancesByCluster.get(clusterId) || []; if (instances.length === 0) return null; return (

{cluster.name || "Unnamed Cluster"}

{instances.length} {instances.length === 1 ? 'instance' : 'instances'} running

{instances.map((instance) => ( ))}
); }) ) : (
{filteredInstances.map(({ instance }) => ( ))}
)}
)}
{/* Modals */} {modifyInstance && ( setModifyInstance(null)} onConfirm={handleModifyConfirm} /> )} {entriesInstance && ( setEntriesInstance(null)} /> )} {diagnosticsInstance && ( setDiagnosticsInstance(null)} /> )}
); }; export default InstancesManagementPage; async function fetchClusterInstances(clusterId: string): Promise { const response = await listInstances({ clusterId }); const normalized = normalizeInstanceList(response); globalCache.set('instances', normalized, clusterId); return normalized; } function normalizeInstanceList(raw: unknown): NormalizedInstanceList { if (Array.isArray(raw)) { return { instances: raw as InstanceResponse[], total: raw.length, }; } if (raw && typeof raw === "object") { const payload = raw as InstanceListPayloadWithMeta; const direct = extractInstancesFromPayload(payload); if (direct) { return direct; } if (payload.data !== undefined) { const nested = normalizeInstanceList(payload.data); return { instances: nested.instances, total: pickTotalValue(payload.total, payload.meta, nested.total), }; } } return { instances: [], total: 0 }; } function extractInstancesFromPayload(payload: InstanceListPayloadWithMeta): NormalizedInstanceList | null { if (Array.isArray(payload.instances)) { const instances = payload.instances as InstanceResponse[]; return { instances, total: pickTotalValue(payload.total, payload.meta, instances.length), }; } if (Array.isArray(payload.items)) { const instances = payload.items as InstanceResponse[]; return { instances, total: pickTotalValue(payload.total, payload.meta, instances.length), }; } if (payload.data && typeof payload.data === "object") { return extractInstancesFromPayload(payload.data as InstanceListPayloadWithMeta); } return null; } function pickTotalValue(totalValue: unknown, metaValue: unknown, fallback: number): number { if (typeof totalValue === "number" && Number.isFinite(totalValue)) { return totalValue; } const metaTotal = getMetaTotal(metaValue); if (typeof metaTotal === "number") { return metaTotal; } return fallback; } function getMetaTotal(metaValue: unknown): number | undefined { if (metaValue && typeof metaValue === "object") { const meta = metaValue as Record; if (typeof meta.total === "number") { return meta.total; } if (typeof meta.count === "number") { return meta.count; } } return undefined; } function isNormalizedInstanceList(data: unknown): data is NormalizedInstanceList { return ( !!data && typeof data === "object" && Array.isArray((data as NormalizedInstanceList).instances) && typeof (data as NormalizedInstanceList).total === "number" ); } type InstanceListPayloadWithMeta = { instances?: unknown; items?: unknown; data?: unknown; total?: unknown; meta?: unknown; }; interface NormalizedInstanceList { instances: InstanceResponse[]; total: number; }