mirror of
https://github.com/tormachris/cf-workers-status-page.git
synced 2024-11-23 22:45:43 +01:00
feat: optimize KV storage read/write operations
- the state is now stored in a single KV key - there is one write for cron and one read for render
This commit is contained in:
parent
293dff9425
commit
c5b9232eb2
@ -12,7 +12,7 @@ You'll need a [Cloudflare Workers account](https://dash.cloudflare.com/sign-up/w
|
||||
|
||||
* A workers domain set up
|
||||
* The Workers Bundled subscription \($5/mo\)
|
||||
* [Try it now with the free tier!](https://blog.cloudflare.com/workers-kv-free-tier/) Check [more info](#workers-kv-free-tier) on how to run on Workers Free.
|
||||
* [It works with Workers Free!](https://blog.cloudflare.com/workers-kv-free-tier/) Check [more info](#workers-kv-free-tier) on how to run on Workers Free.
|
||||
* Some websites/APIs to watch 🙂
|
||||
|
||||
Also, prepare the following secrets
|
||||
@ -85,8 +85,8 @@ You can clone the repository yourself and use Wrangler CLI to develop/deploy, ex
|
||||
* `SECRET_SLACK_WEBHOOK_URL`
|
||||
|
||||
## Workers KV free tier
|
||||
The Workers Free plan includes limited KV usage, in order to not deplete the quota and still have enough room for monitor status changes we recommend the following changes:
|
||||
* Change the CRON trigger to 5 minutes interval (`crons = ["*/5 * * * *"]`) in [wrangler.toml](./wrangler.toml)
|
||||
The Workers Free plan includes limited KV usage, but the quota is sufficient for 2-minute checks only
|
||||
* Change the CRON trigger to 2 minutes interval (`crons = ["*/2 * * * *"]`) in [wrangler.toml](./wrangler.toml)
|
||||
|
||||
## Known issues
|
||||
|
||||
|
@ -2,9 +2,7 @@ import Head from 'flareact/head'
|
||||
import MonitorHistogram from '../src/components/monitorHistogram'
|
||||
|
||||
import {
|
||||
getLastUpdate,
|
||||
getMonitors,
|
||||
getMonitorsHistory,
|
||||
useKeyPress,
|
||||
} from '../src/functions/helpers'
|
||||
|
||||
@ -30,30 +28,13 @@ const filterByTerm = (term) => MonitorStore.set(
|
||||
|
||||
export async function getEdgeProps() {
|
||||
// get KV data
|
||||
const kvMonitors = await getMonitors()
|
||||
const kvMonitorsFailedDays = await getMonitorsHistory()
|
||||
const kvLastUpdate = await getLastUpdate()
|
||||
|
||||
// prepare data maps for components
|
||||
let monitorsOperational = true
|
||||
let kvMonitorsMap = {}
|
||||
kvMonitors.forEach(x => {
|
||||
kvMonitorsMap[x.metadata.id] = x.metadata
|
||||
if (x.metadata.operational === false) monitorsOperational = false
|
||||
})
|
||||
|
||||
// transform KV list to array of failed days
|
||||
const kvMonitorsFailedDaysArray = kvMonitorsFailedDays.map(x => {
|
||||
return x.name
|
||||
})
|
||||
const {value: kvMonitors, metadata: kvMonitorsMetadata } = await getMonitors()
|
||||
|
||||
return {
|
||||
props: {
|
||||
config,
|
||||
kvMonitorsMap,
|
||||
kvMonitorsFailedDaysArray,
|
||||
monitorsOperational,
|
||||
kvLastUpdate,
|
||||
kvMonitors: kvMonitors || {},
|
||||
kvMonitorsMetadata: kvMonitorsMetadata || {}
|
||||
},
|
||||
// Revalidate these props once every x seconds
|
||||
revalidate: 5,
|
||||
@ -62,10 +43,8 @@ export async function getEdgeProps() {
|
||||
|
||||
export default function Index({
|
||||
config,
|
||||
kvMonitorsMap,
|
||||
kvMonitorsFailedDaysArray,
|
||||
monitorsOperational,
|
||||
kvLastUpdate,
|
||||
kvMonitors,
|
||||
kvMonitorsMetadata,
|
||||
}) {
|
||||
const state = useStore(MonitorStore)
|
||||
const slash = useKeyPress('/')
|
||||
@ -96,8 +75,7 @@ export default function Index({
|
||||
/>
|
||||
</div>
|
||||
<MonitorStatusHeader
|
||||
operational={monitorsOperational}
|
||||
lastUpdate={kvLastUpdate}
|
||||
kvMonitorsMetadata={kvMonitorsMetadata}
|
||||
/>
|
||||
{state.visible.map((monitor, key) => {
|
||||
return (
|
||||
@ -115,15 +93,13 @@ export default function Index({
|
||||
<div className="content">{monitor.name}</div>
|
||||
</div>
|
||||
<MonitorStatusLabel
|
||||
kvMonitorsMap={kvMonitorsMap}
|
||||
monitor={monitor}
|
||||
kvMonitor={kvMonitors[monitor.id]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MonitorHistogram
|
||||
kvMonitorsFailedDaysArray={kvMonitorsFailedDaysArray}
|
||||
monitor={monitor}
|
||||
kvMonitor={kvMonitorsMap[monitor.id]}
|
||||
monitorId={monitor.id}
|
||||
kvMonitor={kvMonitors[monitor.id]}
|
||||
/>
|
||||
|
||||
<div className="horizontal flex between grey-text">
|
||||
|
@ -1,8 +1,7 @@
|
||||
import config from '../../config.yaml'
|
||||
|
||||
export default function MonitorHistogram({
|
||||
kvMonitorsFailedDaysArray,
|
||||
monitor,
|
||||
monitorId,
|
||||
kvMonitor,
|
||||
}) {
|
||||
// create date and set date - daysInHistogram for the first day of the histogram
|
||||
@ -12,20 +11,19 @@ export default function MonitorHistogram({
|
||||
if (typeof window !== 'undefined') {
|
||||
return (
|
||||
<div
|
||||
key={`${monitor.id}-histogram`}
|
||||
key={`${monitorId}-histogram`}
|
||||
className="horizontal flex histogram"
|
||||
>
|
||||
{Array.from(Array(config.settings.daysInHistogram).keys()).map(key => {
|
||||
date.setDate(date.getDate() + 1)
|
||||
const dayInHistogram = date.toISOString().split('T')[0]
|
||||
const dayInHistogramKey = 'h_' + monitor.id + '_' + dayInHistogram
|
||||
|
||||
let bg = ''
|
||||
let dayInHistogramLabel = config.settings.dayInHistogramNoData
|
||||
|
||||
// filter all dates before first check, check the rest
|
||||
if (kvMonitor && kvMonitor.firstCheck <= dayInHistogram) {
|
||||
if (!kvMonitorsFailedDaysArray.includes(dayInHistogramKey)) {
|
||||
if (!kvMonitor.failedDays.includes(dayInHistogram)) {
|
||||
bg = 'green'
|
||||
dayInHistogramLabel = config.settings.dayInHistogramOperational
|
||||
} else {
|
||||
@ -48,7 +46,7 @@ export default function MonitorHistogram({
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={`${monitor.id}-histogram`}
|
||||
key={`${monitorId}-histogram`}
|
||||
className="horizontal flex histogram"
|
||||
>
|
||||
<div className="grey-text">Loading histogram ...</div>
|
||||
|
@ -1,17 +1,15 @@
|
||||
import config from '../../config.yaml'
|
||||
|
||||
export default function MonitorStatusHeader({ operational, lastUpdate }) {
|
||||
export default function MonitorStatusHeader({kvMonitorsMetadata}) {
|
||||
let backgroundColor = 'green'
|
||||
let headerText = config.settings.allmonitorsOperational
|
||||
let textColor = 'black'
|
||||
|
||||
if (!operational) {
|
||||
if (!kvMonitorsMetadata.monitorsOperational) {
|
||||
backgroundColor = 'yellow'
|
||||
headerText = config.settings.notAllmonitorsOperational
|
||||
}
|
||||
|
||||
const lastCheckAgo = Math.round((Date.now() - lastUpdate.value) / 1000)
|
||||
|
||||
return (
|
||||
<div className={`ui inverted segment ${backgroundColor}`}>
|
||||
<div className="horizontal flex between">
|
||||
@ -19,9 +17,9 @@ export default function MonitorStatusHeader({ operational, lastUpdate }) {
|
||||
{headerText}
|
||||
</div>
|
||||
{
|
||||
lastUpdate.metadata && typeof window !== 'undefined' && (
|
||||
kvMonitorsMetadata.lastUpdate && typeof window !== 'undefined' && (
|
||||
<div className={`${textColor}-text`}>
|
||||
checked {lastCheckAgo} sec ago (from {lastUpdate.metadata.loc})
|
||||
checked {Math.round((Date.now() - kvMonitorsMetadata.lastUpdate.time) / 1000)} sec ago (from {kvMonitorsMetadata.lastUpdate.loc})
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import config from '../../config.yaml'
|
||||
|
||||
export default function MonitorStatusLabel({ kvMonitorsMap, monitor }) {
|
||||
export default function MonitorStatusLabel({ kvMonitor }) {
|
||||
let labelColor = 'grey'
|
||||
let labelText = 'No data'
|
||||
|
||||
if (typeof kvMonitorsMap[monitor.id] !== 'undefined') {
|
||||
if (kvMonitorsMap[monitor.id].operational) {
|
||||
if (typeof kvMonitor !== 'undefined') {
|
||||
if (kvMonitor.operational) {
|
||||
labelColor = 'green'
|
||||
labelText = config.settings.monitorLabelOperational
|
||||
} else {
|
||||
|
@ -3,7 +3,6 @@ import config from '../../config.yaml'
|
||||
import {
|
||||
setKV,
|
||||
getKVWithMetadata,
|
||||
getKV,
|
||||
notifySlack,
|
||||
} from './helpers'
|
||||
|
||||
@ -12,9 +11,29 @@ function getDate() {
|
||||
}
|
||||
|
||||
export async function processCronTrigger(event) {
|
||||
// Get monitors state from KV
|
||||
let {value: monitorsState, metadata: monitorsStateMetadata} = await getKVWithMetadata('monitors_data', 'json')
|
||||
|
||||
// Create empty state objects if not exists in KV storage yet
|
||||
if (!monitorsState) {
|
||||
monitorsState = {}
|
||||
}
|
||||
if (!monitorsStateMetadata) {
|
||||
monitorsStateMetadata = {}
|
||||
}
|
||||
|
||||
// Reset default all monitors state to true
|
||||
monitorsStateMetadata.monitorsOperational = true
|
||||
|
||||
for (const monitor of config.monitors) {
|
||||
// Create default monitor state if does not exist yet
|
||||
if (typeof monitorsState[monitor.id] === 'undefined') {
|
||||
monitorsState[monitor.id] = {failedDays: []}
|
||||
}
|
||||
|
||||
console.log(`Checking ${monitor.name} ...`)
|
||||
|
||||
// Fetch the monitors URL
|
||||
const init = {
|
||||
method: monitor.method || 'GET',
|
||||
redirect: monitor.followRedirect ? 'follow' : 'manual',
|
||||
@ -24,50 +43,40 @@ export async function processCronTrigger(event) {
|
||||
}
|
||||
|
||||
const checkResponse = await fetch(monitor.url, init)
|
||||
const kvState = await getKVWithMetadata('s_' + monitor.id)
|
||||
const monitorOperational = checkResponse.status === (monitor.expectStatus || 200)
|
||||
|
||||
// metadata from monitor settings
|
||||
const newMetadata = {
|
||||
operational: checkResponse.status === (monitor.expectStatus || 200),
|
||||
id: monitor.id,
|
||||
firstCheck: kvState.metadata ? kvState.metadata.firstCheck : getDate(),
|
||||
// Send Slack message on monitor change
|
||||
if (monitorsState[monitor.id].operational !== monitorOperational && typeof SECRET_SLACK_WEBHOOK_URL !== 'undefined' && SECRET_SLACK_WEBHOOK_URL !== 'default-gh-action-secret') {
|
||||
event.waitUntil(notifySlack(monitor, monitorOperational))
|
||||
}
|
||||
|
||||
// write current status if status changed or for first time
|
||||
if (
|
||||
!kvState.metadata ||
|
||||
kvState.metadata.operational !== newMetadata.operational
|
||||
) {
|
||||
console.log('Saving changed state..')
|
||||
monitorsState[monitor.id].operational = checkResponse.status === (monitor.expectStatus || 200)
|
||||
monitorsState[monitor.id].firstCheck = monitorsState[monitor.id].firstCheck || getDate()
|
||||
|
||||
// first try to notify Slack in case fetch() or other limit is reached
|
||||
if (typeof SECRET_SLACK_WEBHOOK_URL !== 'undefined' && SECRET_SLACK_WEBHOOK_URL !== 'default-gh-action-secret') {
|
||||
await notifySlack(monitor, newMetadata)
|
||||
}
|
||||
// Set monitorsOperational and push current day to failedDays
|
||||
if (!monitorOperational) {
|
||||
monitorsStateMetadata.monitorsOperational = false
|
||||
|
||||
await setKV('s_' + monitor.id, null, newMetadata)
|
||||
}
|
||||
|
||||
// write daily status if monitor is not operational
|
||||
if (!newMetadata.operational) {
|
||||
// try to get failed daily status first as KV read is cheaper than write
|
||||
const kvFailedDayStatusKey = 'h_' + monitor.id + '_' + getDate()
|
||||
const kvFailedDayStatus = await getKV(kvFailedDayStatusKey)
|
||||
|
||||
// write if not found
|
||||
if (!kvFailedDayStatus) {
|
||||
console.log('Saving new failed daily status..')
|
||||
await setKV(kvFailedDayStatusKey, null)
|
||||
const failedDay = getDate()
|
||||
if (!monitorsState[monitor.id].failedDays.includes(failedDay)) {
|
||||
console.log('Saving new failed daily status ...')
|
||||
monitorsState[monitor.id].failedDays.push(failedDay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// save last check timestamp including PoP location
|
||||
// Get Worker PoP and save it to monitorsStateMetadata
|
||||
const res = await fetch('https://cloudflare-dns.com/dns-query', {
|
||||
method: 'OPTIONS',
|
||||
})
|
||||
const loc = res.headers.get('cf-ray').split('-')[1]
|
||||
await setKV('lastUpdate', Date.now(), { loc })
|
||||
monitorsStateMetadata.lastUpdate = {
|
||||
loc,
|
||||
time: Date.now()
|
||||
}
|
||||
|
||||
// Save monitorsState and monitorsStateMetadata to KV storage
|
||||
await setKV('monitors_data', JSON.stringify(monitorsState), monitorsStateMetadata)
|
||||
|
||||
return new Response('OK')
|
||||
}
|
||||
|
@ -2,72 +2,29 @@ import config from '../../config.yaml'
|
||||
import {useEffect, useState} from 'react'
|
||||
|
||||
export async function getMonitors() {
|
||||
const monitors = await listKV('s_')
|
||||
return monitors.keys
|
||||
}
|
||||
|
||||
export async function getMonitorsHistory() {
|
||||
const monitorsHistory = await listKV('h_', 300)
|
||||
return monitorsHistory.keys
|
||||
}
|
||||
|
||||
export async function getLastUpdate() {
|
||||
return await getKVWithMetadata('lastUpdate')
|
||||
}
|
||||
|
||||
export async function listKV(prefix = '', cacheTtl = false) {
|
||||
const cacheKey = 'list_' + prefix + '_' + process.env.BUILD_ID
|
||||
|
||||
if (cacheTtl) {
|
||||
const cachedResponse = await getKV(cacheKey)
|
||||
if (cachedResponse) {
|
||||
return JSON.parse(cachedResponse)
|
||||
}
|
||||
}
|
||||
|
||||
let list = []
|
||||
let cursor = null
|
||||
let res = {}
|
||||
do {
|
||||
res = await KV_STATUS_PAGE.list({ prefix: prefix, cursor })
|
||||
list = list.concat(res.keys)
|
||||
cursor = res.cursor
|
||||
} while (!res.list_complete)
|
||||
|
||||
if (cacheTtl) {
|
||||
await setKV(cacheKey, JSON.stringify({ keys: list }), null, cacheTtl)
|
||||
}
|
||||
return { keys: list }
|
||||
return await getKVWithMetadata('monitors_data', "json")
|
||||
}
|
||||
|
||||
export async function setKV(key, value, metadata, expirationTtl) {
|
||||
return KV_STATUS_PAGE.put(key, value, { metadata, expirationTtl })
|
||||
}
|
||||
|
||||
export async function getKV(key, type = 'text') {
|
||||
return KV_STATUS_PAGE.get(key, type)
|
||||
export async function getKVWithMetadata(key, type = 'text') {
|
||||
return KV_STATUS_PAGE.getWithMetadata(key, type)
|
||||
}
|
||||
|
||||
export async function getKVWithMetadata(key) {
|
||||
return KV_STATUS_PAGE.getWithMetadata(key)
|
||||
}
|
||||
|
||||
export async function deleteKV(key) {
|
||||
return KV_STATUS_PAGE.delete(key)
|
||||
}
|
||||
|
||||
export async function notifySlack(monitor, newMetadata) {
|
||||
export async function notifySlack(monitor, operational) {
|
||||
const payload = {
|
||||
attachments: [
|
||||
{
|
||||
color: newMetadata.operational ? '#36a64f' : '#f2c744',
|
||||
color: operational ? '#36a64f' : '#f2c744',
|
||||
blocks: [
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `Monitor *${monitor.name}* changed status to *${
|
||||
newMetadata.operational
|
||||
operational
|
||||
? config.settings.monitorLabelOperational
|
||||
: config.settings.monitorLabelNotOperational
|
||||
}*`,
|
||||
@ -79,7 +36,7 @@ export async function notifySlack(monitor, newMetadata) {
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `${
|
||||
newMetadata.operational ? ':white_check_mark:' : ':x:'
|
||||
operational ? ':white_check_mark:' : ':x:'
|
||||
} \`${monitor.method ? monitor.method : "GET"} ${monitor.url}\` - :eyes: <${
|
||||
config.settings.url
|
||||
}|Status Page>`,
|
||||
|
Loading…
Reference in New Issue
Block a user