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:
keyhan
2026-05-31 16:55:51 +03:30
parent be2587dcf5
commit 786689e0fd
9 changed files with 798 additions and 247 deletions
+221 -146
View File
@@ -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}