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

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

No files matched your search

+87 -118
View File
@@ -20,12 +20,12 @@ import {
SCHEDULER_JOB_DESCRIPTIONS,
SCHEDULER_JOB_KEYS,
SCHEDULER_JOB_LABELS,
type CollectorSettingsDto,
type SchedulerJobStatusDto,
type SchedulerStatusDto,
type SchedulerRunRowDto,
type UptimeSettingsDto,
} from "@/lib/scheduler-settings"
import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings } from "@/shared/api/collector-settings"
import { getCertificateRenewSettings } from "@/shared/api/certificates"
import { getSchedulerStatus, listSchedulerRuns } from "@/shared/api/scheduler"
import { requestJson, ApiClientError } from "@/shared/api/http-client"
import {
@@ -77,14 +77,11 @@ function makeApiFetch(backendUrl: string) {
function extractApiError(reason: unknown): string {
if (reason instanceof ApiClientError) return reason.message
if (reason instanceof Error && reason.name === "ZodError") return "Настройки не подтверждены: получен некорректный ответ сервера"
if (reason instanceof Error) return reason.message
return String(reason)
}
function readSettled<T>(result: PromiseSettledResult<T>): T | null {
return result.status === "fulfilled" ? result.value : null
}
function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels: string[]): string[] {
const errors: string[] = []
for (let i = 0; i < results.length; i += 1) {
@@ -532,27 +529,10 @@ type SchedulerToggleOverrides = {
certRenewEnabled?: boolean
}
function applySchedulerJobsToDrafts(
jobs: SchedulerJobStatusDto[],
setters: {
setDraftTrafficEnabled: (value: boolean) => void
setDraftServersApiEnabled: (value: boolean) => void
setDraftResourcesEnabled: (value: boolean) => void
setDraftPingEnabled: (value: boolean) => void
setDraftSpeedEnabled: (value: boolean) => void
setDraftInternetPathEnabled: (value: boolean) => void
setDraftCertRenewEnabled: (value: boolean) => void
},
) {
const byKey = Object.fromEntries(jobs.map((job) => [job.jobKey, job])) as Record<string, SchedulerJobStatusDto>
if (byKey.traffic) setters.setDraftTrafficEnabled(!!byKey.traffic.enabled)
if (byKey.servers_rest_ping) setters.setDraftServersApiEnabled(!!byKey.servers_rest_ping.enabled)
if (byKey.uptime_resources) setters.setDraftResourcesEnabled(!!byKey.uptime_resources.enabled)
if (byKey.uptime_ping) setters.setDraftPingEnabled(!!byKey.uptime_ping.enabled)
if (byKey.uptime_speed) setters.setDraftSpeedEnabled(!!byKey.uptime_speed.enabled)
if (byKey.internet_path) setters.setDraftInternetPathEnabled(!!byKey.internet_path.enabled)
if (byKey.certificates_renew) setters.setDraftCertRenewEnabled(!!byKey.certificates_renew.enabled)
}
const SETTINGS_LABELS = { traffic: "Трафик", rest: "Серверы REST API", uptime: "Uptime", internet: "Internet path", cert: "Сертификаты" } as const
type SettingsKey = keyof typeof SETTINGS_LABELS
type SettingsRead = { known: boolean; error: string | null; receivedAt: string | null }
const INITIAL_SETTINGS: Record<SettingsKey, SettingsRead> = Object.fromEntries(Object.keys(SETTINGS_LABELS).map(key => [key, { known: false, error: null, receivedAt: null }])) as Record<SettingsKey, SettingsRead>
export default function DataCollectionPage() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
@@ -565,10 +545,13 @@ function DataCollectionContent() {
const isLive = prefsHydrated && mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
const [serversApiCollector, setServersApiCollector] = useState<CollectorSettingsDto | null>(null)
const [uptimeCollector, setUptimeCollector] = useState<UptimeSettingsDto | null>(null)
const [internetPathCollector, setInternetPathCollector] = useState<CollectorSettingsDto | null>(null)
const [trafficCollector, setTrafficCollector] = useState<Awaited<ReturnType<typeof getTrafficCollectorSettings>> | null>(null)
const [serversApiCollector, setServersApiCollector] = useState<Awaited<ReturnType<typeof getServersApiPingSettings>> | null>(null)
const [uptimeCollector, setUptimeCollector] = useState<Awaited<ReturnType<typeof getUptimeCollectorSettings>> | null>(null)
const [internetPathCollector, setInternetPathCollector] = useState<Awaited<ReturnType<typeof getInternetPathSettings>> | null>(null)
const [settingsReads, setSettingsReads] = useState(INITIAL_SETTINGS)
const dirtySettings = useRef(new Set<SettingsKey>())
const settingsReady = Object.values(settingsReads).every(value => value.known && !value.error)
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
@@ -640,84 +623,51 @@ function DataCollectionContent() {
if (current()) { setSchedulerStatus(data); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
return data
}, error => { if (current()) setSchedulerStatusError(extractApiError(error)); throw error })
const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes, statusRes] = await Promise.allSettled([
apiFetch<CollectorSettingsDto>("/api/traffic/settings", { signal }),
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings", { signal }),
apiFetch<UptimeSettingsDto>("/api/uptime/settings", { signal }),
apiFetch<CollectorSettingsDto>("/api/internet-path/settings", { signal }),
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings", { signal }),
function publish<T>(key: SettingsKey, request: Promise<T>, accept: (data: T, updateDraft: boolean) => void) {
return request.then(data => {
if (current()) {
accept(data, !dirtySettings.current.has(key))
setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
}
}, error => {
if (current()) setSettingsReads(prev => ({ ...prev, [key]: { ...prev[key], error: extractApiError(error) } }))
})
}
await Promise.allSettled([
publish("traffic", getTrafficCollectorSettings(backendUrl, { signal }), (data, draft) => {
setTrafficCollector(data)
if (draft) { setTrafficIntervalDraft(String(data.intervalSec)); setTrafficRetentionDraft(String(data.retentionDays)); setDraftTrafficEnabled(data.enabled) }
}),
publish("rest", getServersApiPingSettings(backendUrl, { signal }), (data, draft) => {
setServersApiCollector(data)
if (draft) { setServersApiIntervalDraft(String(data.intervalSec)); setDraftServersApiEnabled(data.enabled) }
}),
publish("uptime", getUptimeCollectorSettings(backendUrl, { signal }), (data, draft) => {
setUptimeCollector(data)
if (draft) {
setUptimeResourceIntervalDraft(String(data.intervalSec)); setUptimeIntervalDraft(String(data.probeIntervalSec)); setUptimeSpeedIntervalDraft(String(data.speedIntervalSec)); setUptimeRetentionDraft(String(data.retentionDays))
setDraftResourcesEnabled(data.resourcesEnabled); setDraftPingEnabled(data.pingEnabled); setDraftSpeedEnabled(data.speedEnabled)
}
}),
publish("internet", getInternetPathSettings(backendUrl, { signal }), (data, draft) => {
setInternetPathCollector(data)
if (draft) { setInternetPathIntervalDraft(String(data.intervalSec)); setDraftInternetPathEnabled(data.enabled) }
}),
publish("cert", getCertificateRenewSettings(backendUrl, { signal }), (data, draft) => {
if (draft) { setCertRenewIntervalDraft(String(data.intervalSec)); setRenewBeforeDaysDraft(String(data.renewBeforeDays)); setDraftCertRenewEnabled(data.enabled) }
}),
runsRequest, statusRequest,
])
if (!current()) return
const loadErrors = collectSettledErrors(
[trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes, statusRes],
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика", "статус планировщика"],
)
if (loadErrors.length > 0) {
setCollectorError(loadErrors.join("; "))
}
const traffic = readSettled(trafficRes)
if (traffic) {
setTrafficCollector(traffic)
setTrafficIntervalDraft(String(traffic.intervalSec))
setTrafficRetentionDraft(String(traffic.retentionDays))
setDraftTrafficEnabled(!!traffic.enabled)
}
const serversApi = readSettled(serversApiRes)
if (serversApi) {
setServersApiCollector(serversApi)
setDraftServersApiEnabled(!!serversApi.enabled)
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
}
const uptime = readSettled(uptimeRes)
if (uptime) {
setUptimeCollector(uptime)
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
setUptimeRetentionDraft(String(uptime.retentionDays))
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
if (uptime.scheduler?.jobs?.length) {
applySchedulerJobsToDrafts(uptime.scheduler.jobs, {
setDraftTrafficEnabled,
setDraftServersApiEnabled,
setDraftResourcesEnabled,
setDraftPingEnabled,
setDraftSpeedEnabled,
setDraftInternetPathEnabled,
setDraftCertRenewEnabled,
})
}
}
const internetPath = readSettled(internetPathRes)
if (internetPath) {
setInternetPathCollector(internetPath)
setDraftInternetPathEnabled(!!internetPath.enabled)
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
}
const certRenew = readSettled(certRenewRes)
if (certRenew) {
setDraftCertRenewEnabled(!!certRenew.enabled)
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
}
} catch (e) {
if (current()) setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
} finally {
if (current()) setPageBusy(false)
}
}, [apiFetch, backendUrl, isLive, runFilterJobKey])
}, [backendUrl, isLive, runFilterJobKey])
const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => {
if (!settingsReady) throw new Error("Сначала получите подтверждённые настройки всех сборщиков")
const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled
const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled
const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled
@@ -790,6 +740,7 @@ function DataCollectionContent() {
throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
}
}, [
settingsReady,
apiFetch,
certRenewIntervalDraft,
draftCertRenewEnabled,
@@ -811,6 +762,7 @@ function DataCollectionContent() {
])
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
if (!settingsReady) return
const overrides: SchedulerToggleOverrides = {}
if (jobKey === "traffic") {
setDraftTrafficEnabled(nextEnabled)
@@ -847,7 +799,7 @@ function DataCollectionContent() {
} finally {
setSchedulerSaveBusy(false)
}
}, [loadCollectors, persistSchedulerDrafts])
}, [loadCollectors, persistSchedulerDrafts, settingsReady])
useEffect(() => {
if (!isLive) return
@@ -868,7 +820,7 @@ function DataCollectionContent() {
const schedulerGridRows = useMemo<SchedulerJobGridRow[]>(() => {
return SCHEDULER_JOB_KEYS.map((jobKey) => {
const j = schedulerJobsByKey[jobKey]
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
const enabled = fixedSchedule
? Boolean(j?.enabled ?? true)
: jobKey === "traffic"
@@ -930,16 +882,19 @@ function DataCollectionContent() {
? 21600
: 300
const settingsKey: SettingsKey = jobKey === "traffic" ? "traffic" : jobKey === "servers_rest_ping" ? "rest" : jobKey.startsWith("uptime_") ? "uptime" : jobKey === "certificates_renew" ? "cert" : "internet"
const settingsKnown = fixedSchedule ? Boolean(j) : settingsReads[settingsKey].known
return {
id: jobKey,
settingsKnown,
jobKey,
label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
fixedSchedule,
enabled,
intervalValue,
intervalValue: settingsKnown ? intervalValue : "",
intervalReadOnly: fixedSchedule,
intervalDisabled: !enabled && !fixedSchedule,
intervalDisabled: !settingsKnown || schedulerSaveBusy || (!enabled && !fixedSchedule),
defaultInterval,
job: j,
onEnabledChange: fixedSchedule
@@ -947,7 +902,7 @@ function DataCollectionContent() {
: (nextEnabled) => {
void handleJobEnabledChange(jobKey, nextEnabled)
},
onIntervalChange,
onIntervalChange: (value: string) => { dirtySettings.current.add(settingsKey); onIntervalChange(value) },
onRunNow: async () => {
setRunNowJobKey(jobKey)
setCollectorError(null)
@@ -963,10 +918,11 @@ function DataCollectionContent() {
}
},
runNowLoading: runNowJobKey === jobKey,
saveBusy: schedulerSaveBusy,
saveBusy: schedulerSaveBusy || !settingsReady,
}
})
}, [
settingsReady,
apiFetch,
certRenewIntervalDraft,
draftCertRenewEnabled,
@@ -980,6 +936,7 @@ function DataCollectionContent() {
internetPathIntervalDraft,
loadCollectors,
runNowJobKey,
settingsReads,
schedulerJobsByKey,
schedulerSaveBusy,
serversApiIntervalDraft,
@@ -1013,7 +970,7 @@ function DataCollectionContent() {
value: trafficCollector?.lastCollectedAt
? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" })
: "—",
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
sub: settingsReads.traffic.error ? "Последний сбор требует обновления" : trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
icon: trafficCollector?.lastError ? (
<XCircleIcon className="size-4" />
) : (
@@ -1084,6 +1041,12 @@ function DataCollectionContent() {
</Alert>
) : null}
{isLive && schedulerStatusReceivedAt ? <p className="text-xs text-muted-foreground">Статус получен: {new Date(schedulerStatusReceivedAt).toLocaleString("ru-RU")}</p> : null}
{isLive && Object.entries(settingsReads).map(([key, value]) => (
<div key={key} className="text-xs text-muted-foreground" data-testid={"settings-" + key}>
<p role="status">{SETTINGS_LABELS[key as SettingsKey]} — {value.known ? "настройки получены" : "настройки не получены"}{value.receivedAt ? " (получены): " + new Date(value.receivedAt).toLocaleString("ru-RU") : ""}</p>
{value.error ? <Alert variant="warning" className="mt-1"><AlertCircleIcon aria-hidden="true" /><AlertDescription>{value.error}{value.known ? ". Сохранены последние подтверждённые данные." : ""}</AlertDescription></Alert> : null}
</div>
))}
{isLive && collectorError && (
<Alert variant="destructive">
<AlertCircleIcon />
@@ -1135,8 +1098,10 @@ function DataCollectionContent() {
<div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
<Input
value={trafficRetentionDraft}
onChange={(e) => setTrafficRetentionDraft(e.target.value)}
value={settingsReads.traffic.known ? trafficRetentionDraft : ""}
aria-label="Хранение трафика"
disabled={!settingsReads.traffic.known || schedulerSaveBusy}
onChange={(e) => { dirtySettings.current.add("traffic"); setTrafficRetentionDraft(e.target.value) }}
className="h-8 text-sm"
inputMode="numeric"
/>
@@ -1144,8 +1109,10 @@ function DataCollectionContent() {
<div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов uptime (дней)</p>
<Input
value={uptimeRetentionDraft}
onChange={(e) => setUptimeRetentionDraft(e.target.value)}
value={settingsReads.uptime.known ? uptimeRetentionDraft : ""}
aria-label="Хранение uptime"
disabled={!settingsReads.uptime.known || schedulerSaveBusy}
onChange={(e) => { dirtySettings.current.add("uptime"); setUptimeRetentionDraft(e.target.value) }}
className="h-8 text-sm"
inputMode="numeric"
/>
@@ -1153,11 +1120,13 @@ function DataCollectionContent() {
<div>
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
<Input
value={renewBeforeDaysDraft}
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
value={settingsReads.cert.known ? renewBeforeDaysDraft : ""}
aria-label="Обновлять сертификаты за дней"
onChange={(e) => { dirtySettings.current.add("cert"); setRenewBeforeDaysDraft(e.target.value) }}
className="h-8 text-sm"
inputMode="numeric"
disabled={!draftCertRenewEnabled}
disabled={!settingsReads.cert.known || !draftCertRenewEnabled || schedulerSaveBusy}
/>
<p className="text-[11px] text-muted-foreground mt-1">
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
@@ -1169,7 +1138,7 @@ function DataCollectionContent() {
</p>
<Button
size="sm"
disabled={schedulerSaveBusy}
disabled={schedulerSaveBusy || !settingsReady}
onClick={async () => {
setSchedulerSaveBusy(true)
setCollectorError(null)
@@ -1200,7 +1169,7 @@ function DataCollectionContent() {
) : null}
</p>
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : "Трафик: ошибок нет"}
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : (settingsReads.traffic.known ? "Трафик: ошибки не записаны" : "Трафик: состояние неизвестно")}
</p>
<p>
REST API (серверы):{" "}
@@ -1217,7 +1186,7 @@ function DataCollectionContent() {
<p className={cn(serversApiCollector?.lastError ? "text-destructive" : "")}>
{serversApiCollector?.lastError
? `REST API (серверы): ${serversApiCollector.lastError}`
: "REST API (серверы): ошибок нет"}
: (settingsReads.rest.known ? "REST API (серверы): ошибки не записаны" : "REST API (серверы): состояние неизвестно")}
</p>
<p>
Uptime (агрегат в настройках):{" "}
@@ -1227,7 +1196,7 @@ function DataCollectionContent() {
· {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}
</p>
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : "Uptime: ошибок нет"}
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : (settingsReads.uptime.known ? "Uptime: ошибки не записаны" : "Uptime: состояние неизвестно")}
</p>
<p>
Internet Path snapshot:{" "}
@@ -1239,7 +1208,7 @@ function DataCollectionContent() {
<p className={cn(internetPathCollector?.lastError ? "text-destructive" : "")}>
{internetPathCollector?.lastError
? `Internet Path: ${internetPathCollector.lastError}`
: "Internet Path: ошибок нет"}
: (settingsReads.internet.known ? "Internet Path: ошибки не записаны" : "Internet Path: состояние неизвестно")}
</p>
</div>
</div>