feat(devops): add @bytelyst/devops package — runtime metadata + React UI
Some checks are pending
CI — Common Platform / Build, Test & Typecheck (push) Waiting to run
CI — Common Platform / Publish @bytelyst/* to Gitea npm registry (push) Blocked by required conditions

- Server collector via @bytelyst/devops/server: build, runtime, config, deps
- React UI via @bytelyst/devops/ui: tabbed view (Build/Runtime/Config/Deps/Raw)
- Build metadata baked from BYTELYST_COMMIT_SHA / BYTELYST_BUILT_AT / etc env vars
- No secret leakage: only env var keys are exposed, never values

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
root 2026-05-10 04:56:39 +00:00
parent 3bd3b234ec
commit 17780adc1a
5 changed files with 665 additions and 0 deletions

View File

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

View File

@ -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<DependencyStatus>>;
/** Optional: extra service-specific fields. */
extra?: Record<string, unknown>;
}
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<DevopsInfo> {
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<DependencyStatus>>
): Promise<DependencyStatus[]> {
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`;
}

View File

@ -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<string, unknown>;
}

398
packages/devops/src/ui.tsx Normal file
View File

@ -0,0 +1,398 @@
/**
* React UI for rendering devops info.
*
* Usage:
* ```tsx
* import { DevopsPanel } from '@bytelyst/devops/ui';
*
* <DevopsPanel fetchInfo={() => 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<DevopsInfo>;
/** 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<DevopsInfo>;
}
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<DevopsInfo | null>(null);
const [webInfo, setWebInfo] = React.useState<DevopsInfo | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
const [tab, setTab] = React.useState<Tab>('build');
const [source, setSource] = React.useState<'backend' | 'web'>('backend');
const [lastRefreshed, setLastRefreshed] = React.useState<Date | null>(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 <div style={styles.msg}>Loading devops info</div>;
}
if (error && !active) {
return (
<div style={{ ...styles.msg, color: 'var(--destructive, #ef4444)' }}>Error: {error}</div>
);
}
if (!active) {
return <div style={styles.msg}>No devops info available.</div>;
}
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 (
<div style={styles.root}>
<div style={styles.header}>
<div>
<div style={styles.title}>DevOps · {active.config.serviceName}</div>
<div style={styles.subtitle}>
{active.config.productId} · v{active.config.serviceVersion} ·{' '}
{active.build.commitSha ? (
<code style={styles.code}>{active.build.commitSha}</code>
) : (
<span style={styles.muted}>no commit info</span>
)}{' '}
· <span style={styles.muted}>uptime {active.runtime.uptimeHuman}</span>
</div>
</div>
<div style={styles.headerActions}>
{webInfo ? (
<div style={styles.sourceToggle}>
{(['backend', 'web'] as const).map(s => (
<button
key={s}
type="button"
onClick={() => setSource(s)}
style={{ ...styles.sourceBtn, ...(source === s ? styles.sourceBtnActive : {}) }}
>
{s}
</button>
))}
</div>
) : null}
<button
type="button"
onClick={() => void load()}
style={styles.refreshBtn}
disabled={loading}
>
{loading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
</div>
{lastRefreshed ? (
<div style={styles.muted}>Last refreshed: {lastRefreshed.toLocaleTimeString()}</div>
) : null}
{error ? (
<div style={{ ...styles.msg, color: 'var(--destructive, #ef4444)' }}>
Stale data (refresh failed): {error}
</div>
) : null}
<div style={styles.tabs} role="tablist">
{tabs
.filter(t => !t.hidden)
.map(t => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
onClick={() => setTab(t.id)}
style={{ ...styles.tab, ...(tab === t.id ? styles.tabActive : {}) }}
>
{t.label}
</button>
))}
</div>
<div style={styles.panel}>
{tab === 'build' && <KvTable rows={buildRows(active)} />}
{tab === 'runtime' && <KvTable rows={runtimeRows(active)} />}
{tab === 'config' && <KvTable rows={configRows(active)} />}
{tab === 'dependencies' && active.dependencies?.length ? (
<DependenciesTable deps={active.dependencies} />
) : null}
{tab === 'raw' && <pre style={styles.pre}>{JSON.stringify(active, null, 2)}</pre>}
</div>
</div>
);
}
function buildRows(info: DevopsInfo): KvRow[] {
const b = info.build;
return [
{
key: 'Commit',
value: b.commitSha ? <code style={styles.code}>{b.commitShaFull || b.commitSha}</code> : '—',
},
{ 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 ? <code style={styles.code}>{b.dockerImage}</code> : '—',
},
];
}
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: <code style={styles.code}>{r.hostname}</code> },
{ 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 ? <code style={styles.code}>{c.envKeys.join(', ')}</code> : '—',
},
];
if (info.extra) {
for (const [k, v] of Object.entries(info.extra)) {
rows.push({
key: k,
value:
typeof v === 'object' ? <code style={styles.code}>{JSON.stringify(v)}</code> : String(v),
});
}
}
return rows;
}
interface KvRow {
key: string;
value: React.ReactNode;
}
function KvTable({ rows }: { rows: KvRow[] }): React.ReactElement {
return (
<table style={styles.table}>
<tbody>
{rows.map((r, i) => (
<tr key={i} style={styles.tr}>
<th style={styles.th} scope="row">
{r.key}
</th>
<td style={styles.td}>{r.value}</td>
</tr>
))}
</tbody>
</table>
);
}
function DependenciesTable({
deps,
}: {
deps: NonNullable<DevopsInfo['dependencies']>;
}): React.ReactElement {
return (
<table style={styles.table}>
<thead>
<tr style={styles.tr}>
<th style={{ ...styles.th, textAlign: 'left' }}>Name</th>
<th style={{ ...styles.th, textAlign: 'left' }}>Status</th>
<th style={{ ...styles.th, textAlign: 'left' }}>Latency</th>
<th style={{ ...styles.th, textAlign: 'left' }}>Detail</th>
</tr>
</thead>
<tbody>
{deps.map((d, i) => (
<tr key={i} style={styles.tr}>
<td style={styles.td}>{d.name}</td>
<td style={styles.td}>
<span style={{ ...styles.badge, ...(d.ok ? styles.badgeOk : styles.badgeErr) }}>
{d.ok ? 'OK' : 'FAIL'}
</span>
</td>
<td style={styles.td}>{d.latencyMs != null ? `${d.latencyMs} ms` : '—'}</td>
<td style={styles.td}>{d.detail ?? '—'}</td>
</tr>
))}
</tbody>
</table>
);
}
function formatTimestamp(iso: string): string {
try {
const d = new Date(iso);
return `${d.toLocaleString()} (${iso})`;
} catch {
return iso;
}
}
const styles: Record<string, React.CSSProperties> = {
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)',
},
};

View File

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