- Removed unnecessary comments and adjusted the date format to exclude timezone information. - Enhanced clarity of the function by focusing on local time display without timezone context.
311 lines
15 KiB
JavaScript
311 lines
15 KiB
JavaScript
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-red-600'; // DOWN
|
|
if (status === 2) return 'bg-yellow-400'; // PENDING
|
|
if (status === 3) return 'bg-blue-500'; // MAINTENANCE
|
|
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 (
|
|
<div style={{ minWidth: 220 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
|
|
<FiCheckCircle color={hb.status === 1 ? 'green' : 'red'} style={{ marginRight: 6 }} />
|
|
<b>{hb.status === 1 ? 'Up' : 'Down'}</b>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
<FiClock style={{ marginRight: 6 }} />
|
|
Local: {localTime}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
<FiGlobe style={{ marginRight: 6 }} />
|
|
UTC: {utcTime}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
<FiActivity style={{ marginRight: 6 }} />
|
|
Ping: {hb.ping ?? '--'} ms
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
<FiPercent style={{ marginRight: 6 }} />
|
|
Uptime 24h: {monitor.uptime24h?.toFixed(2) ?? '--'}%
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
<FiLock style={{ marginRight: 6 }} />
|
|
Cert valid for: {monitor.certExpiryDaysRemaining ?? '--'} days
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
useEffect(() => {
|
|
setUserZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
setUserLocale(navigator.language || 'en-US');
|
|
}, []);
|
|
|
|
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);
|
|
} catch (err) {
|
|
setError(err.message || 'Unknown error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchData(); // initial fetch
|
|
const interval = setInterval(fetchData, 300000); // fetch every 5 minutes
|
|
return () => clearInterval(interval);
|
|
}, [fetchData]);
|
|
|
|
// Helper to toggle badge info
|
|
const toggleBadge = (monitorId, badge) => {
|
|
setOpenBadge((prev) => ({
|
|
...prev,
|
|
[monitorId]: {
|
|
...prev[monitorId],
|
|
[badge]: !prev[monitorId]?.[badge],
|
|
},
|
|
}));
|
|
};
|
|
|
|
// Close popup on outside click
|
|
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);
|
|
}, []);
|
|
|
|
// 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>;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="grid gap-10 md:grid-cols-2">
|
|
{loading ? (
|
|
<div className="text-center text-gray-500 dark:text-gray-400">Loading...</div>
|
|
) : error ? (
|
|
<div className="text-center text-red-500 dark:text-red-400">{error}</div>
|
|
) : data && data.publicGroupList && data.publicGroupList.length > 0 ? (
|
|
data.publicGroupList.map((group) => (
|
|
<section key={group.id} className="bg-white dark:bg-slate-800 rounded-2xl shadow-lg p-8 flex flex-col">
|
|
<h2 className="text-2xl font-bold mb-6 text-gray-900 dark:text-white">{group.name}</h2>
|
|
<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)}`}
|
|
title={monitor.certExpiryDaysRemaining < 0 ? 'Certificate expired!' : `Certificate expires in ${monitor.certExpiryDaysRemaining} days`}
|
|
onClick={() => toggleBadge(monitor.id, 'cert')}
|
|
style={{ cursor: 'pointer', position: 'relative' }}
|
|
>
|
|
<FiAward className="w-4 h-4" />
|
|
{openBadge[monitor.id]?.cert && (
|
|
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap border border-gray-200 dark:border-gray-700">
|
|
{monitor.certExpiryDaysRemaining < 0
|
|
? 'Expired!'
|
|
: `Expires in ${monitor.certExpiryDaysRemaining} days`}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</>
|
|
)}
|
|
</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)}`}
|
|
onClick={() => toggleBadge(monitor.id, 'avgPing')}
|
|
style={{ cursor: 'pointer', position: 'relative' }}
|
|
>
|
|
<FiActivity className="w-4 h-4" />
|
|
{openBadge[monitor.id]?.avgPing && (
|
|
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap border border-gray-200 dark:border-gray-700">
|
|
{monitor.avgPing} ms
|
|
</span>
|
|
)}
|
|
</span>
|
|
</>
|
|
)}
|
|
</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)}`}
|
|
onClick={() => toggleBadge(monitor.id, 'uptime')}
|
|
style={{ cursor: 'pointer', position: 'relative' }}
|
|
>
|
|
<FiPercent className="w-4 h-4" />
|
|
{openBadge[monitor.id]?.uptime && (
|
|
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap border border-gray-200 dark:border-gray-700">
|
|
{monitor.uptime24h.toFixed(1)}%
|
|
</span>
|
|
)}
|
|
</span>
|
|
</>
|
|
)}
|
|
</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">
|
|
{Array.from({ length: 40 }).map((_, i) => {
|
|
const hbArr = monitor.heartbeatHistory ?? [];
|
|
const hb = hbArr.slice(-40)[i];
|
|
return (
|
|
<Tippy
|
|
key={i}
|
|
content={<HeartbeatPopup hb={hb} userZone={userZone} monitor={monitor} />}
|
|
placement="top"
|
|
animation="shift-away"
|
|
theme="devnet"
|
|
delay={[0, 0]}
|
|
>
|
|
<span
|
|
className={`w-full h-4 rounded-sm ${hb ? getHeartbeatColor(hb.status) : 'bg-gray-400 dark:bg-gray-600'} block`}
|
|
style={{ cursor: 'pointer' }}
|
|
></span>
|
|
</Tippy>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
))
|
|
) : (
|
|
<div className="text-center text-gray-500 dark:text-gray-400">No status data available</div>
|
|
)}
|
|
</div>
|
|
<style>{`
|
|
.grid-cols-40 {
|
|
display: grid;
|
|
grid-template-columns: repeat(40, minmax(0, 1fr));
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|