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
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:
@@ -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">часа (0–59)</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">
|
||||
|
||||
@@ -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 'часов';
|
||||
}
|
||||
Reference in New Issue
Block a user