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 UptimeStatusIsland from './UptimeStatusIsland.jsx';
|
||||||
import UpdateTimer from './UpdateTimer.jsx';
|
|
||||||
---
|
---
|
||||||
<UpdateTimer client:only="react" onRefresh={() => {}} />
|
|
||||||
<UptimeStatusIsland client:only="react" />
|
<UptimeStatusIsland client:only="react" />
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/**** Custom grid for 40 heartbeat squares ****/
|
/**** Custom grid for 40 heartbeat squares ****/
|
||||||
.grid-cols-40 {
|
.grid-cols-40 {
|
||||||
|
@@ -96,12 +96,81 @@ export default function UptimeStatusIsland() {
|
|||||||
const badgeRefs = useRef({});
|
const badgeRefs = useRef({});
|
||||||
const [userZone, setUserZone] = useState(null);
|
const [userZone, setUserZone] = useState(null);
|
||||||
const [userLocale, setUserLocale] = useState(null);
|
const [userLocale, setUserLocale] = useState(null);
|
||||||
|
const [secondsToNextUpdate, setSecondsToNextUpdate] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUserZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
setUserZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||||
setUserLocale(navigator.language || 'en-US');
|
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 () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -110,6 +179,9 @@ export default function UptimeStatusIsland() {
|
|||||||
if (!response.ok) throw new Error('Failed to fetch uptime data');
|
if (!response.ok) throw new Error('Failed to fetch uptime data');
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
setData(json);
|
setData(json);
|
||||||
|
// Cache with current 5-min key
|
||||||
|
const cacheKey = 'uptimeStatusCache';
|
||||||
|
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent5MinKey(), data: json }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Unknown error');
|
setError(err.message || 'Unknown error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -118,12 +190,28 @@ export default function UptimeStatusIsland() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(); // initial fetch
|
const now = new Date();
|
||||||
const interval = setInterval(fetchData, 300000); // fetch every 5 minutes
|
const minutes = now.getMinutes();
|
||||||
return () => clearInterval(interval);
|
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]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// Helper to toggle badge info
|
|
||||||
const toggleBadge = (monitorId, badge) => {
|
const toggleBadge = (monitorId, badge) => {
|
||||||
setOpenBadge((prev) => ({
|
setOpenBadge((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -134,7 +222,6 @@ export default function UptimeStatusIsland() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close popup on outside click
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClick(e) {
|
function handleClick(e) {
|
||||||
let clickedInside = false;
|
let clickedInside = false;
|
||||||
@@ -151,20 +238,63 @@ export default function UptimeStatusIsland() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClick);
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Helper to get/create refs for each badge
|
|
||||||
const getBadgeRef = (monitorId, badge) => {
|
const getBadgeRef = (monitorId, badge) => {
|
||||||
if (!badgeRefs.current[monitorId]) badgeRefs.current[monitorId] = {};
|
if (!badgeRefs.current[monitorId]) badgeRefs.current[monitorId] = {};
|
||||||
if (!badgeRefs.current[monitorId][badge]) badgeRefs.current[monitorId][badge] = React.createRef();
|
if (!badgeRefs.current[monitorId][badge]) badgeRefs.current[monitorId][badge] = React.createRef();
|
||||||
return badgeRefs.current[monitorId][badge];
|
return badgeRefs.current[monitorId][badge];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only render cards when timezone and locale are available
|
const totalBarSeconds = 5 * 60;
|
||||||
if (!userZone || !userLocale) {
|
const barValue = secondsToNextUpdate !== null ? (totalBarSeconds - secondsToNextUpdate) : 0;
|
||||||
return <div className="text-center text-gray-500 dark:text-gray-400">Loading timezone...</div>;
|
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 (
|
return (
|
||||||
<div>
|
<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">
|
<div className="grid gap-10 md:grid-cols-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center text-gray-500 dark:text-gray-400">Loading...</div>
|
<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">
|
<ul className="space-y-6">
|
||||||
{group.monitorList.map((monitor) => (
|
{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">
|
<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">
|
<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">
|
<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={`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>
|
<span className="font-semibold text-lg text-gray-800 dark:text-gray-100 truncate">{monitor.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Cert Exp (center-left, col-span-1) */}
|
|
||||||
<div className="flex flex-col items-center col-span-1 relative">
|
<div className="flex flex-col items-center col-span-1 relative">
|
||||||
{monitor.certExpiryDaysRemaining !== undefined && (
|
{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)}`}
|
<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`}>
|
title={monitor.certExpiryDaysRemaining < 0 ? 'Certificate expired!' : `Certificate expires in ${monitor.certExpiryDaysRemaining} days`}>
|
||||||
{getCertText(monitor.certExpiryDaysRemaining)}
|
{getCertText(monitor.certExpiryDaysRemaining)}
|
||||||
</span>
|
</span>
|
||||||
{/* Mobile: icon badge with click */}
|
|
||||||
<span
|
<span
|
||||||
ref={getBadgeRef(monitor.id, 'cert')}
|
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)}`}
|
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>
|
</div>
|
||||||
{/* Avg. (center-right, col-span-1) */}
|
|
||||||
<div className="flex flex-col items-center col-span-1 relative">
|
<div className="flex flex-col items-center col-span-1 relative">
|
||||||
{monitor.avgPing !== undefined && (
|
{monitor.avgPing !== undefined && (
|
||||||
<>
|
<>
|
||||||
{/* Desktop: full badge */}
|
|
||||||
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium ${getAvgPingBg(monitor.avgPing)}`}
|
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium ${getAvgPingBg(monitor.avgPing)}`}
|
||||||
title={`Avg. response: ${monitor.avgPing} ms`}>
|
title={`Avg. response: ${monitor.avgPing} ms`}>
|
||||||
Avg: {monitor.avgPing} ms
|
Avg: {monitor.avgPing} ms
|
||||||
</span>
|
</span>
|
||||||
{/* Mobile: icon badge with click */}
|
|
||||||
<span
|
<span
|
||||||
ref={getBadgeRef(monitor.id, 'avgPing')}
|
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)}`}
|
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>
|
</div>
|
||||||
{/* 24h Uptime (right, col-span-1) */}
|
|
||||||
<div className="flex flex-col items-center col-span-1 relative">
|
<div className="flex flex-col items-center col-span-1 relative">
|
||||||
{monitor.uptime24h !== undefined && (
|
{monitor.uptime24h !== undefined && (
|
||||||
<>
|
<>
|
||||||
{/* Desktop: full badge */}
|
|
||||||
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium ${getUptime24hBg(monitor.uptime24h)}`}>
|
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium ${getUptime24hBg(monitor.uptime24h)}`}>
|
||||||
24h: {monitor.uptime24h.toFixed(1)}%
|
24h: {monitor.uptime24h.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
{/* Mobile: icon badge with click */}
|
|
||||||
<span
|
<span
|
||||||
ref={getBadgeRef(monitor.id, 'uptime')}
|
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)}`}
|
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>
|
||||||
</div>
|
</div>
|
||||||
{/* Second row: Heartbeat bar, always full width, evenly distributed squares */}
|
|
||||||
{monitor.heartbeatHistory && monitor.heartbeatHistory.length > 0 && (
|
{monitor.heartbeatHistory && monitor.heartbeatHistory.length > 0 && (
|
||||||
<div className="flex items-center w-full">
|
<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">
|
<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
|
<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.
|
proper functioning of our website and do not collect any personal information.
|
||||||
</p>
|
</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>
|
<p>Details about the cookies we use:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
|
Reference in New Issue
Block a user