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';
|
||||
|
||||
export async function deploymentRoutes(fastify: FastifyInstance) {
|
||||
// Get recent deployments across all services
|
||||
fastify.get('/deployments', async (req, reply) => {
|
||||
// Get recent deployments across all services (admin only)
|
||||
fastify.get('/deployments', {
|
||||
preHandler: async (req) => requireAdmin(req),
|
||||
}, async (req, reply) => {
|
||||
const query = QueryParamsSchema.parse(req.query);
|
||||
const deployments = await getRecentDeployments(query.limit);
|
||||
return reply.send(deployments);
|
||||
});
|
||||
|
||||
// Get deployments for a specific service
|
||||
fastify.get('/deployments/service/:serviceId', async (req, reply) => {
|
||||
// Get deployments for a specific service (admin only)
|
||||
fastify.get('/deployments/service/:serviceId', {
|
||||
preHandler: async (req) => requireAdmin(req),
|
||||
}, async (req, reply) => {
|
||||
const params = TriggerDeploymentParamsSchema.parse(req.params);
|
||||
const query = QueryParamsSchema.parse(req.query);
|
||||
const deployments = await getDeploymentsByService(params.serviceId, query.limit);
|
||||
return reply.send(deployments);
|
||||
});
|
||||
|
||||
// Get single deployment
|
||||
fastify.get('/deployments/:id', async (req, reply) => {
|
||||
// Get single deployment (admin only)
|
||||
fastify.get('/deployments/:id', {
|
||||
preHandler: async (req) => requireAdmin(req),
|
||||
}, async (req, reply) => {
|
||||
const params = DeploymentParamsSchema.parse(req.params);
|
||||
const deployment = await getDeploymentById(params.id);
|
||||
if (!deployment) {
|
||||
@ -38,9 +44,11 @@ export async function deploymentRoutes(fastify: FastifyInstance) {
|
||||
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
|
||||
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 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 { BadRequestError } from '../../lib/auth.js';
|
||||
import { BadRequestError, requireAdmin } from '../../lib/auth.js';
|
||||
import { deleteEnvVar, getEnvVar, getEnvVars, upsertEnvVar } from './repository.js';
|
||||
import { EnvVarInputSchema, EnvVarParamsSchema } from './types.js';
|
||||
|
||||
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());
|
||||
});
|
||||
|
||||
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 envVar = await getEnvVar(params.id);
|
||||
if (!envVar) return reply.code(404).send({ error: 'Environment variable not found' });
|
||||
return reply.send(envVar);
|
||||
});
|
||||
|
||||
fastify.post('/env', async (req, reply) => {
|
||||
fastify.post('/env', {
|
||||
preHandler: async (req) => requireAdmin(req),
|
||||
}, async (req, reply) => {
|
||||
try {
|
||||
const input = EnvVarInputSchema.parse(req.body) as { name: string };
|
||||
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 {
|
||||
const params = EnvVarParamsSchema.parse(req.params);
|
||||
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);
|
||||
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();
|
||||
});
|
||||
|
||||
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.'] });
|
||||
});
|
||||
}
|
||||
|
||||
@ -53,16 +53,8 @@ export async function healthRoutes(fastify: FastifyInstance) {
|
||||
// Clear health cache (admin only)
|
||||
fastify.delete('/health/cache', {
|
||||
preHandler: async (req) => requireAdmin(req),
|
||||
}, async (req, reply) => {
|
||||
try {
|
||||
requireAdmin(req);
|
||||
}, async (_req, reply) => {
|
||||
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 type { Service, ServiceHealth } from '@/lib/api';
|
||||
import { Activity, Clock, RefreshCw, TrendingUp } from 'lucide-react';
|
||||
import { getStatusColor } from '@/lib/utils';
|
||||
|
||||
export default function HealthDashboardPage() {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
@ -52,21 +53,6 @@ export default function HealthDashboardPage() {
|
||||
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';
|
||||
|
||||
@ -151,38 +151,58 @@ export default function MetricsPage() {
|
||||
{/* 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">
|
||||
{deployments.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-sm text-gray-400">
|
||||
No deployment data yet
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Bar chart — each column is a flex column-reverse so bars grow from bottom */}
|
||||
<div className="flex items-end justify-between gap-2 h-48">
|
||||
{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 key={day.date} className="flex-1 flex flex-col items-center gap-1">
|
||||
{/* Count label above bars */}
|
||||
<span className="text-xs font-medium text-gray-500 mb-1">
|
||||
{day.count > 0 ? day.count : ''}
|
||||
</span>
|
||||
{/* Stacked bar (grows upward via flex-col-reverse) */}
|
||||
<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}`}
|
||||
/>
|
||||
className="w-full flex flex-col-reverse gap-px overflow-hidden rounded-sm"
|
||||
style={{ height: `${Math.max((day.count / maxCount) * 160, day.count > 0 ? 4 : 0)}px` }}
|
||||
>
|
||||
{day.failed > 0 && (
|
||||
<div
|
||||
className="w-full bg-red-500 rounded-b"
|
||||
style={{ height: `${(day.failed / maxCount) * 100}%`, minHeight: day.failed > 0 ? '4px' : '0' }}
|
||||
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-2 text-xs text-gray-500 text-center">
|
||||
{new Date(day.date).toLocaleDateString('en', { weekday: 'short' })}
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{new Date(day.date + 'T12:00:00').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 justify-center gap-6 mt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-green-500 rounded" />
|
||||
<div className="w-3 h-3 bg-green-500 rounded-sm" />
|
||||
<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" />
|
||||
<div className="w-3 h-3 bg-red-500 rounded-sm" />
|
||||
<span className="text-sm text-gray-600">Failed</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deployments by Service */}
|
||||
|
||||
@ -5,7 +5,8 @@ import { SidebarNav } from '@/components/sidebar-nav';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Service, Deployment } from '@/lib/api';
|
||||
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 { LogViewer } from '@/components/log-viewer';
|
||||
|
||||
@ -126,22 +127,6 @@ export default function DashboardPage() {
|
||||
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) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@ -188,12 +173,6 @@ export default function DashboardPage() {
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Service
|
||||
</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
|
||||
onClick={refreshHealth}
|
||||
disabled={refreshing}
|
||||
@ -269,10 +248,15 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</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
|
||||
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 disabled:bg-gray-300 disabled:cursor-not-allowed 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 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Deploy
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
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 { SidebarNav } from '@/components/sidebar-nav';
|
||||
|
||||
@ -63,16 +64,8 @@ export default function SystemPage() {
|
||||
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()),
|
||||
apiRequest<SystemMetrics>('/api/system/metrics'),
|
||||
apiRequest<DockerStats>('/api/docker/stats'),
|
||||
]);
|
||||
setMetrics(metricsData);
|
||||
setDockerStats(dockerData);
|
||||
@ -101,15 +94,10 @@ export default function SystemPage() {
|
||||
}
|
||||
|
||||
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',
|
||||
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) {
|
||||
@ -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 => {
|
||||
if (percentage < 50) return 'text-green-600 bg-green-50';
|
||||
if (percentage < 75) return 'text-yellow-600 bg-yellow-50';
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { SidebarNav } from '@/components/sidebar-nav';
|
||||
import { vmApi, type VmHealthResult, type VmCheckLevel } from '@/lib/api';
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
@ -20,25 +21,9 @@ import {
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_DEVOPS_API_URL || 'http://localhost:4004';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Level = 'OK' | 'WARN' | 'CRIT';
|
||||
|
||||
interface VmCheck {
|
||||
level: Level;
|
||||
value: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface VmHealthResult {
|
||||
timestamp: string;
|
||||
hostname: string;
|
||||
overall: Level;
|
||||
checks: Record<string, VmCheck>;
|
||||
error?: string;
|
||||
}
|
||||
type Level = VmCheckLevel;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -100,21 +85,14 @@ export default function VmHealthPage() {
|
||||
const [showLog, setShowLog] = useState(false);
|
||||
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
|
||||
|
||||
const authHeader = () => ({
|
||||
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
|
||||
});
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
try {
|
||||
const [healthRes, logRes] = await Promise.all([
|
||||
fetch(`${API}/api/vm/health`, { headers: authHeader() }),
|
||||
fetch(`${API}/api/vm/cleanup-log?lines=40`, { headers: authHeader() }),
|
||||
const [healthData, logData] = await Promise.all([
|
||||
vmApi.getHealth(),
|
||||
vmApi.getCleanupLog(40),
|
||||
]);
|
||||
if (healthRes.ok) setHealth(await healthRes.json());
|
||||
if (logRes.ok) {
|
||||
const { log } = await logRes.json();
|
||||
setCleanupLog(log);
|
||||
}
|
||||
setHealth(healthData);
|
||||
setCleanupLog(logData.log);
|
||||
setLastRefreshed(new Date());
|
||||
} catch (e) {
|
||||
console.error('Failed to load VM health:', e);
|
||||
@ -147,14 +125,8 @@ export default function VmHealthPage() {
|
||||
setCleanupRunning(true);
|
||||
setCleanupResult(null);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/vm/cleanup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
const result = await res.json();
|
||||
const result = await vmApi.runCleanup(mode);
|
||||
setCleanupResult(result);
|
||||
// Refresh health after cleanup
|
||||
await loadHealth();
|
||||
} catch (e) {
|
||||
setCleanupResult({ success: false, output: String(e) });
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
@ -39,7 +39,14 @@ export function SidebarNav() {
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuth();
|
||||
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 = () => {
|
||||
logout();
|
||||
@ -98,7 +105,12 @@ export function SidebarNav() {
|
||||
{/* Footer — theme toggle + user info + logout */}
|
||||
<div className="border-t p-4 space-y-3">
|
||||
<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"
|
||||
>
|
||||
{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);
|
||||
|
||||
// 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
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
|
||||
@ -4,3 +4,29 @@ import { twMerge } from 'tailwind-merge';
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
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