From b969992454394f83ff44706fff57fd5f8c273f69 Mon Sep 17 00:00:00 2001 From: Yunsup Sim Date: Wed, 11 Nov 2026 20:59:15 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=B6=9C=EA=B7=BC=EB=B6=80=20Mater?= =?UTF-8?q?ial=203=20=EB=AC=B4=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/attendance/README.md | 40 +- apps/attendance/src/dashboard.ts | 740 ++++++++++++++++++++++++++----- apps/attendance/wrangler.jsonc | 2 +- 3 files changed, 662 insertions(+), 120 deletions(-) diff --git a/apps/attendance/README.md b/apps/attendance/README.md index e2e1af0..d06932e 100644 --- a/apps/attendance/README.md +++ b/apps/attendance/README.md @@ -36,6 +36,17 @@ KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60 | `GET /api/state` | Basic | 현재 인원, 회원별 오늘·이번 주 시간, 최근 기록 | | `POST /api/members` | Basic, 같은 출처 JSON | `{ "uid", "name" }` 회원 등록·이름 수정 | +## 대시보드 + +- 서버가 제공하는 단일 HTML 화면에 [Mobbin의 공개 업무용 대시보드 패턴](https://mobbin.com/explore/web/screens/analytics-dashboard)을 참고한 Material Design 3의 색상·서피스·타이포그래피를 적용합니다. 사이드바 없이 구성하며 외부 폰트나 UI 라이브러리를 불러오지 않습니다. +- 현재 인원과 입실 중인 회원의 체류 시간을 크게 표시하고, 전체 회원의 오늘·이번 주 체류 시간 합계와 퇴근 미기록 건수를 보여 줍니다. 회원별 표와 이번 주 최근 세션 최대 15건을 함께 제공합니다. +- 16:9 데스크톱 화면에서는 본문 스크롤 없이 주요 패널을 한 화면에 배치하고, 긴 목록은 각 패널의 이전·다음 버튼으로 넘깁니다. 화면 크기에 맞춰 표시 개수가 바뀌고 새로고침 후에도 선택한 페이지를 유지합니다. +- 테마 버튼은 자동(시스템 설정) → 라이트 → 다크 순서로 전환하며 선택을 브라우저에 저장합니다. 작은 화면에서는 카드가 재배치되고 표는 가로로 스크롤할 수 있습니다. +- 데이터는 10초마다 갱신하고 입실 중인 회원의 체류 시계는 매초 갱신합니다. 새로고침 버튼으로 즉시 갱신할 수 있습니다. 연결 실패 시 마지막 데이터임을 표시하고 체류 시계를 마지막 동기화 시각에 멈춥니다. 처음부터 불러오지 못한 경우 수치를 임의로 표시하지 않습니다. +- 미등록 카드는 화면에서 이름을 입력해 등록합니다. 주기적 갱신에도 입력과 포커스를 유지하며 등록 중·완료·오류 상태를 카드별로 표시합니다. 등록 전 태그는 입실 기록이 아니므로 등록 후 카드를 다시 태그해야 합니다. +- 회원 이름과 카드 정보는 `textContent`로만 출력합니다. 상태 조회와 등록은 같은 출처의 기존 인증을 사용하며 등록은 JSON 요청으로만 전송합니다. +- 배포는 BCSDLab 조직 Gateway를 요구하는 접근 정책을 전제로 합니다. 일반 소비자 WARP 접속만 허용하는 정책은 충분하지 않습니다. Worker의 모든 도메인·미리보기 우회 경로를 보호하기 전에는 공개 경로를 활성화하지 않습니다. + ## 배포 ```sh @@ -46,7 +57,7 @@ pnpm deploy:attendance KV 네임스페이스는 첫 배포 때 wrangler가 만들고 `id`를 기록합니다. 기록된 `wrangler.jsonc`를 커밋합니다. 토큰은 충분히 긴 무작위 값을 사용합니다(`openssl rand -hex 32`). -벽걸이 화면은 `https://:@/` 로 열면 인증 창 없이 표시됩니다. +벽걸이 화면은 대시보드 경로(`/`)를 열고 Basic 인증 창에서 `DASHBOARD_PASSWORD`로 인증합니다. 비밀번호를 URL에 넣지 않습니다. ## 로컬 실행 @@ -98,12 +109,27 @@ Raspberry Pi 3B 기준입니다. `READER`로 리더 종류를 고릅니다. ### 설치 ```sh -sudo raspi-config nonint do_spi 0 # rc522: SPI 활성화(/dev/spidev0.0) -sudo apt install python3-spidev python3-rpi.gpio # rc522. hid는 python3-evdev -sudo install -D pi/reader.py /opt/attendance/reader.py -sudo install -m 600 /dev/null /etc/attendance-reader.env # ATTENDANCE_URL=…, DEVICE_TOKEN=…, READER=rc522 -sudo install pi/attendance-reader.service /etc/systemd/system/ -sudo systemctl enable --now attendance-reader +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 +``` + +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 ``` - 오프라인이면 태그를 `/var/lib/attendance/queue.jsonl`에 보관하고 30초마다 순서대로 재전송합니다. 태그 시각(`at`)은 파이 시계 기준이므로 NTP 동기화가 필요합니다. diff --git a/apps/attendance/src/dashboard.ts b/apps/attendance/src/dashboard.ts index 11f6b90..9df9435 100644 --- a/apps/attendance/src/dashboard.ts +++ b/apps/attendance/src/dashboard.ts @@ -8,74 +8,433 @@ export const DASHBOARD_HTML = /* html */ ` + BCSD 출근부 -
-
-

BCSD 출근부

-
+ +
+
+
+
+

CLUBROOM / ATTENDANCE

출입 현황

+
+
+
한국 표준시 · KST
+ + +
- -
-

지금 있는 사람

-
- -
-
-

이번 주 체류 시간 (월요일 시작, KST)

- - - -
이름상태오늘 첫 입실오늘이번 주퇴근 미기록
-
- -
-

최근 기록

- - - -
이름입실퇴실체류
-
-
+
+ +
+
+

지금 동아리방에는

등록 회원 확인 중

+
—명
+

함께 몰입하는 시간, 이곳에서.

+
+
+

지금 있는 사람

입실 현황
+

동아리방 현황을 불러오고 있습니다.

+
+ +
+
+
+

오늘 함께한 시간

—

전체 회원 체류 시간 합계

+

이번 주 함께한 시간

—

월요일부터 지금까지 · KST

+

이번 주 퇴근 미기록

—

미기록 세션은 시간 합계에서 제외

+
+
+

이번 주의 발자취

회원별 입실 현황과 오늘 · 이번 주 체류 시간

확인 중
+

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

+ + +

퇴실 태그 없이 6시간이 지난 세션은 ‘퇴근 미기록’으로 표시하며 체류 시간에서 제외합니다.

+
+
+
+

최근 기록

이번 주 기록 중 최근 입실 순 · 최대 15건

확인 중
+

최근 입실 · 퇴실 기록을 불러오고 있습니다.

+ + +
+
+

미등록 카드

카드의 주인을 연결해 주세요.

확인 중
+

새로 태그한 카드를 확인하고 있습니다.

+
+ +

이름은 최대 30자입니다. 등록 후 카드를 다시 태그하면 입실이 기록됩니다. 미등록 카드는 7일 뒤 자동 삭제됩니다.

+
+
+
첫 데이터를 불러오는 중

아직 동기화되지 않음 · 10초마다 갱신

+

+
+

BCSD LAB · 작은 태그 하나로 이어지는 우리의 하루

모든 시각은 KST · 저장소 반영에 최대 약 60초 소요될 수 있습니다.

+ + + diff --git a/apps/attendance/wrangler.jsonc b/apps/attendance/wrangler.jsonc index ad17b78..6a096bc 100644 --- a/apps/attendance/wrangler.jsonc +++ b/apps/attendance/wrangler.jsonc @@ -3,7 +3,7 @@ "name": "bcsd-attendance", "main": "src/index.ts", "compatibility_date": "2026-10-29", - "workers_dev": true, + "workers_dev": false, "preview_urls": false, "observability": { "enabled": true }, // id는 첫 배포 때 wrangler가 네임스페이스를 만들고 기록합니다. From 9366f00cd17fd2c578885bf1bf6b10b9fc7dcab9 Mon Sep 17 00:00:00 2001 From: Yunsup Sim Date: Fri, 13 Nov 2026 23:03:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=B6=9C=EA=B7=BC=EB=B6=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=ED=95=9C=EA=B3=BC=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=EB=A5=BC=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=A5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상태 조회와 등록 요청을 응답 본문까지 10초로 제한한다. 조회 실패 시 이전 데이터와 멈춘 체류 시계를 유지한다. 자동 갱신 버튼과 미등록 카드 페이지의 입력 포커스를 보존한다. 운영 KV를 명시해 재생성을 막고 비공개 설정과 비밀값을 유지한다. 검증: attendance 타입 검사와 동작 테스트 6개, Wrangler dry-run 통과. 실제 격리 Worker/KV와 브라우저에서 시간 초과, 복구, 포커스를 확인했다. 1280x720과 1920x1080에서 긴 목록 무스크롤을 확인했다. 운영 데이터, 접근 정책, 토큰 값은 변경하지 않았다. --- apps/attendance/README.md | 7 +++---- apps/attendance/src/dashboard.ts | 16 ++++++++++------ apps/attendance/wrangler.jsonc | 4 ++-- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/attendance/README.md b/apps/attendance/README.md index d06932e..7241728 100644 --- a/apps/attendance/README.md +++ b/apps/attendance/README.md @@ -42,7 +42,7 @@ KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60 - 현재 인원과 입실 중인 회원의 체류 시간을 크게 표시하고, 전체 회원의 오늘·이번 주 체류 시간 합계와 퇴근 미기록 건수를 보여 줍니다. 회원별 표와 이번 주 최근 세션 최대 15건을 함께 제공합니다. - 16:9 데스크톱 화면에서는 본문 스크롤 없이 주요 패널을 한 화면에 배치하고, 긴 목록은 각 패널의 이전·다음 버튼으로 넘깁니다. 화면 크기에 맞춰 표시 개수가 바뀌고 새로고침 후에도 선택한 페이지를 유지합니다. - 테마 버튼은 자동(시스템 설정) → 라이트 → 다크 순서로 전환하며 선택을 브라우저에 저장합니다. 작은 화면에서는 카드가 재배치되고 표는 가로로 스크롤할 수 있습니다. -- 데이터는 10초마다 갱신하고 입실 중인 회원의 체류 시계는 매초 갱신합니다. 새로고침 버튼으로 즉시 갱신할 수 있습니다. 연결 실패 시 마지막 데이터임을 표시하고 체류 시계를 마지막 동기화 시각에 멈춥니다. 처음부터 불러오지 못한 경우 수치를 임의로 표시하지 않습니다. +- 데이터는 10초마다 갱신하고 입실 중인 회원의 체류 시계는 매초 갱신합니다. 상태 조회·등록 요청은 응답 본문 수신까지 10초로 제한하며, 새로고침 버튼으로 즉시 갱신할 수 있습니다. 연결 실패 시 마지막 데이터임을 표시하고 체류 시계를 마지막 동기화 시각에 멈춥니다. 처음부터 불러오지 못한 경우 수치를 임의로 표시하지 않습니다. - 미등록 카드는 화면에서 이름을 입력해 등록합니다. 주기적 갱신에도 입력과 포커스를 유지하며 등록 중·완료·오류 상태를 카드별로 표시합니다. 등록 전 태그는 입실 기록이 아니므로 등록 후 카드를 다시 태그해야 합니다. - 회원 이름과 카드 정보는 `textContent`로만 출력합니다. 상태 조회와 등록은 같은 출처의 기존 인증을 사용하며 등록은 JSON 요청으로만 전송합니다. - 배포는 BCSDLab 조직 Gateway를 요구하는 접근 정책을 전제로 합니다. 일반 소비자 WARP 접속만 허용하는 정책은 충분하지 않습니다. Worker의 모든 도메인·미리보기 우회 경로를 보호하기 전에는 공개 경로를 활성화하지 않습니다. @@ -50,12 +50,11 @@ KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60 ## 배포 ```sh -pnpm --filter @koin/attendance exec wrangler secret put DEVICE_TOKEN -pnpm --filter @koin/attendance exec wrangler secret put DASHBOARD_PASSWORD +# 기존 DEVICE_TOKEN·DASHBOARD_PASSWORD는 유지합니다. 비밀값 설정·회전은 별도 운영 절차입니다. pnpm deploy:attendance ``` -KV 네임스페이스는 첫 배포 때 wrangler가 만들고 `id`를 기록합니다. 기록된 `wrangler.jsonc`를 커밋합니다. 토큰은 충분히 긴 무작위 값을 사용합니다(`openssl rand -hex 32`). +`wrangler.jsonc`의 `ATTENDANCE`는 기존 운영 KV 네임스페이스를 고정합니다. 새 네임스페이스를 만들거나 id를 바꾸면 기존 데이터와 분리되므로 배포 중 치환하지 않습니다. 새 토큰이 필요할 때만 충분히 긴 무작위 값으로 별도 설정합니다(`openssl rand -hex 32`). 벽걸이 화면은 대시보드 경로(`/`)를 열고 Basic 인증 창에서 `DASHBOARD_PASSWORD`로 인증합니다. 비밀번호를 URL에 넣지 않습니다. diff --git a/apps/attendance/src/dashboard.ts b/apps/attendance/src/dashboard.ts index 9df9435..519ce51 100644 --- a/apps/attendance/src/dashboard.ts +++ b/apps/attendance/src/dashboard.ts @@ -410,6 +410,8 @@ export const DASHBOARD_HTML = /* html */ ` } var lastPage = Math.max(0, Math.ceil(items.length / limit) - 1); var page = Math.max(0, Math.min(pagePositions.get(id) || 0, lastPage)); + var focusedIndex = kind === 'forms' ? items.findIndex(function (item) { return item.contains(document.activeElement); }) : -1; + if (focusedIndex >= 0) page = Math.floor(focusedIndex / limit); pagePositions.set(id, page); var start = page * limit; items.forEach(function (item, index) { item.hidden = index < start || index >= start + limit; }); @@ -640,6 +642,7 @@ export const DASHBOARD_HTML = /* html */ ` var response = await fetch('/api/members', { method: 'POST', credentials: 'same-origin', + signal: AbortSignal.timeout(POLL_MS), headers: { 'content-type': 'application/json' }, body: JSON.stringify({ uid: uid, name: name }), }); @@ -660,10 +663,11 @@ export const DASHBOARD_HTML = /* html */ ` refresh(); } catch (error) { message.className = 'form-message warn'; - message.textContent = '연결하지 못했습니다. 연결 상태를 확인한 뒤 다시 등록해 주세요.'; + message.textContent = '등록 결과를 확인하지 못했습니다. 목록을 갱신해 등록 여부를 확인한 뒤 다시 시도해 주세요.'; button.disabled = false; input.disabled = false; button.textContent = '다시 등록'; + refresh(); } finally { form.setAttribute('aria-busy', 'false'); } @@ -672,11 +676,11 @@ export const DASHBOARD_HTML = /* html */ ` async function refresh() { if (refreshing) return; refreshing = true; - document.getElementById('refresh').disabled = true; - document.getElementById('retry').disabled = true; + document.getElementById('refresh').setAttribute('aria-busy', 'true'); + document.getElementById('retry').setAttribute('aria-busy', 'true'); document.getElementById('refresh').textContent = '갱신 중'; try { - var response = await fetch('/api/state', { cache: 'no-store', credentials: 'same-origin' }); + var response = await fetch('/api/state', { cache: 'no-store', credentials: 'same-origin', signal: AbortSignal.timeout(POLL_MS) }); if (!response.ok) throw new Error(String(response.status)); var state = await response.json(); serverOffset = state.now - Date.now(); @@ -714,8 +718,8 @@ export const DASHBOARD_HTML = /* html */ ` tick(); } finally { refreshing = false; - document.getElementById('refresh').disabled = false; - document.getElementById('retry').disabled = false; + document.getElementById('refresh').setAttribute('aria-busy', 'false'); + document.getElementById('retry').setAttribute('aria-busy', 'false'); document.getElementById('refresh').textContent = '새로고침'; } } diff --git a/apps/attendance/wrangler.jsonc b/apps/attendance/wrangler.jsonc index 6a096bc..cbc04d7 100644 --- a/apps/attendance/wrangler.jsonc +++ b/apps/attendance/wrangler.jsonc @@ -6,6 +6,6 @@ "workers_dev": false, "preview_urls": false, "observability": { "enabled": true }, - // id는 첫 배포 때 wrangler가 네임스페이스를 만들고 기록합니다. - "kv_namespaces": [{ "binding": "ATTENDANCE" }], + // 기존 운영 네임스페이스를 재사용합니다. 새 네임스페이스로 치환하지 않습니다. + "kv_namespaces": [{ "binding": "ATTENDANCE", "id": "2cc47a111f334c4ea99deb2435263e00" }], }