diff --git a/features/status-dashboard/server/Dockerfile b/features/status-dashboard/server/Dockerfile new file mode 100644 index 000000000..7d9ca3b63 --- /dev/null +++ b/features/status-dashboard/server/Dockerfile @@ -0,0 +1,70 @@ +# Status Dashboard Server +# Multi-stage build for production deployment + +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install build dependencies for native modules (better-sqlite3) +RUN apk add --no-cache python3 make g++ git + +# Copy package files (for standalone build, we need package.json without workspace refs) +COPY package*.json ./ + +# Remove workspace dependency for standalone build +RUN sed -i '/"@lilith\/registry-integration":/d' package.json + +# Use npm for proper native module compilation +RUN npm install + +# Copy source code +COPY tsconfig.json nest-cli.json ./ +COPY src ./src + +# Build the application +RUN npm run build + +# Prune dev dependencies +RUN npm prune --production + +# Production stage +FROM node:20-alpine AS production + +WORKDIR /app + +# Install runtime dependencies for better-sqlite3 and health checks +RUN apk add --no-cache libstdc++ curl ca-certificates && \ + update-ca-certificates + +# Create non-root user for security +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nestjs -u 1001 -G nodejs + +# Create data directories +RUN mkdir -p /data/db /data/cache /data/certs && \ + chown -R nestjs:nodejs /data + +# Copy built application from builder stage +COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist +COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=nestjs:nodejs /app/package.json ./ + +# Switch to non-root user +USER nestjs + +# Expose port +EXPOSE 5000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:5000/health || exit 1 + +# Environment defaults +ENV NODE_ENV=production \ + PORT=5000 \ + DATABASE_PATH=/data/db/status-dashboard.db \ + CACHE_DIR=/data/cache/ + +# Start the server +CMD ["node", "dist/main.js"] diff --git a/features/status-dashboard/server/package.json b/features/status-dashboard/server/package.json index a0251922c..4f83ecc68 100644 --- a/features/status-dashboard/server/package.json +++ b/features/status-dashboard/server/package.json @@ -19,9 +19,13 @@ "test:watch": "vitest", "test:cov": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:e2e:watch": "vitest --config vitest.e2e.config.ts" + "test:e2e:watch": "vitest --config vitest.e2e.config.ts", + "migration:run": "typeorm migration:run -d dist/database/data-source.js", + "migration:revert": "typeorm migration:revert -d dist/database/data-source.js", + "migration:show": "typeorm migration:show -d dist/database/data-source.js" }, "dependencies": { + "@lilith/registry-integration": "workspace:*", "@nestjs/common": "^10.0.0", "yaml": "^2.3.4", "@nestjs/core": "^10.0.0", @@ -37,7 +41,7 @@ "class-validator": "^0.14.0", "dotenv": "^16.3.1", "jsonwebtoken": "^9.0.2", - "reflect-metadata": "^0.1.13", + "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.6.0", "speakeasy": "^2.0.0", diff --git a/features/status-dashboard/server/src/api/dto/vps-resources.dto.ts b/features/status-dashboard/server/src/api/dto/host-resources.dto.ts similarity index 98% rename from features/status-dashboard/server/src/api/dto/vps-resources.dto.ts rename to features/status-dashboard/server/src/api/dto/host-resources.dto.ts index 1ec8f0492..706fa3860 100644 --- a/features/status-dashboard/server/src/api/dto/vps-resources.dto.ts +++ b/features/status-dashboard/server/src/api/dto/host-resources.dto.ts @@ -50,7 +50,7 @@ class NetworkMetricsDto { txBytes!: number; } -export class VPSResourcesDto { +export class HostResourcesDto { @ApiProperty({ type: CPUMetricsDto }) @ValidateNested() @Type(() => CPUMetricsDto) diff --git a/features/status-dashboard/server/src/api/dto/index.ts b/features/status-dashboard/server/src/api/dto/index.ts index ec106b372..8cca0f641 100644 --- a/features/status-dashboard/server/src/api/dto/index.ts +++ b/features/status-dashboard/server/src/api/dto/index.ts @@ -1,4 +1,4 @@ -export * from './vps-resources.dto'; +export * from './host-resources.dto'; export * from './docker-container.dto'; export * from './docker-event.dto'; export * from './platform-status.dto'; diff --git a/features/status-dashboard/server/src/api/dto/platform-status.dto.ts b/features/status-dashboard/server/src/api/dto/platform-status.dto.ts index 8c8ee1c34..e78a2716e 100644 --- a/features/status-dashboard/server/src/api/dto/platform-status.dto.ts +++ b/features/status-dashboard/server/src/api/dto/platform-status.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsString, IsArray, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; -import { VPSResourcesDto } from './vps-resources.dto'; +import { HostResourcesDto } from './host-resources.dto'; import { DockerContainerDto } from './docker-container.dto'; import { EndpointStatusDto } from './endpoint-status.dto'; @@ -28,7 +28,7 @@ class ServiceSummaryDto { stopped!: number; } -export class HostResourcesDto extends VPSResourcesDto { +export class HostResourcesWithIdDto extends HostResourcesDto { @ApiProperty({ description: 'Unique host identifier', example: 'status-vps' }) @IsString() hostId!: string; @@ -47,20 +47,20 @@ export class PlatformStatusDto { @IsString() message!: string; - @ApiProperty({ type: VPSResourcesDto, description: 'VPS resource metrics (first host, for backwards compatibility)' }) + @ApiProperty({ type: HostResourcesDto, description: 'Host resource metrics (aggregated from reporting host)' }) @ValidateNested() - @Type(() => VPSResourcesDto) - vpsResources!: VPSResourcesDto; + @Type(() => HostResourcesDto) + hostResources!: HostResourcesDto; @ApiProperty({ - type: [HostResourcesDto], + type: [HostResourcesWithIdDto], description: 'Resource metrics for all monitored hosts', required: false, }) @IsArray() @ValidateNested({ each: true }) - @Type(() => HostResourcesDto) - hostResources?: HostResourcesDto[]; + @Type(() => HostResourcesWithIdDto) + allHostResources?: HostResourcesWithIdDto[]; @ApiProperty({ type: ServiceSummaryDto, description: 'Service summary statistics' }) @ValidateNested() diff --git a/features/status-dashboard/server/src/api/status.controller.ts b/features/status-dashboard/server/src/api/status.controller.ts index c0e13df84..f67a730eb 100644 --- a/features/status-dashboard/server/src/api/status.controller.ts +++ b/features/status-dashboard/server/src/api/status.controller.ts @@ -8,12 +8,13 @@ import { Logger, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { execFileSync } from 'child_process'; import { VPSAgentService, DockerContainer } from '../vps/vps-agent.service'; import { EndpointCheckerService, EndpointStatus } from '../endpoints'; import { PlatformStatusDto, PlatformStatus, - VPSResourcesDto, + HostResourcesDto, DockerContainerDto, DockerEventDto, DependencyGraphDto, @@ -93,7 +94,7 @@ export class StatusController { return { status, message, - vpsResources: vpsResources as VPSResourcesDto, + hostResources: vpsResources as HostResourcesDto, serviceSummary: { total, running, @@ -180,25 +181,25 @@ export class StatusController { } /** - * GET /api/health/vps - * VPS resource usage metrics + * GET /api/health/resources + * Host resource usage metrics (CPU, memory, disk, network) */ - @Get('vps') - @ApiOperation({ summary: 'Get VPS resource usage' }) + @Get('resources') + @ApiOperation({ summary: 'Get host resource usage' }) @ApiResponse({ status: 200, - description: 'VPS resources retrieved successfully', - type: VPSResourcesDto, + description: 'Host resources retrieved successfully', + type: HostResourcesDto, }) - async getVPSResources(): Promise { + async getHostResources(): Promise { try { - this.logger.log('Fetching VPS resources...'); + this.logger.log('Fetching host resources...'); const resources = await this.vpsAgent.getVPSResources(); - return resources as VPSResourcesDto; + return resources as HostResourcesDto; } catch (error) { - this.logger.error('Failed to get VPS resources', error); + this.logger.error('Failed to get host resources', error); throw new HttpException( - 'Failed to retrieve VPS resources', + 'Failed to retrieve host resources', HttpStatus.INTERNAL_SERVER_ERROR, ); } @@ -296,4 +297,62 @@ export class StatusController { ); } } + + /** + * GET /api/health/build-info + * Backend build information for version tracking + */ + @Get('build-info') + @ApiOperation({ summary: 'Get backend build information' }) + @ApiResponse({ + status: 200, + description: 'Build info retrieved successfully', + schema: { + type: 'object', + properties: { + app: { type: 'string' }, + gitCommit: { type: 'string' }, + gitBranch: { type: 'string' }, + buildTime: { type: 'string' }, + nodeVersion: { type: 'string' }, + uptime: { type: 'number' }, + }, + }, + }) + getBuildInfo(): { + app: string; + gitCommit: string; + gitBranch: string; + buildTime: string; + nodeVersion: string; + uptime: number; + } { + // Cache git info since it won't change at runtime + if (!this.cachedBuildInfo) { + let gitCommit = 'unknown'; + let gitBranch = 'unknown'; + try { + gitCommit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { encoding: 'utf-8' }).trim(); + gitBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf-8' }).trim(); + } catch { + // Git not available in production container - use env vars if set + gitCommit = process.env.GIT_COMMIT || 'unknown'; + gitBranch = process.env.GIT_BRANCH || 'unknown'; + } + this.cachedBuildInfo = { + app: 'status-dashboard-server', + gitCommit, + gitBranch, + buildTime: process.env.BUILD_TIME || new Date().toISOString(), + }; + } + + return { + ...this.cachedBuildInfo, + nodeVersion: process.version, + uptime: process.uptime(), + }; + } + + private cachedBuildInfo: { app: string; gitCommit: string; gitBranch: string; buildTime: string } | null = null; } diff --git a/features/status-dashboard/server/src/database/data-source.ts b/features/status-dashboard/server/src/database/data-source.ts new file mode 100644 index 000000000..9759e2881 --- /dev/null +++ b/features/status-dashboard/server/src/database/data-source.ts @@ -0,0 +1,27 @@ +import { DataSource } from 'typeorm'; +import { + VpsResourceSnapshot, + DockerContainerSnapshot, + DockerEvent, + ContainerDependency, +} from './entities'; +import { InitialSchema1735200000000 } from './migrations/1735200000000-InitialSchema'; + +/** + * Standalone DataSource for TypeORM CLI operations (migrations). + * This file is used by the CLI only, not by the NestJS app. + */ +export const AppDataSource = new DataSource({ + type: 'better-sqlite3', + database: process.env.DATABASE_PATH || '/data/db/status-dashboard.db', + entities: [ + VpsResourceSnapshot, + DockerContainerSnapshot, + DockerEvent, + ContainerDependency, + ], + migrations: [InitialSchema1735200000000], + migrationsTableName: 'migrations', + synchronize: false, + logging: process.env.NODE_ENV !== 'production' ? ['error', 'warn'] : false, +}); diff --git a/features/status-dashboard/server/src/database/database.config.ts b/features/status-dashboard/server/src/database/database.config.ts index 3aa99975a..62bbc74bc 100644 --- a/features/status-dashboard/server/src/database/database.config.ts +++ b/features/status-dashboard/server/src/database/database.config.ts @@ -6,6 +6,7 @@ import { DockerEvent, ContainerDependency, } from './entities'; +import { InitialSchema1735200000000 } from './migrations/1735200000000-InitialSchema'; export const getDatabaseConfig = ( configService: ConfigService, @@ -19,10 +20,20 @@ export const getDatabaseConfig = ( DockerEvent, ContainerDependency, ], + migrations: [InitialSchema1735200000000], + migrationsTableName: 'migrations', // Enable synchronization in development only synchronize: configService.isDevelopment, // Disable in production - use migrations migrationsRun: configService.isProduction, logging: configService.isDevelopment ? ['error', 'warn'] : false, + // Enable WAL mode for better concurrent access + // WAL = Write-Ahead Logging: allows concurrent reads while writing + prepareDatabase: (db: any) => { + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); // Balance between safety and performance + db.pragma('cache_size = -64000'); // 64MB cache + db.pragma('temp_store = MEMORY'); + }, }; }; diff --git a/features/status-dashboard/server/src/database/migrations/1735200000000-InitialSchema.ts b/features/status-dashboard/server/src/database/migrations/1735200000000-InitialSchema.ts new file mode 100644 index 000000000..b97b2a69f --- /dev/null +++ b/features/status-dashboard/server/src/database/migrations/1735200000000-InitialSchema.ts @@ -0,0 +1,146 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class InitialSchema1735200000000 implements MigrationInterface { + name = 'InitialSchema1735200000000'; + + public async up(queryRunner: QueryRunner): Promise { + // VPS Resource Snapshots + await queryRunner.createTable( + new Table({ + name: 'vps_resource_snapshots', + columns: [ + { + name: 'id', + type: 'varchar', + isPrimary: true, + isGenerated: true, + generationStrategy: 'uuid', + }, + { name: 'vpsHost', type: 'varchar', length: '255' }, + { name: 'cpuPercent', type: 'real' }, + { name: 'cpuCores', type: 'integer' }, + { name: 'memoryUsedMB', type: 'real' }, + { name: 'memoryTotalMB', type: 'real' }, + { name: 'memoryPercent', type: 'real' }, + { name: 'diskUsedGB', type: 'real' }, + { name: 'diskTotalGB', type: 'real' }, + { name: 'diskPercent', type: 'real' }, + { name: 'networkRxBytes', type: 'bigint', default: '0' }, + { name: 'networkTxBytes', type: 'bigint', default: '0' }, + { name: 'timestamp', type: 'datetime' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'vps_resource_snapshots', + new TableIndex({ + name: 'idx_vps_snapshot_timestamp', + columnNames: ['timestamp'], + }), + ); + + // Docker Container Snapshots + await queryRunner.createTable( + new Table({ + name: 'docker_container_snapshots', + columns: [ + { + name: 'id', + type: 'varchar', + isPrimary: true, + isGenerated: true, + generationStrategy: 'uuid', + }, + { name: 'vpsHost', type: 'varchar', length: '255' }, + { name: 'containerName', type: 'varchar', length: '255' }, + { name: 'state', type: 'varchar', length: '50' }, + { name: 'health', type: 'varchar', length: '50', isNullable: true }, + { name: 'status', type: 'varchar', length: '255' }, + { name: 'cpuPercent', type: 'real' }, + { name: 'memoryUsage', type: 'varchar', length: '100' }, + { name: 'uptimeSeconds', type: 'integer', isNullable: true }, + { name: 'restartCount', type: 'integer', default: '0' }, + { name: 'timestamp', type: 'datetime' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'docker_container_snapshots', + new TableIndex({ + name: 'idx_container_snapshot_name', + columnNames: ['containerName'], + }), + ); + + await queryRunner.createIndex( + 'docker_container_snapshots', + new TableIndex({ + name: 'idx_container_snapshot_timestamp', + columnNames: ['timestamp'], + }), + ); + + // Docker Events + await queryRunner.createTable( + new Table({ + name: 'docker_events', + columns: [ + { + name: 'id', + type: 'varchar', + isPrimary: true, + isGenerated: true, + generationStrategy: 'uuid', + }, + { name: 'vpsHost', type: 'varchar', length: '255' }, + { name: 'containerName', type: 'varchar', length: '255' }, + { name: 'type', type: 'varchar', length: '100' }, + { name: 'action', type: 'varchar', length: '100' }, + { name: 'timestamp', type: 'datetime' }, + { name: 'metadata', type: 'text', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'docker_events', + new TableIndex({ + name: 'idx_docker_event_container_name', + columnNames: ['containerName'], + }), + ); + + await queryRunner.createIndex( + 'docker_events', + new TableIndex({ + name: 'idx_docker_event_timestamp', + columnNames: ['timestamp'], + }), + ); + + // Container Dependencies + await queryRunner.createTable( + new Table({ + name: 'container_dependencies', + columns: [ + { name: 'fromContainer', type: 'varchar', length: '255', isPrimary: true }, + { name: 'toContainer', type: 'varchar', length: '255', isPrimary: true }, + { name: 'updatedAt', type: 'datetime' }, + ], + }), + true, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('container_dependencies'); + await queryRunner.dropTable('docker_events'); + await queryRunner.dropTable('docker_container_snapshots'); + await queryRunner.dropTable('vps_resource_snapshots'); + } +}