Group monitoring-app resources into one Node Status tile per integration

Integrations whose resources represent many sub-items (Uptime Kuma's
monitors) now collapse into a single tile using the Uptime Kuma CDN
icon, instead of flooding Node Status with one tile per monitor.
Selecting that tile lists every underlying monitor's status in a
scrollable Node Detail panel, so hundreds of monitors stay manageable.

Also drops the temporary debug logging added while diagnosing the
listener-timing bug, now that real monitor/heartbeat data confirmed
coming through.
This commit is contained in:
Claude 2026-06-21 13:00:50 +00:00
parent 0b9acd32a5
commit d50ec0076e
No known key found for this signature in database
2 changed files with 98 additions and 13 deletions

View file

@ -99,11 +99,6 @@ export const uptimeKuma: IntegrationAdapter = {
// single "done" signal, so give it a short window to arrive.
await new Promise((resolve) => setTimeout(resolve, 2500))
console.log(`[uptimeKuma] received ${monitors.size} monitor(s), ${lastHeartbeat.size} heartbeat(s)`)
for (const m of monitors.values()) {
console.log(`[uptimeKuma] monitor ${m.id} "${m.name}" active=${m.active} heartbeat=${JSON.stringify(lastHeartbeat.get(m.id))}`)
}
const resources: Resource[] = []
for (const monitor of monitors.values()) {
if (!monitor.active) continue

View file

@ -68,6 +68,25 @@ const nodeStatusColor: Record<string, string> = {
const integrationPalette = ['#C8A434', '#E67E22', '#2ECC71', '#7A7D85', '#3B82F6', '#8B5E3C']
// Kinds that represent a monitoring/aggregator app with many sub-items (e.g. Uptime
// Kuma's individual monitors) collapse into one tile per integration on Node Status,
// with the underlying items shown in Node Detail once selected.
const groupedKinds = new Set(['app'])
const cdnIconByIntegrationKind: Record<string, string> = {
app: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/uptime-kuma.png',
}
interface NodeGroup {
isGroup: true
integration: string
kind: string
status: Resource['status']
members: Resource[]
}
type NodeTile = Resource | NodeGroup
const kindIcon: Record<string, LucideIcon> = {
vm: MonitorSmartphone,
container: Box,
@ -116,7 +135,7 @@ export default function Infrastructure() {
const [resources, setResources] = useState<Resource[] | null>(null)
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
const [events, setEvents] = useState<Event[] | null>(null)
const [selectedNode, setSelectedNode] = useState<Resource | null>(null)
const [selectedNode, setSelectedNode] = useState<NodeTile | null>(null)
const navigate = useNavigate()
useEffect(() => {
@ -144,6 +163,37 @@ export default function Infrastructure() {
return Array.from(byIntegration.entries()).map(([name, value], i) => ({ name, value, color: integrationPalette[i % integrationPalette.length] }))
}, [resources])
// Kinds like Uptime Kuma's monitors can number in the hundreds — collapse them into
// one tile per integration on Node Status, with members listed in Node Detail.
const nodeTiles = useMemo<NodeTile[]>(() => {
if (!resources) return []
const groups = new Map<string, Resource[]>()
const singles: Resource[] = []
for (const r of resources) {
if (r.kind && groupedKinds.has(r.kind)) {
const arr = groups.get(r.integration) ?? []
arr.push(r)
groups.set(r.integration, arr)
} else {
singles.push(r)
}
}
const groupTiles: NodeGroup[] = Array.from(groups.entries()).map(([integration, members]) => ({
isGroup: true,
integration,
kind: members[0]?.kind ?? '',
status: members.some((m) => m.status === 'critical')
? 'critical'
: members.some((m) => m.status === 'warning')
? 'warning'
: members.every((m) => m.status === 'healthy')
? 'healthy'
: 'unknown',
members,
}))
return [...singles, ...groupTiles]
}, [resources])
return (
<>
{/* Sub-tabs + Add Resource — hero banner is rendered at the layout level behind this */}
@ -254,14 +304,17 @@ export default function Infrastructure() {
<div style={cardVignette} />
<div className="relative z-10 flex min-h-0 flex-1 flex-col">
<h3 style={sectionTitle}>Node Status</h3>
{resources && resources.length > 0 ? (
{nodeTiles.length > 0 ? (
<div className="scrollbar-ghost grid min-h-0 flex-1 grid-cols-5 content-start gap-2" style={{ overflowY: 'auto' }}>
{resources.map((node, i) => {
{nodeTiles.map((node, i) => {
const cdnIcon = cdnIconByIntegrationKind[node.kind ?? '']
const NodeIcon = kindIcon[node.kind ?? ''] ?? Server
const label = 'isGroup' in node ? node.integration : node.name
const tooltip = 'isGroup' in node ? `${node.integration}: ${node.members.length} monitored` : `${node.name}: ${node.detail ?? node.status}`
return (
<div
key={i}
title={`${node.name}: ${node.detail ?? node.status}`}
title={tooltip}
onClick={() => setSelectedNode(node)}
className="cursor-pointer transition-colors"
style={{
@ -276,9 +329,13 @@ export default function Infrastructure() {
>
<div className="flex items-center gap-1.5">
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: nodeStatusColor[node.status], boxShadow: `0 0 6px ${nodeStatusColor[node.status]}` }} />
{cdnIcon ? (
<img src={cdnIcon} width={12} height={12} style={{ borderRadius: '2px' }} alt="" />
) : (
<NodeIcon size={12} style={{ color: '#7A7D85' }} />
)}
</div>
<span style={{ fontSize: '11px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{node.name}</span>
<span style={{ fontSize: '11px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
</div>
)
})}
@ -322,9 +379,42 @@ export default function Infrastructure() {
{/* Node Detail — shows the node selected up in Node Status */}
<div style={{ ...cardBase, height: 'auto' }} className="hover:!border-gold/15">
<div className="relative z-10">
<div className="relative z-10 flex flex-col" style={{ maxHeight: '220px' }}>
<h3 style={sectionTitle}>Node Detail</h3>
{selectedNode ? (
{selectedNode && 'isGroup' in selectedNode ? (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-2">
<span
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: nodeStatusColor[selectedNode.status],
boxShadow: `0 0 6px ${nodeStatusColor[selectedNode.status]}`,
}}
/>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#E8E6E0' }}>{selectedNode.integration}</span>
<span style={{ fontSize: '11px', color: '#7A7D85' }}>({selectedNode.members.length} monitored)</span>
</div>
<div className="scrollbar-ghost flex flex-col gap-1.5" style={{ overflowY: 'auto' }}>
{selectedNode.members.map((m, i) => (
<div key={i} className="flex items-center gap-2" style={{ fontSize: '11.5px' }}>
<span
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: nodeStatusColor[m.status],
flexShrink: 0,
}}
/>
<span style={{ color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.name}</span>
<span style={{ color: '#7A7D85', marginLeft: 'auto', flexShrink: 0 }}>{m.detail ?? m.status}</span>
</div>
))}
</div>
</div>
) : selectedNode ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span