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
@@ -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)' })
+97
View File
@@ -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
}
}
+50 -4
View File
@@ -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')
+61 -4
View File
@@ -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 });
}