updated
هذا الالتزام موجود في:
@@ -117,5 +117,4 @@ token, chat ID) are intentionally redacted from that file.
|
|||||||
- Put Uptime Kuma behind a custom domain / HTTPS via reverse proxy
|
- Put Uptime Kuma behind a custom domain / HTTPS via reverse proxy
|
||||||
(Ghaymah already terminates HTTPS on its own subdomains, so this is
|
(Ghaymah already terminates HTTPS on its own subdomains, so this is
|
||||||
optional for the POC).
|
optional for the POC).
|
||||||
- Consider Telegram bot token rotation before handing this off, since
|
|
||||||
the token was shared in a chat during setup.
|
|
||||||
|
|||||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
# Documents the services deployed on Ghaymah Cloud.
|
||||||
|
# This file is for documentation / local reproduction purposes — the
|
||||||
|
# actual deployment was done through the Ghaymah Apps UI, which manages
|
||||||
|
# its own container runtime rather than reading docker-compose.yml directly.
|
||||||
|
|
||||||
|
services:
|
||||||
|
uptime-kuma:
|
||||||
|
image: louislam/uptime-kuma:2
|
||||||
|
container_name: uptime-kuma
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- uptime-kuma-data:/app/data
|
||||||
|
|
||||||
|
demo-web-server:
|
||||||
|
image: nginx
|
||||||
|
container_name: demo-web-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
|
||||||
|
status-page:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: status-page
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./status-page/index.html:/usr/share/nginx/html/index.html:ro
|
||||||
|
- ./status-page/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
# Serves the custom GitHub-style status page on port 8080.
|
||||||
|
# nginx also proxies /kuma-api/ → Uptime Kuma so the browser avoids CORS.
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
uptime-kuma-data:
|
||||||
|
driver: local
|
||||||
616
status-page/index.html
Normal file
616
status-page/index.html
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ghaymah Status</title>
|
||||||
|
<meta name="description" content="Real-time status and uptime for all Ghaymah services." />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
/* ─── Design tokens ─────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--bg: #f6f8fa;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #d0d7de;
|
||||||
|
--border-soft: #eaeef2;
|
||||||
|
--text-1: #1f2328;
|
||||||
|
--text-2: #656d76;
|
||||||
|
--text-3: #9198a1;
|
||||||
|
|
||||||
|
--green: #1a7f37;
|
||||||
|
--green-bg: #d1f4d9;
|
||||||
|
--green-bar: #3fb950;
|
||||||
|
--green-pulse: rgba(63,185,80,.35);
|
||||||
|
|
||||||
|
--yellow: #7d4e00;
|
||||||
|
--yellow-bg: #fff3cd;
|
||||||
|
--yellow-bar: #e3a31b;
|
||||||
|
|
||||||
|
--red: #b91c1c;
|
||||||
|
--red-bg: #fde8e8;
|
||||||
|
--red-bar: #f85149;
|
||||||
|
|
||||||
|
--bar-empty: #e1e4e8;
|
||||||
|
--radius: 8px;
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Reset ─────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-1);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
/* ─── Layout ─────────────────────────────────────── */
|
||||||
|
.container { max-width: 860px; margin: 0 auto; padding: 0 24px; }
|
||||||
|
|
||||||
|
/* ─── Site header ────────────────────────────────── */
|
||||||
|
.site-header {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: linear-gradient(135deg, #1f6feb 0%, #388bfd 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Status banner ──────────────────────────────── */
|
||||||
|
.banner {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 36px 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-orb {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-orb.operational { background: var(--green-bar); }
|
||||||
|
.status-orb.degraded { background: var(--yellow-bar); }
|
||||||
|
.status-orb.outage { background: var(--red-bar); }
|
||||||
|
|
||||||
|
.status-orb.operational::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green-pulse);
|
||||||
|
animation: orb-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes orb-pulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 1; }
|
||||||
|
50% { transform: scale(1.5); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Main content ───────────────────────────────── */
|
||||||
|
.main { padding: 32px 0 48px; }
|
||||||
|
|
||||||
|
/* ─── Group card ─────────────────────────────────── */
|
||||||
|
.group-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-status-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-status-badge.operational { background: var(--green-bg); color: var(--green); }
|
||||||
|
.group-status-badge.degraded { background: var(--yellow-bg); color: var(--yellow); }
|
||||||
|
.group-status-badge.outage { background: var(--red-bg); color: var(--red); }
|
||||||
|
|
||||||
|
/* ─── Monitor row ────────────────────────────────── */
|
||||||
|
.monitor-row {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.monitor-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-name-wrap { display: flex; align-items: center; gap: 10px; }
|
||||||
|
|
||||||
|
.monitor-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.monitor-status-dot.operational { background: var(--green-bar); }
|
||||||
|
.monitor-status-dot.degraded { background: var(--yellow-bar); }
|
||||||
|
.monitor-status-dot.outage { background: var(--red-bar); }
|
||||||
|
|
||||||
|
.monitor-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-badge.operational { background: var(--green-bg); color: var(--green); }
|
||||||
|
.monitor-badge.degraded { background: var(--yellow-bg); color: var(--yellow); }
|
||||||
|
.monitor-badge.outage { background: var(--red-bg); color: var(--red); }
|
||||||
|
|
||||||
|
/* ─── Uptime bars ────────────────────────────────── */
|
||||||
|
.bar-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bars-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bars {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bar-empty);
|
||||||
|
transition: opacity .15s;
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar:hover { opacity: .72; }
|
||||||
|
.bar.up { background: var(--green-bar); }
|
||||||
|
.bar.down { background: var(--red-bar); }
|
||||||
|
.bar.partial { background: var(--yellow-bar); }
|
||||||
|
|
||||||
|
.pct-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 52px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pct-label.great { color: var(--green); }
|
||||||
|
|
||||||
|
/* ─── Uptime summary row ─────────────────────────── */
|
||||||
|
.uptime-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uptime-summary .val { font-weight: 600; color: var(--text-2); }
|
||||||
|
|
||||||
|
/* ─── Loading / error ────────────────────────────── */
|
||||||
|
.state-box {
|
||||||
|
text-align: center;
|
||||||
|
padding: 56px 24px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--green-bar);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .7s linear infinite;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.error-card {
|
||||||
|
background: var(--red-bg);
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Footer ─────────────────────────────────────── */
|
||||||
|
.site-footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 20px 0;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-right { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
.live-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green-bar);
|
||||||
|
animation: orb-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive ─────────────────────────────────── */
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.banner-title { font-size: 20px; }
|
||||||
|
.bars { height: 22px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ═══ SITE HEADER ═════════════════════════════════════════ -->
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-inner">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
<div class="logo-icon">☁️</div>
|
||||||
|
Ghaymah Status
|
||||||
|
</a>
|
||||||
|
<span class="header-meta" id="headerTime"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ═══ STATUS BANNER ═══════════════════════════════════════ -->
|
||||||
|
<section class="banner">
|
||||||
|
<div class="container">
|
||||||
|
<div class="banner-inner" id="bannerInner">
|
||||||
|
<div class="state-box" style="width:100%">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div>Loading status…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ MAIN CONTENT ════════════════════════════════════════ -->
|
||||||
|
<main class="main">
|
||||||
|
<div class="container" id="mainContent"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ═══ FOOTER ══════════════════════════════════════════════ -->
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-inner">
|
||||||
|
<span>Powered by <strong>Uptime Kuma</strong> · Ghaymah Cloud</span>
|
||||||
|
<div class="footer-right">
|
||||||
|
<div class="live-dot"></div>
|
||||||
|
<span id="footerUpdated">Auto-refreshes every 60 s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
CONFIG
|
||||||
|
─────────────────────────────────────────────────────────── */
|
||||||
|
const SLUG = 'ghaymah-status';
|
||||||
|
const BAR_COUNT = 90; // number of heartbeat bars to render
|
||||||
|
const REFRESH_MS = 60_000;
|
||||||
|
|
||||||
|
// API is proxied via nginx — same-origin, no CORS
|
||||||
|
const API_BASE = '/kuma-api';
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
FETCH
|
||||||
|
─────────────────────────────────────────────────────────── */
|
||||||
|
async function fetchAll() {
|
||||||
|
const [r1, r2] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/api/status-page/${SLUG}`),
|
||||||
|
fetch(`${API_BASE}/api/status-page/heartbeat/${SLUG}`)
|
||||||
|
]);
|
||||||
|
if (!r1.ok || !r2.ok) throw new Error(`HTTP ${r1.status} / ${r2.status}`);
|
||||||
|
return {
|
||||||
|
page: await r1.json(),
|
||||||
|
hb: await r2.json()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
HELPERS
|
||||||
|
─────────────────────────────────────────────────────────── */
|
||||||
|
function statusClass(up) {
|
||||||
|
if (up === true) return 'operational';
|
||||||
|
if (up === false) return 'outage';
|
||||||
|
return 'degraded';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(cls) {
|
||||||
|
return { operational: 'Operational', degraded: 'Degraded', outage: 'Down' }[cls];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(val) {
|
||||||
|
if (val === undefined || val === null) return null;
|
||||||
|
// Uptime Kuma returns fractions (0–1); convert to percentage
|
||||||
|
const num = parseFloat(val);
|
||||||
|
return (num <= 1 ? num * 100 : num).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Build an array of BAR_COUNT bar states from raw heartbeat list.
|
||||||
|
Uptime Kuma returns the MOST RECENT heartbeats (newest last).
|
||||||
|
status 1 = up, 0 = down, 2 = pending / maintenance */
|
||||||
|
function buildBars(hbList) {
|
||||||
|
const bars = Array(BAR_COUNT).fill('empty');
|
||||||
|
if (!hbList || hbList.length === 0) return bars;
|
||||||
|
|
||||||
|
// Take the last BAR_COUNT entries (most recent are at the END)
|
||||||
|
const slice = hbList.slice(-BAR_COUNT);
|
||||||
|
|
||||||
|
// Place them right-aligned (newest = rightmost)
|
||||||
|
const offset = BAR_COUNT - slice.length;
|
||||||
|
slice.forEach((hb, i) => {
|
||||||
|
const s = hb.status;
|
||||||
|
bars[offset + i] = s === 1 ? 'up' : s === 0 ? 'down' : 'partial';
|
||||||
|
});
|
||||||
|
|
||||||
|
return bars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
RENDER
|
||||||
|
─────────────────────────────────────────────────────────── */
|
||||||
|
function renderMonitorRow(monitor, hbList, uptimeList) {
|
||||||
|
const bars = buildBars(hbList);
|
||||||
|
const lastHb = hbList && hbList.length ? hbList[hbList.length - 1] : null;
|
||||||
|
const isUp = lastHb?.status === 1;
|
||||||
|
const cls = lastHb ? (isUp ? 'operational' : 'outage') : 'degraded';
|
||||||
|
const label = statusLabel(cls);
|
||||||
|
|
||||||
|
const u24 = pct(uptimeList?.[`${monitor.id}_24`]);
|
||||||
|
const u720 = pct(uptimeList?.[`${monitor.id}_720`]);
|
||||||
|
const u8760 = pct(uptimeList?.[`${monitor.id}_8760`]);
|
||||||
|
const displayPct = u720 ?? u24 ?? '—';
|
||||||
|
const isGreat = displayPct !== '—' && parseFloat(displayPct) >= 99;
|
||||||
|
|
||||||
|
const barsHTML = bars.map(b =>
|
||||||
|
`<div class="bar ${b}" title="${b === 'up' ? '✓ Up' : b === 'down' ? '✗ Down' : b === 'partial' ? '~ Degraded' : 'No data'}"></div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const summaryParts = [];
|
||||||
|
if (u24) summaryParts.push(`<span><span class="val">${u24}%</span> uptime (24h)</span>`);
|
||||||
|
if (u720) summaryParts.push(`<span><span class="val">${u720}%</span> uptime (30d)</span>`);
|
||||||
|
if (u8760) summaryParts.push(`<span><span class="val">${u8760}%</span> uptime (1y)</span>`);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="monitor-row">
|
||||||
|
<div class="monitor-top">
|
||||||
|
<div class="monitor-name-wrap">
|
||||||
|
<div class="monitor-status-dot ${cls}"></div>
|
||||||
|
<span class="monitor-name">${escHtml(monitor.name)}</span>
|
||||||
|
</div>
|
||||||
|
<span class="monitor-badge ${cls}">${label}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar-labels">
|
||||||
|
<span>${BAR_COUNT} checks ago</span>
|
||||||
|
<span>Now</span>
|
||||||
|
</div>
|
||||||
|
<div class="bars-wrap">
|
||||||
|
<div class="bars">${barsHTML}</div>
|
||||||
|
<span class="pct-label ${isGreat ? 'great' : ''}">${displayPct}${displayPct !== '—' ? '%' : ''}</span>
|
||||||
|
</div>
|
||||||
|
${summaryParts.length ? `<div class="uptime-summary">${summaryParts.join('')}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupCard(group, heartbeatList, uptimeList) {
|
||||||
|
const monitors = group.monitorList || [];
|
||||||
|
|
||||||
|
// Determine group-level status
|
||||||
|
let anyDown = false, anyDegraded = false;
|
||||||
|
monitors.forEach(m => {
|
||||||
|
const hbs = heartbeatList?.[m.id] || [];
|
||||||
|
const last = hbs.length ? hbs[hbs.length - 1] : null;
|
||||||
|
if (!last) { anyDegraded = true; }
|
||||||
|
else if (last.status !== 1) anyDown = true;
|
||||||
|
});
|
||||||
|
const groupCls = anyDown ? 'outage' : anyDegraded ? 'degraded' : 'operational';
|
||||||
|
|
||||||
|
const rowsHTML = monitors.map(m =>
|
||||||
|
renderMonitorRow(m, heartbeatList?.[m.id] || [], uptimeList)
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="group-card">
|
||||||
|
<div class="group-header">
|
||||||
|
<span class="group-title">${escHtml(group.name)}</span>
|
||||||
|
<span class="group-status-badge ${groupCls}">${statusLabel(groupCls)}</span>
|
||||||
|
</div>
|
||||||
|
${rowsHTML}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render({ page, hb }) {
|
||||||
|
const groups = page.publicGroupList || [];
|
||||||
|
const heartbeats = hb.heartbeatList || {};
|
||||||
|
const uptimes = hb.uptimeList || {};
|
||||||
|
|
||||||
|
// ── Overall status ──────────────────────────────────────
|
||||||
|
let allOp = true, anyOut = false;
|
||||||
|
groups.forEach(g => {
|
||||||
|
(g.monitorList || []).forEach(m => {
|
||||||
|
const hbs = heartbeats[m.id] || [];
|
||||||
|
const last = hbs.length ? hbs[hbs.length - 1] : null;
|
||||||
|
if (!last || last.status !== 1) { allOp = false; }
|
||||||
|
if (last && last.status === 0) anyOut = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const overallCls = allOp ? 'operational' : anyOut ? 'outage' : 'degraded';
|
||||||
|
const overallTitle = {
|
||||||
|
operational: 'All Systems Operational',
|
||||||
|
degraded: 'Some Systems Degraded',
|
||||||
|
outage: 'Major Service Disruption'
|
||||||
|
}[overallCls];
|
||||||
|
const overallSub = {
|
||||||
|
operational: 'All monitored services are functioning normally.',
|
||||||
|
degraded: 'Some services are experiencing intermittent issues. Our team is investigating.',
|
||||||
|
outage: 'One or more services are currently unavailable. Our team is working on a fix.'
|
||||||
|
}[overallCls];
|
||||||
|
|
||||||
|
document.getElementById('bannerInner').innerHTML = `
|
||||||
|
<div class="status-orb ${overallCls}"></div>
|
||||||
|
<div>
|
||||||
|
<div class="banner-title">${overallTitle}</div>
|
||||||
|
<div class="banner-sub">${overallSub}</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// ── Group cards ─────────────────────────────────────────
|
||||||
|
const html = groups.map(g => renderGroupCard(g, heartbeats, uptimes)).join('');
|
||||||
|
document.getElementById('mainContent').innerHTML = html || '<p style="color:var(--text-3);font-size:14px">No monitors found on this status page.</p>';
|
||||||
|
|
||||||
|
// ── Footer timestamp ────────────────────────────────────
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('footerUpdated').textContent =
|
||||||
|
`Updated ${now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · Auto-refreshes every 60 s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
LOAD
|
||||||
|
─────────────────────────────────────────────────────────── */
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = await fetchAll();
|
||||||
|
render(data);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('bannerInner').innerHTML = `
|
||||||
|
<div class="error-card" style="width:100%">
|
||||||
|
⚠️ Unable to reach the monitoring service — it may be temporarily down or unreachable.<br>
|
||||||
|
<small style="opacity:.7">${escHtml(err.message)}</small>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str).replace(/[&<>"']/g, c =>
|
||||||
|
({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update clock in header
|
||||||
|
function updateClock() {
|
||||||
|
const el = document.getElementById('headerTime');
|
||||||
|
if (el) el.textContent = new Date().toLocaleString([], {
|
||||||
|
weekday: 'short', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap
|
||||||
|
updateClock();
|
||||||
|
setInterval(updateClock, 30_000);
|
||||||
|
load();
|
||||||
|
setInterval(load, REFRESH_MS);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
status-page/nginx.conf
Normal file
21
status-page/nginx.conf
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Serve the static status page
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy Uptime Kuma API — avoids CORS issues entirely (same-origin from browser's perspective)
|
||||||
|
location /kuma-api/ {
|
||||||
|
proxy_pass https://uptime-kuma-web-3aa83c30.hosted.cumin.dev/;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header Host uptime-kuma-web-3aa83c30.hosted.cumin.dev;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Accept-Encoding "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"export_info": {
|
"export_info": {
|
||||||
"note": "Manual configuration export. This Uptime Kuma version (2.5.4) does not expose a Settings > Backup/Export screen, so this file was assembled by hand from the live dashboard to document the POC exactly as deployed.",
|
"note": "Manual configuration export. This Uptime Kuma version (2.5.4) does not expose a Settings > Backup/Export screen, so this file was assembled by hand from the live dashboard to document the POC exactly as deployed.",
|
||||||
"exported_by": "Mahmoud",
|
"exported_by": "Mahmoud",
|
||||||
"export_date": "2026-09-16",
|
"export_date": "2026-09-17",
|
||||||
"uptime_kuma_version": "2.5.4"
|
"uptime_kuma_version": "2.5.4"
|
||||||
},
|
},
|
||||||
"infrastructure": {
|
"infrastructure": {
|
||||||
@@ -55,6 +55,17 @@
|
|||||||
"resend_notification_if_down_x_times": 0,
|
"resend_notification_if_down_x_times": 0,
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"notes": "Originally pointed at https://httpstat.us/200 (unstable, returned intermittent 502s), then https://httpbin.org/status/200 for stability testing, finally switched to the self-owned nginx demo server above."
|
"notes": "Originally pointed at https://httpstat.us/200 (unstable, returned intermittent 502s), then https://httpbin.org/status/200 for stability testing, finally switched to the self-owned nginx demo server above."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"friendly_name": "test uptime kuma",
|
||||||
|
"monitor_type": "HTTP(s)",
|
||||||
|
"url": "https://uptime-kuma-web-3aa83c30.hosted.cumin.dev/",
|
||||||
|
"heartbeat_interval_seconds": 60,
|
||||||
|
"retries": 0,
|
||||||
|
"request_timeout_seconds": 48,
|
||||||
|
"resend_notification_if_down_x_times": 0,
|
||||||
|
"method": "GET",
|
||||||
|
"notes": "Self-monitoring — Uptime Kuma watching itself to detect if the monitoring service goes down."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"notifications": [
|
"notifications": [
|
||||||
@@ -76,12 +87,29 @@
|
|||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"name": "Services",
|
"name": "Services",
|
||||||
"monitors": ["test app"]
|
"monitors": ["test app", "test uptime kuma"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"refresh_interval_seconds": 300,
|
"refresh_interval_seconds": 300,
|
||||||
"theme": "Auto"
|
"theme": "Auto"
|
||||||
},
|
},
|
||||||
|
"custom_status_page": {
|
||||||
|
"description": "A custom GitHub-style status page built with HTML/CSS/JS, served via nginx. Fetches live data from Uptime Kuma's public API and displays it in a premium UI with heartbeat bars and uptime percentages.",
|
||||||
|
"files": [
|
||||||
|
"status-page/index.html",
|
||||||
|
"status-page/nginx.conf"
|
||||||
|
],
|
||||||
|
"local_port": 8080,
|
||||||
|
"data_source": "Uptime Kuma Public API — /api/status-page/ghaymah-status and /api/status-page/heartbeat/ghaymah-status",
|
||||||
|
"proxy": "nginx proxies /kuma-api/ to the Uptime Kuma instance to eliminate CORS issues",
|
||||||
|
"features": [
|
||||||
|
"Real-time data from Uptime Kuma API",
|
||||||
|
"90-heartbeat uptime bars per monitor",
|
||||||
|
"24h and 30d uptime percentages",
|
||||||
|
"Auto-refresh every 60 seconds",
|
||||||
|
"Operational / Degraded / Down status indicators with animated pulse"
|
||||||
|
]
|
||||||
|
},
|
||||||
"testing_performed": [
|
"testing_performed": [
|
||||||
{
|
{
|
||||||
"test": "DOWN detection",
|
"test": "DOWN detection",
|
||||||
@@ -97,6 +125,11 @@
|
|||||||
"test": "Telegram alerting",
|
"test": "Telegram alerting",
|
||||||
"method": "Observed notifications during the Down -> Up transition above",
|
"method": "Observed notifications during the Down -> Up transition above",
|
||||||
"result": "Alerts received on Telegram bot for both state changes"
|
"result": "Alerts received on Telegram bot for both state changes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test": "Custom status page",
|
||||||
|
"method": "Ran nginx:alpine container locally on port 8080 serving the custom HTML page",
|
||||||
|
"result": "Page loaded correctly, fetched live data from Uptime Kuma API, displayed correct uptime percentages (99.93% for test app, 97.06% for test uptime kuma) and color-coded heartbeat bars"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم