Zeitplan in der Oberflaeche einstellbar

Takt und Schalter der beiden Abgleiche standen in der Umgebung und liessen
sich nur mit einem Neustart aendern. Jetzt stehen sie unter Verwaltung ->
Zeitplan, je Lauf einzeln: ob er automatisch laeuft, in welchem Abstand, beim
Kaeufe-Abgleich zusaetzlich der seltenere Takt fuer ruhende Konten und ab
wann ein Konto als ruhend gilt, beim Forumslauf die Stunde, vor der nicht
gelaufen wird.

Die Abstaende stehen in Minuten - damit laesst sich alles ausdruecken -, und
daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnet.
Unter einer Viertelstunde geht nicht: beide Abgleiche greifen auf Server zu,
die der Community gehoeren.

Aenderungen greifen sofort, ohne Neustart. Die Vorgaben sind die bisherigen
Werte, sie stehen in der Migration.

SCHEDULE_ENABLED und FORUM_SCAN_HOUR entfallen damit.
This commit is contained in:
2026-09-21 13:25:56 +02:00
parent db37d3388a
commit 6094e4eaef
14 changed files with 658 additions and 168 deletions
+14 -2
View File
@@ -352,9 +352,21 @@ export interface ScheduleRun {
lastNote: string | null;
}
export interface ScheduleState {
export type ScheduleName = 'gextension' | 'forum';
export interface ScheduleSetting {
name: ScheduleName;
enabled: boolean;
forumHour: number;
intervalMinutes: number;
/// Nur beim Käufe-Abgleich.
idleIntervalMinutes: number | null;
idleAfterDays: number | null;
/// Nur beim Forumslauf.
onlyAfterHour: number | null;
}
export interface ScheduleState {
settings: ScheduleSetting[];
runs: ScheduleRun[];
}
+248 -61
View File
@@ -10,6 +10,7 @@ import type {
ProductMapping,
ProductTarget,
Role,
ScheduleSetting,
ScheduleState,
} from '../lib/types';
@@ -74,7 +75,7 @@ export default function Admin() {
{tab === 'users' && <UsersTab onError={setError} onDone={notify} />}
{tab === 'mappings' && <MappingsTab onError={setError} onDone={notify} />}
{tab === 'audit' && <AuditTab onError={setError} />}
{tab === 'schedule' && <ScheduleTab onError={setError} />}
{tab === 'schedule' && <ScheduleTab onError={setError} onDone={notify} />}
</div>
);
}
@@ -522,80 +523,266 @@ function AuditTab({ onError }: { onError: (message: string) => void }) {
}
/**
* Was die wiederkehrenden Abgleiche zuletzt getan haben. Sie laufen ohne
* Zutun, deshalb muss nachlesbar sein, ob sie überhaupt laufen - ein still
* stehender Zeitplan fällt sonst erst auf, wenn Wochen fehlen.
* Zeitplan: was von allein laufen soll und wie oft.
*
* Die Abstände stehen in Minuten, weil sich damit alles ausdrücken lässt -
* daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnen
* muss. Beide Abgleiche greifen auf fremde Server zu; ein sehr kurzer Takt
* ist deshalb keine gute Idee und unter einer Viertelstunde nicht möglich.
*/
function ScheduleTab({ onError }: { onError: (message: string) => void }) {
function ScheduleTab({
onError,
onDone,
}: {
onError: (message: string) => void;
onDone: (message: string) => void;
}) {
const [stand, setStand] = useState<ScheduleState | null>(null);
useEffect(() => {
void (async () => {
try {
setStand(await api<ScheduleState>('/api/admin/schedule'));
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
})();
const laden = useCallback(async () => {
try {
setStand(await api<ScheduleState>('/api/admin/schedule'));
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
}, [onError]);
useEffect(() => {
void laden();
}, [laden]);
const LABEL: Record<string, string> = {
gextension: 'Käufe bei GExtension',
forum: 'Forums-Anträge',
letzter_fehler: 'Zuletzt gescheitert',
};
return (
<section className="block">
<h2>Zeitplan</h2>
{stand === null ? (
if (stand === null) {
return (
<section className="block">
<h2>Zeitplan</h2>
<p className="muted">Lade …</p>
) : (
<>
{stand.enabled ? (
<Notice kind="info">
Die Käufe werden alle zwei Stunden geprüft, bei Spielern ohne Kauf in den letzten
drei Jahren alle sechs. Das Forum wird täglich gegen {stand.forumHour} Uhr gelesen.
Gebucht wird dabei nichts von allein: neue Pakete landen in der Import-Vorschau,
neue Anträge in der Liste.
</Notice>
) : (
<Notice kind="warn">
Der Zeitplan ist abgeschaltet (<code className="mono">SCHEDULE_ENABLED=false</code>).
Beide Abgleiche laufen nur, wenn sie von Hand angestoßen werden.
</Notice>
)}
</section>
);
}
{stand.runs.length === 0 ? (
<p className="muted">Noch kein Lauf verzeichnet.</p>
) : (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Lauf</th>
<th>Zuletzt</th>
<th>Ergebnis</th>
return (
<>
<section className="block">
<h2>Zeitplan</h2>
<Notice kind="info">
Gebucht wird hier nichts von allein: neue Pakete landen in der Import-Vorschau, neue
Anträge in der Liste. Beide Abgleiche greifen auf Server zu, die der Community gehören -
ein knapper Takt bringt wenig und belastet sie ohne Not.
</Notice>
{stand.settings.map((setting) => (
<ScheduleForm
key={setting.name}
setting={setting}
titel={LABEL[setting.name] ?? setting.name}
onSaved={(text) => {
onDone(text);
void laden();
}}
onError={onError}
/>
))}
</section>
<section className="block">
<h2>Letzte Läufe</h2>
{stand.runs.length === 0 ? (
<p className="muted">Noch kein Lauf verzeichnet.</p>
) : (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Lauf</th>
<th>Zuletzt</th>
<th>Ergebnis</th>
</tr>
</thead>
<tbody>
{stand.runs.map((run) => (
<tr key={run.name}>
<td>{LABEL[run.name] ?? run.name}</td>
<td className="muted small">{formatDate(run.lastRunAt)}</td>
<td className="muted small">
{run.lastStatus === 'FAILED' && <span className="neg">Fehler: </span>}
{run.lastNote ?? '-'}
</td>
</tr>
</thead>
<tbody>
{stand.runs.map((run) => (
<tr key={run.name}>
<td>{LABEL[run.name] ?? run.name}</td>
<td className="muted small">{formatDate(run.lastRunAt)}</td>
<td className="muted small">
{run.lastStatus === 'FAILED' && <span className="neg">Fehler: </span>}
{run.lastNote ?? '-'}
</td>
</tr>
))}
</tbody>
</table>
))}
</tbody>
</table>
</div>
)}
</section>
</>
);
}
/** Minuten in eine Angabe, die sich lesen lässt. */
function inStunden(minuten: number): string {
if (minuten < 60) return `${minuten} Minuten`;
if (minuten % 1440 === 0) {
const tage = minuten / 1440;
return tage === 1 ? 'einmal täglich' : `alle ${tage} Tage`;
}
const stunden = minuten / 60;
const text = Number.isInteger(stunden) ? String(stunden) : stunden.toFixed(1).replace('.', ',');
return `${text} Stunden`;
}
function ScheduleForm({
setting,
titel,
onSaved,
onError,
}: {
setting: ScheduleSetting;
titel: string;
onSaved: (message: string) => void;
onError: (message: string) => void;
}) {
const [enabled, setEnabled] = useState(setting.enabled);
const [interval, setInterval] = useState(String(setting.intervalMinutes));
const [idleInterval, setIdleInterval] = useState(
setting.idleIntervalMinutes === null ? '' : String(setting.idleIntervalMinutes),
);
const [idleDays, setIdleDays] = useState(
setting.idleAfterDays === null ? '' : String(setting.idleAfterDays),
);
const [hour, setHour] = useState(
setting.onlyAfterHour === null ? '' : String(setting.onlyAfterHour),
);
const [busy, setBusy] = useState(false);
const istKaeufe = setting.name === 'gextension';
const speichern = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true);
try {
await api(`/api/admin/schedule/${setting.name}`, {
method: 'PATCH',
body: {
enabled,
intervalMinutes: Number(interval),
...(istKaeufe
? {
idleIntervalMinutes: idleInterval ? Number(idleInterval) : null,
idleAfterDays: idleDays ? Number(idleDays) : null,
}
: { onlyAfterHour: hour === '' ? null : Number(hour) }),
},
});
onSaved(`Zeitplan für „${titel}" gespeichert.`);
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Speichern fehlgeschlagen');
} finally {
setBusy(false);
}
};
return (
<form onSubmit={speichern} className="schedule-form">
<div className="schedule-head">
<h3>{titel}</h3>
<label className="checkbox">
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
/>
<span>{enabled ? 'läuft automatisch' : 'nur von Hand'}</span>
</label>
</div>
<div className="schedule-fields">
<div>
<label htmlFor={`${setting.name}-interval`}>Abstand (Minuten)</label>
<input
id={`${setting.name}-interval`}
type="number"
min={15}
max={10080}
value={interval}
onChange={(event) => setInterval(event.target.value)}
disabled={!enabled}
required
/>
<span className="muted small">
{Number(interval) >= 15 ? `= ${inStunden(Number(interval))}` : 'mindestens 15 Minuten'}
</span>
</div>
{istKaeufe ? (
<>
<div>
<label htmlFor={`${setting.name}-idle`}>Ruhende Konten (Minuten)</label>
<input
id={`${setting.name}-idle`}
type="number"
min={15}
max={10080}
value={idleInterval}
onChange={(event) => setIdleInterval(event.target.value)}
disabled={!enabled}
placeholder="leer = derselbe Abstand"
/>
<span className="muted small">
{idleInterval && Number(idleInterval) >= 15
? `= ${inStunden(Number(idleInterval))}`
: 'wie oben'}
</span>
</div>
)}
</>
)}
</section>
<div>
<label htmlFor={`${setting.name}-days`}>Ruhend ab (Tage ohne Kauf)</label>
<input
id={`${setting.name}-days`}
type="number"
min={1}
value={idleDays}
onChange={(event) => setIdleDays(event.target.value)}
disabled={!enabled}
placeholder="leer = alle gleich"
/>
<span className="muted small">
{idleDays ? `= rund ${Math.round(Number(idleDays) / 365)} Jahre` : 'kein Unterschied'}
</span>
</div>
</>
) : (
<div>
<label htmlFor={`${setting.name}-hour`}>Frühestens ab (Uhr)</label>
<input
id={`${setting.name}-hour`}
type="number"
min={0}
max={23}
value={hour}
onChange={(event) => setHour(event.target.value)}
disabled={!enabled}
placeholder="leer = jederzeit"
/>
<span className="muted small">
{hour === ''
? 'sobald der Abstand um ist'
: `nicht vor ${hour} Uhr - bei einem täglichen Takt also nachts`}
</span>
</div>
)}
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichere …' : 'Speichern'}
</button>
</div>
</form>
);
}
+34
View File
@@ -1212,3 +1212,37 @@ textarea {
color: var(--accent);
border-color: var(--accent);
}
/* --- Zeitplan --- */
.schedule-form {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.85rem 1rem;
margin-top: 0.85rem;
}
.schedule-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.schedule-head h3 {
margin: 0;
}
.schedule-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 0.75rem 1rem;
margin-top: 0.6rem;
}
/* Die Umrechnung steht direkt unter dem Feld, nicht daneben. */
.schedule-fields span {
display: block;
margin-top: 0.2rem;
}