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:
parent
dcf7ecbb32
commit
b35de88b08
5
dashboard/web/.env.local.example
Normal file
5
dashboard/web/.env.local.example
Normal 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
29
dashboard/web/.gitignore
vendored
Normal 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
52
dashboard/web/Dockerfile
Normal 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
6
dashboard/web/next-env.d.ts
vendored
Normal 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.
|
||||
6
dashboard/web/next.config.js
Normal file
6
dashboard/web/next.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
37
dashboard/web/playwright.config.ts
Normal file
37
dashboard/web/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
6
dashboard/web/postcss.config.js
Normal file
6
dashboard/web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
343
dashboard/web/src/app/code-quality/page.tsx
Normal file
343
dashboard/web/src/app/code-quality/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
dashboard/web/src/app/health/page.tsx
Normal file
241
dashboard/web/src/app/health/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
dashboard/web/src/app/layout.tsx
Normal file
44
dashboard/web/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
dashboard/web/src/app/login/page.tsx
Normal file
112
dashboard/web/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
dashboard/web/src/app/metrics/page.tsx
Normal file
244
dashboard/web/src/app/metrics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 && (
|
||||
|
||||
245
dashboard/web/src/app/settings/cosmos/page.tsx
Normal file
245
dashboard/web/src/app/settings/cosmos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
422
dashboard/web/src/app/system/page.tsx
Normal file
422
dashboard/web/src/app/system/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
dashboard/web/src/components/error-boundary.tsx
Normal file
75
dashboard/web/src/components/error-boundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
116
dashboard/web/src/components/log-viewer.tsx
Normal file
116
dashboard/web/src/components/log-viewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
dashboard/web/src/components/service-form.tsx
Normal file
165
dashboard/web/src/components/service-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
128
dashboard/web/src/lib/api.test.ts
Normal file
128
dashboard/web/src/lib/api.test.ts
Normal 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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
530
dashboard/web/src/lib/api.ts
Normal file
530
dashboard/web/src/lib/api.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
dashboard/web/src/lib/product-config.ts
Normal file
11
dashboard/web/src/lib/product-config.ts
Normal 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;
|
||||
39
dashboard/web/src/lib/telemetry.ts
Normal file
39
dashboard/web/src/lib/telemetry.ts
Normal 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
|
||||
}
|
||||
1
dashboard/web/src/lib/types.ts
Normal file
1
dashboard/web/src/lib/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type { Service, Deployment, ServiceHealth } from './api.js';
|
||||
1
dashboard/web/src/test/setup.ts
Normal file
1
dashboard/web/src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
15
dashboard/web/tailwind.config.ts
Normal file
15
dashboard/web/tailwind.config.ts
Normal 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;
|
||||
41
dashboard/web/tsconfig.json
Normal file
41
dashboard/web/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
12
dashboard/web/vitest.config.ts
Normal file
12
dashboard/web/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user