bytelyst-devops-tools/dashboard/web/src/lib/api.ts
Hermes VM 8d32cb7980 feat(dashboard/vm): Phases 4.1-4.3 — Prometheus trends, sparklines, weekly digest
- prometheus.ts: new Prometheus client with 7d/30d range queries for disk,
  memory, swap, CPU steal, and disk I/O (GB/hr); getWeeklyDigestData()
  aggregates all metrics for digest and API endpoint
- routes.ts: GET /api/vm/metrics/trend?metric=…&range=… and
  GET /api/vm/weekly-digest endpoints
- api.ts: TrendPoint/TrendSeries types; getTrend() and getMemoryTrend()
  added to vmApi
- vm/page.tsx: Sparkline (pure SVG polyline+fill), TrendCard with
  latest/avg/peak and threshold colouring, TrendsPanel with lazy load
  on first open; Promise.allSettled() isolation for all 5 data panels
- vm-weekly-digest.sh: weekly Telegram digest via docker exec into
  devops-backend to reach Prometheus; emoji severity indicators; cron
  summary from /var/log/vm-cleanup.log
- systemd timer: Mon 08:00 UTC, Persistent=true (fires on next boot
  if missed); first trigger 2026-06-02

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 05:26:49 +00:00

678 lines
17 KiB
TypeScript

import { devopsApiUrl, platformUrl } from './product-config';
// Platform service URL for auth
const PLATFORM_SERVICE_URL = platformUrl;
export interface Service {
id: string;
name: string;
scriptPath: string;
healthUrl: string;
repoPath: string;
status: 'up' | 'down' | 'degraded';
version: string;
lastDeployedAt?: string;
lastHealthCheckAt?: string;
productId: string;
}
export interface Deployment {
id: string;
serviceId: string;
version: string;
status: 'running' | 'success' | 'failed';
logs: string;
triggeredBy: string;
triggeredAt: string;
completedAt?: string;
productId: string;
}
export interface ServiceHealth {
serviceId: string;
status: 'up' | 'down' | 'degraded';
responseTime?: number;
lastCheck: string;
}
export interface ApiError {
error: string;
status?: number;
}
export interface DeploymentLogsResponse {
logs: string;
status: 'running' | 'success' | 'failed';
}
export interface EnvVar {
id: string;
name: string;
value: string;
isSecret: boolean;
source: 'local' | 'azure-key-vault';
azureKeyVaultName?: string;
azureSecretName?: 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 HermesOpsSessionSummary {
active: number;
updatedAt: string | null;
}
export interface HermesOpsCronJob {
name: string;
label: string;
active: boolean;
nextRun: string | null;
lastRun: string | null;
}
export interface HermesOpsLink {
label: string;
href: string;
description: string;
}
export interface HermesOpsSnapshot {
generatedAt: string;
tailscaleIp: string | null;
emergencyDriveUpload: HermesOpsTimer;
activeSessions: HermesOpsSessionSummary;
cronJobs: HermesOpsCronJob[];
recentAlerts: string[];
quickLinks: HermesOpsLink[];
instances: HermesOpsInstance[];
warnings: string[];
}
let csrfToken: string | null = null;
let csrfTokenExpiresAt: number = 0;
async function getAccessToken(): Promise<string | null> {
if (typeof window === 'undefined') return null;
let token = getAccessTokenFromStorage();
// If no token, try to refresh
if (!token) {
token = await refreshAccessToken();
}
return token;
}
async function getCsrfToken(): Promise<string | null> {
if (csrfToken && Date.now() < csrfTokenExpiresAt) {
return csrfToken;
}
try {
const token = await getAccessToken();
if (!token) {
return null;
}
const response = await fetch(`${devopsApiUrl}/api/csrf-token`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
csrfToken = data.csrfToken;
csrfTokenExpiresAt = Date.now() + 3000000; // 50 minutes (before 1 hour expiry)
return csrfToken;
}
} catch (error) {
console.error('Failed to fetch CSRF token:', error);
}
return null;
}
export async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
let token = await getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const method = options.method?.toUpperCase();
const stateChangingMethods = ['POST', 'PUT', 'DELETE', 'PATCH'];
if (method && stateChangingMethods.includes(method)) {
const csrf = await getCsrfToken();
if (csrf) {
headers['X-CSRF-Token'] = csrf;
}
}
let response = await fetch(`${devopsApiUrl}${endpoint}`, {
...options,
headers,
});
// Handle 401 - try to refresh token and retry
if (response.status === 401 && token) {
const newToken = await refreshAccessToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
response = await fetch(`${devopsApiUrl}${endpoint}`, {
...options,
headers,
});
}
}
// Handle 403 - CSRF token retry
if (response.status === 403) {
const errorData = await response.json().catch(() => ({}));
if (errorData.error === 'Invalid CSRF token') {
csrfToken = null;
csrfTokenExpiresAt = 0;
const newCsrf = await getCsrfToken();
if (newCsrf) {
headers['X-CSRF-Token'] = newCsrf;
response = await fetch(`${devopsApiUrl}${endpoint}`, {
...options,
headers,
});
}
}
}
if (!response.ok) {
const error: ApiError = {
error: `API error: ${response.status} ${response.statusText}`,
status: response.status
};
throw error;
}
return response.json();
}
export const api = {
// Services
getServices: () => apiRequest<Service[]>('/api/services'),
getService: (id: string) => apiRequest<Service>(`/api/services/${id}`),
createService: (data: Partial<Service>) =>
apiRequest<Service>('/api/services', {
method: 'POST',
body: JSON.stringify(data),
}),
updateService: (id: string, data: Partial<Service>) =>
apiRequest<Service>(`/api/services/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteService: (id: string) =>
apiRequest<void>(`/api/services/${id}`, {
method: 'DELETE',
}),
// Deployments
getDeployments: (limit = 20) => apiRequest<Deployment[]>(`/api/deployments?limit=${limit}`),
getServiceDeployments: (serviceId: string, limit = 50) =>
apiRequest<Deployment[]>(`/api/deployments/service/${serviceId}?limit=${limit}`),
getDeployment: (id: string) => apiRequest<Deployment>(`/api/deployments/${id}`),
getDeploymentLogs: (id: string) =>
apiRequest<DeploymentLogsResponse>(`/api/deployments/${id}/logs`),
triggerDeployment: (serviceId: string) =>
apiRequest<{ deploymentId: string; status: string }>(`/api/deployments/trigger/${serviceId}`, {
method: 'POST',
}),
// Health
getHealth: () => apiRequest<ServiceHealth[]>('/api/health'),
getServiceHealth: (serviceId: string) =>
apiRequest<ServiceHealth>(`/api/health/${serviceId}`),
clearHealthCache: () => apiRequest<{ message: string }>('/api/health/cache', { method: 'DELETE' }),
// Hermes operations
getHermesOps: () => apiRequest<HermesOpsSnapshot>('/api/hermes/ops'),
// Seed
seedServices: () => apiRequest<{ message: string }>('/api/seed', { method: 'POST' }),
// Environment Variables
getEnvVars: () => apiRequest<EnvVar[]>('/api/env'),
getEnvVar: (id: string) => apiRequest<EnvVar>(`/api/env/${id}`),
createEnvVar: (data: Partial<EnvVar>) =>
apiRequest<EnvVar>('/api/env', {
method: 'POST',
body: JSON.stringify(data),
}),
updateEnvVar: (id: string, data: Partial<EnvVar>) =>
apiRequest<EnvVar>(`/api/env/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteEnvVar: (id: string) =>
apiRequest<void>(`/api/env/${id}`, {
method: 'DELETE',
}),
syncAzureKeyVault: () =>
apiRequest<{ synced: number; errors: string[] }>('/api/env/sync-azure', {
method: 'POST',
}),
};
// Standalone functions for environment variables (used by env page)
export const getEnvVars = () => api.getEnvVars();
export const createEnvVar = (data: Partial<EnvVar>) => api.createEnvVar(data);
export const updateEnvVar = (id: string, data: Partial<EnvVar>) => api.updateEnvVar(id, data);
export const deleteEnvVar = (id: string) => api.deleteEnvVar(id);
// Azure Config
export interface AzureConfig {
id: string;
tenantId: string;
clientId: string;
keyVaultUrl: string;
isActive: boolean;
updatedAt: string;
hasClientSecret?: boolean;
}
export const azureApi = {
getAzureConfig: () => apiRequest<AzureConfig>('/api/azure-config'),
createAzureConfig: (data: Partial<AzureConfig>) =>
apiRequest<AzureConfig>('/api/azure-config', {
method: 'POST',
body: JSON.stringify(data),
}),
updateAzureConfig: (id: string, data: Partial<AzureConfig>) =>
apiRequest<AzureConfig>(`/api/azure-config/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteAzureConfig: (id: string) =>
apiRequest<void>(`/api/azure-config/${id}`, {
method: 'DELETE',
}),
testAzureConnection: () =>
apiRequest<{ success: boolean; error?: string }>('/api/azure-config/test', {
method: 'POST',
}),
};
export const getAzureConfig = () => azureApi.getAzureConfig();
export const createAzureConfig = (data: Partial<AzureConfig>) => azureApi.createAzureConfig(data);
export const updateAzureConfig = (id: string, data: Partial<AzureConfig>) => azureApi.updateAzureConfig(id, data);
export const deleteAzureConfig = (id: string) => azureApi.deleteAzureConfig(id);
export const testAzureConnection = () => azureApi.testAzureConnection();
// Code Quality
export interface CodeQualityIssue {
id: string;
type: 'error' | 'warning' | 'info';
category: 'typescript' | 'eslint' | 'build' | 'test' | 'format';
file: string;
line?: number;
column?: number;
message: string;
rule?: string;
}
export interface CodeQualityReport {
id: string;
projectId: string;
projectName: string;
projectPath: string;
timestamp: string;
summary: {
totalIssues: number;
errors: number;
warnings: number;
infos: number;
};
categories: {
typescript: {
errors: number;
warnings: number;
duration: number;
};
eslint: {
errors: number;
warnings: number;
duration: number;
};
build: {
success: boolean;
duration: number;
errors: number;
};
test: {
success: boolean;
passed: number;
failed: number;
duration: number;
};
};
issues: CodeQualityIssue[];
}
export interface CodeQualityCheckParams {
projectId: string;
projectPath: string;
checks: Array<'typescript' | 'eslint' | 'build' | 'test'>;
}
export const codeQualityApi = {
runCheck: (params: CodeQualityCheckParams) =>
apiRequest<CodeQualityReport>('/api/code-quality/check', {
method: 'POST',
body: JSON.stringify(params),
}),
};
export const runCodeQualityCheck = (params: CodeQualityCheckParams) => codeQualityApi.runCheck(params);
// VM Health
export type VmCheckLevel = 'OK' | 'WARN' | 'CRIT';
export interface VmCheck {
level: VmCheckLevel;
value: string;
message: string;
}
export interface VmHealthResult {
timestamp: string;
hostname: string;
overall: VmCheckLevel;
checks: Record<string, VmCheck>;
error?: string;
}
export interface CronRunSummary {
timestamp: string;
mode: 'standard' | 'full';
diskBefore: string;
diskAfter: string;
freedMB: number;
durationSecs: number;
success: boolean;
steps: string[];
jsonSummary?: Record<string, unknown>;
}
export interface CronJob {
name: string;
schedule: string;
description: string;
lastRun: CronRunSummary | null;
nextRun: string | null;
}
export interface CronStatusResponse {
jobs: CronJob[];
recentRuns: CronRunSummary[];
}
export interface UnhealthyContainer {
name: string;
status: string;
restartCount: number;
lastHealthLogs: string[];
unhealthySince: string | null;
}
export interface OllamaModel {
name: string;
sizeGB: number;
modifiedAt: string;
}
export interface OllamaRunning {
name: string;
sizeGB: number;
processor: string;
expiresAt: string;
}
export interface OllamaModelsResponse {
models: OllamaModel[];
running: OllamaRunning[];
}
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 }),
}),
getCronStatus: () => apiRequest<CronStatusResponse>('/api/vm/cron-status'),
getUnhealthyContainers: () =>
apiRequest<{ containers: UnhealthyContainer[] }>('/api/vm/containers/unhealthy'),
restartContainer: (name: string) =>
apiRequest<{ success: boolean; message: string }>(
`/api/vm/containers/${encodeURIComponent(name)}/restart`,
{ method: 'POST' },
),
getOllamaModels: () => apiRequest<OllamaModelsResponse>('/api/vm/ollama/models'),
unloadOllamaModel: (name: string) =>
apiRequest<{ success: boolean; message: string }>(
`/api/vm/ollama/models/${encodeURIComponent(name)}`,
{ method: 'DELETE' },
),
getTrend: (metric: 'disk' | 'steal' | 'io', range: '7d' | '30d') =>
apiRequest<TrendSeries>(`/api/vm/metrics/trend?metric=${metric}&range=${range}`),
getMemoryTrend: (range: '7d' | '30d') =>
apiRequest<{ available: TrendSeries; swap: TrendSeries }>(
`/api/vm/metrics/trend?metric=memory&range=${range}`,
),
};
export interface TrendPoint { t: number; v: number }
export interface TrendSeries {
metric: string;
unit: string;
points: TrendPoint[];
latest: number;
avg: number;
peak: number;
}
// Auth API - calls platform-service for authentication
export interface LoginRequest {
email: string;
password: string;
productId: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
user: {
id: string;
email: string;
role: string;
plan: string;
displayName: string;
products?: Array<{
productId: string;
plan: string;
role: string;
}>;
};
}
export interface RefreshRequest {
refreshToken: string;
}
export interface RefreshResponse {
accessToken: string;
refreshToken: string;
}
export interface MeResponse {
id: string;
email: string;
role: string;
plan: string;
displayName: string;
emailVerified: boolean;
currentProduct: string;
products: Array<{
productId: string;
plan: string;
role: string;
}>;
mfaEnabled: boolean;
mfaMethods: string[];
}
export const authApi = {
login: async (data: LoginRequest): Promise<LoginResponse> => {
const response = await fetch(`${PLATFORM_SERVICE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Login failed' }));
throw new Error(error.error || 'Login failed');
}
return response.json();
},
refresh: async (data: RefreshRequest): Promise<RefreshResponse> => {
const response = await fetch(`${PLATFORM_SERVICE_URL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
return response.json();
},
me: async (token: string): Promise<MeResponse> => {
const response = await fetch(`${PLATFORM_SERVICE_URL}/auth/me`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Failed to get user info');
}
return response.json();
},
};
// Helper functions for auth state management
export function setAccessToken(token: string): void {
if (typeof window !== 'undefined') {
localStorage.setItem('access_token', token);
}
}
export function setRefreshToken(token: string): void {
if (typeof window !== 'undefined') {
localStorage.setItem('refresh_token', token);
}
}
export function getAccessTokenFromStorage(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token');
}
export function getRefreshTokenFromStorage(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('refresh_token');
}
export function clearAuthTokens(): void {
if (typeof window !== 'undefined') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
}
export async function refreshAccessToken(): Promise<string | null> {
const refreshToken = getRefreshTokenFromStorage();
if (!refreshToken) return null;
try {
const response = await authApi.refresh({ refreshToken });
setAccessToken(response.accessToken);
setRefreshToken(response.refreshToken);
return response.accessToken;
} catch {
clearAuthTokens();
return null;
}
}