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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user