Compare commits

...
2 Commits
Author SHA1 Message Date
Denozordec f69e65b014 fix(docker): добавить переменную окружения для внутреннего URL бэкенда
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m34s
Docker images / frontend-image (push) Successful in 1m38s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 22:21:57 +07:00
Denozordec 49d14a00af fix(data-collection): улучшить обработку ошибок и загрузку данных с API
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:11:49 +07:00
6 changed files with 168 additions and 74 deletions
+1
View File
@@ -40,6 +40,7 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
ENV PORT=3000 ENV PORT=3000
ENV BACKEND_INTERNAL_URL=http://backend:8000
COPY --from=build /app/public ./public COPY --from=build /app/public ./public
COPY --from=build /app/.next/standalone ./ COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static COPY --from=build /app/.next/static ./.next/static
+140 -67
View File
@@ -24,7 +24,7 @@ import {
type SchedulerRunRowDto, type SchedulerRunRowDto,
type UptimeSettingsDto, type UptimeSettingsDto,
} from "@/lib/scheduler-settings" } from "@/lib/scheduler-settings"
import { requestJson } from "@/shared/api/http-client" import { requestJson, ApiClientError } from "@/shared/api/http-client"
import { import {
parseSchedulerRunSnapshot, parseSchedulerRunSnapshot,
type AlertEngineRuleDiagSnapshot, type AlertEngineRuleDiagSnapshot,
@@ -58,6 +58,27 @@ function makeApiFetch(backendUrl: string) {
} }
} }
function extractApiError(reason: unknown): string {
if (reason instanceof ApiClientError) return reason.message
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) {
const result = results[i]
if (result.status === "rejected") {
errors.push(`${labels[i]}: ${extractApiError(result.reason)}`)
}
}
return errors
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return ( return (
<button <button
@@ -730,8 +751,8 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
} }
export default function DataCollectionPage() { export default function DataCollectionPage() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live" const isLive = prefsHydrated && mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null) const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
@@ -786,7 +807,7 @@ export default function DataCollectionPage() {
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number]) runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}` ? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
: "?limit=80" : "?limit=80"
const [traffic, serversApi, uptime, internetPath, certRenew, runsRes] = await Promise.all([ const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([
apiFetch<CollectorSettingsDto>("/api/traffic/settings"), apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"), apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
apiFetch<UptimeSettingsDto>("/api/uptime/settings"), apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
@@ -794,28 +815,60 @@ export default function DataCollectionPage() {
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"), apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`), apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
]) ])
setTrafficCollector(traffic)
setServersApiCollector(serversApi) const loadErrors = collectSettledErrors(
setUptimeCollector(uptime) [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes],
setInternetPathCollector(internetPath) ["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"],
setSchedulerRuns(runsRes.runs ?? []) )
setTrafficIntervalDraft(String(traffic.intervalSec)) if (loadErrors.length > 0) {
setTrafficRetentionDraft(String(traffic.retentionDays)) setCollectorError(loadErrors.join("; "))
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300)) }
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60)) const traffic = readSettled(trafficRes)
setUptimeRetentionDraft(String(uptime.retentionDays)) if (traffic) {
setDraftTrafficEnabled(!!traffic.enabled) setTrafficCollector(traffic)
setDraftServersApiEnabled(!!serversApi.enabled) setTrafficIntervalDraft(String(traffic.intervalSec))
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120)) setTrafficRetentionDraft(String(traffic.retentionDays))
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled)) setDraftTrafficEnabled(!!traffic.enabled)
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled)) }
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
setDraftInternetPathEnabled(!!internetPath.enabled) const serversApi = readSettled(serversApiRes)
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300)) if (serversApi) {
setDraftCertRenewEnabled(!!certRenew.enabled) setServersApiCollector(serversApi)
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600)) setDraftServersApiEnabled(!!serversApi.enabled)
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30)) 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))
}
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))
}
const runsPayload = readSettled(runsRes)
if (runsPayload) {
setSchedulerRuns(runsPayload.runs ?? [])
}
} catch (e) { } catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные") setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
} finally { } finally {
@@ -950,7 +1003,18 @@ export default function DataCollectionPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full"> <div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
{!isLive && ( {!prefsHydrated && (
<Card>
<CardHeader>
<CardTitle className="text-base">Загрузка настроек подключения</CardTitle>
<CardDescription className="text-xs">
Читаем режим данных и адрес API из локальных настроек.
</CardDescription>
</CardHeader>
</Card>
)}
{prefsHydrated && !isLive && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Нужен live-режим</CardTitle> <CardTitle className="text-base">Нужен live-режим</CardTitle>
@@ -1226,51 +1290,60 @@ export default function DataCollectionPage() {
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14) const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120) const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300) const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
await apiFetch("/api/traffic/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftTrafficEnabled,
intervalSec: tInt,
retentionDays: tRet,
}),
})
await apiFetch("/api/servers-api-ping/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftServersApiEnabled,
intervalSec: sApiInt,
}),
})
await apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
resourcesEnabled: draftResourcesEnabled,
pingEnabled: draftPingEnabled,
speedEnabled: draftSpeedEnabled,
intervalSec: uRes,
probeIntervalSec: uPing,
speedIntervalSec: uSpd,
retentionDays: uRet,
}),
})
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600) const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30)) const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
await apiFetch("/api/internet-path/settings", { const saveResults = await Promise.allSettled([
method: "PUT", apiFetch("/api/traffic/settings", {
body: JSON.stringify({ method: "PUT",
enabled: draftInternetPathEnabled, body: JSON.stringify({
intervalSec: ipInt, enabled: draftTrafficEnabled,
intervalSec: tInt,
retentionDays: tRet,
}),
}), }),
}) apiFetch("/api/servers-api-ping/settings", {
await apiFetch("/api/certificates/renew-settings", { method: "PUT",
method: "PUT", body: JSON.stringify({
body: JSON.stringify({ enabled: draftServersApiEnabled,
enabled: draftCertRenewEnabled, intervalSec: sApiInt,
intervalSec: certRenewInt, }),
renewBeforeDays,
}), }),
}) apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
resourcesEnabled: draftResourcesEnabled,
pingEnabled: draftPingEnabled,
speedEnabled: draftSpeedEnabled,
intervalSec: uRes,
probeIntervalSec: uPing,
speedIntervalSec: uSpd,
retentionDays: uRet,
}),
}),
apiFetch("/api/internet-path/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftInternetPathEnabled,
intervalSec: ipInt,
}),
}),
apiFetch("/api/certificates/renew-settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftCertRenewEnabled,
intervalSec: certRenewInt,
renewBeforeDays,
}),
}),
])
const saveErrors = collectSettledErrors(
saveResults,
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"],
)
await loadCollectors() await loadCollectors()
if (saveErrors.length > 0) {
setCollectorError(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
}
} catch (e) { } catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить") setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
} finally { } finally {
+4
View File
@@ -18,8 +18,12 @@ services:
image: git.shts.su/denozord/mikrotikmanager-frontend:latest image: git.shts.su/denozord/mikrotikmanager-frontend:latest
container_name: mmapp-frontend container_name: mmapp-frontend
restart: unless-stopped restart: unless-stopped
depends_on:
- backend
ports: ports:
- "3000:3000" - "3000:3000"
environment:
BACKEND_INTERNAL_URL: http://backend:8000
labels: labels:
mmapp.updater.managed: "true" mmapp.updater.managed: "true"
mmapp.updater.target: frontend mmapp.updater.target: frontend
+12 -5
View File
@@ -59,9 +59,13 @@ function normalizeBackendUrl(url: string): string {
} }
export function DataSourceProvider({ children }: { children: React.ReactNode }) { export function DataSourceProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode) const [mode, setModeState] = useState<DataSourceMode>(() =>
const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL) typeof window === "undefined" ? defaultDataSourceMode() : readStoredMode(),
const [prefsHydrated, setPrefsHydrated] = useState(false) )
const [backendUrl, setBackendUrlState] = useState(() =>
typeof window === "undefined" ? LOCAL_DEFAULT_BACKEND_URL : readStoredBackendUrl(),
)
const [prefsHydrated, setPrefsHydrated] = useState(() => typeof window !== "undefined")
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined) const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
const backendUrlLocked = isBackendUrlLocked() const backendUrlLocked = isBackendUrlLocked()
const mockModeAvailable = isMockDataSourceAvailable() const mockModeAvailable = isMockDataSourceAvailable()
@@ -91,9 +95,12 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
}, [backendUrlLocked]) }, [backendUrlLocked])
const checkBackend = useCallback(async () => { const checkBackend = useCallback(async () => {
const url = normalizeBackendUrl(backendUrl) const healthUrl =
configuredBackendUrl().kind === "same-origin"
? "/health"
: `${normalizeBackendUrl(backendUrl)}/health`
try { try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3000) }) const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) })
setBackendStatus(res.ok) setBackendStatus(res.ok)
} catch { } catch {
setBackendStatus(false) setBackendStatus(false)
+10 -1
View File
@@ -1,3 +1,5 @@
import { configuredBackendUrl } from "@/lib/backend-url"
export class ApiClientError extends Error { export class ApiClientError extends Error {
constructor( constructor(
message: string, message: string,
@@ -13,13 +15,20 @@ function trimBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, "") return baseUrl.replace(/\/$/, "")
} }
function resolveRequestUrl(baseUrl: string, path: string): string {
if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") {
return path
}
return trimBaseUrl(baseUrl) + path
}
export async function requestJson<T>( export async function requestJson<T>(
baseUrl: string, baseUrl: string,
path: string, path: string,
init?: RequestInit, init?: RequestInit,
): Promise<T> { ): Promise<T> {
const hasBody = init?.body != null const hasBody = init?.body != null
const res = await fetch(trimBaseUrl(baseUrl) + path, { const res = await fetch(resolveRequestUrl(baseUrl, path), {
...init, ...init,
headers: { headers: {
...(hasBody ? { "Content-Type": "application/json" } : {}), ...(hasBody ? { "Content-Type": "application/json" } : {}),
+1 -1
View File
File diff suppressed because one or more lines are too long