improve: dashboard security, code quality, and UX fixes
Security (backend): - env/routes: add requireAdmin to all 6 env endpoints — GET /env was fully open, exposing all secret values to unauthenticated requests - deployments/routes: add requireAdmin to all 4 GET endpoints (deployment history and logs were publicly readable) - health/routes: remove duplicate requireAdmin call from DELETE /health/cache handler body (was already enforced via preHandler) Frontend — auth/api: - system/page: replace raw fetch + localStorage token with apiRequest (mutations now go through CSRF flow) - vm/page: same — replace raw fetch with vmApi from api.ts - api.ts: add vmApi (getHealth, getCleanupLog, runCleanup) + shared VmHealthResult / VmCheck / VmCheckLevel types Shared utilities: - utils.ts: add formatBytes() and getStatusColor() shared helpers - system/page: remove duplicate formatBytes, import from utils - health/page: remove duplicate getStatusColor, import from utils - page.tsx (home): remove duplicate getStatusColor, import from utils UX improvements: - page.tsx: remove Seed Services button from normal header (debug tool) - page.tsx: deploy button now always enabled; shows inline warning banner when service is not 'up' instead of silently disabling the button - metrics: fix bar chart — bars now grow from bottom (flex-col-reverse), add empty state, fix date parsing timezone edge case - sidebar-nav: theme toggle now functional — persists to localStorage and toggles document.documentElement class 'dark' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d0b8ce2c74
commit
1099d518ef
@ -13,23 +13,29 @@ import { createAuditLog } from '../audit/repository.js';
|
|||||||
import { productId } from '../../lib/config.js';
|
import { productId } from '../../lib/config.js';
|
||||||
|
|
||||||
export async function deploymentRoutes(fastify: FastifyInstance) {
|
export async function deploymentRoutes(fastify: FastifyInstance) {
|
||||||
// Get recent deployments across all services
|
// Get recent deployments across all services (admin only)
|
||||||
fastify.get('/deployments', async (req, reply) => {
|
fastify.get('/deployments', {
|
||||||
|
preHandler: async (req) => requireAdmin(req),
|
||||||
|
}, async (req, reply) => {
|
||||||
const query = QueryParamsSchema.parse(req.query);
|
const query = QueryParamsSchema.parse(req.query);
|
||||||
const deployments = await getRecentDeployments(query.limit);
|
const deployments = await getRecentDeployments(query.limit);
|
||||||
return reply.send(deployments);
|
return reply.send(deployments);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get deployments for a specific service
|
// Get deployments for a specific service (admin only)
|
||||||
fastify.get('/deployments/service/:serviceId', async (req, reply) => {
|
fastify.get('/deployments/service/:serviceId', {
|
||||||
|
preHandler: async (req) => requireAdmin(req),
|
||||||
|
}, async (req, reply) => {
|
||||||
const params = TriggerDeploymentParamsSchema.parse(req.params);
|
const params = TriggerDeploymentParamsSchema.parse(req.params);
|
||||||
const query = QueryParamsSchema.parse(req.query);
|
const query = QueryParamsSchema.parse(req.query);
|
||||||
const deployments = await getDeploymentsByService(params.serviceId, query.limit);
|
const deployments = await getDeploymentsByService(params.serviceId, query.limit);
|
||||||
return reply.send(deployments);
|
return reply.send(deployments);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get single deployment
|
// Get single deployment (admin only)
|
||||||
fastify.get('/deployments/:id', async (req, reply) => {
|
fastify.get('/deployments/:id', {
|
||||||
|
preHandler: async (req) => requireAdmin(req),
|
||||||
|
}, async (req, reply) => {
|
||||||
const params = DeploymentParamsSchema.parse(req.params);
|
const params = DeploymentParamsSchema.parse(req.params);
|
||||||
const deployment = await getDeploymentById(params.id);
|
const deployment = await getDeploymentById(params.id);
|
||||||
if (!deployment) {
|
if (!deployment) {
|
||||||
@ -38,9 +44,11 @@ export async function deploymentRoutes(fastify: FastifyInstance) {
|
|||||||
return reply.send(deployment);
|
return reply.send(deployment);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get deployment logs (SSE disabled due to Fastify 5 compatibility)
|
// Get deployment logs (admin only; SSE disabled due to Fastify 5 compatibility)
|
||||||
// TODO: Re-enable SSE when fastify-sse-v2 supports Fastify 5
|
// TODO: Re-enable SSE when fastify-sse-v2 supports Fastify 5
|
||||||
fastify.get('/deployments/:id/logs', async (req, reply) => {
|
fastify.get('/deployments/:id/logs', {
|
||||||
|
preHandler: async (req) => requireAdmin(req),
|
||||||
|
}, async (req, reply) => {
|
||||||
const params = DeploymentParamsSchema.parse(req.params);
|
const params = DeploymentParamsSchema.parse(req.params);
|
||||||
const deployment = await getDeploymentById(params.id);
|
const deployment = await getDeploymentById(params.id);
|
||||||
|
|
||||||
|
|||||||
29
dashboard/backend/src/modules/env/routes.ts
vendored
29
dashboard/backend/src/modules/env/routes.ts
vendored
@ -1,21 +1,27 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { BadRequestError } from '../../lib/auth.js';
|
import { BadRequestError, requireAdmin } from '../../lib/auth.js';
|
||||||
import { deleteEnvVar, getEnvVar, getEnvVars, upsertEnvVar } from './repository.js';
|
import { deleteEnvVar, getEnvVar, getEnvVars, upsertEnvVar } from './repository.js';
|
||||||
import { EnvVarInputSchema, EnvVarParamsSchema } from './types.js';
|
import { EnvVarInputSchema, EnvVarParamsSchema } from './types.js';
|
||||||
|
|
||||||
export async function envRoutes(fastify: FastifyInstance) {
|
export async function envRoutes(fastify: FastifyInstance) {
|
||||||
fastify.get('/env', async (req, reply) => {
|
fastify.get('/env', {
|
||||||
|
preHandler: async (req) => requireAdmin(req),
|
||||||
|
}, async (req, reply) => {
|
||||||
return reply.send(await getEnvVars());
|
return reply.send(await getEnvVars());
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get('/env/:id', async (req, reply) => {
|
fastify.get('/env/:id', {
|
||||||
|
preHandler: async (req) => requireAdmin(req),
|
||||||
|
}, async (req, reply) => {
|
||||||
const params = EnvVarParamsSchema.parse(req.params);
|
const params = EnvVarParamsSchema.parse(req.params);
|
||||||
const envVar = await getEnvVar(params.id);
|
const envVar = await getEnvVar(params.id);
|
||||||
if (!envVar) return reply.code(404).send({ error: 'Environment variable not found' });
|
if (!envVar) return reply.code(404).send({ error: 'Environment variable not found' });
|
||||||
return reply.send(envVar);
|
return reply.send(envVar);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post('/env', async (req, reply) => {
|
fastify.post('/env', {
|
||||||
|
preHandler: async (req) => requireAdmin(req),
|
||||||
|
}, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const input = EnvVarInputSchema.parse(req.body) as { name: string };
|
const input = EnvVarInputSchema.parse(req.body) as { name: string };
|
||||||
return reply.code(201).send(await upsertEnvVar(input));
|
return reply.code(201).send(await upsertEnvVar(input));
|
||||||
@ -25,7 +31,9 @@ export async function envRoutes(fastify: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.put('/env/:id', async (req, reply) => {
|
fastify.put('/env/:id', {
|
||||||
|
preHandler: async (req) => requireAdmin(req),
|
||||||
|
}, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const params = EnvVarParamsSchema.parse(req.params);
|
const params = EnvVarParamsSchema.parse(req.params);
|
||||||
const input = EnvVarInputSchema.parse({ ...(req.body as object), id: params.id }) as { name: string; id: string };
|
const input = EnvVarInputSchema.parse({ ...(req.body as object), id: params.id }) as { name: string; id: string };
|
||||||
@ -36,13 +44,18 @@ export async function envRoutes(fastify: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.delete('/env/:id', async (req, reply) => {
|
fastify.delete('/env/:id', {
|
||||||
|
preHandler: async (req) => requireAdmin(req),
|
||||||
|
}, async (req, reply) => {
|
||||||
const params = EnvVarParamsSchema.parse(req.params);
|
const params = EnvVarParamsSchema.parse(req.params);
|
||||||
await deleteEnvVar(params.id);
|
const deleted = await deleteEnvVar(params.id);
|
||||||
|
if (!deleted) return reply.code(404).send({ error: 'Environment variable not found' });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post('/env/sync-azure', async (req, reply) => {
|
fastify.post('/env/sync-azure', {
|
||||||
|
preHandler: async (req) => requireAdmin(req),
|
||||||
|
}, async (req, reply) => {
|
||||||
return reply.send({ synced: 0, errors: ['Azure Key Vault sync is not configured in this local dashboard build.'] });
|
return reply.send({ synced: 0, errors: ['Azure Key Vault sync is not configured in this local dashboard build.'] });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,16 +53,8 @@ export async function healthRoutes(fastify: FastifyInstance) {
|
|||||||
// Clear health cache (admin only)
|
// Clear health cache (admin only)
|
||||||
fastify.delete('/health/cache', {
|
fastify.delete('/health/cache', {
|
||||||
preHandler: async (req) => requireAdmin(req),
|
preHandler: async (req) => requireAdmin(req),
|
||||||
}, async (req, reply) => {
|
}, async (_req, reply) => {
|
||||||
try {
|
clearHealthCache();
|
||||||
requireAdmin(req);
|
return reply.send({ message: 'Health cache cleared' });
|
||||||
clearHealthCache();
|
|
||||||
return reply.send({ message: 'Health cache cleared' });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
throw new BadRequestError(error.message);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { SidebarNav } from '@/components/sidebar-nav';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { Service, ServiceHealth } from '@/lib/api';
|
import type { Service, ServiceHealth } from '@/lib/api';
|
||||||
import { Activity, Clock, RefreshCw, TrendingUp } from 'lucide-react';
|
import { Activity, Clock, RefreshCw, TrendingUp } from 'lucide-react';
|
||||||
|
import { getStatusColor } from '@/lib/utils';
|
||||||
|
|
||||||
export default function HealthDashboardPage() {
|
export default function HealthDashboardPage() {
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
@ -52,21 +53,6 @@ export default function HealthDashboardPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadData]);
|
}, [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) {
|
function getResponseTimeColor(responseTime?: number) {
|
||||||
if (!responseTime) return 'text-gray-500';
|
if (!responseTime) return 'text-gray-500';
|
||||||
|
|||||||
@ -151,38 +151,58 @@ export default function MetricsPage() {
|
|||||||
{/* Deployment Trend Chart */}
|
{/* Deployment Trend Chart */}
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-8">
|
<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>
|
<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">
|
{deployments.length === 0 ? (
|
||||||
{deploymentTrend.map((day) => (
|
<div className="h-48 flex items-center justify-center text-sm text-gray-400">
|
||||||
<div key={day.date} className="flex-1 flex flex-col items-center">
|
No deployment data yet
|
||||||
<div className="w-full flex flex-col gap-1">
|
</div>
|
||||||
<div
|
) : (
|
||||||
className="w-full bg-green-500 rounded-t"
|
<>
|
||||||
style={{ height: `${(day.success / maxCount) * 100}%`, minHeight: day.success > 0 ? '4px' : '0' }}
|
{/* Bar chart — each column is a flex column-reverse so bars grow from bottom */}
|
||||||
title={`Success: ${day.success}`}
|
<div className="flex items-end justify-between gap-2 h-48">
|
||||||
/>
|
{deploymentTrend.map((day) => (
|
||||||
<div
|
<div key={day.date} className="flex-1 flex flex-col items-center gap-1">
|
||||||
className="w-full bg-red-500 rounded-b"
|
{/* Count label above bars */}
|
||||||
style={{ height: `${(day.failed / maxCount) * 100}%`, minHeight: day.failed > 0 ? '4px' : '0' }}
|
<span className="text-xs font-medium text-gray-500 mb-1">
|
||||||
title={`Failed: ${day.failed}`}
|
{day.count > 0 ? day.count : ''}
|
||||||
/>
|
</span>
|
||||||
</div>
|
{/* Stacked bar (grows upward via flex-col-reverse) */}
|
||||||
<div className="mt-2 text-xs text-gray-500 text-center">
|
<div
|
||||||
{new Date(day.date).toLocaleDateString('en', { weekday: 'short' })}
|
className="w-full flex flex-col-reverse gap-px overflow-hidden rounded-sm"
|
||||||
</div>
|
style={{ height: `${Math.max((day.count / maxCount) * 160, day.count > 0 ? 4 : 0)}px` }}
|
||||||
<div className="text-xs font-medium text-gray-700">{day.count}</div>
|
>
|
||||||
|
{day.failed > 0 && (
|
||||||
|
<div
|
||||||
|
className="w-full bg-red-500 flex-shrink-0"
|
||||||
|
style={{ height: `${(day.failed / day.count) * 100}%` }}
|
||||||
|
title={`Failed: ${day.failed}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{day.success > 0 && (
|
||||||
|
<div
|
||||||
|
className="w-full bg-green-500 flex-shrink-0"
|
||||||
|
style={{ height: `${(day.success / day.count) * 100}%` }}
|
||||||
|
title={`Success: ${day.success}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">
|
||||||
|
{new Date(day.date + 'T12:00:00').toLocaleDateString('en', { weekday: 'short' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex items-center justify-center gap-6 mt-3">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center justify-center gap-6 mt-4">
|
<div className="w-3 h-3 bg-green-500 rounded-sm" />
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm text-gray-600">Success</span>
|
||||||
<div className="w-3 h-3 bg-green-500 rounded" />
|
</div>
|
||||||
<span className="text-sm text-gray-600">Success</span>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<div className="w-3 h-3 bg-red-500 rounded-sm" />
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm text-gray-600">Failed</span>
|
||||||
<div className="w-3 h-3 bg-red-500 rounded" />
|
</div>
|
||||||
<span className="text-sm text-gray-600">Failed</span>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Deployments by Service */}
|
{/* Deployments by Service */}
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import { SidebarNav } from '@/components/sidebar-nav';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { Service, Deployment } from '@/lib/api';
|
import type { Service, Deployment } from '@/lib/api';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import { Play, Activity, Clock, RefreshCw, Plus, Edit, Trash2, FileText } from 'lucide-react';
|
import { Play, Activity, Clock, RefreshCw, Plus, Edit, Trash2, FileText, AlertCircle } from 'lucide-react';
|
||||||
|
import { getStatusColor } from '@/lib/utils';
|
||||||
import { ServiceForm } from '@/components/service-form';
|
import { ServiceForm } from '@/components/service-form';
|
||||||
import { LogViewer } from '@/components/log-viewer';
|
import { LogViewer } from '@/components/log-viewer';
|
||||||
|
|
||||||
@ -126,22 +127,6 @@ export default function DashboardPage() {
|
|||||||
setViewingLogsDeployment(null);
|
setViewingLogsDeployment(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
@ -188,12 +173,6 @@ export default function DashboardPage() {
|
|||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Create Service
|
Create Service
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => api.seedServices().then(loadData)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
Seed Services
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={refreshHealth}
|
onClick={refreshHealth}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
@ -269,10 +248,15 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{service.status !== 'up' && (
|
||||||
|
<div className="flex items-center gap-1.5 mb-2 text-xs text-yellow-700 bg-yellow-50 border border-yellow-200 rounded px-2 py-1">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
Service is {service.status} — deploy will attempt a fix
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeploy(service.id)}
|
onClick={() => handleDeploy(service.id)}
|
||||||
disabled={service.status !== 'up'}
|
className="w-full flex items-center justify-center gap-2 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 focus:ring-offset-2"
|
||||||
className="w-full flex items-center justify-center gap-2 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"
|
|
||||||
>
|
>
|
||||||
<Play className="w-4 h-4" />
|
<Play className="w-4 h-4" />
|
||||||
Deploy
|
Deploy
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { apiRequest } from '@/lib/api';
|
||||||
|
import { formatBytes } from '@/lib/utils';
|
||||||
import { Cpu, HardDrive, Database, Trash2, RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react';
|
import { Cpu, HardDrive, Database, Trash2, RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||||
import { SidebarNav } from '@/components/sidebar-nav';
|
import { SidebarNav } from '@/components/sidebar-nav';
|
||||||
|
|
||||||
@ -63,16 +64,8 @@ export default function SystemPage() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [metricsData, dockerData] = await Promise.all([
|
const [metricsData, dockerData] = await Promise.all([
|
||||||
fetch(`${process.env.NEXT_PUBLIC_DEVOPS_API_URL || 'http://localhost:4004'}/api/system/metrics`, {
|
apiRequest<SystemMetrics>('/api/system/metrics'),
|
||||||
headers: {
|
apiRequest<DockerStats>('/api/docker/stats'),
|
||||||
'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);
|
setMetrics(metricsData);
|
||||||
setDockerStats(dockerData);
|
setDockerStats(dockerData);
|
||||||
@ -101,15 +94,10 @@ export default function SystemPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_DEVOPS_API_URL || 'http://localhost:4004'}/api/docker/cleanup`, {
|
const result = await apiRequest<{ message: string; freedSpace: number }>('/api/docker/cleanup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ type, force }),
|
body: JSON.stringify({ type, force }),
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
|
||||||
setCleanupResult(result);
|
setCleanupResult(result);
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -118,14 +106,6 @@ export default function SystemPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 => {
|
const getUsageColor = (percentage: number): string => {
|
||||||
if (percentage < 50) return 'text-green-600 bg-green-50';
|
if (percentage < 50) return 'text-green-600 bg-green-50';
|
||||||
if (percentage < 75) return 'text-yellow-600 bg-yellow-50';
|
if (percentage < 75) return 'text-yellow-600 bg-yellow-50';
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { SidebarNav } from '@/components/sidebar-nav';
|
import { SidebarNav } from '@/components/sidebar-nav';
|
||||||
|
import { vmApi, type VmHealthResult, type VmCheckLevel } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@ -20,25 +21,9 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const API = process.env.NEXT_PUBLIC_DEVOPS_API_URL || 'http://localhost:4004';
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Level = 'OK' | 'WARN' | 'CRIT';
|
type Level = VmCheckLevel;
|
||||||
|
|
||||||
interface VmCheck {
|
|
||||||
level: Level;
|
|
||||||
value: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VmHealthResult {
|
|
||||||
timestamp: string;
|
|
||||||
hostname: string;
|
|
||||||
overall: Level;
|
|
||||||
checks: Record<string, VmCheck>;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -100,21 +85,14 @@ export default function VmHealthPage() {
|
|||||||
const [showLog, setShowLog] = useState(false);
|
const [showLog, setShowLog] = useState(false);
|
||||||
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
|
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
|
||||||
|
|
||||||
const authHeader = () => ({
|
|
||||||
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadHealth = useCallback(async () => {
|
const loadHealth = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [healthRes, logRes] = await Promise.all([
|
const [healthData, logData] = await Promise.all([
|
||||||
fetch(`${API}/api/vm/health`, { headers: authHeader() }),
|
vmApi.getHealth(),
|
||||||
fetch(`${API}/api/vm/cleanup-log?lines=40`, { headers: authHeader() }),
|
vmApi.getCleanupLog(40),
|
||||||
]);
|
]);
|
||||||
if (healthRes.ok) setHealth(await healthRes.json());
|
setHealth(healthData);
|
||||||
if (logRes.ok) {
|
setCleanupLog(logData.log);
|
||||||
const { log } = await logRes.json();
|
|
||||||
setCleanupLog(log);
|
|
||||||
}
|
|
||||||
setLastRefreshed(new Date());
|
setLastRefreshed(new Date());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load VM health:', e);
|
console.error('Failed to load VM health:', e);
|
||||||
@ -147,14 +125,8 @@ export default function VmHealthPage() {
|
|||||||
setCleanupRunning(true);
|
setCleanupRunning(true);
|
||||||
setCleanupResult(null);
|
setCleanupResult(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/vm/cleanup`, {
|
const result = await vmApi.runCleanup(mode);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
|
||||||
body: JSON.stringify({ mode }),
|
|
||||||
});
|
|
||||||
const result = await res.json();
|
|
||||||
setCleanupResult(result);
|
setCleanupResult(result);
|
||||||
// Refresh health after cleanup
|
|
||||||
await loadHealth();
|
await loadHealth();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCleanupResult({ success: false, output: String(e) });
|
setCleanupResult({ success: false, output: String(e) });
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@ -39,7 +39,14 @@ export function SidebarNav() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [theme, setTheme] = useState('light');
|
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||||||
|
|
||||||
|
// Sync theme from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = (localStorage.getItem('theme') as 'light' | 'dark') || 'light';
|
||||||
|
setTheme(saved);
|
||||||
|
document.documentElement.classList.toggle('dark', saved === 'dark');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
@ -98,7 +105,12 @@ export function SidebarNav() {
|
|||||||
{/* Footer — theme toggle + user info + logout */}
|
{/* Footer — theme toggle + user info + logout */}
|
||||||
<div className="border-t p-4 space-y-3">
|
<div className="border-t p-4 space-y-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
onClick={() => {
|
||||||
|
const next = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
setTheme(next);
|
||||||
|
localStorage.setItem('theme', next);
|
||||||
|
document.documentElement.classList.toggle('dark', next === 'dark');
|
||||||
|
}}
|
||||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
|||||||
@ -398,6 +398,34 @@ export const codeQualityApi = {
|
|||||||
|
|
||||||
export const runCodeQualityCheck = (params: CodeQualityCheckParams) => codeQualityApi.runCheck(params);
|
export const runCodeQualityCheck = (params: CodeQualityCheckParams) => codeQualityApi.runCheck(params);
|
||||||
|
|
||||||
|
// VM Health
|
||||||
|
export type VmCheckLevel = 'OK' | 'WARN' | 'CRIT';
|
||||||
|
|
||||||
|
export interface VmCheck {
|
||||||
|
level: VmCheckLevel;
|
||||||
|
value: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VmHealthResult {
|
||||||
|
timestamp: string;
|
||||||
|
hostname: string;
|
||||||
|
overall: VmCheckLevel;
|
||||||
|
checks: Record<string, VmCheck>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vmApi = {
|
||||||
|
getHealth: () => apiRequest<VmHealthResult>('/api/vm/health'),
|
||||||
|
getCleanupLog: (lines = 40) =>
|
||||||
|
apiRequest<{ log: string }>(`/api/vm/cleanup-log?lines=${lines}`),
|
||||||
|
runCleanup: (mode: 'weekly' | 'monthly' | 'dry-run') =>
|
||||||
|
apiRequest<{ success: boolean; output: string }>('/api/vm/cleanup', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ mode }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
// Auth API - calls platform-service for authentication
|
// Auth API - calls platform-service for authentication
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email: string;
|
email: string;
|
||||||
|
|||||||
@ -4,3 +4,29 @@ import { twMerge } from 'tailwind-merge';
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format bytes into a human-readable string (B / KB / MB / GB / TB). */
|
||||||
|
export function 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]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tailwind classes for a service/deployment status badge. */
|
||||||
|
export function getStatusColor(status: string): 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user