feat(devops-web): fix responsive layout and add comprehensive dashboard pages

- Fix sidebar layout: use flexbox instead of margin-left approach
- Update sidebar to use responsive display (hidden on mobile, static on desktop)
- Fix mobile overlay z-index and positioning issues
- Add proper flex container structure to all pages
- Add new dashboard pages: health, metrics, system, env, code-quality, settings/cosmos
- Add comprehensive API client and type definitions
- Add error boundary and log viewer components
- Add test infrastructure with Vitest and Playwright
- Add Docker configuration and deployment scripts

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
root 2026-05-11 03:10:31 +00:00
parent dcf7ecbb32
commit b35de88b08
29 changed files with 2955 additions and 8 deletions

View File

@ -0,0 +1,5 @@
NEXT_PUBLIC_DEVOPS_API_URL=https://api.bytelyst.com/devops
NEXT_PUBLIC_PLATFORM_URL=https://api.bytelyst.com/platform/api
NEXT_PUBLIC_ADMIN_WEB_URL=https://admin.bytelyst.com
NEXT_PUBLIC_PRODUCT_ID=bytelyst-devops
NEXT_PUBLIC_PRODUCT_NAME=ByteLyst DevOps Dashboard

29
dashboard/web/.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env

52
dashboard/web/Dockerfile Normal file
View File

@ -0,0 +1,52 @@
# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
# Build arguments for environment variables
ARG NEXT_PUBLIC_DEVOPS_API_URL
ARG NEXT_PUBLIC_PLATFORM_URL
ARG NEXT_PUBLIC_ADMIN_WEB_URL
ARG NEXT_PUBLIC_PRODUCT_ID
ARG NEXT_PUBLIC_PRODUCT_NAME
# Set environment variables for build
ENV NEXT_PUBLIC_DEVOPS_API_URL=${NEXT_PUBLIC_DEVOPS_API_URL}
ENV NEXT_PUBLIC_PLATFORM_URL=${NEXT_PUBLIC_PLATFORM_URL}
ENV NEXT_PUBLIC_ADMIN_WEB_URL=${NEXT_PUBLIC_ADMIN_WEB_URL}
ENV NEXT_PUBLIC_PRODUCT_ID=${NEXT_PUBLIC_PRODUCT_ID}
ENV NEXT_PUBLIC_PRODUCT_NAME=${NEXT_PUBLIC_PRODUCT_NAME}
# Install dependencies
COPY package.json pnpm-lock.yaml* ./
RUN npm install -g pnpm@10.6.5
RUN pnpm install
# Copy source
COPY next.config.js tsconfig.json tailwind.config.ts postcss.config.js ./
COPY src src/
# Build
RUN pnpm build
# Stage 2: Run
FROM node:22-alpine AS runner
WORKDIR /app
# Install production dependencies
COPY package.json pnpm-lock.yaml* ./
RUN npm install -g pnpm@10.6.5
RUN pnpm install --prod --ignore-scripts
# Copy built web
COPY --from=builder /app/.next ./.next
COPY public ./public
# Set environment
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["npm", "start"]

6
dashboard/web/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default nextConfig;

View File

@ -0,0 +1,37 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'cd ../backend && pnpm dev',
url: 'http://localhost:4004',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,343 @@
'use client';
import { useState } from 'react';
import { Play, CheckCircle, XCircle, AlertTriangle, FileText, Clock, Code2, Bug, Loader2 } from 'lucide-react';
import { runCodeQualityCheck, type CodeQualityReport, type CodeQualityIssue } from '@/lib/api';
import { SidebarNav } from '@/components/sidebar-nav';
export default function CodeQualityPage() {
const [projectPath, setProjectPath] = useState('');
const [projectId, setProjectId] = useState('');
const [selectedChecks, setSelectedChecks] = useState<Array<'typescript' | 'eslint' | 'build' | 'test'>>([
'typescript',
'eslint',
'build',
'test',
]);
const [loading, setLoading] = useState(false);
const [report, setReport] = useState<CodeQualityReport | null>(null);
const [error, setError] = useState<string | null>(null);
const handleRunCheck = async () => {
if (!projectPath || !projectId) {
setError('Please provide project ID and path');
return;
}
setLoading(true);
setError(null);
setReport(null);
try {
const result = await runCodeQualityCheck({
projectId,
projectPath,
checks: selectedChecks,
});
setReport(result);
} catch (err) {
setError('Failed to run code quality check');
console.error(err);
} finally {
setLoading(false);
}
};
const toggleCheck = (check: 'typescript' | 'eslint' | 'build' | 'test') => {
setSelectedChecks(prev =>
prev.includes(check) ? prev.filter(c => c !== check) : [...prev, check]
);
};
const getIssueIcon = (type: string) => {
switch (type) {
case 'error':
return <XCircle className="w-4 h-4 text-red-600" />;
case 'warning':
return <AlertTriangle className="w-4 h-4 text-yellow-600" />;
case 'info':
return <FileText className="w-4 h-4 text-blue-600" />;
default:
return null;
}
};
const getCategoryIcon = (category: string) => {
switch (category) {
case 'typescript':
return <Code2 className="w-4 h-4 text-blue-600" />;
case 'eslint':
return <FileText className="w-4 h-4 text-purple-600" />;
case 'build':
return <Play className="w-4 h-4 text-green-600" />;
case 'test':
return <Bug className="w-4 h-4 text-orange-600" />;
default:
return <FileText className="w-4 h-4 text-gray-600" />;
}
};
return (
<div className="flex min-h-screen bg-gray-50">
<SidebarNav />
<main className="flex-1 min-w-0 overflow-y-auto">
<div className="p-8 max-md:p-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Code Quality Analysis</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Run TypeScript, ESLint, build, and test checks on your projects
</p>
</div>
{/* Configuration */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Configuration</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Project ID
</label>
<input
type="text"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="e.g., trading-service"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Project Path
</label>
<input
type="text"
value={projectPath}
onChange={(e) => setProjectPath(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="e.g., /opt/bytelyst/learning_ai_invt_trdg"
/>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Checks to Run
</label>
<div className="flex flex-wrap gap-2">
{(['typescript', 'eslint', 'build', 'test'] as const).map((check) => (
<button
key={check}
onClick={() => toggleCheck(check)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
selectedChecks.includes(check)
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{check.charAt(0).toUpperCase() + check.slice(1)}
</button>
))}
</div>
</div>
<button
onClick={handleRunCheck}
disabled={loading || selectedChecks.length === 0}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Running Checks...
</>
) : (
<>
<Play className="w-4 h-4" />
Run Analysis
</>
)}
</button>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Results */}
{report && (
<div className="space-y-6">
{/* Summary */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Summary</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">
{report.summary.totalIssues}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Issues</div>
</div>
<div className="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
<div className="text-3xl font-bold text-red-600 dark:text-red-400">
{report.summary.errors}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Errors</div>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-4 rounded-lg">
<div className="text-3xl font-bold text-yellow-600 dark:text-yellow-400">
{report.summary.warnings}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Warnings</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-4 rounded-lg">
<div className="text-3xl font-bold text-green-600 dark:text-green-400">
{report.categories.test.passed}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Tests Passed</div>
</div>
</div>
</div>
{/* Categories */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center gap-2 mb-4">
<Code2 className="w-5 h-5 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">TypeScript</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Errors:</span>
<span className="font-medium text-red-600">{report.categories.typescript.errors}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Warnings:</span>
<span className="font-medium text-yellow-600">{report.categories.typescript.warnings}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Duration:</span>
<span className="font-medium text-gray-900 dark:text-white">
{report.categories.typescript.duration}ms
</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-purple-600" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">ESLint</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Errors:</span>
<span className="font-medium text-red-600">{report.categories.eslint.errors}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Warnings:</span>
<span className="font-medium text-yellow-600">{report.categories.eslint.warnings}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Duration:</span>
<span className="font-medium text-gray-900 dark:text-white">
{report.categories.eslint.duration}ms
</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center gap-2 mb-4">
<Play className="w-5 h-5 text-green-600" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Build</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Status:</span>
<span className={`font-medium ${report.categories.build.success ? 'text-green-600' : 'text-red-600'}`}>
{report.categories.build.success ? 'Success' : 'Failed'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Errors:</span>
<span className="font-medium text-red-600">{report.categories.build.errors}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Duration:</span>
<span className="font-medium text-gray-900 dark:text-white">
{report.categories.build.duration}ms
</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center gap-2 mb-4">
<Bug className="w-5 h-5 text-orange-600" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Tests</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Status:</span>
<span className={`font-medium ${report.categories.test.success ? 'text-green-600' : 'text-red-600'}`}>
{report.categories.test.success ? 'Success' : 'Failed'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Passed:</span>
<span className="font-medium text-green-600">{report.categories.test.passed}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Failed:</span>
<span className="font-medium text-red-600">{report.categories.test.failed}</span>
</div>
</div>
</div>
</div>
{/* Issues List */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Issues</h2>
{report.issues.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<CheckCircle className="w-12 h-12 mx-auto mb-3 text-green-600" />
<p>No issues found!</p>
</div>
) : (
<div className="space-y-3">
{report.issues.map((issue) => (
<div
key={issue.id}
className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div className="mt-0.5">{getIssueIcon(issue.type)}</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900 dark:text-white">{issue.file}</span>
{issue.line && (
<span className="text-sm text-gray-500 dark:text-gray-400">
:{issue.line}
{issue.column && `:${issue.column}`}
</span>
)}
<div className="ml-auto">{getCategoryIcon(issue.category)}</div>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300">{issue.message}</p>
{issue.rule && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Rule: {issue.rule}</p>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,241 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { SidebarNav } from '@/components/sidebar-nav';
import { api } from '@/lib/api';
import type { Service, ServiceHealth } from '@/lib/api';
import { Activity, Clock, RefreshCw, TrendingUp } from 'lucide-react';
export default function HealthDashboardPage() {
const [services, setServices] = useState<Service[]>([]);
const [healthData, setHealthData] = useState<Map<string, ServiceHealth>>(new Map());
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadData = useCallback(async () => {
try {
const [servicesData, healthDataArray] = await Promise.all([
api.getServices(),
api.getHealth(),
]);
setServices(servicesData);
const healthMap = new Map(healthDataArray.map(h => [h.serviceId, h]));
setHealthData(healthMap);
} catch (error) {
console.error('Failed to load data:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
const refreshHealth = useCallback(async () => {
setRefreshing(true);
try {
await api.clearHealthCache();
await loadData();
} catch (error) {
console.error('Failed to refresh health:', error);
} finally {
setRefreshing(false);
}
}, [loadData]);
useEffect(() => {
loadData();
const interval = setInterval(() => {
loadData();
}, 30000);
return () => clearInterval(interval);
}, [loadData]);
function getStatusColor(status: string) {
switch (status) {
case 'up':
case 'success':
return 'text-green-600 bg-green-50 border-green-200';
case 'down':
case 'failed':
return 'text-red-600 bg-red-50 border-red-200';
case 'degraded':
case 'running':
return 'text-yellow-600 bg-yellow-50 border-yellow-200';
default:
return 'text-gray-600 bg-gray-50 border-gray-200';
}
}
function getResponseTimeColor(responseTime?: number) {
if (!responseTime) return 'text-gray-500';
if (responseTime < 500) return 'text-green-600';
if (responseTime < 1000) return 'text-yellow-600';
return 'text-red-600';
}
function getUptimePercentage(service: Service): number {
if (!service.lastHealthCheckAt) return 0;
const lastCheck = new Date(service.lastHealthCheckAt).getTime();
const now = Date.now();
const timeDiff = now - lastCheck;
if (timeDiff > 3600000) return 0;
return Math.max(0, 100 - (timeDiff / 3600000) * 100);
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-600">Loading...</div>
</div>
);
}
const healthyCount = services.filter(s => s.status === 'up').length;
const degradedCount = services.filter(s => s.status === 'degraded').length;
const downCount = services.filter(s => s.status === 'down').length;
return (
<div className="flex min-h-screen bg-gray-50">
<SidebarNav />
<main className="flex-1 min-w-0 overflow-y-auto">
<div className="p-8 max-md:p-4">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Health Dashboard</h1>
<p className="text-sm text-gray-600">Real-time service health monitoring</p>
</div>
<button
onClick={refreshHealth}
disabled={refreshing}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Total Services</p>
<p className="text-3xl font-bold text-gray-900">{services.length}</p>
</div>
<Activity className="w-8 h-8 text-blue-600" />
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Healthy</p>
<p className="text-3xl font-bold text-green-600">{healthyCount}</p>
</div>
<Activity className="w-8 h-8 text-green-600" />
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Degraded</p>
<p className="text-3xl font-bold text-yellow-600">{degradedCount}</p>
</div>
<Activity className="w-8 h-8 text-yellow-600" />
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Down</p>
<p className="text-3xl font-bold text-red-600">{downCount}</p>
</div>
<Activity className="w-8 h-8 text-red-600" />
</div>
</div>
</div>
{/* Health Details */}
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Service Health Details</h2>
</div>
<div className="divide-y divide-gray-200">
{services.map((service) => {
const health = healthData.get(service.id);
const uptime = getUptimePercentage(service);
return (
<div key={service.id} className="px-6 py-4">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">{service.name}</h3>
<p className="text-sm text-gray-500">{service.repoPath}</p>
</div>
<span className={`px-3 py-1 text-sm font-medium rounded-full border ${getStatusColor(service.status)}`}>
{service.status}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm text-gray-500">Last Check</p>
<p className="text-sm font-medium text-gray-900">
{service.lastHealthCheckAt
? new Date(service.lastHealthCheckAt).toLocaleString()
: 'Never'}
</p>
</div>
</div>
{health?.responseTime && (
<div className="flex items-center gap-3">
<TrendingUp className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm text-gray-500">Response Time</p>
<p className={`text-sm font-medium ${getResponseTimeColor(health.responseTime)}`}>
{health.responseTime}ms
</p>
</div>
</div>
)}
<div className="flex items-center gap-3">
<Activity className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm text-gray-500">Estimated Uptime</p>
<p className="text-sm font-medium text-gray-900">{uptime.toFixed(1)}%</p>
</div>
</div>
</div>
<div className="mt-4">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-500">Uptime</span>
<span className="text-sm font-medium text-gray-900">{uptime.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${
uptime > 90 ? 'bg-green-600' : uptime > 70 ? 'bg-yellow-600' : 'bg-red-600'
}`}
style={{ width: `${uptime}%` }}
/>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,44 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { AuthProvider } from '@/lib/auth';
import { ErrorBoundary } from '@/components/error-boundary';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'ByteLyst DevOps',
description: 'Internal DevOps dashboard for deployment orchestration',
manifest: '/manifest.json',
themeColor: '#2563eb',
viewport: 'width=device-width, initial-scale=1, maximum-scale=1',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'DevOps',
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white focus:rounded-md"
>
Skip to main content
</a>
<ErrorBoundary>
<AuthProvider>
{children}
</AuthProvider>
</ErrorBoundary>
</body>
</html>
);
}

View File

@ -0,0 +1,112 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { productId as devopsProductId } from '@/lib/product-config';
import { authApi } from '@/lib/api';
import { setAccessToken, setRefreshToken } from '@/lib/api';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('admin@bytelyst.com');
const [password, setPassword] = useState('admin12345');
const [productId, setProductId] = useState('bytelyst-devops');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
console.log('Attempting login for:', email, 'with productId:', productId);
const response = await authApi.login({ email, password, productId });
console.log('Login response received:', response);
setAccessToken(response.accessToken);
setRefreshToken(response.refreshToken);
console.log('Login successful, redirecting to home');
router.push('/');
} catch (err) {
console.error('Login failed:', err);
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<h1 className="text-2xl font-bold text-gray-900 mb-6">DevOps Dashboard Login</h1>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-600 rounded-md">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="admin@bytelyst.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
<div>
<label htmlFor="productId" className="block text-sm font-medium text-gray-700 mb-1">
Product ID
</label>
<input
id="productId"
type="text"
value={productId}
onChange={(e) => setProductId(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="bytelyst-devops"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<p className="mt-4 text-sm text-gray-600 text-center">
Authenticate using your platform-service credentials
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,244 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import type { Deployment } from '@/lib/types';
import { BarChart3, TrendingUp, Clock, CheckCircle, XCircle } from 'lucide-react';
import { SidebarNav } from '@/components/sidebar-nav';
export default function MetricsPage() {
const [deployments, setDeployments] = useState<Deployment[]>([]);
const [loading, setLoading] = useState(true);
const loadData = useCallback(async () => {
try {
const deploymentsData = await api.getDeployments(100);
setDeployments(deploymentsData);
} catch (error) {
console.error('Failed to load data:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
function getDeploymentStats() {
const total = deployments.length;
const success = deployments.filter(d => d.status === 'success').length;
const failed = deployments.filter(d => d.status === 'failed').length;
const running = deployments.filter(d => d.status === 'running').length;
return { total, success, failed, running };
}
function getDeploymentsByService() {
const serviceCount = new Map<string, number>();
deployments.forEach(d => {
serviceCount.set(d.serviceId, (serviceCount.get(d.serviceId) || 0) + 1);
});
return Array.from(serviceCount.entries()).sort((a, b) => b[1] - a[1]);
}
function getAverageDeploymentTime() {
const completedDeployments = deployments.filter(d => d.completedAt);
if (completedDeployments.length === 0) return 0;
const totalTime = completedDeployments.reduce((sum, d) => {
const start = new Date(d.triggeredAt).getTime();
const end = new Date(d.completedAt!).getTime();
return sum + (end - start);
}, 0);
return Math.round(totalTime / completedDeployments.length / 1000);
}
function getDeploymentTrend() {
const last7Days = Array.from({ length: 7 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - i);
return date.toISOString().split('T')[0];
}).reverse();
const trend = last7Days.map(date => {
const dayDeployments = deployments.filter(d =>
d.triggeredAt.startsWith(date)
);
return {
date,
count: dayDeployments.length,
success: dayDeployments.filter(d => d.status === 'success').length,
failed: dayDeployments.filter(d => d.status === 'failed').length,
};
});
return trend;
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-600">Loading...</div>
</div>
);
}
const stats = getDeploymentStats();
const deploymentsByService = getDeploymentsByService();
const avgDeploymentTime = getAverageDeploymentTime();
const deploymentTrend = getDeploymentTrend();
const maxCount = Math.max(...deploymentTrend.map(d => d.count), 1);
return (
<div className="flex min-h-screen bg-gray-50">
<SidebarNav />
<main className="flex-1 min-w-0 overflow-y-auto">
<div className="p-8 max-md:p-4">
<div className="mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Metrics & Analytics</h1>
<p className="text-sm text-gray-600">Deployment statistics and trends</p>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Total Deployments</p>
<p className="text-3xl font-bold text-gray-900">{stats.total}</p>
</div>
<BarChart3 className="w-8 h-8 text-blue-600" />
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Success Rate</p>
<p className="text-3xl font-bold text-green-600">
{stats.total > 0 ? Math.round((stats.success / stats.total) * 100) : 0}%
</p>
</div>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Failed</p>
<p className="text-3xl font-bold text-red-600">{stats.failed}</p>
</div>
<XCircle className="w-8 h-8 text-red-600" />
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Avg Time</p>
<p className="text-3xl font-bold text-blue-600">{avgDeploymentTime}s</p>
</div>
<Clock className="w-8 h-8 text-blue-600" />
</div>
</div>
</div>
{/* Deployment Trend Chart */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Deployment Trend (Last 7 Days)</h2>
<div className="flex items-end justify-between h-64 gap-2">
{deploymentTrend.map((day) => (
<div key={day.date} className="flex-1 flex flex-col items-center">
<div className="w-full flex flex-col gap-1">
<div
className="w-full bg-green-500 rounded-t"
style={{ height: `${(day.success / maxCount) * 100}%`, minHeight: day.success > 0 ? '4px' : '0' }}
title={`Success: ${day.success}`}
/>
<div
className="w-full bg-red-500 rounded-b"
style={{ height: `${(day.failed / maxCount) * 100}%`, minHeight: day.failed > 0 ? '4px' : '0' }}
title={`Failed: ${day.failed}`}
/>
</div>
<div className="mt-2 text-xs text-gray-500 text-center">
{new Date(day.date).toLocaleDateString('en', { weekday: 'short' })}
</div>
<div className="text-xs font-medium text-gray-700">{day.count}</div>
</div>
))}
</div>
<div className="flex items-center justify-center gap-6 mt-4">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-500 rounded" />
<span className="text-sm text-gray-600">Success</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-500 rounded" />
<span className="text-sm text-gray-600">Failed</span>
</div>
</div>
</div>
{/* Deployments by Service */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Deployments by Service</h2>
<div className="space-y-4">
{deploymentsByService.map(([serviceId, count]) => {
const percentage = (count / stats.total) * 100;
return (
<div key={serviceId}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-900">{serviceId}</span>
<span className="text-sm text-gray-500">{count} deployments ({percentage.toFixed(1)}%)</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
{/* Success Rate by Service */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Success Rate by Service</h2>
<div className="space-y-4">
{deploymentsByService.map(([serviceId]) => {
const serviceDeployments = deployments.filter(d => d.serviceId === serviceId);
const success = serviceDeployments.filter(d => d.status === 'success').length;
const rate = serviceDeployments.length > 0 ? (success / serviceDeployments.length) * 100 : 0;
return (
<div key={serviceId}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-900">{serviceId}</span>
<span className="text-sm text-gray-500">{rate.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${
rate >= 90 ? 'bg-green-600' : rate >= 70 ? 'bg-yellow-600' : 'bg-red-600'
}`}
style={{ width: `${rate}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
</main>
</div>
);
}

View File

@ -151,10 +151,11 @@ export default function DashboardPage() {
}
return (
<div className="min-h-screen bg-gray-50">
<div className="flex min-h-screen bg-gray-50">
<SidebarNav />
<main className="ml-64 min-h-screen p-8 max-md:ml-0">
<main className="flex-1 min-w-0 overflow-y-auto">
<div className="p-8 max-md:p-4">
{/* Header */}
<div className="mb-8">
{error && (
@ -338,6 +339,7 @@ export default function DashboardPage() {
</div>
)}
</section>
</div>
</main>
{showServiceForm && (

View File

@ -0,0 +1,245 @@
'use client';
import { useState, useEffect } from 'react';
import { apiRequest } from '@/lib/api';
import { SidebarNav } from '@/components/sidebar-nav';
interface CosmosConfig {
configured: boolean;
endpoint: string | null;
database: string | null;
updatedAt: string | null;
}
interface CosmosStatus {
isInitialized: boolean;
error: string | null;
}
export default function CosmosConfigPage() {
const [config, setConfig] = useState<CosmosConfig | null>(null);
const [status, setStatus] = useState<CosmosStatus | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [endpoint, setEndpoint] = useState('');
const [key, setKey] = useState('');
const [database, setDatabase] = useState('bytelyst-platform');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
loadConfig();
loadStatus();
}, []);
const loadConfig = async () => {
try {
const data = await apiRequest<CosmosConfig>('/cosmos-config');
setConfig(data);
if (data.configured && data.endpoint) {
setEndpoint(data.endpoint);
setDatabase(data.database || 'bytelyst-platform');
}
} catch (error) {
console.error('Failed to load Cosmos config:', error);
} finally {
setLoading(false);
}
};
const loadStatus = async () => {
try {
const data = await apiRequest<CosmosStatus>('/cosmos-status');
setStatus(data);
} catch (error) {
console.error('Failed to load Cosmos status:', error);
}
};
const testConnection = async () => {
setTesting(true);
setMessage(null);
try {
const data = await apiRequest<{ success: boolean; message: string; error?: string }>('/cosmos-test', {
method: 'POST',
body: JSON.stringify({ endpoint, key }),
});
if (data.success) {
setMessage({ type: 'success', text: data.message });
} else {
setMessage({ type: 'error', text: data.error || 'Connection failed' });
}
} catch (error: any) {
setMessage({ type: 'error', text: error.message || 'Failed to test connection' });
} finally {
setTesting(false);
}
};
const saveConfig = async () => {
setSaving(true);
setMessage(null);
try {
const data = await apiRequest<{ success: boolean; message: string; error?: string }>('/cosmos-config', {
method: 'POST',
body: JSON.stringify({ endpoint, key, database }),
});
if (data.success) {
setMessage({ type: 'success', text: data.message });
await loadConfig();
await loadStatus();
} else {
setMessage({ type: 'error', text: data.error || 'Failed to save configuration' });
}
} catch (error: any) {
setMessage({ type: 'error', text: error.message || 'Failed to save configuration' });
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="flex min-h-screen bg-gray-50">
<SidebarNav />
<main className="flex-1 min-w-0 overflow-y-auto">
<div className="p-8 max-md:p-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Cosmos DB Configuration</h1>
<p className="text-gray-600">Configure your Azure Cosmos DB connection for the DevOps dashboard.</p>
</div>
{/* Status Card */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Connection Status</h2>
{status ? (
<div className="space-y-2">
<div className="flex items-center">
<span className="text-gray-600 w-32">Status:</span>
<span className={`font-medium ${status.isInitialized ? 'text-green-600' : 'text-red-600'}`}>
{status.isInitialized ? 'Connected' : 'Not Connected'}
</span>
</div>
{status.error && (
<div className="flex items-start">
<span className="text-gray-600 w-32">Error:</span>
<span className="text-red-600">{status.error}</span>
</div>
)}
</div>
) : (
<p className="text-gray-500">Loading status...</p>
)}
</div>
{/* Configuration Form */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Configuration</h2>
<form onSubmit={(e) => { e.preventDefault(); saveConfig(); }} className="space-y-4">
<div>
<label htmlFor="endpoint" className="block text-sm font-medium text-gray-700 mb-1">
Cosmos DB Endpoint
</label>
<input
type="url"
id="endpoint"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="https://your-account.documents.azure.com:443/"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
<p className="mt-1 text-sm text-gray-500">
The endpoint URL of your Azure Cosmos DB account
</p>
</div>
<div>
<label htmlFor="key" className="block text-sm font-medium text-gray-700 mb-1">
Account Key
</label>
<input
type="password"
id="key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="Enter your Cosmos DB account key"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required={!config?.configured}
/>
<p className="mt-1 text-sm text-gray-500">
The primary or secondary key of your Cosmos DB account
</p>
</div>
<div>
<label htmlFor="database" className="block text-sm font-medium text-gray-700 mb-1">
Database Name
</label>
<input
type="text"
id="database"
value={database}
onChange={(e) => setDatabase(e.target.value)}
placeholder="bytelyst-platform"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
<p className="mt-1 text-sm text-gray-500">
The name of the Cosmos DB database to use
</p>
</div>
{message && (
<div className={`p-3 rounded-md ${message.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
{message.text}
</div>
)}
<div className="flex space-x-3">
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Configuration'}
</button>
<button
type="button"
onClick={testConnection}
disabled={testing || !endpoint || !key}
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{testing ? 'Testing...' : 'Test Connection'}
</button>
</div>
</form>
</div>
{/* Help Section */}
<div className="bg-blue-50 rounded-lg p-6">
<h3 className="text-lg font-semibold text-blue-900 mb-2">Need help?</h3>
<ul className="list-disc list-inside text-blue-800 space-y-1">
<li>For local development, you can use the Azure Cosmos DB Emulator</li>
<li>Emulator endpoint: <code className="bg-blue-100 px-1 rounded">https://localhost:8081</code></li>
<li>Emulator key: <code className="bg-blue-100 px-1 rounded">C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==</code></li>
<li>Download the emulator from: <a href="https://aka.ms/cosmosdb-emulator" target="_blank" rel="noopener noreferrer" className="underline hover:text-blue-600">aka.ms/cosmosdb-emulator</a></li>
</ul>
</div>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,422 @@
'use client';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { Cpu, HardDrive, Database, Trash2, RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react';
import { SidebarNav } from '@/components/sidebar-nav';
interface SystemMetrics {
timestamp: string;
uptime: string;
cpu: {
usage: number;
cores: number;
loadAverage: number[];
};
memory: {
total: number;
used: number;
free: number;
percentage: number;
};
disk: Array<{
path: string;
total: number;
used: number;
free: number;
percentage: number;
}>;
platform: {
nodeVersion: string;
platform: string;
arch: string;
hostname: string;
};
}
interface DockerStats {
images: {
total: number;
dangling: number;
size: number;
};
containers: {
total: number;
running: number;
stopped: number;
size: number;
};
volumes: {
total: number;
unused: number;
size: number;
};
}
export default function SystemPage() {
const [metrics, setMetrics] = useState<SystemMetrics | null>(null);
const [dockerStats, setDockerStats] = useState<DockerStats | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [cleanupResult, setCleanupResult] = useState<{ message: string; freedSpace: number } | null>(null);
const loadData = async () => {
try {
const [metricsData, dockerData] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_DEVOPS_API_URL || 'http://localhost:4004'}/api/system/metrics`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
},
}).then(r => r.json()),
fetch(`${process.env.NEXT_PUBLIC_DEVOPS_API_URL || 'http://localhost:4004'}/api/docker/stats`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
},
}).then(r => r.json()),
]);
setMetrics(metricsData);
setDockerStats(dockerData);
} catch (error) {
console.error('Failed to load system data:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
loadData();
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
}, []);
const handleRefresh = () => {
setRefreshing(true);
loadData();
};
const handleCleanup = async (type: string, force: boolean = false) => {
if (!confirm(`Are you sure you want to clean up Docker ${type}?${force ? ' This will force remove all unused items.' : ''}`)) {
return;
}
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_DEVOPS_API_URL || 'http://localhost:4004'}/api/docker/cleanup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
},
body: JSON.stringify({ type, force }),
});
const result = await response.json();
setCleanupResult(result);
loadData();
} catch (error) {
console.error('Cleanup failed:', error);
alert('Cleanup failed');
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
const getUsageColor = (percentage: number): string => {
if (percentage < 50) return 'text-green-600 bg-green-50';
if (percentage < 75) return 'text-yellow-600 bg-yellow-50';
return 'text-red-600 bg-red-50';
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-600">Loading...</div>
</div>
);
}
return (
<div className="flex min-h-screen bg-gray-50">
<SidebarNav />
<main className="flex-1 min-w-0 overflow-y-auto">
<div className="p-8 max-md:p-4">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">System Management</h1>
<p className="text-sm text-gray-600">Monitor resources and manage Docker</p>
</div>
<button
onClick={handleRefresh}
disabled={refreshing}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
aria-label="Refresh system metrics"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{cleanupResult && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-green-600" />
<div>
<p className="font-medium text-green-900">{cleanupResult.message}</p>
<p className="text-sm text-green-700">Freed space: {formatBytes(cleanupResult.freedSpace * 1024 * 1024)}</p>
</div>
<button
onClick={() => setCleanupResult(null)}
className="ml-auto text-green-600 hover:text-green-800"
>
Dismiss
</button>
</div>
)}
{/* System Metrics */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6">System Metrics</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{/* CPU */}
<div className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3 mb-3">
<Cpu className="w-5 h-5 text-blue-600" />
<div>
<p className="text-sm font-medium text-gray-500">CPU Usage</p>
<p className="text-2xl font-bold text-gray-900">{metrics?.cpu.usage || 0}%</p>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Cores</span>
<span className="font-medium">{metrics?.cpu.cores || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Load Avg (1m)</span>
<span className="font-medium">{metrics?.cpu.loadAverage[0]?.toFixed(2) || '0.00'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Load Avg (5m)</span>
<span className="font-medium">{metrics?.cpu.loadAverage[1]?.toFixed(2) || '0.00'}</span>
</div>
</div>
</div>
{/* Memory */}
<div className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3 mb-3">
<Database className="w-5 h-5 text-purple-600" />
<div>
<p className="text-sm font-medium text-gray-500">Memory</p>
<p className="text-2xl font-bold text-gray-900">{metrics?.memory.percentage || 0}%</p>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Total</span>
<span className="font-medium">{formatBytes((metrics?.memory.total || 0) * 1024 * 1024 * 1024)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Used</span>
<span className="font-medium">{formatBytes((metrics?.memory.used || 0) * 1024 * 1024 * 1024)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Free</span>
<span className="font-medium">{formatBytes((metrics?.memory.free || 0) * 1024 * 1024 * 1024)}</span>
</div>
</div>
<div className="mt-3">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getUsageColor(metrics?.memory.percentage || 0).split(' ')[1]}`}
style={{ width: `${metrics?.memory.percentage || 0}%` }}
/>
</div>
</div>
</div>
{/* Disk */}
<div className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3 mb-3">
<HardDrive className="w-5 h-5 text-orange-600" />
<div>
<p className="text-sm font-medium text-gray-500">Disk Usage</p>
</div>
</div>
<div className="space-y-3">
{metrics?.disk.map((disk) => (
<div key={disk.path}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">{disk.path}</span>
<span className="font-medium">{disk.percentage}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getUsageColor(disk.percentage).split(' ')[1]}`}
style={{ width: `${disk.percentage}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>{formatBytes(disk.used)} used</span>
<span>{formatBytes(disk.free)} free</span>
</div>
</div>
))}
</div>
</div>
</div>
{/* Platform Info */}
<div className="border-t border-gray-200 pt-4">
<h3 className="text-sm font-medium text-gray-500 mb-2">Platform Information</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">Hostname</span>
<p className="font-medium">{metrics?.platform.hostname || 'N/A'}</p>
</div>
<div>
<span className="text-gray-500">Platform</span>
<p className="font-medium">{metrics?.platform.platform || 'N/A'}</p>
</div>
<div>
<span className="text-gray-500">Architecture</span>
<p className="font-medium">{metrics?.platform.arch || 'N/A'}</p>
</div>
<div>
<span className="text-gray-500">Node Version</span>
<p className="font-medium">{metrics?.platform.nodeVersion || 'N/A'}</p>
</div>
</div>
</div>
</div>
{/* Docker Management */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6">Docker Management</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{/* Images */}
<div className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-gray-900">Images</h3>
<Database className="w-5 h-5 text-blue-600" />
</div>
<div className="space-y-2 text-sm mb-4">
<div className="flex justify-between">
<span className="text-gray-600">Total</span>
<span className="font-medium">{dockerStats?.images.total || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Dangling</span>
<span className="font-medium">{dockerStats?.images.dangling || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Size</span>
<span className="font-medium">{formatBytes(dockerStats?.images.size || 0)}</span>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleCleanup('images', false)}
className="flex-1 px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded hover:bg-blue-100"
>
Prune
</button>
<button
onClick={() => handleCleanup('images', true)}
className="flex-1 px-3 py-2 text-sm font-medium text-red-600 bg-red-50 border border-red-200 rounded hover:bg-red-100"
>
Force Prune
</button>
</div>
</div>
{/* Containers */}
<div className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-gray-900">Containers</h3>
<Database className="w-5 h-5 text-green-600" />
</div>
<div className="space-y-2 text-sm mb-4">
<div className="flex justify-between">
<span className="text-gray-600">Total</span>
<span className="font-medium">{dockerStats?.containers.total || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Running</span>
<span className="font-medium text-green-600">{dockerStats?.containers.running || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Stopped</span>
<span className="font-medium text-red-600">{dockerStats?.containers.stopped || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Size</span>
<span className="font-medium">{formatBytes(dockerStats?.containers.size || 0)}</span>
</div>
</div>
<button
onClick={() => handleCleanup('containers', false)}
className="w-full px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded hover:bg-blue-100"
>
Prune Stopped
</button>
</div>
{/* Volumes */}
<div className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-gray-900">Volumes</h3>
<Database className="w-5 h-5 text-purple-600" />
</div>
<div className="space-y-2 text-sm mb-4">
<div className="flex justify-between">
<span className="text-gray-600">Total</span>
<span className="font-medium">{dockerStats?.volumes.total || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Unused</span>
<span className="font-medium">{dockerStats?.volumes.unused || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Size</span>
<span className="font-medium">{formatBytes(dockerStats?.volumes.size || 0)}</span>
</div>
</div>
<button
onClick={() => handleCleanup('volumes', false)}
className="w-full px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded hover:bg-blue-100"
>
Prune Unused
</button>
</div>
</div>
{/* Full Cleanup */}
<div className="border-t border-gray-200 pt-4">
<div className="flex items-center gap-3 mb-3">
<AlertTriangle className="w-5 h-5 text-orange-600" />
<div>
<h3 className="font-medium text-gray-900">Full Cleanup</h3>
<p className="text-sm text-gray-500">Remove all unused Docker resources (images, containers, volumes, build cache)</p>
</div>
</div>
<button
onClick={() => handleCleanup('all', true)}
className="px-4 py-2 text-sm font-medium text-red-600 bg-red-50 border border-red-200 rounded hover:bg-red-100"
>
<Trash2 className="w-4 h-4 inline mr-2" />
Run Full Cleanup
</button>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,75 @@
'use client';
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="max-w-md rounded-lg bg-white p-8 shadow-lg">
<div className="mb-4 flex justify-center">
<svg
className="h-12 w-12 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h1 className="mb-2 text-center text-2xl font-bold text-gray-900">
Something went wrong
</h1>
<p className="mb-6 text-center text-gray-600">
An unexpected error occurred. Please refresh the page or contact
support if the problem persists.
</p>
<div className="mb-6 rounded-md bg-gray-100 p-4">
<p className="text-sm font-mono text-gray-700">
{this.state.error?.message || 'Unknown error'}
</p>
</div>
<div className="flex justify-center">
<button
onClick={() => window.location.reload()}
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Refresh Page
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,116 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { api, streamDeploymentLogs, type SseEvent } from '@/lib/api';
import { X, Maximize2, Minimize2 } from 'lucide-react';
interface LogViewerProps {
deploymentId: string;
onClose: () => void;
}
export function LogViewer({ deploymentId, onClose }: LogViewerProps) {
const [logs, setLogs] = useState<string[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const logContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cleanup: (() => void) | null = null;
const loadInitialLogs = async () => {
try {
const deployment = await api.getDeployment(deploymentId);
if (deployment.logs) {
setLogs(deployment.logs.split('\n'));
}
} catch (err) {
console.error('Failed to load initial logs:', err);
}
};
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);
}
);
return () => {
if (cleanup) cleanup();
};
}, [deploymentId]);
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs]);
return (
<div className={`fixed bg-gray-900 text-gray-100 font-mono text-sm shadow-xl transition-all duration-300 ${
isExpanded ? 'inset-4 z-50' : 'bottom-0 left-0 right-0 h-64 z-40'
}`} role="region" aria-label="Deployment logs" aria-live="polite">
<div className="flex items-center justify-between px-4 py-2 bg-gray-800 border-b border-gray-700">
<div className="flex items-center gap-3">
<span className="font-medium">Deployment Logs</span>
<span className={`flex items-center gap-1 text-xs ${
isConnected ? 'text-green-400' : 'text-gray-500'
}`} aria-live="polite">
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-gray-500'}`} aria-hidden="true" />
{isConnected ? 'Live' : 'Disconnected'}
</span>
{error && <span className="text-xs text-red-400" role="alert">{error}</span>}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
title={isExpanded ? 'Minimize' : 'Expand'}
aria-label={isExpanded ? 'Minimize log viewer' : 'Expand log viewer'}
>
{isExpanded ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
title="Close"
aria-label="Close log viewer"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div
ref={logContainerRef}
className="p-4 overflow-y-auto"
style={{ height: isExpanded ? 'calc(100% - 40px)' : 'calc(100% - 40px)' }}
>
{logs.length === 0 ? (
<div className="text-gray-500">Waiting for logs...</div>
) : (
logs.map((log, index) => (
<div key={index} className="py-0.5">
{log}
</div>
))
)}
</div>
</div>
);
}

View File

@ -0,0 +1,165 @@
'use client';
import { useState } from 'react';
import { api } from '@/lib/api';
import type { Service } from '@/lib/types';
import { X } from 'lucide-react';
interface ServiceFormProps {
service?: Service;
onClose: () => void;
onSuccess: () => void;
}
export function ServiceForm({ service, onClose, onSuccess }: ServiceFormProps) {
const [formData, setFormData] = useState({
id: service?.id || '',
name: service?.name || '',
scriptPath: service?.scriptPath || '',
healthUrl: service?.healthUrl || '',
repoPath: service?.repoPath || '',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
if (service) {
await api.updateService(service.id, formData);
} else {
await api.createService(formData);
}
onSuccess();
onClose();
} catch (err: any) {
setError(err.error || 'Failed to save service');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" role="dialog" aria-modal="true" aria-labelledby="service-form-title">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 id="service-form-title" className="text-xl font-semibold text-gray-900">
{service ? 'Edit Service' : 'Create Service'}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-1"
aria-label="Close form"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-600">
{error}
</div>
)}
<div>
<label htmlFor="id" className="block text-sm font-medium text-gray-700 mb-1">
Service ID
</label>
<input
type="text"
id="id"
value={formData.id}
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
disabled={!!service}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="e.g., trading"
required={!service}
/>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Service Name
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., Investment Trading"
required
/>
</div>
<div>
<label htmlFor="scriptPath" className="block text-sm font-medium text-gray-700 mb-1">
Script Path
</label>
<input
type="text"
id="scriptPath"
value={formData.scriptPath}
onChange={(e) => setFormData({ ...formData, scriptPath: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., ../deploy-invttrdg.sh"
required
/>
</div>
<div>
<label htmlFor="healthUrl" className="block text-sm font-medium text-gray-700 mb-1">
Health URL
</label>
<input
type="url"
id="healthUrl"
value={formData.healthUrl}
onChange={(e) => setFormData({ ...formData, healthUrl: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://api.bytelyst.com/invttrdg/health"
required
/>
</div>
<div>
<label htmlFor="repoPath" className="block text-sm font-medium text-gray-700 mb-1">
Repository Path
</label>
<input
type="text"
id="repoPath"
value={formData.repoPath}
onChange={(e) => setFormData({ ...formData, repoPath: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="../learning_ai_invt_trdg"
required
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{loading ? 'Saving...' : service ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -124,7 +124,7 @@ export function SidebarNav() {
return (
<>
{/* Mobile hamburger */}
<div className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center border-b bg-white px-4 md:hidden">
<div className="fixed top-0 left-0 right-0 z-30 flex h-14 items-center border-b bg-white px-4 md:hidden">
<button onClick={() => setMobileOpen(true)}>
<Menu className="h-6 w-6" />
</button>
@ -133,7 +133,7 @@ export function SidebarNav() {
{/* Spacer for mobile top bar */}
<div className="h-14 md:hidden" />
{/* Overlay */}
{/* Mobile overlay */}
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 md:hidden"
@ -141,9 +141,9 @@ export function SidebarNav() {
/>
)}
{/* Sidebar — always visible on md+, slide-in on mobile */}
{/* Sidebar — static on desktop, fixed on mobile */}
<aside
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r bg-white transition-transform duration-200 max-md:translate-x-[-100%] ${mobileOpen ? 'max-md:translate-x-0' : ''}`}
className={`hidden md:flex md:w-64 md:shrink-0 md:flex-col md:border-r md:bg-white fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r bg-white transition-transform duration-200 translate-x-[-100%] md:translate-x-0 ${mobileOpen ? 'translate-x-0' : ''}`}
>
{sidebarContent}
</aside>

View File

@ -0,0 +1,128 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { api } from './api.js';
// Mock fetch
global.fetch = vi.fn();
describe('API Client', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getServices', () => {
it('should fetch services successfully', async () => {
const mockServices = [
{
id: 'test-service',
name: 'Test Service',
scriptPath: '../deploy-test.sh',
healthUrl: 'https://test.example.com/health',
repoPath: '../test-repo',
status: 'up' as const,
version: '1.0.0',
productId: 'devops-internal',
},
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockServices,
});
const services = await api.getServices();
expect(services).toEqual(mockServices);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:4004/api/services',
expect.objectContaining({
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
})
);
});
it('should throw error on fetch failure', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
});
await expect(api.getServices()).rejects.toThrow('API error: 500 Internal Server Error');
});
it('should include auth token when available', async () => {
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(() => 'test-token'),
};
Object.defineProperty(global, 'localStorage', {
value: localStorageMock,
});
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => [],
});
await api.getServices();
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:4004/api/services',
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Bearer test-token',
}),
})
);
});
});
describe('triggerDeployment', () => {
it('should trigger deployment successfully', async () => {
const mockResponse = {
deploymentId: 'deployment-123',
status: 'running',
};
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const result = await api.triggerDeployment('test-service');
expect(result).toEqual(mockResponse);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:4004/api/deployments/trigger/test-service',
expect.objectContaining({
method: 'POST',
})
);
});
});
describe('seedServices', () => {
it('should seed services successfully', async () => {
const mockResponse = {
message: 'Seeded default services',
};
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const result = await api.seedServices();
expect(result).toEqual(mockResponse);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:4004/api/seed',
expect.objectContaining({
method: 'POST',
})
);
});
});
});

View File

@ -0,0 +1,530 @@
import { devopsApiUrl, platformUrl } from './product-config';
// Platform service URL for auth
const PLATFORM_SERVICE_URL = platformUrl;
export interface Service {
id: string;
name: string;
scriptPath: string;
healthUrl: string;
repoPath: string;
status: 'up' | 'down' | 'degraded';
version: string;
lastDeployedAt?: string;
lastHealthCheckAt?: string;
productId: string;
}
export interface Deployment {
id: string;
serviceId: string;
version: string;
status: 'running' | 'success' | 'failed';
logs: string;
triggeredBy: string;
triggeredAt: string;
completedAt?: string;
productId: string;
}
export interface ServiceHealth {
serviceId: string;
status: 'up' | 'down' | 'degraded';
responseTime?: number;
lastCheck: string;
}
export interface ApiError {
error: string;
status?: number;
}
export interface EnvVar {
id: string;
name: string;
value: string;
isSecret: boolean;
source: 'local' | 'azure-key-vault';
azureKeyVaultName?: string;
azureSecretName?: string;
updatedAt: string;
}
let csrfToken: string | null = null;
let csrfTokenExpiresAt: number = 0;
async function getAccessToken(): Promise<string | null> {
if (typeof window === 'undefined') return null;
let token = getAccessTokenFromStorage();
// If no token, try to refresh
if (!token) {
token = await refreshAccessToken();
}
return token;
}
async function getCsrfToken(): Promise<string | null> {
if (csrfToken && Date.now() < csrfTokenExpiresAt) {
return csrfToken;
}
try {
const token = await getAccessToken();
const response = await fetch(`${devopsApiUrl}/api/csrf-token`, {
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
},
});
if (response.ok) {
const data = await response.json();
csrfToken = data.csrfToken;
csrfTokenExpiresAt = Date.now() + 3000000; // 50 minutes (before 1 hour expiry)
return csrfToken;
}
} catch (error) {
console.error('Failed to fetch CSRF token:', error);
}
return null;
}
export async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
let token = await getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const method = options.method?.toUpperCase();
const stateChangingMethods = ['POST', 'PUT', 'DELETE', 'PATCH'];
if (method && stateChangingMethods.includes(method)) {
const csrf = await getCsrfToken();
if (csrf) {
headers['X-CSRF-Token'] = csrf;
}
}
let response = await fetch(`${devopsApiUrl}${endpoint}`, {
...options,
headers,
});
// Handle 401 - try to refresh token and retry
if (response.status === 401 && token) {
const newToken = await refreshAccessToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
response = await fetch(`${devopsApiUrl}${endpoint}`, {
...options,
headers,
});
}
}
// Handle 403 - CSRF token retry
if (response.status === 403) {
const errorData = await response.json().catch(() => ({}));
if (errorData.error === 'Invalid CSRF token') {
csrfToken = null;
csrfTokenExpiresAt = 0;
const newCsrf = await getCsrfToken();
if (newCsrf) {
headers['X-CSRF-Token'] = newCsrf;
response = await fetch(`${devopsApiUrl}${endpoint}`, {
...options,
headers,
});
}
}
}
if (!response.ok) {
const error: ApiError = {
error: `API error: ${response.status} ${response.statusText}`,
status: response.status
};
throw error;
}
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<Service[]>('/api/services'),
getService: (id: string) => apiRequest<Service>(`/api/services/${id}`),
createService: (data: Partial<Service>) =>
apiRequest<Service>('/api/services', {
method: 'POST',
body: JSON.stringify(data),
}),
updateService: (id: string, data: Partial<Service>) =>
apiRequest<Service>(`/api/services/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteService: (id: string) =>
apiRequest<void>(`/api/services/${id}`, {
method: 'DELETE',
}),
// Deployments
getDeployments: (limit = 20) => apiRequest<Deployment[]>(`/api/deployments?limit=${limit}`),
getServiceDeployments: (serviceId: string, limit = 50) =>
apiRequest<Deployment[]>(`/api/deployments/service/${serviceId}?limit=${limit}`),
getDeployment: (id: string) => apiRequest<Deployment>(`/api/deployments/${id}`),
triggerDeployment: (serviceId: string) =>
apiRequest<{ deploymentId: string; status: string }>(`/api/deployments/trigger/${serviceId}`, {
method: 'POST',
}),
// Health
getHealth: () => apiRequest<ServiceHealth[]>('/api/health'),
getServiceHealth: (serviceId: string) =>
apiRequest<ServiceHealth>(`/api/health/${serviceId}`),
clearHealthCache: () => apiRequest<{ message: string }>('/api/health/cache', { method: 'DELETE' }),
// Seed
seedServices: () => apiRequest<{ message: string }>('/api/seed', { method: 'POST' }),
// Environment Variables
getEnvVars: () => apiRequest<EnvVar[]>('/api/env'),
getEnvVar: (id: string) => apiRequest<EnvVar>(`/api/env/${id}`),
createEnvVar: (data: Partial<EnvVar>) =>
apiRequest<EnvVar>('/api/env', {
method: 'POST',
body: JSON.stringify(data),
}),
updateEnvVar: (id: string, data: Partial<EnvVar>) =>
apiRequest<EnvVar>(`/api/env/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteEnvVar: (id: string) =>
apiRequest<void>(`/api/env/${id}`, {
method: 'DELETE',
}),
syncAzureKeyVault: () =>
apiRequest<{ synced: number; errors: string[] }>('/api/env/sync-azure', {
method: 'POST',
}),
};
// Standalone functions for environment variables (used by env page)
export const getEnvVars = () => api.getEnvVars();
export const createEnvVar = (data: Partial<EnvVar>) => api.createEnvVar(data);
export const updateEnvVar = (id: string, data: Partial<EnvVar>) => api.updateEnvVar(id, data);
export const deleteEnvVar = (id: string) => api.deleteEnvVar(id);
// Azure Config
export interface AzureConfig {
id: string;
tenantId: string;
clientId: string;
keyVaultUrl: string;
isActive: boolean;
updatedAt: string;
hasClientSecret?: boolean;
}
export const azureApi = {
getAzureConfig: () => apiRequest<AzureConfig>('/api/azure-config'),
createAzureConfig: (data: Partial<AzureConfig>) =>
apiRequest<AzureConfig>('/api/azure-config', {
method: 'POST',
body: JSON.stringify(data),
}),
updateAzureConfig: (id: string, data: Partial<AzureConfig>) =>
apiRequest<AzureConfig>(`/api/azure-config/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteAzureConfig: (id: string) =>
apiRequest<void>(`/api/azure-config/${id}`, {
method: 'DELETE',
}),
testAzureConnection: () =>
apiRequest<{ success: boolean; error?: string }>('/api/azure-config/test', {
method: 'POST',
}),
};
export const getAzureConfig = () => azureApi.getAzureConfig();
export const createAzureConfig = (data: Partial<AzureConfig>) => azureApi.createAzureConfig(data);
export const updateAzureConfig = (id: string, data: Partial<AzureConfig>) => azureApi.updateAzureConfig(id, data);
export const deleteAzureConfig = (id: string) => azureApi.deleteAzureConfig(id);
export const testAzureConnection = () => azureApi.testAzureConnection();
// Code Quality
export interface CodeQualityIssue {
id: string;
type: 'error' | 'warning' | 'info';
category: 'typescript' | 'eslint' | 'build' | 'test' | 'format';
file: string;
line?: number;
column?: number;
message: string;
rule?: string;
}
export interface CodeQualityReport {
id: string;
projectId: string;
projectName: string;
projectPath: string;
timestamp: string;
summary: {
totalIssues: number;
errors: number;
warnings: number;
infos: number;
};
categories: {
typescript: {
errors: number;
warnings: number;
duration: number;
};
eslint: {
errors: number;
warnings: number;
duration: number;
};
build: {
success: boolean;
duration: number;
errors: number;
};
test: {
success: boolean;
passed: number;
failed: number;
duration: number;
};
};
issues: CodeQualityIssue[];
}
export interface CodeQualityCheckParams {
projectId: string;
projectPath: string;
checks: Array<'typescript' | 'eslint' | 'build' | 'test'>;
}
export const codeQualityApi = {
runCheck: (params: CodeQualityCheckParams) =>
apiRequest<CodeQualityReport>('/api/code-quality/check', {
method: 'POST',
body: JSON.stringify(params),
}),
};
export const runCodeQualityCheck = (params: CodeQualityCheckParams) => codeQualityApi.runCheck(params);
// Auth API - calls platform-service for authentication
export interface LoginRequest {
email: string;
password: string;
productId: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
user: {
id: string;
email: string;
role: string;
plan: string;
displayName: string;
products?: Array<{
productId: string;
plan: string;
role: string;
}>;
};
}
export interface RefreshRequest {
refreshToken: string;
}
export interface RefreshResponse {
accessToken: string;
refreshToken: string;
}
export interface MeResponse {
id: string;
email: string;
role: string;
plan: string;
displayName: string;
emailVerified: boolean;
currentProduct: string;
products: Array<{
productId: string;
plan: string;
role: string;
}>;
mfaEnabled: boolean;
mfaMethods: string[];
}
export const authApi = {
login: async (data: LoginRequest): Promise<LoginResponse> => {
const response = await fetch(`${PLATFORM_SERVICE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Login failed' }));
throw new Error(error.error || 'Login failed');
}
return response.json();
},
refresh: async (data: RefreshRequest): Promise<RefreshResponse> => {
const response = await fetch(`${PLATFORM_SERVICE_URL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
return response.json();
},
me: async (token: string): Promise<MeResponse> => {
const response = await fetch(`${PLATFORM_SERVICE_URL}/auth/me`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Failed to get user info');
}
return response.json();
},
};
// Helper functions for auth state management
export function setAccessToken(token: string): void {
if (typeof window !== 'undefined') {
localStorage.setItem('access_token', token);
}
}
export function setRefreshToken(token: string): void {
if (typeof window !== 'undefined') {
localStorage.setItem('refresh_token', token);
}
}
export function getAccessTokenFromStorage(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token');
}
export function getRefreshTokenFromStorage(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('refresh_token');
}
export function clearAuthTokens(): void {
if (typeof window !== 'undefined') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
}
export async function refreshAccessToken(): Promise<string | null> {
const refreshToken = getRefreshTokenFromStorage();
if (!refreshToken) return null;
try {
const response = await authApi.refresh({ refreshToken });
setAccessToken(response.accessToken);
setRefreshToken(response.refreshToken);
return response.accessToken;
} catch {
clearAuthTokens();
return null;
}
}

View File

@ -0,0 +1,11 @@
// Local product identity (replaces @bytelyst/config)
const productIdentity = {
productId: process.env.NEXT_PUBLIC_PRODUCT_ID || 'bytelyst-devops',
name: process.env.NEXT_PUBLIC_PRODUCT_NAME || 'ByteLyst DevOps Dashboard',
};
export const devopsApiUrl = process.env.NEXT_PUBLIC_DEVOPS_API_URL || 'http://localhost:4004';
export const platformUrl = process.env.NEXT_PUBLIC_PLATFORM_URL || 'https://api.bytelyst.com/platform/api';
export const productId = productIdentity.productId;
export const productName = productIdentity.name;

View File

@ -0,0 +1,39 @@
/**
* Client-side self-telemetry for the DevOps dashboard.
* Delegates to @bytelyst/telemetry-client shared package.
* Sends to platform-service via /api/telemetry/admin-ingest proxy.
* Privacy: No PII. Only page paths, action names, and timing metrics.
*
* NOTE: Telemetry is disabled for now until @bytelyst/telemetry-client is available
*/
export interface TelemetryEvent {
action: string;
category?: string;
properties?: Record<string, string | number | boolean>;
metrics?: Record<string, number>;
}
export function trackEvent(event: TelemetryEvent): void {
// No-op - telemetry disabled
}
export function trackPageView(path: string): void {
// No-op - telemetry disabled
}
export function trackDeployment(serviceId: string, action: 'trigger' | 'success' | 'failed'): void {
// No-op - telemetry disabled
}
export function trackHealthCheck(serviceId: string, status: 'up' | 'down' | 'degraded'): void {
// No-op - telemetry disabled
}
export function trackUserAction(action: string, properties?: Record<string, string>): void {
// No-op - telemetry disabled
}
export function initTelemetry(): void {
// No-op - telemetry disabled
}

View File

@ -0,0 +1 @@
export type { Service, Deployment, ServiceHealth } from './api.js';

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@ -0,0 +1,15 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
};
export default config;

View File

@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
passWithNoTests: true,
},
});

View File

@ -149,6 +149,25 @@ Key files:
- `utils/`
- `output/`
### `dashboard/`
ByteLyst DevOps dashboard — internal product for deployment orchestration and service monitoring.
This is a full ByteLyst product (backend + web) integrated with the common platform:
- Backend: Fastify 5 (port 4004) with platform-service auth, Cosmos DB, deployment orchestration
- Web: Next.js 16 (port 3000) with react-auth, service status cards, deploy buttons
- Integration: Links to/from admin-web, uses @bytelyst/* packages
Key files:
- `dashboard/backend/src/` — Fastify server, services/deployments/health modules
- `dashboard/web/src/` — Next.js app, API client, auth provider
- `dashboard/shared/product.json` — Product identity (devops-internal)
- `dashboard/README.md` — Setup and usage documentation
See `dashboard/README.md` for architecture and setup instructions.
### `_AZURE/`
Account-specific notes and operational docs.