fix(ui): учитывать права на действия мониторинга

This commit is contained in:
Denozordec committed 2026-09-21 16:16:16 +07:00
1 parent 61950638bc
commit bb9278fe58
4 files changed
+125 -27

No files matched your search

+51 -22
View File
@@ -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 ? "Сохранить" : "Добавить"}