fix(collectors): разделить загрузку настроек и сохранить черновики

This commit is contained in:
Denozordec committed 2026-10-03 11:41:00 +07:00
1 parent dd387cf379
commit d453f2aec3
6 files changed
+169 -122

No files matched your search

+87 -118
View File
@@ -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<T>(result: PromiseSettledResult<T>): T | null {
return result.status === "fulfilled" ? result.value : null
}
function collectSettledErrors(results: PromiseSettledResult<unknown>[], 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<string, SchedulerJobStatusDto>
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<SettingsKey, SettingsRead> = Object.fromEntries(Object.keys(SETTINGS_LABELS).map(key => [key, { known: false, error: null, receivedAt: null }])) as Record<SettingsKey, SettingsRead>
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<CollectorSettingsDto | null>(null)
const [serversApiCollector, setServersApiCollector] = useState<CollectorSettingsDto | null>(null)
const [uptimeCollector, setUptimeCollector] = useState<UptimeSettingsDto | null>(null)
const [internetPathCollector, setInternetPathCollector] = useState<CollectorSettingsDto | null>(null)
const [trafficCollector, setTrafficCollector] = useState<Awaited<ReturnType<typeof getTrafficCollectorSettings>> | null>(null)
const [serversApiCollector, setServersApiCollector] = useState<Awaited<ReturnType<typeof getServersApiPingSettings>> | null>(null)
const [uptimeCollector, setUptimeCollector] = useState<Awaited<ReturnType<typeof getUptimeCollectorSettings>> | null>(null)
const [internetPathCollector, setInternetPathCollector] = useState<Awaited<ReturnType<typeof getInternetPathSettings>> | null>(null)
const [settingsReads, setSettingsReads] = useState(INITIAL_SETTINGS)
const dirtySettings = useRef(new Set<SettingsKey>())
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<CollectorSettingsDto>("/api/traffic/settings", { signal }),
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings", { signal }),
apiFetch<UptimeSettingsDto>("/api/uptime/settings", { signal }),
apiFetch<CollectorSettingsDto>("/api/internet-path/settings", { signal }),
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings", { signal }),
function publish<T>(key: SettingsKey, request: Promise<T>, 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<SchedulerJobGridRow[]>(() => {
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 ? (
<XCircleIcon className="size-4" />
) : (
@@ -1084,6 +1041,12 @@ function DataCollectionContent() {
</Alert>
) : null}
{isLive && schedulerStatusReceivedAt ? <p className="text-xs text-muted-foreground">Статус получен: {new Date(schedulerStatusReceivedAt).toLocaleString("ru-RU")}</p> : null}
{isLive && Object.entries(settingsReads).map(([key, value]) => (
<div key={key} className="text-xs text-muted-foreground" data-testid={"settings-" + key}>
<p role="status">{SETTINGS_LABELS[key as SettingsKey]} — {value.known ? "настройки получены" : "настройки не получены"}{value.receivedAt ? " (получены): " + new Date(value.receivedAt).toLocaleString("ru-RU") : ""}</p>
{value.error ? <Alert variant="warning" className="mt-1"><AlertCircleIcon aria-hidden="true" /><AlertDescription>{value.error}{value.known ? ". Сохранены последние подтверждённые данные." : ""}</AlertDescription></Alert> : null}
</div>
))}
{isLive && collectorError && (
<Alert variant="destructive">
<AlertCircleIcon />
@@ -1135,8 +1098,10 @@ function DataCollectionContent() {
<div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
<Input
value={trafficRetentionDraft}
onChange={(e) => 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() {
<div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов uptime (дней)</p>
<Input
value={uptimeRetentionDraft}
onChange={(e) => 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() {
<div>
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
<Input
value={renewBeforeDaysDraft}
onChange={(e) => 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}
/>
<p className="text-[11px] text-muted-foreground mt-1">
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
@@ -1169,7 +1138,7 @@ function DataCollectionContent() {
</p>
<Button
size="sm"
disabled={schedulerSaveBusy}
disabled={schedulerSaveBusy || !settingsReady}
onClick={async () => {
setSchedulerSaveBusy(true)
setCollectorError(null)
@@ -1200,7 +1169,7 @@ function DataCollectionContent() {
) : null}
</p>
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : "Трафик: ошибок нет"}
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : (settingsReads.traffic.known ? "Трафик: ошибки не записаны" : "Трафик: состояние неизвестно")}
</p>
<p>
REST API (серверы):{" "}
@@ -1217,7 +1186,7 @@ function DataCollectionContent() {
<p className={cn(serversApiCollector?.lastError ? "text-destructive" : "")}>
{serversApiCollector?.lastError
? `REST API (серверы): ${serversApiCollector.lastError}`
: "REST API (серверы): ошибок нет"}
: (settingsReads.rest.known ? "REST API (серверы): ошибки не записаны" : "REST API (серверы): состояние неизвестно")}
</p>
<p>
Uptime (агрегат в настройках):{" "}
@@ -1227,7 +1196,7 @@ function DataCollectionContent() {
· {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}
</p>
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : "Uptime: ошибок нет"}
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : (settingsReads.uptime.known ? "Uptime: ошибки не записаны" : "Uptime: состояние неизвестно")}
</p>
<p>
Internet Path snapshot:{" "}
@@ -1239,7 +1208,7 @@ function DataCollectionContent() {
<p className={cn(internetPathCollector?.lastError ? "text-destructive" : "")}>
{internetPathCollector?.lastError
? `Internet Path: ${internetPathCollector.lastError}`
: "Internet Path: ошибок нет"}
: (settingsReads.internet.known ? "Internet Path: ошибки не записаны" : "Internet Path: состояние неизвестно")}
</p>
</div>
</div>
@@ -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 <span className="text-muted-foreground" aria-label="Настройки не получены">—</span>
return (
<div className="text-center align-top">
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
@@ -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}`}
/>
</div>
)
+1 -1
View File
@@ -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; внешние ограничения — журнал.
| Экран | ПК | Телефон |
| --- | --- | --- |
+9 -1
View File
@@ -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.
+8
View File
@@ -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Б ещё впереди.
@@ -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<string, object> = {
"/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<void>(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")
})