fix(collectors): разделить загрузку настроек и сохранить черновики
This commit is contained in:
1 parent
dd387cf379
commit
d453f2aec3
6 files changed
+169
-122
No files matched your search
@@ -20,12 +20,12 @@ import {
|
||||
SCHEDULER_JOB_DESCRIPTIONS,
|
||||
SCHEDULER_JOB_KEYS,
|
||||
SCHEDULER_JOB_LABELS,
|
||||
type CollectorSettingsDto,
|
||||
type SchedulerJobStatusDto,
|
||||
type SchedulerStatusDto,
|
||||
type SchedulerRunRowDto,
|
||||
type UptimeSettingsDto,
|
||||
} from "@/lib/scheduler-settings"
|
||||
import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings } from "@/shared/api/collector-settings"
|
||||
import { getCertificateRenewSettings } from "@/shared/api/certificates"
|
||||
import { getSchedulerStatus, listSchedulerRuns } from "@/shared/api/scheduler"
|
||||
import { requestJson, ApiClientError } from "@/shared/api/http-client"
|
||||
import {
|
||||
@@ -77,14 +77,11 @@ function makeApiFetch(backendUrl: string) {
|
||||
|
||||
function extractApiError(reason: unknown): string {
|
||||
if (reason instanceof ApiClientError) return reason.message
|
||||
if (reason instanceof Error && reason.name === "ZodError") return "Настройки не подтверждены: получен некорректный ответ сервера"
|
||||
if (reason instanceof Error) return reason.message
|
||||
return String(reason)
|
||||
}
|
||||
|
||||
function readSettled<T>(result: PromiseSettledResult<T>): T | null {
|
||||
return result.status === "fulfilled" ? result.value : null
|
||||
}
|
||||
|
||||
function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels: string[]): string[] {
|
||||
const errors: string[] = []
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
@@ -532,27 +529,10 @@ type SchedulerToggleOverrides = {
|
||||
certRenewEnabled?: boolean
|
||||
}
|
||||
|
||||
function applySchedulerJobsToDrafts(
|
||||
jobs: SchedulerJobStatusDto[],
|
||||
setters: {
|
||||
setDraftTrafficEnabled: (value: boolean) => void
|
||||
setDraftServersApiEnabled: (value: boolean) => void
|
||||
setDraftResourcesEnabled: (value: boolean) => void
|
||||
setDraftPingEnabled: (value: boolean) => void
|
||||
setDraftSpeedEnabled: (value: boolean) => void
|
||||
setDraftInternetPathEnabled: (value: boolean) => void
|
||||
setDraftCertRenewEnabled: (value: boolean) => void
|
||||
},
|
||||
) {
|
||||
const byKey = Object.fromEntries(jobs.map((job) => [job.jobKey, job])) as Record<string, SchedulerJobStatusDto>
|
||||
if (byKey.traffic) setters.setDraftTrafficEnabled(!!byKey.traffic.enabled)
|
||||
if (byKey.servers_rest_ping) setters.setDraftServersApiEnabled(!!byKey.servers_rest_ping.enabled)
|
||||
if (byKey.uptime_resources) setters.setDraftResourcesEnabled(!!byKey.uptime_resources.enabled)
|
||||
if (byKey.uptime_ping) setters.setDraftPingEnabled(!!byKey.uptime_ping.enabled)
|
||||
if (byKey.uptime_speed) setters.setDraftSpeedEnabled(!!byKey.uptime_speed.enabled)
|
||||
if (byKey.internet_path) setters.setDraftInternetPathEnabled(!!byKey.internet_path.enabled)
|
||||
if (byKey.certificates_renew) setters.setDraftCertRenewEnabled(!!byKey.certificates_renew.enabled)
|
||||
}
|
||||
const SETTINGS_LABELS = { traffic: "Трафик", rest: "Серверы REST API", uptime: "Uptime", internet: "Internet path", cert: "Сертификаты" } as const
|
||||
type SettingsKey = keyof typeof SETTINGS_LABELS
|
||||
type SettingsRead = { known: boolean; error: string | null; receivedAt: string | null }
|
||||
const INITIAL_SETTINGS: Record<SettingsKey, SettingsRead> = Object.fromEntries(Object.keys(SETTINGS_LABELS).map(key => [key, { known: false, error: null, receivedAt: null }])) as Record<SettingsKey, SettingsRead>
|
||||
|
||||
export default function DataCollectionPage() {
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
@@ -565,10 +545,13 @@ function DataCollectionContent() {
|
||||
const isLive = prefsHydrated && mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
||||
const [serversApiCollector, setServersApiCollector] = useState<CollectorSettingsDto | null>(null)
|
||||
const [uptimeCollector, setUptimeCollector] = useState<UptimeSettingsDto | null>(null)
|
||||
const [internetPathCollector, setInternetPathCollector] = useState<CollectorSettingsDto | null>(null)
|
||||
const [trafficCollector, setTrafficCollector] = useState<Awaited<ReturnType<typeof getTrafficCollectorSettings>> | null>(null)
|
||||
const [serversApiCollector, setServersApiCollector] = useState<Awaited<ReturnType<typeof getServersApiPingSettings>> | null>(null)
|
||||
const [uptimeCollector, setUptimeCollector] = useState<Awaited<ReturnType<typeof getUptimeCollectorSettings>> | null>(null)
|
||||
const [internetPathCollector, setInternetPathCollector] = useState<Awaited<ReturnType<typeof getInternetPathSettings>> | null>(null)
|
||||
const [settingsReads, setSettingsReads] = useState(INITIAL_SETTINGS)
|
||||
const dirtySettings = useRef(new Set<SettingsKey>())
|
||||
const settingsReady = Object.values(settingsReads).every(value => value.known && !value.error)
|
||||
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
|
||||
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
|
||||
const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
|
||||
@@ -640,84 +623,51 @@ function DataCollectionContent() {
|
||||
if (current()) { setSchedulerStatus(data); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
|
||||
return data
|
||||
}, error => { if (current()) setSchedulerStatusError(extractApiError(error)); throw error })
|
||||
const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes, statusRes] = await Promise.allSettled([
|
||||
apiFetch<CollectorSettingsDto>("/api/traffic/settings", { signal }),
|
||||
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings", { signal }),
|
||||
apiFetch<UptimeSettingsDto>("/api/uptime/settings", { signal }),
|
||||
apiFetch<CollectorSettingsDto>("/api/internet-path/settings", { signal }),
|
||||
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings", { signal }),
|
||||
function publish<T>(key: SettingsKey, request: Promise<T>, accept: (data: T, updateDraft: boolean) => void) {
|
||||
return request.then(data => {
|
||||
if (current()) {
|
||||
accept(data, !dirtySettings.current.has(key))
|
||||
setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
|
||||
}
|
||||
}, error => {
|
||||
if (current()) setSettingsReads(prev => ({ ...prev, [key]: { ...prev[key], error: extractApiError(error) } }))
|
||||
})
|
||||
}
|
||||
await Promise.allSettled([
|
||||
publish("traffic", getTrafficCollectorSettings(backendUrl, { signal }), (data, draft) => {
|
||||
setTrafficCollector(data)
|
||||
if (draft) { setTrafficIntervalDraft(String(data.intervalSec)); setTrafficRetentionDraft(String(data.retentionDays)); setDraftTrafficEnabled(data.enabled) }
|
||||
}),
|
||||
publish("rest", getServersApiPingSettings(backendUrl, { signal }), (data, draft) => {
|
||||
setServersApiCollector(data)
|
||||
if (draft) { setServersApiIntervalDraft(String(data.intervalSec)); setDraftServersApiEnabled(data.enabled) }
|
||||
}),
|
||||
publish("uptime", getUptimeCollectorSettings(backendUrl, { signal }), (data, draft) => {
|
||||
setUptimeCollector(data)
|
||||
if (draft) {
|
||||
setUptimeResourceIntervalDraft(String(data.intervalSec)); setUptimeIntervalDraft(String(data.probeIntervalSec)); setUptimeSpeedIntervalDraft(String(data.speedIntervalSec)); setUptimeRetentionDraft(String(data.retentionDays))
|
||||
setDraftResourcesEnabled(data.resourcesEnabled); setDraftPingEnabled(data.pingEnabled); setDraftSpeedEnabled(data.speedEnabled)
|
||||
}
|
||||
}),
|
||||
publish("internet", getInternetPathSettings(backendUrl, { signal }), (data, draft) => {
|
||||
setInternetPathCollector(data)
|
||||
if (draft) { setInternetPathIntervalDraft(String(data.intervalSec)); setDraftInternetPathEnabled(data.enabled) }
|
||||
}),
|
||||
publish("cert", getCertificateRenewSettings(backendUrl, { signal }), (data, draft) => {
|
||||
if (draft) { setCertRenewIntervalDraft(String(data.intervalSec)); setRenewBeforeDaysDraft(String(data.renewBeforeDays)); setDraftCertRenewEnabled(data.enabled) }
|
||||
}),
|
||||
runsRequest, statusRequest,
|
||||
])
|
||||
|
||||
if (!current()) return
|
||||
const loadErrors = collectSettledErrors(
|
||||
[trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes, statusRes],
|
||||
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика", "статус планировщика"],
|
||||
)
|
||||
if (loadErrors.length > 0) {
|
||||
setCollectorError(loadErrors.join("; "))
|
||||
}
|
||||
|
||||
const traffic = readSettled(trafficRes)
|
||||
if (traffic) {
|
||||
setTrafficCollector(traffic)
|
||||
setTrafficIntervalDraft(String(traffic.intervalSec))
|
||||
setTrafficRetentionDraft(String(traffic.retentionDays))
|
||||
setDraftTrafficEnabled(!!traffic.enabled)
|
||||
}
|
||||
|
||||
const serversApi = readSettled(serversApiRes)
|
||||
if (serversApi) {
|
||||
setServersApiCollector(serversApi)
|
||||
setDraftServersApiEnabled(!!serversApi.enabled)
|
||||
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
|
||||
}
|
||||
|
||||
const uptime = readSettled(uptimeRes)
|
||||
if (uptime) {
|
||||
setUptimeCollector(uptime)
|
||||
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
|
||||
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
|
||||
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
|
||||
setUptimeRetentionDraft(String(uptime.retentionDays))
|
||||
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
|
||||
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
|
||||
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
|
||||
if (uptime.scheduler?.jobs?.length) {
|
||||
applySchedulerJobsToDrafts(uptime.scheduler.jobs, {
|
||||
setDraftTrafficEnabled,
|
||||
setDraftServersApiEnabled,
|
||||
setDraftResourcesEnabled,
|
||||
setDraftPingEnabled,
|
||||
setDraftSpeedEnabled,
|
||||
setDraftInternetPathEnabled,
|
||||
setDraftCertRenewEnabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const internetPath = readSettled(internetPathRes)
|
||||
if (internetPath) {
|
||||
setInternetPathCollector(internetPath)
|
||||
setDraftInternetPathEnabled(!!internetPath.enabled)
|
||||
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
|
||||
}
|
||||
|
||||
const certRenew = readSettled(certRenewRes)
|
||||
if (certRenew) {
|
||||
setDraftCertRenewEnabled(!!certRenew.enabled)
|
||||
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
|
||||
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (current()) setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
||||
} finally {
|
||||
if (current()) setPageBusy(false)
|
||||
}
|
||||
}, [apiFetch, backendUrl, isLive, runFilterJobKey])
|
||||
}, [backendUrl, isLive, runFilterJobKey])
|
||||
|
||||
const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => {
|
||||
if (!settingsReady) throw new Error("Сначала получите подтверждённые настройки всех сборщиков")
|
||||
const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled
|
||||
const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled
|
||||
const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled
|
||||
@@ -790,6 +740,7 @@ function DataCollectionContent() {
|
||||
throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
|
||||
}
|
||||
}, [
|
||||
settingsReady,
|
||||
apiFetch,
|
||||
certRenewIntervalDraft,
|
||||
draftCertRenewEnabled,
|
||||
@@ -811,6 +762,7 @@ function DataCollectionContent() {
|
||||
])
|
||||
|
||||
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
|
||||
if (!settingsReady) return
|
||||
const overrides: SchedulerToggleOverrides = {}
|
||||
if (jobKey === "traffic") {
|
||||
setDraftTrafficEnabled(nextEnabled)
|
||||
@@ -847,7 +799,7 @@ function DataCollectionContent() {
|
||||
} finally {
|
||||
setSchedulerSaveBusy(false)
|
||||
}
|
||||
}, [loadCollectors, persistSchedulerDrafts])
|
||||
}, [loadCollectors, persistSchedulerDrafts, settingsReady])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
@@ -868,7 +820,7 @@ function DataCollectionContent() {
|
||||
const schedulerGridRows = useMemo<SchedulerJobGridRow[]>(() => {
|
||||
return SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||||
const j = schedulerJobsByKey[jobKey]
|
||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
|
||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
|
||||
const enabled = fixedSchedule
|
||||
? Boolean(j?.enabled ?? true)
|
||||
: jobKey === "traffic"
|
||||
@@ -930,16 +882,19 @@ function DataCollectionContent() {
|
||||
? 21600
|
||||
: 300
|
||||
|
||||
const settingsKey: SettingsKey = jobKey === "traffic" ? "traffic" : jobKey === "servers_rest_ping" ? "rest" : jobKey.startsWith("uptime_") ? "uptime" : jobKey === "certificates_renew" ? "cert" : "internet"
|
||||
const settingsKnown = fixedSchedule ? Boolean(j) : settingsReads[settingsKey].known
|
||||
return {
|
||||
id: jobKey,
|
||||
settingsKnown,
|
||||
jobKey,
|
||||
label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
|
||||
description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
|
||||
fixedSchedule,
|
||||
enabled,
|
||||
intervalValue,
|
||||
intervalValue: settingsKnown ? intervalValue : "",
|
||||
intervalReadOnly: fixedSchedule,
|
||||
intervalDisabled: !enabled && !fixedSchedule,
|
||||
intervalDisabled: !settingsKnown || schedulerSaveBusy || (!enabled && !fixedSchedule),
|
||||
defaultInterval,
|
||||
job: j,
|
||||
onEnabledChange: fixedSchedule
|
||||
@@ -947,7 +902,7 @@ function DataCollectionContent() {
|
||||
: (nextEnabled) => {
|
||||
void handleJobEnabledChange(jobKey, nextEnabled)
|
||||
},
|
||||
onIntervalChange,
|
||||
onIntervalChange: (value: string) => { dirtySettings.current.add(settingsKey); onIntervalChange(value) },
|
||||
onRunNow: async () => {
|
||||
setRunNowJobKey(jobKey)
|
||||
setCollectorError(null)
|
||||
@@ -963,10 +918,11 @@ function DataCollectionContent() {
|
||||
}
|
||||
},
|
||||
runNowLoading: runNowJobKey === jobKey,
|
||||
saveBusy: schedulerSaveBusy,
|
||||
saveBusy: schedulerSaveBusy || !settingsReady,
|
||||
}
|
||||
})
|
||||
}, [
|
||||
settingsReady,
|
||||
apiFetch,
|
||||
certRenewIntervalDraft,
|
||||
draftCertRenewEnabled,
|
||||
@@ -980,6 +936,7 @@ function DataCollectionContent() {
|
||||
internetPathIntervalDraft,
|
||||
loadCollectors,
|
||||
runNowJobKey,
|
||||
settingsReads,
|
||||
schedulerJobsByKey,
|
||||
schedulerSaveBusy,
|
||||
serversApiIntervalDraft,
|
||||
@@ -1013,7 +970,7 @@ function DataCollectionContent() {
|
||||
value: trafficCollector?.lastCollectedAt
|
||||
? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" })
|
||||
: "—",
|
||||
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
||||
sub: settingsReads.traffic.error ? "Последний сбор требует обновления" : trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
||||
icon: trafficCollector?.lastError ? (
|
||||
<XCircleIcon className="size-4" />
|
||||
) : (
|
||||
@@ -1084,6 +1041,12 @@ function DataCollectionContent() {
|
||||
</Alert>
|
||||
) : null}
|
||||
{isLive && schedulerStatusReceivedAt ? <p className="text-xs text-muted-foreground">Статус получен: {new Date(schedulerStatusReceivedAt).toLocaleString("ru-RU")}</p> : null}
|
||||
{isLive && Object.entries(settingsReads).map(([key, value]) => (
|
||||
<div key={key} className="text-xs text-muted-foreground" data-testid={"settings-" + key}>
|
||||
<p role="status">{SETTINGS_LABELS[key as SettingsKey]} — {value.known ? "настройки получены" : "настройки не получены"}{value.receivedAt ? " (получены): " + new Date(value.receivedAt).toLocaleString("ru-RU") : ""}</p>
|
||||
{value.error ? <Alert variant="warning" className="mt-1"><AlertCircleIcon aria-hidden="true" /><AlertDescription>{value.error}{value.known ? ". Сохранены последние подтверждённые данные." : ""}</AlertDescription></Alert> : null}
|
||||
</div>
|
||||
))}
|
||||
{isLive && collectorError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon />
|
||||
@@ -1135,8 +1098,10 @@ function DataCollectionContent() {
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
||||
<Input
|
||||
value={trafficRetentionDraft}
|
||||
onChange={(e) => setTrafficRetentionDraft(e.target.value)}
|
||||
value={settingsReads.traffic.known ? trafficRetentionDraft : ""}
|
||||
aria-label="Хранение трафика"
|
||||
disabled={!settingsReads.traffic.known || schedulerSaveBusy}
|
||||
onChange={(e) => { dirtySettings.current.add("traffic"); setTrafficRetentionDraft(e.target.value) }}
|
||||
className="h-8 text-sm"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
@@ -1144,8 +1109,10 @@ function DataCollectionContent() {
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов uptime (дней)</p>
|
||||
<Input
|
||||
value={uptimeRetentionDraft}
|
||||
onChange={(e) => setUptimeRetentionDraft(e.target.value)}
|
||||
value={settingsReads.uptime.known ? uptimeRetentionDraft : ""}
|
||||
aria-label="Хранение uptime"
|
||||
disabled={!settingsReads.uptime.known || schedulerSaveBusy}
|
||||
onChange={(e) => { dirtySettings.current.add("uptime"); setUptimeRetentionDraft(e.target.value) }}
|
||||
className="h-8 text-sm"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
@@ -1153,11 +1120,13 @@ function DataCollectionContent() {
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
|
||||
<Input
|
||||
value={renewBeforeDaysDraft}
|
||||
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
||||
value={settingsReads.cert.known ? renewBeforeDaysDraft : ""}
|
||||
aria-label="Обновлять сертификаты за дней"
|
||||
|
||||
onChange={(e) => { dirtySettings.current.add("cert"); setRenewBeforeDaysDraft(e.target.value) }}
|
||||
className="h-8 text-sm"
|
||||
inputMode="numeric"
|
||||
disabled={!draftCertRenewEnabled}
|
||||
disabled={!settingsReads.cert.known || !draftCertRenewEnabled || schedulerSaveBusy}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
|
||||
@@ -1169,7 +1138,7 @@ function DataCollectionContent() {
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={schedulerSaveBusy}
|
||||
disabled={schedulerSaveBusy || !settingsReady}
|
||||
onClick={async () => {
|
||||
setSchedulerSaveBusy(true)
|
||||
setCollectorError(null)
|
||||
@@ -1200,7 +1169,7 @@ function DataCollectionContent() {
|
||||
) : null}
|
||||
</p>
|
||||
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>
|
||||
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : "Трафик: ошибок нет"}
|
||||
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : (settingsReads.traffic.known ? "Трафик: ошибки не записаны" : "Трафик: состояние неизвестно")}
|
||||
</p>
|
||||
<p>
|
||||
REST API (серверы):{" "}
|
||||
@@ -1217,7 +1186,7 @@ function DataCollectionContent() {
|
||||
<p className={cn(serversApiCollector?.lastError ? "text-destructive" : "")}>
|
||||
{serversApiCollector?.lastError
|
||||
? `REST API (серверы): ${serversApiCollector.lastError}`
|
||||
: "REST API (серверы): ошибок нет"}
|
||||
: (settingsReads.rest.known ? "REST API (серверы): ошибки не записаны" : "REST API (серверы): состояние неизвестно")}
|
||||
</p>
|
||||
<p>
|
||||
Uptime (агрегат в настройках):{" "}
|
||||
@@ -1227,7 +1196,7 @@ function DataCollectionContent() {
|
||||
· {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}
|
||||
</p>
|
||||
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>
|
||||
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : "Uptime: ошибок нет"}
|
||||
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : (settingsReads.uptime.known ? "Uptime: ошибки не записаны" : "Uptime: состояние неизвестно")}
|
||||
</p>
|
||||
<p>
|
||||
Internet Path snapshot:{" "}
|
||||
@@ -1239,7 +1208,7 @@ function DataCollectionContent() {
|
||||
<p className={cn(internetPathCollector?.lastError ? "text-destructive" : "")}>
|
||||
{internetPathCollector?.lastError
|
||||
? `Internet Path: ${internetPathCollector.lastError}`
|
||||
: "Internet Path: ошибок нет"}
|
||||
: (settingsReads.internet.known ? "Internet Path: ошибки не записаны" : "Internet Path: состояние неизвестно")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface SchedulerJobGridRow {
|
||||
description: string
|
||||
fixedSchedule: boolean
|
||||
enabled: boolean
|
||||
settingsKnown: boolean
|
||||
intervalValue: string
|
||||
intervalReadOnly: boolean
|
||||
intervalDisabled: boolean
|
||||
@@ -74,7 +75,8 @@ function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGr
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const { fixedSchedule, enabled, saveBusy, onEnabledChange } = row.original
|
||||
const { fixedSchedule, enabled, saveBusy, onEnabledChange, settingsKnown } = row.original
|
||||
if (!settingsKnown) return <span className="text-muted-foreground" aria-label="Настройки не получены">—</span>
|
||||
return (
|
||||
<div className="text-center align-top">
|
||||
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
||||
@@ -109,7 +111,8 @@ function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGr
|
||||
inputMode="numeric"
|
||||
readOnly={r.intervalReadOnly}
|
||||
disabled={r.intervalDisabled}
|
||||
placeholder={String(r.defaultInterval)}
|
||||
placeholder={r.settingsKnown ? String(r.defaultInterval) : "—"}
|
||||
aria-label={`Интервал: ${r.label}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+1
-1
@@ -226,7 +226,7 @@ REST запрос → ожидание → подтверждённый output /
|
||||
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
|
||||
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
|
||||
|
||||
Каталог Evolution защищён от неполного HTTP-ответа в 11Б.3.3.4.1; Подтверждение testConnection исправлено в 11Б.3.3.4.2; состояния интеграции остаются в объёме 11Б. Актуальная точка продолжения: 11Б.3.3.5 — подключение strict settings readers, независимые known/error/time и dirty drafts. API основа проверена в 11Б.3.3.4; пока не подключена к странице. Независимые scheduler status/runs и достоверные KPI приняты в 11Б.3.3.3. Strict history/unknown/свежесть приняты в 11Б.3.3.2. Контекст/abort/revision и исходный lint исправлены в 11Б.3.3.1; далее независимые readers/dirty states и операции. GeoIP API принят в 11Б.3.2. Scheduler API основа проверена в 11Б.3.1, пока не подключена к странице. Совместимость JSONB/legacy snapshot и foreign guard проверены в 11Б.2; исходные lint/context ограничения страницы описаны в журнале. Общий runner исправлен в 11Б.1; карта оставшегося этапа — [11Б](ux-ui-stage-11b.md). Этап 11А принят локально в 11А.33: backups 71 основных + 28 source E2E, 27 frontend unit, backend профиль и production build. Backup scheduler cleanup проверен в 11А.32; общий dispatcher/journal status исправлен в 11Б.1, реальные интеграции остаются внешней приёмкой. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А приняты локально в 11А.33; внешние ограничения — журнал.
|
||||
Каталог Evolution защищён от неполного HTTP-ответа в 11Б.3.3.4.1; Подтверждение testConnection исправлено в 11Б.3.3.4.2; состояния интеграции остаются в объёме 11Б. Актуальная точка продолжения: 11Б.3.3.6 — точечные операции настроек, строгие receipts и ввод, locks/права и подтверждённый сброс dirty. Независимые strict settings readers и защита черновиков подключены в 11Б.3.3.5; реальные интеграции остаются внешней приёмкой. Независимые scheduler status/runs и достоверные KPI приняты в 11Б.3.3.3. Strict history/unknown/свежесть приняты в 11Б.3.3.2. Контекст/abort/revision и исходный lint исправлены в 11Б.3.3.1; далее независимые readers/dirty states и операции. GeoIP API принят в 11Б.3.2. Scheduler API основа проверена в 11Б.3.1, пока не подключена к странице. Совместимость JSONB/legacy snapshot и foreign guard проверены в 11Б.2; исходные lint/context ограничения страницы описаны в журнале. Общий runner исправлен в 11Б.1; карта оставшегося этапа — [11Б](ux-ui-stage-11b.md). Этап 11А принят локально в 11А.33: backups 71 основных + 28 source E2E, 27 frontend unit, backend профиль и production build. Backup scheduler cleanup проверен в 11А.32; общий dispatcher/journal status исправлен в 11Б.1, реальные интеграции остаются внешней приёмкой. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А приняты локально в 11А.33; внешние ограничения — журнал.
|
||||
|
||||
| Экран | ПК | Телефон |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.3.4.2; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.3.5; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11Б.3.3.5 — независимые настройки и черновики (2026-10-03)
|
||||
|
||||
- Подключены четыре строгих settings reader и существующий certificate reader. У каждого источника независимые known/error/browser receivedAt; медленный uptime не задерживает остальные настройки. Ошибка сохраняет последние подтверждённые данные и время, неполный DTO не подменяется defaults.
|
||||
- Изменённые пользователем интервалы и дополнительные поля сохраняются при обновлении. Состояние источника сбрасывается существующим remount; abort/revision защищают поздние ответы. Uptime scheduler metadata больше не переписывает чужие переключатели. REST ping использует собственный DTO без выдуманного retention.
|
||||
- Неизвестные настройки показывают —/пустые отключённые поля, доступность записи пока консервативно зависит от всех пяти подтверждённых источников. GeoIP расписание отмечено внешне управляемым через NetFlow. Формат/композиция установленной Data Grid сохранены; async metadata объявляется role=status.
|
||||
- Прошли 15 профильных E2E (4 новых settings + история/status/race), ESLint, полный TypeScript и production frontend/contracts build. ReUI audit checklist получен; изменения затрагивают данные/состояния, vendor props и тема не менялись. Реальные DB/RouterOS/ACME не проверены.
|
||||
- Следующий блок 11Б.3.3.6: строгие mutation receipts/проверка ввода, точечные PUT вместо записи всех настроек переключателем, синхронные locks/права и сброс dirty только по подтверждённому результату. Полная приёмка 11Б ещё впереди.
|
||||
|
||||
## Блок 11Б.3.3.4.2 — подтверждение проверки EvoBGP (2026-10-01)
|
||||
|
||||
- testConnection проверяет явное ok=true: HTTP 200 с false, пустым или некорректным receipt не становится сообщением об установленном соединении. Ошибка возвращается существующей форме, без повторного POST.
|
||||
|
||||
@@ -117,3 +117,11 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
|
||||
- Прошли unit для true/false/missing/null/неверных типов, ESLint изменённых файлов и полный TypeScript. Два профильных браузерных теста прошли; после расширения новый тест повторно проверил false → ручная повторная проверка → true и ровно один запрос на каждое нажатие. HTTP-подмена не доказывает соединение с реальным Portal.
|
||||
- Полная production сборка прошла в предыдущем блоке 11Б.3.3.4.1; текущая маленькая правка проверена TypeScript и браузером. Общие settings/RBAC/dirty/context и сохранение последнего каталога остаются впереди. Основная точка продолжения — 11Б.3.3.5.
|
||||
- Блок сохранён отдельным локальным коммитом без push. Процент недельной квоты округлён до 100%, ordinaryUsageAllowed при начале блока оставался true.
|
||||
|
||||
## Блок 11Б.3.3.5 — независимые настройки и черновики (2026-10-03)
|
||||
|
||||
- Подключены четыре строгих settings reader и существующий certificate reader. У каждого источника независимые known/error/browser receivedAt; медленный uptime не задерживает остальные настройки. Ошибка сохраняет последние подтверждённые данные и время, неполный DTO не подменяется defaults.
|
||||
- Изменённые пользователем интервалы и дополнительные поля сохраняются при обновлении. Состояние источника сбрасывается существующим remount; abort/revision защищают поздние ответы. Uptime scheduler metadata больше не переписывает чужие переключатели. REST ping использует собственный DTO без выдуманного retention.
|
||||
- Неизвестные настройки показывают —/пустые отключённые поля, доступность записи пока консервативно зависит от всех пяти подтверждённых источников. GeoIP расписание отмечено внешне управляемым через NetFlow. Формат/композиция установленной Data Grid сохранены; async metadata объявляется role=status.
|
||||
- Прошли 15 профильных E2E (4 новых settings + история/status/race), ESLint, полный TypeScript и production frontend/contracts build. ReUI audit checklist получен; изменения затрагивают данные/состояния, vendor props и тема не менялись. Реальные DB/RouterOS/ACME не проверены.
|
||||
- Следующий блок 11Б.3.3.6: строгие mutation receipts/проверка ввода, точечные PUT вместо записи всех настроек переключателем, синхронные locks/права и сброс dirty только по подтверждённому результату. Полная приёмка 11Б ещё впереди.
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test, expect, type Page } from "@playwright/test"
|
||||
import { geoip } from "./fixtures/geoip"
|
||||
const common = { enabled: true, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: 0, lastError: null }
|
||||
const values: Record<string, object> = {
|
||||
"/api/traffic/settings": { ...common, intervalSec: 45 },
|
||||
"/api/servers-api-ping/settings": { ...common, enabled: false, intervalSec: 120 },
|
||||
"/api/uptime/settings": { ...common, resourcesEnabled: true, pingEnabled: false, speedEnabled: false, probeIntervalSec: 15, speedIntervalSec: 60 },
|
||||
"/api/internet-path/settings": common,
|
||||
"/api/certificates/renew-settings": { enabled: false, intervalSec: 21600, renewBeforeDays: 30 },
|
||||
}
|
||||
async function setup(page: Page) {
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", r => {
|
||||
const path = new URL(r.request().url()).pathname
|
||||
if (path === "/api/auth/config") return r.fulfill({ json: { required: false } })
|
||||
if (path === "/api/geoip") return r.fulfill({ json: geoip })
|
||||
if (path === "/api/scheduler/status") return r.fulfill({ json: { jobs: [] } })
|
||||
if (path === "/api/scheduler/runs") return r.fulfill({ json: { runs: [] } })
|
||||
if (values[path]) return r.fulfill({ json: values[path] })
|
||||
return r.fulfill({ status: 503, json: { error: "outside settings fixture" } })
|
||||
})
|
||||
}
|
||||
test("Настройки: неполный DTO не включает defaults и не разрешает запись", async ({ page }) => {
|
||||
await setup(page); await page.route("**/api/traffic/settings", r => r.fulfill({ json: {} }))
|
||||
await page.goto("/data-collection")
|
||||
await expect(page.getByTestId("settings-traffic")).toContainText("Настройки сборщика не подтверждены")
|
||||
await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toHaveValue("")
|
||||
await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Сохранить настройки планировщика" })).toBeDisabled()
|
||||
await expect(page.getByTestId("settings-rest")).toContainText("настройки получены")
|
||||
})
|
||||
test("Настройки: медленный uptime не задерживает трафик и REST", async ({ page }) => {
|
||||
await setup(page); let started = false, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
|
||||
await page.route("**/api/uptime/settings", async r => { started = true; await gate; try { await r.fulfill({ json: values["/api/uptime/settings"] }) } catch {} })
|
||||
try {
|
||||
await page.goto("/data-collection"); await expect.poll(() => started).toBe(true)
|
||||
await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toHaveValue("45")
|
||||
await expect(page.getByTestId("settings-rest")).toContainText("настройки получены")
|
||||
await expect(page.getByTestId("settings-uptime")).toContainText("настройки не получены")
|
||||
} finally { release() }
|
||||
})
|
||||
test("Настройки: фоновая ошибка сохраняет данные, время и черновик", async ({ page }) => {
|
||||
await setup(page); let fails = false
|
||||
await page.route("**/api/traffic/settings", r => r.fulfill(fails ? { status: 503, json: { error: "traffic unavailable" } } : { json: values["/api/traffic/settings"] }))
|
||||
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })
|
||||
await expect(field).toHaveValue("45"); await field.fill("99")
|
||||
const received = await page.getByTestId("settings-traffic").getByRole("status").textContent(); fails = true
|
||||
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-traffic")).toContainText("traffic unavailable")
|
||||
await expect(field).toHaveValue("99"); await expect(page.getByTestId("settings-traffic").getByRole("status")).toHaveText(received!)
|
||||
})
|
||||
test("Настройки: успешное обновление не затирает черновик", async ({ page }) => {
|
||||
await setup(page); await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })
|
||||
await expect(field).toHaveValue("45"); await field.fill("99")
|
||||
await page.getByRole("textbox", { name: "Хранение трафика", exact: true }).fill("21")
|
||||
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await expect(field).toHaveValue("99")
|
||||
await expect(page.getByRole("textbox", { name: "Хранение трафика", exact: true })).toHaveValue("21")
|
||||
})
|
||||
Reference in new issue
Block a user