diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx index 27b7e5b5..b24aa901 100644 --- a/app/(main)/data-collection/page.tsx +++ b/app/(main)/data-collection/page.tsx @@ -20,12 +20,12 @@ import { SCHEDULER_JOB_DESCRIPTIONS, SCHEDULER_JOB_KEYS, SCHEDULER_JOB_LABELS, - type CollectorSettingsDto, type SchedulerJobStatusDto, type SchedulerStatusDto, type SchedulerRunRowDto, - type UptimeSettingsDto, } from "@/lib/scheduler-settings" +import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings } from "@/shared/api/collector-settings" +import { getCertificateRenewSettings } from "@/shared/api/certificates" import { getSchedulerStatus, listSchedulerRuns } from "@/shared/api/scheduler" import { requestJson, ApiClientError } from "@/shared/api/http-client" import { @@ -77,14 +77,11 @@ function makeApiFetch(backendUrl: string) { function extractApiError(reason: unknown): string { if (reason instanceof ApiClientError) return reason.message + if (reason instanceof Error && reason.name === "ZodError") return "Настройки не подтверждены: получен некорректный ответ сервера" if (reason instanceof Error) return reason.message return String(reason) } -function readSettled(result: PromiseSettledResult): T | null { - return result.status === "fulfilled" ? result.value : null -} - function collectSettledErrors(results: PromiseSettledResult[], labels: string[]): string[] { const errors: string[] = [] for (let i = 0; i < results.length; i += 1) { @@ -532,27 +529,10 @@ type SchedulerToggleOverrides = { certRenewEnabled?: boolean } -function applySchedulerJobsToDrafts( - jobs: SchedulerJobStatusDto[], - setters: { - setDraftTrafficEnabled: (value: boolean) => void - setDraftServersApiEnabled: (value: boolean) => void - setDraftResourcesEnabled: (value: boolean) => void - setDraftPingEnabled: (value: boolean) => void - setDraftSpeedEnabled: (value: boolean) => void - setDraftInternetPathEnabled: (value: boolean) => void - setDraftCertRenewEnabled: (value: boolean) => void - }, -) { - const byKey = Object.fromEntries(jobs.map((job) => [job.jobKey, job])) as Record - if (byKey.traffic) setters.setDraftTrafficEnabled(!!byKey.traffic.enabled) - if (byKey.servers_rest_ping) setters.setDraftServersApiEnabled(!!byKey.servers_rest_ping.enabled) - if (byKey.uptime_resources) setters.setDraftResourcesEnabled(!!byKey.uptime_resources.enabled) - if (byKey.uptime_ping) setters.setDraftPingEnabled(!!byKey.uptime_ping.enabled) - if (byKey.uptime_speed) setters.setDraftSpeedEnabled(!!byKey.uptime_speed.enabled) - if (byKey.internet_path) setters.setDraftInternetPathEnabled(!!byKey.internet_path.enabled) - if (byKey.certificates_renew) setters.setDraftCertRenewEnabled(!!byKey.certificates_renew.enabled) -} +const SETTINGS_LABELS = { traffic: "Трафик", rest: "Серверы REST API", uptime: "Uptime", internet: "Internet path", cert: "Сертификаты" } 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 export default function DataCollectionPage() { const { mode, backendUrl, prefsHydrated } = useDataSource() @@ -565,10 +545,13 @@ function DataCollectionContent() { const isLive = prefsHydrated && mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) - const [trafficCollector, setTrafficCollector] = useState(null) - const [serversApiCollector, setServersApiCollector] = useState(null) - const [uptimeCollector, setUptimeCollector] = useState(null) - const [internetPathCollector, setInternetPathCollector] = useState(null) + const [trafficCollector, setTrafficCollector] = useState> | null>(null) + const [serversApiCollector, setServersApiCollector] = useState> | null>(null) + const [uptimeCollector, setUptimeCollector] = useState> | null>(null) + 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 [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30") const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14") const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300") @@ -640,84 +623,51 @@ function DataCollectionContent() { if (current()) { setSchedulerStatus(data); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) } return data }, error => { if (current()) setSchedulerStatusError(extractApiError(error)); throw error }) - const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes, statusRes] = await Promise.allSettled([ - apiFetch("/api/traffic/settings", { signal }), - apiFetch("/api/servers-api-ping/settings", { signal }), - apiFetch("/api/uptime/settings", { signal }), - apiFetch("/api/internet-path/settings", { signal }), - apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings", { signal }), + function publish(key: SettingsKey, request: Promise, accept: (data: T, updateDraft: boolean) => void) { + return request.then(data => { + if (current()) { + accept(data, !dirtySettings.current.has(key)) + setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } })) + } + }, error => { + if (current()) setSettingsReads(prev => ({ ...prev, [key]: { ...prev[key], error: extractApiError(error) } })) + }) + } + await Promise.allSettled([ + publish("traffic", getTrafficCollectorSettings(backendUrl, { signal }), (data, draft) => { + setTrafficCollector(data) + if (draft) { setTrafficIntervalDraft(String(data.intervalSec)); setTrafficRetentionDraft(String(data.retentionDays)); setDraftTrafficEnabled(data.enabled) } + }), + publish("rest", getServersApiPingSettings(backendUrl, { signal }), (data, draft) => { + setServersApiCollector(data) + if (draft) { setServersApiIntervalDraft(String(data.intervalSec)); setDraftServersApiEnabled(data.enabled) } + }), + publish("uptime", getUptimeCollectorSettings(backendUrl, { signal }), (data, draft) => { + setUptimeCollector(data) + if (draft) { + setUptimeResourceIntervalDraft(String(data.intervalSec)); setUptimeIntervalDraft(String(data.probeIntervalSec)); setUptimeSpeedIntervalDraft(String(data.speedIntervalSec)); setUptimeRetentionDraft(String(data.retentionDays)) + setDraftResourcesEnabled(data.resourcesEnabled); setDraftPingEnabled(data.pingEnabled); setDraftSpeedEnabled(data.speedEnabled) + } + }), + publish("internet", getInternetPathSettings(backendUrl, { signal }), (data, draft) => { + setInternetPathCollector(data) + if (draft) { setInternetPathIntervalDraft(String(data.intervalSec)); setDraftInternetPathEnabled(data.enabled) } + }), + publish("cert", getCertificateRenewSettings(backendUrl, { signal }), (data, draft) => { + if (draft) { setCertRenewIntervalDraft(String(data.intervalSec)); setRenewBeforeDaysDraft(String(data.renewBeforeDays)); setDraftCertRenewEnabled(data.enabled) } + }), runsRequest, statusRequest, ]) - if (!current()) return - const loadErrors = collectSettledErrors( - [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes, statusRes], - ["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика", "статус планировщика"], - ) - if (loadErrors.length > 0) { - setCollectorError(loadErrors.join("; ")) - } - - const traffic = readSettled(trafficRes) - if (traffic) { - setTrafficCollector(traffic) - setTrafficIntervalDraft(String(traffic.intervalSec)) - setTrafficRetentionDraft(String(traffic.retentionDays)) - setDraftTrafficEnabled(!!traffic.enabled) - } - - const serversApi = readSettled(serversApiRes) - if (serversApi) { - setServersApiCollector(serversApi) - setDraftServersApiEnabled(!!serversApi.enabled) - setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120)) - } - - const uptime = readSettled(uptimeRes) - if (uptime) { - setUptimeCollector(uptime) - setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300)) - setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15)) - setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60)) - setUptimeRetentionDraft(String(uptime.retentionDays)) - setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled)) - setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled)) - setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled)) - if (uptime.scheduler?.jobs?.length) { - applySchedulerJobsToDrafts(uptime.scheduler.jobs, { - setDraftTrafficEnabled, - setDraftServersApiEnabled, - setDraftResourcesEnabled, - setDraftPingEnabled, - setDraftSpeedEnabled, - setDraftInternetPathEnabled, - setDraftCertRenewEnabled, - }) - } - } - - const internetPath = readSettled(internetPathRes) - if (internetPath) { - setInternetPathCollector(internetPath) - setDraftInternetPathEnabled(!!internetPath.enabled) - setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300)) - } - - const certRenew = readSettled(certRenewRes) - if (certRenew) { - setDraftCertRenewEnabled(!!certRenew.enabled) - setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600)) - setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30)) - } - } catch (e) { if (current()) setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные") } finally { if (current()) setPageBusy(false) } - }, [apiFetch, backendUrl, isLive, runFilterJobKey]) + }, [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 @@ -790,6 +740,7 @@ function DataCollectionContent() { throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`) } }, [ + settingsReady, apiFetch, certRenewIntervalDraft, draftCertRenewEnabled, @@ -811,6 +762,7 @@ function DataCollectionContent() { ]) const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => { + if (!settingsReady) return const overrides: SchedulerToggleOverrides = {} if (jobKey === "traffic") { setDraftTrafficEnabled(nextEnabled) @@ -847,7 +799,7 @@ function DataCollectionContent() { } finally { setSchedulerSaveBusy(false) } - }, [loadCollectors, persistSchedulerDrafts]) + }, [loadCollectors, persistSchedulerDrafts, settingsReady]) useEffect(() => { if (!isLive) return @@ -868,7 +820,7 @@ function DataCollectionContent() { const schedulerGridRows = useMemo(() => { return SCHEDULER_JOB_KEYS.map((jobKey) => { const j = schedulerJobsByKey[jobKey] - const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" + const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update" const enabled = fixedSchedule ? Boolean(j?.enabled ?? true) : jobKey === "traffic" @@ -930,16 +882,19 @@ 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, + settingsKnown, jobKey, label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey, description: SCHEDULER_JOB_DESCRIPTIONS[jobKey], fixedSchedule, enabled, - intervalValue, + intervalValue: settingsKnown ? intervalValue : "", intervalReadOnly: fixedSchedule, - intervalDisabled: !enabled && !fixedSchedule, + intervalDisabled: !settingsKnown || schedulerSaveBusy || (!enabled && !fixedSchedule), defaultInterval, job: j, onEnabledChange: fixedSchedule @@ -947,7 +902,7 @@ function DataCollectionContent() { : (nextEnabled) => { void handleJobEnabledChange(jobKey, nextEnabled) }, - onIntervalChange, + onIntervalChange: (value: string) => { dirtySettings.current.add(settingsKey); onIntervalChange(value) }, onRunNow: async () => { setRunNowJobKey(jobKey) setCollectorError(null) @@ -963,10 +918,11 @@ function DataCollectionContent() { } }, runNowLoading: runNowJobKey === jobKey, - saveBusy: schedulerSaveBusy, + saveBusy: schedulerSaveBusy || !settingsReady, } }) }, [ + settingsReady, apiFetch, certRenewIntervalDraft, draftCertRenewEnabled, @@ -980,6 +936,7 @@ function DataCollectionContent() { internetPathIntervalDraft, loadCollectors, runNowJobKey, + settingsReads, schedulerJobsByKey, schedulerSaveBusy, serversApiIntervalDraft, @@ -1013,7 +970,7 @@ function DataCollectionContent() { value: trafficCollector?.lastCollectedAt ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" }) : "—", - sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных", + sub: settingsReads.traffic.error ? "Последний сбор требует обновления" : trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных", icon: trafficCollector?.lastError ? ( ) : ( @@ -1084,6 +1041,12 @@ function DataCollectionContent() { ) : null} {isLive && schedulerStatusReceivedAt ?

Статус получен: {new Date(schedulerStatusReceivedAt).toLocaleString("ru-RU")}

: null} + {isLive && Object.entries(settingsReads).map(([key, value]) => ( +
+

{SETTINGS_LABELS[key as SettingsKey]} — {value.known ? "настройки получены" : "настройки не получены"}{value.receivedAt ? " (получены): " + new Date(value.receivedAt).toLocaleString("ru-RU") : ""}

+ {value.error ? : null} +
+ ))} {isLive && collectorError && ( @@ -1135,8 +1098,10 @@ function DataCollectionContent() {

Хранение сэмплов трафика (дней)

setTrafficRetentionDraft(e.target.value)} + value={settingsReads.traffic.known ? trafficRetentionDraft : ""} + aria-label="Хранение трафика" + disabled={!settingsReads.traffic.known || schedulerSaveBusy} + onChange={(e) => { dirtySettings.current.add("traffic"); setTrafficRetentionDraft(e.target.value) }} className="h-8 text-sm" inputMode="numeric" /> @@ -1144,8 +1109,10 @@ function DataCollectionContent() {

Хранение сэмплов uptime (дней)

setUptimeRetentionDraft(e.target.value)} + value={settingsReads.uptime.known ? uptimeRetentionDraft : ""} + aria-label="Хранение uptime" + disabled={!settingsReads.uptime.known || schedulerSaveBusy} + onChange={(e) => { dirtySettings.current.add("uptime"); setUptimeRetentionDraft(e.target.value) }} className="h-8 text-sm" inputMode="numeric" /> @@ -1153,11 +1120,13 @@ function DataCollectionContent() {

Обновлять сертификаты за (дней)

setRenewBeforeDaysDraft(e.target.value)} + value={settingsReads.cert.known ? renewBeforeDaysDraft : ""} + aria-label="Обновлять сертификаты за дней" + + onChange={(e) => { dirtySettings.current.add("cert"); setRenewBeforeDaysDraft(e.target.value) }} className="h-8 text-sm" inputMode="numeric" - disabled={!draftCertRenewEnabled} + disabled={!settingsReads.cert.known || !draftCertRenewEnabled || schedulerSaveBusy} />

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

diff --git a/components/data-grids/data-collection-scheduler-data-grid.tsx b/components/data-grids/data-collection-scheduler-data-grid.tsx index ed193573..1e81f12d 100644 --- a/components/data-grids/data-collection-scheduler-data-grid.tsx +++ b/components/data-grids/data-collection-scheduler-data-grid.tsx @@ -28,6 +28,7 @@ export interface SchedulerJobGridRow { description: string fixedSchedule: boolean enabled: boolean + settingsKnown: boolean intervalValue: string intervalReadOnly: boolean intervalDisabled: boolean @@ -74,7 +75,8 @@ function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGr ), enableSorting: false, cell: ({ row }) => { - const { fixedSchedule, enabled, saveBusy, onEnabledChange } = row.original + const { fixedSchedule, enabled, saveBusy, onEnabledChange, settingsKnown } = row.original + if (!settingsKnown) return — return (
@@ -109,7 +111,8 @@ function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGr inputMode="numeric" readOnly={r.intervalReadOnly} disabled={r.intervalDisabled} - placeholder={String(r.defaultInterval)} + placeholder={r.settingsKnown ? String(r.defaultInterval) : "—"} + aria-label={`Интервал: ${r.label}`} />
) diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index cf8fb2e6..8248d251 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -226,7 +226,7 @@ REST запрос → ожидание → подтверждённый output / | Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) | | Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) | -Каталог Evolution защищён от неполного HTTP-ответа в 11Б.3.3.4.1; Подтверждение testConnection исправлено в 11Б.3.3.4.2; состояния интеграции остаются в объёме 11Б. Актуальная точка продолжения: 11Б.3.3.5 — подключение strict settings readers, независимые known/error/time и dirty drafts. API основа проверена в 11Б.3.3.4; пока не подключена к странице. Независимые scheduler status/runs и достоверные KPI приняты в 11Б.3.3.3. Strict history/unknown/свежесть приняты в 11Б.3.3.2. Контекст/abort/revision и исходный lint исправлены в 11Б.3.3.1; далее независимые readers/dirty states и операции. GeoIP API принят в 11Б.3.2. Scheduler API основа проверена в 11Б.3.1, пока не подключена к странице. Совместимость JSONB/legacy snapshot и foreign guard проверены в 11Б.2; исходные lint/context ограничения страницы описаны в журнале. Общий runner исправлен в 11Б.1; карта оставшегося этапа — [11Б](ux-ui-stage-11b.md). Этап 11А принят локально в 11А.33: backups 71 основных + 28 source E2E, 27 frontend unit, backend профиль и production build. Backup scheduler cleanup проверен в 11А.32; общий dispatcher/journal status исправлен в 11Б.1, реальные интеграции остаются внешней приёмкой. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А приняты локально в 11А.33; внешние ограничения — журнал. +Каталог Evolution защищён от неполного HTTP-ответа в 11Б.3.3.4.1; Подтверждение testConnection исправлено в 11Б.3.3.4.2; состояния интеграции остаются в объёме 11Б. Актуальная точка продолжения: 11Б.3.3.6 — точечные операции настроек, строгие receipts и ввод, locks/права и подтверждённый сброс dirty. Независимые strict settings readers и защита черновиков подключены в 11Б.3.3.5; реальные интеграции остаются внешней приёмкой. Независимые scheduler status/runs и достоверные KPI приняты в 11Б.3.3.3. Strict history/unknown/свежесть приняты в 11Б.3.3.2. Контекст/abort/revision и исходный lint исправлены в 11Б.3.3.1; далее независимые readers/dirty states и операции. GeoIP API принят в 11Б.3.2. Scheduler API основа проверена в 11Б.3.1, пока не подключена к странице. Совместимость JSONB/legacy snapshot и foreign guard проверены в 11Б.2; исходные lint/context ограничения страницы описаны в журнале. Общий runner исправлен в 11Б.1; карта оставшегося этапа — [11Б](ux-ui-stage-11b.md). Этап 11А принят локально в 11А.33: backups 71 основных + 28 source E2E, 27 frontend unit, backend профиль и production build. Backup scheduler cleanup проверен в 11А.32; общий dispatcher/journal status исправлен в 11Б.1, реальные интеграции остаются внешней приёмкой. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А приняты локально в 11А.33; внешние ограничения — журнал. | Экран | ПК | Телефон | | --- | --- | --- | diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index fb1f7f3c..382d668f 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.3.4.2; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.3.5; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.3.3.5 — независимые настройки и черновики (2026-10-03) + +- Подключены четыре строгих settings reader и существующий certificate reader. У каждого источника независимые known/error/browser receivedAt; медленный uptime не задерживает остальные настройки. Ошибка сохраняет последние подтверждённые данные и время, неполный DTO не подменяется defaults. +- Изменённые пользователем интервалы и дополнительные поля сохраняются при обновлении. Состояние источника сбрасывается существующим remount; abort/revision защищают поздние ответы. Uptime scheduler metadata больше не переписывает чужие переключатели. REST ping использует собственный DTO без выдуманного retention. +- Неизвестные настройки показывают —/пустые отключённые поля, доступность записи пока консервативно зависит от всех пяти подтверждённых источников. GeoIP расписание отмечено внешне управляемым через NetFlow. Формат/композиция установленной Data Grid сохранены; async metadata объявляется role=status. +- Прошли 15 профильных E2E (4 новых settings + история/status/race), ESLint, полный TypeScript и production frontend/contracts build. ReUI audit checklist получен; изменения затрагивают данные/состояния, vendor props и тема не менялись. Реальные DB/RouterOS/ACME не проверены. +- Следующий блок 11Б.3.3.6: строгие mutation receipts/проверка ввода, точечные PUT вместо записи всех настроек переключателем, синхронные locks/права и сброс dirty только по подтверждённому результату. Полная приёмка 11Б ещё впереди. + ## Блок 11Б.3.3.4.2 — подтверждение проверки EvoBGP (2026-10-01) - testConnection проверяет явное ok=true: HTTP 200 с false, пустым или некорректным receipt не становится сообщением об установленном соединении. Ошибка возвращается существующей форме, без повторного POST. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 6e7a3092..4485b4f5 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -117,3 +117,11 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S - Прошли unit для true/false/missing/null/неверных типов, ESLint изменённых файлов и полный TypeScript. Два профильных браузерных теста прошли; после расширения новый тест повторно проверил false → ручная повторная проверка → true и ровно один запрос на каждое нажатие. HTTP-подмена не доказывает соединение с реальным Portal. - Полная production сборка прошла в предыдущем блоке 11Б.3.3.4.1; текущая маленькая правка проверена TypeScript и браузером. Общие settings/RBAC/dirty/context и сохранение последнего каталога остаются впереди. Основная точка продолжения — 11Б.3.3.5. - Блок сохранён отдельным локальным коммитом без push. Процент недельной квоты округлён до 100%, ordinaryUsageAllowed при начале блока оставался true. + +## Блок 11Б.3.3.5 — независимые настройки и черновики (2026-10-03) + +- Подключены четыре строгих settings reader и существующий certificate reader. У каждого источника независимые known/error/browser receivedAt; медленный uptime не задерживает остальные настройки. Ошибка сохраняет последние подтверждённые данные и время, неполный DTO не подменяется defaults. +- Изменённые пользователем интервалы и дополнительные поля сохраняются при обновлении. Состояние источника сбрасывается существующим remount; abort/revision защищают поздние ответы. Uptime scheduler metadata больше не переписывает чужие переключатели. REST ping использует собственный DTO без выдуманного retention. +- Неизвестные настройки показывают —/пустые отключённые поля, доступность записи пока консервативно зависит от всех пяти подтверждённых источников. GeoIP расписание отмечено внешне управляемым через NetFlow. Формат/композиция установленной Data Grid сохранены; async metadata объявляется role=status. +- Прошли 15 профильных E2E (4 новых settings + история/status/race), ESLint, полный TypeScript и production frontend/contracts build. ReUI audit checklist получен; изменения затрагивают данные/состояния, vendor props и тема не менялись. Реальные DB/RouterOS/ACME не проверены. +- Следующий блок 11Б.3.3.6: строгие mutation receipts/проверка ввода, точечные PUT вместо записи всех настроек переключателем, синхронные locks/права и сброс dirty только по подтверждённому результату. Полная приёмка 11Б ещё впереди. diff --git a/tests/e2e/data-collection-settings.spec.ts b/tests/e2e/data-collection-settings.spec.ts new file mode 100644 index 00000000..509c5c5c --- /dev/null +++ b/tests/e2e/data-collection-settings.spec.ts @@ -0,0 +1,59 @@ +import { test, expect, type Page } from "@playwright/test" +import { geoip } from "./fixtures/geoip" +const common = { enabled: true, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: 0, lastError: null } +const values: Record = { + "/api/traffic/settings": { ...common, intervalSec: 45 }, + "/api/servers-api-ping/settings": { ...common, enabled: false, intervalSec: 120 }, + "/api/uptime/settings": { ...common, resourcesEnabled: true, pingEnabled: false, speedEnabled: false, probeIntervalSec: 15, speedIntervalSec: 60 }, + "/api/internet-path/settings": common, + "/api/certificates/renew-settings": { enabled: false, intervalSec: 21600, renewBeforeDays: 30 }, +} +async function setup(page: Page) { + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (path === "/api/auth/config") return r.fulfill({ json: { required: false } }) + if (path === "/api/geoip") return r.fulfill({ json: geoip }) + if (path === "/api/scheduler/status") return r.fulfill({ json: { jobs: [] } }) + if (path === "/api/scheduler/runs") return r.fulfill({ json: { runs: [] } }) + if (values[path]) return r.fulfill({ json: values[path] }) + return r.fulfill({ status: 503, json: { error: "outside settings fixture" } }) + }) +} +test("Настройки: неполный DTO не включает defaults и не разрешает запись", async ({ page }) => { + await setup(page); await page.route("**/api/traffic/settings", r => r.fulfill({ json: {} })) + await page.goto("/data-collection") + await expect(page.getByTestId("settings-traffic")).toContainText("Настройки сборщика не подтверждены") + await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toHaveValue("") + await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toBeDisabled() + await expect(page.getByRole("button", { name: "Сохранить настройки планировщика" })).toBeDisabled() + await expect(page.getByTestId("settings-rest")).toContainText("настройки получены") +}) +test("Настройки: медленный uptime не задерживает трафик и REST", async ({ page }) => { + await setup(page); let started = false, release!: () => void; const gate = new Promise(resolve => release = resolve) + await page.route("**/api/uptime/settings", async r => { started = true; await gate; try { await r.fulfill({ json: values["/api/uptime/settings"] }) } catch {} }) + try { + await page.goto("/data-collection"); await expect.poll(() => started).toBe(true) + await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toHaveValue("45") + await expect(page.getByTestId("settings-rest")).toContainText("настройки получены") + await expect(page.getByTestId("settings-uptime")).toContainText("настройки не получены") + } finally { release() } +}) +test("Настройки: фоновая ошибка сохраняет данные, время и черновик", async ({ page }) => { + await setup(page); let fails = false + await page.route("**/api/traffic/settings", r => r.fulfill(fails ? { status: 503, json: { error: "traffic unavailable" } } : { json: values["/api/traffic/settings"] })) + await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }) + await expect(field).toHaveValue("45"); await field.fill("99") + const received = await page.getByTestId("settings-traffic").getByRole("status").textContent(); fails = true + await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByTestId("settings-traffic")).toContainText("traffic unavailable") + await expect(field).toHaveValue("99"); await expect(page.getByTestId("settings-traffic").getByRole("status")).toHaveText(received!) +}) +test("Настройки: успешное обновление не затирает черновик", async ({ page }) => { + await setup(page); await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }) + await expect(field).toHaveValue("45"); await field.fill("99") + await page.getByRole("textbox", { name: "Хранение трафика", exact: true }).fill("21") + await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await expect(field).toHaveValue("99") + await expect(page.getByRole("textbox", { name: "Хранение трафика", exact: true })).toHaveValue("21") +})