diff --git a/dashboard/backend/Dockerfile b/dashboard/backend/Dockerfile index aab5719..4b47235 100644 --- a/dashboard/backend/Dockerfile +++ b/dashboard/backend/Dockerfile @@ -1,40 +1,48 @@ -# Stage 1: Build -FROM node:22-alpine AS builder +# Build context: bytelyst-devops-tools/dashboard/ (monorepo root) +# --- Stage 1: Build --- +FROM node:20-alpine AS builder -WORKDIR /app +WORKDIR /app/backend -# Install dependencies -COPY package.json pnpm-lock.yaml* ./ -RUN npm install -g pnpm@10.6.5 -RUN pnpm install +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci --ignore-scripts -# Copy source -COPY package.json tsconfig.json ./ -COPY src ./src +COPY backend/tsconfig.json ./ +COPY backend/src/ ./src/ -# Skip TypeScript build for now -# RUN pnpm add -D typescript && pnpm build +# Build-time env vars (baked into the bundle) +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 -FROM node:22-alpine AS runner +ENV BYTELYST_COMMIT_SHA=${BYTELYST_COMMIT_SHA} \ + 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 -COPY package.json pnpm-lock.yaml* ./ -RUN npm install -g pnpm@10.6.5 -RUN pnpm install --prod --ignore-scripts -RUN npm install -g tsx +# --- Stage 2: Run --- +FROM node:20-alpine AS runner + +WORKDIR /app/backend + +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts RUN apk add --no-cache curl -# Copy source -COPY package.json tsconfig.json ./ -COPY src ./src +COPY --from=builder /app/backend/dist ./dist -# Set environment ENV NODE_ENV=production ENV PORT=4004 EXPOSE 4004 -CMD ["tsx", "src/server.js"] +CMD ["node", "dist/server.js"] diff --git a/dashboard/backend/package.json b/dashboard/backend/package.json index 48082fd..b4beb70 100644 --- a/dashboard/backend/package.json +++ b/dashboard/backend/package.json @@ -9,7 +9,7 @@ "dev": "node --import tsx src/server.ts", "build": "tsc", "typecheck": "tsc --noEmit", - "start": "node dist/backend/src/server.js", + "start": "node dist/server.js", "test": "vitest", "test:run": "vitest run", "lint": "echo 'No linting configured for backend'", @@ -27,8 +27,7 @@ "@azure/identity": "^4.5.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/cosmos": "^4.1.0", - "dotenv": "^16.4.5", - "@bytelyst/devops": "workspace:*" + "dotenv": "^16.4.5" }, "devDependencies": { "@types/node": "^25.0.3", diff --git a/dashboard/backend/src/modules/azure-config/routes.ts b/dashboard/backend/src/modules/azure-config/routes.ts index ef5e221..22296d4 100644 --- a/dashboard/backend/src/modules/azure-config/routes.ts +++ b/dashboard/backend/src/modules/azure-config/routes.ts @@ -20,7 +20,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) { const { clientSecret, ...safeConfig } = config; return reply.send({ ...safeConfig, hasClientSecret: !!clientSecret }); } 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' }); } }); @@ -33,7 +33,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) { const { clientSecret, ...safeConfig } = config; return reply.code(201).send({ ...safeConfig, hasClientSecret: true }); } 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' }); } }); @@ -50,7 +50,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) { const { clientSecret, ...safeConfig } = config; return reply.send({ ...safeConfig, hasClientSecret: !!clientSecret }); } 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' }); } }); @@ -65,7 +65,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) { } return reply.code(204).send(); } 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' }); } }); @@ -76,7 +76,7 @@ export async function azureConfigRoutes(fastify: FastifyInstance) { const result = await testAzureConnection(); return reply.send(result); } 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' }); } }); diff --git a/dashboard/backend/src/modules/backup/routes.ts b/dashboard/backend/src/modules/backup/routes.ts index c7f9523..afdfcf1 100644 --- a/dashboard/backend/src/modules/backup/routes.ts +++ b/dashboard/backend/src/modules/backup/routes.ts @@ -13,7 +13,7 @@ export async function backupRoutes(fastify: FastifyInstance) { const backup = await createBackup(params); return reply.code(201).send(backup); } 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' }); } }); @@ -26,7 +26,7 @@ export async function backupRoutes(fastify: FastifyInstance) { const backups = await getBackups(); return reply.send(backups); } 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' }); } }); @@ -43,7 +43,7 @@ export async function backupRoutes(fastify: FastifyInstance) { } return reply.send(backup); } 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' }); } }); @@ -57,7 +57,7 @@ export async function backupRoutes(fastify: FastifyInstance) { await restoreBackup(id); return reply.send({ message: 'Backup restored successfully' }); } 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' }); } }); @@ -71,7 +71,7 @@ export async function backupRoutes(fastify: FastifyInstance) { await deleteBackup(id); return reply.code(204).send(); } 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' }); } }); diff --git a/dashboard/backend/src/modules/code-quality/routes.ts b/dashboard/backend/src/modules/code-quality/routes.ts index cb26984..5f75318 100644 --- a/dashboard/backend/src/modules/code-quality/routes.ts +++ b/dashboard/backend/src/modules/code-quality/routes.ts @@ -10,7 +10,7 @@ export async function codeQualityRoutes(fastify: FastifyInstance) { const report = await runCodeQualityCheck(params); return reply.send(report); } 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' }); } }); diff --git a/dashboard/backend/src/modules/cosmos-config/routes.ts b/dashboard/backend/src/modules/cosmos-config/routes.ts index ea9226b..e5c114b 100644 --- a/dashboard/backend/src/modules/cosmos-config/routes.ts +++ b/dashboard/backend/src/modules/cosmos-config/routes.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { getCosmosConfig, updateCosmosConfig, deleteCosmosConfig } from './repository.js'; import { reinitializeContainers, getCosmosStatus } from '../../lib/cosmos-init.js'; import { BadRequestError } from '../../lib/auth.js'; +import type { UpdateCosmosConfig } from './types.js'; const updateConfigSchema = z.object({ endpoint: z.string().url(), @@ -40,7 +41,7 @@ export async function cosmosConfigRoutes(fastify: FastifyInstance) { // Update Cosmos configuration fastify.post('/cosmos-config', async (request: FastifyRequest, reply: FastifyReply) => { try { - const body = updateConfigSchema.parse(request.body); + const body = updateConfigSchema.parse(request.body) as UpdateCosmosConfig; // Update the configuration await updateCosmosConfig(body); diff --git a/dashboard/backend/src/modules/deployments/orchestrator.ts b/dashboard/backend/src/modules/deployments/orchestrator.ts index c6c82b7..5398248 100644 --- a/dashboard/backend/src/modules/deployments/orchestrator.ts +++ b/dashboard/backend/src/modules/deployments/orchestrator.ts @@ -62,7 +62,7 @@ async function runDeploymentScript(service: Service, deploymentId: string) { } } catch (error: any) { 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); // Update deployment as failed diff --git a/dashboard/backend/src/modules/deployments/routes.ts b/dashboard/backend/src/modules/deployments/routes.ts index a5c60b9..fcaae28 100644 --- a/dashboard/backend/src/modules/deployments/routes.ts +++ b/dashboard/backend/src/modules/deployments/routes.ts @@ -38,7 +38,8 @@ export async function deploymentRoutes(fastify: FastifyInstance) { 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) => { const params = DeploymentParamsSchema.parse(req.params); 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' }); } - // Set SSE headers - reply.header('Content-Type', 'text/event-stream'); - reply.header('Cache-Control', 'no-cache'); - reply.header('Connection', 'keep-alive'); - 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(); - } + // Return logs as JSON + return reply.send({ + logs: deployment.logs, + status: deployment.status, + }); }); // Trigger deployment (admin only) diff --git a/dashboard/backend/src/modules/services/services.test.ts b/dashboard/backend/src/modules/services/services.test.ts index d8c85cd..132ffcd 100644 --- a/dashboard/backend/src/modules/services/services.test.ts +++ b/dashboard/backend/src/modules/services/services.test.ts @@ -1,5 +1,5 @@ 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 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 () => { - const service = await getService('test-service'); + const service = await getServiceById('test-service'); expect(service).toBeDefined(); expect(service?.id).toBe('test-service'); }); it('should return null for non-existent service', async () => { - const service = await getService('non-existent'); + const service = await getServiceById('non-existent'); expect(service).toBeNull(); }); @@ -92,7 +92,7 @@ describe('Services Repository', () => { await deleteService('test-service'); // Verify deletion - const service = await getService('test-service'); + const service = await getServiceById('test-service'); expect(service).toBeNull(); }); }); diff --git a/dashboard/backend/src/modules/system/routes.ts b/dashboard/backend/src/modules/system/routes.ts index f56f7a1..94e3f52 100644 --- a/dashboard/backend/src/modules/system/routes.ts +++ b/dashboard/backend/src/modules/system/routes.ts @@ -12,7 +12,7 @@ export async function systemRoutes(fastify: FastifyInstance) { const metrics = await getSystemMetrics(); return reply.send(metrics); } 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' }); } }); @@ -25,7 +25,7 @@ export async function systemRoutes(fastify: FastifyInstance) { const stats = await getDockerStats(); return reply.send(stats); } 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' }); } }); @@ -39,7 +39,7 @@ export async function systemRoutes(fastify: FastifyInstance) { const result = await dockerCleanup(params.type, params.force); return reply.send(result); } 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' }); } }); diff --git a/dashboard/backend/src/server.ts b/dashboard/backend/src/server.ts index a877221..d4112c9 100644 --- a/dashboard/backend/src/server.ts +++ b/dashboard/backend/src/server.ts @@ -1,9 +1,8 @@ import Fastify from 'fastify'; import { config } from './lib/config.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 { collectDevopsInfo, getBuildInfo, httpDependencyCheck, readServiceVersion } from '@bytelyst/devops/server'; import { serviceRoutes } from './modules/services/routes.js'; import { deploymentRoutes } from './modules/deployments/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 { codeQualityRoutes } from './modules/code-quality/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 swagger from '@fastify/swagger'; import swaggerUi from '@fastify/swagger-ui'; @@ -24,7 +23,8 @@ const fastify = Fastify({ }); // Register SSE plugin -await fastify.register(sse); +// TODO: fastify-sse-v2 has compatibility issues with Fastify 5 +// await fastify.register(sse); // Register rate limiting await fastify.register(rateLimit, { @@ -191,14 +191,6 @@ fastify.options('*', async (request, reply) => { // Health check 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 await fastify.register(async function (fastify) { // 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(); return reply.send(metrics); } 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' }); } }); @@ -264,34 +256,6 @@ await fastify.register(async function (fastify) { 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' }); // Register modular routes with /api prefix @@ -314,7 +278,7 @@ async function start() { await initializeContainers(); fastify.log.info('Cosmos containers initialized successfully'); } 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' }); diff --git a/dashboard/docker-compose.yml b/dashboard/docker-compose.yml index b7b235e..bb99431 100644 --- a/dashboard/docker-compose.yml +++ b/dashboard/docker-compose.yml @@ -29,7 +29,7 @@ services: - platform_net restart: unless-stopped healthcheck: - test: ['CMD', 'wget', '-qO-', 'http://localhost:4004/health'] + test: ['CMD', 'curl', '-f', 'http://localhost:4004/health'] interval: 30s timeout: 5s retries: 3 diff --git a/dashboard/web/Dockerfile b/dashboard/web/Dockerfile index f56d851..d00ac06 100644 --- a/dashboard/web/Dockerfile +++ b/dashboard/web/Dockerfile @@ -2,22 +2,16 @@ # --- Stage 1: Build --- FROM node:20-alpine AS builder -RUN corepack enable && corepack prepare pnpm@10.6.5 --activate - WORKDIR /app/web -# Gitea npm registry for @bytelyst/* packages -COPY web/package.json ./package.json +COPY web/package.json web/package-lock.json ./ +RUN npm ci --ignore-scripts -RUN --mount=type=secret,id=gitea_npm_token \ - TOKEN=$(cat /run/secrets/gitea_npm_token) && \ - 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/tsconfig.json ./ +COPY web/next-env.d.ts ./ COPY web/next.config.js ./ -COPY web/tailwind.config.ts ./tailwind.config.ts -COPY web/postcss.config.js ./postcss.config.js +COPY web/tailwind.config.ts ./ +COPY web/postcss.config.js ./ COPY web/src/ ./src/ 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_DOCKER_IMAGE=${BYTELYST_DOCKER_IMAGE} -RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build +RUN npm run build # --- Stage 2: Serve --- FROM node:20-alpine AS runner WORKDIR /app/web -COPY --from=builder /app/web/package.json ./package.json -COPY --from=builder /app/web/pnpm-lock.yaml* ./pnpm-lock.yaml* -RUN corepack enable && corepack prepare pnpm@10.6.5 --activate -RUN pnpm install --prod --ignore-scripts +COPY web/package.json web/package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts COPY --from=builder /app/web/.next ./.next COPY --from=builder /app/web/public ./public @@ -66,4 +58,4 @@ ENV PORT=3000 EXPOSE 3000 -CMD ["pnpm", "start"] +CMD ["npm", "run", "start"] diff --git a/dashboard/web/package.json b/dashboard/web/package.json index 924e29e..d29aa9f 100644 --- a/dashboard/web/package.json +++ b/dashboard/web/package.json @@ -15,7 +15,6 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { - "@bytelyst/devops": "^0.1.3", "clsx": "^2.1.1", "lucide-react": "^0.562.0", "next": "16.0.0", diff --git a/dashboard/web/src/app/devops/page.tsx b/dashboard/web/src/app/devops/page.tsx deleted file mode 100644 index e52d77a..0000000 --- a/dashboard/web/src/app/devops/page.tsx +++ /dev/null @@ -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 { - 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 { - const env = process.env as Record; - 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 ( -
-
-

DevOps

-

System information and deployment details

-
- -
- ); -} diff --git a/dashboard/web/src/components/log-viewer.tsx b/dashboard/web/src/components/log-viewer.tsx index 6c071c4..fe05113 100644 --- a/dashboard/web/src/components/log-viewer.tsx +++ b/dashboard/web/src/components/log-viewer.tsx @@ -1,7 +1,7 @@ 'use client'; 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'; interface LogViewerProps { @@ -13,45 +13,57 @@ export function LogViewer({ deploymentId, onClose }: LogViewerProps) { const [logs, setLogs] = useState([]); const [isExpanded, setIsExpanded] = useState(false); const [error, setError] = useState(null); - const [isConnected, setIsConnected] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); const logContainerRef = useRef(null); useEffect(() => { - let cleanup: (() => void) | null = null; + let cancelled = false; + let intervalId: ReturnType | null = null; - const loadInitialLogs = async () => { + const stopPolling = () => { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + setIsRefreshing(false); + }; + + const loadLogs = async () => { try { - const deployment = await api.getDeployment(deploymentId); - if (deployment.logs) { - setLogs(deployment.logs.split('\n')); + const deployment = await api.getDeploymentLogs(deploymentId); + + if (cancelled) { + return; + } + + setError(null); + setLogs(deployment.logs ? deployment.logs.split('\n') : []); + if (deployment.status === 'running') { + setIsRefreshing(true); + } else { + stopPolling(); } } catch (err) { + if (cancelled) { + return; + } console.error('Failed to load initial logs:', err); + setError(err instanceof Error ? err.message : 'Failed to load logs'); + setIsRefreshing(true); } }; - loadInitialLogs(); - - cleanup = streamDeploymentLogs( - deploymentId, - (event: SseEvent) => { - setIsConnected(true); - setError(null); - if (event.data) { - setLogs((prev) => [...prev, event.data]); - } - }, - (err: Error) => { - setError(err.message); - setIsConnected(false); - }, - () => { - setIsConnected(false); - } - ); + setError(null); + setLogs([]); + setIsRefreshing(true); + intervalId = setInterval(() => { + void loadLogs(); + }, 2000); + void loadLogs(); return () => { - if (cleanup) cleanup(); + cancelled = true; + stopPolling(); }; }, [deploymentId]); @@ -69,10 +81,10 @@ export function LogViewer({ deploymentId, onClose }: LogViewerProps) {
Deployment Logs -
diff --git a/dashboard/web/src/components/sidebar-nav.tsx b/dashboard/web/src/components/sidebar-nav.tsx index 2716015..a56a4cb 100644 --- a/dashboard/web/src/components/sidebar-nav.tsx +++ b/dashboard/web/src/components/sidebar-nav.tsx @@ -17,7 +17,6 @@ import { Sun, Moon, HeartPulse, - Server, } from 'lucide-react'; import { useAuth } from '@/lib/auth'; @@ -28,7 +27,6 @@ const navItems = [ { href: '/system', label: 'System', icon: Cpu }, { href: '/env', label: 'Environment', icon: Key }, { href: '/code-quality', label: 'Code Quality', icon: Code2 }, - { href: '/devops', label: 'DevOps', icon: Server }, { href: '/settings/cosmos', label: 'Settings', icon: Settings }, ]; diff --git a/dashboard/web/src/lib/api.ts b/dashboard/web/src/lib/api.ts index dffbcfe..ef49a9e 100644 --- a/dashboard/web/src/lib/api.ts +++ b/dashboard/web/src/lib/api.ts @@ -40,6 +40,11 @@ export interface ApiError { status?: number; } +export interface DeploymentLogsResponse { + logs: string; + status: 'running' | 'success' | 'failed'; +} + export interface EnvVar { id: string; name: string; @@ -162,56 +167,6 @@ export async function apiRequest( 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 = { // Services getServices: () => apiRequest('/api/services'), @@ -236,6 +191,8 @@ export const api = { getServiceDeployments: (serviceId: string, limit = 50) => apiRequest(`/api/deployments/service/${serviceId}?limit=${limit}`), getDeployment: (id: string) => apiRequest(`/api/deployments/${id}`), + getDeploymentLogs: (id: string) => + apiRequest(`/api/deployments/${id}/logs`), triggerDeployment: (serviceId: string) => apiRequest<{ deploymentId: string; status: string }>(`/api/deployments/trigger/${serviceId}`, { method: 'POST',