Files
infmap/app/templates/index.html
j a384352954
All checks were successful
Build-Publish / build (linux/arm64) (push) Successful in 13s
Build-Publish / build (linux/amd64) (push) Successful in 3s
Build-Publish / create-manifest (push) Successful in 8s
Build-Publish / publish-template (push) Successful in 15s
Collect partition info and unify storage table with disk, partition, and usage data
2026-03-10 21:57:46 +13:00

770 lines
45 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="header-btn" onclick="showAllNotes()" title="View all notes">Notes</button>
<button class="header-btn" onclick="showAllLinks()" title="View all links">Links</button>
<button class="refresh-btn" onclick="triggerRefresh(this)" title="Force re-collect from all servers">&#x21bb; 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 }}">&#x2197;</a>
{% endif %}
<button class="refresh-btn-sm" onclick="event.stopPropagation(); refreshServer(this, {{ server.id }})" title="Refresh this server">&#x21bb;</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>
{% set server_links = server.links or [] %}
{% if server_links %}
<div class="service-links" onclick="event.stopPropagation();">
{% for link in server_links %}
<a href="{{ link.url }}" class="service-link" target="_blank" rel="noopener" title="{{ link.url }}">{{ link.label }}</a>
{% endfor %}
</div>
{% endif %}
{% 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_raw = d.get('container', []) if d.get('container') else [] %}
{% set child_vms = children_map.get(server.hostname, []) %}
{% set child_names = [] %}
{% for vm in child_vms %}
{% set _ = child_names.append(vm.hostname) %}
{% if vm.details and vm.details.get('system', {}).get('hostname') %}
{% set _ = child_names.append(vm.details['system']['hostname']) %}
{% endif %}
{% endfor %}
{% set containers = [] %}
{% for ct in containers_raw %}
{% set ct_name = ct.get('name', ct.get('id', '')) %}
{% if ct_name not in child_names %}
{% set _ = containers.append(ct) %}
{% endif %}
{% endfor %}
{% 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 or child_vms %}
<div class="ct-summary-list">
{% for vm in child_vms %}
<div class="ct-summary-item">
<span class="status-dot-sm {% if vm.is_online %}online{% else %}offline{% endif %}"></span>
<span class="ct-summary-name">{{ vm.details.get('system', {}).get('hostname', vm.hostname) if vm.details else vm.hostname }}</span>
<span class="ct-summary-type">VM</span>
{% set vm_os = vm.details.get('system', {}).get('os_pretty', '') if vm.details else '' %}
{% if vm_os %}<span class="ct-summary-os">{{ vm_os }}</span>{% endif %}
<span class="ct-summary-ip">{{ vm.primary_ip or vm.hostname }}</span>
</div>
{% set vm_cts = vm.details.get('container', []) if vm.details and vm.details.get('container') else [] %}
{% for vct in vm_cts %}
{% set vct_up = vct.get('status', '')|lower in ['running', 'started'] %}
<div class="ct-summary-item nested">
<span class="status-dot-sm {% if vct_up %}online{% else %}offline{% endif %}"></span>
<span class="ct-summary-name">{{ vct.get('name', vct.get('id', '?')) }}</span>
{% if vct.get('ip') %}<span class="ct-summary-ip">{{ vct.get('ip') }}</span>{% endif %}
</div>
{% endfor %}
{% endfor %}
{% 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="links-section" onclick="event.stopPropagation();">
<h4>Service Links</h4>
<div class="links-list" data-server-id="{{ server.id }}">
{% for link in server_links %}
<div class="link-row">
<input type="text" class="link-label" value="{{ link.label }}" placeholder="Label" onchange="saveLinks(this)">
<input type="text" class="link-url" value="{{ link.url }}" placeholder="https://..." onchange="saveLinks(this)">
<button class="link-remove" onclick="removeLink(this)" title="Remove">&times;</button>
</div>
{% endfor %}
</div>
<button class="link-add-btn" onclick="addLink(this, {{ server.id }})">+ Add link</button>
</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 [] %}
{% set partitions = d.get('partition', []) if d.get('partition') else [] %}
{% if disks %}
<table>
<tr class="table-header"><td>Device</td><td>Mount</td><td>Size</td><td>Used</td><td>Avail</td><td>Use%</td></tr>
{% for disk in disks %}
<tr>
<td><strong>{{ disk.get('name', '-') }}</strong></td>
<td></td>
<td>{{ disk.get('size_bytes', '')|format_bytes }}</td>
<td></td><td></td><td></td>
</tr>
{% for part in partitions if part.get('parent') == disk.get('name') %}
{% set part_mount = part.get('mount', '') %}
{% set ns = namespace(du=none) %}
{% for du in disk_usages if du.get('mount') == part_mount and part_mount %}
{% set ns.du = du %}
{% endfor %}
<tr>
<td style="padding-left: 20px;">{{ part.get('name', '-') }}</td>
<td>{{ part_mount or '-' }}</td>
<td>{{ part.get('size_bytes', '')|format_bytes }}</td>
{% if ns.du %}
<td>{{ ns.du.get('used_bytes', '')|format_bytes }}</td>
<td>{{ ns.du.get('available_bytes', '')|format_bytes }}</td>
<td>
<span class="disk-pct" style="color: {{ ns.du.get('usage_percent', '0')|float|usage_color }}">
{{ ns.du.get('usage_percent', '-') }}%
</span>
</td>
{% else %}
<td></td><td></td><td></td>
{% endif %}
</tr>
{% endfor %}
{% endfor %}
{# Show any disk_usage entries not matched to a partition (e.g. NFS, LVM, ZFS) #}
{% for du in disk_usages %}
{% set ns = namespace(matched=false) %}
{% for part in partitions if part.get('mount') == du.get('mount') and part.get('mount') %}
{% set ns.matched = true %}
{% endfor %}
{% if not ns.matched %}
<tr>
<td>{{ du.get('mount', '-') }}</td>
<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>
{% endif %}
{% endfor %}
</table>
{% elif disk_usages %}
{# Fallback: no lsblk data (e.g. containers), just show df data #}
<table>
<tr class="table-header"><td>Mount</td><td>Total</td><td>Used</td><td>Avail</td><td>Use%</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 &amp; 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 %}
<!-- Child VMs (from config) -->
{% if child_vms %}
<div class="detail-section wide">
<h4>Virtual Machines</h4>
<div class="container-grid">
{% for vm in child_vms %}
{% set vd = vm.details or {} %}
{% set vs = vd.get('system', {}) if vd.get('system') else {} %}
{% set vc = vd.get('cpu', {}) if vd.get('cpu') else {} %}
{% set vmem = vd.get('memory', {}) if vd.get('memory') else {} %}
<div class="container-card {% if not vm.is_online %}offline{% endif %}">
<div class="ct-header">
<span class="status-dot {% if vm.is_online %}online{% else %}offline{% endif %}"></span>
<span class="ct-name">{{ vs.get('hostname', vm.hostname) }}</span>
<span class="ct-type">VM</span>
</div>
{% if vs.get('os_pretty') %}
<div class="ct-image">{{ vs.get('os_pretty') }}</div>
{% endif %}
{% if vm.is_online %}
<div class="ct-details">
<span class="ct-ip">{{ vm.primary_ip or vm.hostname }}</span>
{% if vs.get('uptime_seconds') %}
<span class="ct-uptime">{{ vs.get('uptime_seconds', '')|format_uptime }}</span>
{% endif %}
</div>
{% set vm_cpu_pct = vc.get('usage_percent', '0')|float %}
{% set vm_mem_pct = vmem.get('usage_percent', '0')|float %}
{% if vm_cpu_pct > 0 or vm_mem_pct > 0 %}
<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: {{ vm_cpu_pct }}%; background: {{ vm_cpu_pct|usage_color }};"></div>
</div>
<span class="usage-pct">{{ '%.0f'|format(vm_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: {{ vm_mem_pct }}%; background: {{ vm_mem_pct|usage_color }};"></div>
</div>
<span class="usage-pct">{{ '%.0f'|format(vm_mem_pct) }}%</span>
</div>
</div>
{% endif %}
{% set vm_containers = vd.get('container', []) if vd.get('container') else [] %}
{% if vm_containers %}
<div class="vm-containers">
{% for vct in vm_containers %}
{% set vct_up = vct.get('status', '')|lower in ['running', 'started'] %}
<div class="ct-summary-item">
<span class="status-dot-sm {% if vct_up %}online{% else %}offline{% endif %}"></span>
<span class="ct-summary-name">{{ vct.get('name', vct.get('id', '?')) }}</span>
{% if vct.get('type') %}<span class="ct-summary-type">{{ vct.get('type')|upper }}</span>{% endif %}
{% if vct.get('ip') %}<span class="ct-summary-ip">{{ vct.get('ip') }}</span>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="ct-details">
<span class="ct-status-label">offline</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>
<!-- Log Modal -->
<div id="logModal" class="log-modal" style="display:none;">
<div class="log-modal-content">
<div class="log-modal-header">
<span id="logTitle">Collecting...</span>
<span id="logSpinner" class="spinner"></span>
</div>
<div id="logBody" class="log-modal-body"></div>
</div>
</div>
<!-- Overview Modal -->
<div id="overviewModal" class="log-modal" style="display:none;" onclick="if(event.target===this)this.style.display='none'">
<div class="log-modal-content" style="width:700px;">
<div class="log-modal-header">
<span id="overviewTitle"></span>
<button onclick="document.getElementById('overviewModal').style.display='none'" style="margin-left:auto;background:none;border:none;color:#94a3b8;font-size:1.2rem;cursor:pointer;">&times;</button>
</div>
<div id="overviewBody" class="log-modal-body" style="padding:16px 18px;"></div>
</div>
</div>
<script>
function showAllNotes() {
const modal = document.getElementById('overviewModal');
document.getElementById('overviewTitle').textContent = 'All Notes';
const body = document.getElementById('overviewBody');
body.innerHTML = '<div class="spinner" style="margin:20px auto;"></div>';
modal.style.display = 'flex';
fetch('/api/all-notes').then(r => r.json()).then(data => {
if (!data.length) {
body.innerHTML = '<div style="color:#64748b;text-align:center;padding:20px;">No notes yet</div>';
return;
}
body.innerHTML = data.map(s =>
'<div style="margin-bottom:16px;">' +
'<div style="font-weight:600;color:#3b82f6;font-size:0.8rem;margin-bottom:4px;">' +
s.group_name + ' / ' + s.hostname + '</div>' +
'<div style="color:#cbd5e1;font-size:0.85rem;white-space:pre-wrap;background:#0f172a;padding:8px 10px;border-radius:6px;border:1px solid #1e3a5f;">' +
s.notes.replace(/</g,'&lt;') + '</div></div>'
).join('');
});
}
function showAllLinks() {
const modal = document.getElementById('overviewModal');
document.getElementById('overviewTitle').textContent = 'All Links';
const body = document.getElementById('overviewBody');
body.innerHTML = '<div class="spinner" style="margin:20px auto;"></div>';
modal.style.display = 'flex';
fetch('/api/all-links').then(r => r.json()).then(data => {
if (!data.length) {
body.innerHTML = '<div style="color:#64748b;text-align:center;padding:20px;">No links yet</div>';
return;
}
body.innerHTML = data.map(s =>
'<div style="margin-bottom:12px;">' +
'<div style="font-weight:600;color:#3b82f6;font-size:0.8rem;margin-bottom:4px;">' +
s.group_name + ' / ' + s.hostname + '</div>' +
'<div style="display:flex;flex-wrap:wrap;gap:6px;">' +
s.links.map(l =>
'<a href="' + l.url.replace(/"/g,'&quot;') + '" target="_blank" rel="noopener" class="service-link" style="font-size:0.8rem;padding:3px 10px;">' +
l.label.replace(/</g,'&lt;') + '</a>'
).join('') +
'</div></div>'
).join('');
});
}
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 getLinksFromList(listEl) {
const links = [];
listEl.querySelectorAll('.link-row').forEach(row => {
const label = row.querySelector('.link-label').value.trim();
const url = row.querySelector('.link-url').value.trim();
if (label && url) links.push({label, url});
});
return links;
}
function saveLinks(el) {
const list = el.closest('.links-list');
const id = list.dataset.serverId;
const links = getLinksFromList(list);
fetch('/api/servers/' + id + '/links', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({links})
});
}
function addLink(btn, serverId) {
const list = btn.previousElementSibling;
const row = document.createElement('div');
row.className = 'link-row';
row.innerHTML = '<input type="text" class="link-label" placeholder="Label" onchange="saveLinks(this)">' +
'<input type="text" class="link-url" placeholder="https://..." onchange="saveLinks(this)">' +
'<button class="link-remove" onclick="removeLink(this)" title="Remove">&times;</button>';
list.appendChild(row);
row.querySelector('.link-label').focus();
}
function removeLink(btn) {
const row = btn.closest('.link-row');
const list = row.closest('.links-list');
row.remove();
saveLinks(list);
}
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 showLogModal(title) {
const modal = document.getElementById('logModal');
const body = document.getElementById('logBody');
const titleEl = document.getElementById('logTitle');
const spinner = document.getElementById('logSpinner');
titleEl.textContent = title;
body.innerHTML = '';
spinner.style.display = 'inline-block';
modal.style.display = 'flex';
}
function addLogLine(text) {
const body = document.getElementById('logBody');
const line = document.createElement('div');
line.className = 'log-line';
// Color based on content
if (text.includes('online')) line.classList.add('log-ok');
else if (text.includes('offline') || text.includes('error')) line.classList.add('log-err');
line.textContent = text;
body.appendChild(line);
body.scrollTop = body.scrollHeight;
}
function streamRefresh(url, title) {
showLogModal(title);
const es = new EventSource(url);
es.onmessage = function(e) {
if (e.data === '[DONE]') {
es.close();
document.getElementById('logSpinner').style.display = 'none';
setTimeout(() => location.reload(), 800);
} else {
addLogLine(e.data);
}
};
es.onerror = function() {
es.close();
addLogLine('Connection lost');
document.getElementById('logSpinner').style.display = 'none';
setTimeout(() => location.reload(), 1500);
};
}
function refreshServer(btn, serverId) {
btn.disabled = true;
streamRefresh('/api/servers/' + serverId + '/refresh/stream', 'Refreshing server...');
}
function triggerRefresh(btn) {
btn.disabled = true;
streamRefresh('/api/refresh/stream', 'Refreshing all servers...');
}
// 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);
// Detect new backend version and auto-reload
const knownBuild = '{{ build_date }}';
setInterval(function() {
fetch('/api/version').then(r => r.json()).then(d => {
if (d.build_date && d.build_date !== knownBuild) {
location.reload();
}
}).catch(() => {});
}, 5000);
// Restore state on load
restoreExpanded();
</script>
</body>
</html>