ocdp v1
This commit is contained in:
@ -0,0 +1,602 @@
|
||||
/**
|
||||
* Entries Modal Component
|
||||
* 显示实例的入口信息(Services 和 Ingresses)
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { X, Globe, Network, ExternalLink, Copy, CheckCircle, Info } from "lucide-react";
|
||||
import { listInstanceEntries } from "@/api";
|
||||
import type { InstanceEntry, InstanceResponse } from "@/api";
|
||||
import { useToast } from "@/shared";
|
||||
|
||||
interface ServiceEntry {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
type?: string;
|
||||
cluster_ip?: string;
|
||||
external_ips?: string[];
|
||||
ports?: Array<{
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
port?: number | string;
|
||||
target_port?: number | string;
|
||||
node_port?: number;
|
||||
}>;
|
||||
loadBalancer?: {
|
||||
ingress?: Array<{
|
||||
ip?: string;
|
||||
hostname?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface IngressEntry {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
class_name?: string;
|
||||
rules?: Array<{
|
||||
host?: string;
|
||||
paths?: Array<{
|
||||
path?: string;
|
||||
path_type?: string;
|
||||
backend?: {
|
||||
service?: {
|
||||
name?: string;
|
||||
port?: number | string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
tls?: Array<{
|
||||
hosts?: string[];
|
||||
secret_name?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
type EntrySource = 'kubernetes' | 'manifest' | 'notes' | 'none';
|
||||
|
||||
interface InstanceEntries {
|
||||
services: ServiceEntry[];
|
||||
ingresses: IngressEntry[];
|
||||
source: EntrySource;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
const NESTED_ENTRY_KEYS = ["entries", "data", "result", "results", "payload", "services", "ingresses"];
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function collectEntryContainers(value: unknown, seen = new Set<unknown>()): Record<string, unknown>[] {
|
||||
if (!isPlainObject(value) || seen.has(value)) {
|
||||
return [];
|
||||
}
|
||||
seen.add(value);
|
||||
const containers: Record<string, unknown>[] = [value];
|
||||
for (const key of NESTED_ENTRY_KEYS) {
|
||||
const nested = (value as Record<string, unknown>)[key];
|
||||
if (isPlainObject(nested)) {
|
||||
containers.push(...collectEntryContainers(nested, seen));
|
||||
}
|
||||
}
|
||||
return containers;
|
||||
}
|
||||
|
||||
function asEntryArray(value: unknown, seen = new Set<unknown>()): InstanceEntry[] | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value as InstanceEntry[];
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
if (seen.has(value)) {
|
||||
return undefined;
|
||||
}
|
||||
seen.add(value);
|
||||
return (
|
||||
asEntryArray(value.entries, seen) ||
|
||||
asEntryArray(value.items, seen) ||
|
||||
asEntryArray(value.data, seen)
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ENTRY_SOURCES: EntrySource[] = ["kubernetes", "manifest", "notes", "none"];
|
||||
|
||||
async function getInstanceEntries(clusterId: string, instanceId: string): Promise<InstanceEntries> {
|
||||
const raw = (await listInstanceEntries({ clusterId, instanceId })) as unknown;
|
||||
return buildInstanceEntries(raw);
|
||||
}
|
||||
|
||||
function buildInstanceEntries(raw: unknown): InstanceEntries {
|
||||
const baseState: InstanceEntries = {
|
||||
services: [],
|
||||
ingresses: [],
|
||||
source: "none",
|
||||
};
|
||||
|
||||
if (raw == null) {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
let entriesPayload: InstanceEntry[] | undefined = Array.isArray(raw) ? (raw as InstanceEntry[]) : undefined;
|
||||
let source: EntrySource = "none";
|
||||
let notes: string | undefined;
|
||||
let servicesFromResponse: InstanceEntry[] | undefined;
|
||||
let ingressesFromResponse: InstanceEntry[] | undefined;
|
||||
|
||||
const containers = collectEntryContainers(raw);
|
||||
for (const container of containers) {
|
||||
entriesPayload ||= asEntryArray(container.entries);
|
||||
entriesPayload ||= asEntryArray(container.items);
|
||||
entriesPayload ||= asEntryArray(container.data);
|
||||
servicesFromResponse ||= asEntryArray(container.services);
|
||||
ingressesFromResponse ||= asEntryArray(container.ingresses);
|
||||
if (!notes && typeof container.notes === "string") {
|
||||
notes = container.notes;
|
||||
}
|
||||
if (typeof container.source === "string") {
|
||||
const normalized = normalizeSource(container.source);
|
||||
if (normalized) {
|
||||
source = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entries = entriesPayload ?? [];
|
||||
const splitResult = entries.length ? splitEntries(entries) : { services: [], ingresses: [] };
|
||||
const explicitServices = servicesFromResponse?.map(mapServiceEntry) ?? [];
|
||||
const explicitIngresses = ingressesFromResponse?.map(mapIngressEntry) ?? [];
|
||||
const services = explicitServices.length ? explicitServices : splitResult.services;
|
||||
const ingresses = explicitIngresses.length ? explicitIngresses : splitResult.ingresses;
|
||||
const resolvedSource: EntrySource =
|
||||
services.length || ingresses.length
|
||||
? source === "none"
|
||||
? "kubernetes"
|
||||
: source
|
||||
: source;
|
||||
|
||||
return {
|
||||
services,
|
||||
ingresses,
|
||||
source: resolvedSource,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSource(source: string): EntrySource | null {
|
||||
if (ENTRY_SOURCES.includes(source as EntrySource)) {
|
||||
return source as EntrySource;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitEntries(entries: InstanceEntry[]): {
|
||||
services: ServiceEntry[];
|
||||
ingresses: IngressEntry[];
|
||||
} {
|
||||
if (!entries.length) {
|
||||
return { services: [], ingresses: [] };
|
||||
}
|
||||
const services: ServiceEntry[] = [];
|
||||
const ingresses: IngressEntry[] = [];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const kind = entry.kind?.toLowerCase();
|
||||
const looksLikeIngress =
|
||||
kind === "ingress" ||
|
||||
(!!entry.hosts && entry.hosts.length > 0) ||
|
||||
(!!entry.tls && entry.tls.length > 0);
|
||||
const looksLikeService =
|
||||
kind === "service" ||
|
||||
(!!entry.ports && entry.ports.length > 0) ||
|
||||
!!entry.clusterIP ||
|
||||
!!entry.type;
|
||||
|
||||
if (kind === "ingress" || (looksLikeIngress && !looksLikeService)) {
|
||||
ingresses.push(mapIngressEntry(entry));
|
||||
} else {
|
||||
services.push(mapServiceEntry(entry));
|
||||
}
|
||||
});
|
||||
|
||||
return { services, ingresses };
|
||||
}
|
||||
|
||||
function mapServiceEntry(entry: InstanceEntry): ServiceEntry {
|
||||
return {
|
||||
name: entry.name,
|
||||
namespace: entry.namespace,
|
||||
type: entry.type,
|
||||
cluster_ip: entry.clusterIP,
|
||||
external_ips: entry.externalIPs,
|
||||
ports: entry.ports?.map((port) => ({
|
||||
name: port.name,
|
||||
protocol: port.protocol,
|
||||
port: port.port ?? port.targetPort,
|
||||
target_port: port.targetPort ?? port.port,
|
||||
node_port: port.nodePort,
|
||||
})),
|
||||
loadBalancer: mapLoadBalancer(entry.loadBalancerIngress),
|
||||
};
|
||||
}
|
||||
|
||||
function mapIngressEntry(entry: InstanceEntry): IngressEntry {
|
||||
return {
|
||||
name: entry.name,
|
||||
namespace: entry.namespace,
|
||||
class_name: entry.type,
|
||||
rules: entry.hosts?.map((host) => ({
|
||||
host: host.host,
|
||||
paths: host.paths?.map((path) => ({
|
||||
path: path.path,
|
||||
backend: {
|
||||
service: {
|
||||
name: path.serviceName,
|
||||
port: normalizePort(path.servicePort),
|
||||
},
|
||||
},
|
||||
})),
|
||||
})),
|
||||
tls: entry.tls?.map((tls) => ({
|
||||
hosts: tls.hosts,
|
||||
secret_name: tls.secretName,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function mapLoadBalancer(values?: string[]) {
|
||||
if (!values || !values.length) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
ingress: values.map((value) => {
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
const hasAlpha = /[a-zA-Z]/.test(value);
|
||||
if (hasAlpha) {
|
||||
return { hostname: value };
|
||||
}
|
||||
return { ip: value };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePort(port?: string | number | null): number | string | undefined {
|
||||
if (port == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof port === "number") {
|
||||
return port;
|
||||
}
|
||||
const parsed = Number(port);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
interface EntriesModalProps {
|
||||
instance: InstanceResponse;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose }) => {
|
||||
const { success } = useToast();
|
||||
const [entries, setEntries] = useState<InstanceEntries | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadEntries = async () => {
|
||||
if (!instance.clusterId || !instance.id) {
|
||||
setError("Instance identifier is missing");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getInstanceEntries(instance.clusterId, instance.id);
|
||||
setEntries(data);
|
||||
} catch (err: unknown) {
|
||||
setError((err as Error).message || "Failed to load entries");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadEntries();
|
||||
}, [instance.clusterId, instance.id]);
|
||||
|
||||
const copyToClipboard = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedText(text);
|
||||
success(`Copied ${label} to clipboard`);
|
||||
setTimeout(() => setCopiedText(null), 2000);
|
||||
};
|
||||
|
||||
const getSourceBadge = (source?: string) => {
|
||||
const badges = {
|
||||
kubernetes: { color: "bg-green-600/20 text-green-400 border-green-500/30", label: "Live from Kubernetes" },
|
||||
manifest: { color: "bg-blue-600/20 text-blue-400 border-blue-500/30", label: "From Helm Manifest" },
|
||||
notes: { color: "bg-yellow-600/20 text-yellow-400 border-yellow-500/30", label: "From Helm Notes" },
|
||||
none: { color: "bg-gray-600/20 text-gray-400 border-gray-500/30", label: "No Data Available" },
|
||||
};
|
||||
|
||||
const badge = badges[source as keyof typeof badges] || badges.none;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${badge.color}`}>
|
||||
<Info className="w-3 h-3" />
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderService = (service: ServiceEntry, index: number) => (
|
||||
<div key={service.name || `service-${index}`} className="bg-gray-800/50 border border-gray-700 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-white">{service.name || `Service ${index + 1}`}</h4>
|
||||
<p className="text-xs text-gray-400 mt-1">Type: {service.type || 'Unknown'}</p>
|
||||
</div>
|
||||
<span className="px-2 py-1 text-xs font-medium bg-blue-600/20 text-blue-400 border border-blue-500/30 rounded">
|
||||
{service.type || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Cluster IP */}
|
||||
{service.cluster_ip && (
|
||||
<div className="flex items-center justify-between bg-gray-900/50 rounded p-2">
|
||||
<span className="text-xs text-gray-400">Cluster IP:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-mono text-white">{service.cluster_ip}</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(service.cluster_ip!, "Cluster IP")}
|
||||
className="p-1 hover:bg-gray-700 rounded transition"
|
||||
>
|
||||
{copiedText === service.cluster_ip ? (
|
||||
<CheckCircle className="w-3 h-3 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ports */}
|
||||
{service.ports && service.ports.length > 0 && service.ports.map((port, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between bg-gray-900/50 rounded p-2">
|
||||
<span className="text-xs text-gray-400">{port.name || `Port ${idx + 1}`}:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-mono text-white">
|
||||
{port.port} → {port.target_port} {port.protocol || 'TCP'}
|
||||
{port.node_port && ` (NodePort: ${port.node_port})`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* LoadBalancer Status */}
|
||||
{service.loadBalancer?.ingress && service.loadBalancer.ingress.length > 0 && (
|
||||
<div className="mt-2 p-2 bg-green-600/10 border border-green-500/30 rounded">
|
||||
<p className="text-xs text-green-400 mb-2 font-medium">LoadBalancer Entries:</p>
|
||||
{service.loadBalancer.ingress.map((ing, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-mono text-white">
|
||||
{ing.ip || ing.hostname}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{ing.ip && (
|
||||
<>
|
||||
<a
|
||||
href={`http://${ing.ip}:${service.ports?.[0]?.port || 80}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 hover:bg-gray-700 rounded transition"
|
||||
title="Open in browser"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 text-blue-400" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => copyToClipboard(ing.ip!, "IP")}
|
||||
className="p-1 hover:bg-gray-700 rounded transition"
|
||||
>
|
||||
{copiedText === ing.ip ? (
|
||||
<CheckCircle className="w-3 h-3 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderIngress = (ingress: IngressEntry, index: number) => (
|
||||
<div key={ingress.name || `ingress-${index}`} className="bg-gray-800/50 border border-gray-700 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-white">{ingress.name || `Ingress ${index + 1}`}</h4>
|
||||
{ingress.class_name && (
|
||||
<p className="text-xs text-gray-400 mt-1">Class: {ingress.class_name}</p>
|
||||
)}
|
||||
</div>
|
||||
<Globe className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{ingress.rules?.map((rule, ruleIdx) => (
|
||||
<div key={ruleIdx} className="bg-gray-900/50 rounded p-3 space-y-2">
|
||||
{(() => {
|
||||
const host = rule.host;
|
||||
if (!host) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-white">{host}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`https://${host}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 hover:bg-gray-700 rounded transition"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 text-blue-400" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => copyToClipboard(host, "Host")}
|
||||
className="p-1 hover:bg-gray-700 rounded transition"
|
||||
>
|
||||
{copiedText === host ? (
|
||||
<CheckCircle className="w-3 h-3 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{rule.paths?.map((path, pathIdx) => {
|
||||
const serviceName = path.backend?.service?.name || "service";
|
||||
const servicePort = path.backend?.service?.port ?? "-";
|
||||
return (
|
||||
<div key={pathIdx} className="text-xs text-gray-400 ml-4">
|
||||
• {path.path || '/'} → {serviceName}:{servicePort}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{ingress.tls && ingress.tls.length > 0 && (
|
||||
<div className="mt-2 p-2 bg-blue-600/10 border border-blue-500/30 rounded">
|
||||
<p className="text-xs text-blue-400 font-medium">🔒 TLS Enabled</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg max-w-4xl w-full max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Instance Entries</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{instance.name} ({instance.namespace})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
<span className="ml-3 text-gray-400">Loading entries...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-400">{error}</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-4 px-4 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : entries ? (
|
||||
<div className="space-y-6">
|
||||
{/* Source Badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-400">Data Source:</h3>
|
||||
{getSourceBadge(entries.source)}
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
{entries.services && entries.services.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Network className="w-5 h-5 text-blue-400" />
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Services ({entries.services.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{entries.services.map(renderService)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ingresses */}
|
||||
{entries.ingresses && entries.ingresses.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe className="w-5 h-5 text-purple-400" />
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Ingresses ({entries.ingresses.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{entries.ingresses.map(renderIngress)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Helm Notes (as fallback) */}
|
||||
{entries.notes && entries.source === "notes" && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-3">Helm Notes</h3>
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
|
||||
<pre className="text-xs text-gray-300 whitespace-pre-wrap font-mono">
|
||||
{entries.notes}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{(!entries.services || entries.services.length === 0) &&
|
||||
(!entries.ingresses || entries.ingresses.length === 0) &&
|
||||
!entries.notes && (
|
||||
<div className="text-center py-12">
|
||||
<Network className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
||||
<p className="text-gray-400">No entries found for this instance</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Data source: {entries.source || 'unknown'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-700 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Instance Card Component
|
||||
* Display instance information with action buttons
|
||||
*/
|
||||
import React from "react";
|
||||
import {
|
||||
Package,
|
||||
Settings,
|
||||
StopCircle,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Network,
|
||||
Box,
|
||||
Calendar,
|
||||
GitBranch,
|
||||
Layers,
|
||||
AlertTriangle,
|
||||
History,
|
||||
HelpCircle,
|
||||
} from "lucide-react";
|
||||
import type { InstanceResponse, InstanceStatus } from "@/api";
|
||||
import { INSTANCE_LAST_OPERATION, INSTANCE_STATUS } from "@/api";
|
||||
|
||||
interface InstanceCardProps {
|
||||
instance: InstanceResponse;
|
||||
onModify: (instance: InstanceResponse) => void;
|
||||
onTerminate: (instance: InstanceResponse) => void;
|
||||
onRefresh: (instance: InstanceResponse) => void;
|
||||
onViewEntries: (instance: InstanceResponse) => void;
|
||||
}
|
||||
|
||||
type StatusVisual = {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
bg: string;
|
||||
glow: string;
|
||||
label: string;
|
||||
defaultReason: string;
|
||||
};
|
||||
|
||||
const STATUS_INFO_MAP: Record<InstanceStatus, StatusVisual> = {
|
||||
[INSTANCE_STATUS.deployed]: {
|
||||
icon: CheckCircle,
|
||||
color: "text-emerald-400",
|
||||
bg: "bg-gradient-to-r from-emerald-500/20 to-green-500/20 border-emerald-500/40",
|
||||
glow: "shadow-emerald-500/20",
|
||||
label: "Deployed",
|
||||
defaultReason: "Deployment completed successfully.",
|
||||
},
|
||||
[INSTANCE_STATUS.failed]: {
|
||||
icon: XCircle,
|
||||
color: "text-rose-400",
|
||||
bg: "bg-gradient-to-r from-rose-500/20 to-red-500/20 border-rose-500/40",
|
||||
glow: "shadow-rose-500/20",
|
||||
label: "Failed",
|
||||
defaultReason: "Last operation reported a failure.",
|
||||
},
|
||||
[INSTANCE_STATUS["pending-install"]]: {
|
||||
icon: Clock,
|
||||
color: "text-amber-400",
|
||||
bg: "bg-gradient-to-r from-amber-500/20 to-yellow-500/20 border-amber-500/40",
|
||||
glow: "shadow-amber-500/20",
|
||||
label: "Pending Install",
|
||||
defaultReason: "Installation is in progress.",
|
||||
},
|
||||
[INSTANCE_STATUS["pending-upgrade"]]: {
|
||||
icon: Clock,
|
||||
color: "text-amber-400",
|
||||
bg: "bg-gradient-to-r from-amber-500/20 to-yellow-500/20 border-amber-500/40",
|
||||
glow: "shadow-amber-500/20",
|
||||
label: "Pending Upgrade",
|
||||
defaultReason: "Upgrade is in progress.",
|
||||
},
|
||||
[INSTANCE_STATUS["pending-rollback"]]: {
|
||||
icon: Clock,
|
||||
color: "text-amber-400",
|
||||
bg: "bg-gradient-to-r from-amber-500/20 to-yellow-500/20 border-amber-500/40",
|
||||
glow: "shadow-amber-500/20",
|
||||
label: "Pending Rollback",
|
||||
defaultReason: "Rollback is in progress.",
|
||||
},
|
||||
[INSTANCE_STATUS["pending-delete"]]: {
|
||||
icon: Clock,
|
||||
color: "text-orange-400",
|
||||
bg: "bg-gradient-to-r from-orange-500/20 to-red-500/20 border-orange-500/40",
|
||||
glow: "shadow-orange-500/20",
|
||||
label: "Pending Delete",
|
||||
defaultReason: "Deletion is in progress.",
|
||||
},
|
||||
[INSTANCE_STATUS.superseded]: {
|
||||
icon: History,
|
||||
color: "text-indigo-300",
|
||||
bg: "bg-gradient-to-r from-indigo-500/20 to-purple-500/20 border-indigo-500/40",
|
||||
glow: "shadow-indigo-500/20",
|
||||
label: "Superseded",
|
||||
defaultReason: "A newer revision has replaced this instance.",
|
||||
},
|
||||
[INSTANCE_STATUS.uninstalled]: {
|
||||
icon: StopCircle,
|
||||
color: "text-slate-300",
|
||||
bg: "bg-gradient-to-r from-slate-500/20 to-gray-500/20 border-slate-500/40",
|
||||
glow: "shadow-slate-500/20",
|
||||
label: "Uninstalled",
|
||||
defaultReason: "Instance has been removed from the cluster.",
|
||||
},
|
||||
[INSTANCE_STATUS.unknown]: {
|
||||
icon: HelpCircle,
|
||||
color: "text-slate-300",
|
||||
bg: "bg-gradient-to-r from-slate-500/20 to-gray-500/20 border-slate-500/40",
|
||||
glow: "shadow-slate-500/20",
|
||||
label: "Unknown",
|
||||
defaultReason: "Awaiting next state update.",
|
||||
},
|
||||
};
|
||||
|
||||
const LAST_OPERATION_LABELS: Record<string, string> = {
|
||||
[INSTANCE_LAST_OPERATION.install]: "Install",
|
||||
[INSTANCE_LAST_OPERATION.upgrade]: "Upgrade",
|
||||
[INSTANCE_LAST_OPERATION.rollback]: "Rollback",
|
||||
[INSTANCE_LAST_OPERATION.delete]: "Delete",
|
||||
[INSTANCE_LAST_OPERATION.sync]: "Sync",
|
||||
};
|
||||
|
||||
function toTitleCase(value: string): string {
|
||||
return value
|
||||
.split(/[\s-]+/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
instance,
|
||||
onModify,
|
||||
onTerminate,
|
||||
onRefresh,
|
||||
onViewEntries,
|
||||
}) => {
|
||||
const normalizedStatus = (instance.status ?? INSTANCE_STATUS.unknown) as InstanceStatus;
|
||||
const statusInfo =
|
||||
STATUS_INFO_MAP[normalizedStatus] ?? STATUS_INFO_MAP[INSTANCE_STATUS.unknown];
|
||||
const StatusIcon = statusInfo.icon;
|
||||
const statusLabel = statusInfo.label.toUpperCase();
|
||||
const instanceName = instance.name || "Unnamed Instance";
|
||||
const repository = instance.repository || "unknown";
|
||||
const version = instance.version || "latest";
|
||||
const namespace = instance.namespace || "default";
|
||||
const revision = instance.revision ?? "-";
|
||||
const createdAtText = instance.createdAt
|
||||
? new Date(instance.createdAt).toLocaleDateString()
|
||||
: "N/A";
|
||||
const statusReason =
|
||||
typeof instance.statusReason === "string" && instance.statusReason.trim().length > 0
|
||||
? instance.statusReason.trim()
|
||||
: statusInfo.defaultReason;
|
||||
const rawOperation =
|
||||
typeof instance.lastOperation === "string" ? instance.lastOperation.trim() : "";
|
||||
const lastOperationLabel =
|
||||
rawOperation.length > 0
|
||||
? LAST_OPERATION_LABELS[rawOperation] ?? toTitleCase(rawOperation)
|
||||
: null;
|
||||
const lastError =
|
||||
typeof instance.lastError === "string" ? instance.lastError.trim() : "";
|
||||
|
||||
return (
|
||||
<div className="group relative bg-gradient-to-br from-slate-800/80 via-slate-800/50 to-slate-900/80 border border-slate-700/50 rounded-xl hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/10 transition-all duration-300 overflow-hidden">
|
||||
{/* Decorative gradient overlay */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-blue-500/5 to-purple-500/5 rounded-full blur-3xl -z-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
{/* Header with enhanced design */}
|
||||
<div className="relative px-6 py-5 border-b border-slate-700/50 bg-gradient-to-r from-slate-800/30 to-transparent">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
{/* Enhanced icon with glow effect */}
|
||||
<div className="relative p-3 bg-gradient-to-br from-blue-500/20 to-cyan-500/20 rounded-xl border border-blue-500/30 shadow-lg shadow-blue-500/20 group-hover:shadow-blue-500/40 transition-shadow duration-300">
|
||||
<Box className="w-7 h-7 text-blue-400" />
|
||||
<div className="absolute inset-0 bg-blue-400/10 rounded-xl blur-sm"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-white to-slate-300 truncate">
|
||||
{instanceName}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Package className="w-4 h-4 text-slate-400" />
|
||||
<p className="text-sm text-slate-400 font-mono">
|
||||
{repository}
|
||||
</p>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="px-2 py-0.5 text-xs font-semibold text-cyan-400 bg-cyan-500/10 border border-cyan-500/30 rounded">
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Status Badge with glow */}
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-full border shadow-lg ${statusInfo.bg} ${statusInfo.glow} backdrop-blur-sm`}
|
||||
>
|
||||
<StatusIcon className={`w-4 h-4 ${statusInfo.color}`} />
|
||||
<span className={`text-sm font-semibold ${statusInfo.color} uppercase tracking-wide`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-1 text-sm text-slate-300">
|
||||
<span className="font-medium text-slate-200">{statusReason}</span>
|
||||
{lastOperationLabel && (
|
||||
<span className="text-xs uppercase tracking-wide text-slate-400">
|
||||
Operation: {lastOperationLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Content Grid */}
|
||||
<div className="relative px-6 py-5 space-y-4 bg-gradient-to-b from-transparent to-slate-900/30">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Namespace */}
|
||||
<div className="p-3 bg-slate-800/50 border border-slate-700/50 rounded-lg hover:border-purple-500/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Layers className="w-4 h-4 text-purple-400" />
|
||||
<p className="text-xs text-slate-400 uppercase font-semibold tracking-wider">Namespace</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-white">
|
||||
{namespace}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Revision */}
|
||||
<div className="p-3 bg-slate-800/50 border border-slate-700/50 rounded-lg hover:border-green-500/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<GitBranch className="w-4 h-4 text-green-400" />
|
||||
<p className="text-xs text-slate-400 uppercase font-semibold tracking-wider">Revision</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-white">
|
||||
{revision}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Repository - Full Width */}
|
||||
<div className="col-span-2 p-3 bg-slate-800/50 border border-slate-700/50 rounded-lg hover:border-blue-500/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Package className="w-4 h-4 text-blue-400" />
|
||||
<p className="text-xs text-slate-400 uppercase font-semibold tracking-wider">Repository</p>
|
||||
</div>
|
||||
<p className="text-sm font-mono text-white truncate" title={repository}>
|
||||
{repository}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Launched Date - Full Width */}
|
||||
<div className="col-span-2 p-3 bg-slate-800/50 border border-slate-700/50 rounded-lg hover:border-amber-500/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar className="w-4 h-4 text-amber-400" />
|
||||
<p className="text-xs text-slate-400 uppercase font-semibold tracking-wider">Launched</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-white">
|
||||
{createdAtText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastError && (
|
||||
<div className="flex items-start gap-3 p-4 border border-rose-500/30 bg-rose-500/10 rounded-lg">
|
||||
<div className="p-2 bg-rose-500/20 rounded-lg border border-rose-500/40">
|
||||
<AlertTriangle className="w-5 h-5 text-rose-300" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-rose-200">Last error</p>
|
||||
<p className="text-sm text-rose-100/90">{lastError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enhanced Actions Bar */}
|
||||
<div className="relative px-6 py-4 bg-gradient-to-r from-slate-900/80 via-slate-900/50 to-slate-900/80 border-t border-slate-700/50 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onRefresh(instance)}
|
||||
className="group/btn inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-slate-300 bg-slate-700/50 hover:bg-slate-600/50 rounded-lg transition-all duration-200 hover:scale-105 hover:shadow-lg border border-slate-600/50 hover:border-slate-500"
|
||||
title="Refresh status"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 group-hover/btn:rotate-180 transition-transform duration-500" />
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onViewEntries(instance)}
|
||||
className="group/btn inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-emerald-300 bg-gradient-to-r from-emerald-600/20 to-green-600/20 border border-emerald-500/40 rounded-lg hover:from-emerald-600/30 hover:to-green-600/30 hover:border-emerald-500/60 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-emerald-500/20"
|
||||
title="View service entries"
|
||||
>
|
||||
<Network className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
|
||||
Entries
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onModify(instance)}
|
||||
className="group/btn inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-blue-300 bg-gradient-to-r from-blue-600/20 to-cyan-600/20 border border-blue-500/40 rounded-lg hover:from-blue-600/30 hover:to-cyan-600/30 hover:border-blue-500/60 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/20"
|
||||
title="Modify instance configuration"
|
||||
>
|
||||
<Settings className="w-4 h-4 group-hover/btn:rotate-90 transition-transform duration-300" />
|
||||
Modify
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onTerminate(instance)}
|
||||
className="group/btn inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-rose-300 bg-gradient-to-r from-rose-600/20 to-red-600/20 border border-rose-500/40 rounded-lg hover:from-rose-600/30 hover:to-red-600/30 hover:border-rose-500/60 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-rose-500/20"
|
||||
title="Terminate instance"
|
||||
>
|
||||
<StopCircle className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
|
||||
Terminate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Modify Modal Component
|
||||
* Modal for modifying an instance configuration
|
||||
* Supports Values Schema for dynamic form generation
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import type { InstanceResponse, UpdateInstanceRequest } from "@/api";
|
||||
import { getValuesSchema } from "@/api";
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
FormField,
|
||||
Input,
|
||||
Textarea,
|
||||
Checkbox,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
Badge,
|
||||
SchemaFormGenerator
|
||||
} from "@/shared/components";
|
||||
import type { JsonSchema } from "@/shared/components/form/SchemaFormGenerator";
|
||||
|
||||
interface ModifyModalProps {
|
||||
instance: InstanceResponse;
|
||||
onClose: () => void;
|
||||
onConfirm: (clusterId: string, instanceId: string, data: UpdateInstanceRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
instance,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [tag, setTag] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [valuesYaml, setValuesYaml] = useState("");
|
||||
const [wait, setWait] = useState(true);
|
||||
const [timeout, setTimeout_] = useState(300);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Values Schema support
|
||||
const [loadingSchema, setLoadingSchema] = useState(false);
|
||||
const [valuesSchema, setValuesSchema] = useState<JsonSchema | null>(null);
|
||||
const [inputMethod, setInputMethod] = useState<'form' | 'yaml'>('yaml');
|
||||
const [formValues, setFormValues] = useState<Record<string, any>>({});
|
||||
|
||||
// Initialize with current values
|
||||
useEffect(() => {
|
||||
setTag(instance.version || "");
|
||||
setDescription(""); // InstanceResponse doesn't have description field
|
||||
|
||||
// Parse existing values
|
||||
if (instance.values) {
|
||||
try {
|
||||
const parsedValues = typeof instance.values === 'string'
|
||||
? JSON.parse(instance.values)
|
||||
: instance.values;
|
||||
setFormValues(parsedValues);
|
||||
setValuesYaml(typeof parsedValues === 'object' ? JSON.stringify(parsedValues, null, 2) : String(parsedValues));
|
||||
} catch (err) {
|
||||
console.error('[ModifyModal] Failed to parse existing values:', err);
|
||||
setValuesYaml(String(instance.values) || "");
|
||||
}
|
||||
}
|
||||
|
||||
// Load values schema
|
||||
loadValuesSchema();
|
||||
}, [instance]);
|
||||
|
||||
const loadValuesSchema = async () => {
|
||||
if (!instance.registryId || !instance.repository || !instance.version) {
|
||||
setValuesSchema(null);
|
||||
setInputMethod('yaml');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSchema(true);
|
||||
try {
|
||||
const schemaResponse = await getValuesSchema({
|
||||
registryId: instance.registryId,
|
||||
repositoryName: instance.repository,
|
||||
reference: instance.version,
|
||||
});
|
||||
const normalizedSchema = extractJsonSchema(schemaResponse);
|
||||
setValuesSchema(normalizedSchema);
|
||||
|
||||
if (normalizedSchema) {
|
||||
setInputMethod('form');
|
||||
console.log(`[ModifyModal] Loaded values schema with ${Object.keys(normalizedSchema.properties ?? {}).length} properties`);
|
||||
} else {
|
||||
setInputMethod('yaml');
|
||||
console.log('[ModifyModal] No values schema available, using YAML input');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ModifyModal] Failed to load values schema:', err);
|
||||
setValuesSchema(null);
|
||||
setInputMethod('yaml');
|
||||
} finally {
|
||||
setLoadingSchema(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormValuesChange = (values: Record<string, any>) => {
|
||||
setFormValues(values);
|
||||
// Also update YAML representation
|
||||
setValuesYaml(JSON.stringify(values, null, 2));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload: UpdateInstanceRequest = {
|
||||
version: tag && tag !== instance.version ? tag : undefined,
|
||||
values: valuesYaml.trim() ? JSON.parse(valuesYaml) : undefined,
|
||||
};
|
||||
|
||||
if (!instance.clusterId || !instance.id) {
|
||||
setError("Instance identifier is missing");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await onConfirm(instance.clusterId, instance.id, payload);
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof SyntaxError) {
|
||||
setError("Invalid JSON/YAML values. Please fix the configuration.");
|
||||
} else {
|
||||
setError((err as Error).message || "Failed to modify instance");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={true}
|
||||
onClose={onClose}
|
||||
title={`Modify Instance - ${instance.name || "Unnamed"}`}
|
||||
icon={Settings}
|
||||
iconColor="text-blue-400"
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
icon={Settings}
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{loading ? "Modifying..." : "Modify"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<ErrorState message={error} title="Modification Failed" />
|
||||
)}
|
||||
|
||||
{/* Current Info */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm text-gray-300">
|
||||
<span className="font-medium text-white">Current Version:</span> {instance.version || "N/A"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-300">
|
||||
<span className="font-medium text-white">Cluster:</span> {instance.clusterId || "N/A"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-300">
|
||||
<span className="font-medium text-white">Repository:</span> {instance.repository || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tag */}
|
||||
<FormField
|
||||
label="Version Tag"
|
||||
required
|
||||
help="Leave unchanged to keep current version"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
placeholder="e.g., v1.0.0"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Description */}
|
||||
<FormField label="Description">
|
||||
<Input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Modification description"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Values Configuration */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-200">
|
||||
Configuration Values
|
||||
</label>
|
||||
{valuesSchema?.properties && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setInputMethod('form')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge
|
||||
variant={inputMethod === 'form' ? 'success' : 'default'}
|
||||
size="sm"
|
||||
>
|
||||
Form
|
||||
</Badge>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInputMethod('yaml')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge
|
||||
variant={inputMethod === 'yaml' ? 'success' : 'default'}
|
||||
size="sm"
|
||||
>
|
||||
YAML
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingSchema ? (
|
||||
<LoadingState message="Loading configuration schema..." />
|
||||
) : inputMethod === 'form' && valuesSchema ? (
|
||||
<SchemaFormGenerator
|
||||
schema={valuesSchema}
|
||||
values={formValues}
|
||||
onChange={handleFormValuesChange}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
value={valuesYaml}
|
||||
onChange={(e) => setValuesYaml(e.target.value)}
|
||||
rows={12}
|
||||
placeholder="key: value nested: key: value"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-3">
|
||||
<Checkbox
|
||||
id="wait"
|
||||
checked={wait}
|
||||
onChange={(e) => setWait(e.target.checked)}
|
||||
label="Wait for all resources to be ready"
|
||||
/>
|
||||
|
||||
<FormField label="Timeout (seconds)">
|
||||
<Input
|
||||
type="number"
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeout_(parseInt(e.target.value) || 300)}
|
||||
min={60}
|
||||
max={3600}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const isJsonSchemaObject = (value: unknown): value is JsonSchema =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const extractJsonSchema = (schemaResponse: unknown): JsonSchema | null => {
|
||||
if (schemaResponse == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tryParse = (value: unknown): unknown => {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
let candidate: unknown = tryParse(schemaResponse);
|
||||
|
||||
if (candidate && typeof candidate === "object" && "schema" in (candidate as Record<string, unknown>)) {
|
||||
const inner = (candidate as { schema?: unknown }).schema;
|
||||
const normalizedInner = extractJsonSchema(inner);
|
||||
if (normalizedInner) {
|
||||
return normalizedInner;
|
||||
}
|
||||
}
|
||||
|
||||
if (isJsonSchemaObject(candidate)) {
|
||||
return candidate as JsonSchema;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
12
frontend/src/features/artifact/instances/index.ts
Normal file
12
frontend/src/features/artifact/instances/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Service Instances Feature
|
||||
* 服务实例管理功能
|
||||
*/
|
||||
|
||||
// Export pages
|
||||
export { default as InstancesManagementPage } from './pages/InstancesManagementPage';
|
||||
|
||||
// Export components
|
||||
export { InstanceCard } from './components/InstanceCard';
|
||||
export { ModifyModal } from './components/ModifyModal';
|
||||
|
||||
@ -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