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';
|
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 ────────────────────────────────────────────────────
|
// ── Detail sub-components ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
|
const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
|
||||||
@@ -96,6 +106,40 @@ const LOG_SOURCE_ITEMS: ButtonGroupItem[] = [
|
|||||||
{ value: 'container', label: 'Container' },
|
{ 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 ─────────────────────────────────────────────────────────
|
// ── AgentHealth page ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AgentHealth() {
|
export default function AgentHealth() {
|
||||||
@@ -550,60 +594,135 @@ export default function AgentHealth() {
|
|||||||
|
|
||||||
|
|
||||||
{/* Group cards grid */}
|
{/* Group cards grid */}
|
||||||
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
|
{viewMode === 'expanded' || isFullWidth ? (
|
||||||
{groups.map((group) => (
|
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
|
||||||
<GroupCard
|
{groups.map((group) => (
|
||||||
key={group.appId}
|
<GroupCard
|
||||||
title={group.appId}
|
key={group.appId}
|
||||||
accent={appHealth(group)}
|
title={group.appId}
|
||||||
headerRight={
|
accent={appHealth(group)}
|
||||||
<Badge
|
headerRight={
|
||||||
label={`${group.liveCount}/${group.instances.length} LIVE`}
|
<Badge
|
||||||
color={appHealth(group)}
|
label={`${group.liveCount}/${group.instances.length} LIVE`}
|
||||||
variant="filled"
|
color={appHealth(group)}
|
||||||
/>
|
variant="filled"
|
||||||
}
|
/>
|
||||||
meta={
|
}
|
||||||
<div className={styles.groupMeta}>
|
meta={
|
||||||
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
|
<div className={styles.groupMeta}>
|
||||||
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
|
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
|
||||||
<span>
|
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</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>
|
<span>
|
||||||
Single point of failure —{' '}
|
<StatusDot
|
||||||
{group.deadCount === group.instances.length
|
variant={
|
||||||
? 'no redundancy'
|
appHealth(group) === 'success'
|
||||||
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
|
? 'live'
|
||||||
|
: appHealth(group) === 'warning'
|
||||||
|
? 'stale'
|
||||||
|
: 'dead'
|
||||||
|
}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
}
|
||||||
}
|
footer={
|
||||||
>
|
group.deadCount > 0 ? (
|
||||||
<DataTable<AgentInstance & { id: string }>
|
<div className={styles.alertBanner}>
|
||||||
columns={instanceColumns as Column<AgentInstance & { id: string }>[]}
|
<span className={styles.alertIcon}>⚠</span>
|
||||||
data={group.instances.map(i => ({ ...i, id: i.instanceId }))}
|
<span>
|
||||||
onRowClick={handleInstanceClick}
|
Single point of failure —{' '}
|
||||||
pageSize={50}
|
{group.deadCount === group.instances.length
|
||||||
flush
|
? 'no redundancy'
|
||||||
/>
|
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
|
||||||
</GroupCard>
|
</span>
|
||||||
))}
|
</div>
|
||||||
</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 */}
|
{/* Log + Timeline side by side */}
|
||||||
<div className={styles.bottomRow}>
|
<div className={styles.bottomRow}>
|
||||||
|
|||||||
Reference in New Issue
Block a user