هذا الالتزام موجود في:
2026-07-26 19:57:48 +03:00
التزام a6e65f3f37
29 ملفات معدلة مع 3538 إضافات و0 حذوفات

عرض الملف

@@ -0,0 +1,9 @@
FROM nginx:alpine
# Copy frontend files to Nginx's web root
COPY index.html /usr/share/nginx/html/
COPY style.css /usr/share/nginx/html/
COPY script.js /usr/share/nginx/html/
# Expose HTTP port
EXPOSE 80

عرض الملف

@@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>go-web-service · Cloud Monitoring</title>
<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=IBM+Plex+Mono:wght@400;500;600;700&family=Outfit:wght@400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Ambient Floating Cloud Atmosphere -->
<div class="cloud-bg" aria-hidden="true">
<div class="cloud-orb cloud-orb--1"></div>
<div class="cloud-orb cloud-orb--2"></div>
<div class="cloud-orb cloud-orb--3"></div>
</div>
<div class="app">
<!-- ===== Top bar ===== -->
<header class="topbar">
<div class="topbar__identity">
<div class="topbar__logo" title="Cloud Service">
<svg class="cloud-logo-icon" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>
</svg>
</div>
<span class="topbar__dot" id="topStatusDot" aria-hidden="true"></span>
<div class="topbar__text">
<h1 class="topbar__title">go-web-service</h1>
<p class="topbar__subtitle" id="topEndpoint">/health + /metrics &middot; hosted.ghaymah.systems</p>
</div>
</div>
<div class="topbar__actions">
<div class="topbar__updated">
<span class="label">last updated</span>
<span class="mono" id="lastUpdated">&mdash;</span>
</div>
<button class="btn btn--refresh" id="refreshBtn" type="button">
<svg class="btn__icon" id="refreshIcon" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M13.65 2.35A7.5 7.5 0 1 0 15.5 8h-1.5a6 6 0 1 1-1.76-4.24L10 6h5V1l-1.35 1.35Z"/>
</svg>
<span>Refresh</span>
</button>
</div>
</header>
<main class="content">
<!-- ===== KPI cards ===== -->
<section class="kpi-grid" aria-label="Key metrics">
<div class="card kpi kpi--status">
<div class="kpi__head">
<span class="kpi__label">Service Status</span>
<svg class="kpi__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<div class="kpi__status-row">
<span class="status-indicator" id="statusIndicator"></span>
<span class="kpi__status-text" id="statusText">Checking&hellip;</span>
</div>
<div class="kpi__foot" id="statusFoot">awaiting first check</div>
</div>
<div class="card kpi">
<div class="kpi__head">
<span class="kpi__label">Response Time</span>
<svg class="kpi__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div class="kpi__value mono" id="responseTimeValue">&mdash;</div>
<div class="kpi__foot" id="responseTimeFoot">current</div>
</div>
<div class="card kpi">
<div class="kpi__head">
<span class="kpi__label">Total Requests</span>
<svg class="kpi__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div class="kpi__value mono" id="totalRequestsValue">&mdash;</div>
<div class="kpi__foot" id="totalRequestsFoot">reported by /metrics</div>
</div>
</section>
<div class="banner banner--warn" id="networkWarning" hidden>
<strong>Network warning:</strong> Could not reach the service (CORS or connectivity). Metrics may be stale. Try the local fallback at <span class="mono">localhost:8080</span>.
</div>
<!-- ===== Charts ===== -->
<section class="charts-grid" aria-label="Live charts">
<div class="card panel">
<div class="panel__head">
<h2 class="panel__title">Response Time</h2>
<span class="panel__meta">last 30 live checks (ms)</span>
</div>
<div class="chart-wrap">
<canvas id="responseTimeChart" class="chart-canvas" aria-label="Response time line chart"></canvas>
</div>
</div>
<div class="card panel">
<div class="panel__head">
<h2 class="panel__title">Check Results</h2>
<span class="panel__meta">each block = one live check</span>
</div>
<div class="status-timeline-wrap">
<div class="status-timeline-summary">
<span class="status-timeline-stat status-timeline-stat--ok">
<span class="status-timeline-stat__dot" aria-hidden="true"></span>
<span class="mono" id="statusPassCount">0</span> passed
</span>
<span class="status-timeline-stat status-timeline-stat--bad">
<span class="status-timeline-stat__dot" aria-hidden="true"></span>
<span class="mono" id="statusFailCount">0</span> failed
</span>
</div>
<div class="status-timeline" id="statusTimeline" role="img" aria-label="Health check results timeline"></div>
<div class="status-timeline-axis">
<span id="statusTickStart">no data yet</span>
<span>now</span>
</div>
</div>
</div>
</section>
<!-- ===== Uptime & success ratio ===== -->
<section class="stats-grid" aria-label="Uptime and success ratio">
<div class="card panel panel--uptime">
<div class="panel__head">
<h2 class="panel__title">Uptime Window</h2>
<span class="panel__meta" id="uptimeWindowMeta">0 live checks recorded</span>
</div>
<div class="uptime-summary">
<span class="uptime-pct mono" id="uptimePct">&mdash;</span>
<span class="uptime-label">availability</span>
</div>
<div class="uptime-strip" id="uptimeStrip" role="img" aria-label="Uptime history strip"></div>
<div class="uptime-axis">
<span id="uptimeTickStart">no data yet</span>
<span>now</span>
</div>
</div>
<div class="card panel panel--doughnut">
<div class="panel__head">
<h2 class="panel__title">Success Ratio</h2>
<span class="panel__meta">session checks</span>
</div>
<div class="doughnut-wrap">
<canvas id="statsDoughnut" class="chart-canvas doughnut-canvas" aria-label="Success ratio doughnut chart"></canvas>
<div class="doughnut-center">
<span class="doughnut-center__value mono" id="doughnutCenterValue">&mdash;</span>
<span class="doughnut-center__label">success</span>
</div>
</div>
<div class="check-stats">
<div class="check-stats__row">
<span class="check-stats__label">Passed</span>
<span class="check-stats__value mono" id="successValue">&mdash;</span>
<span class="check-stats__foot" id="successRateFoot"></span>
</div>
<div class="check-stats__row">
<span class="check-stats__label">Failed</span>
<span class="check-stats__value mono" id="failedValue">&mdash;</span>
<span class="check-stats__foot" id="failedRateFoot"></span>
</div>
</div>
</div>
</section>
<!-- ===== Detail panels ===== -->
<section class="detail-grid" aria-label="Details">
<div class="card panel">
<div class="panel__head">
<h2 class="panel__title">Latest Health Check</h2>
</div>
<dl class="detail-list">
<div class="detail-list__row">
<dt>Endpoint</dt>
<dd id="lcEndpoint" class="mono">&mdash;</dd>
</div>
<div class="detail-list__row">
<dt>Status</dt>
<dd id="lcStatus">&mdash;</dd>
</div>
<div class="detail-list__row">
<dt>HTTP code</dt>
<dd id="lcCode" class="mono">&mdash;</dd>
</div>
<div class="detail-list__row">
<dt>Latency</dt>
<dd id="lcLatency" class="mono">&mdash;</dd>
</div>
<div class="detail-list__row">
<dt>Timestamp</dt>
<dd id="lcTime" class="mono">&mdash;</dd>
</div>
<div class="detail-list__row detail-list__row--full">
<dt>Raw response</dt>
<dd id="lcRaw" class="mono detail-list__raw">&mdash;</dd>
</div>
</dl>
</div>
<div class="card panel panel--events">
<div class="panel__head">
<h2 class="panel__title">Event Log</h2>
<span class="panel__meta" id="eventsMeta">0 checks logged</span>
</div>
<ul class="events-log" id="eventsLog"></ul>
</div>
</section>
</main>
<footer class="app-footer">
<span>
<svg class="footer-cloud-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>
</svg>
Sources: <span class="mono">/health</span> (status &amp; latency) + <span class="mono">/metrics</span> (total requests)
</span>
<span id="autoRefreshLabel">auto-refresh every 15s</span>
</footer>
</div>
<script src="script.js"></script>
</body>
</html>

عرض الملف

@@ -0,0 +1,690 @@
/* =========================================================================
SRE MONITORING DASHBOARD
Vanilla JS. No frameworks, no external chart libraries.
LIVE DATA SOURCES (NO LOG FILES)
-------------------------------
1. /health → { "status": "healthy" }
Live HTTP GET health check. Evaluates service status (Healthy/Unhealthy)
and measures real round-trip latency in milliseconds.
2. /metrics → { "requests": <int> }
Live HTTP GET metrics check. Retrieves authoritative total request count
tracked by the Go service backend.
========================================================================= */
const CONFIG = {
primaryBaseUrl: 'https://gheyma-app-2b88268529f9.hosted.ghaymah.systems',
fallbackBaseUrl: 'http://localhost:8080',
healthPath: '/health',
metricsPath: '/metrics',
maxHistoryPoints: 30, // recent health check data points for charts
autoRefreshMs: 15000,
uptimeBuckets: 48, // signature strip check count
requestTimeoutMs: 6000,
};
/* =========================================================================
Persistent Request Cache (localStorage)
Prevents Total Requests from resetting to 0 when Go server restarts/hibernates
========================================================================= */
const CACHE_KEYS = {
CUMULATIVE_TOTAL: 'ghaymah_cumulative_total_requests',
LAST_RAW_COUNT: 'ghaymah_last_raw_server_requests',
};
function getStoredTotalRequests() {
try {
const val = localStorage.getItem(CACHE_KEYS.CUMULATIVE_TOTAL);
return val ? parseInt(val, 10) || 0 : 0;
} catch (_) {
return 0;
}
}
function updateStoredTotalRequests(serverRequests) {
try {
let accumulatedTotal = getStoredTotalRequests();
const lastRawStr = localStorage.getItem(CACHE_KEYS.LAST_RAW_COUNT);
const lastRaw = lastRawStr !== null ? parseInt(lastRawStr, 10) || 0 : null;
if (lastRaw === null) {
accumulatedTotal = Math.max(accumulatedTotal, serverRequests);
} else if (serverRequests >= lastRaw) {
const delta = serverRequests - lastRaw;
accumulatedTotal += delta;
} else {
// Server restarted or hibernated (serverCount reset to smaller value)
accumulatedTotal += serverRequests;
}
localStorage.setItem(CACHE_KEYS.CUMULATIVE_TOTAL, accumulatedTotal.toString());
localStorage.setItem(CACHE_KEYS.LAST_RAW_COUNT, serverRequests.toString());
return accumulatedTotal;
} catch (_) {
return serverRequests;
}
}
/* =========================================================================
State
========================================================================= */
const state = {
history: [], // [{ok, latencyMs, httpCode, timestamp, raw, error, url}]
events: [], // [{time, level, message}]
totals: { total: getStoredTotalRequests(), success: 0, failed: 0, totalSource: 'metrics' },
uptimeBuckets: [], // [{state: 'ok'|'down'}]
isChecking: false,
metricsError: null,
activeBaseUrl: CONFIG.primaryBaseUrl,
};
/* =========================================================================
Live HTTP Fetch Helpers
========================================================================= */
async function fetchHealthFromUrl(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs);
const startTime = performance.now();
try {
const res = await fetch(url, { signal: controller.signal, cache: 'no-store' });
const endTime = performance.now();
const latencyMs = Math.round(endTime - startTime);
let body = null;
let rawText = '';
try {
rawText = await res.text();
body = JSON.parse(rawText);
} catch (_) {}
const isHealthy = res.ok && body && body.status === 'healthy';
return {
ok: isHealthy,
httpCode: res.status,
latencyMs,
timestamp: new Date(),
raw: rawText || (body ? JSON.stringify(body) : `HTTP ${res.status}`),
isNetworkError: false,
error: isHealthy ? null : `HTTP ${res.status}${body && body.status ? ` (${body.status})` : ''}`,
url,
};
} catch (err) {
const endTime = performance.now();
const latencyMs = Math.round(endTime - startTime);
const isAbort = err.name === 'AbortError';
const isNetworkError = !isAbort && err.name === 'TypeError';
return {
ok: false,
httpCode: null,
latencyMs,
timestamp: new Date(),
raw: null,
isNetworkError,
error: isAbort ? 'request timed out' : (isNetworkError ? 'network/CORS error — server unreachable' : (err.message || 'network error')),
url,
};
} finally {
clearTimeout(timeout);
}
}
async function fetchMetricsFromUrl(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs);
try {
const res = await fetch(url, { signal: controller.signal, cache: 'no-store' });
if (!res.ok) {
return { ok: false, requests: null, isNetworkError: false, error: `HTTP ${res.status}`, url };
}
const json = await res.json();
return {
ok: true,
requests: typeof json.requests === 'number' ? json.requests : null,
isNetworkError: false,
error: null,
url,
};
} catch (err) {
const isAbort = err.name === 'AbortError';
const isNetworkError = !isAbort && err.name === 'TypeError';
return {
ok: false,
requests: null,
isNetworkError,
error: isAbort ? 'request timed out' : (err.message || 'network error'),
url,
};
} finally {
clearTimeout(timeout);
}
}
/* =========================================================================
Smart Dual-Endpoint Fetch (Primary hosted endpoint -> Local fallback)
========================================================================= */
async function fetchHealthWithFallback() {
const bases = [CONFIG.primaryBaseUrl, CONFIG.fallbackBaseUrl].filter(Boolean);
let lastResult = null;
for (const base of bases) {
const url = `${base}${CONFIG.healthPath}`;
const result = await fetchHealthFromUrl(url);
if (result.ok) {
state.activeBaseUrl = base;
return result;
}
lastResult = result;
}
return lastResult;
}
async function fetchMetricsWithFallback() {
const base = state.activeBaseUrl || CONFIG.primaryBaseUrl;
const primaryUrl = `${base}${CONFIG.metricsPath}`;
const primaryRes = await fetchMetricsFromUrl(primaryUrl);
if (primaryRes.ok) return primaryRes;
const fallbackBase = base === CONFIG.primaryBaseUrl ? CONFIG.fallbackBaseUrl : CONFIG.primaryBaseUrl;
if (fallbackBase) {
const fallbackUrl = `${fallbackBase}${CONFIG.metricsPath}`;
const fallbackRes = await fetchMetricsFromUrl(fallbackUrl);
if (fallbackRes.ok) return fallbackRes;
}
return primaryRes;
}
async function refreshFromSources() {
const [healthRes, metricsRes] = await Promise.all([
fetchHealthWithFallback(),
fetchMetricsWithFallback(),
]);
const anyNetworkError = healthRes.isNetworkError || metricsRes.isNetworkError;
if (anyNetworkError) showNetworkWarning(); else hideNetworkWarning();
// Push live check result into history
state.history.push(healthRes);
if (state.history.length > CONFIG.maxHistoryPoints) {
state.history.shift();
}
// Update uptime bucket tracking
state.uptimeBuckets.push({ state: healthRes.ok ? 'ok' : 'down' });
if (state.uptimeBuckets.length > CONFIG.uptimeBuckets) {
state.uptimeBuckets.shift();
}
// Push new event
state.events.unshift({
time: healthRes.timestamp,
level: healthRes.ok ? 'ok' : 'bad',
message: healthRes.ok
? `Health check passed &mdash; <strong>${healthRes.latencyMs} ms</strong> (${healthRes.url})`
: `Health check failed &mdash; <strong>${healthRes.error}</strong>`,
});
if (state.events.length > 50) {
state.events.pop();
}
// Update success & failed check counts
const totalChecks = state.history.length;
const successChecks = state.history.filter(h => h.ok).length;
const failedChecks = totalChecks - successChecks;
state.totals.success = successChecks;
state.totals.failed = failedChecks;
if (metricsRes.ok && typeof metricsRes.requests === 'number') {
const cumulativeTotal = updateStoredTotalRequests(metricsRes.requests);
state.totals.total = cumulativeTotal;
state.totals.totalSource = 'metrics';
state.metricsError = null;
} else {
const cached = getStoredTotalRequests();
if (cached > 0) {
state.totals.total = cached;
}
state.metricsError = metricsRes.error || 'unavailable';
}
}
/* =========================================================================
Formatting helpers
========================================================================= */
const fmtTime = (d) => d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const fmtShortTime = (d) => d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const fmtPct = (n) => `${n.toFixed(2)}%`;
/* =========================================================================
Render — KPI Cards
========================================================================= */
function renderKPIs() {
const latest = state.history[state.history.length - 1];
const dot = document.getElementById('topStatusDot');
const indicator = document.getElementById('statusIndicator');
const statusText = document.getElementById('statusText');
const statusFoot = document.getElementById('statusFoot');
if (!latest) {
indicator.className = 'status-indicator';
statusText.textContent = 'Checking…';
statusText.style.color = 'var(--text-muted)';
dot.className = 'topbar__dot';
statusFoot.textContent = 'awaiting first check';
} else {
const healthy = latest.ok;
indicator.className = 'status-indicator ' + (healthy ? 'is-healthy' : 'is-unhealthy');
statusText.textContent = healthy ? 'Healthy' : 'Unhealthy';
statusText.style.color = healthy ? 'var(--ok)' : 'var(--bad)';
dot.className = 'topbar__dot' + (healthy ? '' : ' is-bad');
statusFoot.textContent = `checked ${fmtTime(latest.timestamp)}`;
}
const setText = (id, txt) => {
const el = document.getElementById(id);
if (el) el.textContent = txt;
};
setText('responseTimeValue', latest ? `${latest.latencyMs} ms` : '—');
const avgRecent = average(state.history.slice(-10).map(h => h.latencyMs));
setText('responseTimeFoot', state.history.length
? `avg last 10: ${Math.round(avgRecent)} ms`
: 'current');
setText('totalRequestsValue', state.totals.total.toLocaleString());
setText('totalRequestsFoot', state.totals.totalSource === 'metrics'
? 'reported by /metrics'
: `metrics: ${state.metricsError || 'unavailable'}`);
setText('successValue', state.totals.success.toLocaleString());
setText('failedValue', state.totals.failed.toLocaleString());
const checksTotal = state.totals.success + state.totals.failed;
const successRate = checksTotal ? (state.totals.success / checksTotal) * 100 : 100;
const failRate = checksTotal ? (state.totals.failed / checksTotal) * 100 : 0;
setText('successRateFoot', fmtPct(successRate) + ' of checks');
setText('failedRateFoot', fmtPct(failRate) + ' of checks');
setText('lastCheckValue', latest ? fmtTime(latest.timestamp) : '—');
setText('lastCheckFoot', latest
? (latest.ok ? `HTTP ${latest.httpCode}` : `HTTP ${latest.httpCode ?? '—'} · ${latest.error}`)
: '—');
}
function average(arr) {
if (!arr.length) return 0;
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
/* =========================================================================
Render — Latest Health Check Panel
========================================================================= */
function renderLatestCheck() {
const latest = state.history[state.history.length - 1];
const set = (id, text, cls) => {
const el = document.getElementById(id);
if (el) {
el.textContent = text;
el.className = cls ? 'dd-' + cls : '';
}
};
if (!latest) return;
set('lcEndpoint', latest.url || `${state.activeBaseUrl}${CONFIG.healthPath}`);
set('lcStatus', latest.ok ? 'healthy' : 'unhealthy', latest.ok ? 'ok' : 'bad');
set('lcCode', latest.httpCode ?? '—');
set('lcLatency', `${latest.latencyMs} ms`);
set('lcTime', latest.timestamp.toLocaleString());
set('lcRaw', latest.raw || latest.error || '—');
}
/* =========================================================================
Render — Events Log
========================================================================= */
function renderEvents() {
const list = document.getElementById('eventsLog');
if (!list) return;
list.innerHTML = '';
state.events.slice(0, 25).forEach(ev => {
const li = document.createElement('li');
li.className = 'events-log__row';
li.innerHTML = `
<span class="events-log__dot events-log__dot--${ev.level}"></span>
<span class="events-log__time">${fmtShortTime(ev.time)}</span>
<span class="events-log__msg">${ev.message}</span>
`;
list.appendChild(li);
});
const meta = document.getElementById('eventsMeta');
if (meta) meta.textContent = `${state.events.length} checks logged`;
}
/* =========================================================================
Render — Uptime Strip & Percentage
========================================================================= */
function renderUptime() {
const strip = document.getElementById('uptimeStrip');
if (!strip) return;
strip.innerHTML = '';
const buckets = state.uptimeBuckets;
const padding = Math.max(CONFIG.uptimeBuckets - buckets.length, 0);
for (let i = 0; i < padding; i++) {
const cell = document.createElement('div');
cell.className = 'uptime-cell is-unknown';
strip.appendChild(cell);
}
buckets.forEach(b => {
const cell = document.createElement('div');
cell.className = 'uptime-cell' + (b.state === 'ok' ? '' : ` is-${b.state}`);
strip.appendChild(cell);
});
const okCount = buckets.filter(b => b.state === 'ok').length;
const pct = buckets.length ? (okCount / buckets.length) * 100 : 0;
const elPct = document.getElementById('uptimePct');
if (elPct) elPct.textContent = buckets.length ? fmtPct(pct) : '—';
const meta = document.getElementById('uptimeWindowMeta');
if (meta) meta.textContent = `${buckets.length} live check${buckets.length === 1 ? '' : 's'} recorded`;
const now = new Date();
const start = new Date(now.getTime() - CONFIG.uptimeBuckets * CONFIG.autoRefreshMs);
const elStart = document.getElementById('uptimeTickStart');
if (elStart) elStart.textContent = buckets.length ? fmtShortTime(start) : 'no data yet';
}
/* =========================================================================
Canvas Charts — Shared Setup
========================================================================= */
function prepCanvas(canvas) {
if (!canvas) return null;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const w = Math.max(rect.width, 10);
const h = Math.max(rect.height, 10);
canvas.width = w * dpr;
canvas.height = h * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
return { ctx, w, h };
}
const CSSVAR = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
function drawChartPlaceholder(ctx, w, h, message) {
ctx.fillStyle = CSSVAR('--text-dim');
ctx.font = '12px Outfit, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(message, w / 2, h / 2);
}
/* ---- Line chart: Response Time over recent health checks ---- */
function drawResponseTimeChart() {
const canvas = document.getElementById('responseTimeChart');
if (!canvas) return;
const prep = prepCanvas(canvas);
if (!prep) return;
const { ctx, w, h } = prep;
const data = state.history;
if (!data.length) {
drawChartPlaceholder(ctx, w, h, 'Collecting data…');
return;
}
const padL = 34, padR = 10, padT = 12, padB = 20;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
const values = data.map(d => d.latencyMs);
const maxV = Math.max(...values) * 1.15;
const minV = 0;
const xFor = (i) => padL + (data.length === 1 ? plotW / 2 : (i / (data.length - 1)) * plotW);
const yFor = (v) => padT + plotH - ((v - minV) / (maxV - minV || 1)) * plotH;
// Gridlines + y-axis labels
ctx.strokeStyle = CSSVAR('--border-soft');
ctx.fillStyle = CSSVAR('--text-dim');
ctx.font = '10px IBM Plex Mono, monospace';
ctx.textBaseline = 'middle';
const gridLines = 4;
for (let i = 0; i <= gridLines; i++) {
const v = (maxV / gridLines) * i;
const y = yFor(v);
ctx.beginPath();
ctx.moveTo(padL, y);
ctx.lineTo(w - padR, y);
ctx.lineWidth = 1;
ctx.stroke();
ctx.textAlign = 'right';
ctx.fillText(Math.round(v), padL - 8, y);
}
// x-axis labels (first / mid / last)
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
[0, Math.floor((data.length - 1) / 2), data.length - 1].forEach(i => {
if (i < 0 || i >= data.length) return;
ctx.fillText(fmtShortTime(data[i].timestamp), xFor(i), h - padB + 6);
});
// Gradient area fill
const grad = ctx.createLinearGradient(0, padT, 0, padT + plotH);
grad.addColorStop(0, 'rgba(91,157,249,0.28)');
grad.addColorStop(1, 'rgba(91,157,249,0.0)');
ctx.beginPath();
data.forEach((d, i) => {
const x = xFor(i), y = yFor(d.latencyMs);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.lineTo(xFor(data.length - 1), padT + plotH);
ctx.lineTo(xFor(0), padT + plotH);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// Line
ctx.beginPath();
data.forEach((d, i) => {
const x = xFor(i), y = yFor(d.latencyMs);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.strokeStyle = CSSVAR('--info');
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.stroke();
// Data points
data.forEach((d, i) => {
const x = xFor(i), y = yFor(d.latencyMs);
ctx.beginPath();
ctx.arc(x, y, d.ok ? 2.2 : 3.4, 0, Math.PI * 2);
ctx.fillStyle = d.ok ? CSSVAR('--info') : CSSVAR('--bad');
ctx.fill();
});
}
/* ---- Status timeline: pass/fail history ---- */
function renderStatusTimeline() {
const strip = document.getElementById('statusTimeline');
if (!strip) return;
const data = state.history;
strip.innerHTML = '';
const passEl = document.getElementById('statusPassCount');
const failEl = document.getElementById('statusFailCount');
const tickStart = document.getElementById('statusTickStart');
const passCount = data.filter(d => d.ok).length;
const failCount = data.length - passCount;
if (passEl) passEl.textContent = passCount.toLocaleString();
if (failEl) failEl.textContent = failCount.toLocaleString();
if (!data.length) {
if (tickStart) tickStart.textContent = 'no data yet';
return;
}
const padding = Math.max(CONFIG.maxHistoryPoints - data.length, 0);
for (let i = 0; i < padding; i++) {
const cell = document.createElement('div');
cell.className = 'status-timeline__cell is-pending';
cell.title = 'Awaiting check';
cell.setAttribute('aria-label', 'Awaiting check');
strip.appendChild(cell);
}
data.forEach((d) => {
const cell = document.createElement('div');
cell.className = 'status-timeline__cell' + (d.ok ? '' : ' is-fail');
const label = d.ok ? 'Healthy' : 'Unhealthy';
const tip = `${fmtTime(d.timestamp)} · ${label} · ${d.latencyMs} ms`;
cell.title = tip;
cell.setAttribute('aria-label', tip);
cell.tabIndex = 0;
strip.appendChild(cell);
});
if (tickStart) tickStart.textContent = fmtShortTime(data[0].timestamp);
}
/* ---- Doughnut chart: Success vs Failure Ratio ---- */
function drawDoughnut() {
const canvas = document.getElementById('statsDoughnut');
if (!canvas) return;
const prep = prepCanvas(canvas);
if (!prep) return;
const { ctx, w, h } = prep;
const { success, failed } = state.totals;
const total = success + failed;
const cx = w / 2, cy = h / 2;
const radius = Math.min(w, h) / 2 - 8;
const thickness = radius * 0.34;
if (!total) {
ctx.strokeStyle = CSSVAR('--border-soft');
ctx.lineWidth = thickness;
ctx.beginPath();
ctx.arc(cx, cy, radius - thickness / 2, 0, Math.PI * 2);
ctx.stroke();
drawChartPlaceholder(ctx, w, h, 'No checks yet');
return;
}
const successFrac = success / total;
const segments = [
{ frac: successFrac, color: CSSVAR('--ok') },
{ frac: 1 - successFrac, color: CSSVAR('--bad') },
];
let start = -Math.PI / 2;
segments.forEach(seg => {
if (seg.frac <= 0) return;
const end = start + seg.frac * Math.PI * 2;
ctx.beginPath();
ctx.arc(cx, cy, radius - thickness / 2, start, end);
ctx.lineWidth = thickness;
ctx.strokeStyle = seg.color;
ctx.lineCap = segments.length > 1 && seg.frac < 1 ? 'butt' : 'round';
ctx.stroke();
start = end;
});
const centerVal = document.getElementById('doughnutCenterValue');
if (centerVal) centerVal.textContent = fmtPct(successFrac * 100);
}
/* =========================================================================
Network/CORS Warning Banner
========================================================================= */
function showNetworkWarning() {
const el = document.getElementById('networkWarning');
if (el) el.hidden = false;
}
function hideNetworkWarning() {
const el = document.getElementById('networkWarning');
if (el) el.hidden = true;
}
/* =========================================================================
Master Render
========================================================================= */
function renderAll() {
renderKPIs();
renderLatestCheck();
renderEvents();
renderUptime();
drawResponseTimeChart();
renderStatusTimeline();
drawDoughnut();
const elUpdated = document.getElementById('lastUpdated');
if (elUpdated) elUpdated.textContent = new Date().toLocaleTimeString();
}
/* =========================================================================
Refresh Flow
========================================================================= */
async function doRefresh() {
if (state.isChecking) return;
state.isChecking = true;
const btn = document.getElementById('refreshBtn');
btn.classList.add('is-spinning');
btn.disabled = true;
try {
await refreshFromSources();
renderAll();
} finally {
state.isChecking = false;
btn.classList.remove('is-spinning');
btn.disabled = false;
}
}
/* =========================================================================
Init
========================================================================= */
function init() {
renderAll(); // Initial empty state paint
document.getElementById('refreshBtn').addEventListener('click', doRefresh);
window.addEventListener('resize', debounce(renderAll, 150));
doRefresh(); // Trigger first live check
setInterval(doRefresh, CONFIG.autoRefreshMs);
}
function debounce(fn, ms) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
document.addEventListener('DOMContentLoaded', init);

عرض الملف

@@ -0,0 +1,915 @@
/* ========================================================= GHAYMAH CLOUD THEME DESIGN SYSTEM
Modern Atmospheric Glassmorphism & Sky Blue Accents
========================================================= */
:root {
/* Cloud Palette */
--bg: #070B16;
--bg-gradient: radial-gradient(ellipse at 50% 0%, #0F1932 0%, #070B16 75%);
--surface: rgba(15, 23, 42, 0.65);
--surface-raised: rgba(25, 38, 68, 0.55);
--surface-hover: rgba(30, 48, 86, 0.75);
--border: rgba(56, 139, 253, 0.18);
--border-glow: rgba(56, 139, 253, 0.45);
--border-soft: rgba(255, 255, 255, 0.06);
--text: #F0F6FC;
--text-muted: #8B949E;
--text-dim: #6E7681;
--cloud-accent: #388BFD;
--cloud-cyan: #00E5FF;
--cloud-light: #C9D1D9;
--ok: #39D353;
--ok-glow: rgba(57, 211, 83, 0.25);
--bad: #F85149;
--bad-glow: rgba(248, 81, 73, 0.25);
--warn: #D29922;
--info: #58A6FF;
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--font-ui: 'Outfit', 'Plus Jakarta Sans', -apple-system, sans-serif;
--font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
min-height: 100vh;
background: var(--bg);
background-image: var(--bg-gradient);
color: var(--text);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
.mono {
font-family: var(--font-mono);
}
/* =========================================================
Ambient Floating Cloud Background
========================================================= */
.cloud-bg {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.cloud-orb {
position: absolute;
border-radius: 50%;
filter: blur(90px);
opacity: 0.28;
animation: floatCloud 24s ease-in-out infinite alternate;
}
.cloud-orb--1 {
width: 500px;
height: 500px;
background: radial-gradient(circle, #388BFD 0%, rgba(0,229,255,0.4) 100%);
top: -150px;
left: 10%;
}
.cloud-orb--2 {
width: 600px;
height: 600px;
background: radial-gradient(circle, #1F6FEB 0%, rgba(88,166,255,0.3) 100%);
bottom: -200px;
right: 5%;
animation-delay: -8s;
animation-duration: 30s;
}
.cloud-orb--3 {
width: 350px;
height: 350px;
background: radial-gradient(circle, #00E5FF 0%, rgba(56,139,253,0.3) 100%);
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: -15s;
animation-duration: 22s;
opacity: 0.18;
}
@keyframes floatCloud {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(40px, 30px) scale(1.08); }
100% { transform: translate(-30px, -20px) scale(0.95); }
}
/* =========================================================
Layout Shell
========================================================= */
.app {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px 60px;
}
.content {
display: flex;
flex-direction: column;
gap: 24px;
margin-top: 28px;
}
.banner {
margin-top: 20px;
padding: 14px 18px;
border-radius: var(--radius-md);
font-size: 13px;
line-height: 1.5;
backdrop-filter: blur(12px);
}
.banner--warn {
background: rgba(210, 153, 34, 0.12);
border: 1px solid rgba(210, 153, 34, 0.35);
color: #F0E2A0;
}
.banner--warn strong {
color: var(--warn);
}
/* =========================================================
Top Bar (Cloud Header)
========================================================= */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 20px 26px;
background: var(--surface);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.08);
flex-wrap: wrap;
transition: border-color 0.3s ease;
}
.topbar:hover {
border-color: var(--border-glow);
}
.topbar__identity {
display: flex;
align-items: center;
gap: 14px;
}
.topbar__logo {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(56, 139, 253, 0.25), rgba(0, 229, 255, 0.15));
border: 1px solid rgba(56, 139, 253, 0.35);
color: var(--cloud-cyan);
box-shadow: 0 4px 12px rgba(0, 229, 255, 0.15);
}
.cloud-logo-icon {
filter: drop-shadow(0 2px 6px rgba(0, 229, 255, 0.4));
}
.topbar__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 12px var(--ok);
flex-shrink: 0;
transition: background 0.3s, box-shadow 0.3s;
}
.topbar__dot.is-bad {
background: var(--bad);
box-shadow: 0 0 12px var(--bad);
}
.topbar__title {
font-size: 19px;
font-weight: 700;
margin: 0;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #FFFFFF 30%, #A5C7FF 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.topbar__subtitle {
margin: 3px 0 0;
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.topbar__actions {
display: flex;
align-items: center;
gap: 22px;
}
.topbar__updated {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.topbar__updated .label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
font-weight: 600;
}
.topbar__updated .mono {
font-size: 12.5px;
color: var(--cloud-light);
}
.btn {
display: inline-flex;
align-items: center;
gap: 9px;
background: linear-gradient(135deg, rgba(56, 139, 253, 0.18), rgba(25, 38, 68, 0.6));
border: 1px solid var(--border);
color: var(--text);
padding: 10px 18px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
cursor: pointer;
backdrop-filter: blur(8px);
transition: all 0.25s ease;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
}
.btn:hover {
border-color: var(--cloud-accent);
background: linear-gradient(135deg, rgba(56, 139, 253, 0.3), rgba(0, 229, 255, 0.2));
color: #FFFFFF;
box-shadow: 0 6px 20px rgba(56, 139, 253, 0.25);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(1px);
}
.btn__icon {
color: var(--cloud-cyan);
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn.is-spinning .btn__icon {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* =========================================================
KPI Grid & Cloud Cards
========================================================= */
.kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.card {
background: var(--surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 24px 26px;
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--cloud-accent), transparent);
opacity: 0;
transition: opacity 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
border-color: var(--border-glow);
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.45), 0 0 24px rgba(56, 139, 253, 0.15);
}
.card:hover::before {
opacity: 1;
}
.kpi {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 140px;
gap: 12px;
}
.kpi__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.kpi__label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
font-weight: 700;
}
.kpi__icon {
color: var(--cloud-accent);
opacity: 0.85;
}
.kpi__value {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1;
color: #FFFFFF;
}
.kpi__foot {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Status Indicator Card */
.kpi__status-row {
display: flex;
align-items: center;
gap: 12px;
}
.status-indicator {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--text-dim);
flex-shrink: 0;
position: relative;
}
.status-indicator.is-healthy {
background: var(--ok);
box-shadow: 0 0 16px var(--ok-glow);
}
.status-indicator.is-healthy::after {
content: '';
position: absolute;
inset: -5px;
border-radius: 50%;
border: 1.5px solid var(--ok);
animation: pulse-ring 2.2s ease-out infinite;
}
.status-indicator.is-unhealthy {
background: var(--bad);
box-shadow: 0 0 16px var(--bad-glow);
}
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 0.9; }
100% { transform: scale(1.7); opacity: 0; }
}
.kpi__status-text {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
/* =========================================================
Panels, Charts & Data Viz
========================================================= */
.charts-grid,
.stats-grid,
.detail-grid {
display: grid;
gap: 20px;
}
.charts-grid {
grid-template-columns: repeat(2, 1fr);
}
.stats-grid,
.detail-grid {
grid-template-columns: 1.4fr 1fr;
}
.panel {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.panel__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.panel__title {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--cloud-light);
}
.panel__meta {
font-size: 11px;
color: var(--text-dim);
font-family: var(--font-mono);
}
.chart-wrap {
position: relative;
width: 100%;
height: 220px;
}
.chart-canvas {
display: block;
width: 100%;
height: 100%;
}
/* Check results timeline */
.status-timeline-wrap {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 220px;
justify-content: center;
}
.status-timeline-summary {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.status-timeline-stat {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-muted);
}
.status-timeline-stat .mono {
font-size: 18px;
font-weight: 700;
color: #FFFFFF;
}
.status-timeline-stat--ok .mono {
color: var(--ok);
}
.status-timeline-stat--bad .mono {
color: var(--bad);
}
.status-timeline-stat__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-timeline-stat--ok .status-timeline-stat__dot {
background: var(--ok);
box-shadow: 0 0 8px var(--ok-glow);
}
.status-timeline-stat--bad .status-timeline-stat__dot {
background: var(--bad);
box-shadow: 0 0 8px var(--bad-glow);
}
.status-timeline {
display: flex;
align-items: stretch;
gap: 4px;
height: 48px;
padding: 4px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid var(--border-soft);
border-radius: var(--radius-md);
overflow-x: auto;
scrollbar-width: thin;
}
.status-timeline:empty::after {
content: 'Collecting data…';
display: flex;
align-items: center;
justify-content: center;
width: 100%;
font-size: 12px;
color: var(--text-dim);
}
.status-timeline__cell {
flex: 1 1 0;
min-width: 6px;
max-width: 24px;
border-radius: 4px;
background: var(--ok);
opacity: 0.9;
cursor: default;
transition: transform 0.15s ease, opacity 0.15s ease, box-shadow 0.15s ease;
}
.status-timeline__cell:hover,
.status-timeline__cell:focus-visible {
transform: scaleY(1.08);
opacity: 1;
box-shadow: 0 0 10px var(--ok-glow);
outline: none;
}
.status-timeline__cell.is-fail {
background: var(--bad);
}
.status-timeline__cell.is-fail:hover,
.status-timeline__cell.is-fail:focus-visible {
box-shadow: 0 0 10px var(--bad-glow);
}
.status-timeline__cell.is-pending {
background: rgba(255, 255, 255, 0.08);
opacity: 1;
}
.status-timeline__cell.is-pending:hover,
.status-timeline__cell.is-pending:focus-visible {
box-shadow: none;
transform: none;
}
.status-timeline-axis {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-dim);
font-family: var(--font-mono);
}
.doughnut-wrap {
position: relative;
width: 100%;
max-width: 180px;
height: 180px;
margin: 0 auto;
}
.doughnut-canvas {
width: 100%;
height: 100%;
}
.doughnut-center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
}
.doughnut-center__value {
font-size: 22px;
font-weight: 700;
color: #FFFFFF;
line-height: 1;
}
.doughnut-center__label {
margin-top: 4px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
font-weight: 600;
}
.check-stats {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--border-soft);
}
.check-stats__row {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
}
.check-stats__label {
color: var(--text-muted);
min-width: 52px;
}
.check-stats__value {
font-weight: 600;
color: #FFFFFF;
}
.check-stats__foot {
font-size: 11px;
color: var(--text-dim);
font-family: var(--font-mono);
}
/* Uptime strip */
.uptime-summary {
display: flex;
align-items: baseline;
gap: 10px;
}
.uptime-pct {
font-size: 36px;
font-weight: 700;
color: var(--ok);
line-height: 1;
letter-spacing: -0.03em;
}
.uptime-label {
font-size: 12px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.uptime-strip {
display: grid;
grid-template-columns: repeat(48, 1fr);
gap: 3px;
height: 36px;
align-items: stretch;
}
.uptime-cell {
border-radius: 3px;
background: var(--ok);
opacity: 0.85;
min-width: 0;
transition: opacity 0.2s ease;
}
.uptime-cell.is-down {
background: var(--bad);
}
.uptime-cell.is-unknown {
background: rgba(255, 255, 255, 0.08);
opacity: 1;
}
.uptime-axis {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-dim);
font-family: var(--font-mono);
}
/* Detail list */
.detail-list {
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-list__row {
display: grid;
grid-template-columns: 110px 1fr;
gap: 12px;
align-items: start;
}
.detail-list__row--full {
grid-template-columns: 1fr;
}
.detail-list dt {
margin: 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
font-weight: 600;
}
.detail-list dd {
margin: 0;
font-size: 13px;
color: var(--cloud-light);
word-break: break-all;
}
.detail-list dd.dd-ok {
color: var(--ok);
font-weight: 600;
text-transform: capitalize;
}
.detail-list dd.dd-bad {
color: var(--bad);
font-weight: 600;
text-transform: capitalize;
}
.detail-list__raw {
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
font-size: 12px;
line-height: 1.5;
max-height: 120px;
overflow-y: auto;
}
/* Events log */
.panel--events {
min-height: 320px;
}
.events-log {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow-y: auto;
}
.events-log__row {
display: grid;
grid-template-columns: 8px 52px 1fr;
gap: 10px;
align-items: start;
padding: 8px 10px;
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.18);
border: 1px solid var(--border-soft);
font-size: 12px;
line-height: 1.45;
}
.events-log__dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 4px;
flex-shrink: 0;
}
.events-log__dot--ok {
background: var(--ok);
box-shadow: 0 0 8px var(--ok-glow);
}
.events-log__dot--bad {
background: var(--bad);
box-shadow: 0 0 8px var(--bad-glow);
}
.events-log__time {
font-family: var(--font-mono);
color: var(--text-dim);
white-space: nowrap;
}
.events-log__msg {
color: var(--cloud-light);
}
.events-log__msg strong {
color: #FFFFFF;
font-weight: 600;
}
/* =========================================================
Footer
========================================================= */
.app-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid var(--border-soft);
font-size: 12px;
color: var(--text-dim);
font-family: var(--font-mono);
}
.footer-cloud-icon {
vertical-align: middle;
margin-right: 4px;
color: var(--cloud-accent);
}
/* =========================================================
Responsive Layout
========================================================= */
@media (max-width: 900px) {
.kpi-grid {
grid-template-columns: 1fr;
}
.charts-grid,
.stats-grid,
.detail-grid {
grid-template-columns: 1fr;
}
.app {
padding: 20px 16px 40px;
}
.topbar {
padding: 16px 20px;
}
.topbar__actions {
width: 100%;
justify-content: space-between;
}
.kpi__value {
font-size: 22px;
}
.uptime-strip {
grid-template-columns: repeat(24, 1fr);
}
}
@media (max-width: 480px) {
.app {
padding: 20px 14px 32px;
}
.topbar {
padding: 14px;
}
}
@media (prefers-reduced-motion: reduce){
*{ animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}