From b969931a25ff76b9cf35f0bca8855bd001ab899f Mon Sep 17 00:00:00 2001 From: Yunsup Sim Date: Sat, 14 Nov 2026 10:29:51 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Pi=20=EC=B6=9C=EA=B7=BC=EB=B6=80?= =?UTF-8?q?=EB=A5=BC=20=EC=82=AC=EC=84=A4=20=EC=A1=B0=ED=9A=8C=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(refs=20#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Worker UID 태그·수동 등록 API를 종료한다. Pi SQLite 원장의 읽기 전용 스냅샷을 제공한다. 기존 KV 데이터는 변경하거나 삭제하지 않는다. --- .github/workflows/LINT_CHECK.yml | 4 + apps/attendance/README.md | 155 +- apps/attendance/lan/attendance-lan.service | 30 + apps/attendance/lan/render-dashboard.mjs | 1103 +++++++++++++ apps/attendance/lan/server.py | 908 +++++++++++ apps/attendance/lan/test_server.py | 1487 ++++++++++++++++++ apps/attendance/package.json | 4 +- apps/attendance/pi/attendance-reader.service | 8 +- apps/attendance/pi/hce.py | 274 ++++ apps/attendance/pi/reader.py | 319 ++-- apps/attendance/pi/test_hce.py | 155 ++ apps/attendance/pi/test_reader.py | 146 ++ apps/attendance/scripts/build-viewer.mjs | 37 + apps/attendance/scripts/cloud-dashboard.mjs | 301 ++++ apps/attendance/snapshot/publish.py | 681 ++++++++ apps/attendance/snapshot/test_publish.py | 444 ++++++ apps/attendance/src/dashboard.ts | 576 ++++--- apps/attendance/src/html.d.ts | 4 + apps/attendance/src/index.ts | 273 +--- apps/attendance/src/logic.ts | 137 -- apps/attendance/src/sun.ts | 57 + apps/attendance/src/viewer.ts | 327 ++++ apps/attendance/test/logic.test.mjs | 89 -- apps/attendance/test/worker.test.mjs | 443 ++++++ apps/attendance/wrangler.jsonc | 11 +- 25 files changed, 6892 insertions(+), 1081 deletions(-) create mode 100644 apps/attendance/lan/attendance-lan.service create mode 100644 apps/attendance/lan/render-dashboard.mjs create mode 100644 apps/attendance/lan/server.py create mode 100644 apps/attendance/lan/test_server.py create mode 100644 apps/attendance/pi/hce.py create mode 100644 apps/attendance/pi/test_hce.py create mode 100644 apps/attendance/pi/test_reader.py create mode 100644 apps/attendance/scripts/build-viewer.mjs create mode 100644 apps/attendance/scripts/cloud-dashboard.mjs create mode 100644 apps/attendance/snapshot/publish.py create mode 100644 apps/attendance/snapshot/test_publish.py create mode 100644 apps/attendance/src/html.d.ts delete mode 100644 apps/attendance/src/logic.ts create mode 100644 apps/attendance/src/sun.ts create mode 100644 apps/attendance/src/viewer.ts delete mode 100644 apps/attendance/test/logic.test.mjs create mode 100644 apps/attendance/test/worker.test.mjs diff --git a/.github/workflows/LINT_CHECK.yml b/.github/workflows/LINT_CHECK.yml index 6455cef..c20ab92 100644 --- a/.github/workflows/LINT_CHECK.yml +++ b/.github/workflows/LINT_CHECK.yml @@ -40,3 +40,7 @@ jobs: - uses: ./.github/actions/setup - name: Check types run: pnpm typecheck + - name: Check attendance behavior + run: pnpm --filter @koin/attendance test + - name: Check attendance POSIX runtime + run: pnpm --filter @koin/attendance test:runtime diff --git a/apps/attendance/README.md b/apps/attendance/README.md index 7241728..a1f5241 100644 --- a/apps/attendance/README.md +++ b/apps/attendance/README.md @@ -1,80 +1,89 @@ # 출근부 (attendance) -인터널 출입 카드를 리더로 태그하면 입실/퇴실을 기록하고, 대시보드에 현재 인원과 체류 시간을 보여 주는 Worker입니다. 저장소는 Workers KV입니다. - -```text -RC522(SPI) 또는 USB HID 리더 ──▶ 라즈베리파이 3B(pi/reader.py) ──POST /api/taps──▶ Worker + KV ──▶ 대시보드(/) +Pi의 Python 리더·LAN 서버가 학번을 Internal 회원과 대조하고 SQLite에 입퇴실을 저장합니다. Worker는 별도 KV 스냅샷을 제공하는 읽기 전용 원격 화면입니다. Worker VPC binding, Basic 비밀번호, UID 수동 등록은 사용하지 않습니다. + +```mermaid +flowchart LR + Card["학생증 HCE"] --> Reader["Pi reader.py"] + Reader --> LAN["loopback LAN API · SQLite"] + LAN --> Publisher["운영자 PC 또는 전용 게시기"] + Publisher --> KV["별도 VIEW_STATE KV"] + KV --> Viewer["Access · 조직 Gateway로 보호한 원격 화면"] ``` -## 동작 규칙 +## 출입 원장과 회원 식별 -- 태그는 토글입니다. 열린 세션이 없으면 입실, 있으면 퇴실입니다. -- 같은 회원의 직전 이벤트 후 10초 안의 태그(카드 중복 인식)와, 직전 이벤트보다 이른 재전송은 무시합니다. -- 퇴근 태그가 없는 세션은 입실 후 6시간(`MAX_SESSION_MS`)이 지나면 입실 중으로 보지 않습니다. 같은 회원의 다음 태그가 6시간 시점에 그 세션을 자동 종료(`autoClosed`)하고 새 입실을 만듭니다. 이런 세션은 실제 체류 시간을 알 수 없으므로 오늘·이번 주 시간 합계에 넣지 않고, 대시보드에 "퇴근 미기록"으로 건수를 표시합니다. -- 오늘·이번 주(월요일 시작) 경계는 KST입니다. 자정을 걸친 세션은 날짜별로 나눠 합산합니다. -- 등록되지 않은 카드를 태그하면 `unknown:`에 쌓이고, 대시보드의 "미등록 카드"에서 이름을 입력해 등록합니다. 같은 카드로 다시 등록하면 이름만 바뀝니다. 등록하지 않은 카드는 7일 뒤 자동 삭제됩니다. +- 출입 판정은 Pi의 SQLite에서만 수행합니다. 원격 KV의 지연된 읽기로 입퇴실을 판정하지 않습니다. +- 태그는 입실·퇴실을 번갈아 기록합니다. 중복·오래된 재전송은 새 출입으로 처리하지 않습니다. 최대 6시간을 넘긴 열린 세션은 다음 태그에서 미퇴실 세션으로 닫으며 유효 체류 시간에 합산하지 않습니다. +- 날짜·이번 주 경계는 KST입니다. 오늘·이번 주 요약은 양수인 유효 체류 시간이 있는 회원의 중앙값입니다. +- 학번 조회 404는 LAN의 익명 방문자 정책을 따릅니다. 원장에 학번이나 카드 UID를 저장하거나 UID를 학번으로 추측해 연결하지 않습니다. +- 현재 입실자는 이름·체류 시간만 4열×2행으로 표시하고 넘치는 인원은 페이지를 넘깁니다. 최근 기록·사이드바·수동 카드 등록 패널은 제공하지 않습니다. +- 자동 테마는 병천면의 일출·일몰을 브라우저에서 계산합니다. 외부 위치·폰트 서비스를 호출하지 않습니다. +- LAN은 저장 이벤트로 즉시 갱신하고 KOIN 모니터와 15초 간격으로 교대합니다. 유효 태그는 출근부로 복귀시키고 입실·퇴실 인사 모달을 표시합니다. +- 실물 학생증의 비금융 학번 읽기 규격이 없으면 지원으로 간주하지 않습니다. 보호 키·금융 앱을 탐색하거나 UID로 사람을 임의 등록하지 않습니다. -## 저장 구조 (KV) +## 원격 조회·접근 정책 -데이터는 모두 키 메타데이터에 두어 대시보드가 `list` 호출만으로 읽습니다. +- 해당 Worker의 모든 URL을 Cloudflare Access로 보호하고 조직 Gateway를 필수로 요구합니다. 일반 소비자 WARP나 클라이언트가 보낸 인증 헤더는 허용 근거가 아닙니다. +- Worker는 플랫폼의 `ctx.access.aud`를 실제 `CF_ACCESS_AUD`와 비교합니다. 인증되지 않은 별도 호스트·서비스 호출은 데이터를 읽지 못합니다. +- 회원 자격은 Access의 IdP·권한 정책에서 강제해야 합니다. 이메일 문자열 일치나 별도 Keycloak 계정 존재만으로 Internal 회원 자격을 추정하지 않습니다. +- 원격 화면은 기본 180초 간격 스냅샷이며 KV의 지역별 반영 지연이 추가됩니다. 체류 시간·지표는 실제 관측 시각에 고정하고 스냅샷임을 표시합니다. LAN의 즉시 갱신과 다릅니다. +- 관측 후 5분 초과, 필수 데이터 누락·손상, 인증·연결 실패 시 과거 수치를 현재처럼 연장하지 않고 사용 불가로 처리합니다. +- `VIEW_STATE`의 `clubroom:view:v1`만 조회합니다. 기존 `ATTENDANCE` KV 데이터는 보존하며 자동으로 병합·이관·삭제하지 않습니다. +- 서버 응답·관측 시각, 검증된 Wi-Fi PNG, 설정된 실제 Jam PNG만 게시합니다. 카메라·토큰·원본 태그 큐·원장 파일은 게시하지 않습니다. -| 키 | 내용 | -| --------------------------------------- | ------------------------------------------------------- | -| `member:` | 메타 `{ name }` | -| `unknown:` | 메타 `{ firstSeen, lastSeen, count }` | -| `s::<입실 시각 13자리>:` | 세션. 메타 `{ checkOut, autoClosed }` | -| `latest:` | 값 `{ checkIn, checkOut, autoClosed }` 최근 세션 포인터 | +| Worker 경로 | 용도 | +| ------------------------------------------------------------ | -------------------------------------- | +| `GET /`, `HEAD /` | 실제 LAN 템플릿 기반 원격 화면 | +| `GET /api/view-status`, `/health` | 스냅샷 가용성·관측 시각 | +| `GET /api/state` | 출근부 조회 | +| `GET /api/monitor`, `/api/koin-health`, `/api/worker-health` | 실제 서버·서비스 관측 응답 | +| `GET /api/spotify`, `/api/jam` | 관측 당시 재생 정보·실제 Jam 설정 여부 | +| `GET /api/wifi-qr/2.4`, `/api/wifi-qr/5`, `/api/jam-qr` | 스냅샷에 존재하는 검증된 PNG | -KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60초 늦게 보입니다. 리더 한 곳에서 태그하는 용도라 태그 판정은 같은 지역의 읽기-쓰기에 의존하며, 대시보드 수치는 그만큼 지연될 수 있습니다. 같은 카드의 거의 동시 태그는 10초 디바운스가 막습니다. +Worker의 태그·등록·로그인·관리자·카메라 쓰기 경로는 제공하지 않습니다. 원격 화면에서 출입 원장을 변경할 수 없습니다. -## API +## Worker 빌드·배포 -| 경로 | 인증 | 용도 | -| ------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `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" }` 회원 등록·이름 수정 | +```sh +pnpm --filter @koin/attendance build +pnpm --filter @koin/attendance typecheck +pnpm --filter @koin/attendance test +pnpm --filter @koin/attendance test:runtime +pnpm deploy:attendance +``` -## 대시보드 +Worker 검사는 Node.js 24에서 실행합니다. `test:runtime`은 POSIX 파일 권한을 사용하는 Pi·LAN 코드 때문에 Linux와 Python 3.11 이상이 필요합니다. Windows에서는 Worker 검사와 게시기 검사를 실행하고 리더·LAN 검사는 격리된 Pi/Linux에서 실행합니다. 런타임 검사는 실제 GPIO·회원 API·운영 원장을 사용하지 않습니다. -- 서버가 제공하는 단일 HTML 화면에 [Mobbin의 공개 업무용 대시보드 패턴](https://mobbin.com/explore/web/screens/analytics-dashboard)을 참고한 Material Design 3의 색상·서피스·타이포그래피를 적용합니다. 사이드바 없이 구성하며 외부 폰트나 UI 라이브러리를 불러오지 않습니다. -- 현재 인원과 입실 중인 회원의 체류 시간을 크게 표시하고, 전체 회원의 오늘·이번 주 체류 시간 합계와 퇴근 미기록 건수를 보여 줍니다. 회원별 표와 이번 주 최근 세션 최대 15건을 함께 제공합니다. -- 16:9 데스크톱 화면에서는 본문 스크롤 없이 주요 패널을 한 화면에 배치하고, 긴 목록은 각 패널의 이전·다음 버튼으로 넘깁니다. 화면 크기에 맞춰 표시 개수가 바뀌고 새로고침 후에도 선택한 페이지를 유지합니다. -- 테마 버튼은 자동(시스템 설정) → 라이트 → 다크 순서로 전환하며 선택을 브라우저에 저장합니다. 작은 화면에서는 카드가 재배치되고 표는 가로로 스크롤할 수 있습니다. -- 데이터는 10초마다 갱신하고 입실 중인 회원의 체류 시계는 매초 갱신합니다. 상태 조회·등록 요청은 응답 본문 수신까지 10초로 제한하며, 새로고침 버튼으로 즉시 갱신할 수 있습니다. 연결 실패 시 마지막 데이터임을 표시하고 체류 시계를 마지막 동기화 시각에 멈춥니다. 처음부터 불러오지 못한 경우 수치를 임의로 표시하지 않습니다. -- 미등록 카드는 화면에서 이름을 입력해 등록합니다. 주기적 갱신에도 입력과 포커스를 유지하며 등록 중·완료·오류 상태를 카드별로 표시합니다. 등록 전 태그는 입실 기록이 아니므로 등록 후 카드를 다시 태그해야 합니다. -- 회원 이름과 카드 정보는 `textContent`로만 출력합니다. 상태 조회와 등록은 같은 출처의 기존 인증을 사용하며 등록은 JSON 요청으로만 전송합니다. -- 배포는 BCSDLab 조직 Gateway를 요구하는 접근 정책을 전제로 합니다. 일반 소비자 WARP 접속만 허용하는 정책은 충분하지 않습니다. Worker의 모든 도메인·미리보기 우회 경로를 보호하기 전에는 공개 경로를 활성화하지 않습니다. +빌드는 설치된 `tsx`로 LAN 렌더러를 실행해 `.wrangler/viewer/dashboard.html`을 생성합니다. Worker는 이 파일을 Text 모듈로 가져오며 별도 Static Assets 인증 우회 경로를 만들지 않습니다. -## 배포 +`wrangler.jsonc`에는 실제 별도 KV ID와 해당 Access 앱의 audience를 설정합니다. 조직 Gateway·회원 자격 정책이 적용되기 전에는 `workers_dev`를 활성화하지 않습니다. 미리보기 URL은 비활성 상태를 유지합니다. 리소스를 삭제하거나 기존 KV ID를 새 데이터로 덮어쓰지 않습니다. -```sh -# 기존 DEVICE_TOKEN·DASHBOARD_PASSWORD는 유지합니다. 비밀값 설정·회전은 별도 운영 절차입니다. -pnpm deploy:attendance -``` +로컬 `wrangler dev`에서는 실제 플랫폼 인증이 없으면 실패합니다. 인증 개발 설정은 로컬 프로세스에만 적용하고 운영에서 클라이언트 헤더로 인증을 흉내 내지 않습니다. -`wrangler.jsonc`의 `ATTENDANCE`는 기존 운영 KV 네임스페이스를 고정합니다. 새 네임스페이스를 만들거나 id를 바꾸면 기존 데이터와 분리되므로 배포 중 치환하지 않습니다. 새 토큰이 필요할 때만 충분히 긴 무작위 값으로 별도 설정합니다(`openssl rand -hex 32`). +## 스냅샷 게시기 -벽걸이 화면은 대시보드 경로(`/`)를 열고 Basic 인증 창에서 `DASHBOARD_PASSWORD`로 인증합니다. 비밀번호를 URL에 넣지 않습니다. +`snapshot/publish.py`는 고정된 Pi5의 허용된 GET 경로만 수집합니다. 기본 실행은 I/O 없는 계획 출력입니다. 실제 account·namespace, 인증 출처, 영구 private checkpoint 디렉터리를 지정하고 `--once` 또는 `--loop`로 게시합니다. -## 로컬 실행 +- 게시 간격은 최소 180초, 일일 시도 예산은 600회입니다. 실패한 쓰기도 예산에 포함하며 공유 계정 한도 때문에 무과금을 보장하지 않습니다. +- 전용 기기 토큰의 KV Storage Write는 계정 범위입니다. 다른 KV에 대한 쓰기 위험을 검토한 뒤 사용합니다. +- 운영자 PC의 기존 Wrangler 세션은 명시적 `--operator-wrangler-auth-file`로만 사용합니다. 광범위한 OAuth 세션을 Pi로 복사하지 않습니다. 만료 시 인증된 `wrangler kv namespace list`로 정상 갱신하며 `whoami`만으로 갱신됐다고 간주하지 않습니다. +- 관리자 WARP 경로가 SSH만 허용하면 먼저 고정 호스트 키를 검증하는 `ssh -N -L 127.0.0.1:8080:127.0.0.1:8080 bcsd-pi`를 연결하고 게시기에 `--ssh-forward`를 지정합니다. 연결 대상만 localhost로 바꾸며 원래 Host와 허용 경로를 유지합니다. +- 게시기·인증·사설 조회 경로가 중단되면 원격 화면도 신선도 제한 후 사용할 수 없습니다. cloudflared 설치 자체는 게시기나 Internal 송신 인증을 대신하지 않습니다. -```sh -printf 'DEVICE_TOKEN=dev-device-token\nDASHBOARD_PASSWORD=dev-password\n' > apps/attendance/.dev.vars -pnpm dev:attendance # http://localhost:8787 -curl -X POST localhost:8787/api/taps -H 'authorization: Bearer dev-device-token' \ - -H 'content-type: application/json' -d '{"uid":"04A1B2C3"}' -``` +## LAN 서버 + +`lan/server.py`는 IPv4 `0.0.0.0:8080`에 바인딩하지만 읽기 출발지는 loopback·`192.168.1.0/24`로 제한합니다. 이 LAN 읽기에는 로그인이 없고 실제 이름·Wi-Fi 비밀번호를 볼 수 있습니다. 인터넷에 직접 게시하지 않습니다. -## 라즈베리파이 +태그 POST는 loopback 출발지, 올바른 Host·Origin, 별도 `DEVICE_TOKEN`을 모두 요구합니다. 서비스는 root 소유 `0600` 파일의 토큰을 systemd credential로 전달받습니다. `/etc/attendance-lan/config`의 `INTERNAL_LOOKUP_URL`은 고정된 HTTPS `api.internal.bcsdlab.com/v1/service/attendance/member-lookup`이며, lookup·device·monitor 자격증명은 목적별로 분리합니다. HTTPS 전달은 실제 Internal 호스트의 SNI·인증서를 검증하고 TLS 검증을 끄지 않습니다. -Raspberry Pi 3B 기준입니다. `READER`로 리더 종류를 고릅니다. +`lan/attendance-lan.service`와 `pi/attendance-reader.service`를 활성화하면 부팅 시 실행됩니다. 실제 사설 회원 조회 경로·토큰을 준비한 뒤 시작합니다. SQLite와 원본 큐는 private 디렉터리·파일 권한을 유지합니다. -- `rc522`: GPIO(SPI)에 직접 연결한 RC522 모듈. 카드 UID를 16진수 대문자(예: `04A1B2C3`)로 보내고, 결과를 LED 두 개로 알립니다. -- `hid`(기본값): 카드 UID를 키 입력 + Enter로 보내는 USB HID 리더. 장치를 독점해 UID가 콘솔에 입력되지 않으며, 장치가 여러 개면 `READER_DEVICE`로 경로를 지정합니다. +Wi-Fi QR은 실제 비밀번호로 생성한 작은 PNG를 로컬에 설치하고 `WIFI_QR_24_FILE`, `WIFI_QR_5_FILE`로 지정합니다. 화면을 볼 수 있는 사람은 QR에서 비밀번호를 알 수 있으므로 표시 범위를 제한합니다. Jam은 실제 휴대폰 초대 링크로 만든 root 소유 PNG를 `JAM_QR_FILE`로 지정하며 미설정이면 숨깁니다. 곡·기기 URL로 Jam 초대를 만들어 대체하지 않습니다. -같은 카드라도 리더 종류에 따라 UID 문자열이 다를 수 있으므로, 리더를 바꾸면 회원을 다시 등록합니다. +## 라즈베리파이 리더 + +리더는 Android 학생증 HCE에서 읽은 학번·태그 시각을 loopback LAN 서버에만 전송합니다. 서버가 조회한 회원 정보로 입퇴실을 처리하며, 클라우드 UID API·HID 수동 등록은 사용하지 않습니다. 칩 초기화·RF 복구 후에는 카드가 제거된 간격을 확인한 뒤 새 태그를 받으므로 계속 올려 둔 카드를 재시작만으로 새 출입으로 처리하지 않습니다. ### RC522 배선 @@ -94,42 +103,10 @@ Raspberry Pi 3B 기준입니다. `READER`로 리더 종류를 고릅니다. | 빨강 LED | GPIO → 330 Ω → 애노드, 캐소드 → GND | 18 (GPIO24) | | LED 공통 GND | GND | 20 | -| 결과 | LED | -| --------------------------- | ------------- | -| 입실 | 초록 1초 | -| 퇴실 | 초록 2번 깜빡 | -| 10초 안 중복(무시) | 초록 짧게 1번 | -| 미등록 카드 | 빨강 1초 | -| 오프라인·서버 오류(큐 보관) | 빨강 2번 깜빡 | -| 거절(잘못된 UID 등) | 빨강 3번 깜빡 | - -시작할 때 초록·빨강이 차례로 한 번 켜집니다. 카드는 읽은 뒤 HALT시키므로 떼었다 다시 대야 다시 읽힙니다. - ### 설치 -```sh -sudo raspi-config nonint do_spi 0 # rc522: 부팅 시 SPI 활성화 -sudo dtparam spi=on # rc522: 재부팅 없이 현재 부팅에도 적용 -sudo apt install python3-spidev python3-rpi-lgpio # Raspberry Pi OS Trixie. hid는 python3-evdev -sudo install -D -o root -g root -m 755 pi/reader.py /opt/attendance/reader.py -sudo install -o root -g root -m 644 pi/attendance-reader.service /etc/systemd/system/ -sudo systemctl daemon-reload -``` +`reader.py`와 `hce.py`를 함께 `/opt/attendance`에 설치하고 SPI·GPIO 의존성을 준비합니다. APT를 사용할 때는 먼저 해당 Raspberry Pi OS에 맞는 한국 미러를 설정합니다. `/dev/spidev0.0` 존재만으로 카드 통신을 보장하지 않습니다. -RC522는 `/dev/spidev0.0`과 위 배선을 사용합니다. SPI 장치 파일이 있다는 것만으로 모듈 연결이 확인되지는 않습니다. 시작 시 `RC522 응답 없음`이면 3.3V 전원·GND·CE0·SCLK·MOSI·MISO·RST 배선을 확인합니다. USB HID는 `/dev/input/by-id/*-event-kbd`에 실제 리더가 있어야 합니다. - -실제 Worker 주소와 해당 Worker의 `DEVICE_TOKEN`을 받은 뒤에만 런타임 설정을 작성하고 서비스를 시작합니다. 진단 중이거나 모듈·카드 인식이 확인되지 않았다면 전송 루프를 켜지 않습니다. 네트워크 접근 정책이 있는 배포는 Pi에서도 허용된 경로로 Worker에 접근할 수 있어야 합니다. - -```sh -sudo touch /etc/attendance-reader.env -sudo chown root:root /etc/attendance-reader.env -sudo chmod 600 /etc/attendance-reader.env -sudoedit /etc/attendance-reader.env -# ATTENDANCE_URL=<실제 Worker 기본 주소> -# DEVICE_TOKEN=<해당 Worker의 실제 토큰> -# READER=rc522 (USB HID는 hid; 필요하면 READER_DEVICE도 지정) -sudo systemctl enable --now attendance-reader.service -``` +리더 환경 파일은 root 소유 `0600`으로 관리하고 `ATTENDANCE_URL=http://127.0.0.1:8080`, LAN 서버와 일치하는 `DEVICE_TOKEN`을 지정합니다. 원본 큐 기본 경로는 `/var/lib/attendance/queue.jsonl`이며 `QUEUE_FILE`로 private 경로를 지정할 수 있습니다. 학번·토큰·큐 원문을 Git·로그·공유 문서에 넣지 않습니다. -- 오프라인이면 태그를 `/var/lib/attendance/queue.jsonl`에 보관하고 30초마다 순서대로 재전송합니다. 태그 시각(`at`)은 파이 시계 기준이므로 NTP 동기화가 필요합니다. -- 인증 오류(401/403)·408·429·5xx는 큐에 남겨 재시도하고, 그 외 4xx(잘못된 UID 등)는 버립니다. +서비스는 제한된 private 상태 디렉터리를 사용합니다. 전송 실패는 원래 태그 시각으로 순서대로 재전송하므로 NTP 동기화가 필요합니다. 인증·연결 실패를 성공이나 임의 회원 등록으로 바꾸지 않습니다. diff --git a/apps/attendance/lan/attendance-lan.service b/apps/attendance/lan/attendance-lan.service new file mode 100644 index 0000000..719acd3 --- /dev/null +++ b/apps/attendance/lan/attendance-lan.service @@ -0,0 +1,30 @@ +[Unit] +Description=BCSD LAN attendance dashboard and Internal member lookup +After=network.target + +[Service] +Type=simple +DynamicUser=yes +EnvironmentFile=/etc/attendance-lan/config +LoadCredential=lookup:/etc/attendance-lan/lookup-token +LoadCredential=device:/etc/attendance-lan/device-token +Environment=INTERNAL_LOOKUP_TOKEN_FILE=%d/lookup +Environment=DEVICE_TOKEN_FILE=%d/device +Environment=ATTENDANCE_HTML=/opt/attendance/lan/dashboard.html +Environment=ATTENDANCE_DATA_DIR=/var/lib/attendance-lan +ExecStart=/usr/bin/python3 /opt/attendance/lan/server.py +StateDirectory=attendance-lan +StateDirectoryMode=0700 +UMask=0077 +Restart=on-failure +RestartSec=5 +NoNewPrivileges=yes +CapabilityBoundingSet= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +RestrictAddressFamilies=AF_INET AF_UNIX + +[Install] +WantedBy=multi-user.target diff --git a/apps/attendance/lan/render-dashboard.mjs b/apps/attendance/lan/render-dashboard.mjs new file mode 100644 index 0000000..d28ae28 --- /dev/null +++ b/apps/attendance/lan/render-dashboard.mjs @@ -0,0 +1,1103 @@ +#!/usr/bin/env node +// Render src/dashboard.ts with the tsx loader; only the generated HTML runs on the Pi. +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const [modulePath, outputPath] = process.argv.slice(2); +if (!modulePath || !outputPath) + throw new Error('Usage: node --import tsx lan/render-dashboard.mjs '); +const { DASHBOARD_HTML } = await import(pathToFileURL(resolve(modulePath)).href); +if (typeof DASHBOARD_HTML !== 'string') throw new Error('Expected the real DASHBOARD_HTML export'); +const startMarker = 'async function checkSession() {'; +const endMarker = "document.getElementById('login-form').addEventListener"; +const start = DASHBOARD_HTML.indexOf(startMarker); +const end = DASHBOARD_HTML.indexOf(endMarker, start); +if (start < 0 || end < start || DASHBOARD_HTML.indexOf(startMarker, start + 1) !== -1) { + throw new Error('Dashboard authentication structure changed; refusing to generate a partial page'); +} +const pollMarker = 'setInterval(refresh, POLL_MS);'; +if ( + DASHBOARD_HTML.indexOf(pollMarker) < 0 || + DASHBOARD_HTML.indexOf(pollMarker) !== DASHBOARD_HTML.lastIndexOf(pollMarker) || + !DASHBOARD_HTML.includes('if (refreshing || !authActive) return;') +) { + throw new Error('Dashboard refresh structure changed; refusing generate partial page'); +} +const manualRefreshMarker = "document.getElementById('refresh').addEventListener('click', refresh);"; +const headerActionsMarker = '
'; +const fitMarker = 'function fitWall() {'; +for (const marker of [manualRefreshMarker, fitMarker, headerActionsMarker, '']) { + if (!DASHBOARD_HTML.includes(marker) || DASHBOARD_HTML.indexOf(marker) !== DASHBOARD_HTML.lastIndexOf(marker)) { + throw new Error('Dashboard view structure changed; refusing to generate a partial page'); + } +} +const spotifyStyles = ` +.app-header > .brand, .app-header > .header-actions { flex-shrink:0; } +.app-header > .header-actions { min-width:0; } +.app-header .viewer-name { display:none; } +.spotify-now { flex:1 1 320px; min-width:0; max-width:480px; height:56px; padding:4px 10px; display:flex; align-items:center; gap:10px; border:1px solid var(--line); border-radius:16px; background:var(--surface-low); overflow:hidden; } +.spotify-copy { display:grid; grid-template-rows:22px 20px; align-content:center; flex:1; min-width:0; } +.spotify-cover { flex:0 0 44px; width:44px; height:44px; object-fit:cover; border-radius:7px; } +.spotify-title, .spotify-artists { display:block; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.spotify-title { color:var(--text); font-size:clamp(16px,1.8vh,20px); font-weight:650; line-height:20px; } +.spotify-artists { color:var(--muted); font-size:16px; line-height:20px; } +@media (min-width:1000px) and (min-aspect-ratio:3/2) { + .app-header > .brand, .app-header > .header-actions { height:56px; } + .app-header h1 { margin-top:2px; font-size:clamp(26px,3vh,32px); line-height:1.2; } + .app-header #clock { font-size:clamp(26px,3vh,32px); line-height:1.2; } + .app-header #date { font-size:16px; line-height:1.2; } +} +@media (max-width:999px) { + .app-header { flex-wrap:wrap; } + .spotify-now { order:3; flex-basis:100%; max-width:none; } +} +@media (max-width:700px) { + .app-header > .brand, .app-header > .header-actions { flex-shrink:1; } +} +`; +const spotifyMarkup = ` +
+ +
+ 재생 상태를 확인하고 있습니다 + Spotify Connect +
+
+`; +const jamStyles = ` +.wifi-codes.has-jam { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); align-items:center; gap:6px; } +.wifi-codes.has-jam .wifi-code img { width:clamp(104px,15vh,128px); height:auto; aspect-ratio:1; max-width:100%; } +.wifi-codes.has-jam .wifi-code figcaption { margin-top:2px; font-size:16px; line-height:1.3; white-space:nowrap; } +`; +const monitorStyles = ` +.monitor-view { --monitor-cpu:var(--primary); --monitor-ram:var(--amber); display:grid; align-content:start; gap:16px; min-width:0; } +.monitor-heading, .monitor-title, .monitor-section-heading { display:flex; align-items:center; justify-content:space-between; gap:12px 20px; } +.monitor-title { justify-content:flex-start; flex-wrap:wrap; } +.monitor-heading h2 { margin:0; font-size:clamp(28px,3.4vh,40px); line-height:1.2; } +.monitor-description { margin:0; color:var(--muted); font-size:clamp(16px,1.8vh,21px); line-height:1.35; } +.monitor-status { display:flex; align-items:center; gap:12px; flex-shrink:0; margin:0; padding:10px 18px; border-radius:999px; background:var(--amber-container); color:var(--amber); font-size:clamp(20px,2.2vh,28px); line-height:1.3; font-weight:750; } +.monitor-status::before { content:""; width:13px; height:13px; flex-shrink:0; border-radius:50%; background:currentColor; } +.monitor-status[data-state="up"] { color:var(--on-green-container); background:var(--green-container); } +.monitor-status[data-state="down"] { color:var(--error); background:var(--error-container); } +.monitor-paths { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } +.monitor-path { display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0; padding:8px 16px; border:1px solid var(--line); border-radius:999px; color:var(--amber); background:var(--amber-container); line-height:1.2; } +.monitor-path strong { color:var(--text); font-size:clamp(24px,2.5vh,30px); font-weight:750; } +.monitor-path span { display:inline-flex; align-items:center; gap:8px; font-size:clamp(22px,2.2vh,28px); font-weight:750; white-space:nowrap; } +.monitor-path span::before { content:""; width:10px; height:10px; flex-shrink:0; border-radius:50%; background:currentColor; } +.monitor-path[data-health="up"] { color:var(--on-green-container); background:var(--green-container); } +.monitor-path[data-health="down"] { color:var(--error); background:var(--error-container); } +.worker-health { display:flex; align-items:center; justify-content:space-between; gap:8px 12px; min-width:0; margin:0; padding:8px 16px; border:1px solid var(--line); border-radius:24px; background:var(--surface-low); } +.worker-health > strong { color:var(--text); font-size:clamp(22px,2.3vh,28px); line-height:1.2; white-space:nowrap; } +.worker-status { display:inline-flex; align-items:center; gap:6px; color:var(--amber); font-size:clamp(18px,1.9vh,23px); font-weight:750; line-height:1.3; white-space:nowrap; } +.worker-status[data-health="up"] { color:var(--on-green-container); } +.worker-status[data-health="down"] { color:var(--error); } +@media (max-width:1100px) { .worker-health { flex-wrap:wrap; } .worker-health > strong { flex-basis:100%; } } +.koin-services { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; } +.koin-service { display:grid; gap:8px; padding:clamp(10px,1.8vh,22px) clamp(16px,2vw,30px); border:1px solid var(--line); border-left:6px solid var(--amber); border-radius:20px; background:var(--surface); } +.koin-service[data-health="up"] { border-left-color:var(--green); } +.koin-service[data-health="down"] { border-left-color:var(--error); } +.koin-service-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; } +.koin-service-name { margin:0; font-size:clamp(26px,3.1vh,38px); font-weight:750; line-height:1.2; } +.koin-service-detail { display:flex; align-items:baseline; justify-content:space-between; flex-wrap:wrap; gap:2px 14px; color:var(--muted); font-size:clamp(16px,1.85vh,22px); line-height:1.3; } +.koin-service-reason, .koin-service-time { margin:0; } +.monitor-health { display:inline-flex; align-items:center; gap:8px; width:fit-content; flex-shrink:0; padding:5px 12px; border-radius:999px; color:var(--amber); background:var(--amber-container); font-size:clamp(18px,2.1vh,26px); font-weight:750; line-height:1.2; white-space:nowrap; } +.monitor-health::before { content:""; width:9px; height:9px; flex-shrink:0; border-radius:50%; background:currentColor; } +.monitor-health[data-health="up"] { color:var(--on-green-container); background:var(--green-container); } +.monitor-health[data-health="down"] { color:var(--error); background:var(--error-container); } +.monitor-section-heading h3 { margin:0; font-size:clamp(23px,2.5vh,30px); line-height:1.2; } +.monitor-timestamp { margin:0; color:var(--muted); font-size:clamp(16px,1.8vh,21px); line-height:1.3; text-align:right; } +.monitor-timestamp, .monitor-node-time, .koin-service-time { font-variant-numeric:tabular-nums; } +.monitor-cards { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; min-width:0; min-height:0; } +.monitor-node.panel { display:grid; grid-template-rows:auto auto minmax(0,1fr) auto; align-content:start; gap:clamp(8px,1.1vh,16px); padding:clamp(12px,1.8vh,24px); border-radius:22px; min-width:0; } +.monitor-node-heading { min-width:0; } +.monitor-node-title { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:6px 10px; } +.monitor-node h4 { margin:0; font-size:clamp(24px,3vh,36px); line-height:1.2; font-weight:750; overflow-wrap:anywhere; } +.monitor-node-time { margin:0; padding-top:8px; border-top:1px solid var(--line); color:var(--muted); font-size:clamp(16px,1.85vh,22px); line-height:1.3; } +.monitor-value-note { margin:5px 0 0; color:var(--amber); font-size:clamp(14px,1.7vh,20px); line-height:1.3; } +.monitor-metrics { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; } +.monitor-metric { min-width:0; } +.monitor-metric-label { display:flex; align-items:center; gap:9px; color:var(--monitor-cpu); font-size:clamp(18px,2.2vh,26px); line-height:1.2; font-weight:750; } +.monitor-metric-label::before { content:""; width:27px; border-top:5px solid currentColor; } +.monitor-metric[data-metric="ram"] .monitor-metric-label { color:var(--monitor-ram); } +.monitor-metric[data-metric="ram"] .monitor-metric-label::before { border-top-style:dashed; } +.monitor-value { display:block; margin-top:3px; font-size:clamp(44px,5.4vh,72px); font-weight:750; letter-spacing:-.055em; line-height:1.05; font-variant-numeric:tabular-nums; } +.monitor-metric-detail { display:block; margin-top:4px; color:var(--muted); font-size:clamp(13px,1.5vh,18px); line-height:1.3; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; } +.monitor-plot { display:flex; flex-direction:column; min-height:0; } +.monitor-graph { display:block; flex:1; width:100%; height:clamp(100px,15vh,180px); min-height:100px; overflow:hidden; } +.monitor-graph-grid { fill:none; stroke:var(--outline); stroke-width:1; vector-effect:non-scaling-stroke; } +.monitor-graph-line { fill:none; stroke:var(--monitor-cpu); stroke-width:5; stroke-linecap:round; stroke-linejoin:round; vector-effect:non-scaling-stroke; } +.monitor-graph-line[data-series="ram"] { stroke:var(--monitor-ram); stroke-dasharray:10 7; } +.monitor-graph-dot { fill:var(--monitor-cpu); } +.monitor-graph-dot[data-series="ram"] { fill:var(--monitor-ram); } +.monitor-graph-axis { display:flex; align-items:center; justify-content:space-between; gap:8px; color:var(--muted); font-size:clamp(14px,1.65vh,19px); line-height:1.25; } +.monitor-waiting { grid-column:1 / -1; margin:0; padding:24px; color:var(--muted); font-size:clamp(22px,2.5vh,30px); border:2px dashed var(--outline); border-radius:20px; } +@media (max-width:999px) { + .monitor-heading { align-items:start; flex-direction:column; } + .monitor-cards { grid-template-columns:minmax(0,1fr); } + .monitor-node.panel { grid-template-rows:auto auto auto auto; padding:22px; } + .monitor-graph { flex:none; height:170px; } + .monitor-value { font-size:52px; } + .monitor-node h4 { font-size:30px; } + .monitor-health { font-size:20px; } +} +@media (max-width:650px) { + .monitor-paths { grid-template-columns:minmax(0,1fr); } + .koin-services { grid-template-columns:minmax(0,1fr); } + .monitor-section-heading { align-items:start; flex-direction:column; gap:6px; } + .monitor-timestamp { text-align:left; } + .koin-service-name { font-size:26px; } + .monitor-description { font-size:16px; } + .monitor-title { gap:8px; } + .monitor-status { font-size:20px; } +} +@media (min-width:1000px) and (min-aspect-ratio:3/2) { + .monitor-view { flex:1; min-height:0; gap:clamp(8px,1vh,14px); grid-template-rows:auto auto auto auto minmax(0,1fr); overflow:hidden; } + .monitor-title { flex-wrap:nowrap; gap:16px; } + .monitor-heading { gap:12px; } + .monitor-cards { gap:clamp(12px,1.2vw,24px); } + .monitor-node.panel { overflow:hidden; } + .koin-service-name, .monitor-node h4 { font-size:clamp(32px,3.1vh,38px); } + .monitor-value { font-size:clamp(58px,5.4vh,72px); } + .monitor-health { font-size:clamp(22px,2.1vh,26px); } + .monitor-node.panel { padding:clamp(10px,1.4vh,22px); gap:clamp(6px,.9vh,14px); } + .monitor-node-time { padding-top:4px; } +} +@media (min-width:1000px) and (min-aspect-ratio:3/2) and (max-height:800px) { + .monitor-heading h2 { font-size:28px; } + .monitor-description { font-size:16px; } + .monitor-status { padding:7px 12px; font-size:20px; } + .monitor-path { padding:6px 12px; } + .monitor-path strong, .monitor-path span { font-size:22px; } + .koin-service { padding:10px 16px; gap:4px; } + .koin-service-name { font-size:26px; } + .koin-service-detail, .monitor-timestamp, .monitor-node-time { font-size:16px; } + .monitor-section-heading h3 { font-size:23px; } + .monitor-health { font-size:18px; } + .monitor-node h4 { font-size:30px; } + .monitor-metric-label { font-size:20px; } + .monitor-value { font-size:54px; } + .monitor-metric-detail { font-size:16px; } + .monitor-graph-axis { font-size:16px; } +} +`; +const monitorMarkup = ` + +`; +const greetingStyles = ` + .greeting-dialog { + width:min(560px,calc(100vw - 32px)); max-width:calc(100vw - 32px); + max-height:calc(100vh - 32px); max-height:calc(100dvh - 32px); + margin:auto; padding:clamp(24px,4vw,40px); overflow:auto; + border:1px solid var(--line); border-radius:28px; + background:var(--surface-high); color:var(--text); + box-shadow:0 24px 80px #0004; text-align:center; + } + .greeting-dialog.is-entering { animation:greeting-enter 220ms cubic-bezier(.2,.8,.2,1) both; } + .greeting-dialog.is-closing { animation:greeting-exit 160ms ease-in both; } + @keyframes greeting-enter { + from { opacity:0; transform:translateY(12px) scale(.97); } + to { opacity:1; transform:translateY(0) scale(1); } + } + @keyframes greeting-exit { + from { opacity:1; transform:translateY(0) scale(1); } + to { opacity:0; transform:translateY(8px) scale(.98); } + } + @media (prefers-reduced-motion:reduce) { + .greeting-dialog.is-entering, .greeting-dialog.is-closing { animation:none; } + } + .greeting-dialog::backdrop { background:#0006; } + .greeting-brand { + display:inline-block; margin:0 0 20px; padding:6px 14px; border-radius:999px; + background:var(--primary-container); color:var(--on-primary-container); + font-size:16px; font-weight:750; letter-spacing:.12em; + } + .greeting-message { + margin:0; font-size:clamp(24px,3vw,36px); font-weight:700; + line-height:1.5; letter-spacing:-.04em; overflow-wrap:anywhere; + } + .greeting-description { margin:16px 0 24px; color:var(--muted); font-size:16px; } + .greeting-close { min-width:96px; } +`; +const greetingMarkup = ` + + +

+

5초 후 자동으로 닫힙니다.

+ +
+`; +// LAN mode has no fabricated session endpoint. Keep the real rendering and +// refresh/auth guards; queue invalidations arriving during an in-flight fetch. +const bootstrap = `var lanEvents = null; + var lanLastEventAt = 0; + var lanPageHidden = false; + var lanRefreshPending = false; + var lanGreetingTimer = null; + var lanGreetingAnimationTimer = null; + var lanGreetingDialog = document.getElementById('greeting-dialog'); + var lanGreetingMessage = document.getElementById('greeting-message'); + var lanView = 'attendance'; + var lanRotationTimer = null; + var lanMonitorInterval = null; + var lanMonitorAgeInterval = null; + var lanRecoveryInterval = null; + var lanMonitorRequest = null; + var lanMonitorData = null; + var lanMonitorFailed = false; + var lanMonitorSucceededAt = null; + var lanWorkerData = null; + var lanWorkerFailed = false; + var lanWorkerRequest = null; + var lanWorkerRequestTimer = null; + var lanWorkerInterval = null; + var lanSpotifyInterval = null; + var lanSpotifyRequest = null; + var lanSpotifyRequestTimer = null; + var lanSpotifySignature = null; + var lanSpotifyCover = document.getElementById('spotify-cover'); + var lanSpotifyCoverKey = null; + var LAN_SPOTIFY_POLL_MS = 2500; + var lanSpotifyNode = document.getElementById('spotify-now'); + var lanSpotifyTitle = document.getElementById('spotify-title'); + var lanSpotifyArtists = document.getElementById('spotify-artists'); + var lanJamInterval = null; + var lanJamRequest = null; + var lanJamRequestTimer = null; + var lanJamImage = null; + var lanJamImageTimer = null; + var lanJamFigure = null; + var lanJamAvailable = false; + var lanJamCodes = document.querySelector('#wifi-section .wifi-codes'); + var LAN_JAM_POLL_MS = 60000; + var LAN_WORKER_POLL_MS = 60000; + var LAN_WORKER_STALE_MS = 150000; + var lanMonitorCards = []; +var lanKoinData = null; +var lanKoinFailed = false; +var lanKoinRows = []; +var lanMonitorHistory = Object.create(null); +var LAN_HISTORY_LIMIT = 60; +var LAN_HISTORY_MS = 15 * 60 * 1000; +var LAN_GRAPH_GAP_MS = 22500; + var LAN_VIEW_MS = 15000; + var LAN_STALE_MS = 45000; + var lanTimestampFormat = new Intl.DateTimeFormat('ko-KR', { + timeZone: 'Asia/Seoul', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false + }); + + function lanIsVisible() { + return authActive && !lanPageHidden && !document.hidden; + } + function showLanView(view) { +if (view === 'monitor' && lanGreetingDialog.open) return; + lanView = view; + var attendance = document.getElementById('main'); + var monitor = document.getElementById('monitor-view'); + var previous = view === 'attendance' ? monitor : attendance; + var moveFocus = previous.contains(document.activeElement); + attendance.hidden = view !== 'attendance'; + monitor.hidden = view !== 'monitor'; + if (view === 'attendance') scheduleFit(); + else updateLanMonitorStatus(); + if (moveFocus) (view === 'attendance' ? attendance : monitor).focus({ preventScroll: true }); + } + function resetLanRotation() { + clearTimeout(lanRotationTimer); + lanRotationTimer = null; + if (!lanIsVisible()) return; + lanRotationTimer = setTimeout(function () { + lanRotationTimer = null; + if (!lanIsVisible()) return; + if (!lanGreetingDialog.open) showLanView(lanView === 'attendance' ? 'monitor' : 'attendance'); + resetLanRotation(); + }, LAN_VIEW_MS); + } + function attendLanScreen() { + showLanView('attendance'); + resetLanRotation(); + } + function clearLanGreeting() { + clearTimeout(lanGreetingTimer); + clearTimeout(lanGreetingAnimationTimer); + lanGreetingTimer = null; + lanGreetingAnimationTimer = null; + lanGreetingDialog.classList.remove('is-entering', 'is-closing'); + lanGreetingMessage.textContent = ''; + } + function closeLanGreeting(immediate) { + clearTimeout(lanGreetingTimer); + clearTimeout(lanGreetingAnimationTimer); + lanGreetingTimer = null; + lanGreetingAnimationTimer = null; + if (!lanGreetingDialog.open) { clearLanGreeting(); return; } + if (immediate === true || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + lanGreetingDialog.close(); + clearLanGreeting(); + return; + } + lanGreetingDialog.classList.remove('is-entering'); + lanGreetingDialog.classList.add('is-closing'); + lanGreetingAnimationTimer = setTimeout(function () { + lanGreetingAnimationTimer = null; + if (lanGreetingDialog.open) lanGreetingDialog.close(); + }, 160); + } + function showLanGreeting(data) { + if (!lanIsVisible() || !data || (data.name !== null && typeof data.name !== 'string') + || (data.action !== 'check-in' && data.action !== 'check-out')) return; + attendLanScreen(); + clearTimeout(lanGreetingTimer); + clearTimeout(lanGreetingAnimationTimer); + lanGreetingAnimationTimer = null; + lanGreetingMessage.textContent = data.action === 'check-out' + ? (data.name === null ? '' : data.name + '님, ') + '안녕히 가세요' + : data.name === null + ? '안녕하세요 BCSDLab에 처음이신것 같아요' + : data.name + '님 안녕하세요'; + if (!lanGreetingDialog.open) lanGreetingDialog.showModal(); + lanGreetingDialog.classList.remove('is-entering', 'is-closing'); + if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + void lanGreetingDialog.offsetWidth; + lanGreetingDialog.classList.add('is-entering'); + lanGreetingAnimationTimer = setTimeout(function () { + lanGreetingAnimationTimer = null; + lanGreetingDialog.classList.remove('is-entering'); + }, 240); + } + lanGreetingTimer = setTimeout(closeLanGreeting, 5000); + } + document.getElementById('greeting-close').addEventListener('click', function () { closeLanGreeting(); }); + lanGreetingDialog.addEventListener('close', function () { + if (!lanGreetingDialog.open) clearLanGreeting(); + }); + + function validLanTimestamp(value) { + return Number.isSafeInteger(value) && value >= 0 && value <= 8640000000000000; + } + function validLanMonitor(data) { + if (!data || !validLanTimestamp(data.observedAt) + || !Array.isArray(data.nodes) || data.nodes.length !== 3) return false; + var ids = new Set(); + return data.nodes.every(function (node) { + if (!node || ['prod', 'stage', 'db'].indexOf(node.id) < 0 || ids.has(node.id) + || typeof node.label !== 'string' || !node.label.trim() + || ['up', 'down', 'unknown'].indexOf(node.health) < 0 + || (node.collectedAt !== null && !validLanTimestamp(node.collectedAt)) + || (node.cpuPercent !== null && (!Number.isFinite(node.cpuPercent) || node.cpuPercent < 0 || node.cpuPercent > 100)) + || (node.memoryUsedBytes !== null && (!Number.isSafeInteger(node.memoryUsedBytes) || node.memoryUsedBytes < 0)) + || (node.memoryTotalBytes !== null && (!Number.isSafeInteger(node.memoryTotalBytes) || node.memoryTotalBytes <= 0)) + || (node.memoryUsedBytes !== null && node.memoryTotalBytes !== null && node.memoryUsedBytes > node.memoryTotalBytes)) return false; + ids.add(node.id); + return true; + }); + } + function validLanKoin(data) { + if (!data || !validLanTimestamp(data.observedAt) + || !Array.isArray(data.services) || data.services.length !== 2) return false; + var labels = { 'prod-api': 'KOIN API 운영', 'stage-api': 'KOIN API 개발' }; + var ids = new Set(); + return data.services.every(function (service) { + if (!service || !Object.prototype.hasOwnProperty.call(labels, service.id) || ids.has(service.id) + || service.label !== labels[service.id] || !validLanTimestamp(service.checkedAt) + || ['up', 'down', 'unknown'].indexOf(service.status) < 0 + || ['ready', 'not-ready', 'http', 'tls', 'timeout', 'invalid-response', 'unavailable'].indexOf(service.reason) < 0 + || (service.httpStatus !== null && (!Number.isInteger(service.httpStatus) || service.httpStatus < 100 || service.httpStatus > 599)) + || (service.status === 'up' && (service.reason !== 'ready' || service.httpStatus !== 200)) + || (service.status === 'down' && service.reason !== 'not-ready' + && !(service.reason === 'http' && service.httpStatus >= 500)) + || (service.status === 'unknown' && service.reason === 'ready')) return false; + ids.add(service.id); + return true; + }); + } + function lanPublicUrl(value, host, prefix) { + if (typeof value !== 'string' || value.length > 512 || !value.startsWith('https://' + host + prefix)) return null; + try { + var url = new URL(value); + var id = url.pathname.slice(prefix.length); + if (url.protocol !== 'https:' || url.hostname !== host || url.username || url.password + || url.port || url.search || url.hash || !url.pathname.startsWith(prefix) + || !/^[a-zA-Z0-9]{1,128}$/.test(id)) return null; + return url.href; + } catch (_error) { return null; } + } + function validLanWorkers(data) { + if (!data || !validLanTimestamp(data.checkedAt) + || !Array.isArray(data.workers) || data.workers.length !== 1) return false; + var ids = new Set(); + return data.workers.every(function (worker) { + if (!worker || worker.id !== 'koin-web' || ids.has(worker.id) + || typeof worker.label !== 'string' || !worker.label.trim() || worker.label.length > 80 + || ['up', 'down', 'unknown'].indexOf(worker.status) < 0 + || ['http', 'restricted', 'redirect', 'tls', 'timeout', 'unavailable'].indexOf(worker.reason) < 0 + || (worker.httpStatus !== null && (!Number.isInteger(worker.httpStatus) + || worker.httpStatus < 100 || worker.httpStatus > 599))) return false; + ids.add(worker.id); + var code = worker.httpStatus; + if (code === null) return worker.status === 'unknown' + && ['tls', 'timeout', 'unavailable'].indexOf(worker.reason) >= 0; + if (code >= 200 && code < 300) return worker.status === 'up' && worker.reason === 'http'; + if (code >= 500) return worker.status === 'down' && worker.reason === 'http'; + if (worker.status !== 'unknown') return false; + return worker.reason === (code >= 300 && code < 400 ? 'redirect' + : code === 401 || code === 403 ? 'restricted' : 'http'); + }); + } + function lanTimestamp(value) { + return value === null ? '시각 없음' : lanTimestampFormat.format(value) + ' KST'; + } + function lanIsFresh(value, now) { + return value !== null && now - value <= LAN_STALE_MS && now - value >= -5000; + } + function lanPercent(value) { + return value === null ? '—' : value.toLocaleString('ko-KR', { maximumFractionDigits: 1 }) + '%'; + } + function lanMemoryPercent(node) { + return node.memoryUsedBytes === null || node.memoryTotalBytes === null + ? null : node.memoryUsedBytes / node.memoryTotalBytes * 100; + } + function lanBytes(value) { + if (value === null) return '—'; + var gib = value >= 10737418240; + return (value / (gib ? 1073741824 : 1048576)).toLocaleString('ko-KR', { maximumFractionDigits: gib ? 1 : 0 }) + + (gib ? ' GiB' : ' MiB'); + } + function lanHistoryFor(id) { + if (!lanMonitorHistory[id]) lanMonitorHistory[id] = { samples: [], lastAt: null, breakNext: false }; + return lanMonitorHistory[id]; + } + function lanBreakHistory() { + Object.keys(lanMonitorHistory).forEach(function (id) { lanMonitorHistory[id].breakNext = true; }); + } + function lanPruneHistory(history, now) { + while (history.samples.length && (history.samples.length > LAN_HISTORY_LIMIT + || history.samples[0].at < now - LAN_HISTORY_MS)) history.samples.shift(); + } + function rememberLanMonitor() { + var now = Date.now(); + lanMonitorData.nodes.forEach(function (node) { + var history = lanHistoryFor(node.id); + lanPruneHistory(history, now); + if (!lanIsFresh(lanMonitorData.observedAt, now) || !lanIsFresh(node.collectedAt, now)) { + history.breakNext = true; + return; + } + var cpu = node.health === 'up' ? node.cpuPercent : null; + var ram = node.health === 'up' ? lanMemoryPercent(node) : null; + if (history.lastAt !== null && node.collectedAt <= history.lastAt) { + if (cpu === null || ram === null) history.breakNext = true; + return; + } + if (history.samples.length === LAN_HISTORY_LIMIT) history.samples.shift(); + history.samples.push({ + at: node.collectedAt, cpu: cpu, ram: ram, + gapBefore: history.breakNext || (history.lastAt !== null && node.collectedAt - history.lastAt > LAN_GRAPH_GAP_MS) + }); + history.lastAt = node.collectedAt; + history.breakNext = false; + lanPruneHistory(history, now); + }); + } + function lanSvg(tag, attributes) { + var element = document.createElementNS('http://www.w3.org/2000/svg', tag); + Object.keys(attributes).forEach(function (name) { element.setAttribute(name, attributes[name]); }); + return element; + } + function lanDrawGraph(plot, history, now) { + if (plot.drawAt !== undefined && now >= plot.drawAt && now - plot.drawAt < LAN_VIEW_MS + && plot.drawLastAt === history.lastAt && plot.drawCount === history.samples.length) return; + plot.drawAt = now; + plot.drawLastAt = history.lastAt; + plot.drawCount = history.samples.length; + var svg = plot.graph; + svg.replaceChildren(); + svg.appendChild(lanSvg('path', { d: 'M8 12H592 M8 88H592 M8 164H592', class: 'monitor-graph-grid' })); + var firstAt = history.samples.length ? history.samples[0].at : now; + var lastAt = history.samples.length ? history.samples[history.samples.length - 1].at : now; + var span = Math.max(0, lastAt - firstAt); + plot.range.textContent = span >= 60000 ? Math.ceil(span / 60000) + '분 구간' + : span > 0 ? Math.ceil(span / 1000) + '초 구간' : '관측 시작'; + var counts = { cpu: 0, ram: 0 }; + ['cpu', 'ram'].forEach(function (key) { + var path = ''; + var connected = false; + history.samples.forEach(function (sample) { + var value = sample[key]; + if (value === null) { connected = false; return; } + var x = span > 0 ? 8 + Math.max(0, Math.min(1, (sample.at - firstAt) / span)) * 584 : 300; + var y = 164 - value / 100 * 152; + path += (connected && !sample.gapBefore ? 'L' : 'M') + x.toFixed(2) + ' ' + y.toFixed(2) + ' '; + svg.appendChild(lanSvg('circle', { + cx: x.toFixed(2), cy: y.toFixed(2), r: '4', class: 'monitor-graph-dot', 'data-series': key + })); + connected = true; + counts[key] += 1; + }); + if (path) svg.appendChild(lanSvg('path', { d: path, class: 'monitor-graph-line', 'data-series': key })); + }); + svg.setAttribute('aria-label', '실제 관측 구간, 최대 15분, 0에서 100퍼센트. CPU 실측 ' + counts.cpu + '개, RAM 실측 ' + counts.ram + '개. 빈 구간은 미관측.'); + } + function lanMetric(key, label) { + var block = el('div', 'monitor-metric'); + block.dataset.metric = key; + var value = el('strong', 'monitor-value', '—'); + var detail = key === 'ram' ? el('span', 'monitor-metric-detail') : null; + block.append(el('span', 'monitor-metric-label', label), value); + if (detail) block.appendChild(detail); + return { block: block, value: value, detail: detail }; + } + function renderLanMonitor() { + var container = document.getElementById('monitor-cards'); + container.replaceChildren(); + lanMonitorCards = []; + ['prod', 'stage', 'db'].forEach(function (id) { + var node = lanMonitorData.nodes.find(function (entry) { return entry.id === id; }); + var card = el('article', 'panel monitor-node'); + card.dataset.node = id; + var heading = el('div', 'monitor-node-heading'); + var title = el('div', 'monitor-node-title'); + var health = el('span', 'monitor-health'); + title.append(el('h4', '', node.label), health); + var note = el('p', 'monitor-value-note'); + heading.append(title, note); + var metrics = el('div', 'monitor-metrics'); + var cpu = lanMetric('cpu', 'CPU'); + var ram = lanMetric('ram', 'RAM'); + metrics.append(cpu.block, ram.block); + var plotBlock = el('div', 'monitor-plot'); + var graph = lanSvg('svg', { viewBox: '0 0 600 180', preserveAspectRatio: 'none', class: 'monitor-graph', role: 'img' }); + var graphAxis = el('div', 'monitor-graph-axis'); + var graphRange = el('span', '', '관측 시작'); + graphAxis.append(graphRange, el('span', '', '최근 수집')); + plotBlock.append(graph, graphAxis); + var timestamp = el('p', 'monitor-node-time'); + card.append(heading, metrics, plotBlock, timestamp); + container.appendChild(card); + lanMonitorCards.push({ + node: node, health: health, note: note, timestamp: timestamp, cpu: cpu, ram: ram, + plot: { graph: graph, range: graphRange } + }); + }); + } + function renderLanKoin() { + var container = document.getElementById('koin-services'); + container.replaceChildren(); + lanKoinRows = []; + ['prod-api', 'stage-api'].forEach(function (id) { + var service = lanKoinData.services.find(function (entry) { return entry.id === id; }); + var row = el('div', 'koin-service'); + row.dataset.service = id; + var health = el('span', 'monitor-health'); + var detail = el('div', 'koin-service-detail'); + var reason = el('p', 'koin-service-reason'); + var timestamp = el('p', 'koin-service-time'); + detail.append(reason, timestamp); + var heading = el('div', 'koin-service-heading'); + heading.append(el('h3', 'koin-service-name', service.label), health); + row.append(heading, detail); + container.appendChild(row); + lanKoinRows.push({ row: row, service: service, health: health, reason: reason, timestamp: timestamp }); + }); + } + function updateLanPaths(now) { + var internal = document.getElementById('internal-path-status'); + var internalUp = !lanMonitorFailed && lanIsFresh(lanMonitorSucceededAt, now); + internal.dataset.health = internalUp ? 'up' : 'unknown'; + internal.querySelector('span').textContent = internalUp ? '연결 정상' : '연결·인증 확인'; + internal.title = '유효한 /api/monitor 응답 수신 기준 · 노드 상태와 별개' + + (lanMonitorSucceededAt === null ? '' : ' · 수신 ' + lanTimestamp(lanMonitorSucceededAt)); + var data = lanWorkerData; + var age = data ? now - data.checkedAt : Infinity; + var fresh = !lanWorkerFailed && data && age >= -5000 && age <= LAN_WORKER_STALE_MS; + var states = ['koin-web'].map(function (id) { + var worker = fresh ? data.workers.find(function (entry) { return entry.id === id; }) : null; + var state = worker ? worker.status : 'unknown'; + var node = document.getElementById('worker-web-status'); + node.dataset.health = state; + node.querySelector('span').textContent = (state === 'up' ? '정상' : state === 'down' ? '장애' : '확인 불가') + + (worker && worker.httpStatus !== null ? ' · ' + worker.httpStatus : ''); + return state; + }); + document.getElementById('worker-health').dataset.health = states.indexOf('down') >= 0 ? 'down' + : states.every(function (state) { return state === 'up'; }) ? 'up' : 'unknown'; + } + function updateLanMonitorStatus() { + var now = Date.now(); + updateLanPaths(now); + var states = []; + var reasons = { + ready: 'readiness UP', 'not-ready': 'readiness 준비 안 됨', http: 'HTTP 응답', + tls: 'TLS 확인 실패', timeout: '응답 시간 초과', 'invalid-response': '응답 형식 확인 불가', unavailable: '연결 확인 불가' + }; + lanKoinRows.forEach(function (entry) { + var service = entry.service; + var fresh = !lanKoinFailed && lanIsFresh(lanKoinData.observedAt, now) && lanIsFresh(service.checkedAt, now); + var state = fresh ? service.status : 'unknown'; + states.push(state); + entry.row.dataset.health = state; + entry.health.dataset.health = state; + entry.health.textContent = state === 'up' ? '준비됨' : state === 'down' ? '준비 안 됨' : '확인 불가'; + entry.reason.textContent = !fresh ? '최신 상태 확인 불가' + : reasons[service.reason] + (service.httpStatus === null ? '' : ' · HTTP ' + service.httpStatus); + entry.reason.hidden = fresh && service.reason === 'ready'; + entry.timestamp.textContent = '확인 ' + lanTimestamp(service.checkedAt); + }); + if (!lanKoinData) states.push('unknown'); + lanMonitorCards.forEach(function (entry) { + var node = entry.node; + var fresh = !lanMonitorFailed && lanIsFresh(lanMonitorData.observedAt, now) && lanIsFresh(node.collectedAt, now); + var snapshotFresh = !lanMonitorFailed && lanIsFresh(lanMonitorData.observedAt, now); + var cpu = fresh && node.health === 'up' ? node.cpuPercent : null; + var ram = fresh && node.health === 'up' ? lanMemoryPercent(node) : null; + var state = snapshotFresh && node.health === 'down' ? 'down' : !fresh ? 'unknown' + : node.health === 'up' && cpu !== null && ram !== null ? 'up' : 'unknown'; + states.push(state); + entry.health.dataset.health = state; + entry.health.textContent = state === 'up' ? '수집 정상' : state === 'down' ? '수집 실패' : '확인 불가'; + var ageSeconds = node.collectedAt === null ? null : Math.max(0, Math.floor((now - node.collectedAt) / 1000)); + entry.timestamp.textContent = '수집 ' + lanTimestamp(node.collectedAt) + + (ageSeconds === null ? '' : ' · ' + ageSeconds + '초 전') + + (fresh ? '' : ' · 최신 아님'); + entry.note.textContent = !fresh ? '최신 수치 확인 불가' + : cpu === null || ram === null ? '일부 미수집 · —는 확인 불가' : ''; + entry.note.hidden = !entry.note.textContent; + entry.cpu.value.textContent = lanPercent(cpu); + entry.ram.value.textContent = lanPercent(ram); + entry.ram.detail.textContent = ram === null ? '사용 / 전체 확인 불가' + : lanBytes(node.memoryUsedBytes) + ' / ' + lanBytes(node.memoryTotalBytes); + entry.ram.value.title = entry.ram.detail.textContent; + var history = lanHistoryFor(node.id); + if (!fresh) history.breakNext = true; + lanPruneHistory(history, now); + lanDrawGraph(entry.plot, history, now); + }); + if (!lanMonitorData) states.push('unknown'); + var state = states.indexOf('down') >= 0 ? 'down' : states.indexOf('unknown') >= 0 ? 'unknown' : 'up'; + var status = document.getElementById('monitor-status'); + status.dataset.state = state; + status.textContent = state === 'up' ? '관측 정상' + : state === 'down' ? '일부 장애 / 수집 실패' : '일부 상태 확인 불가'; + document.getElementById('monitor-timestamp').textContent = lanMonitorData + ? '서버 관측 ' + lanTimestamp(lanMonitorData.observedAt) : '서버 관측 대기'; + if (!lanKoinData) document.querySelector('#koin-services .monitor-waiting').textContent = lanKoinFailed + ? '상태 확인 불가' : '상태 확인 중'; + if (!lanMonitorData) document.querySelector('#monitor-cards .monitor-waiting').textContent = lanMonitorFailed + ? '서버 조회 실패 · 노드 관측 없음' : '서버 수집 데이터 대기 · 아직 노드 관측 없음'; + } + async function refreshLanMonitor() { + if (!lanIsVisible() || lanMonitorRequest) return; + var controller = new AbortController(); + lanMonitorRequest = controller; + var timeout = setTimeout(function () { controller.abort(); }, 8000); + async function collect(path, validate, accept, fail) { + try { + var response = await fetch(path, { cache: 'no-store', credentials: 'same-origin', signal: controller.signal }); + if (response.status !== 200) throw new Error('Snapshot unavailable'); + var data = await response.json(); + if (!validate(data)) throw new Error('Invalid snapshot'); + if (lanMonitorRequest !== controller || !lanIsVisible()) return; + accept(data); + } catch (_error) { + if (lanMonitorRequest !== controller || !lanIsVisible()) return; + fail(); + } + if (lanMonitorRequest === controller && lanIsVisible()) updateLanMonitorStatus(); + } + try { + await Promise.all([ + collect('/api/monitor', validLanMonitor, function (data) { + lanMonitorData = data; + lanMonitorFailed = false; + lanMonitorSucceededAt = Date.now(); + rememberLanMonitor(); + renderLanMonitor(); + }, function () { lanMonitorFailed = true; lanBreakHistory(); }), + collect('/api/koin-health', validLanKoin, function (data) { + lanKoinData = data; + lanKoinFailed = false; + renderLanKoin(); + }, function () { lanKoinFailed = true; }) + ]); + } finally { + clearTimeout(timeout); + if (lanMonitorRequest === controller) lanMonitorRequest = null; + } + } + async function refreshLanWorkers() { + if (!lanIsVisible() || lanWorkerRequest) return; + var controller = new AbortController(); + lanWorkerRequest = controller; + lanWorkerRequestTimer = setTimeout(function () { controller.abort(); }, 8000); + try { + var response = await fetch('/api/worker-health', { + method: 'GET', cache: 'no-store', credentials: 'omit', signal: controller.signal + }); + if (response.status !== 200) throw new Error('Worker status unavailable'); + var data = await response.json(); + if (!validLanWorkers(data)) throw new Error('Invalid Worker status'); + if (lanWorkerRequest !== controller || !lanIsVisible()) return; + lanWorkerData = data; + lanWorkerFailed = false; + } catch (_error) { + if (lanWorkerRequest !== controller || !lanIsVisible()) return; + lanWorkerData = null; + lanWorkerFailed = true; + } finally { + if (lanWorkerRequest === controller) { + clearTimeout(lanWorkerRequestTimer); + lanWorkerRequestTimer = null; + lanWorkerRequest = null; + if (lanIsVisible()) updateLanPaths(Date.now()); + } + } + } + function renderLanSpotifyCover(key, url) { + if (key === lanSpotifyCoverKey) return; + lanSpotifyCoverKey = key; + lanSpotifyCover.hidden = true; + lanSpotifyCover.onload = null; + lanSpotifyCover.onerror = null; + lanSpotifyCover.removeAttribute('src'); + if (!url) return; + // A new element isolates stale load/error events when tracks change rapidly. + var image = new Image(44, 44); + image.id = 'spotify-cover'; + image.className = 'spotify-cover'; + image.alt = ''; + image.referrerPolicy = 'no-referrer'; + image.decoding = 'async'; + image.hidden = true; + image.onload = function () { + if (lanSpotifyCover === image && lanSpotifyCoverKey === key) image.hidden = false; + }; + image.onerror = function () { + if (lanSpotifyCover !== image || lanSpotifyCoverKey !== key) return; + image.hidden = true; + image.removeAttribute('src'); + }; + lanSpotifyCover.replaceWith(image); + lanSpotifyCover = image; + image.src = url; + } + function renderLanSpotify(data) { + var labels = { + idle: '대기 중', loading: '불러오는 중', playing: '재생 중', + paused: '일시 정지', error: '연결 오류', unknown: '상태 확인 불가' + }; + var state = data && Object.prototype.hasOwnProperty.call(labels, data.state) ? data.state : 'unknown'; + var title = ''; + var artists = ''; + if (state === 'playing' || state === 'paused' || state === 'loading') { + title = typeof data.title === 'string' && data.title.trim() + ? data.title : (state === 'loading' ? '곡 정보를 불러오고 있습니다' : '제목 정보 없음'); + artists = Array.isArray(data.artists) + ? data.artists.filter(function (artist) { return typeof artist === 'string' && artist.trim(); }).join(', ') : ''; + if (!artists) artists = '아티스트 정보 없음'; + } else if (state === 'idle') { + title = '재생 중인 항목 없음'; + artists = ''; + } else if (state === 'error') { + title = '재생 정보를 가져올 수 없습니다'; + artists = ''; + } else { + title = '재생 상태를 확인할 수 없습니다'; + artists = ''; + } + var active = state === 'playing' || state === 'paused' || state === 'loading'; + var cover = active ? lanPublicUrl(data.coverUrl, 'i.scdn.co', '/image/') : null; + // Timestamps alone must not mutate the live region or repeat announcements. + var signature = JSON.stringify([state, title, artists, cover]); + if (signature === lanSpotifySignature) return; + lanSpotifySignature = signature; + lanSpotifyNode.dataset.state = state; + lanSpotifyNode.setAttribute('aria-label', labels[state]); + lanSpotifyTitle.textContent = title; + lanSpotifyArtists.textContent = artists; + renderLanSpotifyCover(active ? JSON.stringify([title, artists, cover]) : null, cover); + } + async function refreshLanSpotify() { + if (!lanIsVisible() || lanSpotifyRequest) return; + var controller = new AbortController(); + lanSpotifyRequest = controller; + lanSpotifyRequestTimer = setTimeout(function () { + if (lanSpotifyRequest === controller) controller.abort(); + }, 5000); + try { + var response = await fetch('/api/spotify', { + cache: 'no-store', credentials: 'same-origin', signal: controller.signal + }); + if (!response.ok) throw new Error('Spotify status unavailable'); + var data = await response.json(); + if (lanSpotifyRequest !== controller || !lanIsVisible()) return; + renderLanSpotify(data); + } catch (_error) { + if (lanSpotifyRequest !== controller || !lanIsVisible()) return; + renderLanSpotify({ state: 'error' }); + } finally { + if (lanSpotifyRequest === controller) { + clearTimeout(lanSpotifyRequestTimer); + lanSpotifyRequestTimer = null; + lanSpotifyRequest = null; + } + } + } + function clearLanJamImage() { + clearTimeout(lanJamImageTimer); + lanJamImageTimer = null; + if (lanJamImage) { + var image = lanJamImage; + lanJamImage = null; + image.onload = null; + image.onerror = null; + image.removeAttribute('src'); + } + if (lanJamFigure) lanJamFigure.remove(); + lanJamFigure = null; + lanJamCodes.classList.remove('has-jam'); + } + function renderLanJam(available) { + lanJamAvailable = available; + if (!available) { clearLanJamImage(); return; } + if (lanJamImage) return; + var figure = document.createElement('figure'); + figure.id = 'spotify-jam'; + figure.className = 'wifi-code'; + var image = new Image(); + image.id = 'spotify-jam-qr'; + image.alt = 'Spotify Jam 참여 QR 코드'; + image.decoding = 'async'; + var caption = document.createElement('figcaption'); + caption.textContent = 'Spotify Jam'; + figure.append(image, caption); + lanJamFigure = figure; + lanJamImage = image; + image.onload = function () { + if (lanJamImage !== image || !lanJamAvailable || !lanIsVisible()) return; + clearTimeout(lanJamImageTimer); + lanJamImageTimer = null; + lanJamCodes.appendChild(figure); + lanJamCodes.classList.add('has-jam'); + }; + image.onerror = function () { + if (lanJamImage !== image) return; + clearLanJamImage(); + }; + lanJamImageTimer = setTimeout(function () { + if (lanJamImage !== image) return; + clearLanJamImage(); + }, 8000); + image.src = '/api/jam-qr'; + } + async function refreshLanJam() { + if (!lanIsVisible() || lanJamRequest) return; + var controller = new AbortController(); + lanJamRequest = controller; + lanJamRequestTimer = setTimeout(function () { + if (lanJamRequest === controller) controller.abort(); + }, 5000); + try { + var response = await fetch('/api/jam', { + cache: 'no-store', credentials: 'same-origin', signal: controller.signal + }); + if (response.status !== 200) throw new Error('Jam status unavailable'); + var data = await response.json(); + if (!data || typeof data.available !== 'boolean') throw new Error('Invalid Jam status'); + if (lanJamRequest !== controller || !lanIsVisible()) return; + renderLanJam(data.available); + } catch (_error) { + if (lanJamRequest !== controller || !lanIsVisible()) return; + renderLanJam(false); + } finally { + if (lanJamRequest === controller) { + clearTimeout(lanJamRequestTimer); + lanJamRequestTimer = null; + lanJamRequest = null; + } + } + } + function refreshLanActiveView() { + refreshLanSpotify(); + refreshLanJam(); + if (lanView === 'monitor') refreshLanMonitor(); + else refresh(); + } + var lanOriginalRefresh = refresh; + refresh = async function () { + if (!lanIsVisible()) return; + if (refreshing) { lanRefreshPending = true; return; } + do { + lanRefreshPending = false; + await lanOriginalRefresh(); + } while (lanRefreshPending && lanIsVisible()); + }; + function stopLanEvents() { + if (lanEvents) { lanEvents.close(); lanEvents = null; } + } + function startLanEvents() { + if (!lanIsVisible() || !window.EventSource) return; + if (lanEvents && lanEvents.readyState !== EventSource.CLOSED) return; + stopLanEvents(); + try { + var source = new EventSource('/api/events'); + lanEvents = source; + lanLastEventAt = Date.now(); + source.addEventListener('state', function () { + if (lanEvents !== source) return; + lanLastEventAt = Date.now(); + refresh(); + }); + source.addEventListener('ping', function () { + if (lanEvents === source) lanLastEventAt = Date.now(); + }); + source.addEventListener('attention', function () { + if (lanEvents !== source || !lanIsVisible()) return; + lanLastEventAt = Date.now(); + attendLanScreen(); + }); + source.addEventListener('greeting', function (event) { + if (lanEvents !== source || !lanIsVisible()) return; + lanLastEventAt = Date.now(); + attendLanScreen(); + try { showLanGreeting(JSON.parse(event.data)); } + catch (_error) { /* Never retain or log event payloads. */ } + }); + } catch (_error) { stopLanEvents(); } + } + function recoverLanState() { + if (!lanIsVisible()) return; + var stalled = lanEvents && Date.now() - lanLastEventAt > POLL_MS; + if (!lanEvents || lanEvents.readyState !== EventSource.OPEN || stalled + || document.getElementById('sync-status').dataset.state === 'error') { + if (stalled) stopLanEvents(); + startLanEvents(); + refresh(); + } + } + function stopLanActivity() { + lanBreakHistory(); + clearInterval(lanJamInterval); + clearTimeout(lanJamRequestTimer); + lanJamInterval = null; + lanJamRequestTimer = null; + if (lanJamRequest) { + var jamRequest = lanJamRequest; + lanJamRequest = null; + jamRequest.abort(); + } + renderLanJam(false); + stopLanEvents(); + clearInterval(lanSpotifyInterval); + clearTimeout(lanSpotifyRequestTimer); + lanSpotifyInterval = null; + lanSpotifyRequestTimer = null; + if (lanSpotifyRequest) { + var spotifyRequest = lanSpotifyRequest; + lanSpotifyRequest = null; + spotifyRequest.abort(); + } + renderLanSpotify({ state: 'unknown' }); + clearTimeout(lanRotationTimer); + clearInterval(lanMonitorInterval); + clearInterval(lanMonitorAgeInterval); + clearInterval(lanRecoveryInterval); + clearInterval(lanWorkerInterval); + clearTimeout(lanWorkerRequestTimer); + lanWorkerInterval = null; + lanWorkerRequestTimer = null; + if (lanWorkerRequest) { + var workerRequest = lanWorkerRequest; + lanWorkerRequest = null; + workerRequest.abort(); + } + lanWorkerData = null; + lanWorkerFailed = true; + updateLanPaths(Date.now()); + lanRotationTimer = null; + lanMonitorInterval = null; + lanMonitorAgeInterval = null; + lanRecoveryInterval = null; + if (lanMonitorRequest) { + var request = lanMonitorRequest; + lanMonitorRequest = null; + request.abort(); + } + lanRefreshPending = false; + closeLanGreeting(true); + if (fitFrame) { cancelAnimationFrame(fitFrame); fitFrame = 0; } + } + function startLanActivity() { + if (!lanIsVisible() || lanMonitorInterval !== null) return; + attendLanScreen(); + startLanEvents(); + refresh(); + refreshLanMonitor(); + refreshLanWorkers(); + refreshLanSpotify(); + lanSpotifyInterval = setInterval(refreshLanSpotify, LAN_SPOTIFY_POLL_MS); + refreshLanJam(); + lanJamInterval = setInterval(refreshLanJam, LAN_JAM_POLL_MS); + lanWorkerInterval = setInterval(refreshLanWorkers, LAN_WORKER_POLL_MS); + lanMonitorInterval = setInterval(refreshLanMonitor, LAN_VIEW_MS); + lanMonitorAgeInterval = setInterval(updateLanMonitorStatus, 1000); + lanRecoveryInterval = setInterval(recoverLanState, POLL_MS); + } + document.addEventListener('visibilitychange', function () { + if (document.hidden) stopLanActivity(); + else startLanActivity(); + }); + window.addEventListener('pagehide', function () { + lanPageHidden = true; + stopLanActivity(); + }); + window.addEventListener('pageshow', function () { + lanPageHidden = false; + startLanActivity(); + }); + async function checkSession() { + authActive = true; + authRevision += 1; + authDeadline = Infinity; + authExpiresAt = Infinity; + authClockOffset = 0; + document.getElementById('login-panel').hidden = true; + document.getElementById('main').hidden = false; + document.getElementById('logout').hidden = true; + document.getElementById('refresh').hidden = false; + document.getElementById('viewer-name').textContent = '임시 LAN 운영 · 로그인 없음'; + document.getElementById('viewer-name').hidden = false; + loadWifiImages(true); + startLanActivity(); + return true; + } + `; +const html = (DASHBOARD_HTML.slice(0, start) + bootstrap + DASHBOARD_HTML.slice(end)) + .replace(pollMarker, '') + .replace(manualRefreshMarker, "document.getElementById('refresh').addEventListener('click', refreshLanActiveView);") + .replace( + fitMarker, + "function fitWall() {\n if (typeof lanView !== 'undefined' && (lanView !== 'attendance' || document.hidden || lanPageHidden)) return;", + ) + .replace(headerActionsMarker, spotifyMarkup + headerActionsMarker) + .replace('', '' + monitorMarkup) + .replace('', greetingStyles + monitorStyles + spotifyStyles + jamStyles + '') + .replace(' diff --git a/apps/attendance/src/html.d.ts b/apps/attendance/src/html.d.ts new file mode 100644 index 0000000..0e9583d --- /dev/null +++ b/apps/attendance/src/html.d.ts @@ -0,0 +1,4 @@ +declare module '*.html' { + const html: string; + export default html; +} diff --git a/apps/attendance/src/index.ts b/apps/attendance/src/index.ts index 359b5ff..dcdf3fa 100644 --- a/apps/attendance/src/index.ts +++ b/apps/attendance/src/index.ts @@ -1,271 +1,4 @@ -import { DAY_MS, decideTap, isUnrecorded, kstDateKey, kstWeekStart, summarize, type SessionRow } from './logic'; -import { DASHBOARD_HTML } from './dashboard'; +import html from '../.wrangler/viewer/dashboard.html'; +import { createViewer } from './viewer.ts'; -interface Env { - /** - * 키 구성 (값은 비어 있고 데이터는 모두 메타데이터에 두어 list 한 번으로 읽습니다): - * - `member:` 메타 {name} - * - `unknown:` 메타 {firstSeen, lastSeen, count} - * - `s::<입실 시각 13자리>:` 메타 {checkOut, autoClosed} — 세션 - * - `latest:` 값 JSON {checkIn, checkOut, autoClosed} — 회원의 최근 세션 포인터 - */ - ATTENDANCE: KVNamespace; - /** 라즈베리파이가 태그 이벤트를 보낼 때 쓰는 Bearer 토큰. */ - DEVICE_TOKEN: string; - /** 대시보드·회원 등록용 Basic 인증 비밀번호(사용자 이름은 무시). */ - DASHBOARD_PASSWORD: string; -} - -interface MemberMeta { - name: string; -} - -interface UnknownMeta { - firstSeen: number; - lastSeen: number; - count: number; -} - -interface SessionMeta { - checkOut: number | null; - autoClosed: boolean; -} - -interface LatestSession extends SessionMeta { - checkIn: number; -} - -const UID_PATTERN = /^[0-9A-Z]{4,32}$/; -const MAX_CLOCK_SKEW_MS = 60_000; -const MAX_OFFLINE_REPLAY_MS = 7 * DAY_MS; -const RECENT_LIMIT = 15; -const UNKNOWN_TTL_SECONDS = 7 * 24 * 3600; -const SECURITY_HEADERS = { - 'cache-control': 'no-store', - 'x-content-type-options': 'nosniff', - 'referrer-policy': 'no-referrer', -}; -const UNAUTHORIZED = { - status: 401, - headers: { ...SECURITY_HEADERS, 'www-authenticate': 'Basic realm="BCSD attendance", charset="UTF-8"' }, -} as const; -const encoder = new TextEncoder(); - -function json(body: unknown, status = 200, headers: HeadersInit = {}): Response { - return Response.json(body, { status, headers: { ...SECURITY_HEADERS, ...headers } }); -} - -/** 길이·내용 모두 시간 차이 없이 비교하도록 SHA-256 다이제스트끼리 비교합니다. */ -async function safeEqual(actual: string, expected: string): Promise { - if (!expected) return false; - const [a, b] = await Promise.all([ - crypto.subtle.digest('SHA-256', encoder.encode(actual)), - crypto.subtle.digest('SHA-256', encoder.encode(expected)), - ]); - return crypto.subtle.timingSafeEqual(a, b); -} - -async function deviceAuthorized(request: Request, env: Env): Promise { - const header = request.headers.get('authorization') ?? ''; - return header.startsWith('Bearer ') && safeEqual(header.slice(7), env.DEVICE_TOKEN); -} - -async function dashboardAuthorized(request: Request, env: Env): Promise { - const header = request.headers.get('authorization') ?? ''; - if (!header.startsWith('Basic ')) return false; - let decoded: string; - try { - decoded = atob(header.slice(6)); - } catch { - return false; - } - const separator = decoded.indexOf(':'); - return separator >= 0 && safeEqual(decoded.slice(separator + 1), env.DASHBOARD_PASSWORD); -} - -/** 브라우저가 Basic 자격증명을 자동 첨부하므로 mutation은 같은 출처의 JSON 요청만 받습니다. */ -function sameOriginJson(request: Request, url: URL): boolean { - const origin = request.headers.get('origin'); - if (origin !== null && origin !== url.origin) return false; - return (request.headers.get('content-type') ?? '').toLowerCase().startsWith('application/json'); -} - -async function readJson(request: Request): Promise | null> { - try { - const body: unknown = await request.json(); - return body !== null && typeof body === 'object' && !Array.isArray(body) ? (body as Record) : null; - } catch { - return null; - } -} - -function sessionKey(uid: string, checkIn: number): string { - return `s:${kstDateKey(checkIn)}:${String(checkIn).padStart(13, '0')}:${uid}`; -} - -/** 접두사 아래 모든 키를 메타데이터와 함께 읽습니다(list는 페이지당 최대 1000개). */ -async function listAll(kv: KVNamespace, prefix: string): Promise<{ name: string; metadata: TMeta }[]> { - const result: { name: string; metadata: TMeta }[] = []; - let cursor: string | undefined; - do { - const page = await kv.list({ prefix, cursor }); - for (const key of page.keys) if (key.metadata) result.push({ name: key.name, metadata: key.metadata }); - cursor = page.list_complete ? undefined : page.cursor; - } while (cursor); - return result; -} - -async function handleTap(request: Request, env: Env): Promise { - if (!(await deviceAuthorized(request, env))) return json({ error: 'unauthorized' }, 401); - const body = await readJson(request); - if (!body || typeof body.uid !== 'string') return json({ error: 'invalid_body' }, 400); - - const uid = body.uid.trim().toUpperCase(); - if (!UID_PATTERN.test(uid)) return json({ error: 'invalid_uid' }, 400); - - const now = Date.now(); - const at = body.at === undefined ? now : body.at; - if ( - typeof at !== 'number' || - !Number.isSafeInteger(at) || - at > now + MAX_CLOCK_SKEW_MS || - at < now - MAX_OFFLINE_REPLAY_MS - ) { - return json({ error: 'invalid_time' }, 400); - } - - const kv = env.ATTENDANCE; - const [{ metadata: member }, latest] = await Promise.all([ - kv.getWithMetadata(`member:${uid}`), - kv.get(`latest:${uid}`, 'json'), - ]); - if (!member) { - const { metadata: seen } = await kv.getWithMetadata(`unknown:${uid}`); - const next: UnknownMeta = { - firstSeen: seen?.firstSeen ?? at, - lastSeen: Math.max(seen?.lastSeen ?? at, at), - count: (seen?.count ?? 0) + 1, - }; - // 등록 전 카드 UID는 개인정보이므로 일정 기간 뒤 자동으로 지웁니다. - await kv.put(`unknown:${uid}`, '', { metadata: next, expirationTtl: UNKNOWN_TTL_SECONDS }); - return json({ status: 'unknown' }, 202); - } - - const action = decideTap(latest ? { memberId: uid, ...latest } : null, at); - if (action.kind === 'ignore') return json({ status: 'ignored', name: member.name }); - - const open = (checkIn: number) => - kv.put(sessionKey(uid, checkIn), '', { metadata: { checkOut: null, autoClosed: false } satisfies SessionMeta }); - const close = (checkIn: number, checkOut: number, autoClosed: boolean) => - kv.put(sessionKey(uid, checkIn), '', { metadata: { checkOut, autoClosed } satisfies SessionMeta }); - // 세션을 먼저 쓰고 포인터를 마지막에 갱신합니다. 중간에 실패하면 포인터가 옛 값이라 다음 태그가 다시 판단합니다. - const point = (value: LatestSession) => kv.put(`latest:${uid}`, JSON.stringify(value)); - - switch (action.kind) { - case 'check-in': - await open(at); - await point({ checkIn: at, checkOut: null, autoClosed: false }); - return json({ status: 'check-in', name: member.name, at }); - case 'check-out': - await close(latest!.checkIn, at, false); - await point({ checkIn: latest!.checkIn, checkOut: at, autoClosed: false }); - return json({ status: 'check-out', name: member.name, at, durationMs: at - latest!.checkIn }); - case 'expire-and-check-in': - await close(latest!.checkIn, action.closeAt, true); - await open(at); - await point({ checkIn: at, checkOut: null, autoClosed: false }); - return json({ status: 'check-in', name: member.name, at, previousAutoClosed: true }); - } -} - -async function handleState(env: Env): Promise { - const now = Date.now(); - // 이번 주와, 자정을 걸쳐 주 경계 전날에 시작한 세션(최대 길이 6시간)까지 날짜별 접두사로 읽습니다. - const days: string[] = []; - for (let day = kstWeekStart(now) - DAY_MS; day <= now; day += DAY_MS) days.push(kstDateKey(day)); - const kv = env.ATTENDANCE; - const [members, unknown, ...sessionDays] = await Promise.all([ - listAll(kv, 'member:'), - listAll(kv, 'unknown:'), - ...days.map((day) => listAll(kv, `s:${day}:`)), - ]); - - const names = new Map(members.map((member) => [member.name.slice('member:'.length), member.metadata.name])); - const sessions: SessionRow[] = sessionDays.flat().map(({ name, metadata }) => { - const [, , checkIn, uid] = name.split(':'); - return { memberId: uid, checkIn: Number(checkIn), checkOut: metadata.checkOut, autoClosed: metadata.autoClosed }; - }); - const recent = sessions - .filter((session) => names.has(session.memberId)) - .sort((a, b) => b.checkIn - a.checkIn) - .slice(0, RECENT_LIMIT) - .map((session) => ({ - name: names.get(session.memberId), - checkIn: session.checkIn, - checkOut: session.checkOut, - // 퇴근 태그가 없어 시간 합계에서 빠진 세션(자동 종료 또는 최대 길이 초과). - unrecorded: isUnrecorded(session, now), - })); - - return json({ - now, - // 카드 UID는 화면에 필요 없으므로 내보내지 않습니다. - members: summarize( - members.map((member) => ({ id: member.name.slice('member:'.length), name: member.metadata.name })), - sessions, - now, - ).map(({ id: _id, ...summary }) => summary), - unknownCards: unknown - .map(({ name, metadata }) => ({ uid: name.slice('unknown:'.length), ...metadata })) - .sort((a, b) => b.lastSeen - a.lastSeen) - .slice(0, 20), - recent, - }); -} - -async function handleMember(request: Request, url: URL, env: Env): Promise { - if (!sameOriginJson(request, url)) return json({ error: 'invalid_request' }, 400); - const body = await readJson(request); - if (!body || typeof body.uid !== 'string' || typeof body.name !== 'string') - return json({ error: 'invalid_body' }, 400); - - const uid = body.uid.trim().toUpperCase(); - const name = body.name.trim(); - if (!UID_PATTERN.test(uid)) return json({ error: 'invalid_uid' }, 400); - if (name.length < 1 || name.length > 30) return json({ error: 'invalid_name' }, 400); - - // 같은 카드로 다시 등록하면 이름만 고칩니다. - await env.ATTENDANCE.put(`member:${uid}`, '', { metadata: { name } satisfies MemberMeta }); - await env.ATTENDANCE.delete(`unknown:${uid}`); - return json({ status: 'saved' }); -} - -export default { - async fetch(request: Request, env: Env): Promise { - const url = new URL(request.url); - - if (url.pathname === '/api/taps') { - return request.method === 'POST' - ? handleTap(request, env) - : json({ error: 'method_not_allowed' }, 405, { allow: 'POST' }); - } - - const isDashboardRoute = url.pathname === '/' || url.pathname === '/api/state' || url.pathname === '/api/members'; - if (!isDashboardRoute) return json({ error: 'not_found' }, 404); - if (!(await dashboardAuthorized(request, env))) return new Response('Unauthorized', UNAUTHORIZED); - - if (url.pathname === '/' && request.method === 'GET') { - return new Response(DASHBOARD_HTML, { - headers: { - ...SECURITY_HEADERS, - 'content-type': 'text/html; charset=utf-8', - 'content-security-policy': - "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", - }, - }); - } - if (url.pathname === '/api/state' && request.method === 'GET') return handleState(env); - if (url.pathname === '/api/members' && request.method === 'POST') return handleMember(request, url, env); - return json({ error: 'method_not_allowed' }, 405); - }, -} satisfies ExportedHandler; +export default createViewer(html); diff --git a/apps/attendance/src/logic.ts b/apps/attendance/src/logic.ts deleted file mode 100644 index d4240c7..0000000 --- a/apps/attendance/src/logic.ts +++ /dev/null @@ -1,137 +0,0 @@ -export const HOUR_MS = 3_600_000; -export const DAY_MS = 24 * HOUR_MS; -/** 한국 표준시는 DST가 없으므로 고정 오프셋으로 하루·주 경계를 계산합니다. */ -export const KST_OFFSET_MS = 9 * HOUR_MS; -/** 퇴실 태그를 잊은 세션이 무한정 늘어나지 않도록 하는 세션 최대 길이입니다. */ -export const MAX_SESSION_MS = 6 * HOUR_MS; -/** 같은 회원의 직전 이벤트 직후 재태그(카드 중복 인식)를 무시하는 시간입니다. */ -export const DEBOUNCE_MS = 10_000; - -export interface SessionRow { - /** 회원 식별자는 카드 UID입니다. */ - memberId: string; - checkIn: number; - checkOut: number | null; - autoClosed: boolean; -} - -export type TapAction = - | { kind: 'ignore' } - | { kind: 'check-in' } - | { kind: 'check-out' } - /** 미퇴실로 남은 세션을 `closeAt`에 자동 종료하고 새 입실을 시작합니다. */ - | { kind: 'expire-and-check-in'; closeAt: number }; - -/** - * 회원의 가장 최근 세션과 태그 시각으로 입실/퇴실을 정합니다. - * 태그는 토글이며, 오프라인 재전송으로 직전 이벤트보다 이르거나 디바운스 안에 든 태그는 버립니다. - */ -export function decideTap(latest: SessionRow | null, at: number): TapAction { - if (!latest) return { kind: 'check-in' }; - const lastEventAt = latest.checkOut ?? latest.checkIn; - if (at - lastEventAt < DEBOUNCE_MS) return { kind: 'ignore' }; - if (latest.checkOut !== null) return { kind: 'check-in' }; - if (at - latest.checkIn >= MAX_SESSION_MS) { - return { kind: 'expire-and-check-in', closeAt: latest.checkIn + MAX_SESSION_MS }; - } - return { kind: 'check-out' }; -} - -/** `ts`가 속한 KST 날짜의 00:00(UTC epoch ms)입니다. */ -export function kstDayStart(ts: number): number { - return Math.floor((ts + KST_OFFSET_MS) / DAY_MS) * DAY_MS - KST_OFFSET_MS; -} - -/** `ts`가 속한 KST 주(월요일 시작)의 시작입니다. */ -export function kstWeekStart(ts: number): number { - const dayStart = kstDayStart(ts); - // 1970-01-01(목)을 기준으로 월요일=0이 되도록 3일을 더합니다. - const daysSinceMonday = (Math.floor((dayStart + KST_OFFSET_MS) / DAY_MS) + 3) % 7; - return dayStart - daysSinceMonday * DAY_MS; -} - -export function isPresent(session: SessionRow, now: number): boolean { - return session.checkOut === null && now - session.checkIn < MAX_SESSION_MS; -} - -/** - * 퇴근 태그 없이 자동 종료됐거나 최대 길이를 넘겨 열려 있는 세션입니다. - * 실제 체류 시간을 알 수 없으므로 시간 합계에 넣지 않습니다. - */ -export function isUnrecorded(session: SessionRow, now: number): boolean { - return session.autoClosed || (session.checkOut === null && now - session.checkIn >= MAX_SESSION_MS); -} - -function overlapMs(session: SessionRow, from: number, to: number, now: number): number { - if (isUnrecorded(session, now)) return 0; - const start = Math.max(session.checkIn, from); - const end = Math.min(session.checkOut ?? now, to); - return Math.max(0, end - start); -} - -export interface MemberSummary { - id: string; - name: string; - present: boolean; - /** 현재 입실 중일 때 입실 시각. */ - since: number | null; - todayMs: number; - weekMs: number; - /** 이번 주 퇴근 태그가 없어 합계에서 뺀 세션 수. */ - unrecordedWeek: number; - /** 오늘 처음 입실한 시각. 오늘 기록이 없으면 null. */ - firstInToday: number | null; -} - -export function summarize( - members: { id: string; name: string }[], - sessions: SessionRow[], - now: number, -): MemberSummary[] { - const dayStart = kstDayStart(now); - const weekStart = kstWeekStart(now); - const byMember = new Map(); - for (const session of sessions) { - const list = byMember.get(session.memberId); - if (list) list.push(session); - else byMember.set(session.memberId, [session]); - } - - const result = members.map((member): MemberSummary => { - let todayMs = 0; - let weekMs = 0; - let unrecordedWeek = 0; - let since: number | null = null; - let firstInToday: number | null = null; - for (const session of byMember.get(member.id) ?? []) { - todayMs += overlapMs(session, dayStart, dayStart + DAY_MS, now); - weekMs += overlapMs(session, weekStart, weekStart + 7 * DAY_MS, now); - if (session.checkIn >= weekStart && isUnrecorded(session, now)) unrecordedWeek++; - if (isPresent(session, now)) since = session.checkIn; - if (session.checkIn >= dayStart && (firstInToday === null || session.checkIn < firstInToday)) { - firstInToday = session.checkIn; - } - } - return { - id: member.id, - name: member.name, - present: since !== null, - since, - todayMs, - weekMs, - unrecordedWeek, - firstInToday, - }; - }); - - return result.sort((a, b) => { - if (a.present !== b.present) return a.present ? -1 : 1; - if (a.present && b.present) return (a.since ?? 0) - (b.since ?? 0); - return b.weekMs - a.weekMs || a.name.localeCompare(b.name, 'ko'); - }); -} - -/** KV 키 접두사에 쓰는 KST 날짜(YYYYMMDD)입니다. */ -export function kstDateKey(ts: number): string { - return new Date(ts + KST_OFFSET_MS).toISOString().slice(0, 10).replaceAll('-', ''); -} diff --git a/apps/attendance/src/sun.ts b/apps/attendance/src/sun.ts new file mode 100644 index 0000000..390fce7 --- /dev/null +++ b/apps/attendance/src/sun.ts @@ -0,0 +1,57 @@ +/** Browser-side NOAA sunrise/sunset calculation for Byeongcheon-myeon. */ +export const solarThemeScript: string = ` +function isByeongcheonDay(now) { + const latitude = 36.76; + const longitude = 127.30; + const radians = Math.PI / 180; + const degrees = 180 / Math.PI; + const dayMs = 86400000; + + // Get the Korean calendar date without depending on the viewer's timezone. + const koreanDate = new Date(now.getTime() + 9 * 60 * 60 * 1000); + const midnightUtc = Date.UTC(koreanDate.getUTCFullYear(), koreanDate.getUTCMonth(), koreanDate.getUTCDate()); + const julianDay = midnightUtc / dayMs + 2440587.5; + + function solarPosition(jd) { + const t = (jd - 2451545) / 36525; + const meanLongitude = ((280.46646 + t * (36000.76983 + t * 0.0003032)) % 360 + 360) % 360; + const meanAnomaly = 357.52911 + t * (35999.05029 - 0.0001537 * t); + const eccentricity = 0.016708634 - t * (0.000042037 + 0.0000001267 * t); + const center = Math.sin(meanAnomaly * radians) * (1.914602 - t * (0.004817 + 0.000014 * t)) + + Math.sin(2 * meanAnomaly * radians) * (0.019993 - 0.000101 * t) + + Math.sin(3 * meanAnomaly * radians) * 0.000289; + const omega = 125.04 - 1934.136 * t; + const apparentLongitude = meanLongitude + center - 0.00569 - 0.00478 * Math.sin(omega * radians); + const obliquity = 23 + (26 + (21.448 - t * (46.815 + t * (0.00059 - t * 0.001813))) / 60) / 60 + + 0.00256 * Math.cos(omega * radians); + const declination = Math.asin(Math.sin(obliquity * radians) * Math.sin(apparentLongitude * radians)); + const y = Math.tan(obliquity * radians / 2) ** 2; + const equationOfTime = 4 * degrees * (y * Math.sin(2 * meanLongitude * radians) + - 2 * eccentricity * Math.sin(meanAnomaly * radians) + + 4 * eccentricity * y * Math.sin(meanAnomaly * radians) * Math.cos(2 * meanLongitude * radians) + - 0.5 * y * y * Math.sin(4 * meanLongitude * radians) + - 1.25 * eccentricity * eccentricity * Math.sin(2 * meanAnomaly * radians)); + return { declination, equationOfTime }; + } + + // The 90.833-degree zenith accounts for atmospheric refraction and the solar disc. + function solarEvent(isSunrise) { + let minutes = 720 - 4 * longitude; + for (let i = 0; i < 3; i++) { + const position = solarPosition(julianDay + minutes / 1440); + const cosineHourAngle = (Math.cos(90.833 * radians) - Math.sin(latitude * radians) * Math.sin(position.declination)) + / (Math.cos(latitude * radians) * Math.cos(position.declination)); + // These cases cannot occur at Byeongcheon, but avoid an invalid acos at the limits. + if (cosineHourAngle > 1) return null; + if (cosineHourAngle < -1) return null; + const hourAngle = Math.acos(cosineHourAngle) * degrees; + minutes = 720 - 4 * (longitude + (isSunrise ? hourAngle : -hourAngle)) - position.equationOfTime; + } + return midnightUtc + minutes * 60000; + } + + const sunrise = solarEvent(true); + const sunset = solarEvent(false); + return sunrise !== null && sunset !== null && now.getTime() >= sunrise && now.getTime() < sunset; +} +`; diff --git a/apps/attendance/src/viewer.ts b/apps/attendance/src/viewer.ts new file mode 100644 index 0000000..96386dc --- /dev/null +++ b/apps/attendance/src/viewer.ts @@ -0,0 +1,327 @@ +export interface Env { + /** 별도 원격 조회 스냅샷이며 출입 원장 KV를 사용하지 않는다. */ + VIEW_STATE?: KVNamespace; + CF_ACCESS_AUD?: string; +} + +const SNAPSHOT_KEY = 'clubroom:view:v1'; +const MAX_AGE_MS = 300_000; +const MAX_SNAPSHOT_BYTES = 2_097_152; +const MAX_PNG_BYTES = 262_144; +const STREAM_MS = 60_000; +const STREAM_POLL_MS = 10_000; +const JSON_PATHS = [ + '/api/state', + '/api/monitor', + '/api/koin-health', + '/api/worker-health', + '/api/spotify', + '/api/jam', +] as const; +const ASSET_PATHS: Record = { + '/api/wifi-qr/2.4': true, + '/api/wifi-qr/5': true, + '/api/jam-qr': true, +}; +const READ_PATHS: Record = { + '/': true, + '/health': true, + '/api/events': true, + '/api/view-status': true, + '/api/state': true, + '/api/monitor': true, + '/api/koin-health': true, + '/api/worker-health': true, + '/api/spotify': true, + '/api/jam': true, + '/api/wifi-qr/2.4': true, + '/api/wifi-qr/5': true, + '/api/jam-qr': true, +}; +const CSP = + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data: https://i.scdn.co https://mosaic.scdn.co; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; +const encoder = new TextEncoder(); +const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }); + +type JsonObject = Record; +interface Payload { + status: number; + body: JsonObject; +} +interface Snapshot { + version: 1; + observedAt: number; + publishedAt: number; + payloads: Record; + assets: Record; +} +type SnapshotResult = + | { snapshot: Snapshot; error?: undefined } + | { + snapshot?: Snapshot; + error: 'snapshot_missing' | 'snapshot_malformed' | 'snapshot_unavailable' | 'snapshot_stale'; + }; + +function record(value: unknown): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function validPng(png: unknown): png is string { + if ( + typeof png !== 'string' || + png.length < 76 || + png.length > Math.ceil(MAX_PNG_BYTES / 3) * 4 || + !png.startsWith('iVBORw0KGgo') || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(png) + ) + return false; + const size = (png.length / 4) * 3 - (png.endsWith('==') ? 2 : png.endsWith('=') ? 1 : 0); + if (size > MAX_PNG_BYTES) return false; + // 전체 이미지를 복사하지 않고 제한된 PNG 헤더·트레일러만 검사한다. + const head = atob(png.slice(0, 44)); + const tail = atob(png.slice(-20)); + if ( + head.slice(8, 16) !== '\x00\x00\x00\x0dIHDR' || + head.charCodeAt(16) !== 0 || + head.charCodeAt(17) !== 0 || + head.charCodeAt(20) !== 0 || + head.charCodeAt(21) !== 0 || + head.charCodeAt(26) !== 0 || + head.charCodeAt(27) !== 0 || + head.charCodeAt(28) > 1 || + !tail.endsWith('\x00\x00\x00\x00IEND\xae\x42\x60\x82') + ) + return false; + const width = head.charCodeAt(18) * 256 + head.charCodeAt(19); + const height = head.charCodeAt(22) * 256 + head.charCodeAt(23); + return width > 0 && width <= 2048 && height > 0 && height <= 2048; +} + +function parseSnapshot(raw: ArrayBuffer, now: number): Snapshot | undefined { + if (raw.byteLength > MAX_SNAPSHOT_BYTES) return; + const value: unknown = JSON.parse(decoder.decode(raw)); + if ( + !record(value) || + value.version !== 1 || + Object.keys(value).length !== 5 || + !Number.isSafeInteger(value.observedAt) || + !Number.isSafeInteger(value.publishedAt) || + (value.observedAt as number) <= 0 || + (value.publishedAt as number) < (value.observedAt as number) || + (value.publishedAt as number) > now || + !record(value.payloads) || + !record(value.assets) + ) + return; + const payloads = value.payloads; + if (Object.keys(payloads).length !== JSON_PATHS.length) return; + for (const path of JSON_PATHS) { + const payload = payloads[path]; + if ( + !record(payload) || + Object.keys(payload).length !== 2 || + !Number.isInteger(payload.status) || + (payload.status as number) < 200 || + (payload.status as number) > 599 || + ((payload.status as number) >= 300 && (payload.status as number) < 400) || + payload.status === 204 || + payload.status === 205 || + !record(payload.body) + ) + return; + } + const assets = value.assets; + if (typeof assets['/api/wifi-qr/2.4'] !== 'string' || typeof assets['/api/wifi-qr/5'] !== 'string') return; + for (const path in assets) { + const png = assets[path]; + if (!Object.hasOwn(ASSET_PATHS, path) || !validPng(png)) return; + } + return value as unknown as Snapshot; +} + +async function loadSnapshot(env: Env): Promise { + let raw: ArrayBuffer | null; + try { + raw = await env.VIEW_STATE!.get(SNAPSHOT_KEY, 'arrayBuffer'); + } catch { + return { error: 'snapshot_unavailable' }; + } + if (raw === null) return { error: 'snapshot_missing' }; + try { + const now = Date.now(); + const snapshot = parseSnapshot(raw, now); + if (!snapshot) return { error: 'snapshot_malformed' }; + if (now - snapshot.observedAt > MAX_AGE_MS) return { snapshot, error: 'snapshot_stale' }; + return { snapshot }; + } catch { + return { error: 'snapshot_malformed' }; + } +} + +function headers(contentType: string, snapshot?: Snapshot): Headers { + const result = new Headers({ + 'content-type': contentType, + 'cache-control': 'private, no-store', + 'content-security-policy': CSP, + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', + 'referrer-policy': 'no-referrer', + 'permissions-policy': 'camera=(), microphone=(), geolocation=()', + 'x-bcsd-view-mode': 'eventually-consistent-snapshot', + }); + if (snapshot) { + result.set('x-bcsd-snapshot-observed-at', String(snapshot.observedAt)); + result.set('x-bcsd-snapshot-published-at', String(snapshot.publishedAt)); + result.set('x-bcsd-snapshot-age-ms', String(Date.now() - snapshot.observedAt)); + } + return result; +} + +function failure(status: number, error: string, snapshot?: Snapshot, allow?: string): Response { + const responseHeaders = headers('application/json; charset=utf-8', snapshot); + if (allow) responseHeaders.set('allow', allow); + return new Response(JSON.stringify({ error, mode: 'hosted_snapshot' }), { status, headers: responseHeaders }); +} + +function events(request: Request, env: Env, initial: Snapshot, deadline: number): Response { + let ended = false; + let poll: ReturnType; + let lifetime: ReturnType; + let output: ReadableStreamDefaultController; + let version = `${initial.observedAt}:${initial.publishedAt}`; + const cleanup = () => { + clearTimeout(poll); + clearTimeout(lifetime); + request.signal.removeEventListener('abort', clientLeft); + }; + const stop = (close: boolean) => { + if (ended) return; + ended = true; + cleanup(); + if (close) output.close(); + }; + const clientLeft = () => stop(true); + const emit = (event: string, body: unknown) => + output.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(body)}\n\n`)); + const check = async () => { + if (ended) return; + const result = await loadSnapshot(env); + if (ended) return; + if (Date.now() >= deadline) { + stop(true); + return; + } + if (result.error) { + emit('unavailable', { error: result.error }); + stop(true); + return; + } + const next = `${result.snapshot.observedAt}:${result.snapshot.publishedAt}`; + if (next !== version) { + version = next; + emit('state', { version }); + } else { + emit('ping', {}); + } + poll = setTimeout(() => { + void check(); + }, STREAM_POLL_MS); + }; + const body = new ReadableStream({ + start(controller) { + output = controller; + request.signal.addEventListener('abort', clientLeft, { once: true }); + if (request.signal.aborted) { + stop(true); + return; + } + emit('state', { version }); + poll = setTimeout(() => { + void check(); + }, STREAM_POLL_MS); + lifetime = setTimeout(() => stop(true), Math.max(0, deadline - Date.now())); + }, + cancel() { + stop(false); + }, + }); + const responseHeaders = headers('text/event-stream; charset=utf-8', initial); + responseHeaders.set('x-accel-buffering', 'no'); + return new Response(body, { headers: responseHeaders }); +} + +/** 기존 LAN 템플릿으로 빌드한 HTML이며 Worker Static Assets는 사용하지 않는다. */ +export function createViewer(html: string) { + return { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const deadline = Date.now() + STREAM_MS; + const url = new URL(request.url); + // 카메라·관리자·인증·쓰기 경로는 원격으로 중계하지 않는다. + if (!Object.hasOwn(READ_PATHS, url.pathname)) return failure(404, 'not_found'); + if (request.method !== 'GET' && !(url.pathname === '/' && request.method === 'HEAD')) { + return failure(405, 'method_not_allowed', undefined, url.pathname === '/' ? 'GET, HEAD' : 'GET'); + } + if (request.url.includes('?') || url.hash) return failure(400, 'query_not_allowed'); + if ( + typeof env.CF_ACCESS_AUD !== 'string' || + !env.CF_ACCESS_AUD || + env.CF_ACCESS_AUD.length > 256 || + env.CF_ACCESS_AUD.trim() !== env.CF_ACCESS_AUD + ) { + return failure(503, 'private_view_unconfigured'); + } + // 플랫폼 인증만 신뢰하고 클라이언트 헤더·쿠키를 권한으로 해석하지 않는다. + if (!ctx?.access || ctx.access.aud !== env.CF_ACCESS_AUD) return failure(403, 'access_required'); + if (request.signal.aborted) return failure(499, 'client_closed'); + if (url.pathname === '/') { + return new Response(request.method === 'HEAD' ? null : html, { headers: headers('text/html; charset=utf-8') }); + } + const result = await loadSnapshot(env); + if (url.pathname === '/api/view-status') { + const snapshot = result.snapshot; + return new Response( + JSON.stringify({ + available: !result.error, + observedAt: snapshot?.observedAt ?? null, + publishedAt: snapshot?.publishedAt ?? null, + ageMs: snapshot ? Date.now() - snapshot.observedAt : null, + }), + { + status: result.error ? 503 : 200, + headers: headers('application/json; charset=utf-8', snapshot), + }, + ); + } + if (result.error) return failure(503, result.error, result.snapshot); + const snapshot = result.snapshot; + if (url.pathname === '/api/events') { + if (Date.now() >= deadline) return failure(503, 'snapshot_unavailable'); + return events(request, env, snapshot, deadline); + } + if (url.pathname === '/health') { + return new Response( + JSON.stringify({ + mode: 'hosted_snapshot', + consistency: 'eventual', + observedAt: snapshot.observedAt, + publishedAt: snapshot.publishedAt, + ageMs: Date.now() - snapshot.observedAt, + }), + { headers: headers('application/json; charset=utf-8', snapshot) }, + ); + } + if (Object.hasOwn(ASSET_PATHS, url.pathname)) { + const png = snapshot.assets[url.pathname]; + if (!png) return failure(404, 'snapshot_asset_missing', snapshot); + const binary = atob(png); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new Response(bytes, { headers: headers('image/png', snapshot) }); + } + const payload = snapshot.payloads[url.pathname]; + return new Response(JSON.stringify(payload.body), { + status: payload.status, + headers: headers('application/json; charset=utf-8', snapshot), + }); + }, + } satisfies ExportedHandler; +} diff --git a/apps/attendance/test/logic.test.mjs b/apps/attendance/test/logic.test.mjs deleted file mode 100644 index 4567b14..0000000 --- a/apps/attendance/test/logic.test.mjs +++ /dev/null @@ -1,89 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - DAY_MS, - DEBOUNCE_MS, - HOUR_MS, - MAX_SESSION_MS, - decideTap, - kstDayStart, - kstWeekStart, - summarize, -} from '../src/logic.ts'; - -// 2026-11-10(화) 00:00 KST -const TUE = Date.UTC(2026, 10, 9, 15, 0, 0); -const MON = TUE - DAY_MS; - -const session = (overrides) => ({ memberId: '가', checkIn: TUE, checkOut: null, autoClosed: false, ...overrides }); - -test('KST 날짜·주 경계는 UTC 자정이 아니라 한국 시간 기준이다', () => { - assert.equal(kstDayStart(TUE + 5 * HOUR_MS), TUE); - assert.equal(kstDayStart(TUE - 1), TUE - DAY_MS); - assert.equal(kstWeekStart(TUE + 5 * HOUR_MS), MON); - assert.equal(kstWeekStart(MON), MON); - // 일요일 23:59는 직전 월요일이 속한 주 - assert.equal(kstWeekStart(MON - 1), MON - 7 * DAY_MS); -}); - -test('태그는 입실과 퇴실을 번갈아 만든다', () => { - assert.deepEqual(decideTap(null, TUE), { kind: 'check-in' }); - assert.deepEqual(decideTap(session({}), TUE + HOUR_MS), { kind: 'check-out' }); - assert.deepEqual(decideTap(session({ checkOut: TUE + HOUR_MS }), TUE + 2 * HOUR_MS), { kind: 'check-in' }); -}); - -test('디바운스 안의 태그와 직전 이벤트보다 이른 재전송은 무시한다', () => { - assert.deepEqual(decideTap(session({}), TUE + DEBOUNCE_MS - 1), { kind: 'ignore' }); - assert.deepEqual(decideTap(session({}), TUE - HOUR_MS), { kind: 'ignore' }); - assert.deepEqual(decideTap(session({}), TUE + DEBOUNCE_MS), { kind: 'check-out' }); -}); - -test('최대 길이를 넘긴 미퇴실 세션은 최대 길이 시점에 자동 종료하고 새로 입실한다', () => { - assert.deepEqual(decideTap(session({}), TUE + MAX_SESSION_MS), { - kind: 'expire-and-check-in', - closeAt: TUE + MAX_SESSION_MS, - }); - assert.deepEqual(decideTap(session({}), TUE + MAX_SESSION_MS - 1), { kind: 'check-out' }); -}); - -test('체류 시간은 오늘/이번 주 경계로 잘리고 입실 중이면 현재까지 더한다', () => { - const now = TUE + 3 * HOUR_MS; - const members = [ - { id: '가', name: '가' }, - { id: '나', name: '나' }, - ]; - const sessions = [ - // 월요일 22:00 → 화요일 01:00: 오늘 1시간, 이번 주 3시간 - session({ memberId: '가', checkIn: TUE - 2 * HOUR_MS, checkOut: TUE + HOUR_MS }), - // 화요일 02:00부터 입실 중: 현재까지 1시간 - session({ memberId: '가', checkIn: TUE + 2 * HOUR_MS }), - // 지난주 세션은 합산하지 않는다 - session({ memberId: '나', checkIn: MON - 8 * DAY_MS, checkOut: MON - 8 * DAY_MS + HOUR_MS }), - ]; - const [first, second] = summarize(members, sessions, now); - assert.equal(first.name, '가'); - assert.equal(first.present, true); - assert.equal(first.since, TUE + 2 * HOUR_MS); - assert.equal(first.todayMs, 2 * HOUR_MS); - assert.equal(first.weekMs, 4 * HOUR_MS); - assert.equal(first.firstInToday, TUE + 2 * HOUR_MS); - assert.equal(second.present, false); - assert.equal(second.weekMs, 0); -}); - -test('퇴근 태그가 없는 세션은 입실 중으로 보지 않고 시간 합계에서 빼며 건수만 센다', () => { - const now = TUE + 20 * HOUR_MS; - const sessions = [ - // 최대 길이를 넘겨 열려 있는 세션 - session({ checkIn: TUE }), - // 다음 태그로 자동 종료된 세션 - session({ checkIn: TUE + 8 * HOUR_MS, checkOut: TUE + 8 * HOUR_MS + MAX_SESSION_MS, autoClosed: true }), - // 정상 세션 1시간 - session({ checkIn: TUE + 17 * HOUR_MS, checkOut: TUE + 18 * HOUR_MS }), - ]; - const [member] = summarize([{ id: '가', name: '가' }], sessions, now); - assert.equal(member.present, false); - assert.equal(member.todayMs, HOUR_MS); - assert.equal(member.weekMs, HOUR_MS); - assert.equal(member.unrecordedWeek, 2); -}); diff --git a/apps/attendance/test/worker.test.mjs b/apps/attendance/test/worker.test.mjs new file mode 100644 index 0000000..5df7bf3 --- /dev/null +++ b/apps/attendance/test/worker.test.mjs @@ -0,0 +1,443 @@ +import assert from 'node:assert/strict'; +import test, { before, after, mock } from 'node:test'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createViewer } from '../src/viewer.ts'; +import { buildViewer } from '../scripts/build-viewer.mjs'; + +const AUD = 'isolated-viewer-test-audience'; +const ctx = { access: { aud: AUD } }; +const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aTyoAAAAASUVORK5CYII='; +const jsonPaths = ['/api/state', '/api/monitor', '/api/koin-health', '/api/worker-health', '/api/spotify', '/api/jam']; +const paths = [ + '/', + '/health', + '/api/events', + '/api/view-status', + ...jsonPaths, + '/api/wifi-qr/2.4', + '/api/wifi-qr/5', + '/api/jam-qr', +]; +let worker, directory, html; +before(async () => { + mock.method(globalThis, 'fetch', () => { + assert.fail('Unexpected real network fetch'); + }); + directory = await mkdtemp(join(tmpdir(), 'attendance-viewer-')); + html = await readFile(await buildViewer(join(directory, 'dashboard.html')), 'utf8'); + worker = createViewer(html); +}); +after(async () => { + mock.restoreAll(); + if (directory) await rm(directory, { recursive: true, force: true }); +}); + +function snapshot(now = Date.now()) { + return { + version: 1, + observedAt: now - 1000, + publishedAt: now - 500, + payloads: Object.fromEntries( + jsonPaths.map((path) => [ + path, + { + status: 200, + body: { path, now: now - 2000, collectedAt: now - 3000, actual: true }, + }, + ]), + ), + assets: { '/api/wifi-qr/2.4': PNG, '/api/wifi-qr/5': PNG, '/api/jam-qr': PNG }, + }; +} +function environment(value = snapshot()) { + const store = { + raw: value === null ? null : JSON.stringify(value), + reads: 0, + async get(key, type) { + assert.equal(key, 'clubroom:view:v1'); + assert.equal(type, 'arrayBuffer'); + this.reads++; + return this.raw === null || this.raw instanceof ArrayBuffer + ? this.raw + : new TextEncoder().encode(this.raw).buffer; + }, + }; + return { store, CF_ACCESS_AUD: AUD, VIEW_STATE: store }; +} +function get(env, path = '/api/state', context = ctx, options = {}) { + return worker.fetch(new Request(`https://attendance.example${path}`, options), env, context); +} +function assertPrivate(response) { + assert.equal(response.headers.get('cache-control'), 'private, no-store'); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); + assert.equal(response.headers.get('x-frame-options'), 'DENY'); + assert.equal(response.headers.get('x-bcsd-view-mode'), 'eventually-consistent-snapshot'); + assert.ok(response.headers.get('content-security-policy').includes("frame-ancestors 'none'")); + for (const name of ['set-cookie', 'location', 'access-control-allow-origin']) + assert.equal(response.headers.get(name), null); +} + +test('missing/wrong platform audience and spoofed headers never cause any KV access', async () => { + const env = environment(); + for (const context of [ + undefined, + {}, + { access: {} }, + { access: { aud: 'other-worker' } }, + { access: { aud: [AUD] } }, + ]) { + for (const path of paths) { + const response = await worker.fetch( + new Request(`https://attendance.example${path}`, { + headers: { + 'cf-access-jwt-assertion': 'unverified.jwt.assertion', + 'cf-access-authenticated-user-email': 'admin@example.test', + 'cf-warp-tag-id': AUD, + authorization: 'Bearer client-token', + cookie: 'CF_Authorization=client-token', + }, + }), + env, + context, + ); + assert.equal(response.status, 403); + assertPrivate(response); + } + } + assert.equal(env.store.reads, 0); + assert.equal((await get(env)).status, 200); + assert.equal(env.store.reads, 1); +}); + +test('missing dedicated namespace/audience fails closed and legacy attendance KV is never used', async () => { + for (const change of [ + { VIEW_STATE: undefined }, + { CF_ACCESS_AUD: undefined }, + { CF_ACCESS_AUD: '' }, + { CF_ACCESS_AUD: ` ${AUD}` }, + { CF_ACCESS_AUD: 'x'.repeat(257) }, + ]) { + const env = Object.assign(environment(), change); + assert.equal((await get(env)).status, 503); + assert.equal(env.store.reads, 0); + } + const legacy = { ATTENDANCE: environment().store, CF_ACCESS_AUD: AUD }; + assert.equal((await get(legacy)).status, 503); + assert.equal(legacy.ATTENDANCE.reads, 0); +}); + +test('build serves the current complete LAN dashboard with truthful hosted notice and no KV HTML', async () => { + const env = environment(null); + const response = await get(env, '/'); + assert.equal(response.status, 200); + assertPrivate(response); + assert.equal(response.headers.get('content-type'), 'text/html; charset=utf-8'); + assert.equal(await response.text(), html); + for (const marker of [ + '', + 'monitor-view', + 'spotify-now', + 'cloud-snapshot', + '원격 스냅샷', + '/api/view-status', + ]) { + assert.ok(html.includes(marker), marker); + } + assert.ok(!html.includes('변경 즉시 갱신')); + assert.ok(!html.includes('임시 LAN 운영 · 로그인 없음')); + assert.ok(!html.includes('new EventSource(')); + const head = await get(env, '/', ctx, { method: 'HEAD' }); + assert.equal(head.status, 200); + assert.equal(await head.text(), ''); + assert.equal(env.store.reads, 0); +}); + +test('every JSON route preserves actual data, original clocks and error statuses', async () => { + const value = snapshot(); + value.payloads['/api/koin-health'].status = 503; + value.payloads['/api/spotify'].status = 401; + const env = environment(value); + for (const path of jsonPaths) { + const response = await get(env, path); + assert.equal(response.status, value.payloads[path].status); + assert.deepEqual(await response.json(), value.payloads[path].body); + assertPrivate(response); + assert.equal(response.headers.get('x-bcsd-snapshot-observed-at'), String(value.observedAt)); + assert.equal(response.headers.get('x-bcsd-snapshot-published-at'), String(value.publishedAt)); + } + const health = await (await get(env, '/health')).json(); + assert.equal(health.mode, 'hosted_snapshot'); + assert.equal(health.consistency, 'eventual'); + assert.equal(health.observedAt, value.observedAt); +}); + +test('view status exposes only actual snapshot availability/timestamps and fails closed on stale/missing data', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['Date'], now }); + const value = snapshot(now); + const env = environment(value); + const fresh = await get(env, '/api/view-status'); + assert.equal(fresh.status, 200); + assertPrivate(fresh); + assert.deepEqual(await fresh.json(), { + available: true, + observedAt: value.observedAt, + publishedAt: value.publishedAt, + ageMs: 1000, + }); + t.mock.timers.tick(300_001); + const stale = await get(env, '/api/view-status'); + assert.equal(stale.status, 503); + assert.deepEqual(await stale.json(), { + available: false, + observedAt: value.observedAt, + publishedAt: value.publishedAt, + ageMs: 301001, + }); + assert.equal(stale.headers.get('x-bcsd-snapshot-observed-at'), String(value.observedAt)); + env.store.raw = null; + const missing = await get(env, '/api/view-status'); + assert.equal(missing.status, 503); + assert.deepEqual(await missing.json(), { available: false, observedAt: null, publishedAt: null, ageMs: null }); + assert.equal(missing.headers.get('x-bcsd-snapshot-observed-at'), null); + env.VIEW_STATE = undefined; + assert.equal((await get(env, '/')).status, 200); + assert.equal((await get(env, '/api/view-status')).status, 503); +}); + +test('camera/auth/admin/writes/arbitrary paths/queries and non-root HEAD never read KV', async () => { + const env = environment(); + for (const path of [ + '/api/login', + '/api/logout', + '/api/taps', + '/api/me', + '/api/members', + '/api/admin', + '/api/camera', + '/api/camera-stream', + '/api/camera/snapshot', + '/api/camera.jpg', + '/api/wifi-qr/config', + '/api/state/', + '/api/%73tate', + '//attacker.example/api/state', + '/http://attacker.example', + '/constructor', + '/__proto__', + ]) { + assert.equal((await get(env, path)).status, 404, path); + } + for (const path of paths) { + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) + assert.equal((await get(env, path, ctx, { method })).status, 405); + if (path !== '/') assert.equal((await get(env, path, ctx, { method: 'HEAD' })).status, 405); + assert.equal((await get(env, `${path}?url=http://attacker.example`)).status, 400); + assert.equal((await get(env, `${path}?`)).status, 400); + } + assert.equal(env.store.reads, 0); +}); + +test('client auth/cookies/forwarding/conditional headers cannot select data or become response headers', async () => { + const value = snapshot(); + const env = environment(value); + const response = await get(env, '/api/state', ctx, { + headers: { + authorization: 'Bearer private', + cookie: 'private-cookie', + 'cf-access-jwt-assertion': 'private-assertion', + origin: 'https://attacker.example', + host: 'attacker.example', + forwarded: 'host=attacker.example', + 'x-forwarded-for': '1.2.3.4', + range: 'bytes=0-5', + 'if-none-match': 'private-etag', + }, + }); + assert.deepEqual(await response.json(), value.payloads['/api/state'].body); + assertPrivate(response); + assert.ok(!JSON.stringify([...response.headers]).includes('private-cookie')); + assert.equal(env.store.reads, 1); +}); + +test('missing, invalid UTF-8, malformed, null, oversized and unavailable snapshots fail closed', async () => { + for (const raw of [null, '', '{', 'null', '[]', '{}', new Uint8Array([0xff]).buffer, new ArrayBuffer(2_097_153)]) { + const env = environment(); + env.store.raw = raw; + const response = await get(env); + assert.equal(response.status, 503); + assertPrivate(response); + } + const env = environment(); + env.store.get = async () => { + throw new Error('private namespace detail'); + }; + const response = await get(env); + assert.equal(response.status, 503); + assert.ok(!(await response.text()).includes('private namespace')); +}); + +test('snapshot schema, timestamps and JSON status validation reject partial or unexpected data', async () => { + const changes = [ + (s) => { + s.version = 2; + }, + (s) => { + delete s.payloads['/api/monitor']; + }, + (s) => { + s.payloads['/api/camera'] = { status: 200, body: {} }; + }, + (s) => { + s.observedAt = '1'; + }, + (s) => { + s.publishedAt = s.observedAt - 1; + }, + (s) => { + s.publishedAt = Date.now() + 60_000; + }, + (s) => { + s.observedAt = 0; + }, + (s) => { + s.admin = { secret: 'private' }; + }, + (s) => { + s.payloads['/api/state'].headers = { 'set-cookie': 'secret' }; + }, + (s) => { + s.payloads['/api/state'].body = null; + }, + (s) => { + delete s.assets['/api/wifi-qr/5']; + }, + ]; + for (const status of [101, 199, 204, 205, 301, 302, 304, 307, 600, 200.5, '200']) { + changes.push((s) => { + s.payloads['/api/state'].status = status; + }); + } + for (const change of changes) { + const value = snapshot(); + change(value); + const response = await get(environment(value)); + assert.equal(response.status, 503); + assert.equal((await response.json()).error, 'snapshot_malformed'); + } +}); + +test('observation age alone enforces cutoff: exactly five minutes accepted, older data rejected', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['Date'], now }); + const value = snapshot(now); + value.observedAt = now - 300_000; + value.publishedAt = now; + const env = environment(value); + assert.equal((await get(env)).status, 200); + t.mock.timers.tick(1); + for (const path of [...jsonPaths, '/health', '/api/events', '/api/wifi-qr/5']) { + const response = await get(env, path); + assert.equal(response.status, 503, path); + assert.equal((await response.json()).error, 'snapshot_stale'); + assert.equal(response.headers.get('x-bcsd-snapshot-observed-at'), String(value.observedAt)); + assert.equal(response.headers.get('x-bcsd-snapshot-age-ms'), '300001'); + } +}); + +test('PNG assets pass exact bytes with bounds and signature checks; optional Jam absence is not fabricated', async () => { + const value = snapshot(); + for (const path of ['/api/wifi-qr/2.4', '/api/wifi-qr/5', '/api/jam-qr']) { + const response = await get(environment(value), path); + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'image/png'); + assertPrivate(response); + assert.deepEqual(Buffer.from(await response.arrayBuffer()), Buffer.from(PNG, 'base64')); + } + delete value.assets['/api/jam-qr']; + assert.equal((await get(environment(value), '/api/jam-qr')).status, 404); + for (const png of [ + '', + 'not-png', + PNG + '\n', + PNG.replace('iVBOR', 'AAAAA'), + PNG.slice(0, -16) + 'AAAAAAAAAAAAAAAA', + 'iVBORw0KGgoA' + 'AAAA'.repeat(90_000), + ]) { + const invalid = snapshot(); + invalid.assets['/api/wifi-qr/5'] = png; + assert.equal((await get(environment(invalid), '/api/wifi-qr/5')).status, 503); + } + const invalid = snapshot(); + invalid.assets['/api/camera'] = PNG; + assert.equal((await get(environment(invalid))).status, 503); +}); + +test('SSE emits initial/version-change state and pings from atomic eventually-consistent KV reads', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now }); + const env = environment(snapshot(now)); + const response = await get(env, '/api/events'); + assert.equal(response.headers.get('content-type'), 'text/event-stream; charset=utf-8'); + const reader = response.body.getReader(); + const decode = (chunk) => new TextDecoder().decode(chunk.value); + assert.match(decode(await reader.read()), /event: state\ndata: \{"version":/); + let pending = reader.read(); + t.mock.timers.tick(10_000); + assert.equal(decode(await pending), 'event: ping\ndata: {}\n\n'); + const next = snapshot(Date.now()); + env.store.raw = JSON.stringify(next); + pending = reader.read(); + t.mock.timers.tick(10_000); + assert.equal(decode(await pending), `event: state\ndata: {"version":"${next.observedAt}:${next.publishedAt}"}\n\n`); + assert.equal(env.store.reads, 3); + await reader.cancel(); + t.mock.timers.tick(120_000); + assert.equal(env.store.reads, 3); +}); + +test('SSE lifetime is at most sixty seconds even when unread', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now }); + const env = environment(snapshot(now)); + const response = await get(env, '/api/events'); + t.mock.timers.tick(60_000); + assert.ok((await response.text()).startsWith('event: state\n')); + const reads = env.store.reads; + t.mock.timers.tick(120_000); + assert.equal(env.store.reads, reads); +}); + +test('SSE closes when the atomic snapshot disappears instead of streaming stale/private payloads', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now }); + const env = environment(snapshot(now)); + const reader = (await get(env, '/api/events')).body.getReader(); + await reader.read(); + env.store.raw = null; + const pending = reader.read(); + t.mock.timers.tick(10_000); + assert.equal( + new TextDecoder().decode((await pending).value), + 'event: unavailable\ndata: {"error":"snapshot_missing"}\n\n', + ); + assert.equal((await reader.read()).done, true); +}); + +test('client disconnect cancels SSE timers and prevents any subsequent KV read', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now }); + const env = environment(snapshot(now)); + const client = new AbortController(); + const reader = (await get(env, '/api/events', ctx, { signal: client.signal })).body.getReader(); + await reader.read(); + const pending = reader.read(); + client.abort(); + assert.equal((await pending).done, true); + t.mock.timers.tick(120_000); + assert.equal(env.store.reads, 1); + assert.equal((await get(env, '/api/events', ctx, { signal: client.signal })).status, 499); + assert.equal(env.store.reads, 1); +}); diff --git a/apps/attendance/wrangler.jsonc b/apps/attendance/wrangler.jsonc index cbc04d7..c461bcb 100644 --- a/apps/attendance/wrangler.jsonc +++ b/apps/attendance/wrangler.jsonc @@ -3,9 +3,14 @@ "name": "bcsd-attendance", "main": "src/index.ts", "compatibility_date": "2026-10-29", - "workers_dev": false, + "workers_dev": true, "preview_urls": false, "observability": { "enabled": true }, - // 기존 운영 네임스페이스를 재사용합니다. 새 네임스페이스로 치환하지 않습니다. - "kv_namespaces": [{ "binding": "ATTENDANCE", "id": "2cc47a111f334c4ea99deb2435263e00" }], + "build": { "command": "node scripts/build-viewer.mjs" }, + "rules": [{ "type": "Text", "globs": ["**/*.html"], "fallthrough": false }], + "vars": { + "CF_ACCESS_AUD": "4f972354a71d1fd312ab4081d44c59d6d698ea529672ba1c2140c49958e520e8", + }, + // 원장 KV는 변경·삭제하지 않으며 이 바인딩은 별도 읽기 전용 스냅샷용입니다. + "kv_namespaces": [{ "binding": "VIEW_STATE", "id": "b519a98db46d4b088baee97a256bf0f0" }], } From 506ca5b66fa8a0b340130d3024189191b2d9f544 Mon Sep 17 00:00:00 2001 From: Yunsup Sim Date: Sat, 14 Nov 2026 17:52:10 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EC=9E=91=EC=9D=80=20=EA=B8=80=EC=94=A8=EB=A5=BC=20=EC=97=86?= =?UTF-8?q?=EC=95=A0=EA=B3=A0=20=EC=B2=B4=EB=A5=98=20=ED=91=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TV 가시 텍스트를 24px 이상으로 유지하고 720p와 1080p 실제 Pi 화면을 확인합니다. Refs: #82 --- apps/attendance/README.md | 1 + apps/attendance/lan/render-dashboard.mjs | 118 ++++++++--------- apps/attendance/scripts/cloud-dashboard.mjs | 56 +++++--- apps/attendance/scripts/internal-login.mjs | 13 +- apps/attendance/src/dashboard.ts | 140 ++++++++++---------- 5 files changed, 163 insertions(+), 165 deletions(-) diff --git a/apps/attendance/README.md b/apps/attendance/README.md index 70958a4..0cc3d73 100644 --- a/apps/attendance/README.md +++ b/apps/attendance/README.md @@ -18,6 +18,7 @@ flowchart LR - 날짜·이번 주 경계는 KST입니다. 오늘·이번 주 요약은 양수인 유효 체류 시간이 있는 회원의 중앙값입니다. - 학번 조회 404는 LAN의 익명 방문자 정책을 따릅니다. 원장에 학번이나 카드 UID를 저장하거나 UID를 학번으로 추측해 연결하지 않습니다. - 현재 입실자는 이름·체류 시간만 4열×2행으로 표시하고 넘치는 인원은 페이지를 넘깁니다. 최근 기록·사이드바·수동 카드 등록 패널은 제공하지 않습니다. +- 16:9 TV 화면의 보이는 글씨는 24px 이상으로 표시합니다. 보조 설명을 줄이고 체류 표는 이름·오늘·이번 주 3열만 사용합니다. 공간이 부족하다고 글씨나 전체 화면 배율을 축소하지 않습니다. - 자동 테마는 병천면의 일출·일몰을 브라우저에서 계산합니다. 외부 위치·폰트 서비스를 호출하지 않습니다. - LAN은 저장 이벤트로 즉시 갱신하고 KOIN 모니터와 15초 간격으로 교대합니다. 유효 태그는 출근부로 복귀시키고 입실·퇴실 인사 모달을 표시합니다. - 실물 학생증의 비금융 학번 읽기 규격이 없으면 지원으로 간주하지 않습니다. 보호 키·금융 앱을 탐색하거나 UID로 사람을 임의 등록하지 않습니다. diff --git a/apps/attendance/lan/render-dashboard.mjs b/apps/attendance/lan/render-dashboard.mjs index defee4e..d00ec1e 100644 --- a/apps/attendance/lan/render-dashboard.mjs +++ b/apps/attendance/lan/render-dashboard.mjs @@ -36,17 +36,17 @@ const spotifyStyles = ` .app-header > .brand, .app-header > .header-actions { flex-shrink:0; } .app-header > .header-actions { min-width:0; } .app-header .viewer-name { display:none; } -.spotify-now { flex:1 1 320px; min-width:0; max-width:480px; height:56px; padding:4px 10px; display:flex; align-items:center; gap:10px; border:1px solid var(--line); border-radius:16px; background:var(--surface-low); overflow:hidden; } -.spotify-copy { display:grid; grid-template-rows:22px 20px; align-content:center; flex:1; min-width:0; } -.spotify-cover { flex:0 0 44px; width:44px; height:44px; object-fit:cover; border-radius:7px; } +.spotify-now { flex:1 1 320px; min-width:0; max-width:480px; height:68px; padding:4px 10px; display:flex; align-items:center; gap:10px; border:1px solid var(--line); border-radius:16px; background:var(--surface-low); overflow:hidden; } +.spotify-copy { display:grid; grid-template-rows:29px 29px; align-content:center; flex:1; min-width:0; } +.spotify-cover { flex:0 0 56px; width:56px; height:56px; object-fit:cover; border-radius:7px; } .spotify-title, .spotify-artists { display:block; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.spotify-title { color:var(--text); font-size:clamp(16px,1.8vh,20px); font-weight:650; line-height:20px; } -.spotify-artists { color:var(--muted); font-size:16px; line-height:20px; } +.spotify-title { color:var(--text); font-size:24px; font-weight:650; line-height:29px; } +.spotify-artists { color:var(--muted); font-size:24px; line-height:29px; } @media (min-width:1000px) and (min-aspect-ratio:3/2) { - .app-header > .brand, .app-header > .header-actions { height:56px; } +.app-header > .brand, .app-header > .header-actions { min-height:68px; } .app-header h1 { margin-top:2px; font-size:clamp(26px,3vh,32px); line-height:1.2; } .app-header #clock { font-size:clamp(26px,3vh,32px); line-height:1.2; } - .app-header #date { font-size:16px; line-height:1.2; } +.app-header #date { font-size:24px; line-height:1.2; } } @media (max-width:999px) { .app-header { flex-wrap:wrap; } @@ -67,29 +67,29 @@ const spotifyMarkup = ` `; const jamStyles = ` .wifi-codes.has-jam { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); align-items:center; gap:6px; } -.wifi-codes.has-jam .wifi-code img { width:clamp(104px,15vh,128px); height:auto; aspect-ratio:1; max-width:100%; } -.wifi-codes.has-jam .wifi-code figcaption { margin-top:2px; font-size:16px; line-height:1.3; white-space:nowrap; } +.wifi-codes.has-jam .wifi-code img { width:clamp(96px,13vh,128px); height:auto; aspect-ratio:1; max-width:100%; } +.wifi-codes.has-jam .wifi-code figcaption { margin-top:2px; font-size:24px; line-height:1.2; white-space:nowrap; } `; const monitorStyles = ` .monitor-view { --monitor-cpu:var(--primary); --monitor-ram:var(--amber); display:grid; align-content:start; gap:16px; min-width:0; } .monitor-heading, .monitor-title, .monitor-section-heading { display:flex; align-items:center; justify-content:space-between; gap:12px 20px; } .monitor-title { justify-content:flex-start; flex-wrap:wrap; } .monitor-heading h2 { margin:0; font-size:clamp(28px,3.4vh,40px); line-height:1.2; } -.monitor-description { margin:0; color:var(--muted); font-size:clamp(16px,1.8vh,21px); line-height:1.35; } -.monitor-status { display:flex; align-items:center; gap:12px; flex-shrink:0; margin:0; padding:10px 18px; border-radius:999px; background:var(--amber-container); color:var(--amber); font-size:clamp(20px,2.2vh,28px); line-height:1.3; font-weight:750; } +.monitor-description { margin:0; color:var(--muted); font-size:24px; line-height:1.2; } +.monitor-status { display:inline-flex; align-items:center; gap:10px; flex-shrink:0; margin:0; padding:8px 16px; border-radius:999px; background:var(--amber-container); color:var(--amber); font-size:clamp(24px,2.2vh,28px); line-height:1.2; font-weight:750; } .monitor-status::before { content:""; width:13px; height:13px; flex-shrink:0; border-radius:50%; background:currentColor; } .monitor-status[data-state="up"] { color:var(--on-green-container); background:var(--green-container); } .monitor-status[data-state="down"] { color:var(--error); background:var(--error-container); } .monitor-paths { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } .monitor-path { display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0; padding:8px 16px; border:1px solid var(--line); border-radius:999px; color:var(--amber); background:var(--amber-container); line-height:1.2; } .monitor-path strong { color:var(--text); font-size:clamp(24px,2.5vh,30px); font-weight:750; } -.monitor-path span { display:inline-flex; align-items:center; gap:8px; font-size:clamp(22px,2.2vh,28px); font-weight:750; white-space:nowrap; } +.monitor-path span { display:inline-flex; align-items:center; gap:8px; font-size:clamp(24px,2.2vh,28px); font-weight:750; white-space:nowrap; } .monitor-path span::before { content:""; width:10px; height:10px; flex-shrink:0; border-radius:50%; background:currentColor; } .monitor-path[data-health="up"] { color:var(--on-green-container); background:var(--green-container); } .monitor-path[data-health="down"] { color:var(--error); background:var(--error-container); } .worker-health { display:flex; align-items:center; justify-content:space-between; gap:8px 12px; min-width:0; margin:0; padding:8px 16px; border:1px solid var(--line); border-radius:24px; background:var(--surface-low); } -.worker-health > strong { color:var(--text); font-size:clamp(22px,2.3vh,28px); line-height:1.2; white-space:nowrap; } -.worker-status { display:inline-flex; align-items:center; gap:6px; color:var(--amber); font-size:clamp(18px,1.9vh,23px); font-weight:750; line-height:1.3; white-space:nowrap; } +.worker-health > strong { color:var(--text); font-size:clamp(24px,2.3vh,28px); line-height:1.2; white-space:nowrap; } +.worker-status { display:inline-flex; align-items:center; gap:6px; color:var(--amber); font-size:24px; font-weight:750; line-height:1.2; white-space:nowrap; } .worker-status[data-health="up"] { color:var(--on-green-container); } .worker-status[data-health="down"] { color:var(--error); } @media (max-width:1100px) { .worker-health { flex-wrap:wrap; } .worker-health > strong { flex-basis:100%; } } @@ -97,32 +97,31 @@ const monitorStyles = ` .koin-service { display:grid; gap:8px; padding:clamp(10px,1.8vh,22px) clamp(16px,2vw,30px); border:1px solid var(--line); border-left:6px solid var(--amber); border-radius:20px; background:var(--surface); } .koin-service[data-health="up"] { border-left-color:var(--green); } .koin-service[data-health="down"] { border-left-color:var(--error); } -.koin-service-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; } +.koin-service-heading { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:4px 12px; } .koin-service-name { margin:0; font-size:clamp(26px,3.1vh,38px); font-weight:750; line-height:1.2; } -.koin-service-detail { display:flex; align-items:baseline; justify-content:space-between; flex-wrap:wrap; gap:2px 14px; color:var(--muted); font-size:clamp(16px,1.85vh,22px); line-height:1.3; } +.koin-service-detail { display:flex; align-items:baseline; justify-content:space-between; flex-wrap:wrap; gap:2px 14px; color:var(--muted); font-size:24px; line-height:1.2; } .koin-service-reason, .koin-service-time { margin:0; } -.monitor-health { display:inline-flex; align-items:center; gap:8px; width:fit-content; flex-shrink:0; padding:5px 12px; border-radius:999px; color:var(--amber); background:var(--amber-container); font-size:clamp(18px,2.1vh,26px); font-weight:750; line-height:1.2; white-space:nowrap; } +.monitor-health { display:inline-flex; align-items:center; gap:8px; width:fit-content; flex-shrink:0; padding:4px 10px; border-radius:999px; color:var(--amber); background:var(--amber-container); font-size:clamp(24px,2.1vh,26px); font-weight:750; line-height:1.2; white-space:nowrap; } .monitor-health::before { content:""; width:9px; height:9px; flex-shrink:0; border-radius:50%; background:currentColor; } .monitor-health[data-health="up"] { color:var(--on-green-container); background:var(--green-container); } .monitor-health[data-health="down"] { color:var(--error); background:var(--error-container); } -.monitor-section-heading h3 { margin:0; font-size:clamp(23px,2.5vh,30px); line-height:1.2; } -.monitor-timestamp { margin:0; color:var(--muted); font-size:clamp(16px,1.8vh,21px); line-height:1.3; text-align:right; } +.monitor-section-heading h3 { margin:0; font-size:clamp(24px,2.5vh,30px); line-height:1.2; } +.monitor-timestamp { margin:0; color:var(--muted); font-size:24px; line-height:1.2; text-align:right; } .monitor-timestamp, .monitor-node-time, .koin-service-time { font-variant-numeric:tabular-nums; } .monitor-cards { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; min-width:0; min-height:0; } .monitor-node.panel { display:grid; grid-template-rows:auto auto minmax(0,1fr) auto; align-content:start; gap:clamp(8px,1.1vh,16px); padding:clamp(12px,1.8vh,24px); border-radius:22px; min-width:0; } .monitor-node-heading { min-width:0; } .monitor-node-title { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:6px 10px; } .monitor-node h4 { margin:0; font-size:clamp(24px,3vh,36px); line-height:1.2; font-weight:750; overflow-wrap:anywhere; } -.monitor-node-time { margin:0; padding-top:8px; border-top:1px solid var(--line); color:var(--muted); font-size:clamp(16px,1.85vh,22px); line-height:1.3; } -.monitor-value-note { margin:5px 0 0; color:var(--amber); font-size:clamp(14px,1.7vh,20px); line-height:1.3; } +.monitor-node-time { margin:0; padding-top:8px; border-top:1px solid var(--line); color:var(--muted); font-size:24px; line-height:1.2; } +.monitor-value-note { margin:5px 0 0; color:var(--amber); font-size:24px; line-height:1.2; } .monitor-metrics { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; } .monitor-metric { min-width:0; } -.monitor-metric-label { display:flex; align-items:center; gap:9px; color:var(--monitor-cpu); font-size:clamp(18px,2.2vh,26px); line-height:1.2; font-weight:750; } +.monitor-metric-label { display:flex; align-items:center; gap:9px; color:var(--monitor-cpu); font-size:clamp(24px,2.2vh,26px); line-height:1.2; font-weight:750; } .monitor-metric-label::before { content:""; width:27px; border-top:5px solid currentColor; } .monitor-metric[data-metric="ram"] .monitor-metric-label { color:var(--monitor-ram); } .monitor-metric[data-metric="ram"] .monitor-metric-label::before { border-top-style:dashed; } .monitor-value { display:block; margin-top:3px; font-size:clamp(44px,5.4vh,72px); font-weight:750; letter-spacing:-.055em; line-height:1.05; font-variant-numeric:tabular-nums; } -.monitor-metric-detail { display:block; margin-top:4px; color:var(--muted); font-size:clamp(13px,1.5vh,18px); line-height:1.3; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; } .monitor-plot { display:flex; flex-direction:column; min-height:0; } .monitor-graph { display:block; flex:1; width:100%; height:clamp(100px,15vh,180px); min-height:100px; overflow:hidden; } .monitor-graph-grid { fill:none; stroke:var(--outline); stroke-width:1; vector-effect:non-scaling-stroke; } @@ -130,8 +129,8 @@ const monitorStyles = ` .monitor-graph-line[data-series="ram"] { stroke:var(--monitor-ram); stroke-dasharray:10 7; } .monitor-graph-dot { fill:var(--monitor-cpu); } .monitor-graph-dot[data-series="ram"] { fill:var(--monitor-ram); } -.monitor-graph-axis { display:flex; align-items:center; justify-content:space-between; gap:8px; color:var(--muted); font-size:clamp(14px,1.65vh,19px); line-height:1.25; } -.monitor-waiting { grid-column:1 / -1; margin:0; padding:24px; color:var(--muted); font-size:clamp(22px,2.5vh,30px); border:2px dashed var(--outline); border-radius:20px; } +.monitor-graph-axis { display:flex; align-items:center; justify-content:space-between; gap:8px; color:var(--muted); font-size:24px; line-height:1.2; } +.monitor-waiting { grid-column:1 / -1; margin:0; padding:16px; color:var(--muted); font-size:clamp(24px,2.5vh,30px); border:2px dashed var(--outline); border-radius:20px; } @media (max-width:999px) { .monitor-heading { align-items:start; flex-direction:column; } .monitor-cards { grid-template-columns:minmax(0,1fr); } @@ -139,7 +138,7 @@ const monitorStyles = ` .monitor-graph { flex:none; height:170px; } .monitor-value { font-size:52px; } .monitor-node h4 { font-size:30px; } - .monitor-health { font-size:20px; } +.monitor-health { font-size:24px; } } @media (max-width:650px) { .monitor-paths { grid-template-columns:minmax(0,1fr); } @@ -147,9 +146,9 @@ const monitorStyles = ` .monitor-section-heading { align-items:start; flex-direction:column; gap:6px; } .monitor-timestamp { text-align:left; } .koin-service-name { font-size:26px; } - .monitor-description { font-size:16px; } +.monitor-description { font-size:24px; } .monitor-title { gap:8px; } - .monitor-status { font-size:20px; } +.monitor-status { font-size:24px; } } @media (min-width:1000px) and (min-aspect-ratio:3/2) { .monitor-view { flex:1; min-height:0; gap:clamp(8px,1vh,14px); grid-template-rows:auto auto auto auto minmax(0,1fr); } @@ -159,7 +158,7 @@ const monitorStyles = ` .monitor-node.panel { min-height:0; } .koin-service-name, .monitor-node h4 { font-size:clamp(32px,3.1vh,38px); } .monitor-value { font-size:clamp(58px,5.4vh,72px); } - .monitor-health { font-size:clamp(22px,2.1vh,26px); } +.monitor-health { font-size:clamp(24px,2.1vh,26px); } .monitor-node.panel { padding:clamp(10px,1.4vh,22px); gap:clamp(6px,.9vh,14px); } .monitor-node-time { padding-top:4px; } .monitor-graph { height:0; min-height:0; flex:1 1 0; } @@ -174,23 +173,22 @@ const monitorStyles = ` .monitor-node-title { gap:4px 8px; } .monitor-node-heading { min-height:0; } .monitor-metrics { gap:8px; } - .monitor-value-note { margin-top:2px; font-size:14px; } - .monitor-node-time { line-height:1.25; } - .monitor-heading h2 { font-size:28px; } - .monitor-description { font-size:16px; } - .monitor-status { padding:7px 12px; font-size:20px; } - .monitor-path { padding:6px 12px; } - .monitor-path strong, .monitor-path span { font-size:22px; } - .koin-service { padding:10px 16px; gap:4px; } - .koin-service-name { font-size:26px; } - .koin-service-detail, .monitor-timestamp, .monitor-node-time { font-size:16px; } - .monitor-section-heading h3 { font-size:23px; } - .monitor-health { font-size:18px; } - .monitor-node h4 { font-size:30px; } - .monitor-metric-label { font-size:20px; } - .monitor-value { font-size:54px; } - .monitor-metric-detail { font-size:16px; } - .monitor-graph-axis { font-size:16px; } +.monitor-value-note { margin-top:2px; font-size:24px; } +.monitor-node-time { line-height:1.2; } +.monitor-heading h2 { font-size:28px; } +.monitor-description { font-size:24px; } +.monitor-status { padding:6px 12px; font-size:24px; } +.monitor-path { padding:6px 12px; } +.monitor-path strong, .monitor-path span { font-size:24px; } +.koin-service { padding:8px 12px; gap:4px; } +.koin-service-name { font-size:26px; } +.koin-service-detail, .monitor-timestamp, .monitor-node-time { font-size:24px; } +.monitor-section-heading h3 { font-size:24px; } +.monitor-health { font-size:24px; } +.monitor-node h4 { font-size:30px; } +.monitor-metric-label { font-size:24px; } +.monitor-value { font-size:48px; } +.monitor-graph-axis { font-size:24px; } } `; const monitorMarkup = ` @@ -223,12 +221,12 @@ const greetingStyles = ` .greeting-dialog.is-entering { animation:greeting-enter 220ms cubic-bezier(.2,.8,.2,1) both; } .greeting-dialog.is-closing { animation:greeting-exit 160ms ease-in both; } @keyframes greeting-enter { - from { opacity:0; transform:translateY(12px) scale(.97); } - to { opacity:1; transform:translateY(0) scale(1); } + from { opacity:0; transform:translateY(12px); } + to { opacity:1; transform:translateY(0); } } @keyframes greeting-exit { - from { opacity:1; transform:translateY(0) scale(1); } - to { opacity:0; transform:translateY(8px) scale(.98); } + from { opacity:1; transform:translateY(0); } + to { opacity:0; transform:translateY(8px); } } @media (prefers-reduced-motion:reduce) { .greeting-dialog.is-entering, .greeting-dialog.is-closing { animation:none; } @@ -237,20 +235,18 @@ const greetingStyles = ` .greeting-brand { display:inline-block; margin:0 0 20px; padding:6px 14px; border-radius:999px; background:var(--primary-container); color:var(--on-primary-container); - font-size:16px; font-weight:750; letter-spacing:.12em; + font-size:24px; font-weight:750; letter-spacing:.12em; } .greeting-message { margin:0; font-size:clamp(24px,3vw,36px); font-weight:700; line-height:1.5; letter-spacing:-.04em; overflow-wrap:anywhere; } - .greeting-description { margin:16px 0 24px; color:var(--muted); font-size:16px; } .greeting-close { min-width:96px; } `; const greetingMarkup = ` - +

-

5초 후 자동으로 닫힙니다.

`; @@ -570,10 +566,8 @@ if (view === 'monitor' && lanGreetingDialog.open) return; var block = el('div', 'monitor-metric'); block.dataset.metric = key; var value = el('strong', 'monitor-value', '—'); - var detail = key === 'ram' ? el('span', 'monitor-metric-detail') : null; block.append(el('span', 'monitor-metric-label', label), value); - if (detail) block.appendChild(detail); - return { block: block, value: value, detail: detail }; + return { block: block, value: value }; } function renderLanMonitor() { var container = document.getElementById('monitor-cards'); @@ -683,18 +677,14 @@ if (view === 'monitor' && lanGreetingDialog.open) return; states.push(state); entry.health.dataset.health = state; entry.health.textContent = state === 'up' ? '수집 정상' : state === 'down' ? '수집 실패' : '확인 불가'; - var ageSeconds = node.collectedAt === null ? null : Math.max(0, Math.floor((now - node.collectedAt) / 1000)); - entry.timestamp.textContent = '수집 ' + lanTimestamp(node.collectedAt) - + (ageSeconds === null ? '' : ' · ' + ageSeconds + '초 전') - + (fresh ? '' : ' · 최신 아님'); + entry.timestamp.textContent = '수집 ' + lanTimestamp(node.collectedAt); entry.note.textContent = !fresh ? '최신 수치 확인 불가' - : cpu === null || ram === null ? '일부 미수집 · —는 확인 불가' : ''; + : cpu === null || ram === null ? '일부 미수집' : ''; entry.note.hidden = !entry.note.textContent; entry.cpu.value.textContent = lanPercent(cpu); entry.ram.value.textContent = lanPercent(ram); - entry.ram.detail.textContent = ram === null ? '사용 / 전체 확인 불가' + entry.ram.value.title = ram === null ? '사용 / 전체 확인 불가' : lanBytes(node.memoryUsedBytes) + ' / ' + lanBytes(node.memoryTotalBytes); - entry.ram.value.title = entry.ram.detail.textContent; var history = lanHistoryFor(node.id); if (!fresh) history.breakNext = true; lanPruneHistory(history, now); diff --git a/apps/attendance/scripts/cloud-dashboard.mjs b/apps/attendance/scripts/cloud-dashboard.mjs index 73b88f0..27a5ee1 100644 --- a/apps/attendance/scripts/cloud-dashboard.mjs +++ b/apps/attendance/scripts/cloud-dashboard.mjs @@ -47,9 +47,9 @@ function createCloudMode() { ) : null; var text = - '원격 스냅샷 · ' + - (metadata ? timestamp.format(metadata.observedAt) + ' KST · ' + age + '초 전' : '관측 시각 없음'); - text += available ? ' · 관측 시점 기준' : ' · ' + problem + ' · 표시 중단'; + '스냅샷 · ' + + (metadata ? timestamp.format(metadata.observedAt) + ' KST 기준 · ' + age + '초 전' : '관측 시각 없음'); + text += available ? '' : ' · ' + problem + ' · 표시 중단'; if (badge.textContent !== text) badge.textContent = text; badge.dataset.state = available ? 'snapshot' : 'unavailable'; document.body.dataset.cloudState = available ? 'snapshot' : 'unavailable'; @@ -244,12 +244,12 @@ function createCloudMode() { }; image.onerror = function () { image.hidden = true; - status.textContent = 'QR 코드를 표시할 수 없습니다'; + status.textContent = 'QR 표시 실패'; }; image.src = url; } catch { if (!lanIsVisible() || revision !== authRevision) return; - status.textContent = 'QR 코드를 확인할 수 없습니다'; + status.textContent = 'QR 연결 실패'; } }); } @@ -357,10 +357,10 @@ function createAutomationMonitor(auth) { node.dataset.state = current ? 'ok' : 'unknown'; node.textContent = current ? name === 'ingress' - ? 'Worker 응답 · 저장소 조회됨' + ? 'Worker·저장소 응답' : '게이트웨이 응답' : part.ok - ? '확인 시간 초과 · 현재 상태 알 수 없음' + ? '오래됨 · 현재 미확인' : part.reason; text( 'am-' + name + '-time', @@ -372,6 +372,10 @@ function createAutomationMonitor(auth) { const prefix = (part) => (fresh(part) ? '' : part.data ? '마지막 확인 값 · ' : ''); for (const name of ['sentry', 'cloudflare']) { const value = ingress.data && ingress.data.sources[name]; + text( + 'am-' + name + '-count', + (name === 'sentry' ? 'Sentry ' : 'Cloudflare ') + prefix(ingress) + (value ? value.count + '건' : '—'), + ); text( 'am-' + name, (name === 'sentry' ? 'Sentry ' : 'Cloudflare ') + @@ -585,7 +589,7 @@ export function cloudDashboard(lan) { '', ` ${INTERNAL_LOGIN_STYLES} -#cloud-snapshot { flex-shrink:0; margin:0 0 8px; padding:4px 10px; border:1px solid var(--line); border-radius:10px; color:var(--amber); background:var(--surface-low); font:500 16px/1.35 system-ui,sans-serif; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; } +#cloud-snapshot { flex-shrink:0; margin:0 0 4px; padding:2px 10px; border:1px solid var(--line); border-radius:10px; color:var(--amber); background:var(--surface-low); font:500 24px/1.2 system-ui,sans-serif; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; } #cloud-snapshot[data-state="unavailable"] { color:var(--error); background:var(--error-container); } body[data-cloud-state="unavailable"] #main, body[data-cloud-state="unavailable"] #monitor-view, body[data-cloud-state="unavailable"] #spotify-now { visibility:hidden; } body[data-cloud-core="pending"] #main { visibility:hidden; } @@ -634,7 +638,6 @@ body[data-cloud-core="pending"] #main { visibility:hidden; } "? '수집 정상' : state === 'down' ? '수집 실패' : '확인 불가';", "? '수집 정상' : state === 'down' ? '수집 실패' : '확인 불가');", ); - replace("+ ageSeconds + '초 전'", "+ ageSeconds + '초 전 수집(관측 기준)'"); replace('>KOIN 시스템 상태', '>KOIN 관측 시점 상태'); replace('lanSpotifyTitle.textContent = title;', "lanSpotifyTitle.textContent = '관측 시 · ' + title;"); replace( @@ -643,7 +646,6 @@ body[data-cloud-core="pending"] #main { visibility:hidden; } ); replace('>지금 동아리방에는', '>관측 시 동아리방에는'); replace('>지금 있는 사람', '>관측 시 입실한 사람'); - replace("'입실 중' : '퇴실'", "'관측 시 입실' : '관측 시 퇴실'"); for (const endpoint of ['/api/state', '/api/worker-health', '/api/spotify', '/api/jam']) { replace("await fetch('" + endpoint + "',", "await cloud.fetch('" + endpoint + "',"); @@ -750,9 +752,9 @@ body[data-cloud-core="pending"] #main { visibility:hidden; } '
-

오늘 체류 시간 중앙값

—

-

이번 주 체류 시간 중앙값

—

+

오늘 중앙값

—

+

이번 주 중앙값

—

이번 주 퇴근 미기록

—

-

이번 주의 발자취

확인 중
+

체류 시간

확인 중

회원별 기록을 불러오고 있습니다.

- - +

Wi-Fi 연결

-

QR 코드를 불러오는 중

+

QR 불러오는 중

BCSD_5G
-

QR 코드를 불러오는 중

+

QR 불러오는 중

BCSD_2.4G
@@ -346,10 +345,10 @@ export const DASHBOARD_HTML = /* html */ ` var image = document.getElementById(entry[0]); var status = document.getElementById(entry[0] + '-status'); image.onload = function () { image.hidden = false; status.hidden = true; }; - image.onerror = function () { image.hidden = true; status.textContent = 'Wi-Fi QR 코드를 사용할 수 없습니다.'; status.hidden = false; }; + image.onerror = function () { image.hidden = true; status.textContent = 'QR 연결 실패'; status.hidden = false; }; image.hidden = true; status.hidden = false; - status.textContent = 'QR 코드를 불러오는 중'; + status.textContent = 'QR 불러오는 중'; if (enabled) image.src = entry[1]; else image.removeAttribute('src'); }); @@ -516,14 +515,9 @@ export const DASHBOARD_HTML = /* html */ ` var row = el('tr'); var name = el('th', '', m.name); name.scope = 'row'; - var status = el('td'); - status.appendChild(el('span', m.present ? 'pill pill-live' : 'pill', m.present ? '입실 중' : '퇴실')); - row.append(name, status); - row.appendChild(el('td', 'muted', m.firstInToday === null ? '—' : timeFormat.format(m.firstInToday))); - row.appendChild(el('td', 'muted', m.lastCheckOut === null ? '—' : timeFormat.format(m.lastCheckOut))); + row.appendChild(name); row.appendChild(el('td', '', formatDuration(m.todayMs))); row.appendChild(el('td', 'duration', formatDuration(m.weekMs))); - row.appendChild(el('td', m.unrecordedWeek > 0 ? 'warn' : 'muted', m.unrecordedWeek > 0 ? m.unrecordedWeek + '건 · 시간 제외' : '—')); body.appendChild(row); }); document.getElementById('members-table').hidden = members.length === 0;