Add live Hermes operations dashboard
This commit is contained in:
parent
babe2e6c13
commit
0e6528b366
2
.gitignore
vendored
2
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
31
dashboard/backend/src/modules/env/repository.ts
vendored
Normal file
31
dashboard/backend/src/modules/env/repository.ts
vendored
Normal 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);
|
||||||
|
}
|
||||||
48
dashboard/backend/src/modules/env/routes.ts
vendored
Normal file
48
dashboard/backend/src/modules/env/routes.ts
vendored
Normal 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.'] });
|
||||||
|
});
|
||||||
|
}
|
||||||
22
dashboard/backend/src/modules/env/types.ts
vendored
Normal file
22
dashboard/backend/src/modules/env/types.ts
vendored
Normal 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>;
|
||||||
215
dashboard/backend/src/modules/hermes-ops/repository.ts
Normal file
215
dashboard/backend/src/modules/hermes-ops/repository.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
13
dashboard/backend/src/modules/hermes-ops/routes.ts
Normal file
13
dashboard/backend/src/modules/hermes-ops/routes.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
51
dashboard/backend/src/modules/hermes-ops/types.ts
Normal file
51
dashboard/backend/src/modules/hermes-ops/types.ts
Normal 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[];
|
||||||
|
}
|
||||||
@ -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
441
dashboard/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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 }) {
|
||||||
|
|||||||
@ -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)]">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
216
dashboard/web/src/components/hermes-ops-panel.tsx
Normal file
216
dashboard/web/src/components/hermes-ops-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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' }),
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user