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:
Hermes VM 2026-05-27 12:50:47 +00:00
parent d0b8ce2c74
commit 1099d518ef
11 changed files with 183 additions and 162 deletions

View File

@ -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);

View File

@ -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.'] });
});
}

View File

@ -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;
}
});
}

View File

@ -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';

View File

@ -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 */}

View File

@ -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

View File

@ -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';

View File

@ -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) });

View File

@ -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" />}

View File

@ -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;

View File

@ -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';
}
}