Commit graph

18 commits

Author SHA1 Message Date
유창욱
c3227c179c fix(security,edge,test): close the confirmed audit backlog; add the guards that prevent recurrence
감사에서 확정된 잔여 항목을 일괄 처리. 이전 커밋이 증상(스테일 번들)을 고쳤다면 이번에는
그것이 조용히 지나가게 만든 구조를 고친다.

보안
- CSP 도입. 인라인 2블록에 per-request nonce, unsafe-inline 없음. 빌드가 없다는 제약이
  nonce를 막지는 않는다. 보안 헤더를 .htaccess 단독에서 PHP로 옮겨 개발/납품이 같아진다.
- /logout에 CSRF. SameSite=Lax가 막아주지만, 사고 대응 중 운영자를 콘솔에서 튕겨내는 건
  이 제품에선 실제 가용성 공격이라 예외로 둘 이유가 없다.
- 잠긴 계정 423 → 401. 아무나 이름을 추측해 5회 실패로 잠근 뒤 423을 계정 실재 확인으로
  쓸 수 있었다. 잠금 사실은 감사 로그에 남고, 익명 호출자는 못 읽는다.

엣지
- 큐 flush에 회당 상한. 정전 복구 직후 2000건을 비우는 동안 감지가 멈추던 구간을 없앤다.
- 타이머 env 전수 검증. COOLDOWN_S=nan은 dedup을 무력화해 매 틱 발보시켰다.
- T2 프레임 스킵(INFER_EVERY). 스킵 틱은 프레임을 읽어 버퍼만 비우고 링버퍼에 넣지 않는다 —
  추론이 없으면 얼굴 박스도 없어 그대로 넣으면 증거 스냅샷에 미블러 얼굴이 실린다.
  T3(모션 게이트)는 의도적으로 미구현: 게이트가 틀리면 '움직이지 않는 쓰러진 작업자'에게
  추론이 안 돈다. 실영상 없이 임계값을 정할 수 없다.
- systemd 워치독(sd_notify) + 메모리 상한. Restart=always는 죽은 프로세스만 잡고
  '살아 있는데 멈춘' 상태는 못 잡는다. UVC 해상도·버퍼 고정(링버퍼 RSS가 웹캠 기본값에 좌우).

테스트 — 감사의 최대 갭
- PDO 통합 테스트가 pdo_sqlite 부재로 통째로 스킵되고 CI도 없어 사실상 한 번도 안 돌았다.
  `-d extension=pdo_sqlite`로 되살리고 리포트·히트맵·익스포트 집계 SQL을 최초로 실행한다.
- 얼굴 블러 테스트가 out is not None만 봤다 — blur()가 무동작이어도 통과. PIPA 대응의
  유일한 실행 가능한 증거였는데 아무것도 증명하지 못했다. 디테일 파괴를 단언하도록 교체.
- index.php 라우트 테이블 대조(프런트 호출 15개 교차검증), dist↔소스 패리티 테스트,
  CI 신설(스킵이 하나라도 있으면 실패).
- ArraySessionStore.regenerate()가 데이터를 지워 실제 session_regenerate_id(true)와
  달랐다. 더블이 실구현과 어긋난 자리 하나를 실제로 잡았다.

프런트
- 이벤트 목록 시그니처 재렌더 스킵 + 포커스 복원. aria-live 영역을 5초마다 통째로 다시
  만들어 스크린리더가 200행을 재낭독하고 키보드 포커스가 body로 날아갔다. 같은 패턴이
  device-tree.js에는 이미 있었다.
- 폴링 중첩 가드(느린 공유호스팅에서 응답 역전), 콜드스타트 실패 표시.

납품
- VERSION 신설, 두 산출물에 version/git_commit/built_at 스탬프. 스테일 번들의 SHA256SUMS는
  자기정합적이라 통과한다 — provenance 없이는 하류에서 탐지할 방법이 없다.
- deploy/README·env.example을 Hailo 단독 번들 실제와 일치(템플릿이 없는 ONNX를 가리켰다).
- 이벤트 보존 정책은 기본 0(영구). 산업안전 기록을 시스템이 임의로 지우지 않는다.

검증: pytest 251 / PHPUnit 162, 스킵 0. CSP nonce 결합은 실제 렌더 경로로 확인.
브라우저 실렌더는 MySQL이 필요해 이 환경에서 미수행.
2026-08-07 23:31:53 +09:00
유창욱
6b1f4c483f fix(security,delivery): scope improvement actions by site; correct upgrade procedure
프로젝트 8개 차원 감사(종합 82/100)에서 확정된 상위 3건 수정.

1. 개선 조치(move_actions)가 유일하게 site 스코프 규율에서 이탈 — 스코프 운영자가
   타 현장의 조치 기록(카메라 ID·메모·작성자)을 목록에서 읽고, 타 현장 카메라에
   조치를 기록해 그 현장 리포트의 전후 효과 지표를 오염시킬 수 있었다.
   - move_actions.site 컬럼 + (site, taken_at) 인덱스, 기존 행 백필 마이그레이션
   - 카메라 소유 현장은 이벤트에서 해석 (move_cameras는 등록 플로우가 없어 항상 비어 있음)
   - 오타 카메라는 400: 전후 효과가 영원히 0/0이 되는 조치를 애초에 못 만들게 한다
   - 타 현장 카메라는 404: 존재 여부를 숨기는 기존 규약 유지

2. README-UPLOAD 업그레이드 절차가 마이그레이션 7종 중 1종만 지시했다. move_ 접두
   리네임을 건너뛰면 이후 전부 실패하고 전 요청이 500이 된다 — 순서·적용조건·재실행
   비안전성을 표로 명시. 문서 4곳의 테이블 수(10→11)·비프리픽스 표기도 정정.

3. 두 납품 번들 재생성: 8/6 이후 4개 기능 커밋이 반영되지 않아 Report/Export/Actions
   컨트롤러와 8/7 마이그레이션이 빠진 채였다. 엣지 소스 패리티도 회복.

검증: pytest 235 passed / PHPUnit 147 passed (PDO 4 skip), 번들 산출물 내부 검증
(28 클래스 해석·전 파일 php -l·SHA256SUMS 55행·휠 11개·HEF 다이제스트 일치).
2026-08-07 18:06:32 +09:00
유창욱
6b69f22275 feat(data-asset): labeled-dataset export + improvement-action effect tracking
The operator's daily clicks become durable assets:

Label export (GET /api/export/labels?days=N, JSONL download):
- Every 확인/오탐 click is a human verdict on a detection, and each verdict
  carries a blurred 3-frame evidence timeline. The export turns that into a
  dataset: one JSON line per event with type/severity/camera/zone/metrics/
  spatial, label (confirmed | false_positive | unreviewed), labeled_by, and
  snapshot uuids (images fetched via /snap, which re-checks session + site).
  ergo_report rows are excluded - statistics are not labelable detections.
- Admin-only ('manage_compliance'): bulk export of detection history is a
  compliance decision, not an operator convenience. Site scope applies.
- This is where the deferred P1-C evaluation set will come from for free.

Improvement actions (move_actions + GET/POST /api/actions):
- Record a site fix against a camera ("미끄럼 방지 매트 설치"); the report's
  new card compares detection density over EQUAL windows before/after the
  fix (capped 30d; under 3 days shows "측정 중" - two days of data is noise,
  not a verdict). Improvement shows accent, worsening shows red - the card
  reports honestly in both directions, which is exactly what makes the
  improvement numbers credible.
- Future timestamps and >1y-old entries are rejected (the comparison could
  not be fair). Recording needs edit_zones + CSRF.

Verified: PHPUnit 145 pass (5 new: export perm gate + line shape/labels/
snapshot join, action validation, equal-window effect math, pending state)
+ Chrome real-render (action card with better/worse/pending rows, export
link, no console errors).
2026-08-07 07:44:00 +09:00
유창욱
d8b8e1f381 feat(analytics): weekday-hour pattern, precursor-spike alert, period trends
The accumulated data starts predicting instead of just recording:

- Time pattern (weekday x hour density grid): WHEN the site is dangerous, on
  one card - shift-change, post-lunch, Thursday-afternoon hotspots become
  visible. Bucketing runs in PHP on the server-local clock (site timezone)
  so the SQL stays portable (the sqlite harness has no FROM_UNIXTIME).
- Precursor-spike alert: last-7-days count of precursor signals (near-miss,
  zone entries, occupancy violations - signals, not injuries) vs the prior
  4-week weekly baseline. Ratio >= 1.5 with at least 5 samples raises a
  banner above the KPIs: an alarm on the PRECURSOR of an accident, with a
  concrete suggestion (staffing/route/rest review). Below the sample floor
  it never fires - a ratio over 2 events is noise, not statistics.
- Period-over-period trends on every KPI: reportSummary gained an optional
  upper bound so the identical aggregation runs over the preceding window.
  Worsening turns red, improving turns accent - except the near-miss count,
  which stays NEUTRAL: more near-misses may mean better detection, not more
  danger, and the report must not editorialize that.

Verified: PHPUnit 140 pass (2 new: window separation + pattern bucketing,
spike ratio + sample floor) + Chrome real-render (alert banner, KPI trend
lines, Thursday-afternoon hotspot readable in the grid).
2026-08-07 07:35:30 +09:00
유창욱
51eed139cf feat(evidence): pre-event snapshot timeline + 24h catch-up brief
Two operator-value features: evidence that shows HOW it happened, and the
one-line answer to "what happened overnight".

Evidence timeline (closes the P1-B "前中後 스냅샷 배선" follow-up):
- The ring buffer now stores per-frame head boxes, and an event captures the
  frames nearest T-2.5s / T-1.0s / T-0 (server cap: 3 snapshots). A fall's
  evidentiary value is in the seconds BEFORE the person hit the floor - one
  end-state frame never showed how it happened.
- Each past frame is blurred with the head positions of ITS OWN moment -
  blurring history with the current tick's boxes would leave a face exactly
  where it actually was (PIPA).
- Right after startup the ring is short: fewer frames go out, never zero and
  never duplicated. Detail view captions the sequence 이전/직전/발생 시점 by
  position (offsets are edge config the server doesn't know).

24h catch-up brief:
- The events console renders one line above the stream on load - "지난 24시간
  - 감지 N건 (중대 n) · 니어미스 n · 평균 대응 t · 미확인 n건 남음" - reusing
  GET /api/report?days=1, with a link into /report. High-tinted when the
  window held critical/high events; hidden entirely until data arrives.

Verified: pytest 235 pass (3 new: timeline selection, short-buffer dedup,
3-frame capture through run_once) / PHPUnit 138 pass + Chrome real-render
(brief line with severe tint on the console; detail page showing the
3-frame timeline with captions; no console errors).
2026-08-07 07:25:25 +09:00
유창욱
d8cef54fe4 feat(report): safety operations report page + event-type filter
The benefit made visible: /report folds everything the system collects into
the one printable page a 안전관리자 takes into the weekly safety meeting.

- GET /api/report?days=N (7/30/90): severity totals, per-type counts,
  worst-camera ranking, response time (TTA - acknowledged critical/high only,
  FP-tagged rows excluded: dismissing a false alarm is not an emergency
  response; clock-skew guarded), the near-miss pyramid (near_fall vs
  fall/no_motion - the Heinrich argument in one ratio), FP rate, summed
  posture-load exposure, occupancy-rule violation counts. ergo_report rows
  feed the sums but are excluded from "감지 N건" (bookkeeping, not detection).
  Fail-closed site scoping throughout.
- /report view: KPI band (중대 이벤트 / 평균 대응 / 니어미스:사고 / 오탐율,
  each with its one-line reading), bar lists for types and cameras (critical
  presence highlighted), ergo bands as share-of-observed-time, rule tiles.
  Print CSS strips the app chrome - the 인쇄 button produces the meeting
  handout. New 리포트 nav tab.
- Events console: event-type filter beside severity - the INFO-volume types
  (near_fall, ergo_report) can now be folded out of the live stream, or a
  single type isolated for investigation.

Verified: PHPUnit 138 pass (3 new: aggregation math incl. TTA/FP-exclusion/
ergo sums, site scope, auth) + Chrome real-render of the full report (KPIs,
bar lists, rule tiles, footer) and the type filter isolating one type.
2026-08-07 07:17:48 +09:00
유창욱
0ffc41681a build(delivery): regenerate both delivery bundles; ship migrations in the cafe24 package
- cafe24 package now stages server/migrations/ beside schema.sql: schema.sql is
  for a FRESH database only, an already-imported host upgrades by running the
  migrations in date order (then deletes them, like schema.sql).
- Rebuilt dist/1_cafe24_upload (+ move-detect-cafe24.zip): autoloader resolve
  check 28 classes, parse check, all of today''s controllers/assets/schema in.
- Rebuilt dist/2_pi_install with --cross: aarch64/cp311 wheelhouse (11 wheels,
  onnxruntime included), HEF verified against the pinned hailo8 digest,
  requirements.lock + sbom.json + SHA256SUMS regenerated, ergonomics detector
  staged.
- FLAG: the MoveNet ONNX upstream URL 404s, so this Pi bundle is Hailo-only
  (no CPU-fallback pose model). fetch_movenet.py needs a new source URL before
  a delivery that requires the CPU path.
2026-08-06 21:51:08 +09:00
유창욱
b358822247 feat(ergo): posture-load exposure scoring (REBA bands) with anonymous reports
Same pose stream, new safety axis: musculoskeletal-load prevention. This is an
honest 2D reduction of REBA - band exposure TIME, not a precise REBA score.

Edge:
- geometry: arm_elevation_deg (shoulder->elbow vs vertical-down, best of both
  arms) and knee_flexion_deg (hip-knee-ankle interior angle, worst of both
  legs). Both return None when no confident side exists - "unmeasurable" must
  never read as "0 deg / straight".
- detectors/ergonomics.py: per-frame dt accumulation into bands (trunk 20-60
  and 60+ deg via the existing torso angle, arm 90+ deg, knee <110 deg), gated
  on confident core keypoints; detection gaps are dt-capped (2s) so a dropout
  never counts as exposure. Hourly per-camera report, skipped when nothing was
  observed (an empty-workshop report is noise).
- Engine: tracks in an incident state (FALLING/FALLEN/NO_MOTION) are excluded
  - a fallen worker's 90-deg trunk is an incident, not an ergonomic exposure.
  The report event (ergo_report/INFO) carries no track_id and no spatial:
  a camera-level anonymous aggregate, per the PIPA-first product stance.
- run_once: NO snapshots on ergo_report (a frame would turn a statistic into
  surveillance imagery); capture is skipped entirely when only ergo fired.

Server/UI: ergo_report accepted (validator + chk_events_type, edited in the
still-undeployed heatmap migration); detail view now renders event metrics
with Korean labels and seconds humanized - the ergo report's whole value IS
its metrics. Korean type label in list + detail.

Verified: pytest 232 pass (10 new) / PHPUnit 135 pass. NOTE: the detail-view
visual check is pending - the Chrome extension disconnected mid-verification;
harness is staged under the session scratchpad (events/1/view).
2026-08-06 21:46:39 +09:00
유창욱
47ab1143e1 feat(fp): operator false-positive tag + per-camera FP-rate report
The feedback loop, per product decision (option a): tags feed STATISTICS only.
A human reads the rate and re-tunes a camera deliberately - the tag never
adjusts edge sensitivity on its own (no silent behavior change in a safety
system).

- POST /events/{id}/false-positive: same RBAC ('acknowledge') + CSRF + site
  scope as ack. Tagging also acknowledges (an FP is a handled event - leaving
  it unacknowledged would keep it alarming). COALESCE keeps the FIRST tagger:
  a second tag never rewrites who made the original audit-relevant call.
- move_events.false_positive_by/at (migration included), audit-logged.
- GET /api/fp-stats?days=N: per-(camera, event_type) totals + FP counts,
  FP>0 groups only, fp-desc, fail-closed site scoping.
- Console: each open row gets a visually-secondary 오탐 button beside 확인
  (can't be fat-fingered as the default); resolved rows show a mono verdict
  ("오탐 (user)" struck through vs "확인됨 (user)"). New rail panel "오탐율 ·
  30일" lists top offenders - 30%+ highlighted as re-tuning candidates -
  refreshed on a slow 60s cadence plus immediately after a tag. Detail view
  shows the tag.

Verified: PHPUnit 135 pass / pytest unchanged + Chrome real-render (tag ->
row flips to struck-through verdict + drops out of unacked KPI/tree counts ->
FP panel updates immediately; no console errors).
2026-08-06 20:38:36 +09:00
유창욱
9e4225a338 feat(heatmap): near-miss events + spatial ingestion + camera risk-heatmap overlay
Heinrich's-law play: the signals we already compute but used to throw away
become a spatial risk map of the workplace, drawn over the camera's own frame.

Edge:
- near_fall (INFO): a track that reached FALLING (sustained prone) but recovered
  before the FALLEN confirm. Treated as a full event per product decision -
  same ingest, snapshots, dedup, ack flow as incidents. Fix en route: prone_s
  used a truthiness check that turned prone_since=0.0 into "no timer".
- Every per-track event (fall/no_motion/near_fall/immobility/zone hits) now
  carries the foot point in the zones' normalized 0..1 space (reusing the zone
  detector's own coordinate math); ghost's pixel-space loss point is normalized
  at emit. to_payload omits the key when absent, so an old server ignores it.

Server:
- move_events.spatial_x/spatial_y (nullable DOUBLE) + validator: optional
  [x, y] finite-number pair; range deliberately NOT enforced (an off-by-a-hair
  foot must never reject a FALL alert - renderers clamp).
- MIGRATION FIX: chk_events_type would have REJECTED the previous commit's
  lone_worker/overcapacity/overstay inserts on MySQL 8.0.16+ - the constraint
  is rebuilt with all 13 types (incl. near_fall).
- GET /api/heatmap/{camera}?days=N (1..90, default 30, 5000-point cap):
  spatial points with the same fail-closed site scoping as the event list.

UI (zone editor): "위험 히트맵" toggle + day window. Additive radial blots in
an offscreen layer under the zone polygons; severity weighs the alpha so a
cluster of near-misses reads lighter than one confirmed fall at the same spot.
Events console/detail label near_fall in Korean.

Verified: pytest 222 pass / PHPUnit 130 pass + Chrome real-render (blue
near-miss cluster + red incident hotspot inside the restricted zone, amber
cluster in the warning zone, zones drawn on top; no console errors).
2026-08-06 20:19:09 +09:00
유창욱
a203c42bf3 feat(zones): occupancy rule engine - lone-worker / capacity / dwell limits
Per-zone declarative safety rules, set in the zone editor and enforced on the
edge from the tracking the pipeline already does (no new geometry, no new model):

- min_workers (2인 1조): 0 < count < minimum sustained 30s -> lone_worker/HIGH.
  A solo worker in a buddy-rule zone is a compliance violation and a rescue-
  delay risk, so it outranks the other two.
- max_workers: count > limit sustained 10s -> overcapacity/WARNING.
- max_dwell_s: one track's continuous stay past the limit -> overstay/WARNING.
  A short detection dropout (<=5s grace) does NOT reset the dwell timer -
  without that, run_once's stale-track filter would quietly neuter every
  10-minute rule at real-world detection rates.

Edge: detectors/occupancy.py consumes Zone.rules; the engine flips the zone
detector's per-track membership into per-zone headcounts (stale tracks already
excluded), so counts always match what was actually detected this tick. Fired
states clear on condition recovery - re-alerts need a fresh violation, not a
cooldown expiry. Malformed rules_json keeps the zone's intrusion detection
alive and logs the rules as inactive (never fail dark).

Server: move_zones.rules_json (nullable JSON, migration included) validated
strictly in ZoneConfigController (bounds, min<=max, unknown keys rejected -
a silently dropped rule is one the operator believes is active). Delivered to
the edge unchanged via GET /api/config. EventValidator accepts the 3 new types.

UI: zone editor gains the three rule fields (dwell entered in minutes, wired
in seconds) and shows each zone's rules in its list row; the events console
and detail view now render every event type in Korean (wire codes fall back).

Verified: pytest 216 pass / PHPUnit 123 pass + real-render harness in Chrome
(editor save payload, list labels, console type labels).
2026-08-06 19:33:12 +09:00
유창욱
eaff1765a4 feat(dashboard): device -> camera tree rail with pair-scoped camera filter
- Rail rework: the flat device strip + camera <select> becomes one device->camera
  tree (device-tree.js owns the DOM). A device row carries the heartbeat verdict
  (online/offline, fps, temp, throttle, config drift); the cameras seen in the
  event stream nest under it and double as the stream's camera filter.
- The camera filter is a (device, camera) PAIR: camera ids are free-form edge
  strings, so two devices may both run a "cam-01" - one device's events no longer
  leak into another's filtered view. Clicking the selected camera (or 전체 보기)
  clears the filter.
- A camera whose device is missing from /api/devices renders as its own
  unknown-device group ("장치 목록에 없음 - 등록 확인") instead of being dropped.
- Offline rows show liveness only - fps/temp are the LAST values a dead device
  reported and would read as current telemetry.
- Poll-failure staleness, collapse state, and focus survive the 5s re-render;
  an unchanged poll never rebuilds the DOM (render signature).
- Stream rows: device NAME + mono camera id + compact 24h stamp (year only when
  not current); thumb slot links to the incident and is honest about 무영상 for
  screen readers; KPI strip captioned "미확인" once.

Verified: PHPUnit 119 pass (4 skipped, PDO gate) + real-render harness in Chrome
(tree states, pair filter, collapse, reset, no console errors).
2026-08-06 16:54:44 +09:00
유창욱
9d25e0a2d1 feat(dashboard): redesign as a dense AI CCTV operations console
Rework the events dashboard from the sparse light-minimal look (which read as
empty / unprofessional) into a professional, information-dense operations
console — keeping the LIGHT direction (the dark HMI was previously rejected).

- App shell: top bar with an "AI SAFETY CCTV" brand mark + product identity, and a
  device/camera rail (a second neutral layer) beside the detection stream.
- Density: device health + filters move into the rail; the four hero KPI cards
  become a compact inline pill strip; the stream is snapshot-forward.
- Every event row is a camera slot (evidence thumbnail, or a "무영상" placeholder)
  so the stream reads as a CCTV grid and rows stay aligned regardless of snapshots.
- Restrained near-monochrome; indigo for interaction only; severity colour spent on
  the unacknowledged-critical alarm only; contrast-checked ink; reduced-motion honoured.
- Pure CSS, system fonts, zero external assets (air-gap). DOM contract (ids/classes)
  unchanged, so polling/ack/alarm/filter logic is untouched. Verified in-browser.
2026-07-25 23:58:09 +09:00
유창욱
9ed759befc docs(deploy): fix MySQL/runbook/secret guidance; offline camera probe
- server-deploy.md: state MySQL 8.0.13+ / MariaDB 10.2+ is required (expression
  defaults), not "5.7+" — an import against 5.7 is a day-1 ERROR 1064 blocker.
- pilot-runbook: add the air-gapped Pi path and mark pip/fetch as build-machine
  only; stop the shipped doc from telling a Pi operator the bundle "does not exist".
- install_offline.sh: instruct the camera probe before enable (mirrors setup_online).
- setup_online.sh: chmod 600 device.env on read; README documents delete + rotate.
- systemd unit: order After/Wants=time-sync.target so occurred_at is real wall time.
2026-07-25 19:21:01 +09:00
유창욱
bf5255e9eb feat(dashboard): device-status panel + event-list rework, hardened
- Device-status panel (DeviceStatusController): site-scoped liveness over stored
  heartbeat telemetry. Fix config_drift false-positive by storing NULL until the
  device actually reports a version (was firing "설정 미적용" on every device).
- Zone save rejects a blank/unknown device_id (DeviceRepository::exists) so a zone
  is never persisted against a device that will never fetch it.
- Snapshot proxy sends immutable private cache headers, ending the 5s-poll
  re-download of every thumbnail (a Cafe24 traffic-cap hazard).
- Event list: paged-in acknowledgements stick; honesty affordances for the paging
  gap, a stale device poll, and a filtered camera aging out; logout wired for
  every page via the shared layout.
2026-07-25 19:20:42 +09:00
유창욱
aa4378e906 feat(dashboard): load a camera's latest frame as the zone-editor background
Drawing hazard zones on a blank canvas meant guessing pixel coordinates.
The editor can now pull the camera's most recent snapshot and draw over
what the camera actually sees.

- SnapshotRepository::latestForCamera() (+ Pdo/in-memory impls), ordered by
  snapshot_id, not the device-reported occurred_at (a skewed Pi RTC would
  otherwise pin 'latest' to a stale frame)
- GET /api/cameras/{camera}/latest-snapshot -> {uuid}; returns only the id,
  the bytes still go through SnapshotProxy's session + site-scope check.
  Out-of-scope and missing both 404, so a scoped operator cannot probe for
  another site's cameras
- ix_events_camera (camera_id, event_id) so the lookup is not a full scan
- '현재 화면 불러오기' button in the zone editor

Tests: PHP 113 pass (4 skipped, 4 new), pytest 174 pass.
2026-07-20 23:41:58 +09:00
유창욱
a2d2d24793 build: name the two delivery folders and stop archiving by default
Delivery had two ambiguously-named outputs and always produced an
archive, which made it unclear what actually gets uploaded/copied.

- dist/1_cafe24_upload/  -> FTP-upload contents to the Cafe24 web host
- dist/2_pi_install/     -> copy folder to the Pi, run ./install_offline.sh

Archives are now opt-in: MAKE_ZIP=1 (Cafe24) / MAKE_TARBALL=1 (Pi).
Docs and tests updated to the new paths.

Tests: 165 pass, 2 skipped.
2026-07-20 12:58:44 +09:00
유창욱
3f2823a764 refactor: rename cloud/ to server/ (cafe24 is web hosting, not cloud)
The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud'
was misleading. Renames the directory, the edge client module, and
every doc/script/identifier that used the term.

- cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths)
- edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient
- cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md
- CLOUD_* env/config keys -> SERVER_*
- tests renamed to match

Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
2026-07-20 11:30:52 +09:00