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:
parent
6d3d76d06c
commit
6331ec12ea
9 changed files with 342 additions and 25 deletions
70
features/status-dashboard/server/Dockerfile
Normal file
70
features/status-dashboard/server/Dockerfile
Normal 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"]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class NetworkMetricsDto {
|
|||
txBytes!: number;
|
||||
}
|
||||
|
||||
export class VPSResourcesDto {
|
||||
export class HostResourcesDto {
|
||||
@ApiProperty({ type: CPUMetricsDto })
|
||||
@ValidateNested()
|
||||
@Type(() => CPUMetricsDto)
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
27
features/status-dashboard/server/src/database/data-source.ts
Normal file
27
features/status-dashboard/server/src/database/data-source.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue