feat: add compact app cards with inline expand to runtime dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,16 @@ function appHealth(group: AppGroup): 'success' | 'warning' | 'error' {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
function latestHeartbeat(group: AppGroup): string | undefined {
|
||||
let latest: string | undefined;
|
||||
for (const inst of group.instances) {
|
||||
if (inst.lastHeartbeat && (!latest || inst.lastHeartbeat > latest)) {
|
||||
latest = inst.lastHeartbeat;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
// ── Detail sub-components ────────────────────────────────────────────────────
|
||||
|
||||
const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
|
||||
@@ -96,6 +106,40 @@ const LOG_SOURCE_ITEMS: ButtonGroupItem[] = [
|
||||
{ value: 'container', label: 'Container' },
|
||||
];
|
||||
|
||||
function CompactAppCard({ group, onExpand }: { group: AppGroup; onExpand: () => void }) {
|
||||
const health = appHealth(group);
|
||||
const heartbeat = latestHeartbeat(group);
|
||||
const isHealthy = health === 'success';
|
||||
|
||||
const variantClass =
|
||||
health === 'success' ? styles.compactCardSuccess
|
||||
: health === 'warning' ? styles.compactCardWarning
|
||||
: styles.compactCardError;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.compactCard} ${variantClass}`}
|
||||
onClick={onExpand}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onExpand(); }}
|
||||
>
|
||||
<div className={styles.compactCardHeader}>
|
||||
<span className={styles.compactCardName}>{group.appId}</span>
|
||||
<ChevronRight size={14} className={styles.compactCardChevron} />
|
||||
</div>
|
||||
<div className={styles.compactCardMeta}>
|
||||
<span className={styles.compactCardLive}>
|
||||
{group.liveCount}/{group.instances.length} live
|
||||
</span>
|
||||
<span className={isHealthy ? styles.compactCardHeartbeat : styles.compactCardHeartbeatWarn}>
|
||||
{heartbeat ? timeAgo(heartbeat) : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AgentHealth page ─────────────────────────────────────────────────────────
|
||||
|
||||
export default function AgentHealth() {
|
||||
@@ -550,60 +594,135 @@ export default function AgentHealth() {
|
||||
|
||||
|
||||
{/* Group cards grid */}
|
||||
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
|
||||
{groups.map((group) => (
|
||||
<GroupCard
|
||||
key={group.appId}
|
||||
title={group.appId}
|
||||
accent={appHealth(group)}
|
||||
headerRight={
|
||||
<Badge
|
||||
label={`${group.liveCount}/${group.instances.length} LIVE`}
|
||||
color={appHealth(group)}
|
||||
variant="filled"
|
||||
/>
|
||||
}
|
||||
meta={
|
||||
<div className={styles.groupMeta}>
|
||||
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
|
||||
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
|
||||
<span>
|
||||
<StatusDot
|
||||
variant={
|
||||
appHealth(group) === 'success'
|
||||
? 'live'
|
||||
: appHealth(group) === 'warning'
|
||||
? 'stale'
|
||||
: 'dead'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
group.deadCount > 0 ? (
|
||||
<div className={styles.alertBanner}>
|
||||
<span className={styles.alertIcon}>⚠</span>
|
||||
{viewMode === 'expanded' || isFullWidth ? (
|
||||
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
|
||||
{groups.map((group) => (
|
||||
<GroupCard
|
||||
key={group.appId}
|
||||
title={group.appId}
|
||||
accent={appHealth(group)}
|
||||
headerRight={
|
||||
<Badge
|
||||
label={`${group.liveCount}/${group.instances.length} LIVE`}
|
||||
color={appHealth(group)}
|
||||
variant="filled"
|
||||
/>
|
||||
}
|
||||
meta={
|
||||
<div className={styles.groupMeta}>
|
||||
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
|
||||
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
|
||||
<span>
|
||||
Single point of failure —{' '}
|
||||
{group.deadCount === group.instances.length
|
||||
? 'no redundancy'
|
||||
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
|
||||
<StatusDot
|
||||
variant={
|
||||
appHealth(group) === 'success'
|
||||
? 'live'
|
||||
: appHealth(group) === 'warning'
|
||||
? 'stale'
|
||||
: 'dead'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<DataTable<AgentInstance & { id: string }>
|
||||
columns={instanceColumns as Column<AgentInstance & { id: string }>[]}
|
||||
data={group.instances.map(i => ({ ...i, id: i.instanceId }))}
|
||||
onRowClick={handleInstanceClick}
|
||||
pageSize={50}
|
||||
flush
|
||||
/>
|
||||
</GroupCard>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
group.deadCount > 0 ? (
|
||||
<div className={styles.alertBanner}>
|
||||
<span className={styles.alertIcon}>⚠</span>
|
||||
<span>
|
||||
Single point of failure —{' '}
|
||||
{group.deadCount === group.instances.length
|
||||
? 'no redundancy'
|
||||
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<DataTable<AgentInstance & { id: string }>
|
||||
columns={instanceColumns as Column<AgentInstance & { id: string }>[]}
|
||||
data={group.instances.map(i => ({ ...i, id: i.instanceId }))}
|
||||
onRowClick={handleInstanceClick}
|
||||
pageSize={50}
|
||||
flush
|
||||
/>
|
||||
</GroupCard>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.compactGrid}>
|
||||
{groups.map((group) =>
|
||||
expandedApps.has(group.appId) ? (
|
||||
<div key={group.appId} className={styles.compactGridExpanded}>
|
||||
<GroupCard
|
||||
title={group.appId}
|
||||
accent={appHealth(group)}
|
||||
headerRight={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Badge
|
||||
label={`${group.liveCount}/${group.instances.length} LIVE`}
|
||||
color={appHealth(group)}
|
||||
variant="filled"
|
||||
/>
|
||||
<button
|
||||
className={styles.collapseBtn}
|
||||
onClick={() => toggleAppExpanded(group.appId)}
|
||||
title="Collapse"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
meta={
|
||||
<div className={styles.groupMeta}>
|
||||
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
|
||||
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
|
||||
<span>
|
||||
<StatusDot
|
||||
variant={
|
||||
appHealth(group) === 'success'
|
||||
? 'live'
|
||||
: appHealth(group) === 'warning'
|
||||
? 'stale'
|
||||
: 'dead'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
group.deadCount > 0 ? (
|
||||
<div className={styles.alertBanner}>
|
||||
<span className={styles.alertIcon}>⚠</span>
|
||||
<span>
|
||||
Single point of failure —{' '}
|
||||
{group.deadCount === group.instances.length
|
||||
? 'no redundancy'
|
||||
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<DataTable<AgentInstance & { id: string }>
|
||||
columns={instanceColumns as Column<AgentInstance & { id: string }>[]}
|
||||
data={group.instances.map(i => ({ ...i, id: i.instanceId }))}
|
||||
onRowClick={handleInstanceClick}
|
||||
pageSize={50}
|
||||
flush
|
||||
/>
|
||||
</GroupCard>
|
||||
</div>
|
||||
) : (
|
||||
<CompactAppCard
|
||||
key={group.appId}
|
||||
group={group}
|
||||
onExpand={() => toggleAppExpanded(group.appId)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log + Timeline side by side */}
|
||||
<div className={styles.bottomRow}>
|
||||
|
||||
Reference in New Issue
Block a user