fix(collectors): сохранять только изменённые настройки с учётом прав

This commit is contained in:
Denozordec committed 2026-10-03 11:50:23 +07:00
1 parent 117aa9a21a
commit cb377fb60c
6 files changed
+193 -208

No files matched your search

+100 -206
View File
@@ -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<unknown>[], 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<SettingsKey, SettingsRead> = Object.fromEntries(Object.keys(SETTINGS_LABELS).map(key => [key, { known: false, error: null, receivedAt: null }])) as Record<SettingsKey, SettingsRead>
@@ -551,7 +532,12 @@ function DataCollectionContent() {
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 dirtyFields = useRef<Record<SettingsKey, Set<string>>>({ traffic: new Set(), rest: new Set(), uptime: new Set(), internet: new Set(), cert: new Set() })
const [dirtyCount, setDirtyCount] = useState(0)
const [saveMessage, setSaveMessage] = useState<string | null>(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<string, number | boolean>) => {
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<string, unknown>)[field] !== value)) throw new Error("Сервер подтвердил другие настройки. Обновите данные перед повторной попыткой")
if (!reader.current.active) return
if (key === "traffic") setTrafficCollector(data as Awaited<ReturnType<typeof getTrafficCollectorSettings>>)
if (key === "rest") setServersApiCollector(data as Awaited<ReturnType<typeof getServersApiPingSettings>>)
if (key === "uptime") setUptimeCollector(data as Awaited<ReturnType<typeof getUptimeCollectorSettings>>)
if (key === "internet") setInternetPathCollector(data as Awaited<ReturnType<typeof getInternetPathSettings>>)
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<SettingsKey, Record<string, string>> = {
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<string, number> }[] = []
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<string, number> = {}
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<SchedulerJobGridRow[]>(() => {
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() {
<Button
variant="outline"
size="sm"
disabled={!isLive || pageBusy}
disabled={!isLive || pageBusy || schedulerSaveBusy}
onClick={() => { void loadCollectors() }}
>
<RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm" disabled={!isLive || pageBusy} onClick={() => { void handleRefreshScheduler() }}>
<Button variant="outline" size="sm" disabled={!isLive || pageBusy || schedulerSaveBusy} onClick={() => { void handleRefreshScheduler() }}>
Перечитать таймеры
</Button>
</div>
@@ -1047,10 +950,11 @@ function DataCollectionContent() {
{value.error ? <Alert variant="warning" className="mt-1"><AlertCircleIcon aria-hidden="true" /><AlertDescription>{value.error}{value.known ? ". Сохранены последние подтверждённые данные." : ""}</AlertDescription></Alert> : null}
</div>
))}
{isLive && saveMessage ? <p role="status" className="text-sm text-muted-foreground">{saveMessage}</p> : null}
{isLive && collectorError && (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>Ошибка загрузки статуса коллекторов</AlertTitle>
<AlertTitle>Операция не завершена</AlertTitle>
<AlertDescription>{collectorError}</AlertDescription>
</Alert>
)}
@@ -1100,8 +1004,8 @@ function DataCollectionContent() {
<Input
value={settingsReads.traffic.known ? trafficRetentionDraft : ""}
aria-label="Хранение трафика"
disabled={!settingsReads.traffic.known || schedulerSaveBusy}
onChange={(e) => { 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() {
<Input
value={settingsReads.uptime.known ? uptimeRetentionDraft : ""}
aria-label="Хранение uptime"
disabled={!settingsReads.uptime.known || schedulerSaveBusy}
onChange={(e) => { 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}
/>
<p className="text-[11px] text-muted-foreground mt-1">
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
@@ -1138,19 +1042,8 @@ function DataCollectionContent() {
</p>
<Button
size="sm"
disabled={schedulerSaveBusy || !settingsReady}
onClick={async () => {
setSchedulerSaveBusy(true)
setCollectorError(null)
try {
await persistSchedulerDrafts()
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setSchedulerSaveBusy(false)
}
}}
disabled={schedulerSaveBusy || dirtyCount === 0}
onClick={() => { void persistSchedulerDrafts() }}
>
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
Сохранить настройки планировщика
@@ -1228,6 +1121,7 @@ function DataCollectionContent() {
id="run-filter"
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[180px]"
value={runFilterJobKey}
disabled={schedulerSaveBusy}
onChange={(e) => { setRunFilterJobKey(e.target.value); setOpenRunIds(new Set()); setSchedulerRuns([]); setRunsKnown(false); setRunsError(null); setRunsReceivedAt(null) }}
>
<option value="">Все</option>
@@ -81,6 +81,7 @@ function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGr
<div className="text-center align-top">
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
<FormToggle
ariaLabel={`Включить: ${row.original.label}`}
checked={enabled}
disabled={fixedSchedule || saveBusy}
onChange={(v) => {
+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.6.2 — точечные операции настроек, строгие 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; внешние ограничения — журнал.
Каталог Evolution защищён от неполного HTTP-ответа в 11Б.3.3.4.1; Подтверждение testConnection исправлено в 11Б.3.3.4.2; состояния интеграции остаются в объёме 11Б. Актуальная точка продолжения: 11Б.3.3.6.3 — строгие run-now/refresh receipts, общий lock/права и сообщения skipped/partial/event/status. Точечные операции настроек, ввод и подтверждённый сброс dirty приняты в 11Б.3.3.6.2. Независимые 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.6.1; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.3.6.2; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11Б.3.3.6.2 — точечное сохранение и права (2026-10-03)
- Переключатель отправляет только флаг выбранного сборщика; сохранение отправляет только dirty поля. Все числовые поля выбранного плана проверяются до первой записи без parseInt/default/clamp. Неполные данные других источников не мешают точечной операции.
- Строгие receipts подключены; certificate PUT дополнительно сверяет echo. Подтверждённая часть очищает dirty и обновляет сохранённые данные, отказ/403/неизвестный receipt сохраняют draft. Частичный успех показан отдельно и при ручном повторе не отправляется снова.
- Синхронный lock предотвращает повтор; pending GET инвалидируется перед записью, поздний ответ не отменяет подтверждённый переключатель. Изменение источника/unmount прекращает публикацию и последующие записи batch, но не гарантирует отмену уже полученной backend операции. Права соответствуют существующим backend permissions каждого endpoint.
- Прошли 26 E2E (15 settings + 11 history/status/race), ESLint и production frontend/contracts build. Проверены malformed/mismatched receipts, selective PUT, partial failure, dirty, fractional/no HTTP, lock, old GET, HTTP 403 и read-only claims. Реальный JWT/DB/RouterOS/ACME не проверен.
- Далее 11Б.3.3.6.3: подключить строгие run-now/refresh receipts, общий lock и права для POST, отдельно показать skipped/partial/event/status failures; затем source E2E/визуальная приёмка страницы. Этап 11Б не завершён.
## Блок 11Б.3.3.6.1 — строгие PUT настроек (2026-10-03)
- Четыре settings PUT проверяют минимумы/целые числа до запроса и реальные backend receipt ok=true/settings после него. Каждый переданный параметр обязан совпасть с подтверждённым. Пустой patch/unknown fields/неверные числа отклоняются; timeout/ошибка/неверный receipt не вызывают повтор.
+8
View File
@@ -131,3 +131,11 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
- Четыре settings PUT проверяют минимумы/целые числа до запроса и реальные backend receipt ok=true/settings после него. Каждый переданный параметр обязан совпасть с подтверждённым. Пустой patch/unknown fields/неверные числа отклоняются; timeout/ошибка/неверный receipt не вызывают повтор.
- Прошли 14 GET/PUT unit, ESLint и полный TypeScript. Первоначальный unit assertion уточнён под две формулировки подтверждения; повтор прошёл. Новый API пока не подключён к операциям страницы; certificate PUT переиспользуется с отдельной проверкой echo на следующем шаге.
- Далее 11Б.3.3.6.2: точечная запись dirty полей и выбранного переключателя, locks/права, сброс dirty по подтверждению и профильные E2E.
## Блок 11Б.3.3.6.2 — точечное сохранение и права (2026-10-03)
- Переключатель отправляет только флаг выбранного сборщика; сохранение отправляет только dirty поля. Все числовые поля выбранного плана проверяются до первой записи без parseInt/default/clamp. Неполные данные других источников не мешают точечной операции.
- Строгие receipts подключены; certificate PUT дополнительно сверяет echo. Подтверждённая часть очищает dirty и обновляет сохранённые данные, отказ/403/неизвестный receipt сохраняют draft. Частичный успех показан отдельно и при ручном повторе не отправляется снова.
- Синхронный lock предотвращает повтор; pending GET инвалидируется перед записью, поздний ответ не отменяет подтверждённый переключатель. Изменение источника/unmount прекращает публикацию и последующие записи batch, но не гарантирует отмену уже полученной backend операции. Права соответствуют существующим backend permissions каждого endpoint.
- Прошли 26 E2E (15 settings + 11 history/status/race), ESLint и production frontend/contracts build. Проверены malformed/mismatched receipts, selective PUT, partial failure, dirty, fractional/no HTTP, lock, old GET, HTTP 403 и read-only claims. Реальный JWT/DB/RouterOS/ACME не проверен.
- Далее 11Б.3.3.6.3: подключить строгие run-now/refresh receipts, общий lock и права для POST, отдельно показать skipped/partial/event/status failures; затем source E2E/визуальная приёмка страницы. Этап 11Б не завершён.
@@ -57,3 +57,77 @@ test("Настройки: успешное обновление не затир
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await expect(field).toHaveValue("99")
await expect(page.getByRole("textbox", { name: "Хранение трафика", exact: true })).toHaveValue("21")
})
test("Настройки: сохраняется только изменённое поле и сбрасывается dirty после receipt", async ({ page }) => {
await setup(page); const writes: { path: string; body: object }[] = []
await page.route("**/api/traffic/settings", r => { if (r.request().method() !== "PUT") return r.fallback(); const body = r.request().postDataJSON(); writes.push({ path: new URL(r.request().url()).pathname, body }); return r.fulfill({ json: { ok: true, settings: { ...values["/api/traffic/settings"], ...body } } }) })
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99")
const save = page.getByRole("button", { name: "Сохранить настройки планировщика" }); await save.click()
await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toBeVisible(); await expect(save).toBeDisabled()
expect(writes).toEqual([{ path: "/api/traffic/settings", body: { intervalSec: 99 } }])
})
for (const raw of [{}, { ok: false }, { ok: true, settings: { ...values["/api/traffic/settings"], intervalSec: 100 } }]) test("Настройки: неверный receipt сохраняет dirty/" + JSON.stringify(raw), async ({ page }) => {
await setup(page); let writes = 0
await page.route("**/api/traffic/settings", r => { if (r.request().method() !== "PUT") return r.fallback(); writes++; return r.fulfill({ json: raw }) })
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("button", { name: "Сохранить настройки планировщика" }).click()
await expect(page.getByText("Операция не завершена", { exact: true })).toBeVisible(); await expect(field).toHaveValue("99")
await expect(page.getByRole("button", { name: "Сохранить настройки планировщика" })).toBeEnabled(); expect(writes).toBe(1)
await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toHaveCount(0)
})
test("Настройки: дробный ввод не вызывает PUT", async ({ page }) => {
await setup(page); let writes = 0; page.on("request", r => { if (r.method() === "PUT") writes++ })
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("30.5")
await page.getByRole("button", { name: "Сохранить настройки планировщика" }).click(); await expect(page.getByText("Трафик: укажите целое значение от 5", { exact: true })).toBeVisible(); expect(writes).toBe(0)
})
test("Настройки: переключатель отправляет только флаг, сохраняя dirty интервал", async ({ page }) => {
await setup(page); const writes: object[] = []
await page.route("**/api/traffic/settings", r => { if (r.request().method() !== "PUT") return r.fallback(); const body = r.request().postDataJSON(); writes.push(body); return r.fulfill({ json: { ok: true, settings: { ...values["/api/traffic/settings"], ...body } } }) })
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("switch", { name: "Включить: Трафик", exact: true }).click()
await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toBeVisible(); expect(writes).toEqual([{ enabled: false }]); await expect(field).toHaveValue("99")
await expect(page.getByRole("button", { name: "Сохранить настройки планировщика" })).toBeEnabled()
})
test("Настройки: частичный сбой очищает только подтверждённую часть", async ({ page }) => {
await setup(page); const writes: string[] = []
await page.route("**/api/traffic/settings", r => { if (r.request().method() !== "PUT") return r.fallback(); writes.push("traffic"); return r.fulfill({ json: { ok: true, settings: { ...values["/api/traffic/settings"], ...r.request().postDataJSON() } } }) })
await page.route("**/api/uptime/settings", r => { if (r.request().method() !== "PUT") return r.fallback(); writes.push("uptime"); return r.fulfill({ status: 503, json: { error: "uptime write unavailable" } }) })
await page.goto("/data-collection"); const traffic = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(traffic).toHaveValue("45"); await traffic.fill("99")
await page.getByRole("textbox", { name: "Хранение uptime", exact: true }).fill("21"); const save = page.getByRole("button", { name: "Сохранить настройки планировщика" }); await save.click()
await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toBeVisible(); await expect(page.getByText(/uptime write unavailable/)).toBeVisible(); await expect(save).toBeEnabled(); await save.click(); await expect.poll(() => writes.length).toBe(3); expect(writes).toEqual(["traffic", "uptime", "uptime"])
})
test("Настройки: lock блокирует второй запуск сохранения", async ({ page }) => {
await setup(page); let writes = 0, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
await page.route("**/api/traffic/settings", async r => { if (r.request().method() !== "PUT") return r.fallback(); writes++; await gate; await r.fulfill({ json: { ok: true, settings: { ...values["/api/traffic/settings"], ...r.request().postDataJSON() } } }) })
try {
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99")
const save = page.getByRole("button", { name: "Сохранить настройки планировщика" }); await save.click(); await expect.poll(() => writes).toBe(1); await expect(save).toBeDisabled(); await expect(field).toBeDisabled()
release(); await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toBeVisible(); expect(writes).toBe(1)
} finally { release() }
})
test("Настройки: поздний GET не отменяет подтверждённый переключатель", async ({ page }) => {
await setup(page); let delay = false, started = false, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
await page.route("**/api/traffic/settings", async r => {
if (r.request().method() === "PUT") return r.fulfill({ json: { ok: true, settings: { ...values["/api/traffic/settings"], ...r.request().postDataJSON() } } })
if (delay) { started = true; await gate }
try { await r.fulfill({ json: values["/api/traffic/settings"] }) } catch {}
})
try {
await page.goto("/data-collection"); const toggle = page.getByRole("switch", { name: "Включить: Трафик", exact: true }); await expect(toggle).toBeChecked(); delay = true
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await page.getByRole("button", { name: "Обновить", exact: true }).click(); await expect.poll(() => started).toBe(true)
await toggle.click(); await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toBeVisible(); release(); await expect(toggle).not.toBeChecked()
} finally { release() }
})
test("Настройки: API 403 сохраняет черновик", async ({ page }) => {
await setup(page); await page.route("**/api/traffic/settings", r => r.request().method() === "PUT" ? r.fulfill({ status: 403, json: { error: "Нет доступа к настройкам трафика" } }) : r.fallback())
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("button", { name: "Сохранить настройки планировщика" }).click()
await expect(page.getByText("Нет доступа к настройкам трафика", { exact: false })).toBeVisible(); await expect(field).toHaveValue("99")
})
test("Настройки: read-only claims отключают запись", async ({ page }) => {
const claims = { sub: "fixture", email: "fixture@example.test", name: "Viewer", apps: ["mm"], permissions: ["mm:dashboard:read", "mm:traffic:read"], exp: Math.floor(Date.now()/1000)+3600 }
const token = "header." + Buffer.from(JSON.stringify(claims)).toString("base64url") + ".fixture"
await page.addInitScript(value => localStorage.setItem("mmapp_token", value), token)
await setup(page); await page.route("**/api/auth/config", r => r.fulfill({ json: { required: true, portalUrl: "http://portal.example.test" } }))
await page.goto("/data-collection"); await expect(page.getByTestId("settings-traffic")).toContainText("настройки получены")
await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toBeDisabled(); await expect(page.getByRole("switch", { name: "Включить: Трафик", exact: true })).toBeDisabled()
})