Grundgerüst-Assistent: alle fehlenden Kanäle auf einmal
Der Einzelknopf war der Anfang; wer frisch startet, klickt sich damit sechzehnmal durch. Der Assistent macht das in einem Durchlauf. Erst eine Vorschau, bevor irgendetwas passiert: was neu entsteht, was schon existiert und nur verknüpft wird, welche Kanäle privat werden und in welche Kategorie alles kommt. Die Kategorie heißt wie die Marke und wird wiederverwendet, wenn es sie schon gibt. Der Plan ist nach Einstellung entdoppelt — Devlogs und Wochen-Rückblick brauchen denselben Kanal, der soll nicht zweimal auftauchen. Rollen kommen ohne Kategorie, die gibt es dort nicht. Zwischen zwei Anlagen liegen 350 ms. Discord verträgt ein Dutzend Kanal-Erstellungen am Stück schlecht, und ein Rate-Limit mitten im Lauf wäre die unangenehmste Art zu scheitern. Scheitert ein Schritt, laufen die übrigen weiter und das Ergebnis zeigt pro Zeile, was passiert ist. Vorher hätte ein einzelner Fehler den Rest verschluckt. Im Test: 19 offene Punkte, Vorschau zeigt 16, danach bleiben 2 — die beiden ohne Vorlage (Gitea-Token und Watchdog-Adressen), die sich nicht anlegen lassen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,11 @@ an, setzt bei privaten Kanälen die Rechte (nur Team sieht sie) und trägt die
|
|||||||
Einstellung gleich ein. Gibt es Kanal oder Rolle schon, werden sie verknüpft
|
Einstellung gleich ein. Gibt es Kanal oder Rolle schon, werden sie verknüpft
|
||||||
statt doppelt angelegt.
|
statt doppelt angelegt.
|
||||||
|
|
||||||
|
Daneben legt **„Alles auf einmal anlegen"** das ganze Grundgerüst an: erst eine
|
||||||
|
Vorschau (was ist neu, was ist schon da, in welche Kategorie kommt es), dann ein
|
||||||
|
Durchlauf mit Ergebnis je Zeile. Neue Kanäle landen in einer eigenen Kategorie,
|
||||||
|
und ein Fehler bei einem Kanal bricht die restlichen nicht ab.
|
||||||
|
|
||||||
Dafür braucht der Bot *Kanäle verwalten* bzw. *Rollen verwalten* — fehlt das
|
Dafür braucht der Bot *Kanäle verwalten* bzw. *Rollen verwalten* — fehlt das
|
||||||
Recht, sagt das Panel genau das. Die Vorlagen (Name, Thema, privat/öffentlich)
|
Recht, sagt das Panel genau das. Die Vorlagen (Name, Thema, privat/öffentlich)
|
||||||
stehen bei den Modulen in [`src/modules.js`](src/modules.js). Gelöscht oder
|
stehen bei den Modulen in [`src/modules.js`](src/modules.js). Gelöscht oder
|
||||||
|
|||||||
@@ -305,6 +305,10 @@ export default function Settings({ me }) {
|
|||||||
const [moduleGroups, setModuleGroups] = useState([]);
|
const [moduleGroups, setModuleGroups] = useState([]);
|
||||||
const [modFilter, setModFilter] = useState('alle');
|
const [modFilter, setModFilter] = useState('alle');
|
||||||
const [creating, setCreating] = useState(null); // Einstellung, die gerade angelegt wird
|
const [creating, setCreating] = useState(null); // Einstellung, die gerade angelegt wird
|
||||||
|
// Grundgerüst-Assistent
|
||||||
|
const [plan, setPlan] = useState(null); // Vorschau, null = zu
|
||||||
|
const [setupRunning, setSetupRunning] = useState(false);
|
||||||
|
const [setupResult, setSetupResult] = useState(null);
|
||||||
// Text-Vorlagen (was der Bot nach außen schreibt)
|
// Text-Vorlagen (was der Bot nach außen schreibt)
|
||||||
const [templates, setTemplates] = useState([]);
|
const [templates, setTemplates] = useState([]);
|
||||||
const [templateGroups, setTemplateGroups] = useState([]);
|
const [templateGroups, setTemplateGroups] = useState([]);
|
||||||
@@ -502,6 +506,35 @@ export default function Settings({ me }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vorschau holen: was würde der Assistent anlegen? */
|
||||||
|
async function loadPlan() {
|
||||||
|
try {
|
||||||
|
const res = await apiGet('/api/setup/plan');
|
||||||
|
setPlan(res);
|
||||||
|
} catch (error) {
|
||||||
|
flash(`✗ ${error.body?.error ?? 'Vorschau fehlgeschlagen'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grundgerüst anlegen — erst nach der Vorschau erreichbar */
|
||||||
|
async function runSetup() {
|
||||||
|
setSetupRunning(true);
|
||||||
|
try {
|
||||||
|
const res = await apiPost('/api/setup/run', { kategorie: plan.kategorie });
|
||||||
|
setModules(res.modules);
|
||||||
|
setForm(res.settings);
|
||||||
|
setData((d) => (d ? { ...d, settings: res.settings } : d));
|
||||||
|
setSetupResult(res.ergebnisse);
|
||||||
|
setPlan(null);
|
||||||
|
const neu = res.ergebnisse.filter((r) => r.ok && !r.wiederverwendet).length;
|
||||||
|
flash(`✓ ${neu} neu angelegt`);
|
||||||
|
} catch (error) {
|
||||||
|
flash(`✗ ${error.body?.error ?? 'Fehlgeschlagen'}`);
|
||||||
|
} finally {
|
||||||
|
setSetupRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Fehlenden Kanal / fehlende Rolle vom Bot anlegen lassen */
|
/** Fehlenden Kanal / fehlende Rolle vom Bot anlegen lassen */
|
||||||
async function createMissing(entry) {
|
async function createMissing(entry) {
|
||||||
setCreating(entry.setting);
|
setCreating(entry.setting);
|
||||||
@@ -1037,8 +1070,70 @@ export default function Settings({ me }) {
|
|||||||
<>
|
<>
|
||||||
<p className="section-intro">
|
<p className="section-intro">
|
||||||
Diese Module sind eingeschaltet, aber es fehlt ihnen noch etwas —
|
Diese Module sind eingeschaltet, aber es fehlt ihnen noch etwas —
|
||||||
bis dahin tun sie nichts.
|
bis dahin tun sie nichts. Einzeln anlegen, oder alles auf einmal.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Grundgerüst: erst Vorschau, dann anlegen */}
|
||||||
|
{!plan && (
|
||||||
|
<div className="settings-actions" style={{ marginBottom: '1.1rem' }}>
|
||||||
|
<button className="btn" onClick={loadPlan}>
|
||||||
|
Alles auf einmal anlegen …
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan && (
|
||||||
|
<div className="setup-plan">
|
||||||
|
<p className="setup-plan-intro">
|
||||||
|
{plan.schritte.filter((s) => !s.vorhanden).length} werden neu angelegt
|
||||||
|
{plan.schritte.some((s) => s.vorhanden)
|
||||||
|
&& ` · ${plan.schritte.filter((s) => s.vorhanden).length} sind schon da und werden nur verknüpft`}
|
||||||
|
. Neue Kanäle landen in der Kategorie <strong>{plan.kategorie}</strong>.
|
||||||
|
</p>
|
||||||
|
<div className="setup-plan-list">
|
||||||
|
{plan.schritte.map((s) => (
|
||||||
|
<div className={`setup-step${s.vorhanden ? ' da' : ''}`} key={s.setting}>
|
||||||
|
<span className="setup-step-name">
|
||||||
|
{s.art === 'rolle' ? '@' : '#'}{s.name}
|
||||||
|
</span>
|
||||||
|
<span className="setup-step-note">
|
||||||
|
{s.vorhanden ? 'vorhanden — wird verknüpft' : s.privat ? 'neu, privat' : 'neu'}
|
||||||
|
</span>
|
||||||
|
<span className="setup-step-for">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="btn btn-save" disabled={setupRunning} onClick={runSetup}>
|
||||||
|
{setupRunning ? 'Legt an …' : 'Jetzt anlegen'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" disabled={setupRunning} onClick={() => setPlan(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{setupResult && (
|
||||||
|
<div className="setup-plan">
|
||||||
|
<p className="setup-plan-intro">Ergebnis:</p>
|
||||||
|
<div className="setup-plan-list">
|
||||||
|
{setupResult.map((r) => (
|
||||||
|
<div className={`setup-step${r.ok ? '' : ' fehler'}`} key={r.setting}>
|
||||||
|
<span className="setup-step-name">{r.name ?? r.label}</span>
|
||||||
|
<span className="setup-step-note">
|
||||||
|
{r.ok
|
||||||
|
? (r.wiederverwendet ? 'verknüpft' : 'angelegt')
|
||||||
|
: r.fehler}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="btn btn-ghost" onClick={() => setSetupResult(null)}>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="key-list">
|
<div className="key-list">
|
||||||
{unfinished.map((m) => (
|
{unfinished.map((m) => (
|
||||||
<div className="key-row" key={m.id}>
|
<div className="key-row" key={m.id}>
|
||||||
|
|||||||
@@ -725,6 +725,41 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
|||||||
.btn-create { color: var(--neon); border-color: rgba(245, 197, 24, .45); }
|
.btn-create { color: var(--neon); border-color: rgba(245, 197, 24, .45); }
|
||||||
.btn-create:hover:not(:disabled) { background: rgba(245, 197, 24, .1); }
|
.btn-create:hover:not(:disabled) { background: rgba(245, 197, 24, .1); }
|
||||||
.btn-create:disabled { opacity: .4; cursor: default; }
|
.btn-create:disabled { opacity: .4; cursor: default; }
|
||||||
|
|
||||||
|
/* Grundgerüst-Assistent: Vorschau und Ergebnis */
|
||||||
|
.setup-plan {
|
||||||
|
border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, .015);
|
||||||
|
padding: 1.1rem 1.2rem; margin-bottom: 1.2rem;
|
||||||
|
animation: riseIn .25s ease both;
|
||||||
|
}
|
||||||
|
.setup-plan-intro { color: var(--muted); font-weight: 300; line-height: 1.6; margin: 0 0 .9rem; }
|
||||||
|
.setup-plan-intro strong { color: var(--neon); font-weight: 600; }
|
||||||
|
.setup-plan-list {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .06); border-radius: 8px;
|
||||||
|
margin-bottom: 1rem; max-height: 22rem; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.setup-step {
|
||||||
|
display: flex; align-items: baseline; gap: 1rem;
|
||||||
|
padding: .45rem .8rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, .05);
|
||||||
|
}
|
||||||
|
.setup-step:last-child { border-bottom: 0; }
|
||||||
|
.setup-step-name {
|
||||||
|
font-family: var(--mono); font-size: .82rem; color: var(--neon);
|
||||||
|
min-width: 11rem;
|
||||||
|
}
|
||||||
|
/* Was es schon gibt, tritt zurück — es passiert ja nichts Neues */
|
||||||
|
.setup-step.da .setup-step-name { color: var(--muted); }
|
||||||
|
.setup-step.fehler .setup-step-name { color: #f23f43; }
|
||||||
|
.setup-step-note {
|
||||||
|
font-family: var(--mono); font-size: .62rem;
|
||||||
|
letter-spacing: .1em; text-transform: uppercase; color: var(--muted2);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.setup-step.fehler .setup-step-note { color: #f23f43; text-transform: none; letter-spacing: .02em; }
|
||||||
|
.setup-step-for { color: var(--muted2); font-size: .82rem; font-weight: 300; }
|
||||||
.settings-nav-item {
|
.settings-nav-item {
|
||||||
display: flex; align-items: center; gap: .7rem;
|
display: flex; align-items: center; gap: .7rem;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
|
|||||||
+103
-3
@@ -9,8 +9,13 @@
|
|||||||
// dessen Verlauf.
|
// dessen Verlauf.
|
||||||
import { ChannelType, PermissionFlagsBits } from 'discord.js';
|
import { ChannelType, PermissionFlagsBits } from 'discord.js';
|
||||||
import { setSetting } from '../db.js';
|
import { setSetting } from '../db.js';
|
||||||
import { creationPlan } from '../modules.js';
|
import { creationPlan, MODULES, moduleEnabled, moduleMissing } from '../modules.js';
|
||||||
import { discordGuildId } from '../runtime-settings.js';
|
import { discordGuildId, brandName } from '../runtime-settings.js';
|
||||||
|
|
||||||
|
// Kurze Pause zwischen zwei Anlagen. Discord verträgt Bursts schlecht, und der
|
||||||
|
// Assistent legt schnell ein Dutzend Kanäle an.
|
||||||
|
const PAUSE_MS = 350;
|
||||||
|
const warte = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
/** Server, auf dem gearbeitet wird — konfigurierter, sonst der erste */
|
/** Server, auf dem gearbeitet wird — konfigurierter, sonst der erste */
|
||||||
function targetGuild(client) {
|
function targetGuild(client) {
|
||||||
@@ -23,10 +28,11 @@ function targetGuild(client) {
|
|||||||
*
|
*
|
||||||
* @param {import('discord.js').Client} client
|
* @param {import('discord.js').Client} client
|
||||||
* @param {string} setting — z. B. 'devlog_channel_id'
|
* @param {string} setting — z. B. 'devlog_channel_id'
|
||||||
|
* @param {{ parent?: object|null }} [opts] — Kategorie für neue Kanäle
|
||||||
* @returns {Promise<{ id: string, name: string, art: 'kanal'|'rolle' }>}
|
* @returns {Promise<{ id: string, name: string, art: 'kanal'|'rolle' }>}
|
||||||
* @throws {Error} mit einer Meldung, die im Panel angezeigt werden kann
|
* @throws {Error} mit einer Meldung, die im Panel angezeigt werden kann
|
||||||
*/
|
*/
|
||||||
export async function createForSetting(client, setting) {
|
export async function createForSetting(client, setting, { parent = null } = {}) {
|
||||||
const plan = creationPlan(setting);
|
const plan = creationPlan(setting);
|
||||||
if (!plan) throw new Error('Dafür gibt es keine Vorlage — bitte von Hand anlegen.');
|
if (!plan) throw new Error('Dafür gibt es keine Vorlage — bitte von Hand anlegen.');
|
||||||
|
|
||||||
@@ -80,6 +86,7 @@ export async function createForSetting(client, setting) {
|
|||||||
const channel = await guild.channels.create({
|
const channel = await guild.channels.create({
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
type: gewuenschterTyp,
|
type: gewuenschterTyp,
|
||||||
|
parent: parent ?? undefined,
|
||||||
topic: plan.sprache ? undefined : plan.topic,
|
topic: plan.sprache ? undefined : plan.topic,
|
||||||
permissionOverwrites: overwrites.length ? overwrites : undefined,
|
permissionOverwrites: overwrites.length ? overwrites : undefined,
|
||||||
reason: 'Über das d4rkbot-Panel angelegt',
|
reason: 'Über das d4rkbot-Panel angelegt',
|
||||||
@@ -88,3 +95,96 @@ export async function createForSetting(client, setting) {
|
|||||||
setSetting(setting, channel.id);
|
setSetting(setting, channel.id);
|
||||||
return { id: channel.id, name: channel.name, art: 'kanal', wiederverwendet: false };
|
return { id: channel.id, name: channel.name, art: 'kanal', wiederverwendet: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Grundgerüst-Assistent ─────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was der Assistent anlegen würde — für die Vorschau, bevor etwas passiert.
|
||||||
|
*
|
||||||
|
* Eine Einstellung kann von mehreren Modulen gebraucht werden (Devlogs und
|
||||||
|
* Wochen-Rückblick teilen sich den Devlog-Kanal), deshalb wird nach
|
||||||
|
* Einstellung entdoppelt.
|
||||||
|
*/
|
||||||
|
export function setupPlan(client) {
|
||||||
|
const guild = targetGuild(client);
|
||||||
|
const namen = new Set(
|
||||||
|
guild ? [...guild.channels.cache.values()].map((c) => c.name.toLowerCase()) : []
|
||||||
|
);
|
||||||
|
const rollennamen = new Set(
|
||||||
|
guild ? [...guild.roles.cache.values()].map((r) => r.name.toLowerCase()) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const gesehen = new Set();
|
||||||
|
const schritte = [];
|
||||||
|
for (const mod of MODULES) {
|
||||||
|
if (!moduleEnabled(mod.id)) continue;
|
||||||
|
for (const fehlt of moduleMissing(mod)) {
|
||||||
|
if (!fehlt.create || gesehen.has(fehlt.setting)) continue;
|
||||||
|
gesehen.add(fehlt.setting);
|
||||||
|
const rolle = Boolean(fehlt.create.rolle);
|
||||||
|
schritte.push({
|
||||||
|
setting: fehlt.setting,
|
||||||
|
label: fehlt.label,
|
||||||
|
name: fehlt.create.name,
|
||||||
|
art: rolle ? 'rolle' : 'kanal',
|
||||||
|
privat: Boolean(fehlt.create.privat),
|
||||||
|
sprache: Boolean(fehlt.create.sprache),
|
||||||
|
// Gleichnamiges vorhanden? Dann wird verknüpft statt angelegt.
|
||||||
|
vorhanden: (rolle ? rollennamen : namen).has(fehlt.create.name.toLowerCase()),
|
||||||
|
fuer: mod.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return schritte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alles aus dem Plan anlegen. Neue Kanäle landen in einer eigenen Kategorie,
|
||||||
|
* damit der Server danach nicht wie ein Sammelsurium aussieht.
|
||||||
|
*
|
||||||
|
* Ein Fehler bei einem Schritt bricht nicht ab — die restlichen werden
|
||||||
|
* versucht, und am Ende steht pro Zeile, was passiert ist.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<{ setting, label, ok, name?, art?, wiederverwendet?, fehler? }>>}
|
||||||
|
*/
|
||||||
|
export async function runSetup(client, { kategorie } = {}) {
|
||||||
|
const schritte = setupPlan(client);
|
||||||
|
if (schritte.length === 0) return [];
|
||||||
|
|
||||||
|
const guild = targetGuild(client);
|
||||||
|
const me = guild?.members.me ?? await guild?.members.fetchMe().catch(() => null);
|
||||||
|
if (!guild || !me) throw new Error('Kein Discord-Server gefunden.');
|
||||||
|
if (!me.permissions.has(PermissionFlagsBits.ManageChannels)) {
|
||||||
|
throw new Error('Dem Bot fehlt das Recht „Kanäle verwalten".');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kategorie anlegen (oder eine gleichnamige wiederverwenden), aber nur wenn
|
||||||
|
// überhaupt Kanäle entstehen
|
||||||
|
const brauchtKategorie = schritte.some((s) => s.art === 'kanal' && !s.vorhanden);
|
||||||
|
const katName = (kategorie ?? brandName()).slice(0, 90);
|
||||||
|
let parent = null;
|
||||||
|
if (brauchtKategorie) {
|
||||||
|
parent = [...guild.channels.cache.values()].find(
|
||||||
|
(c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase() === katName.toLowerCase()
|
||||||
|
) ?? await guild.channels.create({
|
||||||
|
name: katName,
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
reason: 'Grundgerüst über das d4rkbot-Panel',
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ergebnisse = [];
|
||||||
|
for (const [i, schritt] of schritte.entries()) {
|
||||||
|
if (i > 0) await warte(PAUSE_MS);
|
||||||
|
try {
|
||||||
|
const res = await createForSetting(client, schritt.setting, { parent });
|
||||||
|
ergebnisse.push({ setting: schritt.setting, label: schritt.label, ok: true, ...res });
|
||||||
|
} catch (error) {
|
||||||
|
ergebnisse.push({
|
||||||
|
setting: schritt.setting, label: schritt.label, ok: false,
|
||||||
|
fehler: error.message || 'Fehlgeschlagen',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ergebnisse;
|
||||||
|
}
|
||||||
|
|||||||
@@ -226,6 +226,30 @@ export function registerApiRoutes(app, client) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Grundgerüst: Vorschau, was der Assistent anlegen würde
|
||||||
|
app.get('/api/setup/plan', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'settings')) return;
|
||||||
|
const { setupPlan } = await import('../bot/server-setup.js');
|
||||||
|
return { schritte: setupPlan(client), kategorie: brandName() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// … und ausführen
|
||||||
|
app.post('/api/setup/run', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'settings')) return;
|
||||||
|
const kategorie = String(request.body?.kategorie ?? '').trim() || undefined;
|
||||||
|
try {
|
||||||
|
const { runSetup } = await import('../bot/server-setup.js');
|
||||||
|
const ergebnisse = await runSetup(client, { kategorie });
|
||||||
|
const neu = ergebnisse.filter((r) => r.ok && !r.wiederverwendet).length;
|
||||||
|
logAudit(getSessionUser(request), 'grundgerüst angelegt', `${neu} neu, ${ergebnisse.length} gesamt`);
|
||||||
|
request.log.info(`Grundgerüst: ${neu} neu von ${ergebnisse.length} Schritten`);
|
||||||
|
return { ok: true, ergebnisse, modules: moduleStates(), settings: currentSettings() };
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error }, 'Grundgerüst fehlgeschlagen');
|
||||||
|
return reply.code(400).send({ error: error.message || 'Fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Stellwerte: Zahlen, die vorher fest im Code standen ---
|
// --- Stellwerte: Zahlen, die vorher fest im Code standen ---
|
||||||
|
|
||||||
app.get('/api/tuning', async (request, reply) => {
|
app.get('/api/tuning', async (request, reply) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user