fix(ui): учитывать права на действия мониторинга
This commit is contained in:
1 parent
61950638bc
commit
bb9278fe58
4 files changed
+125
-27
No files matched your search
+51
-22
@@ -4,6 +4,7 @@ import probeStyles from "./probe-rows.module.css"
|
||||
|
||||
import { Suspense, useState, useMemo, useEffect, useRef, useCallback } from "react"
|
||||
import Link from "next/link"
|
||||
import { can } from "@/lib/auth"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Alert as ContextAlert, AlertTitle as ContextTitle, AlertDescription as ContextDescription } from "@/components/reui/alert"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
@@ -1054,7 +1055,14 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
||||
|
||||
function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId: string | null; allProbesHref: string }) {
|
||||
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
|
||||
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
|
||||
const canWrite = can("mm:uptime:write")
|
||||
const [permissionError, setPermissionError] = useState<string | null>(null)
|
||||
const requireWrite = useCallback(() => {
|
||||
if (can("mm:uptime:write")) { setPermissionError(null); return true }
|
||||
setPermissionError("Недостаточно прав: изменение проб и запуск измерений требуют разрешения на запись мониторинга.")
|
||||
return false
|
||||
}, [])
|
||||
/** Live не зависит от ещё не завершённой проверки /health. */
|
||||
const liveApi = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
@@ -1189,6 +1197,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
|
||||
const refreshUptimeLive = useCallback(async (opts?: { showSpinner?: boolean; pollDevices?: boolean }) => {
|
||||
if (!liveApi) return
|
||||
if (opts?.pollDevices && !requireWrite()) return
|
||||
if (opts?.showSpinner) setUptimeRefreshBusy(true)
|
||||
let collectErr: string | null = null
|
||||
try {
|
||||
@@ -1207,11 +1216,11 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
} finally {
|
||||
if (opts?.showSpinner) setUptimeRefreshBusy(false)
|
||||
}
|
||||
}, [liveApi, loadLiveOverview, reloadSpeedData, checkBackend, apiFetch])
|
||||
}, [liveApi, loadLiveOverview, reloadSpeedData, checkBackend, apiFetch, requireWrite])
|
||||
|
||||
const refreshProbeGroupPings = useCallback(
|
||||
async (srvId: string, group: { name: string; target: string; probes: PingProbe[] }) => {
|
||||
if (!liveApi) return
|
||||
if (!liveApi || !requireWrite()) return
|
||||
const key = probeGroupActionKey(srvId, group)
|
||||
setProbeGroupPingBusy((m) => ({ ...m, [key]: true }))
|
||||
setOpError(null)
|
||||
@@ -1231,7 +1240,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
})
|
||||
}
|
||||
},
|
||||
[liveApi, apiFetch, loadLiveOverview],
|
||||
[liveApi, apiFetch, loadLiveOverview, requireWrite],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1478,6 +1487,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}, [apiFetch, liveApi])
|
||||
|
||||
const toggleDashboardStar = useCallback((id: string) => {
|
||||
if (!requireWrite()) return
|
||||
const cur = probes.find((p) => p.id === id)
|
||||
if (!cur) return
|
||||
const nextVal = !cur.showOnDashboard
|
||||
@@ -1509,7 +1519,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED))
|
||||
}
|
||||
}
|
||||
}, [probes, liveApi, apiFetch, loadLiveOverview])
|
||||
}, [probes, liveApi, apiFetch, loadLiveOverview, requireWrite])
|
||||
|
||||
const persistSpeedProbes = useCallback((rows: SpeedProbeRow[]) => {
|
||||
if (!liveApi) return
|
||||
@@ -1537,14 +1547,17 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}, [apiFetch, liveApi])
|
||||
|
||||
// ── actions ──
|
||||
const toggleProbe = (id: string, v: boolean) =>
|
||||
const toggleProbe = (id: string, v: boolean) => {
|
||||
if (!requireWrite()) return
|
||||
setProbes((prev) => {
|
||||
const next = prev.map((x) => x.id === id ? { ...x, enabled: v } : x)
|
||||
persistProbes(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const deleteProbe = (id: string) => {
|
||||
if (!requireWrite()) return
|
||||
if (!liveApi) {
|
||||
const s = readMockDashboardStarIds()
|
||||
s.delete(id)
|
||||
@@ -1581,6 +1594,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
setStatusFilter(prev => prev === s ? "all" : s)
|
||||
|
||||
const openEditProbe = (probe: PingProbe) => {
|
||||
if (!requireWrite()) return
|
||||
setEditingProbeId(probe.id)
|
||||
setNewSrcId(probe.srcServerId)
|
||||
setNewSrcInterface(probe.srcInterface || "")
|
||||
@@ -1591,6 +1605,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
|
||||
const openAddSheet = () => {
|
||||
if (!requireWrite()) return
|
||||
setEditingProbeId(null)
|
||||
setNewSrcId(selectableSources[0]?.id ?? "")
|
||||
setNewSrcInterface("")
|
||||
@@ -1599,6 +1614,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
|
||||
const handleSaveProbe = () => {
|
||||
if (!requireWrite()) return
|
||||
if (!newName.trim() || !newTarget.trim() || !newSrcId) return
|
||||
const probeBase: PingProbe = {
|
||||
id: editingProbeId ?? `p${Date.now()}`,
|
||||
@@ -1708,6 +1724,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}, [speedDraft, allServers, speedIfaces])
|
||||
|
||||
const runSpeedTest = async (probe: SpeedProbeRow) => {
|
||||
if (!requireWrite()) return
|
||||
if (!probe.srcServerId || !probe.dstServerId || probe.srcServerId === probe.dstServerId) return
|
||||
setSpeedError(null)
|
||||
setSpeedBusy(true)
|
||||
@@ -1849,6 +1866,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
|
||||
const updateSpeedProbe = (id: string, patch: Partial<SpeedProbeRow>) => {
|
||||
if (!requireWrite()) return
|
||||
setSpeedProbes((prev) => {
|
||||
const nextRows = prev.map((p) => {
|
||||
if (p.id !== id) return p
|
||||
@@ -1864,6 +1882,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
|
||||
const openAddSpeedSheet = () => {
|
||||
if (!requireWrite()) return
|
||||
setEditingSpeedProbeId(null)
|
||||
const src = selectableSources[0]?.id ?? ""
|
||||
const dst = selectableSources.find((s) => s.id !== src)?.id ?? src
|
||||
@@ -1884,6 +1903,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
|
||||
const openEditSpeedSheet = (probe: SpeedProbeRow) => {
|
||||
if (!requireWrite()) return
|
||||
setEditingSpeedProbeId(probe.id)
|
||||
setSpeedDraft({
|
||||
...probe,
|
||||
@@ -1895,6 +1915,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
|
||||
const saveSpeedProbe = () => {
|
||||
if (!requireWrite()) return
|
||||
if (!speedDraft.srcServerId || !speedDraft.dstServerId) return
|
||||
if (editingSpeedProbeId) {
|
||||
setSpeedProbes((prev) => {
|
||||
@@ -1924,6 +1945,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
|
||||
const deleteSpeedProbe = (id: string) => {
|
||||
if (!requireWrite()) return
|
||||
setSpeedProbes((prev) => {
|
||||
const next = prev.filter((p) => p.id !== id)
|
||||
persistSpeedProbes(next)
|
||||
@@ -1959,22 +1981,22 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
|
||||
<Button variant="outline" size="sm" disabled={liveApi && uptimeRefreshBusy} onClick={() => {
|
||||
if (liveApi) {
|
||||
void refreshUptimeLive({ showSpinner: true, pollDevices: selectedProbeId === null && (tab === "resources" || tab === "probes") })
|
||||
void refreshUptimeLive({ showSpinner: true, pollDevices: can("mm:uptime:write") && selectedProbeId === null && (tab === "resources" || tab === "probes") })
|
||||
return
|
||||
}
|
||||
setProbes(mockProbesWithSavedStars(INIT_PROBES))
|
||||
setResources(INIT_RESOURCES)
|
||||
}}>
|
||||
<RefreshCwIcon className={cn("size-4", uptimeRefreshBusy && "animate-spin")} />{liveApi ? (selectedProbeId !== null ? "Обновить данные" : "Обновить") : "Сбросить"}
|
||||
<RefreshCwIcon className={cn("size-4", uptimeRefreshBusy && "animate-spin")} />{liveApi ? (selectedProbeId !== null || !canWrite ? "Обновить данные" : "Обновить") : "Сбросить"}
|
||||
</Button>
|
||||
|
||||
{tab === "probes" && (
|
||||
<Button size="sm" onClick={openAddSheet}>
|
||||
<Button size="sm" disabled={!canWrite} onClick={openAddSheet}>
|
||||
<PlusIcon className="size-4" />Новая проба
|
||||
</Button>
|
||||
)}
|
||||
{tab === "speed" && (
|
||||
<Button size="sm" onClick={openAddSpeedSheet}>
|
||||
<Button size="sm" disabled={!canWrite} onClick={openAddSpeedSheet}>
|
||||
<PlusIcon className="size-4" />Новая speed-проба
|
||||
</Button>
|
||||
)}
|
||||
@@ -1982,6 +2004,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
/>
|
||||
|
||||
{!canWrite && <ContextAlert variant="info" role="status"><ContextTitle>Только просмотр</ContextTitle><ContextDescription>Изменение проб и запуск измерений требуют разрешения на запись. Обновление данных загружает сохранённые результаты.</ContextDescription></ContextAlert>}
|
||||
{permissionError && !sheetOpen && !speedSheetOpen && <ContextAlert variant="destructive" role="alert"><ContextDescription>{permissionError}</ContextDescription></ContextAlert>}
|
||||
|
||||
{/* ── Live indicator ── */}
|
||||
{!isPaused && (
|
||||
<div className="flex items-center gap-2 px-6 py-1.5 border-b shrink-0 text-[11px]"
|
||||
@@ -2126,7 +2151,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
Создайте пробу через кнопку «Новая speed-проба» в шапке страницы
|
||||
</p>
|
||||
<Button size="sm" variant="outline" className="mt-1" onClick={openAddSpeedSheet}>
|
||||
<Button size="sm" variant="outline" className="mt-1" disabled={!canWrite} onClick={openAddSpeedSheet}>
|
||||
<PlusIcon className="size-4" />Новая speed-проба
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2184,7 +2209,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* enable toggle */}
|
||||
<FormToggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
|
||||
<FormToggle disabled={!canWrite} checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
|
||||
|
||||
{/* route: src → dst */}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
@@ -2215,18 +2240,18 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
{/* actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1.5 text-xs"
|
||||
disabled={speedBusy || !canRun}
|
||||
disabled={!canWrite || speedBusy || !canRun}
|
||||
onClick={() => void runSpeedTest(probe)}>
|
||||
<RefreshCwIcon className={cn("size-3", speedBusy && run?.status === "running" && "animate-spin")} />
|
||||
Запустить
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => openEditSpeedSheet(probe)}
|
||||
disabled={!canWrite} aria-label="Редактировать speed-пробу" onClick={() => openEditSpeedSheet(probe)}
|
||||
title="Редактировать speed-пробу">
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteSpeedProbe(probe.id)}>
|
||||
disabled={!canWrite} aria-label="Удалить speed-пробу" onClick={() => deleteSpeedProbe(probe.id)}>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2488,7 +2513,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-[11px]"
|
||||
disabled={!liveApi || groupBusy}
|
||||
disabled={!canWrite || !liveApi || groupBusy}
|
||||
title={liveApi ? "Ping по всем интерфейсам группы и запись в БД" : "Доступно в режиме Live"}
|
||||
onClick={() => { void refreshProbeGroupPings(srv.id, group) }}
|
||||
>
|
||||
@@ -2522,7 +2547,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
!p.enabled && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<label className={probeStyles.toggle}><span className="sr-only">Включить пробу {p.name}</span><span className={probeStyles.label} aria-hidden="true">Включена</span><FormToggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} /></label>
|
||||
<label className={probeStyles.toggle}><span className="sr-only">Включить пробу {p.name}</span><span className={probeStyles.label} aria-hidden="true">Включена</span><FormToggle disabled={!canWrite} checked={p.enabled} onChange={v => toggleProbe(p.id, v)} /></label>
|
||||
<div className={probeStyles.status}><StatusDot
|
||||
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||
pulse={p.status === "up" && p.enabled}
|
||||
@@ -2585,7 +2610,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-7 p-0 text-muted-foreground hover:text-amber-500"
|
||||
onClick={() => toggleDashboardStar(p.id)}
|
||||
disabled={!canWrite} onClick={() => toggleDashboardStar(p.id)}
|
||||
title={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"}
|
||||
aria-label={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"}
|
||||
aria-pressed={p.showOnDashboard === true}
|
||||
@@ -2603,12 +2628,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
<div className={cn(probeStyles.actions, "flex justify-end gap-1")}>
|
||||
<Button size="sm" variant="ghost"
|
||||
className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
type="button" aria-label="Редактировать пробу" onClick={() => openEditProbe(p)} title="Редактировать пробу">
|
||||
disabled={!canWrite} type="button" aria-label="Редактировать пробу" onClick={() => openEditProbe(p)} title="Редактировать пробу">
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost"
|
||||
className="size-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
type="button" aria-label="Удалить пробу" onClick={() => deleteProbe(p.id)} title="Удалить пробу">
|
||||
disabled={!canWrite} type="button" aria-label="Удалить пробу" onClick={() => deleteProbe(p.id)} title="Удалить пробу">
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2652,7 +2677,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
})}
|
||||
|
||||
<button
|
||||
disabled={!canWrite}
|
||||
onClick={() => {
|
||||
if (!requireWrite()) return
|
||||
setEditingProbeId(null)
|
||||
setNewSrcId(srv.id)
|
||||
setNewSrcInterface("")
|
||||
@@ -2702,6 +2729,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{permissionError && <ContextAlert variant="destructive" role="alert"><ContextDescription>{permissionError}</ContextDescription></ContextAlert>}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
||||
<FormField label="Источник">
|
||||
<ServerPickerCards
|
||||
@@ -2811,7 +2839,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
|
||||
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => { setSpeedSheetOpen(false); setEditingSpeedProbeId(null) }}>Отмена</Button>
|
||||
<Button className="flex-1" onClick={saveSpeedProbe} disabled={!speedDraft.srcServerId || !speedDraft.dstServerId || speedDraft.srcServerId === speedDraft.dstServerId}>
|
||||
<Button className="flex-1" onClick={saveSpeedProbe} disabled={!canWrite || !speedDraft.srcServerId || !speedDraft.dstServerId || speedDraft.srcServerId === speedDraft.dstServerId}>
|
||||
{editingSpeedProbeId ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
|
||||
{editingSpeedProbeId ? "Сохранить" : "Добавить"}
|
||||
</Button>
|
||||
@@ -2844,6 +2872,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{permissionError && <ContextAlert variant="destructive" role="alert"><ContextDescription>{permissionError}</ContextDescription></ContextAlert>}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
||||
|
||||
{/* from → to visual */}
|
||||
@@ -2915,7 +2944,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
Отмена
|
||||
</Button>
|
||||
<Button className="flex-1"
|
||||
disabled={!newName.trim() || !newTarget.trim() || !newSrcId}
|
||||
disabled={!canWrite || !newName.trim() || !newTarget.trim() || !newSrcId}
|
||||
onClick={handleSaveProbe}>
|
||||
{editingProbeId ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
|
||||
{editingProbeId ? "Сохранить" : "Добавить"}
|
||||
|
||||
+10
-1
@@ -21,7 +21,7 @@
|
||||
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже |
|
||||
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2 и серверная часть 7.3 завершены; далее права мониторинга |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; далее ошибки сохранения проб |
|
||||
| 8 | Трафик и статистика | Не начат |
|
||||
| 9 | VPN, Firewall и Filters | Не начат |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
@@ -29,6 +29,15 @@
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 7.3Б — ограничения прав мониторинга (2026-09-21)
|
||||
|
||||
- Uptime использует can("mm:uptime:write") для Ping/Resources/Speed: создание, редактирование, удаление, переключение, звезда дашборда, групповой ping, collect-now и bandwidth-test. Дополнительное создание интерфейса из группы также ограничено. Backend-политика и auth disabled не изменены.
|
||||
- Для читателя «Обновить данные» выполняет GET сохранённых результатов без POST collect-now. Фоновые GET, вкладки, фильтры, графики и CSV ресурсов доступны. Права servers:write и флаг is_admin не заменяют uptime:write; наследование uptime:admin сохранено.
|
||||
- Обработчики повторно проверяют актуальные claims до изменения локального состояния/запроса. При локальном отзыве права форма Ping/Speed остаётся открытой, сообщение показано внутри неё. Кнопки пересчитываются на следующем render; подписка на изменения сессии не добавлена.
|
||||
- Проверки: прежние 11 E2E прошли, новые 7 сценариев прав прошли; после проверки дополнительной кнопки повторно прошли 3 read-only сценария. TypeScript прошёл. ESLint теста чистый; uptime сохраняет исходные 1 set-state-in-effect (speedDraft) и 6 unused warnings. ReUI audit: установленные компоненты, композиция, тема сохранены; добавлены state/disabled и Alert. Финальный production build прошёл: 34 статические страницы.
|
||||
- 7.3 ещё не принят целиком: persistProbes/persistSpeedProbes скрывают ошибки PUT, а формы оптимистично закрываются. Следующий блок 7.3В/7.4: подтверждённое сохранение Ping/Speed, сохранение формы на API 403/400/500, блокировка повторов, сообщения ошибок для переключения/звезды/удаления. Не считать локальный отзыв claims проверкой серверного 403.
|
||||
- Реальный JWT/RouterOS не проверялся: использованы HTTP-моки и синтетические claims. На контрольной точке израсходовано 42% пятичасовой и 83% недельной квоты. Локальный коммит без push, исходные сторонние изменения не включать.
|
||||
|
||||
## Блок 7.3А — права действий серверов (2026-09-21)
|
||||
|
||||
- ServersPage использует существующий can("mm:servers:write") после AuthGuard. Backend относит создание/изменение/удаление, POST poll и test-connection к записи; политика API не изменена. Наследование write/admin и auth disabled сохранены, is_admin не даёт отдельного обхода.
|
||||
|
||||
@@ -61,7 +61,7 @@ URL — источник сохранённого состояния. Back/Forwa
|
||||
3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие.
|
||||
4. **7.5 — приёмка этапа.** Сценарий «проблема → сервер/проверка → действие → возврат» с сохранением контекста; профильные E2E, права в HTTP-моках, TypeScript, lint и build. Реальные JWT/RouterOS отдельно в тестовом окружении.
|
||||
|
||||
Следующий исполняемый шаг: блок 7.3Б — права действий мониторинга. Серверная часть 7.3А выполнена: can(mm:servers:write), ограничения кнопок, проверки обработчиков и 5 E2E прав. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
|
||||
Следующий исполняемый шаг: блок 7.3В/7.4 — подтверждённое сохранение проб Ping/Speed и ошибки API. Ограничения действий мониторинга 7.3Б выполнены; серверный 403 сохранения ещё не принят из-за прежних optimistic persist-функций. Серверная часть 7.3А выполнена: can(mm:servers:write), ограничения кнопок, проверки обработчиков и 5 E2E прав. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
|
||||
|
||||
## Решение по синхронизации URL (7.1)
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { test, expect, type Page } from "@playwright/test"
|
||||
|
||||
async function setup(page: Page, url: string, failed = 0, longNames = false) {
|
||||
async function setup(page: Page, url: string, failed = 0, longNames = false, permission?: string, isAdmin = false) {
|
||||
const control = { writes: [] as { method: string; path: string; body: unknown }[], failed, posts: [] as { path: string; body: unknown }[] }
|
||||
const servers = [{ id: 1, name: "Источник", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "64512", status: "online", latency: 1, os: "7", model: "CHR", wanUplinks: [] }]
|
||||
const probes = ["p1", "p/2"].map(id => ({ id, srcServerId: "1", name: "Проверка сети", target: "192.0.2.10", filter: "main", rtt: null, loss: 100, status: "down", series: [0], enabled: true, showOnDashboard: false }))
|
||||
if (permission) {
|
||||
await page.addInitScript(token => localStorage.setItem("mmapp_token", token), uptimeToken(permission, isAdmin))
|
||||
servers.push({ ...servers[0], id: 2, name: "Назначение", host: "192.0.2.2" })
|
||||
}
|
||||
if (longNames) {
|
||||
servers[0].name = "Источник-с-длинным-названием-площадки-для-проверки-переноса"
|
||||
probes.forEach(p => { p.name = "Проверка-доступности-длинного-имени-сетевого-узла"; p.target = "router-with-a-long-hostname.example.test"; p.filter = "Фильтр-с-длинным-названием" })
|
||||
@@ -15,11 +19,11 @@ async function setup(page: Page, url: string, failed = 0, longNames = false) {
|
||||
if (!["GET", "HEAD"].includes(request.method())) control.writes.push({ method: request.method(), path, body: request.postDataJSON() })
|
||||
if (request.method() === "POST") control.posts.push({ path, body: request.postDataJSON() })
|
||||
if (path === "/api/uptime/overview" && control.failed) return route.fulfill({ status: control.failed, json: { error: "Мониторинг недоступен" } })
|
||||
const json = path === "/api/auth/config" ? { required: false }
|
||||
const json = path === "/api/auth/config" ? { required: Boolean(permission) }
|
||||
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false }
|
||||
: path === "/api/servers" ? servers
|
||||
: path === "/api/uptime/overview" ? { probes, resources: [] }
|
||||
: path === "/api/uptime/speed-probes" ? { probes: [] }
|
||||
: path === "/api/uptime/speed-probes" ? { probes: permission ? [{ id: "s1", srcServerId: "1", dstServerId: "2", srcInterface: "", dstInterface: "", protocol: "tcp", direction: "both", durationSec: "10", enabled: true }] : [] }
|
||||
: path === "/api/uptime/speed-test/runs" ? { runs: [] }
|
||||
: path === "/api/bgp/sessions" ? []
|
||||
: path === "/api/filters/gre-tunnels" || path === "/api/vxlan" ? { tunnels: [] }
|
||||
@@ -126,3 +130,59 @@ for (const width of [320, 390, 1440]) {
|
||||
await expect.poll(() => control.writes.some(w => w.method === "PUT" && w.path === "/api/uptime/probes")).toBe(true)
|
||||
})
|
||||
}
|
||||
|
||||
// UI claim fixtures; JWT verification remains a separate integration check.
|
||||
function uptimeToken(permission: string, isAdmin = false) {
|
||||
return "fixture." + Buffer.from(JSON.stringify({ apps: ["mm"], permissions: ["mm:servers:read", permission], is_admin: isAdmin, exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64url") + ".fixture"
|
||||
}
|
||||
|
||||
for (const [permission, isAdmin] of [["mm:uptime:read", false], ["mm:uptime:read", true], ["mm:servers:write", false]] as const) {
|
||||
test(`мониторинг только чтение: ${permission}, is_admin=${isAdmin}`, async ({ page }) => {
|
||||
const control = await setup(page, "/uptime", 0, false, permission, isAdmin)
|
||||
await expect(page.getByText("Только просмотр", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Новая проба", exact: true })).toBeDisabled()
|
||||
const row = page.locator('[data-probe-id="p1"]')
|
||||
for (const name of ["Показать на дашборде", "Редактировать пробу", "Удалить пробу"]) {
|
||||
await expect(row.getByRole("button", { name, exact: true })).toBeDisabled()
|
||||
}
|
||||
await expect(row.getByRole("switch")).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: /Добавить интерфейс для/ })).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeDisabled()
|
||||
await page.getByRole("button", { name: "Обновить данные", exact: true }).click()
|
||||
await page.getByRole("button", { name: /Ресурсы/ }).click()
|
||||
await page.getByRole("button", { name: "Обновить данные", exact: true }).click()
|
||||
await page.getByRole("button", { name: /Скорость/ }).click()
|
||||
await expect(page.getByRole("button", { name: "Новая speed-проба", exact: true })).toBeDisabled()
|
||||
for (const name of ["Запустить", "Редактировать speed-пробу", "Удалить speed-пробу"]) {
|
||||
await expect(page.getByRole("button", { name, exact: true })).toBeDisabled()
|
||||
}
|
||||
await expect(page.getByRole("switch")).toBeDisabled()
|
||||
await page.getByRole("button", { name: "Обновить данные", exact: true }).click()
|
||||
expect(control.writes).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
for (const permission of ["mm:uptime:write", "mm:uptime:admin"]) {
|
||||
test(`мониторинг: ${permission} разрешает ручной ping`, async ({ page }) => {
|
||||
const control = await setup(page, "/uptime?probe=p1", 0, false, permission)
|
||||
await expect(page.getByRole("button", { name: "Новая проба", exact: true })).toBeEnabled()
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect.poll(() => control.posts.map(p => p.path)).toEqual(["/api/uptime/probes/collect-group"])
|
||||
await page.getByRole("button", { name: /Скорость/ }).click()
|
||||
await expect(page.getByRole("button", { name: "Запустить", exact: true })).toBeEnabled()
|
||||
})
|
||||
}
|
||||
|
||||
for (const speed of [false, true]) {
|
||||
test(`мониторинг: отзыв права перед сохранением ${speed ? "Speed" : "Ping"}`, async ({ page }) => {
|
||||
const control = await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write")
|
||||
if (speed) await page.getByRole("button", { name: /Скорость/ }).click()
|
||||
await page.getByRole("button", { name: speed ? "Редактировать speed-пробу" : "Редактировать пробу", exact: true }).click()
|
||||
await expect(page.getByRole("dialog")).toBeVisible()
|
||||
await page.evaluate(token => localStorage.setItem("mmapp_token", token), uptimeToken("mm:uptime:read"))
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Сохранить", exact: true }).click()
|
||||
await expect(page.getByRole("dialog")).toBeVisible()
|
||||
await expect(page.getByRole("dialog").getByRole("alert")).toContainText("Недостаточно прав")
|
||||
expect(control.writes).toEqual([])
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user