From bb9278fe58de65fc4528571b0d5859e328c6f2de Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 21 Sep 2026 16:16:16 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=83=D1=87=D0=B8=D1=82=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/uptime/page.tsx | 73 ++++++++++++++++++++++++---------- docs/ux-ui-progress.md | 11 ++++- docs/ux-ui-stage-7.md | 2 +- tests/e2e/uptime-links.spec.ts | 66 ++++++++++++++++++++++++++++-- 4 files changed, 125 insertions(+), 27 deletions(-) diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 5e2a6b85..1ec5f60c 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -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(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) => { + 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 {tab === "probes" && ( - )} {tab === "speed" && ( - )} @@ -1982,6 +2004,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId } /> + {!canWrite && Только просмотрИзменение проб и запуск измерений требуют разрешения на запись. Обновление данных загружает сохранённые результаты.} + {permissionError && !sheetOpen && !speedSheetOpen && {permissionError}} + {/* ── Live indicator ── */} {!isPaused && (
Создайте пробу через кнопку «Новая speed-проба» в шапке страницы

-
@@ -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")}>
{/* enable toggle */} - updateSpeedProbe(probe.id, { enabled: v })} /> + updateSpeedProbe(probe.id, { enabled: v })} /> {/* route: src → dst */}
@@ -2215,18 +2240,18 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId {/* actions */}
@@ -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", )} > - +
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
@@ -2652,7 +2677,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId })}
+ {permissionError && {permissionError}}
- @@ -2844,6 +2872,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
+ {permissionError && {permissionError}}
{/* from → to visual */} @@ -2915,7 +2944,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId Отмена