diff --git a/apps/server/package.json b/apps/server/package.json index 59f82da..a5ec1e2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -85,6 +85,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.js$": "$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/apps/server/prisma/migrations/20260824221500_add_agent_display_name/migration.sql b/apps/server/prisma/migrations/20260824221500_add_agent_display_name/migration.sql new file mode 100644 index 0000000..88df62a --- /dev/null +++ b/apps/server/prisma/migrations/20260824221500_add_agent_display_name/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "agents" ADD COLUMN "displayName" TEXT; diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index f34322f..ff272a5 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -23,6 +23,8 @@ model Agent { id String @id @default(cuid()) uuid String @unique hostname String + /// Operator-set dashboard label. Null until an admin names the host. + displayName String? os String architecture String version String diff --git a/apps/server/src/agents/agents.service.spec.ts b/apps/server/src/agents/agents.service.spec.ts new file mode 100644 index 0000000..8344c86 --- /dev/null +++ b/apps/server/src/agents/agents.service.spec.ts @@ -0,0 +1,67 @@ +import type { AgentRegisterPayload } from '@docksight/protocol'; +import { AgentsService } from './agents.service'; +import { PrismaService } from '../common/database/prisma.service'; + +describe('AgentsService', () => { + const payload: AgentRegisterPayload = { + uuid: 'agent-uuid', + hostname: 'ip-10-0-0-1', + os: 'linux', + architecture: 'x64', + version: '1.0.0', + }; + + it('does not overwrite displayName on re-registration', async () => { + const upsert = jest.fn().mockResolvedValue({ + id: 'host-1', + uuid: payload.uuid, + hostname: payload.hostname, + status: 'ONLINE', + }); + const service = new AgentsService({ + agent: { upsert }, + } as unknown as PrismaService); + + await service.register(payload); + + const call = upsert.mock.calls[0] as unknown as [ + { update: Record }, + ]; + expect(call[0].update).not.toHaveProperty('displayName'); + }); + + it('updates displayName for an existing host', async () => { + const existing = { id: 'host-1', hostname: 'ip-10-0-0-1' }; + const updated = { ...existing, displayName: 'prod-web-1' }; + const prisma = { + agent: { + findUnique: jest.fn().mockResolvedValue(existing), + update: jest.fn().mockResolvedValue(updated), + }, + }; + const service = new AgentsService(prisma as unknown as PrismaService); + + await expect( + service.updateDisplayName('host-1', 'prod-web-1'), + ).resolves.toEqual(updated); + expect(prisma.agent.update).toHaveBeenCalledWith({ + where: { id: 'host-1' }, + data: { displayName: 'prod-web-1' }, + }); + }); + + it('returns null when updating a missing host', async () => { + const prisma = { + agent: { + findUnique: jest.fn().mockResolvedValue(null), + update: jest.fn(), + }, + }; + const service = new AgentsService(prisma as unknown as PrismaService); + + await expect( + service.updateDisplayName('missing', 'prod-web-1'), + ).resolves.toBeNull(); + expect(prisma.agent.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/agents/agents.service.ts b/apps/server/src/agents/agents.service.ts index ca9fee1..55d705b 100644 --- a/apps/server/src/agents/agents.service.ts +++ b/apps/server/src/agents/agents.service.ts @@ -35,6 +35,7 @@ export class AgentsService { version: payload.version, status: AgentStatus.ONLINE, lastSeen: now, + // displayName is operator-set and must survive re-registration }, }); @@ -77,6 +78,21 @@ export class AgentsService { } } + async updateDisplayName( + id: string, + displayName: string, + ): Promise { + const existing = await this.findById(id); + if (!existing) { + return null; + } + + return this.prisma.agent.update({ + where: { id }, + data: { displayName }, + }); + } + findByUuid(uuid: string): Promise { return this.prisma.agent.findUnique({ where: { uuid } }); } diff --git a/apps/server/src/hosts/dto/update-host.dto.ts b/apps/server/src/hosts/dto/update-host.dto.ts new file mode 100644 index 0000000..fc00486 --- /dev/null +++ b/apps/server/src/hosts/dto/update-host.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export const HOST_DISPLAY_NAME_MAX_LENGTH = 64; + +export class UpdateHostDto { + @ApiProperty({ + example: 'prod-web-1', + minLength: 1, + maxLength: HOST_DISPLAY_NAME_MAX_LENGTH, + }) + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value, + ) + @IsString() + @IsNotEmpty({ message: 'Display name must not be empty' }) + @MaxLength(HOST_DISPLAY_NAME_MAX_LENGTH, { + message: `Display name must be at most ${HOST_DISPLAY_NAME_MAX_LENGTH} characters`, + }) + displayName!: string; +} diff --git a/apps/server/src/hosts/hosts.controller.spec.ts b/apps/server/src/hosts/hosts.controller.spec.ts new file mode 100644 index 0000000..b9bcb27 --- /dev/null +++ b/apps/server/src/hosts/hosts.controller.spec.ts @@ -0,0 +1,118 @@ +import { + CanActivate, + ExecutionContext, + INestApplication, + ValidationPipe, +} from '@nestjs/common'; +import { APP_GUARD, Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import type { App } from 'supertest/types'; +import type { UserRole } from '../../generated/prisma/client'; +import { RolesGuard } from '../auth/roles.guard'; +import { HOST_DISPLAY_NAME_MAX_LENGTH } from './dto/update-host.dto'; +import { HostsController } from './hosts.controller'; +import { HostsService } from './hosts.service'; + +describe('HostsController', () => { + let app: INestApplication; + let role: UserRole; + const updateDisplayName = jest.fn(); + + beforeEach(async () => { + role = 'ADMIN'; + updateDisplayName.mockReset(); + + const authGuard: CanActivate = { + canActivate(context: ExecutionContext) { + const req = context.switchToHttp().getRequest<{ + user?: { id: string; role: UserRole }; + }>(); + req.user = { id: 'user-1', role }; + return true; + }, + }; + + const moduleRef = await Test.createTestingModule({ + controllers: [HostsController], + providers: [ + Reflector, + { + provide: HostsService, + useValue: { updateDisplayName }, + }, + { provide: APP_GUARD, useValue: authGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + }), + ); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('lets an admin update the display name', async () => { + updateDisplayName.mockResolvedValue({ + id: 'host-1', + hostname: 'ip-10-0-0-1', + displayName: 'prod-web-1', + }); + + const res = await request(app.getHttpServer() as App) + .patch('/hosts/host-1') + .send({ displayName: ' prod-web-1 ' }) + .expect(200); + + expect(updateDisplayName).toHaveBeenCalledWith('host-1', 'prod-web-1'); + expect(res.body).toEqual( + expect.objectContaining({ displayName: 'prod-web-1' }), + ); + }); + + it('forbids VIEWER from updating the display name', async () => { + role = 'VIEWER'; + + await request(app.getHttpServer() as App) + .patch('/hosts/host-1') + .send({ displayName: 'prod-web-1' }) + .expect(403); + + expect(updateDisplayName).not.toHaveBeenCalled(); + }); + + it('rejects an empty display name', async () => { + await request(app.getHttpServer() as App) + .patch('/hosts/host-1') + .send({ displayName: ' ' }) + .expect(400); + + expect(updateDisplayName).not.toHaveBeenCalled(); + }); + + it('rejects a display name that is too long', async () => { + await request(app.getHttpServer() as App) + .patch('/hosts/host-1') + .send({ displayName: 'a'.repeat(HOST_DISPLAY_NAME_MAX_LENGTH + 1) }) + .expect(400); + + expect(updateDisplayName).not.toHaveBeenCalled(); + }); + + it('returns 404 when the host does not exist', async () => { + updateDisplayName.mockResolvedValue(null); + + await request(app.getHttpServer() as App) + .patch('/hosts/missing') + .send({ displayName: 'prod-web-1' }) + .expect(404); + }); +}); diff --git a/apps/server/src/hosts/hosts.controller.ts b/apps/server/src/hosts/hosts.controller.ts index e4ea7d4..0dcdee6 100644 --- a/apps/server/src/hosts/hosts.controller.ts +++ b/apps/server/src/hosts/hosts.controller.ts @@ -1,5 +1,22 @@ -import { Controller, Get, NotFoundException, Param } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Get, + NotFoundException, + Param, + Patch, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { Roles } from '../auth/roles.decorator'; +import { UpdateHostDto } from './dto/update-host.dto'; import { HostsService } from './hosts.service'; @ApiTags('hosts') @@ -14,6 +31,28 @@ export class HostsController { return this.hostsService.listHosts(); } + @Patch(':id') + @Roles('ADMIN') + @ApiBearerAuth() + @ApiOperation({ summary: 'Update a host display name' }) + @ApiBody({ type: UpdateHostDto }) + @ApiOkResponse({ description: 'Updated host' }) + @ApiUnauthorizedResponse({ description: 'Missing or invalid token' }) + @ApiForbiddenResponse({ description: 'Requires the ADMIN role' }) + async updateDisplayName( + @Param('id') id: string, + @Body() body: UpdateHostDto, + ) { + const result = await this.hostsService.updateDisplayName( + id, + body.displayName, + ); + if (!result) { + throw new NotFoundException(`Host not found: ${id}`); + } + return result; + } + @Get(':id/metrics') @ApiOperation({ summary: 'Latest CPU and memory usage reported by a host' }) @ApiOkResponse({ description: 'Host resource usage snapshot' }) diff --git a/apps/server/src/hosts/hosts.service.spec.ts b/apps/server/src/hosts/hosts.service.spec.ts new file mode 100644 index 0000000..d05524c --- /dev/null +++ b/apps/server/src/hosts/hosts.service.spec.ts @@ -0,0 +1,85 @@ +import { AgentsService } from '../agents/agents.service'; +import { AgentsGateway } from '../agents/agents.gateway'; +import { ContainerInventoryService } from '../agents/container-inventory.service'; +import { HostMetricsService } from '../metrics/host-metrics.service'; +import { HostsService } from './hosts.service'; +import type { Agent } from '../../generated/prisma/client'; + +describe('HostsService', () => { + const lastSeen = new Date('2026-08-24T10:00:00.000Z'); + + function makeAgent(overrides: Partial = {}): Agent { + return { + id: 'host-1', + uuid: 'uuid-1', + hostname: 'ip-10-0-0-1', + displayName: null, + os: 'linux', + architecture: 'x64', + version: '1.0.0', + status: 'ONLINE', + lastSeen, + createdAt: lastSeen, + updatedAt: lastSeen, + ...overrides, + }; + } + + function makeService(agents: Partial) { + return new HostsService( + agents as AgentsService, + { rememberHost: jest.fn() } as unknown as ContainerInventoryService, + {} as AgentsGateway, + { + rememberHost: jest.fn(), + getByHostId: jest.fn().mockReturnValue(null), + } as unknown as HostMetricsService, + ); + } + + it('falls back to hostname when no display name is set', async () => { + const service = makeService({ + findAll: jest.fn().mockResolvedValue([makeAgent()]), + }); + + const [host] = await service.listHosts(); + + expect(host.hostname).toBe('ip-10-0-0-1'); + expect(host.displayName).toBe('ip-10-0-0-1'); + }); + + it('returns the stored display name when set', async () => { + const service = makeService({ + findAll: jest + .fn() + .mockResolvedValue([makeAgent({ displayName: 'prod-web-1' })]), + }); + + const [host] = await service.listHosts(); + + expect(host.hostname).toBe('ip-10-0-0-1'); + expect(host.displayName).toBe('prod-web-1'); + }); + + it('persists a new display name', async () => { + const updated = makeAgent({ displayName: 'prod-web-1' }); + const updateDisplayName = jest.fn().mockResolvedValue(updated); + const service = makeService({ updateDisplayName }); + + const host = await service.updateDisplayName('host-1', 'prod-web-1'); + + expect(updateDisplayName).toHaveBeenCalledWith('host-1', 'prod-web-1'); + expect(host?.displayName).toBe('prod-web-1'); + expect(host?.hostname).toBe('ip-10-0-0-1'); + }); + + it('returns null when the host does not exist', async () => { + const service = makeService({ + updateDisplayName: jest.fn().mockResolvedValue(null), + }); + + await expect( + service.updateDisplayName('missing', 'prod-web-1'), + ).resolves.toBeNull(); + }); +}); diff --git a/apps/server/src/hosts/hosts.service.ts b/apps/server/src/hosts/hosts.service.ts index 18e8145..9774842 100644 --- a/apps/server/src/hosts/hosts.service.ts +++ b/apps/server/src/hosts/hosts.service.ts @@ -4,6 +4,7 @@ import type { HostCpuMetrics, HostMemoryMetrics, } from '@docksight/protocol'; +import type { Agent } from '../../generated/prisma/client'; import { AgentsGateway } from '../agents/agents.gateway'; import { AgentsService } from '../agents/agents.service'; import { ContainerInventoryService } from '../agents/container-inventory.service'; @@ -24,6 +25,8 @@ export type HostDto = { id: string; uuid: string; hostname: string; + /** Operator label, or hostname when none has been set. */ + displayName: string; os: string; architecture: string; version: string; @@ -59,17 +62,25 @@ export class HostsService { this.hostMetrics.rememberHost(agent.id, agent.uuid); } - return agents.map((agent) => ({ - id: agent.id, - uuid: agent.uuid, - hostname: agent.hostname, - os: agent.os, - architecture: agent.architecture, - version: agent.version, - status: agent.status, - lastSeen: agent.lastSeen ? agent.lastSeen.toISOString() : null, - metrics: toMetricsDto(agent.id, this.hostMetrics.getByHostId(agent.id)), - })); + return agents.map((agent) => + toHostDto(agent, this.hostMetrics.getByHostId(agent.id)), + ); + } + + async updateDisplayName( + hostId: string, + displayName: string, + ): Promise { + const updated = await this.agentsService.updateDisplayName( + hostId, + displayName, + ); + if (!updated) { + return null; + } + + this.hostMetrics.rememberHost(updated.id, updated.uuid); + return toHostDto(updated, this.hostMetrics.getByHostId(updated.id)); } /** @@ -109,6 +120,24 @@ export class HostsService { } } +function toHostDto( + agent: Agent, + snapshot: HostMetricsSnapshot | null, +): HostDto { + return { + id: agent.id, + uuid: agent.uuid, + hostname: agent.hostname, + displayName: agent.displayName?.trim() || agent.hostname, + os: agent.os, + architecture: agent.architecture, + version: agent.version, + status: agent.status, + lastSeen: agent.lastSeen ? agent.lastSeen.toISOString() : null, + metrics: toMetricsDto(agent.id, snapshot), + }; +} + function toMetricsDto( hostId: string, snapshot: HostMetricsSnapshot | null,