diff --git a/dashboard/web/.env.local.example b/dashboard/web/.env.local.example new file mode 100644 index 0000000..918f24a --- /dev/null +++ b/dashboard/web/.env.local.example @@ -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 diff --git a/dashboard/web/.gitignore b/dashboard/web/.gitignore new file mode 100644 index 0000000..98e05a0 --- /dev/null +++ b/dashboard/web/.gitignore @@ -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 diff --git a/dashboard/web/Dockerfile b/dashboard/web/Dockerfile new file mode 100644 index 0000000..1e845ae --- /dev/null +++ b/dashboard/web/Dockerfile @@ -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"] diff --git a/dashboard/web/next-env.d.ts b/dashboard/web/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/dashboard/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +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. diff --git a/dashboard/web/next.config.js b/dashboard/web/next.config.js new file mode 100644 index 0000000..d5456a1 --- /dev/null +++ b/dashboard/web/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/dashboard/web/playwright.config.ts b/dashboard/web/playwright.config.ts new file mode 100644 index 0000000..373d612 --- /dev/null +++ b/dashboard/web/playwright.config.ts @@ -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, + }, +}); diff --git a/dashboard/web/postcss.config.js b/dashboard/web/postcss.config.js new file mode 100644 index 0000000..b4bee66 --- /dev/null +++ b/dashboard/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +}; diff --git a/dashboard/web/src/app/code-quality/page.tsx b/dashboard/web/src/app/code-quality/page.tsx new file mode 100644 index 0000000..7afb4f1 --- /dev/null +++ b/dashboard/web/src/app/code-quality/page.tsx @@ -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>([ + 'typescript', + 'eslint', + 'build', + 'test', + ]); + const [loading, setLoading] = useState(false); + const [report, setReport] = useState(null); + const [error, setError] = useState(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 ; + case 'warning': + return ; + case 'info': + return ; + default: + return null; + } + }; + + const getCategoryIcon = (category: string) => { + switch (category) { + case 'typescript': + return ; + case 'eslint': + return ; + case 'build': + return ; + case 'test': + return ; + default: + return ; + } + }; + + return ( +
+ + +
+
+
+

Code Quality Analysis

+

+ Run TypeScript, ESLint, build, and test checks on your projects +

+
+ + {/* Configuration */} +
+

Configuration

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ +
+ {(['typescript', 'eslint', 'build', 'test'] as const).map((check) => ( + + ))} +
+
+ + +
+ + {error && ( +
+ {error} +
+ )} + + {/* Results */} + {report && ( +
+ {/* Summary */} +
+

Summary

+
+
+
+ {report.summary.totalIssues} +
+
Total Issues
+
+
+
+ {report.summary.errors} +
+
Errors
+
+
+
+ {report.summary.warnings} +
+
Warnings
+
+
+
+ {report.categories.test.passed} +
+
Tests Passed
+
+
+
+ + {/* Categories */} +
+
+
+ +

TypeScript

+
+
+
+ Errors: + {report.categories.typescript.errors} +
+
+ Warnings: + {report.categories.typescript.warnings} +
+
+ Duration: + + {report.categories.typescript.duration}ms + +
+
+
+ +
+
+ +

ESLint

+
+
+
+ Errors: + {report.categories.eslint.errors} +
+
+ Warnings: + {report.categories.eslint.warnings} +
+
+ Duration: + + {report.categories.eslint.duration}ms + +
+
+
+ +
+
+ +

Build

+
+
+
+ Status: + + {report.categories.build.success ? 'Success' : 'Failed'} + +
+
+ Errors: + {report.categories.build.errors} +
+
+ Duration: + + {report.categories.build.duration}ms + +
+
+
+ +
+
+ +

Tests

+
+
+
+ Status: + + {report.categories.test.success ? 'Success' : 'Failed'} + +
+
+ Passed: + {report.categories.test.passed} +
+
+ Failed: + {report.categories.test.failed} +
+
+
+
+ + {/* Issues List */} +
+

Issues

+ {report.issues.length === 0 ? ( +
+ +

No issues found!

+
+ ) : ( +
+ {report.issues.map((issue) => ( +
+
{getIssueIcon(issue.type)}
+
+
+ {issue.file} + {issue.line && ( + + :{issue.line} + {issue.column && `:${issue.column}`} + + )} +
{getCategoryIcon(issue.category)}
+
+

{issue.message}

+ {issue.rule && ( +

Rule: {issue.rule}

+ )} +
+
+ ))} +
+ )} +
+
+ )} +
+
+
+ ); +} diff --git a/dashboard/web/src/app/health/page.tsx b/dashboard/web/src/app/health/page.tsx new file mode 100644 index 0000000..fcc03ba --- /dev/null +++ b/dashboard/web/src/app/health/page.tsx @@ -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([]); + const [healthData, setHealthData] = useState>(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 ( +
+
Loading...
+
+ ); + } + + 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 ( +
+ + +
+
+
+
+

Health Dashboard

+

Real-time service health monitoring

+
+ +
+ {/* Summary Cards */} +
+
+
+
+

Total Services

+

{services.length}

+
+ +
+
+ +
+
+
+

Healthy

+

{healthyCount}

+
+ +
+
+ +
+
+
+

Degraded

+

{degradedCount}

+
+ +
+
+ +
+
+
+

Down

+

{downCount}

+
+ +
+
+
+ + {/* Health Details */} +
+
+

Service Health Details

+
+
+ {services.map((service) => { + const health = healthData.get(service.id); + const uptime = getUptimePercentage(service); + + return ( +
+
+
+

{service.name}

+

{service.repoPath}

+
+ + {service.status} + +
+ +
+
+ +
+

Last Check

+

+ {service.lastHealthCheckAt + ? new Date(service.lastHealthCheckAt).toLocaleString() + : 'Never'} +

+
+
+ + {health?.responseTime && ( +
+ +
+

Response Time

+

+ {health.responseTime}ms +

+
+
+ )} + +
+ +
+

Estimated Uptime

+

{uptime.toFixed(1)}%

+
+
+
+ +
+
+ Uptime + {uptime.toFixed(1)}% +
+
+
90 ? 'bg-green-600' : uptime > 70 ? 'bg-yellow-600' : 'bg-red-600' + }`} + style={{ width: `${uptime}%` }} + /> +
+
+
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/dashboard/web/src/app/layout.tsx b/dashboard/web/src/app/layout.tsx new file mode 100644 index 0000000..8f46f7d --- /dev/null +++ b/dashboard/web/src/app/layout.tsx @@ -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 ( + + + + Skip to main content + + + + {children} + + + + + ); +} diff --git a/dashboard/web/src/app/login/page.tsx b/dashboard/web/src/app/login/page.tsx new file mode 100644 index 0000000..c6a7a81 --- /dev/null +++ b/dashboard/web/src/app/login/page.tsx @@ -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 ( +
+
+

DevOps Dashboard Login

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + 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" + /> +
+ +
+ + 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="••••••••" + /> +
+ +
+ + 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" + /> +
+ + +
+ +

+ Authenticate using your platform-service credentials +

+
+
+ ); +} diff --git a/dashboard/web/src/app/metrics/page.tsx b/dashboard/web/src/app/metrics/page.tsx new file mode 100644 index 0000000..822456d --- /dev/null +++ b/dashboard/web/src/app/metrics/page.tsx @@ -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([]); + 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(); + 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 ( +
+
Loading...
+
+ ); + } + + const stats = getDeploymentStats(); + const deploymentsByService = getDeploymentsByService(); + const avgDeploymentTime = getAverageDeploymentTime(); + const deploymentTrend = getDeploymentTrend(); + const maxCount = Math.max(...deploymentTrend.map(d => d.count), 1); + + return ( +
+ + +
+
+
+
+

Metrics & Analytics

+

Deployment statistics and trends

+
+
+ {/* Summary Stats */} +
+
+
+
+

Total Deployments

+

{stats.total}

+
+ +
+
+ +
+
+
+

Success Rate

+

+ {stats.total > 0 ? Math.round((stats.success / stats.total) * 100) : 0}% +

+
+ +
+
+ +
+
+
+

Failed

+

{stats.failed}

+
+ +
+
+ +
+
+
+

Avg Time

+

{avgDeploymentTime}s

+
+ +
+
+
+ + {/* Deployment Trend Chart */} +
+

Deployment Trend (Last 7 Days)

+
+ {deploymentTrend.map((day) => ( +
+
+
0 ? '4px' : '0' }} + title={`Success: ${day.success}`} + /> +
0 ? '4px' : '0' }} + title={`Failed: ${day.failed}`} + /> +
+
+ {new Date(day.date).toLocaleDateString('en', { weekday: 'short' })} +
+
{day.count}
+
+ ))} +
+
+
+
+ Success +
+
+
+ Failed +
+
+
+ + {/* Deployments by Service */} +
+

Deployments by Service

+
+ {deploymentsByService.map(([serviceId, count]) => { + const percentage = (count / stats.total) * 100; + return ( +
+
+ {serviceId} + {count} deployments ({percentage.toFixed(1)}%) +
+
+
+
+
+ ); + })} +
+
+ + {/* Success Rate by Service */} +
+

Success Rate by Service

+
+ {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 ( +
+
+ {serviceId} + {rate.toFixed(1)}% +
+
+
= 90 ? 'bg-green-600' : rate >= 70 ? 'bg-yellow-600' : 'bg-red-600' + }`} + style={{ width: `${rate}%` }} + /> +
+
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/dashboard/web/src/app/page.tsx b/dashboard/web/src/app/page.tsx index 38ab558..808b9b4 100644 --- a/dashboard/web/src/app/page.tsx +++ b/dashboard/web/src/app/page.tsx @@ -151,12 +151,13 @@ export default function DashboardPage() { } return ( -
+
-
- {/* Header */} -
+
+
+ {/* Header */} +
{error && (
@@ -338,6 +339,7 @@ export default function DashboardPage() {
)} +
{showServiceForm && ( diff --git a/dashboard/web/src/app/settings/cosmos/page.tsx b/dashboard/web/src/app/settings/cosmos/page.tsx new file mode 100644 index 0000000..81f701f --- /dev/null +++ b/dashboard/web/src/app/settings/cosmos/page.tsx @@ -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(null); + const [status, setStatus] = useState(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('/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('/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 ( +
+
+
+ ); + } + + return ( +
+ + +
+
+
+

Cosmos DB Configuration

+

Configure your Azure Cosmos DB connection for the DevOps dashboard.

+
+ + {/* Status Card */} +
+

Connection Status

+ {status ? ( +
+
+ Status: + + {status.isInitialized ? 'Connected' : 'Not Connected'} + +
+ {status.error && ( +
+ Error: + {status.error} +
+ )} +
+ ) : ( +

Loading status...

+ )} +
+ + {/* Configuration Form */} +
+

Configuration

+
{ e.preventDefault(); saveConfig(); }} className="space-y-4"> +
+ + 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 + /> +

+ The endpoint URL of your Azure Cosmos DB account +

+
+ +
+ + 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} + /> +

+ The primary or secondary key of your Cosmos DB account +

+
+ +
+ + 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 + /> +

+ The name of the Cosmos DB database to use +

+
+ + {message && ( +
+ {message.text} +
+ )} + +
+ + +
+
+
+ + {/* Help Section */} +
+

Need help?

+
    +
  • For local development, you can use the Azure Cosmos DB Emulator
  • +
  • Emulator endpoint: https://localhost:8081
  • +
  • Emulator key: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==
  • +
  • Download the emulator from: aka.ms/cosmosdb-emulator
  • +
+
+
+
+
+ ); +} diff --git a/dashboard/web/src/app/system/page.tsx b/dashboard/web/src/app/system/page.tsx new file mode 100644 index 0000000..e90f137 --- /dev/null +++ b/dashboard/web/src/app/system/page.tsx @@ -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(null); + const [dockerStats, setDockerStats] = useState(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 ( +
+
Loading...
+
+ ); + } + + return ( +
+ + +
+
+
+
+

System Management

+

Monitor resources and manage Docker

+
+ +
+ {cleanupResult && ( +
+ +
+

{cleanupResult.message}

+

Freed space: {formatBytes(cleanupResult.freedSpace * 1024 * 1024)}

+
+ +
+ )} + + {/* System Metrics */} +
+

System Metrics

+ +
+ {/* CPU */} +
+
+ +
+

CPU Usage

+

{metrics?.cpu.usage || 0}%

+
+
+
+
+ Cores + {metrics?.cpu.cores || 0} +
+
+ Load Avg (1m) + {metrics?.cpu.loadAverage[0]?.toFixed(2) || '0.00'} +
+
+ Load Avg (5m) + {metrics?.cpu.loadAverage[1]?.toFixed(2) || '0.00'} +
+
+
+ + {/* Memory */} +
+
+ +
+

Memory

+

{metrics?.memory.percentage || 0}%

+
+
+
+
+ Total + {formatBytes((metrics?.memory.total || 0) * 1024 * 1024 * 1024)} +
+
+ Used + {formatBytes((metrics?.memory.used || 0) * 1024 * 1024 * 1024)} +
+
+ Free + {formatBytes((metrics?.memory.free || 0) * 1024 * 1024 * 1024)} +
+
+
+
+
+
+
+
+ + {/* Disk */} +
+
+ +
+

Disk Usage

+
+
+
+ {metrics?.disk.map((disk) => ( +
+
+ {disk.path} + {disk.percentage}% +
+
+
+
+
+ {formatBytes(disk.used)} used + {formatBytes(disk.free)} free +
+
+ ))} +
+
+
+ + {/* Platform Info */} +
+

Platform Information

+
+
+ Hostname +

{metrics?.platform.hostname || 'N/A'}

+
+
+ Platform +

{metrics?.platform.platform || 'N/A'}

+
+
+ Architecture +

{metrics?.platform.arch || 'N/A'}

+
+
+ Node Version +

{metrics?.platform.nodeVersion || 'N/A'}

+
+
+
+
+ + {/* Docker Management */} +
+

Docker Management

+ +
+ {/* Images */} +
+
+

Images

+ +
+
+
+ Total + {dockerStats?.images.total || 0} +
+
+ Dangling + {dockerStats?.images.dangling || 0} +
+
+ Size + {formatBytes(dockerStats?.images.size || 0)} +
+
+
+ + +
+
+ + {/* Containers */} +
+
+

Containers

+ +
+
+
+ Total + {dockerStats?.containers.total || 0} +
+
+ Running + {dockerStats?.containers.running || 0} +
+
+ Stopped + {dockerStats?.containers.stopped || 0} +
+
+ Size + {formatBytes(dockerStats?.containers.size || 0)} +
+
+ +
+ + {/* Volumes */} +
+
+

Volumes

+ +
+
+
+ Total + {dockerStats?.volumes.total || 0} +
+
+ Unused + {dockerStats?.volumes.unused || 0} +
+
+ Size + {formatBytes(dockerStats?.volumes.size || 0)} +
+
+ +
+
+ + {/* Full Cleanup */} +
+
+ +
+

Full Cleanup

+

Remove all unused Docker resources (images, containers, volumes, build cache)

+
+
+ +
+
+
+
+
+ ); +} diff --git a/dashboard/web/src/components/error-boundary.tsx b/dashboard/web/src/components/error-boundary.tsx new file mode 100644 index 0000000..c5a5ea1 --- /dev/null +++ b/dashboard/web/src/components/error-boundary.tsx @@ -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 { + 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 ( +
+
+
+ + + +
+

+ Something went wrong +

+

+ An unexpected error occurred. Please refresh the page or contact + support if the problem persists. +

+
+

+ {this.state.error?.message || 'Unknown error'} +

+
+
+ +
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/dashboard/web/src/components/log-viewer.tsx b/dashboard/web/src/components/log-viewer.tsx new file mode 100644 index 0000000..6c071c4 --- /dev/null +++ b/dashboard/web/src/components/log-viewer.tsx @@ -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([]); + const [isExpanded, setIsExpanded] = useState(false); + const [error, setError] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const logContainerRef = useRef(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 ( +
+
+
+ Deployment Logs + + + {error && {error}} +
+
+ + +
+
+ +
+ {logs.length === 0 ? ( +
Waiting for logs...
+ ) : ( + logs.map((log, index) => ( +
+ {log} +
+ )) + )} +
+
+ ); +} diff --git a/dashboard/web/src/components/service-form.tsx b/dashboard/web/src/components/service-form.tsx new file mode 100644 index 0000000..0e9efaa --- /dev/null +++ b/dashboard/web/src/components/service-form.tsx @@ -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 ( +
+
+
+

+ {service ? 'Edit Service' : 'Create Service'} +

+ +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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} + /> +
+ +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/dashboard/web/src/components/sidebar-nav.tsx b/dashboard/web/src/components/sidebar-nav.tsx index 01edff6..a56a4cb 100644 --- a/dashboard/web/src/components/sidebar-nav.tsx +++ b/dashboard/web/src/components/sidebar-nav.tsx @@ -124,7 +124,7 @@ export function SidebarNav() { return ( <> {/* Mobile hamburger */} -
+
@@ -133,7 +133,7 @@ export function SidebarNav() { {/* Spacer for mobile top bar */}
- {/* Overlay */} + {/* Mobile overlay */} {mobileOpen && (
)} - {/* Sidebar — always visible on md+, slide-in on mobile */} + {/* Sidebar — static on desktop, fixed on mobile */} diff --git a/dashboard/web/src/lib/api.test.ts b/dashboard/web/src/lib/api.test.ts new file mode 100644 index 0000000..1bb8350 --- /dev/null +++ b/dashboard/web/src/lib/api.test.ts @@ -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', + }) + ); + }); + }); +}); diff --git a/dashboard/web/src/lib/api.ts b/dashboard/web/src/lib/api.ts new file mode 100644 index 0000000..dffbcfe --- /dev/null +++ b/dashboard/web/src/lib/api.ts @@ -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 { + 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 { + 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( + endpoint: string, + options: RequestInit = {} +): Promise { + 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('/api/services'), + getService: (id: string) => apiRequest(`/api/services/${id}`), + createService: (data: Partial) => + apiRequest('/api/services', { + method: 'POST', + body: JSON.stringify(data), + }), + updateService: (id: string, data: Partial) => + apiRequest(`/api/services/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + deleteService: (id: string) => + apiRequest(`/api/services/${id}`, { + method: 'DELETE', + }), + + // Deployments + getDeployments: (limit = 20) => apiRequest(`/api/deployments?limit=${limit}`), + getServiceDeployments: (serviceId: string, limit = 50) => + apiRequest(`/api/deployments/service/${serviceId}?limit=${limit}`), + getDeployment: (id: string) => apiRequest(`/api/deployments/${id}`), + triggerDeployment: (serviceId: string) => + apiRequest<{ deploymentId: string; status: string }>(`/api/deployments/trigger/${serviceId}`, { + method: 'POST', + }), + + // Health + getHealth: () => apiRequest('/api/health'), + getServiceHealth: (serviceId: string) => + apiRequest(`/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('/api/env'), + getEnvVar: (id: string) => apiRequest(`/api/env/${id}`), + createEnvVar: (data: Partial) => + apiRequest('/api/env', { + method: 'POST', + body: JSON.stringify(data), + }), + updateEnvVar: (id: string, data: Partial) => + apiRequest(`/api/env/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + deleteEnvVar: (id: string) => + apiRequest(`/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) => api.createEnvVar(data); +export const updateEnvVar = (id: string, data: Partial) => 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('/api/azure-config'), + createAzureConfig: (data: Partial) => + apiRequest('/api/azure-config', { + method: 'POST', + body: JSON.stringify(data), + }), + updateAzureConfig: (id: string, data: Partial) => + apiRequest(`/api/azure-config/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + deleteAzureConfig: (id: string) => + apiRequest(`/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) => azureApi.createAzureConfig(data); +export const updateAzureConfig = (id: string, data: Partial) => 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('/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 => { + 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 => { + 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 => { + 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 { + 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; + } +} diff --git a/dashboard/web/src/lib/product-config.ts b/dashboard/web/src/lib/product-config.ts new file mode 100644 index 0000000..0add3ba --- /dev/null +++ b/dashboard/web/src/lib/product-config.ts @@ -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; diff --git a/dashboard/web/src/lib/telemetry.ts b/dashboard/web/src/lib/telemetry.ts new file mode 100644 index 0000000..0100f83 --- /dev/null +++ b/dashboard/web/src/lib/telemetry.ts @@ -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; + metrics?: Record; +} + +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): void { + // No-op - telemetry disabled +} + +export function initTelemetry(): void { + // No-op - telemetry disabled +} diff --git a/dashboard/web/src/lib/types.ts b/dashboard/web/src/lib/types.ts new file mode 100644 index 0000000..6925596 --- /dev/null +++ b/dashboard/web/src/lib/types.ts @@ -0,0 +1 @@ +export type { Service, Deployment, ServiceHealth } from './api.js'; diff --git a/dashboard/web/src/test/setup.ts b/dashboard/web/src/test/setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/dashboard/web/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/dashboard/web/tailwind.config.ts b/dashboard/web/tailwind.config.ts new file mode 100644 index 0000000..386bc84 --- /dev/null +++ b/dashboard/web/tailwind.config.ts @@ -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; diff --git a/dashboard/web/tsconfig.json b/dashboard/web/tsconfig.json new file mode 100644 index 0000000..f3c9779 --- /dev/null +++ b/dashboard/web/tsconfig.json @@ -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" + ] +} diff --git a/dashboard/web/vitest.config.ts b/dashboard/web/vitest.config.ts new file mode 100644 index 0000000..366bed9 --- /dev/null +++ b/dashboard/web/vitest.config.ts @@ -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, + }, +}); diff --git a/docs/repo-map.md b/docs/repo-map.md index cfac83f..1221639 100644 --- a/docs/repo-map.md +++ b/docs/repo-map.md @@ -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.