From 7c22f5467c1a1e91c41bc4ff98812056de32949e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 02:57:13 +0000 Subject: [PATCH 1/2] feat(status): recovery cleanup, override modal in Broadcast, suppression clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recovery: prime in-memory state on subscription so users who tap Notify still get alerted even if the service never observed the monitor as down (process restart, brief blip). - Recovery + reports: clear active problem reports the moment a monitor flips from down to up — natural recovery and admin "Operational" override both wipe the reports. Manual recovery also dispatches the recovery web push without waiting on the stable window. - Status page: hide the per-monitor description when there's an active user-report or a manual override, since the override label / report badge already speaks for itself. - Status page: admin-only "Resume reports" button next to the suppression banner. New DELETE /uptimerobot/suppression endpoint clears the window. - Broadcast: title is now just "Broadcast"; new "Override service status" button opens the same modal as /status with a service picker inside. - Modal extracted to MonitorOverrideModal as a reusable component (used by both /status and /broadcast). - Copy: "Report a problem" -> "Report a Problem" (button + modal title). --- seerr-api.yml | 12 + server/lib/uptimerobot.ts | 85 ++++++ server/routes/uptimerobot.test.ts | 59 ++++ server/routes/uptimerobot.ts | 60 ++++ src/components/Broadcast/index.tsx | 59 +++- src/components/MonitorOverrideModal/index.tsx | 270 ++++++++++++++++++ src/components/Status/index.tsx | 233 +++++---------- src/i18n/locale/en.json | 41 ++- 8 files changed, 639 insertions(+), 180 deletions(-) create mode 100644 src/components/MonitorOverrideModal/index.tsx diff --git a/seerr-api.yml b/seerr-api.yml index 3fec5341f5..ba9ed9d675 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -2404,6 +2404,18 @@ paths: description: Invalid payload. '404': description: Monitor not found. + /uptimerobot/suppression: + delete: + summary: Clear an active report-suppression window + description: | + Admin-only. Lifts a previously set "suppress problem reports for + N minutes" window so users can file reports again. Idempotent — + returns 204 even when no suppression was active. + tags: + - uptimerobot + responses: + '204': + description: Suppression cleared (or none was active). /uptimerobot/reports: get: summary: List active per-monitor problem report counts diff --git a/server/lib/uptimerobot.ts b/server/lib/uptimerobot.ts index 8cea17dd33..f093cfd025 100644 --- a/server/lib/uptimerobot.ts +++ b/server/lib/uptimerobot.ts @@ -3,6 +3,7 @@ import UptimeRobotAPI, { type UptimeRobotMonitor, } from '@server/api/uptimerobot'; import { getRepository } from '@server/datasource'; +import { ProblemReport } from '@server/entity/ProblemReport'; import { User } from '@server/entity/User'; import { UserMonitorRecoverySubscription } from '@server/entity/UserMonitorRecoverySubscription'; import { UserPushSubscription } from '@server/entity/UserPushSubscription'; @@ -12,6 +13,7 @@ import { type UptimeRobotMonitorOverride, } from '@server/lib/settings'; import logger from '@server/logger'; +import { IsNull } from 'typeorm'; import webpush from 'web-push'; export interface MonitorSummary { @@ -287,6 +289,8 @@ class UptimeRobotService { seen.add(monitor.id); const state = this.recoveryStates.get(monitor.id) ?? {}; + const wasDown = !!state.lastDownAt && !state.upSince; + if (monitor.status === 'down') { state.lastDownAt = now; state.upSince = undefined; @@ -300,6 +304,21 @@ class UptimeRobotService { this.recoveryStates.set(monitor.id, state); + // First poll where the monitor flipped from down to up: clear any + // active user-reports right away so people stop seeing stale "X + // others reporting" badges. Independent of the recovery-stable + // window — if the service is back up we always want to wipe the + // reports. + if (wasDown && monitor.status === 'up') { + await this.clearActiveReportsForMonitor(monitor.id).catch((e) => { + logger.warn('Failed to clear active reports on recovery', { + label: 'UptimeRobot', + monitorId: monitor.id, + errorMessage: (e as Error).message, + }); + }); + } + if (!settings.recoveryNotificationsEnabled) continue; if (monitor.status !== 'up') continue; if (!state.lastDownAt || !state.upSince) continue; @@ -430,6 +449,72 @@ class UptimeRobotService { // Consume the recovery subscriptions — they are one-shot. await subRepo.remove(subscriptions); } + + /** + * Mark every active problem report for the given monitor as resolved. + * Returns the number of rows that were just resolved (0 when none). + */ + public async clearActiveReportsForMonitor( + monitorId: number + ): Promise { + const repo = getRepository(ProblemReport); + const rows = await repo.find({ + where: { monitorId, resolvedAt: IsNull() }, + }); + if (!rows.length) return 0; + const resolvedAt = new Date(); + await repo.save(rows.map((r) => Object.assign(r, { resolvedAt }))); + return rows.length; + } + + /** + * Prime the recovery state for a monitor at subscription time so that + * users who tap "Notify me" still get alerted even if the service + * never observed the monitor as down (e.g. process restarted between + * the down event and the user subscribing). No-op when the monitor is + * currently observed up — there's nothing to recover from. + */ + public primeRecovery(monitorId: number): void { + const monitor = this.latest.find((m) => m.id === monitorId); + if (!monitor) return; + if (monitor.status === 'up') return; + const state = this.recoveryStates.get(monitorId) ?? {}; + if (!state.lastDownAt) { + state.lastDownAt = Date.now(); + state.upSince = undefined; + this.recoveryStates.set(monitorId, state); + } + } + + /** + * Dispatch the recovery web push for a single monitor right now, without + * waiting for the natural recovery-stable window. Called from the + * `/uptimerobot/override` route when an admin pins the monitor back to + * `operational`. Report-clearing is the route's responsibility (it + * awaits `clearActiveReportsForMonitor` directly) — this method only + * handles the push side. + */ + public async fireManualRecovery(monitorId: number): Promise { + const monitor = this.latest.find((m) => m.id === monitorId); + if (!monitor) return; + + // Reset recovery state — the override is the source of truth now. + this.recoveryStates.set(monitorId, {}); + + try { + await this.dispatchRecoveryNotifications({ + ...monitor, + // Force "up" semantics so the dispatch payload reads naturally. + status: 'up', + }); + } catch (e) { + logger.error('Failed to dispatch manual recovery notifications', { + label: 'UptimeRobot', + monitorId, + errorMessage: (e as Error).message, + }); + } + } } const uptimeRobotService = new UptimeRobotService(); diff --git a/server/routes/uptimerobot.test.ts b/server/routes/uptimerobot.test.ts index 7820d7f270..1e60ae14dc 100644 --- a/server/routes/uptimerobot.test.ts +++ b/server/routes/uptimerobot.test.ts @@ -14,6 +14,7 @@ import type { Express } from 'express'; import express from 'express'; import session from 'express-session'; import request from 'supertest'; +import { IsNull } from 'typeorm'; import authRoutes from './auth'; import uptimeRobotRoutes from './uptimerobot'; @@ -409,6 +410,64 @@ describe('POST /uptimerobot/override', () => { assert.strictEqual(ov?.hideFromReports, true); assert.strictEqual(ov?.manualStatus, undefined); }); + + it('clears active reports when status is set to operational', async () => { + // Seed monitor 1002 (which the mock reports as down) with an active + // problem report, then have an admin pin it operational. + const friend = await getRepository(User).findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + await getRepository(ProblemReport).save( + new ProblemReport({ + reporter: friend, + monitorId: 1002, + monitorNameSnapshot: 'Web', + monitorStatusAtReport: 'down', + }) + ); + + const admin = await loginAs('admin@seerr.dev', 'test1234'); + const res = await admin + .post('/uptimerobot/override') + .send({ monitorId: 1002, status: 'operational', minutes: 30 }); + assert.strictEqual(res.status, 200); + + // The route awaits `clearActiveReportsForMonitor` before responding, + // so by the time we get a 200 the reports are already resolved. + const stillActive = await getRepository(ProblemReport).find({ + where: { resolvedAt: IsNull() }, + }); + assert.strictEqual(stillActive.length, 0); + }); +}); + +describe('DELETE /uptimerobot/suppression', () => { + it('rejects non-admin users with 403', async () => { + const friend = await loginAs('friend@seerr.dev', 'test1234'); + const res = await friend.delete('/uptimerobot/suppression'); + assert.strictEqual(res.status, 403); + }); + + it('clears an active suppression window', async () => { + const settings = getSettings(); + settings.uptimerobot.reportsSuppressedUntil = Date.now() + 60 * 60_000; + + const admin = await loginAs('admin@seerr.dev', 'test1234'); + const res = await admin.delete('/uptimerobot/suppression'); + assert.strictEqual(res.status, 204); + assert.strictEqual( + getSettings().uptimerobot.reportsSuppressedUntil, + undefined + ); + }); + + it('is idempotent when no suppression is active', async () => { + const settings = getSettings(); + settings.uptimerobot.reportsSuppressedUntil = undefined; + const admin = await loginAs('admin@seerr.dev', 'test1234'); + const res = await admin.delete('/uptimerobot/suppression'); + assert.strictEqual(res.status, 204); + }); }); describe('Problem Reports', () => { diff --git a/server/routes/uptimerobot.ts b/server/routes/uptimerobot.ts index b748b65397..9f16cc02d3 100644 --- a/server/routes/uptimerobot.ts +++ b/server/routes/uptimerobot.ts @@ -140,6 +140,10 @@ statusRoutes.post<{ monitorId: string }>( }) ); } + // Prime in-memory recovery state so that even if the service never + // observed this monitor as down (process restart, brief blip + // between polls), the next "up" tick starts the recovery clock. + uptimeRobotService.primeRecovery(monitorId); return res.status(204).send(); } catch (e) { logger.error('Failed to register monitor recovery subscription', { @@ -318,6 +322,11 @@ statusRoutes.post< const idx = overrides.findIndex((o) => o.id === monitorId); const existing = idx >= 0 ? overrides[idx] : { id: monitorId }; + // Snapshot the *effective* status before we apply the override so we + // can detect a transition into operational. + const previousMonitor = monitors.find((m) => m.id === monitorId); + const wasUp = previousMonitor?.status === 'up'; + if (status === null) { const next = { ...existing, @@ -355,6 +364,31 @@ statusRoutes.post< settings.uptimerobot.monitorOverrides = overrides; await settings.save(); + // If the admin pinned the monitor back to "operational" while the + // effective status was something other than up, treat it as a recovery + // event: clear any active problem reports synchronously so the + // per-monitor badges go away in the same response, then dispatch the + // recovery web push fire-and-forget (it can be slow when there are + // many subscribers). + if (status === 'operational' && !wasUp) { + try { + await uptimeRobotService.clearActiveReportsForMonitor(monitorId); + } catch (e) { + logger.warn('Failed to clear reports on manual recovery', { + label: 'UptimeRobot', + monitorId, + errorMessage: (e as Error).message, + }); + } + uptimeRobotService.fireManualRecovery(monitorId).catch((e) => { + logger.warn('Manual recovery dispatch failed', { + label: 'UptimeRobot', + monitorId, + errorMessage: (e as Error).message, + }); + }); + } + return res.status(200).json({ monitorId, manualStatus: status, @@ -377,6 +411,32 @@ statusRoutes.post< } }); +/** + * Clear an active "suppress problem reports" window so admins can lift + * the block from the status page UI without waiting it out. + */ +statusRoutes.delete( + '/suppression', + isAuthenticated(Permission.ADMIN), + async (_req, res, next) => { + try { + const settings = getSettings(); + settings.uptimerobot.reportsSuppressedUntil = undefined; + await settings.save(); + return res.status(204).send(); + } catch (e) { + logger.error('Failed to clear report suppression window', { + label: 'API', + errorMessage: (e as Error).message, + }); + return next({ + status: 500, + message: 'Failed to clear suppression.', + }); + } + } +); + /** * Returns the count of active problem reports per monitor visible on the * public status page. Hidden monitors are not surfaced. `userReported` diff --git a/src/components/Broadcast/index.tsx b/src/components/Broadcast/index.tsx index 10e03a84cf..2dfaea48bd 100644 --- a/src/components/Broadcast/index.tsx +++ b/src/components/Broadcast/index.tsx @@ -5,10 +5,17 @@ import CachedImage from '@app/components/Common/CachedImage'; import Header from '@app/components/Common/Header'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; +import MonitorOverrideModal, { + type MonitorOption, +} from '@app/components/MonitorOverrideModal'; +import type { StatusResponse } from '@app/components/Status'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; -import { MegaphoneIcon } from '@heroicons/react/24/outline'; +import { + AdjustmentsHorizontalIcon, + MegaphoneIcon, +} from '@heroicons/react/24/outline'; import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; import { hasPermission } from '@server/lib/permissions'; import axios from 'axios'; @@ -21,9 +28,12 @@ import * as Yup from 'yup'; const messages = defineMessages('components.Broadcast', { broadcast: 'Broadcast', - broadcastTitle: 'Broadcast Notification', + broadcastTitle: 'Broadcast', broadcastDescription: 'Send a custom web push notification to a selection of users or to everyone with push notifications enabled.', + overrideStatus: 'Override service status', + overrideStatusTip: + 'Open the manual-status modal to pin a service to Operational, Maintenance, Degraded, etc. Useful when announcing planned maintenance.', subject: 'Title', subjectPlaceholder: 'Server Maintenance', subjectTip: 'The notification headline that recipients will see.', @@ -81,8 +91,28 @@ const Broadcast = () => { '/api/v1/user?take=1000&sort=displayname&sortDirection=asc' ); + const { data: statusData, mutate: revalidateStatus } = useSWR( + '/api/v1/uptimerobot' + ); + const allUsers = useMemo(() => usersData?.results ?? [], [usersData]); + // The override modal opens to a "blank" picker on Broadcast — admins + // pick the service inside the modal. Use a sentinel monitor object so + // the modal's `monitor` prop is non-null (which is what controls the + // open/closed state). + const [overrideOpen, setOverrideOpen] = useState(false); + const overrideMonitors: MonitorOption[] = useMemo( + () => + (statusData?.monitors ?? []).map((m) => ({ + id: m.id, + name: m.name, + manualStatus: m.manualStatus, + manualStatusUntil: m.manualStatusUntil, + })), + [statusData] + ); + const toggleUser = (userId: number) => { setSelectedUserIds((prev) => prev.includes(userId) @@ -467,8 +497,18 @@ const Broadcast = () => {
-
- +
+ +
+

+ {intl.formatMessage(messages.overrideStatusTip)} +

)} + + setOverrideOpen(false)} + onApplied={() => revalidateStatus()} + /> ); }; diff --git a/src/components/MonitorOverrideModal/index.tsx b/src/components/MonitorOverrideModal/index.tsx new file mode 100644 index 0000000000..2536ef2171 --- /dev/null +++ b/src/components/MonitorOverrideModal/index.tsx @@ -0,0 +1,270 @@ +import Modal from '@app/components/Common/Modal'; +import defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; +import axios from 'axios'; +import { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; + +const messages = defineMessages('components.MonitorOverrideModal', { + title: 'Override status', + description: + 'Pin {name} to a fixed status for the next few minutes. Auto-clears once the duration elapses.', + pickerLabel: 'Service', + pickerPlaceholder: 'Select a service', + statusLabel: 'Status', + minutesLabel: 'Duration (minutes)', + none: 'Automatic', + manualOperational: 'Operational', + manualMaintenance: 'Scheduled Maintenance', + manualDegraded: 'Degraded Performance', + manualPartialOutage: 'Partial Outage', + manualMajorOutage: 'Major Outage', + submit: 'Apply override', + submitting: 'Applying…', + clear: 'Clear override', + selectFirst: 'Pick a service first.', + successApplied: 'Status override applied.', + successCleared: 'Status override cleared.', + failed: 'Could not apply the status override.', +}); + +export type ManualStatus = + | 'operational' + | 'maintenance' + | 'degraded' + | 'partial_outage' + | 'major_outage'; + +export interface MonitorOption { + id: number; + name: string; + manualStatus?: ManualStatus; + manualStatusUntil?: number; +} + +interface MonitorOverrideModalProps { + /** Whether the modal is visible. */ + isOpen: boolean; + /** + * When provided, this service is pre-selected on open. Pass `null` to + * start the modal with the picker unset (admin chooses a service inside + * the modal — used by the Broadcast page). + */ + presetMonitor?: MonitorOption | null; + /** Full list of available monitors for the picker. */ + monitors: MonitorOption[]; + onClose: () => void; + onApplied: () => void; +} + +const MonitorOverrideModal = ({ + isOpen, + presetMonitor, + monitors, + onClose, + onApplied, +}: MonitorOverrideModalProps) => { + const intl = useIntl(); + const { addToast } = useToasts(); + + const [selectedId, setSelectedId] = useState(null); + const [status, setStatus] = useState(''); + const [minutes, setMinutes] = useState(60); + const [submitting, setSubmitting] = useState(false); + + // Re-seed the local form state every time the modal opens. + useEffect(() => { + if (!isOpen) return; + if (!presetMonitor) { + setSelectedId(null); + setStatus(''); + setMinutes(60); + return; + } + setSelectedId(presetMonitor.id); + setStatus(presetMonitor.manualStatus ?? ''); + if ( + presetMonitor.manualStatusUntil && + presetMonitor.manualStatusUntil > Date.now() + ) { + setMinutes( + Math.max( + 1, + Math.round((presetMonitor.manualStatusUntil - Date.now()) / 60000) + ) + ); + } else { + setMinutes(60); + } + }, [isOpen, presetMonitor]); + + const selected = monitors.find((m) => m.id === selectedId) ?? null; + + const apply = async (clear: boolean) => { + if (!selectedId) { + addToast(intl.formatMessage(messages.selectFirst), { + appearance: 'error', + autoDismiss: true, + }); + return; + } + setSubmitting(true); + try { + await axios.post('/api/v1/uptimerobot/override', { + monitorId: selectedId, + status: clear ? null : status || null, + minutes, + }); + addToast( + intl.formatMessage( + clear || !status ? messages.successCleared : messages.successApplied + ), + { appearance: 'success', autoDismiss: true } + ); + onApplied(); + onClose(); + } catch { + addToast(intl.formatMessage(messages.failed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setSubmitting(false); + } + }; + + return ( + + apply(false)} + okText={ + submitting + ? intl.formatMessage(messages.submitting) + : intl.formatMessage(messages.submit) + } + okButtonType="primary" + okDisabled={submitting || !selectedId} + onSecondary={() => apply(true)} + secondaryText={intl.formatMessage(messages.clear)} + secondaryButtonType="warning" + secondaryDisabled={submitting || !selected?.manualStatus} + > + {selected && ( +

+ {intl.formatMessage(messages.description, { + name: selected.name, + })} +

+ )} +
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ + setMinutes( + Math.max(1, Math.min(1440, Number(e.target.value) || 0)) + ) + } + /> +
+
+
+
+
+ ); +}; + +export default MonitorOverrideModal; diff --git a/src/components/Status/index.tsx b/src/components/Status/index.tsx index e38d77c2dc..b325f4096b 100644 --- a/src/components/Status/index.tsx +++ b/src/components/Status/index.tsx @@ -6,6 +6,7 @@ import Header from '@app/components/Common/Header'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import Modal from '@app/components/Common/Modal'; import PageTitle from '@app/components/Common/PageTitle'; +import MonitorOverrideModal from '@app/components/MonitorOverrideModal'; import { Permission, useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; @@ -15,6 +16,7 @@ import { ExclamationTriangleIcon, PencilSquareIcon, TrashIcon, + XCircleIcon, } from '@heroicons/react/24/outline'; import axios from 'axios'; import { useState } from 'react'; @@ -45,19 +47,10 @@ const messages = defineMessages('components.Status', { notifyMe: 'Notify me', notifying: 'Subscribed', cancel: 'Cancel notification', - overrideTitle: 'Override status', - overrideDescription: - 'Pin {name} to a fixed status for the next few minutes. Auto-clears once the duration elapses.', - overrideStatusLabel: 'Status', - overrideMinutesLabel: 'Duration (minutes)', - overrideNone: 'Automatic', - overrideSubmit: 'Apply override', - overrideSubmitting: 'Applying…', - overrideClear: 'Clear override', overrideOpen: 'Override status', - overrideSuccess: 'Status override applied.', - overrideCleared: 'Status override cleared.', - overrideFailed: 'Could not apply the status override.', + clearSuppression: 'Resume reports', + clearSuppressionSuccess: 'Reports are no longer suppressed.', + clearSuppressionFailed: 'Could not lift the suppression.', notConfigured: 'Status monitoring has not been configured. Ask an administrator to set up UptimeRobot in the admin settings.', noMonitors: 'No monitors are currently configured.', @@ -71,8 +64,8 @@ const messages = defineMessages('components.Status', { 'Recovery notifications are currently disabled by the administrator.', visitMonitor: 'Open', lastChecked: 'Last checked {time}', - reportProblem: 'Report a problem', - reportTitle: 'Report a problem', + reportProblem: 'Report a Problem', + reportTitle: 'Report a Problem', reportDescription: 'Check off any services you’re having trouble with. The administrator will be notified, and other people will see that you’re also having issues.', reportSubmit: 'Submit', @@ -176,53 +169,26 @@ const Status = () => { const [overrideMonitor, setOverrideMonitor] = useState( null ); - const [overrideStatus, setOverrideStatus] = useState(''); - const [overrideMinutes, setOverrideMinutes] = useState(60); - const [overrideSubmitting, setOverrideSubmitting] = useState(false); + const [clearingSuppression, setClearingSuppression] = useState(false); const isAdmin = hasPermission(Permission.ADMIN); - const openOverride = (monitor: StatusMonitor) => { - setOverrideMonitor(monitor); - setOverrideStatus(monitor.manualStatus ?? ''); - if (monitor.manualStatusUntil && monitor.manualStatusUntil > Date.now()) { - setOverrideMinutes( - Math.max( - 1, - Math.round((monitor.manualStatusUntil - Date.now()) / 60000) - ) - ); - } else { - setOverrideMinutes(60); - } - }; - - const submitOverride = async (clear = false) => { - if (!overrideMonitor) return; - setOverrideSubmitting(true); + const clearSuppression = async () => { + setClearingSuppression(true); try { - await axios.post('/api/v1/uptimerobot/override', { - monitorId: overrideMonitor.id, - status: clear ? null : overrideStatus || null, - minutes: overrideMinutes, + await axios.delete('/api/v1/uptimerobot/suppression'); + addToast(intl.formatMessage(messages.clearSuppressionSuccess), { + appearance: 'success', + autoDismiss: true, }); - addToast( - intl.formatMessage( - clear || !overrideStatus - ? messages.overrideCleared - : messages.overrideSuccess - ), - { appearance: 'success', autoDismiss: true } - ); - setOverrideMonitor(null); await revalidate(); } catch { - addToast(intl.formatMessage(messages.overrideFailed), { + addToast(intl.formatMessage(messages.clearSuppressionFailed), { appearance: 'error', autoDismiss: true, }); } finally { - setOverrideSubmitting(false); + setClearingSuppression(false); } }; @@ -397,11 +363,27 @@ const Status = () => { {data?.reportsSuppressedUntil && data.reportsSuppressedUntil > Date.now() && ( -
- +
+
+ +
+ {isAdmin && ( +
+ +
+ )}
)} @@ -564,11 +546,15 @@ const Status = () => { })}
)} - {monitor.description && ( -
- {monitor.description} -
- )} + {monitor.description && + !manualActive && + !(reportCounts ?? []).some( + (r) => r.monitorId === monitor.id && r.count > 0 + ) && ( +
+ {monitor.description} +
+ )} {monitor.url && ( {
- {data?.configured && data.monitors.length > 0 && ( -
- -
- )} + {data?.configured && + data.monitors.length > 0 && + hasPermission(Permission.STATUS_REPORT) && ( +
+ +
+ )} {data?.reportsSuppressedUntil && diff --git a/src/components/StatusBanner/index.tsx b/src/components/StatusBanner/index.tsx index b06def6481..57de3e4692 100644 --- a/src/components/StatusBanner/index.tsx +++ b/src/components/StatusBanner/index.tsx @@ -1,5 +1,6 @@ import Button from '@app/components/Common/Button'; import type { StatusResponse } from '@app/components/Status'; +import { Permission, useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; import { BellAlertIcon, @@ -27,11 +28,19 @@ const messages = defineMessages('components.StatusBanner', { const StatusBanner = () => { const intl = useIntl(); const { addToast } = useToasts(); + const { hasPermission } = useUser(); const [busy, setBusy] = useState(null); - const { data, mutate } = useSWR('/api/v1/uptimerobot', { - refreshInterval: 60000, - }); + const canViewStatus = hasPermission(Permission.STATUS_VIEW); + + const { data, mutate } = useSWR( + canViewStatus ? '/api/v1/uptimerobot' : null, + { refreshInterval: 60000 } + ); + + if (!canViewStatus) { + return null; + } if (!data || !data.configured) return null; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index d1117c9155..a54f9e01c2 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -500,6 +500,10 @@ "components.PermissionEdit.requestMoviesDescription": "Grant permission to submit requests for non-4K movies.", "components.PermissionEdit.requestTv": "Request Series", "components.PermissionEdit.requestTvDescription": "Grant permission to submit requests for non-4K series.", + "components.PermissionEdit.statusReport": "Report a Problem", + "components.PermissionEdit.statusReportDescription": "Grant permission to submit a “Report a Problem” form from the Status page.", + "components.PermissionEdit.statusView": "View Status Page", + "components.PermissionEdit.statusViewDescription": "Grant permission to view the Status page and the home page banner that surfaces downed services.", "components.PermissionEdit.users": "Manage Users", "components.PermissionEdit.usersDescription": "Grant permission to manage users. Users with this permission cannot modify users with or grant the Admin privilege.", "components.PermissionEdit.viewblocklistedItems": "View blocklisted media.", @@ -1419,6 +1423,7 @@ "components.Status.monitorUnknown": "Unknown", "components.Status.monitorUp": "Operational", "components.Status.noMonitors": "No monitors are currently configured.", + "components.Status.noPermission": "You don’t have permission to view the Status page. Ask an administrator if you think this is wrong.", "components.Status.notConfigured": "Status monitoring has not been configured. Ask an administrator to set up UptimeRobot in the admin settings.", "components.Status.notifyMe": "Notify me", "components.Status.notifying": "Subscribed",