learning_ai_common_plat/packages/devops/src/ui.tsx
root d2420f5d3c
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
fix(devops): responsive UI + overflow guards in DevopsPanel
@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>
2026-05-10 07:14:29 +00:00

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)',
},
};