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>
This commit is contained in:
@@ -5,7 +5,7 @@ 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, Copy } from 'lucide-react';
|
||||
import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
|
||||
function ResourcePanel({ clusterId }: { clusterId: string }) {
|
||||
@@ -128,7 +128,189 @@ function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function CentralLoggingPanel({
|
||||
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,
|
||||
@@ -137,167 +319,59 @@ function CentralLoggingPanel({
|
||||
selectedClusterId: string | null;
|
||||
onSelectCluster: (id: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const clusterId = selectedClusterId || clusters.find((c) => c.isDefault)?.id || clusters[0]?.id;
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['admin-elasticsearch-status', clusterId],
|
||||
queryFn: () =>
|
||||
api.get('/admin/elasticsearch/status', { params: clusterId ? { clusterId } : {} }).then((r) => r.data),
|
||||
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,
|
||||
});
|
||||
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: (targetClusterId?: string) =>
|
||||
api.post('/admin/elasticsearch/deploy', null, {
|
||||
params: targetClusterId ? { clusterId: targetClusterId } : {},
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] });
|
||||
toast.success(res.data?.message || 'Logging stack deployment started');
|
||||
},
|
||||
onError: (err) => toast.error(apiErrorMessage(err, 'Failed to deploy logging stack')),
|
||||
});
|
||||
|
||||
const undeployMutation = useMutation({
|
||||
mutationFn: (targetClusterId?: string) =>
|
||||
api.delete('/admin/elasticsearch/undeploy', {
|
||||
params: targetClusterId ? { clusterId: targetClusterId } : {},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] });
|
||||
toast.success('Logging stack removed');
|
||||
},
|
||||
onError: (err) => toast.error(apiErrorMessage(err, 'Failed to remove logging stack')),
|
||||
});
|
||||
|
||||
const kibanaCmd = 'kubectl port-forward svc/kibana 5601:5601 -n logging';
|
||||
|
||||
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" /> Central logging (Elasticsearch)
|
||||
<ScrollText className="w-5 h-5 text-indigo-600" /> Tools Management
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Required for the unified Logs page. Installed automatically via Helm when a cluster is registered.
|
||||
End users never get Kibana access — staff use port-forward.
|
||||
Install and manage infrastructure tools per cluster. Nothing is installed automatically —
|
||||
add only what each cluster needs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{clusters.length > 1 && (
|
||||
<select
|
||||
className="input-field text-sm py-1.5 max-w-[200px]"
|
||||
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>
|
||||
)}
|
||||
{!status?.deployed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deployMutation.mutate(clusterId)}
|
||||
disabled={deployMutation.isPending || !clusterId}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{deployMutation.isPending ? 'Deploying…' : 'Deploy Elastic'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => undeployMutation.mutate(clusterId)}
|
||||
disabled={undeployMutation.isPending || !clusterId}
|
||||
className="btn-secondary text-sm text-red-600"
|
||||
>
|
||||
Remove stack
|
||||
</button>
|
||||
)}
|
||||
</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">Checking status…</p>
|
||||
) : status?.deployed ? (
|
||||
<div className="text-sm space-y-2">
|
||||
<p className="text-green-700 font-medium flex items-center gap-1">
|
||||
<CheckCircle className="w-4 h-4" /> Deployed · health: {status.health?.status || 'unknown'}
|
||||
</p>
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-200">
|
||||
<p className="text-xs font-medium text-gray-600 mb-1">Kibana (staff only)</p>
|
||||
<div className="flex items-center gap-2 font-mono text-xs">
|
||||
<code className="flex-1 break-all">{kibanaCmd}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(kibanaCmd);
|
||||
toast.success('Copied');
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Then open http://localhost:5601</p>
|
||||
</div>
|
||||
</div>
|
||||
<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="text-sm text-amber-700 space-y-1">
|
||||
<p>
|
||||
Not ready on this cluster
|
||||
{status?.deployStatus ? ` (${status.deployStatus}${status.helmReleaseStatus ? ` · helm: ${status.helmReleaseStatus}` : ''})` : ''}.
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
New clusters install automatically; if that failed (e.g. missing StorageClass), click Deploy Elastic to install or repair.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{clusterId &&
|
||||
tools.map((tool) => (
|
||||
<ToolRow key={tool.id} clusterId={clusterId} tool={tool} tools={tools} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClusterElasticButton({ clusterId, clusterName }: { clusterId: string; clusterName: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['admin-elasticsearch-status', clusterId],
|
||||
queryFn: () => api.get('/admin/elasticsearch/status', { params: { clusterId } }).then((r) => r.data),
|
||||
});
|
||||
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: () => api.post('/admin/elasticsearch/deploy', null, { params: { clusterId } }),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] });
|
||||
toast.success(res.data?.message || `Elasticsearch deploy started on ${clusterName}`);
|
||||
},
|
||||
onError: (err) => toast.error(apiErrorMessage(err, `Failed to deploy Elasticsearch on ${clusterName}`)),
|
||||
});
|
||||
|
||||
if (status?.deployed) {
|
||||
return (
|
||||
<span className="text-xs text-green-700 font-medium flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" /> Elastic
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deployMutation.mutate()}
|
||||
disabled={deployMutation.isPending}
|
||||
className="btn-ghost text-sm text-indigo-700"
|
||||
title="Install or repair central Elasticsearch on this cluster"
|
||||
>
|
||||
<ScrollText className="w-3 h-3 inline" />
|
||||
{deployMutation.isPending ? 'Deploying…' : 'Deploy Elastic'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminClustersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
@@ -393,11 +467,13 @@ export default function AdminClustersPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CentralLoggingPanel
|
||||
clusters={clusters}
|
||||
selectedClusterId={loggingClusterId}
|
||||
onSelectCluster={setLoggingClusterId}
|
||||
/>
|
||||
{clusters.length > 0 && (
|
||||
<ClusterToolsPanel
|
||||
clusters={clusters}
|
||||
selectedClusterId={loggingClusterId}
|
||||
onSelectCluster={setLoggingClusterId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="card space-y-4 animate-slide-up">
|
||||
@@ -566,7 +642,6 @@ export default function AdminClustersPage() {
|
||||
>
|
||||
<BarChart3 className="w-4 h-4 inline" /> Resources
|
||||
</button>
|
||||
<ClusterElasticButton clusterId={cluster.id} clusterName={cluster.name} />
|
||||
<button
|
||||
onClick={() => testMutation.mutate(cluster.id)}
|
||||
disabled={testingId === cluster.id}
|
||||
|
||||
Reference in New Issue
Block a user