From eff0a8859db65fdd1e5d5327091add2bec63b4d0 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 6 Oct 2026 22:57:12 +0700 Subject: [PATCH 1/5] feat: add camera health checker --- .github/scripts/check-camera-health.mjs | 212 ++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 .github/scripts/check-camera-health.mjs diff --git a/.github/scripts/check-camera-health.mjs b/.github/scripts/check-camera-health.mjs new file mode 100644 index 0000000..1d751f4 --- /dev/null +++ b/.github/scripts/check-camera-health.mjs @@ -0,0 +1,212 @@ +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 || 12) +const TIMEOUT_MS = Number(process.env.CAMERA_HEALTH_TIMEOUT_MS || 6000) +const OFFLINE_AFTER = Number(process.env.CAMERA_HEALTH_OFFLINE_AFTER || 2) +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: {} } + } +} + +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 { value, done } = await reader.read() + await reader.cancel().catch(() => {}) + + if (done || !value || value.byteLength === 0) { + return { + ok: false, + httpStatus: response.status, + contentType, + latencyMs: Date.now() - startedAt, + error: "Empty image body", + } + } + + return { + ok: true, + httpStatus: response.status, + contentType, + latencyMs: Date.now() - startedAt, + firstChunkBytes: value.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) + } + } + + 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 + let status = "unknown" + + if (consecutiveFailures >= OFFLINE_AFTER) { + status = "offline" + } else if (previousCamera.status === "online") { + status = "online" + } + + return [ + camera.CamId, + { + name: camera.CamName || "", + status, + checkedAt, + lastSuccessAt: previousCamera.lastSuccessAt || null, + consecutiveFailures, + httpStatus: result.httpStatus, + contentType: result.contentType, + latencyMs: result.latencyMs, + currentCheckOk: false, + error: result.error, + }, + ] + }) + + 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, + offlineAfterConsecutiveFailures: OFFLINE_AFTER, + }, + ...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 f3a13f3ca34d56b685494380457a13e23c705448 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 6 Oct 2026 22:57:14 +0700 Subject: [PATCH 2/5] ci: publish camera health status every 15 minutes --- .github/workflows/camera-health.yml | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/camera-health.yml diff --git a/.github/workflows/camera-health.yml b/.github/workflows/camera-health.yml new file mode 100644 index 0000000..b339cff --- /dev/null +++ b/.github/workflows/camera-health.yml @@ -0,0 +1,50 @@ +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: 15 + 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: "12" + CAMERA_HEALTH_TIMEOUT_MS: "6000" + CAMERA_HEALTH_OFFLINE_AFTER: "2" + 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 From 4b55a21a8609ee929ca4ece1741dd16ef521e626 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 6 Oct 2026 22:57:41 +0700 Subject: [PATCH 3/5] test: run camera health checker on feature branch --- .github/workflows/camera-health.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/camera-health.yml b/.github/workflows/camera-health.yml index b339cff..f104132 100644 --- a/.github/workflows/camera-health.yml +++ b/.github/workflows/camera-health.yml @@ -1,6 +1,9 @@ name: Camera health status on: + push: + branches: + - feat/camera-health-status workflow_dispatch: schedule: - cron: "*/15 * * * *" From e1f9558318f5bb700d78066ca8d4f16a77e5bbcf Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 6 Oct 2026 22:59:50 +0700 Subject: [PATCH 4/5] test: validate consecutive camera failures --- .github/workflows/camera-health.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/camera-health.yml b/.github/workflows/camera-health.yml index f104132..2eaac91 100644 --- a/.github/workflows/camera-health.yml +++ b/.github/workflows/camera-health.yml @@ -1,4 +1,5 @@ name: Camera health status +# Second validation run for consecutive-failure classification on: push: From 1ee3481ce8363a7a090414412856ede628af80d0 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 6 Oct 2026 23:02:25 +0700 Subject: [PATCH 5/5] chore: keep camera health checks on schedule and manual runs --- .github/workflows/camera-health.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/camera-health.yml b/.github/workflows/camera-health.yml index 2eaac91..b339cff 100644 --- a/.github/workflows/camera-health.yml +++ b/.github/workflows/camera-health.yml @@ -1,10 +1,6 @@ name: Camera health status -# Second validation run for consecutive-failure classification on: - push: - branches: - - feat/camera-health-status workflow_dispatch: schedule: - cron: "*/15 * * * *"