617 أسطر
23 KiB
HTML
617 أسطر
23 KiB
HTML
<!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>
|