Files
cloud-host/frontend/src/app/dashboard/admin/clusters/page.tsx
T
keyhan 786689e0fd Add per-cluster Tools Management and stop auto-installing side tools.
Introduce a catalog-driven Tools Management section under Clusters so
admins can install/uninstall infrastructure tools per cluster: cert-manager
(Helm/jetstack), ClusterIssuer (email + HTTP01 form, depends on cert-manager),
and central Elasticsearch. Cluster creation no longer auto-installs Elastic
or the cloudhost-node-cluster-dns DaemonSet; build bootstrap stays automatic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:55:51 +03:30

675 lines
28 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Cluster, ClusterResources } from '@/types';
import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
function ResourcePanel({ clusterId }: { clusterId: string }) {
const { data, isLoading, error } = useQuery<ClusterResources>({
queryKey: ['cluster-resources', clusterId],
queryFn: () => api.get(`/clusters/${clusterId}/resources`).then((r) => r.data),
refetchInterval: 30000,
});
if (isLoading) return <div className="p-4 text-sm text-gray-500">Loading resources...</div>;
if (error) return <div className="p-4 text-sm text-red-500">Failed to load resources</div>;
if (!data) return null;
const cpuCap = parseFloat(data.totalCpuCapacity);
const cpuAlloc = parseFloat(data.totalCpuAllocatable);
const memCap = parseFloat(data.totalMemoryCapacity);
const memAlloc = parseFloat(data.totalMemoryAllocatable);
const cpuUsedPct = cpuCap > 0 ? ((cpuCap - cpuAlloc) / cpuCap * 100) : 0;
const memUsedPct = memCap > 0 ? ((memCap - memAlloc) / memCap * 100) : 0;
return (
<div className="mt-4 pt-4 border-t border-gray-200 space-y-4 animate-fade-in">
{/* Summary cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="bg-blue-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-700">{data.nodeCount}</div>
<div className="text-xs text-blue-600">Nodes</div>
</div>
<div className="bg-purple-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-purple-700">{data.podCount}</div>
<div className="text-xs text-purple-600">Pods</div>
</div>
<div className="bg-green-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-green-700">{data.appCount}</div>
<div className="text-xs text-green-600">Apps</div>
</div>
<div className="bg-orange-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-orange-700">{data.totalCpuCapacity}</div>
<div className="text-xs text-orange-600">Total CPU</div>
</div>
</div>
{/* CPU & Memory bars */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">CPU Reserved</span>
<span className="font-medium">{cpuUsedPct.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${cpuUsedPct > 80 ? 'bg-red-500' : cpuUsedPct > 60 ? 'bg-yellow-500' : 'bg-blue-500'}`}
style={{ width: `${Math.min(cpuUsedPct, 100)}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1">
{data.totalCpuCapacity} capacity · {data.totalCpuAllocatable} allocatable
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Memory Reserved</span>
<span className="font-medium">{memUsedPct.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${memUsedPct > 80 ? 'bg-red-500' : memUsedPct > 60 ? 'bg-yellow-500' : 'bg-purple-500'}`}
style={{ width: `${Math.min(memUsedPct, 100)}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1">
{data.totalMemoryCapacity} capacity · {data.totalMemoryAllocatable} allocatable
</div>
</div>
</div>
{/* Nodes table */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Nodes</h4>
<div className="overflow-x-auto -mx-2 px-2 rounded-xl border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Roles</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">CPU (Cap / Alloc)</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Memory (Cap / Alloc)</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{data.nodes.map((node) => (
<tr key={node.name} className="hover:bg-gray-50">
<td className="px-4 py-2 font-mono text-xs">{node.name}</td>
<td className="px-4 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
node.status === 'Ready' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{node.status}
</span>
</td>
<td className="px-4 py-2 text-gray-600">{node.roles}</td>
<td className="px-4 py-2 text-gray-600 font-mono text-xs">{node.cpuCapacity} / {node.cpuAllocatable}</td>
<td className="px-4 py-2 text-gray-600 font-mono text-xs">{node.memoryCapacity} / {node.memoryAllocatable}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function apiErrorMessage(err: unknown, fallback: string): string {
const e = err as { response?: { data?: { message?: string | string[] } } };
const msg = e?.response?.data?.message;
if (Array.isArray(msg)) return msg.join(', ');
if (typeof msg === 'string' && msg.trim()) return msg;
return fallback;
}
interface ClusterToolField {
key: string;
label: string;
type: 'text' | 'email';
required: boolean;
placeholder?: string;
helpText?: string;
}
interface ClusterTool {
id: string;
name: string;
description: string;
category: string;
dependencies: string[];
installFields: ClusterToolField[];
status: 'not_installed' | 'installing' | 'installed' | 'failed' | 'unknown';
message?: string;
details?: Record<string, unknown>;
}
const TOOL_STATUS_BADGE: Record<ClusterTool['status'], { label: string; cls: string }> = {
installed: { label: 'Installed', cls: 'badge-green' },
installing: { label: 'Installing…', cls: 'badge-yellow' },
failed: { label: 'Failed', cls: 'badge-red' },
not_installed: { label: 'Not installed', cls: 'badge-gray' },
unknown: { label: 'Unknown', cls: 'badge-gray' },
};
function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterTool; tools: ClusterTool[] }) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [showForm, setShowForm] = useState(false);
const [fields, setFields] = useState<Record<string, string>>({});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ['cluster-tools', clusterId] });
const installMutation = useMutation({
mutationFn: (params: Record<string, string>) =>
api.post(`/clusters/${clusterId}/tools/${tool.id}/install`, params),
onSuccess: (res) => {
invalidate();
setShowForm(false);
setFields({});
toast.success(res.data?.message || `${tool.name} install started`);
},
onError: (err) => toast.error(apiErrorMessage(err, `Failed to install ${tool.name}`)),
});
const uninstallMutation = useMutation({
mutationFn: () => api.delete(`/clusters/${clusterId}/tools/${tool.id}`),
onSuccess: (res) => {
invalidate();
toast.success(res.data?.message || `${tool.name} removed`);
},
onError: (err) => toast.error(apiErrorMessage(err, `Failed to remove ${tool.name}`)),
});
const unmetDeps = tool.dependencies.filter(
(depId) => tools.find((t) => t.id === depId)?.status !== 'installed',
);
const depsBlocked = unmetDeps.length > 0;
const isInstalled = tool.status === 'installed';
const isBusy = installMutation.isPending || uninstallMutation.isPending;
const badge = TOOL_STATUS_BADGE[tool.status];
const startInstall = () => {
if (tool.installFields.length > 0) {
setShowForm((s) => !s);
} else {
installMutation.mutate({});
}
};
const submitForm = () => {
for (const f of tool.installFields) {
if (f.required && !fields[f.key]?.trim()) {
toast.error(`${f.label} is required`);
return;
}
}
installMutation.mutate(fields);
};
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900">{tool.name}</h3>
<span className={`badge ${badge.cls}`}>{badge.label}</span>
<span className="badge badge-gray">{tool.category}</span>
</div>
<p className="text-sm text-gray-600 mt-1">{tool.description}</p>
{tool.message && (
<p className="text-xs text-gray-400 mt-1">{tool.message}</p>
)}
{depsBlocked && !isInstalled && (
<p className="text-xs text-amber-600 mt-1">
Requires:{' '}
{unmetDeps
.map((d) => tools.find((t) => t.id === d)?.name || d)
.join(', ')}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{tool.status === 'installing' && (
<RotateCw className="w-4 h-4 text-yellow-600 animate-spin" />
)}
{isInstalled ? (
<button
type="button"
onClick={async () => {
const ok = await confirm({
title: `Remove ${tool.name}`,
message: `Uninstall "${tool.name}" from this cluster?`,
confirmText: 'Uninstall',
variant: 'danger',
});
if (ok) uninstallMutation.mutate();
}}
disabled={isBusy}
className="btn-secondary text-sm text-red-600 disabled:opacity-50"
>
{uninstallMutation.isPending ? 'Removing…' : 'Uninstall'}
</button>
) : (
<button
type="button"
onClick={startInstall}
disabled={isBusy || depsBlocked}
className="btn-primary text-sm disabled:opacity-50"
title={depsBlocked ? 'Install required tools first' : undefined}
>
{installMutation.isPending
? 'Installing…'
: tool.status === 'failed'
? 'Repair'
: 'Install'}
</button>
)}
</div>
</div>
{showForm && !isInstalled && (
<div className="mt-3 pt-3 border-t border-gray-100 space-y-3 animate-fade-in">
{tool.installFields.map((f) => (
<div key={f.key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{f.label}{f.required ? ' *' : ''}
</label>
<input
type={f.type === 'email' ? 'email' : 'text'}
className="input-field text-sm"
placeholder={f.placeholder}
value={fields[f.key] || ''}
onChange={(e) => setFields({ ...fields, [f.key]: e.target.value })}
/>
{f.helpText && <p className="text-xs text-gray-400 mt-1">{f.helpText}</p>}
</div>
))}
<div className="flex gap-2">
<button
type="button"
onClick={submitForm}
disabled={installMutation.isPending}
className="btn-primary text-sm"
>
{installMutation.isPending ? 'Installing…' : 'Confirm install'}
</button>
<button type="button" onClick={() => setShowForm(false)} className="btn-ghost text-sm">
Cancel
</button>
</div>
</div>
)}
</div>
);
}
function ClusterToolsPanel({
clusters,
selectedClusterId,
onSelectCluster,
}: {
clusters: Cluster[];
selectedClusterId: string | null;
onSelectCluster: (id: string) => void;
}) {
const clusterId = selectedClusterId || clusters.find((c) => c.isDefault)?.id || clusters[0]?.id;
const { data: tools = [], isLoading, error } = useQuery<ClusterTool[]>({
queryKey: ['cluster-tools', clusterId],
queryFn: () => api.get(`/clusters/${clusterId}/tools`).then((r) => r.data),
enabled: !!clusterId,
refetchInterval: (query) =>
(query.state.data || []).some((t) => t.status === 'installing') ? 8000 : 30000,
});
return (
<div className="card p-4 border border-indigo-100 bg-indigo-50/30">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<ScrollText className="w-5 h-5 text-indigo-600" /> Tools Management
</h2>
<p className="text-sm text-gray-600 mt-1">
Install and manage infrastructure tools per cluster. Nothing is installed automatically
add only what each cluster needs.
</p>
</div>
{clusters.length > 1 && (
<select
className="input-field text-sm py-1.5 max-w-[220px]"
value={clusterId || ''}
onChange={(e) => onSelectCluster(e.target.value)}
>
{clusters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}{c.isDefault ? ' (default)' : ''}
</option>
))}
</select>
)}
</div>
{isLoading ? (
<p className="text-sm text-gray-500">Loading tools</p>
) : error ? (
<p className="text-sm text-red-500">Failed to load tools for this cluster.</p>
) : (
<div className="space-y-3">
{clusterId &&
tools.map((tool) => (
<ToolRow key={tool.id} clusterId={clusterId} tool={tool} tools={tools} />
))}
</div>
)}
</div>
);
}
export default function AdminClustersPage() {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [showForm, setShowForm] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
const [loggingClusterId, setLoggingClusterId] = useState<string | null>(null);
const [form, setForm] = useState({
name: '',
description: '',
apiServer: '',
kubeconfig: '',
region: '',
provider: '',
weight: 1,
tags: '',
isDefault: false,
});
const { data: clusters = [], isLoading } = useQuery<Cluster[]>({
queryKey: ['admin-clusters'],
queryFn: () => api.get('/clusters').then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: (data: typeof form) => api.post('/clusters', {
...data,
tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
weight: Number(data.weight) || 1,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
toast.success('Cluster added & connection verified ✓');
setShowForm(false);
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', weight: 1, tags: '', isDefault: false });
},
onError: (err: any) => {
const message = err?.response?.data?.message || 'Failed to add cluster';
toast.error(message);
},
});
const testMutation = useMutation({
mutationFn: (id: string) => {
setTestingId(id);
return api.post(`/clusters/${id}/test`);
},
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
const data = res.data;
if (data.connected) {
toast.success(`Connection OK — Kubernetes ${data.version}`);
} else {
toast.error(`Connection failed: ${data.error}`);
}
setTestingId(null);
},
onError: () => {
toast.error('Failed to test connection');
setTestingId(null);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/clusters/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
toast.success('Cluster removed');
},
});
const toggleResources = (id: string) => {
setExpandedResources((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<div className="space-y-6 animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">Cluster Management</h1>
<p className="page-subtitle">{clusters.length} cluster{clusters.length !== 1 ? 's' : ''} registered</p>
</div>
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
{showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Add Cluster'}
</button>
</div>
{clusters.length > 0 && (
<ClusterToolsPanel
clusters={clusters}
selectedClusterId={loggingClusterId}
onSelectCluster={setLoggingClusterId}
/>
)}
{showForm && (
<div className="card space-y-4 animate-slide-up">
<h2 className="text-lg font-semibold text-gray-900">Register New Cluster</h2>
<p className="text-sm text-gray-500">
The system will verify the Kubernetes connection before registering.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input className="input-field" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">API Server URL</label>
<input className="input-field" placeholder="https://k8s-api:6443" value={form.apiServer} onChange={(e) => setForm({ ...form, apiServer: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Region</label>
<input className="input-field" placeholder="us-east-1" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Provider</label>
<select className="input-field" value={form.provider} onChange={(e) => setForm({ ...form, provider: e.target.value })}>
<option value="">Select provider</option>
<option value="aws">AWS (EKS)</option>
<option value="gcp">GCP (GKE)</option>
<option value="azure">Azure (AKS)</option>
<option value="bare-metal">Bare Metal</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Weight</label>
<input
type="number"
min={1}
className="input-field"
value={form.weight}
onChange={(e) => setForm({ ...form, weight: Number(e.target.value) || 1 })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tags</label>
<input
className="input-field"
placeholder="ssd, production, iran"
value={form.tags}
onChange={(e) => setForm({ ...form, tags: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input className="input-field" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kubeconfig (YAML)</label>
<textarea
className="input-field font-mono text-xs"
rows={8}
placeholder="Paste your kubeconfig here..."
value={form.kubeconfig}
onChange={(e) => setForm({ ...form, kubeconfig: e.target.value })}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="isDefault"
checked={form.isDefault}
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
/>
<label htmlFor="isDefault" className="text-sm text-gray-700">Set as default cluster</label>
</div>
<button
onClick={() => createMutation.mutate(form)}
disabled={!form.name || !form.apiServer || !form.kubeconfig || createMutation.isPending}
className="btn-primary"
>
{createMutation.isPending ? <><RotateCw className="w-4 h-4 inline animate-spin" /> Verifying connection & adding...</> : 'Add Cluster'}
</button>
</div>
)}
{isLoading ? (
<div className="space-y-3">
{[1,2].map(i => (
<div key={i} className="card flex items-center gap-4">
<div className="skeleton w-11 h-11 rounded-xl" />
<div className="flex-1 space-y-2">
<div className="skeleton h-4 w-40" />
<div className="skeleton h-3 w-64" />
</div>
<div className="skeleton h-8 w-24 rounded-lg" />
</div>
))}
</div>
) : clusters.length === 0 ? (
<div className="card text-center py-16">
<Server className="w-12 h-12 mx-auto text-gray-300 mb-4" />
<p className="text-gray-600 font-medium">No clusters registered yet.</p>
<p className="text-gray-400 text-sm mt-1">Add a Kubernetes cluster to start deploying applications.</p>
</div>
) : (
<div className="grid gap-4">
{clusters.map((cluster) => (
<div key={cluster.id} className="card">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${
cluster.status === 'active' ? 'bg-emerald-50' : 'bg-red-50'
}`}>
{cluster.status === 'active' ? <CheckCircle className="w-5 h-5 text-emerald-500" /> : <XCircle className="w-5 h-5 text-red-500" />}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900">{cluster.name}</h3>
{cluster.isDefault && (
<span className="badge badge-blue">Default</span>
)}
<span className={`badge ${
cluster.status === 'active' ? 'badge-green'
: cluster.status === 'maintenance' ? 'badge-yellow'
: 'badge-red'
}`}>
{cluster.status}
</span>
<span className={`badge ${
cluster.healthStatus === 'healthy' ? 'badge-green'
: cluster.healthStatus === 'degraded' ? 'badge-yellow'
: cluster.healthStatus === 'unhealthy' ? 'badge-red'
: 'badge-gray'
}`}>
health: {cluster.healthStatus || 'unknown'}
</span>
<span className="badge badge-gray">weight {cluster.weight || 1}</span>
</div>
<p className="text-sm text-gray-500 truncate">
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer}
</p>
{cluster.healthMessage && (
<p className="text-xs text-gray-400 mt-1">
{cluster.healthMessage}
{cluster.lastHealthCheckedAt ? ` · ${new Date(cluster.lastHealthCheckedAt).toLocaleString()}` : ''}
</p>
)}
{cluster.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{cluster.tags.map((tag) => (
<span key={tag} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 text-xs">
{tag}
</span>
))}
</div>
)}
{cluster.availableResources && (
<p className="text-xs text-gray-500 mt-2">
CPU {cluster.availableResources.totalCpuAllocatable || 'n/a'} · Memory {cluster.availableResources.totalMemoryAllocatable || 'n/a'} · Pods {cluster.availableResources.podCount ?? 'n/a'} · Apps {cluster.availableResources.appCount ?? 'n/a'}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap">
<button
onClick={() => toggleResources(cluster.id)}
className={`btn-ghost text-sm ${expandedResources.has(cluster.id) ? 'bg-purple-50 text-purple-700' : ''}`}
>
<BarChart3 className="w-4 h-4 inline" /> Resources
</button>
<button
onClick={() => testMutation.mutate(cluster.id)}
disabled={testingId === cluster.id}
className="btn-ghost text-sm disabled:opacity-50"
>
{testingId === cluster.id ? <><Clock className="w-3 h-3 inline animate-spin" /> Testing...</> : <><Plug className="w-3 h-3 inline" /> Test</>}
</button>
<button
onClick={async () => {
const ok = await confirm({ title: 'Remove Cluster', message: `Are you sure you want to remove "${cluster.name}"?`, confirmText: 'Remove', variant: 'danger' });
if (ok) deleteMutation.mutate(cluster.id);
}}
className="text-sm text-red-600 hover:text-red-800 font-medium"
>
Remove
</button>
</div>
</div>
{/* Expandable resource panel */}
{expandedResources.has(cluster.id) && (
<ResourcePanel clusterId={cluster.id} />
)}
</div>
))}
</div>
)}
</div>
);
}