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:
2025-06-11 21:48:11 +02:00
parent 0bf251efba
commit db4bde54e6
3 changed files with 142 additions and 24 deletions

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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>