diff --git a/packages/devops/package.json b/packages/devops/package.json new file mode 100644 index 00000000..a856bea0 --- /dev/null +++ b/packages/devops/package.json @@ -0,0 +1,34 @@ +{ + "name": "@bytelyst/devops", + "version": "0.1.1", + "type": "module", + "description": "Runtime devops metadata (build info, uptime, health) — server collector + React UI", + "exports": { + "./server": { + "import": "./dist/server.js", + "types": "./dist/server.d.ts" + }, + "./ui": { + "import": "./dist/ui.js", + "types": "./dist/ui.d.ts" + }, + "./types": { + "import": "./dist/types.js", + "types": "./dist/types.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks" + }, + "peerDependencies": { + "react": ">=18.0.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "react": "^19.2.4" + } +} diff --git a/packages/devops/src/server.ts b/packages/devops/src/server.ts new file mode 100644 index 00000000..230434a3 --- /dev/null +++ b/packages/devops/src/server.ts @@ -0,0 +1,142 @@ +/** + * Server-side devops info collector. + * + * Usage in a Node backend: + * ```ts + * import { collectDevopsInfo } from '@bytelyst/devops/server'; + * + * app.get('/api/devops/info', async (req, res) => { + * const info = await collectDevopsInfo({ + * productId: 'invttrdg', + * serviceName: 'trading-backend', + * serviceVersion: pkg.version, + * }); + * res.json(info); + * }); + * ``` + * + * Build-time values (commit SHA, branch, build time, image) are read from env vars: + * - BYTELYST_COMMIT_SHA / BYTELYST_COMMIT_SHA_FULL + * - BYTELYST_BRANCH + * - BYTELYST_BUILT_AT (ISO 8601) + * - BYTELYST_COMMIT_AUTHOR + * - BYTELYST_COMMIT_MESSAGE + * - BYTELYST_DOCKER_IMAGE + * + * Dockerfiles should bake these via ARG/ENV or a build script. + */ + +import os from 'node:os'; +import process from 'node:process'; +import type { BuildInfo, ConfigInfo, DependencyStatus, DevopsInfo, RuntimeInfo } from './types.js'; + +export type { BuildInfo, ConfigInfo, DependencyStatus, DevopsInfo, RuntimeInfo }; + +/** Options for {@link collectDevopsInfo}. */ +export interface CollectOptions { + /** Required: product identifier (e.g. "invttrdg"). */ + productId: string; + /** Required: service name (e.g. "trading-backend"). */ + serviceName: string; + /** Required: service version from package.json. */ + serviceVersion: string; + /** Optional: prefixes of env var keys that are safe to list (defaults to NODE_ENV, PRODUCT_ID, PLATFORM_, VITE_, BYTELYST_). */ + safeEnvPrefixes?: string[]; + /** Optional: async health checks for external dependencies. */ + dependencyChecks?: Array<() => Promise>; + /** Optional: extra service-specific fields. */ + extra?: Record; +} + +const DEFAULT_SAFE_PREFIXES = [ + 'NODE_ENV', + 'NODE_VERSION', + 'PRODUCT_ID', + 'PLATFORM_', + 'VITE_', + 'BYTELYST_', + 'COSMOS_DATABASE', + 'PORT', +]; + +/** + * Collect runtime + build info. Safe to call from any request handler. + * + * Never returns secret values; only env var *keys* are listed. + */ +export async function collectDevopsInfo(opts: CollectOptions): Promise { + const build = collectBuildInfo(); + const runtime = collectRuntimeInfo(); + const config = collectConfigInfo(opts); + const dependencies = opts.dependencyChecks + ? await runDependencyChecks(opts.dependencyChecks) + : undefined; + return { build, runtime, config, dependencies, extra: opts.extra }; +} + +function collectBuildInfo(): BuildInfo { + const full = process.env.BYTELYST_COMMIT_SHA_FULL || process.env.BYTELYST_COMMIT_SHA || null; + const short = process.env.BYTELYST_COMMIT_SHA || (full ? full.substring(0, 7) : null); + return { + commitSha: short, + commitShaFull: full, + branch: process.env.BYTELYST_BRANCH || null, + builtAt: process.env.BYTELYST_BUILT_AT || null, + commitAuthor: process.env.BYTELYST_COMMIT_AUTHOR || null, + commitMessage: process.env.BYTELYST_COMMIT_MESSAGE || null, + dockerImage: process.env.BYTELYST_DOCKER_IMAGE || null, + }; +} + +function collectRuntimeInfo(): RuntimeInfo { + const uptimeSeconds = Math.floor(process.uptime()); + const mem = process.memoryUsage(); + return { + uptimeSeconds, + uptimeHuman: humanizeUptime(uptimeSeconds), + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + pid: process.pid, + hostname: os.hostname(), + memoryMb: Math.round(mem.rss / 1024 / 1024), + heapMb: Math.round(mem.heapUsed / 1024 / 1024), + startedAt: new Date(Date.now() - uptimeSeconds * 1000).toISOString(), + }; +} + +function collectConfigInfo(opts: CollectOptions): ConfigInfo { + const prefixes = opts.safeEnvPrefixes ?? DEFAULT_SAFE_PREFIXES; + const envKeys = Object.keys(process.env) + .filter(k => prefixes.some(p => (p.endsWith('_') ? k.startsWith(p) : k === p))) + .filter(k => !/SECRET|KEY|TOKEN|PASSWORD|PRIVATE/i.test(k)) + .sort(); + return { + productId: opts.productId, + serviceName: opts.serviceName, + serviceVersion: opts.serviceVersion, + nodeEnv: process.env.NODE_ENV || 'development', + envKeys, + }; +} + +async function runDependencyChecks( + checks: Array<() => Promise> +): Promise { + const results = await Promise.allSettled(checks.map(c => c())); + return results.map((r, i) => + r.status === 'fulfilled' + ? r.value + : { name: `check-${i}`, ok: false, detail: String((r as PromiseRejectedResult).reason) } + ); +} + +function humanizeUptime(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + if (mins < 60) return `${mins}m ${seconds % 60}s`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ${mins % 60}m`; + const days = Math.floor(hrs / 24); + return `${days}d ${hrs % 24}h ${mins % 60}m`; +} diff --git a/packages/devops/src/types.ts b/packages/devops/src/types.ts new file mode 100644 index 00000000..20d8785b --- /dev/null +++ b/packages/devops/src/types.ts @@ -0,0 +1,80 @@ +/** + * Shared types for @bytelyst/devops. + * + * Consumed by both the server collector (@bytelyst/devops/server) + * and the React UI (@bytelyst/devops/ui). + */ + +/** Git build metadata baked at image build time. */ +export interface BuildInfo { + /** Short commit SHA (e.g. "a1b2c3d"). */ + commitSha: string | null; + /** Full commit SHA. */ + commitShaFull: string | null; + /** Git branch name at build time. */ + branch: string | null; + /** ISO 8601 timestamp of the build. */ + builtAt: string | null; + /** Author of the built commit. */ + commitAuthor: string | null; + /** Commit message subject line. */ + commitMessage: string | null; + /** Docker image name:tag. */ + dockerImage: string | null; +} + +/** Runtime info (process + container). */ +export interface RuntimeInfo { + /** Uptime in seconds since process start. */ + uptimeSeconds: number; + /** Human-friendly uptime (e.g. "2d 3h 14m"). */ + uptimeHuman: string; + /** Node.js version. */ + nodeVersion: string; + /** OS platform (linux, darwin, …). */ + platform: string; + /** CPU architecture. */ + arch: string; + /** PID. */ + pid: number; + /** Container hostname (usually the container id short). */ + hostname: string; + /** Resident set size in MB. */ + memoryMb: number; + /** Heap used in MB. */ + heapMb: number; + /** ISO timestamp of process start. */ + startedAt: string; +} + +/** Configuration-level info (safe, non-secret). */ +export interface ConfigInfo { + /** Product identifier. */ + productId: string; + /** Service name. */ + serviceName: string; + /** Service version (from package.json). */ + serviceVersion: string; + /** NODE_ENV. */ + nodeEnv: string; + /** Non-secret env var summary (keys only). */ + envKeys: string[]; +} + +/** Dependency check result. */ +export interface DependencyStatus { + name: string; + ok: boolean; + latencyMs?: number; + detail?: string; +} + +/** Full devops info payload. */ +export interface DevopsInfo { + build: BuildInfo; + runtime: RuntimeInfo; + config: ConfigInfo; + dependencies?: DependencyStatus[]; + /** Arbitrary extra info specific to the service. */ + extra?: Record; +} diff --git a/packages/devops/src/ui.tsx b/packages/devops/src/ui.tsx new file mode 100644 index 00000000..5c5a7b83 --- /dev/null +++ b/packages/devops/src/ui.tsx @@ -0,0 +1,398 @@ +/** + * React UI for rendering devops info. + * + * Usage: + * ```tsx + * import { DevopsPanel } from '@bytelyst/devops/ui'; + * + * fetch('/api/devops/info').then(r => r.json())} /> + * ``` + * + * Styling: uses plain HTML + inline styles (theme-aware via CSS variables). + * No hard dependency on any UI framework — works in any React app. + */ + +import * as React from 'react'; +import type { DevopsInfo } from './types.js'; + +export type { DevopsInfo }; + +export interface DevopsPanelProps { + /** Async loader for devops info — typically calls `/api/devops/info`. */ + fetchInfo: () => Promise; + /** Refresh interval in milliseconds. Default: 10_000 (10s). Set to 0 to disable. */ + refreshIntervalMs?: number; + /** Optional second source (e.g. web bundle's own info). */ + fetchWebInfo?: () => Promise; +} + +type Tab = 'build' | 'runtime' | 'config' | 'dependencies' | 'raw'; + +export function DevopsPanel(props: DevopsPanelProps): React.ReactElement { + const { fetchInfo, fetchWebInfo, refreshIntervalMs = 10_000 } = props; + const [info, setInfo] = React.useState(null); + const [webInfo, setWebInfo] = React.useState(null); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [tab, setTab] = React.useState('build'); + const [source, setSource] = React.useState<'backend' | 'web'>('backend'); + const [lastRefreshed, setLastRefreshed] = React.useState(null); + + const load = React.useCallback(async () => { + try { + const [b, w] = await Promise.all([ + fetchInfo().catch(e => { + throw e; + }), + fetchWebInfo ? fetchWebInfo().catch(() => null) : Promise.resolve(null), + ]); + setInfo(b); + setWebInfo(w); + setError(null); + setLastRefreshed(new Date()); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [fetchInfo, fetchWebInfo]); + + React.useEffect(() => { + void load(); + if (refreshIntervalMs > 0) { + const h = setInterval(() => void load(), refreshIntervalMs); + return () => clearInterval(h); + } + return undefined; + }, [load, refreshIntervalMs]); + + const active = source === 'web' && webInfo ? webInfo : info; + + if (loading && !active) { + return
Loading devops info…
; + } + if (error && !active) { + return ( +
Error: {error}
+ ); + } + if (!active) { + return
No devops info available.
; + } + + const tabs: Array<{ id: Tab; label: string; hidden?: boolean }> = [ + { id: 'build', label: 'Build' }, + { id: 'runtime', label: 'Runtime' }, + { id: 'config', label: 'Config' }, + { id: 'dependencies', label: 'Dependencies', hidden: !active.dependencies?.length }, + { id: 'raw', label: 'Raw JSON' }, + ]; + + return ( +
+
+
+
DevOps · {active.config.serviceName}
+
+ {active.config.productId} · v{active.config.serviceVersion} ·{' '} + {active.build.commitSha ? ( + {active.build.commitSha} + ) : ( + no commit info + )}{' '} + · uptime {active.runtime.uptimeHuman} +
+
+
+ {webInfo ? ( +
+ {(['backend', 'web'] as const).map(s => ( + + ))} +
+ ) : null} + +
+
+ + {lastRefreshed ? ( +
Last refreshed: {lastRefreshed.toLocaleTimeString()}
+ ) : null} + {error ? ( +
+ Stale data (refresh failed): {error} +
+ ) : null} + +
+ {tabs + .filter(t => !t.hidden) + .map(t => ( + + ))} +
+ +
+ {tab === 'build' && } + {tab === 'runtime' && } + {tab === 'config' && } + {tab === 'dependencies' && active.dependencies?.length ? ( + + ) : null} + {tab === 'raw' &&
{JSON.stringify(active, null, 2)}
} +
+
+ ); +} + +function buildRows(info: DevopsInfo): KvRow[] { + const b = info.build; + return [ + { + key: 'Commit', + value: b.commitSha ? {b.commitShaFull || b.commitSha} : '—', + }, + { key: 'Branch', value: b.branch ?? '—' }, + { key: 'Built at', value: b.builtAt ? formatTimestamp(b.builtAt) : '—' }, + { key: 'Author', value: b.commitAuthor ?? '—' }, + { key: 'Message', value: b.commitMessage ?? '—' }, + { + key: 'Docker image', + value: b.dockerImage ? {b.dockerImage} : '—', + }, + ]; +} + +function runtimeRows(info: DevopsInfo): KvRow[] { + const r = info.runtime; + return [ + { key: 'Uptime', value: r.uptimeHuman }, + { key: 'Started at', value: formatTimestamp(r.startedAt) }, + { key: 'Node version', value: r.nodeVersion }, + { key: 'Platform', value: `${r.platform} / ${r.arch}` }, + { key: 'Hostname', value: {r.hostname} }, + { key: 'PID', value: String(r.pid) }, + { key: 'Memory (RSS)', value: `${r.memoryMb} MB` }, + { key: 'Heap used', value: `${r.heapMb} MB` }, + ]; +} + +function configRows(info: DevopsInfo): KvRow[] { + const c = info.config; + const rows: KvRow[] = [ + { key: 'Product', value: c.productId }, + { key: 'Service', value: c.serviceName }, + { key: 'Version', value: c.serviceVersion }, + { key: 'NODE_ENV', value: c.nodeEnv }, + { + key: 'Env keys', + value: c.envKeys.length ? {c.envKeys.join(', ')} : '—', + }, + ]; + if (info.extra) { + for (const [k, v] of Object.entries(info.extra)) { + rows.push({ + key: k, + value: + typeof v === 'object' ? {JSON.stringify(v)} : String(v), + }); + } + } + return rows; +} + +interface KvRow { + key: string; + value: React.ReactNode; +} + +function KvTable({ rows }: { rows: KvRow[] }): React.ReactElement { + return ( + + + {rows.map((r, i) => ( + + + + + ))} + +
+ {r.key} + {r.value}
+ ); +} + +function DependenciesTable({ + deps, +}: { + deps: NonNullable; +}): React.ReactElement { + return ( + + + + + + + + + + + {deps.map((d, i) => ( + + + + + + + ))} + +
NameStatusLatencyDetail
{d.name} + + {d.ok ? 'OK' : 'FAIL'} + + {d.latencyMs != null ? `${d.latencyMs} ms` : '—'}{d.detail ?? '—'}
+ ); +} + +function formatTimestamp(iso: string): string { + try { + const d = new Date(iso); + return `${d.toLocaleString()} (${iso})`; + } catch { + return iso; + } +} + +const styles: Record = { + root: { display: 'flex', flexDirection: 'column', gap: 12 }, + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + gap: 16, + flexWrap: 'wrap', + }, + title: { fontSize: 16, fontWeight: 600, color: 'var(--foreground, inherit)' }, + subtitle: { fontSize: 13, color: 'var(--muted-foreground, #6b7280)', marginTop: 4 }, + muted: { fontSize: 12, color: 'var(--muted-foreground, #6b7280)' }, + headerActions: { display: 'flex', gap: 8, alignItems: 'center' }, + sourceToggle: { + display: 'inline-flex', + border: '1px solid var(--border, #e5e7eb)', + borderRadius: 6, + overflow: 'hidden', + }, + sourceBtn: { + padding: '4px 10px', + fontSize: 12, + background: 'transparent', + color: 'var(--muted-foreground, #6b7280)', + border: 'none', + cursor: 'pointer', + }, + sourceBtnActive: { + background: 'var(--accent, #2563eb)', + color: 'var(--accent-foreground, #fff)', + }, + refreshBtn: { + padding: '4px 10px', + fontSize: 12, + background: 'transparent', + border: '1px solid var(--border, #e5e7eb)', + borderRadius: 6, + cursor: 'pointer', + color: 'var(--foreground, inherit)', + }, + tabs: { + display: 'flex', + gap: 2, + borderBottom: '1px solid var(--border, #e5e7eb)', + }, + tab: { + padding: '8px 14px', + fontSize: 13, + background: 'transparent', + border: 'none', + borderBottom: '2px solid transparent', + cursor: 'pointer', + color: 'var(--muted-foreground, #6b7280)', + }, + tabActive: { + color: 'var(--foreground, inherit)', + borderBottomColor: 'var(--accent, #2563eb)', + fontWeight: 500, + }, + panel: { paddingTop: 8 }, + table: { width: '100%', borderCollapse: 'collapse', fontSize: 13 }, + tr: { borderBottom: '1px solid var(--border, #f3f4f6)' }, + th: { + textAlign: 'left', + fontWeight: 500, + color: 'var(--muted-foreground, #6b7280)', + padding: '8px 12px 8px 0', + verticalAlign: 'top', + width: '30%', + }, + td: { padding: '8px 0', verticalAlign: 'top', color: 'var(--foreground, inherit)' }, + code: { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: 12, + background: 'color-mix(in oklab, var(--foreground, #000) 5%, transparent)', + padding: '2px 6px', + borderRadius: 4, + }, + pre: { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: 12, + background: 'color-mix(in oklab, var(--foreground, #000) 5%, transparent)', + padding: 12, + borderRadius: 6, + overflow: 'auto', + maxHeight: 480, + }, + badge: { + display: 'inline-block', + padding: '2px 8px', + borderRadius: 999, + fontSize: 11, + fontWeight: 500, + }, + badgeOk: { + background: 'color-mix(in oklab, #10b981 20%, transparent)', + color: '#10b981', + }, + badgeErr: { + background: 'color-mix(in oklab, #ef4444 20%, transparent)', + color: '#ef4444', + }, + msg: { + padding: 16, + fontSize: 13, + color: 'var(--muted-foreground, #6b7280)', + }, +}; diff --git a/packages/devops/tsconfig.json b/packages/devops/tsconfig.json new file mode 100644 index 00000000..4447784f --- /dev/null +++ b/packages/devops/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +}