Refactor UptimeStatus component to improve data fetching and user experience
- Removed UpdateTimer component and integrated countdown functionality directly into UptimeStatusIsland. - Implemented sessionStorage caching for system status data to enhance performance and user experience. - Added visual countdown display for the next update, improving user awareness of data refresh timing. - Updated privacy policy to clarify the use of sessionStorage for caching non-personal data.
This commit is contained in:
@@ -1,11 +1,8 @@
|
||||
---
|
||||
import UptimeStatusIsland from './UptimeStatusIsland.jsx';
|
||||
import UpdateTimer from './UpdateTimer.jsx';
|
||||
---
|
||||
<UpdateTimer client:only="react" onRefresh={() => {}} />
|
||||
<UptimeStatusIsland client:only="react" />
|
||||
|
||||
|
||||
<style>
|
||||
/**** Custom grid for 40 heartbeat squares ****/
|
||||
.grid-cols-40 {
|
||||
|
@@ -96,12 +96,81 @@ export default function UptimeStatusIsland() {
|
||||
const badgeRefs = useRef({});
|
||||
const [userZone, setUserZone] = useState(null);
|
||||
const [userLocale, setUserLocale] = useState(null);
|
||||
const [secondsToNextUpdate, setSecondsToNextUpdate] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setUserZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
setUserLocale(navigator.language || 'en-US');
|
||||
}, []);
|
||||
|
||||
const getSecondsToNext5Min = () => {
|
||||
const now = new Date();
|
||||
const minutes = now.getMinutes();
|
||||
const seconds = now.getSeconds();
|
||||
const ms = now.getMilliseconds();
|
||||
const next5 = Math.ceil((minutes + 0.01) / 5) * 5;
|
||||
let next = new Date(now);
|
||||
next.setMinutes(next5, 0, 0);
|
||||
if (next <= now) {
|
||||
next.setMinutes(next.getMinutes() + 5);
|
||||
}
|
||||
return Math.floor((next - now) / 1000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setSecondsToNextUpdate(getSecondsToNext5Min());
|
||||
update();
|
||||
const interval = setInterval(update, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Helper: get the current 5-min window key (e.g., '2024-06-07T12:00')
|
||||
const getCurrent5MinKey = () => {
|
||||
const now = new Date();
|
||||
now.setSeconds(0, 0);
|
||||
const min = now.getMinutes();
|
||||
now.setMinutes(Math.floor(min / 5) * 5);
|
||||
return now.toISOString().slice(0, 16); // 'YYYY-MM-DDTHH:mm'
|
||||
};
|
||||
|
||||
// On mount, check sessionStorage for valid cached data
|
||||
useEffect(() => {
|
||||
const cacheKey = 'uptimeStatusCache';
|
||||
const cache = sessionStorage.getItem(cacheKey);
|
||||
if (cache) {
|
||||
try {
|
||||
const { key, data: cachedData } = JSON.parse(cache);
|
||||
if (key === getCurrent5MinKey() && cachedData) {
|
||||
setData(cachedData);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// If no valid cache, fetch immediately
|
||||
fetchDataImmediate();
|
||||
}, []);
|
||||
|
||||
// Helper: fetch and cache immediately (used on mount if no cache)
|
||||
const fetchDataImmediate = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/uptime');
|
||||
if (!response.ok) throw new Error('Failed to fetch uptime data');
|
||||
const json = await response.json();
|
||||
setData(json);
|
||||
// Cache with current 5-min key
|
||||
const cacheKey = 'uptimeStatusCache';
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent5MinKey(), data: json }));
|
||||
} catch (err) {
|
||||
setError(err.message || 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Scheduled fetch logic (every 5 min, update cache)
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -110,6 +179,9 @@ export default function UptimeStatusIsland() {
|
||||
if (!response.ok) throw new Error('Failed to fetch uptime data');
|
||||
const json = await response.json();
|
||||
setData(json);
|
||||
// Cache with current 5-min key
|
||||
const cacheKey = 'uptimeStatusCache';
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent5MinKey(), data: json }));
|
||||
} catch (err) {
|
||||
setError(err.message || 'Unknown error');
|
||||
} finally {
|
||||
@@ -118,12 +190,28 @@ export default function UptimeStatusIsland() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(); // initial fetch
|
||||
const interval = setInterval(fetchData, 300000); // fetch every 5 minutes
|
||||
return () => clearInterval(interval);
|
||||
const now = new Date();
|
||||
const minutes = now.getMinutes();
|
||||
const seconds = now.getSeconds();
|
||||
const ms = now.getMilliseconds();
|
||||
const next5 = Math.ceil((minutes + 0.01) / 5) * 5;
|
||||
let next = new Date(now);
|
||||
next.setMinutes(next5, 0, 0);
|
||||
if (next <= now) {
|
||||
next.setMinutes(next.getMinutes() + 5);
|
||||
}
|
||||
const msToNext = next - now;
|
||||
const timeout = setTimeout(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 5 * 60 * 1000);
|
||||
fetchData.interval = interval;
|
||||
}, msToNext);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
if (fetchData.interval) clearInterval(fetchData.interval);
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
// Helper to toggle badge info
|
||||
const toggleBadge = (monitorId, badge) => {
|
||||
setOpenBadge((prev) => ({
|
||||
...prev,
|
||||
@@ -134,7 +222,6 @@ export default function UptimeStatusIsland() {
|
||||
}));
|
||||
};
|
||||
|
||||
// Close popup on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e) {
|
||||
let clickedInside = false;
|
||||
@@ -151,20 +238,63 @@ export default function UptimeStatusIsland() {
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
// Helper to get/create refs for each badge
|
||||
const getBadgeRef = (monitorId, badge) => {
|
||||
if (!badgeRefs.current[monitorId]) badgeRefs.current[monitorId] = {};
|
||||
if (!badgeRefs.current[monitorId][badge]) badgeRefs.current[monitorId][badge] = React.createRef();
|
||||
return badgeRefs.current[monitorId][badge];
|
||||
};
|
||||
|
||||
// Only render cards when timezone and locale are available
|
||||
if (!userZone || !userLocale) {
|
||||
return <div className="text-center text-gray-500 dark:text-gray-400">Loading timezone...</div>;
|
||||
const totalBarSeconds = 5 * 60;
|
||||
const barValue = secondsToNextUpdate !== null ? (totalBarSeconds - secondsToNextUpdate) : 0;
|
||||
const barPercent = (barValue / totalBarSeconds) * 100;
|
||||
const barGradient = 'linear-gradient(90deg, #ef4444 0%, #eab308 40%, #22c55e 100%)';
|
||||
|
||||
// Format countdown as 'X min XX sec' or 'XXs'
|
||||
let countdownText = '--';
|
||||
if (typeof secondsToNextUpdate === 'number' && secondsToNextUpdate >= 0) {
|
||||
if (secondsToNextUpdate >= 60) {
|
||||
const min = Math.floor(secondsToNextUpdate / 60);
|
||||
const sec = secondsToNextUpdate % 60;
|
||||
countdownText = `${min} min ${sec.toString().padStart(2, '0')} sec`;
|
||||
} else {
|
||||
countdownText = `${secondsToNextUpdate}s`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ width: '100%', margin: '0 auto', maxWidth: 1600, padding: '24px 0 8px 0' }}>
|
||||
<div style={{ height: 12, borderRadius: 8, background: '#e5e7eb', position: 'relative', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: barGradient,
|
||||
borderRadius: 8,
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
style={{
|
||||
width: `${100 - barPercent}%`,
|
||||
height: '100%',
|
||||
background: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
transition: 'width 0.5s cubic-bezier(0.4,0,0.2,1)',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', color: '#a3a3a3', fontWeight: 600, marginTop: 8, fontSize: 18 }}>
|
||||
Next update in {countdownText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-10 md:grid-cols-2">
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">Loading...</div>
|
||||
@@ -177,23 +307,18 @@ export default function UptimeStatusIsland() {
|
||||
<ul className="space-y-6">
|
||||
{group.monitorList.map((monitor) => (
|
||||
<li key={monitor.id} className="flex flex-col gap-2 bg-gray-50 dark:bg-slate-900 rounded-xl px-5 py-4 shadow border border-gray-200 dark:border-gray-700">
|
||||
{/* First row: 5-column grid for fixed badge placement, name gets col-span-2 */}
|
||||
<div className="grid grid-cols-5 items-center w-full gap-2">
|
||||
{/* Name (left, col-span-2, min-w-0) */}
|
||||
<div className="flex items-center min-w-0 col-span-2">
|
||||
<span className={`inline-block w-3 h-3 rounded-full ${getStatusColor(monitor.validCert)} mr-2`}></span>
|
||||
<span className="font-semibold text-lg text-gray-800 dark:text-gray-100 truncate">{monitor.name}</span>
|
||||
</div>
|
||||
{/* Cert Exp (center-left, col-span-1) */}
|
||||
<div className="flex flex-col items-center col-span-1 relative">
|
||||
{monitor.certExpiryDaysRemaining !== undefined && (
|
||||
<>
|
||||
{/* Desktop: full badge */}
|
||||
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium text-white ${getCertBg(monitor.certExpiryDaysRemaining)}`}
|
||||
title={monitor.certExpiryDaysRemaining < 0 ? 'Certificate expired!' : `Certificate expires in ${monitor.certExpiryDaysRemaining} days`}>
|
||||
{getCertText(monitor.certExpiryDaysRemaining)}
|
||||
</span>
|
||||
{/* Mobile: icon badge with click */}
|
||||
<span
|
||||
ref={getBadgeRef(monitor.id, 'cert')}
|
||||
className={`sm:hidden px-2 py-0 rounded-full text-xs font-medium flex items-center justify-center ${getCertBg(monitor.certExpiryDaysRemaining)}`}
|
||||
@@ -213,16 +338,13 @@ export default function UptimeStatusIsland() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Avg. (center-right, col-span-1) */}
|
||||
<div className="flex flex-col items-center col-span-1 relative">
|
||||
{monitor.avgPing !== undefined && (
|
||||
<>
|
||||
{/* Desktop: full badge */}
|
||||
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium ${getAvgPingBg(monitor.avgPing)}`}
|
||||
title={`Avg. response: ${monitor.avgPing} ms`}>
|
||||
Avg: {monitor.avgPing} ms
|
||||
</span>
|
||||
{/* Mobile: icon badge with click */}
|
||||
<span
|
||||
ref={getBadgeRef(monitor.id, 'avgPing')}
|
||||
className={`sm:hidden px-2 py-0 rounded-full text-xs font-medium flex items-center justify-center ${getAvgPingBg(monitor.avgPing)}`}
|
||||
@@ -239,15 +361,12 @@ export default function UptimeStatusIsland() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* 24h Uptime (right, col-span-1) */}
|
||||
<div className="flex flex-col items-center col-span-1 relative">
|
||||
{monitor.uptime24h !== undefined && (
|
||||
<>
|
||||
{/* Desktop: full badge */}
|
||||
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium ${getUptime24hBg(monitor.uptime24h)}`}>
|
||||
24h: {monitor.uptime24h.toFixed(1)}%
|
||||
</span>
|
||||
{/* Mobile: icon badge with click */}
|
||||
<span
|
||||
ref={getBadgeRef(monitor.id, 'uptime')}
|
||||
className={`sm:hidden px-2 py-0 rounded-full text-xs font-medium flex items-center justify-center ${getUptime24hBg(monitor.uptime24h)}`}
|
||||
@@ -265,7 +384,6 @@ export default function UptimeStatusIsland() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Second row: Heartbeat bar, always full width, evenly distributed squares */}
|
||||
{monitor.heartbeatHistory && monitor.heartbeatHistory.length > 0 && (
|
||||
<div className="flex items-center w-full">
|
||||
<div className="grid grid-cols-40 gap-x-0.5 bg-gray-200 dark:bg-slate-700 rounded px-2 py-1 w-full overflow-x-auto">
|
||||
|
@@ -148,6 +148,9 @@ function parseAndFormatTime(rawTime) {
|
||||
<strong>Our website uses cookies strictly for essential functionality.</strong> These cookies are necessary for the
|
||||
proper functioning of our website and do not collect any personal information.
|
||||
</p>
|
||||
<p>
|
||||
<strong>SessionStorage:</strong> For performance and user experience, we temporarily cache the latest system status data in your browser's sessionStorage. This data is <strong>not personal</strong>, is never sent to our servers, and is automatically deleted as soon as you close your browser tab or window. No personal or identifying information is ever stored in sessionStorage.
|
||||
</p>
|
||||
<p>Details about the cookies we use:</p>
|
||||
<ul>
|
||||
<li>
|
||||
|
Reference in New Issue
Block a user