This commit is contained in:
mangomqy
2025-11-13 02:54:06 +00:00
commit c5e51ed069
254 changed files with 54901 additions and 0 deletions

View File

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

View File

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

View File

@ -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&#10;nested:&#10; 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;
};