786689e0fd
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>
675 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|