Merge pull request #10 from lroolle/feat/notify-example
feat: add notify telegram & bark, and some minor fixespull/11/head
commit
5db2013f65
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
146
uptime.config.ts
146
uptime.config.ts
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue