266 lines
10 KiB
HTML
266 lines
10 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Yodmon – Yodeck Monitor</title>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||
<meta http-equiv="refresh" content="60">
|
||
<style>
|
||
body { background: #f0f2f5; }
|
||
.stat-card { border: none; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
|
||
.online { color: #198754; font-weight: 600; }
|
||
.offline { color: #dc3545; font-weight: 600; }
|
||
.badge-yodeck_fetch { background: #0d6efd; }
|
||
.badge-zabbix_sync { background: #6f42c1; }
|
||
.badge-snmp_transfer { background: #20c997; }
|
||
.badge-error { background: #dc3545; }
|
||
.scroll-table { max-height: 420px; overflow: auto; }
|
||
thead.sticky-top th { position: sticky; top: 0; z-index: 1; }
|
||
.clickable-card { cursor: pointer; transition: box-shadow .15s; }
|
||
.clickable-card:hover { box-shadow: 0 0 0 2px #dc354580; }
|
||
.clickable-card.active { box-shadow: 0 0 0 2px #dc3545; background: #fff5f5; }
|
||
#player-table th { cursor: pointer; user-select: none; white-space: nowrap; }
|
||
#player-table th:hover { background: #343a40cc; }
|
||
#player-table th.sort-asc::after { content: ' ▲'; font-size: .7em; }
|
||
#player-table th.sort-desc::after { content: ' ▼'; font-size: .7em; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<nav class="navbar navbar-dark bg-dark mb-4 px-3">
|
||
<span class="navbar-brand fw-bold fs-5">Yodmon</span>
|
||
<span class="text-secondary small">Yodeck → Zabbix Bridge</span>
|
||
</nav>
|
||
|
||
<div class="container-fluid px-4">
|
||
|
||
<!-- Summary cards -->
|
||
<div class="row g-3 mb-4">
|
||
<div class="col-6 col-md-3">
|
||
<div class="card stat-card text-center py-3 h-100">
|
||
<div class="display-6 fw-bold text-primary">{{ total }}</div>
|
||
<div class="text-muted small">Total Players</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<div class="card stat-card text-center py-3 h-100">
|
||
<div class="display-6 fw-bold text-success">{{ online }}</div>
|
||
<div class="text-muted small">Online</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<div class="card stat-card text-center py-3 h-100 clickable-card" id="offline-card" role="button" title="Click to filter offline players">
|
||
<div class="display-6 fw-bold text-danger">{{ total - online }}</div>
|
||
<div class="text-muted small">Offline <span class="text-muted" style="font-size:.7rem">(click to filter)</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<div class="card stat-card text-center py-3 h-100">
|
||
<div class="fs-6 fw-semibold text-secondary">
|
||
{% set last_fetch = logs | selectattr('event_type', 'equalto', 'yodeck_fetch') | first %}
|
||
{% if last_fetch %}<span class="local-time" data-utc="{{ last_fetch.timestamp }}">{{ last_fetch.timestamp[:19] }}</span>{% else %}—{% endif %}
|
||
</div>
|
||
<div class="text-muted small">Last API Fetch</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Player table -->
|
||
<div class="card shadow-sm mb-4">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<span class="fw-semibold">Players <span id="filter-badge" class="badge bg-danger ms-1 d-none">Offline only ✕</span></span>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<span class="badge bg-secondary" id="player-count">{{ total }}</span>
|
||
<button class="btn btn-sm btn-outline-primary" id="sync-btn" onclick="triggerSync()">↻ Sync now</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div class="scroll-table">
|
||
<table class="table table-sm table-hover mb-0" id="player-table">
|
||
<thead class="table-dark sticky-top">
|
||
<tr>
|
||
<th>Yodeck ID</th>
|
||
<th>Name</th>
|
||
<th>Hostname</th>
|
||
<th>Workspace</th>
|
||
<th>Type</th>
|
||
<th>Status</th>
|
||
<th>Last Seen</th>
|
||
<th>Last Pushed</th>
|
||
<th>Status Updated</th>
|
||
<th>Resolution</th>
|
||
<th>Hardware</th>
|
||
<th>Last IP</th>
|
||
<th>eth0 IP</th>
|
||
<th>Updating</th>
|
||
<th>Registered</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="player-tbody">
|
||
{% for p in players %}
|
||
<tr data-online="{{ '1' if p.online else '0' }}">
|
||
<td class="text-muted font-monospace">{{ p.id }}</td>
|
||
<td>{{ p.name }}</td>
|
||
<td class="font-monospace small">{{ p.hostname or '—' }}</td>
|
||
<td>{{ p.workspace_name or '—' }}</td>
|
||
<td>{{ p.player_type or '—' }}</td>
|
||
<td>
|
||
{% if p.online %}
|
||
<span class="online">● Online</span>
|
||
{% else %}
|
||
<span class="offline">● Offline</span>
|
||
{% endif %}
|
||
</td>
|
||
<td class="font-monospace small">{% if p.last_seen %}<span class="local-time" data-utc="{{ p.last_seen }}">{{ p.last_seen[:19] }}</span>{% else %}—{% endif %}</td>
|
||
<td class="font-monospace small">{% if p.last_pushed %}<span class="local-time" data-utc="{{ p.last_pushed }}">{{ p.last_pushed[:19] }}</span>{% else %}—{% endif %}</td>
|
||
<td class="font-monospace small">{% if p.status_last_updated %}<span class="local-time" data-utc="{{ p.status_last_updated }}">{{ p.status_last_updated[:19] }}</span>{% else %}—{% endif %}</td>
|
||
<td class="small">{{ p.screen_resolution or '—' }}</td>
|
||
<td class="small">{{ p.hardware_version or '—' }}</td>
|
||
<td class="font-monospace small">{{ p.last_ip_address or '—' }}</td>
|
||
<td class="font-monospace small">{{ p.eth0_ip or '—' }}</td>
|
||
<td>{% if p.updating %}<span class="text-warning fw-semibold">Yes</span>{% else %}No{% endif %}</td>
|
||
<td>{% if p.registered %}<span class="text-success">✓</span>{% else %}<span class="text-danger">✗</span>{% endif %}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Activity log -->
|
||
<div class="card shadow-sm mb-4">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<span class="fw-semibold">Activity Log</span>
|
||
<span class="badge bg-secondary">last 200</span>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div class="scroll-table">
|
||
<table class="table table-sm table-hover mb-0">
|
||
<thead class="table-dark sticky-top">
|
||
<tr>
|
||
<th style="width:180px">Timestamp</th>
|
||
<th style="width:160px">Event</th>
|
||
<th>Message</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for l in logs %}
|
||
<tr>
|
||
<td class="font-monospace small text-muted"><span class="local-time" data-utc="{{ l.timestamp }}">{{ l.timestamp[:19] }}</span></td>
|
||
<td>
|
||
<span class="badge badge-{{ l.event_type }}">{{ l.event_type }}</span>
|
||
</td>
|
||
<td class="small">{{ l.message }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="text-center text-muted small pb-3">
|
||
Page auto-refreshes every 60 seconds |
|
||
<a href="/api/stats" class="text-muted">API: stats</a>
|
||
<a href="/api/players" class="text-muted">players</a>
|
||
<a href="/api/logs" class="text-muted">logs</a>
|
||
</div>
|
||
|
||
<script>
|
||
const TZ = 'Europe/Vienna';
|
||
const dtFmt = new Intl.DateTimeFormat('sv-SE', {
|
||
timeZone: TZ,
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||
hour12: false,
|
||
});
|
||
|
||
function toVienna(isoStr) {
|
||
try {
|
||
const d = new Date(isoStr);
|
||
if (isNaN(d)) return isoStr;
|
||
return dtFmt.format(d).replace('T', ' ');
|
||
} catch { return isoStr; }
|
||
}
|
||
|
||
document.querySelectorAll('.local-time').forEach(el => {
|
||
el.textContent = toVienna(el.dataset.utc);
|
||
});
|
||
|
||
let offlineFilter = false;
|
||
|
||
function applyFilter() {
|
||
const rows = document.querySelectorAll('#player-tbody tr');
|
||
let visible = 0;
|
||
rows.forEach(r => {
|
||
const show = !offlineFilter || r.dataset.online === '0';
|
||
r.style.display = show ? '' : 'none';
|
||
if (show) visible++;
|
||
});
|
||
document.getElementById('player-count').textContent = visible;
|
||
document.getElementById('filter-badge').classList.toggle('d-none', !offlineFilter);
|
||
document.getElementById('offline-card').classList.toggle('active', offlineFilter);
|
||
}
|
||
|
||
document.getElementById('offline-card').addEventListener('click', () => {
|
||
offlineFilter = !offlineFilter;
|
||
applyFilter();
|
||
});
|
||
|
||
document.getElementById('filter-badge').addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
offlineFilter = false;
|
||
applyFilter();
|
||
});
|
||
|
||
// Column sorting
|
||
let sortCol = -1, sortAsc = true;
|
||
|
||
document.querySelectorAll('#player-table thead th').forEach((th, idx) => {
|
||
th.addEventListener('click', () => {
|
||
if (sortCol === idx) {
|
||
sortAsc = !sortAsc;
|
||
} else {
|
||
sortCol = idx;
|
||
sortAsc = true;
|
||
}
|
||
document.querySelectorAll('#player-table thead th').forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||
th.classList.add(sortAsc ? 'sort-asc' : 'sort-desc');
|
||
|
||
const tbody = document.getElementById('player-tbody');
|
||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||
rows.sort((a, b) => {
|
||
const av = a.cells[idx]?.textContent.trim() ?? '';
|
||
const bv = b.cells[idx]?.textContent.trim() ?? '';
|
||
const an = parseFloat(av), bn = parseFloat(bv);
|
||
const cmp = (!isNaN(an) && !isNaN(bn)) ? an - bn : av.localeCompare(bv);
|
||
return sortAsc ? cmp : -cmp;
|
||
});
|
||
rows.forEach(r => tbody.appendChild(r));
|
||
applyFilter();
|
||
});
|
||
});
|
||
|
||
function triggerSync() {
|
||
const btn = document.getElementById('sync-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = '↻ Syncing…';
|
||
fetch('/api/sync', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
btn.textContent = d.ok ? '✓ Done' : '✗ Failed';
|
||
setTimeout(() => location.reload(), 1500);
|
||
})
|
||
.catch(() => {
|
||
btn.textContent = '✗ Error';
|
||
btn.disabled = false;
|
||
});
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|