Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
364 lines
13 KiB
JavaScript
364 lines
13 KiB
JavaScript
const TOKEN = ""; // Legacy: Auth über Session-Cookie oder Bearer von außen
|
||
const CSRF = document.querySelector('meta[name="csrf"]')?.content || "";
|
||
|
||
const STATE_LABELS = {
|
||
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
|
||
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline",
|
||
waiting: "wartet", running: "läuft",
|
||
};
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const headers = new Headers(options.headers || {});
|
||
if (TOKEN) headers.set("Authorization", "Bearer " + TOKEN);
|
||
const method = (options.method || "GET").toUpperCase();
|
||
if (CSRF && method !== "GET" && method !== "HEAD") {
|
||
headers.set("X-CSRF-Token", CSRF);
|
||
}
|
||
let body = options.body;
|
||
if (options.json !== undefined) {
|
||
headers.set("Content-Type", "application/json");
|
||
body = JSON.stringify(options.json);
|
||
}
|
||
const timeoutMs = options.timeoutMs != null ? Number(options.timeoutMs) : 20000;
|
||
const ctrl = options.signal ? null : new AbortController();
|
||
const timer = ctrl && timeoutMs > 0
|
||
? setTimeout(() => ctrl.abort(), timeoutMs)
|
||
: 0;
|
||
let response;
|
||
try {
|
||
response = await fetch("/api/v1" + path, {
|
||
method,
|
||
headers,
|
||
body,
|
||
cache: "no-store",
|
||
credentials: "same-origin",
|
||
signal: options.signal || (ctrl && ctrl.signal),
|
||
});
|
||
} catch (error) {
|
||
if (error && error.name === "AbortError") {
|
||
throw new Error("Zeitüberschreitung — Server antwortet nicht.");
|
||
}
|
||
throw error;
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
if (response.status === 401) {
|
||
const here = location.pathname + location.search;
|
||
if (!here.startsWith("/login") && !here.startsWith("/setup")) {
|
||
location.href = "/login?next=" + encodeURIComponent(here);
|
||
}
|
||
throw new Error("Anmeldung nötig.");
|
||
}
|
||
if (!response.ok) {
|
||
let message = response.statusText;
|
||
try {
|
||
const payload = await response.json();
|
||
message = payload.error || message;
|
||
} catch (error) {
|
||
message = response.statusText;
|
||
}
|
||
throw new Error(message);
|
||
}
|
||
if (response.status === 204) return null;
|
||
const type = response.headers.get("content-type") || "";
|
||
if (type.includes("json")) return response.json();
|
||
return response;
|
||
}
|
||
|
||
function withToken(url) {
|
||
// Session-Cookie reicht für same-origin Kameras; kein Token in der URL.
|
||
return url;
|
||
}
|
||
|
||
const CAMERA_MODE_KEY = "grotti-camera";
|
||
|
||
function cameraMode() {
|
||
try {
|
||
return localStorage.getItem(CAMERA_MODE_KEY) === "stream" ? "stream" : "snapshot";
|
||
} catch (error) {
|
||
return "snapshot";
|
||
}
|
||
}
|
||
|
||
function setCameraMode(mode) {
|
||
const value = mode === "stream" ? "stream" : "snapshot";
|
||
try { localStorage.setItem(CAMERA_MODE_KEY, value); } catch (error) {}
|
||
window.dispatchEvent(new CustomEvent("grotti-camera-mode", { detail: value }));
|
||
return value;
|
||
}
|
||
|
||
/** Snapshot oder Stream-URL je nach Browser-Einstellung. bustMs nur für Snapshot. */
|
||
function cameraUrl(printer, bustMs) {
|
||
if (!printer || !printer.camera) return "";
|
||
if (cameraMode() === "stream" && printer.camera_stream) {
|
||
return withToken(printer.camera_stream);
|
||
}
|
||
let url = withToken(printer.camera);
|
||
if (bustMs) url += "&t=" + Math.floor(Date.now() / bustMs);
|
||
return url;
|
||
}
|
||
|
||
function euro(value) {
|
||
return Number(value || 0).toLocaleString("de-DE", { style: "currency", currency: "EUR" });
|
||
}
|
||
|
||
function grams(value) {
|
||
return Number(value || 0).toLocaleString("de-DE", { maximumFractionDigits: 0 }) + " g";
|
||
}
|
||
|
||
function hours(seconds) {
|
||
seconds = Math.round(Number(seconds) || 0);
|
||
if (seconds <= 0) return "";
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
return h > 0 ? h + " h " + String(m).padStart(2, "0") + " min" : m + " min";
|
||
}
|
||
|
||
function finishClock(remainSeconds) {
|
||
const sec = Number(remainSeconds) || 0;
|
||
if (sec <= 0) return "";
|
||
const at = new Date(Date.now() + sec * 1000);
|
||
return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||
}
|
||
|
||
function clockOf(iso) {
|
||
if (!iso) return "";
|
||
const at = new Date(iso);
|
||
if (Number.isNaN(at.getTime())) return "";
|
||
return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: appTimeZone() });
|
||
}
|
||
|
||
const TZ_KEY = "grotti-timezone";
|
||
|
||
function appTimeZone() {
|
||
try {
|
||
const value = localStorage.getItem(TZ_KEY);
|
||
if (value && value !== "auto") return value;
|
||
} catch (error) { /* Browser */ }
|
||
return undefined;
|
||
}
|
||
|
||
function formatDateTime(iso, opts) {
|
||
if (!iso) return "—";
|
||
const at = new Date(iso);
|
||
if (Number.isNaN(at.getTime())) return "—";
|
||
const options = {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
timeZone: appTimeZone(),
|
||
...(opts || {}),
|
||
};
|
||
return at.toLocaleString("de-DE", options);
|
||
}
|
||
|
||
function formatDateRange(startIso, endIso) {
|
||
const start = startIso ? new Date(startIso) : null;
|
||
const end = endIso ? new Date(endIso) : null;
|
||
if (start && Number.isNaN(start.getTime())) return "—";
|
||
if (!start && !end) return "—";
|
||
if (start && !end) return formatDateTime(startIso) + " → …";
|
||
if (!start && end) return "… → " + formatDateTime(endIso);
|
||
const sameDay = start.toDateString() === end.toDateString();
|
||
const left = formatDateTime(startIso);
|
||
if (sameDay) {
|
||
const right = end.toLocaleTimeString("de-DE", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
timeZone: appTimeZone(),
|
||
});
|
||
return left + " – " + right;
|
||
}
|
||
return left + " – " + formatDateTime(endIso);
|
||
}
|
||
|
||
function tempChartHtml(samples) {
|
||
if (!samples || samples.length < 2) {
|
||
return '<p class="tiny">Noch zu wenig Proben für den Verlauf.</p>';
|
||
}
|
||
const times = samples.map((s) => new Date(s.sampled_at).getTime());
|
||
const t0 = times[0];
|
||
const t1 = times[times.length - 1];
|
||
const span = Math.max(1, t1 - t0);
|
||
const temps = samples.flatMap((s) => [s.nozzle, s.bed, s.chamber, s.nozzle_target, s.bed_target].filter((v) => v != null));
|
||
const minT = Math.min(...temps, 0);
|
||
const maxT = Math.max(...temps, 100);
|
||
const hasEd = samples.some((s) => s.nozzle_power != null || s.bed_power != null);
|
||
const w = 640;
|
||
const h = 210;
|
||
const padL = 44;
|
||
const padR = hasEd ? 44 : 28;
|
||
const padT = 22;
|
||
const padB = 30;
|
||
const x = (ms) => padL + ((ms - t0) / span) * (w - padL - padR);
|
||
const yT = (v) => padT + (1 - (Number(v) - minT) / Math.max(1, maxT - minT)) * (h - padT - padB);
|
||
const yEd = (v) => padT + (1 - Math.max(0, Math.min(1, Number(v) || 0))) * (h - padT - padB);
|
||
|
||
function poly(key, color, dashed, scaleEd) {
|
||
const pts = [];
|
||
samples.forEach((s, i) => {
|
||
const v = s[key];
|
||
if (v == null) return;
|
||
const yy = scaleEd ? yEd(Number(v)) : yT(v);
|
||
pts.push(`${x(times[i]).toFixed(1)},${yy.toFixed(1)}`);
|
||
});
|
||
if (pts.length < 2) return "";
|
||
const dash = dashed ? ' stroke-dasharray="5 4"' : "";
|
||
return `<polyline fill="none" stroke="${color}" stroke-width="${dashed ? 1.2 : 1.7}"${dash} points="${pts.join(" ")}" />`;
|
||
}
|
||
|
||
const tickCount = Math.min(5, samples.length);
|
||
const timeTicks = [];
|
||
for (let i = 0; i < tickCount; i++) {
|
||
const ms = t0 + (span * i) / Math.max(1, tickCount - 1);
|
||
const label = new Date(ms).toLocaleTimeString("de-DE", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
timeZone: appTimeZone(),
|
||
});
|
||
timeTicks.push(`<text x="${x(ms).toFixed(1)}" y="${h - 6}" text-anchor="middle" class="chart-label">${escapeHtml(label)}</text>`);
|
||
}
|
||
const midY = (padT + h - padB) / 2;
|
||
|
||
return `<svg class="temp-chart" viewBox="0 0 ${w} ${h}" role="img" aria-label="Temperatur links in °C, Einschaltdauer rechts in Prozent">
|
||
${poly("nozzle_target", "#fdba74", true, false)}
|
||
${poly("bed_target", "#93c5fd", true, false)}
|
||
${poly("nozzle", "#ea580c", false, false)}
|
||
${poly("bed", "#2563eb", false, false)}
|
||
${poly("chamber", "#16a34a", false, false)}
|
||
${hasEd ? poly("nozzle_power", "#c084fc", false, true) : ""}
|
||
${hasEd ? poly("bed_power", "#22d3ee", false, true) : ""}
|
||
<text x="10" y="${midY}" class="chart-axis" transform="rotate(-90 10 ${midY})">Temp °C</text>
|
||
<text x="${padL}" y="14" class="chart-label">${Math.round(maxT)}</text>
|
||
<text x="${padL}" y="${h - padB + 2}" class="chart-label">${Math.round(minT)}</text>
|
||
${hasEd ? `<text x="${w - 10}" y="${midY}" class="chart-axis" text-anchor="middle" transform="rotate(90 ${w - 10} ${midY})">ED %</text>` : ""}
|
||
${hasEd ? `<text x="${w - padR}" y="14" text-anchor="end" class="chart-label">100</text>` : ""}
|
||
${hasEd ? `<text x="${w - padR}" y="${h - padB + 2}" text-anchor="end" class="chart-label">0</text>` : ""}
|
||
${timeTicks.join("")}
|
||
</svg>
|
||
<p class="tiny chart-legend">
|
||
<span class="leg nozzle">Düse</span>
|
||
<span class="leg bed">Bett</span>
|
||
<span class="leg chamber">Kammer</span>
|
||
${hasEd ? '<span class="leg ed-n">ED Düse</span><span class="leg ed-b">ED Bett</span>' : ""}
|
||
· links °C · rechts % · gestrichelt ≈ Soll
|
||
</p>`;
|
||
}
|
||
|
||
function showNote(id, error, ok) {
|
||
const node = document.getElementById(id);
|
||
if (node) {
|
||
if (error) {
|
||
node.textContent = error.message || String(error);
|
||
node.className = "msg";
|
||
} else {
|
||
node.textContent = ok || "";
|
||
node.className = ok ? "ok" : "ok";
|
||
}
|
||
}
|
||
if (error) toast(error.message || String(error), "bad");
|
||
else if (ok) toast(ok, "good");
|
||
}
|
||
|
||
function icon(name, cls) {
|
||
return `<svg class="ico${cls ? " " + cls : ""}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
|
||
}
|
||
|
||
function chipHtml(state, label, small, iconName) {
|
||
const text = label || STATE_LABELS[state] || state || "";
|
||
const ico = iconName ? icon(iconName, "chip-ico") : "";
|
||
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${ico}${escapeHtml(text)}</span>`;
|
||
}
|
||
|
||
function shownState(printer) {
|
||
const state = (printer && printer.state) || "offline";
|
||
const hot = ((printer && printer.nozzle_target) || 0) > 40 || ((printer && printer.bed_target) || 0) > 30;
|
||
if ((state === "standby" || state === "complete") && hot) return "heating";
|
||
return state;
|
||
}
|
||
|
||
function stepsHtml(labels, index, compact) {
|
||
if (!labels || !labels.length) return "";
|
||
const parts = labels.map((label, i) => {
|
||
const cls = i < index ? "done" : i === index ? "now" : "";
|
||
return `<span class="step ${cls}">${escapeHtml(label)}</span>`;
|
||
});
|
||
return `<span class="steps${compact ? " compact" : ""}">${parts.join('<span class="step-sep"></span>')}</span>`;
|
||
}
|
||
|
||
function repeatText(job) {
|
||
const total = Math.max(1, Number(job.repeat_total) || 1);
|
||
if (total <= 1) return "";
|
||
return Math.min(total, (Number(job.repeat_done) || 0) + 1) + "/" + total;
|
||
}
|
||
|
||
function phaseText(job) {
|
||
const message = (job && job.message) || "";
|
||
if (!job || !job.is_ablauf || job.status !== "running") return message;
|
||
const parts = message.split(" · ");
|
||
return parts.length > 1 ? parts.slice(-1)[0] : message;
|
||
}
|
||
|
||
function jobLabel(filename) {
|
||
return (filename || "").replace(/\.gcode(\.3mf)?$/i, "").replace(/\.3mf$/i, "").replace(/^(\d+-)+/, "");
|
||
}
|
||
|
||
function toast(message, kind) {
|
||
let host = document.querySelector(".toast-host");
|
||
if (!host) {
|
||
host = document.createElement("div");
|
||
host.className = "toast-host";
|
||
host.setAttribute("role", "status");
|
||
host.setAttribute("aria-live", "polite");
|
||
document.body.appendChild(host);
|
||
}
|
||
const text = String(message ?? "");
|
||
host.querySelectorAll(".toast").forEach((node) => {
|
||
const body = node.querySelector("div");
|
||
if (body && body.textContent === text) node.remove();
|
||
});
|
||
while (host.children.length >= 3) host.firstElementChild?.remove();
|
||
const node = document.createElement("div");
|
||
node.className = "toast " + (kind || "good");
|
||
node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "<div></div>";
|
||
node.querySelector("div").textContent = text;
|
||
host.appendChild(node);
|
||
const ttl = kind === "bad" ? 7000 : 4000;
|
||
setTimeout(() => {
|
||
node.classList.add("out");
|
||
setTimeout(() => node.remove(), 260);
|
||
}, ttl);
|
||
}
|
||
|
||
function armButton(button, armedText, onConfirm, timeoutMs) {
|
||
if (button.dataset.armed === "1") {
|
||
clearTimeout(Number(button.dataset.armTimer || 0));
|
||
button.dataset.armed = "";
|
||
button.classList.remove("confirm-armed");
|
||
if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml;
|
||
onConfirm();
|
||
return;
|
||
}
|
||
button.dataset.idleHtml = button.innerHTML;
|
||
button.dataset.armed = "1";
|
||
button.classList.add("confirm-armed");
|
||
button.textContent = armedText;
|
||
const timer = setTimeout(() => {
|
||
button.dataset.armed = "";
|
||
button.classList.remove("confirm-armed");
|
||
if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml;
|
||
}, timeoutMs || 5000);
|
||
button.dataset.armTimer = String(timer);
|
||
}
|