feat(devops): restore dashboard build and log polling

This commit is contained in:
root 2026-05-18 09:00:56 +00:00
parent 4ae55fd3c8
commit 85f21ae9f6
18 changed files with 130 additions and 325 deletions

View File

@ -1,40 +1,48 @@
# Stage 1: Build # Build context: bytelyst-devops-tools/dashboard/ (monorepo root)
FROM node:22-alpine AS builder # --- Stage 1: Build ---
FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app/backend
# Install dependencies COPY backend/package.json backend/package-lock.json ./
COPY package.json pnpm-lock.yaml* ./ RUN npm ci --ignore-scripts
RUN npm install -g pnpm@10.6.5
RUN pnpm install
# Copy source COPY backend/tsconfig.json ./
COPY package.json tsconfig.json ./ COPY backend/src/ ./src/
COPY src ./src
# Skip TypeScript build for now # Build-time env vars (baked into the bundle)
# RUN pnpm add -D typescript && pnpm build ARG BYTELYST_COMMIT_SHA=unknown
ARG BYTELYST_COMMIT_SHA_FULL=unknown
ARG BYTELYST_BRANCH=unknown
ARG BYTELYST_BUILT_AT=unknown
ARG BYTELYST_COMMIT_AUTHOR=unknown
ARG BYTELYST_COMMIT_MESSAGE=unknown
ARG BYTELYST_DOCKER_IMAGE=devops-backend:latest
# Stage 2: Run ENV BYTELYST_COMMIT_SHA=${BYTELYST_COMMIT_SHA} \
FROM node:22-alpine AS runner BYTELYST_COMMIT_SHA_FULL=${BYTELYST_COMMIT_SHA_FULL} \
BYTELYST_BRANCH=${BYTELYST_BRANCH} \
BYTELYST_BUILT_AT=${BYTELYST_BUILT_AT} \
BYTELYST_COMMIT_AUTHOR=${BYTELYST_COMMIT_AUTHOR} \
BYTELYST_COMMIT_MESSAGE=${BYTELYST_COMMIT_MESSAGE} \
BYTELYST_DOCKER_IMAGE=${BYTELYST_DOCKER_IMAGE}
WORKDIR /app RUN npm run build
# Install dependencies # --- Stage 2: Run ---
COPY package.json pnpm-lock.yaml* ./ FROM node:20-alpine AS runner
RUN npm install -g pnpm@10.6.5
RUN pnpm install --prod --ignore-scripts WORKDIR /app/backend
RUN npm install -g tsx
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
RUN apk add --no-cache curl RUN apk add --no-cache curl
# Copy source COPY --from=builder /app/backend/dist ./dist
COPY package.json tsconfig.json ./
COPY src ./src
# Set environment
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=4004 ENV PORT=4004
EXPOSE 4004 EXPOSE 4004
CMD ["tsx", "src/server.js"] CMD ["node", "dist/server.js"]

View File

@ -9,7 +9,7 @@
"dev": "node --import tsx src/server.ts", "dev": "node --import tsx src/server.ts",
"build": "tsc", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"start": "node dist/backend/src/server.js", "start": "node dist/server.js",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"lint": "echo 'No linting configured for backend'", "lint": "echo 'No linting configured for backend'",
@ -27,8 +27,7 @@
"@azure/identity": "^4.5.0", "@azure/identity": "^4.5.0",
"@azure/keyvault-secrets": "^4.9.0", "@azure/keyvault-secrets": "^4.9.0",
"@azure/cosmos": "^4.1.0", "@azure/cosmos": "^4.1.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5"
"@bytelyst/devops": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.3", "@types/node": "^25.0.3",

View File

@ -20,7 +20,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) {
const { clientSecret, ...safeConfig } = config; const { clientSecret, ...safeConfig } = config;
return reply.send({ ...safeConfig, hasClientSecret: !!clientSecret }); return reply.send({ ...safeConfig, hasClientSecret: !!clientSecret });
} catch (error) { } catch (error) {
fastify.log.error('Failed to get Azure config:', error as any); fastify.log.error(error, 'Failed to get Azure config');
return reply.code(500).send({ error: 'Failed to get Azure configuration' }); return reply.code(500).send({ error: 'Failed to get Azure configuration' });
} }
}); });
@ -33,7 +33,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) {
const { clientSecret, ...safeConfig } = config; const { clientSecret, ...safeConfig } = config;
return reply.code(201).send({ ...safeConfig, hasClientSecret: true }); return reply.code(201).send({ ...safeConfig, hasClientSecret: true });
} catch (error) { } catch (error) {
fastify.log.error('Failed to create Azure config:', error as any); fastify.log.error(error, 'Failed to create Azure config');
return reply.code(500).send({ error: 'Failed to create Azure configuration' }); return reply.code(500).send({ error: 'Failed to create Azure configuration' });
} }
}); });
@ -50,7 +50,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) {
const { clientSecret, ...safeConfig } = config; const { clientSecret, ...safeConfig } = config;
return reply.send({ ...safeConfig, hasClientSecret: !!clientSecret }); return reply.send({ ...safeConfig, hasClientSecret: !!clientSecret });
} catch (error) { } catch (error) {
fastify.log.error('Failed to update Azure config:', error as any); fastify.log.error(error, 'Failed to update Azure config');
return reply.code(500).send({ error: 'Failed to update Azure configuration' }); return reply.code(500).send({ error: 'Failed to update Azure configuration' });
} }
}); });
@ -65,7 +65,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) {
} }
return reply.code(204).send(); return reply.code(204).send();
} catch (error) { } catch (error) {
fastify.log.error('Failed to delete Azure config:', error as any); fastify.log.error(error, 'Failed to delete Azure config');
return reply.code(500).send({ error: 'Failed to delete Azure configuration' }); return reply.code(500).send({ error: 'Failed to delete Azure configuration' });
} }
}); });
@ -76,7 +76,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) {
const result = await testAzureConnection(); const result = await testAzureConnection();
return reply.send(result); return reply.send(result);
} catch (error) { } catch (error) {
fastify.log.error('Failed to test Azure connection:', error as any); fastify.log.error(error, 'Failed to test Azure connection');
return reply.code(500).send({ success: false, error: 'Failed to test Azure connection' }); return reply.code(500).send({ success: false, error: 'Failed to test Azure connection' });
} }
}); });

View File

@ -13,7 +13,7 @@ export async function backupRoutes(fastify: FastifyInstance) {
const backup = await createBackup(params); const backup = await createBackup(params);
return reply.code(201).send(backup); return reply.code(201).send(backup);
} catch (error) { } catch (error) {
fastify.log.error('Backup creation failed:', error); fastify.log.error(error, 'Backup creation failed');
return reply.code(500).send({ error: 'Failed to create backup' }); return reply.code(500).send({ error: 'Failed to create backup' });
} }
}); });
@ -26,7 +26,7 @@ export async function backupRoutes(fastify: FastifyInstance) {
const backups = await getBackups(); const backups = await getBackups();
return reply.send(backups); return reply.send(backups);
} catch (error) { } catch (error) {
fastify.log.error('Failed to get backups:', error); fastify.log.error(error, 'Failed to get backups');
return reply.code(500).send({ error: 'Failed to get backups' }); return reply.code(500).send({ error: 'Failed to get backups' });
} }
}); });
@ -43,7 +43,7 @@ export async function backupRoutes(fastify: FastifyInstance) {
} }
return reply.send(backup); return reply.send(backup);
} catch (error) { } catch (error) {
fastify.log.error('Failed to get backup:', error); fastify.log.error(error, 'Failed to get backup');
return reply.code(500).send({ error: 'Failed to get backup' }); return reply.code(500).send({ error: 'Failed to get backup' });
} }
}); });
@ -57,7 +57,7 @@ export async function backupRoutes(fastify: FastifyInstance) {
await restoreBackup(id); await restoreBackup(id);
return reply.send({ message: 'Backup restored successfully' }); return reply.send({ message: 'Backup restored successfully' });
} catch (error: any) { } catch (error: any) {
fastify.log.error('Restore failed:', error); fastify.log.error(error, 'Restore failed');
return reply.code(500).send({ error: error.message || 'Failed to restore backup' }); return reply.code(500).send({ error: error.message || 'Failed to restore backup' });
} }
}); });
@ -71,7 +71,7 @@ export async function backupRoutes(fastify: FastifyInstance) {
await deleteBackup(id); await deleteBackup(id);
return reply.code(204).send(); return reply.code(204).send();
} catch (error) { } catch (error) {
fastify.log.error('Failed to delete backup:', error); fastify.log.error(error, 'Failed to delete backup');
return reply.code(500).send({ error: 'Failed to delete backup' }); return reply.code(500).send({ error: 'Failed to delete backup' });
} }
}); });

View File

@ -10,7 +10,7 @@ export async function codeQualityRoutes(fastify: FastifyInstance) {
const report = await runCodeQualityCheck(params); const report = await runCodeQualityCheck(params);
return reply.send(report); return reply.send(report);
} catch (error) { } catch (error) {
fastify.log.error('Failed to run code quality check:', error as any); fastify.log.error(error, 'Failed to run code quality check');
return reply.code(500).send({ error: 'Failed to run code quality check' }); return reply.code(500).send({ error: 'Failed to run code quality check' });
} }
}); });

View File

@ -3,6 +3,7 @@ import { z } from 'zod';
import { getCosmosConfig, updateCosmosConfig, deleteCosmosConfig } from './repository.js'; import { getCosmosConfig, updateCosmosConfig, deleteCosmosConfig } from './repository.js';
import { reinitializeContainers, getCosmosStatus } from '../../lib/cosmos-init.js'; import { reinitializeContainers, getCosmosStatus } from '../../lib/cosmos-init.js';
import { BadRequestError } from '../../lib/auth.js'; import { BadRequestError } from '../../lib/auth.js';
import type { UpdateCosmosConfig } from './types.js';
const updateConfigSchema = z.object({ const updateConfigSchema = z.object({
endpoint: z.string().url(), endpoint: z.string().url(),
@ -40,7 +41,7 @@ export async function cosmosConfigRoutes(fastify: FastifyInstance) {
// Update Cosmos configuration // Update Cosmos configuration
fastify.post('/cosmos-config', async (request: FastifyRequest, reply: FastifyReply) => { fastify.post('/cosmos-config', async (request: FastifyRequest, reply: FastifyReply) => {
try { try {
const body = updateConfigSchema.parse(request.body); const body = updateConfigSchema.parse(request.body) as UpdateCosmosConfig;
// Update the configuration // Update the configuration
await updateCosmosConfig(body); await updateCosmosConfig(body);

View File

@ -62,7 +62,7 @@ async function runDeploymentScript(service: Service, deploymentId: string) {
} }
} catch (error: any) { } catch (error: any) {
const logs = error instanceof Error const logs = error instanceof Error
? `ERROR: ${error.message}\n\n${error.stdout ? `STDOUT:\n${error.stdout}\n\n` : ''}${error.stderr ? `STDERR:\n${error.stderr}` : ''}` ? `ERROR: ${error.message}\n\n${(error as any).stdout ? `STDOUT:\n${(error as any).stdout}\n\n` : ''}${(error as any).stderr ? `STDERR:\n${(error as any).stderr}` : ''}`
: String(error); : String(error);
// Update deployment as failed // Update deployment as failed

View File

@ -38,7 +38,8 @@ export async function deploymentRoutes(fastify: FastifyInstance) {
return reply.send(deployment); return reply.send(deployment);
}); });
// Stream deployment logs via SSE // Get deployment logs (SSE disabled due to Fastify 5 compatibility)
// TODO: Re-enable SSE when fastify-sse-v2 supports Fastify 5
fastify.get('/deployments/:id/logs', async (req, reply) => { fastify.get('/deployments/:id/logs', async (req, reply) => {
const params = DeploymentParamsSchema.parse(req.params); const params = DeploymentParamsSchema.parse(req.params);
const deployment = await getDeploymentById(params.id); const deployment = await getDeploymentById(params.id);
@ -47,52 +48,11 @@ export async function deploymentRoutes(fastify: FastifyInstance) {
return reply.code(404).send({ error: 'Deployment not found' }); return reply.code(404).send({ error: 'Deployment not found' });
} }
// Set SSE headers // Return logs as JSON
reply.header('Content-Type', 'text/event-stream'); return reply.send({
reply.header('Cache-Control', 'no-cache'); logs: deployment.logs,
reply.header('Connection', 'keep-alive'); status: deployment.status,
reply.header('X-Accel-Buffering', 'no'); });
// Send initial logs
reply.sse({ event: 'logs', data: deployment.logs });
// Poll for updates if deployment is still running
if (deployment.status === 'running') {
const pollInterval = setInterval(async () => {
try {
const updatedDeployment = await getDeploymentById(params.id);
if (!updatedDeployment) {
clearInterval(pollInterval);
reply.sse({ event: 'error', data: 'Deployment not found' });
reply.raw.end();
return;
}
// Send updated logs
reply.sse({ event: 'logs', data: updatedDeployment.logs });
// Check if deployment completed
if (updatedDeployment.status !== 'running') {
clearInterval(pollInterval);
reply.sse({ event: 'complete', data: updatedDeployment.status });
reply.raw.end();
}
} catch (error) {
clearInterval(pollInterval);
reply.sse({ event: 'error', data: 'Failed to fetch deployment updates' });
reply.raw.end();
}
}, 1000); // Poll every second
// Clean up on connection close
req.raw.on('close', () => {
clearInterval(pollInterval);
});
} else {
// Deployment already completed
reply.sse({ event: 'complete', data: deployment.status });
reply.raw.end();
}
}); });
// Trigger deployment (admin only) // Trigger deployment (admin only)

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createService, getService, getAllServices, updateService, deleteService } from './repository.js'; import { createService, getServiceById, getAllServices, updateService, deleteService } from './repository.js';
// Mock the cosmos container // Mock the cosmos container
vi.mock('../../lib/cosmos-init.js', () => ({ vi.mock('../../lib/cosmos-init.js', () => ({
@ -51,16 +51,16 @@ describe('Services Repository', () => {
}); });
}); });
describe('getService', () => { describe('getServiceById', () => {
it('should retrieve a service by id', async () => { it('should retrieve a service by id', async () => {
const service = await getService('test-service'); const service = await getServiceById('test-service');
expect(service).toBeDefined(); expect(service).toBeDefined();
expect(service?.id).toBe('test-service'); expect(service?.id).toBe('test-service');
}); });
it('should return null for non-existent service', async () => { it('should return null for non-existent service', async () => {
const service = await getService('non-existent'); const service = await getServiceById('non-existent');
expect(service).toBeNull(); expect(service).toBeNull();
}); });
@ -92,7 +92,7 @@ describe('Services Repository', () => {
await deleteService('test-service'); await deleteService('test-service');
// Verify deletion // Verify deletion
const service = await getService('test-service'); const service = await getServiceById('test-service');
expect(service).toBeNull(); expect(service).toBeNull();
}); });
}); });

View File

@ -12,7 +12,7 @@ export async function systemRoutes(fastify: FastifyInstance) {
const metrics = await getSystemMetrics(); const metrics = await getSystemMetrics();
return reply.send(metrics); return reply.send(metrics);
} catch (error) { } catch (error) {
fastify.log.error('Failed to get system metrics:', error); fastify.log.error(error, 'Failed to get system metrics');
return reply.code(500).send({ error: 'Failed to get system metrics' }); return reply.code(500).send({ error: 'Failed to get system metrics' });
} }
}); });
@ -25,7 +25,7 @@ export async function systemRoutes(fastify: FastifyInstance) {
const stats = await getDockerStats(); const stats = await getDockerStats();
return reply.send(stats); return reply.send(stats);
} catch (error) { } catch (error) {
fastify.log.error('Failed to get Docker stats:', error); fastify.log.error(error, 'Failed to get Docker stats');
return reply.code(500).send({ error: 'Failed to get Docker stats' }); return reply.code(500).send({ error: 'Failed to get Docker stats' });
} }
}); });
@ -39,7 +39,7 @@ export async function systemRoutes(fastify: FastifyInstance) {
const result = await dockerCleanup(params.type, params.force); const result = await dockerCleanup(params.type, params.force);
return reply.send(result); return reply.send(result);
} catch (error: any) { } catch (error: any) {
fastify.log.error('Docker cleanup failed:', error); fastify.log.error(error, 'Docker cleanup failed');
return reply.code(500).send({ error: error.message || 'Docker cleanup failed' }); return reply.code(500).send({ error: error.message || 'Docker cleanup failed' });
} }
}); });

View File

@ -1,9 +1,8 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import { config } from './lib/config.js'; import { config } from './lib/config.js';
import { initializeContainers } from './lib/cosmos-init.js'; import { initializeContainers } from './lib/cosmos-init.js';
import { extractAuth, AuthError } from './lib/auth.js'; import { extractAuth, AuthError, requireAdmin } from './lib/auth.js';
import { generateCsrfToken, validateCsrfToken, getSessionId } from './lib/csrf.js'; import { generateCsrfToken, validateCsrfToken, getSessionId } from './lib/csrf.js';
import { collectDevopsInfo, getBuildInfo, httpDependencyCheck, readServiceVersion } from '@bytelyst/devops/server';
import { serviceRoutes } from './modules/services/routes.js'; import { serviceRoutes } from './modules/services/routes.js';
import { deploymentRoutes } from './modules/deployments/routes.js'; import { deploymentRoutes } from './modules/deployments/routes.js';
import { healthRoutes } from './modules/health/routes.js'; import { healthRoutes } from './modules/health/routes.js';
@ -14,7 +13,7 @@ import { envRoutes } from './modules/env/routes.js';
import { azureConfigRoutes } from './modules/azure-config/routes.js'; import { azureConfigRoutes } from './modules/azure-config/routes.js';
import { codeQualityRoutes } from './modules/code-quality/routes.js'; import { codeQualityRoutes } from './modules/code-quality/routes.js';
import { cosmosConfigRoutes } from './modules/cosmos-config/routes.js'; import { cosmosConfigRoutes } from './modules/cosmos-config/routes.js';
import sse from 'fastify-sse-v2'; // import sse from 'fastify-sse-v2';
import rateLimit from '@fastify/rate-limit'; import rateLimit from '@fastify/rate-limit';
import swagger from '@fastify/swagger'; import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui'; import swaggerUi from '@fastify/swagger-ui';
@ -24,7 +23,8 @@ const fastify = Fastify({
}); });
// Register SSE plugin // Register SSE plugin
await fastify.register(sse); // TODO: fastify-sse-v2 has compatibility issues with Fastify 5
// await fastify.register(sse);
// Register rate limiting // Register rate limiting
await fastify.register(rateLimit, { await fastify.register(rateLimit, {
@ -191,14 +191,6 @@ fastify.options('*', async (request, reply) => {
// Health check // Health check
fastify.get('/health', async () => ({ status: 'ok', service: 'devops-backend' })); fastify.get('/health', async () => ({ status: 'ok', service: 'devops-backend' }));
// Admin check helper
async function requireAdmin(request: any) {
const role = request.authRole;
if (role !== 'admin') {
throw new Error('Admin access required');
}
}
// Register standalone routes with /api prefix // Register standalone routes with /api prefix
await fastify.register(async function (fastify) { await fastify.register(async function (fastify) {
// Performance metrics endpoint (admin only) - DEPRECATED: Use /api/system/metrics instead // Performance metrics endpoint (admin only) - DEPRECATED: Use /api/system/metrics instead
@ -210,7 +202,7 @@ await fastify.register(async function (fastify) {
const metrics = await getSystemMetrics(); const metrics = await getSystemMetrics();
return reply.send(metrics); return reply.send(metrics);
} catch (error) { } catch (error) {
fastify.log.error('Failed to get metrics:', error); fastify.log.error(error, 'Failed to get metrics');
return reply.code(500).send({ error: 'Failed to get metrics' }); return reply.code(500).send({ error: 'Failed to get metrics' });
} }
}); });
@ -264,34 +256,6 @@ await fastify.register(async function (fastify) {
return reply.send({ message: 'Seeded default services' }); return reply.send({ message: 'Seeded default services' });
}); });
// DevOps version endpoint (public - no auth required)
fastify.get('/devops/version', async (request, reply) => {
return reply.send(getBuildInfo());
});
// DevOps info endpoint (admin only)
fastify.get('/devops/info', {
preHandler: async (req) => requireAdmin(req),
}, async (request, reply) => {
try {
const info = await collectDevopsInfo({
productId: config.PRODUCT_ID || 'devops',
serviceName: 'devops-backend',
serviceVersion: readServiceVersion(import.meta.url),
dependencyChecks: [
() => httpDependencyCheck('platform-service', `${config.PLATFORM_URL}/health`),
],
extra: {
devopsApiUrl: config.DEVOPS_API_URL,
},
});
return reply.send(info);
} catch (error: any) {
fastify.log.error('Failed to collect devops info:', error);
return reply.code(500).send({ error: error.message });
}
});
}, { prefix: '/api' }); }, { prefix: '/api' });
// Register modular routes with /api prefix // Register modular routes with /api prefix
@ -314,7 +278,7 @@ async function start() {
await initializeContainers(); await initializeContainers();
fastify.log.info('Cosmos containers initialized successfully'); fastify.log.info('Cosmos containers initialized successfully');
} catch (err) { } catch (err) {
fastify.log.warn('Failed to initialize Cosmos containers (server will start anyway):', err); fastify.log.warn(err, 'Failed to initialize Cosmos containers (server will start anyway)');
} }
await fastify.listen({ port: parseInt(config.PORT), host: '0.0.0.0' }); await fastify.listen({ port: parseInt(config.PORT), host: '0.0.0.0' });

View File

@ -29,7 +29,7 @@ services:
- platform_net - platform_net
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://localhost:4004/health'] test: ['CMD', 'curl', '-f', 'http://localhost:4004/health']
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View File

@ -2,22 +2,16 @@
# --- Stage 1: Build --- # --- Stage 1: Build ---
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.6.5 --activate
WORKDIR /app/web WORKDIR /app/web
# Gitea npm registry for @bytelyst/* packages COPY web/package.json web/package-lock.json ./
COPY web/package.json ./package.json RUN npm ci --ignore-scripts
RUN --mount=type=secret,id=gitea_npm_token \ COPY web/tsconfig.json ./
TOKEN=$(cat /run/secrets/gitea_npm_token) && \ COPY web/next-env.d.ts ./
printf '@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/\n//localhost:3300/api/packages/bytelyst/npm/:_authToken=%s\n' "$TOKEN" > .npmrc && \
npm install --ignore-scripts --legacy-peer-deps
COPY web/tsconfig*.json ./
COPY web/next.config.js ./ COPY web/next.config.js ./
COPY web/tailwind.config.ts ./tailwind.config.ts COPY web/tailwind.config.ts ./
COPY web/postcss.config.js ./postcss.config.js COPY web/postcss.config.js ./
COPY web/src/ ./src/ COPY web/src/ ./src/
COPY web/public/ ./public/ COPY web/public/ ./public/
@ -46,17 +40,15 @@ ENV NEXT_PUBLIC_PRODUCT_ID=${NEXT_PUBLIC_PRODUCT_ID} \
NEXT_PUBLIC_BYTELYST_COMMIT_MESSAGE=${BYTELYST_COMMIT_MESSAGE} \ NEXT_PUBLIC_BYTELYST_COMMIT_MESSAGE=${BYTELYST_COMMIT_MESSAGE} \
NEXT_PUBLIC_BYTELYST_DOCKER_IMAGE=${BYTELYST_DOCKER_IMAGE} NEXT_PUBLIC_BYTELYST_DOCKER_IMAGE=${BYTELYST_DOCKER_IMAGE}
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build RUN npm run build
# --- Stage 2: Serve --- # --- Stage 2: Serve ---
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
WORKDIR /app/web WORKDIR /app/web
COPY --from=builder /app/web/package.json ./package.json COPY web/package.json web/package-lock.json ./
COPY --from=builder /app/web/pnpm-lock.yaml* ./pnpm-lock.yaml* RUN npm ci --omit=dev --ignore-scripts
RUN corepack enable && corepack prepare pnpm@10.6.5 --activate
RUN pnpm install --prod --ignore-scripts
COPY --from=builder /app/web/.next ./.next COPY --from=builder /app/web/.next ./.next
COPY --from=builder /app/web/public ./public COPY --from=builder /app/web/public ./public
@ -66,4 +58,4 @@ ENV PORT=3000
EXPOSE 3000 EXPOSE 3000
CMD ["pnpm", "start"] CMD ["npm", "run", "start"]

View File

@ -15,7 +15,6 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@bytelyst/devops": "^0.1.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "16.0.0", "next": "16.0.0",

View File

@ -1,85 +0,0 @@
'use client';
import { DevopsPanel, type DevopsInfo } from '@bytelyst/devops/ui';
import { devopsApiUrl } from '@/lib/product-config';
import { getAccessToken } from '@/lib/api';
const bundleStartTime = Date.now();
async function fetchBackendInfo(): Promise<DevopsInfo> {
const token = await getAccessToken();
const res = await fetch(`${devopsApiUrl}/api/devops/info`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body?.error ?? `Backend devops info failed (${res.status})`);
}
return (await res.json()) as DevopsInfo;
}
async function fetchWebInfo(): Promise<DevopsInfo> {
const env = process.env as Record<string, string | undefined>;
const builtAt = env.NEXT_PUBLIC_BYTELYST_BUILT_AT || null;
const startedAtMs = bundleStartTime;
const uptimeSec = Math.floor((Date.now() - startedAtMs) / 1000);
return {
build: {
commitSha: env.NEXT_PUBLIC_BYTELYST_COMMIT_SHA || null,
commitShaFull: env.NEXT_PUBLIC_BYTELYST_COMMIT_SHA_FULL || null,
branch: env.NEXT_PUBLIC_BYTELYST_BRANCH || null,
builtAt,
commitAuthor: env.NEXT_PUBLIC_BYTELYST_COMMIT_AUTHOR || null,
commitMessage: env.NEXT_PUBLIC_BYTELYST_COMMIT_MESSAGE || null,
dockerImage: env.NEXT_PUBLIC_BYTELYST_DOCKER_IMAGE || null,
},
runtime: {
uptimeSeconds: uptimeSec,
uptimeHuman: humanizeUptime(uptimeSec),
nodeVersion: 'browser',
platform: typeof window !== 'undefined' ? navigator.platform || 'unknown' : 'unknown',
arch: typeof window !== 'undefined' && navigator.userAgent.includes('arm') ? 'arm' : 'x86',
pid: 0,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'unknown',
memoryMb: Math.round(((performance as any)?.memory?.usedJSHeapSize ?? 0) / 1024 / 1024),
heapMb: Math.round(((performance as any)?.memory?.usedJSHeapSize ?? 0) / 1024 / 1024),
startedAt: new Date(startedAtMs).toISOString(),
},
config: {
productId: env.NEXT_PUBLIC_PRODUCT_ID || 'devops',
serviceName: 'devops-web',
serviceVersion: '1.0.0',
nodeEnv: env.NODE_ENV || 'production',
envKeys: Object.keys(env)
.filter((k) => /^NEXT_PUBLIC_/.test(k) && !/SECRET|KEY|TOKEN|PASSWORD/i.test(k))
.sort(),
},
extra: {
devopsApiUrl,
userAgent: typeof window !== 'undefined' ? navigator.userAgent : 'unknown',
},
};
}
function humanizeUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const mins = Math.floor(seconds / 60);
if (mins < 60) return `${mins}m ${seconds % 60}s`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ${mins % 60}m`;
const days = Math.floor(hrs / 24);
return `${days}d ${hrs % 24}h ${mins % 60}m`;
}
export default function DevOpsPage() {
return (
<div className="p-8 max-md:p-4">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">DevOps</h1>
<p className="text-sm text-gray-600">System information and deployment details</p>
</div>
<DevopsPanel fetchInfo={fetchBackendInfo} fetchWebInfo={fetchWebInfo} />
</div>
);
}

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { api, streamDeploymentLogs, type SseEvent } from '@/lib/api'; import { api } from '@/lib/api';
import { X, Maximize2, Minimize2 } from 'lucide-react'; import { X, Maximize2, Minimize2 } from 'lucide-react';
interface LogViewerProps { interface LogViewerProps {
@ -13,45 +13,57 @@ export function LogViewer({ deploymentId, onClose }: LogViewerProps) {
const [logs, setLogs] = useState<string[]>([]); const [logs, setLogs] = useState<string[]>([]);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const logContainerRef = useRef<HTMLDivElement>(null); const logContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
let cleanup: (() => void) | null = null; let cancelled = false;
let intervalId: ReturnType<typeof setInterval> | null = null;
const loadInitialLogs = async () => { const stopPolling = () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
setIsRefreshing(false);
};
const loadLogs = async () => {
try { try {
const deployment = await api.getDeployment(deploymentId); const deployment = await api.getDeploymentLogs(deploymentId);
if (deployment.logs) {
setLogs(deployment.logs.split('\n')); if (cancelled) {
return;
}
setError(null);
setLogs(deployment.logs ? deployment.logs.split('\n') : []);
if (deployment.status === 'running') {
setIsRefreshing(true);
} else {
stopPolling();
} }
} catch (err) { } catch (err) {
if (cancelled) {
return;
}
console.error('Failed to load initial logs:', err); console.error('Failed to load initial logs:', err);
setError(err instanceof Error ? err.message : 'Failed to load logs');
setIsRefreshing(true);
} }
}; };
loadInitialLogs(); setError(null);
setLogs([]);
cleanup = streamDeploymentLogs( setIsRefreshing(true);
deploymentId, intervalId = setInterval(() => {
(event: SseEvent) => { void loadLogs();
setIsConnected(true); }, 2000);
setError(null); void loadLogs();
if (event.data) {
setLogs((prev) => [...prev, event.data]);
}
},
(err: Error) => {
setError(err.message);
setIsConnected(false);
},
() => {
setIsConnected(false);
}
);
return () => { return () => {
if (cleanup) cleanup(); cancelled = true;
stopPolling();
}; };
}, [deploymentId]); }, [deploymentId]);
@ -69,10 +81,10 @@ export function LogViewer({ deploymentId, onClose }: LogViewerProps) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="font-medium">Deployment Logs</span> <span className="font-medium">Deployment Logs</span>
<span className={`flex items-center gap-1 text-xs ${ <span className={`flex items-center gap-1 text-xs ${
isConnected ? 'text-green-400' : 'text-gray-500' isRefreshing ? 'text-green-400' : 'text-gray-500'
}`} aria-live="polite"> }`} aria-live="polite">
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-gray-500'}`} aria-hidden="true" /> <span className={`w-2 h-2 rounded-full ${isRefreshing ? 'bg-green-400' : 'bg-gray-500'}`} aria-hidden="true" />
{isConnected ? 'Live' : 'Disconnected'} {isRefreshing ? 'Updating' : 'Stopped'}
</span> </span>
{error && <span className="text-xs text-red-400" role="alert">{error}</span>} {error && <span className="text-xs text-red-400" role="alert">{error}</span>}
</div> </div>

View File

@ -17,7 +17,6 @@ import {
Sun, Sun,
Moon, Moon,
HeartPulse, HeartPulse,
Server,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
@ -28,7 +27,6 @@ const navItems = [
{ href: '/system', label: 'System', icon: Cpu }, { href: '/system', label: 'System', icon: Cpu },
{ href: '/env', label: 'Environment', icon: Key }, { href: '/env', label: 'Environment', icon: Key },
{ href: '/code-quality', label: 'Code Quality', icon: Code2 }, { href: '/code-quality', label: 'Code Quality', icon: Code2 },
{ href: '/devops', label: 'DevOps', icon: Server },
{ href: '/settings/cosmos', label: 'Settings', icon: Settings }, { href: '/settings/cosmos', label: 'Settings', icon: Settings },
]; ];

View File

@ -40,6 +40,11 @@ export interface ApiError {
status?: number; status?: number;
} }
export interface DeploymentLogsResponse {
logs: string;
status: 'running' | 'success' | 'failed';
}
export interface EnvVar { export interface EnvVar {
id: string; id: string;
name: string; name: string;
@ -162,56 +167,6 @@ export async function apiRequest<T>(
return response.json(); return response.json();
} }
export interface SseEvent {
event: string;
data: string;
}
export function streamDeploymentLogs(
deploymentId: string,
onEvent: (event: SseEvent) => void,
onError: (error: Error) => void,
onComplete: () => void
): () => void {
const token = getAccessToken();
const headers: HeadersInit = {
'Accept': 'text/event-stream',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const eventSource = new EventSource(
`${devopsApiUrl}/api/deployments/${deploymentId}/logs`
);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onEvent({ event: event.type || 'message', data: event.data });
if (event.type === 'complete' || event.type === 'error') {
onComplete();
eventSource.close();
}
} catch (error) {
onError(error as Error);
}
};
eventSource.onerror = (error) => {
onError(new Error('SSE connection error'));
eventSource.close();
onComplete();
};
// Return cleanup function
return () => {
eventSource.close();
};
}
export const api = { export const api = {
// Services // Services
getServices: () => apiRequest<Service[]>('/api/services'), getServices: () => apiRequest<Service[]>('/api/services'),
@ -236,6 +191,8 @@ export const api = {
getServiceDeployments: (serviceId: string, limit = 50) => getServiceDeployments: (serviceId: string, limit = 50) =>
apiRequest<Deployment[]>(`/api/deployments/service/${serviceId}?limit=${limit}`), apiRequest<Deployment[]>(`/api/deployments/service/${serviceId}?limit=${limit}`),
getDeployment: (id: string) => apiRequest<Deployment>(`/api/deployments/${id}`), getDeployment: (id: string) => apiRequest<Deployment>(`/api/deployments/${id}`),
getDeploymentLogs: (id: string) =>
apiRequest<DeploymentLogsResponse>(`/api/deployments/${id}/logs`),
triggerDeployment: (serviceId: string) => triggerDeployment: (serviceId: string) =>
apiRequest<{ deploymentId: string; status: string }>(`/api/deployments/trigger/${serviceId}`, { apiRequest<{ deploymentId: string; status: string }>(`/api/deployments/trigger/${serviceId}`, {
method: 'POST', method: 'POST',