[WIP] feat: local deployment, still need more testing, data migration & CI todo

pull/177/head
lyc8503 2026-01-01 02:26:37 +08:00
parent e050065a3c
commit 0258ae1ddc
14 changed files with 792 additions and 686 deletions

40
.dockerignore Normal file
View File

@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.terraform/
terraform.tfstate*
/.wrangler

33
Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# Stage 1: Build
FROM node:lts-bookworm AS builder
WORKDIR /app
COPY package*.json ./
COPY worker/package*.json ./worker/
RUN npm ci
RUN cd worker && npm ci
# Copy all source files
COPY . .
# Build the Next.js application
RUN npx @cloudflare/next-on-pages
# Stage 2: Production
FROM node:lts-bookworm AS production
# Install bash and curl for runtime
RUN apt-get update && apt-get install -y curl cron
# Copy runtime dependencies from builder stage
COPY --from=builder /app/ /app/
COPY --from=builder /app/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Expose the Pages port
EXPOSE 8788
ENTRYPOINT ["/entrypoint.sh"]

23
entrypoint.sh Normal file
View File

@ -0,0 +1,23 @@
#!/bin/bash
echo "Initializing database schema..."
cd /app
npx wrangler d1 execute uptimeflare_d1 --file=/app/init.sql
# Start Worker
echo "Starting Worker..."
cd /app/worker
npx wrangler dev --inspector-port 9229 --test-scheduled --persist-to ../.wrangler/state --ip 0.0.0.0 --port 8787 2>&1 > /app/worker.log &
# Start Pages
echo "Starting Pages..."
cd /app
npx wrangler pages dev .vercel/output/static --inspector-port 9230 --ip 0.0.0.0 --port 8788 2>&1 > /app/pages.log &
# CRON Loop
echo "Starting CRON loop..."
touch /app/scheduled.log
echo "* * * * * /usr/bin/curl -m 60 -s 'http://127.0.0.1:8787/__scheduled' >> /app/scheduled.log 2>&1" | crontab -
cron
exec tail -f /app/worker.log /app/pages.log /app/scheduled.log

View File

@ -29,7 +29,7 @@
"No incidents in this month": "No incidents in this month",
"There are no incidents for this month": "There are no incidents for this month.",
"Monitor not found": "Monitor with id {{id}} not found!",
"Monitor State not defined": "Monitor State is not defined now, please check your worker's status and KV binding!",
"Monitor State not defined": "Monitor State is not defined now, please check your worker's status and binding!",
"All": "All",
"Select monitor": "Select monitor",
"Backwards": "← Backwards",

View File

@ -29,7 +29,7 @@
"No incidents in this month": "本月无故障",
"There are no incidents for this month": "本月没有发生任何故障。",
"Monitor not found": "未找到 ID 为 {{id}} 的监控!",
"Monitor State not defined": "监控状态未定义,请检查您的 Worker 状态和 KV 绑定!",
"Monitor State not defined": "监控状态未定义,请检查您的 Worker 状态和绑定!",
"All": "全部",
"Select monitor": "选择监控",
"Backwards": "← 上一月",

View File

@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"preview": "npx @cloudflare/next-on-pages && wrangler pages dev .vercel/output/static --compatibility-flag nodejs_compat --kv UPTIMEFLARE_STATE",
"preview": "npx @cloudflare/next-on-pages && wrangler pages dev .vercel/output/static",
"build": "next build",
"start": "next start",
"lint": "next lint"

View File

@ -1,6 +1,5 @@
import { workerConfig } from '@/uptime.config'
import { MonitorState } from '@/types/config'
import { NextRequest } from 'next/server'
import { CompactedMonitorStateWrapper, getFromStore } from '@/worker/src/store'
export const runtime = 'edge'
@ -31,8 +30,7 @@ export default async function handler(req: NextRequest): Promise<Response> {
try {
const url = new URL(req.url)
const defaultMonitorId = workerConfig.monitors[0]?.id
const monitorId = url.searchParams.get('id') ?? defaultMonitorId
const monitorId = url.searchParams.get('id')
const label = url.searchParams.get('label') ?? monitorId ?? 'UptimeFlare'
const upMsg = url.searchParams.get('up') ?? 'UP'
@ -47,31 +45,12 @@ export default async function handler(req: NextRequest): Promise<Response> {
})
}
const { UPTIMEFLARE_STATE } = process.env as unknown as {
UPTIMEFLARE_STATE: KVNamespace
}
const compactedState = new CompactedMonitorStateWrapper(
await getFromStore(process.env as any, 'state')
)
const stateStr = await UPTIMEFLARE_STATE?.get('state')
if (!stateStr) {
return new Response(JSON.stringify(errorBadge(label, 'unavailable')), {
headers: jsonHeaders,
status: 503,
})
}
const state = JSON.parse(stateStr) as MonitorState
const monitorIncidentHistory = state.incident?.[monitorId]
const hasLatencyData = Boolean(state.latency?.[monitorId]?.length)
if (!monitorIncidentHistory || monitorIncidentHistory.length === 0 || !hasLatencyData) {
return new Response(JSON.stringify(errorBadge(label, 'unknown')), {
headers: jsonHeaders,
status: 404,
})
}
const latestIncident = monitorIncidentHistory.slice(-1)[0]
const isUp = latestIncident.end !== undefined
const lastIncident = compactedState.getIncident(monitorId, compactedState.incidentLen(monitorId) - 1)
const isUp = lastIncident?.end !== null
const badge: BadgePayload = {
schemaVersion: 1,

View File

@ -1,53 +1,49 @@
import { workerConfig } from '@/uptime.config'
import { MonitorState } from '@/types/config'
import { NextRequest } from 'next/server'
import { CompactedMonitorStateWrapper, getFromStore } from '@/worker/src/store'
export const runtime = 'edge'
export default async function handler(req: NextRequest): Promise<Response> {
const { UPTIMEFLARE_STATE } = process.env as unknown as {
UPTIMEFLARE_STATE: KVNamespace
}
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
const stateStr = await UPTIMEFLARE_STATE?.get('state')
if (!stateStr) {
export default async function handler(req: NextRequest): Promise<Response> {
const compactedState = new CompactedMonitorStateWrapper(await getFromStore(process.env as any, 'state'))
if (compactedState.data.lastUpdate === 0) {
return new Response(JSON.stringify({ error: 'No data available' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
headers
})
}
const state = JSON.parse(stateStr) as unknown as MonitorState
let monitors: any = {}
for (let monitor of workerConfig.monitors) {
const isUp = state.incident[monitor.id].slice(-1)[0].end !== undefined
const lastIncident = compactedState.getIncident(monitor.id, compactedState.incidentLen(monitor.id) - 1)
const isUp = lastIncident?.end !== null
const latency = compactedState.getLastLatency(monitor.id)
monitors[monitor.id] = {
up: isUp,
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],
latency: latency.ping,
location: latency.loc,
message: isUp ? 'OK' : lastIncident?.error[lastIncident.error.length - 1],
}
}
let ret = {
up: state.overallUp,
down: state.overallDown,
updatedAt: state.lastUpdate,
up: compactedState.data.overallUp,
down: compactedState.data.overallDown,
updatedAt: compactedState.data.lastUpdate,
monitors: monitors,
}
return new Response(JSON.stringify(ret), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
headers
})
}

View File

@ -1,8 +1,7 @@
import Head from 'next/head'
import { Inter } from 'next/font/google'
import { MonitorState, MonitorTarget } from '@/types/config'
import { KVNamespace } from '@cloudflare/workers-types'
import { MonitorTarget } from '@/types/config'
import { maintenances, pageConfig, workerConfig } from '@/uptime.config'
import OverallStatus from '@/components/OverallStatus'
import Header from '@/components/Header'
@ -11,25 +10,22 @@ import { Center, Text } from '@mantine/core'
import MonitorDetail from '@/components/MonitorDetail'
import Footer from '@/components/Footer'
import { useTranslation } from 'react-i18next'
import { getFromStore } from '@/worker/src/store'
import { CompactedMonitorStateWrapper, getFromStore } from '@/worker/src/store'
export const runtime = 'experimental-edge'
const inter = Inter({ subsets: ['latin'] })
export default function Home({
state: stateStr,
compactedStateStr,
monitors,
}: {
state: string
compactedStateStr: string
monitors: MonitorTarget[]
tooltip?: string
statusPageLink?: string
}) {
const { t } = useTranslation('common')
let state
if (stateStr !== undefined) {
state = JSON.parse(stateStr) as MonitorState
}
let state = new CompactedMonitorStateWrapper(compactedStateStr).uncompact()
// Specify monitorId in URL hash to view a specific monitor (can be used in iframe)
const monitorId = window.location.hash.substring(1)
@ -55,7 +51,7 @@ export default function Home({
<main className={inter.className}>
<Header />
{state == undefined ? (
{state.lastUpdate === 0 ? (
<Center>
<Text fw={700}>{t('Monitor State not defined')}</Text>
</Center>
@ -73,8 +69,8 @@ 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
// Read state as string from storage, to avoid hitting server-side cpu time limit
const compactedStateStr = await getFromStore(process.env as any, 'state')
// Only present these values to client
const monitors = workerConfig.monitors.map((monitor) => {
@ -90,5 +86,5 @@ export async function getServerSideProps() {
}
})
return { props: { state, monitors } }
return { props: { compactedStateStr, monitors } }
}

1180
worker/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,12 +4,12 @@
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev --config wrangler-dev.toml --test-scheduled --persist-to ../.wrangler/state",
"dev": "wrangler dev --test-scheduled --persist-to ../.wrangler/state",
"start": "wrangler dev"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250410.0",
"typescript": "^5.0.4",
"wrangler": "^4.10.0"
"wrangler": "^4.54.0"
}
}

View File

@ -1,5 +1,5 @@
import { Env } from '.'
import { IncidentRecord, LatencyRecord, MonitorStateCompacted } from '../../types/config'
import { IncidentRecord, LatencyRecord, MonitorState, 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 = ?')
@ -36,6 +36,64 @@ export class CompactedMonitorStateWrapper {
return JSON.stringify(this.data)
}
// Don't use this method at server-side
uncompact(): MonitorState {
let state: MonitorState = {
lastUpdate: this.data.lastUpdate,
overallUp: this.data.overallUp,
overallDown: this.data.overallDown,
incident: {},
latency: {},
}
Object.keys(this.data.incident).forEach((monitorId) => {
state.incident[monitorId] = []
const incidents = this.data.incident[monitorId]
if (incidents.start.length !== incidents.end.length || incidents.start.length !== incidents.error.length) {
throw new Error('Inconsistent incident data lengths, please report an issue at https://github.com/lyc8503/UptimeFlare')
}
for (let i = 0; i < incidents.start.length; i++) {
state.incident[monitorId].push({
start: incidents.start[i],
end: incidents.end[i],
error: incidents.error[i],
})
}
})
Object.keys(this.data.latency).forEach((monitorId) => {
state.latency[monitorId] = []
const latencies = this.data.latency[monitorId]
const locUncompacted: string[] = []
latencies.loc.c.forEach((count, index) => {
for (let i = 0; i < count; i++) {
locUncompacted.push(latencies.loc.v[index])
}
})
// @ts-expect-error
const timeArr = new Uint32Array(Uint8Array.fromHex(latencies.time).buffer)
// @ts-expect-error
const pingArr = new Uint16Array(Uint8Array.fromHex(latencies.ping).buffer)
if (timeArr.length !== pingArr.length || timeArr.length !== locUncompacted.length) {
throw new Error('Inconsistent latency data lengths, please report an issue at https://github.com/lyc8503/UptimeFlare.')
}
for (let i = 0; i < timeArr.length; i++) {
state.latency[monitorId].push({
time: timeArr[i],
ping: pingArr[i],
loc: locUncompacted[i],
})
}
})
return state
}
incidentLen(monitorId: string): number {
const incidents = this.data.incident[monitorId]
if (!incidents) return 0
@ -140,6 +198,18 @@ export class CompactedMonitorStateWrapper {
}
}
getLastLatency(monitorId: string): LatencyRecord {
let latencies = this.data.latency[monitorId]
return {
// @ts-expect-error
time: new Uint32Array(Uint8Array.fromHex(latencies.time.slice(-8)).buffer)[0],
// @ts-expect-error
ping: new Uint16Array(Uint8Array.fromHex(latencies.ping.slice(-4)).buffer)[0],
loc: latencies.loc.v[latencies.loc.v.length - 1],
}
}
unshiftLatency(monitorId: string) {
let latencies = this.data.latency[monitorId]

View File

@ -1,9 +0,0 @@
name = "uptimeflare_worker"
main = "src/index.ts"
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"

View File

@ -3,3 +3,7 @@ main = "src/index.ts"
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"