Files
grotti/web/static/api.js
T

364 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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", starting: "startet",
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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);
}