Files
ocdp-go/frontend/src/features/artifact/instances/pages/InstancesManagementPage.tsx
Ivan087 49b92e66c3 fix: UI redesign — horizontal instance rows, proper scaling, readable tag cards
- 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
2026-05-13 12:30:52 +08:00

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;
}