From cb6daf1a392cd327c4daf92089aa893f27d0d353 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 6 Oct 2026 23:33:26 +0700 Subject: [PATCH 1/2] chore: remove duplicate camera health checker --- .github/scripts/check-camera-health.mjs | 278 ------------------------ 1 file changed, 278 deletions(-) delete mode 100644 .github/scripts/check-camera-health.mjs diff --git a/.github/scripts/check-camera-health.mjs b/.github/scripts/check-camera-health.mjs deleted file mode 100644 index 3591ff2..0000000 --- a/.github/scripts/check-camera-health.mjs +++ /dev/null @@ -1,278 +0,0 @@ -import fs from "node:fs/promises" - -const PROXY_BASE = process.env.CAMERA_PROXY_URL || "https://camera-proxy.eplus.dev/camera" -const ORIGIN = process.env.CAMERA_PROXY_ORIGIN || "https://eplus.dev" -const CONCURRENCY = Number(process.env.CAMERA_HEALTH_CONCURRENCY || 1) -const TIMEOUT_MS = Number(process.env.CAMERA_HEALTH_TIMEOUT_MS || 6000) -const DELAY_MS = Number(process.env.CAMERA_HEALTH_DELAY_MS || 250) -const OFFLINE_AFTER = Number(process.env.CAMERA_HEALTH_OFFLINE_AFTER || 2) -const ONLINE_GRACE_MINUTES = Number(process.env.CAMERA_HEALTH_ONLINE_GRACE_MINUTES || 30) -const ONLINE_GRACE_MS = ONLINE_GRACE_MINUTES * 60 * 1000 -const PREVIOUS_URL = - process.env.CAMERA_HEALTH_PREVIOUS_URL || - "https://raw.githubusercontent.com/ePlus-DEV/camera/status/camera-status.json" - -async function loadPrevious() { - try { - const response = await fetch(`${PREVIOUS_URL}?t=${Date.now()}`, { cache: "no-store" }) - if (!response.ok) return { cameras: {} } - const json = await response.json() - return json && typeof json === "object" ? json : { cameras: {} } - } catch { - return { cameras: {} } - } -} - -function isKnownUnavailablePlaceholder(bytes) { - if (!bytes || bytes.byteLength < 24) return false - - const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] - for (let index = 0; index < pngSignature.length; index += 1) { - if (bytes[index] !== pngSignature[index]) return false - } - - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) - const width = view.getUint32(16, false) - const height = view.getUint32(20, false) - - return width === 290 && height === 183 -} - -async function readImagePrefix(reader, minimumBytes = 24) { - const chunks = [] - let total = 0 - - while (total < minimumBytes) { - const { value, done } = await reader.read() - if (done) break - if (value?.byteLength) { - chunks.push(value) - total += value.byteLength - } - } - - const prefix = new Uint8Array(total) - let offset = 0 - for (const chunk of chunks) { - prefix.set(chunk, offset) - offset += chunk.byteLength - } - - return prefix -} - -async function probe(camera) { - const startedAt = Date.now() - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS) - - try { - const url = new URL(PROXY_BASE) - url.searchParams.set("id", camera.CamId) - url.searchParams.set("health", String(Date.now())) - - const response = await fetch(url, { - method: "GET", - headers: { - Origin: ORIGIN, - Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", - "Cache-Control": "no-cache", - }, - cache: "no-store", - signal: controller.signal, - }) - - const contentType = response.headers.get("content-type") || "" - if (!response.ok) { - return { - ok: false, - httpStatus: response.status, - contentType, - latencyMs: Date.now() - startedAt, - error: `HTTP ${response.status}`, - } - } - - if (!contentType.toLowerCase().startsWith("image/")) { - return { - ok: false, - httpStatus: response.status, - contentType, - latencyMs: Date.now() - startedAt, - error: "Non-image response", - } - } - - const reader = response.body?.getReader() - if (!reader) { - return { - ok: false, - httpStatus: response.status, - contentType, - latencyMs: Date.now() - startedAt, - error: "Missing response body", - } - } - - const prefix = await readImagePrefix(reader) - await reader.cancel().catch(() => {}) - - if (!prefix.byteLength) { - return { - ok: false, - httpStatus: response.status, - contentType, - latencyMs: Date.now() - startedAt, - error: "Empty image body", - } - } - - if (isKnownUnavailablePlaceholder(prefix)) { - return { - ok: false, - httpStatus: response.status, - contentType, - latencyMs: Date.now() - startedAt, - error: "Image unavailable placeholder", - placeholder: true, - } - } - - return { - ok: true, - httpStatus: response.status, - contentType, - latencyMs: Date.now() - startedAt, - prefixBytes: prefix.byteLength, - } - } catch (error) { - return { - ok: false, - httpStatus: null, - contentType: "", - latencyMs: Date.now() - startedAt, - error: error?.name === "AbortError" ? "Timeout" : String(error?.message || error), - } - } finally { - clearTimeout(timeout) - } -} - -async function mapLimit(items, concurrency, mapper) { - const results = new Array(items.length) - let cursor = 0 - - async function worker() { - while (true) { - const index = cursor++ - if (index >= items.length) return - results[index] = await mapper(items[index], index) - if (DELAY_MS > 0) { - await new Promise((resolve) => setTimeout(resolve, DELAY_MS)) - } - } - } - - await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker())) - return results -} - -async function main() { - const startedAt = Date.now() - const raw = JSON.parse(await fs.readFile("data-camera.json", "utf8")) - const cameras = raw.filter((camera) => /^[a-f0-9]{24}$/i.test(String(camera?.CamId || ""))) - const previous = await loadPrevious() - const previousCameras = previous?.cameras || {} - - const checks = await mapLimit(cameras, CONCURRENCY, async (camera, index) => { - const result = await probe(camera) - const previousCamera = previousCameras[camera.CamId] || {} - const checkedAt = new Date().toISOString() - - if (result.ok) { - return [ - camera.CamId, - { - name: camera.CamName || "", - status: "online", - checkedAt, - lastSuccessAt: checkedAt, - consecutiveFailures: 0, - httpStatus: result.httpStatus, - contentType: result.contentType, - latencyMs: result.latencyMs, - currentCheckOk: true, - }, - ] - } - - const consecutiveFailures = Number(previousCamera.consecutiveFailures || 0) + 1 - const lastSuccessAt = previousCamera.lastSuccessAt || null - const lastSuccessMs = lastSuccessAt ? Date.parse(lastSuccessAt) : Number.NaN - const recentlyHealthy = - Number.isFinite(lastSuccessMs) && Date.now() - lastSuccessMs <= ONLINE_GRACE_MS - - let status = "unknown" - - // A transient placeholder is common. Keep a camera Online when a real - // frame was observed recently, even if the current probe is unavailable. - // Offline is reserved for sustained failures after the grace window. - if (recentlyHealthy) { - status = "online" - } else if (consecutiveFailures >= OFFLINE_AFTER) { - status = "offline" - } - - return [ - camera.CamId, - { - name: camera.CamName || "", - status, - checkedAt, - lastSuccessAt, - consecutiveFailures, - httpStatus: result.httpStatus, - contentType: result.contentType, - latencyMs: result.latencyMs, - currentCheckOk: false, - error: result.error, - placeholder: Boolean(result.placeholder), - }, - ] - }) - - const cameraStatus = Object.fromEntries(checks) - const values = Object.values(cameraStatus) - const summary = { - total: values.length, - online: values.filter((camera) => camera.status === "online").length, - offline: values.filter((camera) => camera.status === "offline").length, - unknown: values.filter((camera) => camera.status === "unknown").length, - } - - const output = { - version: 1, - source: PROXY_BASE, - generatedAt: new Date().toISOString(), - durationMs: Date.now() - startedAt, - checker: { - concurrency: CONCURRENCY, - timeoutMs: TIMEOUT_MS, - delayMs: DELAY_MS, - offlineAfterConsecutiveFailures: OFFLINE_AFTER, - onlineGraceMinutes: ONLINE_GRACE_MINUTES, - }, - ...summary, - cameras: cameraStatus, - } - - await fs.mkdir("status", { recursive: true }) - await fs.writeFile("status/camera-status.json", JSON.stringify(output, null, 2) + "\n") - - console.log(JSON.stringify({ ...summary, durationMs: output.durationMs }, null, 2)) -} - -main().catch((error) => { - console.error(error) - process.exit(1) -}) From edf84faa1087b627704c9062898bf4ba333792df Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 6 Oct 2026 23:33:29 +0700 Subject: [PATCH 2/2] chore: remove duplicate camera health workflow --- .github/workflows/camera-health.yml | 52 ----------------------------- 1 file changed, 52 deletions(-) delete mode 100644 .github/workflows/camera-health.yml diff --git a/.github/workflows/camera-health.yml b/.github/workflows/camera-health.yml deleted file mode 100644 index 9056654..0000000 --- a/.github/workflows/camera-health.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Camera health status - -on: - workflow_dispatch: - schedule: - - cron: "*/15 * * * *" - -permissions: - contents: write - -concurrency: - group: camera-health-status - cancel-in-progress: false - -jobs: - check: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Check camera health - env: - CAMERA_PROXY_URL: https://camera-proxy.eplus.dev/camera - CAMERA_PROXY_ORIGIN: https://eplus.dev - CAMERA_HEALTH_CONCURRENCY: "1" - CAMERA_HEALTH_TIMEOUT_MS: "6000" - CAMERA_HEALTH_DELAY_MS: "250" - CAMERA_HEALTH_OFFLINE_AFTER: "2" - CAMERA_HEALTH_ONLINE_GRACE_MINUTES: "30" - run: node .github/scripts/check-camera-health.mjs - - - name: Publish status branch - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tmp="$(mktemp -d)" - cp status/camera-status.json "$tmp/camera-status.json" - cd "$tmp" - git init - git checkout -b status - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add camera-status.json - git commit -m "chore: update camera health status" - git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git push --force origin status