From 28a0ce9ccdbb635a2ea17f0613b58d496342fd7f Mon Sep 17 00:00:00 2001 From: Yunsup Sim Date: Fri, 13 Nov 2026 23:38:05 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=98=A4=EB=A5=98=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=9C=EA=B3=B5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전용 모니터 Bearer와 고정 Worker 바인딩으로 수신 요약만 조회한다. 고유 오류 등록 건수와 처리 상태만 반환하고 모델·잡 실행을 하지 않는다. VPC·SSH·CPU/RAM 감시를 추가하지 않고 koin-api 기록을 제외한다. 오류·오래된 응답은 알 수 없음으로 표시하고 비밀값을 브라우저에 주지 않는다. 검증: 관련 패키지 3개 타입 검사, 오류 자동화 122개와 출근부 14개 테스트 통과. oxlint와 두 Worker dry-run을 통과했다. 실제 로컬 두 Worker와 SQLite에서 정상 빈 조회·제공자 중단·복구를 확인했다. 1280x720·1920x1080 무스크롤 및 모바일 가로 넘침 없음을 확인했다. 운영 활성화는 main 승격과 별도 모니터 secret 설정 후에 수행한다. --- apps/attendance/README.md | 23 +- apps/attendance/package.json | 5 +- apps/attendance/src/automation.ts | 115 ++++++++++ apps/attendance/src/dashboard.ts | 142 ++++++++++++- apps/attendance/src/index.ts | 17 +- apps/attendance/test/automation.test.mjs | 223 ++++++++++++++++++++ apps/attendance/tsconfig.json | 2 +- apps/attendance/worker-configuration.d.ts | 37 ++++ apps/attendance/wrangler.jsonc | 1 + apps/error-automation/package.json | 5 +- apps/error-automation/src/index.ts | 30 +++ apps/error-automation/src/queue.ts | 79 ++++++- apps/error-automation/src/types.ts | 2 + apps/error-automation/test/monitor.test.mjs | 135 ++++++++++++ apps/error-automation/test/queue.test.mjs | 170 +++++++++++++++ packages/telemetry/package.json | 1 + packages/telemetry/src/monitor.ts | 123 +++++++++++ pnpm-lock.yaml | 9 + scripts/autofix/README.md | 10 + 19 files changed, 1111 insertions(+), 18 deletions(-) create mode 100644 apps/attendance/src/automation.ts create mode 100644 apps/attendance/test/automation.test.mjs create mode 100644 apps/attendance/worker-configuration.d.ts create mode 100644 apps/error-automation/test/monitor.test.mjs create mode 100644 packages/telemetry/src/monitor.ts diff --git a/apps/attendance/README.md b/apps/attendance/README.md index 72417283..c34ff792 100644 --- a/apps/attendance/README.md +++ b/apps/attendance/README.md @@ -29,12 +29,13 @@ KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60 ## API -| 경로 | 인증 | 용도 | -| ------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `POST /api/taps` | `Authorization: Bearer ` | `{ "uid": "04A1B2C3", "at": 1760000000000 }` (`at` 생략 시 서버 시각. 미래 60초·과거 7일을 벗어나면 400) | -| `GET /` | Basic (비밀번호 = `DASHBOARD_PASSWORD`, 사용자 이름 무시) | 대시보드 | -| `GET /api/state` | Basic | 현재 인원, 회원별 오늘·이번 주 시간, 최근 기록 | -| `POST /api/members` | Basic, 같은 출처 JSON | `{ "uid", "name" }` 회원 등록·이름 수정 | +| 경로 | 인증 | 용도 | +| --------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `POST /api/taps` | `Authorization: Bearer ` | `{ "uid": "04A1B2C3", "at": 1760000000000 }` (`at` 생략 시 서버 시각. 미래 60초·과거 7일을 벗어나면 400) | +| `GET /` | Basic (비밀번호 = `DASHBOARD_PASSWORD`, 사용자 이름 무시) | 대시보드 | +| `GET /api/state` | Basic | 현재 인원, 회원별 오늘·이번 주 시간, 최근 기록 | +| `POST /api/members` | Basic, 같은 출처 JSON | `{ "uid", "name" }` 회원 등록·이름 수정 | +| `GET /api/automation` | Basic | error-automation Worker의 24시간 고유 오류 등록 요약. RFID Bearer로 접근 불가 | ## 대시보드 @@ -47,6 +48,16 @@ KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60 - 회원 이름과 카드 정보는 `textContent`로만 출력합니다. 상태 조회와 등록은 같은 출처의 기존 인증을 사용하며 등록은 JSON 요청으로만 전송합니다. - 배포는 BCSDLab 조직 Gateway를 요구하는 접근 정책을 전제로 합니다. 일반 소비자 WARP 접속만 허용하는 정책은 충분하지 않습니다. Worker의 모든 도메인·미리보기 우회 경로를 보호하기 전에는 공개 경로를 활성화하지 않습니다. +## 오류 수신 전용 모니터 + +- 서버의 `ERROR_AUTOMATION` Worker service binding이 고정된 `bcsd-error-automation`의 `GET /healthz`만 읽습니다. 브라우저·Pi에는 관리/모델/진단 토큰을 주지 않으며 VPC·Rocky SSH는 모니터링에 필요하지 않습니다. +- 두 Worker에 별도의 동일한 `ERROR_MONITOR_TOKEN`을 secret으로 설정해야 합니다(32자 이상 무작위 값). `DEVICE_TOKEN`, `DASHBOARD_PASSWORD`, 실행기·진단·모델 토큰을 재사용하지 않습니다. secret 설정은 배포 작업이므로 정상 운영 승인 절차를 따릅니다. +- Sentry/Cloudflare의 최근 24시간 **고유 오류 등록 건수**, 그 기간의 최근 신규 등록 시각과 최대 10개의 처리 상태를 보여 줍니다. 중복 웹훅 전달 횟수나 마지막 재전송 시각이 아니며 GitHub PR 리뷰 작업과 미사용 `koin-api` Worker 기록은 제외합니다. +- 오류 전문·프롬프트·소스·로그·원본 provider ID·자격 증명을 반환하지 않습니다. 조회가 작업 claim, 모델 생성, GitHub 작업을 시작하지 않습니다. +- 모니터는 출근부 조회와 별개로 60초마다 갱신합니다. 5초 본문 수신 기한·16 KiB JSON 한도·리다이렉트 거절을 적용하고 120초보다 오래된 스냅샷은 unknown으로 처리합니다. +- 자격 증명 미설정·연결 실패·응답 불일치는 `state: unknown`, `data: null`입니다. 실제로 조회한 빈 저장소만 0건으로 표시합니다. 조용한 수신량을 장애로 판단하지 않습니다. +- 이 패널은 Worker 응답과 등록 저장소만 확인합니다. Rocky 실행기·모델·CPU/RAM의 정상 여부를 측정하거나 추정하지 않습니다. 조직 Gateway 보호와 기존 비공개 노출 경계는 그대로 유지합니다. + ## 배포 ```sh diff --git a/apps/attendance/package.json b/apps/attendance/package.json index 2707b15f..03da9822 100644 --- a/apps/attendance/package.json +++ b/apps/attendance/package.json @@ -5,7 +5,10 @@ "scripts": { "dev": "wrangler dev", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --experimental-strip-types --test test/logic.test.mjs", + "test": "node --experimental-strip-types --test test/*.test.mjs", "deploy": "wrangler deploy" + }, + "dependencies": { + "@koin/telemetry": "workspace:*" } } diff --git a/apps/attendance/src/automation.ts b/apps/attendance/src/automation.ts new file mode 100644 index 00000000..b5a9d649 --- /dev/null +++ b/apps/attendance/src/automation.ts @@ -0,0 +1,115 @@ +import { parseErrorMonitorSnapshot, type ErrorMonitorSnapshot } from '@koin/telemetry/monitor'; + +export type AutomationEnv = Partial> & { + ERROR_MONITOR_TOKEN?: string; +}; + +export interface AutomationResult { + state: 'ok' | 'unknown'; + checkedAt: number | null; + data: ErrorMonitorSnapshot | null; + reason?: 'not_configured' | 'unavailable' | 'stale'; +} + +const MAX_BYTES = 16 * 1024; +const DEADLINE_MS = 5_000; +const FRESH_MS = 120_000; +const HEALTH_URL = 'https://error-automation.internal/healthz'; +const UNKNOWN_HEADERS = { 'cache-control': 'no-store' }; + +function unknown(reason: NonNullable): AutomationResult { + return { state: 'unknown', checkedAt: null, data: null, reason }; +} + +async function readBoundedJson(response: Response, signal: AbortSignal): Promise { + const length = response.headers.get('content-length'); + if (length !== null && (!/^\d+$/.test(length) || Number(length) > MAX_BYTES)) { + void response.body?.cancel().catch(() => {}); + throw new Error('oversized response'); + } + if (!/^application\/json(?:\s*;|\s*$)/i.test(response.headers.get('content-type') ?? '')) { + void response.body?.cancel().catch(() => {}); + throw new Error('non-JSON response'); + } + if (!response.body) throw new Error('missing response body'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }); + let bytes = 0; + let text = ''; + let complete = false; + const cancel = () => { + void reader.cancel().catch(() => {}); + }; + signal.addEventListener('abort', cancel, { once: true }); + if (signal.aborted) { + signal.removeEventListener('abort', cancel); + cancel(); + reader.releaseLock(); + throw new Error('monitor deadline exceeded'); + } + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > MAX_BYTES) throw new Error('oversized response'); + text += decoder.decode(value, { stream: true }); + } + complete = true; + text += decoder.decode(); + return JSON.parse(text); + } finally { + signal.removeEventListener('abort', cancel); + if (!complete) cancel(); + reader.releaseLock(); + } +} + +/** The deadline covers both the binding fetch and consumption of the complete response. */ +export async function getAutomationResult(env: AutomationEnv, now: () => number = Date.now): Promise { + const binding = env.ERROR_AUTOMATION; + const token = env.ERROR_MONITOR_TOKEN; + if (!binding || !token || token.length < 32) return unknown('not_configured'); + + const controller = new AbortController(); + const { promise: deadline, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + controller.abort(); + reject(new Error('monitor deadline exceeded')); + }, DEADLINE_MS); + try { + const task = async (): Promise => { + const response = await binding.fetch(HEALTH_URL, { + method: 'GET', + headers: { authorization: `Bearer ${token}` }, + redirect: 'manual', + signal: controller.signal, + }); + if (response.status !== 200 || response.redirected) return unknown('unavailable'); + const payload = await readBoundedJson(response, controller.signal); + const checkedAt = + payload !== null && typeof payload === 'object' && !Array.isArray(payload) && 'checkedAt' in payload + ? payload.checkedAt + : null; + const currentTime = now(); + const snapshot = parseErrorMonitorSnapshot(payload, currentTime); + if (!snapshot) + return unknown( + typeof checkedAt === 'number' && Number.isFinite(checkedAt) && currentTime - checkedAt > FRESH_MS + ? 'stale' + : 'unavailable', + ); + return { state: 'ok', checkedAt: snapshot.checkedAt, data: snapshot }; + }; + return await Promise.race([task(), deadline]); + } catch { + return unknown('unavailable'); + } finally { + clearTimeout(timer); + } +} + +export async function handleAutomation(env: AutomationEnv): Promise { + return Response.json(await getAutomationResult(env), { headers: UNKNOWN_HEADERS }); +} diff --git a/apps/attendance/src/dashboard.ts b/apps/attendance/src/dashboard.ts index 519ce51c..6b80cd95 100644 --- a/apps/attendance/src/dashboard.ts +++ b/apps/attendance/src/dashboard.ts @@ -144,6 +144,21 @@ export const DASHBOARD_HTML = /* html */ ` #sync-status[data-state="error"] { color:var(--error); } .sync-note { margin:0; color:var(--muted); font-size:12px; } footer { display:flex; justify-content:space-between; flex-wrap:wrap; gap:8px; padding:24px 4px 0; color:var(--muted); font-size:11px; } + .monitor-strip { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 16px; border:1px solid var(--line); border-radius:18px; background:var(--surface); box-shadow:var(--shadow); } + .monitor-summary { display:flex; align-items:center; flex-wrap:wrap; gap:6px 18px; min-width:0; font-size:12px; } + .monitor-summary strong { font-size:13px; } + .monitor-summary small { color:var(--muted); } + #monitor-state[data-state="ok"] { background:var(--green-container); color:var(--on-green-container); } + #monitor-state[data-state="unknown"] { background:var(--amber-container); color:var(--amber); } + .monitor-dialog { width:min(650px,calc(100% - 32px)); max-height:min(80vh,700px); overflow:auto; border:1px solid var(--outline); border-radius:24px; padding:22px; background:var(--surface); color:var(--text); box-shadow:0 20px 60px #10131b44; } + .monitor-dialog::backdrop { background:#10131b88; } + .monitor-dialog header { display:flex; justify-content:space-between; align-items:center; gap:12px; } + .monitor-dialog ul { list-style:none; margin:12px 0; padding:0; } + .monitor-dialog li { padding:8px 0; border-bottom:1px solid var(--line); font-size:13px; overflow-wrap:anywhere; } + @media (max-width:700px) { + .monitor-strip { align-items:flex-start; } + .monitor-summary { gap:4px 10px; } + } footer p { margin:0; } @media (min-width:1600px) { .app-shell { max-width:1600px; } @@ -213,7 +228,12 @@ export const DASHBOARD_HTML = /* html */ ` #date { font-size:10px; } .header-actions { gap:7px; } .header-actions button { min-height:36px; padding:8px 12px; font-size:11px; } - main { flex:1; min-height:0; display:grid; grid-template-columns:minmax(0,1.8fr) minmax(290px,1fr); grid-template-rows:auto minmax(0,.9fr) minmax(0,1.2fr) minmax(0,1fr) 18px; grid-template-areas:"error error" "hero summary" "members unknown" "recent unknown" "sync sync"; gap:10px 12px; } + main { flex:1; min-height:0; display:grid; grid-template-columns:minmax(0,1.8fr) minmax(290px,1fr); grid-template-rows:auto auto minmax(0,.9fr) minmax(0,1.2fr) minmax(0,1fr) 18px; grid-template-areas:"monitor monitor" "error error" "hero summary" "members unknown" "recent unknown" "sync sync"; gap:8px 12px; } + .monitor-strip { grid-area:monitor; padding:4px 12px; min-height:38px; border-radius:12px; overflow:hidden; } + .monitor-summary { gap:2px 12px; font-size:clamp(9px,1.1vh,14px); line-height:1.3; } + .monitor-summary strong { font-size:inherit; } + .monitor-summary small { font-size:inherit; } + .monitor-strip button { min-height:30px; padding:4px 10px; font-size:10px; } #error { grid-area:error; padding:8px 12px; border-radius:12px; } #error p { font-size:11px; } .hero { grid-area:hero; grid-template-columns:clamp(180px,13vw,340px) minmax(0,1fr); grid-template-rows:minmax(0,1fr); } @@ -293,6 +313,17 @@ export const DASHBOARD_HTML = /* html */ `
+
+
+ 오류 자동화 · 수신 전용 + 확인 중 + Sentry 24시간 고유 오류 등록 — · 최근 신규 등록 시각 — + Cloudflare 24시간 고유 오류 등록 — · 최근 신규 등록 시각 — + 마지막 확인 — + Worker/등록 저장소만 확인 · Rocky 실행기, 모델, CPU·RAM 미측정 +
+ +
+ +

오류 자동화 · 최근 등록

+

+
    +
    diff --git a/apps/attendance/src/index.ts b/apps/attendance/src/index.ts index 359b5ffc..f34eaa94 100644 --- a/apps/attendance/src/index.ts +++ b/apps/attendance/src/index.ts @@ -1,7 +1,8 @@ -import { DAY_MS, decideTap, isUnrecorded, kstDateKey, kstWeekStart, summarize, type SessionRow } from './logic'; -import { DASHBOARD_HTML } from './dashboard'; +import { DAY_MS, decideTap, isUnrecorded, kstDateKey, kstWeekStart, summarize, type SessionRow } from './logic.ts'; +import { DASHBOARD_HTML } from './dashboard.ts'; +import { handleAutomation } from './automation.ts'; -interface Env { +interface Env extends Cloudflare.Env { /** * 키 구성 (값은 비어 있고 데이터는 모두 메타데이터에 두어 list 한 번으로 읽습니다): * - `member:` 메타 {name} @@ -9,11 +10,12 @@ interface Env { * - `s::<입실 시각 13자리>:` 메타 {checkOut, autoClosed} — 세션 * - `latest:` 값 JSON {checkIn, checkOut, autoClosed} — 회원의 최근 세션 포인터 */ - ATTENDANCE: KVNamespace; /** 라즈베리파이가 태그 이벤트를 보낼 때 쓰는 Bearer 토큰. */ DEVICE_TOKEN: string; /** 대시보드·회원 등록용 Basic 인증 비밀번호(사용자 이름은 무시). */ DASHBOARD_PASSWORD: string; + /** 서버 측에서만 사용하는 오류 자동화 모니터 토큰. */ + ERROR_MONITOR_TOKEN?: string; } interface MemberMeta { @@ -250,7 +252,11 @@ export default { : json({ error: 'method_not_allowed' }, 405, { allow: 'POST' }); } - const isDashboardRoute = url.pathname === '/' || url.pathname === '/api/state' || url.pathname === '/api/members'; + const isDashboardRoute = + url.pathname === '/' || + url.pathname === '/api/state' || + url.pathname === '/api/members' || + url.pathname === '/api/automation'; if (!isDashboardRoute) return json({ error: 'not_found' }, 404); if (!(await dashboardAuthorized(request, env))) return new Response('Unauthorized', UNAUTHORIZED); @@ -265,6 +271,7 @@ export default { }); } if (url.pathname === '/api/state' && request.method === 'GET') return handleState(env); + if (url.pathname === '/api/automation' && request.method === 'GET') return handleAutomation(env); if (url.pathname === '/api/members' && request.method === 'POST') return handleMember(request, url, env); return json({ error: 'method_not_allowed' }, 405); }, diff --git a/apps/attendance/test/automation.test.mjs b/apps/attendance/test/automation.test.mjs new file mode 100644 index 00000000..6fb6a7ba --- /dev/null +++ b/apps/attendance/test/automation.test.mjs @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { timingSafeEqual } from 'node:crypto'; +import test from 'node:test'; +import worker from '../src/index.ts'; +import { getAutomationResult, handleAutomation } from '../src/automation.ts'; + +// Workers expose crypto.subtle.timingSafeEqual; Node's Web Crypto does not. +if (!globalThis.crypto.subtle.timingSafeEqual) { + Object.defineProperty(globalThis.crypto.subtle, 'timingSafeEqual', { + value: (left, right) => timingSafeEqual(Buffer.from(left), Buffer.from(right)), + configurable: true, + }); +} + +const NOW = Date.UTC(2026, 10, 13, 12); +const TOKEN = 'm'.repeat(32); +const AUTH = 'Basic ' + btoa('dashboard:secret'); +const JOB_ID = 'autofix-' + 'a'.repeat(64); +const snapshot = (overrides = {}) => ({ + version: 1, + scope: 'ingress-only', + checkedAt: NOW, + windowStart: NOW - 24 * 60 * 60 * 1000, + worker: 'responding', + sources: { + sentry: { count: 1, latestAt: NOW - 1000 }, + cloudflare: { count: 0, latestAt: null }, + }, + recent: [{ id: JOB_ID, source: 'sentry', app: 'web', registeredAt: NOW - 1000, status: 'queued' }], + ...overrides, +}); +const json = (value) => Response.json(value); +const binding = (fetch) => ({ fetch }); +const env = (fetch) => ({ + ERROR_AUTOMATION: binding(fetch), + ERROR_MONITOR_TOKEN: TOKEN, + DASHBOARD_PASSWORD: 'secret', + DEVICE_TOKEN: 'device-secret', + ATTENDANCE: new Proxy( + {}, + { + get: () => { + throw new Error('monitor must never touch attendance KV'); + }, + }, + ), +}); +const request = (headers = {}, path = '/api/automation') => + new Request('https://attendance.example' + path, { headers }); + +// The route, not a test-only wrapper, must enforce dashboard Basic before any binding call. +test('automation route rejects unauthenticated and RFID Bearer callers before upstream access', async (t) => { + t.mock.method(Date, 'now', () => NOW); + let calls = 0; + const workerEnv = env(async (url) => { + calls++; + assert.equal(url, 'https://error-automation.internal/healthz'); + return json(snapshot()); + }); + for (const headers of [{}, { authorization: 'Bearer device-secret' }, { authorization: 'Basic !!!' }]) { + const response = await worker.fetch(request(headers), workerEnv); + assert.equal(response.status, 401); + assert.match(response.headers.get('www-authenticate'), /Basic/); + } + assert.equal(calls, 0); + const response = await worker.fetch( + request({ authorization: AUTH }, '/api/automation?url=https%3A%2F%2Fattacker.example%2Fhealthz'), + workerEnv, + ); + assert.equal(response.status, 200); + assert.equal(calls, 1); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal((await response.json()).state, 'ok'); +}); + +test('fixed service binding, HTTPS path, private token, and no redirect follow', async () => { + let calls = 0; + const result = await getAutomationResult( + env(async (url, options) => { + calls++; + assert.equal(url, 'https://error-automation.internal/healthz'); + assert.equal(options.method, 'GET'); + assert.equal(options.headers.authorization, 'Bearer ' + TOKEN); + assert.equal(options.redirect, 'manual'); + assert.ok(options.signal); + return Response.redirect('https://attacker.example/collect', 302); + }), + () => NOW, + ); + assert.equal(calls, 1); + assert.deepEqual(result, { state: 'unknown', checkedAt: null, data: null, reason: 'unavailable' }); + assert.ok(!JSON.stringify(result).includes(TOKEN)); +}); + +test('missing or short server-side secret and missing binding never fabricate empty traffic', async () => { + let calls = 0; + const base = env(async () => { + calls++; + return json(snapshot()); + }); + for (const settings of [ + { ERROR_AUTOMATION: undefined }, + { ERROR_MONITOR_TOKEN: undefined }, + { ERROR_MONITOR_TOKEN: 'device-secret' }, + ]) { + const result = await getAutomationResult({ ...base, ...settings }, () => NOW); + assert.deepEqual(result, { state: 'unknown', checkedAt: null, data: null, reason: 'not_configured' }); + } + assert.equal(calls, 0); +}); + +test('quiet observed zero is healthy; stale, malformed, and sensitive extra fields are unknown', async () => { + const empty = snapshot({ + sources: { sentry: { count: 0, latestAt: null }, cloudflare: { count: 0, latestAt: null } }, + recent: [], + }); + const good = await getAutomationResult( + env(async () => json(empty)), + () => NOW, + ); + assert.equal(good.state, 'ok'); + assert.equal(good.data.sources.sentry.count, 0); + for (const payload of [ + snapshot({ + checkedAt: NOW - 121_000, + windowStart: NOW - 121_000 - 24 * 60 * 60 * 1000, + sources: empty.sources, + recent: [], + }), + snapshot({ sources: { sentry: { count: -1, latestAt: NOW }, cloudflare: { count: 0, latestAt: null } } }), + snapshot({ recent: [{ ...snapshot().recent[0], diagnostic: 'private stack trace' }] }), + snapshot({ pr_url: 'https://github.example/private' }), + ]) { + const result = await getAutomationResult( + env(async () => json(payload)), + () => NOW, + ); + assert.equal(result.state, 'unknown'); + assert.equal(result.data, null); + assert.equal(result.checkedAt, null); + assert.ok(!JSON.stringify(result).includes('private stack trace')); + } +}); + +test('stale but structurally valid snapshot has explicit unknown/stale state, not zero', async () => { + const checkedAt = NOW - 121_000; + const payload = snapshot({ + checkedAt, + windowStart: checkedAt - 24 * 60 * 60 * 1000, + sources: { sentry: { count: 0, latestAt: null }, cloudflare: { count: 0, latestAt: null } }, + recent: [], + }); + assert.deepEqual( + await getAutomationResult( + env(async () => json(payload)), + () => NOW, + ), + { + state: 'unknown', + checkedAt: null, + data: null, + reason: 'stale', + }, + ); +}); + +test('body size bounds apply even to streaming responses without Content-Length', async () => { + const oversized = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(10_000)); + controller.enqueue(new Uint8Array(7_000)); + controller.close(); + }, + }); + for (const response of [ + () => new Response('x', { headers: { 'content-type': 'application/json', 'content-length': '16385' } }), + () => new Response(oversized, { headers: { 'content-type': 'application/json' } }), + () => new Response(Uint8Array.of(0xff), { headers: { 'content-type': 'application/json' } }), + () => new Response(JSON.stringify(snapshot()), { headers: { 'content-type': 'text/html' } }), + ]) { + const result = await getAutomationResult( + env(async () => response()), + () => NOW, + ); + assert.deepEqual(result, { state: 'unknown', checkedAt: null, data: null, reason: 'unavailable' }); + } +}); + +test('five-second deadline covers a stalled response body, not merely binding fetch', { timeout: 8_000 }, async () => { + let aborted = false; + const start = performance.now(); + const result = await getAutomationResult( + env(async (_url, options) => { + options.signal.addEventListener('abort', () => { + aborted = true; + }); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(123)); + }, + }), + { + headers: { 'content-type': 'application/json' }, + }, + ); + }), + () => NOW, + ); + assert.deepEqual(result, { state: 'unknown', checkedAt: null, data: null, reason: 'unavailable' }); + assert.ok(aborted); + assert.ok(performance.now() - start >= 4_900); +}); + +test('API response does not expose monitor token, upstream diagnostic fields, or cacheable data', async () => { + const response = await handleAutomation(env(async () => json(snapshot()))); + const body = await response.text(); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.ok(!body.includes(TOKEN)); + assert.ok(!body.includes('diagnostic')); + assert.ok(!body.includes('sourceId')); + assert.ok(!body.includes('pr_url')); +}); diff --git a/apps/attendance/tsconfig.json b/apps/attendance/tsconfig.json index 817dd5d8..783e3f63 100644 --- a/apps/attendance/tsconfig.json +++ b/apps/attendance/tsconfig.json @@ -11,5 +11,5 @@ "erasableSyntaxOnly": true, "allowImportingTsExtensions": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "worker-configuration.d.ts"] } diff --git a/apps/attendance/worker-configuration.d.ts b/apps/attendance/worker-configuration.d.ts new file mode 100644 index 00000000..2e8c08ec --- /dev/null +++ b/apps/attendance/worker-configuration.d.ts @@ -0,0 +1,37 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: f10478503a80b9bd2db903e0b46e7468) +interface __BaseEnv_Env { + ATTENDANCE: KVNamespace; + ERROR_AUTOMATION: Fetcher /* bcsd-error-automation */; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} +declare module "*.txt" { + const value: string; + export default value; +} +declare module "*.html" { + const value: string; + export default value; +} +declare module "*.sql" { + const value: string; + export default value; +} +declare module "*.bin" { + const value: ArrayBuffer; + export default value; +} +declare module "*.wasm" { + const value: WebAssembly.Module; + export default value; +} +declare module "*.wasm?module" { + const value: WebAssembly.Module; + export default value; +} \ No newline at end of file diff --git a/apps/attendance/wrangler.jsonc b/apps/attendance/wrangler.jsonc index cbc04d7b..1fe3d5a8 100644 --- a/apps/attendance/wrangler.jsonc +++ b/apps/attendance/wrangler.jsonc @@ -8,4 +8,5 @@ "observability": { "enabled": true }, // 기존 운영 네임스페이스를 재사용합니다. 새 네임스페이스로 치환하지 않습니다. "kv_namespaces": [{ "binding": "ATTENDANCE", "id": "2cc47a111f334c4ea99deb2435263e00" }], + "services": [{ "binding": "ERROR_AUTOMATION", "service": "bcsd-error-automation" }], } diff --git a/apps/error-automation/package.json b/apps/error-automation/package.json index daba39cd..d028f66f 100644 --- a/apps/error-automation/package.json +++ b/apps/error-automation/package.json @@ -5,11 +5,12 @@ "scripts": { "dev": "wrangler dev", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --experimental-strip-types --test test/webhook.test.mjs test/workflow.test.mjs test/queue.test.mjs test/model.test.mjs test/pull-request.test.mjs", + "test": "node --experimental-strip-types --test test/webhook.test.mjs test/workflow.test.mjs test/queue.test.mjs test/model.test.mjs test/pull-request.test.mjs test/monitor.test.mjs", "smoke:local": "node test/smoke-local.mjs", "deploy": "wrangler deploy" }, "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31" + "@jridgewell/trace-mapping": "^0.3.31", + "@koin/telemetry": "workspace:*" } } diff --git a/apps/error-automation/src/index.ts b/apps/error-automation/src/index.ts index e956d107..1e386d60 100644 --- a/apps/error-automation/src/index.ts +++ b/apps/error-automation/src/index.ts @@ -1,4 +1,5 @@ import { DurableObject } from 'cloudflare:workers'; +import { parseErrorMonitorSnapshot } from '@koin/telemetry/monitor'; import { DiagnosticProviderError, trustedDiagnostics } from './diagnostics'; import { SourceMapEvidenceError } from './source-maps'; import { gatewayTransport, modelRateLimit, parseModelRequest } from './model'; @@ -444,6 +445,7 @@ export function redact(value: unknown, env: Env): string { env.SENTRY_READ_TOKEN, env.DIAGNOSTICS_READ_TOKEN, env.AUTOFIX_EXECUTOR_TOKEN, + env.ERROR_MONITOR_TOKEN, ]) { if (secret) text = text.replaceAll(secret, '[REDACTED]'); } @@ -606,6 +608,33 @@ async function verifySecret(value: string | null, secret: string): Promise } } +/** Worker와 저장된 이슈만 읽으며 실행기·모델·외부 제공자 상태를 추정하지 않습니다. */ +async function monitoringHealth(request: Request, env: Env, url: URL): Promise { + if (!env.ERROR_MONITOR_TOKEN || env.ERROR_MONITOR_TOKEN.length < 32) + throw new RequestError(503, 'Read-only monitoring is not configured.'); + const authorization = request.headers.get('Authorization'); + await verifySecret(authorization?.startsWith('Bearer ') ? authorization.slice(7) : null, env.ERROR_MONITOR_TOKEN); + if (request.method !== 'GET') + return new Response('Use GET.', { status: 405, headers: { Allow: 'GET', 'Cache-Control': 'no-store' } }); + if (url.search) throw new RequestError(400, 'Monitoring query parameters are not supported.'); + const { promise: deadline, reject } = Promise.withResolvers(); + const timer = setTimeout(() => reject(new RequestError(503, 'Monitoring storage timed out.')), 3000); + try { + const result = await Promise.race([ + env.AUTOFIX_QUEUE.getByName('bcsdlab/bcsd_united').monitorSnapshot(Date.now()), + deadline, + ]); + const snapshot = parseErrorMonitorSnapshot(result, Date.now()); + if (!snapshot) throw new RequestError(503, 'Monitoring snapshot is unavailable or stale.'); + return reply(200, { ...snapshot }, null); + } catch (error) { + if (error instanceof RequestError) throw error; + throw new RequestError(503, 'Monitoring snapshot is unavailable.'); + } finally { + clearTimeout(timer); + } +} + export function identityKey(identity: Identity): string { return identity.source === 'sentry' ? `sentry/${identity.scope.organization}/${identity.project}/${identity.sourceId}` @@ -932,6 +961,7 @@ export default { if (url.protocol !== 'https:') throw new RequestError(400, 'HTTPS is required.'); if (env.ENVIRONMENT !== 'production') throw new RequestError(503, 'Only the fixed production installation is enabled.'); + if (url.pathname === '/healthz') return await monitoringHealth(request, env, url); if (url.pathname.startsWith('/executor/')) return await executorRequest(request, env, url); const retrieval = url.pathname.startsWith('/diagnostics/'); if (!retrieval && !['/webhooks/sentry', '/webhooks/cloudflare', '/webhooks/github'].includes(url.pathname)) diff --git a/apps/error-automation/src/queue.ts b/apps/error-automation/src/queue.ts index 061b175d..445badc4 100644 --- a/apps/error-automation/src/queue.ts +++ b/apps/error-automation/src/queue.ts @@ -1,4 +1,5 @@ import { DurableObject } from 'cloudflare:workers'; +import { parseErrorMonitorSnapshot, type ErrorMonitorSnapshot } from '@koin/telemetry/monitor'; import { autoMergePullRequest, type AutoMergeOutcome } from './auto-merge'; import { prepare } from './prepare'; import { redact } from './index'; @@ -71,7 +72,7 @@ const INPUT_OVERHEAD_TOKENS = 1024; const MAX_PENDING = 100; const encoder = new TextEncoder(); -type Status = 'queued' | 'preparing' | 'leased' | 'verifying' | 'reporting' | 'publishing' | 'completed' | 'failed'; +type Status = ErrorMonitorSnapshot['recent'][number]['status']; interface Row extends Record { run_id: string; identity_key: string; @@ -91,6 +92,17 @@ interface Row extends Record { model_input_tokens: number; model_output_tokens: number; } + +interface MonitorRow extends Record { + run_id: string; + source: 'sentry' | 'cloudflare'; + app: 'web' | 'api'; + status: Status; + enqueued_at: number; + source_count: number; + latest_at: number; + invalid_count: number; +} export interface Claim { version: 1; runId: string; @@ -248,6 +260,71 @@ export class AutofixQueue extends DurableObject { if (columns.has('issue_number')) ctx.storage.sql.exec('ALTER TABLE jobs DROP COLUMN issue_number'); } + /** 처음 등록된 오류 identity만 집계한다. 조회는 만료 처리·알람·작업 상태를 건드리지 않는다. */ + monitorSnapshot(now: number): ErrorMonitorSnapshot { + if (!Number.isSafeInteger(now) || now < 24 * 60 * 60_000) throw new Error('Invalid monitor time'); + const windowStart = now - 24 * 60 * 60_000; + const rows = this.ctx.storage.sql + .exec( + `WITH registered AS ( + SELECT run_id, status, enqueued_at, + json_extract(identity, '$.source') AS source, + json_extract(identity, '$.app') AS app, + json_extract(identity, '$.scope.workerName') AS worker_name, + json_type(identity, '$.scope.workerName') AS worker_type + FROM jobs + WHERE enqueued_at BETWEEN ? AND ? + AND (identity_key LIKE 'sentry/%' OR identity_key LIKE 'cloudflare/%') + ), classified AS ( + SELECT *, + CASE WHEN source = 'sentry' AND app = 'web' THEN 0 + WHEN source = 'cloudflare' AND app IN ('web', 'api') AND worker_type = 'text' AND worker_name != '' THEN 0 + ELSE 1 END AS invalid + FROM registered + ), eligible AS ( + SELECT run_id, source, app, status, enqueued_at, invalid + FROM classified + WHERE invalid = 1 OR source = 'sentry' OR worker_name != 'koin-api' + ) + SELECT run_id, source, app, status, enqueued_at, + count(*) OVER (PARTITION BY source) AS source_count, + max(enqueued_at) OVER (PARTITION BY source) AS latest_at, + sum(invalid) OVER () AS invalid_count + FROM eligible + ORDER BY enqueued_at DESC, run_id DESC + LIMIT 10`, + windowStart, + now, + ) + .toArray(); + const snapshot: ErrorMonitorSnapshot = { + version: 1, + scope: 'ingress-only', + checkedAt: now, + windowStart, + worker: 'responding', + sources: { + sentry: { count: 0, latestAt: null }, + cloudflare: { count: 0, latestAt: null }, + }, + recent: [], + }; + for (const row of rows) { + if (row.invalid_count !== 0 || (row.source !== 'sentry' && row.source !== 'cloudflare')) + throw new Error('Invalid monitor identity'); + snapshot.sources[row.source] = { count: row.source_count, latestAt: row.latest_at }; + snapshot.recent.push({ + id: row.run_id, + source: row.source, + app: row.app, + registeredAt: row.enqueued_at, + status: row.status, + }); + } + if (!parseErrorMonitorSnapshot(snapshot, now)) throw new Error('Invalid monitor snapshot'); + return snapshot; + } + private async admit( identity: QueueIdentity, key: string, diff --git a/apps/error-automation/src/types.ts b/apps/error-automation/src/types.ts index 54939096..b3022e18 100644 --- a/apps/error-automation/src/types.ts +++ b/apps/error-automation/src/types.ts @@ -145,6 +145,8 @@ export interface Env { CLOUDFLARE_SOURCES: string; CLOUDFLARE_DIAGNOSTICS_TOKEN?: string; DIAGNOSTICS_READ_TOKEN: string; + /** 수신 이슈 요약 전용 Bearer. 모델·실행기·진단·태그 토큰과 재사용하지 않습니다. */ + ERROR_MONITOR_TOKEN?: string; ERROR_PROJECT_DOMAINS: string; ERROR_SOURCE_BRANCHES: string; /** The only model upstream: Workers VPC service binding to the Rocky omp gateway, paired with its bearer. */ diff --git a/apps/error-automation/test/monitor.test.mjs b/apps/error-automation/test/monitor.test.mjs new file mode 100644 index 00000000..366e9ace --- /dev/null +++ b/apps/error-automation/test/monitor.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { test } from 'node:test'; + +const platform = + 'data:text/javascript,' + + encodeURIComponent('export class DurableObject { constructor(ctx,env) { this.ctx=ctx; this.env=env; } }'); +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'cloudflare:workers') return { url: platform, shortCircuit: true }; + if ( + specifier.startsWith('./') && + context.parentURL?.includes('/apps/error-automation/src/') && + !specifier.endsWith('.ts') + ) + return nextResolve(specifier + '.ts', context); + return nextResolve(specifier, context); + }, +}); +const { default: worker } = await import('../src/index.ts'); +const monitorToken = 'read-only-monitor-' + 'm'.repeat(40); +const executorToken = 'executor-only-' + 'e'.repeat(40); +function harness(read) { + let reads = 0; + let forbidden = 0; + const never = () => { + forbidden++; + throw new Error('Privileged operation must not run'); + }; + const env = { + ENVIRONMENT: 'production', + ERROR_MONITOR_TOKEN: monitorToken, + AUTOFIX_EXECUTOR_ID: 'koin-rocky-umpc', + AUTOFIX_EXECUTOR_TOKEN: executorToken, + OMP_GATEWAY: { fetch: never }, + ERROR_ISSUES: { get: never, idFromName: never }, + AUTOFIX_QUEUE: { + getByName() { + return { + async monitorSnapshot(now) { + reads++; + if (read) return read(now); + return { + version: 1, + scope: 'ingress-only', + worker: 'responding', + checkedAt: now, + windowStart: now - 86400000, + sources: { sentry: { count: 0, latestAt: null }, cloudflare: { count: 0, latestAt: null } }, + recent: [], + }; + }, + claim: never, + enqueue: never, + errorStatus: never, + }; + }, + }, + }; + function request(path = '/healthz', token = monitorToken, method = 'GET') { + return worker.fetch( + new Request('https://automation.example' + path, { + method, + headers: { + ...(token ? { Authorization: 'Bearer ' + token } : {}), + 'X-Executor-Id': 'koin-rocky-umpc', + 'Content-Type': 'application/json', + }, + }), + env, + ); + } + return { env, request, reads: () => reads, forbidden: () => forbidden }; +} + +test('monitor authentication is separate from executor access and runs without model configuration', async () => { + const h = harness(); + assert.equal((await h.request('/healthz', null)).status, 401); + assert.equal((await h.request('/healthz', executorToken)).status, 401); + assert.equal(h.reads(), 0); + const response = await h.request(); + assert.equal(response.status, 200); + assert.equal(response.headers.get('cache-control'), 'no-store'); + const body = await response.json(); + assert.equal(body.scope, 'ingress-only'); + assert.ok(!('executor' in body) && !('cpu' in body) && !('ram' in body)); + assert.equal((await h.request('/executor/claim', monitorToken, 'POST')).status, 401); + assert.equal(h.forbidden(), 0); +}); + +test('monitor credentials and exact GET path are required before reading storage', async () => { + const h = harness(); + assert.equal((await h.request('/healthz', monitorToken, 'POST')).status, 405); + assert.equal((await h.request('/healthz?url=https://untrusted.example')).status, 400); + assert.equal(h.reads(), 0); + h.env.ERROR_MONITOR_TOKEN = undefined; + assert.equal((await h.request()).status, 503); + assert.equal(h.reads(), 0); +}); + +test('unavailable or unexpectedly detailed monitoring data cannot be reported as healthy', async () => { + const secret = 'private-provider-detail'; + const h = harness(() => { + throw new Error(secret); + }); + const response = await h.request(); + assert.equal(response.status, 503); + assert.ok(!(await response.text()).includes(secret)); + const stale = harness((now) => ({ + version: 1, + scope: 'ingress-only', + worker: 'responding', + checkedAt: now - 121000, + windowStart: now - 121000 - 86400000, + sources: { sentry: { count: 0, latestAt: null }, cloudflare: { count: 0, latestAt: null } }, + recent: [], + })); + assert.equal((await stale.request()).status, 503); + assert.equal(h.forbidden(), 0); +}); + +test('monitoring storage has a bounded deadline without invoking privileged work', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const started = Promise.withResolvers(); + const stopped = Promise.withResolvers(); + const h = harness(() => { + started.resolve(); + return stopped.promise; + }); + const pending = h.request(); + await started.promise; + t.mock.timers.tick(3000); + assert.equal((await pending).status, 503); + assert.equal(h.forbidden(), 0); +}); diff --git a/apps/error-automation/test/queue.test.mjs b/apps/error-automation/test/queue.test.mjs index c231bf7a..29297503 100644 --- a/apps/error-automation/test/queue.test.mjs +++ b/apps/error-automation/test/queue.test.mjs @@ -20,6 +20,7 @@ registerHooks({ }, }); const { AutofixQueue } = await import('../src/queue.ts'); +const { parseErrorMonitorSnapshot } = await import('../../../packages/telemetry/src/monitor.ts'); const identity = { source: 'sentry', sourceId: '123', @@ -309,3 +310,172 @@ test('issue-free schema migration preserves prior identity tombstones and PR rec assert.equal(receipt.prUrl, 'https://github.com/BCSDLab/BCSD_united/pull/99'); assert.equal(receipt.reason, null); }); + +test('monitor snapshot counts registered identities in 24h without exposing job payloads or changing state', async (t) => { + const h = harness(t); + const now = Date.now(); + const start = now - 24 * 60 * 60_000; + const secret = 'private-source-id-and-diagnostic'; + const insert = h.db.prepare( + 'INSERT INTO jobs (run_id, identity_key, identity, status, enqueued_at, reason, prepared) VALUES (?, ?, ?, ?, ?, ?, ?)', + ); + const add = (digit, source, app, workerName, registeredAt, status = 'queued') => { + const rowIdentity = { + source, + sourceId: `${secret}-${digit}`, + project: workerName ?? 'koin-united-prod', + app, + scope: + source === 'cloudflare' ? { accountId: 'private', policyId: 'private', workerName } : { organization: 'bcsd' }, + }; + insert.run( + `autofix-${digit.toString(16).padStart(64, '0')}`, + `${source}/${digit}`, + JSON.stringify(rowIdentity), + status, + registeredAt, + `private-reason-${digit}`, + `private-prepared-${digit}`, + ); + }; + assert.equal(await h.queue.enqueue(identity, key), 'queued'); + assert.equal(await h.queue.enqueue(identity, key), 'duplicate'); + h.db.prepare('UPDATE jobs SET enqueued_at = ? WHERE identity_key = ?').run(start, key); + for (let i = 1; i <= 12; i++) add(i, 'sentry', 'web', null, now - i * 1000, i === 1 ? 'failed' : 'completed'); + add(13, 'cloudflare', 'api', 'koin-web-api', now - 500, 'verifying'); + add(14, 'cloudflare', 'web', 'koin-web', now - 700, 'leased'); + add(15, 'cloudflare', 'api', 'koin-api', now - 300); + add(16, 'cloudflare', 'web', 'koin-api', now - 200); + add(17, 'sentry', 'web', null, start - 1); + add(18, 'cloudflare', 'web', 'koin-web', now + 1); + insert.run( + `autofix-${'f'.repeat(64)}`, + 'github-pr/42', + JSON.stringify({ source: 'github-pr', pullNumber: 42 }), + 'queued', + now, + 'private-pr-reason', + 'private-pr-payload', + ); + const before = h.db.prepare('SELECT * FROM jobs ORDER BY run_id').all(); + const changes = h.db.prepare('SELECT total_changes() AS count').get().count; + const snapshot = h.queue.monitorSnapshot(now); + assert.deepEqual(snapshot.sources, { + sentry: { count: 13, latestAt: now - 1000 }, + cloudflare: { count: 2, latestAt: now - 500 }, + }); + assert.equal(snapshot.checkedAt, now); + assert.equal(snapshot.windowStart, start); + assert.equal(snapshot.scope, 'ingress-only'); + assert.equal(snapshot.worker, 'responding'); + assert.equal(snapshot.recent.length, 10); + assert.deepEqual( + snapshot.recent.slice(0, 3).map(({ source, registeredAt, status }) => [source, registeredAt, status]), + [ + ['cloudflare', now - 500, 'verifying'], + ['cloudflare', now - 700, 'leased'], + ['sentry', now - 1000, 'failed'], + ], + ); + assert.equal( + snapshot.recent.every( + (item, index) => + item.registeredAt >= start && (!index || item.registeredAt <= snapshot.recent[index - 1].registeredAt), + ), + true, + ); + assert.equal( + snapshot.recent.every(({ id }) => /^autofix-[a-f0-9]{64}$/.test(id)), + true, + ); + assert.doesNotMatch( + JSON.stringify(snapshot), + /private|sourceId|identity_key|prepared|reason|pr_url|koin-api|github-pr/, + ); + assert.deepEqual(h.db.prepare('SELECT * FROM jobs ORDER BY run_id').all(), before); + assert.equal(h.db.prepare('SELECT total_changes() AS count').get().count, changes); + assert.deepEqual(h.alarms, []); + assert.deepEqual(h.writes, []); + assert.deepEqual(new AutofixQueue(h.queue.ctx, h.queue.env).monitorSnapshot(now), snapshot); +}); + +test('monitor snapshot reports observed empty windows, not stale jobs or malformed identities', (t) => { + const h = harness(t); + const now = Date.now(); + assert.deepEqual(h.queue.monitorSnapshot(now).sources, { + sentry: { count: 0, latestAt: null }, + cloudflare: { count: 0, latestAt: null }, + }); + h.db + .prepare('INSERT INTO jobs (run_id, identity_key, identity, status, enqueued_at) VALUES (?, ?, ?, ?, ?)') + .run(`autofix-${'a'.repeat(64)}`, 'sentry/bad', '{not json', 'queued', now); + assert.throws(() => h.queue.monitorSnapshot(now)); + h.db.prepare('UPDATE jobs SET identity = ?').run( + JSON.stringify({ + source: 'cloudflare', + sourceId: 'id', + project: 'koin-web', + app: 'web', + scope: { accountId: 'account', policyId: 'policy' }, + }), + ); + assert.throws(() => h.queue.monitorSnapshot(now), /Invalid monitor identity/); + assert.throws(() => h.queue.monitorSnapshot(NaN), /Invalid monitor time/); +}); + +test('shared monitor parser accepts fresh ingress snapshots and rejects stale, unsafe, or inconsistent payloads', () => { + const now = Date.now(); + const snapshot = { + version: 1, + scope: 'ingress-only', + checkedAt: now, + windowStart: now - 24 * 60 * 60_000, + worker: 'responding', + sources: { + sentry: { count: 1, latestAt: now - 10 }, + cloudflare: { count: 0, latestAt: null }, + }, + recent: [ + { id: `autofix-${'a'.repeat(64)}`, source: 'sentry', app: 'web', registeredAt: now - 10, status: 'queued' }, + ], + }; + assert.deepEqual(parseErrorMonitorSnapshot(snapshot, now), snapshot); + const shift = (offset) => ({ + ...snapshot, + checkedAt: now + offset, + windowStart: now + offset - 24 * 60 * 60_000, + sources: { ...snapshot.sources, sentry: { count: 1, latestAt: now + offset - 10 } }, + recent: [{ ...snapshot.recent[0], registeredAt: now + offset - 10 }], + }); + assert.deepEqual(parseErrorMonitorSnapshot(shift(-120_000), now), shift(-120_000)); + assert.deepEqual(parseErrorMonitorSnapshot(shift(60_000), now), shift(60_000)); + assert.equal(parseErrorMonitorSnapshot(shift(-120_001), now), null); + assert.equal(parseErrorMonitorSnapshot(shift(60_001), now), null); + assert.equal(parseErrorMonitorSnapshot(snapshot, NaN), null); + const invalid = [ + { ...snapshot, version: 2 }, + { ...snapshot, scope: 'executor-healthy' }, + { ...snapshot, worker: 'healthy' }, + { ...snapshot, checkedAt: Infinity }, + { ...snapshot, windowStart: snapshot.windowStart - 1 }, + { ...snapshot, diagnostic: 'secret' }, + { ...snapshot, sources: { ...snapshot.sources, sentry: { count: -1, latestAt: now - 10 } } }, + { ...snapshot, sources: { ...snapshot.sources, sentry: { count: 1.5, latestAt: now - 10 } } }, + { ...snapshot, sources: { ...snapshot.sources, sentry: { count: 1, latestAt: null } } }, + { ...snapshot, sources: { ...snapshot.sources, sentry: { count: 1, latestAt: now + 1 } } }, + { ...snapshot, sources: { ...snapshot.sources, cloudflare: { count: 0, latestAt: null, token: 'secret' } } }, + { ...snapshot, sources: { ...snapshot.sources, cloudflare: { count: 1, latestAt: null } } }, + { ...snapshot, recent: [{ ...snapshot.recent[0], status: 'unknown' }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], sourceId: 'secret' }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], id: 'source-id' }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], registeredAt: snapshot.windowStart - 1 }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], registeredAt: now + 1 }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], app: 'api' }] }, + { ...snapshot, recent: Array(11).fill(snapshot.recent[0]) }, + { + ...snapshot, + recent: [snapshot.recent[0], { ...snapshot.recent[0], id: `autofix-${'b'.repeat(64)}`, registeredAt: now - 1 }], + }, + ]; + for (const candidate of invalid) assert.equal(parseErrorMonitorSnapshot(candidate, now), null); +}); diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 1e00570d..4b8cc090 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -4,6 +4,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./monitor": "./src/monitor.ts", "./runtime": { "workerd": "./src/worker.ts", "browser": "./src/browser.ts", diff --git a/packages/telemetry/src/monitor.ts b/packages/telemetry/src/monitor.ts new file mode 100644 index 00000000..0e519745 --- /dev/null +++ b/packages/telemetry/src/monitor.ts @@ -0,0 +1,123 @@ +export interface ErrorMonitorSnapshot { + version: 1; + scope: 'ingress-only'; + checkedAt: number; + windowStart: number; + worker: 'responding'; + sources: { + sentry: { count: number; latestAt: number | null }; + cloudflare: { count: number; latestAt: number | null }; + }; + recent: { + id: string; + source: 'sentry' | 'cloudflare'; + app: 'web' | 'api'; + registeredAt: number; + status: 'queued' | 'preparing' | 'leased' | 'verifying' | 'reporting' | 'publishing' | 'completed' | 'failed'; + }[]; +} + +const WINDOW_MS = 24 * 60 * 60_000; + +function record(value: unknown, keys: readonly string[]): value is Record { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function timestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function status(value: unknown): value is ErrorMonitorSnapshot['recent'][number]['status'] { + switch (value) { + case 'queued': + case 'preparing': + case 'leased': + case 'verifying': + case 'reporting': + case 'publishing': + case 'completed': + case 'failed': + return true; + default: + return false; + } +} + +/** Reject stale, malformed, or unexpectedly detailed snapshots rather than displaying invented health. */ +function isErrorMonitorSnapshot(value: unknown, now: number): value is ErrorMonitorSnapshot { + if ( + !timestamp(now) || + !record(value, ['version', 'scope', 'checkedAt', 'windowStart', 'worker', 'sources', 'recent']) + ) + return false; + const { checkedAt, windowStart, sources, recent } = value; + if ( + value.version !== 1 || + value.scope !== 'ingress-only' || + value.worker !== 'responding' || + !timestamp(checkedAt) || + !timestamp(windowStart) || + checkedAt - windowStart !== WINDOW_MS || + checkedAt < now - 120_000 || + checkedAt > now + 60_000 || + !record(sources, ['sentry', 'cloudflare']) || + !Array.isArray(recent) || + recent.length > 10 + ) + return false; + + let sentryCount = 0; + let cloudflareCount = 0; + for (const name of ['sentry', 'cloudflare'] as const) { + const source = sources[name]; + if (!record(source, ['count', 'latestAt'])) return false; + if ( + typeof source.count !== 'number' || + !Number.isSafeInteger(source.count) || + source.count < 0 || + (source.latestAt !== null && + (!timestamp(source.latestAt) || source.latestAt < windowStart || source.latestAt > checkedAt)) || + (source.count === 0) !== (source.latestAt === null) + ) + return false; + if (name === 'sentry') sentryCount = source.count; + else cloudflareCount = source.count; + } + + const ids = new Set(); + let previous = checkedAt; + let sentryRecent = 0; + let cloudflareRecent = 0; + for (const entry of recent) { + if ( + !record(entry, ['id', 'source', 'app', 'registeredAt', 'status']) || + typeof entry.id !== 'string' || + !/^autofix-[a-f0-9]{64}$/.test(entry.id) || + ids.has(entry.id) || + (entry.source !== 'sentry' && entry.source !== 'cloudflare') || + (entry.app !== 'web' && entry.app !== 'api') || + (entry.source === 'sentry' && entry.app !== 'web') || + !timestamp(entry.registeredAt) || + entry.registeredAt < windowStart || + entry.registeredAt > previous || + !status(entry.status) + ) + return false; + ids.add(entry.id); + previous = entry.registeredAt; + if (entry.source === 'sentry') sentryRecent++; + else cloudflareRecent++; + } + if (sentryRecent > sentryCount || cloudflareRecent > cloudflareCount) return false; + return true; +} + +export function parseErrorMonitorSnapshot(value: unknown, now: number): ErrorMonitorSnapshot | null { + return isErrorMonitorSnapshot(value, now) ? value : null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c57b6c4b..8f904db4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,12 @@ importers: specifier: 3.24.6 version: 3.24.6(@types/node@24.19.2) + apps/attendance: + dependencies: + '@koin/telemetry': + specifier: workspace:* + version: link:../../packages/telemetry + apps/b-bot: dependencies: '@koin/b-bot-ai': @@ -332,6 +338,9 @@ importers: '@jridgewell/trace-mapping': specifier: ^0.3.31 version: 0.3.31 + '@koin/telemetry': + specifier: workspace:* + version: link:../../packages/telemetry apps/web: dependencies: diff --git a/scripts/autofix/README.md b/scripts/autofix/README.md index ca1a6fc6..33ea03b8 100644 --- a/scripts/autofix/README.md +++ b/scripts/autofix/README.md @@ -8,6 +8,16 @@ - **Cloudflare Issues:** Worker가 공식 Notifications envelope를 인증하고 계정·정책·Worker·앱을 재검증한다. 불투명한 `data`를 예외/경로/소스 프레임으로 임의 해석하지 않고, 일치하는 Issues occurrence·Tail/Telemetry·고정 소스 버전의 안전한 GET 경로와 매핑된 앱 소스 프레임을 별도 확인한 경우에만 수정 후보를 만든다. 근거가 부족하면 `CLOUDFLARE_EVIDENCE_UNAVAILABLE`로 실패 기록을 남기며 원본 Cloudflare Issue는 변경하지 않는다. 기존 Workers 지표 알림을 Issues 대신 쓰거나 존재하지 않는 비공개 Issues 수정 API를 호출하지 않는다. - 외부 API, 인증된 사용자 상태, dependency/config/generated code 수정, Node 회귀로 재현할 수 없는 오류는 안전 범위 밖이다. 실패를 우회하거나 빈 PR을 만들지 않는다. +## 읽기 전용 수신 상태 + +정확한 HTTPS `GET /healthz`는 별도의 `ERROR_MONITOR_TOKEN` Bearer만 인증합니다. 쿼리 파라미터는 받지 않으며 잘못된/없는 토큰은 401, 미설정은 503, 다른 메서드는 405입니다. 이 토큰은 실행기·진단·모델·기기 토큰과 분리합니다. + +응답은 `version: 1`, `scope: ingress-only`, 관측 시각, 24시간 창의 Sentry/Cloudflare 고유 등록 건수·최근 신규 등록 시각·최대 10개 처리 상태뿐입니다. `enqueued_at`은 처음 등록된 시각이므로 중복 웹훅의 수신량/재전송 시각으로 해석하지 않습니다. GitHub PR 작업과 Cloudflare `koin-api` 기록은 제외합니다. 원본 provider ID, 오류 전문, 진단, 소스, 로그, 토큰, lease, PR URL은 노출하지 않습니다. + +이 조회는 고정 저장소 큐의 SELECT만 수행하고 알람·claim·모델·GitHub 작업을 실행하지 않습니다. VPC나 모델 자격 증명 없이도 동작하지만 기존 자동 수정 모델 경로의 `OMP_GATEWAY` VPC 바인딩은 그대로 유지합니다. 저장소 오류·오래되거나 잘못된 응답은 503이며 0건/정상으로 대체하지 않습니다. 응답은 no-store, 저장소 응답 기한은 3초입니다. + +출근부는 Worker service binding과 서버 secret으로 이 경로를 읽습니다. 기능 커밋의 정상 main 승격, 두 Worker 배포, 별도 secret 설정이 운영 활성화의 전제입니다. Worker 응답 가능은 Rocky 실행기·브로커·모델·CPU/RAM 정상의 증거가 아닙니다. 이 기능을 위해 공개 hostname, 사설 SSH 라우트, OpenBao 권한을 추가하지 않습니다. + ## 고정 제공자 식별자 웹훅은 아래 식별자만 저장하고 고정 저장소 큐에 등록한다. 추가 프롬프트·URL·저장소·호스트·브랜치·명령·파일 경로로 실행 대상을 바꿀 수 없다. From 4b64cf582582d4cc7167b0a2d1e43c97c89d2548 Mon Sep 17 00:00:00 2001 From: Yunsup Sim Date: Sat, 14 Nov 2026 09:26:13 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(monitor):=20=EA=B8=B0=EC=A1=B4=20Rocky?= =?UTF-8?q?=20=ED=84=B0=EB=84=90=EB=A1=9C=20=EC=9D=BD=EA=B8=B0=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=ED=97=AC=EC=8A=A4=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신 집계는 최근 목록과 독립적으로 전체 24시간을 계산합니다. Rocky 전용 Bearer와 고정 VPC healthz로 CPU·RAM·서비스 상태만 조회합니다. 모델 호출 없이 수신 요약과 호스트 장애를 분리합니다. --- apps/attendance/README.md | 16 +- apps/attendance/src/automation.ts | 97 +++---- apps/attendance/src/dashboard.ts | 109 ++++++-- apps/attendance/test/automation.test.mjs | 69 ++++- apps/error-automation/README.md | 5 + apps/error-automation/src/index.ts | 68 ++++- apps/error-automation/src/queue.ts | 51 ++-- apps/error-automation/src/types.ts | 2 + apps/error-automation/test/monitor.test.mjs | 132 ++++++++++ apps/error-automation/test/queue.test.mjs | 52 ++++ packages/telemetry/src/monitor.ts | 149 +++++++++++ scripts/autofix/README.md | 36 ++- scripts/autofix/koin-omp-gateway.service | 2 + scripts/autofix/local-omp-gateway.mjs | 267 +++++++++++++++++-- scripts/autofix/local-omp-gateway.test.mjs | 276 ++++++++++++++++++++ 15 files changed, 1199 insertions(+), 132 deletions(-) diff --git a/apps/attendance/README.md b/apps/attendance/README.md index c34ff792..0e6d9c25 100644 --- a/apps/attendance/README.md +++ b/apps/attendance/README.md @@ -35,7 +35,7 @@ KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60 | `GET /` | Basic (비밀번호 = `DASHBOARD_PASSWORD`, 사용자 이름 무시) | 대시보드 | | `GET /api/state` | Basic | 현재 인원, 회원별 오늘·이번 주 시간, 최근 기록 | | `POST /api/members` | Basic, 같은 출처 JSON | `{ "uid", "name" }` 회원 등록·이름 수정 | -| `GET /api/automation` | Basic | error-automation Worker의 24시간 고유 오류 등록 요약. RFID Bearer로 접근 불가 | +| `GET /api/automation` | Basic | 독립적인 오류 수신 요약과 Rocky 읽기 전용 헬스. RFID Bearer로 접근 불가 | ## 대시보드 @@ -48,15 +48,17 @@ KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60 - 회원 이름과 카드 정보는 `textContent`로만 출력합니다. 상태 조회와 등록은 같은 출처의 기존 인증을 사용하며 등록은 JSON 요청으로만 전송합니다. - 배포는 BCSDLab 조직 Gateway를 요구하는 접근 정책을 전제로 합니다. 일반 소비자 WARP 접속만 허용하는 정책은 충분하지 않습니다. Worker의 모든 도메인·미리보기 우회 경로를 보호하기 전에는 공개 경로를 활성화하지 않습니다. -## 오류 수신 전용 모니터 +## 오류 수신·Rocky 읽기 전용 모니터 -- 서버의 `ERROR_AUTOMATION` Worker service binding이 고정된 `bcsd-error-automation`의 `GET /healthz`만 읽습니다. 브라우저·Pi에는 관리/모델/진단 토큰을 주지 않으며 VPC·Rocky SSH는 모니터링에 필요하지 않습니다. +- 서버의 `ERROR_AUTOMATION` Worker service binding이 고정된 `bcsd-error-automation`의 `GET /healthz`와 `GET /healthz/rocky`를 병렬로 읽습니다. 브라우저·Pi에는 관리/모델/진단/호스트 모니터 토큰을 주지 않습니다. - 두 Worker에 별도의 동일한 `ERROR_MONITOR_TOKEN`을 secret으로 설정해야 합니다(32자 이상 무작위 값). `DEVICE_TOKEN`, `DASHBOARD_PASSWORD`, 실행기·진단·모델 토큰을 재사용하지 않습니다. secret 설정은 배포 작업이므로 정상 운영 승인 절차를 따릅니다. -- Sentry/Cloudflare의 최근 24시간 **고유 오류 등록 건수**, 그 기간의 최근 신규 등록 시각과 최대 10개의 처리 상태를 보여 줍니다. 중복 웹훅 전달 횟수나 마지막 재전송 시각이 아니며 GitHub PR 리뷰 작업과 미사용 `koin-api` Worker 기록은 제외합니다. +- 수신 요약은 Sentry/Cloudflare의 최근 24시간 **고유 오류 등록 건수**, 최근 신규 등록 시각과 최대 10개의 처리 상태입니다. 건수와 최근 시각은 목록 제한과 독립적으로 전체 기간에서 계산합니다. 중복 웹훅 전달 횟수나 마지막 재전송 시각이 아니며 GitHub PR 리뷰와 미사용 `koin-api` 기록은 제외합니다. +- Rocky는 기존 `OMP_GATEWAY` VPC와 `koin-omp-gateway-rocky` 터널을 통해 게이트웨이의 고정 `GET /healthz`만 조회합니다. `OMP_GATEWAY_MONITOR_TOKEN`은 error-automation과 호스트만 공유하는 별도 secret이며 모델·실행기·수신 요약 토큰과 같으면 거부합니다. 호스트 설정은 [게이트웨이 운영 계약](../../scripts/autofix/README.md#rocky-읽기-전용-헬스-활성화)을 따릅니다. +- CPU는 Linux `/proc/stat` 두 표본의 busy 비율, RAM 사용량은 `MemTotal − MemAvailable`입니다. 실행기·브로커는 고정 systemd 서비스의 설치/활성/프로세스 상태를 구분합니다. 서비스 활성이나 프로세스 실행은 브로커 HTTP 응답·모델 인증·작업 완료의 증거가 아니며 모델 상태는 미확인으로 표시합니다. - 오류 전문·프롬프트·소스·로그·원본 provider ID·자격 증명을 반환하지 않습니다. 조회가 작업 claim, 모델 생성, GitHub 작업을 시작하지 않습니다. -- 모니터는 출근부 조회와 별개로 60초마다 갱신합니다. 5초 본문 수신 기한·16 KiB JSON 한도·리다이렉트 거절을 적용하고 120초보다 오래된 스냅샷은 unknown으로 처리합니다. -- 자격 증명 미설정·연결 실패·응답 불일치는 `state: unknown`, `data: null`입니다. 실제로 조회한 빈 저장소만 0건으로 표시합니다. 조용한 수신량을 장애로 판단하지 않습니다. -- 이 패널은 Worker 응답과 등록 저장소만 확인합니다. Rocky 실행기·모델·CPU/RAM의 정상 여부를 측정하거나 추정하지 않습니다. 조직 Gateway 보호와 기존 비공개 노출 경계는 그대로 유지합니다. +- 출근부 조회와 별개로 60초마다 갱신합니다. 조회별 5초 본문 수신 기한·16 KiB JSON 한도·리다이렉트 거절을 적용하고 120초보다 오래된 스냅샷은 unknown으로 처리합니다. +- `/api/automation`의 최상위 `state`/`data`는 수신 요약이고 `rocky.state`/`rocky.data`는 호스트 관측입니다. 한쪽 실패가 다른 쪽의 관측을 지우지 않습니다. 미설정·연결 실패·응답 불일치는 해당 결과의 `state: unknown`, `data: null`이며 측정 실패 CPU/RAM도 null입니다. 실제 조회한 빈 저장소만 0건으로 표시하고 조용한 수신량을 장애로 판단하지 않습니다. +- 연결 실패·오프라인·숨긴 화면·확인 기한 초과 시 마지막 관측은 과거 기록임을 표시합니다. 조직 Gateway 보호와 기존 비공개 노출 경계는 유지하며 새 터널·공개 SSH·WARP/OpenBao 권한은 추가하지 않습니다. ## 배포 diff --git a/apps/attendance/src/automation.ts b/apps/attendance/src/automation.ts index b5a9d649..701fc508 100644 --- a/apps/attendance/src/automation.ts +++ b/apps/attendance/src/automation.ts @@ -1,73 +1,44 @@ -import { parseErrorMonitorSnapshot, type ErrorMonitorSnapshot } from '@koin/telemetry/monitor'; +import { + parseErrorMonitorSnapshot, + parseRockyMonitorSnapshot, + readMonitorJson, + type ErrorMonitorSnapshot, + type RockyMonitorSnapshot, +} from '@koin/telemetry/monitor'; export type AutomationEnv = Partial> & { ERROR_MONITOR_TOKEN?: string; }; -export interface AutomationResult { +interface MonitorResult { state: 'ok' | 'unknown'; checkedAt: number | null; - data: ErrorMonitorSnapshot | null; + data: TSnapshot | null; reason?: 'not_configured' | 'unavailable' | 'stale'; } -const MAX_BYTES = 16 * 1024; +export type AutomationResult = MonitorResult; +export interface AutomationDashboardResult extends AutomationResult { + rocky: MonitorResult; +} + const DEADLINE_MS = 5_000; const FRESH_MS = 120_000; const HEALTH_URL = 'https://error-automation.internal/healthz'; +const ROCKY_HEALTH_URL = 'https://error-automation.internal/healthz/rocky'; const UNKNOWN_HEADERS = { 'cache-control': 'no-store' }; -function unknown(reason: NonNullable): AutomationResult { +function unknown(reason: NonNullable): MonitorResult { return { state: 'unknown', checkedAt: null, data: null, reason }; } -async function readBoundedJson(response: Response, signal: AbortSignal): Promise { - const length = response.headers.get('content-length'); - if (length !== null && (!/^\d+$/.test(length) || Number(length) > MAX_BYTES)) { - void response.body?.cancel().catch(() => {}); - throw new Error('oversized response'); - } - if (!/^application\/json(?:\s*;|\s*$)/i.test(response.headers.get('content-type') ?? '')) { - void response.body?.cancel().catch(() => {}); - throw new Error('non-JSON response'); - } - if (!response.body) throw new Error('missing response body'); - - const reader = response.body.getReader(); - const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }); - let bytes = 0; - let text = ''; - let complete = false; - const cancel = () => { - void reader.cancel().catch(() => {}); - }; - signal.addEventListener('abort', cancel, { once: true }); - if (signal.aborted) { - signal.removeEventListener('abort', cancel); - cancel(); - reader.releaseLock(); - throw new Error('monitor deadline exceeded'); - } - try { - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - bytes += value.byteLength; - if (bytes > MAX_BYTES) throw new Error('oversized response'); - text += decoder.decode(value, { stream: true }); - } - complete = true; - text += decoder.decode(); - return JSON.parse(text); - } finally { - signal.removeEventListener('abort', cancel); - if (!complete) cancel(); - reader.releaseLock(); - } -} - /** The deadline covers both the binding fetch and consumption of the complete response. */ -export async function getAutomationResult(env: AutomationEnv, now: () => number = Date.now): Promise { +async function getMonitorResult( + env: AutomationEnv, + url: string, + parse: (value: unknown, now: number) => TSnapshot | null, + now: () => number, +): Promise> { const binding = env.ERROR_AUTOMATION; const token = env.ERROR_MONITOR_TOKEN; if (!binding || !token || token.length < 32) return unknown('not_configured'); @@ -79,21 +50,24 @@ export async function getAutomationResult(env: AutomationEnv, now: () => number reject(new Error('monitor deadline exceeded')); }, DEADLINE_MS); try { - const task = async (): Promise => { - const response = await binding.fetch(HEALTH_URL, { + const task = async (): Promise> => { + const response = await binding.fetch(url, { method: 'GET', headers: { authorization: `Bearer ${token}` }, redirect: 'manual', signal: controller.signal, }); - if (response.status !== 200 || response.redirected) return unknown('unavailable'); - const payload = await readBoundedJson(response, controller.signal); + if (response.status !== 200 || response.redirected) { + void response.body?.cancel().catch(() => {}); + return unknown('unavailable'); + } + const payload = await readMonitorJson(response, controller.signal); const checkedAt = payload !== null && typeof payload === 'object' && !Array.isArray(payload) && 'checkedAt' in payload ? payload.checkedAt : null; const currentTime = now(); - const snapshot = parseErrorMonitorSnapshot(payload, currentTime); + const snapshot = parse(payload, currentTime); if (!snapshot) return unknown( typeof checkedAt === 'number' && Number.isFinite(checkedAt) && currentTime - checkedAt > FRESH_MS @@ -110,6 +84,15 @@ export async function getAutomationResult(env: AutomationEnv, now: () => number } } +export function getAutomationResult(env: AutomationEnv, now: () => number = Date.now): Promise { + return getMonitorResult(env, HEALTH_URL, parseErrorMonitorSnapshot, now); +} + export async function handleAutomation(env: AutomationEnv): Promise { - return Response.json(await getAutomationResult(env), { headers: UNKNOWN_HEADERS }); + const [ingress, rocky] = await Promise.all([ + getAutomationResult(env), + getMonitorResult(env, ROCKY_HEALTH_URL, parseRockyMonitorSnapshot, Date.now), + ]); + const result: AutomationDashboardResult = { ...ingress, rocky }; + return Response.json(result, { headers: UNKNOWN_HEADERS }); } diff --git a/apps/attendance/src/dashboard.ts b/apps/attendance/src/dashboard.ts index 6b80cd95..7a23d4d8 100644 --- a/apps/attendance/src/dashboard.ts +++ b/apps/attendance/src/dashboard.ts @@ -145,11 +145,12 @@ export const DASHBOARD_HTML = /* html */ ` .sync-note { margin:0; color:var(--muted); font-size:12px; } footer { display:flex; justify-content:space-between; flex-wrap:wrap; gap:8px; padding:24px 4px 0; color:var(--muted); font-size:11px; } .monitor-strip { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 16px; border:1px solid var(--line); border-radius:18px; background:var(--surface); box-shadow:var(--shadow); } + .monitor-panels { flex:1; min-width:0; display:grid; gap:6px; } .monitor-summary { display:flex; align-items:center; flex-wrap:wrap; gap:6px 18px; min-width:0; font-size:12px; } .monitor-summary strong { font-size:13px; } .monitor-summary small { color:var(--muted); } - #monitor-state[data-state="ok"] { background:var(--green-container); color:var(--on-green-container); } - #monitor-state[data-state="unknown"] { background:var(--amber-container); color:var(--amber); } + #monitor-state[data-state="ok"], #rocky-state[data-state="ok"] { background:var(--green-container); color:var(--on-green-container); } + #monitor-state[data-state="unknown"], #rocky-state[data-state="unknown"] { background:var(--amber-container); color:var(--amber); } .monitor-dialog { width:min(650px,calc(100% - 32px)); max-height:min(80vh,700px); overflow:auto; border:1px solid var(--outline); border-radius:24px; padding:22px; background:var(--surface); color:var(--text); box-shadow:0 20px 60px #10131b44; } .monitor-dialog::backdrop { background:#10131b88; } .monitor-dialog header { display:flex; justify-content:space-between; align-items:center; gap:12px; } @@ -313,16 +314,27 @@ export const DASHBOARD_HTML = /* html */ `
    -
    -
    - 오류 자동화 · 수신 전용 - 확인 중 - Sentry 24시간 고유 오류 등록 — · 최근 신규 등록 시각 — - Cloudflare 24시간 고유 오류 등록 — · 최근 신규 등록 시각 — - 마지막 확인 — - Worker/등록 저장소만 확인 · Rocky 실행기, 모델, CPU·RAM 미측정 +
    +
    +
    + 오류 수신 · 24시간 + 확인 중 + Sentry 고유 등록 — · 최근 신규 — + Cloudflare 고유 등록 — · 최근 신규 — + 수신 확인 — +
    +
    + Rocky + 확인 중 + CPU — + RAM — + 실행기 — + 브로커 — + 호스트 확인 — + 브로커 HTTP·모델 실행 미확인 +
    - +
    -

    오류 자동화 · 최근 등록

    +

    오류 수신 · Rocky 관측

      +

      Rocky 읽기 전용 헬스

      +

      아직 조회된 호스트 정보가 없습니다.