From aefeb461e246b98cfd1d864542a1100605aae276 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 6 Oct 2026 23:19:21 +0700 Subject: [PATCH 1/4] fix: reject unavailable placeholder in health checks --- .github/scripts/check-camera-health.mjs | 61 +++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/.github/scripts/check-camera-health.mjs b/.github/scripts/check-camera-health.mjs index 1d751f4..6d76c1a 100644 --- a/.github/scripts/check-camera-health.mjs +++ b/.github/scripts/check-camera-health.mjs @@ -4,6 +4,7 @@ const PROXY_BASE = process.env.CAMERA_PROXY_URL || "https://camera-proxy.eplus.d 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 DELAY_MS = Number(process.env.CAMERA_HEALTH_DELAY_MS || 250) const OFFLINE_AFTER = Number(process.env.CAMERA_HEALTH_OFFLINE_AFTER || 2) const PREVIOUS_URL = process.env.CAMERA_HEALTH_PREVIOUS_URL || @@ -20,6 +21,44 @@ async function loadPrevious() { } } +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() @@ -73,10 +112,10 @@ async function probe(camera) { } } - const { value, done } = await reader.read() + const prefix = await readImagePrefix(reader) await reader.cancel().catch(() => {}) - if (done || !value || value.byteLength === 0) { + if (!prefix.byteLength) { return { ok: false, httpStatus: response.status, @@ -86,12 +125,23 @@ async function probe(camera) { } } + 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, - firstChunkBytes: value.byteLength, + prefixBytes: prefix.byteLength, } } catch (error) { return { @@ -115,6 +165,9 @@ async function mapLimit(items, concurrency, mapper) { 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)) + } } } @@ -173,6 +226,7 @@ async function main() { latencyMs: result.latencyMs, currentCheckOk: false, error: result.error, + placeholder: Boolean(result.placeholder), }, ] }) @@ -194,6 +248,7 @@ async function main() { checker: { concurrency: CONCURRENCY, timeoutMs: TIMEOUT_MS, + delayMs: DELAY_MS, offlineAfterConsecutiveFailures: OFFLINE_AFTER, }, ...summary, From 8cec005ea837fdbb88d2f2c464e133153ad5c3e0 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 6 Oct 2026 23:19:28 +0700 Subject: [PATCH 2/4] fix: pace camera health probes --- .github/workflows/camera-health.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/camera-health.yml b/.github/workflows/camera-health.yml index b339cff..d95872e 100644 --- a/.github/workflows/camera-health.yml +++ b/.github/workflows/camera-health.yml @@ -15,7 +15,7 @@ concurrency: jobs: check: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 @@ -27,8 +27,9 @@ jobs: env: CAMERA_PROXY_URL: https://camera-proxy.eplus.dev/camera CAMERA_PROXY_ORIGIN: https://eplus.dev - CAMERA_HEALTH_CONCURRENCY: "12" + CAMERA_HEALTH_CONCURRENCY: "2" CAMERA_HEALTH_TIMEOUT_MS: "6000" + CAMERA_HEALTH_DELAY_MS: "250" CAMERA_HEALTH_OFFLINE_AFTER: "2" run: node .github/scripts/check-camera-health.mjs From 577839cbc5eace10a6bfb664a4ba4703987aa9d6 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 6 Oct 2026 23:21:54 +0700 Subject: [PATCH 3/4] fix: keep recently healthy cameras online through transient placeholders --- .github/scripts/check-camera-health.mjs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/scripts/check-camera-health.mjs b/.github/scripts/check-camera-health.mjs index 6d76c1a..3591ff2 100644 --- a/.github/scripts/check-camera-health.mjs +++ b/.github/scripts/check-camera-health.mjs @@ -2,10 +2,12 @@ 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 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" @@ -205,12 +207,20 @@ async function main() { } 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" - if (consecutiveFailures >= OFFLINE_AFTER) { - status = "offline" - } else if (previousCamera.status === "online") { + // 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 [ @@ -219,7 +229,7 @@ async function main() { name: camera.CamName || "", status, checkedAt, - lastSuccessAt: previousCamera.lastSuccessAt || null, + lastSuccessAt, consecutiveFailures, httpStatus: result.httpStatus, contentType: result.contentType, @@ -250,6 +260,7 @@ async function main() { timeoutMs: TIMEOUT_MS, delayMs: DELAY_MS, offlineAfterConsecutiveFailures: OFFLINE_AFTER, + onlineGraceMinutes: ONLINE_GRACE_MINUTES, }, ...summary, cameras: cameraStatus, From 954a533165b37cfeaa3d5398a43732ce1894862d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 6 Oct 2026 23:21:59 +0700 Subject: [PATCH 4/4] fix: serialize health checks and add availability grace window --- .github/workflows/camera-health.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/camera-health.yml b/.github/workflows/camera-health.yml index d95872e..9056654 100644 --- a/.github/workflows/camera-health.yml +++ b/.github/workflows/camera-health.yml @@ -27,10 +27,11 @@ jobs: env: CAMERA_PROXY_URL: https://camera-proxy.eplus.dev/camera CAMERA_PROXY_ORIGIN: https://eplus.dev - CAMERA_HEALTH_CONCURRENCY: "2" + 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