refactor: full-stack restructure with multi-tenancy, workspace management, and K8s diagnostics

- Add Workspace domain (entity, repository, service, handler, DTO)
- Add multi-tenant K8s client with tenant binding and quota management
- Add K8s diagnostics client (instance diagnostics)
- Add authorization middleware (authz package)
- Restructure frontend to feature-based architecture (features/)
- Add User Management page in configuration
- Add AccessDenied page and route guards
- Refactor shared components (form inputs, layout, UI)
- Update Tailwind config for new design system
- Add comprehensive documentation (docs/, tasks/, plans)
- Improve cluster service with better kubeconfig handling
- Add tests for crypto, config, helm client, tenant binding
This commit is contained in:
Ivan087
2026-05-12 16:15:14 +08:00
parent c5e51ed069
commit 7f238a3168
172 changed files with 15703 additions and 3162 deletions

View File

@ -0,0 +1,244 @@
import React, { useEffect, useMemo, useState } from "react";
import { Activity, AlertTriangle, Box, Copy, FileText, RotateCw, Server, Terminal, X } from "lucide-react";
import { getInstanceDiagnostics, type InstanceDiagnosticsResponse, type InstanceResponse } from "@/api";
import { Button, Badge, LoadingState } from "@/shared/components";
import { formatApiError } from "@/shared/utils";
import { useToast } from "@/shared";
type TabKey = "summary" | "events" | "logs";
interface DiagnosticsModalProps {
instance: InstanceResponse;
onClose: () => void;
}
export const DiagnosticsModal: React.FC<DiagnosticsModalProps> = ({ instance, onClose }) => {
const { success, error: toastError } = useToast();
const [data, setData] = useState<InstanceDiagnosticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabKey>("summary");
const loadDiagnostics = async () => {
if (!instance.clusterId || !instance.id) return;
setLoading(true);
try {
setData(await getInstanceDiagnostics({ clusterId: instance.clusterId, instanceId: instance.id }, { tailLines: 300 }));
} catch (err) {
toastError(formatApiError(err) || "Failed to load diagnostics");
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadDiagnostics();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [instance.clusterId, instance.id]);
const combinedLogs = useMemo(
() =>
(data?.logs ?? [])
.map((entry) => `# ${entry.pod || "pod"} / ${entry.container || "container"}\n${entry.error || entry.log || ""}`)
.join("\n\n"),
[data?.logs]
);
const copyLogs = async () => {
await navigator.clipboard.writeText(combinedLogs);
success("Logs copied");
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 p-4 backdrop-blur-sm">
<div className="flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl">
<div className="flex items-start justify-between border-b border-slate-200 px-6 py-5">
<div>
<div className="flex items-center gap-2 text-sm font-semibold text-blue-700">
<Activity className="h-4 w-4" />
Runtime diagnostics
</div>
<h2 className="mt-1 text-xl font-semibold text-slate-950">{instance.name}</h2>
<p className="mt-1 text-sm text-slate-500">
{instance.namespace} · {data?.collectedAt ? new Date(data.collectedAt).toLocaleString() : "live Kubernetes data"}
</p>
</div>
<div className="flex items-center gap-2">
<Button type="button" variant="secondary" size="sm" icon={RotateCw} onClick={loadDiagnostics} loading={loading}>
Refresh
</Button>
<button onClick={onClose} className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-900">
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="border-b border-slate-200 px-6 py-3">
<div className="flex flex-wrap gap-2">
<TabButton active={activeTab === "summary"} onClick={() => setActiveTab("summary")} icon={Box} label="Describe" />
<TabButton active={activeTab === "events"} onClick={() => setActiveTab("events")} icon={AlertTriangle} label="Events" />
<TabButton active={activeTab === "logs"} onClick={() => setActiveTab("logs")} icon={Terminal} label="Pod Logs" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<LoadingState message="Loading Kubernetes diagnostics..." />
) : !data ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
Diagnostics data is not available.
</div>
) : activeTab === "summary" ? (
<SummaryTab data={data} />
) : activeTab === "events" ? (
<EventsTab data={data} />
) : (
<LogsTab data={data} combinedLogs={combinedLogs} onCopy={copyLogs} />
)}
</div>
</div>
</div>
);
};
const TabButton: React.FC<{
active: boolean;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}> = ({ active, icon: Icon, label, onClick }) => (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
active ? "bg-blue-600 text-white shadow-sm" : "bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-950"
}`}
>
<Icon className="h-4 w-4" />
{label}
</button>
);
const SummaryTab = ({ data }: { data: InstanceDiagnosticsResponse }) => (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-3">
<MetricCard icon={Box} label="Pods" value={data.pods?.length ?? 0} />
<MetricCard icon={Server} label="Services" value={data.services?.length ?? 0} />
<MetricCard icon={AlertTriangle} label="Events" value={data.events?.length ?? 0} />
</div>
<section className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">Pods</h3>
{(data.pods ?? []).length === 0 ? (
<EmptyLine text="No pods matched this Helm release." />
) : (
(data.pods ?? []).map((pod) => (
<div key={pod.name} className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h4 className="font-mono text-sm font-semibold text-slate-950">{pod.name}</h4>
<p className="mt-1 text-xs text-slate-500">
{pod.nodeName || "unscheduled"} · podIP {pod.podIp || "-"} · restarts {pod.restartCount ?? 0}
</p>
</div>
<Badge variant={pod.phase === "Running" ? "success" : "warning"} size="sm">
{pod.phase || "Unknown"}
</Badge>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-2">
{(pod.containers ?? []).map((container) => (
<div key={container.name} className="rounded-md border border-slate-200 bg-white p-3">
<div className="flex items-center justify-between">
<span className="font-medium text-slate-900">{container.name}</span>
<Badge variant={container.ready ? "success" : "warning"} size="sm">
{container.state || "unknown"}
</Badge>
</div>
<p className="mt-1 truncate font-mono text-xs text-slate-500" title={container.image}>
{container.image}
</p>
{(container.reason || container.message) && (
<p className="mt-2 text-xs text-amber-700">{container.reason || container.message}</p>
)}
</div>
))}
</div>
</div>
))
)}
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">Services</h3>
{(data.services ?? []).length === 0 ? <EmptyLine text="No services matched this Helm release." /> : null}
{(data.services ?? []).map((svc) => (
<div key={svc.name} className="rounded-lg border border-slate-200 bg-white p-4">
<div className="flex items-center justify-between">
<span className="font-mono text-sm font-semibold text-slate-950">{svc.name}</span>
<Badge variant="secondary" size="sm">{svc.type}</Badge>
</div>
<p className="mt-1 text-xs text-slate-500">ClusterIP {svc.clusterIP || "-"}</p>
<div className="mt-2 flex flex-wrap gap-2">
{(svc.ports ?? []).map((port) => (
<span key={`${port.name}-${port.port}`} className="rounded bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{port.name || "port"} {port.port}:{port.targetPort}
</span>
))}
</div>
</div>
))}
</section>
</div>
);
const EventsTab = ({ data }: { data: InstanceDiagnosticsResponse }) => (
<div className="space-y-3">
{(data.events ?? []).length === 0 ? <EmptyLine text="No Kubernetes events matched this release." /> : null}
{(data.events ?? []).map((event, index) => (
<div key={`${event.involvedName}-${event.reason}-${index}`} className="rounded-lg border border-slate-200 bg-white p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Badge variant={event.type === "Warning" ? "warning" : "secondary"} size="sm">{event.type || "Normal"}</Badge>
<span className="font-semibold text-slate-950">{event.reason}</span>
</div>
<span className="text-xs text-slate-500">{event.lastTimestamp ? new Date(event.lastTimestamp).toLocaleString() : ""}</span>
</div>
<p className="mt-2 text-sm text-slate-700">{event.message}</p>
<p className="mt-2 text-xs text-slate-500">
{event.involvedKind}/{event.involvedName} · count {event.count ?? 1}
</p>
</div>
))}
</div>
);
const LogsTab = ({ data, combinedLogs, onCopy }: { data: InstanceDiagnosticsResponse; combinedLogs: string; onCopy: () => void }) => (
<div className="space-y-4">
<div className="flex justify-end">
<Button type="button" variant="secondary" size="sm" icon={Copy} onClick={onCopy} disabled={!combinedLogs}>
Copy Logs
</Button>
</div>
{(data.logs ?? []).length === 0 ? <EmptyLine text="No pod logs were returned." /> : null}
{(data.logs ?? []).map((entry) => (
<div key={`${entry.pod}-${entry.container}`} className="overflow-hidden rounded-lg border border-slate-800 bg-slate-950">
<div className="flex items-center gap-2 border-b border-slate-800 px-4 py-2 text-xs text-slate-300">
<FileText className="h-3.5 w-3.5" />
<span className="font-mono">{entry.pod}/{entry.container}</span>
</div>
<pre className="max-h-96 overflow-auto p-4 text-xs leading-5 text-slate-100">{entry.error || entry.log || ""}</pre>
</div>
))}
</div>
);
const MetricCard = ({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: number }) => (
<div className="rounded-lg border border-slate-200 bg-white p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-500">{label}</span>
<Icon className="h-5 w-5 text-blue-600" />
</div>
<p className="mt-2 text-3xl font-semibold text-slate-950">{value}</p>
</div>
);
const EmptyLine = ({ text }: { text: string }) => (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-slate-500">{text}</div>
);

View File

@ -321,7 +321,7 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
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" },
none: { color: "bg-slate-200/20 text-slate-500 border-gray-500/30", label: "No Data Available" },
};
const badge = badges[source as keyof typeof badges] || badges.none;
@ -335,11 +335,11 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
};
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 key={service.name || `service-${index}`} className="bg-slate-50 border border-slate-200 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>
<h4 className="text-sm font-semibold text-slate-900">{service.name || `Service ${index + 1}`}</h4>
<p className="text-xs text-slate-500 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'}
@ -349,18 +349,18 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
<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 justify-between bg-slate-50 rounded p-2">
<span className="text-xs text-slate-500">Cluster IP:</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-white">{service.cluster_ip}</span>
<span className="text-sm font-mono text-slate-900">{service.cluster_ip}</span>
<button
onClick={() => copyToClipboard(service.cluster_ip!, "Cluster IP")}
className="p-1 hover:bg-gray-700 rounded transition"
className="p-1 hover:bg-slate-100 rounded transition"
>
{copiedText === service.cluster_ip ? (
<CheckCircle className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3 text-gray-400" />
<Copy className="w-3 h-3 text-slate-500" />
)}
</button>
</div>
@ -369,10 +369,10 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
{/* 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 key={idx} className="flex items-center justify-between bg-slate-50 rounded p-2">
<span className="text-xs text-slate-500">{port.name || `Port ${idx + 1}`}:</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-white">
<span className="text-sm font-mono text-slate-900">
{port.port} {port.target_port} {port.protocol || 'TCP'}
{port.node_port && ` (NodePort: ${port.node_port})`}
</span>
@ -386,7 +386,7 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
<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">
<span className="text-sm font-mono text-slate-900">
{ing.ip || ing.hostname}
</span>
<div className="flex items-center gap-2">
@ -396,19 +396,19 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
href={`http://${ing.ip}:${service.ports?.[0]?.port || 80}`}
target="_blank"
rel="noopener noreferrer"
className="p-1 hover:bg-gray-700 rounded transition"
className="p-1 hover:bg-slate-100 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"
className="p-1 hover:bg-slate-100 rounded transition"
>
{copiedText === ing.ip ? (
<CheckCircle className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3 text-gray-400" />
<Copy className="w-3 h-3 text-slate-500" />
)}
</button>
</>
@ -423,12 +423,12 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
);
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 key={ingress.name || `ingress-${index}`} className="bg-slate-50 border border-slate-200 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>
<h4 className="text-sm font-semibold text-slate-900">{ingress.name || `Ingress ${index + 1}`}</h4>
{ingress.class_name && (
<p className="text-xs text-gray-400 mt-1">Class: {ingress.class_name}</p>
<p className="text-xs text-slate-500 mt-1">Class: {ingress.class_name}</p>
)}
</div>
<Globe className="w-5 h-5 text-purple-400" />
@ -436,30 +436,30 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
<div className="space-y-2">
{ingress.rules?.map((rule, ruleIdx) => (
<div key={ruleIdx} className="bg-gray-900/50 rounded p-3 space-y-2">
<div key={ruleIdx} className="bg-slate-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>
<span className="text-sm font-medium text-slate-900">{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"
className="p-1 hover:bg-slate-100 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"
className="p-1 hover:bg-slate-100 rounded transition"
>
{copiedText === host ? (
<CheckCircle className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3 text-gray-400" />
<Copy className="w-3 h-3 text-slate-500" />
)}
</button>
</div>
@ -470,7 +470,7 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
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">
<div key={pathIdx} className="text-xs text-slate-500 ml-4">
{path.path || '/'} {serviceName}:{servicePort}
</div>
);
@ -489,20 +489,20 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
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">
<div className="bg-white border border-slate-200 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 className="flex items-center justify-between p-6 border-b border-slate-200">
<div>
<h2 className="text-xl font-semibold text-white">Instance Entries</h2>
<p className="text-sm text-gray-400 mt-1">
<h2 className="text-xl font-semibold text-slate-900">Instance Entries</h2>
<p className="text-sm text-slate-500 mt-1">
{instance.name} ({instance.namespace})
</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-800 rounded-lg transition"
className="p-2 hover:bg-white rounded-lg transition"
>
<X className="w-5 h-5 text-gray-400" />
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
@ -511,14 +511,14 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
{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>
<span className="ml-3 text-slate-500">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"
className="mt-4 px-4 py-2 bg-white text-slate-900 rounded-lg hover:bg-slate-100 transition"
>
Close
</button>
@ -527,7 +527,7 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
<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>
<h3 className="text-sm font-medium text-slate-500">Data Source:</h3>
{getSourceBadge(entries.source)}
</div>
@ -536,7 +536,7 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
<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">
<h3 className="text-lg font-semibold text-slate-900">
Services ({entries.services.length})
</h3>
</div>
@ -551,7 +551,7 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
<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">
<h3 className="text-lg font-semibold text-slate-900">
Ingresses ({entries.ingresses.length})
</h3>
</div>
@ -564,9 +564,9 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
{/* 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">
<h3 className="text-lg font-semibold text-slate-900 mb-3">Helm Notes</h3>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<pre className="text-xs text-slate-700 whitespace-pre-wrap font-mono">
{entries.notes}
</pre>
</div>
@ -579,8 +579,8 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
!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>
<p className="text-slate-500">No entries found for this instance</p>
<p className="text-xs text-slate-500 mt-2">Data source: {entries.source || 'unknown'}</p>
</div>
)}
</div>
@ -588,10 +588,10 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
</div>
{/* Footer */}
<div className="p-6 border-t border-gray-700 flex justify-end">
<div className="p-6 border-t border-slate-200 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition"
className="px-4 py-2 bg-white text-slate-900 rounded-lg hover:bg-slate-100 transition"
>
Close
</button>

View File

@ -12,6 +12,7 @@ import {
XCircle,
Clock,
Network,
Activity,
Box,
Calendar,
GitBranch,
@ -29,6 +30,7 @@ interface InstanceCardProps {
onTerminate: (instance: InstanceResponse) => void;
onRefresh: (instance: InstanceResponse) => void;
onViewEntries: (instance: InstanceResponse) => void;
onViewDiagnostics: (instance: InstanceResponse) => void;
}
type StatusVisual = {
@ -99,16 +101,16 @@ const STATUS_INFO_MAP: Record<InstanceStatus, StatusVisual> = {
},
[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",
color: "text-slate-700",
bg: "bg-gradient-to-r from-slate-500/20 to-gray-500/20 border-slate-300/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",
color: "text-slate-700",
bg: "bg-gradient-to-r from-slate-500/20 to-gray-500/20 border-slate-300/40",
glow: "shadow-slate-500/20",
label: "Unknown",
defaultReason: "Awaiting next state update.",
@ -136,6 +138,7 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
onTerminate,
onRefresh,
onViewEntries,
onViewDiagnostics,
}) => {
const normalizedStatus = (instance.status ?? INSTANCE_STATUS.unknown) as InstanceStatus;
const statusInfo =
@ -164,12 +167,12 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
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">
<div className="group relative bg-gradient-to-br from-white via-white to-slate-50 border border-slate-200 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="relative px-6 py-5 border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4 flex-1">
{/* Enhanced icon with glow effect */}
@ -179,12 +182,12 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
</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">
<h3 className="text-xl font-bold text-slate-950 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">
<Package className="w-4 h-4 text-slate-500" />
<p className="text-sm text-slate-500 font-mono">
{repository}
</p>
<span className="text-slate-600"></span>
@ -206,10 +209,10 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
</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>
<div className="mt-4 flex flex-col gap-1 text-sm text-slate-700">
<span className="font-medium text-slate-700">{statusReason}</span>
{lastOperationLabel && (
<span className="text-xs uppercase tracking-wide text-slate-400">
<span className="text-xs uppercase tracking-wide text-slate-500">
Operation: {lastOperationLabel}
</span>
)}
@ -217,48 +220,48 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
</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="relative px-6 py-5 space-y-4 bg-gradient-to-b from-white to-slate-50">
<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="p-3 bg-white border border-slate-200 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>
<p className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Namespace</p>
</div>
<p className="text-sm font-bold text-white">
<p className="text-sm font-bold text-slate-900">
{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="p-3 bg-white border border-slate-200 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>
<p className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Revision</p>
</div>
<p className="text-sm font-bold text-white">
<p className="text-sm font-bold text-slate-900">
{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="col-span-2 p-3 bg-white border border-slate-200 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>
<p className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Repository</p>
</div>
<p className="text-sm font-mono text-white truncate" title={repository}>
<p className="text-sm font-mono text-slate-900 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="col-span-2 p-3 bg-white border border-slate-200 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>
<p className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Launched</p>
</div>
<p className="text-sm font-bold text-white">
<p className="text-sm font-bold text-slate-900">
{createdAtText}
</p>
</div>
@ -267,7 +270,7 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
{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" />
<AlertTriangle className="w-5 h-5 text-rose-700" />
</div>
<div>
<p className="text-sm font-semibold text-rose-200">Last error</p>
@ -278,47 +281,51 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
</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">
<div className="relative px-6 py-4 bg-gradient-to-r from-slate-50 via-slate-50 to-white border-t border-slate-200 backdrop-blur-sm">
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-5">
<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"
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm font-semibold text-slate-700 transition-all duration-200 hover:border-slate-300 hover:bg-slate-100 hover:shadow-lg"
title="Refresh status"
>
<RefreshCw className="w-4 h-4 group-hover/btn:rotate-180 transition-transform duration-500" />
Refresh
<span className="truncate">Refresh</span>
</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"
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-emerald-500/40 bg-emerald-50 px-3 py-2.5 text-sm font-semibold text-emerald-700 transition-all duration-200 hover:border-emerald-500/60 hover:bg-emerald-100 hover:shadow-lg"
title="View service entries"
>
<Network className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
Entries
<span className="truncate">Entries</span>
</button>
<button
onClick={() => onViewDiagnostics(instance)}
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2.5 text-sm font-semibold text-indigo-700 transition-all duration-200 hover:border-indigo-300 hover:bg-indigo-100 hover:shadow-lg"
title="View describe, events, and pod logs"
>
<Activity className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
<span className="truncate">Diagnostics</span>
</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"
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-blue-500/40 bg-blue-50 px-3 py-2.5 text-sm font-semibold text-blue-700 transition-all duration-200 hover:border-blue-500/60 hover:bg-blue-100 hover:shadow-lg"
title="Modify instance configuration"
>
<Settings className="w-4 h-4 group-hover/btn:rotate-90 transition-transform duration-300" />
Modify
<span className="truncate">Modify</span>
</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"
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-rose-500/40 bg-red-50 px-3 py-2.5 text-sm font-semibold text-rose-700 transition-all duration-200 hover:border-rose-500/60 hover:bg-rose-100 hover:shadow-lg"
title="Terminate instance"
>
<StopCircle className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
Terminate
<span className="truncate">Delete</span>
</button>
</div>
</div>
</div>
</div>

View File

@ -5,6 +5,7 @@
*/
import React, { useState, useEffect } from "react";
import { Settings } from "lucide-react";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import type { InstanceResponse, UpdateInstanceRequest } from "@/api";
import { getValuesSchema } from "@/api";
import {
@ -13,7 +14,6 @@ import {
FormField,
Input,
Textarea,
Checkbox,
ErrorState,
LoadingState,
Badge,
@ -35,8 +35,6 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
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);
@ -58,7 +56,7 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
? JSON.parse(instance.values)
: instance.values;
setFormValues(parsedValues);
setValuesYaml(typeof parsedValues === 'object' ? JSON.stringify(parsedValues, null, 2) : String(parsedValues));
setValuesYaml(typeof parsedValues === 'object' ? stringifyYaml(parsedValues) : String(parsedValues));
} catch (err) {
console.error('[ModifyModal] Failed to parse existing values:', err);
setValuesYaml(String(instance.values) || "");
@ -104,8 +102,7 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
const handleFormValuesChange = (values: Record<string, any>) => {
setFormValues(values);
// Also update YAML representation
setValuesYaml(JSON.stringify(values, null, 2));
setValuesYaml(stringifyYaml(values));
};
const handleSubmit = async (e: React.FormEvent) => {
@ -116,7 +113,9 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
try {
const payload: UpdateInstanceRequest = {
version: tag && tag !== instance.version ? tag : undefined,
values: valuesYaml.trim() ? JSON.parse(valuesYaml) : undefined,
description: description.trim() || undefined,
values: valuesYaml.trim() ? parseValuesYaml(valuesYaml) : undefined,
valuesYaml: valuesYaml.trim() || undefined,
};
if (!instance.clusterId || !instance.id) {
@ -128,8 +127,8 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
await onConfirm(instance.clusterId, instance.id, payload);
onClose();
} catch (err: unknown) {
if (err instanceof SyntaxError) {
setError("Invalid JSON/YAML values. Please fix the configuration.");
if (err instanceof Error && err.message.includes("YAML")) {
setError(err.message);
} else {
setError((err as Error).message || "Failed to modify instance");
}
@ -144,7 +143,7 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
onClose={onClose}
title={`Modify Instance - ${instance.name || "Unnamed"}`}
icon={Settings}
iconColor="text-blue-400"
iconColor="text-blue-600"
size="lg"
footer={
<>
@ -175,15 +174,15 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
)}
{/* 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"}
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 space-y-2">
<p className="text-sm text-slate-700">
<span className="font-medium text-slate-900">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 className="text-sm text-slate-700">
<span className="font-medium text-slate-900">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 className="text-sm text-slate-700">
<span className="font-medium text-slate-900">Repository:</span> {instance.repository || "N/A"}
</p>
</div>
@ -215,12 +214,13 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
{/* Values Configuration */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-200">
<label className="block text-sm font-medium text-slate-700">
Configuration Values
</label>
{valuesSchema?.properties && (
<div className="flex gap-2">
<button
type="button"
onClick={() => setInputMethod('form')}
className="cursor-pointer"
>
@ -232,6 +232,7 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
</Badge>
</button>
<button
type="button"
onClick={() => setInputMethod('yaml')}
className="cursor-pointer"
>
@ -265,25 +266,9 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
)}
</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>
<p className="text-xs text-slate-500">
Update applies the selected chart version and values override. Resource readiness is tracked from the instance list after submit.
</p>
</form>
</Modal>
);
@ -324,3 +309,14 @@ const extractJsonSchema = (schemaResponse: unknown): JsonSchema | null => {
return null;
};
const parseValuesYaml = (source: string): Record<string, any> => {
const parsed = parseYaml(source);
if (parsed == null) {
return {};
}
if (typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Values YAML must be an object");
}
return parsed as Record<string, any>;
};

View File

@ -20,6 +20,7 @@ import { InstanceErrors, SuccessMessages, formatApiError } from "@/shared/utils"
import { InstanceCard } from "../components/InstanceCard";
import { ModifyModal } from "../components/ModifyModal";
import { EntriesModal } from "../components/EntriesModal";
import { DiagnosticsModal } from "../components/DiagnosticsModal";
import { globalCache } from "@/shared/services/artifact-cache";
const AUTO_REFRESH_INTERVAL_MS = 30000;
@ -47,6 +48,7 @@ const InstancesManagementPage: React.FC = () => {
// Modals
const [modifyInstance, setModifyInstance] = useState<Instance | null>(null);
const [entriesInstance, setEntriesInstance] = useState<Instance | null>(null);
const [diagnosticsInstance, setDiagnosticsInstance] = useState<Instance | null>(null);
// 核心数据加载函数 - 使用全局缓存
const loadDataCore = useCallback(async (options: LoadDataOptions = {}) => {
@ -225,6 +227,10 @@ const InstancesManagementPage: React.FC = () => {
setEntriesInstance(instance);
}, []);
const handleViewDiagnostics = useCallback((instance: Instance) => {
setDiagnosticsInstance(instance);
}, []);
const handleModifyConfirm = useCallback(async (
clusterId: string,
instanceId: string,
@ -333,43 +339,40 @@ const InstancesManagementPage: React.FC = () => {
<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 group overflow-hidden bg-white border border-blue-100 rounded-lg p-6 hover:border-blue-200 hover:shadow-md transition-all duration-300">
<div className="relative flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-blue-300 uppercase tracking-wider mb-2">Total Instances</p>
<p className="text-4xl font-bold text-white">{totalInstances}</p>
<p className="text-sm font-semibold text-blue-700 uppercase tracking-wider mb-2">Total Instances</p>
<p className="text-4xl font-bold text-slate-900">{totalInstances}</p>
</div>
<div className="p-4 bg-blue-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 className="p-4 bg-blue-50 rounded-lg border border-blue-100">
<Package className="w-8 h-8 text-blue-600" />
</div>
</div>
</div>
<div className="relative group overflow-hidden bg-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 group overflow-hidden bg-white border border-emerald-100 rounded-lg p-6 hover:border-emerald-200 hover:shadow-md transition-all duration-300">
<div className="relative flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-emerald-300 uppercase tracking-wider mb-2">Clusters</p>
<p className="text-4xl font-bold text-white">{clusters.length}</p>
<p className="text-sm font-semibold text-emerald-700 uppercase tracking-wider mb-2">Clusters</p>
<p className="text-4xl font-bold text-slate-900">{clusters.length}</p>
</div>
<div className="p-4 bg-emerald-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 className="p-4 bg-emerald-50 rounded-lg border border-emerald-100">
<Server className="w-8 h-8 text-emerald-600" />
</div>
</div>
</div>
{/* Only show Filtered when there are multiple clusters */}
{clusters.length > 1 && (
<div className="relative group overflow-hidden bg-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 group overflow-hidden bg-white border border-violet-100 rounded-lg p-6 hover:border-violet-200 hover:shadow-md transition-all duration-300">
<div className="relative flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-purple-300 uppercase tracking-wider mb-2">Showing</p>
<p className="text-4xl font-bold text-white">{filteredInstances.length}</p>
<p className="text-sm font-semibold text-violet-700 uppercase tracking-wider mb-2">Showing</p>
<p className="text-4xl font-bold text-slate-900">{filteredInstances.length}</p>
</div>
<div className="p-4 bg-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 className="p-4 bg-violet-50 rounded-lg border border-violet-100">
<Boxes className="w-8 h-8 text-violet-600" />
</div>
</div>
</div>
@ -379,13 +382,13 @@ const InstancesManagementPage: React.FC = () => {
{/* 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="mb-6 p-5 bg-white border border-slate-200 rounded-lg shadow-sm">
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-cyan-500/20 to-blue-500/20 rounded-lg border border-cyan-500/30">
<Server className="w-5 h-5 text-cyan-400" />
</div>
<label className="text-sm font-semibold text-slate-300">
<label className="text-sm font-semibold text-slate-700">
Filter by Cluster:
</label>
</div>
@ -441,10 +444,10 @@ const InstancesManagementPage: React.FC = () => {
<Server className="w-5 h-5 text-emerald-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">
<h2 className="text-xl font-bold text-slate-900">
{cluster.name || "Unnamed Cluster"}
</h2>
<p className="text-sm text-slate-400 mt-0.5">
<p className="text-sm text-slate-500 mt-0.5">
{instances.length} {instances.length === 1 ? 'instance' : 'instances'} running
</p>
</div>
@ -458,6 +461,7 @@ const InstancesManagementPage: React.FC = () => {
onTerminate={handleTerminate}
onRefresh={handleRefresh}
onViewEntries={handleViewEntries}
onViewDiagnostics={handleViewDiagnostics}
/>
))}
</div>
@ -474,6 +478,7 @@ const InstancesManagementPage: React.FC = () => {
onTerminate={handleTerminate}
onRefresh={handleRefresh}
onViewEntries={handleViewEntries}
onViewDiagnostics={handleViewDiagnostics}
/>
))}
</div>
@ -497,6 +502,13 @@ const InstancesManagementPage: React.FC = () => {
onClose={() => setEntriesInstance(null)}
/>
)}
{diagnosticsInstance && (
<DiagnosticsModal
instance={diagnosticsInstance}
onClose={() => setDiagnosticsInstance(null)}
/>
)}
</div>
);
};

View File

@ -4,10 +4,13 @@
* Supports Values Schema for dynamic form generation
*/
import React, { useState, useEffect } from "react";
import { Rocket, AlertCircle, FileCode, FormInput } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Rocket, AlertCircle, FileCode, FormInput, Sparkles, SlidersHorizontal } from "lucide-react";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { useToast } from "@/shared";
import { createInstance, listClusters, getValuesSchema } from "@/api";
import { createInstance, listClusters, getValuesSchema, getValuesYaml } from "@/api";
import type { CreateInstanceRequest, ClusterResponse } from "@/api";
import { useAuth } from "@/app/providers";
import { ClusterErrors, InstanceErrors, SuccessMessages, ValidationErrors, formatApiError } from "@/shared/utils";
import {
Modal,
@ -23,6 +26,24 @@ import {
import type { ArtifactCategory } from "../utils/artifactType";
import type { JsonSchema } from "@/shared/components/form/SchemaFormGenerator";
type NamespacePolicyValue =
| string
| {
allowedNamespaces?: string[];
defaultNamespace?: string;
namespace?: string;
readOnly?: boolean;
readonly?: boolean;
};
type ClusterWithNamespacePolicy = ClusterResponse & {
allowedNamespaces?: string[];
namespacePolicy?: NamespacePolicyValue;
namespaceReadOnly?: boolean;
namespaceReadonly?: boolean;
defaultNamespace?: string;
};
interface LaunchModalProps {
isOpen: boolean;
onClose: () => void;
@ -40,29 +61,52 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
tag,
artifactType,
}) => {
const navigate = useNavigate();
const { user } = useAuth();
const { success, error: toastError, info: toastInfo } = useToast();
const [clusters, setClusters] = useState<ClusterResponse[]>([]);
const [clusters, setClusters] = useState<ClusterWithNamespacePolicy[]>([]);
const [loadingClusters, setLoadingClusters] = useState(false);
// Form fields
const [clusterId, setClusterId] = useState("");
const [namespace, setNamespace] = useState("default");
const [namespace, setNamespace] = useState(user?.namespace || "default");
const [instanceName, setInstanceName] = useState("");
const [description, setDescription] = useState("");
// Values Schema support
const [valuesSchema, setValuesSchema] = useState<JsonSchema | null>(null);
const [defaultValuesYaml, setDefaultValuesYaml] = useState("");
const [loadingSchema, setLoadingSchema] = useState(false);
const [inputMethod, setInputMethod] = useState<'form' | 'yaml'>('yaml');
const [inputMethod, setInputMethod] = useState<'quick' | 'form' | 'yaml'>('quick');
const [valuesForm, setValuesForm] = useState<Record<string, any>>({});
const [valuesYaml, setValuesYaml] = useState("");
const [yamlError, setYamlError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const schemaDefaults = React.useMemo(
() => valuesSchema ? extractSchemaDefaults(valuesSchema) : {},
[valuesSchema]
);
const schemaDefaultCount = React.useMemo(
() => countLeafValues(schemaDefaults),
[schemaDefaults]
);
const isVllmChart = /(^|[/_-])vllm([/_-]|$)|vllm-serve/i.test(repositoryName);
const selectedCluster = React.useMemo(
() => clusters.find((cluster) => cluster.id === clusterId),
[clusters, clusterId]
);
const namespaceAccess = React.useMemo(
() => getNamespaceAccess(selectedCluster, user?.namespace),
[selectedCluster, user?.namespace]
);
// Load clusters and schema on mount
useEffect(() => {
if (isOpen) {
loadClusters();
loadValuesSchema();
loadDefaultValuesYaml();
}
}, [isOpen, registryId, repositoryName, tag]);
@ -70,10 +114,13 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
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);
const normalizedClusters = data as ClusterWithNamespacePolicy[];
setClusters(normalizedClusters);
const preferredCluster =
normalizedClusters.find((cluster) => cluster.id && cluster.id === user?.defaultClusterId) ??
normalizedClusters.find((cluster) => typeof cluster.id === "string" && cluster.id.length > 0);
if (preferredCluster?.id) {
setClusterId(preferredCluster.id);
} else {
setClusterId("");
}
@ -85,6 +132,16 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
}
};
const loadDefaultValuesYaml = async () => {
try {
const response = await getValuesYaml({ registryId, repositoryName, reference: tag });
setDefaultValuesYaml(typeof response.valuesYaml === "string" ? response.valuesYaml : "");
} catch (err) {
console.error("[LaunchModal] Failed to load chart values.yaml:", err);
setDefaultValuesYaml("");
}
};
const loadValuesSchema = async () => {
setLoadingSchema(true);
try {
@ -93,16 +150,13 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
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');
console.log('[LaunchModal] No values schema available; keeping quick launch with optional YAML overrides');
}
} catch (err) {
console.error('[LaunchModal] Failed to load values schema:', err);
setValuesSchema(null);
setInputMethod('yaml');
} finally {
setLoadingSchema(false);
}
@ -112,13 +166,32 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
setInstanceName("");
setDescription("");
setValuesYaml("");
setYamlError(null);
setValuesForm({});
setNamespace("default");
setNamespace(user?.namespace || "default");
setInputMethod("quick");
};
const handleSubmit = (e: React.FormEvent) => {
useEffect(() => {
if (!selectedCluster) {
return;
}
const access = getNamespaceAccess(selectedCluster, user?.namespace);
if (access.defaultNamespace && namespace !== access.defaultNamespace) {
setNamespace(access.defaultNamespace);
} else if (!access.defaultNamespace && user?.namespace && namespace !== user.namespace) {
setNamespace(user.namespace);
}
}, [selectedCluster, namespace, user?.namespace]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (artifactType !== "chart") {
toastError("Only Helm chart artifacts can be launched.");
return;
}
if (!clusterId) {
toastError(ValidationErrors.REQUIRED_FIELD("Cluster"));
return;
@ -133,15 +206,27 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
toastError(ValidationErrors.REQUIRED_FIELD("Namespace"));
return;
}
if (
namespaceAccess.allowedNamespaces.length > 0 &&
!namespaceAccess.allowedNamespaces.includes(namespace.trim())
) {
toastError("Selected namespace is not allowed for this cluster.");
return;
}
let valuesObj: Record<string, any> = {};
let normalizedValuesYaml = "";
if (inputMethod === "form" && Object.keys(valuesForm).length > 0) {
valuesObj = valuesForm;
} else if (valuesYaml.trim()) {
valuesObj = pruneEmptyValues(valuesForm);
} else if (inputMethod === "yaml" && valuesYaml.trim()) {
try {
valuesObj = JSON.parse(valuesYaml.trim());
} catch {
toastError("Invalid YAML format. Please check your values.");
valuesObj = parseValuesYaml(valuesYaml);
normalizedValuesYaml = valuesYaml.trim();
} catch (err) {
console.error(err);
const message = "Invalid YAML format. Please check your values.";
setYamlError(message);
toastError(message);
return;
}
}
@ -152,7 +237,9 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
registryId,
repository: repositoryName,
tag,
...(description.trim() ? { description: description.trim() } : {}),
...(Object.keys(valuesObj).length > 0 ? { values: valuesObj } : {}),
...(normalizedValuesYaml ? { valuesYaml: normalizedValuesYaml } : {}),
};
toastInfo("Launching instance...", {
@ -161,17 +248,19 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
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);
});
setSubmitting(true);
try {
await createInstance({ clusterId }, request);
success(SuccessMessages.INSTANCE_DEPLOYED);
resetFormState();
onClose();
navigate("/artifact/instances");
} catch (err) {
toastError(formatApiError(err) || InstanceErrors.DEPLOY_FAILED);
console.error(err);
} finally {
setSubmitting(false);
}
};
if (!isOpen) return null;
@ -198,7 +287,8 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
variant="success"
icon={Rocket}
onClick={handleSubmit}
disabled={clusters.length === 0}
loading={submitting}
disabled={clusters.length === 0 || artifactType !== "chart" || submitting || Boolean(yamlError)}
>
Launch
</Button>
@ -206,7 +296,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
}
>
<div className="space-y-1 mb-4">
<p className="text-sm text-gray-400">
<p className="text-sm text-slate-500">
{repositoryName}:{tag}
</p>
</div>
@ -215,9 +305,9 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
{/* Cluster Selection */}
<FormField label="Target Cluster" required>
{loadingClusters ? (
<div className="text-sm text-gray-500">Loading clusters...</div>
<div className="text-sm text-slate-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">
<div className="flex items-center gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm">
<AlertCircle className="w-4 h-4" />
<span>No clusters available. Please add a cluster first.</span>
</div>
@ -254,13 +344,33 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
{/* Namespace */}
<FormField label="Namespace" required>
<Input
type="text"
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
placeholder="default"
required
/>
{namespaceAccess.allowedNamespaces.length > 0 ? (
<DropdownSelect
value={namespace}
onChange={(value) => setNamespace(value)}
options={namespaceAccess.allowedNamespaces.map((allowedNamespace) => ({
value: allowedNamespace,
label: allowedNamespace,
}))}
placeholder="Select a namespace"
required
/>
) : (
<Input
type="text"
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
placeholder="default"
required
disabled={namespaceAccess.readOnly}
/>
)}
{namespaceAccess.readOnly && (
<div className="mt-2 flex items-center gap-2 rounded-md border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-700">
<AlertCircle className="h-3.5 w-3.5" />
Namespace is controlled by your workspace policy.
</div>
)}
</FormField>
{/* Description */}
@ -275,83 +385,174 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
{/* Values Configuration */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-300">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label className="block text-sm font-medium text-slate-700">
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 className="flex gap-1 rounded-md border border-slate-200 bg-slate-50 p-1">
<ModeButton active={inputMethod === "quick"} onClick={() => setInputMethod("quick")} icon={Sparkles} label="Quick" />
<ModeButton active={inputMethod === "form"} onClick={() => setInputMethod("form")} icon={FormInput} label="Guided" disabled={!valuesSchema?.properties} />
<ModeButton active={inputMethod === "yaml"} onClick={() => setInputMethod("yaml")} icon={FileCode} label="YAML" />
</div>
</div>
{loadingSchema ? (
<LoadingState message="Loading configuration schema..." size="sm" />
) : inputMethod === 'quick' ? (
<div className="rounded-lg border border-blue-100 bg-blue-50/70 p-4">
<div className="flex items-start gap-3">
<Sparkles className="mt-0.5 h-5 w-5 text-blue-600" />
<div className="space-y-2 text-sm text-slate-700">
<p className="font-medium text-slate-900">
Quick launch uses the chart defaults.
</p>
<p>
{isVllmChart
? "For vLLM charts, this keeps the one-click path stable while image/model/resource overrides stay in Guided or YAML."
: "Use Guided or YAML only when this release needs explicit overrides."}
</p>
<div className="flex flex-wrap gap-2 pt-1">
<Badge variant="info" size="sm">No values override</Badge>
{defaultValuesYaml && (
<Badge variant="secondary" size="sm">
Chart values.yaml available
</Badge>
)}
{valuesSchema?.properties && (
<Badge variant="secondary" size="sm">
{schemaDefaultCount} schema defaults detected
</Badge>
)}
</div>
{defaultValuesYaml && (
<div className="pt-2">
<Button
type="button"
variant="secondary"
size="sm"
icon={FileCode}
onClick={() => {
setValuesYaml(defaultValuesYaml);
setYamlError(null);
setInputMethod("yaml");
}}
>
Load Defaults from values.yaml
</Button>
</div>
)}
</div>
</div>
</div>
) : 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 className="space-y-3">
{schemaDefaultCount > 0 && (
<div className="flex justify-end">
<Button
type="button"
variant="secondary"
size="sm"
icon={SlidersHorizontal}
onClick={() => setValuesForm(schemaDefaults)}
>
Load Defaults
</Button>
</div>
)}
<div className="border border-slate-200 rounded-lg p-4 max-h-96 overflow-y-auto bg-slate-50">
<SchemaFormGenerator
schema={valuesSchema}
values={valuesForm}
onChange={setValuesForm}
/>
</div>
</div>
) : (
<FormField
help={valuesSchema
? "Optional: Override default values with custom YAML configuration"
? "Optional: advanced YAML overrides. Invalid YAML is blocked before submit."
: "Optional: Chart does not provide a schema. Enter YAML configuration manually."
}
>
<div className="mb-2 flex flex-wrap justify-end gap-2">
{defaultValuesYaml && (
<Button
type="button"
variant="secondary"
size="sm"
icon={FileCode}
onClick={() => {
setValuesYaml(defaultValuesYaml);
setYamlError(null);
}}
>
Load Defaults from values.yaml
</Button>
)}
{!defaultValuesYaml && schemaDefaultCount > 0 && (
<Button
type="button"
variant="secondary"
size="sm"
icon={SlidersHorizontal}
onClick={() => {
setValuesYaml(stringifyYaml(schemaDefaults));
setYamlError(null);
}}
>
Load Schema Defaults
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setValuesYaml("");
setYamlError(null);
}}
>
Clear
</Button>
</div>
<Textarea
value={valuesYaml}
onChange={(e) => setValuesYaml(e.target.value)}
onChange={(e) => {
const next = e.target.value;
setValuesYaml(next);
if (!next.trim()) {
setYamlError(null);
return;
}
try {
parseValuesYaml(next);
setYamlError(null);
} catch (err) {
setYamlError(err instanceof Error ? err.message : "Invalid YAML");
}
}}
placeholder="# Enter custom values in YAML format&#10;# Example:&#10;# replicaCount: 3&#10;# image:&#10;# repository: myapp&#10;# tag: latest"
rows={8}
className="font-mono text-sm"
error={yamlError || undefined}
/>
</FormField>
)}
</div>
{/* Artifact Info */}
<div className="p-4 bg-gray-800/50 border border-gray-700 rounded-lg space-y-2">
<div className="p-4 bg-slate-50 border border-slate-200 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>
<span className="text-slate-500">Repository:</span>
<span className="text-slate-900 font-mono">{repositoryName}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Tag:</span>
<span className="text-slate-500">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>
<span className="text-slate-500">Type:</span>
<span className="text-slate-900">{artifactType}</span>
</div>
</div>
</form>
@ -362,6 +563,28 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
const isJsonSchemaObject = (value: unknown): value is JsonSchema =>
typeof value === "object" && value !== null && !Array.isArray(value);
const ModeButton: React.FC<{
active: boolean;
disabled?: boolean;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}> = ({ active, disabled = false, icon: Icon, label, onClick }) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`flex items-center gap-2 rounded px-3 py-1.5 text-xs font-medium transition ${
active
? "bg-white text-blue-700 shadow-sm ring-1 ring-slate-200"
: "text-slate-500 hover:text-slate-800 disabled:cursor-not-allowed disabled:opacity-40"
}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
);
const extractJsonSchema = (schemaResponse: unknown): JsonSchema | null => {
if (schemaResponse == null) {
return null;
@ -394,3 +617,111 @@ const extractJsonSchema = (schemaResponse: unknown): JsonSchema | null => {
return null;
};
const getNamespaceAccess = (cluster?: ClusterWithNamespacePolicy, userNamespace?: string) => {
const policy = cluster?.namespacePolicy;
const policyObject = isRecord(policy) ? policy : undefined;
const allowedNamespaces = uniqueStrings([
...(Array.isArray(cluster?.allowedNamespaces) ? cluster.allowedNamespaces : []),
...(Array.isArray(policyObject?.allowedNamespaces) ? policyObject.allowedNamespaces : []),
]);
const defaultNamespace =
firstString(cluster?.defaultNamespace, policyObject?.defaultNamespace, policyObject?.namespace) ??
allowedNamespaces[0] ??
userNamespace ??
"default";
const policyName = typeof policy === "string" ? policy.toLowerCase() : "";
const readOnly =
cluster?.namespaceReadOnly === true ||
cluster?.namespaceReadonly === true ||
policyObject?.readOnly === true ||
policyObject?.readonly === true ||
["readonly", "read_only", "allowed_only", "restricted"].includes(policyName) ||
allowedNamespaces.length > 0;
return {
allowedNamespaces,
defaultNamespace,
readOnly,
};
};
const uniqueStrings = (values: unknown[]): string[] =>
Array.from(
new Set(
values.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
)
);
const firstString = (...values: unknown[]): string | undefined =>
values.find((value): value is string => typeof value === "string" && value.trim().length > 0);
const parseValuesYaml = (source: string): Record<string, any> => {
const parsed = parseYaml(source);
if (parsed == null) {
return {};
}
if (typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Values YAML must be an object");
}
return parsed as Record<string, any>;
};
const extractSchemaDefaults = (schema: JsonSchema): Record<string, any> => {
if (schema.default !== undefined && isRecord(schema.default)) {
return schema.default as Record<string, any>;
}
const output: Record<string, any> = {};
for (const [key, property] of Object.entries(schema.properties ?? {})) {
const defaultValue = defaultForSchema(property);
if (defaultValue !== undefined) {
output[key] = defaultValue;
}
}
return output;
};
const defaultForSchema = (schema: JsonSchema): unknown => {
if (schema.default !== undefined) {
return schema.default;
}
if (schema.type === "object" && schema.properties) {
const nested: Record<string, unknown> = {};
for (const [key, property] of Object.entries(schema.properties)) {
const defaultValue = defaultForSchema(property);
if (defaultValue !== undefined) {
nested[key] = defaultValue;
}
}
return Object.keys(nested).length > 0 ? nested : undefined;
}
return undefined;
};
const pruneEmptyValues = (value: Record<string, any>): Record<string, any> => {
const output: Record<string, any> = {};
for (const [key, fieldValue] of Object.entries(value)) {
if (isRecord(fieldValue)) {
const nested = pruneEmptyValues(fieldValue as Record<string, any>);
if (Object.keys(nested).length > 0) {
output[key] = nested;
}
} else if (fieldValue !== "" && fieldValue !== undefined && fieldValue !== null) {
output[key] = fieldValue;
}
}
return output;
};
const countLeafValues = (value: unknown): number => {
if (!isRecord(value)) {
return value === undefined ? 0 : 1;
}
return Object.values(value).reduce<number>((sum, item) => sum + countLeafValues(item), 0);
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);

View File

@ -3,7 +3,7 @@
* Simple card for displaying a single tag/artifact
*/
import React, { useState } from "react";
import { Package, Rocket, Copy, HardDrive } from "lucide-react";
import { Box, Copy, File, HardDrive, Package, Rocket } from "lucide-react";
import { LaunchModal } from "./LaunchModal";
import { useToast } from "@/shared";
import type { ArtifactListItem } from "@/api";
@ -11,22 +11,30 @@ import { inferArtifactCategory, type ArtifactCategory } from "../utils/artifactT
interface TagCardProps {
registryId: string;
registryUrl?: string;
tag: ArtifactListItem;
}
export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
export const TagCard: React.FC<TagCardProps> = ({ registryId, registryUrl, tag }) => {
const { success } = useToast();
const [launchModalOpen, setLaunchModalOpen] = useState(false);
const category = inferArtifactCategory(tag);
const handleLaunch = () => {
if (category !== "chart") {
return;
}
setLaunchModalOpen(true);
};
const handleCopy = () => {
const tagName = tag.tag || '';
if (!tagName || !tag.repositoryName) return;
const pullCommand = `helm pull oci://${tag.repositoryName}:${tagName}`;
const registryHost = normalizeRegistryHost(registryUrl);
const repositoryPath = registryHost
? `${registryHost}/${tag.repositoryName}`
: tag.repositoryName;
const pullCommand = `helm pull oci://${repositoryPath} --version ${tagName}`;
navigator.clipboard.writeText(pullCommand);
success("Pull command copied to clipboard!");
};
@ -47,24 +55,25 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
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";
return "text-slate-500 bg-gray-500/10 border-gray-500/30";
}
};
const getTypeIcon = (type: ArtifactCategory) => {
const className = "w-5 h-5";
switch (type) {
case "chart":
return "📦";
return <Package className={className} />;
case "image":
return "🐳";
return <Box className={className} />;
default:
return "📄";
return <File className={className} />;
}
};
return (
<>
<div className="bg-dark-card border border-dark-border rounded-lg p-4 hover:border-brand-blue/50 transition-all group">
<div className="bg-white border border-slate-200 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">
@ -78,8 +87,8 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
<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">
<Package className="w-4 h-4 text-blue-600 flex-shrink-0" />
<h3 className="text-sm font-semibold text-slate-900 truncate">
{tag.tag || 'N/A'}
</h3>
<span
@ -91,12 +100,12 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
</div>
{/* Repository path */}
<p className="text-xs text-gray-500 truncate mb-2">
<p className="text-xs text-slate-500 truncate mb-2">
{tag.repositoryName}
</p>
{/* Size */}
<div className="flex items-center gap-2 text-xs text-gray-400">
<div className="flex items-center gap-2 text-xs text-slate-500">
<HardDrive className="w-3.5 h-3.5" />
<span>{formatSize(tag.size || 0)}</span>
</div>
@ -104,20 +113,22 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
{/* 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>
{category === "chart" && (
<button
onClick={handleLaunch}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded
text-xs font-medium transition-colors flex items-center gap-1.5"
title="Launch this Helm chart"
>
<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"
className="px-3 py-1.5 bg-white hover:bg-slate-50 text-slate-700
border border-slate-200 rounded text-xs transition-colors flex items-center gap-1.5"
title="Copy pull command"
>
<Copy className="w-3.5 h-3.5" />
@ -142,3 +153,11 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
);
};
const normalizeRegistryHost = (url?: string) => {
if (!url) return "";
try {
return new URL(url).host;
} catch {
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
}
};

View File

@ -48,10 +48,8 @@ interface RegistryNode {
}
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" },
{ value: undefined, label: "All tags" },
];
const ArtifactBrowserPage: React.FC = () => {
@ -67,7 +65,7 @@ const ArtifactBrowserPage: React.FC = () => {
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 [filter, setFilter] = useState<ListArtifactsFilter | undefined>("chart");
const [searchTerm, setSearchTerm] = useState("");
@ -143,7 +141,7 @@ const ArtifactBrowserPage: React.FC = () => {
try {
let repoNodes = globalCache.get<RepositoryNode[]>("repositories", registryId);
if (!repoNodes) {
const response = await listRepositories({ registryId });
const response = await listRepositories({ registryId }, { artifactType: "chart" });
repoNodes = normalizeRepositories(registry, response);
globalCache.set("repositories", repoNodes, registryId);
}
@ -255,17 +253,21 @@ const ArtifactBrowserPage: React.FC = () => {
? registryNodes.find((node) => node.registry.id === selectedRepository.registryId)?.registry
.name
: null;
const selectedRegistryUrl = selectedRepository
? registryNodes.find((node) => node.registry.id === selectedRepository.registryId)?.registry
.url
: undefined;
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-shrink-0 border-b border-slate-200 bg-white 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" />
<Package className="w-6 h-6 text-blue-600" />
<div>
<h1 className="text-xl font-semibold text-white">Artifact Browser</h1>
<p className="text-sm text-gray-400">
Browse registries, repositories, and artifacts
<h1 className="text-xl font-semibold text-slate-900">Chart Browser</h1>
<p className="text-sm text-slate-500">
Select a Harbor chart and launch it into a Kubernetes cluster
</p>
</div>
</div>
@ -280,23 +282,23 @@ const ArtifactBrowserPage: React.FC = () => {
</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="flex-1 flex overflow-hidden bg-slate-50">
<aside className="w-80 border-r border-slate-200 bg-white flex flex-col">
<div className="p-4 border-b border-slate-200 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" />
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<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"
className="w-full pl-8 pr-3 py-2 rounded-lg bg-white border border-slate-200 text-sm text-slate-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{repositoryError && (
<p className="text-xs text-red-400">{repositoryError}</p>
)}
<div className="flex items-center justify-between text-xs text-gray-400">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Registries</span>
<Badge variant="secondary">{registryNodes.length}</Badge>
</div>
@ -312,7 +314,7 @@ const ArtifactBrowserPage: React.FC = () => {
<EmptyState
icon={Database}
title="No registries"
description="Add a registry to get started."
description="Add a Harbor registry to browse deployable charts."
/>
</div>
) : (
@ -320,18 +322,18 @@ const ArtifactBrowserPage: React.FC = () => {
<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"
className="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-50 transition"
>
<div className="flex items-center gap-2">
{node.expanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
<ChevronDown className="w-4 h-4 text-slate-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
<ChevronRight className="w-4 h-4 text-slate-500" />
)}
<Database className="w-4 h-4 text-purple-400" />
<Database className="w-4 h-4 text-blue-600" />
<div className="text-left">
<p className="text-sm text-white">{node.registry.name || "Unnamed"}</p>
<p className="text-[11px] text-gray-500 truncate">
<p className="text-sm text-slate-900">{node.registry.name || "Unnamed"}</p>
<p className="text-[11px] text-slate-500 truncate">
{node.registry.url}
</p>
</div>
@ -339,12 +341,12 @@ const ArtifactBrowserPage: React.FC = () => {
<Badge variant="secondary">{node.repositories.length}</Badge>
</button>
{node.expanded && (
<div className="bg-gray-900/60">
<div className="bg-slate-50/60">
{node.repositories.length === 0 ? (
<p className="px-8 py-3 text-xs text-gray-500">
<p className="px-8 py-3 text-xs text-slate-500">
{loadingRepositories
? "Loading repositories..."
: "No repositories found."}
: "No chart repositories found."}
</p>
) : (
node.repositories.map((repo) => {
@ -357,13 +359,13 @@ const ArtifactBrowserPage: React.FC = () => {
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"
? "bg-blue-50 text-blue-700"
: "hover:bg-white/80 text-slate-700"
}`}
>
<span className="truncate">{repo.name}</span>
{repo.artifactCount !== undefined && (
<span className="text-xs text-gray-500">
<span className="text-xs text-slate-500">
{repo.artifactCount}
</span>
)}
@ -379,38 +381,38 @@ const ArtifactBrowserPage: React.FC = () => {
</div>
</aside>
<main className="flex-1 flex flex-col bg-dark-card overflow-hidden">
<main className="flex-1 flex flex-col bg-white 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."
description="Choose a chart repository from the left panel."
/>
</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-shrink-0 border-b border-slate-200 p-5 bg-slate-50">
<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">
<p className="text-xs uppercase text-slate-500">Chart repository</p>
<h2 className="text-2xl font-semibold text-slate-900">
{selectedRepository.name}
</h2>
<p className="text-sm text-gray-400">
<p className="text-sm text-slate-500">
{selectedRegistryName || selectedRepository.registryId}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Filter className="w-4 h-4 text-gray-400" />
<Filter className="w-4 h-4 text-slate-500" />
{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"
? "bg-blue-600 text-white border-blue-600"
: "border-slate-200 text-slate-700 hover:border-slate-400"
}`}
>
{option.label}
@ -432,8 +434,8 @@ const ArtifactBrowserPage: React.FC = () => {
title="No artifacts"
description={
filter
? `No ${filter} artifacts found for this repository.`
: "This repository doesn't contain any artifacts yet."
? `No ${filter} tags found for this repository.`
: "This repository doesn't contain any tagged artifacts yet."
}
/>
) : (
@ -442,6 +444,7 @@ const ArtifactBrowserPage: React.FC = () => {
<TagCard
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}
registryId={selectedRepository.registryId}
registryUrl={selectedRegistryUrl}
tag={artifact}
/>
))}

View File

@ -3,7 +3,7 @@
* Display all registries and their repositories
*/
import React, { useState, useEffect } from "react";
import { Database, RefreshCw, Plus, Package } from "lucide-react";
import { Database, RefreshCw, Plus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/shared";
import {
@ -176,63 +176,6 @@ const RegistriesBrowserPage: React.FC = () => {
{!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>
);
};