1. Manuelle Reservierungen (dhcp_manual_reservations) fuer Geraete, die nicht als Client in dieser App gepflegt werden -- bewusst eine eigene, explizite Aktion (MAC/IP/Name), damit kein externes Geraet unueberwacht automatisch eine feste IP bekommt. _dhcp_reservation_candidates fuehrt automatische (aus devices) und manuelle Reservierungen jetzt zusammen, case-insensitiv nach MAC dedupliziert (automatisch gewinnt bei Konflikt). Automatische Reservierungen sind in der Tabelle nicht loeschbar (Schloss-Symbol statt Button, sie entstehen/verschwinden mit den Client-Stammdaten), manuelle schon. Live verifiziert: Hinzufuegen, doppelte MAC ablehnen, ungueltige MAC ablehnen, generierte Kea-Config mit gemischten Auto-/manuellen Reservierungen besteht kea-dhcp4 -t. 2. Standard-DHCP-Optionen um 10 weitere verifizierte Optionen erweitert (u.a. host-name/12 -- Kea-Name mit Bindestrich, NICHT "hostname", live gegen echtes Kea geprueft) -- jetzt 57 insgesamt. Standard-Optionen sind nicht mehr loeschbar (Backend lehnt es ab, UI zeigt ein Schloss-Symbol statt Loeschen-Button) -- vorher liess sich eine vorbefuellte Options- Definition versehentlich entfernen. 3. Options-Tabellen (global UND pro Reservierung) zeigen jetzt nur noch tatsaechlich genutzte Options -- eigene immer, Standard-Optionen nur mit gesetztem Wert. Weitere Standard-Optionen kommen ueber ein Dropdown "+ Option hinzufuegen" dazu statt permanent alle 57 als leere Felder zu zeigen. Das neu eingeblendete Feld wird automatisch in den sichtbaren Bereich gescrollt und fokussiert (bei bis zu 57 moeglichen Optionen kann das Feld sonst weit unterhalb des sichtbaren Modal-Ausschnitts liegen und es wirkt so, als waere nichts passiert). 4. Lease-Countdown-Fix: die Ablaufzeit wird jetzt explizit als UTC ausgewiesen (Kea arbeitet intern in UTC-Epoch) und zusaetzlich als live tickender Countdown angezeigt (gleiches Prinzip wie der bestehende "Naechste Pruefung"-Timer). Vorher wirkte eine reine Absolutzeit auf der nicht selbst aktualisierenden Seite bei den kurzen Standard-Lease-Zeiten (600s) schnell "schon abgelaufen", sobald etwas Zeit vergangen war, bis man tatsaechlich hinschaut -- live im echten Betrieb genau als dieses Missverstaendnis beobachtet und anhand der rohen Kea-CSV verifiziert, dass die richtige Spalte (expire) korrekt gelesen wird. 5. UI-Feinschliff: "In Datei schreiben" bei den Reservierungen heisst jetzt "Speichern" und ist der primaere (orange) Button, "Reservierung hinzufuegen" sekundaer -- das Hinzufuegen legt nur einen Datensatz an, erst "Speichern" schreibt die tatsaechlich wirksame Kea-Config. Alles live auf der Test-VM deployt und verifiziert (Playwright fuer die Dropdown-Sichtbarkeit/Fokus-Interaktion, curl fuer die Backend-Validierung, kea-dhcp4 -t fuer die generierte Konfiguration), keine Fehler im journalctl-Log.
446 lines
18 KiB
JavaScript
446 lines
18 KiB
JavaScript
/* ==========================================================================
|
|
PoE Manager — shared UI behaviour (sidebar, theme, modals, toasts)
|
|
========================================================================== */
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
/* ---------------- Theme ---------------- */
|
|
|
|
const THEME_KEY = "poe-theme";
|
|
|
|
function applyTheme(theme) {
|
|
document.documentElement.setAttribute("data-theme", theme);
|
|
document.querySelectorAll("[data-theme-icon]").forEach((el) => {
|
|
el.innerHTML = theme === "light" ? ICONS.moon : ICONS.sun;
|
|
});
|
|
}
|
|
|
|
function initTheme() {
|
|
const saved = localStorage.getItem(THEME_KEY) ||
|
|
(window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
|
|
applyTheme(saved);
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
|
|
const next = current === "light" ? "dark" : "light";
|
|
localStorage.setItem(THEME_KEY, next);
|
|
applyTheme(next);
|
|
}
|
|
|
|
const ICONS = {
|
|
sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>',
|
|
moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z"/></svg>',
|
|
};
|
|
|
|
/* ---------------- Sidebar (mobile) ---------------- */
|
|
|
|
function initSidebar() {
|
|
const sidebar = document.querySelector(".sidebar");
|
|
const backdrop = document.querySelector(".sidebar-backdrop");
|
|
const main = document.querySelector(".main");
|
|
const toggles = document.querySelectorAll("[data-sidebar-toggle]");
|
|
if (!sidebar) return;
|
|
|
|
const DESKTOP_BREAKPOINT = 900;
|
|
const COLLAPSE_KEY = "poe-sidebar-collapsed";
|
|
|
|
// Mobile: temporäres Überlagern per Hamburger + Backdrop.
|
|
function open() {
|
|
sidebar.classList.add("open");
|
|
backdrop && backdrop.classList.add("open");
|
|
}
|
|
function close() {
|
|
sidebar.classList.remove("open");
|
|
backdrop && backdrop.classList.remove("open");
|
|
}
|
|
|
|
// Desktop: dauerhaftes Ein-/Ausklappen, über Neuladen hinweg gemerkt.
|
|
function setCollapsed(collapsed) {
|
|
sidebar.classList.toggle("collapsed", collapsed);
|
|
main && main.classList.toggle("sidebar-collapsed", collapsed);
|
|
localStorage.setItem(COLLAPSE_KEY, collapsed ? "1" : "0");
|
|
}
|
|
|
|
if (localStorage.getItem(COLLAPSE_KEY) === "1") setCollapsed(true);
|
|
|
|
toggles.forEach((btn) => btn.addEventListener("click", () => {
|
|
if (window.innerWidth <= DESKTOP_BREAKPOINT) {
|
|
sidebar.classList.contains("open") ? close() : open();
|
|
} else {
|
|
setCollapsed(!sidebar.classList.contains("collapsed"));
|
|
}
|
|
}));
|
|
backdrop && backdrop.addEventListener("click", close);
|
|
document.querySelectorAll(".nav-item").forEach((a) => a.addEventListener("click", close));
|
|
}
|
|
|
|
/* ---------------- Modals ---------------- */
|
|
|
|
function openModal(id) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.classList.add("open");
|
|
document.body.style.overflow = "hidden";
|
|
}
|
|
|
|
function closeModal(el) {
|
|
const overlay = el.closest ? el.closest(".modal-overlay") : el;
|
|
if (!overlay) return;
|
|
overlay.classList.remove("open");
|
|
document.body.style.overflow = "";
|
|
}
|
|
|
|
function initModals() {
|
|
document.querySelectorAll("[data-open-modal]").forEach((trigger) => {
|
|
trigger.addEventListener("click", () => openModal(trigger.getAttribute("data-open-modal")));
|
|
});
|
|
document.querySelectorAll("[data-close-modal]").forEach((btn) => {
|
|
btn.addEventListener("click", () => closeModal(btn));
|
|
});
|
|
document.querySelectorAll(".modal-overlay").forEach((overlay) => {
|
|
overlay.addEventListener("click", (e) => {
|
|
if (e.target === overlay) closeModal(overlay);
|
|
});
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") {
|
|
document.querySelectorAll(".modal-overlay.open").forEach((o) => closeModal(o));
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------------- Confirm dialog (replaces window.confirm) ---------------- */
|
|
|
|
function ensureConfirmModal() {
|
|
if (document.getElementById("confirm-modal")) return;
|
|
const html = `
|
|
<div class="modal-overlay" id="confirm-modal">
|
|
<div class="modal" style="max-width:380px;">
|
|
<div class="modal-header">
|
|
<h3 id="confirm-title">Bist du sicher?</h3>
|
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p id="confirm-message" class="text-dim"></p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
|
<button type="button" class="btn btn-danger" id="confirm-ok" style="background:var(--danger);color:#fff;">Bestätigen</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
document.body.insertAdjacentHTML("beforeend", html);
|
|
document.querySelectorAll("#confirm-modal [data-close-modal]").forEach((btn) => {
|
|
btn.addEventListener("click", () => closeModal(btn));
|
|
});
|
|
document.getElementById("confirm-modal").addEventListener("click", (e) => {
|
|
if (e.target.id === "confirm-modal") closeModal(e.target);
|
|
});
|
|
}
|
|
|
|
window.confirmAction = function (message, onConfirm, title) {
|
|
ensureConfirmModal();
|
|
document.getElementById("confirm-title").innerText = title || "Bist du sicher?";
|
|
document.getElementById("confirm-message").innerText = message;
|
|
const okBtn = document.getElementById("confirm-ok");
|
|
const freshBtn = okBtn.cloneNode(true);
|
|
okBtn.parentNode.replaceChild(freshBtn, okBtn);
|
|
freshBtn.addEventListener("click", () => {
|
|
closeModal(freshBtn);
|
|
onConfirm();
|
|
});
|
|
openModal("confirm-modal");
|
|
};
|
|
|
|
/* Intercept forms/buttons marked with data-confirm="message" */
|
|
function initConfirmables() {
|
|
document.querySelectorAll("form[data-confirm]").forEach((form) => {
|
|
form.addEventListener("submit", function (e) {
|
|
if (form.dataset.confirmed === "1") return;
|
|
e.preventDefault();
|
|
// Welcher Button hat den Submit ausgelöst? Muss beim erneuten
|
|
// requestSubmit() explizit mitgegeben werden — sonst geht bei
|
|
// Formularen, die ihre ID über name/value des Submit-Buttons
|
|
// transportieren (statt über ein <input type="hidden">), dieses
|
|
// Feld beim Neu-Absenden stillschweigend verloren (requestSubmit()
|
|
// ohne Argument zählt als "kein Button aktiviert").
|
|
const submitter = e.submitter;
|
|
window.confirmAction(form.getAttribute("data-confirm"), () => {
|
|
form.dataset.confirmed = "1";
|
|
form.requestSubmit ? form.requestSubmit(submitter) : form.submit();
|
|
}, form.getAttribute("data-confirm-title"));
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------------- Toasts ---------------- */
|
|
|
|
function ensureToastStack() {
|
|
let stack = document.querySelector(".toast-stack");
|
|
if (!stack) {
|
|
stack = document.createElement("div");
|
|
stack.className = "toast-stack";
|
|
document.body.appendChild(stack);
|
|
}
|
|
return stack;
|
|
}
|
|
|
|
window.showToast = function (message, type) {
|
|
const stack = ensureToastStack();
|
|
const toast = document.createElement("div");
|
|
toast.className = "toast " + (type || "info");
|
|
toast.innerHTML = `<span>${message}</span><span class="toast-close">×</span>`;
|
|
stack.appendChild(toast);
|
|
const remove = () => {
|
|
toast.classList.add("hide");
|
|
setTimeout(() => toast.remove(), 180);
|
|
};
|
|
toast.querySelector(".toast-close").addEventListener("click", remove);
|
|
setTimeout(remove, 5000);
|
|
};
|
|
|
|
/* Zeigt ein per Dropdown ausgewähltes, bis dahin verstecktes DHCP-Options-
|
|
Feld (siehe settings_dhcp.html, "+ Option hinzufügen"-Auswahl bei
|
|
Reservierungen) — so sind nur tatsächlich genutzte Options-Felder
|
|
sichtbar, statt jede einzeln immer als leeres Eingabefeld anzuzeigen.
|
|
Scrollt das neue Feld zusätzlich in den sichtbaren Bereich und fokussiert
|
|
es — bei bis zu ~60 möglichen Optionen kann das neu eingeblendete Feld
|
|
sonst weit unterhalb des sichtbaren Modal-Ausschnitts liegen und wirkt
|
|
dann so, als wäre gar nichts passiert. */
|
|
window.poeShowOptionField = function (select) {
|
|
const id = select.value;
|
|
if (id) {
|
|
const field = document.getElementById(id);
|
|
if (field) {
|
|
field.style.display = "";
|
|
field.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
const input = field.querySelector("input");
|
|
if (input) input.focus();
|
|
}
|
|
select.value = "";
|
|
}
|
|
};
|
|
|
|
function initFlashedMessages() {
|
|
const data = document.getElementById("flashed-data");
|
|
if (!data) return;
|
|
try {
|
|
const messages = JSON.parse(data.textContent);
|
|
messages.forEach(([category, msg]) => {
|
|
const type = category === "danger" ? "danger" : category === "success" ? "success" : "info";
|
|
window.showToast(msg, type);
|
|
});
|
|
} catch (e) { /* noop */ }
|
|
}
|
|
|
|
/* Nav-Gruppen mit Untermenü (z.B. "Logs") auf-/zuklappen — der manuell
|
|
aufgeklappte Zustand bleibt über Seitenwechsel hinweg erhalten
|
|
(localStorage), zusätzlich zur automatischen Aufklappung der Gruppe
|
|
der aktuell aktiven Seite (server-seitig via "expanded"-Klasse). */
|
|
const NAV_EXPANDED_KEY = "poe-nav-expanded";
|
|
|
|
function getExpandedNavGroups() {
|
|
try {
|
|
return new Set(JSON.parse(localStorage.getItem(NAV_EXPANDED_KEY) || "[]"));
|
|
} catch (e) {
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
function saveExpandedNavGroups(set) {
|
|
localStorage.setItem(NAV_EXPANDED_KEY, JSON.stringify(Array.from(set)));
|
|
}
|
|
|
|
function initNavGroups() {
|
|
const expanded = getExpandedNavGroups();
|
|
document.querySelectorAll("[data-nav-group]").forEach((group) => {
|
|
const key = group.dataset.navGroupKey;
|
|
if (key && expanded.has(key)) group.classList.add("expanded");
|
|
});
|
|
document.querySelectorAll("[data-nav-group-toggle]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const group = btn.closest("[data-nav-group]");
|
|
const isExpanded = group.classList.toggle("expanded");
|
|
const key = group.dataset.navGroupKey;
|
|
if (!key) return;
|
|
const set = getExpandedNavGroups();
|
|
if (isExpanded) set.add(key); else set.delete(key);
|
|
saveExpandedNavGroups(set);
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------------- Globaler "Nächste Prüfung"-Timer (Topbar, jede Seite) ---------------- */
|
|
|
|
function initCheckTimer() {
|
|
const pill = document.getElementById("global-timer-pill");
|
|
const timerEl = document.getElementById("global-timer");
|
|
if (!pill || !timerEl) return;
|
|
|
|
// lastRunMs/intervalMs werden bei JEDEM Tick frisch aus dem DOM gelesen
|
|
// (nicht einmalig gecacht) — so kann z.B. das Dashboard nach einem
|
|
// Live-Update (siehe dashboard-tiles.js) oder ein manueller "Jetzt
|
|
// prüfen"-Klick das data-last-run-ms-Attribut aktualisieren und der
|
|
// global laufende Countdown übernimmt das automatisch.
|
|
function update() {
|
|
const lastRunMs = parseInt(pill.dataset.lastRunMs, 10);
|
|
const intervalMs = parseInt(pill.dataset.intervalMs, 10);
|
|
if (!intervalMs) { timerEl.textContent = "--"; return; }
|
|
let remainingMs = intervalMs;
|
|
if (!isNaN(lastRunMs) && lastRunMs > 0) {
|
|
const elapsed = Date.now() - lastRunMs;
|
|
remainingMs = intervalMs - (((elapsed % intervalMs) + intervalMs) % intervalMs);
|
|
}
|
|
timerEl.textContent = Math.max(0, Math.ceil(remainingMs / 1000));
|
|
}
|
|
update();
|
|
setInterval(update, 1000);
|
|
}
|
|
|
|
/* "Jetzt prüfen"-Button neben dem globalen Timer (nur Admins): startet
|
|
rpi-check.service neu (frisches Logfile + sofortiger Durchlauf) und
|
|
meldet das per Custom Event, damit Dashboard/Live-Log sich selbst
|
|
aktualisieren können, ohne dass app.js die Seiteninhalte kennen muss. */
|
|
function initRunCheckNow() {
|
|
const btn = document.getElementById("run-check-now");
|
|
if (!btn) return;
|
|
btn.addEventListener("click", () => {
|
|
btn.disabled = true;
|
|
btn.classList.add("spinning");
|
|
fetch("/check/run-now", { method: "POST" })
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
if (!data.success) {
|
|
window.showToast(data.msg || "Fehler beim Starten der Prüfung.", "danger");
|
|
btn.disabled = false;
|
|
btn.classList.remove("spinning");
|
|
return;
|
|
}
|
|
window.showToast(data.msg || "Prüfung gestartet.", "success");
|
|
document.dispatchEvent(new CustomEvent("poe:check-triggered"));
|
|
// Topbar-Countdown auf JEDER Seite aktualisieren, nicht nur auf
|
|
// dem Dashboard — vorher lief er auf allen anderen Seiten mit dem
|
|
// alten Zeitpunkt weiter, obwohl im Hintergrund längst neu geprüft
|
|
// wurde (die Prüfung lief trotzdem korrekt, nur sichtbar tat sich
|
|
// nichts). Der Server liefert den frischen Zeitpunkt direkt in
|
|
// dieser Antwort mit (siehe run_check_now) statt eines zweiten
|
|
// Requests dafür.
|
|
const pill = document.getElementById("global-timer-pill");
|
|
if (pill && data.last_run_epoch_ms) {
|
|
pill.dataset.lastRunMs = data.last_run_epoch_ms;
|
|
}
|
|
btn.disabled = false;
|
|
btn.classList.remove("spinning");
|
|
})
|
|
.catch(() => {
|
|
window.showToast("Fehler beim Starten der Prüfung.", "danger");
|
|
btn.disabled = false;
|
|
btn.classList.remove("spinning");
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------------- Sortierbare Tabellen ---------------- */
|
|
/*
|
|
* Klick auf <th data-sort-key="..."> sortiert die Tabelle. Zwei Modi:
|
|
* - Normale Tabellen: sortiert <tr>-Zeilen innerhalb des einzigen <tbody>
|
|
* anhand von data-sort-<key> auf der jeweiligen <tr>.
|
|
* - Akkordeon-Tabellen (mehrere <tbody>, z.B. Gruppen mit Detail-Zeile):
|
|
* sortiert ganze <tbody>-Blöcke anhand von data-sort-<key> auf dem
|
|
* jeweiligen <tbody>, damit Haupt- und Detail-Zeile zusammenbleiben.
|
|
* <tbody data-sort-pinned> (z.B. die virtuelle "Admin"-Zeile) bleibt
|
|
* dabei immer an ihrer Position.
|
|
*/
|
|
function compareSortValues(a, b, asc) {
|
|
a = (a === null || a === undefined) ? "" : String(a);
|
|
b = (b === null || b === undefined) ? "" : String(b);
|
|
const na = parseFloat(a), nb = parseFloat(b);
|
|
const bothNumeric = a !== "" && b !== "" && !isNaN(na) && !isNaN(nb);
|
|
const cmp = bothNumeric ? (na - nb) : a.toLowerCase().localeCompare(b.toLowerCase(), "de");
|
|
return asc ? cmp : -cmp;
|
|
}
|
|
|
|
function sortTable(table, th) {
|
|
const key = th.dataset.sortKey;
|
|
const asc = th.dataset.sortDir !== "asc";
|
|
table.querySelectorAll("thead th[data-sort-key]").forEach((h) => {
|
|
delete h.dataset.sortDir;
|
|
h.classList.remove("sort-asc", "sort-desc");
|
|
});
|
|
th.dataset.sortDir = asc ? "asc" : "desc";
|
|
th.classList.add(asc ? "sort-asc" : "sort-desc");
|
|
|
|
if (table.tBodies.length > 1) {
|
|
const movable = Array.prototype.filter.call(table.tBodies, (tb) => !tb.hasAttribute("data-sort-pinned"));
|
|
movable.sort((a, b) => compareSortValues(
|
|
a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc
|
|
));
|
|
movable.forEach((tb) => table.appendChild(tb));
|
|
} else {
|
|
const tbody = table.tBodies[0];
|
|
const rows = Array.prototype.filter.call(tbody.rows, (r) => !r.classList.contains("empty-row"));
|
|
rows.sort((a, b) => compareSortValues(
|
|
a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc
|
|
));
|
|
rows.forEach((r) => tbody.appendChild(r));
|
|
}
|
|
}
|
|
|
|
function initSortableTables() {
|
|
document.querySelectorAll("table[data-sortable] thead th[data-sort-key]").forEach((th) => {
|
|
th.classList.add("sortable-col");
|
|
th.addEventListener("click", () => sortTable(th.closest("table"), th));
|
|
});
|
|
}
|
|
|
|
/* ---------------- Init ---------------- */
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
initTheme();
|
|
initSidebar();
|
|
initModals();
|
|
initConfirmables();
|
|
initFlashedMessages();
|
|
initNavGroups();
|
|
initCheckTimer();
|
|
initRunCheckNow();
|
|
initLeaseCountdowns();
|
|
initSortableTables();
|
|
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
|
});
|
|
|
|
/* ---------------- DHCP-Lease-Countdown (settings_dhcp.html) ---------------- */
|
|
/* Ergänzt die absolute (UTC-)Ablaufzeit einer Lease um einen live tickenden
|
|
Countdown — bei den kurzen Standard-Lease-Zeiten (600s) wirkt eine reine
|
|
Absolutzeit sonst schnell "schon abgelaufen", sobald etwas Zeit vergangen
|
|
ist, bis man die (nicht selbst aktualisierende) Seite tatsächlich
|
|
anschaut. Gleiches Tick-Prinzip wie initCheckTimer oben. */
|
|
function formatLeaseCountdown(remainingMs) {
|
|
const abs = Math.abs(Math.round(remainingMs / 1000));
|
|
const m = Math.floor(abs / 60);
|
|
const s = abs % 60;
|
|
const duration = m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
return remainingMs >= 0 ? `läuft ab in ${duration}` : `abgelaufen vor ${duration}`;
|
|
}
|
|
|
|
function initLeaseCountdowns() {
|
|
const els = document.querySelectorAll(".lease-countdown");
|
|
if (!els.length) return;
|
|
function tick() {
|
|
const now = Date.now();
|
|
els.forEach((el) => {
|
|
const expireMs = parseInt(el.dataset.expireMs, 10);
|
|
if (isNaN(expireMs)) return;
|
|
el.textContent = formatLeaseCountdown(expireMs - now);
|
|
el.classList.toggle("text-danger", expireMs < now);
|
|
});
|
|
}
|
|
tick();
|
|
setInterval(tick, 1000);
|
|
}
|
|
|
|
window.PoeUI = { openModal, closeModal };
|
|
})();
|