From a6c8eb6e12e129427bb4271c03167b5f31ce78eb Mon Sep 17 00:00:00 2001 From: Bennet Gallein Date: Wed, 30 Apr 2025 19:09:53 +0200 Subject: [PATCH] maintenance mode (#101) * feat: move types * feat: add maintenance alert * feat: move to config/base class to have fallback values on missing exports * fix: wrong import for types * Revert "fix: wrong import for types" This reverts commit ca2b2e3a68464cfa1b4aef4d99978de02591df74. * Revert "feat: move to config/base class to have fallback values on missing exports" This reverts commit 9932ea2590193aabd248fbba6b2c3a215da45d18. * fix: imports * chore: formatting * fix: handle filtering for global status * fix: pass props * fix: ignore active maintenances when sending notifications * fix: mark as warning when monitor is in maintenance * fix: title width & warning icon * fix: remove accordeons again * alert config/style changes, worker code improve * update example config * update example config * set monitors to be optional --------- Co-authored-by: lyc8503 --- components/DetailBar.tsx | 2 +- components/DetailChart.tsx | 2 +- components/Header.tsx | 7 ++- components/MaintenanceAlert.tsx | 53 ++++++++++++++++ components/MonitorDetail.tsx | 32 ++++++++-- components/MonitorList.tsx | 6 +- components/OverallStatus.tsx | 54 +++++++++++------ pages/api/data.ts | 2 +- pages/index.tsx | 6 +- types/config.ts | 103 ++++++++++++++++++++++++++++++++ uptime.config.ts | 31 +++++++++- uptime.types.ts | 51 ---------------- worker/src/index.ts | 32 +++++++--- 13 files changed, 283 insertions(+), 98 deletions(-) create mode 100644 components/MaintenanceAlert.tsx create mode 100644 types/config.ts delete mode 100644 uptime.types.ts diff --git a/components/DetailBar.tsx b/components/DetailBar.tsx index 4c05b93..d336fcc 100644 --- a/components/DetailBar.tsx +++ b/components/DetailBar.tsx @@ -1,4 +1,4 @@ -import { MonitorState, MonitorTarget } from '@/uptime.types' +import { MonitorState, MonitorTarget } from '@/types/config' import { getColor } from '@/util/color' import { Box, Tooltip, Modal } from '@mantine/core' import { useResizeObserver } from '@mantine/hooks' diff --git a/components/DetailChart.tsx b/components/DetailChart.tsx index c6c45e6..76a6536 100644 --- a/components/DetailChart.tsx +++ b/components/DetailChart.tsx @@ -11,7 +11,7 @@ import { TimeScale, } from 'chart.js' import 'chartjs-adapter-moment' -import { MonitorState, MonitorTarget } from '@/uptime.types' +import { MonitorState, MonitorTarget } from '@/types/config' import { iataToCountry } from '@/util/iata' ChartJS.register( diff --git a/components/Header.tsx b/components/Header.tsx index 2b1be33..2e6a149 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,9 +1,10 @@ import { Container, Group, Text } from '@mantine/core' import classes from '@/styles/Header.module.css' import { pageConfig } from '@/uptime.config' +import { PageConfigLink } from '@/types/config' export default function Header() { - const linkToElement = (link: { label: string; link: string; highlight?: boolean }) => { + const linkToElement = (link: PageConfigLink) => { return ( - {pageConfig.links.map(linkToElement)} + {pageConfig.links?.map(linkToElement)} - {pageConfig.links.filter((link) => (link as any).highlight).map(linkToElement)} + {pageConfig.links?.filter((link) => link.highlight).map(linkToElement)} diff --git a/components/MaintenanceAlert.tsx b/components/MaintenanceAlert.tsx new file mode 100644 index 0000000..40f9d20 --- /dev/null +++ b/components/MaintenanceAlert.tsx @@ -0,0 +1,53 @@ +import { Alert, List, Text } from '@mantine/core' +import { IconAlertTriangle } from '@tabler/icons-react' +import { MaintenanceConfig, MonitorTarget } from '@/types/config' + +export default function MaintenanceAlert({ + maintenance, + style, +}: { + maintenance: Omit & { monitors?: MonitorTarget[] } + style?: React.CSSProperties +}) { + return ( + } + title={maintenance.title || 'Scheduled Maintenance'} + color={maintenance.color || 'yellow'} + withCloseButton={false} + style={{ position: 'relative', margin: '16px auto 0 auto', ...style }} + > + {/* Date range in top right */} +
+ From: {new Date(maintenance.start).toLocaleString()} +
+ To:{' '} + {maintenance.end ? new Date(maintenance.end).toLocaleString() : 'Until further notice'} +
+ + {maintenance.body} + {maintenance.monitors && maintenance.monitors.length > 0 && ( + <> + + Affected components: + + + {maintenance.monitors.map((comp, compIdx) => ( + {comp.name} + ))} + + + )} +
+ ) +} diff --git a/components/MonitorDetail.tsx b/components/MonitorDetail.tsx index 3fd9cf6..66f95c6 100644 --- a/components/MonitorDetail.tsx +++ b/components/MonitorDetail.tsx @@ -1,9 +1,10 @@ import { Text, Tooltip } from '@mantine/core' -import { MonitorState, MonitorTarget } from '@/uptime.types' -import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-react' +import { MonitorState, MonitorTarget } from '@/types/config' +import { IconAlertCircle, IconAlertTriangle, IconCircleCheck } from '@tabler/icons-react' import DetailChart from './DetailChart' import DetailBar from './DetailBar' import { getColor } from '@/util/color' +import { maintenances } from '@/uptime.config' export default function MonitorDetail({ monitor, @@ -25,11 +26,32 @@ export default function MonitorDetail({ ) - const statusIcon = + let statusIcon = state.incident[monitor.id].slice(-1)[0].end === undefined ? ( - + ) : ( - + + ) + + // Hide real status icon if monitor is in maintenance + const now = new Date() + const hasMaintenance = maintenances + .filter((m) => now >= new Date(m.start) && (!m.end || now <= new Date(m.end))) + .find((maintenance) => maintenance.monitors?.includes(monitor.id)) + if (hasMaintenance) + statusIcon = ( + ) let totalTime = Date.now() / 1000 - state.incident[monitor.id][0].start[0] diff --git a/components/MonitorList.tsx b/components/MonitorList.tsx index c8530b5..f1f9d2f 100644 --- a/components/MonitorList.tsx +++ b/components/MonitorList.tsx @@ -1,4 +1,4 @@ -import { MonitorState, MonitorTarget } from '@/uptime.types' +import { MonitorState, MonitorTarget } from '@/types/config' import { Accordion, Card, Center, Text } from '@mantine/core' import MonitorDetail from './MonitorDetail' import { pageConfig } from '@/uptime.config' @@ -102,8 +102,8 @@ export default function MonitorList({ shadow="sm" padding="lg" radius="md" - ml="xl" - mr="xl" + ml="md" + mr="md" mt="xl" withBorder={!groupedMonitor} style={{ width: groupedMonitor ? '897px' : '865px' }} diff --git a/components/OverallStatus.tsx b/components/OverallStatus.tsx index fba8a9f..3c42ed5 100644 --- a/components/OverallStatus.tsx +++ b/components/OverallStatus.tsx @@ -1,31 +1,32 @@ -import { Center, Title } from '@mantine/core' +import { MaintenanceConfig, MonitorTarget } from '@/types/config' +import { Center, Container, Title } from '@mantine/core' import { IconCircleCheck, IconAlertCircle } from '@tabler/icons-react' import { useEffect, useState } from 'react' +import MaintenanceAlert from './MaintenanceAlert' +import { pageConfig } from '@/uptime.config' function useWindowVisibility() { const [isVisible, setIsVisible] = useState(true) - useEffect(() => { - const handleVisibilityChange = () => { - console.log('visibility change', document.visibilityState) - setIsVisible(document.visibilityState === 'visible') - } - + const handleVisibilityChange = () => setIsVisible(document.visibilityState === 'visible') document.addEventListener('visibilitychange', handleVisibilityChange) - - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange) - } + return () => document.removeEventListener('visibilitychange', handleVisibilityChange) }, []) - return isVisible } export default function OverallStatus({ state, + maintenances, + monitors, }: { state: { overallUp: number; overallDown: number; lastUpdate: number } + maintenances: MaintenanceConfig[] + monitors: MonitorTarget[] }) { + let group = pageConfig.group + let groupedMonitor = (group && Object.keys(group).length > 0) || false + let statusString = '' let icon = if (state.overallUp === 0 && state.overallDown === 0) { @@ -47,21 +48,28 @@ export default function OverallStatus({ useEffect(() => { const interval = setInterval(() => { - if (!isWindowVisible) { - return - } + if (!isWindowVisible) return if (currentTime - state.lastUpdate > 300 && currentTime - openTime > 30) { - // trigger a re-fetch window.location.reload() } setCurrentTime(Math.round(Date.now() / 1000)) }, 1000) - return () => clearInterval(interval) }) + const now = new Date() + let filteredMaintenances: (Omit & { monitors?: MonitorTarget[] })[] = + maintenances + .filter((m) => now >= new Date(m.start) && (!m.end || now <= new Date(m.end))) + .map((maintenance) => ({ + ...maintenance, + monitors: maintenance.monitors?.map( + (monitorId) => monitors.find((mon) => monitorId === mon.id)! + ), + })) + return ( - <> +
{icon}
{statusString} @@ -72,6 +80,14 @@ export default function OverallStatus({ currentTime - state.lastUpdate } sec ago)`} - + + {filteredMaintenances.map((maintenance, idx) => ( + + ))} +
) } diff --git a/pages/api/data.ts b/pages/api/data.ts index efeb0b9..ba52661 100644 --- a/pages/api/data.ts +++ b/pages/api/data.ts @@ -1,5 +1,5 @@ import { workerConfig } from '@/uptime.config' -import { MonitorState } from '@/uptime.types' +import { MonitorState } from '@/types/config' import { NextRequest } from 'next/server' export const runtime = 'edge' diff --git a/pages/index.tsx b/pages/index.tsx index 38a947a..7a00c3c 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,9 +1,9 @@ import Head from 'next/head' import { Inter } from 'next/font/google' -import { MonitorState, MonitorTarget } from '@/uptime.types' +import { MonitorState, MonitorTarget } from '@/types/config' import { KVNamespace } from '@cloudflare/workers-types' -import { pageConfig, workerConfig } from '@/uptime.config' +import { maintenances, pageConfig, workerConfig } from '@/uptime.config' import OverallStatus from '@/components/OverallStatus' import Header from '@/components/Header' import MonitorList from '@/components/MonitorList' @@ -60,7 +60,7 @@ export default function Home({ ) : (
- +
)} diff --git a/types/config.ts b/types/config.ts new file mode 100644 index 0000000..a18be28 --- /dev/null +++ b/types/config.ts @@ -0,0 +1,103 @@ +export type PageConfig = { + title?: string + links?: PageConfigLink[] + group?: PageConfigGroup +} + +export type MaintenanceConfig = { + monitors?: string[] + title?: string + body: string + start: number | string + end?: number | string + color?: string +} + +export type PageConfigGroup = { [key: string]: string[] } + +export type PageConfigLink = { + link: string + label: string + highlight?: boolean +} + +export type MonitorTarget = { + id: string + name: string + method: string + target: string + tooltip?: string + statusPageLink?: string + hideLatencyChart?: boolean + expectedCodes?: number[] + timeout?: number + headers?: { [key: string]: string | number } + body?: string + responseKeyword?: string + responseForbiddenKeyword?: string + checkProxy?: string + checkProxyFallback?: boolean +} + +export type WorkerConfig = { + kvWriteCooldownMinutes: number + monitors: MonitorTarget[] + notification?: Notification + callbacks?: Callbacks +} + +export type Notification = { + appriseApiServer?: string + recipientUrl?: string + timeZone?: string + gracePeriod?: number + skipNotificationIds?: string[] +} + +export type Callbacks = { + onStatusChange: ( + env: any, + monitor: any, + isUp: boolean, + timeIncidentStart: number, + timeNow: number, + reason: string + ) => Promise + onIncident: ( + env: any, + monitor: any, + timeIncidentStart: number, + timeNow: number, + reason: string + ) => Promise +} + +export type MonitorState = { + lastUpdate: number + overallUp: number + overallDown: number + incident: Record< + string, + { + start: number[] + end: number | undefined // undefined if it's still open + error: string[] + }[] + > + + latency: Record< + string, + { + recent: { + loc: string + ping: number + time: number + }[] // recent 12 hour data, 2 min interval + all: { + loc: string + ping: number + time: number + }[] // all data in 90 days, 1 hour interval + } + > +} diff --git a/uptime.config.ts b/uptime.config.ts index 3d22e6d..65e9356 100644 --- a/uptime.config.ts +++ b/uptime.config.ts @@ -1,4 +1,6 @@ -const pageConfig = { +import { MaintenanceConfig, PageConfig, WorkerConfig } from './types/config' + +const pageConfig: PageConfig = { // Title for your status page title: "lyc8503's Status Page", // Links shown at the header of your status page, could set `highlight` to `true` @@ -16,7 +18,7 @@ const pageConfig = { }, } -const workerConfig = { +const workerConfig: WorkerConfig = { // Write KV at most every 3 minutes unless the status changed kvWriteCooldownMinutes: 3, // Enable HTTP Basic auth for status page & API by uncommenting the line below, format `:` @@ -117,5 +119,28 @@ const workerConfig = { }, } +// You can define multiple maintenances here +// During maintenance, an alert will be shown at status page +// Also, related downtime notifications will be skipped (if any) +// Of course, you can leave it empty if you don't need this feature +// const maintenances: MaintenanceConfig[] = [] +const maintenances: MaintenanceConfig[] = [ + { + // [Optional] Monitor IDs to be affected by this maintenance + monitors: ['foo_monitor', 'bar_monitor'], + // [Optional] default to "Scheduled Maintenance" if not specified + title: 'Test Maintenance', + // Description of the maintenance, will be shown at status page + body: 'This is a test maintenance, server software upgrade', + // Start time of the maintenance, in UNIX timestamp or ISO 8601 format + start: '2025-04-27T00:00:00+08:00', + // [Optional] end time of the maintenance, in UNIX timestamp or ISO 8601 format + // if not specified, the maintenance will be considered as on-going + end: '2025-04-30T00:00:00+08:00', + // [Optional] color of the maintenance alert at status page, default to "yellow" + color: 'blue', + }, +] + // Don't forget this, otherwise compilation fails. -export { pageConfig, workerConfig } +export { pageConfig, workerConfig, maintenances } diff --git a/uptime.types.ts b/uptime.types.ts deleted file mode 100644 index f287622..0000000 --- a/uptime.types.ts +++ /dev/null @@ -1,51 +0,0 @@ -type MonitorState = { - lastUpdate: number - overallUp: number - overallDown: number - incident: Record< - string, - { - start: number[] - end: number | undefined // undefined if it's still open - error: string[] - }[] - > - - latency: Record< - string, - { - recent: { - loc: string - ping: number - time: number - }[] // recent 12 hour data, 2 min interval - all: { - loc: string - ping: number - time: number - }[] // all data in 90 days, 1 hour interval - } - > -} - -type MonitorTarget = { - id: string - name: string - method: string // "TCP_PING" or Http Method (e.g. GET, POST, OPTIONS, etc.) - target: string // url for http, hostname:port for tcp - tooltip?: string - statusPageLink?: string - hideLatencyChart?: boolean - checkProxy?: string - checkProxyFallback?: boolean - - // HTTP Code - expectedCodes?: number[] - timeout?: number - headers?: Record - body?: BodyInit - responseKeyword?: string - responseForbiddenKeyword?: string -} - -export type { MonitorState, MonitorTarget } diff --git a/worker/src/index.ts b/worker/src/index.ts index d4bb455..7ee17cd 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,6 +1,6 @@ -import { workerConfig } from '../../uptime.config' +import { workerConfig, maintenances } from '../../uptime.config' import { formatStatusChangeNotification, getWorkerLocation, notifyWithApprise } from './util' -import { MonitorState, MonitorTarget } from '../../uptime.types' +import { MonitorState, MonitorTarget } from '../../types/config' import { getStatus } from './monitor' import { DurableObject } from 'cloudflare:workers' @@ -9,7 +9,7 @@ export interface Env { REMOTE_CHECKER_DO: DurableObjectNamespace } -export default { +const Worker = { async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { const workerLocation = (await getWorkerLocation()) || 'ERROR' console.log(`Running scheduled event on ${workerLocation}...`) @@ -23,8 +23,7 @@ export default { reason: string ) => { // Skip notification if monitor is in the skip list - // @ts-ignore - const skipList: string[] = workerConfig.notification?.skipNotificationIds + const skipList = workerConfig.notification?.skipNotificationIds if (skipList && skipList.includes(monitor.id)) { console.log( `Skipping notification for ${monitor.name} (${monitor.id} in skipNotificationIds)` @@ -32,6 +31,21 @@ export default { return } + // Skip notification if monitor is in maintenance + const maintenanceList = maintenances + .filter( + (m) => + new Date(timeNow * 1000) >= new Date(m.start) && + (!m.end || new Date(timeNow * 1000) <= new Date(m.end)) + ) + .map((e) => (e.monitors || [])) + .flat() + + if (maintenanceList.includes(monitor.id)) { + console.log(`Skipping notification for ${monitor.name} (in maintenance)`) + return + } + if (workerConfig.notification?.appriseApiServer && workerConfig.notification?.recipientUrl) { const notification = formatStatusChangeNotification( monitor, @@ -159,7 +173,7 @@ export default { } console.log('Calling config onStatusChange callback...') - await workerConfig.callbacks.onStatusChange( + await workerConfig.callbacks?.onStatusChange( env, monitor, true, @@ -230,7 +244,7 @@ export default { if (monitorStatusChanged) { console.log('Calling config onStatusChange callback...') - await workerConfig.callbacks.onStatusChange( + await workerConfig.callbacks?.onStatusChange( env, monitor, false, @@ -246,7 +260,7 @@ export default { try { console.log('Calling config onIncident callback...') - await workerConfig.callbacks.onIncident( + await workerConfig.callbacks?.onIncident( env, monitor, currentIncident.start[0], @@ -323,6 +337,8 @@ export default { }, } +export default Worker + export class RemoteChecker extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env)