[WIP] workers works! (lol) pages+migration todo

pull/177/head
lyc8503 2025-12-31 22:19:29 +08:00
parent 45d86424b2
commit e050065a3c
15 changed files with 329 additions and 154 deletions

View File

@ -34,7 +34,7 @@ export default function DetailChart({
state: MonitorState
}) {
const { t } = useTranslation('common')
const latencyData = state.latency[monitor.id].recent.map((point) => ({
const latencyData = state.latency[monitor.id].map((point) => ({
x: point.time * 1000,
y: point.ping,
loc: point.loc,

View File

@ -70,7 +70,9 @@ export default function MaintenanceAlert({
{upcoming ? t('Expected end') : t('To')}
</div>
<div>
{maintenance.end ? new Date(maintenance.end).toLocaleString() : t('Until further notice')}
{maintenance.end
? new Date(maintenance.end).toLocaleString()
: t('Until further notice')}
</div>
</div>
</div>

View File

@ -2,7 +2,3 @@ CREATE TABLE IF NOT EXISTS uptimeflare (
key VARCHAR(255) PRIMARY KEY,
value BLOB NOT NULL
);
REPLACE INTO uptimeflare (key, value) VALUES ('state', '{"test": 1}');
INSERT INTO uptimeflare (key, value) VALUES ('state', '{"test": 1}') ON CONFLICT(key) DO UPDATE SET value = excluded.value;

View File

@ -61,7 +61,7 @@ export default async function handler(req: NextRequest): Promise<Response> {
const state = JSON.parse(stateStr) as MonitorState
const monitorIncidentHistory = state.incident?.[monitorId]
const hasLatencyData = Boolean(state.latency?.[monitorId]?.recent?.length)
const hasLatencyData = Boolean(state.latency?.[monitorId]?.length)
if (!monitorIncidentHistory || monitorIncidentHistory.length === 0 || !hasLatencyData) {
return new Response(JSON.stringify(errorBadge(label, 'unknown')), {

View File

@ -29,8 +29,8 @@ export default async function handler(req: NextRequest): Promise<Response> {
const isUp = state.incident[monitor.id].slice(-1)[0].end !== undefined
monitors[monitor.id] = {
up: isUp,
latency: state.latency[monitor.id].recent.slice(-1)[0].ping,
location: state.latency[monitor.id].recent.slice(-1)[0].loc,
latency: state.latency[monitor.id].slice(-1)[0].ping,
location: state.latency[monitor.id].slice(-1)[0].loc,
message: isUp ? 'OK' : state.incident[monitor.id].slice(-1)[0].error.slice(-1)[0],
}
}

View File

@ -57,9 +57,7 @@ export default function Home({
{state == undefined ? (
<Center>
<Text fw={700}>
{t('Monitor State not defined')}
</Text>
<Text fw={700}>{t('Monitor State not defined')}</Text>
</Center>
) : (
<div>
@ -76,7 +74,7 @@ export default function Home({
export async function getServerSideProps() {
// Read state as string from KV, to avoid hitting server-side cpu time limit
const state = await getFromStore(process.env as any, 'state') as unknown as MonitorState
const state = (await getFromStore(process.env as any, 'state')) as unknown as MonitorState
// Only present these values to client
const monitors = workerConfig.monitors.map((monitor) => {

View File

@ -92,32 +92,61 @@ export type Callbacks<TEnv = Env> = {
) => Promise<any> | any
}
export type IncidentRecord = {
start: number[]
end: number | null // null if it's still open
error: string[]
}
export type LatencyRecord = {
loc: string
ping: number
time: number
}
export type MonitorState = {
lastUpdate: number
overallUp: number
overallDown: number
incident: Record<string, IncidentRecord[]>
latency: Record<string, LatencyRecord[]> // recent 12 hour data, N min interval
}
// This is now the actual stored format (after TODO D1 migration) to improve (de)serialization performance
// This gives a ~3.5x speedup in computing and a 58% reduction in size
// The CPULimitExceeded issue with 10+ monitors on free tier should be largely mitigated by this change
// local profiling result (1 op = parse + stringify):
// MonitorState (original): 277 ops/s, ±0.51% | slowest, 71.09% slower
// MonitorStateCompacted: 958 ops/s, ±1.17% | fastest
export type MonitorStateCompacted = {
lastUpdate: number
overallUp: number
overallDown: number
// incident in stored in columnar format
incident: Record<
string,
string, // monitor id
{
start: number[]
end: number | undefined // undefined if it's still open
error: string[]
}[]
start: number[][]
end: (number | null)[]
error: string[][]
}
>
// latency in stored in columnar format
// also uses Run-length encoding for loc & Base64 encoding for number arrays
latency: Record<
string,
string, // monitor id
{
recent: {
loc: string
ping: number
time: number
}[] // recent 12 hour data, 2 min interval
all: {
loc: string
ping: number
time: number
}[] // all data in 90 days, 1 hour interval
loc: {
v: string[] // RLE values
c: number[] // RLE counts
}
// Hex results in a larger size and slower encoding/decoding than base64,
// but we can pop/append arbitrary number of bytes without decoding then re-encoding the whole string
// This is useful in Workers and shows a ~2% speedup comapred to base64, and it also simplifies the code
ping: string // Hex encoded Uint16Array
time: string // Hex encoded Uint32Array
}
>
}

View File

@ -1,8 +1,8 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from '../locales/en/common.json';
import zhCN from '../locales/zh-CN/common.json';
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import en from '../locales/en/common.json'
import zhCN from '../locales/zh-CN/common.json'
i18n
.use(LanguageDetector)
@ -20,6 +20,6 @@ i18n
detection: {
order: ['navigator'],
},
});
})
export default i18n;
export default i18n

View File

@ -1,9 +1,9 @@
import { DurableObject } from 'cloudflare:workers'
import { MonitorState, MonitorTarget } from '../../types/config'
import { maintenances, workerConfig } from '../../uptime.config'
import { MonitorTarget } from '../../types/config'
import { workerConfig } from '../../uptime.config'
import { getStatus, getStatusWithGlobalPing } from './monitor'
import { formatStatusChangeNotification, getWorkerLocation, webhookNotify } from './util'
import { getFromStore, setToStore } from './store'
import { formatAndNotify, getWorkerLocation } from './util'
import { CompactedMonitorStateWrapper, getFromStore, setToStore } from './store'
export interface Env {
UPTIMEFLARE_STATE: KVNamespace
@ -16,66 +16,12 @@ const Worker = {
const workerLocation = (await getWorkerLocation()) || 'ERROR'
console.log(`Running scheduled event on ${workerLocation}...`)
// Auxiliary function to format notification and send it via webhook
let formatAndNotify = async (
monitor: MonitorTarget,
isUp: boolean,
timeIncidentStart: number,
timeNow: number,
reason: string
) => {
// Skip notification if monitor is in the skip list
const skipList = workerConfig.notification?.skipNotificationIds
if (skipList && skipList.includes(monitor.id)) {
console.log(
`Skipping notification for ${monitor.name} (${monitor.id} in skipNotificationIds)`
// Create a wrapped MonitorState from stored compacted state
const state = new CompactedMonitorStateWrapper(
await getFromStore(env, 'state')
)
return
}
// Skip notification if monitor is in maintenance
const maintenanceList = maintenances
.filter(
(m) =>
new Date(timeNow * 1000) >= new Date(m.start) &&
(!m.end || new Date(timeNow * 1000) <= new Date(m.end))
)
.map((e) => e.monitors || [])
.flat()
if (maintenanceList.includes(monitor.id)) {
console.log(`Skipping notification for ${monitor.name} (in maintenance)`)
return
}
if (workerConfig.notification?.webhook) {
const notification = formatStatusChangeNotification(
monitor,
isUp,
timeIncidentStart,
timeNow,
reason,
workerConfig.notification?.timeZone ?? 'Etc/GMT'
)
await webhookNotify(workerConfig.notification.webhook, notification)
} else {
console.log(`Webhook not set, skipping notification for ${monitor.name}`)
}
}
// Read state, set init state if it doesn't exist
let state = JSON.parse(await getFromStore(env, 'state') || '{}') as unknown as MonitorState
if (!state || Object.keys(state).length === 0) {
state = {
lastUpdate: 0,
overallUp: 0,
overallDown: 0,
incident: {},
latency: {},
}
}
state.overallDown = 0
state.overallUp = 0
state.data.overallDown = 0
state.data.overallUp = 0
let statusChanged = false
const currentTimeSecond = Math.round(Date.now() / 1000)
@ -126,6 +72,7 @@ const Worker = {
console.log('Falling back to local check...')
status = await getStatus(monitor)
} else {
// TODO: more consistent error handling (throw or return?)
status = { ping: 0, up: false, err: 'Unknown check proxy error' }
}
}
@ -134,29 +81,32 @@ const Worker = {
status = await getStatus(monitor)
}
// const status = await getStatus(monitor)
const currentTimeSecond = Math.round(Date.now() / 1000)
// Update counters
status.up ? state.overallUp++ : state.overallDown++
status.up ? state.data.overallUp++ : state.data.overallDown++
// Update incidents
// Create a dummy incident to store the start time of the monitoring and simplify logic
state.incident[monitor.id] = state.incident[monitor.id] || [
{
if (state.incidentLen(monitor.id) === 0) {
state.appendIncident(monitor.id, {
start: [currentTimeSecond],
end: currentTimeSecond,
error: ['dummy'],
},
]
// Then lastIncident here must not be undefined
let lastIncident = state.incident[monitor.id].slice(-1)[0]
})
}
// Then lastIncident here must not be null
let lastIncident = state.getIncident(monitor.id, state.incidentLen(monitor.id) - 1)
if (status.up) {
// Current status is up
// close existing incident if any
if (lastIncident.end === undefined) {
if (lastIncident.end === null) {
lastIncident.end = currentTimeSecond
// write back the modified last incident
state.setIncident(monitor.id, state.incidentLen(monitor.id) - 1, lastIncident)
monitorStatusChanged = true
try {
if (
@ -190,24 +140,30 @@ const Worker = {
} else {
// Current status is down
// open new incident if not already open
if (lastIncident.end !== undefined) {
state.incident[monitor.id].push({
if (lastIncident.end !== null) {
state.appendIncident(monitor.id, {
start: [currentTimeSecond],
end: undefined,
end: null,
error: [status.err],
})
monitorStatusChanged = true
} else if (
lastIncident.end === undefined &&
lastIncident.end === null &&
lastIncident.error.slice(-1)[0] !== status.err
) {
// append if the error message changes
lastIncident.start.push(currentTimeSecond)
lastIncident.error.push(status.err)
// write back the modified last incident
state.setIncident(monitor.id, state.incidentLen(monitor.id) - 1, lastIncident)
monitorStatusChanged = true
}
const currentIncident = state.incident[monitor.id].slice(-1)[0]
const currentIncident = state.getIncident(
monitor.id,
state.incidentLen(monitor.id) - 1
)
try {
if (
// monitor status changed AND...
@ -284,63 +240,54 @@ const Worker = {
}
// append to latency data
let latencyLists = state.latency[monitor.id] || {
recent: [],
}
latencyLists.all = []
const record = {
state.appendLatency(monitor.id,{
loc: checkLocation,
ping: status.ping,
time: currentTimeSecond,
}
latencyLists.recent.push(record)
})
// discard old data
while (latencyLists.recent[0]?.time < currentTimeSecond - 12 * 60 * 60) {
latencyLists.recent.shift()
while (state.getFirstLatency(monitor.id).time < currentTimeSecond - 12 * 60 * 60) {
state.unshiftLatency(monitor.id)
}
state.latency[monitor.id] = latencyLists
// discard old incidents
let incidentList = state.incident[monitor.id]
while (
incidentList.length > 0 &&
incidentList[0].end &&
incidentList[0].end < currentTimeSecond - 90 * 24 * 60 * 60
state.incidentLen(monitor.id) > 0 &&
state.getIncident(monitor.id, 0).end &&
state.getIncident(monitor.id, 0).end! < currentTimeSecond - 90 * 24 * 60 * 60
) {
incidentList.shift()
state.shiftIncident(monitor.id)
}
if (
incidentList.length == 0 ||
(incidentList[0].start[0] > currentTimeSecond - 90 * 24 * 60 * 60 &&
incidentList[0].error[0] != 'dummy')
state.incidentLen(monitor.id) === 0 ||
(state.getIncident(monitor.id, 0).start[0] > currentTimeSecond - 90 * 24 * 60 * 60 &&
state.getIncident(monitor.id, 0).error[0] != 'dummy')
) {
// put the dummy incident back
incidentList.unshift({
state.unshiftIncident(monitor.id, {
start: [currentTimeSecond - 90 * 24 * 60 * 60],
end: currentTimeSecond - 90 * 24 * 60 * 60,
error: ['dummy'],
})
}
state.incident[monitor.id] = incidentList
statusChanged ||= monitorStatusChanged
}
console.log(
`statusChanged: ${statusChanged}, lastUpdate: ${state.lastUpdate}, currentTime: ${currentTimeSecond}`
`statusChanged: ${statusChanged}, lastUpdate: ${state.data.lastUpdate}, currentTime: ${currentTimeSecond}`
)
// Update state
// Allow for a cooldown period before writing to KV
// Allow for a cooldown period before writing to storage
if (
statusChanged ||
currentTimeSecond - state.lastUpdate >= (workerConfig.kvWriteCooldownMinutes ?? 3) * 60 - 10 // Allow for 10 seconds of clock drift
currentTimeSecond - state.data.lastUpdate >= (workerConfig.kvWriteCooldownMinutes ?? 3) * 60 - 10 // Allow for 10 seconds of clock drift
) {
console.log('Updating state...')
state.lastUpdate = currentTimeSecond
await setToStore(env, 'state', JSON.stringify(state))
state.data.lastUpdate = currentTimeSecond
await setToStore(env, 'state', state.getCompactedStateStr())
} else {
console.log('Skipping state update due to cooldown period.')
}

View File

@ -304,7 +304,9 @@ export async function getStatus(
response.status,
response.text.bind(response)
)
try { await response.body?.cancel() } catch(e) {} // Always try to cancel body, see issue #166
try {
await response.body?.cancel()
} catch (e) {} // Always try to cancel body, see issue #166
if (err !== null) {
console.log(`${monitor.name} didn't pass response check: ${err}`)

View File

@ -1,17 +1,155 @@
import { Env } from ".";
import { Env } from '.'
import { IncidentRecord, LatencyRecord, MonitorStateCompacted } from '../../types/config'
export async function getFromStore(env: Env, key: string): Promise<string | null> {
const stmt = env.UPTIMEFLARE_D1.prepare("SELECT value FROM uptimeflare WHERE key = ?")
const stmt = env.UPTIMEFLARE_D1.prepare('SELECT value FROM uptimeflare WHERE key = ?')
const result = await stmt.bind(key).first<{ value: string }>()
if (!result) {
return await env.UPTIMEFLARE_STATE.get(key)
}
return result?.value || null
}
export async function setToStore(env: Env, key: string, value: string): Promise<void> {
const stmt = env.UPTIMEFLARE_D1.prepare("INSERT INTO uptimeflare (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value;")
const stmt = env.UPTIMEFLARE_D1.prepare(
'INSERT INTO uptimeflare (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value;'
)
await stmt.bind(key, value).run()
}
export class CompactedMonitorStateWrapper {
data: MonitorStateCompacted
constructor(compactedStateStr: string | null) {
if (!compactedStateStr) {
// Initialize empty state
this.data = {
lastUpdate: 0,
overallUp: 0,
overallDown: 0,
incident: {},
latency: {},
}
return
}
this.data = JSON.parse(compactedStateStr)
}
getCompactedStateStr(): string {
return JSON.stringify(this.data)
}
incidentLen(monitorId: string): number {
const incidents = this.data.incident[monitorId]
if (!incidents) return 0
return incidents.start.length
}
getIncident(monitorId: string, index: number): IncidentRecord {
const incidents = this.data.incident[monitorId]
if (!incidents || index < 0 || index >= incidents.start.length) {
throw new Error('Index out of bounds or monitor not found')
}
return {
start: incidents.start[index],
end: incidents.end[index],
error: incidents.error[index],
}
}
setIncident(monitorId: string, index: number, incident: IncidentRecord) {
const incidents = this.data.incident[monitorId]
if (!incidents || index < 0 || index >= incidents.start.length) {
throw new Error('Index out of bounds or monitor not found')
}
incidents.start[index] = incident.start
incidents.end[index] = incident.end
incidents.error[index] = incident.error
}
appendIncident(monitorId: string, incident: IncidentRecord) {
let incidents = this.data.incident[monitorId]
if (!incidents) {
// Initialize incident arrays
this.data.incident[monitorId] = {
start: [],
end: [],
error: [],
}
incidents = this.data.incident[monitorId]
}
incidents.start.push(incident.start)
incidents.end.push(incident.end)
incidents.error.push(incident.error)
}
shiftIncident(monitorId: string) {
const incidents = this.data.incident[monitorId]
incidents.start.shift()
incidents.end.shift()
incidents.error.shift()
}
unshiftIncident(monitorId: string, incident: IncidentRecord) {
const incidents = this.data.incident[monitorId]
incidents.start.unshift(incident.start)
incidents.end.unshift(incident.end)
incidents.error.unshift(incident.error)
}
latencyLen(monitorId: string): number {
const latencies = this.data.latency[monitorId]
if (!latencies) return 0
return latencies.ping.length / 4 // Uint16Array, 4 characters per entry in hex
}
appendLatency(monitorId: string, record: LatencyRecord) {
let latencies = this.data.latency[monitorId]
if (!latencies) {
// Initialize latency arrays
this.data.latency[monitorId] = {
time: '',
ping: '',
loc: {
c: [],
v: [],
},
}
latencies = this.data.latency[monitorId]
}
// @ts-expect-error
latencies.time += new Uint8Array(new Uint32Array([record.time]).buffer).toHex()
// @ts-expect-error
latencies.ping += new Uint8Array(new Uint16Array([record.ping]).buffer).toHex()
if (latencies.loc.v[latencies.loc.v.length - 1] !== record.loc) {
latencies.loc.c.push(1)
latencies.loc.v.push(record.loc)
} else {
latencies.loc.c[latencies.loc.c.length - 1] += 1
}
}
getFirstLatency(monitorId: string): LatencyRecord {
let latencies = this.data.latency[monitorId]
return {
// @ts-expect-error
time: new Uint32Array(Uint8Array.fromHex(latencies.time.slice(0, 8)).buffer)[0],
// @ts-expect-error
ping: new Uint16Array(Uint8Array.fromHex(latencies.ping.slice(0, 4)).buffer)[0],
loc: latencies.loc.v[0],
}
}
unshiftLatency(monitorId: string) {
let latencies = this.data.latency[monitorId]
latencies.time = latencies.time.slice(8)
latencies.ping = latencies.ping.slice(4)
latencies.loc.c[0] -= 1
if (latencies.loc.c[0] === 0) {
latencies.loc.c.shift()
latencies.loc.v.shift()
}
}
}

View File

@ -1,4 +1,5 @@
import { WebhookConfig } from '../../types/config'
import { MonitorTarget, WebhookConfig } from '../../types/config'
import { maintenances, workerConfig } from '../../uptime.config'
async function getWorkerLocation() {
const res = await fetch('https://cloudflare.com/cdn-cgi/trace')
@ -145,10 +146,59 @@ async function webhookNotify(webhook: WebhookConfig, message: string) {
}
}
// Auxiliary function to format notification and send it via webhook
const formatAndNotify = async (
monitor: MonitorTarget,
isUp: boolean,
timeIncidentStart: number,
timeNow: number,
reason: string
) => {
// Skip notification if monitor is in the skip list
const skipList = workerConfig.notification?.skipNotificationIds
if (skipList && skipList.includes(monitor.id)) {
console.log(
`Skipping notification for ${monitor.name} (${monitor.id} in skipNotificationIds)`
)
return
}
// Skip notification if monitor is in maintenance
const maintenanceList = maintenances
.filter(
(m) =>
new Date(timeNow * 1000) >= new Date(m.start) &&
(!m.end || new Date(timeNow * 1000) <= new Date(m.end))
)
.map((e) => e.monitors || [])
.flat()
if (maintenanceList.includes(monitor.id)) {
console.log(`Skipping notification for ${monitor.name} (in maintenance)`)
return
}
if (workerConfig.notification?.webhook) {
const notification = formatStatusChangeNotification(
monitor,
isUp,
timeIncidentStart,
timeNow,
reason,
workerConfig.notification?.timeZone ?? 'Etc/GMT'
)
await webhookNotify(workerConfig.notification.webhook, notification)
} else {
console.log(`Webhook not set, skipping notification for ${monitor.name}`)
}
}
export {
getWorkerLocation,
fetchTimeout,
withTimeout,
webhookNotify,
formatStatusChangeNotification,
formatAndNotify,
}

View File

@ -1,5 +1,9 @@
name = "uptimeflare_worker"
main = "src/index.ts"
compatibility_date = "2025-04-02"
kv_namespaces = [{ binding = "UPTIMEFLARE_STATE", id = "UPTIMEFLARE_STATE" }]
compatibility_flags = [ "nodejs_compat" ]
[[d1_databases]]
binding = "UPTIMEFLARE_D1"
database_name = "uptimeflare_d1"
database_id = "00000000-0000-0000-0000-000000000000"

View File

@ -2,3 +2,4 @@ name = "uptimeflare_worker"
main = "src/index.ts"
compatibility_date = "2025-04-02"
compatibility_flags = [ "nodejs_compat" ]

8
wrangler.toml Normal file
View File

@ -0,0 +1,8 @@
name = "uptimeflare"
compatibility_date = "2025-04-02"
compatibility_flags = [ "nodejs_compat" ]
[[d1_databases]]
binding = "UPTIMEFLARE_D1"
database_name = "uptimeflare_d1"
database_id = "00000000-0000-0000-0000-000000000000"