visual refactor

This commit is contained in:
Mateusz Gruszczyński
2025-10-19 15:07:15 +02:00
parent 3694211fd3
commit 813d7d5099
3 changed files with 296 additions and 236 deletions

63
static/js/ui.js Normal file
View File

@@ -0,0 +1,63 @@
// static/js/ui.js
(() => {
const docEl = document.documentElement;
// ---- THEME ----
const THEME_KEY = "pve-ui-theme";
const storedTheme = localStorage.getItem(THEME_KEY);
if (storedTheme === "light" || storedTheme === "dark") {
docEl.setAttribute("data-bs-theme", storedTheme);
}
const btnTheme = document.getElementById("btnTheme");
if (btnTheme) {
btnTheme.addEventListener("click", () => {
const current = docEl.getAttribute("data-bs-theme") === "light" ? "dark" : "light";
docEl.setAttribute("data-bs-theme", current);
localStorage.setItem(THEME_KEY, current);
btnTheme.innerHTML = current === "dark"
? '<i class="bi bi-moon-stars"></i> Theme'
: '<i class="bi bi-sun"></i> Theme';
});
}
// ---- DENSITY ----
const DENSITY_KEY = "pve-ui-density";
const storedDensity = localStorage.getItem(DENSITY_KEY);
if (storedDensity === "compact") {
docEl.setAttribute("data-density", "compact");
}
const btnDensity = document.getElementById("btnDensity");
if (btnDensity) {
btnDensity.addEventListener("click", () => {
const isCompact = docEl.getAttribute("data-density") === "compact";
if (isCompact) {
docEl.removeAttribute("data-density");
localStorage.setItem(DENSITY_KEY, "normal");
} else {
docEl.setAttribute("data-density", "compact");
localStorage.setItem(DENSITY_KEY, "compact");
}
});
}
// ---- PRE blocks: keep nicely scrollable ----
const pres = document.querySelectorAll("pre.pre-scrollable");
pres.forEach((p) => {
p.style.maxHeight = "40vh";
p.style.overflow = "auto";
});
// Optional: subtle overflow shadows for horizontally scrollable areas
const scrollables = document.querySelectorAll(".table-responsive, .overflow-auto");
scrollables.forEach((el) => {
el.addEventListener("scroll", () => {
// Add classes for left/right shadow indicators
const atStart = el.scrollLeft <= 0;
const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
el.classList.toggle("is-scrolled-start", !atStart);
el.classList.toggle("is-scrolled-end", !atEnd);
});
});
})();

View File

@@ -1,198 +1,130 @@
/* Dark theme */ /* ========== Root, theme & density ========== */
body { :root {
background-color: #0f1115; --radius: 14px;
--shadow-sm: 0 4px 12px rgba(0, 0, 0, .08);
} }
.card.health-card { html[data-density="compact"] .table-sm> :not(caption)>*>* {
background: #101520; padding: .3rem .4rem;
} }
.health-dot { html[data-density="compact"] .input-group-sm>.form-control,
html[data-density="compact"] .form-select,
html[data-density="compact"] .btn-sm {
padding-top: .2rem;
padding-bottom: .2rem;
}
/* Rounded cards / sections */
.card {
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.accordion .accordion-item {
border: none;
}
.accordion-button {
border-radius: 0 !important;
}
/* Navbar polish */
.navbar .navbar-brand i {
font-size: 1.15rem;
}
/* Health dot — controlled by JS via .ok / .bad */
.health-card .health-dot {
width: 12px; width: 12px;
height: 12px; height: 12px;
border-radius: 50%; border-radius: 50%;
background: #dc3545; background: var(--bs-secondary-color);
/* neutral by default */
box-shadow: 0 0 0 4px rgba(0, 0, 0, .12) inset, 0 0 10px rgba(0, 0, 0, .1);
} }
.health-dot.ok { /* green when OK, red when problem */
background: #28a745; .health-card .health-dot.ok {
background: var(--bs-success);
} }
.health-dot.bad { .health-card .health-dot.bad {
background: #dc3545; background: var(--bs-danger);
} }
/* Tables */
.table td, /* Tables: sticky head & compact borders */
.table th { .table thead th {
position: sticky;
top: 0;
z-index: 2;
background: var(--bs-body-bg);
}
.table thead {
border-bottom: 1px solid var(--bs-border-color);
}
.table> :not(caption)>*>* {
vertical-align: middle; vertical-align: middle;
} }
/* Dividers */ /* Overflow shadows for scrollable containers (JS toggles classes) */
.vr {
width: 1px;
min-height: 1rem;
background: rgba(255, 255, 255, .15);
}
/* --- horizontal scroll & nowrap for wide tables --- */
.table-responsive { .table-responsive {
overflow-x: auto; position: relative;
mask-image: linear-gradient(to right, transparent 0, black 12px, black calc(100% - 12px), transparent 100%);
} }
.table-nowrap { .table-responsive.is-scrolled-start {
mask-image: linear-gradient(to right, black 0, black calc(100% - 12px), transparent 100%);
}
.table-responsive.is-scrolled-end {
mask-image: linear-gradient(to right, transparent 0, black 12px, black 100%);
}
/* Pre blocks */
pre {
background: var(--bs-tertiary-bg);
border-radius: 10px;
padding: .75rem;
border: 1px solid var(--bs-border-color);
}
/* Footer */
.site-footer {
background: var(--bs-body-bg);
}
/* Buttons & utilities */
.btn .bi {
margin-right: .35rem;
}
/* Make first column narrow-friendly */
.w-1 {
width: 1%;
white-space: nowrap; white-space: nowrap;
} }
@media (min-width: 992px) { /* Slightly denser tabs bar and nicer corners */
.table-nowrap-lg-normal { .nav-tabs .nav-link {
white-space: normal; border: 0;
} border-bottom: 2px solid transparent;
padding: .6rem .8rem;
} }
/* sticky first column (for wide tables) */ .nav-tabs .nav-link.active {
.sticky-col { border-bottom-color: var(--bs-primary);
position: sticky; font-weight: 600;
left: 0;
z-index: 2;
background: var(--bs-body-bg);
box-shadow: 1px 0 0 rgba(255, 255, 255, .08);
} }
footer.site-footer { /* Global loading spacing */
border-top: 1px solid rgba(255, 255, 255, .1); #global-loading {
display: none;
} }
footer.site-footer a { #global-loading.show {
text-decoration: none; display: block;
}
footer.site-footer a:hover {
text-decoration: underline;
}
/* Toast container constraints */
#toast-container .toast {
max-width: min(420px, 90vw);
word-wrap: break-word;
}
#toast-container {
max-width: 92vw;
}
#toast-container {
width: min(480px, 96vw);
max-width: min(480px, 96vw);
}
#toast-container .toast {
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}
.position-fixed.bottom-0.end-0.p-3 {
right: max(env(safe-area-inset-right), 1rem);
bottom: max(env(safe-area-inset-bottom), 1rem);
}
/* Row chevron (expandable rows) */
.table .chev {
width: 1.25rem;
text-align: center;
user-select: none;
}
.table tr.expandable {
cursor: pointer;
}
.table tr.expandable .chev::before {
content: "▸";
display: inline-block;
transition: transform .15s ease;
}
.table tr.expanded .chev::before {
transform: rotate(90deg);
content: "▾";
}
/* Small utility widths */
.w-1 {
width: 1.25rem;
}
/* Subtle skeleton */
.skel {
position: relative;
background: linear-gradient(90deg, rgba(255, 255, 255, .05) 25%, rgba(255, 255, 255, .10) 37%, rgba(255, 255, 255, .05) 63%);
background-size: 400% 100%;
animation: skel 1.4s ease-in-out infinite;
}
@keyframes skel {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
#vm-admin,
#vm-admin .table-responsive,
#vm-admin table,
#vm-admin tbody,
#vm-admin tr,
#vm-admin td {
overflow: visible !important;
}
#vm-admin td {
position: relative;
z-index: 1;
}
#vm-admin .target-node {
position: relative;
z-index: 1001;
}
/* Toasts: hard-pinned to bottom-right corner */
#toast-container {
position: fixed !important;
right: 0 !important;
bottom: 0 !important;
left: auto !important;
top: auto !important;
margin: 0 !important;
padding: 1rem !important;
width: auto;
/* allow toast's own width (e.g., 350px in Bootstrap) */
max-width: 100vw;
/* safety on tiny screens */
pointer-events: none;
z-index: 3000;
}
#toast-container .toast {
pointer-events: auto;
margin: 0.25rem 0 0 0;
/* stack vertically */
}
@supports (inset: 0) {
/* If the browser supports logical inset, keep it exact as well */
#toast-container {
inset: auto 0 0 auto !important;
}
} }

View File

@@ -2,75 +2,134 @@
<html lang="en" data-bs-theme="dark"> <html lang="en" data-bs-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1" />
<title>PVE HA Panel</title> <title>PVE HA Panel</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="{{ url_for('static', filename='styles.css') }}" rel="stylesheet"> <!-- Bootstrap & Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" />
<!-- App styles -->
<link href="{{ url_for('static', filename='styles.css') }}" rel="stylesheet" />
</head> </head>
<body> <body>
<div class="container py-4"> <!-- Top Navbar -->
<nav class="navbar navbar-expand-lg sticky-top border-bottom bg-body">
<div class="container-fluid">
<a class="navbar-brand fw-semibold d-flex align-items-center gap-2" href="#">
<i class="bi bi-hdd-network"></i> PVE HA Panel
</a>
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#topNav">
<h1 class="h4 m-0">PVE HA Panel</h1> <span class="navbar-toggler-icon"></span>
<div class="d-flex flex-wrap align-items-center gap-2"> </button>
<input class="form-control form-control-sm" id="node" value="{{ node }}" style="width: 180px" aria-label="Node">
<button class="btn btn-outline-secondary btn-sm" id="btnToggleAll" aria-expanded="false">Collapse/Expand
all</button>
<div class="vr d-none d-md-block"></div> <div class="collapse navbar-collapse" id="topNav">
<!-- Left: quick filters -->
<form class="d-flex align-items-center gap-2 me-auto mt-3 mt-lg-0 flex-wrap" role="search">
<div class="input-group input-group-sm" style="max-width: 220px;">
<span class="input-group-text"><i class="bi bi-diagram-3"></i></span>
<input class="form-control" id="node" value="{{ node }}" placeholder="Node" aria-label="Node" />
</div>
</form>
<button class="btn btn-primary btn-sm" id="btnRefresh">Refresh now</button> <!-- Right: global controls -->
<div class="input-group input-group-sm" style="width:220px"> <div class="d-flex flex-wrap align-items-center gap-2 mt-3 mt-lg-0">
<span class="input-group-text">Auto-refresh</span> <button class="btn btn-outline-secondary btn-sm" id="btnToggleAll" aria-expanded="false">
<select class="form-select" id="selInterval" disabled> <i class="bi bi-arrows-collapse"></i> Collapse/Expand all
<option value="10000">10 s</option> </button>
<option value="20000">20 s</option>
<option value="30000" selected>30 s</option> <div class="vr d-none d-lg-block"></div>
<option value="45000">45 s</option>
<option value="60000">60 s</option> <button class="btn btn-primary btn-sm" id="btnRefresh">
<option value="120000">120 s</option> <i class="bi bi-arrow-clockwise"></i> Refresh now
</select> </button>
<button class="btn btn-outline-success" id="btnAuto">OFF</button>
<div class="input-group input-group-sm" style="width: 240px;">
<span class="input-group-text"><i class="bi bi-clock-history"></i> Auto</span>
<select class="form-select" id="selInterval" disabled>
<option value="10000">10 s</option>
<option value="20000">20 s</option>
<option value="30000" selected>30 s</option>
<option value="45000">45 s</option>
<option value="60000">60 s</option>
<option value="120000">120 s</option>
</select>
<button class="btn btn-outline-success" id="btnAuto">OFF</button>
</div>
<div class="vr d-none d-lg-block"></div>
<button type="button" class="btn btn-success btn-sm" id="btnEnable">
<i class="bi bi-hammer"></i> Enable maintenance
</button>
<button type="button" class="btn btn-danger btn-sm" id="btnDisable">
<i class="bi bi-exclamation-octagon"></i> Disable maintenance
</button>
<div class="vr d-none d-lg-block"></div>
<!-- UI-only toggles (localStorage) -->
<button type="button" class="btn btn-outline-secondary btn-sm" id="btnDensity" title="Toggle compact density">
<i class="bi bi-distribute-vertical"></i> Density
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="btnTheme" title="Toggle dark / light">
<i class="bi bi-moon-stars"></i> Theme
</button>
</div> </div>
<div class="vr d-none d-md-block"></div>
<button type="button" class="btn btn-success btn-sm" id="btnEnable">Enable maintenance</button>
<button type="button" class="btn btn-danger btn-sm" id="btnDisable">Disable maintenance</button>
</div> </div>
</div> </div>
</nav>
<!-- Global loading --> <!-- Global loading -->
<div id="global-loading" class="d-flex align-items-center gap-2 mb-3"> <div id="global-loading" class="container-fluid py-2">
<div class="d-flex align-items-center gap-2 small text-muted">
<div class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></div> <div class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></div>
<span class="small text-muted">Loading data…</span> <span>Loading data…</span>
</div> </div>
</div>
<!-- Health & Tabs -->
<main class="container-fluid py-3">
<!-- HEALTH --> <!-- HEALTH -->
<div class="card mb-3 border-0 shadow health-card"> <section class="card border-0 shadow-sm mb-3 health-card">
<div class="card-body d-flex flex-wrap align-items-center gap-3"> <div class="card-body d-flex flex-wrap align-items-center gap-3">
<div class="health-dot" id="healthDot" aria-hidden="true"></div> <span class="health-dot" id="healthDot" aria-hidden="true"></span>
<div> <div class="me-auto">
<div class="fw-bold" id="healthTitle">Loading…</div> <div class="fw-semibold fs-6" id="healthTitle">Loading…</div>
<div class="text-muted small" id="healthSub"></div> <div class="text-muted small" id="healthSub"></div>
</div> </div>
</div>
</div>
<ul class="nav nav-tabs mb-3" id="mainTabs" role="tablist"> <!-- Small inline metrics (space efficient) -->
<div class="d-flex flex-wrap gap-3 small text-muted" id="inlineMetrics" aria-live="polite">
<!-- You can fill from your JS if you wish; purely presentational -->
</div>
</div>
</section>
<!-- Main tabs -->
<ul class="nav nav-tabs nav-fill shadow-sm rounded overflow-auto mb-3" id="mainTabs" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-ha" type="button">HA</button> <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-ha" type="button">
<i class="bi bi-shield-lock"></i> HA
</button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-nonha" type="button">VM/CT (non-HA)</button> <button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-nonha" type="button">
<i class="bi bi-cpu"></i> VM/CT (non-HA)
</button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-admin" type="button">VM Admin</button> <button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-admin" type="button">
<i class="bi bi-tools"></i> VM Admin
</button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-nodes" type="button">Nodes</button> <button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-nodes" type="button">
<i class="bi bi-diagram-3"></i> Nodes
</button>
</li> </li>
</ul> </ul>
@@ -80,7 +139,7 @@
<div class="accordion" id="acc"> <div class="accordion" id="acc">
<!-- Cluster / Quorum --> <!-- Cluster / Quorum -->
<div class="accordion-item"> <div class="accordion-item rounded-3 overflow-hidden mb-2">
<h2 class="accordion-header" id="h-q"> <h2 class="accordion-header" id="h-q">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-q"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-q">
Cluster / Quorum Cluster / Quorum
@@ -96,8 +155,8 @@
</div> </div>
</div> </div>
<!-- Systemd --> <!-- Systemd (HA) -->
<div class="accordion-item"> <div class="accordion-item rounded-3 overflow-hidden mb-2">
<h2 class="accordion-header" id="h-sd"> <h2 class="accordion-header" id="h-sd">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-sd"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-sd">
Systemd (HA) Systemd (HA)
@@ -105,13 +164,15 @@
</h2> </h2>
<div id="c-sd" class="accordion-collapse collapse" data-bs-parent="#acc"> <div id="c-sd" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body"> <div class="accordion-body">
<div id="units" class="d-flex flex-wrap gap-2"><span class="text-muted small">Loading…</span></div> <div id="units" class="d-flex flex-wrap gap-2">
<span class="text-muted small">Loading…</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Replication --> <!-- Replication -->
<div class="accordion-item"> <div class="accordion-item rounded-3 overflow-hidden mb-2">
<h2 class="accordion-header" id="h-repl"> <h2 class="accordion-header" id="h-repl">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#c-repl"> data-bs-target="#c-repl">
@@ -126,7 +187,7 @@
</div> </div>
<!-- HA resources --> <!-- HA resources -->
<div class="accordion-item"> <div class="accordion-item rounded-3 overflow-hidden mb-2">
<h2 class="accordion-header" id="h-res"> <h2 class="accordion-header" id="h-res">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#c-res"> data-bs-target="#c-res">
@@ -136,7 +197,7 @@
<div id="c-res" class="accordion-collapse collapse" data-bs-parent="#acc"> <div id="c-res" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body table-responsive"> <div class="accordion-body table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap" id="ha-res"> <table class="table table-sm table-striped align-middle table-nowrap" id="ha-res">
<thead> <thead class="position-sticky top-0 bg-body">
<tr> <tr>
<th class="w-1"></th> <th class="w-1"></th>
<th>SID</th> <th>SID</th>
@@ -148,13 +209,13 @@
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
<div class="small text-muted">Click to expand VM/CT data.</div> <div class="small text-muted">Click a row to expand VM/CT details.</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Raw --> <!-- Raw -->
<div class="accordion-item"> <div class="accordion-item rounded-3 overflow-hidden">
<h2 class="accordion-header" id="h-raw"> <h2 class="accordion-header" id="h-raw">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#c-raw"> data-bs-target="#c-raw">
@@ -165,10 +226,10 @@
<div class="accordion-body"> <div class="accordion-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-lg-6"> <div class="col-lg-6">
<pre id="pvecm" class="mb-0 small"></pre> <pre id="pvecm" class="mb-0 small pre-scrollable"></pre>
</div> </div>
<div class="col-lg-6"> <div class="col-lg-6">
<pre id="cfgtool" class="mb-0 small"></pre> <pre id="cfgtool" class="mb-0 small pre-scrollable"></pre>
</div> </div>
</div> </div>
</div> </div>
@@ -180,10 +241,10 @@
<!-- TAB: Non-HA --> <!-- TAB: Non-HA -->
<div class="tab-pane fade" id="tab-nonha"> <div class="tab-pane fade" id="tab-nonha">
<div class="card border-0"> <div class="card border-0 shadow-sm">
<div class="card-body table-responsive"> <div class="card-body table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap" id="nonha"> <table class="table table-sm table-striped align-middle table-nowrap" id="nonha">
<thead> <thead class="position-sticky top-0 bg-body">
<tr> <tr>
<th class="w-1"></th> <th class="w-1"></th>
<th>SID</th> <th>SID</th>
@@ -199,17 +260,17 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="small text-muted">Kliknij wiersz, aby rozwinąć szczegóły VM/CT.</div> <div class="small text-muted">Click a row to expand VM/CT details.</div>
</div> </div>
</div> </div>
</div> </div>
<!-- TAB: VM Admin --> <!-- TAB: VM Admin -->
<div class="tab-pane fade" id="tab-admin"> <div class="tab-pane fade" id="tab-admin">
<div class="card border-0"> <div class="card border-0 shadow-sm">
<div class="card-body table-responsive"> <div class="card-body table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap" id="vm-admin"> <table class="table table-sm table-striped align-middle table-nowrap" id="vm-admin">
<thead> <thead class="position-sticky top-0 bg-body">
<tr> <tr>
<th>SID</th> <th>SID</th>
<th>Type</th> <th>Type</th>
@@ -232,13 +293,12 @@
</div> </div>
</div> </div>
<!-- TAB: Nodes -->
<!-- TAB: Nodes (expandable) -->
<div class="tab-pane fade" id="tab-nodes"> <div class="tab-pane fade" id="tab-nodes">
<div class="card border-0"> <div class="card border-0 shadow-sm">
<div class="card-body table-responsive"> <div class="card-body table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap" id="nodes"> <table class="table table-sm table-striped align-middle table-nowrap" id="nodes">
<thead> <thead class="position-sticky top-0 bg-body">
<tr> <tr>
<th class="w-1"></th> <th class="w-1"></th>
<th>Node</th> <th>Node</th>
@@ -264,8 +324,8 @@
<div class="text-muted small mt-3" id="footer"></div> <div class="text-muted small mt-3" id="footer"></div>
<!-- SITE FOOTER --> <!-- Footer -->
<footer class="site-footer mt-4 pt-3 pb-4 text-muted small"> <footer class="site-footer mt-4 pt-3 pb-4 text-muted small border-top">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2"> <div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
<div>© 2025 PVE HA Panel</div> <div>© 2025 PVE HA Panel</div>
<div> <div>
@@ -276,15 +336,20 @@
</div> </div>
</div> </div>
</footer> </footer>
</div> </main>
<!-- Toasts --> <!-- Toasts -->
<div aria-live="polite" aria-atomic="true" class="position-fixed bottom-0 end-0" style="z-index: 1080"> <div aria-live="polite" aria-atomic="true" class="position-fixed bottom-0 end-0 p-2" style="z-index:1080">
<div id="toast-container" class="toast-container"></div> <div id="toast-container" class="toast-container"></div>
</div> </div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- UI-only enhancements (safe; no API changes) -->
<script type="module" src="{{ url_for('static', filename='js/ui.js') }}"></script>
<!-- Your logic (unchanged ids/contracts) -->
<script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script> <script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>
</body> </body>