use template
parent
d95ad33d67
commit
6179edc0e3
|
|
@ -63,5 +63,5 @@ Please refer to [Wiki](https://github.com/lyc8503/UptimeFlare/wiki)
|
||||||
- [ ] Cloudflare D1 database
|
- [ ] Cloudflare D1 database
|
||||||
- [x] Scheduled maintenances (via IIFE)
|
- [x] Scheduled maintenances (via IIFE)
|
||||||
- [ ] Simpler config example
|
- [ ] Simpler config example
|
||||||
- [ ] Upcoming maintenances
|
- [x] Upcoming maintenances
|
||||||
- [ ] Universal Webhook upgrade
|
- [x] Universal Webhook upgrade
|
||||||
|
|
|
||||||
|
|
@ -54,27 +54,19 @@ export type WorkerConfig<TEnv = Env> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Notification = {
|
export type Notification = {
|
||||||
appriseApiServer?: string
|
webhook?: WebhookConfig
|
||||||
recipientUrl?: string
|
|
||||||
timeZone?: string
|
timeZone?: string
|
||||||
gracePeriod?: number
|
gracePeriod?: number
|
||||||
skipNotificationIds?: string[]
|
skipNotificationIds?: string[]
|
||||||
webhook?: Webhook
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Webhook<TEnv = Env> = {
|
export type WebhookConfig = {
|
||||||
url: string
|
url: string
|
||||||
method?: 'POST' | 'GET'
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH'
|
||||||
headers?: { [key: string]: string | number }
|
headers?: { [key: string]: string | number }
|
||||||
|
payloadType: 'param' | 'json' | 'x-www-form-urlencoded'
|
||||||
|
payload: { [key: string]: string | number }
|
||||||
timeout?: 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> = {
|
export type Callbacks<TEnv = Env> = {
|
||||||
|
|
|
||||||
|
|
@ -84,13 +84,34 @@ const workerConfig: WorkerConfig = {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// [Optional] Notification settings
|
||||||
notification: {
|
notification: {
|
||||||
// [Optional] apprise API server URL
|
// [Optional] Notification webhook settings, if not specified, no notification will be sent
|
||||||
// if not specified, no notification will be sent
|
// Wiki: TODO
|
||||||
appriseApiServer: 'https://apprise.example.com/notify',
|
webhook: {
|
||||||
// [Optional] recipient URL for apprise, refer to https://github.com/caronc/apprise
|
// [Required] webhook URL (example: Telegram Bot API)
|
||||||
// if not specified, no notification will be sent
|
url: 'https://api.telegram.org/bot123456:ABCDEF/sendMessage',
|
||||||
recipientUrl: 'tgram://bottoken/ChatID',
|
// [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"
|
// [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
|
// [Optional] grace period in minutes before sending a notification
|
||||||
|
|
@ -99,34 +120,6 @@ const workerConfig: WorkerConfig = {
|
||||||
gracePeriod: 5,
|
gracePeriod: 5,
|
||||||
// [Optional] disable notification for monitors with specified ids
|
// [Optional] disable notification for monitors with specified ids
|
||||||
skipNotificationIds: ['foo_monitor', 'bar_monitor'],
|
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: {
|
callbacks: {
|
||||||
onStatusChange: async (
|
onStatusChange: async (
|
||||||
|
|
@ -181,15 +174,15 @@ const maintenances: MaintenanceConfig[] = [
|
||||||
// This COULD BE DANGEROUS, as generating too many maintenance entries can lead to performance problems
|
// This COULD BE DANGEROUS, as generating too many maintenance entries can lead to performance problems
|
||||||
// Undeterministic outputs may also lead to bugs or unexpected behavior
|
// Undeterministic outputs may also lead to bugs or unexpected behavior
|
||||||
// If you don't know how to DEBUG, use this approach WITH CAUTION
|
// If you don't know how to DEBUG, use this approach WITH CAUTION
|
||||||
...(function (){
|
...(function () {
|
||||||
const schedules = [];
|
const schedules = []
|
||||||
const today = new Date();
|
const today = new Date()
|
||||||
|
|
||||||
for (let i = -1; i <= 1; i++) {
|
for (let i = -1; i <= 1; i++) {
|
||||||
// JavaScript's Date object will automatically handle year rollovers
|
// JavaScript's Date object will automatically handle year rollovers
|
||||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 15);
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 15)
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear()
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
|
||||||
schedules.push({
|
schedules.push({
|
||||||
title: `${year}/${parseInt(month)} - Test scheduled maintenance`,
|
title: `${year}/${parseInt(month)} - Test scheduled maintenance`,
|
||||||
|
|
@ -197,12 +190,11 @@ const maintenances: MaintenanceConfig[] = [
|
||||||
body: 'Monthly scheduled maintenance',
|
body: 'Monthly scheduled maintenance',
|
||||||
start: `${year}-${month}-15T02:00:00.000+08:00`,
|
start: `${year}-${month}-15T02:00:00.000+08:00`,
|
||||||
end: `${year}-${month}-15T04: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.
|
// Don't forget this, otherwise compilation fails.
|
||||||
export { maintenances, pageConfig, workerConfig }
|
export { maintenances, pageConfig, workerConfig }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { DurableObject } from 'cloudflare:workers'
|
||||||
import { MonitorState, MonitorTarget } from '../../types/config'
|
import { MonitorState, MonitorTarget } from '../../types/config'
|
||||||
import { maintenances, workerConfig } from '../../uptime.config'
|
import { maintenances, workerConfig } from '../../uptime.config'
|
||||||
import { getStatus } from './monitor'
|
import { getStatus } from './monitor'
|
||||||
import { formatStatusChangeNotification, getWorkerLocation, notifyWithApprise } from './util'
|
import { formatStatusChangeNotification, getWorkerLocation, webhookNotify } from './util'
|
||||||
|
|
||||||
export interface Env {
|
export interface Env {
|
||||||
UPTIMEFLARE_STATE: KVNamespace
|
UPTIMEFLARE_STATE: KVNamespace
|
||||||
|
|
@ -14,7 +14,7 @@ const Worker = {
|
||||||
const workerLocation = (await getWorkerLocation()) || 'ERROR'
|
const workerLocation = (await getWorkerLocation()) || 'ERROR'
|
||||||
console.log(`Running scheduled event on ${workerLocation}...`)
|
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 (
|
let formatAndNotify = async (
|
||||||
monitor: MonitorTarget,
|
monitor: MonitorTarget,
|
||||||
isUp: boolean,
|
isUp: boolean,
|
||||||
|
|
@ -46,63 +46,18 @@ const Worker = {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if(workerConfig.notification?.webhook?.url) {
|
if (workerConfig.notification?.webhook) {
|
||||||
const controller = new AbortController()
|
const notification = formatStatusChangeNotification(
|
||||||
const timeoutId = setTimeout(() => controller.abort(), workerConfig.notification.webhook.timeout ?? 30000) // 30 second default timeout
|
monitor,
|
||||||
const headers = { 'Content-Type': 'application/json' }
|
isUp,
|
||||||
let body: { [key: string]: any } | string
|
timeIncidentStart,
|
||||||
if(workerConfig.notification.webhook.body) {
|
timeNow,
|
||||||
body = workerConfig.notification.webhook.body(
|
reason,
|
||||||
env,
|
workerConfig.notification?.timeZone ?? 'Etc/GMT'
|
||||||
monitor,
|
)
|
||||||
isUp,
|
await webhookNotify(workerConfig.notification.webhook, notification)
|
||||||
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 {
|
} else {
|
||||||
if (workerConfig.notification?.appriseApiServer && workerConfig.notification?.recipientUrl) {
|
console.log(`Webhook not set, skipping notification for ${monitor.name}`)
|
||||||
const notification = formatStatusChangeNotification(
|
|
||||||
monitor,
|
|
||||||
isUp,
|
|
||||||
timeIncidentStart,
|
|
||||||
timeNow,
|
|
||||||
reason,
|
|
||||||
workerConfig.notification?.timeZone ?? 'Etc/GMT'
|
|
||||||
)
|
|
||||||
await notifyWithApprise(
|
|
||||||
workerConfig.notification.appriseApiServer,
|
|
||||||
workerConfig.notification.recipientUrl,
|
|
||||||
notification.title,
|
|
||||||
notification.body
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`Apprise API server or recipient URL not set, skipping apprise notification for ${monitor.name}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,7 +167,7 @@ const Worker = {
|
||||||
await formatAndNotify(monitor, true, lastIncident.start[0], currentTimeSecond, 'OK')
|
await formatAndNotify(monitor, true, lastIncident.start[0], currentTimeSecond, 'OK')
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
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
|
`Grace period (${workerConfig.notification
|
||||||
?.gracePeriod}m) not met (currently down for ${
|
?.gracePeriod}m) not met (currently down for ${
|
||||||
currentTimeSecond - currentIncident.start[0]
|
currentTimeSecond - currentIncident.start[0]
|
||||||
}s, changed ${monitorStatusChanged}), skipping apprise DOWN notification for ${
|
}s, changed ${monitorStatusChanged}), skipping webhook DOWN notification for ${
|
||||||
monitor.name
|
monitor.name
|
||||||
}`
|
}`
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { WebhookConfig } from '../../types/config'
|
||||||
|
|
||||||
async function getWorkerLocation() {
|
async function getWorkerLocation() {
|
||||||
const res = await fetch('https://cloudflare.com/cdn-cgi/trace')
|
const res = await fetch('https://cloudflare.com/cdn-cgi/trace')
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|
@ -48,65 +50,81 @@ function formatStatusChangeNotification(
|
||||||
const timeIncidentStartFormatted = dateFormatter.format(new Date(timeIncidentStart * 1000))
|
const timeIncidentStartFormatted = dateFormatter.format(new Date(timeIncidentStart * 1000))
|
||||||
|
|
||||||
if (isUp) {
|
if (isUp) {
|
||||||
return {
|
return `✅ ${monitor.name} is up! \nThe service is up again after being down for ${downtimeDuration} minutes.`
|
||||||
title: `✅ ${monitor.name} is up!`,
|
|
||||||
body: `The service is up again after being down for ${downtimeDuration} minutes.`,
|
|
||||||
}
|
|
||||||
} else if (timeNow == timeIncidentStart) {
|
} else if (timeNow == timeIncidentStart) {
|
||||||
return {
|
return `🔴 ${
|
||||||
title: `🔴 ${monitor.name} is currently down.`,
|
monitor.name
|
||||||
body: `Service is unavailable at ${timeNowFormatted}. Issue: ${reason || 'unspecified'}`,
|
} is currently down. \nService is unavailable at ${timeNowFormatted}. \nIssue: ${
|
||||||
}
|
reason || 'unspecified'
|
||||||
|
}`
|
||||||
} else {
|
} else {
|
||||||
return {
|
return `🔴 ${
|
||||||
title: `🔴 ${monitor.name} is still down.`,
|
monitor.name
|
||||||
body: `Service is unavailable since ${timeIncidentStartFormatted} (${downtimeDuration} minutes). Issue: ${
|
} is still down. \nService is unavailable since ${timeIncidentStartFormatted} (${downtimeDuration} minutes). \nIssue: ${
|
||||||
reason || 'unspecified'
|
reason || 'unspecified'
|
||||||
}`,
|
}`
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function notifyWithApprise(
|
async function webhookNotify(webhook: WebhookConfig, message: string) {
|
||||||
appriseApiServer: string,
|
|
||||||
recipientUrl: string,
|
|
||||||
title: string,
|
|
||||||
body: string
|
|
||||||
) {
|
|
||||||
console.log(
|
console.log(
|
||||||
'Sending Apprise notification: ' +
|
'Sending webhook notification: ' + JSON.stringify(message) + ' to webhook ' + webhook.url
|
||||||
title +
|
|
||||||
'-' +
|
|
||||||
body +
|
|
||||||
' to ' +
|
|
||||||
recipientUrl +
|
|
||||||
' via ' +
|
|
||||||
appriseApiServer
|
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
const resp = await fetchTimeout(appriseApiServer, 5000, {
|
let url = webhook.url
|
||||||
method: 'POST',
|
let method = webhook.method
|
||||||
headers: {
|
let headers = new Headers(webhook.headers as any)
|
||||||
'Content-Type': 'application/json',
|
let payloadTemplated = webhook.payload
|
||||||
},
|
Object.keys(payloadTemplated).forEach((k) => {
|
||||||
body: JSON.stringify({
|
if (payloadTemplated[k] === '$MSG') {
|
||||||
urls: recipientUrl,
|
payloadTemplated[k] = message
|
||||||
title,
|
}
|
||||||
body,
|
|
||||||
type: 'warning',
|
|
||||||
format: 'text',
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
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) {
|
if (!resp.ok) {
|
||||||
console.log(
|
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 {
|
} else {
|
||||||
console.log('Apprise notification sent successfully, code: ' + resp.status)
|
console.log('Webhook notification sent successfully, code: ' + resp.status)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Error calling apprise server: ' + e)
|
console.log('Error calling webhook server: ' + e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +132,6 @@ export {
|
||||||
getWorkerLocation,
|
getWorkerLocation,
|
||||||
fetchTimeout,
|
fetchTimeout,
|
||||||
withTimeout,
|
withTimeout,
|
||||||
notifyWithApprise,
|
webhookNotify,
|
||||||
formatStatusChangeNotification,
|
formatStatusChangeNotification,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue