Skip to content

Commit 6c05553

Browse files
feat(protocol-dashboard): show validator stats from /health-check and link to /console (#14376)
Mirrors [OpenAudio/staking#2](OpenAudio/staking#2) on the protocol dashboard — same code, applied here since the NodeOverview / useNodeHealth components are nearly identical between the two repos. ## Summary - Replaces the old uptime/disk/db health rows on the validator node page with stats parsed from the validator `/health-check` endpoint: **Node Type**, **Peers** (recursive count over nested groupings like `inbound`/`outbound`), **Current Height**, **Storage Type**, **Last Restart** (computed from `timestamp - uptime`), and **Git SHA** (short, full on hover). - Adds a **View Console** button on the validator overview that opens `${endpoint}/console` in a new tab. - Drops the `IndividualNodeUptimeChart` section for validators only. Content Node and Discovery Node pages are unchanged. - Guards the `ServiceTable` country flag against missing or non-ISO-3166 alpha-2 codes (validators may not report a country) — renders a neutral fallback instead. ### Implementation notes vs. the staking PR - The View Console button reuses the existing `styles.modifyBtn` / `styles.modifyBtnText` classes (matching the Manage Node button) instead of the staking app's global `gradient-button manageNodeButton` classes, which don't exist in this repo. ## Test plan - [ ] Navigate to a validator node page — confirm the six new stats render and the View Console button opens the validator's console in a new tab. - [ ] Confirm Peers count matches reality on a node whose `core.peers` is grouped (e.g. inbound/outbound) — recursion sums across nested groups. - [ ] Confirm Content Node and Discovery Node pages are visually unchanged (still show disk/db/peer-reachability/uptime chart). - [ ] Confirm a validator with no `core.peers` shows `0` rather than blank. - [ ] Confirm fetch failure renders the "Failed to fetch health data" row instead of an empty section. - [ ] Confirm the ServiceTable falls back to a neutral 🏁 icon for rows whose `country` is missing or not a 2-letter code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c5a95ac commit 6c05553

4 files changed

Lines changed: 214 additions & 30 deletions

File tree

packages/protocol-dashboard/src/components/NodeOverview/NodeOverview.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,14 @@ const messages = {
5252
checkDiskWarning: 'Check disk/mount',
5353
seedingDataText: 'Awaiting completion',
5454
uptimeWarning: 'Failed to determine when the server last restarted',
55-
pending: 'Loading...'
55+
pending: 'Loading...',
56+
nodeType: 'Node Type',
57+
peers: 'Peers',
58+
currentHeight: 'Current Height',
59+
storageType: 'Storage Type',
60+
lastRestart: 'Last Restart',
61+
gitSha: 'Git SHA',
62+
viewConsole: 'View Console'
5663
}
5764

5865
type ServiceDetailProps = {
@@ -118,6 +125,7 @@ const NodeOverview = ({
118125
}: NodeOverviewProps) => {
119126
const { isOpen, onClick, onClose } = useModalControls()
120127
const { health, status, error } = useNodeHealth(endpoint, serviceType)
128+
const isValidator = serviceType === ServiceType.Validator
121129

122130
let healthDetails = null
123131
if (status === 'pending') {
@@ -139,6 +147,48 @@ const NodeOverview = ({
139147
}
140148
/>
141149
)
150+
} else if (isValidator) {
151+
healthDetails = (
152+
<>
153+
{health?.nodeType && (
154+
<ServiceDetail label={messages.nodeType} value={health.nodeType} />
155+
)}
156+
{typeof health?.peerCount === 'number' && (
157+
<ServiceDetail label={messages.peers} value={health.peerCount} />
158+
)}
159+
{typeof health?.currentHeight === 'number' && (
160+
<ServiceDetail
161+
label={messages.currentHeight}
162+
value={health.currentHeight.toLocaleString()}
163+
/>
164+
)}
165+
{health?.storageType && (
166+
<ServiceDetail
167+
label={messages.storageType}
168+
value={health.storageType}
169+
/>
170+
)}
171+
{health?.startedAt && (
172+
<ServiceDetail
173+
label={messages.lastRestart}
174+
value={
175+
<TextWithIcon
176+
icon={<>{timeSince(health.startedAt)}</>}
177+
text={`ago (${health.startedAt.toLocaleString()})`}
178+
/>
179+
}
180+
/>
181+
)}
182+
{health?.gitSha && (
183+
<ServiceDetail
184+
label={messages.gitSha}
185+
value={
186+
<span title={health.gitSha}>{health.gitSha.slice(0, 7)}</span>
187+
}
188+
/>
189+
)}
190+
</>
191+
)
142192
} else {
143193
healthDetails = (
144194
<>
@@ -320,6 +370,23 @@ const NodeOverview = ({
320370
direction='column'
321371
alignItems='flex-end'
322372
>
373+
{isValidator && endpoint && !isDeregistered && (
374+
<Box>
375+
<Button
376+
onClick={() =>
377+
window.open(
378+
`${endpoint.replace(/\/$/, '')}/console`,
379+
'_blank',
380+
'noopener,noreferrer'
381+
)
382+
}
383+
type={ButtonType.PRIMARY}
384+
text={messages.viewConsole}
385+
className={clsx(styles.modifyBtn)}
386+
textClassName={styles.modifyBtnText}
387+
/>
388+
</Box>
389+
)}
323390
{!isDeregistered && isUnregistered && (
324391
<Box>
325392
<Button

packages/protocol-dashboard/src/components/ServiceTable/ServiceTable.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,16 @@ const ServiceTable: React.FC<ServiceTableProps> = ({
8080
return (
8181
<div className={styles.rowContainer}>
8282
<div className={clsx(styles.rowCol, styles.colEndpoint)}>
83-
<ReactCountryFlag
84-
className={styles.countryFlag}
85-
countryCode={data.country}
86-
/>
83+
{data.country && /^[A-Za-z]{2}$/.test(data.country) ? (
84+
<ReactCountryFlag
85+
className={styles.countryFlag}
86+
countryCode={data.country}
87+
/>
88+
) : (
89+
<span className={styles.countryFlag} aria-label='Unknown location'>
90+
🏁
91+
</span>
92+
)}
8793
{data.endpoint}
8894
</div>
8995
<div className={clsx(styles.rowCol, styles.colVersion)}>

packages/protocol-dashboard/src/containers/Node/Node.tsx

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -131,29 +131,19 @@ const Validator: React.FC<ValidatorProps> = ({
131131
const isOwner = accountWallet === validator?.owner
132132

133133
return (
134-
<>
135-
<div className={styles.section}>
136-
<NodeOverview
137-
spID={spID}
138-
serviceType={ServiceType.Validator}
139-
version={validator?.version}
140-
endpoint={validator?.endpoint}
141-
operatorWallet={validator?.owner}
142-
delegateOwnerWallet={validator?.delegateOwnerWallet}
143-
isOwner={isOwner}
144-
isDeregistered={validator?.isDeregistered}
145-
isLoading={status === Status.Loading}
146-
/>
147-
</div>
148-
{validator ? (
149-
<div className={clsx(styles.section, styles.chart)}>
150-
<IndividualNodeUptimeChart
151-
nodeType={ServiceType.Validator}
152-
node={validator.endpoint}
153-
/>
154-
</div>
155-
) : null}
156-
</>
134+
<div className={styles.section}>
135+
<NodeOverview
136+
spID={spID}
137+
serviceType={ServiceType.Validator}
138+
version={validator?.version}
139+
endpoint={validator?.endpoint}
140+
operatorWallet={validator?.owner}
141+
delegateOwnerWallet={validator?.delegateOwnerWallet}
142+
isOwner={isOwner}
143+
isDeregistered={validator?.isDeregistered}
144+
isLoading={status === Status.Loading}
145+
/>
146+
</div>
157147
)
158148
}
159149

packages/protocol-dashboard/src/hooks/useNodeHealth.ts

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { ServiceType } from 'types'
55
const bytesToGb = (bytes: number) => Math.floor(bytes / 10 ** 9)
66

77
const useNodeHealth = (endpoint: string, serviceType: ServiceType) => {
8+
const isValidator = serviceType === ServiceType.Validator
9+
const healthPath = isValidator ? '/health-check' : '/health_check'
810
const { data, status, error } = useQuery({
9-
queryKey: ['health', { endpoint }],
11+
queryKey: ['health', { endpoint, healthPath }],
1012
queryFn: async () => {
11-
const response = await fetch(`${endpoint}/health_check`)
13+
const response = await fetch(`${endpoint}${healthPath}`)
1214
if (!response.ok) {
1315
throw new Error(
1416
`Failed fetching health check from ${endpoint}: ${response.status} ${response.statusText}`
@@ -37,6 +39,125 @@ const useNodeHealth = (endpoint: string, serviceType: ServiceType) => {
3739
return { status, error, health: null }
3840
}
3941

42+
if (isValidator) {
43+
const core = data?.core ?? {}
44+
45+
const hasPrimitiveProps = (o: Record<string, unknown>) =>
46+
Object.values(o).some(
47+
(x) => x === null || typeof x !== 'object' || Array.isArray(x)
48+
)
49+
const countPeers = (val: any): number => {
50+
if (val == null) return 0
51+
if (Array.isArray(val)) {
52+
return val.reduce(
53+
(sum: number, item) =>
54+
sum +
55+
(item && typeof item === 'object' && !Array.isArray(item)
56+
? hasPrimitiveProps(item)
57+
? 1
58+
: countPeers(item)
59+
: 1),
60+
0
61+
)
62+
}
63+
if (typeof val !== 'object') return 0
64+
const subObjects = Object.values(val).filter(
65+
(v): v is Record<string, unknown> =>
66+
v !== null && typeof v === 'object' && !Array.isArray(v)
67+
)
68+
const arrays = Object.values(val).filter((v): v is unknown[] =>
69+
Array.isArray(v)
70+
)
71+
if (subObjects.length === 0 && arrays.length === 0) return 0
72+
if (subObjects.some(hasPrimitiveProps)) {
73+
return subObjects.length + arrays.reduce((s, a) => s + countPeers(a), 0)
74+
}
75+
return (
76+
subObjects.reduce((s, o) => s + countPeers(o), 0) +
77+
arrays.reduce((s, a) => s + countPeers(a), 0)
78+
)
79+
}
80+
const peerCount = countPeers(core?.peers)
81+
82+
const syncInfo = core?.sync_info ?? {}
83+
const findHeight = (obj: any): number | undefined => {
84+
if (!obj || typeof obj !== 'object') return undefined
85+
for (const k of [
86+
'latest_block_height',
87+
'block_height',
88+
'height',
89+
'current_height'
90+
]) {
91+
const v = obj[k]
92+
if (typeof v === 'number') return v
93+
if (typeof v === 'string' && /^\d+$/.test(v)) return Number(v)
94+
}
95+
for (const v of Object.values(obj)) {
96+
const found = findHeight(v)
97+
if (found !== undefined) return found
98+
}
99+
return undefined
100+
}
101+
const currentHeight = findHeight(syncInfo) ?? findHeight(core)
102+
103+
const parseDurationMs = (s: unknown): number | undefined => {
104+
if (typeof s !== 'string') return undefined
105+
let total = 0
106+
const re = /(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g
107+
let match: RegExpExecArray | null
108+
let matched = false
109+
while ((match = re.exec(s)) !== null) {
110+
matched = true
111+
const n = parseFloat(match[1])
112+
switch (match[2]) {
113+
case 'ns':
114+
total += n / 1e6
115+
break
116+
case 'us':
117+
case 'µs':
118+
total += n / 1e3
119+
break
120+
case 'ms':
121+
total += n
122+
break
123+
case 's':
124+
total += n * 1000
125+
break
126+
case 'm':
127+
total += n * 60 * 1000
128+
break
129+
case 'h':
130+
total += n * 60 * 60 * 1000
131+
break
132+
}
133+
}
134+
return matched ? total : undefined
135+
}
136+
let startedAt: Date | undefined
137+
const uptimeMs = parseDurationMs(data?.uptime)
138+
const ts = data?.timestamp ? Date.parse(data.timestamp) : NaN
139+
if (!isNaN(ts) && uptimeMs !== undefined) {
140+
startedAt = new Date(ts - uptimeMs)
141+
}
142+
143+
return {
144+
status,
145+
error: null,
146+
health: {
147+
version: data?.data?.version ?? data?.version,
148+
chainId: core?.chain_info?.chain_id,
149+
nodeType: core?.node_info?.node_type,
150+
ethAddress: core?.node_info?.eth_address ?? data?.signer,
151+
peerCount,
152+
currentHeight,
153+
storageType: core?.storage_info?.storage_type,
154+
startedAt,
155+
gitSha: typeof data?.git === 'string' ? data.git : undefined,
156+
delegateOwnerWallet: data?.signer
157+
}
158+
}
159+
}
160+
40161
const { data: health } = data
41162
let res = {}
42163

0 commit comments

Comments
 (0)