feat: add notify telegram & bark, and some minor fixes

pull/10/head
Eric Wang 2024-03-18 00:06:10 +08:00
parent 63be746b4d
commit 0c6556eda9
6 changed files with 174 additions and 40 deletions

View File

@ -22,16 +22,21 @@ jobs:
cache: 'npm'
# Automatically get an account id via the API Token
# if secrets.CLOUDFLARE_ACCOUNT_ID is not set.
- name: Fetch Account ID
id: fetch_account_id
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
ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
echo "Using provided CLOUDFLARE_ACCOUNT_ID from secrets."
else
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
else
echo 'account_id='$ACCOUNT_ID >> $GITHUB_OUTPUT
fi
fi
echo 'account_id='$ACCOUNT_ID >> $GITHUB_OUTPUT
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@ -62,7 +67,7 @@ jobs:
terraform apply -auto-approve -input=false
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
TF_VAR_CLOUDFLARE_ACCOUNT_ID: ${{ steps.fetch_account_id.outputs.account_id }}
TF_VAR_CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || steps.fetch_account_id.outputs.account_id }}
# Still need to upload worker to keep it up-to-date (Terraform will fail after first-time setup)
- name: Upload worker
@ -70,7 +75,7 @@ jobs:
curl --fail-with-body -X PUT https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/content --header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN -F 'index.js=@worker/dist/index.js;type=application/javascript+module' -F 'metadata={"main_module": "index.js"}'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ steps.fetch_account_id.outputs.account_id }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || steps.fetch_account_id.outputs.account_id }}
# Currently Terraform Cloudflare provider doesn't support direct upload, use wrangler to upload instead.
- name: Upload pages
@ -78,3 +83,4 @@ jobs:
npx wrangler pages deploy .vercel/output/static --project-name uptimeflare
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || steps.fetch_account_id.outputs.account_id }}

View File

@ -34,7 +34,9 @@ Please refer to [Quickstart](https://github.com/lyc8503/UptimeFlare/wiki/Quickst
- [x] TCP `opened` promise
- [x] ~~SSL certificate checks~~
- [ ] Slack / Telegram webhook example
- [x] Telegram example
- [x] [Bark](https://bark.day.app) example
- [ ] Slack example
- [ ] Incident timeline
- [ ] Email notification via Cloudflare Email Workers
- [x] Specify region for monitors

View File

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

View File

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

View File

@ -10,6 +10,8 @@ const pageConfig = {
}
const workerConfig = {
// Write KV at most every 10 minutes unless the status changed.
kvWriteCooldownMinutes: 10,
// Define all your monitors here
monitors: [
// Example HTTP Monitor
@ -18,6 +20,8 @@ const workerConfig = {
id: 'foo_monitor',
// `name` is used at status page and callback message
name: 'My API Monitor',
// `url` for clickable link at status page
url: 'https://example.com',
// `method` should be a valid HTTP Method
method: 'POST',
// `target` is a valid URL
@ -55,35 +59,22 @@ const workerConfig = {
],
callbacks: {
onStatusChange: async (
id: string,
name: string,
env: Env,
monitor: any,
isUp: boolean,
timeIncidentStart: number,
timeNow: number,
reason: string
) => {
// This callback will be called when any monitor's status changed
// Write any Typescript code here
// Example implementation:
// const timeString = new Date(timeNow * 1000).toLocaleString('zh-CN', {
// timeZone: 'Asia/Shanghai',
// })
// 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)
await notify(env, monitor, isUp, timeIncidentStart, timeNow, reason)
},
onIncident: async (
id: string,
name: string,
env: Env,
monitor: any,
isUp: boolean,
timeIncidentStart: 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
// Write any Typescript code here
@ -91,5 +82,117 @@ const workerConfig = {
},
}
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.
export { pageConfig, workerConfig }

View File

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