Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: aggregating checkLocationWorkerRoute #47

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions uptime.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const pageConfig = {
import type { PageConfig, UptimeConfig } from './uptime.types'

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`
Expand All @@ -9,7 +11,7 @@ const pageConfig = {
],
}

const workerConfig = {
const workerConfig: UptimeConfig = {
// Write KV at most every 3 minutes unless the status changed.
kvWriteCooldownMinutes: 3,
// Define all your monitors here
Expand Down Expand Up @@ -61,12 +63,12 @@ const workerConfig = {
notification: {
// [Optional] apprise API server URL
// if not specified, no notification will be sent
appriseApiServer: "https://apprise.example.com/notify",
appriseApiServer: 'https://apprise.example.com/notify',
// [Optional] recipient URL for apprise, refer to https://github.com/caronc/apprise
// if not specified, no notification will be sent
recipientUrl: "tgram://bottoken/ChatID",
recipientUrl: 'tgram://bottoken/ChatID',
// [Optional] timezone used in notification messages, default to "Etc/GMT"
timeZone: "Asia/Shanghai",
timeZone: 'Asia/Shanghai',
// [Optional] grace period in minutes before sending a notification
// notification will be sent only if the monitor is down for N continuous checks after the initial failure
// if not specified, notification will be sent immediately
Expand All @@ -83,7 +85,6 @@ const workerConfig = {
) => {
// This callback will be called when there's a status change for any monitor
// Write any Typescript code here

// This will not follow the grace period settings and will be called immediately when the status changes
// You need to handle the grace period manually if you want to implement it
},
Expand Down
42 changes: 39 additions & 3 deletions uptime.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
type MonitorState = {
export type MonitorState = {
version: number
lastUpdate: number
overallUp: number
overallDown: number
Expand Down Expand Up @@ -28,7 +29,7 @@ type MonitorState = {
>
}

type MonitorTarget = {
export type MonitorTarget = {
id: string
name: string
method: string // "TCP_PING" or Http Method (e.g. GET, POST, OPTIONS, etc.)
Expand All @@ -45,4 +46,39 @@ type MonitorTarget = {
responseKeyword?: string
}

export type { MonitorState, MonitorTarget }
export type UptimeConfig = {
kvWriteCooldownMinutes: number
monitors: MonitorTarget[]
notification?: {
appriseApiServer?: string
recipientUrl?: string
timeZone?: string
gracePeriod?: number
}
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 PageConfig = {
title: string
links: Array<{
link: string
label: string
highlight?: boolean
}>
}
103 changes: 65 additions & 38 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,64 @@
import { workerConfig } from '../../uptime.config'
import { formatStatusChangeNotification, getWorkerLocation, notifyWithApprise } from './util'
import { MonitorState } from '../../uptime.types'
import { getStatus } from './monitor'
import { getStatus, Status } from './monitor'

export interface Env {
UPTIMEFLARE_STATE: KVNamespace
}

interface CheckLocationWorkerResult {
location: string
status: Record<string, Status>
}

export default {
async fetch(request: Request): Promise<Response> {
const workerLocation = request.cf?.colo
const workerLocation = request.cf?.colo as string
console.log(`Handling request event at ${workerLocation}...`)

if (request.method !== 'POST') {
return new Response('Remote worker is working...', { status: 405 })
}

const targetId = (await request.json<{ target: string }>())['target']
const target = workerConfig.monitors.find((m) => m.id === targetId)
const targetIds = new Set((await request.json<{ targets: string[] }>())['targets'])
const targets = workerConfig.monitors.filter(({ id }) => targetIds.has(id))

if (target === undefined) {
if (!targets.length) {
return new Response('Target Not Found', { status: 404 })
}

const status = await getStatus(target)
const status: Record<string, Status> = {}

return new Response(
JSON.stringify({
location: workerLocation,
status: status,
}),
{
headers: {
'content-type': 'application/json;charset=UTF-8',
},
}
)
for (const target of targets) {
status[target.id] = await getStatus(target)
}

const result: CheckLocationWorkerResult = {
location: workerLocation,
status,
}

return new Response(JSON.stringify(result), {
headers: {
'content-type': 'application/json;charset=UTF-8',
},
})
},

async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
const workerLocation = (await getWorkerLocation()) || 'ERROR'
console.log(`Running scheduled event on ${workerLocation}...`)

// Auxiliary function to format notification and send it via apprise
let formatAndNotify = async (
const formatAndNotify = async (
monitor: any,
isUp: boolean,
timeIncidentStart: number,
timeNow: number,
reason: string
) => {
if (workerConfig.notification?.appriseApiServer && workerConfig.notification?.recipientUrl) {
if (workerConfig.notification?.appriseApiServer && workerConfig.notification.recipientUrl) {
const notification = formatStatusChangeNotification(
monitor,
isUp,
Expand Down Expand Up @@ -89,31 +97,50 @@ export default {
let statusChanged = false
const currentTimeSecond = Math.round(Date.now() / 1000)

// Check monitors with `checkLocationWorkerRoute`
const groupedCheckLocation = new Map<string, string[]>()
const groupedCheckLocationResult = new Map<string, CheckLocationWorkerResult>()
workerConfig.monitors.forEach(({ id, checkLocationWorkerRoute: url }) => {
if (!url) return
const targets = groupedCheckLocation.get(url) || []
if (!groupedCheckLocation.has(url)) {
groupedCheckLocation.set(url, targets)
}
targets.push(id)
})
for (const [url, targets] of groupedCheckLocation) {
// Initiate a check from a different location
console.log('Calling worker: ' + url)
try {
const resp = await (
await fetch(url, {
method: 'POST',
body: JSON.stringify({ targets }),
})
).json<CheckLocationWorkerResult>()
groupedCheckLocationResult.set(url, resp)
} catch (err) {
console.log('Error calling worker: ' + err)
}
}

// Check each monitor
// TODO: concurrent status check
for (const monitor of workerConfig.monitors) {
console.log(`[${workerLocation}] Checking ${monitor.name}...`)

let monitorStatusChanged = false
let checkLocation = workerLocation
let status
let status: Status

if (monitor.checkLocationWorkerRoute) {
// Initiate a check from a different location
try {
console.log('Calling worker: ' + monitor.checkLocationWorkerRoute)
const resp = await (
await fetch(monitor.checkLocationWorkerRoute, {
method: 'POST',
body: JSON.stringify({
target: monitor.id,
}),
})
).json<{ location: string; status: { ping: number; up: boolean; err: string } }>()
checkLocation = resp.location
status = resp.status
} catch (err) {
console.log('Error calling worker: ' + err)
// Get check result from a different location
const result = groupedCheckLocationResult.get(monitor.checkLocationWorkerRoute)
const resultStatus = result?.status[monitor.id]
if (resultStatus) {
checkLocation = result.location
status = resultStatus
} else {
status = { ping: 0, up: false, err: 'Error initiating check from remote worker' }
}
} else {
Expand Down Expand Up @@ -164,7 +191,7 @@ export default {
}

console.log('Calling config onStatusChange callback...')
await workerConfig.callbacks.onStatusChange(
await workerConfig.callbacks?.onStatusChange?.(
env,
monitor,
true,
Expand Down Expand Up @@ -230,7 +257,7 @@ export default {

if (monitorStatusChanged) {
console.log('Calling config onStatusChange callback...')
await workerConfig.callbacks.onStatusChange(
await workerConfig.callbacks?.onStatusChange?.(
env,
monitor,
false,
Expand All @@ -246,7 +273,7 @@ export default {

try {
console.log('Calling config onIncident callback...')
await workerConfig.callbacks.onIncident(
await workerConfig.callbacks?.onIncident?.(
env,
monitor,
currentIncident.start[0],
Expand Down
10 changes: 7 additions & 3 deletions worker/src/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import { connect } from "cloudflare:sockets";
import { MonitorTarget } from "../../uptime.types";
import { withTimeout, fetchTimeout } from "./util";

export async function getStatus(
monitor: MonitorTarget
): Promise<{ ping: number; up: boolean; err: string }> {
export interface Status {
ping: number
up: boolean
err: string
}

export async function getStatus(monitor: MonitorTarget): Promise<Status> {
let status = {
ping: 0,
up: false,
Expand Down