@bytelyst/devops 0.1.3: - Wrap tables in scrollable container (overflow-x: auto) so long values never push the panel wider than its parent. - table-layout: fixed + min-width on key column prevents column blow-out. - Code/value cells use overflow-wrap: anywhere + word-break: break-word so long commit SHAs and Docker image strings wrap. - pre block uses pre-wrap + break-word for raw JSON. - Header actions wrap on narrow viewports. - Tabs row scrolls horizontally rather than wrapping awkwardly. - root container: maxWidth 100% + minWidth 0 to play nicely with flex parents. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
422 lines
13 KiB
TypeScript
422 lines
13 KiB
TypeScript
/**
|
|
* 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 (
|
|
<div style={styles.tableWrap}>
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DependenciesTable({
|
|
deps,
|
|
}: {
|
|
deps: NonNullable<DevopsInfo['dependencies']>;
|
|
}): React.ReactElement {
|
|
return (
|
|
<div style={styles.tableWrap}>
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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, maxWidth: '100%', minWidth: 0 },
|
|
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', flexWrap: 'wrap' },
|
|
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)',
|
|
overflowX: 'auto',
|
|
whiteSpace: 'nowrap',
|
|
},
|
|
tab: {
|
|
padding: '8px 14px',
|
|
fontSize: 13,
|
|
background: 'transparent',
|
|
border: 'none',
|
|
borderBottom: '2px solid transparent',
|
|
cursor: 'pointer',
|
|
color: 'var(--muted-foreground, #6b7280)',
|
|
flexShrink: 0,
|
|
},
|
|
tabActive: {
|
|
color: 'var(--foreground, inherit)',
|
|
borderBottomColor: 'var(--accent, #2563eb)',
|
|
fontWeight: 500,
|
|
},
|
|
panel: { paddingTop: 8, minWidth: 0 },
|
|
tableWrap: {
|
|
width: '100%',
|
|
overflowX: 'auto',
|
|
overflowY: 'visible',
|
|
WebkitOverflowScrolling: 'touch',
|
|
},
|
|
table: { width: '100%', borderCollapse: 'collapse', fontSize: 13, tableLayout: 'fixed' },
|
|
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%',
|
|
minWidth: 120,
|
|
},
|
|
td: {
|
|
padding: '8px 0',
|
|
verticalAlign: 'top',
|
|
color: 'var(--foreground, inherit)',
|
|
overflowWrap: 'anywhere',
|
|
wordBreak: 'break-word',
|
|
},
|
|
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,
|
|
wordBreak: 'break-all',
|
|
},
|
|
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,
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
},
|
|
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)',
|
|
},
|
|
};
|