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,12 +151,13 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="flex min-h-screen bg-gray-50">
|
||||||
<SidebarNav />
|
<SidebarNav />
|
||||||
|
|
||||||
<main className="ml-64 min-h-screen p-8 max-md:ml-0">
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||||
{/* Header */}
|
<div className="p-8 max-md:p-4">
|
||||||
<div className="mb-8">
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -338,6 +339,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{showServiceForm && (
|
{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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile hamburger */}
|
{/* 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)}>
|
<button onClick={() => setMobileOpen(true)}>
|
||||||
<Menu className="h-6 w-6" />
|
<Menu className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@ -133,7 +133,7 @@ export function SidebarNav() {
|
|||||||
{/* Spacer for mobile top bar */}
|
{/* Spacer for mobile top bar */}
|
||||||
<div className="h-14 md:hidden" />
|
<div className="h-14 md:hidden" />
|
||||||
|
|
||||||
{/* Overlay */}
|
{/* Mobile overlay */}
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-black/50 md:hidden"
|
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
|
<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}
|
{sidebarContent}
|
||||||
</aside>
|
</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/`
|
- `utils/`
|
||||||
- `output/`
|
- `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/`
|
### `_AZURE/`
|
||||||
|
|
||||||
Account-specific notes and operational docs.
|
Account-specific notes and operational docs.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user