هذا الالتزام موجود في:
2026-09-17 22:11:14 +03:00
الأصل 7593b08483
التزام b9136d2a71
5 ملفات معدلة مع 712 إضافات و4 حذوفات

عرض الملف

@@ -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
عرض الملف

@@ -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
عرض الملف

@@ -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 (01); 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%">
⚠️ &nbsp;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 =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[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
عرض الملف

@@ -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"
} }
] ]
} }