Grotti 3.5.0: Pagination, Logging, Deb-Paket, Makro-Doku, PLA-Match-Fix.

This commit is contained in:
Dominic Gundel committed 2026-10-19 11:14:24 +02:00
1 parent 4ffe1dcfc6
commit f1f79c2ce2
38 files changed
+1062 -75

No files matched your search

+12 -4
View File
@@ -19,7 +19,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
from grotti import auth as grotti_auth
from grotti import config as grotti_config
from grotti import db as grotti_db
from grotti import eject, jobs, library, moonraker, notify, store
from grotti import eject, jobs, library, logutil, moonraker, notify, store
from grotti.catalog import color_catalog
from grotti.cost import calculate, offer_from_file, order_actuals
from grotti import orders as grotti_orders
@@ -32,6 +32,8 @@ from grotti.version import __version__
from grotti import verdict as grotti_verdict
from grotti.watcher import StateWatcher
log = logutil.get("grotti.web")
ROOT = Path(__file__).resolve().parent
TOOL_ROOT = ROOT.parent
DOCS_DIR = TOOL_ROOT / "docs"
@@ -316,7 +318,7 @@ def create_app(cfg):
message = "Diesen Eintrag gibt es schon."
elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
message = "Ungültiger Wert in der Anfrage."
print("DB: %s" % err, flush=True)
log.warning("DB: %s", err)
return jsonify({"error": message, "code": "db"}), 500
def body():
@@ -582,6 +584,7 @@ def create_app(cfg):
ANLEITUNG_ORDER = (
"Anleitung-Einrichtung",
"Anleitung-Installation-Pi",
"Anleitung-Oberflaeche",
"Anleitung-Wand",
"Anleitung-Dateien",
@@ -605,6 +608,7 @@ def create_app(cfg):
def anleitung_title(name):
labels = {
"Anleitung-Einrichtung": "Einrichtung",
"Anleitung-Installation-Pi": "Raspberry Pi (.deb)",
"Anleitung-Oberflaeche": "Oberfläche",
"Anleitung-Wand": "Druckerwand",
"Anleitung-Dateien": "Dateien senden",
@@ -1698,7 +1702,7 @@ def create_app(cfg):
try:
jobs.apply_deduction(c, closed, cfg["energy"])
except Exception as exc:
print("Abräumen Abzug %s: %s" % (host, exc), flush=True)
log.warning("Abräumen Abzug %s: %s", host, exc)
row = store.get_run_row(c, closed["id"]) or closed
run_json = store.run_out(row)
reprint = None
@@ -2451,7 +2455,11 @@ def create_app(cfg):
@app.get("/api/v1/laeufe")
def run_list():
return jsonify(store.list_runs(conn()))
try:
limit = min(1000, max(1, int(request.args.get("limit") or 500)))
except (TypeError, ValueError):
limit = 500
return jsonify(store.list_runs(conn(), limit=limit))
@app.get("/api/v1/laeufe/aktuell")
def run_aktuell():
+30 -8
View File
@@ -2510,21 +2510,22 @@ article.machine.flash { animation: flash 2s var(--ease); }
.lib-info > summary { list-style: none; }
.lib-info > summary::-webkit-details-marker { display: none; }
.lib-info-pop {
position: absolute;
right: 0;
top: calc(100% + 0.25rem);
z-index: 20;
position: fixed;
z-index: 80;
min-width: 11rem;
max-width: min(18rem, calc(100vw - 1rem));
padding: 0.55rem 0.7rem;
border-radius: 10px;
border: 1px solid var(--line-strong);
background: #121217;
background: var(--card-2);
box-shadow: var(--shadow);
font-size: var(--t-xs);
color: var(--muted);
display: grid;
color: var(--ink);
display: none;
gap: 0.2rem;
line-height: 1.35;
}
.lib-info[open] > .lib-info-pop { display: grid; }
.picked-details { opacity: 0.9; }
.flow-step { padding: 0.9rem 0 0.95rem; border-top: 1px solid var(--line); }
@@ -3644,8 +3645,29 @@ dialog::backdrop { background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(4px);
margin-right: 0.35rem;
}
.pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin: 0.75rem 0 0;
padding: 0.35rem 0;
}
.pager[hidden] { display: none !important; }
.pager-info {
font-size: 0.85rem;
color: var(--muted);
min-width: 7rem;
text-align: center;
}
.pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media print {
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; }
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle, .pager { display: none !important; }
.app-shell { display: block; }
.app-main { margin: 0; }
body { background: #fff; color: #111; }
+44
View File
@@ -333,3 +333,47 @@ window.setConnectionBanner = setConnectionBanner;
window.createPoller = createPoller;
window.promptAbraeumen = promptAbraeumen;
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
/** Listen-Pagination (clientseitig auf gefiltertem Array). */
const DEFAULT_PAGE_SIZE = 40;
function pageSlice(items, page, pageSize) {
const list = Array.isArray(items) ? items : [];
const size = Math.max(1, Number(pageSize) || DEFAULT_PAGE_SIZE);
const total = list.length;
const pages = Math.max(1, Math.ceil(total / size) || 1);
let p = Math.max(1, parseInt(page, 10) || 1);
if (p > pages) p = pages;
const start = (p - 1) * size;
return {
items: list.slice(start, start + size),
page: p,
pages,
total,
pageSize: size,
from: total ? start + 1 : 0,
to: Math.min(start + size, total),
};
}
function renderPager(el, state, onPage) {
if (!el) return;
const s = state || pageSlice([], 1);
if (s.total <= s.pageSize) {
el.hidden = true;
el.innerHTML = "";
return;
}
el.hidden = false;
el.className = "pager";
el.innerHTML =
'<button type="button" class="ghost pager-prev" ' + (s.page <= 1 ? "disabled" : "") + ">Zurück</button>" +
'<span class="pager-info">' + s.from + "–" + s.to + " von " + s.total + "</span>" +
'<button type="button" class="ghost pager-next" ' + (s.page >= s.pages ? "disabled" : "") + ">Weiter</button>";
el.querySelector(".pager-prev").onclick = () => onPage(s.page - 1);
el.querySelector(".pager-next").onclick = () => onPage(s.page + 1);
}
window.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
window.pageSlice = pageSlice;
window.renderPager = renderPager;
+16 -3
View File
@@ -26,9 +26,12 @@
<div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div>
</div>
<div class="pager" id="tl-pager" hidden></div>
{% endblock %}
{% block script %}
<script>
let tlPage = 1;
let tlItems = [];
const KINDS = [
["queue", "Warteschlange"],
["ablauf", "Ablauf"],
@@ -111,14 +114,19 @@ function itemHtml(ev) {
function render(items) {
const box = document.getElementById("timeline");
const note = document.getElementById("tl-note");
if (!items.length) {
const pager = document.getElementById("tl-pager");
tlItems = items || [];
if (!tlItems.length) {
box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
note.textContent = "";
renderPager(pager, null, () => {});
return;
}
const paged = pageSlice(tlItems, tlPage, DEFAULT_PAGE_SIZE);
tlPage = paged.page;
const groups = [];
for (const ev of items) {
for (const ev of paged.items) {
const label = dayLabel(ev.created_at);
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
groups[groups.length - 1].items.push(ev);
@@ -126,7 +134,11 @@ function render(items) {
box.innerHTML = groups.map((g) =>
`<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>`
).join("");
note.textContent = items.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
note.textContent = tlItems.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
renderPager(pager, paged, (p) => {
tlPage = p;
render(tlItems);
});
}
async function load() {
@@ -137,6 +149,7 @@ async function load() {
if (filter.host) q.set("host", filter.host);
try {
const data = await api("/ereignisse?" + q.toString());
tlPage = 1;
render(data.items || []);
} catch (err) {
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
+13 -1
View File
@@ -29,6 +29,7 @@
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
<tbody id="order-rows"></tbody>
</table>
<div class="pager" id="order-pager" hidden></div>
</div>
</section>
<section class="card">
@@ -246,16 +247,22 @@ async function einreihen(itemId) {
}
}
let orderPage = 1;
function renderList() {
const body = document.getElementById("order-rows");
body.innerHTML = "";
const pager = document.getElementById("order-pager");
if (!orders.length) {
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="ghost" id="order-empty-new">Ersten Auftrag anlegen →</button></p></div></td></tr>';
document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null));
renderPager(pager, null, () => {});
return;
}
const selected = document.getElementById("order-id").value;
orders.forEach((order) => {
const paged = pageSlice(orders, orderPage, DEFAULT_PAGE_SIZE);
orderPage = paged.page;
paged.items.forEach((order) => {
const tr = document.createElement("tr");
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
tr.innerHTML = "<td></td><td></td><td></td>";
@@ -275,12 +282,17 @@ function renderList() {
});
body.appendChild(tr);
});
renderPager(pager, paged, (p) => {
orderPage = p;
renderList();
});
}
async function loadList() {
const st = document.getElementById("filter-status").value;
const q = st ? "?status=" + encodeURIComponent(st) : "";
orders = await api("/auftraege" + q);
orderPage = 1;
renderList();
}
+63 -1
View File
@@ -57,6 +57,7 @@
<div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div>
</div>
<div class="pager" id="lib-pager" hidden></div>
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
</section>
@@ -839,6 +840,39 @@ function infoPopupHtml(file) {
</details>`;
}
function placeLibInfoPop(details) {
const pop = details.querySelector(".lib-info-pop");
const anchor = details.querySelector("summary");
if (!pop || !anchor) return;
const margin = 6;
const r = anchor.getBoundingClientRect();
pop.style.left = "0px";
pop.style.top = "0px";
const pw = pop.offsetWidth;
const ph = pop.offsetHeight;
let left = r.right - pw;
left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin));
let top = r.bottom + margin;
if (top + ph > window.innerHeight - margin && r.top - margin - ph >= margin) {
top = r.top - margin - ph;
} else {
top = Math.min(top, Math.max(margin, window.innerHeight - ph - margin));
}
pop.style.left = left + "px";
pop.style.top = top + "px";
}
function closeOpenLibInfo(except) {
document.querySelectorAll("details.lib-info[open]").forEach((node) => {
if (node !== except) node.removeAttribute("open");
});
}
function repositionOpenLibInfo() {
const open = document.querySelector("details.lib-info[open]");
if (open) placeLibInfoPop(open);
}
function thumbHtml(file) {
return file.preview
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
@@ -856,6 +890,8 @@ function libItemHtml(file) {
</div>`;
}
let libPage = 1;
function renderLibrary() {
const list = document.getElementById("lib-list");
const shown = filteredFiles();
@@ -863,15 +899,19 @@ function renderLibrary() {
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
if (!files.length) {
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return;
}
if (!shown.length) {
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return;
}
const paged = pageSlice(shown, libPage, DEFAULT_PAGE_SIZE);
libPage = paged.page;
const parts = [];
let lastGroup = null;
shown.forEach((file) => {
paged.items.forEach((file) => {
let group = null;
if (sort === "material") group = materialKey(file) || "Ohne Material";
else if (sort === "folder") group = folderLabel(folderKey(file));
@@ -882,6 +922,10 @@ function renderLibrary() {
parts.push(libItemHtml(file));
});
list.innerHTML = parts.join("");
renderPager(document.getElementById("lib-pager"), paged, (p) => {
libPage = p;
renderLibrary();
});
}
/* ---------- Flow rechts ---------- */
@@ -1546,6 +1590,23 @@ async function doAblauf() {
/* ---------- Ereignisse ---------- */
document.getElementById("lib-list").addEventListener("toggle", (event) => {
const details = event.target;
if (!(details instanceof HTMLDetailsElement) || !details.classList.contains("lib-info")) return;
if (details.open) {
closeOpenLibInfo(details);
placeLibInfoPop(details);
}
}, true);
document.getElementById("lib-list").addEventListener("scroll", repositionOpenLibInfo, { passive: true });
window.addEventListener("resize", repositionOpenLibInfo);
document.addEventListener("pointerdown", (event) => {
if (event.target.closest("details.lib-info")) return;
closeOpenLibInfo();
});
document.getElementById("lib-list").addEventListener("click", (event) => {
if (event.target.closest(".lib-move")) {
event.stopPropagation();
@@ -1628,6 +1689,7 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
const node = document.getElementById(id);
if (!node) return;
const onChange = () => {
libPage = 1;
saveLibMemory();
renderLibrary();
};
+2 -2
View File
@@ -9,7 +9,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=68">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=70">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
<script>
@@ -161,7 +161,7 @@
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
</dialog>
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=5"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
<script>
async function doLogout() {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
+17 -1
View File
@@ -65,6 +65,7 @@
<thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead>
<tbody id="spool-rows"></tbody>
</table>
<div class="pager" id="spool-pager" hidden></div>
</section>
<section class="card mat-form" id="spool-panel" hidden>
@@ -683,6 +684,8 @@ function fillSelects() {
materialSelect.value = currentMaterial;
}
let spoolPage = 1;
function renderSpools() {
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
@@ -698,14 +701,17 @@ function renderSpools() {
spoolFilter === "archive" ? shown.length : activeCount
);
const note = document.getElementById("spool-list-note");
const pager = document.getElementById("spool-pager");
if (!spools.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>';
renderPager(pager, null, () => {});
return;
}
if (!shown.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
renderPager(pager, null, () => {});
return;
}
note.textContent = spoolFilter === "archive"
@@ -719,8 +725,10 @@ function renderSpools() {
const bh = b.printer_host || "zzz";
return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de");
});
const paged = pageSlice(ordered, spoolPage, DEFAULT_PAGE_SIZE);
spoolPage = paged.page;
let lastPlace = null;
ordered.forEach((spool) => {
paged.items.forEach((spool) => {
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
if (group !== lastPlace) {
lastPlace = group;
@@ -753,6 +761,10 @@ function renderSpools() {
tr.addEventListener("click", () => fill(spool));
body.appendChild(tr);
});
renderPager(pager, paged, (p) => {
spoolPage = p;
renderSpools();
});
}
function renderSorten() {
@@ -867,6 +879,7 @@ document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
});
document.getElementById("spool-filter").addEventListener("click", (event) => {
spoolPage = 1;
const btn = event.target.closest("[data-filter]");
if (!btn) return;
spoolFilter = btn.dataset.filter || "all";
@@ -874,6 +887,7 @@ document.getElementById("spool-filter").addEventListener("click", (event) => {
});
document.getElementById("spool-search").addEventListener("input", (event) => {
spoolSearch = event.target.value || "";
spoolPage = 1;
renderSpools();
});
document.getElementById("sorten-search").addEventListener("input", (event) => {
@@ -996,10 +1010,12 @@ document.getElementById("spool-delete").addEventListener("click", () => {
});
});
document.getElementById("spool-filter-type")?.addEventListener("change", (event) => {
spoolPage = 1;
spoolTypeFilter = event.target.value || "";
renderSpools();
});
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
spoolPage = 1;
spoolColorFilter = event.target.value || "";
renderSpools();
});
+12 -1
View File
@@ -22,6 +22,7 @@
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
<tbody id="proj-rows"></tbody>
</table>
<div class="pager" id="proj-pager" hidden></div>
</div>
</section>
@@ -110,15 +111,21 @@ function fill(project) {
renderDetail(project);
}
let projPage = 1;
function renderList() {
const body = document.getElementById("proj-rows");
const pager = document.getElementById("proj-pager");
document.getElementById("proj-count").textContent = String(projects.length);
if (!projects.length) {
body.innerHTML = '<tr><td class="empty" colspan="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
renderPager(pager, null, () => {});
return;
}
body.innerHTML = "";
projects.forEach((project) => {
const paged = pageSlice(projects, projPage, DEFAULT_PAGE_SIZE);
projPage = paged.page;
paged.items.forEach((project) => {
const tr = document.createElement("tr");
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></td>";
@@ -130,6 +137,10 @@ function renderList() {
tr.addEventListener("click", () => openProject(project.id));
body.appendChild(tr);
});
renderPager(pager, paged, (p) => {
projPage = p;
renderList();
});
}
function renderDetail(project) {
+15 -4
View File
@@ -27,6 +27,7 @@
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
<tbody id="run-rows"></tbody>
</table>
<div class="pager" id="run-pager" hidden></div>
</div>
{% endblock %}
{% block script %}
@@ -37,6 +38,7 @@ const states = {
};
let runs = [];
let spools = [];
let runPage = 1;
function fillPrinterFilter() {
const select = document.getElementById("run-filter-printer");
@@ -82,13 +84,17 @@ function render() {
const shown = filteredRuns();
if (!runs.length) {
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
renderPager(document.getElementById("run-pager"), null, () => {});
return;
}
if (!shown.length) {
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("filter") + '<p>Kein Lauf passt zum Filter.</p></div></td></tr>';
renderPager(document.getElementById("run-pager"), null, () => {});
return;
}
shown.forEach((run) => {
const paged = pageSlice(shown, runPage, DEFAULT_PAGE_SIZE);
runPage = paged.page;
paged.items.forEach((run) => {
const tr = document.createElement("tr");
tr.className = "pick";
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
@@ -233,16 +239,21 @@ function render() {
}
body.appendChild(tr);
});
renderPager(document.getElementById("run-pager"), paged, (p) => {
runPage = p;
render();
});
}
async function load() {
[runs, spools] = await Promise.all([api("/laeufe"), api("/spulen")]);
[runs, spools] = await Promise.all([api("/laeufe?limit=500"), api("/spulen")]);
runPage = 1;
render();
}
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
document.getElementById(id).addEventListener("change", render);
document.getElementById(id).addEventListener("input", render);
document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
document.getElementById(id).addEventListener("input", () => { runPage = 1; render(); });
});
document.getElementById("sync").addEventListener("click", async () => {
+14 -2
View File
@@ -34,6 +34,7 @@
</thead>
<tbody id="q-rows"></tbody>
</table>
<div class="pager" id="q-pager" hidden></div>
</div>
{% endblock %}
{% block script %}
@@ -45,6 +46,7 @@ const STATUS_LABEL = {
let jobs = [];
let liveDirect = [];
let filter = "";
let qPage = 1;
function targetOf(job) {
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
@@ -86,6 +88,7 @@ function render() {
});
const body = document.getElementById("q-rows");
const shown = shownJobs();
const pager = document.getElementById("q-pager");
if (!shown.length) {
const hint = jobs.length
? "Nichts in dieser Ansicht."
@@ -97,10 +100,13 @@ function render() {
: "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p>' + cta + '</div></td></tr>';
document.getElementById("q-note").textContent = "";
renderPager(pager, null, () => {});
return;
}
const waiting = jobs.filter((j) => j.status === "waiting");
body.innerHTML = shown.map((job) => {
const paged = pageSlice(shown, qPage, DEFAULT_PAGE_SIZE);
qPage = paged.page;
body.innerHTML = paged.items.map((job) => {
const st = job.status;
if (job.direct) {
return `<tr data-direct="${escapeHtml(job.host)}">
@@ -141,17 +147,22 @@ function render() {
</tr>`;
}).join("");
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
renderPager(pager, paged, (p) => {
qPage = p;
render();
});
}
async function load() {
try {
const [queue, active] = await Promise.all([
api("/druck-queue?limit=120"),
api("/druck-queue?limit=200"),
api("/laeuft").catch(() => []),
]);
jobs = queue || [];
const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host));
liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host));
qPage = 1;
render();
} catch (error) {
document.getElementById("q-rows").innerHTML =
@@ -163,6 +174,7 @@ document.getElementById("q-filter").addEventListener("click", (event) => {
const btn = event.target.closest("[data-status]");
if (!btn) return;
filter = btn.dataset.status || "";
qPage = 1;
render();
});
document.getElementById("q-refresh").addEventListener("click", load);