386 أسطر
14 KiB
HTML
386 أسطر
14 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ar" dir="rtl">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>mithal.space — لوحة المراقبة</title>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.4/chart.umd.min.js"></script>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root{
|
|
--bg: #0a0e17;
|
|
--panel: #121826;
|
|
--panel-border: #202838;
|
|
--text: #e7ebf3;
|
|
--muted: #7c8aa5;
|
|
--ok: #34d399;
|
|
--warn: #f2b84b;
|
|
--danger: #ef5a6f;
|
|
--accent: #4fd1ff;
|
|
--mono: 'JetBrains Mono', monospace;
|
|
--sans: 'Inter', sans-serif;
|
|
}
|
|
*{ box-sizing:border-box; }
|
|
body{
|
|
margin:0;
|
|
background: radial-gradient(1200px 600px at 80% -10%, #12203a 0%, var(--bg) 55%);
|
|
color: var(--text);
|
|
font-family: var(--sans);
|
|
min-height:100vh;
|
|
padding: 28px 20px 60px;
|
|
}
|
|
header{
|
|
display:flex; align-items:center; justify-content:space-between;
|
|
max-width:1180px; margin:0 auto 24px; flex-wrap:wrap; gap:12px;
|
|
}
|
|
.brand{ display:flex; align-items:center; gap:12px; }
|
|
.pulse-dot{
|
|
width:14px; height:14px; border-radius:50%;
|
|
background: var(--ok);
|
|
box-shadow: 0 0 0 0 rgba(52,211,153,0.6);
|
|
animation: pulse 1.8s infinite;
|
|
}
|
|
.pulse-dot.down{ background: var(--danger); box-shadow:0 0 0 0 rgba(239,90,111,0.6); animation: pulse-down 1.8s infinite; }
|
|
@keyframes pulse{
|
|
0%{ box-shadow: 0 0 0 0 rgba(52,211,153,0.55); }
|
|
70%{ box-shadow: 0 0 0 12px rgba(52,211,153,0); }
|
|
100%{ box-shadow: 0 0 0 0 rgba(52,211,153,0); }
|
|
}
|
|
@keyframes pulse-down{
|
|
0%{ box-shadow: 0 0 0 0 rgba(239,90,111,0.55); }
|
|
70%{ box-shadow: 0 0 0 12px rgba(239,90,111,0); }
|
|
100%{ box-shadow: 0 0 0 0 rgba(239,90,111,0); }
|
|
}
|
|
h1{ font-size:20px; margin:0; letter-spacing:0.2px; }
|
|
h1 span{ color: var(--muted); font-weight:400; font-size:14px; display:block; font-family: var(--mono); margin-top:2px; }
|
|
.controls{ display:flex; gap:8px; align-items:center; }
|
|
.controls label{
|
|
font-family: var(--mono); font-size:12px; color:var(--muted);
|
|
background: var(--panel); border:1px solid var(--panel-border);
|
|
padding:8px 12px; border-radius:8px; cursor:pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
.controls label:hover{ background: #1a2234; color: var(--text); }
|
|
.controls input[type=file]{ display:none; }
|
|
.last-updated{ font-family: var(--mono); font-size:12px; color:var(--muted); }
|
|
|
|
main{ max-width:1180px; margin:0 auto; display:grid; gap:16px; grid-template-columns: repeat(12,1fr); }
|
|
|
|
.card{
|
|
background: var(--panel); border:1px solid var(--panel-border);
|
|
border-radius:14px; padding:18px 20px;
|
|
}
|
|
.card h2{
|
|
margin:0 0 10px; font-size:12px; text-transform:uppercase; letter-spacing:1px;
|
|
color: var(--muted); font-weight:600;
|
|
}
|
|
.metric{ font-family: var(--mono); font-size:34px; font-weight:700; line-height:1; }
|
|
.metric small{ font-size:13px; color:var(--muted); font-weight:400; margin-right:6px; }
|
|
.sub{ margin-top:8px; font-family: var(--mono); font-size:12px; color:var(--muted); word-break: break-word; }
|
|
.ok-text{ color:var(--ok); } .warn-text{ color:var(--warn); } .danger-text{ color:var(--danger); }
|
|
|
|
.col-4{ grid-column: span 4; }
|
|
.col-12{ grid-column: span 12; }
|
|
.col-6{ grid-column: span 6; }
|
|
|
|
@media (max-width: 800px){
|
|
.col-4, .col-6{ grid-column: span 12; }
|
|
}
|
|
|
|
canvas{ max-height:260px; width:100%; }
|
|
|
|
table{ width:100%; border-collapse:collapse; font-family: var(--mono); font-size:13px; }
|
|
th, td{ text-align:right; padding:9px 8px; border-bottom:1px solid var(--panel-border); }
|
|
th{ color:var(--muted); font-weight:500; font-size:11px; text-transform:uppercase; letter-spacing:0.5px; }
|
|
.badge{ display:inline-block; padding:2px 9px; border-radius:20px; font-size:11px; font-weight:700; }
|
|
.badge.up{ background: rgba(52,211,153,0.15); color:var(--ok); }
|
|
.badge.down{ background: rgba(239,90,111,0.15); color:var(--danger); }
|
|
|
|
.empty-state{
|
|
text-align:center; padding:60px 20px; color:var(--muted); font-family: var(--mono); font-size:13px;
|
|
}
|
|
.empty-state b{ color:var(--text); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<div class="brand">
|
|
<div class="pulse-dot" id="pulseDot"></div>
|
|
<h1>mithal.space<span>لوحة مراقبة الموقع — Uptime · Latency · SSL · DNS · Search</span></h1>
|
|
</div>
|
|
<div class="controls">
|
|
<span class="last-updated" id="lastUpdated">لا توجد بيانات بعد</span>
|
|
<label for="fileInput">📂 تحميل data.jsonl</label>
|
|
<input type="file" id="fileInput" accept=".jsonl,.json,.txt">
|
|
</div>
|
|
</header>
|
|
|
|
<main id="dashboard">
|
|
<div class="card col-12" id="emptyCard" style="display: block;">
|
|
<div class="empty-state" id="emptyState">
|
|
شغّل <b>monitor.py</b> لجمع البيانات، ثم اخدم هذا المجلد عبر خادم محلي
|
|
(مثال: <b>python3 -m http.server 8080</b>) ليتم تحميل <b>data.jsonl</b> تلقائياً،
|
|
أو استخدم زر «تحميل data.jsonl» أعلاه لاختيار الملف يدوياً.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card col-4 main-content" style="display:none;">
|
|
<h2>Uptime (24 ساعة)</h2>
|
|
<div class="metric" id="uptimeMetric">—</div>
|
|
<div class="sub" id="uptimeSub">—</div>
|
|
</div>
|
|
|
|
<div class="card col-4 main-content" style="display:none;">
|
|
<h2>شهادة SSL</h2>
|
|
<div class="metric" id="sslMetric">—</div>
|
|
<div class="sub" id="sslSub">—</div>
|
|
</div>
|
|
|
|
<div class="card col-4 main-content" style="display:none;">
|
|
<h2>DNS (متوسط 24س) / آخر بحث</h2>
|
|
<div class="metric" id="dnsMetric">—</div>
|
|
<div class="sub" id="dnsSub">—</div>
|
|
</div>
|
|
|
|
<div class="card col-12 main-content" style="display:none;">
|
|
<h2>زمن الاستجابة — آخر ساعة (Latency)</h2>
|
|
<canvas id="latencyChart"></canvas>
|
|
</div>
|
|
|
|
<div class="card col-12 main-content" style="display:none;">
|
|
<h2>سجل آخر 10 فحوصات</h2>
|
|
<table>
|
|
<thead>
|
|
<tr><th>الوقت</th><th>الحالة</th><th>Latency</th><th>DNS</th><th>SSL (أيام)</th><th>Search</th></tr>
|
|
</thead>
|
|
<tbody id="logBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
const REFRESH_MS = 60000; // إعادة تحميل تلقائي كل دقيقة
|
|
|
|
let records = [];
|
|
let latencyChart = null;
|
|
|
|
function parseJsonl(text){
|
|
return text.split('\n')
|
|
.map(l => l.trim())
|
|
.filter(Boolean)
|
|
.map(l => { try { return JSON.parse(l); } catch(e){ return null; } })
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function fmtTime(iso){
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return iso;
|
|
const hours = String(d.getHours()).padStart(2, '0');
|
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|
const seconds = String(d.getSeconds()).padStart(2, '0');
|
|
return `${d.toLocaleDateString('ar-EG')} ${hours}:${minutes}:${seconds}`;
|
|
}
|
|
|
|
function fmtShortTime(iso){
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return iso;
|
|
const hours = String(d.getHours()).padStart(2, '0');
|
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|
const seconds = String(d.getSeconds()).padStart(2, '0');
|
|
return `${hours}:${minutes}:${seconds}`;
|
|
}
|
|
|
|
function statusColorClass(days){
|
|
if (days === null || days === undefined) return 'danger-text';
|
|
if (days <= 7) return 'danger-text';
|
|
if (days <= 30) return 'warn-text';
|
|
return 'ok-text';
|
|
}
|
|
|
|
function initChart() {
|
|
const chartCanvas = document.getElementById('latencyChart');
|
|
if (!chartCanvas) return;
|
|
const ctx = chartCanvas.getContext('2d');
|
|
latencyChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: [],
|
|
datasets: [
|
|
{
|
|
label: 'HTTP latency (ms)',
|
|
data: [],
|
|
borderColor: '#4fd1ff',
|
|
backgroundColor: 'rgba(79,209,255,0.08)',
|
|
tension: 0.3,
|
|
pointRadius: 2,
|
|
spanGaps: true,
|
|
fill: true,
|
|
},
|
|
{
|
|
label: 'Search latency (ms)',
|
|
data: [],
|
|
borderColor: '#f2b84b',
|
|
backgroundColor: 'rgba(242,184,75,0.06)',
|
|
tension: 0.3,
|
|
pointRadius: 2,
|
|
spanGaps: true,
|
|
fill: true,
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { labels: { color: '#7c8aa5', font: { family: 'JetBrains Mono', size: 11 } } }
|
|
},
|
|
scales: {
|
|
x: { ticks: { color: '#7c8aa5', maxTicksLimit: 12 }, grid: { color: '#202838' } },
|
|
y: { ticks: { color: '#7c8aa5' }, grid: { color: '#202838' }, title:{display:true, text:'ms', color:'#7c8aa5'} }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function render(){
|
|
const emptyCard = document.getElementById('emptyCard');
|
|
const mainCards = document.querySelectorAll('.main-content');
|
|
|
|
if (!records.length){
|
|
if (emptyCard) emptyCard.style.display = 'block';
|
|
mainCards.forEach(c => c.style.display = 'none');
|
|
return;
|
|
}
|
|
|
|
if (emptyCard) emptyCard.style.display = 'none';
|
|
mainCards.forEach(c => c.style.display = 'block');
|
|
|
|
const sorted = [...records].sort((a,b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
const latest = sorted[sorted.length - 1];
|
|
const nowMs = new Date(latest.timestamp).getTime();
|
|
|
|
// --- Uptime % over last 24h ---
|
|
const last24h = sorted.filter(r => nowMs - new Date(r.timestamp).getTime() <= 24*3600*1000);
|
|
const upCount = last24h.filter(r => r.http && r.http.up).length;
|
|
const uptimePct = last24h.length ? ((upCount / last24h.length) * 100).toFixed(2) : 'N/A';
|
|
const uptimeClass = uptimePct === 'N/A' ? '' : (uptimePct >= 99.5 ? 'ok-text' : uptimePct >= 95 ? 'warn-text' : 'danger-text');
|
|
|
|
const uptimeEl = document.getElementById('uptimeMetric');
|
|
if (uptimeEl) {
|
|
uptimeEl.className = `metric ${uptimeClass}`;
|
|
uptimeEl.innerHTML = `${uptimePct}${uptimePct !== 'N/A' ? '<small>%</small>' : ''}`;
|
|
}
|
|
const uptimeSubEl = document.getElementById('uptimeSub');
|
|
if (uptimeSubEl) {
|
|
uptimeSubEl.textContent = `${upCount}/${last24h.length} فحص ناجح آخر 24 ساعة`;
|
|
}
|
|
|
|
// --- SSL ---
|
|
const ssl = latest.ssl || {};
|
|
const hasValidDays = ssl.valid && ssl.days_remaining !== null && ssl.days_remaining !== undefined;
|
|
const sslClass = hasValidDays ? statusColorClass(ssl.days_remaining) : 'danger-text';
|
|
const sslMetricText = hasValidDays ? ssl.days_remaining : '—';
|
|
const sslSubText = hasValidDays ? (`تنتهي في ${ssl.expiry_date}`) : (ssl.error ? `غير سارية (${ssl.error})` : 'غير سارية أو تعذّر التحقق');
|
|
|
|
const sslMetricEl = document.getElementById('sslMetric');
|
|
if (sslMetricEl) {
|
|
sslMetricEl.className = `metric ${sslClass}`;
|
|
sslMetricEl.innerHTML = `${sslMetricText}${hasValidDays ? '<small>يوم متبقي</small>' : ''}`;
|
|
}
|
|
const sslSubEl = document.getElementById('sslSub');
|
|
if (sslSubEl) {
|
|
sslSubEl.textContent = sslSubText;
|
|
}
|
|
|
|
// --- DNS avg over last 24h ---
|
|
const dnsTimes = last24h.map(r => r.dns && r.dns.time_ms).filter(v => v !== null && v !== undefined);
|
|
const dnsAvg = dnsTimes.length ? (dnsTimes.reduce((a,b)=>a+b,0)/dnsTimes.length).toFixed(1) : 'N/A';
|
|
|
|
const dnsMetricEl = document.getElementById('dnsMetric');
|
|
if (dnsMetricEl) {
|
|
dnsMetricEl.innerHTML = `${dnsAvg}${dnsAvg !== 'N/A' ? '<small>ms</small>' : ''}`;
|
|
}
|
|
const dnsSubEl = document.getElementById('dnsSub');
|
|
if (dnsSubEl) {
|
|
dnsSubEl.textContent = `زمن البحث الأخير: ${latest.search && latest.search.latency_ms !== null && latest.search.latency_ms !== undefined ? latest.search.latency_ms + ' ms' : 'فشل الطلب'}`;
|
|
}
|
|
|
|
// --- pulse dot & last updated ---
|
|
const dot = document.getElementById('pulseDot');
|
|
if (dot) dot.classList.toggle('down', !latest.http || !latest.http.up);
|
|
|
|
const lastUpdatedEl = document.getElementById('lastUpdated');
|
|
if (lastUpdatedEl) lastUpdatedEl.textContent = 'آخر تحديث: ' + fmtTime(latest.timestamp);
|
|
|
|
// --- update Chart smoothly ---
|
|
const lastHour = sorted.filter(r => nowMs - new Date(r.timestamp).getTime() <= 3600*1000);
|
|
const chartSource = lastHour.length ? lastHour : sorted.slice(-30);
|
|
|
|
const labels = chartSource.map(r => fmtShortTime(r.timestamp));
|
|
const httpData = chartSource.map(r => r.http ? r.http.latency_ms : null);
|
|
const searchData = chartSource.map(r => r.search ? r.search.latency_ms : null);
|
|
|
|
if (!latencyChart) {
|
|
initChart();
|
|
}
|
|
if (latencyChart) {
|
|
latencyChart.data.labels = labels;
|
|
latencyChart.data.datasets[0].data = httpData;
|
|
latencyChart.data.datasets[1].data = searchData;
|
|
latencyChart.update();
|
|
}
|
|
|
|
// --- log table (last 10) ---
|
|
const logBody = document.getElementById('logBody');
|
|
if (logBody) {
|
|
const last10 = sorted.slice(-10).reverse();
|
|
logBody.innerHTML = last10.map(r => {
|
|
const rSslValid = r.ssl && r.ssl.valid && r.ssl.days_remaining !== null && r.ssl.days_remaining !== undefined;
|
|
return `
|
|
<tr>
|
|
<td>${fmtTime(r.timestamp)}</td>
|
|
<td><span class="badge ${r.http && r.http.up ? 'up' : 'down'}">${r.http && r.http.up ? 'UP' : 'DOWN'}</span></td>
|
|
<td>${r.http && r.http.latency_ms !== null && r.http.latency_ms !== undefined ? r.http.latency_ms + ' ms' : '—'}</td>
|
|
<td>${r.dns && r.dns.time_ms !== null && r.dns.time_ms !== undefined ? r.dns.time_ms + ' ms' : '—'}</td>
|
|
<td class="${statusColorClass(rSslValid ? r.ssl.days_remaining : null)}">${rSslValid ? r.ssl.days_remaining : '—'}</td>
|
|
<td>${r.search && r.search.latency_ms !== null && r.search.latency_ms !== undefined ? r.search.latency_ms + ' ms' : '—'}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
async function loadFromServer(){
|
|
try{
|
|
const res = await fetch('data.jsonl', { cache: 'no-store' });
|
|
if (!res.ok) throw new Error('not found');
|
|
const text = await res.text();
|
|
records = parseJsonl(text);
|
|
render();
|
|
} catch(e){
|
|
// Fallback when served directly as file:// or if fetch fails
|
|
}
|
|
}
|
|
|
|
document.getElementById('fileInput').addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (evt) => {
|
|
records = parseJsonl(evt.target.result);
|
|
render();
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
initChart();
|
|
loadFromServer();
|
|
setInterval(loadFromServer, REFRESH_MS);
|
|
</script>
|
|
</body>
|
|
</html>
|