Compare commits
10 Commits
d0c2fd1ac3
...
d27a288128
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d27a288128 | ||
|
|
7825aae274 | ||
|
|
4b264b3308 | ||
|
|
b57fe875f3 | ||
|
|
911ba591a9 | ||
|
|
9d1cf7577a | ||
|
|
1fa897fbb5 | ||
|
|
9f7951aa2b | ||
|
|
61df59853b | ||
|
|
5229e08b27 |
@@ -9,7 +9,7 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
|
||||
|
||||
- **Exchanges** — route execution search and detail (`ui/src/pages/Exchanges/`)
|
||||
- **Dashboard** — metrics and stats with L1/L2/L3 drill-down (`ui/src/pages/DashboardTab/`)
|
||||
- **Runtime** — live agent status, logs, commands (`ui/src/pages/RuntimeTab/`)
|
||||
- **Runtime** — live agent status, logs, commands (`ui/src/pages/RuntimeTab/`). AgentHealth supports compact view (dense health-tinted cards) and expanded view (full GroupCard+DataTable per app). View mode persisted to localStorage.
|
||||
- **Deployments** — app management, JAR upload, deployment lifecycle (`ui/src/pages/AppsTab/`)
|
||||
- Config sub-tabs: **Monitoring | Resources | Variables | Traces & Taps | Route Recording**
|
||||
- Create app: full page at `/apps/new` (not a modal)
|
||||
|
||||
@@ -329,6 +329,7 @@ public class AgentRegistrationController {
|
||||
|
||||
// Enrich with runtime metrics from continuous aggregates
|
||||
Map<String, double[]> agentMetrics = queryAgentMetrics();
|
||||
Map<String, Double> cpuByInstance = queryAgentCpuUsage();
|
||||
final List<AgentInfo> finalAgents = agents;
|
||||
|
||||
List<AgentInstanceResponse> response = finalAgents.stream()
|
||||
@@ -341,7 +342,11 @@ public class AgentRegistrationController {
|
||||
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
|
||||
double errorRate = m[1];
|
||||
int activeRoutes = (int) m[2];
|
||||
return dto.withMetrics(agentTps, errorRate, activeRoutes);
|
||||
dto = dto.withMetrics(agentTps, errorRate, activeRoutes);
|
||||
}
|
||||
Double cpu = cpuByInstance.get(a.instanceId());
|
||||
if (cpu != null) {
|
||||
dto = dto.withCpuUsage(cpu);
|
||||
}
|
||||
return dto;
|
||||
})
|
||||
@@ -377,6 +382,27 @@ public class AgentRegistrationController {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Query average CPU usage per agent instance over the last 2 minutes. */
|
||||
private Map<String, Double> queryAgentCpuUsage() {
|
||||
Map<String, Double> result = new HashMap<>();
|
||||
Instant now = Instant.now();
|
||||
Instant from2m = now.minus(2, ChronoUnit.MINUTES);
|
||||
try {
|
||||
jdbc.query(
|
||||
"SELECT instance_id, avg(metric_value) AS cpu_avg " +
|
||||
"FROM agent_metrics " +
|
||||
"WHERE metric_name = 'process.cpu.usage.value'" +
|
||||
" AND collected_at >= " + lit(from2m) + " AND collected_at < " + lit(now) +
|
||||
" GROUP BY instance_id",
|
||||
rs -> {
|
||||
result.put(rs.getString("instance_id"), rs.getDouble("cpu_avg"));
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not query agent CPU usage: {}", e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Format an Instant as a ClickHouse DateTime literal. */
|
||||
private static String lit(Instant instant) {
|
||||
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
@@ -25,7 +25,8 @@ public record AgentInstanceResponse(
|
||||
double errorRate,
|
||||
int activeRoutes,
|
||||
int totalRoutes,
|
||||
long uptimeSeconds
|
||||
long uptimeSeconds,
|
||||
@Schema(description = "Recent average CPU usage (0.0–1.0), -1 if unavailable") double cpuUsage
|
||||
) {
|
||||
public static AgentInstanceResponse from(AgentInfo info) {
|
||||
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
|
||||
@@ -37,7 +38,7 @@ public record AgentInstanceResponse(
|
||||
info.version(), info.capabilities(),
|
||||
0.0, 0.0,
|
||||
0, info.routeIds() != null ? info.routeIds().size() : 0,
|
||||
uptime
|
||||
uptime, -1
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +47,16 @@ public record AgentInstanceResponse(
|
||||
instanceId, displayName, applicationId, environmentId,
|
||||
status, routeIds, registeredAt, lastHeartbeat,
|
||||
version, capabilities,
|
||||
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
|
||||
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds, cpuUsage
|
||||
);
|
||||
}
|
||||
|
||||
public AgentInstanceResponse withCpuUsage(double cpuUsage) {
|
||||
return new AgentInstanceResponse(
|
||||
instanceId, displayName, applicationId, environmentId,
|
||||
status, routeIds, registeredAt, lastHeartbeat,
|
||||
version, capabilities,
|
||||
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds, cpuUsage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
2
ui/src/api/schema.d.ts
vendored
2
ui/src/api/schema.d.ts
vendored
@@ -2065,6 +2065,8 @@ export interface components {
|
||||
totalRoutes: number;
|
||||
/** Format: int64 */
|
||||
uptimeSeconds: number;
|
||||
/** Format: double */
|
||||
cpuUsage: number;
|
||||
};
|
||||
SseEmitter: {
|
||||
/** Format: int64 */
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
/* Stat strip */
|
||||
.statStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr) auto;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -99,6 +99,7 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Group meta row */
|
||||
@@ -291,6 +292,12 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compactCardName:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--running);
|
||||
}
|
||||
|
||||
.compactCardChevron {
|
||||
@@ -309,6 +316,16 @@
|
||||
color: var(--card-accent);
|
||||
}
|
||||
|
||||
.compactCardTps {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.compactCardCpu {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.compactCardHeartbeat {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -317,25 +334,53 @@
|
||||
color: var(--card-accent);
|
||||
}
|
||||
|
||||
/* Expanded card inside compact grid */
|
||||
/* Wrapper for each compact grid cell — anchor for overlay */
|
||||
.compactGridCell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Expanded card overlay — floats from the clicked card */
|
||||
.compactGridExpanded {
|
||||
grid-column: span 2;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
top: 0;
|
||||
left: 0;
|
||||
min-width: 500px;
|
||||
filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
/* Click-outside backdrop for expanded overlay */
|
||||
.overlayBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
/* Expand/collapse animation wrapper */
|
||||
.expandWrapper {
|
||||
overflow: hidden;
|
||||
transition: max-height 200ms ease, opacity 150ms ease;
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.expandWrapperCollapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transform: scaleY(0.95);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.expandWrapperExpanded {
|
||||
max-height: 1000px;
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
/* View toolbar — between stat strip and cards grid */
|
||||
.viewToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* View mode toggle */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
@@ -52,6 +52,7 @@ interface AppGroup {
|
||||
totalTps: number;
|
||||
totalActiveRoutes: number;
|
||||
totalRoutes: number;
|
||||
maxCpu: number;
|
||||
}
|
||||
|
||||
function groupByApp(agentList: AgentInstance[]): AppGroup[] {
|
||||
@@ -71,6 +72,7 @@ function groupByApp(agentList: AgentInstance[]): AppGroup[] {
|
||||
totalTps: instances.reduce((s, i) => s + (i.tps ?? 0), 0),
|
||||
totalActiveRoutes: instances.reduce((s, i) => s + (i.activeRoutes ?? 0), 0),
|
||||
totalRoutes: instances.reduce((s, i) => s + (i.totalRoutes ?? 0), 0),
|
||||
maxCpu: Math.max(...instances.map((i) => (i as AgentInstance & { cpuUsage?: number }).cpuUsage ?? -1)),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -80,6 +82,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 +108,54 @@ const LOG_SOURCE_ITEMS: ButtonGroupItem[] = [
|
||||
{ value: 'container', label: 'Container' },
|
||||
];
|
||||
|
||||
function CompactAppCard({ group, onExpand, onNavigate }: { group: AppGroup; onExpand: () => void; onNavigate: () => 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}
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(); }}
|
||||
role="link"
|
||||
>
|
||||
{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={styles.compactCardTps}>
|
||||
{group.totalTps.toFixed(1)} tps
|
||||
</span>
|
||||
{group.maxCpu >= 0 && (
|
||||
<span className={styles.compactCardCpu}>
|
||||
{(group.maxCpu * 100).toFixed(0)}% cpu
|
||||
</span>
|
||||
)}
|
||||
<span className={isHealthy ? styles.compactCardHeartbeat : styles.compactCardHeartbeatWarn}>
|
||||
{heartbeat ? timeAgo(heartbeat) : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AgentHealth page ─────────────────────────────────────────────────────────
|
||||
|
||||
export default function AgentHealth() {
|
||||
@@ -135,6 +195,57 @@ export default function AgentHealth() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [animatingApps, setAnimatingApps] = useState<Map<string, 'expanding' | 'collapsing'>>(new Map());
|
||||
const animationTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const animateToggle = useCallback((appIdToToggle: string) => {
|
||||
// Clear any existing timer for this app
|
||||
const existing = animationTimers.current.get(appIdToToggle);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
const isCurrentlyExpanded = expandedApps.has(appIdToToggle);
|
||||
|
||||
if (isCurrentlyExpanded) {
|
||||
// Collapsing: start animation, then remove from expandedApps after transition
|
||||
setAnimatingApps((prev) => new Map(prev).set(appIdToToggle, 'collapsing'));
|
||||
const timer = setTimeout(() => {
|
||||
setExpandedApps((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(appIdToToggle);
|
||||
return next;
|
||||
});
|
||||
setAnimatingApps((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(appIdToToggle);
|
||||
return next;
|
||||
});
|
||||
animationTimers.current.delete(appIdToToggle);
|
||||
}, 200);
|
||||
animationTimers.current.set(appIdToToggle, timer);
|
||||
} else {
|
||||
// Expanding: add to expandedApps immediately, animate in
|
||||
setExpandedApps((prev) => new Set(prev).add(appIdToToggle));
|
||||
setAnimatingApps((prev) => new Map(prev).set(appIdToToggle, 'expanding'));
|
||||
// Use requestAnimationFrame to ensure the collapsed state renders first
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setAnimatingApps((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(appIdToToggle);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [expandedApps]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
animationTimers.current.forEach((timer) => clearTimeout(timer));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [configEditing, setConfigEditing] = useState(false);
|
||||
const [configDraft, setConfigDraft] = useState<Record<string, string | boolean>>({});
|
||||
|
||||
@@ -194,7 +305,7 @@ export default function AgentHealth() {
|
||||
|
||||
const agentList = agents ?? [];
|
||||
|
||||
const groups = useMemo(() => groupByApp(agentList), [agentList]);
|
||||
const groups = useMemo(() => groupByApp(agentList).sort((a, b) => a.appId.localeCompare(b.appId)), [agentList]);
|
||||
|
||||
// Aggregate stats
|
||||
const totalInstances = agentList.length;
|
||||
@@ -420,22 +531,6 @@ export default function AgentHealth() {
|
||||
accent={deadCount > 0 ? 'error' : 'success'}
|
||||
detail={deadCount > 0 ? 'requires attention' : 'all healthy'}
|
||||
/>
|
||||
<div className={styles.viewToggle}>
|
||||
<button
|
||||
className={`${styles.viewToggleBtn} ${viewMode === 'compact' ? styles.viewToggleBtnActive : ''}`}
|
||||
onClick={() => toggleViewMode('compact')}
|
||||
title="Compact view"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.viewToggleBtn} ${viewMode === 'expanded' ? styles.viewToggleBtnActive : ''}`}
|
||||
onClick={() => toggleViewMode('expanded')}
|
||||
title="Expanded view"
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Application config bar */}
|
||||
@@ -550,60 +645,184 @@ 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>
|
||||
{group.maxCpu >= 0 && <span><strong>{(group.maxCpu * 100).toFixed(0)}%</strong> cpu</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.viewToolbar}>
|
||||
<div className={styles.viewToggle}>
|
||||
<button
|
||||
className={`${styles.viewToggleBtn} ${styles.viewToggleBtnActive}`}
|
||||
onClick={() => toggleViewMode('compact')}
|
||||
title="Compact view"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={styles.viewToggleBtn}
|
||||
onClick={() => toggleViewMode('expanded')}
|
||||
title="Expanded view"
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.compactGrid}>
|
||||
{groups.map((group) => (
|
||||
<div key={group.appId} className={styles.compactGridCell}>
|
||||
<CompactAppCard
|
||||
group={group}
|
||||
onExpand={() => animateToggle(group.appId)}
|
||||
onNavigate={() => navigate(`/runtime/${group.appId}`)}
|
||||
/>
|
||||
{expandedApps.has(group.appId) && (
|
||||
<>
|
||||
<div className={styles.overlayBackdrop} onClick={() => animateToggle(group.appId)} />
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (!el) return;
|
||||
// Constrain overlay within viewport
|
||||
const rect = el.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
if (rect.right > vw - 16) {
|
||||
el.style.left = 'auto';
|
||||
el.style.right = '0';
|
||||
}
|
||||
if (rect.bottom > document.documentElement.clientHeight) {
|
||||
const overflow = rect.bottom - document.documentElement.clientHeight + 16;
|
||||
el.style.maxHeight = `${rect.height - overflow}px`;
|
||||
el.style.overflowY = 'auto';
|
||||
}
|
||||
}}
|
||||
className={`${styles.compactGridExpanded} ${styles.expandWrapper} ${
|
||||
animatingApps.get(group.appId) === 'expanding'
|
||||
? styles.expandWrapperCollapsed
|
||||
: animatingApps.get(group.appId) === 'collapsing'
|
||||
? styles.expandWrapperCollapsed
|
||||
: styles.expandWrapperExpanded
|
||||
}`}
|
||||
>
|
||||
<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={(e) => { e.stopPropagation(); animateToggle(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>
|
||||
{group.maxCpu >= 0 && <span><strong>{(group.maxCpu * 100).toFixed(0)}%</strong> cpu</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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Log + Timeline side by side */}
|
||||
<div className={styles.bottomRow}>
|
||||
|
||||
Reference in New Issue
Block a user