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:
@@ -54,6 +54,13 @@ export class ClustersController {
|
||||
return this.clustersService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id/resources')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Get cluster resource usage — nodes, CPU, memory, pods (Admin only)' })
|
||||
async getClusterResources(@Param('id') id: string) {
|
||||
return this.clustersService.getClusterResources(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Get cluster details (Admin only)' })
|
||||
|
||||
@@ -348,4 +348,101 @@ export class ClustersService {
|
||||
this.logger.log(`Pool "${pool.name}" least-apps → cluster "${selected.name}" (${countMap.get(selected.id) || 0} apps)`);
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource usage for a specific cluster — nodes, total CPU/memory, pod counts.
|
||||
*/
|
||||
async getClusterResources(id: string): Promise<{
|
||||
nodes: { name: string; status: string; roles: string; cpuCapacity: string; memoryCapacity: string; cpuAllocatable: string; memoryAllocatable: string; }[];
|
||||
totalCpuCapacity: string;
|
||||
totalMemoryCapacity: string;
|
||||
totalCpuAllocatable: string;
|
||||
totalMemoryAllocatable: string;
|
||||
podCount: number;
|
||||
nodeCount: number;
|
||||
appCount: number;
|
||||
}> {
|
||||
const cluster = await this.findOne(id);
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||
|
||||
try {
|
||||
// Get nodes
|
||||
const nodesRes = await coreApi.listNode();
|
||||
const nodes = nodesRes.body.items.map((node) => {
|
||||
const conditions = node.status?.conditions || [];
|
||||
const readyCondition = conditions.find((c) => c.type === 'Ready');
|
||||
const roles = Object.keys(node.metadata?.labels || {})
|
||||
.filter((l) => l.startsWith('node-role.kubernetes.io/'))
|
||||
.map((l) => l.replace('node-role.kubernetes.io/', ''))
|
||||
.join(', ') || 'worker';
|
||||
|
||||
return {
|
||||
name: node.metadata?.name || 'unknown',
|
||||
status: readyCondition?.status === 'True' ? 'Ready' : 'NotReady',
|
||||
roles,
|
||||
cpuCapacity: node.status?.capacity?.cpu || '0',
|
||||
memoryCapacity: node.status?.capacity?.memory || '0',
|
||||
cpuAllocatable: node.status?.allocatable?.cpu || '0',
|
||||
memoryAllocatable: node.status?.allocatable?.memory || '0',
|
||||
};
|
||||
});
|
||||
|
||||
// Total capacity
|
||||
let totalCpuCap = 0;
|
||||
let totalMemCap = 0;
|
||||
let totalCpuAlloc = 0;
|
||||
let totalMemAlloc = 0;
|
||||
for (const node of nodes) {
|
||||
totalCpuCap += this.parseCpuToMillicores(node.cpuCapacity);
|
||||
totalMemCap += this.parseMemoryToMi(node.memoryCapacity);
|
||||
totalCpuAlloc += this.parseCpuToMillicores(node.cpuAllocatable);
|
||||
totalMemAlloc += this.parseMemoryToMi(node.memoryAllocatable);
|
||||
}
|
||||
|
||||
// Get all pods count
|
||||
const podsRes = await coreApi.listPodForAllNamespaces();
|
||||
const podCount = podsRes.body.items.length;
|
||||
|
||||
// Get app count for this cluster
|
||||
const appCountResult = await this.dataSource.query(
|
||||
`SELECT COUNT(*) as count FROM applications WHERE "clusterId" = $1`,
|
||||
[id],
|
||||
);
|
||||
const appCount = parseInt(appCountResult[0]?.count || '0', 10);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
totalCpuCapacity: `${totalCpuCap}m`,
|
||||
totalMemoryCapacity: `${totalMemCap.toFixed(0)}Mi`,
|
||||
totalCpuAllocatable: `${totalCpuAlloc}m`,
|
||||
totalMemoryAllocatable: `${totalMemAlloc.toFixed(0)}Mi`,
|
||||
podCount,
|
||||
nodeCount: nodes.length,
|
||||
appCount,
|
||||
};
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Failed to get cluster resources for "${cluster.name}": ${err.message}`);
|
||||
throw new BadRequestException(`Cannot fetch resources: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private parseCpuToMillicores(cpu: string): number {
|
||||
if (!cpu || cpu === '0') return 0;
|
||||
if (cpu.endsWith('n')) return parseFloat(cpu) / 1_000_000;
|
||||
if (cpu.endsWith('u')) return parseFloat(cpu) / 1_000;
|
||||
if (cpu.endsWith('m')) return parseFloat(cpu);
|
||||
return parseFloat(cpu) * 1000;
|
||||
}
|
||||
|
||||
private parseMemoryToMi(memory: string): number {
|
||||
if (!memory || memory === '0') return 0;
|
||||
if (memory.endsWith('Ki')) return parseFloat(memory) / 1024;
|
||||
if (memory.endsWith('Mi')) return parseFloat(memory);
|
||||
if (memory.endsWith('Gi')) return parseFloat(memory) * 1024;
|
||||
if (memory.endsWith('Ti')) return parseFloat(memory) * 1024 * 1024;
|
||||
return parseFloat(memory) / (1024 * 1024); // bytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,49 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsEnum } from 'class-validator';
|
||||
import { UsersService } from './users.service';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole } from '../common/enums';
|
||||
|
||||
class AdminCreateUserDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(64)
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
firstName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
lastName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role?: UserRole;
|
||||
}
|
||||
|
||||
class UpdateRoleDto {
|
||||
@IsEnum(UserRole)
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
@ApiTags('Users')
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
@@ -34,9 +64,25 @@ export class UsersController {
|
||||
|
||||
@Get()
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'List all users (Admin only)' })
|
||||
async findAll() {
|
||||
return this.usersService.findAll();
|
||||
@ApiOperation({ summary: 'List all users with optional search (Admin only)' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by name or email' })
|
||||
async findAll(@Query('search') search?: string) {
|
||||
return this.usersService.findAll(search);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Create a new user (Admin only)' })
|
||||
async adminCreate(@Body() dto: AdminCreateUserDto) {
|
||||
return this.usersService.adminCreate(dto);
|
||||
}
|
||||
|
||||
@Patch(':id/role')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Update user role (Admin only)' })
|
||||
async updateRole(@Param('id') id: string, @Body() dto: UpdateRoleDto) {
|
||||
await this.usersService.updateRole(id, dto.role);
|
||||
return { message: 'Role updated' };
|
||||
}
|
||||
|
||||
@Patch(':id/deactivate')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Repository, ILike } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UserRole } from '../common/enums';
|
||||
|
||||
@@ -19,6 +20,30 @@ export class UsersService {
|
||||
return this.usersRepository.save(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin create user — hashes password, checks duplicate email
|
||||
*/
|
||||
async adminCreate(data: {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role?: UserRole;
|
||||
}): Promise<Omit<User, 'password'>> {
|
||||
const existing = await this.findByEmail(data.email);
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
const hashedPassword = await bcrypt.hash(data.password, 12);
|
||||
const user = await this.create({
|
||||
...data,
|
||||
password: hashedPassword,
|
||||
role: data.role || UserRole.USER,
|
||||
});
|
||||
const { password, ...result } = user;
|
||||
return result as Omit<User, 'password'>;
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({ where: { email } });
|
||||
}
|
||||
@@ -27,10 +52,33 @@ export class UsersService {
|
||||
return this.usersRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findAll(): Promise<User[]> {
|
||||
return this.usersRepository.find({
|
||||
async findAll(search?: string): Promise<any[]> {
|
||||
const where = search
|
||||
? [
|
||||
{ email: ILike(`%${search}%`) },
|
||||
{ firstName: ILike(`%${search}%`) },
|
||||
{ lastName: ILike(`%${search}%`) },
|
||||
]
|
||||
: undefined;
|
||||
|
||||
const users = await this.usersRepository.find({
|
||||
where,
|
||||
select: ['id', 'email', 'firstName', 'lastName', 'role', 'isActive', 'namespace', 'createdAt'],
|
||||
relations: ['applications'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
firstName: u.firstName,
|
||||
lastName: u.lastName,
|
||||
role: u.role,
|
||||
isActive: u.isActive,
|
||||
namespace: u.namespace,
|
||||
createdAt: u.createdAt,
|
||||
appCount: u.applications?.length || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<User>): Promise<User> {
|
||||
@@ -42,6 +90,15 @@ export class UsersService {
|
||||
return this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
async updateRole(id: string, role: UserRole): Promise<void> {
|
||||
const user = await this.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
user.role = role;
|
||||
await this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
async deactivate(id: string): Promise<void> {
|
||||
await this.usersRepository.update(id, { isActive: false });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,3 +156,28 @@ export interface ResourceUsage {
|
||||
pods: PodInfo[];
|
||||
metrics: PodMetric[];
|
||||
}
|
||||
|
||||
export interface AdminUser extends User {
|
||||
appCount?: number;
|
||||
}
|
||||
|
||||
export interface ClusterNode {
|
||||
name: string;
|
||||
status: string;
|
||||
roles: string;
|
||||
cpuCapacity: string;
|
||||
memoryCapacity: string;
|
||||
cpuAllocatable: string;
|
||||
memoryAllocatable: string;
|
||||
}
|
||||
|
||||
export interface ClusterResources {
|
||||
nodes: ClusterNode[];
|
||||
totalCpuCapacity: string;
|
||||
totalMemoryCapacity: string;
|
||||
totalCpuAllocatable: string;
|
||||
totalMemoryAllocatable: string;
|
||||
podCount: number;
|
||||
nodeCount: number;
|
||||
appCount: number;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user