Files
florian.wildeandClaude Opus 5 c5293f5915 fix(ui): Ueberbreite auf dem Handy beseitigen (#038)
Beim Oeffnen der Warnungen und auf der Host-Detailansicht zoomte der Browser
die ganze Seite heraus. Ursache war jeweils ein Element breiter als das
Fenster: die 22 rem breite Klappliste, drei Tabellen ohne table-responsive
und eine 32-stellige Server-ID ohne Umbruch.

Klapplisten richten sich auf dem Telefon jetzt am Fenster aus statt am Knopf,
die Tabellen bekommen ihre Huelle, lange IDs duerfen umbrechen. Geprueft mit
einer neuen Aufnahme in Telefonbreite (--mobil).

Dabei ein eigener Fehler aus #037 aufgefallen: Die Laufzeit-Detailseite
rechnete weiter mit dem Listenpreis statt mit dem Betrag nach Rabatt - Marge
und Vorschlag fuer die Kundenrechnung waren um den Rabatt zu hoch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:01:43 +02:00

237 lines
9.1 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.
#!/usr/bin/env node
/**
* Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/).
*
* npm run screenshots # heller Modus, Desktop
* npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht
* npm run screenshots -- --seite name=/pfad # zusätzlich eine bestimmte Seite
*
* Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten.
* Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt
* und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet.
*/
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { request as httpAnfrage } from "node:http";
import { SignJWT } from "jose";
import pg from "pg";
import "dotenv/config";
const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
const ALLE = process.argv.includes("--alle");
/** Zusätzliche Handy-Aufnahmen von der Befehlszeile: --mobil name=/pfad */
const MOBIL_ZUSATZ = process.argv
.filter((wert, i) => process.argv[i - 1] === "--mobil")
.map((eintrag) => {
const [name, ...rest] = eintrag.split("=");
return { name, pfad: rest.join("="), titel: "Handy" };
});
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
const ZUSATZ = process.argv
.filter((wert, i) => process.argv[i - 1] === "--seite")
.map((eintrag) => {
const [name, ...rest] = eintrag.split("=");
return { name, pfad: rest.join("="), titel: "zusätzlich" };
});
// Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen.
const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots");
const SEITEN = [
{ name: "dashboard", pfad: "/", titel: "Dashboard" },
{ name: "hosts", pfad: "/hosts", titel: "Host-Tabelle" },
{ name: "host-detail", pfad: "/hosts?q=pve-brandl-01", titel: "Host-Suche" },
{ name: "kunden", pfad: "/kunden", titel: "Kundenübersicht" },
{ name: "nicht-zugeordnet", pfad: "/hosts/nicht-zugeordnet", titel: "Nicht zugeordnete Hosts" },
{ name: "rechnungen", pfad: "/rechnungen", titel: "Rechnungen" },
{ name: "jahresbahn", pfad: "/jahresbahn", titel: "Jahresbahn" },
{ name: "warnungen", pfad: "/warnungen", titel: "Warnungen" },
{ name: "warnregeln", pfad: "/einstellungen/warnregeln", titel: "Warnregeln" },
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
{ name: "system", pfad: "/einstellungen/system", titel: "Systemeinstellungen" },
{ name: "benutzer", pfad: "/einstellungen/benutzer", titel: "Benutzerverwaltung" },
{ name: "passkeys", pfad: "/einstellungen/passkeys", titel: "Passkeys" },
];
async function sitzungsToken() {
const geheimnis = process.env.APP_SECRET;
if (!geheimnis || geheimnis.length < 32) {
throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder.");
}
// Seit #027 schlägt die Sitzungsprüfung den Benutzer in der Datenbank nach: Ein erfundenes
// Konto landet wieder auf der Anmeldeseite. Deshalb hier ein echtes Administratorkonto nehmen.
// Kein Prisma-Client hier: Das Skript läuft als reines JavaScript, der erzeugte Client ist TypeScript.
const datenbank = new pg.Client({ connectionString: process.env.DATABASE_URL });
await datenbank.connect();
const { rows } = await datenbank.query(
`SELECT id, email, name FROM "Benutzer" WHERE rolle = 'ADMIN' AND aktiv = true ORDER BY "erstelltAm" LIMIT 1`,
);
await datenbank.end();
const benutzer = rows[0];
if (!benutzer) {
throw new Error("Kein aktiver Administrator in der Datenbank – erst `npm run db:seed` ausführen.");
}
return new SignJWT({
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "passwort",
})
.setProtectedHeader({ alg: "HS256" })
.setSubject(benutzer.id)
.setIssuedAt()
.setIssuer("proxledger")
.setExpirationTime("1h")
.sign(new TextEncoder().encode(geheimnis));
}
/**
* Kleiner Vermittler vor dem Dashboard: Er reicht jede Anfrage weiter und hängt die Sitzung
* als Kopfzeile an. Damit braucht der Browser kein Cookie – das Einschleusen in das Firefox-Profil
* erwies sich als unzuverlässig (Firefox verwirft fremd erzeugte Cookie-Dateien stillschweigend,
* die Aufnahmen zeigten dann kommentarlos die Anmeldeseite).
*/
function vermittlerStarten(token) {
const ziel = new URL(BASIS);
const server = createServer((anfrage, antwort) => {
const weiterleitung = {
hostname: ziel.hostname,
port: ziel.port || 80,
path: anfrage.url,
method: anfrage.method,
headers: { ...anfrage.headers, host: ziel.host, cookie: `pl_sitzung=${token}` },
};
const verbindung = httpAnfrage(weiterleitung, (antwortVomServer) => {
antwort.writeHead(antwortVomServer.statusCode ?? 502, antwortVomServer.headers);
antwortVomServer.pipe(antwort);
});
verbindung.on("error", () => antwort.writeHead(502).end("Dashboard nicht erreichbar"));
anfrage.pipe(verbindung);
});
return new Promise((fertig) => {
server.listen(0, "127.0.0.1", () => fertig({ server, port: server.address().port }));
});
}
/** Legt ein Profil mit dem gewünschten Farbschema an. */
function profilVorbereiten(name, dunkel) {
const verzeichnis = path.join(PROFIL_BASIS, name);
rmSync(verzeichnis, { recursive: true, force: true });
mkdirSync(verzeichnis, { recursive: true });
writeFileSync(
path.join(verzeichnis, "user.js"),
[
`user_pref("ui.systemUsesDarkTheme", ${dunkel ? 1 : 0});`,
`user_pref("layout.css.prefers-color-scheme.content-override", ${dunkel ? 0 : 1});`,
'user_pref("browser.shell.checkDefaultBrowser", false);',
"",
].join("\n"),
);
return verzeichnis;
}
/**
* Nimmt eine Seite auf. Bewusst asynchron: Der Vermittler läuft im selben Prozess, und ein
* blockierender Aufruf würde ihn währenddessen stumm schalten – Firefox liefe in die Zeitüberschreitung.
*/
function aufnehmen(profil, pfad, datei, breite, hoehe, adresse) {
return new Promise((fertig, fehlgeschlagen) => {
const vorgang = spawn(
"firefox",
[
"--profile",
profil,
"--headless",
"--window-size",
`${breite},${hoehe}`,
"--screenshot",
datei,
`${adresse}${pfad}`,
],
{ stdio: "ignore", timeout: 120_000 },
);
vorgang.on("error", fehlgeschlagen);
vorgang.on("close", () => fertig());
});
}
async function main() {
const antwort = await fetch(`${BASIS}/login`).catch(() => null);
if (!antwort?.ok) throw new Error(`${BASIS} ist nicht erreichbar – läuft das Dashboard?`);
mkdirSync(ZIEL, { recursive: true });
const token = await sitzungsToken();
const { server, port } = await vermittlerStarten(token);
const adresse = `http://127.0.0.1:${port}`;
const hell = profilVorbereiten("hell", false);
console.log(`Bilder nach ${ZIEL}`);
// Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich.
await aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900, BASIS);
console.log(" login.png");
for (const seite of [...SEITEN, ...ZUSATZ]) {
await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150, adresse);
console.log(` ${seite.name}.png – ${seite.titel}`);
}
for (const seite of MOBIL_ZUSATZ) {
await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 390, 1800, adresse);
console.log(` ${seite.name}.png – ${seite.titel}`);
}
if (ALLE) {
const dunkel = profilVorbereiten("dunkel", true);
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
const eintrag = SEITEN.find((s) => s.name === seite);
await aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150, adresse);
console.log(` ${seite}-dunkel.png`);
}
// Die Seiten mit den breitesten Tabellen – dort entscheidet sich die Mobilansicht (#035).
for (const seite of ["dashboard", "hosts", "kunden", "rechnungen", "jahresbahn"]) {
const eintrag = SEITEN.find((s) => s.name === seite);
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
console.log(` mobil-${seite}.png`);
}
}
server.close();
if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true });
// Sicherung gegen stillen Fehlschlag: Alle Aufnahmen gleich groß heißt, jede zeigt die Anmeldeseite.
const groessen = SEITEN.map((seite) => statSync(path.join(ZIEL, `${seite.name}.png`)).size);
if (new Set(groessen).size === 1) {
throw new Error(
"Alle Aufnahmen sind gleich groß – vermutlich zeigt jede die Anmeldeseite. Sitzung prüfen.",
);
}
console.log("Fertig.");
}
main().catch((fehler) => {
console.error(fehler.message ?? fehler);
process.exit(1);
});