From cb377fb60c2f69103e4b396e93f695ad8f2e7aad Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 11:50:23 +0700 Subject: [PATCH] =?UTF-8?q?fix(collectors):=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=8F=D1=82=D1=8C=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=81=20=D1=83=D1=87=D1=91=D1=82=D0=BE=D0=BC=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/data-collection/page.tsx | 306 ++++++------------ .../data-collection-scheduler-data-grid.tsx | 1 + docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 +- docs/ux-ui-stage-11b.md | 8 + tests/e2e/data-collection-settings.spec.ts | 74 +++++ 6 files changed, 193 insertions(+), 208 deletions(-) diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx index b24aa901..f84300bd 100644 --- a/app/(main)/data-collection/page.tsx +++ b/app/(main)/data-collection/page.tsx @@ -24,8 +24,9 @@ import { type SchedulerStatusDto, type SchedulerRunRowDto, } from "@/lib/scheduler-settings" -import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings } from "@/shared/api/collector-settings" -import { getCertificateRenewSettings } from "@/shared/api/certificates" +import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings, putTrafficCollectorSettings, putServersApiPingSettings, putUptimeCollectorSettings, putInternetPathSettings } from "@/shared/api/collector-settings" +import { can } from "@/lib/auth" +import { getCertificateRenewSettings, putCertificateRenewSettings } from "@/shared/api/certificates" import { getSchedulerStatus, listSchedulerRuns } from "@/shared/api/scheduler" import { requestJson, ApiClientError } from "@/shared/api/http-client" import { @@ -82,17 +83,6 @@ function extractApiError(reason: unknown): string { return String(reason) } -function collectSettledErrors(results: PromiseSettledResult[], labels: string[]): string[] { - const errors: string[] = [] - for (let i = 0; i < results.length; i += 1) { - const result = results[i] - if (result.status === "rejected") { - errors.push(`${labels[i]}: ${extractApiError(result.reason)}`) - } - } - return errors -} - function fmtMs(ms: number): string { if (ms < 1000) return `${ms} мс` const s = ms / 1000 @@ -519,17 +509,8 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) { ) } -type SchedulerToggleOverrides = { - trafficEnabled?: boolean - serversApiEnabled?: boolean - resourcesEnabled?: boolean - pingEnabled?: boolean - speedEnabled?: boolean - internetPathEnabled?: boolean - certRenewEnabled?: boolean -} - const SETTINGS_LABELS = { traffic: "Трафик", rest: "Серверы REST API", uptime: "Uptime", internet: "Internet path", cert: "Сертификаты" } as const +const SETTINGS_PERMISSIONS = { traffic: "mm:traffic:write", rest: "mm:servers:write", uptime: "mm:uptime:write", internet: "mm:network:write", cert: "mm:certificates:write" } as const type SettingsKey = keyof typeof SETTINGS_LABELS type SettingsRead = { known: boolean; error: string | null; receivedAt: string | null } const INITIAL_SETTINGS: Record = Object.fromEntries(Object.keys(SETTINGS_LABELS).map(key => [key, { known: false, error: null, receivedAt: null }])) as Record @@ -551,7 +532,12 @@ function DataCollectionContent() { const [internetPathCollector, setInternetPathCollector] = useState> | null>(null) const [settingsReads, setSettingsReads] = useState(INITIAL_SETTINGS) const dirtySettings = useRef(new Set()) - const settingsReady = Object.values(settingsReads).every(value => value.known && !value.error) + const dirtyFields = useRef>>({ traffic: new Set(), rest: new Set(), uptime: new Set(), internet: new Set(), cert: new Set() }) + const [dirtyCount, setDirtyCount] = useState(0) + const [saveMessage, setSaveMessage] = useState(null) + const mutationLock = useRef(false) + const writable = (key: SettingsKey) => isLive && can(SETTINGS_PERMISSIONS[key]) && settingsReads[key].known && !settingsReads[key].error + const markDirty = (key: SettingsKey, field: string) => { dirtySettings.current.add(key); dirtyFields.current[key].add(field); setDirtyCount(Object.values(dirtyFields.current).reduce((count, fields) => count + fields.size, 0)) } const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30") const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14") const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300") @@ -597,7 +583,7 @@ function DataCollectionContent() { else next.delete(id) return next }) - }, []) + }, [setOpenRunIds]) const schedulerJobsByKey = useMemo(() => { const jobs = schedulerStatus?.jobs ?? [] @@ -605,7 +591,7 @@ function DataCollectionContent() { }, [schedulerStatus?.jobs]) const loadCollectors = useCallback(async () => { - if (!isLive || !reader.current.active) return + if (!isLive || !reader.current.active || mutationLock.current) return const revision = ++reader.current.revision reader.current.controller?.abort() const controller = new AbortController(); reader.current.controller = controller @@ -666,140 +652,80 @@ function DataCollectionContent() { } }, [backendUrl, isLive, runFilterJobKey]) - const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => { - if (!settingsReady) throw new Error("Сначала получите подтверждённые настройки всех сборщиков") - const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled - const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled - const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled - const pingEnabled = overrides.pingEnabled ?? draftPingEnabled - const speedEnabled = overrides.speedEnabled ?? draftSpeedEnabled - const internetPathEnabled = overrides.internetPathEnabled ?? draftInternetPathEnabled - const certRenewEnabled = overrides.certRenewEnabled ?? draftCertRenewEnabled + const writeSettings = useCallback(async (key: SettingsKey, patch: Record) => { + if (!isLive || !reader.current.active || !can(SETTINGS_PERMISSIONS[key]) || !settingsReads[key].known || settingsReads[key].error) throw new Error("Настройки не получены или недостаточно прав для записи") + const data = key === "traffic" ? await putTrafficCollectorSettings(backendUrl, patch) + : key === "rest" ? await putServersApiPingSettings(backendUrl, patch) + : key === "uptime" ? await putUptimeCollectorSettings(backendUrl, patch) + : key === "internet" ? await putInternetPathSettings(backendUrl, patch) + : await putCertificateRenewSettings(backendUrl, patch) + if (Object.entries(patch).some(([field, value]) => (data as Record)[field] !== value)) throw new Error("Сервер подтвердил другие настройки. Обновите данные перед повторной попыткой") + if (!reader.current.active) return + if (key === "traffic") setTrafficCollector(data as Awaited>) + if (key === "rest") setServersApiCollector(data as Awaited>) + if (key === "uptime") setUptimeCollector(data as Awaited>) + if (key === "internet") setInternetPathCollector(data as Awaited>) + setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } })) + }, [backendUrl, isLive, settingsReads]) - const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30) - const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14) - const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300) - const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15) - const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60) - const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14) - const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120) - const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300) - const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600) - const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30)) - - const saveResults = await Promise.allSettled([ - apiFetch("/api/traffic/settings", { - method: "PUT", - body: JSON.stringify({ - enabled: trafficEnabled, - intervalSec: tInt, - retentionDays: tRet, - }), - }), - apiFetch("/api/servers-api-ping/settings", { - method: "PUT", - body: JSON.stringify({ - enabled: serversApiEnabled, - intervalSec: sApiInt, - }), - }), - apiFetch("/api/uptime/settings", { - method: "PUT", - body: JSON.stringify({ - resourcesEnabled, - pingEnabled, - speedEnabled, - intervalSec: uRes, - probeIntervalSec: uPing, - speedIntervalSec: uSpd, - retentionDays: uRet, - }), - }), - apiFetch("/api/internet-path/settings", { - method: "PUT", - body: JSON.stringify({ - enabled: internetPathEnabled, - intervalSec: ipInt, - }), - }), - apiFetch("/api/certificates/renew-settings", { - method: "PUT", - body: JSON.stringify({ - enabled: certRenewEnabled, - intervalSec: certRenewInt, - renewBeforeDays, - }), - }), - ]) - - const saveErrors = collectSettledErrors( - saveResults, - ["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"], - ) - if (saveErrors.length > 0) { - throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`) + const persistSchedulerDrafts = async () => { + if (mutationLock.current || !reader.current.active) return + const drafts: Record> = { + traffic: { intervalSec: trafficIntervalDraft, retentionDays: trafficRetentionDraft }, rest: { intervalSec: serversApiIntervalDraft }, + uptime: { intervalSec: uptimeResourceIntervalDraft, probeIntervalSec: uptimeIntervalDraft, speedIntervalSec: uptimeSpeedIntervalDraft, retentionDays: uptimeRetentionDraft }, + internet: { intervalSec: internetPathIntervalDraft }, cert: { intervalSec: certRenewIntervalDraft, renewBeforeDays: renewBeforeDaysDraft }, } - }, [ - settingsReady, - apiFetch, - certRenewIntervalDraft, - draftCertRenewEnabled, - draftInternetPathEnabled, - draftPingEnabled, - draftResourcesEnabled, - draftServersApiEnabled, - draftSpeedEnabled, - draftTrafficEnabled, - internetPathIntervalDraft, - renewBeforeDaysDraft, - serversApiIntervalDraft, - trafficIntervalDraft, - trafficRetentionDraft, - uptimeIntervalDraft, - uptimeResourceIntervalDraft, - uptimeRetentionDraft, - uptimeSpeedIntervalDraft, - ]) + const plan: { key: SettingsKey; patch: Record }[] = [] + try { + for (const key of Object.keys(drafts) as SettingsKey[]) { + if (!dirtyFields.current[key].size) continue + if (!writable(key)) throw new Error("Настройки не получены или недостаточно прав: " + SETTINGS_LABELS[key]) + const patch: Record = {} + for (const field of dirtyFields.current[key]) { + const raw = drafts[key][field]; const value = Number(raw) + const min = field === "retentionDays" || field === "renewBeforeDays" ? 1 : key === "cert" ? 300 : key === "internet" ? 30 : key === "rest" || field === "speedIntervalSec" ? 10 : 5 + if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < min || (field === "renewBeforeDays" && value > 90)) throw new Error(SETTINGS_LABELS[key] + ": укажите целое значение от " + min + (field === "renewBeforeDays" ? " до 90" : "")) + patch[field] = value + } + plan.push({ key, patch }) + } + if (!plan.length) return + mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null) + const successes: string[] = [], errors: string[] = [] + for (const { key, patch } of plan) { + if (!reader.current.active) break + try { + await writeSettings(key, patch) + if (!reader.current.active) return + for (const field of Object.keys(patch)) dirtyFields.current[key].delete(field) + if (!dirtyFields.current[key].size) dirtySettings.current.delete(key) + successes.push(SETTINGS_LABELS[key]) + } catch (error) { if (reader.current.active) errors.push(SETTINGS_LABELS[key] + ": " + extractApiError(error)) } + } + if (!reader.current.active) return + setDirtyCount(Object.values(dirtyFields.current).reduce((count, fields) => count + fields.size, 0)) + if (successes.length) setSaveMessage("Сохранение подтверждено: " + successes.join(", ")) + if (errors.length) setCollectorError(errors.join("; ")) + } catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) } + finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) } + } const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => { - if (!settingsReady) return - const overrides: SchedulerToggleOverrides = {} - if (jobKey === "traffic") { - setDraftTrafficEnabled(nextEnabled) - overrides.trafficEnabled = nextEnabled - } else if (jobKey === "servers_rest_ping") { - setDraftServersApiEnabled(nextEnabled) - overrides.serversApiEnabled = nextEnabled - } else if (jobKey === "uptime_resources") { - setDraftResourcesEnabled(nextEnabled) - overrides.resourcesEnabled = nextEnabled - } else if (jobKey === "uptime_ping") { - setDraftPingEnabled(nextEnabled) - overrides.pingEnabled = nextEnabled - } else if (jobKey === "uptime_speed") { - setDraftSpeedEnabled(nextEnabled) - overrides.speedEnabled = nextEnabled - } else if (jobKey === "certificates_renew") { - setDraftCertRenewEnabled(nextEnabled) - overrides.certRenewEnabled = nextEnabled - } else if (jobKey === "internet_path") { - setDraftInternetPathEnabled(nextEnabled) - overrides.internetPathEnabled = nextEnabled - } else { - return - } - - setCollectorError(null) - setSchedulerSaveBusy(true) + const targets = { + traffic: ["traffic", "enabled", setDraftTrafficEnabled], servers_rest_ping: ["rest", "enabled", setDraftServersApiEnabled], + uptime_resources: ["uptime", "resourcesEnabled", setDraftResourcesEnabled], uptime_ping: ["uptime", "pingEnabled", setDraftPingEnabled], uptime_speed: ["uptime", "speedEnabled", setDraftSpeedEnabled], + internet_path: ["internet", "enabled", setDraftInternetPathEnabled], certificates_renew: ["cert", "enabled", setDraftCertRenewEnabled], + } as const + if (!(jobKey in targets) || mutationLock.current || !reader.current.active) return + const [key, field, accept] = targets[jobKey as keyof typeof targets] + mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null) try { - await persistSchedulerDrafts(overrides) - await loadCollectors() - } catch (e) { - setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить") - } finally { - setSchedulerSaveBusy(false) - } - }, [loadCollectors, persistSchedulerDrafts, settingsReady]) + await writeSettings(key, { [field]: nextEnabled }) + if (!reader.current.active) return + accept(nextEnabled); setSaveMessage("Сохранение подтверждено: " + SETTINGS_LABELS[key]) + } catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) } + finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) } + }, [writeSettings]) useEffect(() => { if (!isLive) return @@ -817,9 +743,10 @@ function DataCollectionContent() { } } - const schedulerGridRows = useMemo(() => { + const schedulerGridRows: SchedulerJobGridRow[] = (() => { return SCHEDULER_JOB_KEYS.map((jobKey) => { const j = schedulerJobsByKey[jobKey] + const settingsKey: SettingsKey = jobKey === "traffic" ? "traffic" : jobKey === "servers_rest_ping" ? "rest" : jobKey.startsWith("uptime_") ? "uptime" : jobKey === "certificates_renew" ? "cert" : "internet" const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update" const enabled = fixedSchedule ? Boolean(j?.enabled ?? true) @@ -882,7 +809,6 @@ function DataCollectionContent() { ? 21600 : 300 - const settingsKey: SettingsKey = jobKey === "traffic" ? "traffic" : jobKey === "servers_rest_ping" ? "rest" : jobKey.startsWith("uptime_") ? "uptime" : jobKey === "certificates_renew" ? "cert" : "internet" const settingsKnown = fixedSchedule ? Boolean(j) : settingsReads[settingsKey].known return { id: jobKey, @@ -894,7 +820,7 @@ function DataCollectionContent() { enabled, intervalValue: settingsKnown ? intervalValue : "", intervalReadOnly: fixedSchedule, - intervalDisabled: !settingsKnown || schedulerSaveBusy || (!enabled && !fixedSchedule), + intervalDisabled: !settingsKnown || schedulerSaveBusy || (!fixedSchedule && !writable(settingsKey)), defaultInterval, job: j, onEnabledChange: fixedSchedule @@ -902,7 +828,7 @@ function DataCollectionContent() { : (nextEnabled) => { void handleJobEnabledChange(jobKey, nextEnabled) }, - onIntervalChange: (value: string) => { dirtySettings.current.add(settingsKey); onIntervalChange(value) }, + onIntervalChange: (value: string) => { markDirty(settingsKey, jobKey === "uptime_ping" ? "probeIntervalSec" : jobKey === "uptime_speed" ? "speedIntervalSec" : "intervalSec"); onIntervalChange(value) }, onRunNow: async () => { setRunNowJobKey(jobKey) setCollectorError(null) @@ -918,33 +844,10 @@ function DataCollectionContent() { } }, runNowLoading: runNowJobKey === jobKey, - saveBusy: schedulerSaveBusy || !settingsReady, + saveBusy: schedulerSaveBusy || !writable(settingsKey), } }) - }, [ - settingsReady, - apiFetch, - certRenewIntervalDraft, - draftCertRenewEnabled, - draftInternetPathEnabled, - draftPingEnabled, - draftResourcesEnabled, - draftServersApiEnabled, - draftSpeedEnabled, - draftTrafficEnabled, - handleJobEnabledChange, - internetPathIntervalDraft, - loadCollectors, - runNowJobKey, - settingsReads, - schedulerJobsByKey, - schedulerSaveBusy, - serversApiIntervalDraft, - trafficIntervalDraft, - uptimeIntervalDraft, - uptimeResourceIntervalDraft, - uptimeSpeedIntervalDraft, - ]) + })() const enabledJobsCount = schedulerStatus?.jobs.filter(job => job.enabled).length ?? 0 const schedulerJobCount = schedulerStatus?.jobs.length ?? 0 @@ -997,13 +900,13 @@ function DataCollectionContent() { - @@ -1047,10 +950,11 @@ function DataCollectionContent() { {value.error ? : null} ))} + {isLive && saveMessage ?

{saveMessage}

: null} {isLive && collectorError && ( - Ошибка загрузки статуса коллекторов + Операция не завершена {collectorError} )} @@ -1100,8 +1004,8 @@ function DataCollectionContent() { { dirtySettings.current.add("traffic"); setTrafficRetentionDraft(e.target.value) }} + disabled={!writable("traffic") || schedulerSaveBusy} + onChange={(e) => { markDirty("traffic", "retentionDays"); setTrafficRetentionDraft(e.target.value) }} className="h-8 text-sm" inputMode="numeric" /> @@ -1111,8 +1015,8 @@ function DataCollectionContent() { { dirtySettings.current.add("uptime"); setUptimeRetentionDraft(e.target.value) }} + disabled={!writable("uptime") || schedulerSaveBusy} + onChange={(e) => { markDirty("uptime", "retentionDays"); setUptimeRetentionDraft(e.target.value) }} className="h-8 text-sm" inputMode="numeric" /> @@ -1123,10 +1027,10 @@ function DataCollectionContent() { value={settingsReads.cert.known ? renewBeforeDaysDraft : ""} aria-label="Обновлять сертификаты за дней" - onChange={(e) => { dirtySettings.current.add("cert"); setRenewBeforeDaysDraft(e.target.value) }} + onChange={(e) => { markDirty("cert", "renewBeforeDays"); setRenewBeforeDaysDraft(e.target.value) }} className="h-8 text-sm" inputMode="numeric" - disabled={!settingsReads.cert.known || !draftCertRenewEnabled || schedulerSaveBusy} + disabled={!writable("cert") || schedulerSaveBusy} />

Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS. @@ -1138,19 +1042,8 @@ function DataCollectionContent() {