diff --git a/src/components/UpdateTimer.jsx b/src/components/UpdateTimer.jsx new file mode 100644 index 0000000..31d8727 --- /dev/null +++ b/src/components/UpdateTimer.jsx @@ -0,0 +1,43 @@ +import React, { useState, useEffect } from 'react'; + +const REFRESH_INTERVAL = 300; // seconds + +export default function UpdateTimer({ onRefresh }) { + const [secondsLeft, setSecondsLeft] = useState(REFRESH_INTERVAL); + + useEffect(() => { + const interval = setInterval(() => { + setSecondsLeft((prev) => { + if (prev > 1) { + return prev - 1; + } else { + if (typeof onRefresh === 'function') onRefresh(); + return REFRESH_INTERVAL; + } + }); + }, 1000); + return () => clearInterval(interval); + }, [onRefresh]); + + const percent = (secondsLeft / REFRESH_INTERVAL) * 100; + const coverPercent = 100 - percent; + + return ( +
+
+ Next update in {secondsLeft}s +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/UptimeStatus.astro b/src/components/UptimeStatus.astro new file mode 100644 index 0000000..ad319b9 --- /dev/null +++ b/src/components/UptimeStatus.astro @@ -0,0 +1,15 @@ +--- +import UptimeStatusIsland from './UptimeStatusIsland.jsx'; +import UpdateTimer from './UpdateTimer.jsx'; +--- + {}} /> + + + + \ No newline at end of file diff --git a/src/components/UptimeStatusIsland.jsx b/src/components/UptimeStatusIsland.jsx new file mode 100644 index 0000000..a0b28ea --- /dev/null +++ b/src/components/UptimeStatusIsland.jsx @@ -0,0 +1,151 @@ +import React, { useState, useEffect, useCallback } from 'react'; + +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.9) return 'bg-green-600 text-white dark:bg-green-700 dark:text-white'; + if (uptime >= 99) 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'; +} + +export default function UptimeStatusIsland() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + 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(); + }, [fetchData]); + + return ( +
+
+ {loading ? ( +
Loading...
+ ) : error ? ( +
{error}
+ ) : data && data.publicGroupList && data.publicGroupList.length > 0 ? ( + data.publicGroupList.map((group) => ( +
+

{group.name}

+
    + {group.monitorList.map((monitor) => ( +
  • + {/* First row: 5-column grid for fixed badge placement, name gets col-span-2 */} +
    + {/* Name (left, col-span-2, min-w-0) */} +
    + + {monitor.name} +
    + {/* Cert Exp (center-left, col-span-1) */} +
    + {monitor.certExpiryDaysRemaining !== undefined && ( + + {getCertText(monitor.certExpiryDaysRemaining)} + + )} +
    + {/* Avg. (center-right, col-span-1) */} +
    + {monitor.avgPing !== undefined && ( + + Avg.: {monitor.avgPing < 100 ? monitor.avgPing.toFixed(1) : Math.round(monitor.avgPing)} ms + + )} +
    + {/* 24h (right, col-span-1) */} +
    + {monitor.uptime24h !== undefined && ( + + 24h: {monitor.uptime24h.toFixed(1)}% + + )} +
    +
    + {/* Second row: Heartbeat bar, always full width, evenly distributed squares */} + {monitor.heartbeatHistory && monitor.heartbeatHistory.length > 0 && ( +
    +
    + {Array.from({ length: 40 }).map((_, i) => { + const hbArr = monitor.heartbeatHistory ?? []; + const hb = hbArr.slice(-40)[i]; + return ( + + ); + })} +
    +
    + )} +
  • + ))} +
+
+ )) + ) : ( +
No status data available
+ )} +
+ +
+ ); +} \ No newline at end of file diff --git a/src/components/common/ToggleTheme.astro b/src/components/common/ToggleTheme.astro index 8f3aafb..48f3364 100644 --- a/src/components/common/ToggleTheme.astro +++ b/src/components/common/ToggleTheme.astro @@ -13,7 +13,7 @@ export interface Props { const { label = 'Toggle between Dark and Light mode', class: - className = 'text-muted dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5 inline-flex items-center', + className = 'text-muted dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5 inline-flex items-center', iconClass = 'w-6 h-6', iconName = 'tabler:sun', } = Astro.props; diff --git a/src/components/widgets/Footer.astro b/src/components/widgets/Footer.astro index 618b0c6..25c2e53 100644 --- a/src/components/widgets/Footer.astro +++ b/src/components/widgets/Footer.astro @@ -125,6 +125,12 @@ const { )) } + + Uptime + diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 97a92d7..57b4230 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -131,6 +131,10 @@ export interface Translation { description: string; }; }; + uptime: { + title: string; + subtitle: string; + }; } export const supportedLanguages = ['en', 'nl', 'de', 'fr'] as const; @@ -573,7 +577,11 @@ export const translations: Record = { 'Experienced in providing advanced technical support for complex IT issues that require in-depth knowledge and specialized expertise. Proficient in troubleshooting, diagnosing, and resolving critical system problems across various platforms and applications.', }, ], - } + }, + uptime: { + title: 'System Status', + subtitle: 'Real-time monitoring of our services and systems', + }, }, nl: { metadata: { @@ -1010,6 +1018,10 @@ export const translations: Record = { }, ], }, + uptime: { + title: 'Systeemstatus', + subtitle: 'Realtime monitoring van onze diensten en systemen', + }, }, de: { metadata: { @@ -1115,8 +1127,8 @@ export const translations: Record = { missionContent: [ 'Wir sind bestrebt, IT-Exzellenz durch strategische Cloud-Optimierung, Prozessautomatisierung und technischen Support auf Unternehmensebene voranzutreiben. Wir nutzen modernste Technologie, um komplexe geschäftliche Herausforderungen zu bewältigen und messbaren Mehrwert zu liefern.', 'Mit tiefgreifender Expertise in Microsoft-Technologien und Automatisierung befähigen wir Organisationen, ihre digitalen Fähigkeiten zu transformieren und ihre Geschäftsziele zu erreichen. Wir entwickeln Lösungen, die die Benutzererfahrung verbessern und die Produktivität maximieren, damit Technologie Ihr Unternehmen stärkt.', - 'Wir bleiben an der Spitze durch Erforschung und Implementierung aufkommender Technologien und bieten skalierbare Lösungen, die sich an Ihre sich entwickelnden Anforderungen anpassen. Wir stimmen technische Lösungen auf Ihre Kerngeschäftsziele ab und liefern messbaren ROI und Wettbewerbsvorteile.', - 'Unsere Mission ist es, Technologie zu nutzen, um echte geschäftliche Herausforderungen zu lösen und durch Innovation Wert zu schaffen. Mit über 15 Jahren IT-Erfahrung bringen wir einen reichen Schatz an Wissen in Microsoft-Technologieën, automatiseringstools und systeemintegratie om organisaties te helpen ihre digitalen Fähigkeiten zu transformieren und ihre strategischen Ziele zu erreichen.', + 'Wir bleiben an der Spitze durch Erforschung und Implementierung aufkommender Technologien und bieten schaalbare Lösungen, die sich an Ihre sich entwickelnden Anforderungen anpassen. Wir stimmen technische Lösungen auf Ihre Kerngeschäftsziele ab und liefern messbaren ROI und Wettbewerbsvorteile.', + 'Unsere Mission ist es, Technologie zu nutzen, um echte geschäftliche Herausforderungen zu lösen und durch Innovation Wert zu schaffen. Mit über 15 Jahren IT-Erfahrung bringen wir eine reiche Schatz an Wissen in Microsoft-Technologieën, automatiseringstools und systeemintegratie um organisaties te helpen ihre digitalen Fähigkeiten zu transformieren und ihre strategischen Ziele zu erreichen.', ], items: [ { @@ -1446,6 +1458,10 @@ export const translations: Record = { }, ], }, + uptime: { + title: 'Systemstatus', + subtitle: 'Echtzeitüberwachung unserer Dienste und Systeme', + }, }, fr: { metadata: { @@ -1881,5 +1897,9 @@ export const translations: Record = { }, ], }, + uptime: { + title: 'Statut du système', + subtitle: 'Surveillance en temps réel de nos services et systèmes', + }, }, }; diff --git a/src/pages/[lang]/uptime.astro b/src/pages/[lang]/uptime.astro new file mode 100644 index 0000000..5eaddce --- /dev/null +++ b/src/pages/[lang]/uptime.astro @@ -0,0 +1,47 @@ +--- +import UptimeStatus from '../../components/UptimeStatus.astro'; +import Layout from '../../layouts/PageLayout.astro'; +import { supportedLanguages, getTranslation } from '~/i18n/translations'; + +// Define the type for supported languages +type SupportedLanguage = (typeof supportedLanguages)[number]; + +// Get current language from URL +const currentPath = `/${Astro.url.pathname.replace(/^\/+/g, '').replace(/\/+$/, '')}`; +const pathSegments = currentPath.split('/').filter(Boolean); + +// Check for language in URL path +let currentLang = + pathSegments[0] && supportedLanguages.includes(pathSegments[0] as SupportedLanguage) + ? (pathSegments[0] as SupportedLanguage) + : null; + +// If no language in URL, check cookies +if (!currentLang) { + const cookies = Astro.request.headers.get('cookie') || ''; + const cookieLanguage = cookies + .split(';') + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith('preferredLanguage=')) + ?.split('=')[1]; + + if (cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)) { + currentLang = cookieLanguage as SupportedLanguage; + } else { + // Default to English if no language is found + currentLang = 'en'; + } +} + +const t = getTranslation(currentLang); +--- + + +
+
+

{t.uptime.title}

+

{t.uptime.subtitle}

+
+ +
+
\ No newline at end of file diff --git a/src/pages/api/uptime.ts b/src/pages/api/uptime.ts new file mode 100644 index 0000000..7ae8cee --- /dev/null +++ b/src/pages/api/uptime.ts @@ -0,0 +1,156 @@ +import type { APIRoute } from 'astro'; +import fetch from 'node-fetch'; +import type { RequestInit } from 'node-fetch'; + +const UPTIME_KUMA_URL = import.meta.env.UPTIME_KUMA_URL; +const STATUS_PAGE_SLUG = '365devnet'; // all lowercase + +interface Heartbeat { + status: number; // 0=DOWN, 1=UP, 2=PENDING, 3=MAINTENANCE + time: string; + msg: string; + ping: number | null; + important?: boolean; + duration?: number; + localDateTime?: string; + timezone?: string; + retries?: number; + downCount?: number; +} + +interface Monitor { + id: number; + name: string; + type: string; + certExpiryDaysRemaining?: number; + validCert?: boolean; + heartbeatHistory?: Heartbeat[]; + uptimePercent?: number; + currentPing?: number; + avgPing?: number; + lastChecked?: string; + uptime24h?: number; +} + +interface Group { + id: number; + name: string; + monitorList: Monitor[]; +} + +// Add timeout to fetch request +const fetchWithTimeout = async (url: string, options: RequestInit, timeout = 10000) => { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal + }); + clearTimeout(id); + return response; + } catch (error) { + clearTimeout(id); + throw error; + } +}; + +export const GET: APIRoute = async () => { + try { + if (!UPTIME_KUMA_URL) { + console.error('Missing environment variable: UPTIME_KUMA_URL'); + return new Response(JSON.stringify({ error: 'Configuration error: Missing environment variable' }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + // Fetch main status page data + const statusPageUrl = `${UPTIME_KUMA_URL}/api/status-page/${STATUS_PAGE_SLUG}`; + const response = await fetchWithTimeout( + statusPageUrl, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + 10000 // 10 second timeout + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`); + } + + const data = (await response.json()) as { publicGroupList?: Group[] }; + + // Fetch all heartbeat history in one call + const heartbeatUrl = `${UPTIME_KUMA_URL}/api/status-page/heartbeat/${STATUS_PAGE_SLUG}`; + const heartbeatResp = await fetchWithTimeout( + heartbeatUrl, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + 10000 + ); + const heartbeatData = (await heartbeatResp.json()) as { heartbeatList: Record; uptimeList?: Record }; + const heartbeatList: Record = heartbeatData.heartbeatList || {}; + const uptimeList: Record = heartbeatData.uptimeList || {}; + + // Attach heartbeat history and calculated stats to each monitor + if (data && typeof data === 'object' && Array.isArray(data.publicGroupList)) { + for (const group of data.publicGroupList) { + for (const monitor of group.monitorList) { + const hbArr = heartbeatList[monitor.id.toString()] || []; + monitor.heartbeatHistory = hbArr; + // Uptime % (last 40 heartbeats) + if (hbArr.length > 0) { + const last40 = hbArr.slice(-40); + const upCount = last40.filter(hb => hb.status === 1).length; + monitor.uptimePercent = Math.round((upCount / last40.length) * 1000) / 10; // 1 decimal + // Current ping (most recent heartbeat) + monitor.currentPing = last40[last40.length - 1]?.ping ?? undefined; + // Average ping (last 40 heartbeats) + const pings = last40.map(hb => hb.ping).filter(p => typeof p === 'number') as number[]; + monitor.avgPing = pings.length > 0 ? Math.round((pings.reduce((a, b) => a + b, 0) / pings.length) * 10) / 10 : undefined; + // Last checked + monitor.lastChecked = last40[last40.length - 1]?.time ?? undefined; + } else { + monitor.uptimePercent = undefined; + monitor.currentPing = undefined; + monitor.avgPing = undefined; + monitor.lastChecked = undefined; + } + // Attach 24h uptime percentage if available + const uptimeKey = `${monitor.id}_24`; + if (uptimeList[uptimeKey] !== undefined) { + monitor.uptime24h = Math.round(uptimeList[uptimeKey] * 10000) / 100; // e.g. 0.9998 -> 99.98 + } + } + } + } + + return new Response(JSON.stringify(data), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (err) { + console.error('Error fetching uptime data:', err); + return new Response(JSON.stringify({ + error: 'Failed to fetch uptime data', + details: err instanceof Error ? err.message : 'Unknown error' + }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + }); + } +}; \ No newline at end of file