ocdp v1
This commit is contained in:
@ -0,0 +1,611 @@
|
||||
/**
|
||||
* 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 { 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 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 handleRefresh = useCallback(async (instance: Instance) => {
|
||||
const clusterId = instance.clusterId;
|
||||
if (!clusterId) {
|
||||
toastError("Cluster ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
toastInfo(`Refreshing status for "${instance.name || "instance"}"...`, {
|
||||
title: "Instance Status",
|
||||
durationMs: 1800,
|
||||
mergeKey: `instance-refresh-${instance.id || clusterId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const normalized = await fetchClusterInstances(clusterId);
|
||||
setInstancesByCluster((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(clusterId, normalized.instances);
|
||||
return next;
|
||||
});
|
||||
setInstanceTotals((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(clusterId, normalized.total);
|
||||
return next;
|
||||
});
|
||||
success(SuccessMessages.INSTANCE_STATUS_REFRESHED);
|
||||
} catch (err: unknown) {
|
||||
toastError(formatApiError(err) || InstanceErrors.STATUS_FETCH_FAILED);
|
||||
}
|
||||
}, [success, toastError, toastInfo]);
|
||||
|
||||
const handleModify = useCallback((instance: Instance) => {
|
||||
setModifyInstance(instance);
|
||||
}, []);
|
||||
|
||||
const handleViewEntries = useCallback((instance: Instance) => {
|
||||
setEntriesInstance(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-gradient-to-br from-blue-900/40 via-blue-800/30 to-blue-900/40 border border-blue-500/30 rounded-xl p-6 hover:border-blue-400/50 hover:shadow-xl hover:shadow-blue-500/20 transition-all duration-300">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl group-hover:bg-blue-500/20 transition-all"></div>
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-blue-300 uppercase tracking-wider mb-2">Total Instances</p>
|
||||
<p className="text-4xl font-bold text-white">{totalInstances}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-500/20 rounded-xl border border-blue-400/30 shadow-lg shadow-blue-500/30">
|
||||
<Package className="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative group overflow-hidden bg-gradient-to-br from-emerald-900/40 via-emerald-800/30 to-green-900/40 border border-emerald-500/30 rounded-xl p-6 hover:border-emerald-400/50 hover:shadow-xl hover:shadow-emerald-500/20 transition-all duration-300">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-emerald-500/10 rounded-full blur-3xl group-hover:bg-emerald-500/20 transition-all"></div>
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-emerald-300 uppercase tracking-wider mb-2">Clusters</p>
|
||||
<p className="text-4xl font-bold text-white">{clusters.length}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-emerald-500/20 rounded-xl border border-emerald-400/30 shadow-lg shadow-emerald-500/30">
|
||||
<Server className="w-8 h-8 text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Only show Filtered when there are multiple clusters */}
|
||||
{clusters.length > 1 && (
|
||||
<div className="relative group overflow-hidden bg-gradient-to-br from-purple-900/40 via-purple-800/30 to-purple-900/40 border border-purple-500/30 rounded-xl p-6 hover:border-purple-400/50 hover:shadow-xl hover:shadow-purple-500/20 transition-all duration-300">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-purple-500/10 rounded-full blur-3xl group-hover:bg-purple-500/20 transition-all"></div>
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-purple-300 uppercase tracking-wider mb-2">Showing</p>
|
||||
<p className="text-4xl font-bold text-white">{filteredInstances.length}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-500/20 rounded-xl border border-purple-400/30 shadow-lg shadow-purple-500/30">
|
||||
<Boxes className="w-8 h-8 text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enhanced Filters */}
|
||||
{clusters.length > 1 && (
|
||||
<div className="mb-6 p-5 bg-gradient-to-r from-slate-800/50 via-slate-800/30 to-slate-800/50 border border-slate-700/50 rounded-xl">
|
||||
<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-300">
|
||||
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-white">
|
||||
{cluster.name || "Unnamed Cluster"}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mt-0.5">
|
||||
{instances.length} {instances.length === 1 ? 'instance' : 'instances'} running
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{instances.map((instance) => (
|
||||
<InstanceCard
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
onModify={handleModify}
|
||||
onTerminate={handleTerminate}
|
||||
onRefresh={handleRefresh}
|
||||
onViewEntries={handleViewEntries}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filteredInstances.map(({ instance }) => (
|
||||
<InstanceCard
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
onModify={handleModify}
|
||||
onTerminate={handleTerminate}
|
||||
onRefresh={handleRefresh}
|
||||
onViewEntries={handleViewEntries}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{modifyInstance && (
|
||||
<ModifyModal
|
||||
instance={modifyInstance}
|
||||
onClose={() => setModifyInstance(null)}
|
||||
onConfirm={handleModifyConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{entriesInstance && (
|
||||
<EntriesModal
|
||||
instance={entriesInstance}
|
||||
onClose={() => setEntriesInstance(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;
|
||||
}
|
||||
Reference in New Issue
Block a user