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/permissions.ts b/server/lib/permissions.ts index edc9f7e183..5075087f1f 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -1,5 +1,9 @@ export enum Permission { NONE = 0, + // 1 (2^0) is used by STATUS_VIEW below — it was the only unused bit + // beneath the upstream MANAGE_BLOCKLIST/VIEW_BLOCKLIST values that + // still fits comfortably in Postgres `int4` for `user.permissions`. + STATUS_VIEW = 1, ADMIN = 2, MANAGE_SETTINGS = 4, MANAGE_USERS = 8, @@ -28,6 +32,9 @@ export enum Permission { RECENT_VIEW = 67108864, WATCHLIST_VIEW = 134217728, MANAGE_BLOCKLIST = 268435456, + // 2^29 (536870912) was unused in upstream — reserve it for the + // status-page report permission so we stay inside int4. + STATUS_REPORT = 536870912, VIEW_BLOCKLIST = 1073741824, } diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index f125b8c04f..aa93be9fff 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -478,7 +478,10 @@ class Settings { applicationTitle: 'Seerr', applicationUrl: '', cacheImages: false, - defaultPermissions: Permission.REQUEST, + defaultPermissions: + Permission.REQUEST | + Permission.STATUS_VIEW | + Permission.STATUS_REPORT, defaultQuotas: { movie: {}, tv: {}, 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/migration/postgres/1777500000000-GrantStatusPermissions.ts b/server/migration/postgres/1777500000000-GrantStatusPermissions.ts new file mode 100644 index 0000000000..9f4c230a85 --- /dev/null +++ b/server/migration/postgres/1777500000000-GrantStatusPermissions.ts @@ -0,0 +1,24 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Grants the two new status-page permissions (STATUS_VIEW = 1, + * STATUS_REPORT = 536870912) to every existing user so accounts created + * before this fork's status feature don't suddenly lose access. New + * users created from now on inherit these bits via `defaultPermissions`. + */ +export class GrantStatusPermissions1777500000000 implements MigrationInterface { + name = 'GrantStatusPermissions1777500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "user" SET "permissions" = "permissions" | 1 | 536870912` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Strip both bits on rollback. In Postgres bitwise NOT is `~`. + await queryRunner.query( + `UPDATE "user" SET "permissions" = "permissions" & ~1 & ~536870912` + ); + } +} diff --git a/server/migration/sqlite/1777500000000-GrantStatusPermissions.ts b/server/migration/sqlite/1777500000000-GrantStatusPermissions.ts new file mode 100644 index 0000000000..4273b1b853 --- /dev/null +++ b/server/migration/sqlite/1777500000000-GrantStatusPermissions.ts @@ -0,0 +1,24 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Grants the two new status-page permissions (STATUS_VIEW = 1, + * STATUS_REPORT = 536870912) to every existing user so accounts created + * before this fork's status feature don't suddenly lose access. New + * users created from now on inherit these bits via `defaultPermissions`. + */ +export class GrantStatusPermissions1777500000000 implements MigrationInterface { + name = 'GrantStatusPermissions1777500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "user" SET "permissions" = "permissions" | 1 | 536870912` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Strip both bits on rollback. Bitwise NOT in SQLite is `~`. + await queryRunner.query( + `UPDATE "user" SET "permissions" = "permissions" & ~1 & ~536870912` + ); + } +} diff --git a/server/routes/index.ts b/server/routes/index.ts index a0bda29321..7de1539983 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -172,7 +172,11 @@ router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/service', isAuthenticated(), serviceRoutes); router.use('/issue', isAuthenticated(), issueRoutes); router.use('/issueComment', isAuthenticated(), issueCommentRoutes); -router.use('/uptimerobot', isAuthenticated(), uptimeRobotRoutes); +router.use( + '/uptimerobot', + isAuthenticated(Permission.STATUS_VIEW), + uptimeRobotRoutes +); router.use('/auth', authRoutes); router.use( '/overrideRule', diff --git a/server/routes/uptimerobot.test.ts b/server/routes/uptimerobot.test.ts index 7820d7f270..1a5acc1987 100644 --- a/server/routes/uptimerobot.test.ts +++ b/server/routes/uptimerobot.test.ts @@ -6,6 +6,7 @@ import { Announcement } from '@server/entity/Announcement'; import { ProblemReport } from '@server/entity/ProblemReport'; import { User } from '@server/entity/User'; import { UserMonitorRecoverySubscription } from '@server/entity/UserMonitorRecoverySubscription'; +import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import uptimeRobotService from '@server/lib/uptimerobot'; import { checkUser, isAuthenticated } from '@server/middleware/auth'; @@ -14,6 +15,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'; @@ -31,7 +33,11 @@ function createApp() { ); app.use(checkUser); app.use('/auth', authRoutes); - app.use('/uptimerobot', isAuthenticated(), uptimeRobotRoutes); + app.use( + '/uptimerobot', + isAuthenticated(Permission.STATUS_VIEW), + uptimeRobotRoutes + ); app.use( ( err: { status?: number; message?: string }, @@ -409,6 +415,104 @@ 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('Status permissions', () => { + it('rejects users without STATUS_VIEW with 403 on GET /uptimerobot', async () => { + const limited = await loginAs('limited@seerr.dev', 'test1234'); + const res = await limited.get('/uptimerobot'); + assert.strictEqual(res.status, 403); + }); + + it('rejects users without STATUS_REPORT with 403 on POST /uptimerobot/reports', async () => { + // Grant just STATUS_VIEW so the user passes the parent gate. + const repo = getRepository(User); + const limited = await repo.findOneOrFail({ + where: { email: 'limited@seerr.dev' }, + }); + limited.permissions = 1; // STATUS_VIEW only + await repo.save(limited); + + const agent = await loginAs('limited@seerr.dev', 'test1234'); + const res = await agent + .post('/uptimerobot/reports') + .send({ monitorIds: [1002] }); + assert.strictEqual(res.status, 403); + }); + + it('lets users with STATUS_VIEW + STATUS_REPORT submit reports', async () => { + const repo = getRepository(User); + const limited = await repo.findOneOrFail({ + where: { email: 'limited@seerr.dev' }, + }); + limited.permissions = 1 | 536870912; // VIEW + REPORT + await repo.save(limited); + + const agent = await loginAs('limited@seerr.dev', 'test1234'); + const res = await agent + .post('/uptimerobot/reports') + .send({ monitorIds: [1002] }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.created, 1); + }); }); describe('Problem Reports', () => { diff --git a/server/routes/uptimerobot.ts b/server/routes/uptimerobot.ts index b748b65397..1dc8685002 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` @@ -440,6 +500,7 @@ statusRoutes.get('/reports', async (req, res, next) => { */ statusRoutes.post( '/reports', + isAuthenticated(Permission.STATUS_REPORT), async (req, res, next) => { if (!req.user) { return next({ status: 403, message: 'Authentication required.' }); diff --git a/server/utils/seedTestDb.ts b/server/utils/seedTestDb.ts index 266169d45c..990e0c1fe2 100644 --- a/server/utils/seedTestDb.ts +++ b/server/utils/seedTestDb.ts @@ -55,12 +55,34 @@ async function seedTestUsers(): Promise { otherUser.email = 'friend@seerr.dev'; otherUser.userType = UserType.PLEX; otherUser.password = TEST_USER_PASSWORD_HASH; - otherUser.permissions = 32; + // 32 = REQUEST, 1 = STATUS_VIEW, 536870912 = STATUS_REPORT. + // Mirrors the new default permissions for fresh users. + otherUser.permissions = 32 | 1 | 536870912; otherUser.avatar = gravatarUrl('friend@seerr.dev', { default: 'mm', size: 200, }); await userRepository.save(otherUser); + + // A user with absolutely no granted permissions, used by tests that + // exercise permission gating (status-page view + report). + const limited = + (await userRepository.findOne({ + where: { email: 'limited@seerr.dev' }, + })) ?? new User(); + limited.plexId = admin?.plexId ?? 1; + limited.plexToken = '1234'; + limited.plexUsername = 'limited'; + limited.username = 'limited'; + limited.email = 'limited@seerr.dev'; + limited.userType = UserType.PLEX; + limited.password = TEST_USER_PASSWORD_HASH; + limited.permissions = 0; + limited.avatar = gravatarUrl('limited@seerr.dev', { + default: 'mm', + size: 200, + }); + await userRepository.save(limited); } /** 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/Layout/MobileMenu/index.tsx b/src/components/Layout/MobileMenu/index.tsx index d2389b76ab..db5525c565 100644 --- a/src/components/Layout/MobileMenu/index.tsx +++ b/src/components/Layout/MobileMenu/index.tsx @@ -135,6 +135,7 @@ const MobileMenu = ({ svgIconSelected: , activeRegExp: /^\/status/, dataTestId: 'sidebar-menu-status', + requiredPermission: Permission.STATUS_VIEW, }, { href: '/users', diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index f24244be24..4f0e5cb58d 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -110,6 +110,7 @@ const SidebarLinks: SidebarLinkProps[] = [ svgIcon: , activeRegExp: /^\/status/, dataTestId: 'sidebar-menu-status', + requiredPermission: Permission.STATUS_VIEW, }, { href: '/users', 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/PermissionEdit/index.tsx b/src/components/PermissionEdit/index.tsx index cbba176cba..c654810298 100644 --- a/src/components/PermissionEdit/index.tsx +++ b/src/components/PermissionEdit/index.tsx @@ -85,6 +85,12 @@ export const messages = defineMessages('components.PermissionEdit', { viewblocklistedItems: 'View blocklisted media.', viewblocklistedItemsDescription: 'Grant permission to view blocklisted media.', + statusView: 'View Status Page', + statusViewDescription: + 'Grant permission to view the Status page and the home page banner that surfaces downed services.', + statusReport: 'Report a Problem', + statusReportDescription: + 'Grant permission to submit a “Report a Problem” form from the Status page.', }); interface PermissionEditProps { @@ -355,6 +361,20 @@ export const PermissionEdit = ({ }, ], }, + { + id: 'statusview', + name: intl.formatMessage(messages.statusView), + description: intl.formatMessage(messages.statusViewDescription), + permission: Permission.STATUS_VIEW, + children: [ + { + id: 'statusreport', + name: intl.formatMessage(messages.statusReport), + description: intl.formatMessage(messages.statusReportDescription), + permission: Permission.STATUS_REPORT, + }, + ], + }, ]; return ( diff --git a/src/components/Status/index.tsx b/src/components/Status/index.tsx index e38d77c2dc..539595e929 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,21 +47,14 @@ 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.', + noPermission: + 'You don’t have permission to view the Status page. Ask an administrator if you think this is wrong.', noMonitors: 'No monitors are currently configured.', fetchError: 'Unable to fetch the latest status from UptimeRobot. Showing the last known status.', @@ -71,8 +66,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', @@ -151,11 +146,13 @@ const Status = () => { const { hasPermission } = useUser(); const [busy, setBusy] = useState(null); + const canView = hasPermission(Permission.STATUS_VIEW); + const { data, error, mutate: revalidate, - } = useSWR('/api/v1/uptimerobot', { + } = useSWR(canView ? '/api/v1/uptimerobot' : null, { refreshInterval: 30000, }); @@ -176,53 +173,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); } }; @@ -358,6 +328,21 @@ const Status = () => { } }; + if (!canView) { + return ( + <> + +
{intl.formatMessage(messages.statusTitle)}
+
+ +
+ + ); + } + if (!data && !error) return ; if (!data) return null; @@ -373,35 +358,53 @@ const Status = () => { {intl.formatMessage(messages.statusDescription)}

- {data?.configured && data.monitors.length > 0 && ( -
- -
- )} + {data?.configured && + data.monitors.length > 0 && + hasPermission(Permission.STATUS_REPORT) && ( +
+ +
+ )} {data?.reportsSuppressedUntil && data.reportsSuppressedUntil > Date.now() && ( -
- +
+
+ +
+ {isAdmin && ( +
+ +
+ )}
)} @@ -564,11 +567,15 @@ const Status = () => { })}
)} - {monitor.description && ( -
- {monitor.description} -
- )} + {monitor.description && + !manualActive && + !(reportCounts ?? []).some( + (r) => r.monitorId === monitor.id && r.count > 0 + ) && ( +
+ {monitor.description} +
+ )} {monitor.url && ( {