Skip to content

Commit ad82c75

Browse files
committed
feat: add useSkeletonTiming hook and FacilityDashboard with skeleton integration
1 parent 14e29e1 commit ad82c75

3 files changed

Lines changed: 289 additions & 0 deletions

File tree

app/dashboard/facility/page.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
'use client'
2+
3+
import { FacilityDashboard } from '@/src/pages/monitor/FacilityDashboard'
4+
5+
export default function FacilityDashboardPage() {
6+
return <FacilityDashboard />
7+
}

src/hooks/useSkeletonTiming.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use client'
2+
3+
import { useEffect, useRef, useState } from 'react'
4+
5+
const MIN_DISPLAY_MS = 300
6+
const MAX_DISPLAY_MS = 10_000
7+
8+
export function useSkeletonTiming(
9+
getReady: () => boolean,
10+
subscribe: (listener: () => void) => () => void,
11+
): { showSkeleton: boolean; timedOut: boolean } {
12+
const [showSkeleton, setShowSkeleton] = useState(true)
13+
const [timedOut, setTimedOut] = useState(false)
14+
15+
const dataReadyRef = useRef(false)
16+
const startTimeRef = useRef(Date.now())
17+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
18+
const maxTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
19+
const getReadyRef = useRef(getReady)
20+
const subscribeRef = useRef(subscribe)
21+
getReadyRef.current = getReady
22+
subscribeRef.current = subscribe
23+
24+
useEffect(() => {
25+
const maxTimer = setTimeout(() => {
26+
if (!dataReadyRef.current) {
27+
setTimedOut(true)
28+
}
29+
}, MAX_DISPLAY_MS)
30+
maxTimerRef.current = maxTimer
31+
32+
const unsub = subscribeRef.current(() => {
33+
if (dataReadyRef.current) return
34+
35+
const ready = getReadyRef.current()
36+
if (ready) {
37+
dataReadyRef.current = true
38+
clearTimeout(maxTimer)
39+
40+
const elapsed = Date.now() - startTimeRef.current
41+
const remaining = Math.max(0, MIN_DISPLAY_MS - elapsed)
42+
43+
timerRef.current = setTimeout(() => {
44+
setShowSkeleton(false)
45+
}, remaining)
46+
}
47+
})
48+
49+
if (getReadyRef.current()) {
50+
dataReadyRef.current = true
51+
clearTimeout(maxTimer)
52+
const elapsed = Date.now() - startTimeRef.current
53+
const remaining = Math.max(0, MIN_DISPLAY_MS - elapsed)
54+
timerRef.current = setTimeout(() => {
55+
setShowSkeleton(false)
56+
}, remaining)
57+
}
58+
59+
return () => {
60+
unsub()
61+
if (timerRef.current) clearTimeout(timerRef.current)
62+
if (maxTimerRef.current) clearTimeout(maxTimerRef.current)
63+
}
64+
}, [])
65+
66+
return { showSkeleton, timedOut }
67+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import { create } from 'zustand'
5+
import { NodeList } from '@/src/components/network/NodeList'
6+
import { AlertFeed } from '@/src/components/dashboard/AlertFeed'
7+
import { SkeletonCard } from '@/src/components/skeleton/SkeletonCard'
8+
import { SkeletonChart } from '@/src/components/skeleton/SkeletonChart'
9+
import { useSkeletonTiming } from '@/src/hooks/useSkeletonTiming'
10+
import type { NodePosition } from '@/src/types/network'
11+
12+
interface DashboardStore {
13+
nodesReady: boolean
14+
alertsReady: boolean
15+
metricsReady: boolean
16+
setNodesReady: () => void
17+
setAlertsReady: () => void
18+
setMetricsReady: () => void
19+
}
20+
21+
const useDashboardStore = create<DashboardStore>((set) => ({
22+
nodesReady: false,
23+
alertsReady: false,
24+
metricsReady: false,
25+
setNodesReady: () => set({ nodesReady: true }),
26+
setAlertsReady: () => set({ alertsReady: true }),
27+
setMetricsReady: () => set({ metricsReady: true }),
28+
}))
29+
30+
const MOCK_NODES: NodePosition[] = Array.from({ length: 12 }, (_, i) => ({
31+
id: `node-${i + 1}`,
32+
x: Math.random() * 800,
33+
y: Math.random() * 600,
34+
z: Math.random() * 100,
35+
label: `Validator ${String.fromCharCode(65 + i)}`,
36+
color: ['#0f766e', '#9a3412', '#2563eb', '#7c3aed', '#db2777', '#ca8a04'][i % 6],
37+
metadata: {
38+
description: `Network node ${i + 1}`,
39+
location: ['us-east', 'eu-west', 'ap-southeast', 'us-west', 'eu-central', 'ap-northeast'][i % 6],
40+
ownerName: `Org ${String.fromCharCode(65 + i)}`,
41+
firmwareVersion: `v${Math.floor(Math.random() * 5)}.${Math.floor(Math.random() * 10)}`,
42+
hardwareModel: ['X1', 'P2', 'Z3', 'Q4'][i % 4],
43+
ipAddress: `10.0.${Math.floor(i / 4)}.${(i % 4) * 64 + 1}`,
44+
uptime: `${Math.floor(Math.random() * 365)}d ${Math.floor(Math.random() * 24)}h`,
45+
},
46+
}))
47+
48+
function NodeSectionSkeleton() {
49+
return (
50+
<div className="rounded-lg border border-[#d8d0c1] bg-white">
51+
<div className="flex items-center justify-between border-b border-[#d8d0c1] px-5 py-3">
52+
<div>
53+
<div className="skeleton skeleton-text skeleton-text--lg" style={{ width: 60, height: 16, margin: 0 }} />
54+
<div className="skeleton skeleton-text skeleton-text--sm" style={{ width: 100, height: 12, margin: '4px 0 0' }} />
55+
</div>
56+
</div>
57+
<div className="space-y-2 p-3">
58+
{Array.from({ length: 4 }, (_, i) => (
59+
<SkeletonCard key={i} variant="node" />
60+
))}
61+
</div>
62+
</div>
63+
)
64+
}
65+
66+
function AlertSectionSkeleton() {
67+
return (
68+
<div>
69+
<div className="flex items-center gap-2 mb-3">
70+
<div className="skeleton skeleton-text skeleton-text--md" style={{ width: 80, height: 16, margin: 0 }} />
71+
<div className="skeleton" style={{ width: 24, height: 20, borderRadius: 9999 }} />
72+
</div>
73+
{Array.from({ length: 3 }, (_, i) => (
74+
<div key={i} className="mb-3">
75+
<SkeletonCard variant="alert" />
76+
</div>
77+
))}
78+
</div>
79+
)
80+
}
81+
82+
export function FacilityDashboard() {
83+
const [nodesData, setNodesData] = useState<NodePosition[] | null>(null)
84+
const setNodesReady = useDashboardStore((s) => s.setNodesReady)
85+
const setAlertsReady = useDashboardStore((s) => s.setAlertsReady)
86+
const setMetricsReady = useDashboardStore((s) => s.setMetricsReady)
87+
88+
const nodesSkeleton = useSkeletonTiming(
89+
() => useDashboardStore.getState().nodesReady,
90+
useDashboardStore.subscribe,
91+
)
92+
const alertsSkeleton = useSkeletonTiming(
93+
() => useDashboardStore.getState().alertsReady,
94+
useDashboardStore.subscribe,
95+
)
96+
const metricsSkeleton = useSkeletonTiming(
97+
() => useDashboardStore.getState().metricsReady,
98+
useDashboardStore.subscribe,
99+
)
100+
101+
useEffect(() => {
102+
const nodesTimer = setTimeout(() => {
103+
setNodesData(MOCK_NODES)
104+
setNodesReady()
105+
}, 800)
106+
const alertsTimer = setTimeout(() => {
107+
setAlertsReady()
108+
}, 1200)
109+
const metricsTimer = setTimeout(() => {
110+
setMetricsReady()
111+
}, 2000)
112+
return () => {
113+
clearTimeout(nodesTimer)
114+
clearTimeout(alertsTimer)
115+
clearTimeout(metricsTimer)
116+
}
117+
}, [setNodesReady, setAlertsReady, setMetricsReady])
118+
119+
return (
120+
<main className="min-h-screen bg-[#f7f4ee] text-[#171512]">
121+
<div className="mx-auto flex min-h-screen w-full max-w-7xl flex-col px-5 py-5 sm:px-8 lg:px-10">
122+
<header className="flex flex-col gap-4 border-b border-[#d8d0c1] pb-5 sm:flex-row sm:items-center sm:justify-between">
123+
<div>
124+
<p className="text-sm font-semibold uppercase tracking-[0.22em] text-[#6f5f48]">
125+
Facility Monitor
126+
</p>
127+
<h1 className="mt-2 text-3xl font-semibold text-[#171512] sm:text-4xl">
128+
Network Dashboard
129+
</h1>
130+
</div>
131+
</header>
132+
133+
<div className="grid grid-cols-1 gap-6 py-6 lg:grid-cols-3">
134+
<section className="lg:col-span-2">
135+
<h2 className="sr-only">Node List</h2>
136+
{nodesSkeleton.showSkeleton ? (
137+
<>
138+
{nodesSkeleton.timedOut && (
139+
<div className="skeleton-timeout">
140+
<span>Taking longer than expected</span>
141+
</div>
142+
)}
143+
<NodeSectionSkeleton />
144+
</>
145+
) : (
146+
<div className="skeleton-fade-active">
147+
<NodeList nodes={nodesData ?? []} maxDisplay={10} />
148+
</div>
149+
)}
150+
</section>
151+
152+
<section className="lg:col-span-1">
153+
<h2 className="sr-only">Alerts</h2>
154+
{alertsSkeleton.showSkeleton ? (
155+
<>
156+
{alertsSkeleton.timedOut && (
157+
<div className="skeleton-timeout">
158+
<span>Taking longer than expected</span>
159+
</div>
160+
)}
161+
<AlertSectionSkeleton />
162+
</>
163+
) : (
164+
<div className="skeleton-fade-active">
165+
<AlertFeed />
166+
</div>
167+
)}
168+
</section>
169+
</div>
170+
171+
<section>
172+
<h2 className="text-lg font-semibold text-[#171512] mb-4">Network Metrics</h2>
173+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
174+
{['Latency', 'Throughput', 'Packet Loss', 'Uptime'].map((metric) => (
175+
<div key={metric}>
176+
{metricsSkeleton.showSkeleton ? (
177+
<SkeletonCard variant="metric" />
178+
) : (
179+
<div className="skeleton-fade-active rounded-lg border border-[#d8d0c1] bg-white p-4">
180+
<p className="text-xs font-semibold uppercase tracking-wider text-[#6f5f48]">
181+
{metric}
182+
</p>
183+
<p className="mt-2 text-2xl font-semibold text-[#171512]">
184+
{metric === 'Latency' && '24ms'}
185+
{metric === 'Throughput' && '1,247/s'}
186+
{metric === 'Packet Loss' && '0.02%'}
187+
{metric === 'Uptime' && '99.97%'}
188+
</p>
189+
<p className="mt-1 text-xs text-[#6f5f48]">
190+
{metric === 'Latency' && '12ms avg'}
191+
{metric === 'Throughput' && 'Peak: 2,100/s'}
192+
{metric === 'Packet Loss' && 'Last 24h'}
193+
{metric === 'Uptime' && '30d rolling'}
194+
</p>
195+
</div>
196+
)}
197+
</div>
198+
))}
199+
</div>
200+
201+
<div className="mt-6">
202+
{metricsSkeleton.showSkeleton ? (
203+
<SkeletonChart bars={16} height={220} />
204+
) : (
205+
<div className="skeleton-fade-active rounded-lg border border-[#d8d0c1] bg-white p-4">
206+
<h3 className="text-sm font-semibold text-[#171512] mb-4">Throughput Over Time</h3>
207+
<SkeletonChart bars={16} height={220} />
208+
</div>
209+
)}
210+
</div>
211+
</section>
212+
</div>
213+
</main>
214+
)
215+
}

0 commit comments

Comments
 (0)