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>