Add live Hermes operations dashboard

This commit is contained in:
root 2026-05-27 13:04:25 +00:00
parent babe2e6c13
commit 0e6528b366
19 changed files with 1119 additions and 21 deletions

2
.gitignore vendored
View File

@ -10,6 +10,8 @@ __pycache__/
venv/ venv/
env/ env/
ENV/ ENV/
!dashboard/backend/src/modules/env/
!dashboard/backend/src/modules/env/**
# IDE files # IDE files
.vscode/ .vscode/

View File

@ -0,0 +1,31 @@
import type { EnvVar } from './types.js';
const envVars = new Map<string, EnvVar>();
export async function getEnvVars(): Promise<EnvVar[]> {
return Array.from(envVars.values()).sort((a, b) => a.name.localeCompare(b.name));
}
export async function getEnvVar(id: string): Promise<EnvVar | null> {
return envVars.get(id) ?? null;
}
export async function upsertEnvVar(input: Partial<EnvVar> & { name: string }): Promise<EnvVar> {
const id = input.id || input.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_');
const envVar: EnvVar = {
id,
name: input.name,
value: input.isSecret ? 'REDACTED' : input.value ?? '',
isSecret: input.isSecret ?? true,
source: input.source ?? 'local',
azureKeyVaultName: input.azureKeyVaultName,
azureSecretName: input.azureSecretName,
updatedAt: new Date().toISOString(),
};
envVars.set(id, envVar);
return envVar;
}
export async function deleteEnvVar(id: string): Promise<boolean> {
return envVars.delete(id);
}

View File

@ -0,0 +1,48 @@
import type { FastifyInstance } from 'fastify';
import { BadRequestError } 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) => {
return reply.send(await getEnvVars());
});
fastify.get('/env/:id', 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) => {
try {
const input = EnvVarInputSchema.parse(req.body) as { name: string };
return reply.code(201).send(await upsertEnvVar(input));
} catch (error) {
if (error instanceof Error) throw new BadRequestError(error.message);
throw error;
}
});
fastify.put('/env/:id', 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 };
return reply.send(await upsertEnvVar(input));
} catch (error) {
if (error instanceof Error) throw new BadRequestError(error.message);
throw error;
}
});
fastify.delete('/env/:id', async (req, reply) => {
const params = EnvVarParamsSchema.parse(req.params);
await deleteEnvVar(params.id);
return reply.code(204).send();
});
fastify.post('/env/sync-azure', async (req, reply) => {
return reply.send({ synced: 0, errors: ['Azure Key Vault sync is not configured in this local dashboard build.'] });
});
}

View File

@ -0,0 +1,22 @@
import { z } from 'zod';
export const EnvVarSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
value: z.string().default(''),
isSecret: z.boolean().default(true),
source: z.enum(['local', 'azure-key-vault']).default('local'),
azureKeyVaultName: z.string().optional(),
azureSecretName: z.string().optional(),
updatedAt: z.string().datetime().default(() => new Date().toISOString()),
});
export const EnvVarParamsSchema = z.object({
id: z.string().min(1),
});
export const EnvVarInputSchema = EnvVarSchema.omit({ name: true }).partial().extend({
name: z.string().min(1),
});
export type EnvVar = z.infer<typeof EnvVarSchema>;

View File

@ -0,0 +1,215 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import { readFile, stat } from 'fs/promises';
import { existsSync } from 'fs';
import type { HermesOpsInstance, HermesOpsRepo, HermesOpsSnapshot, HermesOpsTimer } from './types.js';
const execFileAsync = promisify(execFile);
const instances = [
{
id: 'vijay' as const,
label: 'Vijay / root',
hermesHome: '/root/.hermes',
gatewayKind: 'system',
gatewayService: 'hermes-gateway.service',
dashboardService: 'hermes-root-dashboard.service',
dashboardPort: 9119,
backupTimer: 'hermes-root-backup.timer',
repoPath: '/root/repos/bytelyst_hostinger_hermes_vm',
driveFolder: 'Vijay Drive',
},
{
id: 'bheem' as const,
label: 'Bheem / Uma',
hermesHome: '/home/uma/.hermes',
gatewayKind: 'uma-user',
gatewayService: 'uma-hermes-gateway.service',
dashboardService: 'uma-hermes-dashboard.service',
dashboardPort: 9120,
backupTimer: 'uma-hermes-backup.timer',
repoPath: '/home/uma/repos/uma_hostinger_hermes_vm',
driveFolder: 'Bheem Drive',
},
];
async function run(command: string, args: string[], cwd?: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync(command, args, {
cwd,
timeout: 5000,
maxBuffer: 1024 * 1024,
});
return stdout.trim();
} catch {
return null;
}
}
async function isActive(unit: string): Promise<boolean> {
return (await run('systemctl', ['is-active', unit])) === 'active';
}
async function isEnabled(unit: string): Promise<boolean> {
return (await run('systemctl', ['is-enabled', unit])) === 'enabled';
}
async function getTimer(name: string): Promise<HermesOpsTimer> {
const active = await isActive(name);
const show = await run('systemctl', [
'show',
name,
'-p',
'NextElapseUSecRealtime',
'-p',
'LastTriggerUSec',
'--no-pager',
]);
const properties = Object.fromEntries(
(show ?? '')
.split('\n')
.map((line) => {
const [key, ...value] = line.split('=');
return [key, value.join('=') || null] as const;
})
.filter(([key]) => key),
);
return {
name,
active,
nextRun: properties.NextElapseUSecRealtime ?? null,
lastRun: properties.LastTriggerUSec ?? null,
};
}
async function isUmaGatewayActive(): Promise<boolean> {
const output = await run('ps', ['-eo', 'user=,args=']);
return Boolean(
output?.split('\n').some((line) => {
const trimmed = line.trimStart();
return trimmed.startsWith('uma ') && trimmed.includes('hermes_cli.main gateway');
}),
);
}
async function isUmaGatewayEnabled(): Promise<boolean> {
return existsSync('/home/uma/.config/systemd/user/default.target.wants/uma-hermes-gateway.service');
}
async function getRepo(path: string): Promise<HermesOpsRepo> {
const [branch, status, head, lastCommitAt, size] = await Promise.all([
run('git', ['branch', '--show-current'], path),
run('git', ['status', '--porcelain'], path),
run('git', ['rev-parse', '--short', 'HEAD'], path),
run('git', ['log', '-1', '--format=%cI'], path),
run('du', ['-sh', '.git', 'hermes_persistent_backup'], path),
]);
return {
path,
branch: branch || null,
clean: status === '',
head: head || null,
lastCommitAt: lastCommitAt || null,
size: size ? size.replace(/\n/g, ' / ') : null,
};
}
async function manifestStats(hermesHome: string): Promise<{ files: number | null; cronJobs: number | null }> {
try {
const manifestPath = `${hermesHome}/MANIFEST.json`;
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { files?: unknown[] };
const jobsPath = `${hermesHome}/cron/jobs.json`;
const jobs = JSON.parse(await readFile(jobsPath, 'utf8'));
const cronJobs = Array.isArray(jobs) ? jobs.length : Array.isArray(jobs?.jobs) ? jobs.jobs.length : null;
return {
files: Array.isArray(manifest.files) ? manifest.files.length : null,
cronJobs,
};
} catch {
return { files: null, cronJobs: null };
}
}
async function tokenExists(path: string): Promise<boolean> {
try {
const info = await stat(path);
return info.isFile() && info.size > 100;
} catch {
return false;
}
}
async function getTailscaleIp(): Promise<string | null> {
const output = await run('tailscale', ['ip', '-4']);
return output?.split('\n')[0] || null;
}
export async function getHermesOpsSnapshot(): Promise<HermesOpsSnapshot> {
const tailscaleIp = await getTailscaleIp();
const warnings: string[] = [];
const emergencyDriveUpload = await getTimer('hermes-emergency-drive-upload.timer');
const results: HermesOpsInstance[] = [];
for (const item of instances) {
const gatewayActiveCheck =
item.gatewayKind === 'uma-user' ? isUmaGatewayActive() : isActive(item.gatewayService);
const gatewayEnabledCheck =
item.gatewayKind === 'uma-user' ? isUmaGatewayEnabled() : isEnabled(item.gatewayService);
const [gatewayActive, gatewayEnabled, dashboardActive, backupTimer, repo, stats, googleToken] = await Promise.all([
gatewayActiveCheck,
gatewayEnabledCheck,
isActive(item.dashboardService),
getTimer(item.backupTimer),
getRepo(item.repoPath),
manifestStats(`${item.repoPath}/hermes_persistent_backup`),
tokenExists(`${item.hermesHome}/google_token.json`),
]);
const dashboardUrl = tailscaleIp ? `http://${tailscaleIp}:${item.dashboardPort}/` : `:${item.dashboardPort}`;
if (!gatewayActive) warnings.push(`${item.label} gateway is not active`);
if (!backupTimer.active) warnings.push(`${item.label} backup timer is not active`);
if (!repo.clean) warnings.push(`${item.label} backup repo has uncommitted changes`);
if (!googleToken) warnings.push(`${item.label} Google Workspace token is missing`);
results.push({
id: item.id,
label: item.label,
hermesHome: item.hermesHome,
gateway: {
service: item.gatewayService,
active: gatewayActive,
enabled: gatewayEnabled,
},
dashboard: {
service: item.dashboardService,
active: dashboardActive,
url: dashboardUrl,
},
backup: {
timer: backupTimer,
repo,
restoredFileCount: stats.files,
restoredCronJobs: stats.cronJobs,
},
google: {
workspaceToken: googleToken,
driveFolder: item.driveFolder,
},
});
}
if (!emergencyDriveUpload.active) warnings.push('Emergency Google Drive upload timer is not active');
if (!existsSync('/root/.config/hermes-google-drive/user-token.json')) {
warnings.push('Emergency Drive OAuth token is missing');
}
return {
generatedAt: new Date().toISOString(),
tailscaleIp,
emergencyDriveUpload,
instances: results,
warnings,
};
}

View File

@ -0,0 +1,13 @@
import type { FastifyInstance } from 'fastify';
import { getHermesOpsSnapshot } from './repository.js';
export async function hermesOpsRoutes(fastify: FastifyInstance) {
fastify.get('/hermes/ops', async (req, reply) => {
try {
return reply.send(await getHermesOpsSnapshot());
} catch (error) {
fastify.log.error(error, 'Failed to get Hermes operations snapshot');
return reply.code(500).send({ error: 'Failed to get Hermes operations snapshot' });
}
});
}

View File

@ -0,0 +1,51 @@
export interface HermesOpsTimer {
name: string;
active: boolean;
nextRun: string | null;
lastRun: string | null;
}
export interface HermesOpsRepo {
path: string;
branch: string | null;
clean: boolean;
head: string | null;
lastCommitAt: string | null;
size: string | null;
}
export interface HermesOpsGoogle {
workspaceToken: boolean;
driveFolder: string;
}
export interface HermesOpsInstance {
id: 'vijay' | 'bheem';
label: string;
hermesHome: string;
gateway: {
service: string;
active: boolean;
enabled: boolean;
};
dashboard: {
service: string;
active: boolean;
url: string;
};
backup: {
timer: HermesOpsTimer;
repo: HermesOpsRepo;
restoredFileCount: number | null;
restoredCronJobs: number | null;
};
google: HermesOpsGoogle;
}
export interface HermesOpsSnapshot {
generatedAt: string;
tailscaleIp: string | null;
emergencyDriveUpload: HermesOpsTimer;
instances: HermesOpsInstance[];
warnings: string[];
}

View File

@ -13,6 +13,7 @@ import { envRoutes } from './modules/env/routes.js';
import { azureConfigRoutes } from './modules/azure-config/routes.js'; import { azureConfigRoutes } from './modules/azure-config/routes.js';
import { codeQualityRoutes } from './modules/code-quality/routes.js'; import { codeQualityRoutes } from './modules/code-quality/routes.js';
import { cosmosConfigRoutes } from './modules/cosmos-config/routes.js'; import { cosmosConfigRoutes } from './modules/cosmos-config/routes.js';
import { hermesOpsRoutes } from './modules/hermes-ops/routes.js';
// import sse from 'fastify-sse-v2'; // import sse from 'fastify-sse-v2';
import rateLimit from '@fastify/rate-limit'; import rateLimit from '@fastify/rate-limit';
import swagger from '@fastify/swagger'; import swagger from '@fastify/swagger';
@ -269,6 +270,7 @@ await fastify.register(envRoutes, { prefix: '/api' });
await fastify.register(azureConfigRoutes, { prefix: '/api' }); await fastify.register(azureConfigRoutes, { prefix: '/api' });
await fastify.register(codeQualityRoutes, { prefix: '/api' }); await fastify.register(codeQualityRoutes, { prefix: '/api' });
await fastify.register(cosmosConfigRoutes, { prefix: '/api' }); await fastify.register(cosmosConfigRoutes, { prefix: '/api' });
await fastify.register(hermesOpsRoutes, { prefix: '/api' });
// Start server // Start server
async function start() { async function start() {

441
dashboard/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -3,3 +3,8 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
html,
body {
font-family: var(--ml-font-body), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

View File

@ -33,7 +33,7 @@ export default function HermesAgentsPage() {
<p className="text-lg font-semibold text-[var(--bl-text-primary)]">{agent.name}</p> <p className="text-lg font-semibold text-[var(--bl-text-primary)]">{agent.name}</p>
<p className="text-sm text-[var(--bl-text-secondary)]">{agent.type} · {agent.callsToday} calls today</p> <p className="text-sm text-[var(--bl-text-secondary)]">{agent.type} · {agent.callsToday} calls today</p>
</div> </div>
<Badge variant={agent.status === 'healthy' ? 'success' : agent.status === 'degraded' ? 'warning' : 'danger'}>{agent.status}</Badge> <Badge variant={agent.status === 'healthy' ? 'success' : agent.status === 'degraded' ? 'warning' : 'error'}>{agent.status}</Badge>
</div> </div>
<div className="mt-4 grid gap-3 text-sm text-[var(--bl-text-secondary)] md:grid-cols-2"> <div className="mt-4 grid gap-3 text-sm text-[var(--bl-text-secondary)] md:grid-cols-2">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3">Last success: {agent.lastSuccessAt ? new Date(agent.lastSuccessAt).toLocaleString() : '—'}</div> <div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3">Last success: {agent.lastSuccessAt ? new Date(agent.lastSuccessAt).toLocaleString() : '—'}</div>

View File

@ -4,6 +4,7 @@ import Link from 'next/link';
import { ArrowRight, BadgeCheck, Bot, CheckCircle2, Clock3, LayoutDashboard, OctagonAlert, Rocket, ShieldAlert, Sparkles, TriangleAlert } from 'lucide-react'; import { ArrowRight, BadgeCheck, Bot, CheckCircle2, Clock3, LayoutDashboard, OctagonAlert, Rocket, ShieldAlert, Sparkles, TriangleAlert } from 'lucide-react';
import { Badge, Button } from '@/components/ui/Primitives'; import { Badge, Button } from '@/components/ui/Primitives';
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
import { HermesOpsPanel } from '@/components/hermes-ops-panel';
import { import {
getHermesAgents, getHermesAgents,
getHermesOverview, getHermesOverview,
@ -22,14 +23,14 @@ const fmtDate = new Intl.DateTimeFormat('en', {
minute: '2-digit', minute: '2-digit',
}); });
const statusTone: Record<string, 'success' | 'warning' | 'danger' | 'info' | 'default'> = { const statusTone: Record<string, 'success' | 'warning' | 'error' | 'info' | 'neutral'> = {
running: 'info', running: 'info',
idle: 'default', idle: 'neutral',
degraded: 'warning', degraded: 'warning',
error: 'danger', error: 'error',
queued: 'default', queued: 'neutral',
blocked: 'warning', blocked: 'warning',
failed: 'danger', failed: 'error',
completed: 'success', completed: 'success',
}; };
@ -38,7 +39,7 @@ function taskStatusLabel(task: HermesTask) {
} }
function getTaskTone(task: HermesTask) { function getTaskTone(task: HermesTask) {
return statusTone[task.status] ?? 'default'; return statusTone[task.status] ?? 'neutral';
} }
function ProductMiniCard({ product }: { product: HermesProduct }) { function ProductMiniCard({ product }: { product: HermesProduct }) {
@ -117,6 +118,8 @@ export default function HermesMissionControlPage() {
<MetricCard label="Success rate" value={`${overview.successRate}%`} tone="success" icon={<BadgeCheck className="h-5 w-5" />} helpText={`${overview.productsTouchedRecently} products touched in the last 14 days`} /> <MetricCard label="Success rate" value={`${overview.successRate}%`} tone="success" icon={<BadgeCheck className="h-5 w-5" />} helpText={`${overview.productsTouchedRecently} products touched in the last 14 days`} />
</section> </section>
<HermesOpsPanel />
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]"> <div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]">
<SectionCard title="Active Missions" subtitle="What Hermes is currently running or waiting on." actions={<Button asChild variant="ghost" size="sm"><Link href="/hermes/tasks">View all tasks <ArrowRight className="ml-2 h-4 w-4" /></Link></Button>}> <SectionCard title="Active Missions" subtitle="What Hermes is currently running or waiting on." actions={<Button asChild variant="ghost" size="sm"><Link href="/hermes/tasks">View all tasks <ArrowRight className="ml-2 h-4 w-4" /></Link></Button>}>
<div className="space-y-3"> <div className="space-y-3">
@ -162,7 +165,7 @@ export default function HermesMissionControlPage() {
<Link href={`/hermes/tasks/${task.id}`} className="font-medium text-[var(--bl-text-primary)] hover:underline">{task.title}</Link> <Link href={`/hermes/tasks/${task.id}`} className="font-medium text-[var(--bl-text-primary)] hover:underline">{task.title}</Link>
<p className="text-sm text-[var(--bl-text-secondary)]">{task.blockerReason ?? task.error ?? task.nextAction}</p> <p className="text-sm text-[var(--bl-text-secondary)]">{task.blockerReason ?? task.error ?? task.nextAction}</p>
</div> </div>
<Badge variant={task.status === 'failed' ? 'danger' : 'warning'}>{task.status}</Badge> <Badge variant={task.status === 'failed' ? 'error' : 'warning'}>{task.status}</Badge>
</div> </div>
</div> </div>
))} ))}
@ -256,7 +259,7 @@ export default function HermesMissionControlPage() {
<p className="text-sm text-[var(--bl-text-secondary)]">{agent.type} · {agent.callsToday} calls today</p> <p className="text-sm text-[var(--bl-text-secondary)]">{agent.type} · {agent.callsToday} calls today</p>
{agent.configIssue ? <p className="mt-1 text-sm text-[var(--bl-warning)]">{agent.configIssue}</p> : null} {agent.configIssue ? <p className="mt-1 text-sm text-[var(--bl-warning)]">{agent.configIssue}</p> : null}
</div> </div>
<Badge variant={agent.status === 'healthy' ? 'success' : agent.status === 'degraded' ? 'warning' : 'danger'}>{agent.status}</Badge> <Badge variant={agent.status === 'healthy' ? 'success' : agent.status === 'degraded' ? 'warning' : 'error'}>{agent.status}</Badge>
</div> </div>
</div> </div>
))} ))}

View File

@ -20,7 +20,7 @@ function getHealthTone(score: number) {
if (score >= 85) return 'success'; if (score >= 85) return 'success';
if (score >= 70) return 'info'; if (score >= 70) return 'info';
if (score >= 55) return 'warning'; if (score >= 55) return 'warning';
return 'danger'; return 'error';
} }
function ProductCard({ product }: { product: HermesProduct }) { function ProductCard({ product }: { product: HermesProduct }) {

View File

@ -12,7 +12,7 @@ function levelTone(level: 'debug' | 'info' | 'warn' | 'error' | 'success') {
switch (level) { switch (level) {
case 'success': return 'success'; case 'success': return 'success';
case 'warn': return 'warning'; case 'warn': return 'warning';
case 'error': return 'danger'; case 'error': return 'error';
case 'debug': return 'neutral'; case 'debug': return 'neutral';
default: return 'info'; default: return 'info';
} }
@ -58,7 +58,7 @@ export default function HermesTaskDetailPage({ params }: { params: { id: string
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 space-y-3"> <div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'danger' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge> <Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'error' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge>
<Badge variant="neutral">{task.source}</Badge> <Badge variant="neutral">{task.source}</Badge>
</div> </div>
<div className="space-y-2 text-sm text-[var(--bl-text-secondary)]"> <div className="space-y-2 text-sm text-[var(--bl-text-secondary)]">

View File

@ -146,8 +146,8 @@ export default function HermesTaskLedgerPage() {
</div> </div>
</td> </td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{product?.name ?? 'Unknown'}</td> <td className="px-4 py-4 text-[var(--bl-text-secondary)]">{product?.name ?? 'Unknown'}</td>
<td className="px-4 py-4"><Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'danger' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge></td> <td className="px-4 py-4"><Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'error' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge></td>
<td className="px-4 py-4"><Badge variant={task.priority === 'P0' ? 'danger' : task.priority === 'P1' ? 'warning' : 'neutral'}>{task.priority}</Badge></td> <td className="px-4 py-4"><Badge variant={task.priority === 'P0' ? 'error' : task.priority === 'P1' ? 'warning' : 'neutral'}>{task.priority}</Badge></td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.type}</td> <td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.type}</td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.source}</td> <td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.source}</td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{prettyDate(task.createdAt)}</td> <td className="px-4 py-4 text-[var(--bl-text-secondary)]">{prettyDate(task.createdAt)}</td>

View File

@ -1,11 +1,8 @@
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css'; import './globals.css';
import { AuthProvider } from '@/lib/auth'; import { AuthProvider } from '@/lib/auth';
import { ErrorBoundary } from '@/components/error-boundary'; import { ErrorBoundary } from '@/components/error-boundary';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'ByteLyst DevOps', title: 'ByteLyst DevOps',
description: 'Internal DevOps dashboard for deployment orchestration', description: 'Internal DevOps dashboard for deployment orchestration',
@ -31,7 +28,7 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body className={inter.className}> <body>
<a <a
href="#main-content" 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" 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"

View File

@ -0,0 +1,216 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { AlertTriangle, CheckCircle2, Cloud, DatabaseBackup, ExternalLink, Gauge, HardDrive, RefreshCw, ShieldCheck, Timer, Wifi } from 'lucide-react';
import { Badge, Button } from '@/components/ui/Primitives';
import { SectionCard } from '@/components/hermes-shell';
import { api, type HermesOpsInstance, type HermesOpsSnapshot } from '@/lib/api';
function boolTone(value: boolean): 'success' | 'error' {
return value ? 'success' : 'error';
}
function boolText(value: boolean) {
return value ? 'OK' : 'Needs attention';
}
function formatDate(value: string | null) {
if (!value) return 'unknown';
return new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
function StatusRow({ label, value, ok }: { label: string; value: string; ok: boolean }) {
return (
<div className="flex items-center justify-between gap-3 rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] px-3 py-2">
<span className="text-sm text-[var(--bl-text-secondary)]">{label}</span>
<Badge variant={boolTone(ok)}>{value}</Badge>
</div>
);
}
function InstanceCard({ instance }: { instance: HermesOpsInstance }) {
const score = [
instance.gateway.active,
instance.gateway.enabled,
instance.dashboard.active,
instance.backup.timer.active,
instance.backup.repo.clean,
instance.google.workspaceToken,
].filter(Boolean).length;
return (
<article className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4 shadow-[var(--bl-shadow-sm)]">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Gauge className="h-4 w-4 text-[var(--bl-accent)]" />
<h3 className="font-semibold text-[var(--bl-text-primary)]">{instance.label}</h3>
</div>
<p className="mt-1 text-xs text-[var(--bl-text-secondary)]">{instance.hermesHome}</p>
</div>
<Badge variant={score === 6 ? 'success' : score >= 4 ? 'warning' : 'error'}>{score}/6 healthy</Badge>
</div>
<div className="mt-4 grid gap-2">
<StatusRow label={instance.gateway.service} value={boolText(instance.gateway.active)} ok={instance.gateway.active} />
<StatusRow label="Auto-start enabled" value={instance.gateway.enabled ? 'enabled' : 'disabled'} ok={instance.gateway.enabled} />
<StatusRow label="Private dashboard" value={boolText(instance.dashboard.active)} ok={instance.dashboard.active} />
<StatusRow label="10-minute backup timer" value={boolText(instance.backup.timer.active)} ok={instance.backup.timer.active} />
<StatusRow label="Google Workspace token" value={boolText(instance.google.workspaceToken)} ok={instance.google.workspaceToken} />
</div>
<div className="mt-4 grid gap-3 text-sm text-[var(--bl-text-secondary)] md:grid-cols-2">
<div className="rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-3">
<div className="flex items-center gap-2 text-[var(--bl-text-primary)]">
<DatabaseBackup className="h-4 w-4" />
Backup repo
</div>
<p className="mt-2">HEAD {instance.backup.repo.head ?? 'unknown'}</p>
<p>Last commit {formatDate(instance.backup.repo.lastCommitAt)}</p>
<p>{instance.backup.repo.clean ? 'Clean working tree' : 'Uncommitted changes present'}</p>
</div>
<div className="rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-3">
<div className="flex items-center gap-2 text-[var(--bl-text-primary)]">
<HardDrive className="h-4 w-4" />
Restore payload
</div>
<p className="mt-2">{instance.backup.restoredFileCount ?? 'unknown'} tracked files</p>
<p>{instance.backup.restoredCronJobs ?? 'unknown'} cron job definitions</p>
<p>{instance.backup.repo.size ?? 'size unknown'}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button asChild variant="secondary" size="sm">
<a href={instance.dashboard.url} target="_blank" rel="noreferrer">
Open dashboard <ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</div>
</article>
);
}
export function HermesOpsPanel() {
const [snapshot, setSnapshot] = useState<HermesOpsSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
setSnapshot(await api.getHermesOps());
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to load Hermes operations status');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
const id = window.setInterval(() => void load(), 60_000);
return () => window.clearInterval(id);
}, []);
const allHealthy = useMemo(() => snapshot ? snapshot.warnings.length === 0 : false, [snapshot]);
return (
<SectionCard
title="Live Recovery And Dashboard Status"
subtitle="Real VM status for Vijay/root and Bheem/Uma: gateways, private dashboards, backups, Google auth, and restore payload health."
actions={(
<div className="flex flex-wrap items-center gap-2">
{snapshot ? <Badge variant={allHealthy ? 'success' : 'warning'}>{allHealthy ? 'All green' : `${snapshot.warnings.length} warning(s)`}</Badge> : null}
<Button variant="ghost" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
)}
>
{error ? (
<div className="rounded-2xl border border-[var(--bl-danger)]/30 bg-[var(--bl-danger)]/10 p-4 text-sm text-[var(--bl-danger)]">
{error}
</div>
) : null}
{snapshot ? (
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<div className="flex items-center gap-2 text-sm text-[var(--bl-text-secondary)]">
<Wifi className="h-4 w-4" />
Tailscale IP
</div>
<p className="mt-2 text-xl font-semibold text-[var(--bl-text-primary)]">{snapshot.tailscaleIp ?? 'unknown'}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<div className="flex items-center gap-2 text-sm text-[var(--bl-text-secondary)]">
<Cloud className="h-4 w-4" />
Emergency Drive
</div>
<p className="mt-2 text-xl font-semibold text-[var(--bl-text-primary)]">{snapshot.emergencyDriveUpload.active ? 'active' : 'inactive'}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<div className="flex items-center gap-2 text-sm text-[var(--bl-text-secondary)]">
<Timer className="h-4 w-4" />
Next Drive bundle
</div>
<p className="mt-2 text-xl font-semibold text-[var(--bl-text-primary)]">{snapshot.emergencyDriveUpload.nextRun ?? 'unknown'}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<div className="flex items-center gap-2 text-sm text-[var(--bl-text-secondary)]">
<ShieldCheck className="h-4 w-4" />
Generated
</div>
<p className="mt-2 text-xl font-semibold text-[var(--bl-text-primary)]">{formatDate(snapshot.generatedAt)}</p>
</div>
</div>
{snapshot.warnings.length ? (
<div className="rounded-2xl border border-[var(--bl-warning)]/40 bg-[var(--bl-warning)]/10 p-4">
<div className="flex items-center gap-2 font-medium text-[var(--bl-text-primary)]">
<AlertTriangle className="h-4 w-4 text-[var(--bl-warning)]" />
Recovery warnings
</div>
<div className="mt-3 grid gap-2 md:grid-cols-2">
{snapshot.warnings.map((warning) => (
<div key={warning} className="rounded-xl bg-[var(--bl-surface-card)] px-3 py-2 text-sm text-[var(--bl-text-secondary)]">{warning}</div>
))}
</div>
</div>
) : (
<div className="flex items-center gap-2 rounded-2xl border border-[var(--bl-success)]/30 bg-[var(--bl-success)]/10 p-4 text-sm text-[var(--bl-text-secondary)]">
<CheckCircle2 className="h-4 w-4 text-[var(--bl-success)]" />
Vijay and Bheem recovery paths are healthy.
</div>
)}
<div className="grid gap-4 xl:grid-cols-2">
{snapshot.instances.map((instance) => (
<InstanceCard key={instance.id} instance={instance} />
))}
</div>
<div className="text-sm text-[var(--bl-text-secondary)]">
Disaster recovery details live in{' '}
<Link href="/hermes/settings" className="text-[var(--bl-accent)] hover:underline">Hermes settings</Link>
{' '}and the tracked runbook in `docs/hermes-disaster-recovery.md`.
</div>
</div>
) : (
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 text-sm text-[var(--bl-text-secondary)]">
Loading live Hermes operations status...
</div>
)}
</SectionCard>
);
}

View File

@ -28,8 +28,9 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const classes = cn(baseStyles, variantStyles[variant], sizeStyles[size], className); const classes = cn(baseStyles, variantStyles[variant], sizeStyles[size], className);
if (asChild && React.isValidElement(children)) { if (asChild && React.isValidElement(children)) {
return React.cloneElement(children as React.ReactElement<{ className?: string }>, { const child = children as React.ReactElement<{ className?: string }>;
className: cn(children.props.className, classes), return React.cloneElement(child, {
className: cn(child.props.className, classes),
}); });
} }

View File

@ -56,6 +56,56 @@ export interface EnvVar {
updatedAt: string; updatedAt: string;
} }
export interface HermesOpsTimer {
name: string;
active: boolean;
nextRun: string | null;
lastRun: string | null;
}
export interface HermesOpsRepo {
path: string;
branch: string | null;
clean: boolean;
head: string | null;
lastCommitAt: string | null;
size: string | null;
}
export interface HermesOpsInstance {
id: 'vijay' | 'bheem';
label: string;
hermesHome: string;
gateway: {
service: string;
active: boolean;
enabled: boolean;
};
dashboard: {
service: string;
active: boolean;
url: string;
};
backup: {
timer: HermesOpsTimer;
repo: HermesOpsRepo;
restoredFileCount: number | null;
restoredCronJobs: number | null;
};
google: {
workspaceToken: boolean;
driveFolder: string;
};
}
export interface HermesOpsSnapshot {
generatedAt: string;
tailscaleIp: string | null;
emergencyDriveUpload: HermesOpsTimer;
instances: HermesOpsInstance[];
warnings: string[];
}
let csrfToken: string | null = null; let csrfToken: string | null = null;
let csrfTokenExpiresAt: number = 0; let csrfTokenExpiresAt: number = 0;
@ -208,6 +258,9 @@ export const api = {
apiRequest<ServiceHealth>(`/api/health/${serviceId}`), apiRequest<ServiceHealth>(`/api/health/${serviceId}`),
clearHealthCache: () => apiRequest<{ message: string }>('/api/health/cache', { method: 'DELETE' }), clearHealthCache: () => apiRequest<{ message: string }>('/api/health/cache', { method: 'DELETE' }),
// Hermes operations
getHermesOps: () => apiRequest<HermesOpsSnapshot>('/api/hermes/ops'),
// Seed // Seed
seedServices: () => apiRequest<{ message: string }>('/api/seed', { method: 'POST' }), seedServices: () => apiRequest<{ message: string }>('/api/seed', { method: 'POST' }),