diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 940f6f4..d29d19f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 [[ "$ACCOUNT_ID" == "null" ]]; then - echo "Failed to get an account id, please make sure you have set up CLOUDFLARE_API_TOKEN correctly!" - exit 1 + if [[ -n "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" ]]; then + ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" + echo "Using provided CLOUDFLARE_ACCOUNT_ID from secrets." 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 + echo 'account_id='$ACCOUNT_ID >> $GITHUB_OUTPUT env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -56,13 +61,13 @@ jobs: # since we didn't setup a remote backend to store state, # following runs will fail with name conflict, which is normal. continue-on-error: true - + run: | terraform init 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 }} diff --git a/README.md b/README.md index 619c3c8..a828124 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/components/MonitorDetail.tsx b/components/MonitorDetail.tsx index 2869866..96ada51 100644 --- a/components/MonitorDetail.tsx +++ b/components/MonitorDetail.tsx @@ -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 = ( {statusIcon} {monitor.name} + {monitor.url ? ( + + {statusIcon} {monitor.name} + + ) : ( + <> + {statusIcon} {monitor.name} + + )} ) diff --git a/deploy.tf b/deploy.tf index ff8dd9c..65e2a05 100644 --- a/deploy.tf +++ b/deploy.tf @@ -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` ] } @@ -56,4 +56,4 @@ resource "cloudflare_pages_project" "uptimeflare" { compatibility_flags = ["nodejs_compat"] } } -} \ No newline at end of file +} diff --git a/uptime.config.ts b/uptime.config.ts index 8d770cd..81bb18e 100644 --- a/uptime.config.ts +++ b/uptime.config.ts @@ -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 } diff --git a/worker/src/index.ts b/worker/src/index.ts index dfea631..496892a 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -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) - 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)) + } }, }