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 SchedulerStatusDto,
type SchedulerRunRowDto, type SchedulerRunRowDto,
} from "@/lib/scheduler-settings" } from "@/lib/scheduler-settings"
import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings } from "@/shared/api/collector-settings" import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings, putTrafficCollectorSettings, putServersApiPingSettings, putUptimeCollectorSettings, putInternetPathSettings } from "@/shared/api/collector-settings"
import { getCertificateRenewSettings } from "@/shared/api/certificates" import { can } from "@/lib/auth"
import { getCertificateRenewSettings, putCertificateRenewSettings } from "@/shared/api/certificates"
import { getSchedulerStatus, listSchedulerRuns } from "@/shared/api/scheduler" import { getSchedulerStatus, listSchedulerRuns } from "@/shared/api/scheduler"
import { requestJson, ApiClientError } from "@/shared/api/http-client" import { requestJson, ApiClientError } from "@/shared/api/http-client"
import { import {
@@ -82,17 +83,6 @@ function extractApiError(reason: unknown): string {
return String(reason) 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 { function fmtMs(ms: number): string {
if (ms < 1000) return `${ms} мс` if (ms < 1000) return `${ms} мс`
const s = ms / 1000 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_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 SettingsKey = keyof typeof SETTINGS_LABELS
type SettingsRead = { known: boolean; error: string | null; receivedAt: string | null } 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> 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 [internetPathCollector, setInternetPathCollector] = useState<Awaited<ReturnType<typeof getInternetPathSettings>> | null>(null)
const [settingsReads, setSettingsReads] = useState(INITIAL_SETTINGS) const [settingsReads, setSettingsReads] = useState(INITIAL_SETTINGS)
const dirtySettings = useRef(new Set<SettingsKey>()) 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 [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14") const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300") const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
@@ -597,7 +583,7 @@ function DataCollectionContent() {
else next.delete(id) else next.delete(id)
return next return next
}) })
}, []) }, [setOpenRunIds])
const schedulerJobsByKey = useMemo(() => { const schedulerJobsByKey = useMemo(() => {
const jobs = schedulerStatus?.jobs ?? [] const jobs = schedulerStatus?.jobs ?? []
@@ -605,7 +591,7 @@ function DataCollectionContent() {
}, [schedulerStatus?.jobs]) }, [schedulerStatus?.jobs])
const loadCollectors = useCallback(async () => { const loadCollectors = useCallback(async () => {
if (!isLive || !reader.current.active) return if (!isLive || !reader.current.active || mutationLock.current) return
const revision = ++reader.current.revision const revision = ++reader.current.revision
reader.current.controller?.abort() reader.current.controller?.abort()
const controller = new AbortController(); reader.current.controller = controller const controller = new AbortController(); reader.current.controller = controller
@@ -666,140 +652,80 @@ function DataCollectionContent() {
} }
}, [backendUrl, isLive, runFilterJobKey]) }, [backendUrl, isLive, runFilterJobKey])
const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => { const writeSettings = useCallback(async (key: SettingsKey, patch: Record<string, number | boolean>) => {
if (!settingsReady) throw new Error("Сначала получите подтверждённые настройки всех сборщиков") if (!isLive || !reader.current.active || !can(SETTINGS_PERMISSIONS[key]) || !settingsReads[key].known || settingsReads[key].error) throw new Error("Настройки не получены или недостаточно прав для записи")
const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled const data = key === "traffic" ? await putTrafficCollectorSettings(backendUrl, patch)
const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled : key === "rest" ? await putServersApiPingSettings(backendUrl, patch)
const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled : key === "uptime" ? await putUptimeCollectorSettings(backendUrl, patch)
const pingEnabled = overrides.pingEnabled ?? draftPingEnabled : key === "internet" ? await putInternetPathSettings(backendUrl, patch)
const speedEnabled = overrides.speedEnabled ?? draftSpeedEnabled : await putCertificateRenewSettings(backendUrl, patch)
const internetPathEnabled = overrides.internetPathEnabled ?? draftInternetPathEnabled if (Object.entries(patch).some(([field, value]) => (data as Record<string, unknown>)[field] !== value)) throw new Error("Сервер подтвердил другие настройки. Обновите данные перед повторной попыткой")
const certRenewEnabled = overrides.certRenewEnabled ?? draftCertRenewEnabled 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 persistSchedulerDrafts = async () => {
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14) if (mutationLock.current || !reader.current.active) return
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300) const drafts: Record<SettingsKey, Record<string, string>> = {
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15) traffic: { intervalSec: trafficIntervalDraft, retentionDays: trafficRetentionDraft }, rest: { intervalSec: serversApiIntervalDraft },
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60) uptime: { intervalSec: uptimeResourceIntervalDraft, probeIntervalSec: uptimeIntervalDraft, speedIntervalSec: uptimeSpeedIntervalDraft, retentionDays: uptimeRetentionDraft },
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14) internet: { intervalSec: internetPathIntervalDraft }, cert: { intervalSec: certRenewIntervalDraft, renewBeforeDays: renewBeforeDaysDraft },
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 plan: { key: SettingsKey; patch: Record<string, number> }[] = []
settingsReady, try {
apiFetch, for (const key of Object.keys(drafts) as SettingsKey[]) {
certRenewIntervalDraft, if (!dirtyFields.current[key].size) continue
draftCertRenewEnabled, if (!writable(key)) throw new Error("Настройки не получены или недостаточно прав: " + SETTINGS_LABELS[key])
draftInternetPathEnabled, const patch: Record<string, number> = {}
draftPingEnabled, for (const field of dirtyFields.current[key]) {
draftResourcesEnabled, const raw = drafts[key][field]; const value = Number(raw)
draftServersApiEnabled, const min = field === "retentionDays" || field === "renewBeforeDays" ? 1 : key === "cert" ? 300 : key === "internet" ? 30 : key === "rest" || field === "speedIntervalSec" ? 10 : 5
draftSpeedEnabled, if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < min || (field === "renewBeforeDays" && value > 90)) throw new Error(SETTINGS_LABELS[key] + ": укажите целое значение от " + min + (field === "renewBeforeDays" ? " до 90" : ""))
draftTrafficEnabled, patch[field] = value
internetPathIntervalDraft, }
renewBeforeDaysDraft, plan.push({ key, patch })
serversApiIntervalDraft, }
trafficIntervalDraft, if (!plan.length) return
trafficRetentionDraft, mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
uptimeIntervalDraft, const successes: string[] = [], errors: string[] = []
uptimeResourceIntervalDraft, for (const { key, patch } of plan) {
uptimeRetentionDraft, if (!reader.current.active) break
uptimeSpeedIntervalDraft, 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) => { const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
if (!settingsReady) return const targets = {
const overrides: SchedulerToggleOverrides = {} traffic: ["traffic", "enabled", setDraftTrafficEnabled], servers_rest_ping: ["rest", "enabled", setDraftServersApiEnabled],
if (jobKey === "traffic") { uptime_resources: ["uptime", "resourcesEnabled", setDraftResourcesEnabled], uptime_ping: ["uptime", "pingEnabled", setDraftPingEnabled], uptime_speed: ["uptime", "speedEnabled", setDraftSpeedEnabled],
setDraftTrafficEnabled(nextEnabled) internet_path: ["internet", "enabled", setDraftInternetPathEnabled], certificates_renew: ["cert", "enabled", setDraftCertRenewEnabled],
overrides.trafficEnabled = nextEnabled } as const
} else if (jobKey === "servers_rest_ping") { if (!(jobKey in targets) || mutationLock.current || !reader.current.active) return
setDraftServersApiEnabled(nextEnabled) const [key, field, accept] = targets[jobKey as keyof typeof targets]
overrides.serversApiEnabled = nextEnabled mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
} 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)
try { try {
await persistSchedulerDrafts(overrides) await writeSettings(key, { [field]: nextEnabled })
await loadCollectors() if (!reader.current.active) return
} catch (e) { accept(nextEnabled); setSaveMessage("Сохранение подтверждено: " + SETTINGS_LABELS[key])
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить") } catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
} finally { finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
setSchedulerSaveBusy(false) }, [writeSettings])
}
}, [loadCollectors, persistSchedulerDrafts, settingsReady])
useEffect(() => { useEffect(() => {
if (!isLive) return if (!isLive) return
@@ -817,9 +743,10 @@ function DataCollectionContent() {
} }
} }
const schedulerGridRows = useMemo<SchedulerJobGridRow[]>(() => { const schedulerGridRows: SchedulerJobGridRow[] = (() => {
return SCHEDULER_JOB_KEYS.map((jobKey) => { return SCHEDULER_JOB_KEYS.map((jobKey) => {
const j = schedulerJobsByKey[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 fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
const enabled = fixedSchedule const enabled = fixedSchedule
? Boolean(j?.enabled ?? true) ? Boolean(j?.enabled ?? true)
@@ -882,7 +809,6 @@ function DataCollectionContent() {
? 21600 ? 21600
: 300 : 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 const settingsKnown = fixedSchedule ? Boolean(j) : settingsReads[settingsKey].known
return { return {
id: jobKey, id: jobKey,
@@ -894,7 +820,7 @@ function DataCollectionContent() {
enabled, enabled,
intervalValue: settingsKnown ? intervalValue : "", intervalValue: settingsKnown ? intervalValue : "",
intervalReadOnly: fixedSchedule, intervalReadOnly: fixedSchedule,
intervalDisabled: !settingsKnown || schedulerSaveBusy || (!enabled && !fixedSchedule), intervalDisabled: !settingsKnown || schedulerSaveBusy || (!fixedSchedule && !writable(settingsKey)),
defaultInterval, defaultInterval,
job: j, job: j,
onEnabledChange: fixedSchedule onEnabledChange: fixedSchedule
@@ -902,7 +828,7 @@ function DataCollectionContent() {
: (nextEnabled) => { : (nextEnabled) => {
void handleJobEnabledChange(jobKey, 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 () => { onRunNow: async () => {
setRunNowJobKey(jobKey) setRunNowJobKey(jobKey)
setCollectorError(null) setCollectorError(null)
@@ -918,33 +844,10 @@ function DataCollectionContent() {
} }
}, },
runNowLoading: runNowJobKey === jobKey, 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 enabledJobsCount = schedulerStatus?.jobs.filter(job => job.enabled).length ?? 0
const schedulerJobCount = schedulerStatus?.jobs.length ?? 0 const schedulerJobCount = schedulerStatus?.jobs.length ?? 0
@@ -997,13 +900,13 @@ function DataCollectionContent() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!isLive || pageBusy} disabled={!isLive || pageBusy || schedulerSaveBusy}
onClick={() => { void loadCollectors() }} onClick={() => { void loadCollectors() }}
> >
<RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} /> <RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} />
Обновить Обновить
</Button> </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> </Button>
</div> </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} {value.error ? <Alert variant="warning" className="mt-1"><AlertCircleIcon aria-hidden="true" /><AlertDescription>{value.error}{value.known ? ". Сохранены последние подтверждённые данные." : ""}</AlertDescription></Alert> : null}
</div> </div>
))} ))}
{isLive && saveMessage ? <p role="status" className="text-sm text-muted-foreground">{saveMessage}</p> : null}
{isLive && collectorError && ( {isLive && collectorError && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircleIcon /> <AlertCircleIcon />
<AlertTitle>Ошибка загрузки статуса коллекторов</AlertTitle> <AlertTitle>Операция не завершена</AlertTitle>
<AlertDescription>{collectorError}</AlertDescription> <AlertDescription>{collectorError}</AlertDescription>
</Alert> </Alert>
)} )}
@@ -1100,8 +1004,8 @@ function DataCollectionContent() {
<Input <Input
value={settingsReads.traffic.known ? trafficRetentionDraft : ""} value={settingsReads.traffic.known ? trafficRetentionDraft : ""}
aria-label="Хранение трафика" aria-label="Хранение трафика"
disabled={!settingsReads.traffic.known || schedulerSaveBusy} disabled={!writable("traffic") || schedulerSaveBusy}
onChange={(e) => { dirtySettings.current.add("traffic"); setTrafficRetentionDraft(e.target.value) }} onChange={(e) => { markDirty("traffic", "retentionDays"); setTrafficRetentionDraft(e.target.value) }}
className="h-8 text-sm" className="h-8 text-sm"
inputMode="numeric" inputMode="numeric"
/> />
@@ -1111,8 +1015,8 @@ function DataCollectionContent() {
<Input <Input
value={settingsReads.uptime.known ? uptimeRetentionDraft : ""} value={settingsReads.uptime.known ? uptimeRetentionDraft : ""}
aria-label="Хранение uptime" aria-label="Хранение uptime"
disabled={!settingsReads.uptime.known || schedulerSaveBusy} disabled={!writable("uptime") || schedulerSaveBusy}
onChange={(e) => { dirtySettings.current.add("uptime"); setUptimeRetentionDraft(e.target.value) }} onChange={(e) => { markDirty("uptime", "retentionDays"); setUptimeRetentionDraft(e.target.value) }}
className="h-8 text-sm" className="h-8 text-sm"
inputMode="numeric" inputMode="numeric"
/> />
@@ -1123,10 +1027,10 @@ function DataCollectionContent() {
value={settingsReads.cert.known ? renewBeforeDaysDraft : ""} value={settingsReads.cert.known ? renewBeforeDaysDraft : ""}
aria-label="Обновлять сертификаты за дней" 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" className="h-8 text-sm"
inputMode="numeric" inputMode="numeric"
disabled={!settingsReads.cert.known || !draftCertRenewEnabled || schedulerSaveBusy} disabled={!writable("cert") || schedulerSaveBusy}
/> />
<p className="text-[11px] text-muted-foreground mt-1"> <p className="text-[11px] text-muted-foreground mt-1">
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS. Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
@@ -1138,19 +1042,8 @@ function DataCollectionContent() {
</p> </p>
<Button <Button
size="sm" size="sm"
disabled={schedulerSaveBusy || !settingsReady} disabled={schedulerSaveBusy || dirtyCount === 0}
onClick={async () => { onClick={() => { void persistSchedulerDrafts() }}
setSchedulerSaveBusy(true)
setCollectorError(null)
try {
await persistSchedulerDrafts()
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setSchedulerSaveBusy(false)
}
}}
> >
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null} {schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
Сохранить настройки планировщика Сохранить настройки планировщика
@@ -1228,6 +1121,7 @@ function DataCollectionContent() {
id="run-filter" id="run-filter"
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[180px]" className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[180px]"
value={runFilterJobKey} value={runFilterJobKey}
disabled={schedulerSaveBusy}
onChange={(e) => { setRunFilterJobKey(e.target.value); setOpenRunIds(new Set()); setSchedulerRuns([]); setRunsKnown(false); setRunsError(null); setRunsReceivedAt(null) }} onChange={(e) => { setRunFilterJobKey(e.target.value); setOpenRunIds(new Set()); setSchedulerRuns([]); setRunsKnown(false); setRunsError(null); setRunsReceivedAt(null) }}
> >
<option value="">Все</option> <option value="">Все</option>
@@ -81,6 +81,7 @@ function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGr
<div className="text-center align-top"> <div className="text-center align-top">
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}> <span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
<FormToggle <FormToggle
ariaLabel={`Включить: ${row.original.label}`}
checked={enabled} checked={enabled}
disabled={fixedSchedule || saveBusy} disabled={fixedSchedule || saveBusy}
onChange={(v) => { 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/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) | | Контейнеры | [Обзор](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 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 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 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 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) ## Блок 11Б.3.3.6.1 — строгие PUT настроек (2026-10-03)
- Четыре settings PUT проверяют минимумы/целые числа до запроса и реальные backend receipt ok=true/settings после него. Каждый переданный параметр обязан совпасть с подтверждённым. Пустой patch/unknown fields/неверные числа отклоняются; timeout/ошибка/неверный receipt не вызывают повтор. - Четыре 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 не вызывают повтор. - Четыре settings PUT проверяют минимумы/целые числа до запроса и реальные backend receipt ok=true/settings после него. Каждый переданный параметр обязан совпасть с подтверждённым. Пустой patch/unknown fields/неверные числа отклоняются; timeout/ошибка/неверный receipt не вызывают повтор.
- Прошли 14 GET/PUT unit, ESLint и полный TypeScript. Первоначальный unit assertion уточнён под две формулировки подтверждения; повтор прошёл. Новый API пока не подключён к операциям страницы; certificate PUT переиспользуется с отдельной проверкой echo на следующем шаге. - Прошли 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: точечная запись 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("button", { name: "Обновить", exact: true })).toBeEnabled(); await expect(field).toHaveValue("99")
await expect(page.getByRole("textbox", { name: "Хранение трафика", exact: true })).toHaveValue("21") 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()
})