- Backend: add replicas field to InstanceResponse (extracted from values.replicaCount) - InstanceCard: complete redesign as horizontal row layout - Status bar | Name+Chart | Replicas +/- | Action buttons - Scale controls show for deployed AND failed statuses (scale to 0) - Fix replicas display using new instance.replicas backend field - InstancesManagementPage: vertical row list + onScale callback to update state - TagCard: restore proper padding (p-4), min-width, readable button sizes - ArtifactBrowserPage: reduce grid density (sm:1 md:2 lg:3) - ModifyModal: simplify to YAML-only editing with current values pre-populated - Remove schema-based form generator - Keep values-diff as collapsible reference panel
610 lines
21 KiB
TypeScript
610 lines
21 KiB
TypeScript
/**
|
|
* 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<ClusterResponse[]>([]);
|
|
const [instancesByCluster, setInstancesByCluster] = useState<
|
|
Map<string, InstanceResponse[]>
|
|
>(new Map());
|
|
const [instanceTotals, setInstanceTotals] = useState<Map<string, number>>(new Map());
|
|
const [selectedCluster, setSelectedCluster] = useState<string>("all");
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Modals
|
|
const [modifyInstance, setModifyInstance] = useState<Instance | null>(null);
|
|
const [entriesInstance, setEntriesInstance] = useState<Instance | null>(null);
|
|
const [diagnosticsInstance, setDiagnosticsInstance] = useState<Instance | null>(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<ClusterResponse[]>('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<string, InstanceResponse[]>();
|
|
const totalsMap = new Map<string, number>();
|
|
for (const cluster of validClusters) {
|
|
const clusterId = cluster.id;
|
|
try {
|
|
let normalized: NormalizedInstanceList;
|
|
if (!skipCache) {
|
|
const cachedInstances = globalCache.get<unknown>('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 (
|
|
<div className="p-6">
|
|
{/* Header */}
|
|
<PageHeader
|
|
title="Artifact - Instances"
|
|
description="Manage service instances across clusters"
|
|
icon={Boxes}
|
|
iconColor="text-green-400"
|
|
actions={
|
|
<Button
|
|
variant="secondary"
|
|
icon={RefreshCw}
|
|
onClick={loadData}
|
|
loading={refreshing}
|
|
spinIcon={true}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
{/* Enhanced Stats with gradient cards */}
|
|
{!loading && clusters.length > 0 && (
|
|
<div className={`grid grid-cols-1 gap-5 mb-8 ${
|
|
clusters.length > 1 ? 'md:grid-cols-3' : 'md:grid-cols-2'
|
|
}`}>
|
|
<div className="relative group overflow-hidden bg-white border border-blue-100 rounded-lg p-6 hover:border-blue-200 hover:shadow-md transition-all duration-300">
|
|
<div className="relative flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-semibold text-blue-700 uppercase tracking-wider mb-2">Total Instances</p>
|
|
<p className="text-4xl font-bold text-slate-900">{totalInstances}</p>
|
|
</div>
|
|
<div className="p-4 bg-blue-50 rounded-lg border border-blue-100">
|
|
<Package className="w-8 h-8 text-blue-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative group overflow-hidden bg-white border border-emerald-100 rounded-lg p-6 hover:border-emerald-200 hover:shadow-md transition-all duration-300">
|
|
<div className="relative flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-semibold text-emerald-700 uppercase tracking-wider mb-2">Clusters</p>
|
|
<p className="text-4xl font-bold text-slate-900">{clusters.length}</p>
|
|
</div>
|
|
<div className="p-4 bg-emerald-50 rounded-lg border border-emerald-100">
|
|
<Server className="w-8 h-8 text-emerald-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Only show Filtered when there are multiple clusters */}
|
|
{clusters.length > 1 && (
|
|
<div className="relative group overflow-hidden bg-white border border-violet-100 rounded-lg p-6 hover:border-violet-200 hover:shadow-md transition-all duration-300">
|
|
<div className="relative flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-semibold text-violet-700 uppercase tracking-wider mb-2">Showing</p>
|
|
<p className="text-4xl font-bold text-slate-900">{filteredInstances.length}</p>
|
|
</div>
|
|
<div className="p-4 bg-violet-50 rounded-lg border border-violet-100">
|
|
<Boxes className="w-8 h-8 text-violet-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Enhanced Filters */}
|
|
{clusters.length > 1 && (
|
|
<div className="mb-6 p-5 bg-white border border-slate-200 rounded-lg shadow-sm">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-gradient-to-br from-cyan-500/20 to-blue-500/20 rounded-lg border border-cyan-500/30">
|
|
<Server className="w-5 h-5 text-cyan-400" />
|
|
</div>
|
|
<label className="text-sm font-semibold text-slate-700">
|
|
Filter by Cluster:
|
|
</label>
|
|
</div>
|
|
<DropdownSelect
|
|
value={selectedCluster}
|
|
onChange={(value) => 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)`,
|
|
};
|
|
}),
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div>
|
|
{loading ? (
|
|
<LoadingState message="Loading instances..." />
|
|
) : error ? (
|
|
<ErrorState
|
|
message={error}
|
|
onRetry={loadData}
|
|
/>
|
|
) : filteredInstances.length === 0 ? (
|
|
<EmptyState
|
|
icon={Package}
|
|
title="No instances found"
|
|
description="Launch your first service instance from Artifact Registries"
|
|
/>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{/* 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 (
|
|
<div key={clusterId || `cluster-${index}`} className="mb-8">
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<div className="p-2.5 bg-gradient-to-br from-emerald-500/20 to-green-500/20 rounded-lg border border-emerald-500/30 shadow-lg shadow-emerald-500/10">
|
|
<Server className="w-5 h-5 text-emerald-400" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-xl font-bold text-slate-900">
|
|
{cluster.name || "Unnamed Cluster"}
|
|
</h2>
|
|
<p className="text-sm text-slate-500 mt-0.5">
|
|
{instances.length} {instances.length === 1 ? 'instance' : 'instances'} running
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
{instances.map((instance) => (
|
|
<InstanceCard
|
|
key={instance.id}
|
|
instance={instance}
|
|
onModify={handleModify}
|
|
onTerminate={handleTerminate}
|
|
|
|
onScale={handleScale}
|
|
onViewEntries={handleViewEntries}
|
|
onViewDiagnostics={handleViewDiagnostics}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
{filteredInstances.map(({ instance }) => (
|
|
<InstanceCard
|
|
key={instance.id}
|
|
instance={instance}
|
|
onModify={handleModify}
|
|
onTerminate={handleTerminate}
|
|
|
|
onViewEntries={handleViewEntries}
|
|
onViewDiagnostics={handleViewDiagnostics}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modals */}
|
|
{modifyInstance && (
|
|
<ModifyModal
|
|
instance={modifyInstance}
|
|
onClose={() => setModifyInstance(null)}
|
|
onConfirm={handleModifyConfirm}
|
|
/>
|
|
)}
|
|
|
|
{entriesInstance && (
|
|
<EntriesModal
|
|
instance={entriesInstance}
|
|
onClose={() => setEntriesInstance(null)}
|
|
/>
|
|
)}
|
|
|
|
{diagnosticsInstance && (
|
|
<DiagnosticsModal
|
|
instance={diagnosticsInstance}
|
|
onClose={() => setDiagnosticsInstance(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default InstancesManagementPage;
|
|
|
|
async function fetchClusterInstances(clusterId: string): Promise<NormalizedInstanceList> {
|
|
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<string, unknown>;
|
|
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;
|
|
}
|