fix(backups): разделить загрузку каталогов и права действий
This commit is contained in:
1 parent
b460997cbf
commit
3dcc7c9ea6
11 files changed
+258
-90
No files matched your search
+118
-85
@@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import type { Backup, Server } from "@/lib/data"
|
import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data"
|
||||||
import { createFilterQuery } from "@/components/reui/filters/filters-query"
|
import { createFilterQuery } from "@/components/reui/filters/filters-query"
|
||||||
import type { FilterQuery } from "@/components/reui/filters/filters-types"
|
import type { FilterQuery } from "@/components/reui/filters/filters-types"
|
||||||
import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups"
|
import type { BackupScheduleSettingsDto, BackupStorageSettingsDto } from "@mmapp/contracts/backups"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { listServers } from "@/shared/api/servers"
|
import { serverListSchema } from "@mmapp/contracts/servers"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import { userApiError } from "@/shared/api/users"
|
||||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||||
import {
|
import {
|
||||||
createBackupsAsync,
|
createBackupsAsync,
|
||||||
@@ -34,7 +36,7 @@ import {
|
|||||||
testBackupStorage,
|
testBackupStorage,
|
||||||
type BackupItem,
|
type BackupItem,
|
||||||
} from "@/shared/api/backups"
|
} from "@/shared/api/backups"
|
||||||
import { requestBlob } from "@/shared/api/http-client"
|
import { requestJson, requestBlob } from "@/shared/api/http-client"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { BackupsHistory } from "@/components/backups/backups-history"
|
import { BackupsHistory } from "@/components/backups/backups-history"
|
||||||
import {
|
import {
|
||||||
@@ -51,14 +53,36 @@ import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/ba
|
|||||||
type PageTab = "history" | "settings"
|
type PageTab = "history" | "settings"
|
||||||
type KindFilter = "all" | "auto" | "manual"
|
type KindFilter = "all" | "auto" | "manual"
|
||||||
|
|
||||||
|
type Resource = "backups" | "servers" | "schedule" | "storage"
|
||||||
|
type ResourceState = { known: boolean; error: string | null; receivedAt: string | null }
|
||||||
|
const unknownResource = (): ResourceState => ({ known: false, error: null, receivedAt: null })
|
||||||
|
|
||||||
export default function BackupsPage() {
|
export default function BackupsPage() {
|
||||||
const { backendUrl } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
|
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
|
||||||
|
return <BackupsContent key={mode + ":" + backendUrl} />
|
||||||
|
}
|
||||||
|
function BackupsContent() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
const canRead = can("mm:backups:read")
|
||||||
|
const canWrite = isLive && can("mm:backups:write")
|
||||||
|
const canReadServers = can("mm:servers:read")
|
||||||
|
const [resources, setResources] = useState<Record<Resource, ResourceState>>(() => ({ backups: unknownResource(), servers: unknownResource(), schedule: unknownResource(), storage: unknownResource() }))
|
||||||
|
const reader = useRef<AbortController | null>(null)
|
||||||
|
const revision = useRef(0)
|
||||||
|
const alive = useRef(true)
|
||||||
|
const scheduleDirty = useRef(false)
|
||||||
|
const storageDirty = useRef(false)
|
||||||
|
const known = (resource: Resource) => !isLive || resources[resource].known
|
||||||
|
const current = (resource: Resource) => known(resource) && !resources[resource].error
|
||||||
const [tab, setTab] = useState<PageTab>("history")
|
const [tab, setTab] = useState<PageTab>("history")
|
||||||
const [backupList, setBackupList] = useState<Backup[]>([])
|
const [backupList, setBackupList] = useState<Backup[]>(isLive ? [] : mockBackups)
|
||||||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [advancedFilters, setAdvancedFilters] = useState<FilterQuery>(() => createFilterQuery())
|
const [advancedFilters, setAdvancedFilters] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
const [liveServers, setLiveServers] = useState<Server[]>(isLive ? [] : mockServers)
|
||||||
|
const canCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [opBusy, setOpBusy] = useState(false)
|
const [opBusy, setOpBusy] = useState(false)
|
||||||
const [opError, setOpError] = useState<string | null>(null)
|
const [opError, setOpError] = useState<string | null>(null)
|
||||||
@@ -68,6 +92,7 @@ export default function BackupsPage() {
|
|||||||
const [syncBusy, setSyncBusy] = useState(false)
|
const [syncBusy, setSyncBusy] = useState(false)
|
||||||
const [schedule, setSchedule] = useState<BackupScheduleForm>(defaultSchedule)
|
const [schedule, setSchedule] = useState<BackupScheduleForm>(defaultSchedule)
|
||||||
const [storage, setStorage] = useState<BackupStorageForm>(defaultStorageForm)
|
const [storage, setStorage] = useState<BackupStorageForm>(defaultStorageForm)
|
||||||
|
const [savedSchedule, setSavedSchedule] = useState<BackupScheduleSettingsDto | null>(null)
|
||||||
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
|
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
|
||||||
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
|
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
|
||||||
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set())
|
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set())
|
||||||
@@ -78,86 +103,69 @@ export default function BackupsPage() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null)
|
||||||
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
|
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
|
||||||
|
|
||||||
const setSched = <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) =>
|
const setSched = <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) }
|
||||||
setSchedule((s) => ({ ...s, [k]: v }))
|
const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => { storageDirty.current = true; setStorage(s => ({ ...s, [k]: v })) }
|
||||||
const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) =>
|
|
||||||
setStorage((s) => ({ ...s, [k]: v }))
|
|
||||||
|
|
||||||
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
|
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
|
||||||
const kb = Math.max(1, Math.round(b.sizeBytes / 1024))
|
const kb = Math.round(b.sizeBytes / 1024)
|
||||||
return {
|
return {
|
||||||
id: b.id,
|
id: b.id,
|
||||||
server: b.serverName,
|
server: b.serverName,
|
||||||
serverId: b.serverId == null ? null : String(b.serverId),
|
serverId: b.serverId == null ? null : String(b.serverId),
|
||||||
filename: b.filename,
|
filename: b.filename,
|
||||||
size: `${kb} КБ`,
|
size: b.sizeBytes < 1024 ? `${b.sizeBytes} Б` : `${kb} КБ`,
|
||||||
sizeBytes: b.sizeBytes,
|
sizeBytes: b.sizeBytes,
|
||||||
created: new Date(b.createdAt).toLocaleString("ru-RU"),
|
created: new Date(b.createdAt).toLocaleString("ru-RU"),
|
||||||
kind: b.kind,
|
kind: b.kind,
|
||||||
notes: b.notes ?? "",
|
notes: b.notes ?? "",
|
||||||
storage: b.storage ?? "local",
|
storage: b.storage,
|
||||||
uploadError: b.uploadError ?? undefined,
|
uploadError: b.uploadError ?? undefined,
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadLive = useCallback(async () => {
|
const loadLive = useCallback(async () => {
|
||||||
|
if (!isLive || !canRead || !alive.current) return
|
||||||
|
reader.current?.abort()
|
||||||
|
const abort = new AbortController(), version = ++revision.current
|
||||||
|
reader.current = abort
|
||||||
|
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)])
|
||||||
|
const active = () => alive.current && !abort.signal.aborted && revision.current === version
|
||||||
|
const success = (resource: Resource) => { if (active()) setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } })) }
|
||||||
|
const failure = (resource: Resource, error: unknown) => { if (active()) setResources(prev => ({ ...prev, [resource]: { ...prev[resource], error: userApiError(error) } })) }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setOpError(null)
|
await Promise.allSettled([
|
||||||
try {
|
listBackups(backendUrl, { signal }).then(rows => { if (!active()) return; setBackupList(rows.map(mapApiBackupToUi)); success("backups") }).catch(error => failure("backups", error)),
|
||||||
const [serversRows, backupsRows, scheduleRow, storageRow] = await Promise.all([
|
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
|
||||||
listServers(backendUrl),
|
const rows = serverListSchema.parse(payload)
|
||||||
listBackups(backendUrl),
|
if (!active()) return
|
||||||
getBackupScheduleSettings(backendUrl),
|
setLiveServers(rows.map(toFrontendServer)); success("servers")
|
||||||
getBackupStorageSettings(backendUrl),
|
}).catch(error => failure("servers", error)) : Promise.resolve(),
|
||||||
|
getBackupScheduleSettings(backendUrl, { signal }).then(row => {
|
||||||
|
if (!active()) return
|
||||||
|
if (!scheduleDirty.current) { setSchedule(row); setSelectedServers(new Set(row.serverIds)) }
|
||||||
|
setSavedSchedule(row); setScheduleLastError(row.lastError ?? null); success("schedule")
|
||||||
|
}).catch(error => failure("schedule", error)),
|
||||||
|
getBackupStorageSettings(backendUrl, { signal }).then(row => {
|
||||||
|
if (!active()) return
|
||||||
|
setSavedStorage(row)
|
||||||
|
if (!storageDirty.current) setStorage(prev => ({ ...prev, ...row, s3SecretAccessKey: "" }))
|
||||||
|
success("storage")
|
||||||
|
}).catch(error => failure("storage", error)),
|
||||||
])
|
])
|
||||||
const mappedServers = serversRows.map((s) => toFrontendServer(s))
|
if (active()) setLoading(false)
|
||||||
setLiveServers(mappedServers)
|
}, [backendUrl, isLive, canRead, canReadServers, mapApiBackupToUi])
|
||||||
setBackupList(backupsRows.map(mapApiBackupToUi))
|
|
||||||
setSchedule({
|
|
||||||
enabled: scheduleRow.enabled,
|
|
||||||
frequency: scheduleRow.frequency,
|
|
||||||
hour: scheduleRow.hour,
|
|
||||||
minute: scheduleRow.minute,
|
|
||||||
weekDay: scheduleRow.weekDay,
|
|
||||||
monthDay: scheduleRow.monthDay,
|
|
||||||
keepCount: scheduleRow.keepCount,
|
|
||||||
format: scheduleRow.format,
|
|
||||||
})
|
|
||||||
setSavedStorage(storageRow)
|
|
||||||
setScheduleLastError(scheduleRow.lastError ?? null)
|
|
||||||
setStorage((prev) => ({
|
|
||||||
...prev,
|
|
||||||
provider: storageRow.provider,
|
|
||||||
s3Endpoint: storageRow.s3Endpoint,
|
|
||||||
s3Region: storageRow.s3Region,
|
|
||||||
s3Bucket: storageRow.s3Bucket,
|
|
||||||
s3Prefix: storageRow.s3Prefix,
|
|
||||||
s3AccessKeyId: storageRow.s3AccessKeyId,
|
|
||||||
s3SecretAccessKey: "",
|
|
||||||
s3ForcePathStyle: storageRow.s3ForcePathStyle,
|
|
||||||
keepLocalCopy: storageRow.keepLocalCopy,
|
|
||||||
}))
|
|
||||||
const serverIds = scheduleRow.serverIds.length > 0
|
|
||||||
? scheduleRow.serverIds
|
|
||||||
: mappedServers.map((s) => s.id)
|
|
||||||
setSelectedServers(new Set(serverIds.filter((id) => mappedServers.some((s) => s.id === id))))
|
|
||||||
} catch (e) {
|
|
||||||
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [backendUrl, mapApiBackupToUi])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadLive()
|
alive.current = true
|
||||||
|
queueMicrotask(() => { if (alive.current) void loadLive() })
|
||||||
|
return () => { alive.current = false; reader.current?.abort() }
|
||||||
}, [loadLive])
|
}, [loadLive])
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (scheduleSaveBusy) return
|
if (!canWrite || !current("schedule") || !current("storage") || scheduleSaveBusy) return
|
||||||
setScheduleSaveBusy(true)
|
setScheduleSaveBusy(true)
|
||||||
setOpError(null)
|
setOpError(null)
|
||||||
try {
|
try {
|
||||||
await putBackupScheduleSettings(backendUrl, {
|
const confirmedSchedule = await putBackupScheduleSettings(backendUrl, {
|
||||||
enabled: schedule.enabled,
|
enabled: schedule.enabled,
|
||||||
frequency: schedule.frequency,
|
frequency: schedule.frequency,
|
||||||
hour: schedule.hour,
|
hour: schedule.hour,
|
||||||
@@ -168,6 +176,7 @@ export default function BackupsPage() {
|
|||||||
format: "rsc",
|
format: "rsc",
|
||||||
serverIds: [...selectedServers],
|
serverIds: [...selectedServers],
|
||||||
})
|
})
|
||||||
|
setSavedSchedule(confirmedSchedule)
|
||||||
const saved = await putBackupStorageSettings(backendUrl, {
|
const saved = await putBackupStorageSettings(backendUrl, {
|
||||||
provider: storage.provider,
|
provider: storage.provider,
|
||||||
s3Endpoint: storage.s3Endpoint,
|
s3Endpoint: storage.s3Endpoint,
|
||||||
@@ -190,6 +199,7 @@ export default function BackupsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleTestStorage() {
|
async function handleTestStorage() {
|
||||||
|
if (!canWrite || !current("storage")) return
|
||||||
setTestBusy(true)
|
setTestBusy(true)
|
||||||
try {
|
try {
|
||||||
await putBackupStorageSettings(backendUrl, {
|
await putBackupStorageSettings(backendUrl, {
|
||||||
@@ -219,6 +229,7 @@ export default function BackupsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSyncStorage() {
|
async function handleSyncStorage() {
|
||||||
|
if (!canWrite || !current("storage")) return
|
||||||
setSyncBusy(true)
|
setSyncBusy(true)
|
||||||
try {
|
try {
|
||||||
const result = await syncBackupsFromStorage(backendUrl)
|
const result = await syncBackupsFromStorage(backendUrl)
|
||||||
@@ -232,7 +243,7 @@ export default function BackupsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleManualBackup() {
|
async function handleManualBackup() {
|
||||||
if (manualServers.size === 0 || opBusy) return
|
if (!canCreate || manualServers.size === 0 || opBusy) return
|
||||||
setOpError(null)
|
setOpError(null)
|
||||||
try {
|
try {
|
||||||
const res = await createBackupsAsync(backendUrl, {
|
const res = await createBackupsAsync(backendUrl, {
|
||||||
@@ -291,7 +302,7 @@ export default function BackupsPage() {
|
|||||||
}, [opError])
|
}, [opError])
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!deleteTarget) return
|
if (!canWrite || !deleteTarget) return
|
||||||
setOpBusy(true)
|
setOpBusy(true)
|
||||||
setOpError(null)
|
setOpError(null)
|
||||||
try {
|
try {
|
||||||
@@ -306,7 +317,7 @@ export default function BackupsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRestore() {
|
async function handleRestore() {
|
||||||
if (!restoreTarget) return
|
if (!canWrite || !restoreTarget) return
|
||||||
setOpBusy(true)
|
setOpBusy(true)
|
||||||
setOpError(null)
|
setOpError(null)
|
||||||
try {
|
try {
|
||||||
@@ -321,6 +332,7 @@ export default function BackupsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownload(id: string, fallbackFilename: string) {
|
async function handleDownload(id: string, fallbackFilename: string) {
|
||||||
|
if (!isLive || !canRead) return
|
||||||
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
|
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
@@ -336,19 +348,21 @@ export default function BackupsPage() {
|
|||||||
const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length
|
const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length
|
||||||
|
|
||||||
const schedSummary = (() => {
|
const schedSummary = (() => {
|
||||||
if (!schedule.enabled) return "Расписание отключено"
|
const confirmed = isLive ? savedSchedule : defaultSchedule
|
||||||
const t = `${String(schedule.hour).padStart(2, "0")}:${String(schedule.minute).padStart(2, "0")}`
|
if (!confirmed) return "Расписание пока неизвестно"
|
||||||
if (schedule.frequency === "daily") return `Каждый день в ${t}`
|
if (!confirmed.enabled) return "Расписание отключено"
|
||||||
if (schedule.frequency === "weekly") return `Каждую неделю (${WEEK_DAYS[schedule.weekDay]}) в ${t}`
|
const time = String(confirmed.hour).padStart(2, "0") + ":" + String(confirmed.minute).padStart(2, "0")
|
||||||
return `${schedule.monthDay}-го числа каждого месяца в ${t}`
|
if (confirmed.frequency === "daily") return "Каждый день в " + time
|
||||||
|
if (confirmed.frequency === "weekly") return "Каждую неделю (" + WEEK_DAYS[confirmed.weekDay] + ") в " + time
|
||||||
|
return confirmed.monthDay + "-го числа каждого месяца в " + time
|
||||||
})()
|
})()
|
||||||
|
const confirmedStorage = isLive ? savedStorage : defaultStorageForm
|
||||||
const destinationLabel = storage.provider === "s3"
|
const destinationLabel = !confirmedStorage ? "Хранилище пока неизвестно" : confirmedStorage.provider === "s3"
|
||||||
? (storage.keepLocalCopy ? "локально и в S3" : "в S3")
|
? (confirmedStorage.keepLocalCopy ? "локально и в S3" : "в S3")
|
||||||
: "локально (storage/backups)"
|
: "локально (storage/backups)"
|
||||||
|
|
||||||
const statusDetail = [
|
const statusDetail = [
|
||||||
backupJobId ? null : `${destinationLabel} · ${selectedServers.size} из ${liveServers.length} серверов`,
|
backupJobId ? null : `${destinationLabel} · ${known("servers") ? liveServers.length + " серверов в каталоге" : "Серверы пока неизвестны"}`,
|
||||||
scheduleLastError,
|
scheduleLastError,
|
||||||
savedStorage?.lastTestError,
|
savedStorage?.lastTestError,
|
||||||
].filter(Boolean).join(" · ")
|
].filter(Boolean).join(" · ")
|
||||||
@@ -360,6 +374,7 @@ export default function BackupsPage() {
|
|||||||
: "default"
|
: "default"
|
||||||
|
|
||||||
function openCreate(all: boolean) {
|
function openCreate(all: boolean) {
|
||||||
|
if (!canCreate) return
|
||||||
setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
|
setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
|
||||||
setManualNotes("")
|
setManualNotes("")
|
||||||
setManualStep(1)
|
setManualStep(1)
|
||||||
@@ -377,7 +392,7 @@ export default function BackupsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openCreate(true)}
|
onClick={() => openCreate(true)}
|
||||||
disabled={loading}
|
disabled={!canCreate || loading}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="size-4" />
|
<RefreshCwIcon className="size-4" />
|
||||||
Снять со всех
|
Снять со всех
|
||||||
@@ -386,7 +401,7 @@ export default function BackupsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openCreate(false)}
|
onClick={() => openCreate(false)}
|
||||||
disabled={loading}
|
disabled={!canCreate || loading}
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Новый бэкап
|
Новый бэкап
|
||||||
@@ -397,34 +412,46 @@ export default function BackupsPage() {
|
|||||||
|
|
||||||
<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">
|
<div className="flex flex-col gap-5">
|
||||||
|
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демонстрационные данные. Операции и сохранение недоступны.</p>}
|
||||||
|
{!canRead && <p role="status">Нет права mm:backups:read</p>}
|
||||||
|
{isLive && !canReadServers && <p role="status">Каталог устройств недоступен без mm:servers:read.</p>}
|
||||||
|
{isLive && canRead && <div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => void loadLive()} disabled={loading}>Обновить</Button>
|
||||||
|
<span role="status">{loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}</span>
|
||||||
|
</div>}
|
||||||
|
{isLive && (Object.keys(resources) as Resource[]).filter(resource => resources[resource].error).map(resource => <Alert key={resource} variant="warning" aria-label={"Данные резервных копий: " + resource}>
|
||||||
|
<AlertTitle>{resources[resource].known ? "Последние данные сохранены" : "Данные пока неизвестны"}</AlertTitle>
|
||||||
|
<AlertDescription>{({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}</AlertDescription>
|
||||||
|
</Alert>)}
|
||||||
|
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка бэкапов"
|
aria-label="Сводка бэкапов"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
id: "all",
|
id: "all",
|
||||||
label: "Всего бэкапов",
|
label: "Всего бэкапов",
|
||||||
value: backupList.length,
|
value: known("backups") ? backupList.length : "—",
|
||||||
icon: <HardDriveIcon className="size-4" />,
|
icon: <HardDriveIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "auto",
|
id: "auto",
|
||||||
label: "Авто",
|
label: "Авто",
|
||||||
value: autoCount,
|
value: known("backups") ? autoCount : "—",
|
||||||
icon: <ClockIcon className="size-4" />,
|
icon: <ClockIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "manual",
|
id: "manual",
|
||||||
label: "Вручную",
|
label: "Вручную",
|
||||||
value: manualCount,
|
value: known("backups") ? manualCount : "—",
|
||||||
icon: <PlusIcon className="size-4" />,
|
icon: <PlusIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "s3",
|
id: "s3",
|
||||||
label: "В S3",
|
label: "В S3",
|
||||||
value: s3Count,
|
value: known("backups") ? s3Count : "—",
|
||||||
icon: <CloudIcon className="size-4" />,
|
icon: <CloudIcon className="size-4" />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
@@ -458,7 +485,7 @@ export default function BackupsPage() {
|
|||||||
<TabsTrigger value="settings">Настройки</TabsTrigger>
|
<TabsTrigger value="settings">Настройки</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="history" className="flex flex-col gap-5">
|
<TabsContent value="history" className="flex flex-col gap-5">
|
||||||
<BackupsHistory
|
{!canRead ? <p role="status">История недоступна без права чтения</p> : !known("backups") ? <p role="status">{resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}</p> : <BackupsHistory
|
||||||
backups={backupList}
|
backups={backupList}
|
||||||
kindFilter={kindFilter}
|
kindFilter={kindFilter}
|
||||||
onKindFilterChange={setKindFilter}
|
onKindFilterChange={setKindFilter}
|
||||||
@@ -470,9 +497,13 @@ export default function BackupsPage() {
|
|||||||
onRestore={setRestoreTarget}
|
onRestore={setRestoreTarget}
|
||||||
onDelete={setDeleteTarget}
|
onDelete={setDeleteTarget}
|
||||||
onCreate={() => openCreate(false)}
|
onCreate={() => openCreate(false)}
|
||||||
/>
|
canCreate={canCreate}
|
||||||
|
canWrite={canWrite}
|
||||||
|
canDownload={isLive && canRead}
|
||||||
|
/>}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="settings">
|
<TabsContent value="settings">
|
||||||
|
{(!known("schedule") || !known("storage")) ? <p role="status">Настройки резервных копий пока неизвестны</p> : <fieldset disabled={!canWrite || !current("schedule") || !current("storage")} className="min-w-0">
|
||||||
<BackupsSettings
|
<BackupsSettings
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
onScheduleChange={setSched}
|
onScheduleChange={setSched}
|
||||||
@@ -482,6 +513,7 @@ export default function BackupsPage() {
|
|||||||
servers={liveServers}
|
servers={liveServers}
|
||||||
selectedServers={selectedServers}
|
selectedServers={selectedServers}
|
||||||
onToggleServer={(id) => {
|
onToggleServer={(id) => {
|
||||||
|
scheduleDirty.current = true
|
||||||
setSelectedServers((prev) => {
|
setSelectedServers((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(id)) next.delete(id)
|
if (next.has(id)) next.delete(id)
|
||||||
@@ -489,8 +521,8 @@ export default function BackupsPage() {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onSelectAll={() => setSelectedServers(new Set(liveServers.map((s) => s.id)))}
|
onSelectAll={() => { scheduleDirty.current = true; setSelectedServers(new Set(liveServers.map(s => s.id))) }}
|
||||||
onClearServers={() => setSelectedServers(new Set())}
|
onClearServers={() => { scheduleDirty.current = true; setSelectedServers(new Set()) }}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onTest={handleTestStorage}
|
onTest={handleTestStorage}
|
||||||
onSync={handleSyncStorage}
|
onSync={handleSyncStorage}
|
||||||
@@ -498,6 +530,7 @@ export default function BackupsPage() {
|
|||||||
testBusy={testBusy}
|
testBusy={testBusy}
|
||||||
syncBusy={syncBusy}
|
syncBusy={syncBusy}
|
||||||
/>
|
/>
|
||||||
|
</fieldset>}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export function BackupsHistory({
|
|||||||
onRestore,
|
onRestore,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
canCreate = true,
|
||||||
|
canWrite = true,
|
||||||
|
canDownload = true,
|
||||||
}: {
|
}: {
|
||||||
backups: Backup[]
|
backups: Backup[]
|
||||||
kindFilter: KindFilter
|
kindFilter: KindFilter
|
||||||
@@ -39,6 +42,9 @@ export function BackupsHistory({
|
|||||||
onDownload: (id: string, filename: string) => void
|
onDownload: (id: string, filename: string) => void
|
||||||
onRestore: (backup: Backup) => void
|
onRestore: (backup: Backup) => void
|
||||||
onDelete: (backup: Backup) => void
|
onDelete: (backup: Backup) => void
|
||||||
|
canCreate?: boolean
|
||||||
|
canWrite?: boolean
|
||||||
|
canDownload?: boolean
|
||||||
onCreate: () => void
|
onCreate: () => void
|
||||||
}) {
|
}) {
|
||||||
const autoCount = backups.filter((b) => b.kind === "auto").length
|
const autoCount = backups.filter((b) => b.kind === "auto").length
|
||||||
@@ -76,6 +82,8 @@ export function BackupsHistory({
|
|||||||
onDownload={onDownload}
|
onDownload={onDownload}
|
||||||
onRestore={onRestore}
|
onRestore={onRestore}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
canWrite={canWrite}
|
||||||
|
canDownload={canDownload}
|
||||||
emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"}
|
emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"}
|
||||||
emptyDescription={
|
emptyDescription={
|
||||||
isEmptyAll
|
isEmptyAll
|
||||||
@@ -83,7 +91,7 @@ export function BackupsHistory({
|
|||||||
: "Измените фильтры или поисковый запрос"
|
: "Измените фильтры или поисковый запрос"
|
||||||
}
|
}
|
||||||
emptyAction={
|
emptyAction={
|
||||||
isEmptyAll ? (
|
isEmptyAll && canCreate ? (
|
||||||
<Button type="button" size="sm" onClick={onCreate}>
|
<Button type="button" size="sm" onClick={onCreate}>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Новый бэкап
|
Новый бэкап
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ interface BackupsDataGridProps {
|
|||||||
onDownload: (id: string, filename: string) => void
|
onDownload: (id: string, filename: string) => void
|
||||||
onRestore: (backup: Backup) => void
|
onRestore: (backup: Backup) => void
|
||||||
onDelete: (backup: Backup) => void
|
onDelete: (backup: Backup) => void
|
||||||
|
canWrite?: boolean
|
||||||
|
canDownload?: boolean
|
||||||
emptyAction?: ReactNode
|
emptyAction?: ReactNode
|
||||||
emptyTitle?: string
|
emptyTitle?: string
|
||||||
emptyDescription?: string
|
emptyDescription?: string
|
||||||
@@ -48,6 +50,8 @@ function BackupsDataGrid({
|
|||||||
onDownload,
|
onDownload,
|
||||||
onRestore,
|
onRestore,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
canWrite = true,
|
||||||
|
canDownload = true,
|
||||||
emptyAction,
|
emptyAction,
|
||||||
emptyTitle = "Нет бэкапов",
|
emptyTitle = "Нет бэкапов",
|
||||||
emptyDescription = "Создайте первый бэкап вручную или настройте расписание",
|
emptyDescription = "Создайте первый бэкап вручную или настройте расписание",
|
||||||
@@ -191,6 +195,7 @@ function BackupsDataGrid({
|
|||||||
className="size-7"
|
className="size-7"
|
||||||
title="Скачать"
|
title="Скачать"
|
||||||
aria-label={`Скачать ${b.filename}`}
|
aria-label={`Скачать ${b.filename}`}
|
||||||
|
disabled={!canDownload}
|
||||||
onClick={() => onDownload(b.id, b.filename)}
|
onClick={() => onDownload(b.id, b.filename)}
|
||||||
>
|
>
|
||||||
<DownloadIcon className="size-3.5" />
|
<DownloadIcon className="size-3.5" />
|
||||||
@@ -202,6 +207,7 @@ function BackupsDataGrid({
|
|||||||
className="size-7"
|
className="size-7"
|
||||||
title="Восстановить"
|
title="Восстановить"
|
||||||
aria-label={`Восстановить ${b.filename}`}
|
aria-label={`Восстановить ${b.filename}`}
|
||||||
|
disabled={!canWrite || !b.serverId}
|
||||||
onClick={() => onRestore(b)}
|
onClick={() => onRestore(b)}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="size-3.5" />
|
<RefreshCwIcon className="size-3.5" />
|
||||||
@@ -213,6 +219,7 @@ function BackupsDataGrid({
|
|||||||
className="size-7 text-destructive hover:text-destructive"
|
className="size-7 text-destructive hover:text-destructive"
|
||||||
title="Удалить"
|
title="Удалить"
|
||||||
aria-label={`Удалить ${b.filename}`}
|
aria-label={`Удалить ${b.filename}`}
|
||||||
|
disabled={!canWrite}
|
||||||
onClick={() => onDelete(b)}
|
onClick={() => onDelete(b)}
|
||||||
>
|
>
|
||||||
<Trash2Icon className="size-3.5" />
|
<Trash2Icon className="size-3.5" />
|
||||||
@@ -228,7 +235,7 @@ function BackupsDataGrid({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[onDelete, onDownload, onRestore],
|
[onDelete, onDownload, onRestore, canWrite, canDownload],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useTable({
|
const table = useTable({
|
||||||
|
|||||||
+1
-1
@@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output /
|
|||||||
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
|
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
|
||||||
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
|
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
|
||||||
|
|
||||||
Актуальная точка продолжения: 11А.25 — независимые каталоги резервных копий, source/mode guard, права и состояния данных. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты.
|
Актуальная точка продолжения: 11А.26 — достоверность backend операций резервных копий при отказах событий, затем формы настроек и операции. Каталоги/source/права реализованы в 11А.25. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты.
|
||||||
+10
-1
@@ -27,7 +27,7 @@
|
|||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.24; пользователи/контейнеры/сертификаты приняты локально, API резервных копий строгий; далее 11А.25 |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.25; пользователи/контейнеры/сертификаты приняты локально, каталоги резервных копий подключены; далее 11А.26 |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11А.25 — независимые каталоги резервных копий (2026-10-01)
|
||||||
|
|
||||||
|
- После hydration страница привязана к mode/backend. Live не начинает с демонстрационных строк; mock использует локальный каталог с явной подписью и отключёнными операциями. Запросы отменяются при unmount/новом чтении, revision подавляет поздние ответы.
|
||||||
|
- История, серверы, расписание и хранилище читаются независимо с отдельными known/error/receivedAt. Отказ/медленный ответ зависимости не скрывает историю. Initial unknown, loading, подтверждённый empty и старый подтверждённый список различаются; ручной повтор и время чтения показаны. 0 байт остаётся 0 Б.
|
||||||
|
- Read/write/server read права проверены перед запросами/действиями. Создание требует актуальный каталог серверов и хранилища; удаление/restore отключены для reader, download требует read. Restore для удалённого serverId недоступен.
|
||||||
|
- Фоновое чтение не сбрасывает dirty черновики. Сводка расписания/назначения берётся из подтверждённых DTO; draft не называется сохранённым. UI настроек пока ожидает оба DTO, отдельные формы/валидация/частичное сохранение входят в следующий блок.
|
||||||
|
- 12 data E2E + 2 существующих table/Filters E2E + 4 context E2E, полный TypeScript, профильный ESLint и production frontend прошли. Реальные PostgreSQL/RouterOS/S3 не проверены.
|
||||||
|
- **Следующий блок 11А.26:** backend достоверность резервного копирования/restore при сбоях журнала событий. Затем 11А.27 формы настроек, dirty/секреты/проверка S3/общий lock, создание и polling, delete/restore/download, URL и визуальная приёмка. Весь backups ещё не принят.
|
||||||
|
|
||||||
## Блок 11А.24.1 — актуальная E2E-фикстура резервных копий (2026-10-01)
|
## Блок 11А.24.1 — актуальная E2E-фикстура резервных копий (2026-10-01)
|
||||||
|
|
||||||
- Полный TypeScript после строгого BackupItem обнаружил старую admin-tables фикстуру без обязательного storage. Добавлено фактическое local; API-схема не ослаблена.
|
- Полный TypeScript после строгого BackupItem обнаружил старую admin-tables фикстуру без обязательного storage. Добавлено фактическое local; API-схема не ослаблена.
|
||||||
|
|||||||
@@ -311,3 +311,12 @@
|
|||||||
- Полный TypeScript после строгого BackupItem обнаружил старую admin-tables фикстуру без обязательного storage. Добавлено фактическое local; API-схема не ослаблена.
|
- Полный TypeScript после строгого BackupItem обнаружил старую admin-tables фикстуру без обязательного storage. Добавлено фактическое local; API-схема не ослаблена.
|
||||||
- Повторный полный TypeScript и 2 существующих E2E резервных копий (сортировка/подтверждение удаления, Filters) прошли. Production frontend/backend и API unit выполнены в 11А.24.
|
- Повторный полный TypeScript и 2 существующих E2E резервных копий (сортировка/подтверждение удаления, Filters) прошли. Production frontend/backend и API unit выполнены в 11А.24.
|
||||||
- Далее 11А.25: независимые каталоги, права и источник страницы.
|
- Далее 11А.25: независимые каталоги, права и источник страницы.
|
||||||
|
|
||||||
|
## Блок 11А.25 — независимые каталоги резервных копий (2026-10-01)
|
||||||
|
|
||||||
|
- После hydration страница привязана к mode/backend. Live не начинает с демонстрационных строк; mock использует локальный каталог с явной подписью и отключёнными операциями. Запросы отменяются при unmount/новом чтении, revision подавляет поздние ответы.
|
||||||
|
- История, серверы, расписание и хранилище читаются независимо с отдельными known/error/receivedAt. Отказ/медленный ответ зависимости не скрывает историю. Initial unknown, loading, подтверждённый empty и старый подтверждённый список различаются; ручной повтор и время чтения показаны. 0 байт остаётся 0 Б.
|
||||||
|
- Read/write/server read права проверены перед запросами/действиями. Создание требует актуальный каталог серверов и хранилища; удаление/restore отключены для reader, download требует read. Restore для удалённого serverId недоступен.
|
||||||
|
- Фоновое чтение не сбрасывает dirty черновики. Сводка расписания/назначения берётся из подтверждённых DTO; draft не называется сохранённым. UI настроек пока ожидает оба DTO, отдельные формы/валидация/частичное сохранение входят в следующий блок.
|
||||||
|
- 12 data E2E + 2 существующих table/Filters E2E + 4 context E2E, полный TypeScript, профильный ESLint и production frontend прошли. Реальные PostgreSQL/RouterOS/S3 не проверены.
|
||||||
|
- **Следующий блок 11А.26:** backend достоверность резервного копирования/restore при сбоях журнала событий. Затем 11А.27 формы настроек, dirty/секреты/проверка S3/общий lock, создание и polling, delete/restore/download, URL и визуальная приёмка. Весь backups ещё не принят.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "@playwright/test"
|
||||||
|
import base from "./playwright.config"
|
||||||
|
const server=base.webServer
|
||||||
|
if(!server||Array.isArray(server))throw new Error("Ожидается один локальный сервер")
|
||||||
|
export default defineConfig({
|
||||||
|
...base, use:{...base.use,baseURL:"http://127.0.0.1:3101"},testIgnore:[],testMatch:"backups-context.spec.ts",
|
||||||
|
webServer:{...server,command:"node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101",url:"http://127.0.0.1:3101",env:{...server.env,NEXT_PUBLIC_BACKEND_URL:"local"}},
|
||||||
|
})
|
||||||
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
testIgnore: ["**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
|
testIgnore: ["**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
import { backup, setupBackups } from "./fixtures/backups"
|
||||||
|
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,DELETE,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
|
||||||
|
for (const change of ["backend", "mode"]) for (const fails of [false, true]) test("Бэкапы: позднее чтение/" + change + "/" + fails, async ({ page }) => {
|
||||||
|
await setupBackups(page, headers)
|
||||||
|
let started = false, completed = false, release!: () => void; const gate = new Promise<void>(r => release = r)
|
||||||
|
await page.route("**/api/backups", async r => {
|
||||||
|
if (new URL(r.request().url()).port !== "8000") return r.fallback()
|
||||||
|
started = true; await gate
|
||||||
|
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРАЯ ОШИБКА БЭКАПА" } } : { headers, json: [{ ...backup, filename: "СТАРЫЙ БЭКАП.rsc" }] }) } catch {} finally { completed = true }
|
||||||
|
})
|
||||||
|
await page.goto("/backups"); await expect.poll(() => started).toBe(true); await page.goto("/settings")
|
||||||
|
if (change === "backend") { const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter") }
|
||||||
|
else await page.getByRole("button", { name: "Моковые", exact: true }).click()
|
||||||
|
await page.goto("/backups"); release(); await expect.poll(() => completed).toBe(true)
|
||||||
|
if (change === "backend") await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
|
||||||
|
else { await expect(page.getByText("Демонстрационные данные. Операции и сохранение недоступны.", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Новый бэкап", exact: true }).first()).toBeDisabled() }
|
||||||
|
await expect(page.getByText("СТАРЫЙ БЭКАП.rsc", { exact: true })).toHaveCount(0)
|
||||||
|
await expect(page.getByText(/СТАРАЯ ОШИБКА БЭКАПА/)).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
import { backup, setupBackups } from "./fixtures/backups"
|
||||||
|
|
||||||
|
for (const invalid of ["API", "malformed", "duplicate"]) test("Бэкапы: первоначальный отказ " + invalid + " и повтор", async ({ page }) => {
|
||||||
|
await setupBackups(page); let fail = true
|
||||||
|
await page.route("**/api/backups", r => r.fulfill(fail ? invalid === "API" ? { status: 503, json: { error: "История отказала" } } : { json: invalid === "duplicate" ? [backup, backup] : {} } : { json: [backup] }))
|
||||||
|
await page.goto("/backups")
|
||||||
|
await expect(page.getByRole("alert", { name: "Данные резервных копий: backups" })).toContainText("Данные пока неизвестны")
|
||||||
|
await expect(page.getByText("Нет бэкапов", { exact: true })).toHaveCount(0)
|
||||||
|
fail = false; await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||||
|
await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
test("Бэкапы: подтверждённая пустая история", async ({ page }) => {
|
||||||
|
await setupBackups(page); await page.route("**/api/backups", r => r.fulfill({ json: [] })); await page.goto("/backups")
|
||||||
|
await expect(page.getByText("Нет бэкапов", { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
for (const resource of ["servers", "backups/schedule", "backups/storage"]) test("Бэкапы: отказ зависимости " + resource + " сохраняет историю", async ({ page }) => {
|
||||||
|
await setupBackups(page); await page.route("**/api/" + resource, r => r.fulfill({ status: 503, json: { error: "Зависимость отказала" } }))
|
||||||
|
await page.goto("/backups"); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText("0 Б", { exact: true })).toBeVisible()
|
||||||
|
if (resource !== "backups/schedule") await expect(page.getByRole("button", { name: "Новый бэкап", exact: true })).toBeDisabled()
|
||||||
|
})
|
||||||
|
test("Бэкапы: поздняя зависимость не задерживает историю", async ({ page }) => {
|
||||||
|
await setupBackups(page); let release!: () => void; const gate = new Promise<void>(r => release = r)
|
||||||
|
await page.route("**/api/backups/storage", async r => { await gate; await r.fallback().catch(() => {}) })
|
||||||
|
await page.goto("/backups"); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
|
||||||
|
release(); await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled()
|
||||||
|
})
|
||||||
|
test("Бэкапы: до ответа нет ложного empty, фоновая ошибка сохраняет строки", async ({ page }) => {
|
||||||
|
await setupBackups(page); let release!: () => void; const gate = new Promise<void>(r => release = r); let fail = false
|
||||||
|
await page.route("**/api/backups", async r => { await gate; await r.fulfill(fail ? { status: 503, json: { error: "Фоновый отказ" } } : { json: [backup] }).catch(() => {}) })
|
||||||
|
await page.goto("/backups"); await expect(page.getByText("Загрузка резервных копий…", { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText("Нет бэкапов", { exact: true })).toHaveCount(0)
|
||||||
|
release(); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
|
||||||
|
fail = true; await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||||
|
await expect(page.getByRole("alert", { name: "Данные резервных копий: backups" })).toContainText("Последние данные сохранены")
|
||||||
|
await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
for (const serverRead of [false, true]) test("Бэкапы: read-only и server read " + serverRead, async ({ page }) => {
|
||||||
|
await setupBackups(page)
|
||||||
|
await page.addInitScript(({ serverRead }) => localStorage.setItem("mmapp_token", "header." + btoa(JSON.stringify({ sub: "test", apps: ["mm"], permissions: ["mm:backups:read", ...(serverRead ? ["mm:servers:read"] : [])], exp: Math.floor(Date.now() / 1000) + 3600 })) + ".signature"), { serverRead })
|
||||||
|
await page.route("**/api/auth/config", r => r.fulfill({ json: { required: true } }))
|
||||||
|
let serverRequests = 0; await page.route("**/api/servers", r => { serverRequests++; return r.fallback() })
|
||||||
|
await page.goto("/backups"); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByRole("button", { name: "Новый бэкап", exact: true })).toBeDisabled()
|
||||||
|
await expect(page.getByRole("button", { name: "Удалить " + backup.filename, exact: true })).toBeDisabled()
|
||||||
|
await page.getByRole("tab", { name: "Настройки", exact: true }).click()
|
||||||
|
await expect(page.getByRole("button", { name: "Сохранить настройки", exact: true })).toBeDisabled()
|
||||||
|
if (!serverRead) expect(serverRequests).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Бэкапы: фоновое чтение сохраняет черновик хранилища", async ({ page }) => {
|
||||||
|
await setupBackups(page); await page.goto("/backups")
|
||||||
|
await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
|
||||||
|
await page.getByRole("tab", { name: "Настройки", exact: true }).click()
|
||||||
|
await page.getByRole("button", { name: "S3", exact: true }).click()
|
||||||
|
await page.getByPlaceholder("mikrotik-backups", { exact: true }).fill("draft-bucket")
|
||||||
|
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||||
|
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled()
|
||||||
|
await expect(page.getByPlaceholder("mikrotik-backups", { exact: true })).toHaveValue("draft-bucket")
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
export { server } from "./users"
|
||||||
|
import { server } from "./users"
|
||||||
|
export const backup = { id: "backup-1", serverId: 1, serverName: "router", filename: "live-config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" }
|
||||||
|
export const schedule = { enabled: false, frequency: "daily", hour: 0, minute: 0, weekDay: 0, monthDay: 1, keepCount: 7, format: "rsc", serverIds: ["1"] }
|
||||||
|
export const storage = { provider: "local", s3Endpoint: "", s3Region: "", s3Bucket: "", s3Prefix: "", s3AccessKeyId: "", secretConfigured: false, s3ForcePathStyle: false, keepLocalCopy: false }
|
||||||
|
export async function setupBackups(page: Page, headers: Record<string, string> = {}) {
|
||||||
|
await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
|
||||||
|
await page.route("**/api/**", r => {
|
||||||
|
const path = new URL(r.request().url()).pathname
|
||||||
|
return r.fulfill({ headers, json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/backups" ? [backup] : path === "/api/backups/schedule" ? schedule : path === "/api/backups/storage" ? storage : {} })
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user