Merge pull request #10 from lroolle/feat/notify-example

feat: add notify telegram & bark, and some minor fixes
pull/11/head
lyc8503 2024-03-18 15:44:54 +08:00 committed by GitHub
commit 5db2013f65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 177 additions and 40 deletions

View File

@ -22,16 +22,21 @@ jobs:
cache: 'npm' cache: 'npm'
# Automatically get an account id via the API Token # Automatically get an account id via the API Token
# if secrets.CLOUDFLARE_ACCOUNT_ID is not set.
- name: Fetch Account ID - name: Fetch Account ID
id: fetch_account_id id: fetch_account_id
run: | run: |
ACCOUNT_ID=$(curl -X GET "https://api.cloudflare.com/client/v4/accounts" -H "Authorization: Bearer "$CLOUDFLARE_API_TOKEN -H "Content-Type:application/json" | jq ".result[0].id" -r) if [[ -n "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" ]]; then
if [[ "$ACCOUNT_ID" == "null" ]]; then ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
echo "Failed to get an account id, please make sure you have set up CLOUDFLARE_API_TOKEN correctly!" echo "Using provided CLOUDFLARE_ACCOUNT_ID from secrets."
exit 1
else else
echo 'account_id='$ACCOUNT_ID >> $GITHUB_OUTPUT ACCOUNT_ID=$(curl -X GET "https://api.cloudflare.com/client/v4/accounts" -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" -H "Content-Type:application/json" | jq ".result[0].id" -r)
if [[ "$ACCOUNT_ID" == "null" ]]; then
echo "Failed to get an account id, please make sure you have set up CLOUDFLARE_API_TOKEN correctly!"
exit 1
fi
fi fi
echo 'account_id='$ACCOUNT_ID >> $GITHUB_OUTPUT
env: env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@ -56,7 +61,7 @@ jobs:
# since we didn't setup a remote backend to store state, # since we didn't setup a remote backend to store state,
# following runs will fail with name conflict, which is normal. # following runs will fail with name conflict, which is normal.
continue-on-error: true continue-on-error: true
run: | run: |
terraform init terraform init
terraform apply -auto-approve -input=false terraform apply -auto-approve -input=false
@ -78,3 +83,4 @@ jobs:
npx wrangler pages deploy .vercel/output/static --project-name uptimeflare npx wrangler pages deploy .vercel/output/static --project-name uptimeflare
env: env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ steps.fetch_account_id.outputs.account_id }}

View File

@ -5,7 +5,7 @@ A more advanced, serverless, and free uptime monitoring & status page solution,
## ⭐Features ## ⭐Features
- Open-source, easy to deploy (in under 10 minutes, no local tools required), and free - Open-source, easy to deploy (in under 10 minutes, no local tools required), and free
- Monitoring capabilities - Monitoring capabilities
- Up to 50 checks at 2-minute intervals - Up to 50 checks at 1-minute intervals
- Geo-specific checks from over [310 cities](https://www.cloudflare.com/network/) worldwide - Geo-specific checks from over [310 cities](https://www.cloudflare.com/network/) worldwide
- Support for HTTP/HTTPS/TCP port monitoring - Support for HTTP/HTTPS/TCP port monitoring
- Up to 90-day uptime history and uptime percentage tracking - Up to 90-day uptime history and uptime percentage tracking
@ -34,7 +34,9 @@ Please refer to [Quickstart](https://github.com/lyc8503/UptimeFlare/wiki/Quickst
- [x] TCP `opened` promise - [x] TCP `opened` promise
- [x] ~~SSL certificate checks~~ - [x] ~~SSL certificate checks~~
- [ ] Slack / Telegram webhook example - [x] Telegram example
- [x] [Bark](https://bark.day.app) example
- [ ] Slack example
- [ ] Incident timeline - [ ] Incident timeline
- [ ] Email notification via Cloudflare Email Workers - [x] ~~Email notification via Cloudflare Email Workers~~
- [x] Specify region for monitors - [x] Specify region for monitors

View File

@ -40,9 +40,19 @@ export default function MonitorDetail({
const uptimePercent = (((totalTime - downTime) / totalTime) * 100).toPrecision(4) const uptimePercent = (((totalTime - downTime) / totalTime) * 100).toPrecision(4)
// Conditionally render monitor name with or without hyperlink based on monitor.url presence
const monitorNameElement = ( const monitorNameElement = (
<Text mt="sm" fw={700} style={{ display: 'inline-flex', alignItems: 'center' }}> <Text mt="sm" fw={700} style={{ display: 'inline-flex', alignItems: 'center' }}>
{statusIcon} {monitor.name} {statusIcon} {monitor.name}
{monitor.url ? (
<a href={monitor.url} style={{ display: 'inline-flex', alignItems: 'center', color: 'inherit', textDecoration: 'none' }}>
{statusIcon} {monitor.name}
</a>
) : (
<>
{statusIcon} {monitor.name}
</>
)}
</Text> </Text>
) )

View File

@ -38,7 +38,7 @@ resource "cloudflare_worker_cron_trigger" "uptimeflare_worker_cron" {
account_id = var.CLOUDFLARE_ACCOUNT_ID account_id = var.CLOUDFLARE_ACCOUNT_ID
script_name = cloudflare_worker_script.uptimeflare.name script_name = cloudflare_worker_script.uptimeflare.name
schedules = [ schedules = [
"*/2 * * * *", # every 2 minutes "* * * * *", # every 1 minute, you can reduce the KV write by increase the worker settings of `kvWriteCooldownMinutes`
] ]
} }
@ -56,4 +56,4 @@ resource "cloudflare_pages_project" "uptimeflare" {
compatibility_flags = ["nodejs_compat"] compatibility_flags = ["nodejs_compat"]
} }
} }
} }

View File

@ -10,6 +10,8 @@ const pageConfig = {
} }
const workerConfig = { const workerConfig = {
// Write KV at most every 3 minutes unless the status changed.
kvWriteCooldownMinutes: 3,
// Define all your monitors here // Define all your monitors here
monitors: [ monitors: [
// Example HTTP Monitor // Example HTTP Monitor
@ -18,6 +20,8 @@ const workerConfig = {
id: 'foo_monitor', id: 'foo_monitor',
// `name` is used at status page and callback message // `name` is used at status page and callback message
name: 'My API Monitor', name: 'My API Monitor',
// `url` for clickable link at status page
url: 'https://example.com',
// `method` should be a valid HTTP Method // `method` should be a valid HTTP Method
method: 'POST', method: 'POST',
// `target` is a valid URL // `target` is a valid URL
@ -55,41 +59,143 @@ const workerConfig = {
], ],
callbacks: { callbacks: {
onStatusChange: async ( onStatusChange: async (
id: string, env: Env,
name: string, monitor: any,
isUp: boolean, isUp: boolean,
timeIncidentStart: number, timeIncidentStart: number,
timeNow: number, timeNow: number,
reason: string reason: string
) => { ) => {
// This callback will be called when any monitor's status changed // This callback will be called when there's a status change for any monitor
// Write any Typescript code here // Write any Typescript code here
// Example implementation:
// const timeString = new Date(timeNow * 1000).toLocaleString('zh-CN', { // By default, this sends Bark and Telegram notification on every status change if you setup Env correctly.
// timeZone: 'Asia/Shanghai', await notify(env, monitor, isUp, timeIncidentStart, timeNow, reason)
// })
// let statusChangeMsg
// if (isUp) {
// statusChangeMsg = `✔️${name} came back up at ${timeString} after ${Math.round(
// (timeNow - timeIncidentStart) / 60
// )} minutes of downtime`
// } else {
// statusChangeMsg = `❌${name} was down at ${timeString} with error ${reason}`
// }
// await fetch('https://api.example.com/callback?msg=' + statusChangeMsg)
}, },
onIncident: async ( onIncident: async (
id: string, env: Env,
name: string, monitor: any,
isUp: boolean,
timeIncidentStart: number, timeIncidentStart: number,
timeNow: number, timeNow: number,
currentError: string reason: string
) => { ) => {
// This callback will be called EVERY 2 MINTUES if there's an on-going incident for any monitor // This callback will be called EVERY 1 MINTUE if there's an on-going incident for any monitor
// Write any Typescript code here // Write any Typescript code here
}, },
}, },
} }
const escapeMarkdown = (text: string) => {
return text.replace(/[_*[\](){}~`>#+\-=|.!\\]/g, '\\$&');
};
async function notify(
env: Env,
monitor: any,
isUp: boolean,
timeIncidentStart: number,
timeNow: number,
reason: string,
) {
const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'numeric',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'Asia/Shanghai',
});
let downtimeDuration = Math.round((timeNow - timeIncidentStart) / 60);
const timeIncidentStartFormatted = dateFormatter.format(new Date(timeIncidentStart * 1000));
let statusText = isUp
? `The service is up again after being down for ${downtimeDuration} minutes.`
: `Service became unavailable at ${timeIncidentStartFormatted}. Issue: ${reason || 'unspecified'}`;
console.log('Notifying: ', monitor.name, statusText);
if (env.BARK_SERVER && env.BARK_DEVICE_KEY) {
try {
let title = isUp ? `${monitor.name} is up again!` : `🔴 ${monitor.name} is currently down.`;
await sendBarkNotification(env, monitor, title, statusText);
} catch (error) {
console.error('Error sending Bark notification:', error);
}
}
if (env.SECRET_TELEGRAM_CHAT_ID && env.SECRET_TELEGRAM_API_TOKEN) {
try {
let operationalLabel = isUp ? 'Up' : 'Down';
let statusEmoji = isUp ? '✅' : '🔴';
let telegramText = `*${escapeMarkdown(
monitor.name,
)}* is currently *${operationalLabel}*\n${statusEmoji} ${escapeMarkdown(statusText)}`;
await notifyTelegram(env, monitor, isUp, telegramText);
} catch (error) {
console.error('Error sending Telegram notification:', error);
}
}
}
export async function notifyTelegram(env: Env, monitor: any, operational: boolean, text: string) {
const chatId = env.SECRET_TELEGRAM_CHAT_ID;
const apiToken = env.SECRET_TELEGRAM_API_TOKEN;
const payload = new URLSearchParams({
chat_id: chatId,
parse_mode: 'MarkdownV2',
text: text,
});
try {
const response = await fetch(`https://api.telegram.org/bot${apiToken}/sendMessage`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: payload.toString(),
});
if (!response.ok) {
console.error(
`Failed to send Telegram notification "${text}", ${response.status} ${response.statusText
} ${await response.text()}`,
);
}
} catch (error) {
console.error('Error sending Telegram notification:', error);
}
}
async function sendBarkNotification(env: Env, monitor: any, title: string, body: string, group: string = '') {
const barkServer = env.BARK_SERVER;
const barkDeviceKey = env.BARK_DEVICE_KEY;
const barkUrl = `${barkServer}/push`;
const data = {
title: title,
body: body,
group: group,
url: monitor.url,
device_key: barkDeviceKey,
};
const response = await fetch(barkUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.ok) {
console.log('Bark notification sent successfully.');
} else {
const respText = await response.text();
console.error('Failed to send Bark notification:', response.status, response.statusText, respText);
}
}
// Don't forget this, otherwise compilation fails. // Don't forget this, otherwise compilation fails.
export { pageConfig, workerConfig } export { pageConfig, workerConfig }

View File

@ -58,8 +58,12 @@ export default {
state.overallDown = 0 state.overallDown = 0
state.overallUp = 0 state.overallUp = 0
let statusChanged = false
const currentTimeSecond = Math.round(Date.now() / 1000)
// Check each monitor // Check each monitor
// TODO: concurrent status check // TODO: concurrent status check
for (const monitor of workerConfig.monitors) { for (const monitor of workerConfig.monitors) {
console.log(`[${workerLocation}] Checking ${monitor.name}...`) console.log(`[${workerLocation}] Checking ${monitor.name}...`)
@ -112,11 +116,12 @@ export default {
// close existing incident if any // close existing incident if any
if (lastIncident.end === undefined) { if (lastIncident.end === undefined) {
lastIncident.end = currentTimeSecond lastIncident.end = currentTimeSecond
statusChanged = true
try { try {
await workerConfig.callbacks.onStatusChange( await workerConfig.callbacks.onStatusChange(
monitor.id, env,
monitor.name, monitor,
true, true,
lastIncident.start[0], lastIncident.start[0],
currentTimeSecond, currentTimeSecond,
@ -136,11 +141,12 @@ export default {
end: undefined, end: undefined,
error: [status.err], error: [status.err],
}) })
statusChanged = true
try { try {
await workerConfig.callbacks.onStatusChange( await workerConfig.callbacks.onStatusChange(
monitor.id, env,
monitor.name, monitor,
false, false,
currentTimeSecond, currentTimeSecond,
currentTimeSecond, currentTimeSecond,
@ -157,11 +163,12 @@ export default {
// append if the error message changes // append if the error message changes
lastIncident.start.push(currentTimeSecond) lastIncident.start.push(currentTimeSecond)
lastIncident.error.push(status.err) lastIncident.error.push(status.err)
statusChanged = true
try { try {
await workerConfig.callbacks.onStatusChange( await workerConfig.callbacks.onStatusChange(
monitor.id, env,
monitor.name, monitor,
false, false,
lastIncident.start[0], lastIncident.start[0],
currentTimeSecond, currentTimeSecond,
@ -175,8 +182,8 @@ export default {
try { try {
await workerConfig.callbacks.onIncident( await workerConfig.callbacks.onIncident(
monitor.id, env,
monitor.name, monitor,
lastIncident.start[0], lastIncident.start[0],
currentTimeSecond, currentTimeSecond,
status.err status.err
@ -215,6 +222,12 @@ export default {
// Update state // Update state
state.lastUpdate = Math.round(Date.now() / 1000) state.lastUpdate = Math.round(Date.now() / 1000)
await env.UPTIMEFLARE_STATE.put('state', JSON.stringify(state)) if (
statusChanged ||
currentTimeSecond - state.lastUpdate >= workerConfig.kvWriteCooldownMinutes * 60
) {
state.lastUpdate = currentTimeSecond
await env.UPTIMEFLARE_STATE.put('state', JSON.stringify(state))
}
}, },
} }