Add YAML config loading with validation, env overrides for secrets, and a web dashboard exposing health, stats, and recent events.
173 lines
7.4 KiB
HTML
173 lines
7.4 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>ntfy-bridge Dashboard</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
|
|
header{background:#1e293b;border-bottom:1px solid #334155;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}
|
|
header h1{font-size:1.25rem;font-weight:600;color:#f1f5f9}
|
|
header .badge{background:#16a34a;color:#fff;padding:4px 12px;border-radius:12px;font-size:.75rem;font-weight:600}
|
|
.container{max-width:1200px;margin:0 auto;padding:24px}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:20px;margin-bottom:24px}
|
|
.card{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:20px}
|
|
.card h2{font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;margin-bottom:16px;display:flex;align-items:center;gap:8px}
|
|
.card h2 .icon{font-size:1.1rem}
|
|
.stat-value{font-size:2rem;font-weight:700;color:#f1f5f9;line-height:1.2}
|
|
.stat-label{font-size:.75rem;color:#64748b;margin-top:4px}
|
|
.stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
|
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:8px}
|
|
.status-up{background:#16a34a}
|
|
.status-down{background:#dc2626}
|
|
.status-item{display:flex;align-items:center;padding:8px 0;border-bottom:1px solid #334155;font-size:.875rem}
|
|
.status-item:last-child{border-bottom:none}
|
|
.config-row{display:flex;justify-content:space-between;padding:6px 0;font-size:.8125rem;border-bottom:1px solid #1e293b}
|
|
.config-row:last-child{border-bottom:none}
|
|
.config-key{color:#94a3b8}
|
|
.config-val{color:#e2e8f0;font-weight:500}
|
|
.history-item{padding:10px 0;border-bottom:1px solid #334155;font-size:.8125rem}
|
|
.history-item:last-child{border-bottom:none}
|
|
.history-meta{display:flex;gap:12px;color:#64748b;margin-bottom:4px;flex-wrap:wrap}
|
|
.history-msg{color:#e2e8f0;word-break:break-all}
|
|
.priority-tag{display:inline-block;padding:1px 6px;border-radius:4px;font-size:.6875rem;font-weight:700}
|
|
.p5{background:#7f1d1d;color:#fca5a5}
|
|
.p4{background:#78350f;color:#fcd34d}
|
|
.p3{background:#1e3a5f;color:#93c5fd}
|
|
.p2{background:#1e293b;color:#94a3b8}
|
|
.p1{background:#0f172a;color:#64748b}
|
|
.no-data{color:#64748b;font-style:italic;text-align:center;padding:24px 0}
|
|
.tabs{display:flex;gap:4px;margin-bottom:16px;background:#0f172a;border-radius:6px;padding:4px}
|
|
.tab{padding:6px 16px;border-radius:4px;font-size:.8125rem;cursor:pointer;color:#94a3b8;border:none;background:none}
|
|
.tab.active{background:#334155;color:#f1f5f9}
|
|
.tab-content{display:none}
|
|
.tab-content.active{display:block}
|
|
footer{text-align:center;padding:24px;color:#475569;font-size:.75rem}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>🔔 ntfy-bridge</h1>
|
|
<span class="badge" id="uptime-badge">LIVE</span>
|
|
</header>
|
|
<div class="container">
|
|
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h2><span class="icon">📊</span> Notifications</h2>
|
|
<div class="stat-grid">
|
|
<div><div class="stat-value" id="total-notif">—</div><div class="stat-label">Total</div></div>
|
|
<div><div class="stat-value" id="total-errors">—</div><div class="stat-label">Errors</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2><span class="icon">⏱️</span> Uptime</h2>
|
|
<div class="stat-value" id="uptime">—</div>
|
|
<div class="stat-label" id="uptime-human">Server running</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2><span class="icon">🔗</span> Ntfy Server</h2>
|
|
<div class="stat-value" style="font-size:1rem" id="ntfy-url">—</div>
|
|
<div class="stat-label">Target</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<div class="card" style="grid-column:span 2">
|
|
<h2><span class="icon">📋</span> Details</h2>
|
|
<div class="tabs">
|
|
<button class="tab active" onclick="switchTab('status')">Service Status</button>
|
|
<button class="tab" onclick="switchTab('config')">Configuration</button>
|
|
<button class="tab" onclick="switchTab('history')">Recent History</button>
|
|
</div>
|
|
<div class="tab-content active" id="tab-status">
|
|
<div id="status-list" class="no-data">Loading...</div>
|
|
</div>
|
|
<div class="tab-content" id="tab-config">
|
|
<div id="config-list" class="no-data">Loading...</div>
|
|
</div>
|
|
<div class="tab-content" id="tab-history">
|
|
<div id="history-list" class="no-data">Loading...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<footer>ntfy-bridge v0.1.0 · Homelab Notification Hub</footer>
|
|
|
|
<script>
|
|
function switchTab(name) {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
|
var sel = '.tab[onclick*="switchTab(\'' + name + '\')"]';
|
|
document.querySelector(sel).classList.add('active');
|
|
document.getElementById('tab-' + name).classList.add('active');
|
|
}
|
|
|
|
async function fetchJSON(url) {
|
|
var r = await fetch(url);
|
|
if (!r.ok) throw new Error(r.status);
|
|
return r.json();
|
|
}
|
|
|
|
function fmtUptime(s) {
|
|
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
|
if (h > 24) { var d = Math.floor(h / 24); return d + 'd ' + (h % 24) + 'h'; }
|
|
return h + 'h ' + m + 'm';
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
var health = await fetchJSON('/health');
|
|
document.getElementById('total-notif').textContent = health.total_notifications;
|
|
document.getElementById('uptime').textContent = fmtUptime(health.uptime_seconds);
|
|
document.getElementById('uptime-human').textContent = 'since ' + new Date(Date.now() - health.uptime_seconds * 1000).toLocaleString();
|
|
|
|
var stats = await fetchJSON('/stats');
|
|
document.getElementById('total-errors').textContent = stats.errors;
|
|
|
|
var config = await fetchJSON('/api/config');
|
|
document.getElementById('ntfy-url').textContent = config.server_url;
|
|
|
|
var statusData = await fetchJSON('/api/status');
|
|
var sl = document.getElementById('status-list');
|
|
if (!statusData || statusData.length === 0) {
|
|
sl.innerHTML = '<div class=no-data>No HTTP poll checks configured</div>';
|
|
} else {
|
|
sl.innerHTML = statusData.map(function(s) {
|
|
var cls = s.status === 'up' ? 'status-up' : 'status-down';
|
|
return '<div class=status-item><span class="status-dot ' + cls + '"></span>' + s.name + ' — <strong>' + s.status.toUpperCase() + '</strong></div>';
|
|
}).join('');
|
|
}
|
|
|
|
var cl = document.getElementById('config-list');
|
|
cl.innerHTML = Object.entries(config).map(function(e) {
|
|
var k = e[0], v = e[1];
|
|
return '<div class=config-row><span class=config-key>' + k.replace(/_/g, ' ') + '</span><span class=config-val>' + v + '</span></div>';
|
|
}).join('');
|
|
|
|
var history = await fetchJSON('/api/history');
|
|
var hl = document.getElementById('history-list');
|
|
if (!history || history.length === 0) {
|
|
hl.innerHTML = '<div class=no-data>No events yet</div>';
|
|
} else {
|
|
hl.innerHTML = history.slice().reverse().map(function(e) {
|
|
var pcls = 'p' + e.priority;
|
|
var ts = new Date(e.timestamp).toLocaleTimeString();
|
|
var safeMsg = e.message.replace(/</g, '<').replace(/>/g, '>').substring(0, 150);
|
|
return '<div class=history-item><div class=history-meta><span>' + ts + '</span><span>' + e.source + ':' + e.name + '</span><span class="priority-tag ' + pcls + '">P' + e.priority + '</span><span> → ' + e.topic + '</span></div><div class=history-msg>' + safeMsg + '</div></div>';
|
|
}).join('');
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
load();
|
|
setInterval(load, 15000);
|
|
</script>
|
|
</body>
|
|
</html>
|