feat(devops): restore dashboard build and log polling
This commit is contained in:
parent
4ae55fd3c8
commit
85f21ae9f6
@ -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"]
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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' });
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user