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:
+248
-61
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user