mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2026-02-04 02:25:30 +00:00
BUGFIX: stresstest u select-vals
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"debug": true,
|
||||
"debug": false,
|
||||
"allow_test_login": true
|
||||
}
|
||||
|
||||
@@ -402,13 +402,11 @@ var USER_ROLES = []string{
|
||||
}
|
||||
|
||||
var AGENT_RELATIONS = []string{
|
||||
"Schöpfer",
|
||||
"Autor:in",
|
||||
"Herausgeber:in",
|
||||
"Verlag",
|
||||
"Druck",
|
||||
"Vertrieb",
|
||||
"Stecher:in",
|
||||
"Zeichner:in",
|
||||
"Komponist:in",
|
||||
"Künstler:in",
|
||||
|
||||
@@ -471,13 +471,11 @@
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"Schöpfer",
|
||||
"Autor:in",
|
||||
"Herausgeber:in",
|
||||
"Verlag",
|
||||
"Druck",
|
||||
"Vertrieb",
|
||||
"Stecher:in",
|
||||
"Zeichner:in",
|
||||
"Komponist:in",
|
||||
"Künstler:in",
|
||||
@@ -615,13 +613,11 @@
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"Schöpfer",
|
||||
"Autor:in",
|
||||
"Herausgeber:in",
|
||||
"Verlag",
|
||||
"Druck",
|
||||
"Vertrieb",
|
||||
"Stecher:in",
|
||||
"Zeichner:in",
|
||||
"Komponist:in",
|
||||
"Künstler:in",
|
||||
|
||||
@@ -60,7 +60,7 @@ const (
|
||||
<p>Die Mannigfaltigkeit des periodischen Taschenbuches zeigt so viele Facetten, daß es schwer hält, eine beständige Gattung auszumachen. Beschreiben lassen sich wiederkehrende Einzelmerkmale, die in unterschiedlicher Häufung, kaum aber in ihrer Gesamtheit beim jeweiligen Exemplar vorkommen. Unsicher ist schon die Verwendung der Ausdrücke ALMANACH und TASCHENBUCH oder auch KALENDER; sie überschneiden sich großenteils, ohne sich ganz zu decken. Es könnte sich empfehlen, im Taschenbuch den Oberbegriff zu sehen, wenn nicht heutzutage unter dem Taschenbuch eine ganz andere Produktform des Buches bezeichnet wäre.</p>
|
||||
<p>Das Wort ALMANACH (arabischen Ursprungs) ist eine Bezeichnung für Kalender, und mit dem Kalender hat das hier dargestellte Taschenbuch die angelegte Jährlichkeit gemein, auch wenn so manche Erscheinung über den ersten Jahrgang nicht hinauskommt. Oftmals, aber keineswegs immer und immer seltener werdend, ist ein Kalender dem Textteil vorgebunden. Regional erhobene Kalender-Stempelsteuern konnten hier prohibitiv wirken. Einige besonders erfolgreiche Almanache erfuhren noch Jahre nach dem Erstdruck Folgeauflagen, in denen dann der überflüssig gewordene Kalender, nicht jedoch die ursprüngliche Jahresdatierung, entfallen konnte. – Seiner Entwicklungsgeschichte nach ist das Taschenbuch durchaus vom Kalender herzuleiten, aber es emanzipiert sich gleichsam von diesem und läßt seine Herkunft vergessen. Was bleibt ist die Taschenhandlichkeit des Formates: Sedez oder Duodez, seltener Octav (aber auch hierzu in der Spätzeit die seltene Ausnahme des Großoctav). Und es scheint, daß die Almanache, Kalendern gleich, meist keinen Ruheplatz in den Bücherschränken gefunden haben, sondern zur Hand genommen und vernutzt wurden; die bis heute erhalten gebliebenen Exemplare sind nicht selten ramponiert, zum Schaden für den zierlich gestalteten Einband.</p>
|
||||
<p>Welche Art von Texten füllte die Almanache und Taschenbücher? Anfangs war es Lyrik, sehr bald aber, als die Mode grassierte: quodlibet, alles was beliebt; unterhalten sollte es, in Spaß oder Ernst. Nur selten mischt Belehrendes sich ein, im Unterschied zum größer formatierten aber sehr viel schmaleren Land- oder Volkskalender. Sieht man in das Register der vorzüglichen <em>Geschichte der deutschen Taschenbücher und Almanache aus der klassisch-romantischen Zeit</em> von LANCKORONSKA und RÜMANN, so findet man schon in den Titeln die Hinweise auf jede nur denkbare Art von Adressaten und zugehörigen Inhalten: Wanderer, Reiter, Bienenfreunde, Künstler, Scheidekünstler und Apotheker, Liebende, Tollhäusler, Ketzer, Ärzte und Nichtärzte, Charadenfreunde, Kaufleute, Lottospieler u.v.a.m.. Vor allem aber wird die Weiblichkeit angesprochen, seien es Frauenzimmer oder Damen, Dienstmädchen, das Schöne Geschlecht, Kammerjungfern, Grabennymphen, Edle Weiber und Mädchen. Selbst wenn es der Titel nicht verrät, ist öfter an die Leserin gedacht als an den Herrn, sie hatte wohl mehr gesellige Muße, und sie war der gemeinte Empfänger des hübschen kleinen Geschenks. Denn zum Schenken war er bestimmt und dazu fügte sich der Erscheinungstermin zur Michaelismesse, rechtzeitig zu Weihnachten und Neujahr.</p>
|
||||
<p>Schwerpunkt der bibliographischen Erfassung und inhaltlichen Erschließung sind zunächst die literarischen Almanache – ungeachtet ihres Niveaus. Sie sind Versammlungsort nicht nur der Großen, sondern vorzüglich derjenigen Dichter und Prosaisten, deren Schriften heute – zu Recht oder zu Unrecht– vergessen sind, die aber aus manchen Gründen gelegentlich doch in den Blick des Interesses rücken. Das Verzeichnis soll sie, die bislang nur unter Schwierigkeiten aufzufinden waren, zugänglich machen. Besonders wichtig, weil eine Wahrnehmungslücke füllend, erschien uns daneben die Registrierung der Zeichner und Stecher, deren Graphiken wir als Vollbild wiedergeben wollen. Daß gerade in diesem Bereich die vorliegenden Exemplare oft unvollständig sind, führt gelegentlich zu Fehlstellen in unserer Darstellung (die aber auf Dauer geschlossen werden); es unterstreicht zugleich die Notwendigkeit des gesetzten Ziels. Indes werden nicht nur die Vorlagen Mängel aufweisen, auch in der Bearbeitung werden unvermeidbar Fehler entstehen. Wir bitten aufmerksame Benutzer, uns hierüber zu informieren und dadurch zur Besserung zu verhelfen.</p>
|
||||
<p>Schwerpunkt der bibliographischen Erfassung und inhaltlichen Erschließung sind zunächst die literarischen Almanache – ungeachtet ihres Niveaus. Sie sind Versammlungsort nicht nur der Großen, sondern vorzüglich derjenigen Dichter und Prosaisten, deren Schriften heute – zu Recht oder zu Unrecht– vergessen sind, die aber aus manchen Gründen gelegentlich doch in den Blick des Interesses rücken. Das Verzeichnis soll sie, die bislang nur unter Schwierigkeiten aufzufinden waren, zugänglich machen. Besonders wichtig, weil eine Wahrnehmungslücke füllend, erschien uns daneben die Registrierung der Zeichner und Kupferstecher, deren Graphiken wir als Vollbild wiedergeben wollen. Daß gerade in diesem Bereich die vorliegenden Exemplare oft unvollständig sind, führt gelegentlich zu Fehlstellen in unserer Darstellung (die aber auf Dauer geschlossen werden); es unterstreicht zugleich die Notwendigkeit des gesetzten Ziels. Indes werden nicht nur die Vorlagen Mängel aufweisen, auch in der Bearbeitung werden unvermeidbar Fehler entstehen. Wir bitten aufmerksame Benutzer, uns hierüber zu informieren und dadurch zur Besserung zu verhelfen.</p>
|
||||
<p>Auf längere Sicht sollen alle periodisch angelegten Almanache und Taschenbücher des 18. und 19. Jahrhunderts aufgenommen werden, um das gesamte Spektrum dieser Publikationsart sichtbar zu machen. Im nicht-literarischen Bereich werden wir uns jedoch zumeist beschränken auf die bibliographische Registrierung und eine kurze Beschreibung der Einzelbände und wir werden hierbei auf die ausführliche Inhaltsübersicht verzichten und uns mit der Wiedergabe eines Inhaltsverzeichnisses begnügen.</p>
|
||||
<p>Grundsätzlich ist Voraussetzung unserer bibliographischen Erfassung die Autopsie des Einzelemplares. Dies schützt indes nicht immer vor Verwirrung: Variante Doppeldrucke (etwa bei unbezeichnetet Folgeauflagen oder nach Zensureingriffen), fehlende Blätter und andere Fehlerquellen sind nicht in jedem Fall wahrnehmbar. Auf alles auffällig Sonderliche wird anmerkend hingewiesen. Um uns möglicher Vollständigkeit anzunähern, behalten wir uns vor, im Einzelfall auch ohne Autopsie nach bibliographischen Vorgaben aufzunehmen; wir werden dies jedoch immer unter Nennung der Quelle ausdrücklich anmerken.</p>
|
||||
<p>Adrian Braunbehrens</p>`
|
||||
|
||||
@@ -41,7 +41,7 @@ func RecordsFromRelationInhalteAkteure(
|
||||
|
||||
switch relation.Relation {
|
||||
case "1":
|
||||
record.SetType("Schöpfer")
|
||||
record.SetType("Autor:in")
|
||||
cat := content.MusenalmType()
|
||||
ber := agent.Profession()
|
||||
probt := 0
|
||||
@@ -100,7 +100,7 @@ func RecordsFromRelationInhalteAkteure(
|
||||
break
|
||||
}
|
||||
|
||||
record.SetType("Schöpfer")
|
||||
record.SetType("Autor:in")
|
||||
case "2":
|
||||
record.SetType("Autor:in")
|
||||
case "3":
|
||||
|
||||
@@ -14,7 +14,7 @@ TODO:
|
||||
BUGS:
|
||||
- Schriftgröße Edit-Screens
|
||||
- doppelte Einträge Reihen-Liste, Endpoint /reihen (siehe Abendstunden)
|
||||
- Neuer ort anlegen führ manachmal auf DB-ID
|
||||
- Neuer Ort anlegen führ manachmal auf DB-ID
|
||||
|
||||
Features:
|
||||
- Extra-DB für FTS5: ist eigentlich nichtTeil der Haupt-DB, sondern nur Suchindex
|
||||
|
||||
1
test/clicker/.gitignore
vendored
Normal file
1
test/clicker/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
51
test/clicker/README.md
Normal file
51
test/clicker/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Clicker
|
||||
|
||||
Parallel random-click runner for http://localhost:8090/ using Playwright.
|
||||
|
||||
## Setup
|
||||
|
||||
From repo root:
|
||||
|
||||
```
|
||||
cd test/clicker
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
Default: 20 workers, 200 steps, 2–30s delay, start at `/reihen`, staggered start, random typing enabled (3–5 chars), random POST submit.
|
||||
|
||||
```
|
||||
cd test/clicker
|
||||
npm run click
|
||||
```
|
||||
|
||||
Custom:
|
||||
|
||||
```
|
||||
node clicker.mjs --base http://localhost:8090/reihen --workers 20 --steps 300 --minDelay 2000 --maxDelay 30000 --timeout 10000 --startStagger 500 --typeChance 0.25 --enterChance 0.35 --postChance 0.1 --minTypeLen 3 --maxTypeLen 5
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Filters out logout/delete/login links, external links, mailto/js links.
|
||||
- Each worker uses a separate browser context.
|
||||
- HTTP clicker logs error URLs and counts non-2xx responses as errors.
|
||||
|
||||
## HTTP clicker
|
||||
|
||||
```
|
||||
cd test/clicker
|
||||
node http_clicker.mjs --base http://localhost:8090/ --workers 300 --steps 500 --minDelay 50 --maxDelay 500 --timeout 5000 --minConcurrent 2 --maxConcurrent 6 --warmupMs 15000 --warmupConcurrent 1 --fetchImages 1 --imageChance 0.25
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--fetchImages 0|1` (default 1): also request `<img src>` URLs discovered in HTML.
|
||||
- `--imageChance 0..1` (default 0.25): chance a request is an image instead of a page.
|
||||
- `--maxImagePool` (default 500): max cached image URLs.
|
||||
- `--fetchAssets 0|1` (default 1): also request `<script src>` and `<link rel="stylesheet">` URLs.
|
||||
- `--assetChance 0..1` (default 0.2): chance a request is a JS/CSS asset instead of a page.
|
||||
- `--maxAssetPool` (default 500): max cached asset URLs.
|
||||
- `--warmupMs` (default 15000): warmup duration before full concurrency.
|
||||
- `--warmupConcurrent` (default 1): per-worker concurrency during warmup.
|
||||
- `--progressEvery` (default 0): log per-worker step progress every N steps.
|
||||
187
test/clicker/clicker.mjs
Normal file
187
test/clicker/clicker.mjs
Normal file
@@ -0,0 +1,187 @@
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const getArg = (name, fallback) => {
|
||||
const idx = args.indexOf(`--${name}`);
|
||||
if (idx === -1 || idx + 1 >= args.length) return fallback;
|
||||
return args[idx + 1];
|
||||
};
|
||||
|
||||
const baseUrl = getArg("base", "http://localhost:8090/reihen");
|
||||
const workers = Number.parseInt(getArg("workers", "20"), 10);
|
||||
const steps = Number.parseInt(getArg("steps", "200"), 10);
|
||||
const minDelay = Number.parseInt(getArg("minDelay", "2000"), 10);
|
||||
const maxDelay = Number.parseInt(getArg("maxDelay", "30000"), 10);
|
||||
const timeout = Number.parseInt(getArg("timeout", "10000"), 10);
|
||||
const startStagger = Number.parseInt(getArg("startStagger", "500"), 10);
|
||||
const typeChance = Number.parseFloat(getArg("typeChance", "0.8"));
|
||||
const enterChance = Number.parseFloat(getArg("enterChance", "0.35"));
|
||||
const postChance = Number.parseFloat(getArg("postChance", "0.15"));
|
||||
const minTypeLen = Number.parseInt(getArg("minTypeLen", "3"), 10);
|
||||
const maxTypeLen = Number.parseInt(getArg("maxTypeLen", "5"), 10);
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
const randomText = (minLen, maxLen) => {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzäöüß ";
|
||||
const len = rand(minLen, maxLen);
|
||||
let out = "";
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
out += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return out.trim() || "test";
|
||||
};
|
||||
|
||||
const pickAndClick = async (page) => {
|
||||
return page.evaluate(() => {
|
||||
const origin = window.location.origin;
|
||||
const links = Array.from(document.querySelectorAll("a[href]"));
|
||||
const candidates = links.filter((a) => {
|
||||
const raw = a.getAttribute("href") || "";
|
||||
if (!raw || raw.startsWith("#")) return false;
|
||||
if (raw.startsWith("javascript:")) return false;
|
||||
if (raw.startsWith("mailto:")) return false;
|
||||
if (a.getAttribute("target") === "_blank") return false;
|
||||
if (a.getAttribute("aria-disabled") === "true") return false;
|
||||
const url = new URL(raw, window.location.href);
|
||||
const path = `${url.pathname}${url.search}`.toLowerCase();
|
||||
if (path.includes("logout") || path.includes("delete") || path.includes("/login")) return false;
|
||||
if (url.origin !== origin) return false;
|
||||
return true;
|
||||
});
|
||||
if (!candidates.length) return null;
|
||||
const link = candidates[Math.floor(Math.random() * candidates.length)];
|
||||
const href = link.href;
|
||||
const clickerId = `clicker-${Math.random().toString(36).slice(2)}`;
|
||||
link.setAttribute("data-clicker-id", clickerId);
|
||||
return { href, clickerId };
|
||||
});
|
||||
};
|
||||
|
||||
const pickAndFocusInput = async (page) => {
|
||||
return page.evaluate(() => {
|
||||
const candidates = Array.from(document.querySelectorAll("input, textarea")).filter((el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
if (el.closest("[aria-hidden='true']")) return false;
|
||||
if (el.closest("[data-htmx-busy='true']")) return false;
|
||||
if (el.hasAttribute("disabled") || el.hasAttribute("readonly")) return false;
|
||||
if (el instanceof HTMLInputElement) {
|
||||
const type = (el.type || "text").toLowerCase();
|
||||
if (["hidden", "password", "checkbox", "radio", "file", "submit", "button"].includes(type)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (el.offsetParent === null) return false;
|
||||
return true;
|
||||
});
|
||||
if (!candidates.length) return null;
|
||||
const el = candidates[Math.floor(Math.random() * candidates.length)];
|
||||
el.focus();
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
id: el.id || "",
|
||||
name: el.getAttribute("name") || "",
|
||||
placeholder: el.getAttribute("placeholder") || "",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const submitRandomPostForm = async (page) => {
|
||||
return page.evaluate(() => {
|
||||
const forms = Array.from(document.querySelectorAll("form")).filter((form) => {
|
||||
if (!(form instanceof HTMLFormElement)) return false;
|
||||
const method = (form.getAttribute("method") || "get").toLowerCase();
|
||||
if (method !== "post") return false;
|
||||
const action = (form.getAttribute("action") || "").toLowerCase();
|
||||
if (action.includes("/login") || action.includes("logout") || action.includes("delete")) return false;
|
||||
if (form.hasAttribute("disabled")) return false;
|
||||
if (form.closest("[aria-hidden='true']")) return false;
|
||||
if (form.closest("[data-htmx-busy='true']")) return false;
|
||||
return true;
|
||||
});
|
||||
if (!forms.length) return null;
|
||||
const form = forms[Math.floor(Math.random() * forms.length)];
|
||||
const submit = form.querySelector("button[type='submit'], input[type='submit']");
|
||||
if (submit instanceof HTMLElement) {
|
||||
submit.click();
|
||||
} else {
|
||||
form.requestSubmit();
|
||||
}
|
||||
return form.getAttribute("action") || window.location.href;
|
||||
});
|
||||
};
|
||||
|
||||
const runWorker = async (id, browser, delayMs) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(timeout);
|
||||
const label = `worker-${id}`;
|
||||
try {
|
||||
if (delayMs > 0) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
|
||||
for (let i = 0; i < steps; i += 1) {
|
||||
const clickInfo = await pickAndClick(page);
|
||||
if (clickInfo) {
|
||||
const selector = `[data-clicker-id="${clickInfo.clickerId}"]`;
|
||||
await page.click(selector).catch(() => {});
|
||||
console.log(`${label}: click ${clickInfo.href}`);
|
||||
} else {
|
||||
console.log(`${label}: no candidates`);
|
||||
}
|
||||
await page.waitForLoadState("domcontentloaded").catch(() => {});
|
||||
await page.waitForTimeout(200);
|
||||
const currentUrl = page.url();
|
||||
if (currentUrl) {
|
||||
console.log(`${label}: at ${currentUrl}`);
|
||||
}
|
||||
if (Math.random() < typeChance) {
|
||||
const focused = await pickAndFocusInput(page);
|
||||
if (focused) {
|
||||
const text = randomText(minTypeLen, maxTypeLen);
|
||||
await page.keyboard.type(text, { delay: rand(10, 80) });
|
||||
console.log(
|
||||
`${label}: type \"${text}\" into ${focused.tag}${focused.id ? `#${focused.id}` : ""}${focused.name ? `[name=\"${focused.name}\"]` : ""}`,
|
||||
);
|
||||
if (focused.tag !== "textarea" && Math.random() < enterChance) {
|
||||
await page.keyboard.press("Enter");
|
||||
console.log(`${label}: press Enter`);
|
||||
}
|
||||
} else {
|
||||
console.log(`${label}: no inputs to type`);
|
||||
}
|
||||
} else {
|
||||
console.log(`${label}: skip typing`);
|
||||
}
|
||||
if (Math.random() < postChance) {
|
||||
const action = await submitRandomPostForm(page);
|
||||
if (action) {
|
||||
console.log(`${label}: submit POST ${action}`);
|
||||
} else {
|
||||
console.log(`${label}: no POST forms`);
|
||||
}
|
||||
}
|
||||
await sleep(rand(minDelay, maxDelay));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${label}: error`, error?.message || error);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const runs = Array.from({ length: workers }, (_, idx) => runWorker(idx + 1, browser, idx * startStagger));
|
||||
await Promise.all(runs);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
285
test/clicker/http_clicker.mjs
Normal file
285
test/clicker/http_clicker.mjs
Normal file
@@ -0,0 +1,285 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const getArg = (name, fallback) => {
|
||||
const idx = args.indexOf(`--${name}`);
|
||||
if (idx === -1 || idx + 1 >= args.length) return fallback;
|
||||
return args[idx + 1];
|
||||
};
|
||||
|
||||
const baseUrl = getArg("base", "http://localhost:8090/");
|
||||
const workers = Number.parseInt(getArg("workers", "50"), 10);
|
||||
const steps = Number.parseInt(getArg("steps", "200"), 10);
|
||||
const minDelay = Number.parseInt(getArg("minDelay", "200"), 10);
|
||||
const maxDelay = Number.parseInt(getArg("maxDelay", "2000"), 10);
|
||||
const timeout = Number.parseInt(getArg("timeout", "8000"), 10);
|
||||
const reportEvery = Number.parseInt(getArg("reportEvery", "50"), 10);
|
||||
const progressEvery = Number.parseInt(getArg("progressEvery", "0"), 10);
|
||||
const maxPool = Number.parseInt(getArg("maxPool", "500"), 10);
|
||||
const maxTextBytes = Number.parseInt(getArg("maxTextBytes", "1048576"), 10);
|
||||
const maxConcurrent = Number.parseInt(getArg("maxConcurrent", "6"), 10);
|
||||
const minConcurrent = Number.parseInt(getArg("minConcurrent", "2"), 10);
|
||||
const dropChance = Number.parseFloat(getArg("dropChance", "0.05"));
|
||||
const concurrencyJitterSteps = Number.parseInt(getArg("concurrencyJitterSteps", "25"), 10);
|
||||
const fetchImages = getArg("fetchImages", "1") !== "0";
|
||||
const imageChance = Number.parseFloat(getArg("imageChance", "0.25"));
|
||||
const maxImagePool = Number.parseInt(getArg("maxImagePool", "500"), 10);
|
||||
const fetchAssets = getArg("fetchAssets", "1") !== "0";
|
||||
const assetChance = Number.parseFloat(getArg("assetChance", "0.2"));
|
||||
const maxAssetPool = Number.parseInt(getArg("maxAssetPool", "500"), 10);
|
||||
const warmupMs = Number.parseInt(getArg("warmupMs", "15000"), 10);
|
||||
const warmupConcurrent = Number.parseInt(getArg("warmupConcurrent", "1"), 10);
|
||||
|
||||
const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
const sleepRand = () => sleep(rand(minDelay, maxDelay));
|
||||
|
||||
const pool = [];
|
||||
const poolSet = new Set();
|
||||
const imagePool = [];
|
||||
const imagePoolSet = new Set();
|
||||
const assetPool = [];
|
||||
const assetPoolSet = new Set();
|
||||
|
||||
const isSafeHref = (href) => {
|
||||
if (!href) return false;
|
||||
const raw = href.trim();
|
||||
if (!raw || raw.startsWith("#")) return false;
|
||||
if (raw.startsWith("mailto:")) return false;
|
||||
if (raw.startsWith("javascript:")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const isSafePath = (path) => {
|
||||
const p = path.toLowerCase();
|
||||
if (p.includes("logout") || p.includes("delete") || p.includes("/login")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const addToPool = (url) => {
|
||||
if (poolSet.has(url)) return;
|
||||
if (pool.length >= maxPool) return;
|
||||
poolSet.add(url);
|
||||
pool.push(url);
|
||||
};
|
||||
|
||||
const addToImagePool = (url) => {
|
||||
if (imagePoolSet.has(url)) return;
|
||||
if (imagePool.length >= maxImagePool) return;
|
||||
imagePoolSet.add(url);
|
||||
imagePool.push(url);
|
||||
};
|
||||
|
||||
const addToAssetPool = (url) => {
|
||||
if (assetPoolSet.has(url)) return;
|
||||
if (assetPool.length >= maxAssetPool) return;
|
||||
assetPoolSet.add(url);
|
||||
assetPool.push(url);
|
||||
};
|
||||
|
||||
const pickFromPool = () => {
|
||||
if (!pool.length) return baseUrl;
|
||||
return pool[rand(0, pool.length - 1)];
|
||||
};
|
||||
|
||||
const pickImage = () => {
|
||||
if (!imagePool.length) return null;
|
||||
return imagePool[rand(0, imagePool.length - 1)];
|
||||
};
|
||||
|
||||
const pickAsset = () => {
|
||||
if (!assetPool.length) return null;
|
||||
return assetPool[rand(0, assetPool.length - 1)];
|
||||
};
|
||||
|
||||
const extractLinks = (html, origin, base) => {
|
||||
const links = [];
|
||||
const images = [];
|
||||
const assets = [];
|
||||
const re = /href\s*=\s*["']([^"']+)["']/gi;
|
||||
let match;
|
||||
while ((match = re.exec(html)) !== null) {
|
||||
const href = match[1];
|
||||
if (!isSafeHref(href)) continue;
|
||||
try {
|
||||
const url = new URL(href, base);
|
||||
if (url.origin !== origin) continue;
|
||||
const path = `${url.pathname}${url.search}`;
|
||||
if (!isSafePath(path)) continue;
|
||||
links.push(url.toString());
|
||||
} catch {
|
||||
// ignore bad urls
|
||||
}
|
||||
}
|
||||
const imgRe = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi;
|
||||
while ((match = imgRe.exec(html)) !== null) {
|
||||
const src = match[1];
|
||||
if (!isSafeHref(src)) continue;
|
||||
try {
|
||||
const url = new URL(src, base);
|
||||
if (url.origin !== origin) continue;
|
||||
images.push(url.toString());
|
||||
} catch {
|
||||
// ignore bad urls
|
||||
}
|
||||
}
|
||||
const scriptRe = /<script[^>]+src\s*=\s*["']([^"']+)["']/gi;
|
||||
while ((match = scriptRe.exec(html)) !== null) {
|
||||
const src = match[1];
|
||||
if (!isSafeHref(src)) continue;
|
||||
try {
|
||||
const url = new URL(src, base);
|
||||
if (url.origin !== origin) continue;
|
||||
assets.push(url.toString());
|
||||
} catch {
|
||||
// ignore bad urls
|
||||
}
|
||||
}
|
||||
const cssRe = /<link[^>]+rel\\s*=\\s*["']stylesheet["'][^>]*href\\s*=\\s*["']([^"']+)["']/gi;
|
||||
while ((match = cssRe.exec(html)) !== null) {
|
||||
const href = match[1];
|
||||
if (!isSafeHref(href)) continue;
|
||||
try {
|
||||
const url = new URL(href, base);
|
||||
if (url.origin !== origin) continue;
|
||||
assets.push(url.toString());
|
||||
} catch {
|
||||
// ignore bad urls
|
||||
}
|
||||
}
|
||||
return { links, images, assets };
|
||||
};
|
||||
|
||||
const stats = {
|
||||
count: 0,
|
||||
errors: 0,
|
||||
totalMs: 0,
|
||||
minMs: Infinity,
|
||||
maxMs: 0,
|
||||
samples: [],
|
||||
sampleMax: 5000,
|
||||
};
|
||||
|
||||
const addSample = (ms) => {
|
||||
stats.count += 1;
|
||||
stats.totalMs += ms;
|
||||
if (ms < stats.minMs) stats.minMs = ms;
|
||||
if (ms > stats.maxMs) stats.maxMs = ms;
|
||||
if (stats.samples.length < stats.sampleMax) {
|
||||
stats.samples.push(ms);
|
||||
} else {
|
||||
const idx = rand(0, stats.count - 1);
|
||||
if (idx < stats.sampleMax) stats.samples[idx] = ms;
|
||||
}
|
||||
};
|
||||
|
||||
const percentile = (arr, p) => {
|
||||
if (!arr.length) return null;
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
const idx = Math.floor((sorted.length - 1) * p);
|
||||
return sorted[idx];
|
||||
};
|
||||
|
||||
const report = () => {
|
||||
const avg = stats.count ? stats.totalMs / stats.count : 0;
|
||||
const p50 = percentile(stats.samples, 0.5);
|
||||
const p95 = percentile(stats.samples, 0.95);
|
||||
console.log(
|
||||
`report: count=${stats.count} errors=${stats.errors} avg=${avg.toFixed(1)}ms min=${stats.minMs.toFixed(1)}ms max=${stats.maxMs.toFixed(1)}ms p50=${p50?.toFixed(1)}ms p95=${p95?.toFixed(1)}ms pool=${pool.length}`,
|
||||
);
|
||||
};
|
||||
|
||||
const fetchWithTimeout = async (url) => {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeout);
|
||||
const start = performance.now();
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal, headers: { "user-agent": "http-clicker" } });
|
||||
const elapsed = performance.now() - start;
|
||||
addSample(elapsed);
|
||||
if (!res.ok) {
|
||||
stats.errors += 1;
|
||||
console.log(`error: ${url} status=${res.status}`);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
if (contentType.includes("text/html")) {
|
||||
const buf = await res.arrayBuffer();
|
||||
const sliced = buf.byteLength > maxTextBytes ? buf.slice(0, maxTextBytes) : buf;
|
||||
const text = new TextDecoder("utf-8", { fatal: false }).decode(sliced);
|
||||
const { links, images, assets } = extractLinks(text, new URL(baseUrl).origin, res.url);
|
||||
for (const link of links) addToPool(link);
|
||||
if (fetchImages) {
|
||||
for (const img of images) addToImagePool(img);
|
||||
}
|
||||
if (fetchAssets) {
|
||||
for (const asset of assets) addToAssetPool(asset);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
stats.errors += 1;
|
||||
const reason = error?.name || "error";
|
||||
console.log(`error: ${url} ${reason}`);
|
||||
} finally {
|
||||
clearTimeout(id);
|
||||
}
|
||||
};
|
||||
|
||||
const runWorker = async (id) => {
|
||||
const label = `worker-${id}`;
|
||||
let currentMax = warmupConcurrent;
|
||||
let inWarmup = warmupMs > 0;
|
||||
const inFlight = new Set();
|
||||
if (inWarmup) {
|
||||
setTimeout(() => {
|
||||
inWarmup = false;
|
||||
}, warmupMs);
|
||||
}
|
||||
for (let i = 0; i < steps; i += 1) {
|
||||
if (!inWarmup && concurrencyJitterSteps > 0 && i % concurrencyJitterSteps === 0) {
|
||||
currentMax = rand(minConcurrent, maxConcurrent);
|
||||
}
|
||||
if (Math.random() < dropChance) {
|
||||
await sleepRand();
|
||||
continue;
|
||||
}
|
||||
while (inFlight.size < currentMax) {
|
||||
let url = pickFromPool();
|
||||
if (fetchImages && Math.random() < imageChance) {
|
||||
url = pickImage() || url;
|
||||
} else if (fetchAssets && Math.random() < assetChance) {
|
||||
url = pickAsset() || url;
|
||||
}
|
||||
const task = fetchWithTimeout(url).finally(() => inFlight.delete(task));
|
||||
inFlight.add(task);
|
||||
if (inFlight.size >= currentMax) break;
|
||||
if (Math.random() < 0.5) break;
|
||||
}
|
||||
if (progressEvery > 0 && (i + 1) % progressEvery === 0) {
|
||||
console.log(`${label}: step ${i + 1}/${steps}`);
|
||||
}
|
||||
await sleepRand();
|
||||
}
|
||||
await Promise.allSettled(Array.from(inFlight));
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
addToPool(baseUrl);
|
||||
const startedAt = Date.now();
|
||||
const runs = Array.from({ length: workers }, (_, idx) => runWorker(idx + 1));
|
||||
const reporter = setInterval(report, Math.max(1000, reportEvery * 50));
|
||||
try {
|
||||
await Promise.all(runs);
|
||||
} finally {
|
||||
clearInterval(reporter);
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const elapsedSec = Math.round(elapsedMs / 1000);
|
||||
console.log(`runtime: ${elapsedSec}s`);
|
||||
report();
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
57
test/clicker/package-lock.json
generated
Normal file
57
test/clicker/package-lock.json
generated
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "musenalm-clicker",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "musenalm-clicker",
|
||||
"dependencies": {
|
||||
"playwright": "^1.50.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
|
||||
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
|
||||
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
test/clicker/package.json
Normal file
11
test/clicker/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "musenalm-clicker",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"click": "node ./clicker.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.50.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@
|
||||
</head>
|
||||
|
||||
<body class="w-full min-h-full" id="body" hx-ext="response-targets" hx-boost="true">
|
||||
{{ template "_global_notice" . }}
|
||||
<div class="pb-12">
|
||||
{{ block "body" . }}
|
||||
<!-- Default app body... -->
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<body id="body" class="w-full min-h-full" hx-ext="response-targets" hx-boost="true">
|
||||
<div class="flex flex-col min-h-screen w-full">
|
||||
{{ template "_global_notice" . }}
|
||||
<header class="container-normal bg-slate-100 " id="header">
|
||||
{{ template "_menu" . }}
|
||||
</header>
|
||||
|
||||
12
views/layouts/components/_global_notice.gohtml
Normal file
12
views/layouts/components/_global_notice.gohtml
Normal file
@@ -0,0 +1,12 @@
|
||||
<div
|
||||
id="global-notice"
|
||||
class="global-notice hidden"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
data-state="">
|
||||
<div class="global-notice-inner">
|
||||
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||||
<span data-role="global-notice-text">Laden…</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
<body id="body" class="w-full text-lg" hx-ext="response-targets" hx-boost="true">
|
||||
<div class="flex flex-col min-h-screen w-full">
|
||||
{{ template "_global_notice" . }}
|
||||
<header class="container-normal pb-0" id="header">
|
||||
{{ block "_menu" . }}
|
||||
<!-- Default app menu... -->
|
||||
|
||||
@@ -495,10 +495,12 @@ type AlmanachResult struct {
|
||||
</div>
|
||||
</div>
|
||||
<div id="series-section" class="rel-section-container">
|
||||
{{- $hasNonPreferredSeries := false -}}
|
||||
{{- if $model.result.Series -}}
|
||||
{{- range $i, $s := $model.result.Series -}}
|
||||
{{- $rel := index $model.result.EntriesSeries $s.Id -}}
|
||||
{{- if and $rel (ne $rel.Type "Bevorzugter Reihentitel") -}}
|
||||
{{- $hasNonPreferredSeries = true -}}
|
||||
<div data-rel-row class="entries-series-row rel-row">
|
||||
<div class="rel-grid">
|
||||
<div data-rel-strike class="relation-strike rel-name-col">
|
||||
@@ -542,7 +544,8 @@ type AlmanachResult struct {
|
||||
</div>
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- else -}}
|
||||
{{- end -}}
|
||||
{{- if not $hasNonPreferredSeries -}}
|
||||
<div class="rel-empty-text">Keine Reihen verknüpft.</div>
|
||||
{{- end -}}
|
||||
</div>
|
||||
@@ -716,7 +719,7 @@ type AlmanachResult struct {
|
||||
<label for="entries_agents_new_type" class="sr-only">Beziehung</label>
|
||||
<select data-role="relation-type-select" name="entries_agents_new_type" id="entries_agents_new_type" autocomplete="off" class="inputselect font-bold w-full">
|
||||
{{- range $t := $model.agent_relations -}}
|
||||
<option value="{{- $t -}}">{{- $t -}}</option>
|
||||
<option value="{{- $t -}}" {{ if eq $t "Herausgeber:in" }}selected{{ end }}>{{- $t -}}</option>
|
||||
{{- end -}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -260,7 +260,7 @@
|
||||
<div data-rel-strike class="relation-strike">
|
||||
<select name="{{ $agentsPrefix }}_new_type" class="inputselect font-bold w-full">
|
||||
{{- range $t := $agentRelations -}}
|
||||
<option value="{{- $t -}}" {{ if eq $r.Type $t }}selected{{ end }}>{{- $t -}}</option>
|
||||
<option value="{{- $t -}}" {{ if or (eq $r.Type $t) (and (eq $r.Type "") (eq $t "Autor:in")) }}selected{{ end }}>{{- $t -}}</option>
|
||||
{{- end -}}
|
||||
</select>
|
||||
</div>
|
||||
@@ -300,7 +300,7 @@
|
||||
<label for="{{ $agentsPrefix }}_new_type" class="sr-only">Beziehung</label>
|
||||
<select data-role="relation-type-select" name="{{ $agentsPrefix }}_new_type" id="{{ $agentsPrefix }}_new_type" autocomplete="off" class="inputselect font-bold w-full">
|
||||
{{- range $t := $agentRelations -}}
|
||||
<option value="{{- $t -}}">{{- $t -}}</option>
|
||||
<option value="{{- $t -}}" {{ if eq $t "Autor:in" }}selected{{ end }}>{{- $t -}}</option>
|
||||
{{- end -}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -324,6 +324,168 @@ function DisconnectNoEnters(textarea) {
|
||||
textarea.removeEventListener("keydown", NoEnters);
|
||||
}
|
||||
|
||||
function InitGlobalHtmxNotice() {
|
||||
if (!window.htmx) {
|
||||
return;
|
||||
}
|
||||
const ensureNotice = () => {
|
||||
let noticeEl = document.getElementById("global-notice");
|
||||
if (!noticeEl) {
|
||||
noticeEl = document.createElement("div");
|
||||
noticeEl.id = "global-notice";
|
||||
noticeEl.className = "global-notice hidden";
|
||||
noticeEl.setAttribute("role", "status");
|
||||
noticeEl.setAttribute("aria-live", "polite");
|
||||
noticeEl.setAttribute("aria-atomic", "true");
|
||||
noticeEl.dataset.state = "";
|
||||
noticeEl.innerHTML = `
|
||||
<div class="global-notice-inner">
|
||||
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||||
<span data-role="global-notice-text">Laden…</span>
|
||||
</div>
|
||||
`;
|
||||
document.body?.appendChild(noticeEl);
|
||||
}
|
||||
return noticeEl;
|
||||
};
|
||||
let notice = ensureNotice();
|
||||
let textEl = notice ? notice.querySelector("[data-role='global-notice-text']") : null;
|
||||
let pending = 0;
|
||||
let errorTimeout = null;
|
||||
|
||||
const setNoticeState = (state, message) => {
|
||||
notice = ensureNotice();
|
||||
if (notice && !textEl) {
|
||||
textEl = notice.querySelector("[data-role='global-notice-text']");
|
||||
}
|
||||
if (textEl && message) {
|
||||
textEl.textContent = message;
|
||||
}
|
||||
if (notice && state) {
|
||||
notice.dataset.state = state;
|
||||
} else if (notice) {
|
||||
notice.removeAttribute("data-state");
|
||||
}
|
||||
};
|
||||
|
||||
const showNotice = (state, message) => {
|
||||
notice = ensureNotice();
|
||||
if (!notice) {
|
||||
return;
|
||||
}
|
||||
setNoticeState(state, message);
|
||||
notice.classList.remove("hidden");
|
||||
};
|
||||
|
||||
const hideNotice = () => {
|
||||
notice = ensureNotice();
|
||||
if (!notice) {
|
||||
return;
|
||||
}
|
||||
notice.classList.add("hidden");
|
||||
notice.removeAttribute("data-state");
|
||||
};
|
||||
|
||||
const setBodyBusy = (busy) => {
|
||||
const root = document.documentElement;
|
||||
if (busy) {
|
||||
if (root) {
|
||||
root.dataset.htmxBusy = "true";
|
||||
}
|
||||
if (document.body) {
|
||||
document.body.dataset.htmxBusy = "true";
|
||||
}
|
||||
} else {
|
||||
if (root) {
|
||||
delete root.dataset.htmxBusy;
|
||||
}
|
||||
if (document.body) {
|
||||
delete document.body.dataset.htmxBusy;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const markElementBusy = (element, busy) => {
|
||||
if (!element || !(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (busy) {
|
||||
element.dataset.htmxBusy = "true";
|
||||
element.setAttribute("aria-busy", "true");
|
||||
if (element instanceof HTMLButtonElement && !element.disabled) {
|
||||
element.dataset.htmxDisabled = "true";
|
||||
element.disabled = true;
|
||||
}
|
||||
} else if (element.dataset.htmxBusy === "true") {
|
||||
delete element.dataset.htmxBusy;
|
||||
element.removeAttribute("aria-busy");
|
||||
if (element instanceof HTMLButtonElement && element.dataset.htmxDisabled === "true") {
|
||||
element.disabled = false;
|
||||
delete element.dataset.htmxDisabled;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearErrorTimeout = () => {
|
||||
if (errorTimeout) {
|
||||
clearTimeout(errorTimeout);
|
||||
errorTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("htmx:beforeRequest", (event) => {
|
||||
pending += 1;
|
||||
clearErrorTimeout();
|
||||
setBodyBusy(true);
|
||||
showNotice("loading", "Laden...");
|
||||
markElementBusy(event.detail?.elt, true);
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:afterRequest", (event) => {
|
||||
markElementBusy(event.detail?.elt, false);
|
||||
pending = Math.max(0, pending - 1);
|
||||
if (pending === 0) {
|
||||
setBodyBusy(false);
|
||||
if (notice.dataset.state !== "error") {
|
||||
hideNotice();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:responseError", () => {
|
||||
setBodyBusy(false);
|
||||
showNotice("error", "Laden fehlgeschlagen.");
|
||||
clearErrorTimeout();
|
||||
errorTimeout = setTimeout(() => {
|
||||
if (pending === 0) {
|
||||
hideNotice();
|
||||
} else {
|
||||
showNotice("loading", "Laden...");
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:sendError", () => {
|
||||
setBodyBusy(false);
|
||||
showNotice("error", "Verbindung fehlgeschlagen.");
|
||||
clearErrorTimeout();
|
||||
errorTimeout = setTimeout(() => {
|
||||
if (pending === 0) {
|
||||
hideNotice();
|
||||
} else {
|
||||
showNotice("loading", "Laden...");
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:afterSwap", () => {
|
||||
notice = ensureNotice();
|
||||
if (notice && !textEl) {
|
||||
textEl = notice.querySelector("[data-role='global-notice-text']");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function MutateObserve(mutations, observer) {
|
||||
const needsJSResize = !supportsFieldSizing();
|
||||
|
||||
@@ -476,5 +638,6 @@ window.PathPlusQuery = PathPlusQuery;
|
||||
window.HookupRBChange = HookupRBChange;
|
||||
window.FormLoad = FormLoad;
|
||||
window.TextareaAutoResize = TextareaAutoResize;
|
||||
InitGlobalHtmxNotice();
|
||||
|
||||
export { FilterList, ScrollButton, AbbreviationTooltips, MultiSelectSimple, MultiSelectRole, ToolTip, PopupImage, TabList, FilterPill, ImageReel, IntLink, ItemsEditor, SingleSelectRemote, AlmanachEditPage, RelationsEditor, EditPage, FabMenu, LookupField };
|
||||
|
||||
@@ -624,6 +624,30 @@
|
||||
@apply !inline-block;
|
||||
}
|
||||
|
||||
.global-notice {
|
||||
@apply fixed right-6 bottom-6 z-50 hidden;
|
||||
}
|
||||
|
||||
.global-notice-inner {
|
||||
@apply flex items-center gap-2 rounded-md border border-slate-200 bg-white/95 px-3 py-2 text-sm font-semibold text-gray-700 shadow-lg backdrop-blur;
|
||||
}
|
||||
|
||||
.global-notice[data-state="error"] .global-notice-inner {
|
||||
@apply border-red-200 bg-red-50 text-red-800;
|
||||
}
|
||||
|
||||
html[data-htmx-busy="true"] .global-notice,
|
||||
body[data-htmx-busy="true"] .global-notice {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
html[data-htmx-busy="true"],
|
||||
body[data-htmx-busy="true"],
|
||||
[data-htmx-busy="true"] {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tab-list-head[aria-pressed="true"] {
|
||||
@apply !text-slate-900 bg-stone-50;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user