ocdp v1
This commit is contained in:
16
frontend/src/features/artifact/index.ts
Normal file
16
frontend/src/features/artifact/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Artifact Module
|
||||
* 制品模块 - 制品仓库浏览和实例管理
|
||||
*/
|
||||
|
||||
// Registries (Browser) - VSCode-style unified browser
|
||||
export { default as ArtifactBrowserPage } from './registries/pages/ArtifactBrowserPage';
|
||||
export * from './registries/components/RepositoryItem';
|
||||
export * from './registries/components/LaunchModal';
|
||||
export * from './registries/components/TagCard';
|
||||
|
||||
// Instances
|
||||
export { default as InstancesManagementPage } from './instances/pages/InstancesManagementPage';
|
||||
export * from './instances/components/InstanceCard';
|
||||
export * from './instances/components/ModifyModal';
|
||||
export * from './instances/components/EntriesModal';
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Launch Modal Component
|
||||
* Launch service instance from artifact
|
||||
* Supports Values Schema for dynamic form generation
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Rocket, AlertCircle, FileCode, FormInput } from "lucide-react";
|
||||
import { useToast } from "@/shared";
|
||||
import { createInstance, listClusters, getValuesSchema } from "@/api";
|
||||
import type { CreateInstanceRequest, ClusterResponse } from "@/api";
|
||||
import { ClusterErrors, InstanceErrors, SuccessMessages, ValidationErrors, formatApiError } from "@/shared/utils";
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
FormField,
|
||||
Input,
|
||||
DropdownSelect,
|
||||
Textarea,
|
||||
LoadingState,
|
||||
Badge,
|
||||
SchemaFormGenerator
|
||||
} from "@/shared/components";
|
||||
import type { ArtifactCategory } from "../utils/artifactType";
|
||||
import type { JsonSchema } from "@/shared/components/form/SchemaFormGenerator";
|
||||
|
||||
interface LaunchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
registryId: string;
|
||||
repositoryName: string;
|
||||
tag: string;
|
||||
artifactType: ArtifactCategory;
|
||||
}
|
||||
|
||||
export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
registryId,
|
||||
repositoryName,
|
||||
tag,
|
||||
artifactType,
|
||||
}) => {
|
||||
const { success, error: toastError, info: toastInfo } = useToast();
|
||||
|
||||
const [clusters, setClusters] = useState<ClusterResponse[]>([]);
|
||||
const [loadingClusters, setLoadingClusters] = useState(false);
|
||||
|
||||
// Form fields
|
||||
const [clusterId, setClusterId] = useState("");
|
||||
const [namespace, setNamespace] = useState("default");
|
||||
const [instanceName, setInstanceName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// Values Schema support
|
||||
const [valuesSchema, setValuesSchema] = useState<JsonSchema | null>(null);
|
||||
const [loadingSchema, setLoadingSchema] = useState(false);
|
||||
const [inputMethod, setInputMethod] = useState<'form' | 'yaml'>('yaml');
|
||||
const [valuesForm, setValuesForm] = useState<Record<string, any>>({});
|
||||
const [valuesYaml, setValuesYaml] = useState("");
|
||||
|
||||
// Load clusters and schema on mount
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadClusters();
|
||||
loadValuesSchema();
|
||||
}
|
||||
}, [isOpen, registryId, repositoryName, tag]);
|
||||
|
||||
const loadClusters = async () => {
|
||||
setLoadingClusters(true);
|
||||
try {
|
||||
const data = await listClusters();
|
||||
setClusters(data);
|
||||
const firstWithId = data.find((cluster) => typeof cluster.id === "string" && cluster.id.length > 0);
|
||||
if (firstWithId?.id) {
|
||||
setClusterId(firstWithId.id);
|
||||
} else {
|
||||
setClusterId("");
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || ClusterErrors.LOAD_FAILED);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoadingClusters(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadValuesSchema = async () => {
|
||||
setLoadingSchema(true);
|
||||
try {
|
||||
const schemaResponse = await getValuesSchema({ registryId, repositoryName, reference: tag });
|
||||
const normalizedSchema = extractJsonSchema(schemaResponse);
|
||||
setValuesSchema(normalizedSchema);
|
||||
|
||||
if (normalizedSchema) {
|
||||
setInputMethod('form');
|
||||
console.log(`[LaunchModal] Loaded values schema with ${Object.keys(normalizedSchema.properties ?? {}).length} properties`);
|
||||
} else {
|
||||
setInputMethod('yaml');
|
||||
console.log('[LaunchModal] No values schema available, using YAML input');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LaunchModal] Failed to load values schema:', err);
|
||||
setValuesSchema(null);
|
||||
setInputMethod('yaml');
|
||||
} finally {
|
||||
setLoadingSchema(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetFormState = () => {
|
||||
setInstanceName("");
|
||||
setDescription("");
|
||||
setValuesYaml("");
|
||||
setValuesForm({});
|
||||
setNamespace("default");
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!clusterId) {
|
||||
toastError(ValidationErrors.REQUIRED_FIELD("Cluster"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!instanceName.trim()) {
|
||||
toastError(ValidationErrors.REQUIRED_FIELD("Instance name"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!namespace.trim()) {
|
||||
toastError(ValidationErrors.REQUIRED_FIELD("Namespace"));
|
||||
return;
|
||||
}
|
||||
|
||||
let valuesObj: Record<string, any> = {};
|
||||
if (inputMethod === "form" && Object.keys(valuesForm).length > 0) {
|
||||
valuesObj = valuesForm;
|
||||
} else if (valuesYaml.trim()) {
|
||||
try {
|
||||
valuesObj = JSON.parse(valuesYaml.trim());
|
||||
} catch {
|
||||
toastError("Invalid YAML format. Please check your values.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const request: CreateInstanceRequest = {
|
||||
name: instanceName.trim(),
|
||||
namespace: namespace.trim(),
|
||||
registryId,
|
||||
repository: repositoryName,
|
||||
tag,
|
||||
...(Object.keys(valuesObj).length > 0 ? { values: valuesObj } : {}),
|
||||
};
|
||||
|
||||
toastInfo("Launching instance...", {
|
||||
title: "Launch Instance",
|
||||
durationMs: 1800,
|
||||
mergeKey: `instance-launch-${registryId}-${repositoryName}-${tag}`,
|
||||
});
|
||||
|
||||
resetFormState();
|
||||
onClose();
|
||||
|
||||
createInstance({ clusterId }, request)
|
||||
.then(() => {
|
||||
success(SuccessMessages.INSTANCE_DEPLOYED);
|
||||
})
|
||||
.catch((err) => {
|
||||
toastError(formatApiError(err) || InstanceErrors.DEPLOY_FAILED);
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title="Launch Instance"
|
||||
icon={Rocket}
|
||||
iconColor="text-green-400"
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="success"
|
||||
icon={Rocket}
|
||||
onClick={handleSubmit}
|
||||
disabled={clusters.length === 0}
|
||||
>
|
||||
Launch
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1 mb-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
{repositoryName}:{tag}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Cluster Selection */}
|
||||
<FormField label="Target Cluster" required>
|
||||
{loadingClusters ? (
|
||||
<div className="text-sm text-gray-500">Loading clusters...</div>
|
||||
) : clusters.length === 0 ? (
|
||||
<div className="flex items-center gap-2 p-3 bg-yellow-900/20 border border-yellow-700/50 rounded-lg text-yellow-300 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>No clusters available. Please add a cluster first.</span>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownSelect
|
||||
value={clusterId}
|
||||
onChange={(value) => setClusterId(value)}
|
||||
options={clusters
|
||||
.filter((cluster): cluster is ClusterResponse & { id: string } => Boolean(cluster.id))
|
||||
.map((cluster) => ({
|
||||
value: cluster.id,
|
||||
label: cluster.name || cluster.id,
|
||||
}))}
|
||||
placeholder="Select a cluster"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* Instance Name */}
|
||||
<FormField
|
||||
label="Instance Name"
|
||||
required
|
||||
help="Lowercase alphanumeric characters, '-' or '.'"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={instanceName}
|
||||
onChange={(e) => setInstanceName(e.target.value)}
|
||||
placeholder="my-app"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Namespace */}
|
||||
<FormField label="Namespace" required>
|
||||
<Input
|
||||
type="text"
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
placeholder="default"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Description */}
|
||||
<FormField label="Description">
|
||||
<Input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional 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-300">
|
||||
Configuration Values
|
||||
</label>
|
||||
|
||||
{/* Input Method Toggle (only show if schema is available) */}
|
||||
{valuesSchema?.properties && (
|
||||
<div className="flex gap-1 bg-gray-800 rounded-lg p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInputMethod('form')}
|
||||
className={`flex items-center gap-2 px-3 py-1 rounded text-xs transition ${
|
||||
inputMethod === 'form'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'text-gray-400 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<FormInput className="w-3 h-3" />
|
||||
Form
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInputMethod('yaml')}
|
||||
className={`flex items-center gap-2 px-3 py-1 rounded text-xs transition ${
|
||||
inputMethod === 'yaml'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'text-gray-400 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<FileCode className="w-3 h-3" />
|
||||
YAML
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingSchema ? (
|
||||
<LoadingState message="Loading configuration schema..." size="sm" />
|
||||
) : inputMethod === 'form' && valuesSchema ? (
|
||||
<div className="border border-gray-700 rounded-lg p-4 max-h-96 overflow-y-auto bg-gray-900/30">
|
||||
<SchemaFormGenerator
|
||||
schema={valuesSchema}
|
||||
values={valuesForm}
|
||||
onChange={setValuesForm}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FormField
|
||||
help={valuesSchema
|
||||
? "Optional: Override default values with custom YAML configuration"
|
||||
: "Optional: Chart does not provide a schema. Enter YAML configuration manually."
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
value={valuesYaml}
|
||||
onChange={(e) => setValuesYaml(e.target.value)}
|
||||
placeholder="# Enter custom values in YAML format # Example: # replicaCount: 3 # image: # repository: myapp # tag: latest"
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Artifact Info */}
|
||||
<div className="p-4 bg-gray-800/50 border border-gray-700 rounded-lg space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Repository:</span>
|
||||
<span className="text-white font-mono">{repositoryName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Tag:</span>
|
||||
<Badge variant="info" size="sm">{tag}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Type:</span>
|
||||
<span className="text-white">{artifactType}</span>
|
||||
</div>
|
||||
</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;
|
||||
};
|
||||
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Registry Tree Explorer Component
|
||||
* Display registries in a tree-like structure
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Database,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Package,
|
||||
Server,
|
||||
ExternalLink,
|
||||
Shield,
|
||||
Globe
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { AppRegistry } from "@/core/types";
|
||||
|
||||
interface RegistryTreeExplorerProps {
|
||||
registries: AppRegistry[];
|
||||
}
|
||||
|
||||
export const RegistryTreeExplorer: React.FC<RegistryTreeExplorerProps> = ({ registries }) => {
|
||||
const navigate = useNavigate();
|
||||
const [expandedRegistries, setExpandedRegistries] = useState<Set<string>>(new Set());
|
||||
const [hoveredRegistry, setHoveredRegistry] = useState<string | null>(null);
|
||||
|
||||
const toggleRegistry = (registryId: string) => {
|
||||
const newExpanded = new Set(expandedRegistries);
|
||||
if (newExpanded.has(registryId)) {
|
||||
newExpanded.delete(registryId);
|
||||
} else {
|
||||
newExpanded.add(registryId);
|
||||
}
|
||||
setExpandedRegistries(newExpanded);
|
||||
};
|
||||
|
||||
const handleRegistryClick = (registryId: string) => {
|
||||
navigate(`/artifact/registries/${registryId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="relative bg-gradient-to-br from-slate-800/80 via-slate-800/50 to-slate-900/80 border border-slate-700/50 rounded-xl overflow-hidden shadow-2xl">
|
||||
{/* Decorative background effect */}
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-br from-purple-500/5 via-transparent to-blue-500/5 pointer-events-none"></div>
|
||||
|
||||
{/* Header with enhanced design */}
|
||||
<div className="relative p-6 border-b border-slate-700/50 bg-gradient-to-r from-purple-900/20 via-transparent to-blue-900/20 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-gradient-to-br from-purple-500/20 to-blue-500/20 rounded-xl border border-purple-500/30 shadow-lg shadow-purple-500/20">
|
||||
<Database className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-white to-slate-300">
|
||||
OCI Registries
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400 mt-0.5">
|
||||
{registries.length} {registries.length === 1 ? 'registry' : 'registries'} configured
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registry List */}
|
||||
<div className="divide-y divide-slate-700/50">
|
||||
{registries.map((registry) => {
|
||||
const isExpanded = expandedRegistries.has(registry.id || '');
|
||||
const isHovered = hoveredRegistry === registry.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={registry.id}
|
||||
className="relative group transition-all duration-300"
|
||||
onMouseEnter={() => setHoveredRegistry(registry.id || '')}
|
||||
onMouseLeave={() => setHoveredRegistry(null)}
|
||||
>
|
||||
{/* Hover glow effect */}
|
||||
{isHovered && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-purple-500/10 via-blue-500/10 to-cyan-500/10 pointer-events-none"></div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="relative flex items-center justify-between p-5 cursor-pointer hover:bg-slate-800/50 transition-colors"
|
||||
onClick={() => toggleRegistry(registry.id || '')}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
{/* Expand/Collapse Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5 text-purple-400 transition-transform duration-200" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-purple-400 transition-colors duration-200" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Registry Icon with glow */}
|
||||
<div className={`relative p-3 rounded-xl border transition-all duration-300 ${
|
||||
isExpanded
|
||||
? 'bg-gradient-to-br from-purple-500/30 to-blue-500/30 border-purple-500/50 shadow-lg shadow-purple-500/30'
|
||||
: 'bg-gradient-to-br from-purple-500/10 to-blue-500/10 border-purple-500/30 group-hover:border-purple-500/50'
|
||||
}`}>
|
||||
<Server className={`w-6 h-6 transition-colors duration-300 ${
|
||||
isExpanded ? 'text-purple-300' : 'text-purple-400'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
{/* Registry Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-base font-bold text-white group-hover:text-purple-300 transition-colors">
|
||||
{registry.name || 'Unnamed Registry'}
|
||||
</h4>
|
||||
{registry.url && (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-slate-700/50 border border-slate-600/50 rounded-md">
|
||||
<Globe className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span className="text-xs text-cyan-400 font-medium">Connected</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{registry.description && (
|
||||
<p className="text-sm text-slate-400 mt-1.5 line-clamp-1">
|
||||
{registry.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{registry.url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Shield className="w-3.5 h-3.5 text-slate-500" />
|
||||
<p className="text-xs text-slate-500 font-mono truncate">
|
||||
{registry.url}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Browse Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRegistryClick(registry.id || '');
|
||||
}}
|
||||
className="group/btn flex-shrink-0 inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-blue-300 bg-gradient-to-r from-blue-600/20 to-cyan-600/20 hover:from-blue-600/30 hover:to-cyan-600/30 border border-blue-500/40 hover:border-blue-400/60 rounded-lg transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/30"
|
||||
>
|
||||
<Package className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
|
||||
Browse
|
||||
<ExternalLink className="w-3.5 h-3.5 opacity-70 group-hover/btn:opacity-100 group-hover/btn:translate-x-0.5 group-hover/btn:-translate-y-0.5 transition-all" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="relative px-5 pb-5 pl-20 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div className="relative bg-gradient-to-br from-slate-800/80 to-slate-900/80 border border-slate-700/50 rounded-lg p-5 shadow-inner">
|
||||
{/* Decorative corner accent */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-purple-500/10 to-transparent rounded-bl-full pointer-events-none"></div>
|
||||
|
||||
<div className="relative flex items-start gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-purple-500/10 border border-purple-500/30 rounded-lg">
|
||||
<Package className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-white mb-1">
|
||||
Ready to explore
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Click <span className="font-semibold text-blue-400">"Browse"</span> to view all repositories and artifacts available in this registry.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Repository Item Component
|
||||
* Expandable to show tags list with type filtering
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Package, ChevronDown, ChevronRight, Copy, Tag as TagIcon, Filter, AlertCircle, Rocket } from "lucide-react";
|
||||
import { listArtifacts } from "@/api";
|
||||
import type { ArtifactListItem } from "@/api";
|
||||
import { useToast } from "@/shared";
|
||||
import { LaunchModal } from "./LaunchModal";
|
||||
import { artifactCache } from "@/shared/services/artifact-cache";
|
||||
import { RegistryErrors, formatApiError } from "@/shared/utils";
|
||||
import { inferArtifactCategory, type ArtifactCategory } from "../utils/artifactType";
|
||||
|
||||
interface RepositoryItemProps {
|
||||
registryId: string;
|
||||
registryName: string;
|
||||
registryUrl: string;
|
||||
repository: string;
|
||||
}
|
||||
|
||||
export const RepositoryItem: React.FC<RepositoryItemProps> = ({
|
||||
registryId,
|
||||
registryName,
|
||||
registryUrl,
|
||||
repository,
|
||||
}) => {
|
||||
const { success, error: toastError } = useToast();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [allTags, setAllTags] = useState<ArtifactListItem[]>([]); // All tags
|
||||
const [filteredTags, setFilteredTags] = useState<ArtifactListItem[]>([]); // Filtered tags
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tagsLoaded, setTagsLoaded] = useState(false); // Whether tags are loaded
|
||||
const [filterType, setFilterType] = useState<ArtifactCategory | "all">("chart"); // Default filter: chart
|
||||
const [loadError, setLoadError] = useState<string | null>(null); // Load error message
|
||||
const [launchModalOpen, setLaunchModalOpen] = useState(false);
|
||||
const [selectedTag, setSelectedTag] = useState<ArtifactListItem | null>(null);
|
||||
|
||||
// Load tags when expanded (using isMounted pattern for Strict Mode compatibility)
|
||||
// 🚀 Enhanced with cache support and auto-retry
|
||||
useEffect(() => {
|
||||
if (!expanded || tagsLoaded) {
|
||||
return; // Not expanded or already loaded, skip
|
||||
}
|
||||
|
||||
const isMounted = { current: true };
|
||||
|
||||
const loadTags = async (attempt = 1) => {
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
// 🚀 Check cache first
|
||||
const cachedData = artifactCache.get(registryId, repository);
|
||||
if (cachedData !== null) {
|
||||
console.log(`[RepositoryItem] ✅ Cache hit for ${repository} (${cachedData.length} tags)`);
|
||||
setAllTags(cachedData);
|
||||
setTagsLoaded(true);
|
||||
setLoadError(null);
|
||||
return; // Skip API call
|
||||
}
|
||||
|
||||
// Cache miss, load from API
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
|
||||
try {
|
||||
console.log(`[RepositoryItem] 📡 Loading tags from API for: ${repository} (attempt ${attempt})`);
|
||||
|
||||
// Mark as loading in cache to prevent duplicate requests
|
||||
artifactCache.setLoading(registryId, repository);
|
||||
|
||||
// Fetch all tags to support client-side filter switching
|
||||
const tagList = await listArtifacts({ registryId, repositoryName: repository });
|
||||
|
||||
// Only update state if component is still mounted
|
||||
if (isMounted.current) {
|
||||
console.log(`[RepositoryItem] ✅ Loaded ${tagList.length} tags from API`);
|
||||
|
||||
// Update cache
|
||||
artifactCache.set(registryId, repository, tagList);
|
||||
|
||||
setAllTags(tagList);
|
||||
setTagsLoaded(true);
|
||||
setLoadError(null);
|
||||
}
|
||||
} catch (error) {
|
||||
// Only show error if component is still mounted
|
||||
if (isMounted.current) {
|
||||
// Retry if attempts remaining
|
||||
if (attempt < MAX_RETRIES) {
|
||||
console.log(`[RepositoryItem] 🔄 Retrying ${repository} (${attempt + 1}/${MAX_RETRIES})...`);
|
||||
await new Promise(resolve => setTimeout(resolve, 500 * attempt)); // Exponential backoff
|
||||
return loadTags(attempt + 1);
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
console.error(`[RepositoryItem] ❌ All retries exhausted for ${repository}:`, error);
|
||||
|
||||
// Format error message using unified error handling
|
||||
const errorMsg = formatApiError(error) || RegistryErrors.TAGS_LOAD_FAILED;
|
||||
setLoadError(errorMsg);
|
||||
toastError(errorMsg);
|
||||
|
||||
// 🔧 Clear cache to allow manual retry
|
||||
artifactCache.clear(registryId, repository);
|
||||
|
||||
setAllTags([]);
|
||||
// Don't set tagsLoaded to true - allow retry
|
||||
}
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadTags();
|
||||
|
||||
// Cleanup: mark as unmounted to prevent state updates
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, [expanded, tagsLoaded, registryId, repository, toastError]);
|
||||
|
||||
// Filter tags by type
|
||||
useEffect(() => {
|
||||
if (filterType === "all") {
|
||||
setFilteredTags(allTags);
|
||||
return;
|
||||
}
|
||||
setFilteredTags(
|
||||
allTags.filter((tagItem) => inferArtifactCategory(tagItem) === filterType),
|
||||
);
|
||||
}, [allTags, filterType]);
|
||||
|
||||
// Get plural form of type name
|
||||
const getTypeName = (type: string): string => {
|
||||
if (type === "all") return "artifacts";
|
||||
// Convert to plural form
|
||||
const typeMap: Record<string, string> = {
|
||||
chart: "charts",
|
||||
image: "images",
|
||||
other: "others",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
const handleCopyPullCommand = (tagName?: string, tagType?: ArtifactCategory) => {
|
||||
const baseUrl = registryUrl.replace(/^https?:\/\//, "");
|
||||
|
||||
let pullCommand: string;
|
||||
if (tagType === "chart") {
|
||||
// Helm Chart - use helm pull
|
||||
pullCommand = tagName
|
||||
? `helm pull oci://${baseUrl}/${repository} --version ${tagName}`
|
||||
: `helm pull oci://${baseUrl}/${repository}`;
|
||||
} else if (tagType === "image") {
|
||||
// Docker Image - use docker pull
|
||||
pullCommand = tagName
|
||||
? `docker pull ${baseUrl}/${repository}:${tagName}`
|
||||
: `docker pull ${baseUrl}/${repository}`;
|
||||
} else {
|
||||
// Other types - generic format
|
||||
pullCommand = `${baseUrl}/${repository}${tagName ? `:${tagName}` : ""}`;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(pullCommand);
|
||||
success("Pull command copied to clipboard");
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
setExpanded(!expanded);
|
||||
};
|
||||
|
||||
const handleLaunch = (tagItem: ArtifactListItem) => {
|
||||
setSelectedTag(tagItem);
|
||||
setLaunchModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseLaunchModal = () => {
|
||||
setLaunchModalOpen(false);
|
||||
setSelectedTag(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Launch Modal */}
|
||||
{selectedTag && selectedTag.repositoryName && selectedTag.tag && (
|
||||
<LaunchModal
|
||||
isOpen={launchModalOpen}
|
||||
onClose={handleCloseLaunchModal}
|
||||
registryId={registryId}
|
||||
repositoryName={selectedTag.repositoryName}
|
||||
tag={selectedTag.tag}
|
||||
artifactType={inferArtifactCategory(selectedTag)}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Repository Header */}
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-800 transition group">
|
||||
{/* Left: Repository Info */}
|
||||
<div
|
||||
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{/* Expand/Collapse Icon */}
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<Package className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
<span className="text-sm text-gray-200 font-mono truncate" title={repository}>
|
||||
{repository}
|
||||
</span>
|
||||
{/* Registry Name Badge */}
|
||||
<span className="px-2 py-0.5 bg-purple-600/20 border border-purple-500/30 rounded text-xs text-purple-300 flex-shrink-0">
|
||||
🏷️ {registryName}
|
||||
</span>
|
||||
{/* Tag Count Badge */}
|
||||
{allTags.length > 0 && (
|
||||
<span className="px-2 py-0.5 bg-blue-600/20 border border-blue-500/30 rounded text-xs text-blue-300">
|
||||
{filteredTags.length}/{allTags.length} {getTypeName(filterType)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Action Buttons */}
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 使用第一个tag的类型作为默认类型
|
||||
const firstTagType = allTags.length > 0 ? inferArtifactCategory(allTags[0]) : undefined;
|
||||
handleCopyPullCommand(undefined, firstTagType);
|
||||
}}
|
||||
className="p-1.5 hover:bg-gray-700 rounded transition"
|
||||
title="Copy pull command"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 text-gray-400 hover:text-blue-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags List (shown when expanded) */}
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-700 bg-gray-900/50 p-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<p className="text-xs text-gray-500">Loading tags...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Filter */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-3 border-b border-gray-700">
|
||||
<Filter className="w-3.5 h-3.5 text-gray-400" />
|
||||
<span className="text-xs text-gray-400">Type:</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setFilterType("chart")}
|
||||
className={`px-2 py-0.5 text-xs rounded transition ${
|
||||
filterType === "chart"
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-gray-700 text-gray-400 hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
Chart
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType("image")}
|
||||
className={`px-2 py-0.5 text-xs rounded transition ${
|
||||
filterType === "image"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-700 text-gray-400 hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
Image
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType("other")}
|
||||
className={`px-2 py-0.5 text-xs rounded transition ${
|
||||
filterType === "other"
|
||||
? "bg-purple-600 text-white"
|
||||
: "bg-gray-700 text-gray-400 hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
Other
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType("all")}
|
||||
className={`px-2 py-0.5 text-xs rounded transition ${
|
||||
filterType === "all"
|
||||
? "bg-gray-600 text-white"
|
||||
: "bg-gray-700 text-gray-400 hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{loadError ? (
|
||||
<div className="text-center py-4 text-red-400 text-sm">
|
||||
<AlertCircle className="w-8 h-8 mx-auto mb-2" />
|
||||
<p>Failed to load artifacts</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{loadError}</p>
|
||||
</div>
|
||||
) : filteredTags.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
<TagIcon className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
<p>No {filterType === "all" ? "" : getTypeName(filterType)} tags</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{filteredTags.map((tagItem, index) => {
|
||||
const category = inferArtifactCategory(tagItem);
|
||||
return (
|
||||
<div
|
||||
key={`${tagItem.tag}-${index}`}
|
||||
className="flex items-center justify-between p-2 bg-gray-800/50 hover:bg-gray-800 rounded transition group/tag"
|
||||
>
|
||||
{/* Tag Info */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<TagIcon className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
|
||||
<span className="text-sm text-gray-300 font-mono truncate" title={tagItem.tag}>
|
||||
{tagItem.tag}
|
||||
</span>
|
||||
{/* Artifact Type Badge */}
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-xs rounded ${
|
||||
category === "chart"
|
||||
? "bg-green-600/20 text-green-300 border border-green-500/30"
|
||||
: category === "image"
|
||||
? "bg-blue-600/20 text-blue-300 border border-blue-500/30"
|
||||
: "bg-purple-600/20 text-purple-300 border border-purple-500/30"
|
||||
}`}
|
||||
title={tagItem.mediaType || tagItem.type}
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
{/* Artifact Size */}
|
||||
{tagItem.size && tagItem.size > 0 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{(tagItem.size / 1024 / 1024).toFixed(2)} MB
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag Action Buttons */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/tag:opacity-100">
|
||||
{/* Launch button - only for chart/helm type */}
|
||||
{category === "chart" && (
|
||||
<button
|
||||
onClick={() => handleLaunch(tagItem)}
|
||||
className="p-1 hover:bg-green-700/50 rounded transition"
|
||||
title={`Launch ${tagItem.tag}`}
|
||||
>
|
||||
<Rocket className="w-3 h-3 text-green-400 hover:text-green-300" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCopyPullCommand(tagItem.tag, category)}
|
||||
className="p-1 hover:bg-gray-700 rounded transition"
|
||||
title={`Copy pull command for ${tagItem.tag}`}
|
||||
>
|
||||
<Copy className="w-3 h-3 text-gray-400 hover:text-blue-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
144
frontend/src/features/artifact/registries/components/TagCard.tsx
Normal file
144
frontend/src/features/artifact/registries/components/TagCard.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Tag Card Component
|
||||
* Simple card for displaying a single tag/artifact
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Package, Rocket, Copy, HardDrive } from "lucide-react";
|
||||
import { LaunchModal } from "./LaunchModal";
|
||||
import { useToast } from "@/shared";
|
||||
import type { ArtifactListItem } from "@/api";
|
||||
import { inferArtifactCategory, type ArtifactCategory } from "../utils/artifactType";
|
||||
|
||||
interface TagCardProps {
|
||||
registryId: string;
|
||||
tag: ArtifactListItem;
|
||||
}
|
||||
|
||||
export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
|
||||
const { success } = useToast();
|
||||
const [launchModalOpen, setLaunchModalOpen] = useState(false);
|
||||
const category = inferArtifactCategory(tag);
|
||||
|
||||
const handleLaunch = () => {
|
||||
setLaunchModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
const tagName = tag.tag || '';
|
||||
if (!tagName || !tag.repositoryName) return;
|
||||
const pullCommand = `helm pull oci://${tag.repositoryName}:${tagName}`;
|
||||
navigator.clipboard.writeText(pullCommand);
|
||||
success("Pull command copied to clipboard!");
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes === 0) return "N/A";
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (mb < 1) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const getTypeColor = (type: ArtifactCategory) => {
|
||||
switch (type) {
|
||||
case "chart":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
|
||||
case "image":
|
||||
return "text-green-400 bg-green-500/10 border-green-500/30";
|
||||
default:
|
||||
return "text-gray-400 bg-gray-500/10 border-gray-500/30";
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: ArtifactCategory) => {
|
||||
switch (type) {
|
||||
case "chart":
|
||||
return "📦";
|
||||
case "image":
|
||||
return "🐳";
|
||||
default:
|
||||
return "📄";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-dark-card border border-dark-border rounded-lg p-4 hover:border-brand-blue/50 transition-all group">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`w-10 h-10 rounded-lg border ${getTypeColor(category)}
|
||||
flex items-center justify-center text-lg`}>
|
||||
{getTypeIcon(category)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Tag name */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Package className="w-4 h-4 text-purple-400 flex-shrink-0" />
|
||||
<h3 className="text-sm font-semibold text-white truncate">
|
||||
{tag.tag || 'N/A'}
|
||||
</h3>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs border ${getTypeColor(category)}`}
|
||||
title={tag.mediaType || tag.type || ''}
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Repository path */}
|
||||
<p className="text-xs text-gray-500 truncate mb-2">
|
||||
{tag.repositoryName}
|
||||
</p>
|
||||
|
||||
{/* Size */}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<HardDrive className="w-3.5 h-3.5" />
|
||||
<span>{formatSize(tag.size || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex-shrink-0 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleLaunch}
|
||||
className="px-3 py-1.5 bg-brand-blue hover:bg-brand-blue/80 text-white rounded
|
||||
text-xs font-medium transition-colors flex items-center gap-1.5"
|
||||
title="Launch this artifact"
|
||||
>
|
||||
<Rocket className="w-3.5 h-3.5" />
|
||||
<span>Launch</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="px-3 py-1.5 bg-dark-lighter hover:bg-white/5 text-gray-300
|
||||
border border-dark-border rounded text-xs transition-colors flex items-center gap-1.5"
|
||||
title="Copy pull command"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Launch Modal */}
|
||||
{launchModalOpen && tag.repositoryName && tag.tag && (
|
||||
<LaunchModal
|
||||
isOpen={launchModalOpen}
|
||||
onClose={() => setLaunchModalOpen(false)}
|
||||
registryId={registryId}
|
||||
repositoryName={tag.repositoryName}
|
||||
tag={tag.tag}
|
||||
artifactType={category}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
13
frontend/src/features/artifact/registries/index.ts
Normal file
13
frontend/src/features/artifact/registries/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Registry Browser Feature (Flattened Architecture)
|
||||
* OCI 仓库浏览功能(扁平化架构)
|
||||
*/
|
||||
|
||||
// Export pages
|
||||
export { default as RegistriesBrowserPage } from './pages/RegistriesBrowserPage';
|
||||
|
||||
// Export components
|
||||
export { RepositoryItem } from './components/RepositoryItem';
|
||||
export { TagCard } from './components/TagCard';
|
||||
export { LaunchModal } from './components/LaunchModal';
|
||||
|
||||
@ -0,0 +1,511 @@
|
||||
/**
|
||||
* Artifact Browser Page
|
||||
* 左侧 Registry/Repository 树(自动加载并展开),右侧 Artifact 卡片。
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Package,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
Search,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/shared";
|
||||
import {
|
||||
Button,
|
||||
LoadingState,
|
||||
EmptyState,
|
||||
Badge,
|
||||
} from "@/shared/components";
|
||||
import {
|
||||
listRegistries,
|
||||
listRepositories,
|
||||
listArtifacts,
|
||||
} from "@/api";
|
||||
import type {
|
||||
RegistryResponse,
|
||||
ListRepositories200Item,
|
||||
ArtifactListItem,
|
||||
ListArtifactsFilter,
|
||||
} from "@/api";
|
||||
import { TagCard } from "../components/TagCard";
|
||||
import { globalCache } from "@/shared/services/artifact-cache";
|
||||
import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils";
|
||||
|
||||
interface RepositoryNode {
|
||||
name: string;
|
||||
registryId: string;
|
||||
registryName: string;
|
||||
artifactCount?: number;
|
||||
}
|
||||
|
||||
interface RegistryNode {
|
||||
registry: RegistryResponse;
|
||||
repositories: RepositoryNode[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS: Array<{ value: ListArtifactsFilter | undefined; label: string }> = [
|
||||
{ value: undefined, label: "All" },
|
||||
{ value: "chart", label: "Charts" },
|
||||
{ value: "image", label: "Images" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
const ArtifactBrowserPage: React.FC = () => {
|
||||
const { info: toastInfo, success: toastSuccess, error: toastError } = useToast();
|
||||
|
||||
const [registryNodes, setRegistryNodes] = useState<RegistryNode[]>([]);
|
||||
const [loadingRegistries, setLoadingRegistries] = useState(true);
|
||||
const [loadingRepositories, setLoadingRepositories] = useState(true);
|
||||
const [repositoryError, setRepositoryError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const [selectedRepository, setSelectedRepository] = useState<RepositoryNode | null>(null);
|
||||
const [artifacts, setArtifacts] = useState<ArtifactListItem[]>([]);
|
||||
const [loadingArtifacts, setLoadingArtifacts] = useState(false);
|
||||
const [artifactError, setArtifactError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<ListArtifactsFilter | undefined>(undefined);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const loadArtifacts = useCallback(
|
||||
async (
|
||||
registryId: string,
|
||||
repositoryName: string,
|
||||
skipCache = false,
|
||||
isRefresh = false
|
||||
) => {
|
||||
if (!isRefresh) {
|
||||
setLoadingArtifacts(true);
|
||||
}
|
||||
setArtifactError(null);
|
||||
const filterKey = filter ?? "all";
|
||||
try {
|
||||
let data: ArtifactListItem[] | null = null;
|
||||
if (!skipCache) {
|
||||
data = globalCache.get<ArtifactListItem[]>("tags", registryId, repositoryName, filterKey);
|
||||
}
|
||||
if (!data) {
|
||||
data = await listArtifacts(
|
||||
{ registryId, repositoryName },
|
||||
filter ? { filter } : undefined
|
||||
);
|
||||
globalCache.set("tags", data, registryId, repositoryName, filterKey);
|
||||
}
|
||||
setArtifacts(data ?? []);
|
||||
} catch (err) {
|
||||
const errorMsg = formatApiError(err) || "Failed to load artifacts";
|
||||
setArtifactError(errorMsg);
|
||||
toastError(errorMsg);
|
||||
} finally {
|
||||
if (!isRefresh) {
|
||||
setLoadingArtifacts(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[filter, toastError]
|
||||
);
|
||||
|
||||
const fetchRegsAndRepos = useCallback(async (isRefresh = false) => {
|
||||
if (!isRefresh) {
|
||||
setLoadingRegistries(true);
|
||||
setLoadingRepositories(true);
|
||||
}
|
||||
setRepositoryError(null);
|
||||
let succeeded = false;
|
||||
|
||||
try {
|
||||
let registries = globalCache.get<RegistryResponse[]>("registries");
|
||||
if (!registries) {
|
||||
registries = await listRegistries();
|
||||
globalCache.set("registries", registries);
|
||||
}
|
||||
|
||||
const baseNodes: RegistryNode[] = registries.map((registry) => ({
|
||||
registry,
|
||||
repositories: [],
|
||||
expanded: true,
|
||||
}));
|
||||
setRegistryNodes(baseNodes);
|
||||
if (!isRefresh) {
|
||||
setLoadingRegistries(false);
|
||||
}
|
||||
|
||||
const repoMap = await Promise.all(
|
||||
registries.map(async (registry) => {
|
||||
const registryId = registry.id;
|
||||
if (!registryId) {
|
||||
return { id: registryId, repos: [] };
|
||||
}
|
||||
try {
|
||||
let repoNodes = globalCache.get<RepositoryNode[]>("repositories", registryId);
|
||||
if (!repoNodes) {
|
||||
const response = await listRepositories({ registryId });
|
||||
repoNodes = normalizeRepositories(registry, response);
|
||||
globalCache.set("repositories", repoNodes, registryId);
|
||||
}
|
||||
return { id: registryId, repos: repoNodes };
|
||||
} catch (err) {
|
||||
console.error(`Failed to load repositories for ${registry.name}`, err);
|
||||
return { id: registryId, repos: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setRegistryNodes((prev) =>
|
||||
prev.map((node) => {
|
||||
const mapping = repoMap.find((entry) => entry.id === node.registry.id);
|
||||
if (!mapping) return node;
|
||||
return { ...node, repositories: mapping.repos };
|
||||
})
|
||||
);
|
||||
|
||||
const firstRepo = repoMap.find((entry) => entry.repos.length > 0)?.repos[0];
|
||||
if (firstRepo) {
|
||||
setSelectedRepository(firstRepo);
|
||||
await loadArtifacts(firstRepo.registryId, firstRepo.name, false, isRefresh);
|
||||
} else {
|
||||
setSelectedRepository(null);
|
||||
setArtifacts([]);
|
||||
}
|
||||
succeeded = true;
|
||||
} catch (err) {
|
||||
const msg = formatApiError(err) || RegistryErrors.LOAD_FAILED;
|
||||
toastError(msg);
|
||||
setRepositoryError(msg);
|
||||
} finally {
|
||||
if (!isRefresh) {
|
||||
setLoadingRepositories(false);
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
}, [toastError, loadArtifacts]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (refreshing) return;
|
||||
toastInfo("Refreshing registries & repositories...", {
|
||||
title: "Artifact Browser Refresh",
|
||||
durationMs: 1800,
|
||||
mergeKey: "artifact-browser-refresh",
|
||||
});
|
||||
setRefreshing(true);
|
||||
globalCache.clearAll();
|
||||
try {
|
||||
const refreshed = await fetchRegsAndRepos(true);
|
||||
if (refreshed) {
|
||||
toastSuccess(SuccessMessages.DATA_REFRESHED);
|
||||
}
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRepositoryClick = (repo: RepositoryNode) => {
|
||||
setSelectedRepository(repo);
|
||||
loadArtifacts(repo.registryId, repo.name, true);
|
||||
};
|
||||
|
||||
const toggleRegistry = (registryId?: string) => {
|
||||
if (!registryId) return;
|
||||
setRegistryNodes((prev) =>
|
||||
prev.map((node) =>
|
||||
node.registry.id === registryId ? { ...node, expanded: !node.expanded } : node
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const hasInitialized = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) {
|
||||
return;
|
||||
}
|
||||
hasInitialized.current = true;
|
||||
fetchRegsAndRepos();
|
||||
}, [fetchRegsAndRepos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRepository) {
|
||||
loadArtifacts(selectedRepository.registryId, selectedRepository.name);
|
||||
}
|
||||
}, [filter, selectedRepository, loadArtifacts]);
|
||||
|
||||
const filteredNodes = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase();
|
||||
if (!term) return registryNodes;
|
||||
return registryNodes
|
||||
.map((node) => ({
|
||||
...node,
|
||||
repositories: node.repositories.filter((repo) => {
|
||||
const registryMatch = node.registry.name?.toLowerCase().includes(term);
|
||||
const repoMatch = repo.name.toLowerCase().includes(term);
|
||||
return registryMatch || repoMatch;
|
||||
}),
|
||||
}))
|
||||
.filter(
|
||||
(node) =>
|
||||
node.repositories.length > 0 ||
|
||||
node.registry.name?.toLowerCase().includes(term)
|
||||
);
|
||||
}, [registryNodes, searchTerm]);
|
||||
|
||||
const selectedRegistryName = selectedRepository
|
||||
? registryNodes.find((node) => node.registry.id === selectedRepository.registryId)?.registry
|
||||
.name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-8rem)] -m-6 flex flex-col">
|
||||
<div className="flex-shrink-0 border-b border-dark-border bg-dark-card px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="w-6 h-6 text-purple-400" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">Artifact Browser</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
Browse registries, repositories, and artifacts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={RefreshCw}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
spinIcon
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex overflow-hidden bg-dark-bg">
|
||||
<aside className="w-80 border-r border-dark-border bg-gradient-to-b from-gray-900 via-gray-950 to-gray-900 flex flex-col">
|
||||
<div className="p-4 border-b border-dark-border space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search registries / repositories..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-2 rounded-lg bg-gray-900/70 border border-gray-700 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
{repositoryError && (
|
||||
<p className="text-xs text-red-400">{repositoryError}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||
<span>Registries</span>
|
||||
<Badge variant="secondary">{registryNodes.length}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{loadingRegistries ? (
|
||||
<div className="p-4">
|
||||
<LoadingState message="Loading registries..." />
|
||||
</div>
|
||||
) : filteredNodes.length === 0 ? (
|
||||
<div className="p-4">
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title="No registries"
|
||||
description="Add a registry to get started."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
filteredNodes.map((node) => (
|
||||
<div key={node.registry.id || node.registry.name}>
|
||||
<button
|
||||
onClick={() => toggleRegistry(node.registry.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-gray-800/60 transition"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{node.expanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<Database className="w-4 h-4 text-purple-400" />
|
||||
<div className="text-left">
|
||||
<p className="text-sm text-white">{node.registry.name || "Unnamed"}</p>
|
||||
<p className="text-[11px] text-gray-500 truncate">
|
||||
{node.registry.url}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">{node.repositories.length}</Badge>
|
||||
</button>
|
||||
{node.expanded && (
|
||||
<div className="bg-gray-900/60">
|
||||
{node.repositories.length === 0 ? (
|
||||
<p className="px-8 py-3 text-xs text-gray-500">
|
||||
{loadingRepositories
|
||||
? "Loading repositories..."
|
||||
: "No repositories found."}
|
||||
</p>
|
||||
) : (
|
||||
node.repositories.map((repo) => {
|
||||
const isSelected =
|
||||
selectedRepository?.registryId === repo.registryId &&
|
||||
selectedRepository?.name === repo.name;
|
||||
return (
|
||||
<button
|
||||
key={`${repo.registryId}-${repo.name}`}
|
||||
onClick={() => handleRepositoryClick(repo)}
|
||||
className={`w-full text-left px-8 py-2 flex items-center justify-between text-sm transition ${
|
||||
isSelected
|
||||
? "bg-purple-600/20 text-white"
|
||||
: "hover:bg-gray-800/80 text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{repo.name}</span>
|
||||
{repo.artifactCount !== undefined && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{repo.artifactCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 flex flex-col bg-dark-card overflow-hidden">
|
||||
{!selectedRepository ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="Select a repository"
|
||||
description="Choose a repository from the left panel to view artifacts."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-shrink-0 border-b border-dark-border p-5 bg-gradient-to-r from-gray-900 to-gray-850">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">Repository</p>
|
||||
<h2 className="text-2xl font-semibold text-white">
|
||||
{selectedRepository.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
{selectedRegistryName || selectedRepository.registryId}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
onClick={() => setFilter(option.value)}
|
||||
className={`px-3 py-1.5 text-xs rounded-full border transition ${
|
||||
filter === option.value
|
||||
? "bg-purple-600 text-white border-purple-500"
|
||||
: "border-gray-700 text-gray-300 hover:border-gray-500"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{artifactError && (
|
||||
<p className="text-sm text-red-400 mb-3">{artifactError}</p>
|
||||
)}
|
||||
{loadingArtifacts ? (
|
||||
<LoadingState message="Loading artifacts..." />
|
||||
) : artifacts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="No artifacts"
|
||||
description={
|
||||
filter
|
||||
? `No ${filter} artifacts found for this repository.`
|
||||
: "This repository doesn't contain any artifacts yet."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{artifacts.map((artifact, index) => (
|
||||
<TagCard
|
||||
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}
|
||||
registryId={selectedRepository.registryId}
|
||||
tag={artifact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArtifactBrowserPage;
|
||||
|
||||
function normalizeRepositories(
|
||||
registry: RegistryResponse,
|
||||
payload: ListRepositories200Item[] | { repositories?: string[] } | null | undefined
|
||||
): RepositoryNode[] {
|
||||
const registryId = registry.id || "";
|
||||
const registryName = registry.name || registryId;
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
.map((repo): RepositoryNode | null => {
|
||||
if (typeof repo === "string") {
|
||||
return {
|
||||
name: repo,
|
||||
registryId,
|
||||
registryName,
|
||||
artifactCount: undefined,
|
||||
};
|
||||
}
|
||||
const name = repo.name || "";
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
registryId,
|
||||
registryName,
|
||||
artifactCount: (repo as any).artifact_count ?? (repo as any).artifactCount,
|
||||
};
|
||||
})
|
||||
.filter((repo): repo is RepositoryNode => Boolean(repo));
|
||||
}
|
||||
|
||||
if (payload && typeof payload === "object" && Array.isArray((payload as any).repositories)) {
|
||||
const repoEntries = (payload as any).repositories.map(
|
||||
(name: unknown): RepositoryNode | null =>
|
||||
typeof name === "string"
|
||||
? {
|
||||
name,
|
||||
registryId,
|
||||
registryName,
|
||||
artifactCount: undefined,
|
||||
}
|
||||
: null
|
||||
);
|
||||
return repoEntries.filter(
|
||||
(repo: RepositoryNode | null): repo is RepositoryNode => Boolean(repo)
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Artifacts Browser Page
|
||||
* 浏览特定 Repository 的所有 Artifacts (Tags)
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Package, RefreshCw, ArrowLeft, Filter } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useToast } from "@/shared";
|
||||
import {
|
||||
PageHeader,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Badge
|
||||
} from "@/shared/components";
|
||||
import { listArtifacts, listRegistries } from "@/api";
|
||||
import type { ArtifactListItem, ListArtifactsFilter } from "@/api";
|
||||
import { TagCard } from "../components/TagCard";
|
||||
import { globalCache } from "@/shared/services/artifact-cache";
|
||||
import { SuccessMessages, formatApiError } from "@/shared/utils";
|
||||
|
||||
const ArtifactsBrowserPage: React.FC = () => {
|
||||
const { registryId, repositoryName } = useParams<{ registryId: string; repositoryName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { info: toastInfo, success: toastSuccess, error: toastError } = useToast();
|
||||
|
||||
const [artifacts, setArtifacts] = useState<ArtifactListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<ListArtifactsFilter | undefined>(undefined);
|
||||
const [registryName, setRegistryName] = useState<string>("");
|
||||
|
||||
// Decode repository name (URL encoded)
|
||||
const decodedRepositoryName = repositoryName ? decodeURIComponent(repositoryName) : "";
|
||||
|
||||
// Load registry info
|
||||
useEffect(() => {
|
||||
const loadRegistryInfo = async () => {
|
||||
if (!registryId) return;
|
||||
try {
|
||||
const registries = await listRegistries();
|
||||
const registry = registries.find(r => r.id === registryId);
|
||||
if (registry) {
|
||||
setRegistryName(registry.name || "");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load registry info:", err);
|
||||
}
|
||||
};
|
||||
loadRegistryInfo();
|
||||
}, [registryId]);
|
||||
|
||||
// Load artifacts
|
||||
const loadArtifacts = async (isMounted = { current: true }, skipCache = false, isRefresh = false) => {
|
||||
if (!registryId || !decodedRepositoryName) {
|
||||
setError("Registry ID or Repository Name is missing");
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const filterKey = filter ?? "all";
|
||||
|
||||
if (!skipCache) {
|
||||
const cached = globalCache.get<ArtifactListItem[]>(
|
||||
"tags",
|
||||
registryId,
|
||||
decodedRepositoryName,
|
||||
filterKey
|
||||
);
|
||||
if (cached) {
|
||||
console.log(`[ArtifactsBrowserPage] Using cached artifacts for ${decodedRepositoryName}`);
|
||||
setArtifacts(cached);
|
||||
setLoading(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
let succeeded = false;
|
||||
try {
|
||||
const data = await listArtifacts(
|
||||
{ registryId, repositoryName: decodedRepositoryName },
|
||||
filter ? { filter } : undefined
|
||||
);
|
||||
if (isMounted.current) {
|
||||
setArtifacts(data);
|
||||
globalCache.set("tags", data, registryId, decodedRepositoryName, filterKey);
|
||||
succeeded = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted.current) {
|
||||
const errorMsg = formatApiError(err) || "Failed to load artifacts";
|
||||
setError(errorMsg);
|
||||
toastError(errorMsg);
|
||||
console.error(err);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
if (isRefresh) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const isMounted = { current: true };
|
||||
loadArtifacts(isMounted);
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, [registryId, decodedRepositoryName, filter]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
toastInfo("Refreshing artifacts...", {
|
||||
title: "Artifacts Refresh",
|
||||
durationMs: 1800,
|
||||
mergeKey: "artifacts-refresh",
|
||||
});
|
||||
globalCache.clearAll();
|
||||
const refreshed = await loadArtifacts({ current: true }, true, true);
|
||||
if (refreshed) {
|
||||
toastSuccess(SuccessMessages.DATA_REFRESHED);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (registryId) {
|
||||
navigate(`/artifact/registries/${registryId}`);
|
||||
} else {
|
||||
navigate("/artifact/registries");
|
||||
}
|
||||
};
|
||||
|
||||
const filterOptions: Array<{ value: ListArtifactsFilter | undefined; label: string }> = [
|
||||
{ value: undefined, label: "All Types" },
|
||||
{ value: "chart", label: "Helm Charts" },
|
||||
{ value: "image", label: "Container Images" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title={`Artifacts - ${decodedRepositoryName}`}
|
||||
description={`Browse artifacts in ${registryName || registryId || "registry"}`}
|
||||
icon={Package}
|
||||
iconColor="text-purple-400"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={ArrowLeft}
|
||||
onClick={handleBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={RefreshCw}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
spinIcon={true}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filter Bar */}
|
||||
{!loading && !error && (
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-400">Filter by type:</span>
|
||||
<div className="flex gap-2">
|
||||
{filterOptions.map((option) => (
|
||||
<button
|
||||
key={option.value || "all"}
|
||||
onClick={() => setFilter(option.value)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||
filter === option.value
|
||||
? "bg-purple-600 text-white"
|
||||
: "bg-dark-lighter text-gray-300 hover:bg-dark-border"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filter && (
|
||||
<Badge variant="info">
|
||||
{artifacts.length} {filter} artifact{artifacts.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <LoadingState message="Loading artifacts..." />}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !loading && (
|
||||
<ErrorState message={error} onRetry={handleRefresh} />
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && !error && artifacts.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="No Artifacts Found"
|
||||
description={
|
||||
filter
|
||||
? `No ${filter} artifacts found in this repository`
|
||||
: "This repository doesn't contain any artifacts yet"
|
||||
}
|
||||
action={{
|
||||
label: "Back to Repositories",
|
||||
icon: ArrowLeft,
|
||||
onClick: handleBack,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Artifacts Grid */}
|
||||
{!loading && !error && artifacts.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{artifacts.map((artifact, index) => (
|
||||
<TagCard
|
||||
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}
|
||||
registryId={registryId || ""}
|
||||
tag={artifact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Breadcrumb Info */}
|
||||
{!loading && artifacts.length > 0 && (
|
||||
<div className="mt-6 p-4 bg-dark-card border border-dark-border rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<span>Registry:</span>
|
||||
<span className="text-white font-medium">{registryName || registryId}</span>
|
||||
<span className="mx-2">/</span>
|
||||
<span>Repository:</span>
|
||||
<span className="text-white font-medium">{decodedRepositoryName}</span>
|
||||
<span className="mx-2">/</span>
|
||||
<span>Artifacts:</span>
|
||||
<span className="text-white font-medium">{artifacts.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArtifactsBrowserPage;
|
||||
@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Registries Browser Page
|
||||
* Display all registries and their repositories
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Database, RefreshCw, Plus, Package } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useToast } from "@/shared";
|
||||
import {
|
||||
PageHeader,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState
|
||||
} from "@/shared/components";
|
||||
import { RegistryTreeExplorer } from "../components/RegistryTreeExplorer";
|
||||
import { listRegistries } from "@/api";
|
||||
import type { AppRegistry } from "@/core/types";
|
||||
import { globalCache } from "@/shared/services/artifact-cache";
|
||||
import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils";
|
||||
|
||||
const RegistriesBrowserPage: React.FC = () => {
|
||||
const { info: toastInfo, success: toastSuccess, error: toastError } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [registries, setRegistries] = useState<AppRegistry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load registries with global cache
|
||||
const loadRegistries = async (
|
||||
isMounted = { current: true },
|
||||
skipCache = false,
|
||||
isRefresh = false
|
||||
) => {
|
||||
let succeeded = false;
|
||||
if (!skipCache) {
|
||||
// Check global cache first
|
||||
const cachedRegistries = globalCache.get<AppRegistry[]>('registries');
|
||||
if (cachedRegistries) {
|
||||
console.log('[RegistriesBrowserPage] Using cached registries');
|
||||
setRegistries(cachedRegistries);
|
||||
setLoading(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listRegistries();
|
||||
if (isMounted.current) {
|
||||
setRegistries(data);
|
||||
// Cache registries list (30 minutes TTL)
|
||||
globalCache.set('registries', data);
|
||||
succeeded = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted.current) {
|
||||
const errorMsg = formatApiError(err) || RegistryErrors.LOAD_FAILED;
|
||||
setError(errorMsg);
|
||||
toastError(errorMsg);
|
||||
console.error(err);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
if (isRefresh) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const isMounted = { current: true };
|
||||
loadRegistries(isMounted);
|
||||
|
||||
// Listen for storage events to auto-refresh when registries are added
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'registry_updated') {
|
||||
console.log('[RegistriesBrowserPage] Detected registry update, refreshing...');
|
||||
loadRegistries(isMounted);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Refresh handler
|
||||
const handleRefresh = async () => {
|
||||
toastInfo("Refreshing registry list and clearing cache...", {
|
||||
title: "Registry Browser Refresh",
|
||||
durationMs: 1800,
|
||||
mergeKey: "registry-browser-refresh",
|
||||
});
|
||||
// Clear all caches to force reload all data
|
||||
globalCache.clearAll();
|
||||
const refreshed = await loadRegistries({ current: true }, true, true); // skipCache = true, refresh
|
||||
if (refreshed) {
|
||||
toastSuccess(SuccessMessages.DATA_REFRESHED);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to config page
|
||||
const handleAddRegistry = () => {
|
||||
navigate("/configuration/registries");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Artifact - Registries"
|
||||
description="Browse all OCI registries and their artifacts"
|
||||
icon={Database}
|
||||
iconColor="text-purple-400"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={RefreshCw}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
spinIcon={true}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={Plus}
|
||||
onClick={handleAddRegistry}
|
||||
>
|
||||
Add Registry
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <LoadingState message="Loading registries..." />}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !loading && (
|
||||
<ErrorState message={error} onRetry={handleRefresh} />
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && !error && registries.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title="No Registries Found"
|
||||
description="Add your first OCI registry to start managing artifacts"
|
||||
action={{
|
||||
label: "Add Registry",
|
||||
icon: Plus,
|
||||
onClick: handleAddRegistry,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Registry Tree Explorer (Three-Level Layout) */}
|
||||
{!loading && !error && registries.length > 0 && (
|
||||
<RegistryTreeExplorer registries={registries} />
|
||||
)}
|
||||
|
||||
{/* Enhanced Usage Tips */}
|
||||
{!loading && registries.length > 0 && (
|
||||
<div className="mt-6 relative overflow-hidden bg-gradient-to-br from-purple-900/30 via-blue-900/20 to-purple-900/30 border border-purple-500/30 rounded-xl shadow-lg shadow-purple-500/10">
|
||||
{/* Decorative background */}
|
||||
<div className="absolute top-0 right-0 w-48 h-48 bg-gradient-to-br from-purple-500/10 to-transparent rounded-full blur-2xl"></div>
|
||||
|
||||
<div className="relative p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-gradient-to-br from-purple-500/20 to-blue-500/20 rounded-lg border border-purple-500/30">
|
||||
<Package className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-300 to-blue-300">
|
||||
Quick Tips & Guide
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-3 p-3 bg-slate-800/40 border border-slate-700/50 rounded-lg hover:border-purple-500/30 transition-colors">
|
||||
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center bg-purple-500/20 text-purple-400 rounded-full text-xs font-bold border border-purple-500/30">1</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-slate-300 leading-relaxed">
|
||||
Click on a registry to <span className="text-purple-400 font-semibold">expand and view</span> its repositories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-slate-800/40 border border-slate-700/50 rounded-lg hover:border-blue-500/30 transition-colors">
|
||||
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center bg-blue-500/20 text-blue-400 rounded-full text-xs font-bold border border-blue-500/30">2</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-slate-300 leading-relaxed">
|
||||
Use <span className="text-blue-400 font-semibold">"Browse"</span> to explore all tags and artifacts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-slate-800/40 border border-slate-700/50 rounded-lg hover:border-green-500/30 transition-colors">
|
||||
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center bg-green-500/20 text-green-400 rounded-full text-xs font-bold border border-green-500/30">3</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-slate-300 leading-relaxed">
|
||||
Click <span className="text-green-400 font-semibold">"Launch"</span> to deploy artifacts to your cluster
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-slate-800/40 border border-slate-700/50 rounded-lg hover:border-cyan-500/30 transition-colors">
|
||||
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center bg-cyan-500/20 text-cyan-400 rounded-full text-xs font-bold border border-cyan-500/30">4</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-slate-300 leading-relaxed">
|
||||
Use <span className="text-cyan-400 font-semibold">search</span> to quickly find specific registries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistriesBrowserPage;
|
||||
@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Repositories Browser Page
|
||||
* 浏览特定 Registry 的所有 Repositories
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Database, RefreshCw, ArrowLeft, Search, Package } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useToast } from "@/shared";
|
||||
import {
|
||||
PageHeader,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState
|
||||
} from "@/shared/components";
|
||||
import { listRegistries, listRepositories } from "@/api";
|
||||
import type { ListRepositories200Item, RepositoryListResponse } from "@/api";
|
||||
import { RepositoryItem } from "../components/RepositoryItem";
|
||||
import { globalCache } from "@/shared/services/artifact-cache";
|
||||
import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils";
|
||||
|
||||
const RepositoriesBrowserPage: React.FC = () => {
|
||||
const { registryId } = useParams<{ registryId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { info: toastInfo, success: toastSuccess, error: toastError } = useToast();
|
||||
|
||||
const [repositories, setRepositories] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [registryName, setRegistryName] = useState<string>("");
|
||||
const [registryUrl, setRegistryUrl] = useState<string>("");
|
||||
|
||||
// Load registry info
|
||||
useEffect(() => {
|
||||
const loadRegistryInfo = async () => {
|
||||
if (!registryId) return;
|
||||
try {
|
||||
const registries = await listRegistries();
|
||||
const foundRegistry = registries.find(r => r.id === registryId);
|
||||
if (foundRegistry) {
|
||||
setRegistryName(foundRegistry.name || "");
|
||||
setRegistryUrl(foundRegistry.url || "");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load registry info:", err);
|
||||
}
|
||||
};
|
||||
loadRegistryInfo();
|
||||
}, [registryId]);
|
||||
|
||||
// Load repositories
|
||||
const loadRepositories = async (
|
||||
isMounted = { current: true },
|
||||
skipCache = false,
|
||||
isRefresh = false
|
||||
) => {
|
||||
let succeeded = false;
|
||||
if (!registryId) {
|
||||
setError("Registry ID is missing");
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!skipCache && registryId) {
|
||||
const cached = globalCache.get<string[]>("repositories", registryId);
|
||||
if (cached) {
|
||||
console.log(`[RepositoriesBrowserPage] Using cached repositories for ${registryId}`);
|
||||
setRepositories(cached);
|
||||
setLoading(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const response = await listRepositories({ registryId });
|
||||
const repos = normalizeRepositoryNames(response);
|
||||
if (isMounted.current) {
|
||||
setRepositories(repos);
|
||||
if (registryId) {
|
||||
globalCache.set("repositories", repos, registryId);
|
||||
}
|
||||
succeeded = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted.current) {
|
||||
const errorMsg = formatApiError(err) || RegistryErrors.LOAD_FAILED;
|
||||
setError(errorMsg);
|
||||
toastError(errorMsg);
|
||||
console.error(err);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
if (isRefresh) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const isMounted = { current: true };
|
||||
loadRepositories(isMounted);
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, [registryId]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
toastInfo("Refreshing repositories...", {
|
||||
title: "Repositories Refresh",
|
||||
durationMs: 1800,
|
||||
mergeKey: "repositories-refresh",
|
||||
});
|
||||
globalCache.clearAll();
|
||||
const refreshed = await loadRepositories({ current: true }, true, true);
|
||||
if (refreshed) {
|
||||
toastSuccess(SuccessMessages.DATA_REFRESHED);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate("/artifact/registries");
|
||||
};
|
||||
|
||||
const handleRepositoryClick = (repositoryName: string) => {
|
||||
if (registryId) {
|
||||
const encodedName = encodeURIComponent(repositoryName);
|
||||
navigate(`/artifact/registries/${registryId}/repositories/${encodedName}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter repositories by search term
|
||||
const filteredRepositories = repositories.filter((repo) =>
|
||||
repo.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title={`Repositories - ${registryName || registryId || "Registry"}`}
|
||||
description={`Browse repositories in ${registryName || registryId || "this registry"}`}
|
||||
icon={Database}
|
||||
iconColor="text-purple-400"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={ArrowLeft}
|
||||
onClick={handleBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={RefreshCw}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
spinIcon={true}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Search Bar */}
|
||||
{!loading && !error && repositories.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search repositories..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-dark-lighter border border-dark-border rounded-lg
|
||||
text-white placeholder-gray-500 focus:outline-none focus:border-purple-500
|
||||
focus:ring-1 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <LoadingState message="Loading repositories..." />}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !loading && (
|
||||
<ErrorState message={error} onRetry={handleRefresh} />
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && !error && filteredRepositories.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title={searchTerm ? "No Repositories Found" : "No Repositories"}
|
||||
description={
|
||||
searchTerm
|
||||
? `No repositories match "${searchTerm}"`
|
||||
: "This registry doesn't contain any repositories yet"
|
||||
}
|
||||
action={
|
||||
!searchTerm
|
||||
? {
|
||||
label: "Back to Registries",
|
||||
icon: ArrowLeft,
|
||||
onClick: handleBack,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Repositories List */}
|
||||
{!loading && !error && filteredRepositories.length > 0 && (
|
||||
<div className="mt-6 space-y-3">
|
||||
{filteredRepositories.map((repo) => (
|
||||
<div
|
||||
key={repo}
|
||||
onClick={() => handleRepositoryClick(repo)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RepositoryItem
|
||||
registryId={registryId || ""}
|
||||
registryName={registryName}
|
||||
registryUrl={registryUrl}
|
||||
repository={repo}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{!loading && repositories.length > 0 && (
|
||||
<div className="mt-6 p-4 bg-dark-card border border-dark-border rounded-lg">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
<span>Registry:</span>
|
||||
<span className="text-white font-medium">{registryName || registryId}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
<span>Repositories:</span>
|
||||
<span className="text-white font-medium">
|
||||
{filteredRepositories.length}
|
||||
{searchTerm && ` of ${repositories.length}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RepositoriesBrowserPage;
|
||||
|
||||
function normalizeRepositoryNames(
|
||||
payload: RepositoryListResponse | ListRepositories200Item[] | null | undefined
|
||||
): string[] {
|
||||
if (!payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
.map((repo) => (typeof repo === "string" ? repo : repo.name || ""))
|
||||
.filter((name): name is string => Boolean(name));
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.repositories)) {
|
||||
return payload.repositories.filter((name): name is string => typeof name === "string" && Boolean(name));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import type { ArtifactListItem } from "@/api";
|
||||
|
||||
export type ArtifactCategory = "chart" | "image" | "other";
|
||||
|
||||
/**
|
||||
* 推断 artifact 分类
|
||||
*
|
||||
* 后端 OpenAPI 已经返回规范化的类型值:'chart', 'image', 'other'
|
||||
* 这个函数主要是类型转换和空值处理
|
||||
*/
|
||||
export const inferArtifactCategory = (
|
||||
tag: ArtifactListItem | undefined,
|
||||
): ArtifactCategory => {
|
||||
if (!tag) {
|
||||
return "other";
|
||||
}
|
||||
|
||||
if (!tag.type) {
|
||||
return "other";
|
||||
}
|
||||
|
||||
// 后端已经返回规范化的值:'chart', 'image', 'other'
|
||||
return tag.type as ArtifactCategory;
|
||||
};
|
||||
|
||||
export const inferArtifactCategoryFromValues = (
|
||||
rawType?: string | null,
|
||||
): ArtifactCategory => {
|
||||
if (!rawType) return "other";
|
||||
|
||||
const typeValue = rawType.toLowerCase().trim();
|
||||
|
||||
// 后端返回规范化的值
|
||||
if (typeValue === "chart" || typeValue === "image" || typeValue === "other") {
|
||||
return typeValue as ArtifactCategory;
|
||||
}
|
||||
|
||||
return "other";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user