feat(web): implement schedule editor in MaintenancePoliciesTab
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Successful in 51s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 3m48s

Enhanced the MaintenancePoliciesTab by integrating a schedule editor for maintenance policies. Users can now select schedule modes, input custom cron expressions, and dynamically update the schedule preview. This update improves the user interface and experience for managing maintenance schedules.
This commit is contained in:
Denozordec
2026-06-12 18:40:22 +07:00
parent 132559cb8e
commit de64374c91
71 changed files with 15643 additions and 4 deletions
@@ -25,6 +25,16 @@
presetForm,
type MaintenancePolicyPreset
} from '$lib/maintenance/policy-presets.js';
import {
applyScheduleEditor,
cronToEditor,
defaultScheduleEditor,
describeCron,
scheduleModeOptions,
weekdayOptions,
type ScheduleEditor,
type ScheduleMode
} from '$lib/maintenance/policy-schedule.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
@@ -78,6 +88,33 @@
let selectedPresetIds = $state<string[]>([]);
let applyingPresets = $state(false);
let activePresetId = $state<string | null>(null);
let scheduleEditor = $state<ScheduleEditor>(defaultScheduleEditor());
const schedulePreview = $derived(describeCron(form.schedule));
function loadScheduleEditor(cron: string) {
scheduleEditor = cronToEditor(cron);
}
function patchSchedule(patch: Partial<ScheduleEditor>) {
scheduleEditor = { ...scheduleEditor, ...patch };
syncScheduleToForm();
}
function setScheduleMode(mode: ScheduleMode) {
patchSchedule({ mode });
}
function syncScheduleToForm() {
const { editor, cron } = applyScheduleEditor(scheduleEditor);
scheduleEditor = editor;
form.schedule = cron;
}
function onCustomCronInput(value: string) {
scheduleEditor = { ...scheduleEditor, customCron: value };
form.schedule = value.trim() || '0 3 * * *';
}
const creatablePresets = $derived(filterAvailablePresets(policies, selectedPresetIds));
@@ -94,6 +131,7 @@
function applyPresetToForm(preset: MaintenancePolicyPreset) {
form = presetForm(preset);
activePresetId = preset.id;
loadScheduleEditor(form.schedule);
}
async function createSelectedPresets() {
@@ -152,6 +190,7 @@
form = emptyMaintenancePolicyForm();
hints = null;
activePresetId = null;
loadScheduleEditor(form.schedule);
dialogOpen = true;
}
@@ -172,6 +211,7 @@
enabled: p.enabled,
dry_run_enabled: p.dry_run_enabled
};
loadScheduleEditor(form.schedule);
hints = null;
dialogOpen = true;
void loadHints(p.id);
@@ -317,7 +357,7 @@
</span>
<span class="block text-xs text-muted-foreground">{preset.description}</span>
<span class="block font-mono text-[11px] text-muted-foreground">
{preset.form.table_name} · cron {preset.form.schedule}
{preset.form.table_name} · {describeCron(preset.form.schedule)}
</span>
</span>
<Button
@@ -396,7 +436,10 @@
{:else if column.id === 'table_name'}
{row.table_name}
{:else if column.id === 'schedule'}
<span class="font-mono text-xs">{row.schedule}</span>
<div class="space-y-0.5">
<span class="text-sm">{describeCron(row.schedule)}</span>
<span class="block font-mono text-[11px] text-muted-foreground">{row.schedule}</span>
</div>
{:else if column.id !== 'actions'}
{/if}
@@ -486,8 +529,141 @@
{/each}
</select>
</FormField>
<FormField label="Schedule (cron, UTC)" id="mp-schedule" required>
<AppInput bind:value={form.schedule} class="font-mono" disabled={!isOperator} />
<FormField label="Расписание (UTC)" id="mp-schedule" required>
<div class="grid gap-3">
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={scheduleEditor.mode}
disabled={!isOperator}
onchange={(e) => setScheduleMode(e.currentTarget.value as ScheduleMode)}
>
{#each scheduleModeOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if scheduleEditor.mode === 'minutes'}
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">Каждые</span>
<AppInput
class="w-24"
type="number"
min="1"
max="59"
value={scheduleEditor.intervalMinutes}
disabled={!isOperator}
oninput={(e) => patchSchedule({ intervalMinutes: e.currentTarget.value })}
/>
<span class="text-muted-foreground">минут</span>
</div>
{:else if scheduleEditor.mode === 'hours'}
<div class="grid gap-3 sm:grid-cols-2">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">Каждые</span>
<AppInput
class="w-24"
type="number"
min="1"
max="23"
value={scheduleEditor.intervalHours}
disabled={!isOperator}
oninput={(e) => patchSchedule({ intervalHours: e.currentTarget.value })}
/>
<span class="text-muted-foreground">часов</span>
</div>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">В минуту</span>
<AppInput
class="w-24"
type="number"
min="0"
max="59"
value={scheduleEditor.minute}
disabled={!isOperator}
oninput={(e) => patchSchedule({ minute: e.currentTarget.value })}
/>
<span class="text-muted-foreground">часа (059)</span>
</div>
</div>
{:else if scheduleEditor.mode === 'daily'}
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">В</span>
<AppInput
class="w-20"
type="number"
min="0"
max="23"
value={scheduleEditor.hour}
disabled={!isOperator}
oninput={(e) => patchSchedule({ hour: e.currentTarget.value })}
/>
<span class="text-muted-foreground">:</span>
<AppInput
class="w-20"
type="number"
min="0"
max="59"
value={scheduleEditor.minute}
disabled={!isOperator}
oninput={(e) => patchSchedule({ minute: e.currentTarget.value })}
/>
<span class="text-muted-foreground">UTC</span>
</div>
{:else if scheduleEditor.mode === 'weekly'}
<div class="grid gap-3">
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={scheduleEditor.weekday}
disabled={!isOperator}
onchange={(e) => patchSchedule({ weekday: e.currentTarget.value })}
>
{#each weekdayOptions as wd (wd.value)}
<option value={wd.value}>{wd.label}</option>
{/each}
</select>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">В</span>
<AppInput
class="w-20"
type="number"
min="0"
max="23"
value={scheduleEditor.hour}
disabled={!isOperator}
oninput={(e) => patchSchedule({ hour: e.currentTarget.value })}
/>
<span class="text-muted-foreground">:</span>
<AppInput
class="w-20"
type="number"
min="0"
max="59"
value={scheduleEditor.minute}
disabled={!isOperator}
oninput={(e) => patchSchedule({ minute: e.currentTarget.value })}
/>
<span class="text-muted-foreground">UTC</span>
</div>
</div>
{:else}
<AppInput
class="font-mono"
value={scheduleEditor.customCron}
disabled={!isOperator}
placeholder="0 3 * * *"
oninput={(e) => onCustomCronInput(e.currentTarget.value)}
/>
<p class="text-xs text-muted-foreground">
5 полей: минута час день месяц день_недели (UTC). Пример: <code>0 6 * * 0</code> — воскресенье
06:00.
</p>
{/if}
<p class="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<span class="font-medium text-foreground">{schedulePreview}</span>
<span class="mt-1 block font-mono">{form.schedule}</span>
</p>
</div>
</FormField>
<div class="flex flex-wrap gap-6">
<div class="flex items-center gap-2">
+181
View File
@@ -0,0 +1,181 @@
/** Режимы расписания (5-field cron, UTC) — совместимы с robfig/cron в scheduler. */
export type ScheduleMode = 'minutes' | 'hours' | 'daily' | 'weekly' | 'custom';
export type ScheduleEditor = {
mode: ScheduleMode;
/** Интервал в минутах (режим minutes). */
intervalMinutes: string;
/** Интервал в часах (режим hours). */
intervalHours: string;
minute: string;
hour: string;
/** 0 = воскресенье … 6 = суббота */
weekday: string;
customCron: string;
};
export const scheduleModeOptions: { value: ScheduleMode; label: string }[] = [
{ value: 'minutes', label: 'Каждые N минут' },
{ value: 'hours', label: 'Каждые N часов' },
{ value: 'daily', label: 'Ежедневно в указанное время' },
{ value: 'weekly', label: 'Еженедельно в указанный день' },
{ value: 'custom', label: 'Cron вручную (расширенный)' }
];
export const weekdayOptions = [
{ value: '0', label: 'Воскресенье' },
{ value: '1', label: 'Понедельник' },
{ value: '2', label: 'Вторник' },
{ value: '3', label: 'Среда' },
{ value: '4', label: 'Четверг' },
{ value: '5', label: 'Пятница' },
{ value: '6', label: 'Суббота' }
] as const;
export function defaultScheduleEditor(cron = '0 3 * * *'): ScheduleEditor {
return cronToEditor(cron);
}
function splitCron(cron: string): [string, string, string, string, string] | null {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return null;
return [parts[0], parts[1], parts[2], parts[3], parts[4]];
}
function clampInt(raw: string, min: number, max: number): number {
const n = Math.floor(Number(String(raw).trim()));
if (!Number.isFinite(n)) return min;
return Math.min(max, Math.max(min, n));
}
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
/** Пытается разобрать cron в редактор; неизвестные выражения → custom. */
export function cronToEditor(cron: string): ScheduleEditor {
const base: ScheduleEditor = {
mode: 'custom',
intervalMinutes: '30',
intervalHours: '2',
minute: '0',
hour: '3',
weekday: '0',
customCron: cron.trim() || '0 3 * * *'
};
const parts = splitCron(cron);
if (!parts) return base;
const [min, hour, dom, month, dow] = parts;
const minEvery = min.match(/^\*\/(\d+)$/);
if (minEvery && hour === '*' && dom === '*' && month === '*' && dow === '*') {
return { ...base, mode: 'minutes', intervalMinutes: minEvery[1], customCron: cron.trim() };
}
const hourEvery = hour.match(/^\*\/(\d+)$/);
if (hourEvery && !min.includes('*') && dom === '*' && month === '*' && dow === '*') {
return {
...base,
mode: 'hours',
intervalHours: hourEvery[1],
minute: min,
customCron: cron.trim()
};
}
if (!min.includes('*') && !hour.includes('*') && dom === '*' && month === '*' && dow === '*') {
return { ...base, mode: 'daily', minute: min, hour, customCron: cron.trim() };
}
if (!min.includes('*') && !hour.includes('*') && dom === '*' && month === '*' && dow !== '*') {
return { ...base, mode: 'weekly', minute: min, hour, weekday: dow, customCron: cron.trim() };
}
return base;
}
/** Собирает 5-field cron из редактора. */
export function editorToCron(editor: ScheduleEditor): string {
switch (editor.mode) {
case 'minutes': {
const n = clampInt(editor.intervalMinutes, 1, 59);
return `*/${n} * * * *`;
}
case 'hours': {
const n = clampInt(editor.intervalHours, 1, 23);
const m = clampInt(editor.minute, 0, 59);
return `${m} */${n} * * *`;
}
case 'daily': {
const m = clampInt(editor.minute, 0, 59);
const h = clampInt(editor.hour, 0, 23);
return `${m} ${h} * * *`;
}
case 'weekly': {
const m = clampInt(editor.minute, 0, 59);
const h = clampInt(editor.hour, 0, 23);
const d = clampInt(editor.weekday, 0, 6);
return `${m} ${h} * * ${d}`;
}
case 'custom':
return editor.customCron.trim() || '0 3 * * *';
}
}
/** Человекочитаемое описание расписания для таблицы и подсказок. */
export function describeCron(cron: string): string {
const ed = cronToEditor(cron);
switch (ed.mode) {
case 'minutes': {
const n = clampInt(ed.intervalMinutes, 1, 59);
return `Каждые ${n} ${minutesLabel(n)} (UTC)`;
}
case 'hours': {
const n = clampInt(ed.intervalHours, 1, 23);
const m = clampInt(ed.minute, 0, 59);
return `Каждые ${n} ${hoursLabel(n)}, в :${pad2(m)} (UTC)`;
}
case 'daily': {
const h = clampInt(ed.hour, 0, 23);
const m = clampInt(ed.minute, 0, 59);
return `Ежедневно в ${pad2(h)}:${pad2(m)} UTC`;
}
case 'weekly': {
const wd =
weekdayOptions.find((w) => w.value === String(clampInt(ed.weekday, 0, 6)))?.label ??
ed.weekday;
const h = clampInt(ed.hour, 0, 23);
const m = clampInt(ed.minute, 0, 59);
return `Каждую ${wd.toLowerCase()} в ${pad2(h)}:${pad2(m)} UTC`;
}
case 'custom':
return `Cron: ${cron.trim()}`;
}
}
/** Синхронизирует form.schedule из редактора. */
export function applyScheduleEditor(editor: ScheduleEditor): {
editor: ScheduleEditor;
cron: string;
} {
if (editor.mode === 'custom') {
const cron = editor.customCron.trim() || '0 3 * * *';
return { editor: { ...editor, customCron: cron }, cron };
}
const cron = editorToCron(editor);
return { editor: { ...editor, customCron: cron }, cron };
}
function minutesLabel(n: number): string {
if (n % 10 === 1 && n % 100 !== 11) return 'минуту';
if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'минуты';
return 'минут';
}
function hoursLabel(n: number): string {
if (n % 10 === 1 && n % 100 !== 11) return 'час';
if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'часа';
return 'часов';
}