Enhance ToggleTheme component and add Uptime link in Footer with translations

- Updated ToggleTheme component styles for better visibility in dark mode.
- Added a new Uptime link in the Footer for system status monitoring.
- Introduced translations for Uptime in English, Dutch, German, and French to support multilingual users.
This commit is contained in:
becarta
2025-06-08 01:13:56 +02:00
parent 7f06a0d546
commit 46fa503dda
8 changed files with 442 additions and 4 deletions

View File

@@ -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 (
<div className="w-full flex flex-col items-center mt-2 mb-2 max-w-5xl mx-auto">
<div className="flex justify-center items-center mb-0.5 w-full">
<span className="text-base font-semibold text-gray-300 dark:text-gray-200 text-center w-full">Next update in {secondsLeft}s</span>
</div>
<div
className="w-full h-3 rounded bg-gray-300 dark:bg-gray-700 overflow-hidden relative"
style={{
background: 'linear-gradient(90deg, #22c55e 0%, #eab308 60%, #ef4444 100%)',
}}
>
<div
className="h-3 bg-gray-300 dark:bg-gray-700 absolute top-0 right-0 transition-all duration-300"
style={{ width: `${coverPercent}%` }}
></div>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
---
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 {
display: grid;
grid-template-columns: repeat(40, minmax(0, 1fr));
}
</style>

View File

@@ -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 (
<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 justify-center col-span-1">
{monitor.certExpiryDaysRemaining !== undefined && (
<span className={`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>
)}
</div>
{/* Avg. (center-right, col-span-1) */}
<div className="flex justify-center col-span-1">
{monitor.avgPing !== undefined && (
<span className={`px-2 py-0 rounded-full text-xs font-medium ${getAvgPingBg(monitor.avgPing)}`}>
Avg.: {monitor.avgPing < 100 ? monitor.avgPing.toFixed(1) : Math.round(monitor.avgPing)} ms
</span>
)}
</div>
{/* 24h (right, col-span-1) */}
<div className="flex justify-end col-span-1">
{monitor.uptime24h !== undefined && (
<span className={`px-2 py-0 rounded-full text-xs font-medium ${getUptime24hBg(monitor.uptime24h)}`}>
24h: {monitor.uptime24h.toFixed(1)}%
</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 (
<span
key={i}
title={hb ? `Status: ${hb.status === 1 ? 'Up' : 'Down'}\nTime: ${hb.time}` : ''}
className={`w-full h-4 rounded-sm ${hb ? getHeartbeatColor(hb.status) : 'bg-gray-400 dark:bg-gray-600'} block`}
></span>
);
})}
</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>
);
}

View File

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

View File

@@ -125,6 +125,12 @@ const {
</a>
))
}
<a
class="hover:text-gray-700 hover:underline dark:hover:text-gray-200 transition duration-150 ease-in-out"
href="/uptime"
>
Uptime
</a>
</div>
</div>
</div>

View File

@@ -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<string, Translation> = {
'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<string, Translation> = {
},
],
},
uptime: {
title: 'Systeemstatus',
subtitle: 'Realtime monitoring van onze diensten en systemen',
},
},
de: {
metadata: {
@@ -1115,8 +1127,8 @@ export const translations: Record<string, Translation> = {
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<string, Translation> = {
},
],
},
uptime: {
title: 'Systemstatus',
subtitle: 'Echtzeitüberwachung unserer Dienste und Systeme',
},
},
fr: {
metadata: {
@@ -1881,5 +1897,9 @@ export const translations: Record<string, Translation> = {
},
],
},
uptime: {
title: 'Statut du système',
subtitle: 'Surveillance en temps réel de nos services et systèmes',
},
},
};

View File

@@ -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);
---
<Layout>
<main class="max-w-7xl mx-auto px-4 sm:px-6 py-12">
<div class="text-center mb-5">
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">{t.uptime.title}</h1>
<p class="text-lg text-gray-600 dark:text-gray-300">{t.uptime.subtitle}</p>
</div>
<UptimeStatus />
</main>
</Layout>

156
src/pages/api/uptime.ts Normal file
View File

@@ -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<string, Heartbeat[]>; uptimeList?: Record<string, number> };
const heartbeatList: Record<string, Heartbeat[]> = heartbeatData.heartbeatList || {};
const uptimeList: Record<string, number> = 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',
},
});
}
};