[WIP] feat: local deployment, still need more testing, data migration & CI todo
parent
e050065a3c
commit
0258ae1ddc
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "← 上一月",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue