import React, { useState, useEffect, useCallback, useRef } from 'react';
import { FiAward, FiPercent, FiActivity, FiCheckCircle, FiClock, FiGlobe, FiLock } from 'react-icons/fi';
import { DateTime } from 'luxon';
import Tippy from '@tippyjs/react';
import 'tippy.js/dist/tippy.css';
function getStatusColor(validCert) {
if (validCert === false) return 'bg-red-600';
if (validCert === true) return 'bg-green-600';
return 'bg-gray-400';
}
function getHeartbeatColor(status) {
if (status === 1) return 'bg-green-600'; // UP
if (status === 0) return 'bg-yellow-400'; // PENDING
if (status === 2) return 'bg-red-600'; // DOWN
return 'bg-gray-300';
}
function getCertBg(days) {
if (typeof days !== 'number') return 'bg-gray-400 dark:bg-gray-600';
if (days < 0) return 'bg-red-600 animate-pulse border-2 border-red-500 dark:bg-red-800';
if (days < 5) return 'bg-red-600 dark:bg-red-800';
if (days <= 15) return 'bg-yellow-400 dark:bg-yellow-500 text-gray-900';
return 'bg-green-600 dark:bg-green-700';
}
function getCertText(days) {
if (typeof days !== 'number') return 'Cert Exp';
if (days < 0) return 'Cert Expired!';
return 'Cert Exp';
}
function getAvgPingBg(avgPing) {
if (typeof avgPing !== 'number') return 'bg-gray-300 dark:bg-gray-700';
if (avgPing <= 80) return 'bg-green-600 text-white dark:bg-green-700 dark:text-white';
if (avgPing <= 200) return 'bg-yellow-400 text-gray-900 dark:bg-yellow-500 dark:text-gray-900';
return 'bg-red-600 text-white dark:bg-red-800 dark:text-white';
}
function getUptime24hBg(uptime) {
if (typeof uptime !== 'number') return 'bg-gray-300 dark:bg-gray-700';
if (uptime >= 99) return 'bg-green-600 text-white dark:bg-green-700 dark:text-white';
if (uptime >= 95) return 'bg-yellow-400 text-gray-900 dark:bg-yellow-500 dark:text-gray-900';
return 'bg-red-600 text-white dark:bg-red-800 dark:text-white';
}
function formatLocalTime(rawTime, zone = 'utc') {
if (!rawTime) return '';
const dt = DateTime.fromFormat(rawTime, 'yyyy-MM-dd HH:mm:ss.SSS', { zone: 'utc' });
const localDt = dt.isValid ? dt.setZone(zone) : null;
return localDt && localDt.isValid
? localDt.toFormat('dd-MM-yyyy, HH:mm:ss')
: 'Invalid DateTime';
}
function HeartbeatPopup({ hb, userZone, monitor }) {
const localTime = hb ? formatLocalTime(hb.time, userZone) : '';
const utcTime = hb ? formatLocalTime(hb.time, 'utc') : '';
return (
{hb.status === 1 ? 'Up' : 'Down'}
Local: {localTime}
UTC: {utcTime}
Ping: {hb.ping ?? '--'} ms
Uptime 24h: {monitor.uptime24h?.toFixed(2) ?? '--'}%
Cert valid for: {monitor.certExpiryDaysRemaining ?? '--'} days
);
}
export default function UptimeStatusIsland() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [openBadge, setOpenBadge] = useState({});
const badgeRefs = useRef({});
const [userZone, setUserZone] = useState(null);
const [userLocale, setUserLocale] = useState(null);
const [secondsToNextUpdate, setSecondsToNextUpdate] = useState(null);
const [isMobile, setIsMobile] = useState(false);
const [showLeftShadow, setShowLeftShadow] = useState(false);
const heartbeatContainerRefs = useRef({});
useEffect(() => {
setUserZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
setUserLocale(navigator.language || 'en-US');
}, []);
// Helper: get the current 30-min window key (e.g., '2024-06-07T12:00')
const getCurrent30MinKey = () => {
const now = new Date();
now.setSeconds(0, 0);
const min = now.getMinutes();
now.setMinutes(Math.floor(min / 30) * 30);
return now.toISOString().slice(0, 16); // 'YYYY-MM-DDTHH:mm'
};
// Helper: get seconds to next 30-min mark
const getSecondsToNext30Min = () => {
const now = new Date();
const minutes = now.getMinutes();
const seconds = now.getSeconds();
const ms = now.getMilliseconds();
const next30 = Math.ceil((minutes + 0.01) / 30) * 30;
let next = new Date(now);
next.setMinutes(next30, 0, 0);
if (next <= now) {
next.setMinutes(next.getMinutes() + 30);
}
return Math.floor((next - now) / 1000);
};
useEffect(() => {
const update = () => setSecondsToNextUpdate(getSecondsToNext30Min());
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, []);
// 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 === getCurrent30MinKey() && 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 30-min key
const cacheKey = 'uptimeStatusCache';
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent30MinKey(), data: json }));
} catch (err) {
setError(err.message || 'Unknown error');
} finally {
setLoading(false);
}
}, []);
// Scheduled fetch logic (every 30 min, update cache)
const fetchData = 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 30-min key
const cacheKey = 'uptimeStatusCache';
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent30MinKey(), data: json }));
} catch (err) {
setError(err.message || 'Unknown error');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const now = new Date();
const minutes = now.getMinutes();
const seconds = now.getSeconds();
const ms = now.getMilliseconds();
const next30 = Math.ceil((minutes + 0.01) / 30) * 30;
let next = new Date(now);
next.setMinutes(next30, 0, 0);
if (next <= now) {
next.setMinutes(next.getMinutes() + 30);
}
const msToNext = next - now;
const timeout = setTimeout(() => {
fetchData();
const interval = setInterval(fetchData, 30 * 60 * 1000);
fetchData.interval = interval;
}, msToNext);
return () => {
clearTimeout(timeout);
if (fetchData.interval) clearInterval(fetchData.interval);
};
}, [fetchData]);
const toggleBadge = (monitorId, badge) => {
setOpenBadge((prev) => ({
...prev,
[monitorId]: {
...prev[monitorId],
[badge]: !prev[monitorId]?.[badge],
},
}));
};
useEffect(() => {
function handleClick(e) {
let clickedInside = false;
Object.values(badgeRefs.current).forEach((monitorBadges) => {
Object.values(monitorBadges || {}).forEach((ref) => {
if (ref && ref.current && ref.current.contains(e.target)) {
clickedInside = true;
}
});
});
if (!clickedInside) setOpenBadge({});
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
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];
};
const totalBarSeconds = 30 * 60;
const barValue = secondsToNextUpdate !== null ? (totalBarSeconds - secondsToNextUpdate) : 0;
const barPercent = (barValue / totalBarSeconds) * 100;
const barGradient = 'linear-gradient(90deg, #0161ef 0%, #0154cf 100%)';
// Format countdown as 'X min XX sec' or 'XX sec'
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} sec`;
}
}
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 640);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// Track scroll position for left shadow on mobile heartbeat bar
const handleHeartbeatScroll = (monitorId, e) => {
if (!isMobile) return;
setShowLeftShadow(e.target.scrollLeft > 0);
};
return (
Next update in {countdownText}
{loading ? (
Loading...
) : error ? (
{error}
) : data && data.publicGroupList && data.publicGroupList.length > 0 ? (
data.publicGroupList.map((group) => (
{group.name}
{group.monitorList.map((monitor) => (
-
{isMobile ? (
<>
{monitor.name}
{monitor.certExpiryDaysRemaining !== undefined && (
toggleBadge(monitor.id, 'cert')}
style={{ cursor: 'pointer', position: 'relative' }}
>
{openBadge[monitor.id]?.cert && (
{monitor.certExpiryDaysRemaining < 0
? 'Expired!'
: `Expires in ${monitor.certExpiryDaysRemaining} days`}
)}
)}
{monitor.avgPing !== undefined && (
toggleBadge(monitor.id, 'avgPing')}
style={{ cursor: 'pointer', position: 'relative' }}
>
{openBadge[monitor.id]?.avgPing && (
{monitor.avgPing} ms
)}
)}
{monitor.uptime24h !== undefined && (
toggleBadge(monitor.id, 'uptime')}
style={{ cursor: 'pointer', position: 'relative' }}
>
{openBadge[monitor.id]?.uptime && (
{monitor.uptime24h.toFixed(1)}%
)}
)}
>
) : (
{monitor.name}
{monitor.certExpiryDaysRemaining !== undefined && (
<>
{getCertText(monitor.certExpiryDaysRemaining)}
toggleBadge(monitor.id, 'cert')}
style={{ cursor: 'pointer', position: 'relative' }}
>
{openBadge[monitor.id]?.cert && (
{monitor.certExpiryDaysRemaining < 0
? 'Expired!'
: `Expires in ${monitor.certExpiryDaysRemaining} days`}
)}
>
)}
{monitor.avgPing !== undefined && (
<>
Avg: {monitor.avgPing} ms
toggleBadge(monitor.id, 'avgPing')}
style={{ cursor: 'pointer', position: 'relative' }}
>
{openBadge[monitor.id]?.avgPing && (
{monitor.avgPing} ms
)}
>
)}
{monitor.uptime24h !== undefined && (
<>
24h: {monitor.uptime24h.toFixed(1)}%
toggleBadge(monitor.id, 'uptime')}
style={{ cursor: 'pointer', position: 'relative' }}
>
{openBadge[monitor.id]?.uptime && (
{monitor.uptime24h.toFixed(1)}%
)}
>
)}
)}
{monitor.heartbeatHistory && monitor.heartbeatHistory.length > 0 && (
{ if (el && isMobile) heartbeatContainerRefs.current[monitor.id] = el; }}
onScroll={e => handleHeartbeatScroll(monitor.id, e)}
>
{/* Left shadow overlay for mobile, only if scrolled */}
{isMobile && showLeftShadow && (
)}
{(() => {
const hbArr = monitor.heartbeatHistory ?? [];
const slice = isMobile ? hbArr.slice(-10) : hbArr.slice(-40);
// Show most recent first (left), so reverse the slice
const ordered = slice;
return ordered.map((hb, i) => (
}
placement="top"
animation="shift-away"
theme="devnet"
delay={[0, 0]}
>
));
})()}
)}
))}
))
) : (
No status data available
)}
);
}