fix(status-dashboard): add migrations, rename VPS→Host API

Root cause fixes for Apricot showing as "down":
- Create TypeORM migrations (production mode requires them)
- Tables: vps_resource_snapshots, docker_container_snapshots,
  docker_events, container_dependencies
- Add data-source.ts for TypeORM CLI operations

API naming alignment (host isn't a VPS):
- Rename /api/health/vps → /api/health/resources
- Rename VPSResourcesDto → HostResourcesDto
- Rename vps-resources.dto.ts → host-resources.dto.ts

Infrastructure:
- Add Dockerfile with curl, ca-certificates for health checks
- Add npm migration scripts to package.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-26 00:29:27 -08:00
parent 6d3d76d06c
commit 6331ec12ea
9 changed files with 342 additions and 25 deletions

View file

@ -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"]

View file

@ -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",

View file

@ -50,7 +50,7 @@ class NetworkMetricsDto {
txBytes!: number;
}
export class VPSResourcesDto {
export class HostResourcesDto {
@ApiProperty({ type: CPUMetricsDto })
@ValidateNested()
@Type(() => CPUMetricsDto)

View file

@ -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';

View file

@ -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()

View file

@ -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<VPSResourcesDto> {
async getHostResources(): Promise<HostResourcesDto> {
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;
}

View file

@ -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,
});

View file

@ -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');
},
};
};

View file

@ -0,0 +1,146 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class InitialSchema1735200000000 implements MigrationInterface {
name = 'InitialSchema1735200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
await queryRunner.dropTable('container_dependencies');
await queryRunner.dropTable('docker_events');
await queryRunner.dropTable('docker_container_snapshots');
await queryRunner.dropTable('vps_resource_snapshots');
}
}