442 lines
25 KiB
HTML
442 lines
25 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Infrastructure Map</title>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Infrastructure Map</h1>
|
|
<span class="subtitle">Auto-refreshes every 60s | Built: {{ build_date }}</span>
|
|
<button class="refresh-btn" onclick="triggerRefresh(this)" title="Force re-collect from all servers">↻ Refresh</button>
|
|
</header>
|
|
|
|
<main>
|
|
{% for group_name, servers in groups.items() %}
|
|
<section class="group">
|
|
<h2 class="group-header">{{ group_name }}</h2>
|
|
<div class="server-grid">
|
|
{% for server in servers %}
|
|
{% set d = server.details or {} %}
|
|
{% set sys = d.get('system', {}) if d.get('system') else {} %}
|
|
{% set cpu = d.get('cpu', {}) if d.get('cpu') else {} %}
|
|
{% set mem = d.get('memory', {}) if d.get('memory') else {} %}
|
|
{% set temps = d.get('temperatures', {}) if d.get('temperatures') else {} %}
|
|
{% set max_temp = namespace(val=0.0) %}
|
|
{% for k, v in temps.items() %}
|
|
{% if v|float > max_temp.val %}
|
|
{% set max_temp.val = v|float %}
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% set cpu_pct = cpu.get('usage_percent', '0')|float %}
|
|
{% set mem_pct = mem.get('usage_percent', '0')|float %}
|
|
{% set disk_usages = d.get('disk_usage', []) if d.get('disk_usage') else [] %}
|
|
{% set root_disk = namespace(pct=0.0) %}
|
|
{% for du in disk_usages %}
|
|
{% if du.get('mount') == '/' %}
|
|
{% set root_disk.pct = du.get('usage_percent', '0')|float %}
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% if root_disk.pct == 0.0 and disk_usages|length > 0 %}
|
|
{% set root_disk.pct = disk_usages[0].get('usage_percent', '0')|float %}
|
|
{% endif %}
|
|
|
|
<div class="server-card {% if not server.is_online %}offline{% endif %}"
|
|
data-server-id="{{ server.id }}"
|
|
onclick="toggleDetails(this)">
|
|
<div class="card-summary">
|
|
<div class="card-header">
|
|
<span class="status-dot {% if server.is_online %}online{% else %}offline{% endif %}"></span>
|
|
<span class="server-name">{{ server.hostname }}</span>
|
|
{% if server.url %}
|
|
<a href="{{ server.url }}" class="server-link" target="_blank" rel="noopener" onclick="event.stopPropagation();" title="{{ server.url }}">↗</a>
|
|
{% endif %}
|
|
<button class="refresh-btn-sm" onclick="event.stopPropagation(); refreshServer(this, {{ server.id }})" title="Refresh this server">↻</button>
|
|
</div>
|
|
<div class="server-ip">{{ server.primary_ip or 'No IP' }}</div>
|
|
<div class="server-os">{% if sys.get('platform') %}{{ sys.get('platform')|capitalize }} {{ sys.get('platform_version', '') }} / {% endif %}{{ sys.get('os_pretty', '') }}</div>
|
|
{% if server.is_online and (cpu.get('model') or mem.get('total_mb')) %}
|
|
<div class="server-hw">
|
|
{%- if cpu.get('model') %}{{ cpu.get('model') }}{% endif %}
|
|
{%- if cpu.get('cores') %} ({{ cpu.get('cores') }}c){% endif %}
|
|
{%- if mem.get('total_mb') %} / {{ mem.get('total_mb', '')|format_mb }}{% endif -%}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% set containers = d.get('container', []) if d.get('container') else [] %}
|
|
|
|
{% if server.is_online %}
|
|
<div class="usage-bars">
|
|
<div class="usage-row">
|
|
<span class="usage-label">CPU</span>
|
|
<div class="usage-bar-bg">
|
|
<div class="usage-bar-fill" style="width: {{ cpu_pct }}%; background: {{ cpu_pct|usage_color }};"></div>
|
|
</div>
|
|
<span class="usage-pct">{{ '%.0f'|format(cpu_pct) }}%</span>
|
|
</div>
|
|
<div class="usage-row">
|
|
<span class="usage-label">RAM</span>
|
|
<div class="usage-bar-bg">
|
|
<div class="usage-bar-fill" style="width: {{ mem_pct }}%; background: {{ mem_pct|usage_color }};"></div>
|
|
</div>
|
|
<span class="usage-pct">{{ '%.0f'|format(mem_pct) }}%</span>
|
|
</div>
|
|
<div class="usage-row">
|
|
<span class="usage-label">DISK</span>
|
|
<div class="usage-bar-bg">
|
|
<div class="usage-bar-fill" style="width: {{ root_disk.pct }}%; background: {{ root_disk.pct|usage_color }};"></div>
|
|
</div>
|
|
<span class="usage-pct">{{ '%.0f'|format(root_disk.pct) }}%</span>
|
|
</div>
|
|
{% if max_temp.val > 0 %}
|
|
<div class="usage-row">
|
|
<span class="usage-label">TEMP</span>
|
|
<div class="usage-bar-bg">
|
|
<div class="usage-bar-fill" style="width: {{ [max_temp.val, 100.0]|min }}%; background: {{ max_temp.val|temp_color }};"></div>
|
|
</div>
|
|
<span class="usage-pct">{{ '%.0f'|format(max_temp.val) }}°</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% if containers %}
|
|
<div class="ct-summary-list">
|
|
{% for ct in containers %}
|
|
{% set ct_up = ct.get('status', '')|lower in ['running', 'started'] %}
|
|
<div class="ct-summary-item">
|
|
<span class="status-dot-sm {% if ct_up %}online{% else %}offline{% endif %}"></span>
|
|
<span class="ct-summary-name">{{ ct.get('name', ct.get('id', '?')) }}</span>
|
|
{% if ct.get('os') %}<span class="ct-summary-os">{{ ct.get('os') }}</span>{% endif %}
|
|
{% if ct.get('ip') %}<span class="ct-summary-ip">{{ ct.get('ip') }}</span>{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% else %}
|
|
<div class="offline-label">Unreachable</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Expanded Details -->
|
|
<div class="card-details" style="display: none;">
|
|
<div class="notes-section">
|
|
<textarea class="notes-input"
|
|
data-server-id="{{ server.id }}"
|
|
placeholder="Add notes..."
|
|
onclick="event.stopPropagation();"
|
|
oninput="autoResizeNotes(this); debounceSaveNotes(this);">{{ server.notes or '' }}</textarea>
|
|
</div>
|
|
<div class="details-grid">
|
|
<!-- System Info -->
|
|
<div class="detail-section">
|
|
<h4>System</h4>
|
|
<table>
|
|
<tr><td>Hostname</td><td>{{ sys.get('hostname', '-') }}</td></tr>
|
|
<tr><td>OS</td><td>{{ sys.get('os_pretty', '-') }}</td></tr>
|
|
{% if sys.get('platform') %}
|
|
<tr><td>Platform</td><td>{{ sys.get('platform')|capitalize }} {{ sys.get('platform_version', '') }}</td></tr>
|
|
{% endif %}
|
|
<tr><td>Kernel</td><td>{{ sys.get('kernel', '-') }}</td></tr>
|
|
<tr><td>Arch</td><td>{{ sys.get('arch', '-') }}</td></tr>
|
|
<tr><td>Uptime</td><td>{{ sys.get('uptime_seconds', '')|format_uptime }}</td></tr>
|
|
<tr><td>Board</td><td>{{ sys.get('board_vendor', '') }} {{ sys.get('board_name', '') }}</td></tr>
|
|
<tr><td>Board Version</td><td>{{ sys.get('board_version', '-') }}</td></tr>
|
|
<tr><td>BIOS</td><td>{{ sys.get('bios_version', '-') }} ({{ sys.get('bios_date', '-') }})</td></tr>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- CPU -->
|
|
<div class="detail-section">
|
|
<h4>CPU</h4>
|
|
<table>
|
|
<tr><td>Model</td><td>{{ cpu.get('model', '-') }}</td></tr>
|
|
<tr><td>Cores</td><td>{{ cpu.get('cores', '-') }}</td></tr>
|
|
<tr><td>Sockets</td><td>{{ cpu.get('sockets', '-') }}</td></tr>
|
|
<tr><td>Threads/Core</td><td>{{ cpu.get('threads_per_core', '-') }}</td></tr>
|
|
<tr><td>Usage</td><td>{{ cpu.get('usage_percent', '-') }}%</td></tr>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Temperatures -->
|
|
{% if temps %}
|
|
<div class="detail-section">
|
|
<h4>Temperatures</h4>
|
|
<table>
|
|
{% for sensor, value in temps.items() %}
|
|
<tr>
|
|
<td>{{ sensor }}</td>
|
|
<td style="color: {{ value|float|temp_color }}">{{ value }}°C</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Memory -->
|
|
<div class="detail-section">
|
|
<h4>Memory</h4>
|
|
<table>
|
|
<tr><td>Total</td><td>{{ mem.get('total_mb', '')|format_mb }}</td></tr>
|
|
<tr><td>Used</td><td>{{ mem.get('used_mb', '')|format_mb }}</td></tr>
|
|
<tr><td>Available</td><td>{{ mem.get('available_mb', '')|format_mb }}</td></tr>
|
|
<tr><td>Usage</td><td>{{ mem.get('usage_percent', '-') }}%</td></tr>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- GPUs -->
|
|
{% set gpus = d.get('gpu', []) if d.get('gpu') else [] %}
|
|
{% if gpus %}
|
|
<div class="detail-section">
|
|
<h4>GPUs</h4>
|
|
<table>
|
|
{% for gpu in gpus %}
|
|
<tr><td>GPU {{ loop.index0 }}</td><td>{{ gpu.get('description', '-')|clean_gpu }}</td></tr>
|
|
{% endfor %}
|
|
</table>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Network -->
|
|
<div class="detail-section wide">
|
|
<h4>Network Interfaces</h4>
|
|
<table>
|
|
<tr class="table-header"><td>Interface</td><td>IPv4</td><td>IPv6</td><td>MAC</td><td>State</td><td>Speed</td><td>Driver</td></tr>
|
|
{% set nets = d.get('net', []) if d.get('net') else [] %}
|
|
{% for iface in nets %}
|
|
{% if iface.get('ipv4') or iface.get('ipv6') %}
|
|
<tr>
|
|
<td>{{ iface.get('name', iface.get('_name', '-')) }}</td>
|
|
<td>{{ iface.get('ipv4', '-') or '-' }}</td>
|
|
<td>{{ iface.get('ipv6', '-') or '-' }}</td>
|
|
<td>{{ iface.get('mac', '-') or '-' }}</td>
|
|
<td>{{ iface.get('state', '-') }}</td>
|
|
<td>{% if iface.get('speed_mbps') %}{{ iface.get('speed_mbps') }} Mbps{% else %}-{% endif %}</td>
|
|
<td>{{ iface.get('driver', '-') or '-' }}</td>
|
|
</tr>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</table>
|
|
|
|
{% set routing = d.get('routing', {}) if d.get('routing') else {} %}
|
|
{% set dns = d.get('dns', {}) if d.get('dns') else {} %}
|
|
{% set ts = d.get('tailscale', {}) if d.get('tailscale') else {} %}
|
|
<table style="margin-top: 8px;">
|
|
<tr><td>Gateway</td><td>{{ routing.get('gateway', '-') }} ({{ routing.get('interface', '-') }})</td></tr>
|
|
<tr><td>DNS</td><td>
|
|
{% set servers_val = dns.get('server', '-') %}
|
|
{% if servers_val is string %}{{ servers_val }}{% elif servers_val is iterable %}{{ servers_val|join(', ') }}{% else %}-{% endif %}
|
|
</td></tr>
|
|
{% if ts.get('installed') == 'true' %}
|
|
<tr><td>Tailscale IP</td><td>{{ ts.get('ipv4', '-') }}</td></tr>
|
|
<tr><td>Tailscale Name</td><td>{{ ts.get('hostname', '-') }}</td></tr>
|
|
{% endif %}
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Disks -->
|
|
<div class="detail-section wide">
|
|
<h4>Storage</h4>
|
|
{% set disks = d.get('disk', []) if d.get('disk') else [] %}
|
|
{% if disks %}
|
|
<table>
|
|
<tr class="table-header"><td>Device</td><td>Size</td></tr>
|
|
{% for disk in disks %}
|
|
<tr>
|
|
<td>{{ disk.get('name', '-') }}</td>
|
|
<td>{{ disk.get('size_bytes', '')|format_bytes }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
{% endif %}
|
|
|
|
{% if disk_usages %}
|
|
<table style="margin-top: 8px;">
|
|
<tr class="table-header"><td>Mount</td><td>Total</td><td>Used</td><td>Available</td><td>Usage</td></tr>
|
|
{% for du in disk_usages %}
|
|
<tr>
|
|
<td>{{ du.get('mount', '-') }}</td>
|
|
<td>{{ du.get('total_bytes', '')|format_bytes }}</td>
|
|
<td>{{ du.get('used_bytes', '')|format_bytes }}</td>
|
|
<td>{{ du.get('available_bytes', '')|format_bytes }}</td>
|
|
<td>
|
|
<span class="disk-pct" style="color: {{ du.get('usage_percent', '0')|float|usage_color }}">
|
|
{{ du.get('usage_percent', '-') }}%
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Containers / VMs -->
|
|
{% if containers %}
|
|
<div class="detail-section wide">
|
|
<h4>Containers & VMs</h4>
|
|
<div class="container-grid">
|
|
{% for ct in containers %}
|
|
{% set ct_running = ct.get('status', '')|lower in ['running', 'started'] %}
|
|
<div class="container-card {% if not ct_running %}offline{% endif %}">
|
|
<div class="ct-header">
|
|
<span class="status-dot {% if ct_running %}online{% else %}offline{% endif %}"></span>
|
|
<span class="ct-name">{{ ct.get('name', ct.get('id', '?')) }}</span>
|
|
<span class="ct-type">{{ ct.get('type', '')|upper }}</span>
|
|
</div>
|
|
{% if ct.get('image') %}
|
|
<div class="ct-image">{{ ct.get('image') }}</div>
|
|
{% endif %}
|
|
{% if ct_running %}
|
|
<div class="ct-details">
|
|
{% if ct.get('ip') %}
|
|
<span class="ct-ip">{{ ct.get('ip') }}</span>
|
|
{% endif %}
|
|
{% if ct.get('uptime_seconds') %}
|
|
<span class="ct-uptime">{{ ct.get('uptime_seconds', '')|format_uptime }}</span>
|
|
{% endif %}
|
|
{% if ct.get('status_text') %}
|
|
<span class="ct-uptime">{{ ct.get('status_text') }}</span>
|
|
{% endif %}
|
|
</div>
|
|
{% if ct.get('mem_percent') or ct.get('cpu_percent') %}
|
|
<div class="usage-bars">
|
|
{% if ct.get('cpu_percent') %}
|
|
<div class="usage-row">
|
|
<span class="usage-label">CPU</span>
|
|
<div class="usage-bar-bg">
|
|
<div class="usage-bar-fill" style="width: {{ [ct.get('cpu_percent', '0')|float, 100.0]|min }}%; background: {{ ct.get('cpu_percent', '0')|float|usage_color }};"></div>
|
|
</div>
|
|
<span class="usage-pct">{{ ct.get('cpu_percent', '0') }}%</span>
|
|
</div>
|
|
{% endif %}
|
|
{% if ct.get('mem_percent') %}
|
|
<div class="usage-row">
|
|
<span class="usage-label">RAM</span>
|
|
<div class="usage-bar-bg">
|
|
<div class="usage-bar-fill" style="width: {{ ct.get('mem_percent', 0) }}%; background: {{ ct.get('mem_percent', '0')|float|usage_color }};"></div>
|
|
</div>
|
|
<span class="usage-pct">{{ ct.get('mem_percent', '0') }}%</span>
|
|
</div>
|
|
{% endif %}
|
|
{% if ct.get('disk_percent') %}
|
|
<div class="usage-row">
|
|
<span class="usage-label">DISK</span>
|
|
<div class="usage-bar-bg">
|
|
<div class="usage-bar-fill" style="width: {{ ct.get('disk_percent', 0) }}%; background: {{ ct.get('disk_percent', '0')|float|usage_color }};"></div>
|
|
</div>
|
|
<span class="usage-pct">{{ ct.get('disk_percent', '0') }}%</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
{% else %}
|
|
<div class="ct-details">
|
|
<span class="ct-status-label">{{ ct.get('status', 'stopped') }}</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if d.get('error') %}
|
|
<div class="detail-section wide">
|
|
<h4>Error</h4>
|
|
<p class="error-text">{{ d.get('error') }}</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="last-updated">
|
|
Last collected: {{ server.last_collected.strftime('%Y-%m-%d %H:%M:%S UTC') if server.last_collected else 'Never' }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</section>
|
|
{% else %}
|
|
<div class="empty-state">
|
|
<h2>No servers configured</h2>
|
|
<p>Edit <code>infrastructure.conf</code> to add your servers.</p>
|
|
</div>
|
|
{% endfor %}
|
|
</main>
|
|
|
|
<script>
|
|
function toggleDetails(card) {
|
|
const details = card.querySelector('.card-details');
|
|
const isOpen = details.style.display !== 'none';
|
|
// Close all other details
|
|
document.querySelectorAll('.card-details').forEach(d => d.style.display = 'none');
|
|
document.querySelectorAll('.server-card').forEach(c => c.classList.remove('expanded'));
|
|
if (!isOpen) {
|
|
details.style.display = 'block';
|
|
card.classList.add('expanded');
|
|
location.hash = card.dataset.serverId;
|
|
// Auto-resize notes textarea
|
|
const ta = details.querySelector('.notes-input');
|
|
if (ta) autoResizeNotes(ta);
|
|
} else {
|
|
history.replaceState(null, '', location.pathname);
|
|
}
|
|
}
|
|
|
|
function autoResizeNotes(ta) {
|
|
ta.style.height = 'auto';
|
|
ta.style.height = ta.scrollHeight + 'px';
|
|
}
|
|
|
|
let _notesTimers = {};
|
|
function debounceSaveNotes(ta) {
|
|
const id = ta.dataset.serverId;
|
|
clearTimeout(_notesTimers[id]);
|
|
_notesTimers[id] = setTimeout(() => saveNotes(ta), 800);
|
|
}
|
|
|
|
function saveNotes(ta) {
|
|
const id = ta.dataset.serverId;
|
|
fetch('/api/servers/' + id + '/notes', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({notes: ta.value})
|
|
});
|
|
}
|
|
|
|
// Restore expanded card from URL hash after reload
|
|
function restoreExpanded() {
|
|
const id = location.hash.slice(1);
|
|
if (!id) return;
|
|
const card = document.querySelector('.server-card[data-server-id="' + id + '"]');
|
|
if (card) toggleDetails(card);
|
|
}
|
|
|
|
function refreshServer(btn, serverId) {
|
|
btn.disabled = true;
|
|
btn.textContent = '...';
|
|
fetch('/api/servers/' + serverId + '/refresh', {method: 'POST'})
|
|
.then(() => location.reload());
|
|
}
|
|
|
|
function triggerRefresh(btn) {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Collecting...';
|
|
fetch('/api/refresh', {method: 'POST'}).then(() => {
|
|
// Wait a few seconds for collection to finish, then reload
|
|
setTimeout(() => location.reload(), 8000);
|
|
});
|
|
}
|
|
|
|
// If no servers have data yet, refresh quickly; otherwise every 60s
|
|
const hasData = document.querySelectorAll('.server-card').length > 0;
|
|
const refreshMs = hasData ? 60000 : 5000;
|
|
setInterval(function() { location.reload(); }, refreshMs);
|
|
|
|
// Restore state on load
|
|
restoreExpanded();
|
|
</script>
|
|
</body>
|
|
</html>
|