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 ca2b2e3a68.

* Revert "feat: move to config/base class to have fallback values on missing exports"

This reverts commit 9932ea2590.

* 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 <me@lyc8503.net>
pull/108/head
Bennet Gallein 2025-04-30 19:09:53 +02:00 committed by GitHub
parent 8a6cde7240
commit a6c8eb6e12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 283 additions and 98 deletions

View File

@ -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'

View File

@ -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(

View File

@ -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 (
<a
key={link.label}
@ -38,11 +39,11 @@ export default function Header() {
</div>
<Group gap={5} visibleFrom="sm">
{pageConfig.links.map(linkToElement)}
{pageConfig.links?.map(linkToElement)}
</Group>
<Group gap={5} hiddenFrom="sm">
{pageConfig.links.filter((link) => (link as any).highlight).map(linkToElement)}
{pageConfig.links?.filter((link) => link.highlight).map(linkToElement)}
</Group>
</Container>
</header>

View File

@ -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<MaintenanceConfig, 'monitors'> & { monitors?: MonitorTarget[] }
style?: React.CSSProperties
}) {
return (
<Alert
icon={<IconAlertTriangle />}
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 */}
<div
style={{
position: 'absolute',
top: 10,
right: 10,
fontSize: '0.85rem',
textAlign: 'right',
padding: '2px 8px',
borderRadius: 6,
}}
>
<b>From:</b> {new Date(maintenance.start).toLocaleString()}
<br />
<b>To:</b>{' '}
{maintenance.end ? new Date(maintenance.end).toLocaleString() : 'Until further notice'}
</div>
<Text>{maintenance.body}</Text>
{maintenance.monitors && maintenance.monitors.length > 0 && (
<>
<Text mt="xs">
<b>Affected components:</b>
</Text>
<List size="sm" withPadding>
{maintenance.monitors.map((comp, compIdx) => (
<List.Item key={compIdx}>{comp.name}</List.Item>
))}
</List>
</>
)}
</Alert>
)
}

View File

@ -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 ? (
<IconAlertCircle style={{ width: '1.25em', height: '1.25em', color: '#b91c1c' }} />
<IconAlertCircle
style={{ width: '1.25em', height: '1.25em', color: '#b91c1c', marginRight: '3px' }}
/>
) : (
<IconCircleCheck style={{ width: '1.25em', height: '1.25em', color: '#059669' }} />
<IconCircleCheck
style={{ width: '1.25em', height: '1.25em', color: '#059669', marginRight: '3px' }}
/>
)
// 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 = (
<IconAlertTriangle
style={{
width: '1.25em',
height: '1.25em',
color: '#fab005',
marginRight: '3px',
}}
/>
)
let totalTime = Date.now() / 1000 - state.incident[monitor.id][0].start[0]

View File

@ -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' }}

View File

@ -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 = <IconAlertCircle style={{ width: 64, height: 64, color: '#b91c1c' }} />
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<MaintenanceConfig, 'monitors'> & { 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 (
<>
<Container size="md" mt="xl">
<Center>{icon}</Center>
<Title mt="sm" style={{ textAlign: 'center' }} order={1}>
{statusString}
@ -72,6 +80,14 @@ export default function OverallStatus({
currentTime - state.lastUpdate
} sec ago)`}
</Title>
</>
{filteredMaintenances.map((maintenance, idx) => (
<MaintenanceAlert
key={idx}
maintenance={maintenance}
style={{ maxWidth: groupedMonitor ? '897px' : '865px' }}
/>
))}
</Container>
)
}

View File

@ -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'

View File

@ -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({
</Center>
) : (
<div>
<OverallStatus state={state} />
<OverallStatus state={state} monitors={monitors} maintenances={maintenances} />
<MonitorList monitors={monitors} state={state} />
</div>
)}

103
types/config.ts Normal file
View File

@ -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<any>
onIncident: (
env: any,
monitor: any,
timeIncidentStart: number,
timeNow: number,
reason: string
) => Promise<any>
}
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
}
>
}

View File

@ -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 `<USERNAME>:<PASSWORD>`
@ -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 }

View File

@ -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<string, string | undefined>
body?: BodyInit
responseKeyword?: string
responseForbiddenKeyword?: string
}
export type { MonitorState, MonitorTarget }

View File

@ -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<RemoteChecker>
}
export default {
const Worker = {
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
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)