Files
MyFSIO/crates/myfsio-server/templates/cluster.html
2026-04-26 19:24:18 +08:00

462 lines
25 KiB
HTML

{% extends "base.html" %}
{% block title %}Cluster - S3 Compatible Storage{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center mb-4">
<div>
<p class="text-uppercase text-muted small mb-1">Cluster Overview</p>
<h1 class="h3 mb-1 d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
<path d="M7.752.066a.5.5 0 0 1 .496 0l3.75 2.143a.5.5 0 0 1 .252.434v3.995l3.498 2A.5.5 0 0 1 16 9.07v4.286a.5.5 0 0 1-.252.434l-3.75 2.143a.5.5 0 0 1-.496 0l-3.502-2-3.502 2.001a.5.5 0 0 1-.496 0l-3.75-2.143A.5.5 0 0 1 0 13.357V9.071a.5.5 0 0 1 .252-.434L3.75 6.638V2.643a.5.5 0 0 1 .252-.434L7.752.066ZM4.25 7.504 1.508 9.071l2.742 1.567 2.742-1.567L4.25 7.504ZM7.5 9.933l-2.75 1.571v3.134l2.75-1.571V9.933Zm1 3.134 2.75 1.571v-3.134L8.5 9.933v3.134Zm.508-3.996 2.742 1.567 2.742-1.567-2.742-1.567-2.742 1.567Zm2.242-2.433V3.504L8.5 5.076V8.21l2.75-1.572ZM7.5 8.21V5.076L4.75 3.504v3.134L7.5 8.21ZM5.258 2.643 8 4.21l2.742-1.567L8 1.076 5.258 2.643ZM15 9.933l-2.75 1.571v3.134L15 13.067V9.933ZM3.75 14.638v-3.134L1 9.933v3.134l2.75 1.571Z"/>
</svg>
Cluster
</h1>
<p class="text-muted mb-0 mt-1">Live view across this site and every registered peer.</p>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-success bg-opacity-10 text-success fs-6 px-3 py-2" id="cluster-online-badge">
{{ cluster_online_count }} / {{ cluster_total_count }} online
</span>
<span class="text-muted small d-none d-md-inline" id="cluster-updated-at" title="Last refresh">just now</span>
<button type="button" class="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1" id="cluster-refresh-btn" title="Refresh now (bypass 10s cache)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" id="cluster-refresh-icon">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
<span>Refresh</span>
</button>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-3 col-sm-6">
<div class="card shadow-sm border-0 h-100" style="border-radius: 1rem;">
<div class="card-body d-flex align-items-center gap-3">
<div class="d-flex align-items-center justify-content-center rounded-3 bg-primary bg-opacity-10 text-primary" style="width:44px;height:44px;flex-shrink:0;">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
<path d="M7.752.066a.5.5 0 0 1 .496 0l3.75 2.143a.5.5 0 0 1 .252.434v3.995l3.498 2A.5.5 0 0 1 16 9.07v4.286a.5.5 0 0 1-.252.434l-3.75 2.143a.5.5 0 0 1-.496 0l-3.502-2-3.502 2.001a.5.5 0 0 1-.496 0l-3.75-2.143A.5.5 0 0 1 0 13.357V9.071a.5.5 0 0 1 .252-.434L3.75 6.638V2.643a.5.5 0 0 1 .252-.434L7.752.066Z"/>
</svg>
</div>
<div class="flex-grow-1">
<div class="text-uppercase text-muted small">Sites</div>
<div class="h3 mb-0" id="cluster-total-sites">{{ cluster_total_count }}</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card shadow-sm border-0 h-100" style="border-radius: 1rem;">
<div class="card-body d-flex align-items-center gap-3">
<div class="d-flex align-items-center justify-content-center rounded-3 bg-info bg-opacity-10 text-info" style="width:44px;height:44px;flex-shrink:0;">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>
</svg>
</div>
<div class="flex-grow-1">
<div class="text-uppercase text-muted small">Buckets</div>
<div class="h3 mb-0" id="cluster-total-buckets">{{ cluster_total_buckets }}</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card shadow-sm border-0 h-100" style="border-radius: 1rem;">
<div class="card-body d-flex align-items-center gap-3">
<div class="d-flex align-items-center justify-content-center rounded-3 bg-warning bg-opacity-10 text-warning" style="width:44px;height:44px;flex-shrink:0;">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
</svg>
</div>
<div class="flex-grow-1">
<div class="text-uppercase text-muted small">Objects</div>
<div class="h3 mb-0" id="cluster-total-objects">{{ cluster_total_objects }}</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card shadow-sm border-0 h-100" style="border-radius: 1rem;">
<div class="card-body d-flex align-items-center gap-3">
<div class="d-flex align-items-center justify-content-center rounded-3 bg-success bg-opacity-10 text-success" style="width:44px;height:44px;flex-shrink:0;">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8H0v2zm1.5 1a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm2 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zM0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v3H0V4z"/>
<path d="M1.5 6a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zm2 0a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1z"/>
</svg>
</div>
<div class="flex-grow-1">
<div class="text-uppercase text-muted small">Size</div>
<div class="h3 mb-0" id="cluster-total-size" data-bytes="{{ cluster_total_size_bytes }}">{{ cluster_total_size_bytes }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4" id="cluster-sites-row">
{% for site in cluster_sites %}
<div class="col-xl-6">
<div class="card shadow-sm border-0 h-100 site-card" data-site-id="{{ site.site_id }}" style="border-radius: 1rem;">
<div class="card-body p-4">
<div class="d-flex align-items-start justify-content-between mb-3">
<div class="flex-grow-1 min-w-0">
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
<span class="badge bg-success bg-opacity-10 text-success site-status-online {% if not site.online %}d-none{% endif %}">
<span class="d-inline-block rounded-circle bg-success me-1" style="width:6px;height:6px;"></span>online
</span>
<span class="badge bg-danger bg-opacity-10 text-danger site-status-offline {% if site.online %}d-none{% endif %}">
<span class="d-inline-block rounded-circle bg-danger me-1" style="width:6px;height:6px;"></span>offline
</span>
<span class="badge bg-warning bg-opacity-10 text-warning site-status-stale {% if not site.stale %}d-none{% endif %}" title="Could not reach peer">stale</span>
{% if site.is_local %}
<span class="badge bg-primary bg-opacity-10 text-primary">this site</span>
{% endif %}
</div>
<h5 class="fw-semibold mb-0 text-truncate">
{% if site.display_name and site.display_name != "" %}{{ site.display_name }}{% else %}{{ site.site_id }}{% endif %}
</h5>
<div class="text-muted small">
<span class="font-monospace">{{ site.site_id }}</span>
{% if site.region %} · {{ site.region }}{% elif site.registered_region %} · {{ site.registered_region }}{% endif %}
</div>
</div>
{% if site.endpoint %}
<code class="small text-muted text-end ms-2" style="word-break:break-all;">{{ site.endpoint }}</code>
{% endif %}
</div>
<div class="site-online-content {% if not site.online %}d-none{% endif %}">
<div class="row g-3 mb-3">
<div class="col-4">
<div class="text-uppercase text-muted small mb-1 d-flex align-items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" fill="currentColor" viewBox="0 0 16 16"><path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/></svg>
Buckets
</div>
<div class="h4 mb-0 site-buckets">{{ site.buckets | default(value=0) }}</div>
</div>
<div class="col-4">
<div class="text-uppercase text-muted small mb-1 d-flex align-items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" fill="currentColor" viewBox="0 0 16 16"><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2z"/></svg>
Objects
</div>
<div class="h4 mb-0 site-objects">{{ site.objects | default(value=0) }}</div>
</div>
<div class="col-4">
<div class="text-uppercase text-muted small mb-1 d-flex align-items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" fill="currentColor" viewBox="0 0 16 16"><path d="M0 10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8H0v2zM0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v3H0V4z"/></svg>
Size
</div>
<div class="h4 mb-0 site-size" data-bytes="{{ site.size_bytes | default(value=0) }}">{{ site.size_bytes | default(value=0) }}</div>
</div>
</div>
{% if site.capacity %}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-uppercase text-muted small">Disk Capacity</span>
<span class="small text-muted">
<span class="site-disk-used" data-bytes="0">0</span> / <span class="site-disk-total" data-bytes="{{ site.capacity.total_bytes | default(value=0) }}">{{ site.capacity.total_bytes | default(value=0) }}</span>
</span>
</div>
<div class="progress" style="height:6px;border-radius:3px;">
<div class="progress-bar bg-primary site-disk-bar" role="progressbar" style="width:0%;" data-total="{{ site.capacity.total_bytes | default(value=0) }}" data-available="{{ site.capacity.available_bytes | default(value=0) }}"></div>
</div>
</div>
{% endif %}
{% if site.system and site.system.cpu_percent is defined %}
<div class="mb-3">
<div class="text-uppercase text-muted small mb-2">System</div>
<div class="d-flex flex-column gap-2">
<div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">CPU</span>
<span class="site-cpu-label">{{ site.system.cpu_percent | default(value=0) }}%</span>
</div>
<div class="progress" style="height:4px;border-radius:2px;">
<div class="progress-bar site-cpu-bar" role="progressbar" style="width:{{ site.system.cpu_percent | default(value=0) }}%;"></div>
</div>
</div>
<div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Memory</span>
<span class="site-mem-label">{{ site.system.memory_percent | default(value=0) }}%</span>
</div>
<div class="progress" style="height:4px;border-radius:2px;">
<div class="progress-bar site-mem-bar" role="progressbar" style="width:{{ site.system.memory_percent | default(value=0) }}%;"></div>
</div>
</div>
<div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Disk</span>
<span class="site-diskpct-label">{{ site.system.disk_percent | default(value=0) }}%</span>
</div>
<div class="progress" style="height:4px;border-radius:2px;">
<div class="progress-bar site-diskpct-bar" role="progressbar" style="width:{{ site.system.disk_percent | default(value=0) }}%;"></div>
</div>
</div>
</div>
</div>
{% endif %}
{% if site.sync %}
<div class="d-flex align-items-center justify-content-between border-top pt-3">
<div class="d-flex align-items-center gap-2 small">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
<span class="text-muted">Sync</span>
<span class="site-sync-label">
{% if site.sync.last_sync_at %}
<span data-last-sync-at="{{ site.sync.last_sync_at }}">last sync <span class="last-sync-rel">just now</span></span>
{% else %}
<span class="text-muted">no sync yet</span>
{% endif %}
</span>
</div>
<span class="badge bg-danger bg-opacity-10 text-danger site-sync-errors {% if not site.sync.errors or site.sync.errors == 0 %}d-none{% endif %}">
<span class="site-sync-errors-count">{{ site.sync.errors | default(value=0) }}</span> err
</span>
</div>
{% endif %}
</div>
<div class="site-offline-content {% if site.online %}d-none{% endif %}">
<div class="alert alert-light border-0 mb-0 py-2 px-3 small">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1 text-warning" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
<span class="site-offline-message">{% if site.error %}{{ site.error }}{% else %}Peer unreachable.{% endif %}</span>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
{% if cluster_total_count <= 1 %}
<div class="col-xl-6">
<a href="{{ url_for(endpoint='ui.sites_dashboard') }}" class="card shadow-sm border-0 h-100 text-decoration-none text-reset" style="border-radius: 1rem; border: 2px dashed var(--bs-border-color) !important;">
<div class="card-body d-flex flex-column align-items-center justify-content-center text-center p-5">
<div class="d-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary mb-3" style="width:64px;height:64px;">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
</svg>
</div>
<h5 class="fw-semibold mb-1">Add a peer site</h5>
<p class="text-muted small mb-0">Register another MyFSIO instance to see it appear here side-by-side.</p>
</div>
</a>
</div>
{% endif %}
</div>
<script>
(function () {
function fmtBytes(n) {
if (!n || n < 0) return "0 B";
var u = ["B", "KB", "MB", "GB", "TB", "PB"];
var i = 0;
var v = n;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return v.toFixed(i === 0 ? 0 : (v >= 100 ? 0 : 1)) + " " + u[i];
}
function fmtRel(ts) {
var diff = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (diff < 60) return diff + "s ago";
if (diff < 3600) return Math.floor(diff / 60) + "m ago";
if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
return Math.floor(diff / 86400) + "d ago";
}
function pctColor(p) {
if (p >= 80) return "bg-danger";
if (p >= 60) return "bg-warning";
return "bg-success";
}
function applyBytesFormat(root) {
(root || document).querySelectorAll("[data-bytes]").forEach(function (el) {
var n = parseInt(el.getAttribute("data-bytes"), 10);
if (!isNaN(n)) el.textContent = fmtBytes(n);
});
}
function applyDiskBars(root) {
(root || document).querySelectorAll(".site-disk-bar").forEach(function (bar) {
var total = parseFloat(bar.getAttribute("data-total")) || 0;
var avail = parseFloat(bar.getAttribute("data-available")) || 0;
var used = Math.max(0, total - avail);
var pct = total > 0 ? (used / total) * 100 : 0;
bar.style.width = pct.toFixed(1) + "%";
bar.classList.remove("bg-success", "bg-warning", "bg-danger", "bg-primary");
bar.classList.add(pctColor(pct));
var card = bar.closest(".site-card");
if (card) {
var usedEl = card.querySelector(".site-disk-used");
if (usedEl) {
usedEl.setAttribute("data-bytes", String(Math.floor(used)));
usedEl.textContent = fmtBytes(used);
}
}
});
}
function applyPctBars(root) {
var pairs = [
[".site-cpu-bar", ".site-cpu-label"],
[".site-mem-bar", ".site-mem-label"],
[".site-diskpct-bar", ".site-diskpct-label"],
];
pairs.forEach(function (sel) {
(root || document).querySelectorAll(sel[0]).forEach(function (bar) {
var label = bar.closest(".site-card").querySelector(sel[1]);
var pct = parseFloat(label ? label.textContent : "0") || 0;
bar.classList.remove("bg-success", "bg-warning", "bg-danger");
bar.classList.add(pctColor(pct));
});
});
}
function refreshRel() {
document.querySelectorAll("[data-last-sync-at]").forEach(function (el) {
var ts = parseFloat(el.getAttribute("data-last-sync-at"));
var span = el.querySelector(".last-sync-rel");
if (span && !isNaN(ts)) span.textContent = fmtRel(ts);
});
}
function updateCard(card, site) {
if (!card) return;
var online = !!site.online;
var stale = !!site.stale;
function toggle(sel, show) {
var el = card.querySelector(sel);
if (el) el.classList.toggle("d-none", !show);
}
toggle(".site-status-online", online);
toggle(".site-status-offline", !online);
toggle(".site-status-stale", stale);
toggle(".site-online-content", online);
toggle(".site-offline-content", !online);
if (online) {
var setNum = function (sel, val) {
var el = card.querySelector(sel);
if (el) el.textContent = String(val == null ? 0 : val);
};
setNum(".site-buckets", site.buckets);
setNum(".site-objects", site.objects);
var sizeEl = card.querySelector(".site-size");
if (sizeEl) {
sizeEl.setAttribute("data-bytes", String(site.size_bytes || 0));
sizeEl.textContent = fmtBytes(site.size_bytes || 0);
}
var capacity = site.capacity || {};
var diskBar = card.querySelector(".site-disk-bar");
if (diskBar) {
diskBar.setAttribute("data-total", String(capacity.total_bytes || 0));
diskBar.setAttribute("data-available", String(capacity.available_bytes || 0));
}
var diskTotalEl = card.querySelector(".site-disk-total");
if (diskTotalEl) {
diskTotalEl.setAttribute("data-bytes", String(capacity.total_bytes || 0));
diskTotalEl.textContent = fmtBytes(capacity.total_bytes || 0);
}
var sys = site.system || {};
var setPct = function (labelSel, val) {
var label = card.querySelector(labelSel);
if (label) label.textContent = (val == null ? 0 : val) + "%";
};
setPct(".site-cpu-label", sys.cpu_percent);
setPct(".site-mem-label", sys.memory_percent);
setPct(".site-diskpct-label", sys.disk_percent);
var setBarPct = function (sel, val) {
var bar = card.querySelector(sel);
if (bar) bar.style.width = (val == null ? 0 : val) + "%";
};
setBarPct(".site-cpu-bar", sys.cpu_percent);
setBarPct(".site-mem-bar", sys.memory_percent);
setBarPct(".site-diskpct-bar", sys.disk_percent);
var sync = site.sync || {};
var syncLabel = card.querySelector(".site-sync-label");
if (syncLabel) {
if (sync.last_sync_at) {
syncLabel.innerHTML = '<span data-last-sync-at="' + sync.last_sync_at + '">last sync <span class="last-sync-rel">' + fmtRel(sync.last_sync_at) + '</span></span>';
} else {
syncLabel.innerHTML = '<span class="text-muted">no sync yet</span>';
}
}
var errBadge = card.querySelector(".site-sync-errors");
var errCount = sync.errors || 0;
if (errBadge) {
errBadge.classList.toggle("d-none", errCount === 0);
var c = errBadge.querySelector(".site-sync-errors-count");
if (c) c.textContent = errCount;
}
} else {
var msg = card.querySelector(".site-offline-message");
if (msg) msg.textContent = site.error || "Peer unreachable.";
}
}
function poll(force) {
var url = "/ui/cluster/data" + (force ? "?force=1" : "");
var icon = document.getElementById("cluster-refresh-icon");
var btn = document.getElementById("cluster-refresh-btn");
if (force && icon) icon.classList.add("spin");
if (force && btn) btn.disabled = true;
return fetch(url, { credentials: "same-origin", cache: "no-store" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
var totals = data.totals || {};
var setTotal = function (id, v) {
var el = document.getElementById(id);
if (el) el.textContent = String(v == null ? 0 : v);
};
setTotal("cluster-total-sites", totals.total_count);
setTotal("cluster-total-buckets", totals.buckets);
setTotal("cluster-total-objects", totals.objects);
var sizeEl = document.getElementById("cluster-total-size");
if (sizeEl) {
sizeEl.setAttribute("data-bytes", String(totals.size_bytes || 0));
sizeEl.textContent = fmtBytes(totals.size_bytes || 0);
}
var onlineBadge = document.getElementById("cluster-online-badge");
if (onlineBadge) onlineBadge.textContent = (totals.online_count || 0) + " / " + (totals.total_count || 0) + " online";
(data.sites || []).forEach(function (site) {
var card = document.querySelector('.site-card[data-site-id="' + site.site_id + '"]');
if (card) updateCard(card, site);
});
applyDiskBars();
applyPctBars();
var stamp = document.getElementById("cluster-updated-at");
if (stamp) stamp.textContent = "updated " + new Date().toLocaleTimeString();
})
.catch(function () { /* silent — keep last good state */ })
.finally(function () {
if (icon) icon.classList.remove("spin");
if (btn) btn.disabled = false;
});
}
applyBytesFormat();
applyDiskBars();
applyPctBars();
refreshRel();
setInterval(refreshRel, 5000);
setInterval(function () { poll(false); }, 10000);
var refreshBtn = document.getElementById("cluster-refresh-btn");
if (refreshBtn) {
refreshBtn.addEventListener("click", function () { poll(true); });
}
})();
</script>
<style>
@keyframes cluster-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.spin { animation: cluster-spin 0.8s linear infinite; transform-origin: 50% 50%; }
</style>
{% endblock %}