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:
keyhan
2026-04-05 17:48:15 +03:30
parent 2621dc0cc6
commit e97af36740
8 changed files with 564 additions and 69 deletions
@@ -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>
+182 -59
View File
@@ -1,16 +1,40 @@
'use client';
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 { User } from '@/types';
import type { AdminUser } from '@/types';
export default function AdminUsersPage() {
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
email: '',
password: '',
firstName: '',
lastName: '',
role: 'user' as 'user' | 'admin',
});
const { data: users = [], isLoading } = useQuery<User[]>({
queryKey: ['admin-users'],
queryFn: () => api.get('/users').then((r) => r.data),
const { data: users = [], isLoading } = useQuery<AdminUser[]>({
queryKey: ['admin-users', search],
queryFn: () =>
api.get('/users', { params: search ? { search } : {} }).then((r) => r.data),
});
const createUser = useMutation({
mutationFn: (data: typeof form) => api.post('/users', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('User created successfully');
setShowForm(false);
setForm({ email: '', password: '', firstName: '', lastName: '', role: 'user' });
},
onError: (err: any) => {
toast.error(err?.response?.data?.message || 'Failed to create user');
},
});
const toggleActive = useMutation({
@@ -31,64 +55,163 @@ export default function AdminUsersPage() {
},
});
if (isLoading) {
return <div className="card text-center py-12 text-gray-500">Loading users...</div>;
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Namespace</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm font-medium text-gray-900">
{user.firstName} {user.lastName}
</td>
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
<td className="px-6 py-4">
<select
className="text-sm border border-gray-300 rounded px-2 py-1"
value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500 font-mono">{user.namespace || '—'}</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
className={`text-sm ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
>
{user.isActive ? 'Deactivate' : 'Activate'}
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<button onClick={() => setShowForm(!showForm)} className="btn-primary">
{showForm ? 'Cancel' : '+ Add User'}
</button>
</div>
{/* Create user form */}
{showForm && (
<div className="card space-y-4">
<h2 className="text-lg font-semibold">Create New User</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">First Name</label>
<input
className="input-field"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
<input
className="input-field"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
className="input-field"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
className="input-field"
type="password"
placeholder="Min 8 characters"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
<select
className="input-field w-48"
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as 'user' | 'admin' })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<button
onClick={() => createUser.mutate(form)}
disabled={!form.email || !form.password || !form.firstName || !form.lastName || createUser.isPending}
className="btn-primary"
>
{createUser.isPending ? '🔄 Creating...' : 'Create User'}
</button>
</div>
)}
{/* Search bar */}
<div className="relative">
<input
className="input-field pl-10 w-full"
placeholder="Search by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{isLoading ? (
<div className="card text-center py-12 text-gray-500">Loading users...</div>
) : users.length === 0 ? (
<div className="card text-center py-12">
<p className="text-gray-500">{search ? 'No users found matching your search.' : 'No users yet.'}</p>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Apps</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Namespace</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm font-medium text-gray-900">
{user.firstName} {user.lastName}
</td>
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
<td className="px-6 py-4">
<select
className="text-sm border border-gray-300 rounded px-2 py-1"
value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
{user.appCount ?? 0}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500 font-mono">{user.namespace || '—'}</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
className={`text-sm ${user.isActive ? 'text-red-600 hover:text-red-800' : 'text-green-600 hover:text-green-800'}`}
>
{user.isActive ? 'Deactivate' : 'Activate'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}