feat: admin user management (create/search/role) and cluster resource monitoring
- Add POST /users endpoint for admin to create users with hashed passwords - Add GET /users?search= with ILike search on email/firstName/lastName - Add PATCH /users/:id/role for role assignment (user/admin) - Return appCount per user in the users list - Add GET /clusters/:id/resources for node, CPU, memory, pod monitoring - Parse K8s node capacity/allocatable with CPU millicores and memory MiB helpers - Frontend: admin users page with search bar, create form, role dropdown, app count - Frontend: cluster resource panel with nodes table, CPU/memory bars, summary cards
This commit is contained in:
@@ -4,12 +4,125 @@ import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { Cluster } from '@/types';
|
||||
import type { Cluster, ClusterResources } from '@/types';
|
||||
|
||||
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">
|
||||
{/* Summary cards */}
|
||||
<div className="grid 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-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-hidden rounded-lg 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminClustersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -68,6 +181,18 @@ export default function AdminClustersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
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">
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -177,6 +302,16 @@ export default function AdminClustersPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => toggleResources(cluster.id)}
|
||||
className={`text-sm px-3 py-1.5 rounded-md transition-colors ${
|
||||
expandedResources.has(cluster.id)
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-purple-50 text-purple-600 hover:bg-purple-100'
|
||||
}`}
|
||||
>
|
||||
📊 Resources
|
||||
</button>
|
||||
<button
|
||||
onClick={() => testMutation.mutate(cluster.id)}
|
||||
disabled={testingId === cluster.id}
|
||||
@@ -192,6 +327,11 @@ export default function AdminClustersPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable resource panel */}
|
||||
{expandedResources.has(cluster.id) && (
|
||||
<ResourcePanel clusterId={cluster.id} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user