Files
Yodmon/templates/index.html
2026-04-20 09:04:02 +02:00

266 lines
10 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 &nbsp;|&nbsp;
<a href="/api/stats" class="text-muted">API: stats</a> &nbsp;
<a href="/api/players" class="text-muted">players</a> &nbsp;
<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>