use template

pull/143/head
lyc8503 2025-09-29 21:13:24 +08:00
parent d95ad33d67
commit 6179edc0e3
5 changed files with 120 additions and 163 deletions

View File

@ -63,5 +63,5 @@ Please refer to [Wiki](https://github.com/lyc8503/UptimeFlare/wiki)
- [ ] Cloudflare D1 database
- [x] Scheduled maintenances (via IIFE)
- [ ] Simpler config example
- [ ] Upcoming maintenances
- [ ] Universal Webhook upgrade
- [x] Upcoming maintenances
- [x] Universal Webhook upgrade

View File

@ -54,27 +54,19 @@ export type WorkerConfig<TEnv = Env> = {
}
export type Notification = {
appriseApiServer?: string
recipientUrl?: string
webhook?: WebhookConfig
timeZone?: string
gracePeriod?: number
skipNotificationIds?: string[]
webhook?: Webhook
}
export type Webhook<TEnv = Env> = {
export type WebhookConfig = {
url: string
method?: 'POST' | 'GET'
method?: 'GET' | 'POST' | 'PUT' | 'PATCH'
headers?: { [key: string]: string | number }
payloadType: 'param' | 'json' | 'x-www-form-urlencoded'
payload: { [key: string]: string | number }
timeout?: number
body?: (
env: TEnv,
monitor: MonitorTarget,
isUp: boolean,
timeIncidentStart: number,
timeNow: number,
reason: string
) => { [key: string]: any } | string
}
export type Callbacks<TEnv = Env> = {

View File

@ -84,13 +84,34 @@ const workerConfig: WorkerConfig = {
timeout: 5000,
},
],
// [Optional] Notification settings
notification: {
// [Optional] apprise API server URL
// if not specified, no notification will be sent
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',
// [Optional] Notification webhook settings, if not specified, no notification will be sent
// Wiki: TODO
webhook: {
// [Required] webhook URL (example: Telegram Bot API)
url: 'https://api.telegram.org/bot123456:ABCDEF/sendMessage',
// [Optional] HTTP method, default to 'GET' for payloadType=param, 'POST' otherwise
method: 'POST',
// [Optional] headers to be sent
headers: {
foo: 'bar',
},
// [Required] Specify how to encode the payload
// Should be one of 'param', 'json' or 'x-www-form-urlencoded'
// 'param': append url-encoded payload to URL search parameters
// 'json': POST json payload as body, set content-type header to 'application/json'
// 'x-www-form-urlencoded': POST url-encoded payload as body, set content-type header to 'x-www-form-urlencoded'
payloadType: 'x-www-form-urlencoded',
// [Required] payload to be sent
// $MSG will be replaced with the human-readable notification message
payload: {
chat_id: 12345678,
text: '$MSG',
},
// [Optional] timeout calling this webhook, in millisecond, default to 5000
timeout: 10000,
},
// [Optional] timezone used in notification messages, default to "Etc/GMT"
timeZone: 'Asia/Shanghai',
// [Optional] grace period in minutes before sending a notification
@ -99,34 +120,6 @@ const workerConfig: WorkerConfig = {
gracePeriod: 5,
// [Optional] disable notification for monitors with specified ids
skipNotificationIds: ['foo_monitor', 'bar_monitor'],
/* [Optional] use webhook configuration instead of apprise configuration (appriseApiServer & recipientUrl)
webhook: {
// [Required] webhook URL
url: 'https://hooks.zapier.com/hooks/catch/123456/abcdef/',
// [Optional] HTTP method, default to "POST"
method: 'POST',
// [Optional] headers to be sent
headers: {
// [Optional] header defaults to Content-Type: application/json, additional properties will be merged, example of authorization header below
'Authorization': 'Bearer YOUR_TOKEN_HERE',
},
// [Optional] body to be sent, could be an object or a string
// if it's an object, it will be sent as JSON
// if it's a string, it will be sent as-is
body: (env, monitor, isUp, timeIncidentStart, timeNow, reason) => {
return {
event: 'status_change',
monitor,
isUp,
timeIncidentStart,
timeNow,
reason,
}
},
// [Optional] timeout in millisecond, defaults to 30000
timeout: 30000,
},
*/
},
callbacks: {
onStatusChange: async (
@ -182,14 +175,14 @@ const maintenances: MaintenanceConfig[] = [
// Undeterministic outputs may also lead to bugs or unexpected behavior
// If you don't know how to DEBUG, use this approach WITH CAUTION
...(function () {
const schedules = [];
const today = new Date();
const schedules = []
const today = new Date()
for (let i = -1; i <= 1; i++) {
// JavaScript's Date object will automatically handle year rollovers
const date = new Date(today.getFullYear(), today.getMonth() + i, 15);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const date = new Date(today.getFullYear(), today.getMonth() + i, 15)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
schedules.push({
title: `${year}/${parseInt(month)} - Test scheduled maintenance`,
@ -197,12 +190,11 @@ const maintenances: MaintenanceConfig[] = [
body: 'Monthly scheduled maintenance',
start: `${year}-${month}-15T02:00:00.000+08:00`,
end: `${year}-${month}-15T04:00:00.000+08:00`,
});
})
}
return schedules;
})()
return schedules
})(),
]
// Don't forget this, otherwise compilation fails.
export { maintenances, pageConfig, workerConfig }

View File

@ -2,7 +2,7 @@ import { DurableObject } from 'cloudflare:workers'
import { MonitorState, MonitorTarget } from '../../types/config'
import { maintenances, workerConfig } from '../../uptime.config'
import { getStatus } from './monitor'
import { formatStatusChangeNotification, getWorkerLocation, notifyWithApprise } from './util'
import { formatStatusChangeNotification, getWorkerLocation, webhookNotify } from './util'
export interface Env {
UPTIMEFLARE_STATE: KVNamespace
@ -14,7 +14,7 @@ const Worker = {
const workerLocation = (await getWorkerLocation()) || 'ERROR'
console.log(`Running scheduled event on ${workerLocation}...`)
// Auxiliary function to format notification and send it via apprise
// Auxiliary function to format notification and send it via webhook
let formatAndNotify = async (
monitor: MonitorTarget,
isUp: boolean,
@ -46,44 +46,7 @@ const Worker = {
return
}
if(workerConfig.notification?.webhook?.url) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), workerConfig.notification.webhook.timeout ?? 30000) // 30 second default timeout
const headers = { 'Content-Type': 'application/json' }
let body: { [key: string]: any } | string
if(workerConfig.notification.webhook.body) {
body = workerConfig.notification.webhook.body(
env,
monitor,
isUp,
timeIncidentStart,
timeNow,
reason
)
} else {
body = {
monitor,
isUp,
timeIncidentStart,
timeNow,
reason,
}
}
if(workerConfig.notification.webhook.headers) {
Object.assign(headers, workerConfig.notification.webhook.headers)
}
try {
await fetch(workerConfig.notification.webhook.url, {
method: workerConfig.notification.webhook.method ?? 'POST',
headers,
body: typeof body === 'object' ? JSON.stringify(body) : body,
signal: controller.signal,
})
} finally {
clearTimeout(timeoutId)
}
} else {
if (workerConfig.notification?.appriseApiServer && workerConfig.notification?.recipientUrl) {
if (workerConfig.notification?.webhook) {
const notification = formatStatusChangeNotification(
monitor,
isUp,
@ -92,17 +55,9 @@ const Worker = {
reason,
workerConfig.notification?.timeZone ?? 'Etc/GMT'
)
await notifyWithApprise(
workerConfig.notification.appriseApiServer,
workerConfig.notification.recipientUrl,
notification.title,
notification.body
)
await webhookNotify(workerConfig.notification.webhook, notification)
} else {
console.log(
`Apprise API server or recipient URL not set, skipping apprise notification for ${monitor.name}`
)
}
console.log(`Webhook not set, skipping notification for ${monitor.name}`)
}
}
@ -212,7 +167,7 @@ const Worker = {
await formatAndNotify(monitor, true, lastIncident.start[0], currentTimeSecond, 'OK')
} else {
console.log(
`grace period (${workerConfig.notification?.gracePeriod}m) not met, skipping apprise UP notification for ${monitor.name}`
`grace period (${workerConfig.notification?.gracePeriod}m) not met, skipping webhook UP notification for ${monitor.name}`
)
}
@ -280,7 +235,7 @@ const Worker = {
`Grace period (${workerConfig.notification
?.gracePeriod}m) not met (currently down for ${
currentTimeSecond - currentIncident.start[0]
}s, changed ${monitorStatusChanged}), skipping apprise DOWN notification for ${
}s, changed ${monitorStatusChanged}), skipping webhook DOWN notification for ${
monitor.name
}`
)

View File

@ -1,3 +1,5 @@
import { WebhookConfig } from '../../types/config'
async function getWorkerLocation() {
const res = await fetch('https://cloudflare.com/cdn-cgi/trace')
const text = await res.text()
@ -48,65 +50,81 @@ function formatStatusChangeNotification(
const timeIncidentStartFormatted = dateFormatter.format(new Date(timeIncidentStart * 1000))
if (isUp) {
return {
title: `${monitor.name} is up!`,
body: `The service is up again after being down for ${downtimeDuration} minutes.`,
}
return `${monitor.name} is up! \nThe service is up again after being down for ${downtimeDuration} minutes.`
} else if (timeNow == timeIncidentStart) {
return {
title: `🔴 ${monitor.name} is currently down.`,
body: `Service is unavailable at ${timeNowFormatted}. Issue: ${reason || 'unspecified'}`,
}
} else {
return {
title: `🔴 ${monitor.name} is still down.`,
body: `Service is unavailable since ${timeIncidentStartFormatted} (${downtimeDuration} minutes). Issue: ${
return `🔴 ${
monitor.name
} is currently down. \nService is unavailable at ${timeNowFormatted}. \nIssue: ${
reason || 'unspecified'
}`,
}
}`
} else {
return `🔴 ${
monitor.name
} is still down. \nService is unavailable since ${timeIncidentStartFormatted} (${downtimeDuration} minutes). \nIssue: ${
reason || 'unspecified'
}`
}
}
async function notifyWithApprise(
appriseApiServer: string,
recipientUrl: string,
title: string,
body: string
) {
async function webhookNotify(webhook: WebhookConfig, message: string) {
console.log(
'Sending Apprise notification: ' +
title +
'-' +
body +
' to ' +
recipientUrl +
' via ' +
appriseApiServer
'Sending webhook notification: ' + JSON.stringify(message) + ' to webhook ' + webhook.url
)
try {
const resp = await fetchTimeout(appriseApiServer, 5000, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
urls: recipientUrl,
title,
body,
type: 'warning',
format: 'text',
}),
let url = webhook.url
let method = webhook.method
let headers = new Headers(webhook.headers as any)
let payloadTemplated = webhook.payload
Object.keys(payloadTemplated).forEach((k) => {
if (payloadTemplated[k] === '$MSG') {
payloadTemplated[k] = message
}
})
let body = undefined
switch (webhook.payloadType) {
case 'param':
method = method ?? 'GET'
const urlTmp = new URL(url)
for (const [k, v] of Object.entries(payloadTemplated)) {
urlTmp.searchParams.append(k, v.toString())
}
url = urlTmp.toString()
break
case 'json':
method = method ?? 'POST'
if (headers.get('content-type') === null) {
headers.set('content-type', 'application/json')
}
body = JSON.stringify(payloadTemplated)
break
case 'x-www-form-urlencoded':
method = method ?? 'POST'
if (headers.get('content-type') === null) {
headers.set('content-type', 'application/x-www-form-urlencoded')
}
body = new URLSearchParams(payloadTemplated as any).toString()
break
default:
throw 'Unrecognized payload type: ' + webhook.payloadType
}
console.log(
`Webhook finalized parameters: ${method} ${url}, headers ${JSON.stringify(
Object.fromEntries(headers.entries())
)}, body ${JSON.stringify(body)}`
)
const resp = await fetchTimeout(url, webhook.timeout ?? 5000, { method, headers, body })
if (!resp.ok) {
console.log(
'Error calling apprise server, code: ' + resp.status + ', response: ' + (await resp.text())
'Error calling webhook server, code: ' + resp.status + ', response: ' + (await resp.text())
)
} else {
console.log('Apprise notification sent successfully, code: ' + resp.status)
console.log('Webhook notification sent successfully, code: ' + resp.status)
}
} catch (e) {
console.log('Error calling apprise server: ' + e)
console.log('Error calling webhook server: ' + e)
}
}
@ -114,6 +132,6 @@ export {
getWorkerLocation,
fetchTimeout,
withTimeout,
notifyWithApprise,
webhookNotify,
formatStatusChangeNotification,
}