Compare commits

...

74 Commits

Author SHA1 Message Date
64515333ae 📋 HOTFIX-005 계획 추가
- 프로젝트 카드 아바타 제거
- Sprint timeline / Hotfix history 분리
2026-04-04 21:31:19 +09:00
09dbb57cd7 Merge pull request 'SPRINT-013: 자매 관리/타임라인/선택 UX 정리' (#12) from feature/sprint-013 into main 2026-04-04 21:19:21 +09:00
b34da9a288 QA: SPRINT-013 review iteration 2 — PASSED 2026-04-04 21:18:03 +09:00
78ca4a72bb fix(sprint-013): admin harness/logs selector 이모지 제거 2026-04-04 21:17:23 +09:00
c8f82ba9a8 QA: SPRINT-013 review iteration 1 — FAILED 2026-04-04 21:16:07 +09:00
a40b6eda32 feat(sprint-013): 자매 관리/타임라인/선택 UX 정리
TASK-057: 자매 상세 설정 탭 제거 (개요/세션/활동/하네스/로그만 유지)
TASK-058: 관리 > 자매 관리 카드 이모지 제거, SisterAvatar 적용
TASK-059: 프로젝트 상세 이력 API/history 추가, Sprint + Hotfix timeline 표시
TASK-060: GlobalStyle에 user-select 정책 추가 (기본 none, 편집/로그/입력/코드만 text)

테스트 23/23 pass, FE 16 routes build 성공
2026-04-04 21:14:16 +09:00
c2a60f910b 📋 Sprint 013 계획 추가
- 자매 상세 설정 탭 제거
- 관리자 자매 관리 카드 이모지 제거
- Hotfix 진행 이력 표시
- user-select 정책 정리
2026-04-04 21:10:24 +09:00
fb21678870 Merge pull request 'SPRINT-012: 정보 구조 재배치 + 대시보드 그래프 강화' (#11) from feature/sprint-012 into main 2026-04-04 20:57:34 +09:00
a4f391d15b QA: SPRINT-012 review iteration 1 — PASSED 2026-04-04 20:56:10 +09:00
ad0b489633 feat(sprint-012): 프로젝트 UI 재설계 + 자매 상세 탭 통합 + 대시보드 그래프
TASK-052: 프로젝트 목록 3구역 구조로 재배치 (avatar/name-desc | progress+sprint counts | status+updated)
TASK-053: 프로젝트 대표 avatar를 자매 프로필 사진으로 교체 (ownerSister 기반)
TASK-054: 자매 상세 탭에 세션/활동/하네스/로그 통합
TASK-055: 메인 대시보드에 실데이터 막대 그래프 2종 추가 (sister load, project progress)
TASK-056: 관리자 상단 탭에서 하네스 제거 (자매 상세로 이동)

테스트 23/23 pass, FE 16 routes build 성공
2026-04-04 20:54:45 +09:00
9f6bba9aeb 📋 Sprint 012 계획 추가
- 프로젝트 진행률 UI 재설계
- 자매 프로필 사진 활용
- 하네스/로그를 자매 상세로 통합
- 대시보드 실데이터 그래프 강화
2026-04-04 20:49:59 +09:00
7a91589ded Merge pull request 'HOTFIX-004: DISK bar + 카드 전체 클릭 + 운영 메타 교체' (#10) from hotfix/sisters-card-ui into main 2026-04-04 20:19:18 +09:00
5c810b03c6 QA: HOTFIX-004 review iteration 1 — PASSED 2026-04-04 20:17:54 +09:00
2cacdcf54e fix(hotfix-004): DISK bar + 카드 전체 클릭 + 운영 메타 교체
TASK-049: DISK 사용률 퍼센트 계산 추가, NaN/0 fallback 처리
TASK-050: 자매 카드 전체를 Link로 전환
TASK-051: 우측 영역을 리소스 중복 텍스트에서 운영 메타(STATUS/ROLE/LXC/LAST CHECK/CURRENT TASK)로 교체

테스트 23/23 pass, FE 16 routes build 성공
2026-04-04 20:16:35 +09:00
59bf0f38de 📋 HOTFIX-004 계획 추가
- DISK progress bar 렌더 수정
- 카드 전체 클릭 가능화
- 우측 메타 중복 제거
2026-04-04 20:14:39 +09:00
b342d6be52 Merge pull request 'HOTFIX-003: 자매 상세 프로필 hero 확대 + activity stderr 정리' (#9) from hotfix/profile-header into main 2026-04-04 19:49:43 +09:00
89a590839d QA: HOTFIX-003 review iteration 1 — PASSED 2026-04-04 19:48:20 +09:00
16660e5611 fix(hotfix-003): 자매 상세 hero 확대 + activity raw stderr 사용자 친화화
TASK-046: /sisters/[name] 상단 프로필 hero 재디자인, avatar 120px 확대
TASK-047: 개요 레이아웃 우선순위 재정렬 (hero → system summary → activity)
TASK-048: ActivityService sanitize 추가
  - stderr/bash command not found/raw 내부 오류 직접 노출 금지
  - 원격 노드 연결 실패 메시지 사용자 친화화

테스트 23/23 pass, FE 16 routes build 성공
2026-04-04 19:46:58 +09:00
fcdac04481 📋 HOTFIX-003 계획 추가
- 자매 상세 프로필 헤더 확대
- 개요 레이아웃 위계 조정
- activity raw stderr 노출 정리
2026-04-04 19:44:34 +09:00
f7f0d050ec Merge pull request 'HOTFIX-002: Sprint task count + 완료 count 계산 보정' (#8) from hotfix/task-count into main 2026-04-04 18:37:52 +09:00
49bba64f1f QA: HOTFIX-002 review iteration 1 — PASSED 2026-04-04 18:36:26 +09:00
3ce7169c21 fix(hotfix-002): Sprint task count/완료 수 파싱 보정
TASK-044: sprint 문서에서 실제 task heading/list 파싱
  - ### TASK-001: ...
  - - [ ] TASK-001 ...
  - - [x] TASK-001 ...
  - - TASK-001: ...
  - TASK-001: ...
  - 중복 taskId 방지
TASK-045: Sprint status done이면 task status 정보 없을 때 done fallback
  - parsed task를 DB Task 테이블 upsert
  - 프로젝트 상세의 태스크 수/완료 수 정상 반영 기반 마련

테스트 23/23 pass, FE 16 routes build 성공
2026-04-04 18:34:38 +09:00
1a5ad12e54 📋 HOTFIX-002 계획 추가
- Sprint task count 파싱 수정
- 완료 task count 계산 보정
2026-04-04 18:32:26 +09:00
b5d7746e36 Merge pull request 'SPRINT-011: 진행률 계산 수정 + SisterAvatar 공통 컴포넌트' (#7) from feature/sprint-011 into main 2026-04-04 17:47:10 +09:00
ff3a057b28 QA: SPRINT-011 review iteration 1 — PASSED 2026-04-04 17:45:43 +09:00
b85ca55482 fix(sprint-011): 진행률 실제 계산 + Sprint status QA 기반 파싱 + 아바타 공통 컴포넌트
TASK-040: SprintSyncService .qa/SPRINT-XXX-review-*.md PASSED 여부로 status 결정
TASK-041: ProjectsService 진행률을 Sprint done수/전체Sprint수 기반으로 수정
TASK-042/043: SisterAvatar 공통 컴포넌트 생성, 대시보드/자매/상세 페이지 적용
  - onError 시 이니셜 fallback으로 전환 (숨기기 제거)
  - API_URL 기반 src 사용

테스트 23/23 pass, FE 16 routes build 성공
2026-04-04 17:43:51 +09:00
8a59b63d9f 📋 Sprint 011 계획 추가
- 진행률 0% 문제 수정
- Sprint status 실제 반영
- 자매 프로필 사진 실제 노출 수정
- 아바타 UI 일관화
2026-04-04 17:39:35 +09:00
7aab46bae9 Merge pull request 'SPRINT-010: API Key 제거 + JWT 단일화 + dead code 정리' (#6) from feature/sprint-010 into main 2026-04-04 17:27:48 +09:00
b4855accee QA: SPRINT-010 review iteration 1 — PASSED 2026-04-04 17:26:23 +09:00
176cb51f8a feat(sprint-010): API Key 완전 제거 + JWT 전용 정리 + 죽은 코드 청소
TASK-036: admin/layout.tsx API Key UI 완전 제거
TASK-037: adminFetch JWT 전용 단순화 (api key fallback 제거)
TASK-038: ApiKeyStrategy, ApiKeyGuard, CompositeGuard 제거
  - api-key.strategy.ts, api-key.guard.ts 파일 삭제
  - 모든 컨트롤러 @UseGuards JwtGuard로 전환
  - AuthModule에서 ApiKey 관련 providers 제거
TASK-039: PUBLIC_PATHS에서 /register 제거, login FooterLink dead code 제거

테스트 23/23 pass, FE 16 routes build 성공
2026-04-04 17:24:32 +09:00
784000ebba Merge pull request 'SPRINT-009: 회원가입 제거 + Sprint 동기화 + 진행률 계산 수정' (#5) from feature/sprint-009 into main 2026-04-04 17:18:43 +09:00
489f7cd649 QA: SPRINT-009 review iteration 2 — PASSED 2026-04-04 17:12:15 +09:00
39c1cdd6f1 fix(sprint-009): B1 repoUrl trailing slash 정규화 + B2 status 파싱 범위 제한
B1: repoUrl.replace(/\/+$/, '').split('/').pop() — trailing slash 안전 처리
B2: parseSprintMd status를 'Status: value' 명시 라인만 파싱 (전체 본문 검색 제거)

테스트 23/23 pass
2026-04-04 17:11:23 +09:00
416c27518c QA: SPRINT-009 review iteration 1 — FAILED (blocking 2) 2026-04-04 17:09:58 +09:00
9135dc59f9 feat(sprint-009): 회원가입 제거 + Sprint 동기화 + 진행률 + 아바타 버그 수정
TASK-032: /register → /login redirect, register API/로직 제거
TASK-033: SprintSyncService(.plans/sprints/ 파싱), /api/projects/:id/sync-sprints, sync-all
TASK-034: 프로젝트 목록 SPRINT SYNC 버튼 추가, Gitea sync 시 Sprint 자동 동기화
TASK-035: 아바타 onError 처리, 캐시 실패 시 삭제 로직 추가

테스트 23/23 pass, FE 16 routes build 성공
2026-04-04 17:01:19 +09:00
988c6905ea 📋 Sprint 009~010 계획 추가
- SPRINT-009: 회원가입 제거 + Sprint 동기화 + 진행률 정상화 + 프로필 사진 수정
- SPRINT-010: 관리자 코드 정리 + JWT 단일화 + API Key 정리
2026-04-04 16:53:25 +09:00
ce4f39ced7 Merge pull request 'SPRINT-008: 실시간 자매 시스템 정보 + 설정 + 스켈레톤 UI (최종 Sprint)' (#4) from feature/sprint-008 into main 2026-04-04 16:40:51 +09:00
b836e8470d QA: SPRINT-008 review iteration 2 — PASSED (final sprint) 2026-04-04 16:38:57 +09:00
cc5c74d75a fix(sprint-008): B1 Settings키 필터링 + B2 SistersScheduler 중복 제거 + B3 반영
B1: saveAll()에서 DEFAULT_SETTINGS 키만 허용 (임의 키 삽입 차단)
B2: EventsScheduler가 이미 동일 역할 수행 — SistersScheduler 제거
B3: SistersScheduler 제거로 clearInterval 이슈 함께 해소

테스트 26/26 pass, build 성공
2026-04-04 16:37:47 +09:00
bc83c12e53 QA: SPRINT-008 review iteration 1 — FAILED (blocking 3) 2026-04-04 16:36:32 +09:00
ff76887c50 feat(sprint-008): 자매 상세 실시간 반영 + 하드코딩 제거 완료
- /sisters/[name] 상세 페이지: systemInfo/avatar 반영
- 상세 페이지 UPTIME/CPU/MEM/DISK 실시간 표시
- 하드코딩 제거 최종 확인 완료
- 테스트 26/26 pass, FE 16 routes build 성공
2026-04-04 16:30:31 +09:00
abef8576fe feat(sprint-008): 실시간 자매 정보/아바타/설정 DB 연동
- TASK-028: /api/sisters/:name/system 추가, 자매 페이지 uptime/cpu/memory/disk 실시간화
- TASK-029: /api/sisters/:name/avatar 추가, SSH avatar fetch + 5분 캐시 + SVG fallback
- TASK-030: SystemSettings 모델 + /api/admin/settings GET/PUT + FE settings DB 연동/토스트
- TASK-031 일부: 대시보드/자매/설정 스켈레톤 UI + 에러 fallback
- 30초 자매 상태 websocket 브로드캐스트 시작

테스트 26/26 pass, build 성공
2026-04-04 15:56:12 +09:00
e74432c8b3 Merge pull request 'fix: CORS origin + EventsGateway getClientCount null safety' (#3) from hotfix/cors-and-ws-size into main 2026-04-04 15:50:29 +09:00
ded66c5cb6 fix: CORS origin + EventsGateway getClientCount null safety 2026-04-04 06:50:17 +00:00
194bc5a91c Merge pull request 'SPRINT-007: Gitea 연동 + 활동 로그 자동화' (#2) from feature/sprint-007 into main 2026-04-04 15:43:35 +09:00
339678b962 QA: SPRINT-007 review iteration 2 — PASSED 2026-04-04 15:41:01 +09:00
035ba96381 fix(sprint-007): B1 commits limit NaN clamp(1~100) 2026-04-04 15:40:08 +09:00
ce323fdfc1 QA: SPRINT-007 review iteration 1 — FAILED (blocking 1: limit NaN) 2026-04-04 15:39:03 +09:00
a942f4e62d feat(sprint-007): Gitea 연동 + 활동 로그 자동 기록
Sprint 006 non-blocking:
- N2: AuthContext 401 시 refresh token 자동 재발급
- N3: login 후 /me API로 실제 userId 조회
- N4: register refreshToken localStorage 저장

TASK-024: Gitea 동기화
- GiteaSyncService: syncRepos() (Project upsert + ActivityLog)
- POST /api/admin/gitea/sync + GET /api/admin/gitea/status
- GiteaSyncModule (CompositeGuard + @Roles('admin'))

TASK-025: Commit/Branch/PR API
- GiteaService 강화: getCommits, getBranches, getPulls
- GiteaCommit/GiteaBranch 인터페이스 추가
- GET /api/projects/:id/{commits,branches,pulls}

TASK-026: 프로젝트 FE
- /projects: GITEA SYNC 버튼 + sync 결과 표시
- /projects/[id]: Overview/Commits/Branches/PRs 탭

TASK-027: 활동 로그 자동 기록
- SistersService: 상태 변경 시 ActivityLog 자동 기록 (@Optional ActivityService)
- GiteaSyncService: sync 시 new_repo/sync_complete 로그
- /activities: 실제 API 연결 확인 (하드코딩 없음)

테스트 26/26 pass, FE 16 routes build 성공
2026-04-04 15:34:27 +09:00
nabomhalang
e0fd04b4f1 fix: API Key strategy returns admin role for RoleGuard compatibility 2026-04-04 06:23:44 +00:00
b387c638c9 QA: SPRINT-006 review iteration 2 — PASSED 2026-04-04 06:23:44 +00:00
2089283386 fix(sprint-006): Critical 보안 이슈 5건 수정
B1(Critical): JWT_SECRET 'changeme' fallback 3곳 전부 제거
- auth.service.ts / auth.module.ts / events.module.ts: 미설정 시 throw
- events.gateway.ts: 미설정 시 client.disconnect()

B2(Critical): User.role 기본값 'admin' → 'viewer'
- Prisma schema @default('viewer')
- RoleGuard 신규 생성 (Roles 데코레이터)
- AdminController / CostsController @Roles('admin') 적용

B3: WebSocket JWT 실패/미제공 시 client.disconnect()
- 토큰 없으면 즉시 disconnect
- 토큰 있지만 검증 실패해도 disconnect
- FE useSocket: access token을 auth.token으로 전달

B4: refresh endpoint throw Error → BadRequestException (400)

B5: register Throttle 추가 (3회/분, 초대코드 brute-force 방지)
2026-04-04 06:23:44 +00:00
286f472afd QA: SPRINT-006 review iteration 1 — FAILED (blocking 5, 2 critical) 2026-04-04 06:23:44 +00:00
9a86d04731 feat(sprint-006): 버그 수정 3건 + JWT 인증 시스템
TASK-020 버그 수정:
- BUG-1: adminFetch Authorization 헤더 우선순위 수정 (JWT > API Key)
  하네스 저장 실패 에러 핸들링 개선 (401/non-ok 명시적 처리)
- BUG-2: /admin/logs useEffect 초기 로드 추가 (마운트 시 자동 조회)
- BUG-3: costs/record python3 → node/python3/jq fallback 체인

TASK-021 JWT BE:
- User 모델 추가 (Prisma schema)
- AuthService: register(초대코드 검증+bcrypt) / login / refresh / getMe
- JwtStrategy (passport-jwt), JwtGuard, CompositeGuard (JWT+API Key)
- AuthController: /api/auth/{register,login,refresh,me}
- ThrottlerModule: 로그인 5회/분 Rate limiting
- CompositeGuard로 AdminController, CostsController Guard 전환
- EventsGateway: Socket.IO handshake JWT 검증 추가
- 테스트 26/26 pass

TASK-022 FE:
- AuthContext (AuthProvider + useAuth)
- AppShell (AuthProvider + AuthGate 라우트 보호)
- /login 페이지 (터미널 UI)
- /register 페이지 (초대 코드 필드)
- Sidebar: 로그인 사용자명 + 로그아웃 버튼
- FE 16 routes build 성공
2026-04-04 06:23:44 +00:00
b469a1337a 📋 Sprint 006~008 계획 추가
- SPRINT-006: 버그 수정 3건 + JWT 인증 시스템 (초대 코드 가입)
- SPRINT-007: Gitea 연동 + 활동 로그 실시간화
- SPRINT-008: 실시간 전환 + 설정 구현 + 자매 강화 + 스켈레톤 UI
2026-04-04 14:53:42 +09:00
nabomhalang
0ce70ec615 Merge feature/redesign-v2: 미니멀 터미널 UI 리디자인 2026-04-04 04:13:42 +00:00
97668007b1 QA: SPRINT-REDESIGN review iteration 1 — PASSED 2026-04-04 13:12:56 +09:00
230d3847ef feat(redesign-v2): 미니멀 터미널 UI 전체 리디자인
- 디자인 시스템: #151515 배경, 1px 보더 카드, CSS vars, mono 포인트
- GlobalStyle/theme.ts v2 (DESIGN-SYSTEM.md 기반)
- 공통 컴포넌트 (ui/base.tsx): LabelMeta, BracketValue, Card, TechBar, Timeline 등
- Sidebar: [대시] 브라켓 네비 + 모바일 하단 탭바 (767px)
- LayoutShell: SidebarContext 제거, 단순화
- 대시보드 (/): SYS 상태 카드 4개 + ONGOING PROJECTS + ACTIVITY FEED
- 활동 로그 (/activities): 로그 테이블 + 필터 + 검색 + 페이지네이션 (신규)
- 설정 (/settings): 4섹션 Toggle/Bracket Input 그리드
- 자매 노드 관리 (/sisters): 3열 수평 레이아웃 (INFO/GRAPH/SYNC)
- 자매 상세 (/sisters/[name]): 간소화 + 터미널 스타일
- 조직도 (/org): 터미널 카드 트리 구조 + 레벨 색상
- 프로젝트 목록 (/projects): 새 라우트
- 프로젝트 상세 (/projects/[id]): Phase Timeline + Audit Log + Checklist
- FE 14 routes build 성공
2026-04-04 13:10:57 +09:00
5e6c7085d3 📐 디자인 v2: 전체 리디자인 + 모바일 반응형 설계
- DESIGN-SYSTEM.md: 색상/타이포/컴포넌트/반응형 규칙
- 6개 페이지 디자인 문서 재작성 (대시/프로젝트/자매/설정/활동로그/조직도)
- 관리자 페이지 디자인 가이드
- 원본 HTML 참조 파일 6개
- glassmorphism → 미니멀 터미널 UI
- 모바일 breakpoint: 768px/767px
2026-04-04 12:47:15 +09:00
nabomhalang
23fd1ecb09 fix: export SistersService from SistersModule for EventsScheduler DI 2026-04-04 03:38:56 +00:00
nabomhalang
be052ec707 Merge feature/sprint-005 into main (SPRINT-005) 2026-04-04 03:35:39 +00:00
b3690c9206 QA: SPRINT-005 review iteration 1 — PASSED (final sprint) 2026-04-04 12:34:46 +09:00
914903466c feat(sprint-005): cost monitor + WebSocket realtime
Sprint 004 non-blocking:
- null byte 방지 (harness file write)

Sprint 005 본문:
- CostLog Prisma 모델 추가
- TASK-016: GET /api/admin/costs + POST /api/admin/costs/record/:name
  자매별/모델별/일별 토큰 + 예상 비용 (USD), 기간 필터(day/week/month)
- TASK-018: WebSocket Gateway (@WebSocketGateway /ws namespace)
  sisters:update, activity:new 브로드캐스트
  EventsScheduler: 30초 주기 자매 상태 체크 + 브로드캐스트
  ActivityService: 새 로그 생성 시 실시간 브로드캐스트
- TASK-017: /admin/costs 비용 대시보드 (BarChart + 요약 카드)
- 메인 대시보드 useSocket 훅 + 실시간 연결 상태 표시
- 테스트 22/22 pass, FE 12 routes build 성공
- NEXT_PUBLIC_WS_URL env (.env.local.example 업데이트)
2026-04-04 12:28:25 +09:00
nabomhalang
929b4e0745 Merge feature/sprint-004 into main (SPRINT-004 blocking fix) 2026-04-04 03:20:14 +00:00
64802c30c5 QA: SPRINT-004 review iteration 2 — PASSED 2026-04-04 12:19:30 +09:00
fa33308fe4 fix(sprint-004): B1 DTO validation, B2 lines clamp, B3 CORS PUT
B1: UpdateHarnessDto @IsString @IsNotEmpty @MaxLength(50000) 추가
B2: getSisterLogs lines clamp(1~500)
B3: CORS methods에 PUT 추가
2026-04-04 12:18:14 +09:00
dcf5073693 QA: SPRINT-004 review iteration 1 — FAILED (blocking 3) 2026-04-04 12:17:12 +09:00
nabomhalang
50ea60f944 Merge feature/hotfix-api-url into main (HOTFIX-001) 2026-04-04 03:15:29 +00:00
3d8a1efa2a fix(hotfix-001): NEXT_PUBLIC_API_URL env 의존 제거 → Next.js rewrites 프록시
문제: FE 빌드 시 NEXT_PUBLIC_API_URL env 없으면 localhost:3005로 하드베이킹됨
→ 배포 환경에서 API 요청이 localhost로 가는 버그

해결:
- next.config.ts: /api/* → BE(API_URL) rewrites 추가
- lib/config.ts: CSR에서 API_URL='' (상대경로) 사용
  SSR에서는 process.env.API_URL ?? NEXT_PUBLIC_API_URL fallback
- lib/adminFetch.ts: 상대경로 직접 사용
- 모든 페이지가 use client이므로 CSR 상대경로 + rewrites로 완전 처리
- .env.local.example 업데이트 (API_URL 서버사이드용 추가)
2026-04-04 12:13:23 +09:00
033c142c0e feat(sprint-004): admin features + API auth guard
Sprint 003 non-blocking 처리:
- API Key Guard (passport-http-bearer) + AuthModule
- Helmet 적용 (main.ts)
- SisterNamePipe - name 파라미터 Controller 검증
- lib/sisters.ts - formatLastSeen/sisterEmojis/sisterDisplayNames 공통 추출

Sprint 004 본문:
- TASK-012: POST /api/admin/sisters/:name/{restart,reset} (ApiKeyGuard 적용)
- TASK-013: GET/PUT /api/admin/harness/:name/:file (허용 파일 allowlist)
- TASK-015: GET /api/admin/logs/:name
- 관리자 전 API @UseGuards(ApiKeyGuard)
- TASK-014: /admin 자매 관리 (재시작/리셋 + ConfirmModal)
- TASK-014: /admin/harness 하네스 편집 (CodeEditor + 파일트리)
- TASK-015: /admin/logs 로그 뷰어 (LogTerminal + 검색/에러필터)
- /admin/repos 저장소 목록
- AdminLayout (API Key 로컬스토리지 저장)
- 테스트 22/22 pass, FE 11 routes build 성공
2026-04-04 12:11:06 +09:00
nabomhalang
4e5a077d40 Merge feature/sprint-003 into main (SPRINT-003) 2026-04-04 03:02:36 +00:00
86751a4879 QA: SPRINT-003 review iteration 1 — PASSED 2026-04-04 12:01:47 +09:00
4015e5f22f feat(sprint-003): sister detail + org chart + settings viewer
Sprint 002 non-blocking:
- API_URL/POLL_INTERVAL lib/config.ts 공통 추출
- 폴링 간격 NEXT_PUBLIC_POLL_INTERVAL_MS env화

Sprint 003 본문:
- TASK-008: SisterDetailService (config/sessions/subagents/activity 조회)
- TASK-008: GET /api/sisters/:name/{config,sessions,subagents,activity}
- TASK-008: GET /api/org → 조직도 + 파이프라인 데이터
- TASK-009: /sisters 목록 페이지 (카드 클릭 → 상세 이동)
- TASK-009: /sisters/[name] 상세 페이지 (탭: 개요/설정/세션/서브에이전트)
- TASK-009: SisterCard → /sisters/:name 링크 연결
- TASK-010: /org 조직도 페이지 (OrgTree 컴포넌트, 호버 팝업)
- TASK-011: /settings 설정 뷰어 페이지 (자매 선택 드롭다운)
- 테스트 16/16 pass, FE build 성공 (7 routes)
2026-04-04 11:55:14 +09:00
nabomhalang
dd6a2595f7 Merge feature/sprint-002 into main (SPRINT-002) 2026-04-04 02:45:41 +00:00
143 changed files with 12333 additions and 781 deletions

View File

@@ -0,0 +1,150 @@
# 하나랑 대시보드 — 디자인 시스템 v2
## 디자인 철학
미니멀 터미널 UI. 장식 최소화, 정보 밀도 최대화.
glassmorphism 제거 → 1px 보더 카드 + 모노스페이스 라벨.
---
## 색상 (Color Tokens)
| Token | Value | 용도 |
|-------|-------|------|
| `--bg-main` | `#151515` | 전체 배경 |
| `--bg-surface` | `#151515` | 카드/패널 배경 (배경과 동일, 보더로 구분) |
| `--bg-input` | `#1a1a1a` | 인풋/검색 배경 |
| `--bg-code` | `#0d0d0d` | 로그 테이블/코드 블록 배경 |
| `--text-primary` | `#F5F5F5` | 주 텍스트 |
| `--text-secondary` | `#8A8A8A` | 보조 텍스트, 라벨 |
| `--border-color` | `#333333` | 카드 보더, 구분선 |
| `--border-hover` | `#555555` | hover 시 보더 |
| `--accent-hover` | `#FFFFFF` | 네비 active, 강조 |
### 상태 색상 (조직도 전용)
| Token | Value | 용도 |
|-------|-------|------|
| `--level-high` | `#f43f5e` | 최상위/긴급 |
| `--level-mid` | `#3b82f6` | 중간/R&D |
| `--level-low` | `#10b981` | 하위/운영 |
| `--level-none` | `#525252` | 비활성 |
### 로그 태그 색상
| Tag | Color |
|-----|-------|
| SYSTEM | `#888` |
| SECURITY | `#ff5f5f` |
| ORG | `#5fafff` |
| BACKUP | `#5fff8a` |
### 상태 표시 색상
| Status | Color |
|--------|-------|
| PASS / 성공 | `#00FF00` |
| ACTIVE dot | `#FFF` + `box-shadow: 0 0 8px rgba(255,255,255,0.3)` |
| INACTIVE dot | `#555` |
---
## 타이포그래피
| 용도 | Font | Size | Weight |
|------|------|------|--------|
| 본문 | `--font-sans` | 14px | 400 |
| 페이지 제목 | `--font-sans` | 28px | 600 |
| 섹션 제목 | `--font-sans` | 14px | 600 |
| 라벨 (label-meta) | `--font-sans` | 11px | 700, uppercase |
| 브라켓 값 | `--font-sans` | 32px | 600 |
| 타임스탬프 | `--font-mono` | 11px | 400 |
| 로그 텍스트 | `--font-mono` | 12px | 400 |
| 브라켓 인풋 | `--font-mono` | 14px | 400 |
| 뱃지 | `--font-mono` | 9px | 400, uppercase |
**폰트 스택:**
- `--font-sans`: `-apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif`
- `--font-mono`: `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace`
---
## 스페이싱
| Token | Value |
|-------|-------|
| `--space-xs` | 4px |
| `--space-sm` | 8px |
| `--space-md` | 16px |
| `--space-lg` | 24px |
| `--space-xl` | 40px |
| `--space-xxl` | 64px |
---
## 컴포넌트
### Sidebar
- 너비: 240px (데스크톱), 64px (모바일 아이콘 모드)
- `border-right: 1px solid var(--border-color)`
- `position: sticky; top: 0; height: 100vh`
- 로고: ● + ◗ (circle-full + circle-half)
- 네비 active: `[대시]` 브라켓 표기 + 흰색 텍스트
- 네비 inactive: `#8A8A8A`
- 메뉴: 대시, 활동, 프로, 자매, 조직, 설정, 관리
### Card (상태 카드)
- `border: 1px solid var(--border-color)`
- `background: var(--bg-surface)`
- `padding: var(--space-lg)`
- hover: `border-color: #555`
- 내부: header(SYS:이름) + bracket-value([ON]) + label + tech-bar
### Tech Bar (진행 바)
- `height: 2px; background: var(--border-color)`
- fill: `background: var(--text-primary)`
### Timeline
- 왼쪽 세로선: `left: 3px; width: 1px; background: var(--border-color)`
- 도트: `width: 7px; height: 7px; border: 1px solid var(--text-primary); border-radius: 50%`
### Toggle Switch
- 34x18px, border 1px
- checked: border `var(--text-primary)`, dot 흰색
### Bracket Input
- `[ value ]` 형태, mono font
- 투명 배경, 포커스 시 흰색 텍스트
### Button
- 기본: 투명 + 보더, uppercase 12px
- primary: 흰색 배경 + 검정 텍스트, hover 반전
- toggle: fit-content, 11px, active 시 흰색 배경
### Log Table
- 배경: `#0d0d0d`, border 1px
- th: 11px uppercase, `--text-secondary`
- td: 12px mono, hover 배경 `#1a1a1a`
### Terminal Card (조직도)
- `border-left: 3px solid` (레벨별 색상)
- `box-shadow: 0 10px 30px rgba(0,0,0,0.5)`
- 뱃지: 9px mono, `#1a1a1a` 배경
---
## 반응형 (Breakpoints)
| Breakpoint | 화면 | 변경 사항 |
|------------|------|-----------|
| `≥1200px` | Desktop | 기본 레이아웃 |
| `7681199px` | Tablet | 사이드바 64px 아이콘 모드, 카드 2열, 2열 그리드→1열 |
| `≤767px` | Mobile | 사이드바 하단 탭바, 카드 1열, 노드 엔트리 수직 스택, 테이블 카드 변환 |
### Mobile 세부 규칙
1. **사이드바 → 하단 탭바**: 고정 하단, 아이콘+짧은 라벨, `height: 56px`
2. **상태 카드 그리드**: `grid-template-columns: repeat(2, 1fr)``1fr` (모바일)
3. **data-columns (대시 하단)**: `1.5fr 1fr``1fr` 수직 스택
4. **project-grid (프로젝트 상세)**: `1fr 320px``1fr` 수직 스택
5. **node-entry (자매 관리)**: 3열 가로 → 수직 스택
6. **settings-grid**: 2열 → 1열
7. **log-table**: 가로 스크롤 또는 카드 형태 변환
8. **조직도 tree**: 수평 스크롤 + 핀치 줌
9. **padding 축소**: `--space-xxl``--space-lg`, `--space-xl``--space-md`
10. **bracket-value 크기**: 32px → 24px (모바일)

View File

@@ -0,0 +1,510 @@
The code below contains a design. This design should be used to create a new app or be added to an existing one.
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
Run the dev command so the user can see the app once finished.
```
<html lang="ko" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">하나랑 대시보드</title>
<style vid="5">
:root {
--bg-main: #151515;
--bg-surface: #151515;
--text-primary: #F5F5F5;
--text-secondary: #8A8A8A;
--border-color: #333333;
--accent-hover: #FFFFFF;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--space-xxl: 64px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
min-height: 100vh;
overflow-x: hidden;
}
.label-meta {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-primary);
}
.label-meta span {
color: var(--text-secondary);
margin-right: var(--space-sm);
}
.bracket-value {
font-size: 32px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
}
.app-container {
display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: var(--space-xl) var(--space-lg);
position: sticky;
top: 0;
height: 100vh;
}
.logo-container {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
}
.logo-circle-full {
width: 20px;
height: 20px;
background-color: var(--text-primary);
border-radius: 50%;
}
.logo-circle-half {
width: 10px;
height: 20px;
background-color: var(--text-primary);
border-radius: 0 20px 20px 0;
}
.nav-menu {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.nav-item {
display: flex;
align-items: center;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
padding: var(--space-sm) 0;
transition: color 0.2s ease;
display: block;
width: 100%;
}
.nav-link:hover, .nav-link.active {
color: var(--accent-hover);
}
.nav-link.active::before {
content: "[";
margin-right: 4px;
color: var(--text-secondary);
}
.nav-link.active::after {
content: "]";
margin-left: 4px;
color: var(--text-secondary);
}
.main-content {
flex-grow: 1;
padding: var(--space-xl) var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-xl);
min-width: 0;
}
.page-header {
margin-bottom: var(--space-md);
}
.page-title {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.status-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-lg);
}
.card {
border: 1px solid var(--border-color);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
background: var(--bg-surface);
transition: border-color 0.2s;
}
.card:hover {
border-color: #555;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.card-data-row {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.tech-bar {
height: 2px;
width: 100%;
background-color: var(--border-color);
margin-top: auto;
position: relative;
}
.tech-bar-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: var(--text-primary);
}
.data-columns {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: var(--space-xxl);
align-items: start;
}
.section-title {
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-sm);
margin-bottom: var(--space-lg);
display: flex;
justify-content: space-between;
}
.project-list {
display: flex;
flex-direction: column;
}
.project-row {
display: grid;
grid-template-columns: 40px 1fr auto;
gap: var(--space-md);
padding: var(--space-md) 0;
border-bottom: 1px solid #222;
align-items: center;
}
.project-row:last-child {
border-bottom: none;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #333;
overflow: hidden;
filter: grayscale(100%) contrast(120%);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.project-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.project-name {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
.project-desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
.timeline {
display: flex;
flex-direction: column;
position: relative;
}
.timeline::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 3px;
width: 1px;
background-color: var(--border-color);
}
.timeline-item {
position: relative;
padding-left: var(--space-lg);
padding-bottom: var(--space-lg);
}
.timeline-item::before {
content: '';
position: absolute;
left: 0;
top: 6px;
width: 7px;
height: 7px;
background-color: var(--bg-main);
border: 1px solid var(--text-primary);
border-radius: 50%;
}
.time-stamp {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
margin-bottom: var(--space-xs);
display: block;
}
.timeline-content {
font-size: 13px;
color: var(--text-primary);
line-height: 1.5;
}
.timeline-content strong {
font-weight: 600;
}
</style>
</head>
<body vid="6">
<div class="app-container" vid="7">
<aside class="sidebar" vid="8">
<div class="logo-container" vid="9">
<div class="logo-circle-full" vid="10"></div>
<div class="logo-circle-half" vid="11"></div>
</div>
<nav vid="12">
<ul class="nav-menu" vid="13">
<li class="nav-item" vid="14"><a href="#" class="nav-link active" vid="15">대시</a></li>
<li class="nav-item" vid="16"><a href="#" class="nav-link" vid="17">프로</a></li>
<li class="nav-item" vid="18"><a href="#" class="nav-link" vid="19">자매</a></li>
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">조직</a></li>
<li class="nav-item" vid="22"><a href="#" class="nav-link" vid="23">설정</a></li>
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">관리</a></li>
</ul>
</nav>
</aside>
<main class="main-content" vid="26">
<header class="page-header" vid="27">
<h1 class="page-title" vid="28">하나랑 대시보드</h1>
</header>
<section class="status-grid" vid="29">
<div class="card" vid="30">
<div class="card-header" vid="31">
<div class="label-meta" vid="32"><span vid="33">SYS:</span>하랑</div>
</div>
<div class="card-data-row" vid="34">
<div class="bracket-value" vid="35">[ON]</div>
<div class="label-meta" vid="36">Active Node</div>
</div>
<div class="tech-bar" vid="37"><div class="tech-bar-fill" style="width: 100%;" vid="38"></div></div>
</div>
<div class="card" vid="39">
<div class="card-header" vid="40">
<div class="label-meta" vid="41"><span vid="42">SYS:</span>나랑</div>
</div>
<div class="card-data-row" vid="43">
<div class="bracket-value" vid="44">[84]</div>
<div class="label-meta" vid="45">Capacity %</div>
</div>
<div class="tech-bar" vid="46"><div class="tech-bar-fill" style="width: 84%;" vid="47"></div></div>
</div>
<div class="card" vid="48">
<div class="card-header" vid="49">
<div class="label-meta" vid="50"><span vid="51">SYS:</span>다랑</div>
</div>
<div class="card-data-row" vid="52">
<div class="bracket-value" style="color: var(--text-secondary);" vid="53">[--]</div>
<div class="label-meta" vid="54">Standby</div>
</div>
<div class="tech-bar" vid="55"><div class="tech-bar-fill" style="width: 0%;" vid="56"></div></div>
</div>
<div class="card" vid="57">
<div class="card-header" vid="58">
<div class="label-meta" vid="59"><span vid="60">SYS:</span>이랑</div>
</div>
<div class="card-data-row" vid="61">
<div class="bracket-value" vid="62">[12]</div>
<div class="label-meta" vid="63">Sync Queue</div>
</div>
<div class="tech-bar" vid="64"><div class="tech-bar-fill" style="width: 12%;" vid="65"></div></div>
</div>
</section>
<div class="data-columns" vid="66">
<section vid="67">
<div class="section-title" vid="68">
<span vid="69">ONGOING PROJECTS</span>
<span class="label-meta" vid="70">VOL: 04</span>
</div>
<div class="project-list" vid="71">
<div class="project-row" vid="72">
<div class="avatar" vid="73">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23444'/%3E%3Ccircle cx='50' cy='40' r='20' fill='%23666'/%3E%3Cpath d='M20 90 Q50 60 80 90' stroke='%23666' stroke-width='10' fill='none'/%3E%3C/svg%3E" alt="avatar" vid="74">
</div>
<div class="project-details" vid="75">
<div class="project-name" vid="76">데이터 파이프라인 최적화</div>
<div class="project-desc" vid="77">하랑 및 나랑 시스템 간의 실시간 동기화 지연율 개선.</div>
</div>
<div class="label-meta" vid="78">PHASE 2</div>
</div>
<div class="project-row" vid="79">
<div class="avatar" vid="80">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23555'/%3E%3Cpath d='M30 80 L50 30 L70 80' stroke='%23777' stroke-width='8' fill='none'/%3E%3C/svg%3E" alt="avatar" vid="81">
</div>
<div class="project-details" vid="82">
<div class="project-name" vid="83">새로운 자매 노드 배포 (아랑)</div>
<div class="project-desc" vid="84">인프라 구성 및 초기 보안 감사 진행 중.</div>
</div>
<div class="label-meta" vid="85">INIT</div>
</div>
<div class="project-row" vid="86">
<div class="avatar" vid="87">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23333'/%3E%3Ccircle cx='50' cy='50' r='25' fill='none' stroke='%23888' stroke-width='6'/%3E%3C/svg%3E" alt="avatar" vid="88">
</div>
<div class="project-details" vid="89">
<div class="project-name" vid="90">관리자 권한 체계 개편</div>
<div class="project-desc" vid="91">조직별 접근 제어 정책 재수립 및 적용.</div>
</div>
<div class="label-meta" vid="92">REVIEW</div>
</div>
</div>
</section>
<section vid="93">
<div class="section-title" vid="94">
<span vid="95">ACTIVITY FEED</span>
<span class="label-meta" vid="96">LIVE</span>
</div>
<div class="timeline" vid="97">
<div class="timeline-item" vid="98">
<span class="time-stamp" vid="99">14:02:45 GMT+9</span>
<div class="timeline-content" vid="100">
<strong vid="101">System (다랑)</strong> status updated to Standby mode by admin protocol.
</div>
</div>
<div class="timeline-item" vid="102">
<span class="time-stamp" vid="103">11:30:12 GMT+9</span>
<div class="timeline-content" vid="104">
<strong vid="105">Security Audit</strong> completed for project [데이터 파이프라인 최적화]. No critical vulnerabilities found.
</div>
</div>
<div class="timeline-item" vid="106">
<span class="time-stamp" vid="107">09:15:00 GMT+9</span>
<div class="timeline-content" vid="108">
New user batch imported to <strong vid="109">Organization [Beta]</strong> structure.
</div>
</div>
<div class="timeline-item" vid="110">
<span class="time-stamp" vid="111">08:00:05 GMT+9</span>
<div class="timeline-content" vid="112">
Daily automated backup initiated across all active nodes.
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</body></html>
```

View File

@@ -0,0 +1,465 @@
The code below contains a design. This design should be used to create a new app or be added to an existing one.
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
Run the dev command so the user can see the app once finished.
```
<html lang="ko" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">하나랑 - 프로젝트 상세</title>
<style vid="5">
:root {
--bg-main: #151515;
--bg-surface: #151515;
--text-primary: #F5F5F5;
--text-secondary: #8A8A8A;
--border-color: #333333;
--accent-hover: #FFFFFF;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--space-xxl: 64px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased;
display: flex;
min-height: 100vh;
overflow-x: hidden;
}
.label-meta {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-primary);
}
.label-meta span {
color: var(--text-secondary);
margin-right: var(--space-sm);
}
.app-container {
display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: var(--space-xl) var(--space-lg);
position: sticky;
top: 0;
height: 100vh;
}
.logo-container {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
}
.logo-circle-full {
width: 20px;
height: 20px;
background-color: var(--text-primary);
border-radius: 50%;
}
.logo-circle-half {
width: 10px;
height: 20px;
background-color: var(--text-primary);
border-radius: 0 20px 20px 0;
}
.nav-menu {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
padding: var(--space-sm) 0;
transition: color 0.2s ease;
display: block;
width: 100%;
}
.nav-link:hover, .nav-link.active {
color: var(--accent-hover);
}
.nav-link.active::before { content: "["; margin-right: 4px; color: var(--text-secondary); }
.nav-link.active::after { content: "]"; margin-left: 4px; color: var(--text-secondary); }
.main-content {
flex-grow: 1;
padding: var(--space-xl) var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-xl);
min-width: 0;
}
.breadcrumb {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
margin-bottom: var(--space-sm);
text-transform: uppercase;
}
.page-header {
margin-bottom: var(--space-md);
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-lg);
}
.page-title-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
}
.project-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--space-xxl);
}
.section-title {
font-size: 14px;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-sm);
margin-bottom: var(--space-lg);
display: flex;
justify-content: space-between;
}
.phase-timeline {
display: flex;
flex-direction: column;
gap: var(--space-lg);
margin-bottom: var(--space-xl);
}
.phase-item {
display: grid;
grid-template-columns: 120px 1fr;
gap: var(--space-lg);
}
.phase-meta {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
padding-top: 4px;
}
.phase-box {
border-left: 2px solid var(--border-color);
padding-left: var(--space-lg);
position: relative;
}
.phase-box.active {
border-left-color: var(--text-primary);
}
.phase-box.active::before {
content: '';
position: absolute;
left: -5px;
top: 0;
width: 8px;
height: 8px;
background: var(--text-primary);
border-radius: 50%;
}
.phase-name {
font-weight: 600;
margin-bottom: 4px;
}
.phase-desc {
font-size: 13px;
color: var(--text-secondary);
}
.checklist {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.check-item {
display: flex;
align-items: center;
gap: var(--space-md);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
.check-box {
width: 14px;
height: 14px;
border: 1px solid var(--border-color);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.check-box.checked::after {
content: '■';
font-size: 10px;
}
.check-item.done {
color: var(--text-secondary);
text-decoration: line-through;
}
.node-stack {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.node-mini-card {
border: 1px solid var(--border-color);
padding: var(--space-sm) var(--space-md);
display: flex;
justify-content: space-between;
align-items: center;
}
.node-status-dot {
width: 6px;
height: 6px;
background: #00FF00;
border-radius: 50%;
}
.audit-log {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border-color);
border: 1px solid var(--border-color);
}
.audit-row {
background: var(--bg-main);
padding: var(--space-sm) var(--space-md);
display: grid;
grid-template-columns: 100px 1fr 80px;
font-family: var(--font-mono);
font-size: 11px;
}
.audit-header {
color: var(--text-secondary);
text-transform: uppercase;
font-weight: 700;
}
.status-tag {
padding: 2px 6px;
font-size: 10px;
border: 1px solid var(--border-color);
text-align: center;
}
.status-tag.pass { border-color: #00FF00; color: #00FF00; }
</style>
</head>
<body vid="6">
<div class="app-container" vid="7">
<aside class="sidebar" vid="8">
<div class="logo-container" vid="9">
<div class="logo-circle-full" vid="10"></div>
<div class="logo-circle-half" vid="11"></div>
</div>
<nav vid="12">
<ul class="nav-menu" vid="13">
<li class="nav-item" vid="14"><a href="#" class="nav-link" vid="15">대시</a></li>
<li class="nav-item" vid="16"><a href="#" class="nav-link active" vid="17">프로</a></li>
<li class="nav-item" vid="18"><a href="#" class="nav-link" vid="19">자매</a></li>
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">조직</a></li>
<li class="nav-item" vid="22"><a href="#" class="nav-link" vid="23">설정</a></li>
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">관리</a></li>
</ul>
</nav>
</aside>
<main class="main-content" vid="26">
<div class="breadcrumb" vid="27">PROJECTS / P-402 / SUMMARY</div>
<header class="page-header" vid="28">
<div class="page-title-row" vid="29">
<h1 class="page-title" vid="30">데이터 파이프라인 최적화</h1>
<div class="label-meta" vid="31"><span vid="32">STATUS:</span>ACTIVE / PHASE 02</div>
</div>
</header>
<div class="project-grid" vid="33">
<div class="content-left" vid="34">
<section vid="35">
<div class="section-title" vid="36">
<span vid="37">PHASE TIMELINE</span>
<span class="label-meta" vid="38">CURRENT: P2</span>
</div>
<div class="phase-timeline" vid="39">
<div class="phase-item" vid="40">
<div class="phase-meta" vid="41">COMPLETED<br vid="42">2023.10.12</div>
<div class="phase-box" vid="43">
<div class="phase-name" vid="44">PHASE 01: 아키텍처 분석</div>
<div class="phase-desc" vid="45">기존 하랑-나랑 노드 간의 데이터 흐름 병목 현상 진단 및 병렬 처리 로직 설계 완료.</div>
</div>
</div>
<div class="phase-item" vid="46">
<div class="phase-meta" vid="47">IN PROGRESS<br vid="48">EST: 11.05</div>
<div class="phase-box active" vid="49">
<div class="phase-name" vid="50">PHASE 02: 실시간 동기화 구현</div>
<div class="phase-desc" vid="51">Redis 스트림을 활용한 비동기 메시지 큐 시스템 구축 및 데이터 무결성 검증 레이어 통합.</div>
</div>
</div>
<div class="phase-item" vid="52">
<div class="phase-meta" vid="53">PENDING<br vid="54">TBD</div>
<div class="phase-box" vid="55">
<div class="phase-name" vid="56">PHASE 03: 스트레스 테스트 및 배포</div>
<div class="phase-desc" vid="57">최대 트래픽 상황에서의 안정성 확보 및 프로덕션 환경 순차 배포 예정.</div>
</div>
</div>
</div>
</section>
<section vid="58">
<div class="section-title" vid="59">
<span vid="60">SECURITY AUDIT LOG</span>
<span class="label-meta" vid="61">LAST CHECK: 4H AGO</span>
</div>
<div class="audit-log" vid="62">
<div class="audit-row audit-header" vid="63">
<div vid="64">TIMESTAMP</div>
<div vid="65">ACTION / RESOURCE</div>
<div vid="66">RESULT</div>
</div>
<div class="audit-row" vid="67">
<div vid="68">14:02:45</div>
<div vid="69">CRYPTO_KEY_ROTATION / NODE_HARANG</div>
<div class="status-tag pass" vid="70">PASS</div>
</div>
<div class="audit-row" vid="71">
<div vid="72">11:30:12</div>
<div vid="73">CODE_SCAN_STATIC / PIPELINE_V2</div>
<div class="status-tag pass" vid="74">PASS</div>
</div>
<div class="audit-row" vid="75">
<div vid="76">09:15:00</div>
<div vid="77">ACCESS_LOG_REVIEW / ADMIN_ROOT</div>
<div class="status-tag" vid="78">WARN</div>
</div>
</div>
</section>
</div>
<div class="content-right" vid="79">
<section style="margin-bottom: var(--space-xl);" vid="80">
<div class="section-title" vid="81">ASSIGNED NODES</div>
<div class="node-stack" vid="82">
<div class="node-mini-card" vid="83">
<span class="label-meta" vid="84">하랑 [PRIMARY]</span>
<div class="node-status-dot" vid="85"></div>
</div>
<div class="node-mini-card" vid="86">
<span class="label-meta" vid="87">나랑 [SECONDARY]</span>
<div class="node-status-dot" vid="88"></div>
</div>
</div>
</section>
<section vid="89">
<div class="section-title" vid="90">TASK CHECKLIST</div>
<ul class="checklist" vid="91">
<li class="check-item done" vid="92">
<div class="check-box checked" vid="93"></div>
<span vid="94">인프라 요구사항 명세 작성</span>
</li>
<li class="check-item done" vid="95">
<div class="check-box checked" vid="96"></div>
<span vid="97">보안 프로토콜 V3 적용</span>
</li>
<li class="check-item" vid="98">
<div class="check-box" vid="99"></div>
<span vid="100">캐시 무효화 로직 테스트</span>
</li>
<li class="check-item" vid="101">
<div class="check-box" vid="102"></div>
<span vid="103">부하 분산 알고리즘 튜닝</span>
</li>
<li class="check-item" vid="104">
<div class="check-box" vid="105"></div>
<span vid="106">최종 문서화 및 코드 리뷰</span>
</li>
</ul>
</section>
</div>
</div>
</main>
</div>
</body></html>
```

View File

@@ -0,0 +1,472 @@
The code below contains a design. This design should be used to create a new app or be added to an existing one.
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
Run the dev command so the user can see the app once finished.
```
<html lang="ko" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">하나랑 - 자매 노드 관리</title>
<style vid="5">
:root {
--bg-main: #151515;
--bg-surface: #151515;
--text-primary: #F5F5F5;
--text-secondary: #8A8A8A;
--border-color: #333333;
--accent-hover: #FFFFFF;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--space-xxl: 64px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased;
display: flex;
min-height: 100vh;
overflow-x: hidden;
}
.label-meta {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-primary);
}
.label-meta span {
color: var(--text-secondary);
margin-right: var(--space-sm);
}
.app-container {
display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: var(--space-xl) var(--space-lg);
position: sticky;
top: 0;
height: 100vh;
}
.logo-container {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
}
.logo-circle-full {
width: 20px;
height: 20px;
background-color: var(--text-primary);
border-radius: 50%;
}
.logo-circle-half {
width: 10px;
height: 20px;
background-color: var(--text-primary);
border-radius: 0 20px 20px 0;
}
.nav-menu {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
padding: var(--space-sm) 0;
transition: color 0.2s ease;
display: block;
width: 100%;
}
.nav-link:hover, .nav-link.active {
color: var(--accent-hover);
}
.nav-link.active::before {
content: "[";
margin-right: 4px;
color: var(--text-secondary);
}
.nav-link.active::after {
content: "]";
margin-left: 4px;
color: var(--text-secondary);
}
.main-content {
flex-grow: 1;
padding: var(--space-xl) var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-md);
}
.page-title {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
}
.node-grid {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.node-entry {
display: grid;
grid-template-columns: 240px 1fr 300px;
gap: var(--space-xxl);
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-xl);
}
.node-info {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.node-name {
font-size: 20px;
font-weight: 600;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #555;
}
.status-indicator.active {
background-color: #FFF;
box-shadow: 0 0 8px rgba(255,255,255,0.3);
}
.node-controls {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.btn-toggle {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
cursor: pointer;
text-align: left;
width: fit-content;
transition: all 0.2s;
}
.btn-toggle:hover {
border-color: #666;
background: #1a1a1a;
}
.btn-toggle.active {
background: var(--text-primary);
color: var(--bg-main);
}
.capacity-section {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.graph-container {
height: 60px;
display: flex;
align-items: flex-end;
gap: 2px;
border-left: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
padding-left: 4px;
}
.graph-bar {
flex: 1;
background-color: #333;
transition: height 0.3s;
}
.graph-bar.highlight {
background-color: var(--text-primary);
}
.sync-history {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.sync-row {
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid #222;
padding-bottom: 4px;
}
.sync-row span:last-child {
color: var(--text-primary);
}
.meta-pair {
display: flex;
flex-direction: column;
gap: 2px;
}
.meta-pair .val {
font-size: 16px;
font-family: var(--font-mono);
}
</style>
</head>
<body vid="6">
<div class="app-container" vid="7">
<aside class="sidebar" vid="8">
<div class="logo-container" vid="9">
<div class="logo-circle-full" vid="10"></div>
<div class="logo-circle-half" vid="11"></div>
</div>
<nav vid="12">
<ul class="nav-menu" vid="13">
<li class="nav-item" vid="14"><a href="#" class="nav-link" vid="15">대시</a></li>
<li class="nav-item" vid="16"><a href="#" class="nav-link" vid="17">프로</a></li>
<li class="nav-item" vid="18"><a href="#" class="nav-link active" vid="19">자매</a></li>
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">조직</a></li>
<li class="nav-item" vid="22"><a href="#" class="nav-link" vid="23">설정</a></li>
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">관리</a></li>
</ul>
</nav>
</aside>
<main class="main-content" vid="26">
<header class="page-header" vid="27">
<h1 class="page-title" vid="28">자매 노드 관리</h1>
<div class="label-meta" vid="29"><span vid="30">TOTAL NODES:</span> 04</div>
</header>
<section class="node-grid" vid="31">
<div class="node-entry" vid="32">
<div class="node-info" vid="33">
<div class="node-name" vid="34">
<div class="status-indicator active" vid="35"></div>
하랑 (Primary)
</div>
<div class="meta-pair" vid="36">
<div class="label-meta" vid="37">Uptime</div>
<div class="val" vid="38">342:12:05</div>
</div>
<div class="node-controls" vid="39">
<button class="btn-toggle active" vid="40">ACTIVE</button>
<button class="btn-toggle" vid="41">REBOOT</button>
</div>
</div>
<div class="capacity-section" vid="42">
<div class="label-meta" vid="43">CAPACITY HISTORY (24H)</div>
<div class="graph-container" vid="44">
<div class="graph-bar" style="height: 40%" vid="45"></div>
<div class="graph-bar" style="height: 45%" vid="46"></div>
<div class="graph-bar" style="height: 60%" vid="47"></div>
<div class="graph-bar" style="height: 55%" vid="48"></div>
<div class="graph-bar" style="height: 70%" vid="49"></div>
<div class="graph-bar" style="height: 85%" vid="50"></div>
<div class="graph-bar highlight" style="height: 92%" vid="51"></div>
<div class="graph-bar highlight" style="height: 84%" vid="52"></div>
</div>
<div class="sync-row" vid="53"><span vid="54">LOAD INDEX</span><span vid="55">84.2%</span></div>
</div>
<div class="sync-history" vid="56">
<div class="label-meta" vid="57">SYNC LOG</div>
<div class="sync-row" vid="58"><span vid="59">14:05:22</span><span vid="60">SUCCESS</span></div>
<div class="sync-row" vid="61"><span vid="62">13:55:01</span><span vid="63">SUCCESS</span></div>
<div class="sync-row" vid="64"><span vid="65">13:44:10</span><span vid="66">SUCCESS</span></div>
</div>
</div>
<div class="node-entry" vid="67">
<div class="node-info" vid="68">
<div class="node-name" vid="69">
<div class="status-indicator active" vid="70"></div>
나랑 (Secondary)
</div>
<div class="meta-pair" vid="71">
<div class="label-meta" vid="72">Uptime</div>
<div class="val" vid="73">112:44:21</div>
</div>
<div class="node-controls" vid="74">
<button class="btn-toggle active" vid="75">ACTIVE</button>
<button class="btn-toggle" vid="76">REBOOT</button>
</div>
</div>
<div class="capacity-section" vid="77">
<div class="label-meta" vid="78">CAPACITY HISTORY (24H)</div>
<div class="graph-container" vid="79">
<div class="graph-bar" style="height: 30%" vid="80"></div>
<div class="graph-bar" style="height: 32%" vid="81"></div>
<div class="graph-bar" style="height: 35%" vid="82"></div>
<div class="graph-bar" style="height: 40%" vid="83"></div>
<div class="graph-bar" style="height: 38%" vid="84"></div>
<div class="graph-bar" style="height: 45%" vid="85"></div>
<div class="graph-bar highlight" style="height: 50%" vid="86"></div>
<div class="graph-bar highlight" style="height: 48%" vid="87"></div>
</div>
<div class="sync-row" vid="88"><span vid="89">LOAD INDEX</span><span vid="90">48.7%</span></div>
</div>
<div class="sync-history" vid="91">
<div class="label-meta" vid="92">SYNC LOG</div>
<div class="sync-row" vid="93"><span vid="94">14:02:11</span><span vid="95">SUCCESS</span></div>
<div class="sync-row" vid="96"><span vid="97">13:51:44</span><span vid="98">SUCCESS</span></div>
<div class="sync-row" vid="99"><span vid="100">13:40:02</span><span vid="101">SUCCESS</span></div>
</div>
</div>
<div class="node-entry" vid="102">
<div class="node-info" vid="103">
<div class="node-name" vid="104">
<div class="status-indicator" vid="105"></div>
다랑 (Standby)
</div>
<div class="meta-pair" vid="106">
<div class="label-meta" vid="107">Last Active</div>
<div class="val" vid="108">02:15:00 ago</div>
</div>
<div class="node-controls" vid="109">
<button class="btn-toggle" vid="110">STANDBY</button>
<button class="btn-toggle" vid="111">ACTIVATE</button>
</div>
</div>
<div class="capacity-section" vid="112">
<div class="label-meta" vid="113">CAPACITY HISTORY (24H)</div>
<div class="graph-container" vid="114">
<div class="graph-bar" style="height: 10%" vid="115"></div>
<div class="graph-bar" style="height: 10%" vid="116"></div>
<div class="graph-bar" style="height: 5%" vid="117"></div>
<div class="graph-bar" style="height: 5%" vid="118"></div>
<div class="graph-bar" style="height: 5%" vid="119"></div>
<div class="graph-bar" style="height: 5%" vid="120"></div>
<div class="graph-bar" style="height: 2%" vid="121"></div>
<div class="graph-bar" style="height: 2%" vid="122"></div>
</div>
<div class="sync-row" vid="123"><span vid="124">LOAD INDEX</span><span vid="125">0.0%</span></div>
</div>
<div class="sync-history" vid="126">
<div class="label-meta" vid="127">SYNC LOG</div>
<div class="sync-row" vid="128"><span vid="129">14:00:00</span><span vid="130">IDLE</span></div>
<div class="sync-row" vid="131"><span vid="132">13:45:00</span><span vid="133">IDLE</span></div>
<div class="sync-row" vid="134"><span vid="135">13:30:00</span><span vid="136">IDLE</span></div>
</div>
</div>
<div class="node-entry" vid="137">
<div class="node-info" vid="138">
<div class="node-name" vid="139">
<div class="status-indicator active" vid="140"></div>
이랑 (Sync)
</div>
<div class="meta-pair" vid="141">
<div class="label-meta" vid="142">Queue Depth</div>
<div class="val" vid="143">12 PKTS</div>
</div>
<div class="node-controls" vid="144">
<button class="btn-toggle active" vid="145">SYNCING</button>
<button class="btn-toggle" vid="146">FLUSH</button>
</div>
</div>
<div class="capacity-section" vid="147">
<div class="label-meta" vid="148">CAPACITY HISTORY (24H)</div>
<div class="graph-container" vid="149">
<div class="graph-bar" style="height: 20%" vid="150"></div>
<div class="graph-bar" style="height: 25%" vid="151"></div>
<div class="graph-bar" style="height: 15%" vid="152"></div>
<div class="graph-bar" style="height: 30%" vid="153"></div>
<div class="graph-bar" style="height: 40%" vid="154"></div>
<div class="graph-bar" style="height: 35%" vid="155"></div>
<div class="graph-bar highlight" style="height: 20%" vid="156"></div>
<div class="graph-bar highlight" style="height: 15%" vid="157"></div>
</div>
<div class="sync-row" vid="158"><span vid="159">LOAD INDEX</span><span vid="160">15.2%</span></div>
</div>
<div class="sync-history" vid="161">
<div class="label-meta" vid="162">SYNC LOG</div>
<div class="sync-row" vid="163"><span vid="164">14:06:01</span><span vid="165">SYNCING</span></div>
<div class="sync-row" vid="166"><span vid="167">14:05:45</span><span vid="168">SYNCING</span></div>
<div class="sync-row" vid="169"><span vid="170">14:05:30</span><span vid="171">SYNCING</span></div>
</div>
</div>
</section>
</main>
</div>
</body></html>
```

View File

@@ -0,0 +1,502 @@
The code below contains a design. This design should be used to create a new app or be added to an existing one.
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
Run the dev command so the user can see the app once finished.
```
<html lang="ko" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">하나랑 대시보드 - 설정</title>
<style vid="5">
:root {
--bg-main: #151515;
--bg-surface: #151515;
--text-primary: #F5F5F5;
--text-secondary: #8A8A8A;
--border-color: #333333;
--accent-hover: #FFFFFF;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--space-xxl: 64px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
min-height: 100vh;
overflow-x: hidden;
}
.label-meta {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-primary);
}
.label-meta span {
color: var(--text-secondary);
margin-right: var(--space-sm);
}
.app-container {
display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: var(--space-xl) var(--space-lg);
position: sticky;
top: 0;
height: 100vh;
}
.logo-container {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
}
.logo-circle-full {
width: 20px;
height: 20px;
background-color: var(--text-primary);
border-radius: 50%;
}
.logo-circle-half {
width: 10px;
height: 20px;
background-color: var(--text-primary);
border-radius: 0 20px 20px 0;
}
.nav-menu {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.nav-item {
display: flex;
align-items: center;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
padding: var(--space-sm) 0;
transition: color 0.2s ease;
display: block;
width: 100%;
}
.nav-link:hover, .nav-link.active {
color: var(--accent-hover);
}
.nav-link.active::before {
content: "[";
margin-right: 4px;
color: var(--text-secondary);
}
.nav-link.active::after {
content: "]";
margin-left: 4px;
color: var(--text-secondary);
}
.main-content {
flex-grow: 1;
padding: var(--space-xl) var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-xl);
min-width: 0;
}
.page-header {
margin-bottom: var(--space-md);
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.section-title {
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-sm);
margin-bottom: var(--space-lg);
display: flex;
justify-content: space-between;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-xxl);
}
.settings-section {
display: flex;
flex-direction: column;
}
.settings-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) 0;
border-bottom: 1px solid #222;
}
.settings-row:last-child {
border-bottom: none;
}
.setting-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.setting-label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.setting-desc {
font-size: 12px;
color: var(--text-secondary);
}
.switch {
position: relative;
display: inline-block;
width: 34px;
height: 18px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #2a2a2a;
transition: .2s;
border: 1px solid var(--border-color);
}
.slider:before {
position: absolute;
content: "";
height: 10px;
width: 10px;
left: 3px;
bottom: 3px;
background-color: var(--text-secondary);
transition: .2s;
}
input:checked + .slider {
background-color: #151515;
border-color: var(--text-primary);
}
input:checked + .slider:before {
transform: translateX(16px);
background-color: var(--text-primary);
}
.bracket-input-group {
display: flex;
align-items: center;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.bracket-input {
background: transparent;
border: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 14px;
width: 60px;
text-align: center;
outline: none;
padding: 0 4px;
}
.bracket-input:focus {
color: var(--accent-hover);
}
.action-bar {
margin-top: auto;
padding-top: var(--space-xl);
display: flex;
justify-content: flex-end;
gap: var(--space-md);
}
.btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: var(--space-sm) var(--space-lg);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn:hover {
border-color: var(--text-primary);
color: var(--text-primary);
}
.btn-primary {
background: var(--text-primary);
color: var(--bg-main);
border-color: var(--text-primary);
}
.btn-primary:hover {
background: transparent;
color: var(--text-primary);
}
</style>
</head>
<body vid="6">
<div class="app-container" vid="7">
<aside class="sidebar" vid="8">
<div class="logo-container" vid="9">
<div class="logo-circle-full" vid="10"></div>
<div class="logo-circle-half" vid="11"></div>
</div>
<nav vid="12">
<ul class="nav-menu" vid="13">
<li class="nav-item" vid="14"><a href="#" class="nav-link" vid="15">대시</a></li>
<li class="nav-item" vid="16"><a href="#" class="nav-link" vid="17">프로</a></li>
<li class="nav-item" vid="18"><a href="#" class="nav-link" vid="19">자매</a></li>
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">조직</a></li>
<li class="nav-item" vid="22"><a href="#" class="nav-link active" vid="23">설정</a></li>
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">관리</a></li>
</ul>
</nav>
</aside>
<main class="main-content" vid="26">
<header class="page-header" vid="27">
<h1 class="page-title" vid="28">시스템 설정</h1>
<div class="label-meta" vid="29"><span vid="30">CONFIG:</span>v2.0.4-STABLE</div>
</header>
<div class="settings-grid" vid="31">
<section class="settings-section" vid="32">
<div class="section-title" vid="33">
<span vid="34">ADMIN PROTOCOL RULES</span>
<span class="label-meta" vid="35">SEC: 01</span>
</div>
<div class="settings-row" vid="36">
<div class="setting-info" vid="37">
<div class="setting-label" vid="38">강제 2단계 인증 (2FA)</div>
<div class="setting-desc" vid="39">모든 관리자 계정에 대해 생체 인식 또는 하드웨어 키 요구.</div>
</div>
<label class="switch" vid="40">
<input type="checkbox" checked="" vid="41">
<span class="slider" vid="42"></span>
</label>
</div>
<div class="settings-row" vid="43">
<div class="setting-info" vid="44">
<div class="setting-label" vid="45">세션 유효 시간</div>
<div class="setting-desc" vid="46">비활동 시 관리 콘솔 자동 로그아웃 시간(분).</div>
</div>
<div class="bracket-input-group" vid="47">
[ <input type="text" class="bracket-input" value="15" vid="48"> ]
</div>
</div>
<div class="settings-row" vid="49">
<div class="setting-info" vid="50">
<div class="setting-label" vid="51">IP 화이트리스트</div>
<div class="setting-desc" vid="52">지정된 대역에서만 관리자 권한 접근 허용.</div>
</div>
<label class="switch" vid="53">
<input type="checkbox" vid="54">
<span class="slider" vid="55"></span>
</label>
</div>
</section>
<section class="settings-section" vid="56">
<div class="section-title" vid="57">
<span vid="58">BACKUP &amp; REDUNDANCY</span>
<span class="label-meta" vid="59">SEC: 02</span>
</div>
<div class="settings-row" vid="60">
<div class="setting-info" vid="61">
<div class="setting-label" vid="62">자동 백업 활성화</div>
<div class="setting-desc" vid="63">전체 시스템 상태의 주기적인 스냅샷 생성.</div>
</div>
<label class="switch" vid="64">
<input type="checkbox" checked="" vid="65">
<span class="slider" vid="66"></span>
</label>
</div>
<div class="settings-row" vid="67">
<div class="setting-info" vid="68">
<div class="setting-label" vid="69">백업 주기 (시간)</div>
<div class="setting-desc" vid="70">스냅샷 생성 간격 설정.</div>
</div>
<div class="bracket-input-group" vid="71">
[ <input type="text" class="bracket-input" value="08:00" vid="72"> ]
</div>
</div>
<div class="settings-row" vid="73">
<div class="setting-info" vid="74">
<div class="setting-label" vid="75">보관 주기 (일)</div>
<div class="setting-desc" vid="76">백업 데이터 자동 삭제 전 유지 기간.</div>
</div>
<div class="bracket-input-group" vid="77">
[ <input type="text" class="bracket-input" value="30" vid="78"> ]
</div>
</div>
</section>
<section class="settings-section" vid="79">
<div class="section-title" vid="80">
<span vid="81">SYNC QUEUE THRESHOLDS</span>
<span class="label-meta" vid="82">SEC: 03</span>
</div>
<div class="settings-row" vid="83">
<div class="setting-info" vid="84">
<div class="setting-label" vid="85">대기열 경고 임계값</div>
<div class="setting-desc" vid="86">동기화 대기 항목이 설정치를 초과할 시 경보 발생.</div>
</div>
<div class="bracket-input-group" vid="87">
[ <input type="text" class="bracket-input" value="500" vid="88"> ]
</div>
</div>
<div class="settings-row" vid="89">
<div class="setting-info" vid="90">
<div class="setting-label" vid="91">자동 스케일링</div>
<div class="setting-desc" vid="92">부하 증가 시 임시 노드 리소스 할당.</div>
</div>
<label class="switch" vid="93">
<input type="checkbox" checked="" vid="94">
<span class="slider" vid="95"></span>
</label>
</div>
</section>
<section class="settings-section" vid="96">
<div class="section-title" vid="97">
<span vid="98">NODE ALERT TRIGGERS</span>
<span class="label-meta" vid="99">SEC: 04</span>
</div>
<div class="settings-row" vid="100">
<div class="setting-info" vid="101">
<div class="setting-label" vid="102">CPU 사용량 임계값 (%)</div>
<div class="setting-desc" vid="103">시스템 부하 경고 기준점.</div>
</div>
<div class="bracket-input-group" vid="104">
[ <input type="text" class="bracket-input" value="90" vid="105"> ]
</div>
</div>
<div class="settings-row" vid="106">
<div class="setting-info" vid="107">
<div class="setting-label" vid="108">레이턴시 경보 (ms)</div>
<div class="setting-desc" vid="109">응답 속도 지연에 대한 임계값 설정.</div>
</div>
<div class="bracket-input-group" vid="110">
[ <input type="text" class="bracket-input" value="250" vid="111"> ]
</div>
</div>
<div class="settings-row" vid="112">
<div class="setting-info" vid="113">
<div class="setting-label" vid="114">노드 오프라인 알림</div>
<div class="setting-desc" vid="115">활성 노드 연결 해제 시 즉시 알림.</div>
</div>
<label class="switch" vid="116">
<input type="checkbox" checked="" vid="117">
<span class="slider" vid="118"></span>
</label>
</div>
</section>
</div>
<div class="action-bar" vid="119">
<button class="btn" vid="120">초기화</button>
<button class="btn btn-primary" vid="121">설정 저장</button>
</div>
</main>
</div>
</body></html>
```

View File

@@ -0,0 +1,408 @@
The code below contains a design. This design should be used to create a new app or be added to an existing one.
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
Run the dev command so the user can see the app once finished.
```
<html lang="ko" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">하나랑 대시보드 - ACTIVITY LOG</title>
<style vid="5">
:root {
--bg-main: #151515;
--bg-surface: #151515;
--text-primary: #F5F5F5;
--text-secondary: #8A8A8A;
--border-color: #333333;
--accent-hover: #FFFFFF;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--space-xxl: 64px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
min-height: 100vh;
overflow-x: hidden;
}
.app-container {
display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: var(--space-xl) var(--space-lg);
position: sticky;
top: 0;
height: 100vh;
}
.logo-container {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
}
.logo-circle-full {
width: 20px;
height: 20px;
background-color: var(--text-primary);
border-radius: 50%;
}
.logo-circle-half {
width: 10px;
height: 20px;
background-color: var(--text-primary);
border-radius: 0 20px 20px 0;
}
.nav-menu {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
padding: var(--space-sm) 0;
transition: color 0.2s ease;
display: block;
width: 100%;
}
.nav-link:hover, .nav-link.active {
color: var(--accent-hover);
}
.nav-link.active::before { content: "["; margin-right: 4px; color: var(--text-secondary); }
.nav-link.active::after { content: "]"; margin-left: 4px; color: var(--text-secondary); }
.main-content {
flex-grow: 1;
padding: var(--space-xl) var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-lg);
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-md);
margin-bottom: var(--space-md);
}
.page-title {
font-size: 24px;
font-weight: 600;
letter-spacing: -0.02em;
}
.header-meta {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.log-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-lg);
margin-bottom: var(--space-md);
}
.filter-group {
display: flex;
gap: var(--space-md);
}
.filter-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 6px 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover {
border-color: #555;
color: var(--text-primary);
}
.filter-btn.active {
background: var(--text-primary);
color: var(--bg-main);
border-color: var(--text-primary);
}
.search-box {
position: relative;
flex-grow: 1;
max-width: 300px;
}
.search-input {
width: 100%;
background: #1a1a1a;
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 12px;
outline: none;
}
.search-input:focus {
border-color: #555;
}
.log-container {
border: 1px solid var(--border-color);
background: #0d0d0d;
font-family: var(--font-mono);
}
.log-table {
width: 100%;
border-collapse: collapse;
}
.log-table th {
text-align: left;
font-size: 11px;
color: var(--text-secondary);
padding: var(--space-md);
border-bottom: 1px solid var(--border-color);
text-transform: uppercase;
}
.log-table td {
padding: 12px 16px;
font-size: 12px;
border-bottom: 1px solid #1a1a1a;
vertical-align: top;
}
.log-row:hover {
background-color: #1a1a1a;
}
.col-time { width: 180px; color: var(--text-secondary); }
.col-type { width: 100px; font-weight: bold; }
.col-event { color: var(--text-primary); }
.col-status { width: 100px; text-align: right; }
.tag {
font-size: 10px;
padding: 2px 6px;
border: 1px solid currentColor;
}
.tag-sys { color: #888; }
.tag-sec { color: #ff5f5f; }
.tag-org { color: #5fafff; }
.tag-bak { color: #5fff8a; }
.pagination {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-md);
margin-top: var(--space-md);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.page-nav {
cursor: pointer;
color: var(--text-primary);
text-decoration: none;
}
.page-nav:hover {
text-decoration: underline;
}
.bracket-label {
font-weight: bold;
color: var(--text-primary);
}
</style>
</head>
<body vid="6">
<div class="app-container" vid="7">
<aside class="sidebar" vid="8">
<div class="logo-container" vid="9">
<div class="logo-circle-full" vid="10"></div>
<div class="logo-circle-half" vid="11"></div>
</div>
<nav vid="12">
<ul class="nav-menu" vid="13">
<li class="nav-item" vid="14"><a href="#" class="nav-link" vid="15">대시</a></li>
<li class="nav-item" vid="16"><a href="#" class="nav-link active" vid="17">활동</a></li>
<li class="nav-item" vid="18"><a href="#" class="nav-link" vid="19">프로</a></li>
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">자매</a></li>
<li class="nav-item" vid="22"><a href="#" class="nav-link" vid="23">조직</a></li>
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">설정</a></li>
<li class="nav-item" vid="26"><a href="#" class="nav-link" vid="27">관리</a></li>
</ul>
</nav>
</aside>
<main class="main-content" vid="28">
<header class="page-header" vid="29">
<h1 class="page-title" vid="30">활동 로그 <span style="color:var(--text-secondary); font-size: 18px;" vid="31">/ ACTIVITY LOG</span></h1>
<div class="header-meta" vid="32">SYS_LOG_V.4.2 // RUNTIME: 428:12:05</div>
</header>
<section class="log-controls" vid="33">
<div class="filter-group" vid="34">
<button class="filter-btn active" vid="35">ALL</button>
<button class="filter-btn" vid="36">SYSTEM</button>
<button class="filter-btn" vid="37">SECURITY</button>
<button class="filter-btn" vid="38">ORG</button>
<button class="filter-btn" vid="39">BACKUP</button>
</div>
<div class="search-box" vid="40">
<input type="text" class="search-input" placeholder="SEARCH LOGS..." vid="41">
</div>
</section>
<div class="log-container" vid="42">
<table class="log-table" vid="43">
<thead vid="44">
<tr vid="45">
<th vid="46">TIMESTAMP</th>
<th vid="47">TYPE</th>
<th vid="48">EVENT DESCRIPTION</th>
<th style="text-align: right;" vid="49">CODE</th>
</tr>
</thead>
<tbody vid="50">
<tr class="log-row" vid="51">
<td class="col-time" vid="52">2023-10-27 14:02:45</td>
<td class="col-type" vid="53"><span class="tag tag-sys" vid="54">SYSTEM</span></td>
<td class="col-event" vid="55">Node <span class="bracket-label" vid="56">[다랑]</span> transitioned to status: <span class="bracket-label" vid="57">STANDBY</span>. Protocol initiated by admin_auth_01.</td>
<td class="col-status" vid="58">0x402</td>
</tr>
<tr class="log-row" vid="59">
<td class="col-time" vid="60">2023-10-27 13:45:12</td>
<td class="col-type" vid="61"><span class="tag tag-org" vid="62">ORG</span></td>
<td class="col-event" vid="63">New sub-directory created under <span class="bracket-label" vid="64">[Organization Beta]</span>. Path mapping complete.</td>
<td class="col-status" vid="65">0x112</td>
</tr>
<tr class="log-row" vid="66">
<td class="col-time" vid="67">2023-10-27 11:30:12</td>
<td class="col-type" vid="68"><span class="tag tag-sec" vid="69">SECURITY</span></td>
<td class="col-event" vid="70">Full security sweep completed for project <span class="bracket-label" vid="71">[DATA_PIPE_OPT]</span>. Zero vulnerabilities identified.</td>
<td class="col-status" vid="72">0x000</td>
</tr>
<tr class="log-row" vid="73">
<td class="col-time" vid="74">2023-10-27 10:15:33</td>
<td class="col-type" vid="75"><span class="tag tag-sys" vid="76">SYSTEM</span></td>
<td class="col-event" vid="77">Memory allocation threshold reached on <span class="bracket-label" vid="78">[나랑]</span>. Auto-scaling provisioned +4GB swap.</td>
<td class="col-status" vid="79">0x284</td>
</tr>
<tr class="log-row" vid="80">
<td class="col-time" vid="81">2023-10-27 09:15:00</td>
<td class="col-type" vid="82"><span class="tag tag-org" vid="83">ORG</span></td>
<td class="col-event" vid="84">Batch import: 42 new identities integrated into <span class="bracket-label" vid="85">[Beta]</span> structure. </td>
<td class="col-status" vid="86">0x105</td>
</tr>
<tr class="log-row" vid="87">
<td class="col-time" vid="88">2023-10-27 08:00:05</td>
<td class="col-type" vid="89"><span class="tag tag-bak" vid="90">BACKUP</span></td>
<td class="col-event" vid="91">Scheduled snapshot <span class="bracket-label" vid="92">[DAILY_BKP_2710]</span> finalized. Target: S3_COLD_STORAGE.</td>
<td class="col-status" vid="93">0x900</td>
</tr>
<tr class="log-row" vid="94">
<td class="col-time" vid="95">2023-10-27 07:42:11</td>
<td class="col-type" vid="96"><span class="tag tag-sec" vid="97">SECURITY</span></td>
<td class="col-event" vid="98">SSH login attempt blocked. IP: 192.168.1.104. Failed attempts: 3. Protocol: BAN_IP.</td>
<td class="col-status" vid="99">0x666</td>
</tr>
<tr class="log-row" vid="100">
<td class="col-time" vid="101">2023-10-27 04:20:00</td>
<td class="col-type" vid="102"><span class="tag tag-sys" vid="103">SYSTEM</span></td>
<td class="col-event" vid="104">Garbage collection cycle completed on all active nodes. Freed 1.2GB.</td>
<td class="col-status" vid="105">0x401</td>
</tr>
<tr class="log-row" vid="106">
<td class="col-time" vid="107">2023-10-27 01:12:45</td>
<td class="col-type" vid="108"><span class="tag tag-sys" vid="109">SYSTEM</span></td>
<td class="col-event" vid="110">Kernel patch 1.2.4 deployed to <span class="bracket-label" vid="111">[하랑]</span>. Restart bypassed via hotpatching.</td>
<td class="col-status" vid="112">0x400</td>
</tr>
</tbody>
</table>
</div>
<footer class="pagination" vid="113">
<span class="page-info" vid="114">SHOWING 1-9 OF 2,410 ENTRIES</span>
<div style="display: flex; gap: var(--space-md);" vid="115">
<span class="page-nav" vid="116">[PREV]</span>
<span style="color: var(--text-primary);" vid="117">PAGE 01</span>
<span class="page-nav" vid="118">[NEXT]</span>
</div>
</footer>
</main>
</div>
</body></html>
```

View File

@@ -0,0 +1,363 @@
The code below contains a design. This design should be used to create a new app or be added to an existing one.
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
Run the dev command so the user can see the app once finished.
```
<html lang="ko" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">하나랑 - 시스템 아키텍처</title>
<style vid="5">
:root {
--bg-main: #0a0a0a;
--bg-card: #141414;
--text-primary: #e0e0e0;
--text-secondary: #707070;
--border-color: #262626;
--level-high: #f43f5e;
--level-mid: #3b82f6;
--level-low: #10b981;
--level-none: #525252;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.5;
font-size: 13px;
-webkit-font-smoothing: antialiased;
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
height: 100vh;
}
.sidebar {
width: 64px;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 24px;
background: #0d0d0d;
}
.logo-container {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 48px;
}
.logo-circle-full { width: 16px; height: 16px; background: #fff; border-radius: 50%; }
.logo-circle-half { width: 8px; height: 16px; background: #fff; border-radius: 0 16px 16px 0; margin-left: 8px; }
.nav-item {
color: var(--text-secondary);
font-family: var(--font-mono);
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180px);
margin: 16px 0;
text-decoration: none;
font-size: 11px;
letter-spacing: 1px;
transition: color 0.2s;
}
.nav-item.active { color: #fff; }
.main-viewport {
flex-grow: 1;
overflow: auto;
padding: 40px;
background-image: radial-gradient(circle, #1a1a1a 1px, transparent 1px);
background-size: 30px 30px;
display: flex;
flex-direction: column;
align-items: center;
}
.header-info {
width: 100%;
max-width: 1000px;
display: flex;
justify-content: space-between;
margin-bottom: 60px;
font-family: var(--font-mono);
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
}
.tree-container {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.tree-branch {
display: flex;
justify-content: center;
position: relative;
padding-top: 40px;
}
.tree-branch::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 1px;
height: 40px;
background: var(--border-color);
}
.tree-children {
display: flex;
gap: 32px;
position: relative;
}
.tree-children::before {
content: "";
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 100px);
height: 1px;
background: var(--border-color);
}
.terminal-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-left: 3px solid var(--level-none);
width: 200px;
padding: 12px;
position: relative;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.terminal-card.high { border-left-color: var(--level-high); }
.terminal-card.mid { border-left-color: var(--level-mid); }
.terminal-card.low { border-left-color: var(--level-low); }
.node-label {
font-family: var(--font-mono);
font-weight: 600;
font-size: 13px;
display: block;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-label span {
color: var(--text-secondary);
font-size: 11px;
margin-right: 4px;
}
.badge-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.badge {
font-family: var(--font-mono);
font-size: 9px;
background: #1a1a1a;
color: var(--text-secondary);
padding: 2px 4px;
border: 1px solid #262626;
text-transform: uppercase;
}
.badge b { color: var(--text-primary); font-weight: normal; }
.node-group {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.toggle {
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 16px;
height: 16px;
background: var(--bg-card);
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
z-index: 2;
}
</style>
</head>
<body vid="6">
<div class="app-container" vid="7">
<aside class="sidebar" vid="8">
<div class="logo-container" vid="9">
<div class="logo-circle-full" vid="10"></div>
<div class="logo-circle-half" vid="11"></div>
</div>
<a href="#" class="nav-item" vid="12">DASH</a>
<a href="#" class="nav-item" vid="13">PROJ</a>
<a href="#" class="nav-item active" vid="14">ORG_TREE</a>
<a href="#" class="nav-item" vid="15">CONF</a>
</aside>
<main class="main-viewport" vid="16">
<header class="header-info" vid="17">
<div vid="18">SYS_TYPE: <span style="color: #fff;" vid="19">CORE_ROOT_01</span></div>
<div vid="20">TIMESTAMP: <span style="color: var(--text-secondary);" vid="21">2023.10.27_14:42</span></div>
</header>
<div class="tree-container" vid="22">
<div class="node-group" vid="23">
<div class="terminal-card high" vid="24">
<div class="node-label" vid="25"><span vid="26">[HQ]</span> 하나랑 글로벌</div>
<div class="badge-row" vid="27">
<div class="badge" vid="28">MEM: <b vid="29">142</b></div>
<div class="badge" vid="30">S_ADMIN</div>
<div class="badge" vid="31">LV_09</div>
</div>
<div class="toggle" vid="32"></div>
</div>
<div class="tree-branch" vid="33">
<div class="tree-children" vid="34">
<div class="node-group" vid="35">
<div class="terminal-card mid" vid="36">
<div class="node-label" vid="37"><span vid="38">[R&amp;D]</span> 기술연구소</div>
<div class="badge-row" vid="39">
<div class="badge" vid="40">MEM: <b vid="41">48</b></div>
<div class="badge" vid="42">LEAD</div>
<div class="badge" vid="43">LV_07</div>
</div>
<div class="toggle" vid="44"></div>
</div>
<div class="tree-branch" vid="45">
<div class="tree-children" vid="46">
<div class="terminal-card mid" vid="47">
<div class="node-label" vid="48">Core Engine</div>
<div class="badge-row" vid="49">
<div class="badge" vid="50">MEM: <b vid="51">12</b></div>
<div class="badge" vid="52">WRITE</div>
<div class="badge" vid="53">LV_05</div>
</div>
</div>
<div class="terminal-card mid" vid="54">
<div class="node-label" vid="55">Infrastructure</div>
<div class="badge-row" vid="56">
<div class="badge" vid="57">MEM: <b vid="58">36</b></div>
<div class="badge" vid="59">DEPLOY</div>
<div class="badge" vid="60">LV_06</div>
</div>
</div>
</div>
</div>
</div>
<div class="node-group" vid="61">
<div class="terminal-card low" vid="62">
<div class="node-label" vid="63"><span vid="64">[OPS]</span> 운영지원팀</div>
<div class="badge-row" vid="65">
<div class="badge" vid="66">MEM: <b vid="67">94</b></div>
<div class="badge" vid="68">MGR</div>
<div class="badge" vid="69">LV_04</div>
</div>
<div class="toggle" vid="70"></div>
</div>
<div class="tree-branch" vid="71">
<div class="terminal-card low" vid="72">
<div class="node-label" vid="73">Monitoring</div>
<div class="badge-row" vid="74">
<div class="badge" vid="75">MEM: <b vid="76">24</b></div>
<div class="badge" vid="77">READ</div>
<div class="badge" vid="78">LV_02</div>
</div>
</div>
</div>
</div>
<div class="node-group" vid="79">
<div class="terminal-card low" vid="80">
<div class="node-label" vid="81"><span vid="82">[EXT]</span> 외부 협력사</div>
<div class="badge-row" vid="83">
<div class="badge" vid="84">MEM: <b vid="85">12</b></div>
<div class="badge" vid="86">GUEST</div>
<div class="badge" vid="87">LV_01</div>
</div>
<div class="toggle" vid="88"></div>
</div>
<div class="tree-branch" vid="89">
<div class="terminal-card low" vid="90">
<div class="node-label" vid="91">Security Audit</div>
<div class="badge-row" vid="92">
<div class="badge" vid="93">MEM: <b vid="94">04</b></div>
<div class="badge" vid="95">LIMIT</div>
<div class="badge" vid="96">LV_01</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<footer style="margin-top: auto; width: 100%; max-width: 1000px; padding: 24px 0; display: flex; justify-content: space-between; border-top: 1px solid var(--border-color); font-family: var(--font-mono); color: var(--text-secondary); font-size: 10px;" vid="97">
<div vid="98">TOTAL_NODES: 07</div>
<div vid="99">STATUS: SYNC_COMPLETE</div>
</footer>
</main>
</div>
</body></html>
```

View File

@@ -0,0 +1,81 @@
# 활동 로그 (`/activities`) — 디자인 v2
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #5
## 레이아웃
```
┌──────────┬──────────────────────────────────────────────────┐
│ Sidebar │ 활동 로그 / ACTIVITY LOG SYS_LOG_V.4.2 //... │
│ │ ────────────────────────────────────────────── │
│ │ │
│ │ [ALL] [SYSTEM] [SECURITY] [ORG] [BACKUP] 🔍... │
│ │ │
│ │ ┌──────────────────────────────────────────────┐│
│ │ │ TIMESTAMP TYPE EVENT CODE││
│ │ │ 2023-10-27 14:02 SYSTEM Node [다랑] 0x402││
│ │ │ 2023-10-27 13:45 ORG New sub-dir 0x112││
│ │ │ 2023-10-27 11:30 SECURITY Full sweep 0x000││
│ │ │ 2023-10-27 10:15 SYSTEM Memory alloc 0x284││
│ │ │ 2023-10-27 09:15 ORG Batch import 0x105││
│ │ │ 2023-10-27 08:00 BACKUP Snapshot 0x900││
│ │ │ 2023-10-27 07:42 SECURITY SSH blocked 0x666││
│ │ │ 2023-10-27 04:20 SYSTEM GC cycle 0x401││
│ │ │ 2023-10-27 01:12 SYSTEM Kernel patch 0x400││
│ │ └──────────────────────────────────────────────┘│
│ │ SHOWING 1-9 OF 2,410 [PREV] 01 [NEXT]│
└──────────┴──────────────────────────────────────────────────┘
```
## 네비게이션 변경
- 사이드바에 **"활동"** 메뉴 추가 (대시 다음)
## 페이지 헤더
- page-title: "활동 로그" (24px 600) + ` / ACTIVITY LOG` (18px secondary)
- header-meta: mono 11px, "SYS_LOG_V.4.2 // RUNTIME: 428:12:05"
- border-bottom 1px
## 필터 + 검색 (Log Controls)
- `justify-content: space-between; align-items: center`
- **filter-group**: 가로 flex, gap 16px
- filter-btn: 11px 700 uppercase, border 1px
- active: 흰색 배경 + 검정 텍스트
- 옵션: ALL / SYSTEM / SECURITY / ORG / BACKUP
- **search-box**: max-width 300px, flex-grow
- search-input: `#1a1a1a` 배경, border 1px, mono 12px
- placeholder: "SEARCH LOGS..."
## 로그 테이블 (Log Container)
- 외곽: border 1px, `background: #0d0d0d`
- table: 전체 너비, border-collapse
- **th**: 11px uppercase secondary, padding 16px, border-bottom 1px
- **td**: 12px mono, padding 12px 16px, border-bottom `#1a1a1a`
- **hover**: 배경 `#1a1a1a`
### 컬럼
| 컬럼 | 너비 | 스타일 |
|------|------|--------|
| TIMESTAMP | 180px | secondary |
| TYPE | 100px | bold + tag |
| EVENT | auto | primary, `[이름]` bold 강조 |
| CODE | 100px | right align |
### 태그 (Tag)
- 10px, padding 2px 6px, border 1px currentColor
- SYSTEM: `#888`
- SECURITY: `#ff5f5f`
- ORG: `#5fafff`
- BACKUP: `#5fff8a`
## 페이지네이션
- `justify-content: flex-end` / mono 11px
- "SHOWING 1-9 OF 2,410 ENTRIES"
- `[PREV]` `PAGE 01` `[NEXT]`
- PREV/NEXT: primary 색, hover underline
## 모바일 반응형
- filter-group: 가로 스크롤 또는 드롭다운 변환
- search-box: 풀 너비
- log-table: 가로 스크롤 wrapper 또는 카드 형태 변환
- 카드: 타임스탬프 + 태그 상단, 이벤트 본문, 코드 하단
- 페이지네이션: 간소화 (< 01 >)

View File

@@ -1,47 +1,47 @@
# 관리자 페이지 — UI 디자인 (`/admin/*`)
# 관리자 페이지 (`/admin/*`) — 디자인 v2
## 자매 관리 (`/admin/sisters`)
> 참조: DESIGN-SYSTEM.md
> 관리자 페이지는 기존 Sprint 004에서 구현된 기능을 v2 디자인 시스템에 맞춰 리스타일링.
> 별도 디자인 파일이 없으므로, 다른 페이지의 패턴을 조합.
```
┌──────────────────────────────────────┐
│ 자매 관리 │
├──────────────────────────────────────┤
│ ┌─────────────────────────────────┐ │
│ │ 하랑이 [온라인] [재시작] [리셋] │ │
│ │ 나랑이 [온라인] [재시작] [리셋] │ │
│ │ 다랑이 [오프라인] [재시작] [리셋] │ │
│ │ 이랑이 [온라인] [재시작] [리셋] │ │
│ └─────────────────────────────────┘ │
└──────────────────────────────────────┘
```
## 페이지 목록
- `/admin` — 자매 관리 (재시작/리셋)
- `/admin/harness` — 하네스 파일 편집
- `/admin/logs` — 로그 뷰어
- `/admin/costs` — 비용 대시보드
- `/admin/repos` — 저장소 관리
- 각 자매 행: 이름 + 상태 + 액션 버튼
- 재시작/리셋 버튼은 확인 모달 필수
- 실시간 상태 업데이트
## 공통 스타일
- page-header: 제목 28px + label-meta (ADMIN PANEL)
- section-title 패턴 동일
- 모든 입력/버튼: DESIGN-SYSTEM.md의 btn, bracket-input, switch 사용
- 인증 필요 표시: 상단에 `label-meta``AUTH: BEARER TOKEN REQUIRED`
## 하네스 편집 (`/admin/harness`)
## /admin — 자매 관리
- node-mini-card 스타일 (프로젝트 상세의 ASSIGNED NODES와 동일)
- 각 자매: 이름 + 상태 도트 + btn-toggle (RESTART / RESET)
- 결과 표시: audit-row 스타일 (타임스탬프 + 액션 + 결과)
- 좌측: 파일 트리 (자매 선택 → 파일 선택)
- 우측: 코드 에디터 (Monaco Editor 또는 CodeMirror)
- 저장 버튼 → SSH로 파일 쓰기 + SSOT push
- 변경 사항 diff 표시
## /admin/harness — 하네스 편집
- 드롭다운: 자매 선택 + 파일 선택
- 텍스트 에디터: `#0d0d0d` 배경, mono 12px, border 1px
- 하단: btn (취소) + btn-primary (저장)
## 로그 뷰어 (`/admin/logs`)
## /admin/logs — 로그 뷰어
- activity-log-design.md의 로그 테이블 패턴 활용
- 자매 필터 + lines 제한
- 실시간 스트림: mono 텍스트, 자동 스크롤
- 자매 선택 탭
- 실시간 로그 스트림 (terminal 스타일)
- 검색 + 에러 필터
- 다크 터미널 배경
## /admin/costs — 비용 대시보드
- 상단: 요약 카드 3개 (card 스타일)
- 총 토큰 / 총 비용 / 평균 일일 비용
- bracket-value로 표시
- 중앙: BarChart (graph-container 스타일 확장)
- 일별 바, highlight 최근
- border-left + border-bottom
- 하단: 기간 필터 (filter-btn 스타일)
## 비용 모니터 (`/admin/costs`)
- 모델별 토큰 사용량 차트 (bar chart)
- 자매별 사용량 비교
- 기간 필터 (일/주/월)
- 예상 비용 계산
## 공통 컴포넌트
- `<ConfirmModal />` — 확인 모달
- `<CodeEditor />` — 코드 에디터
- `<LogTerminal />` — 터미널 스타일 로그 뷰어
- `<CostChart />` — 비용 차트
## 모바일 반응형
- 카드/행: 1열 수직 스택
- 텍스트 에디터: 풀 너비, 높이 축소
- 비용 차트: 가로 스크롤

View File

@@ -1,67 +1,58 @@
# 메인 대시보드 — UI 디자인 (`/`)
# 대시보드 메인 (`/`) — 디자인 v2
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #1
## 레이아웃
```
┌─────────────────────────────────────────────────────┐
[사이드바] [메인 콘텐츠]
┌──────┐ ┌────────────────────────────────────┐
│ 로고 │ 헤더: "하나랑 대시보드"
│ │ ├────────────────────────────────────┤
│ 메뉴 │ [자매 상태 카드 4개 - 가로 배치] │
│ - 대시 │ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│ - 프로 │ │ │하랑 │ │나랑 │ │다랑 │ │이랑
│ - 자매 │ │ └────┘ └────┘ └────┘ └────┘
│ - 조직 │ ├────────────────────────────────────┤
│ - 설정 │ │ [진행 중 프로젝트] [최근 활동 피드]
│ - 관리 │ │ 카드 리스트 타임라인
└──────┘ └────────────────────────────────────┘
└─────────────────────────────────────────────────────┘
┌──────────┬─────────────────────────────────────────────┐
│ 하나랑 대시보드
●◗ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
[대시] │ │SYS: │ │SYS: │ │SYS: │ │SYS: │
활동 │ │하랑 │나랑 │ │다랑 │ │이랑 │
프로 │ │[ON] │ │[84] │ │[--] │ │[12] │
자매 │ │▬▬▬▬▬ │ │▬▬▬▬░ │ │░░░░░ │ │▬░░░░ │ │
조직 └──────┘ └────┘ └────┘ └──────┘ │
설정 │
관리 │ ONGOING PROJECTS ACTIVITY FEED
───────────────── ──────────────
│ ● 데이터 파이프라인 PHASE 2 │ 14:02:45
│ │ ● 새로운 자매 노드 INIT │ System... │
│ │ ● 관리자 권한 체계 REVIEW │ 11:30:12 │
│ │ │ Security... │
└──────────┴─────────────────────────────────────────────┘
```
## 자매 상태 카드
- **크기:** 가로 4등분 (반응형: 태블릿 2열, 모바일 1열)
- **내용:**
- 이름 + 역할 (예: "하랑이 · Orchestrator")
- 상태 뱃지 (온라인/오프라인/작업중) + 상태 색상 dot
- 마지막 활동 시간
- 현재 작업 요약 (있으면)
- **스타일:**
- glassmorphism 카드 (반투명 배경 + blur)
- 호버 시 살짝 확대 + 그림자 강화
- 상태별 좌측 보더 색상: 온라인=#00E676, 오프라인=#FF1744, 작업중=#2979FF
## 상태 카드 (Status Grid)
- `grid-template-columns: repeat(4, 1fr)` / gap: 24px
- 각 카드: `card` 컴포넌트 (DESIGN-SYSTEM.md 참조)
- 구조:
- header: `label-meta``SYS:` (secondary) + 이름 (primary)
- body: `bracket-value``[ON]`, `[84]`, `[--]`, `[12]`
- sub-label: Active Node / Capacity % / Standby / Sync Queue
- tech-bar: 2px 진행 바 (100% / 84% / 0% / 12%)
- standby 카드: bracket-value `color: var(--text-secondary)`
## 진행 중 프로젝트 섹션
- 카드 리스트: 프로젝트명 + 현재 Sprint + 진행률 바
- 클릭 시 `/projects/:id`로 이동
## 하단 2열 (Data Columns)
- `grid-template-columns: 1.5fr 1fr` / gap: 64px
## 최근 활동 피드
- 타임라인 형태
- 각 항목: 시간 + 자매 아이콘 + 활동 내용
- 예: "10:47 🦊 하랑이 — Sprint 001 핸드오프 전달"
### 좌: ONGOING PROJECTS
- section-title: `ONGOING PROJECTS` + `VOL: 04`
- project-row: `grid-template-columns: 40px 1fr auto`
- 아바타: 32px 원형, grayscale, SVG placeholder
- 프로젝트명: 15px 500
- 설명: 13px secondary
- 상태 라벨: label-meta (PHASE 2 / INIT / REVIEW)
## 사이드바
- 고정 사이드바 (접기 가능)
- 로고 + 메뉴 아이콘
- 현재 페이지 하이라이트
- 하단: 관리자 메뉴 (접이식)
### 우: ACTIVITY FEED
- section-title: `ACTIVITY FEED` + `LIVE`
- timeline 컴포넌트 (세로선 + 도트 + 타임스탬프 + 내용)
- 타임스탬프: mono 11px, `14:02:45 GMT+9`
- 내용: 13px, bold로 시스템명 강조
## 색상 (다크 테마)
| 용도 | 색상 |
|------|------|
| 배경 | #0D1117 |
| 카드 배경 | rgba(22, 27, 34, 0.8) |
| 텍스트 (주) | #E6EDF3 |
| 텍스트 (부) | #8B949E |
| 액센트 | #58A6FF |
| 보더 | rgba(240, 246, 252, 0.1) |
| 온라인 | #00E676 |
| 오프라인 | #FF1744 |
| 작업중 | #2979FF |
## 컴포넌트
- `<SisterCard />` — 자매 상태 카드
- `<ProjectCard />` — 프로젝트 카드
- `<ActivityFeed />` — 활동 피드 타임라인
- `<Sidebar />` — 사이드바 네비게이션
- `<StatusBadge />` — 상태 뱃지 (색상 dot + 텍스트)
## 모바일 반응형
- 상태 카드: `repeat(2, 1fr)``1fr`
- data-columns: 1열 수직 스택 (프로젝트 먼저, 활동 아래)
- bracket-value: 24px
- 패딩 축소

View File

@@ -1,32 +1,97 @@
# 조직도 — UI 디자인 (`/org`)
# 조직도 (`/org`) — 디자인 v2
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #6
## 레이아웃
```
┌──────────────────────────────────────────────────┐
자기야
┌──────┴──────┐
│ 하랑이 │
│ │ Orchestrator │ │
└──────┬──────┘
──────────────────────┐
│ ┌──────────┐ ┌───────┐ ┌───┴────┐
│ │ 나랑이 │ │ 다랑이 │ │ 이랑이 │
│ │ Generator │ │Evaluator│ │ Infra │ │
└─────┬─────┘ └───┬────┘ └────────┘
서브에이전트 서브에이전트
└──────────────────────────────────────────────────
┌─────┬──────────────────────────────────────────────────────┐
64px│ SYS_TYPE: CORE_ROOT_01 2023.10.27_14:42
│ ──────────────────────────────────────────────────
DASH│ ┌─────────────────┐
PROJ│[HQ] 하나랑 글로벌 │ │
│◗ORG │ MEM:142 S_ADMIN │
│ CONF────────────────
┌──────────────────────┐
┌──────┴──────┐ ┌─┴────┐ ┌───┴───┐
│[R&D] 기술연구│ │[OPS] │ │[EXT]
│ │ MEM:48 LEAD │ │운영팀 │ │외부협력│
│ └──────┬──────┘ └──┬───┘ └───┬───┘
│ │ ┌──────────┐ │ │ │
│ │ ┌──┴──┐ ┌┴─────┐ ┌──┴───┐ ┌───┴────┐ │
│ │ │Core │ │Infra │ │Monitor│ │Security│ │
│ │ │Eng. │ │struct│ │ing │ │Audit │ │
│ │ └─────┘ └──────┘ └──────┘ └────────┘ │
│ │ │
│ │ TOTAL_NODES: 07 STATUS: SYNC_COMPLETE │
└─────┴──────────────────────────────────────────────────────┘
```
## 시각화
- 트리 구조 (위→아래)
- 각 노드: glassmorphism 카드
- 상태 색상 적용
- 파이프라인 흐름 화살표: 하랑이 → 나랑이 ↔ 다랑이 → 이랑이
- 호버 시 해당 자매 정보 팝업
## 사이드바 (조직도 전용)
- **64px 너비** (다른 페이지의 240px과 다름)
- 세로 텍스트: `writing-mode: vertical-rl`
- 아이콘 모드: DASH / PROJ / ORG_TREE / CONF
- 로고: 세로 배치 (circle-full 위, circle-half 아래)
## 컴포넌
- `<OrgTree />` — 조직도 트리
- `<OrgNode />` — 개별 노드 카드
- `<PipelineArrow />` — 파이프라인 화살표
## 메인 뷰포
- 배경: dot grid pattern — `radial-gradient(circle, #1a1a1a 1px, transparent 1px)` / 30px 30px
- `overflow: auto` (스크롤 가능)
- padding: 40px, 중앙 정렬
## 헤더
- 좌: `SYS_TYPE: CORE_ROOT_01` (mono, 값은 흰색)
- 우: `TIMESTAMP: 2023.10.27_14:42` (mono secondary)
- border-bottom 1px
## 트리 구조
### Terminal Card
- 200px 너비, padding 12px
- `border: 1px solid var(--border-color)`
- **border-left: 3px solid** (레벨별 색상)
- high (`#f43f5e`): HQ
- mid (`#3b82f6`): R&D, Core Engine, Infrastructure
- low (`#10b981`): OPS, EXT, Monitoring, Security Audit
- `box-shadow: 0 10px 30px rgba(0,0,0,0.5)`
- node-label: mono 13px 600
- 접두사 span: `[HQ]` `[R&D]` `[OPS]` `[EXT]` — secondary 11px
- badge-row: flex wrap, gap 6px
- badge: mono 9px, `#1a1a1a` bg, border 1px `#262626`
- `MEM: 142` (값은 primary)
- `S_ADMIN`, `LEAD`, `MGR`, `GUEST`
- `LV_09`, `LV_07`
### 접기/펼치기 토글
- `` / `+` 아이콘, 16x16px
- 카드 하단 중앙 absolute 배치
- bg: card bg, border 1px
### 연결선
- 세로선: `width: 1px; background: var(--border-color)`
- 가로선 (형제 연결): children `::before` — 상단 -40px
### 트리 구조
```
하나랑 글로벌 (HQ, high)
├── 기술연구소 (R&D, mid)
│ ├── Core Engine (mid)
│ └── Infrastructure (mid)
├── 운영지원팀 (OPS, low)
│ └── Monitoring (low)
└── 외부 협력사 (EXT, low)
└── Security Audit (low)
```
## 푸터
- `margin-top: auto; border-top: 1px solid`
- 좌: `TOTAL_NODES: 07`
- 우: `STATUS: SYNC_COMPLETE`
- mono 10px secondary
## 모바일 반응형
- 사이드바: 하단 탭바로 전환
- 트리: 수평 스크롤 컨테이너 + 핀치 줌
- terminal-card: 너비 160px으로 축소
- badge 폰트: 8px
- dot grid: 20px 간격으로 축소

View File

@@ -1,47 +1,69 @@
# 프로젝트 상세 — UI 디자인 (`/projects/:id`)
# 프로젝트 상세 (`/projects/[id]`) — 디자인 v2
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #2
## 레이아웃
```
┌──────────────────────────────────────────────┐
[사이드바] [메인 콘텐츠]
┌──────────────────────────────┐
│ 헤더: 프로젝트명 + Gitea 링크 │
├──────────────────────────────┤
│ [탭: Sprint | Tasks | Plans] │
──────────────────────────────
│ Sprint 목록 (아코디언) │
│ ┌─ Sprint 001 ✅ ──────────┐ │
TASK-001 ✅ done │ │
│ │ TASK-002 ✅ done │ │
│ │ TASK-003 ✅ done │ │
│ └──────────────────────────┘ │
│ ┌─ Sprint 002 🔄 진행중 ───┐ │
│ │ TASK-004 🔵 in_progress │ │
│ │ TASK-005 ⏳ pending │ │
│ │ └──────────────────────────┘ │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────────────────┘
┌──────────┬────────────────────────────────────────────┐
Sidebar │ PROJECTS / P-402 / SUMMARY
데이터 파이프라인 최적화 STATUS: ACTIVE/P2
│ ─────────────────────────────────────────
│ PHASE TIMELINE │ ASSIGNED NODES
─────────────────────────
│ P1: 아키텍처 분석 ✓ │ 하랑 [PRIMARY] ●
●P2: 실시간 동기화 │ 나랑 [SECONDARY]●
│ P3: 스트레스 테스트
│ │ TASK CHECKLIST
│ SECURITY AUDIT LOG │ ─────────────
─────────────── │ ■ 인프라 요구사항
│ 14:02 CRYPTO_KEY PASS │ ■ 보안 프로토콜
11:30 CODE_SCAN PASS │ □ 캐시 무효화
09:15 ACCESS_LOG WARN │ □ 부하 분산
└──────────┴────────────────────────────────────────────┘
```
## Sprint 섹션
- 아코디언: Sprint 번호 + 이름 + 상태 아이콘
- 펼치면 Task 목록
- 진행률 바 (done/total)
- Sprint 상태: pending(⏳), in_progress(🔄), review(🔍), done(✅), failed(❌)
## 헤더
- breadcrumb: mono 11px uppercase, secondary
- page-title-row: 제목(28px) + STATUS label-meta
## Task Ledger 탭
- 테이블: Task ID | 제목 | 담당 | 상태 | 반복 횟수
- 상태 필터링
- 상태별 색상 뱃지
## 본문 그리드
- `grid-template-columns: 1fr 320px` / gap: 64px
## .plans/ 뷰어 탭
- 파일 트리 + 마크다운 렌더러
- Gitea raw URL에서 실시간 로드
### 좌: Phase Timeline + Audit Log
## 컴포넌트
- `<SprintAccordion />` — Sprint 아코디언
- `<TaskTable />` — Task 테이블
- `<PlansViewer />` — .plans/ 마크다운 뷰어
- `<ProgressBar />` — 진행률 바
- `<TabNav />` — 탭 네비게이션
#### Phase Timeline
- phase-item: `grid-template-columns: 120px 1fr`
- 왼쪽: phase-meta — mono 11px, "COMPLETED / 2023.10.12"
- 오른쪽: phase-box — `border-left: 2px solid`
- active: border 흰색 + 8px 원형 도트
- 완료/대기: border `--border-color`
- phase-name: 600 weight
- phase-desc: 13px secondary
#### Security Audit Log
- section-title: `SECURITY AUDIT LOG` + `LAST CHECK: 4H AGO`
- audit-log: 1px gap 줄무늬 (배경 #333)
- audit-row: `grid-template-columns: 100px 1fr 80px`
- header row: secondary uppercase 700
- status-tag: 10px, border 1px
- `.pass`: border #00FF00, color #00FF00
- 기본: border #333
### 우: Assigned Nodes + Task Checklist
#### Assigned Nodes
- node-mini-card: border 1px, 가로 정렬 (이름 + 녹색 도트)
- 녹색 도트: 6x6px, `#00FF00`
#### Task Checklist
- check-item: mono 12px, flex 가로
- check-box: 14x14px, border 1px
- checked: `■` 10px 텍스트
- done 항목: secondary + line-through
## 모바일 반응형
- project-grid: 1열 수직 스택 (좌측 먼저, 우측 아래)
- phase-item: 1열 (meta 위, box 아래)
- audit-row: 가로 스크롤 또는 카드 변환

View File

@@ -0,0 +1,65 @@
# 설정 (`/settings`) — 디자인 v2
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #4
## 레이아웃
```
┌──────────┬────────────────────────────────────────────────┐
│ Sidebar │ 시스템 설정 CONFIG: v2.0.4-STABLE │
│ │ │
│ │ ADMIN PROTOCOL RULES │ BACKUP & REDUNDANCY │
│ │ ───────────────── │ ────────────────── │
│ │ 강제 2FA [●] │ 자동 백업 [●] │
│ │ 세션 유효 시간 [ 15 ] │ 백업 주기 [08:00] │
│ │ IP 화이트리스트 [○] │ 보관 주기 [ 30 ]│
│ │ │ │
│ │ SYNC QUEUE THRESHOLDS │ NODE ALERT TRIGGERS │
│ │ ───────────────── │ ────────────────── │
│ │ 대기열 경고 [ 500 ] │ CPU 임계값 [ 90 ]│
│ │ 자동 스케일링 [●] │ 레이턴시 경보 [ 250 ] │
│ │ │ 노드 오프라인 [●] │
│ │ │
│ │ [초기화] [설정 저장] │
└──────────┴────────────────────────────────────────────────┘
```
## 페이지 헤더
- page-title: "시스템 설정" (28px 600)
- label-meta: `CONFIG: v2.0.4-STABLE`
## 설정 그리드
- `grid-template-columns: repeat(2, 1fr)` / gap: 64px
### 섹션 구조 (4개)
1. **ADMIN PROTOCOL RULES** (SEC: 01)
2. **BACKUP & REDUNDANCY** (SEC: 02)
3. **SYNC QUEUE THRESHOLDS** (SEC: 03)
4. **NODE ALERT TRIGGERS** (SEC: 04)
### 설정 행 (Settings Row)
- `justify-content: space-between; align-items: center`
- `border-bottom: 1px solid #222`
- 좌: setting-label (14px 500) + setting-desc (12px secondary)
- 우: Toggle Switch 또는 Bracket Input
### Toggle Switch
- 34x18px
- unchecked: `#2a2a2a` 배경, border `#333`, dot `#8A8A8A`
- checked: `#151515` 배경, border `#F5F5F5`, dot `#F5F5F5`, translate 16px
### Bracket Input
- `[ value ]` 형태
- `--font-mono` 14px, transparent bg, center text
- 포커스: 흰색 텍스트
- 너비: 60px
## Action Bar
- `margin-top: auto; justify-content: flex-end`
- 초기화: 기본 btn (투명 + 보더)
- 설정 저장: btn-primary (흰색 배경, hover 반전)
- 12px 600 uppercase
## 모바일 반응형
- settings-grid: 2열 → 1열
- action-bar: 풀 너비 버튼 또는 sticky bottom

View File

@@ -1,45 +1,69 @@
# 자매 상세 — UI 디자인 (`/sisters/:name`)
# 자매 노드 관리 (`/sisters`) — 디자인 v2
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #3
## 레이아웃
```
┌──────────────────────────────────────────────┐
[사이드바] [메인 콘텐츠]
──────────────────────────────
│ 헤더: 자매 이름 + 상태 뱃지 │
│ 역할 + IP + LXC 정보 │
├──────────────────────────────┤
[탭: 개요 | 설정 | 세션 | 서브] │
──────────────────────────────
│ 개요 탭: │
│ - 상태 카드 (큰 버전)
│ - 최근 활동 타임라인 │
│ - 현재 작업 │
└──────────────────────────────┘
└──────────────────────────────────────────────┘
┌──────────┬──────────────────────────────────────────────┐
Sidebar │ 자매 노드 관리 TOTAL NODES: 04
─────────────────────────────────────────── │
● 하랑 (Primary) │ CAPACITY 24H │ SYNC LOG
Uptime: 342:12 │ ▁▂▃▂▄▆█▇ │ 14:05 ✓
[ACTIVE][REBOOT]│ LOAD: 84.2% │ 13:55 ✓
─────────────────────────────────────────── │
│ ● 나랑 (Secondary)│ CAPACITY 24H │ SYNC LOG
│ Uptime: 112:44 │ ▁▁▁▂▂▃▃▃14:02 ✓
│ [ACTIVE][REBOOT]│ LOAD: 48.7% │ 13:51 ✓
│ ───────────────────────────────────────────
○ 다랑 (Standby) │ CAPACITY 24H │ SYNC LOG
│ │ Last: 02:15 ago │ ░░░░░░░░ │ IDLE │
│ │ [STANDBY][ACTIV]│ LOAD: 0.0% │ IDLE │
│ │ ─────────────────────────────────────────── │
│ │ ● 이랑 (Sync) │ CAPACITY 24H │ SYNC LOG│
│ │ Queue: 12 PKTS │ ▁▂▁▂▃▂▁▁ │ SYNCING │
│ │ [SYNCING][FLUSH]│ LOAD: 15.2% │ SYNCING │
└──────────┴──────────────────────────────────────────────┘
```
## 탭별 내용
## 페이지 헤더
- page-title: "자매 노드 관리" (28px 600)
- label-meta: `TOTAL NODES: 04`
- border-bottom 1px
### 개요 탭
- 큰 상태 카드 (상세 정보 포함)
- 최근 활동 5개
- 현재 진행 중인 Task
## 노드 엔트리 (Node Entry)
- `grid-template-columns: 240px 1fr 300px` / gap: 64px
- `border-bottom: 1px solid var(--border-color)` / padding-bottom: 40px
### 설정 탭
- openclaw.json 내용 (코드 뷰어, read-only)
- 모델, 채널, 플러그인 정보
### 좌: Node Info (240px)
- node-name: 20px 600 + status-indicator
- active: 8px 흰색 원 + glow shadow
- inactive: 8px `#555`
- meta-pair: label-meta + val (16px mono)
- 하랑: Uptime 342:12:05
- 다랑: Last Active 02:15:00 ago
- 이랑: Queue Depth 12 PKTS
- node-controls: btn-toggle 2개
- active 상태: 흰색 배경 + 검정 텍스트
- 비active: 투명 + 보더
### 세션 탭
- 최근 세션 목록 (시간, 메시지 수, 토큰 사용량)
- 세션 클릭 시 로그 뷰어
### 중: Capacity Section
- label-meta: "CAPACITY HISTORY (24H)"
- graph-container: 60px 높이, flex-end
- graph-bar: 등분, `#333` 기본
- highlight: 최근 2개 바 `var(--text-primary)`
- border-left + border-bottom 1px
- sync-row: "LOAD INDEX" + 값 (mono)
### 서브에이전트 탭
- 서브에이전트 목록 + 실행 이력
- 성공/실패 비율
### 우: Sync History (300px)
- label-meta: "SYNC LOG"
- sync-row: 타임스탬프 + 상태 (mono 11px)
- 성공: `var(--text-primary)` 색 "SUCCESS"
- IDLE: secondary
- SYNCING: primary
## 컴포넌트
- `<SisterHeader />` — 자매 헤더 (이름 + 상태 + 정보)
- `<ConfigViewer />` — JSON 코드 뷰어
- `<SessionList />` — 세션 목록
- `<SubagentList />` — 서브에이전트 목록
## 모바일 반응형
- node-entry: 3열 → 수직 스택 (info → capacity → sync)
- graph-container: 높이 유지, 너비 100%
- btn-toggle: 가로 나란히

View File

@@ -0,0 +1,31 @@
# HOTFIX-002: Sprint 태스크 수/완료 수 파싱 보정
## 목표
프로젝트 상세 화면에서 Sprint 상태는 맞게 보이지만 `태스크 0개 / 완료 0개`로 표시되는 문제 수정
## 태스크
### TASK-044: Sprint task count 파싱 수정
- `.plans/sprints/SPRINT-XXX.md`에서 실제 태스크 개수를 파싱
- 지원 포맷:
- `TASK-001: 설명`
- `- TASK-001 설명`
- `- [ ] TASK-001 설명`
- `- [x] TASK-001 설명`
- 중복 카운트 방지
- heading/본문에 포함된 단순 문자열 오탐 방지
### TASK-045: 완료 task count 계산 보정
- 완료 태스크 수 계산 규칙 정의
- 우선순위:
1. 체크박스 `[x]` / `[X]`
2. 명시 status (`done`, `completed`, `passed` 등 정해진 패턴)
3. Sprint 자체가 done이면, 태스크 status 정보가 없을 경우 전체 완료 처리 여부 검토
- UI에 `총 태스크 수 / 완료 태스크 수` 정상 반영
## 검증 기준
- 프로젝트 상세 Sprint 섹션에서 `0개 / 0개` 고정 해소
- 실제 Sprint 문서 기준으로 task total 표시
- 완료 태스크 수가 포맷에 맞게 계산
- 빌드/테스트 통과
- 외부 URL QA 필수

View File

@@ -0,0 +1,34 @@
# HOTFIX-003: 자매 상세 프로필 헤더/개요 레이아웃 개선 + Activity 로그 정리
## 목표
자매 상세 페이지에서 프로필 사진이 너무 작고 개요 정보 우선순위가 어색한 문제, Activity 로그에 raw stderr가 그대로 노출되는 문제를 개선
## 태스크
### TASK-046: 자매 상세 프로필 헤더 확대
- `/sisters/[name]` 상단 프로필 영역 재디자인
- 프로필 사진 크기 확대 (최소 96px~128px 권장)
- 이름 / 역할 / 상태 배지 / 핵심 메타를 프로필 헤더에 재배치
- “프로필 페이지” 느낌이 나도록 상단 hero 영역 구성
### TASK-047: 개요 레이아웃 우선순위 조정
- 현재 시스템 카드/활동 로그/개요의 시각적 비중 재조정
- 프로필 헤더 → 핵심 상태 요약 → 활동 로그 순으로 위계 정리
- 시스템 정보 카드(UPTIME, CPU, MEM, DISK)는 유지하되 보조 정보로 배치
- 모바일에서도 헤더/요약/로그 순서가 자연스럽게 보이도록 조정
### TASK-048: Activity 로그 사용자 친화화
- raw stderr / 내부 명령문 / `bash: ... command not found` 같은 내부 오류 문자열 직접 노출 금지
- ActivityLog 저장 시 또는 렌더 시 필터링/정규화:
- 내부 stderr는 숨기거나
- 사람이 읽을 수 있는 문장으로 변환
- 사용자용 feed에는 의미 있는 이벤트만 노출
- 필요하면 내부 디버그 로그와 사용자 표시 로그를 분리
## 검증 기준
- 자매 상세 페이지 프로필 사진이 현재보다 명확히 크게 표시
- 개요 탭 시각적 우선순위가 자연스러움
- 모바일에서도 레이아웃 깨짐 없음
- activity feed에 raw stderr 직접 노출 0건
- npm run build 성공 + 테스트 통과
- 외부 URL QA 필수

View File

@@ -0,0 +1,34 @@
# HOTFIX-004: 자매 상세 카드 UI 보정
## 목표
자매 상세 화면에서 DISK bar 미노출, 이름만 클릭되는 UX, 리소스 중복 표시 문제를 짧게 정리
## 태스크
### TASK-049: DISK progress bar 렌더 수정
- DISK 사용률 계산/퍼센트 변환/width 스타일 점검
- 0 또는 NaN으로 들어가는 경우 fallback 처리
- CPU/MEM/DISK 3개 모두 bar 렌더 일관성 확인
### TASK-050: 카드 전체 클릭 가능화
- 현재 이름 텍스트만 링크인 부분을 카드 전체 링크/클릭 처리로 변경
- hover/active 상태도 카드 단위로 적용
- 접근성: button/link semantics 유지
### TASK-051: 우측 중복 메타 정보 교체
- CPU/MEM/DISK 텍스트 중복 제거
- 우측 영역은 운영 메타 정보로 교체:
- STATUS
- ROLE
- LXC ID
- LAST CHECK
- CURRENT TASK
- 가운데 그래프는 리소스 시각화 전용으로 유지
## 검증 기준
- DISK bar 정상 노출
- 카드 전체 클릭 시 상세 페이지 이동
- 리소스 정보 중복 제거
- 우측 메타가 운영 정보로 자연스럽게 표시
- 빌드/테스트 통과
- 외부 URL QA 필수

View File

@@ -0,0 +1,24 @@
# HOTFIX-005: 프로젝트 카드/이력 UI 보정
## 목표
프로젝트 목록의 잘못된 자매 프로필 사진 사용을 제거하고, 프로젝트 상세에서 Sprint timeline과 Hotfix history를 분리해 정보 구조를 자연스럽게 정리
## 태스크
### TASK-061: 프로젝트 카드 대표 아바타 제거
- 프로젝트 목록에서 자매 프로필 사진 제거
- 프로젝트 전용 placeholder / initial / neutral symbol로 교체
- 자매 프로필 사진은 자매 관련 화면에만 유지
### TASK-062: 프로젝트 상세 이력 구조 분리
- Sprint timeline은 Sprint만 표시
- Hotfix는 별도 `HOTFIX HISTORY` 섹션으로 분리
- 현재 `TIMELINE` 라벨 반복/중복 구조 제거
- Hotfix는 별도 태그/스타일로 간결하게 표시
## 검증 기준
- 프로젝트 목록에서 자매 프로필 사진 제거 확인
- 프로젝트 상세에서 Sprint timeline과 Hotfix history 분리 확인
- 반복되는 `TIMELINE` 라벨 제거
- 빌드/테스트 통과
- 외부 URL QA 필수

View File

@@ -0,0 +1,72 @@
# SPRINT-006: 버그 수정 + 인증 시스템
## 목표
기존 버그 3건 수정 + JWT 기반 관리자 인증 시스템 구축
## 태스크
### TASK-020: 버그 수정 3건
**우선순위: 최우선 (인증 전환 전에 기존 기능 안정화)**
#### BUG-1: 하네스 편집 저장 실패 (PUT /api/admin/harness/:name/:file)
- 증상: API Key 입력해도 저장 실패
- 원인 조사: CORS PUT 메서드, DTO validation, AdminService.updateHarnessFile() 로직 확인
- FE 측: Authorization 헤더 전달, Content-Type, 에러 핸들링 확인
#### BUG-2: 로그 뷰어 초기 로드 안 됨
- 증상: /admin/logs 페이지 진입 시 로그가 비어있음 (수동 새로고침 필요)
- 수정: 컴포넌트 마운트 시 자동 fetch (useEffect 초기 로드)
- 자매/lines 기본값 설정 후 자동 조회
#### BUG-3: /api/admin/costs/record/:name 작동 안 함
- 증상: POST 요청 시 응답 없거나 에러
- 원인 조사: SSH 연결, python3 의존성, main.json 경로, 파싱 로직 확인
- python3 미설치 시 fallback (jq 또는 Node.js 스크립트)
### TASK-021: JWT 인증 BE
- User 테이블 추가 (Prisma 마이그레이션)
```
model User {
id Int @id @default(autoincrement())
username String @unique
password String // bcrypt hash
role String @default("admin")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
- 인증 API:
- `POST /api/auth/register` — 초대 코드 필수 (INVITE_CODE env), bcrypt hash
- `POST /api/auth/login` — JWT access token (15분) + refresh token (7일)
- `POST /api/auth/refresh` — refresh token으로 새 access token 발급
- `GET /api/auth/me` — 현재 사용자 정보
- JWT_SECRET, INVITE_CODE 환경변수
- Rate limiting: 로그인 엔드포인트 (5회/분)
- 테스트: auth 관련 유닛 테스트
### TASK-022: 로그인/회원가입 페이지 FE
- `/login` 페이지: username + password + 로그인 버튼
- `/register` 페이지: username + password + 초대 코드 + 가입 버튼
- 디자인: DESIGN-SYSTEM.md v2 준수 (미니멀 터미널 UI)
- 로그인 실패 시 에러 메시지 표시
- 토큰 저장: localStorage (access) + httpOnly cookie 검토
- 라우트 보호: 비로그인 시 `/login`으로 리다이렉트
- AuthContext/AuthProvider 구현
### TASK-023: API Key Guard → JWT Guard 전환
- JwtGuard 생성 (passport-jwt)
- 기존 ApiKeyGuard 유지 + JwtGuard 병행 (CompositeGuard: JWT 먼저 → API Key fallback)
- 하위호환: API Key는 Sprint 008까지 유지, 이후 제거 예정
- WebSocket 인증: Socket.IO handshake에 JWT 토큰 검증 추가
- 모든 @UseGuards 교체 (ApiKeyGuard → CompositeGuard)
## 의존성
- TASK-020 먼저 완료 → TASK-021 → TASK-022 → TASK-023
- TASK-022는 TASK-021 완료 필요 (API가 있어야 FE 연동)
## 검증 기준
- 버그 3건 모두 재현 → 수정 → 동작 확인
- 회원가입(초대 코드) → 로그인 → JWT 발급 → 보호된 API 접근 성공
- API Key로도 기존 방식 그대로 접근 가능 (하위호환)
- WebSocket 연결 시 JWT 검증
- npm run build 성공 + 테스트 통과

View File

@@ -0,0 +1,61 @@
# SPRINT-007: Gitea 연동 + 활동 로그 실시간화
## 목표
프로젝트 데이터를 Gitea 기반으로 전환 + 활동 로그 자동 기록
## 태스크
### TASK-024: Gitea 동기화 API
- GiteaService 강화 (기존 optional 패턴 유지)
- API 엔드포인트:
- `POST /api/admin/gitea/sync` — Gitea org의 전체 repo를 DB에 동기화
- `GET /api/projects` — DB에서 프로젝트 목록 (Gitea 데이터 포함)
- 동기화 로직:
- Gitea API `/api/v1/orgs/hanarang/repos` 호출
- 각 repo → Project 테이블 upsert (giteaId, name, repoUrl, description)
- 마지막 동기화 시간 기록
- 자동 동기화: EventsScheduler에서 5분마다 폴링 (선택)
### TASK-025: Commit/Branch/PR 조회 API
- API 엔드포인트:
- `GET /api/projects/:id/commits` — 최근 커밋 목록 (Gitea API `/repos/:owner/:repo/commits`)
- `GET /api/projects/:id/branches` — 브랜치 목록
- `GET /api/projects/:id/pulls` — PR 목록 (open/closed)
- 응답 포맷: 커밋 해시, 메시지, 작성자, 날짜 / 브랜치명, 최신 커밋 / PR 제목, 상태
- 에러 핸들링: Gitea 연결 실패 시 graceful fallback
### TASK-026: 프로젝트 페이지 FE (Gitea 데이터)
- `/projects` 목록 페이지:
- DB 프로젝트 목록 표시 (이름, 설명, 상태, repo URL)
- 동기화 버튼 (관리자)
- `/projects/[id]` 상세 페이지:
- 탭: Overview / Commits / Branches / PRs
- Commits: 커밋 히스토리 테이블 (해시, 메시지, 작성자, 날짜)
- Branches: 브랜치 카드 목록
- PRs: PR 리스트 (상태 태그: open/merged/closed)
- 디자인: DESIGN-SYSTEM.md v2 준수 (project-detail-design.md 확장)
- 로딩: 스켈레톤 UI
### TASK-027: 활동 로그 자동 기록
- ActivityLog 모델 활용 (기존 테이블 사용)
- 자동 기록 트리거:
- 자매 상태 변경 시 (online ↔ offline)
- Gitea 동기화 시 (새 repo/커밋 감지)
- 관리자 액션 시 (재시작, 하네스 편집, 설정 변경)
- ActivityService에 `logActivity()` 메서드 추가 → 각 서비스에서 호출
- WebSocket으로 실시간 푸시 (`activity:new` 이벤트 활용)
- `/activities` 페이지: 실제 ActivityLog 데이터 표시 (하드코딩 제거)
- 필터/검색/페이지네이션 실제 동작
## 의존성
- Sprint 006 완료 필요 (JWT 인증)
- TASK-024 → TASK-025 (repo가 DB에 있어야 commit 조회 가능)
- TASK-026은 TASK-025 완료 후 연동
- TASK-027은 독립적 (다른 태스크와 병렬 가능)
## 검증 기준
- Gitea 동기화 → DB에 프로젝트 생성 확인
- 커밋/브랜치/PR 조회 정상 동작
- 활동 로그 자동 기록 + WebSocket 실시간 푸시
- /activities 페이지에서 실제 데이터 표시 (필터/페이지네이션 동작)
- npm run build 성공 + 테스트 통과

View File

@@ -0,0 +1,99 @@
# SPRINT-008: 실시간 전환 + 설정 + 자매 강화
## 목표
모든 하드코딩 데이터를 실시간 전환 + 설정 페이지 구현 + 자매 프로필 강화
## 태스크
### TASK-028: 자매 실시간 데이터 (Uptime/시스템 정보)
- SistersService 확장:
- SSH로 실제 uptime 조회: `cat /proc/uptime`
- CPU 사용률: `top -bn1 | grep Cpu`
- 메모리: `free -m`
- 디스크: `df -h /`
- API 응답에 시스템 정보 추가:
```json
{
"uptime": "342:12:05",
"cpu": 23.4,
"memory": { "used": 1024, "total": 4096 },
"disk": { "used": "12G", "total": "50G" }
}
```
- 자매 페이지(/sisters): 실제 Uptime 표시 (하드코딩 제거)
- 자매 카드: Capacity → 실제 CPU/메모리 기반
- WebSocket: 30초마다 자매 상태 업데이트에 시스템 정보 포함
### TASK-029: 자매 프로필 사진 API
- API: `GET /api/sisters/:name/avatar`
- SSH로 각 자매 서버에서 프로필 사진 가져오기:
- 경로: `~/.openclaw/avatar.png` 또는 `~/.openclaw/avatar.jpg`
- SCP/SSH cat으로 바이너리 전송
- 캐싱: 메모리 캐시 (5분 TTL) — 매번 SSH 호출 방지
- 대체(fallback): 프로필 사진 없으면 이름 이니셜 아바타 생성
- FE: 자매 카드/상세 페이지에서 아바타 이미지 교체
### TASK-030: 설정 페이지 BE + FE
- SystemSettings 테이블 (Prisma):
```
model SystemSettings {
id Int @id @default(autoincrement())
key String @unique
value String @db.Text
updatedAt DateTime @updatedAt
}
```
- API:
- `GET /api/admin/settings` — 전체 설정 조회
- `PUT /api/admin/settings` — 설정 일괄 저장
- 설정 항목 (settings-design.md 기반):
- 강제 2FA (boolean)
- 세션 유효 시간 (number, 분)
- IP 화이트리스트 (boolean)
- 자동 백업 (boolean)
- 백업 주기 (string, HH:mm)
- 보관 주기 (number, 일)
- 대기열 경고 임계값 (number)
- 자동 스케일링 (boolean)
- CPU 임계값 (number, %)
- 레이턴시 경보 (number, ms)
- 노드 오프라인 알림 (boolean)
- FE /settings 페이지:
- 마운트 시 설정 로드 (API fetch)
- 토글/인풋 변경 → 로컬 state
- "설정 저장" → PUT API → 성공 토스트
- "초기화" → 기본값 복원
### TASK-031: 하드코딩 제거 + 스켈레톤 UI
**대상 목록:**
| 페이지 | 하드코딩 항목 | 전환 방식 |
|--------|-------------|-----------|
| `/` (대시보드) | 상태 카드 값 ([ON]/[84]/[--]/[12]) | → /api/sisters 실시간 |
| `/` (대시보드) | ONGOING PROJECTS 목록 | → /api/projects 실시간 |
| `/` (대시보드) | ACTIVITY FEED | → /api/activities 실시간 |
| `/sisters` | Uptime, Load Index, Sync Log | → TASK-028 API |
| `/org` | 조직도 데이터 | → /api/org (이미 연동, 확인만) |
| `/settings` | 토글/인풋 값 | → TASK-030 API |
| `/activities` | 전체 로그 테이블 | → /api/activities (TASK-027에서 연동) |
- 모든 API 호출에 **스켈레톤 UI** 추가:
- 카드 스켈레톤: 회색 박스 펄스 애니메이션
- 테이블 스켈레톤: 행 placeholder
- 타임라인 스켈레톤: 도트 + 라인 placeholder
- 에러 상태: "데이터를 불러올 수 없습니다" fallback UI
## 의존성
- Sprint 007 완료 필요 (Gitea 데이터 + 활동 로그가 있어야 실시간 전환 가능)
- TASK-028, TASK-029는 독립적 (병렬 가능)
- TASK-030은 독립적
- TASK-031은 TASK-028 + TASK-030 완료 후 최종 정리
## 검증 기준
- 자매 페이지에서 실제 Uptime/CPU/메모리 표시
- 프로필 사진 정상 렌더링 (없으면 이니셜 fallback)
- 설정 저장/로드/초기화 정상 동작 + 토스트
- 모든 페이지에서 하드코딩 0건 (curl/검색으로 확인)
- 스켈레톤 UI 동작 (느린 네트워크에서 확인)
- npm run build 성공 + 테스트 통과
- **외부 URL QA 필수**

View File

@@ -0,0 +1,63 @@
# SPRINT-009: 관리자 단순화 + 프로젝트 진행률 정상화 + 프로필 사진 수정
## 목표
관리자 전용 서비스에 맞게 회원가입 제거, 프로젝트 진행률 계산 정상화, 자매 프로필 사진 표시 버그 수정
## 태스크
### TASK-032: 회원가입 제거
- `/register` 페이지 제거
- `POST /api/auth/register` 엔드포인트 제거 또는 비활성화
- `INVITE_CODE` 환경변수 및 초대코드 기반 가입 로직 제거
- 로그인 전용 플로우로 단순화
- FE: 로그인 페이지에서 회원가입 링크 제거
- UX: 비로그인 시 `/login`으로만 이동
### TASK-033: 프로젝트 Sprint 동기화
- 각 프로젝트 repo의 `.plans/sprints/` 디렉토리를 읽어 Sprint DB 레코드 생성/업데이트
- Gitea API로 repo tree 조회 또는 raw 파일 fetch
- 동기화 대상:
- `SPRINT-001.md` ~ `SPRINT-XXX.md`
- Sprint number, name, status(기본 pending) 추출
- API:
- `POST /api/admin/projects/:id/sync-sprints`
- `POST /api/admin/projects/sync-all-sprints`
- Gitea sync 시 Sprint 동기화도 함께 수행 가능하도록 확장
### TASK-034: 프로젝트 진행률 계산 수정
- 현재 “스프린트 없음” 문제 해결
- 진행률 계산 방식:
- 기본: `완료 Sprint 수 / 전체 Sprint 수`
- 가능하면 `.qa/` 또는 상태 기반으로 `done/review/in_progress` 반영
- 프로젝트 목록 페이지(`/projects`): 진행률 바/퍼센트 표시
- 프로젝트 상세 페이지(`/projects/[id]`): Sprint 목록 + 상태 표시
- “No sprint data” fallback 제거
### TASK-035: 프로필 사진 버그 수정
- 현재 아이콘 fallback만 보이는 문제 원인 파악
- 점검 항목:
- avatar API 경로 연결 여부
- FE 이미지 src 설정
- 캐시(5분 TTL) fallback 지속 문제
- 실제 서버 파일 경로 (`~/.openclaw/avatar.png|jpg|webp`) 확인
- 수정 목표:
- 실제 사진 있으면 항상 우선 표시
- 실패 시에만 SVG fallback
- 반영 위치:
- 대시보드 자매 카드
- `/sisters`
- `/sisters/[name]`
## 의존성
- TASK-032는 독립적
- TASK-033 → TASK-034 순서 필수
- TASK-035는 독립적, 병렬 가능
## 검증 기준
- `/register` 접근 불가 또는 로그인으로 리다이렉트
- 회원가입 API 제거/비활성화 확인
- 프로젝트별 Sprint 동기화 후 진행률 표시 정상
- 프로젝트 목록/상세에서 Sprint 데이터 렌더링
- 자매 프로필 사진 실제 이미지 표시 확인
- npm run build 성공 + 테스트 통과
- 외부 URL QA 필수

View File

@@ -0,0 +1,56 @@
# SPRINT-010: 관리자 코드 정리 + 인증 단일화
## 목표
JWT 로그인 기반으로 완전히 정리하고, 불필요한 API Key/초대코드/죽은 코드를 제거
## 태스크
### TASK-036: 관리 페이지 API Key UI 제거
- 관리자 페이지에서 API Key 입력 필드/상태 제거
- adminFetch가 JWT만 사용하도록 FE 정리
- AuthContext 기반 인증 상태만 사용
- 관련 안내 문구/placeholder 제거
### TASK-037: JWT 전용 FE 정리
- 로그인/토큰 관리 흐름 단순화
- CompositeGuard 하위호환 때문에 남긴 FE 분기 제거
- refresh/login/logout 로직 정리
- 불필요한 fallback 코드 제거
- 에러 처리 UX 정리 (401 → 로그인 페이지 이동)
### TASK-038: API Key 하위호환 제거 또는 축소
- 백엔드에서 API Key 지원 범위를 정리
- 원칙:
- 일반 웹 UI는 JWT만 사용
- 필요한 내부 자동화가 없다면 API Key 완전 제거
- 제거 대상 후보:
- ApiKeyStrategy
- CompositeGuard
- ADMIN_API_KEYS env
- 만약 완전 제거가 부담되면 “운영/내부 스크립트 전용 엔드포인트”만 남기고 UI 경로는 JWT 전용으로 제한
### TASK-039: 죽은 코드/불필요 코드 청소
- invite/register 관련 unused code 제거
- API Key 관련 사용 안 하는 util 제거
- old fallback 코드 정리
- 문서 업데이트:
- `.plans/design/` 변경 필요 시 반영
- README / 환경변수 설명 업데이트
- grep 기반 점검:
- `INVITE_CODE`
- `register`
- `API_KEY`
- `CompositeGuard`
- 사용 안 되는 state/props/components
## 의존성
- Sprint 009 완료 후 진행 권장
- TASK-036 → TASK-037 → TASK-038 → TASK-039 순서 권장
## 검증 기준
- 관리 페이지에서 API Key 입력 UI 0건
- 로그인 후 JWT만으로 관리자 기능 정상 동작
- `/register` 관련 코드/엔드포인트 정리 확인
- API Key 제거 또는 제한 후 기존 UI 정상 동작
- unused code 정리 후 build/test 통과
- 외부 URL QA 필수

View File

@@ -0,0 +1,56 @@
# SPRINT-011: 진행률/프로필 사진 실제 반영 핫픽스
## 목표
프로젝트 진행률이 0%로 보이는 문제와 자매 프로필 사진이 fallback으로만 보이는 문제를 실제 데이터 기준으로 수정
## 태스크
### TASK-040: Sprint status 동기화 수정
- 현재 `Status: value` 단일 줄 파싱이 실제 `.plans/sprints/*.md` 포맷과 맞지 않는 문제 수정
- 실제 Sprint 문서 포맷 조사 후 파싱 규칙 재정의
- 우선순위:
1. 명시 메타 블록 / heading 기반 status 파싱
2. QA 결과 파일(`.qa/SPRINT-XXX-review-*.md`) 기반 완료 여부 보조 판정
3. 없으면 pending
- 오탐 방지: 본문 전체 키워드 검색 금지
### TASK-041: 프로젝트 진행률 계산 수정
- Sprint status가 실제 반영되도록 프로젝트 진행률 계산 로직 수정
- 표시 기준:
- `done` Sprint 수 / 전체 Sprint 수 → %
- 필요 시 `review`, `in_progress`는 별도 상태 태그 표시
- `/projects` 목록: 퍼센트 + progress bar 정상 반영
- `/projects/[id]` 상세: Sprint 상태 목록 정확 표시
- “0%만 뜨는” 현상 해소
### TASK-042: 프로필 사진 실제 노출 수정
- avatar API 응답 200이어도 fallback만 보이는 원인 파악
- 점검 항목:
- FE 이미지 src 연결 여부
- `onError`가 과도하게 fallback으로 전환되는지
- `next/image` / `img` 캐시 문제
- content-type/image 응답 정상 여부
- avatar cache miss 후 stale fallback 유지 여부
- 실제 파일 경로 재확인:
- `~/.openclaw/avatar.png`
- `~/.openclaw/avatar.jpg`
- `~/.openclaw/avatar.webp`
### TASK-043: 아바타 UI 일관화
- 대시보드, `/sisters`, `/sisters/[name]`, `/admin` 전부 같은 Avatar 컴포넌트 사용
- 실제 이미지 우선 렌더
- 실패 시에만 fallback (이니셜 or SVG)
- 관리자 페이지에 남은 이모지 fallback 제거
## 의존성
- TASK-040 → TASK-041 순서 필수
- TASK-042, TASK-043은 병렬 가능하나 최종적으로 Avatar 공통 컴포넌트로 수렴
## 검증 기준
- 프로젝트 진행률이 0% 고정이 아닌 실제 값으로 표시
- Sprint 상세 상태가 실제 문서/QA 결과와 일치
- 자매 프로필 사진이 실제 이미지로 표시
- 관리자/자매/대시보드 전부 동일한 사진 표시 정책 사용
- fallback은 이미지 실패 시에만 동작
- npm run build 성공 + 테스트 통과
- 외부 URL QA 필수

View File

@@ -0,0 +1,59 @@
# SPRINT-012: 정보 구조 재배치 + 대시보드 시각화 강화
## 목표
대시보드다운 정보 구조로 재정리. 프로젝트 목록 진행률 UI 개선, 자매 프로필 사진 활용, 하네스/로그를 자매 상세로 이동, 실데이터 기반 그래프 강화
## 태스크
### TASK-052: 프로젝트 목록 UI 재설계
- 현재 진행률 UI(설명 옆 퍼센트/바) 제거
- 프로젝트 행/카드를 3구역으로 재배치:
1. 왼쪽: 대표 자매 사진 + 프로젝트 이름 + 설명
2. 가운데: 진행률 바 + 퍼센트 + Sprint counts
3. 오른쪽: 상태 태그 + 최근 업데이트
- 진행률 바 시각적 강화
- 프로젝트 상세와 시각적으로 연결감 있게 정리
### TASK-053: 프로젝트 대표 아바타 개선
- 기본 아이콘 제거
- 프로젝트 담당 자매 또는 최근 활동 자매의 실제 프로필 사진 사용
- 프로젝트 카드/리스트에 아바타 적용
- 이미지 없을 때만 fallback
### TASK-054: 자매 상세에 하네스 편집/로그 뷰어 통합
- `/sisters/[name]`에 새 탭 추가:
- 개요
- 세션
- 활동
- 하네스
- 로그
- 기존 관리자 페이지의 자매별 하네스/로그 기능을 자매 상세로 이동
- 전체 로그 페이지(`/activities`, `/admin/logs`)는 유지
### TASK-055: 대시보드 그래프 추가
- 실데이터 기반 그래프 2~3개 추가
- 후보:
- 자매 활동량 막대그래프 (최근 24h)
- 프로젝트 진행률 차트
- 자매 리소스 스파크라인
- 메인 대시보드에서 “관제/모니터링” 느낌 강화
- 가짜 그래프 금지, 실제 API 데이터만 사용
### TASK-056: 관리자 페이지 단순화
- 자매 상세로 이동한 기능만큼 관리자 페이지 정리
- 관리자 페이지는 시스템 전역 기능 위주로 축소
- 중복 진입점 제거
## 의존성
- TASK-052 ↔ TASK-053 함께 진행 가능
- TASK-054 후 TASK-056 정리
- TASK-055는 병렬 가능
## 검증 기준
- 프로젝트 목록에서 진행률 UI가 더 자연스럽고 명확하게 보임
- 프로젝트 목록에 실제 자매 프로필 사진 반영
- 자매 상세에서 하네스 편집/로그 뷰어 접근 가능
- 전체 로그 페이지는 유지
- 대시보드에 실제 데이터 기반 그래프 추가
- 빌드/테스트 통과
- 외부 URL QA 필수

View File

@@ -0,0 +1,50 @@
# SPRINT-013: 자매 관리/타임라인/선택 UX 정리
## 목표
자매 상세 탭 단순화, 관리자 자매 관리 카드의 이모지 제거, 프로젝트 진행 이력에 Hotfix 표시, 불필요한 텍스트 드래그 방지
## 태스크
### TASK-057: 자매 상세 탭 정리
- `/sisters/[name]` 탭에서 `설정` 제거
- 유지 탭:
- 개요
- 세션
- 활동
- 하네스
- 로그
- 탭 순서/간격 정리
### TASK-058: 관리자 자매 관리 카드 아이콘 교체
- 관리 > 자매 관리 페이지의 여우/고양이/늑대 이모지 제거
- 각 자매 실제 프로필 사진 사용
- 이미지 없을 때만 fallback
- 자매 관리 카드와 자매 상세의 시각적 일관성 맞추기
### TASK-059: 프로젝트 진행 이력에 Hotfix 표시
- 프로젝트 상세의 timeline/phase/history에 Sprint뿐 아니라 Hotfix도 표시
- 예시:
- Sprint 001
- Sprint 002
- HOTFIX-001
- Sprint 003
- HOTFIX-002
- Sprint/Hotfix를 함께 보여 프로젝트 실제 진행 이력 반영
- UI에서 Hotfix는 별도 태그/스타일로 구분
### TASK-060: user-select 정책 정리
- 기본적으로 텍스트 드래그 방지 (`user-select: none`)
- 예외 허용 영역:
- 로그 뷰어
- 코드/하네스 편집기
- input / textarea
- 복사 가능한 코드 블록
- 버튼/카드/탭/라벨/네비는 드래그 방지
## 검증 기준
- 자매 상세 탭에서 설정 제거 확인
- 관리자 자매 관리 카드에 실제 프로필 사진 반영
- 프로젝트 상세에서 Hotfix 포함 이력 표시
- 로그/편집칸 외 텍스트 드래그 불가
- 빌드/테스트 통과
- 외부 URL QA 필수

View File

@@ -0,0 +1,42 @@
# HOTFIX-002 QA Review — Iteration 1
- **검증일시:** 2026-04-04 18:35 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| prisma generate | ✅ |
| npm test | ✅ 23/23 pass |
| npm run build (BE) | ✅ |
| npm run build (FE) | ✅ 16 routes |
## Task 파싱 확인 (TASK-044)
| 포맷 | 지원 |
|------|------|
| `### TASK-001: title` | ✅ |
| `- [x] TASK-001: title` (done) | ✅ |
| `- [ ] TASK-001: title` (pending) | ✅ |
| `- TASK-001: title` | ✅ |
| `TASK-001: title` | ✅ |
| 중복 taskId 방지 (seen Set) | ✅ |
## 완료 count 계산 (TASK-045)
| 규칙 | 확인 |
|------|------|
| Sprint done → task (status 없으면) done fallback | ✅ |
| `[x]` → done | ✅ |
| `[ ]` → pending | ✅ |
| 기존 done task 보호 | ✅ |
| Task 테이블 upsert | ✅ |
## Non-blocking
| ID | 이슈 |
|----|------|
| NB1 | task assignee 하드코딩 'narang' — 문서 파싱으로 개선 권장 |

View File

@@ -0,0 +1,25 @@
# HOTFIX-003 QA Review — Iteration 1
- **검증일시:** 2026-04-04 19:48 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm test | ✅ 23/23 pass |
| npm run build (BE) | ✅ |
| npm run build (FE) | ✅ 16 routes |
## 변경 확인
### 프로필 hero (TASK-046/047)
- ✅ SisterAvatar size=120 (기존 36px → 120px 확대)
- ✅ 상단 프로필 hero 재디자인
### Activity stderr 정리 (TASK-048)
-`sanitizeActivityDetail()` 함수 추가
- ✅ stderr/bash/command not found → 사용자 친화 메시지 변환
- ✅ SSH 연결 실패 → "원격 노드 연결에 실패했어" 변환
- ✅ log() + getFeed() + getProjectFeed() 전부 적용

View File

@@ -0,0 +1,21 @@
# HOTFIX-004 QA Review — Iteration 1
- **검증일시:** 2026-04-04 20:17 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm test | ✅ 23/23 pass |
| npm run build (BE) | ✅ |
| npm run build (FE) | ✅ 16 routes |
## 변경 확인
| Task | 내용 | 확인 |
|------|------|------|
| TASK-049 | DISK bar 퍼센트 계산 | ✅ diskPercent() — G/M/K/T 변환, NaN/0 fallback |
| TASK-050 | 카드 전체 클릭 | ✅ NodeEntry = styled(Link) + href |
| TASK-051 | 우측 운영 메타 | ✅ STATUS/ROLE/LXC ID/LAST CHECK/CURRENT TASK |

View File

@@ -0,0 +1,71 @@
# SPRINT-003 QA Review — Iteration 1
- **검증일시:** 2026-04-04 03:00 UTC
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| git clone + checkout | ✅ |
| npm install (BE/FE) | ✅ |
| prisma generate | ✅ |
| npm test (backend) | ✅ 16/16 pass |
| npm run build (backend) | ✅ |
| npm run build (frontend) | ✅ 7 routes |
## 설계 대조
| 설계 항목 | 일치 |
|-----------|------|
| SisterDetailService (SSH config/sessions/subagents) | ✅ |
| GET /api/sisters/:name/config | ⚠️ (SOUL.md+AGENTS.md, 설계는 openclaw.json — 문서 업데이트 필요) |
| GET /api/sisters/:name/sessions | ✅ |
| GET /api/sisters/:name/subagents | ✅ |
| GET /api/sisters/:name/activity | ✅ |
| GET /api/org | ✅ |
| /sisters 목록 페이지 | ✅ |
| /sisters/[name] 상세 (4탭) | ✅ |
| /org 조직도 (트리, 호버 팝업) | ✅ |
| /settings 설정 뷰어 (드롭다운) | ✅ |
| SSH graceful fallback | ✅ |
## Sprint 002 non-blocking 처리 확인
| 항목 | 상태 |
|------|------|
| API_URL/POLL_INTERVAL lib/config.ts 공통 추출 | ✅ |
| 폴링 간격 env화 | ✅ |
## Non-blocking 이슈
### 코드 품질 (code-reviewer)
| ID | 이슈 |
|----|------|
| N1 | name 파라미터 Controller 검증 추가 권장 (BadRequestException) |
| N2 | /api/sisters/:name/config — 설계 문서(openclaw.json) vs 구현(SOUL.md+AGENTS.md) 불일치 → api-design.md 업데이트 필요 |
| N3 | useState<any> 다수 — types/ 공유 인터페이스 정의 권장 |
| N4 | ls -lt 파싱 취약 — find/stat 기반으로 개선 권장 |
| N5 | OrgTree에서 API pipeline 데이터 미사용 (하드코딩 라벨) |
| N6 | formatLastSeen / sisterEmojis / sisterDisplayNames 3곳 중복 → lib/utils.ts, lib/constants.ts 추출 |
| N7 | SisterDetailPage 폴링 없음 |
| N8 | catch 블록 silent 처리 — 최소 에러 상태 표시 필요 |
### 보안 감사 (security-auditor)
| ID | 이슈 | 심각도 |
|----|------|--------|
| H-1 | name 파라미터 입력 검증 없음 | High (non-blocking) |
| H-2 | API 인증/인가 전면 부재 — Sprint-004 필수 | High (non-blocking) |
| M-1 | SOUL.md 원문 60줄 노출 — 프롬프트 인젝션 활용 가능 | Medium |
| M-2 | lxcId 내부 인프라 정보 노출 | Medium |
| M-3 | CORS 에러에 origin 노출 | Medium |
| M-4 | Helmet 미적용 | Medium |
| L-1 | npm audit 7건 (dev dependency) | Low |
## 판정 근거
- 테스트/빌드 전부 통과
- 설계 대조 일치 (config 엔드포인트만 문서 업데이트 필요)
- blocking 0건 (code-reviewer B1,B2 재검증 후 non-blocking 하향)
- SSH graceful fallback 일관적
- OrgTree 순수 CSS 구현 양호

View File

@@ -0,0 +1,51 @@
# SPRINT-004 QA Review — Iteration 1
- **검증일시:** 2026-04-04 03:16 UTC
- **검증자:** 다랑이 (Evaluator)
- **결과:** ❌ FAILED (blocking 3건)
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| prisma generate | ✅ |
| npm test (backend) | ✅ 22/22 pass |
| npm run build (backend) | ✅ |
| npm run build (frontend) | ✅ 11 routes |
## Sprint 003 non-blocking 수정 확인
| 항목 | 상태 |
|------|------|
| API Key Guard (passport-http-bearer) | ✅ |
| Helmet 적용 | ✅ |
| SisterNamePipe (name 검증) | ✅ |
| lib/sisters.ts 공통 함수 추출 | ✅ |
## 🔴 Blocking 이슈
### B1: UpdateHarnessDto class-validator 미적용
- **파일:** `admin/admin.controller.ts`
- **문제:** `@IsString() @IsNotEmpty()` 없음 → 빈 content로 하네스 파일 삭제 가능
- **수정:** `@IsString() @IsNotEmpty() @MaxLength(50000)` 추가
### B2: getSisterLogs lines 파라미터 상한 없음
- **파일:** `admin/admin.service.ts`
- **문제:** `lines=999999999` → journalctl/tail 메모리 폭발
- **수정:** `Math.min(Math.max(parseInt(lines, 10) || 100, 1), 500)` clamp
### B3: CORS methods에 PUT 누락
- **파일:** `main.ts`
- **문제:** `PUT /api/admin/harness/:name/:file`가 preflight 차단
- **수정:** methods 배열에 `'PUT'` 추가
## Non-blocking 이슈
| ID | 이슈 | 심각도 |
|----|------|--------|
| N1 | 셸 escape에서 null byte 미처리 | Medium |
| N2 | localStorage 평문 API Key | Medium |
| N3 | CORS 에러에 origin 노출 | Low |
| N4 | resetSisterSession catch에서 activity.log 누락 | Low |
| N5 | /api/sisters 비인증 노출 (config/sessions 포함) | Low |

View File

@@ -0,0 +1,26 @@
# SPRINT-004 QA Review — Iteration 2
- **검증일시:** 2026-04-04 03:19 UTC
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## Blocking 수정 확인
| ID | 이슈 | 수정 확인 |
|----|------|----------|
| B1 | UpdateHarnessDto validation | ✅ @IsString @IsNotEmpty @MaxLength(50000) |
| B2 | lines clamp | ✅ Controller에서 Math.min(Math.max(NaN→100, 1), 500) |
| B3 | CORS PUT 추가 | ✅ methods: ['GET','POST','PATCH','PUT','DELETE','OPTIONS'] |
## 검증 항목
| 항목 | 결과 |
|------|------|
| git fetch + reset | ✅ |
| npm test | ✅ 22/22 pass |
| npm run build | ✅ |
## 판정 근거
- Blocking 3건 전부 정확히 수정됨
- 테스트/빌드 통과
- Non-blocking 5건은 Sprint-005 권고

View File

@@ -0,0 +1,67 @@
# SPRINT-005 QA Review — Iteration 1 (최종 Sprint)
- **검증일시:** 2026-04-04 03:33 UTC
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| git clone + checkout | ✅ |
| npm install (BE/FE) | ✅ |
| prisma generate | ✅ |
| npm test (backend) | ✅ 22/22 pass |
| npm run build (backend) | ✅ |
| npm run build (frontend) | ✅ 12 routes |
## 설계 대조
| 설계 항목 | 일치 |
|-----------|------|
| CostLog 모델 + 인덱스 | ✅ |
| GET /api/admin/costs (period=day/week/month) | ✅ |
| POST /api/admin/costs/record/:name | ✅ |
| @UseGuards(ApiKeyGuard) on costs API | ✅ |
| WebSocket Gateway (/ws namespace) | ✅ |
| EventsScheduler 30초 브로드캐스트 | ✅ |
| sisters:update + activity:new 이벤트 | ✅ |
| ActivityService 실시간 연동 | ✅ |
| /admin/costs 비용 대시보드 (BarChart+요약) | ✅ |
| 기간 필터 (day/week/month) | ✅ |
| FE useSocket 훅 | ✅ |
| 메인 대시보드 실시간 반영 | ✅ |
## Sprint 004 non-blocking 수정 확인
| 항목 | 상태 |
|------|------|
| null byte 방지 (셸 escape) | ✅ |
## Non-blocking 이슈
### 코드 품질 (code-reviewer)
| ID | 이슈 | 심각도 |
|----|------|--------|
| N1 | WebSocket Gateway 인증 없음 — handleConnection에 token 검증 추가 권장 | Medium |
| N2 | useSocket stale closure — useRef 패턴으로 개선 권장 | Low |
| N3 | recordCosts python3 의존 — jq 또는 Node.js 대안 권장 | Low |
| N4 | estimatedUsd Float 정밀도 — Decimal 또는 정수(cents) 저장 권장 | Low |
| N5 | MODEL_PRICING 하드코딩 — config/DB 분리 권장 | Low |
| N6 | recordAll Promise.all → Promise.allSettled 권장 | Low |
| N7 | EventsScheduler setInterval → setTimeout 재스케줄링 권장 | Low |
| N8 | WebSocket + REST 이중 폴링 — connected 시 간격 조절 권장 | Low |
| N9 | POST record/:name 응답 body 없음 — {recorded: true} 반환 권장 | Low |
### 보안 감사 (security-auditor)
| ID | 이슈 | 심각도 |
|----|------|--------|
| SEC-001 | WebSocket Gateway 무인증 — origin/token 검증 추가 권장 | Medium |
| SEC-002 | lodash 의존성 취약점 (Prototype Pollution) — @nestjs/config 업그레이드 권고 | High (실제 악용 Low) |
## 판정 근거
- 테스트/빌드 전부 통과
- 설계 문서와 코드 일치
- blocking 0건 (code-reviewer B1~B3 재검증 후 non-blocking 하향)
- CostLog API 인증 적용, CORS 일관, Helmet, ValidationPipe 등 보안 기반 양호
- WebSocket 인증은 내부망 대시보드 특성상 non-blocking으로 판정

View File

@@ -0,0 +1,52 @@
# SPRINT-006 QA Review — Iteration 1
- **검증일시:** 2026-04-04 15:10 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ❌ FAILED (blocking 5건)
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| prisma generate | ✅ |
| npm test (backend) | ✅ 26/26 pass |
| npm run build (backend) | ✅ |
| npm run build (frontend) | ✅ 16 routes |
## 🔴 Blocking 이슈
### B1: JWT_SECRET fallback 'changeme' — 3곳 (Critical)
- **파일:** auth.service.ts:85, auth.module.ts:22, events.gateway.ts:48
- **문제:** JWT_SECRET 미설정 시 'changeme'로 토큰 서명 → 공격자가 유효한 JWT 위조 가능
- **모순:** jwt.strategy.ts는 throw하는데 나머지는 fallback → 불일치
- **수정:** 3곳 모두 `?? 'changeme'` 제거, ConfigService.getOrThrow 또는 throw 처리
### B2: User.role 기본값 "admin" (Critical)
- **파일:** prisma/schema.prisma
- **문제:** 모든 가입자 자동 admin → 초대코드만 알면 SSH restart, 하네스 수정 등 전체 인프라 제어
- **수정:** `@default("viewer")` + admin 승격 별도 로직 + AdminController에 RoleGuard 추가
### B3: WebSocket JWT 실패 시 disconnect 안 함
- **파일:** events.gateway.ts:52-53
- **문제:** invalid token 시 연결 유지 → 미인증 실시간 데이터 수신
- **수정:** catch 블록에서 `client.disconnect()` 호출
### B4: `throw new Error` → HttpException
- **파일:** auth.controller.ts:32
- **문제:** refresh token 없을 때 `throw new Error` → 500 반환
- **수정:** `throw new BadRequestException('Refresh token required')`
### B5: register 엔드포인트 rate limit 없음
- **파일:** auth.controller.ts:18-20
- **문제:** 초대코드 brute-force 가능
- **수정:** `@UseGuards(ThrottlerGuard) @Throttle({ default: { limit: 3, ttl: 60000 } })` 추가
## Non-blocking 이슈 (7건)
- N1: refresh token localStorage 평문 (httpOnly cookie 전환 권장)
- N2: AuthProvider 토큰 만료 시 자동 refresh 안 함
- N3: register에서 refreshToken 미저장
- N4: login 후 userId: 0 하드코딩
- N5: DTO 서비스 파일 내 정의 (별도 파일 권장)
- N6: adminFetch 주석-코드 불일치
- N7: refresh secret suffix 방식 → 별도 env 권장

View File

@@ -0,0 +1,23 @@
# SPRINT-006 QA Review — Iteration 2
- **검증일시:** 2026-04-04 15:17 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## Blocking 수정 확인
| ID | 이슈 | 수정 확인 |
|----|------|----------|
| B1 (Critical) | JWT_SECRET 'changeme' 4곳 | ✅ 전부 제거, 0건 grep 확인 |
| B2 (Critical) | User.role "admin" | ✅ @default("viewer") + RoleGuard + @Roles('admin') on Admin/Costs |
| B3 | WS disconnect | ✅ catch 블록에서 client.disconnect() 호출 |
| B4 | throw Error | ✅ BadRequestException 변경 |
| B5 | register Throttle | ✅ @Throttle 3회/분 적용 |
## 검증 항목
| 항목 | 결과 |
|------|------|
| git fetch + reset | ✅ |
| npm test | ✅ 26/26 pass |
| npm run build | ✅ |

View File

@@ -0,0 +1,39 @@
# SPRINT-007 QA Review — Iteration 1
- **검증일시:** 2026-04-04 15:38 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ❌ FAILED (blocking 1건)
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| prisma generate | ✅ |
| npm test | ✅ 26/26 pass |
| npm run build (BE) | ✅ |
| npm run build (FE) | ✅ 16 routes |
## Sprint 006 NB 처리 확인
| 항목 | 상태 |
|------|------|
| 자동 refresh (401 시) | ✅ |
| refreshToken 저장 | ✅ |
## 🔴 Blocking
### B1: getProjectCommits limit 파라미터 NaN 미처리
- **파일:** projects.controller.ts:25
- **문제:** `parseInt(limit, 10)` → NaN 가능 → Gitea API에 `?limit=NaN`
- **수정:** `isNaN` 체크 + `Math.min(Math.max(값, 1), 100)` clamp
## Non-blocking (8건)
- N1: ProjectsController 인증 없음 — 공개 의도라면 주석 명시
- N2: userId: 0 하드코딩 (refresh 후) — 현재 FE에서 미참조이나 개선 권장
- N3: syncRepos N+1 쿼리 + 트랜잭션 미사용
- N4: login 후 /me 실패 시 userId: 0 fallback
- N5: fetch 에러 시 에러 상태 없이 빈 목록
- N6: lastSyncAt 메모리만 저장
- N7: getOpenPRs/getPulls 중복
- N8: useEffect deps 불완전

View File

@@ -0,0 +1,18 @@
# SPRINT-007 QA Review — Iteration 2
- **검증일시:** 2026-04-04 15:41 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## Blocking 수정 확인
| ID | 이슈 | 수정 확인 |
|----|------|----------|
| B1 | commits limit NaN | ✅ isNaN 체크 + clamp(1~100) |
## 검증 항목
| 항목 | 결과 |
|------|------|
| git fetch + reset | ✅ |
| npm test | ✅ 26/26 pass |

View File

@@ -0,0 +1,43 @@
# SPRINT-008 QA Review — Iteration 1
- **검증일시:** 2026-04-04 16:35 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ❌ FAILED (blocking 3건)
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| prisma generate | ✅ |
| npm test | ✅ 26/26 pass |
| npm run build (BE) | ✅ |
| npm run build (FE) | ✅ 16 routes |
## 🔴 Blocking
### B1: SettingsController PUT body 미검증 — 임의 키 주입
- **파일:** settings.controller.ts, settings.service.ts
- **문제:** `Record<string, string>` 그대로 upsert → DEFAULT_SETTINGS에 없는 키도 DB에 삽입
- **수정:** saveAll()에서 DEFAULT_SETTINGS 키만 필터링
### B2: EventsScheduler + SistersScheduler 중복 — SSH 2배 호출
- **파일:** events.module.ts, events.scheduler.ts, sisters.scheduler.ts
- **문제:** 둘 다 providers에 등록, 둘 다 30초마다 broadcastSisterStatus() 실행
- **수정:** EventsScheduler 제거 (SistersScheduler가 대체)
### B3: SistersScheduler에 OnModuleDestroy 없음 — 메모리 누수
- **파일:** sisters.scheduler.ts
- **문제:** clearInterval 없음 → 서버 종료/HMR 시 좀비 타이머
- **수정:** `implements OnModuleDestroy` + `clearInterval(this.timer)`
## Non-blocking (9건)
- N1: avatar @Res() → NotFoundException 대신 res.status(404)
- N2: getSystemInfo null 반환 → 컨트롤러 null 체크 없음
- N3: SSH 커넥션 풀링 없음 (30초×4자매=16개 핸드셰이크)
- N4: top/free/df 파싱 환경 의존성 + disk % 미파싱
- N5: sisters 페이지 15초 폴링 + WS 30초 브로드캐스트 중복
- N6: avatar 캐시 사이즈 제한 없음
- N7: DEFAULT_SETTINGS FE/BE 중복 정의
- N8: sisterEmojis/sisterDisplayNames 여전히 3곳 중복
- N9: SSH 4개 병렬 → 1개 연결 4개 명령으로 최적화 권장

View File

@@ -0,0 +1,21 @@
# SPRINT-008 QA Review — Iteration 2
- **검증일시:** 2026-04-04 16:38 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## Blocking 수정 확인
| ID | 이슈 | 수정 확인 |
|----|------|----------|
| B1 | Settings 키 필터링 | ✅ saveAll()에서 DEFAULT_SETTINGS 키만 allowed.includes 필터 |
| B2 | Scheduler 중복 | ✅ SistersScheduler events.module에서 제거 → EventsScheduler만 동작 |
| B3 | OnModuleDestroy 누락 | ✅ SistersScheduler 제거로 해소, EventsScheduler에 이미 clearInterval |
## 검증 항목
| 항목 | 결과 |
|------|------|
| git fetch + reset | ✅ |
| prisma generate | ✅ |
| npm test | ✅ 26/26 pass |

View File

@@ -0,0 +1,37 @@
# SPRINT-009 QA Review — Iteration 1
- **검증일시:** 2026-04-04 17:06 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ❌ FAILED (blocking 2건)
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| prisma generate | ✅ |
| npm test | ✅ 23/23 pass |
| npm run build (BE) | ✅ |
| npm run build (FE) | ✅ 16 routes |
## 🔴 Blocking
### B1: repoUrl.split('/').pop() trailing slash 취약
- **파일:** sprint-sync.service.ts:42, 51
- **문제:** trailing slash URL → repoName 빈 문자열 → Gitea API 깨짐
- **수정:** `.replace(/\/+$/, '').split('/').pop()`
### B2: parseSprintMd status 전체 본문 키워드 검색 오탐
- **파일:** sprint-sync.service.ts:22-24
- **문제:** 본문에 "완료", "진행" 단어 포함 시 오탐 → DB status 부정확 → 진행률 계산 오류
- **수정:** 명시적 섹션(## Status 등)만 파싱하도록 범위 제한
## Non-blocking (8건)
- NB1: syncAllProjectSprints 순차 처리
- NB2: getRawFile ref='main' 하드코딩
- NB3: getRepoTree 전체 tree fetch 비효율
- NB4: /register redirect 방식은 적절 (OK)
- NB5: login page dead import/styled component
- NB6: auth spec mock 잔여
- NB7: sprint update 조건 코멘트 부족
- NB8: avatar onError display:none

View File

@@ -0,0 +1,20 @@
# SPRINT-009 QA Review — Iteration 2
- **검증일시:** 2026-04-04 17:12 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## Blocking 수정 확인
| ID | 이슈 | 수정 확인 |
|----|------|----------|
| B1 | repoUrl trailing slash | ✅ `.replace(/\/+$/, '').split('/').pop()` |
| B2 | status 파싱 범위 | ✅ `Status: value` 명시 줄만 regex 파싱, 본문 전체 검색 제거 |
## 검증 항목
| 항목 | 결과 |
|------|------|
| git fetch + reset | ✅ |
| prisma generate | ✅ |
| npm test | ✅ 23/23 pass |

View File

@@ -0,0 +1,51 @@
# SPRINT-010 QA Review — Iteration 1
- **검증일시:** 2026-04-04 17:25 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| prisma generate | ✅ |
| npm test | ✅ 23/23 pass |
| npm run build (BE) | ✅ |
| npm run build (FE) | ✅ 16 routes |
## API Key 완전 제거 확인
| 항목 | 결과 |
|------|------|
| api-key.strategy.ts 파일 삭제 | ✅ |
| api-key.guard.ts 파일 삭제 | ✅ |
| CompositeGuard 코드 0건 (grep) | ✅ |
| ADMIN_API_KEYS 참조 0건 | ✅ |
| hanarang_admin_key 참조 0건 | ✅ |
| admin layout API Key UI 제거 | ✅ |
| adminFetch JWT 전용 단순화 | ✅ |
## JWT 전환 확인
| Controller | Guard |
|------------|-------|
| AdminController | JwtGuard + RoleGuard ✅ |
| CostsController | JwtGuard + RoleGuard ✅ |
| GiteaSyncController | JwtGuard + RoleGuard ✅ |
| SettingsController | JwtGuard + RoleGuard ✅ |
## Dead Code 정리 확인
| 항목 | 결과 |
|------|------|
| INVITE_CODE BE 코드 | ✅ 0건 |
| register BE 엔드포인트 | ✅ 제거됨 |
| PUBLIC_PATHS /register | ✅ 제거됨 |
## 판정 근거
- 코드 정리/제거 스프린트 — 새 기능 없이 기존 코드 축소
- API Key 관련 파일/코드 완전 제거 확인 (grep 0건)
- JWT 전환 일관 적용
- 테스트/빌드 통과
- blocking 0건

View File

@@ -0,0 +1,37 @@
# SPRINT-011 QA Review — Iteration 1
- **검증일시:** 2026-04-04 17:45 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| prisma generate | ✅ |
| npm test | ✅ 23/23 pass |
| npm run build (BE) | ✅ |
| npm run build (FE) | ✅ 16 routes |
## 핵심 변경 확인
### Sprint status 파싱 (TASK-040)
-`.qa/SPRINT-XXX-review-*.md`에서 PASSED 여부로 status 결정
- ✅ 본문 전체 키워드 검색 완전 제거
-`inferStatusFromQaContent()`로 PASSED regex 체크
- ✅ passedMap으로 Sprint number → boolean 매핑
### 진행률 계산 (TASK-041)
-`doneSprints / totalSprints * 100` — Sprint done 기반 진행률
### SisterAvatar 공통 컴포넌트 (TASK-042/043)
-`components/common/SisterAvatar.tsx` 생성
- ✅ API URL → 이미지 로드 → onError → 이니셜 fallback
- ✅ 대시보드(/) + 자매(/sisters) + 상세(/sisters/[name]) 전부 적용
- ✅ 이모지 fallback 대신 이니셜 텍스트 fallback
## 판정 근거
- 자기야가 직접 지적한 2가지 문제(진행률 0%, 프로필 사진 미노출) 핵심 원인 수정됨
- 테스트/빌드 전부 통과
- blocking 0건

View File

@@ -0,0 +1,23 @@
# SPRINT-012 QA Review — Iteration 1
- **검증일시:** 2026-04-04 20:55 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm test | ✅ 23/23 pass |
| npm run build (BE) | ✅ |
| npm run build (FE) | ✅ 16 routes |
## 변경 확인
| Task | 내용 | 확인 |
|------|------|------|
| TASK-052 | 프로젝트 목록 3구역 재배치 | ✅ `1.4fr 1fr 180px` grid |
| TASK-053 | 프로젝트 아바타 → 자매 프로필 사진 | ✅ SisterAvatar(ownerSister) |
| TASK-054 | 자매 상세 탭 통합 (하네스/로그) | ✅ 6탭: 개요/세션/활동/하네스/로그/설정 |
| TASK-055 | 대시보드 그래프 2종 | ✅ Sister CPU load + Project progress BarChart |
| TASK-056 | 관리자 탭 단순화 | ✅ |

View File

@@ -0,0 +1,33 @@
# SPRINT-013 QA Review — Iteration 1
- **검증일시:** 2026-04-04 21:15 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ❌ FAILED
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm test | ✅ 23/23 pass |
| npm run build (BE) | ✅ |
| npm run build (FE) | ✅ 16 routes |
## 확인된 사항
| Task | 내용 | 확인 |
|------|------|------|
| TASK-057 | 자매 상세 설정 탭 제거 | ✅ |
| TASK-059 | 프로젝트 이력에 Sprint + Hotfix 표시 | ✅ `/api/projects/:id/history` + timeline UI |
| TASK-060 | user-select 정책 정리 | ✅ GlobalStyle 기본 none + 예외 text |
## Blocking
| ID | 이슈 |
|----|------|
| B1 | **관리 페이지 이모지 제거 미완료**`frontend/app/admin/logs/page.tsx``🦊/🐱/🐺` label 잔존 |
| B2 | **관리 페이지 이모지 제거 미완료**`frontend/app/admin/harness/page.tsx``🦊/🐱/🐺` label 잔존 |
## 판정
- 자기야가 정정한 요구는 "프로젝트 아이콘"이 아니라 **관리 쪽 자매 아이콘(여우/고양이/늑대) 제거**였어.
- admin 메인 카드에는 SisterAvatar가 적용됐지만, **admin logs / admin harness selector에는 여전히 이모지 label이 남아 있음**.
- 즉 TASK-058 요구 반영이 완결되지 않았어.

View File

@@ -0,0 +1,17 @@
# SPRINT-013 QA Review — Iteration 2
- **검증일시:** 2026-04-04 21:18 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 재검증 항목
| 항목 | 결과 |
|------|------|
| B1 | `frontend/app/admin/logs/page.tsx` 이모지 제거 | ✅ |
| B2 | `frontend/app/admin/harness/page.tsx` 이모지 제거 | ✅ |
| npm run build (FE) | ✅ 16 routes |
## 판정
- admin logs / admin harness selector의 `🦊/🐱/🐺` 이모지 잔존 문제 해결됨
- SPRINT-013 전체 요구사항 기준 blocking 0건

View File

@@ -0,0 +1,60 @@
# SPRINT-REDESIGN QA Review — Iteration 1
- **검증일시:** 2026-04-04 04:13 UTC
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| npm run build | ✅ 14 routes |
| 백엔드 변경 | 없음 (FE only) |
## DESIGN-SYSTEM.md 충실도
| 디자인 토큰 | 코드 일치 |
|-------------|-----------|
| 색상 (--bg-main, --text-primary, --border-color 등) | ✅ |
| 폰트 (--font-sans, --font-mono) | ✅ |
| 스페이싱 (--space-xs ~ --space-xxl) | ✅ |
| Sidebar 240px / 64px / 하단 탭바 | ✅ |
| 로고 ● + ◗ | ✅ |
| 네비 [브라켓] active 표기 | ✅ |
| Card 1px border + hover | ✅ |
| TechBar 2px | ✅ |
| Timeline 세로선 + 7px dot | ✅ |
| BracketValue 32px → 24px 모바일 | ✅ |
| LabelMeta 11px 700 uppercase | ✅ |
## 반응형 3단계
| Breakpoint | 확인 |
|------------|------|
| ≥1200px Desktop | ✅ 기본 레이아웃 |
| 768-1199px Tablet | ✅ Sidebar 64px 아이콘, 카드 2열 |
| ≤767px Mobile | ✅ 하단 탭바 56px, 카드 1열(2→1), padding-bottom 72px |
## 새 페이지/컴포넌트
| 항목 | 확인 |
|------|------|
| /activities 페이지 | ✅ |
| /projects 목록 페이지 | ✅ |
| components/ui/base.tsx 공통 컴포넌트 | ✅ |
| CSS vars 전면 적용 (theme.ts 하드코딩 대체) | ✅ |
## Non-blocking 이슈
| ID | 이슈 |
|----|------|
| N1 | 일부 admin 컴포넌트(BarChart, CodeEditor 등)에서 old theme import 잔존 가능 — 빌드 통과했으므로 미사용 import만 정리하면 됨 |
| N2 | SidebarContext가 아직 존재하나 LayoutShell에서 Provider 감싸지 않음 — 사용 안 되면 삭제 권장 |
## 판정 근거
- FE 빌드 14 routes 전부 통과
- DESIGN-SYSTEM.md 디자인 토큰 전부 충실히 구현
- 반응형 3단계 breakpoint 코드 확인
- glassmorphism → 미니멀 터미널 UI 전환 완료
- 백엔드 변경 없어 기능 회귀 위험 없음

View File

@@ -12,18 +12,29 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.18",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.18",
"@prisma/adapter-mariadb": "^7.6.0",
"@prisma/client": "^7.6.0",
"axios": "^1.14.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.4.0",
"helmet": "^8.1.0",
"mariadb": "^3.5.2",
"node-ssh": "^13.2.1",
"passport": "^0.7.0",
"passport-http-bearer": "^1.0.1",
"passport-jwt": "^4.0.1",
"prisma": "^7.6.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"socket.io": "^4.8.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -31,9 +42,12 @@
"@nestjs/cli": "^11.0.17",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/passport-http-bearer": "^1.0.42",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
@@ -2280,6 +2294,29 @@
}
}
},
"node_modules/@nestjs/jwt": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz",
"integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==",
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "9.0.10",
"jsonwebtoken": "9.0.3"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/passport": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
"integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"passport": "^0.5.0 || ^0.6.0 || ^0.7.0"
}
},
"node_modules/@nestjs/platform-express": {
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.18.tgz",
@@ -2301,6 +2338,25 @@
"@nestjs/core": "^11.0.0"
}
},
"node_modules/@nestjs/platform-socket.io": {
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.18.tgz",
"integrity": "sha512-DiFRpMIdFaHqZQFwqLqGHMdNurrKVkRkMHxIrecjooPHJNNIMgrbpYZ+oJW8hpwifUyZUL4r4uXXRy4+yFEMjA==",
"license": "MIT",
"dependencies": {
"socket.io": "4.8.3",
"tslib": "2.8.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nest"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/websockets": "^11.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "11.0.10",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.10.tgz",
@@ -2440,6 +2496,40 @@
}
}
},
"node_modules/@nestjs/throttler": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@nestjs/websockets": {
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.18.tgz",
"integrity": "sha512-HLO/QGlJoJaMMDphgjbcIwW7/8EA8nnFQjf+qPvA+wWLLfPKoiFPUezc5m9YgN2A7jmzwo6YmEAXeHyqO9tvTw==",
"license": "MIT",
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
"tslib": "2.8.1"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-socket.io": "^11.0.0",
"reflect-metadata": "^0.1.12 || ^0.2.0",
"rxjs": "^7.1.0"
},
"peerDependenciesMeta": {
"@nestjs/platform-socket.io": {
"optional": true
}
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -2921,6 +3011,12 @@
"@sinonjs/commons": "^3.0.1"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2989,6 +3085,16 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/accepts": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz",
"integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3034,6 +3140,16 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -3055,6 +3171,13 @@
"@types/node": "*"
}
},
"node_modules/@types/content-disposition": {
"version": "0.5.9",
"resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz",
"integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -3062,6 +3185,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/cookies": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.2.tgz",
"integrity": "sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/express": "*",
"@types/keygrip": "*",
"@types/node": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -3122,6 +3267,13 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/http-assert": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz",
"integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -3174,6 +3326,50 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/keygrip": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz",
"integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/koa": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/koa/-/koa-3.0.2.tgz",
"integrity": "sha512-7TRzVOBcH/q8CfPh9AmHBQ8TZtimT4Sn+rw8//hXveI6+F41z93W8a+0B0O8L7apKQv+vKBIEZSECiL0Oo1JFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/accepts": "*",
"@types/content-disposition": "*",
"@types/cookies": "*",
"@types/http-assert": "*",
"@types/http-errors": "^2",
"@types/keygrip": "*",
"@types/koa-compose": "*",
"@types/node": "*"
}
},
"node_modules/@types/koa-compose": {
"version": "3.2.9",
"resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.9.tgz",
"integrity": "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/koa": "*"
}
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -3181,6 +3377,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
@@ -3190,6 +3392,50 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/passport": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/passport-http-bearer": {
"version": "1.0.42",
"resolved": "https://registry.npmjs.org/@types/passport-http-bearer/-/passport-http-bearer-1.0.42.tgz",
"integrity": "sha512-cGezyf9hy3Cth+zWS779FR9XYhIX/DExsVZURqcSeUU/nhj0Aw8PUhvCyfS35ScwOSd5AFiFhtfWmqHa/2aYZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/koa": "*",
"@types/passport": "*"
}
},
"node_modules/@types/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
"integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "*",
"@types/passport-strategy": "*"
}
},
"node_modules/@types/passport-strategy": {
"version": "0.2.38",
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/passport": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -3272,6 +3518,15 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -4438,6 +4693,15 @@
],
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz",
@@ -4451,6 +4715,20 @@
"node": ">=6.0.0"
}
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@@ -4614,6 +4892,12 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -5436,6 +5720,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -5497,6 +5790,79 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz",
"integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"@types/ws": "^8.5.12",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
@@ -6677,6 +7043,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/hono": {
"version": "4.12.10",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz",
@@ -7944,6 +8319,49 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -8047,6 +8465,42 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -8061,6 +8515,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -8505,6 +8965,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-emoji": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
@@ -8521,6 +8990,17 @@
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"license": "MIT"
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -8631,6 +9111,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -8818,6 +9307,53 @@
"node": ">= 0.8"
}
},
"node_modules/passport": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
"utils-merge": "^1.0.1"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-http-bearer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/passport-http-bearer/-/passport-http-bearer-1.0.1.tgz",
"integrity": "sha512-SELQM+dOTuMigr9yu8Wo4Fm3ciFfkMq5h/ZQ8ffi4ELgZrX1xh9PlglqZdcUZ1upzJD/whVyt+YWF62s3U6Ipw==",
"dependencies": {
"passport-strategy": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
"passport-strategy": "^1.0.0"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -8900,6 +9436,11 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/pause": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -9509,7 +10050,6 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -9701,6 +10241,90 @@
"node": ">=8"
}
},
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.18.3"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@@ -10755,6 +11379,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
@@ -11120,6 +11753,27 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -24,18 +24,29 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.18",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.18",
"@prisma/adapter-mariadb": "^7.6.0",
"@prisma/client": "^7.6.0",
"axios": "^1.14.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.4.0",
"helmet": "^8.1.0",
"mariadb": "^3.5.2",
"node-ssh": "^13.2.1",
"passport": "^0.7.0",
"passport-http-bearer": "^1.0.1",
"passport-jwt": "^4.0.1",
"prisma": "^7.6.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"socket.io": "^4.8.3"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
@@ -46,9 +57,12 @@
"@nestjs/cli": "^11.0.17",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/passport-http-bearer": "^1.0.42",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",

View File

@@ -78,3 +78,34 @@ model ActivityLog {
@@index([createdAt])
}
model CostLog {
id Int @id @default(autoincrement())
sisterName String // harang, narang, darang, erang
model String // model name
inputTokens Int @default(0)
outputTokens Int @default(0)
totalTokens Int @default(0)
estimatedUsd Float @default(0)
sessionId String? @db.VarChar(128)
recordedAt DateTime @default(now())
@@index([sisterName, recordedAt])
@@index([recordedAt])
}
model User {
id Int @id @default(autoincrement())
username String @unique
password String // bcrypt hash
role String @default("viewer")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SystemSettings {
id Int @id @default(autoincrement())
key String @unique
value String @db.Text
updatedAt DateTime @updatedAt
}

View File

@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { ActivityController } from './activity.controller';
import { ActivityService } from './activity.service';
import { PrismaModule } from '../prisma/prisma.module';
import { EventsModule } from '../events/events.module';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, EventsModule],
controllers: [ActivityController],
providers: [ActivityService],
exports: [ActivityService],

View File

@@ -27,9 +27,11 @@ describe('ActivityService', () => {
it('log: activityLog.create 호출', async () => {
await service.log({ action: 'test_action', detail: 'detail' });
expect(mockPrisma.activityLog.create).toHaveBeenCalledWith({
data: { action: 'test_action', detail: 'detail' },
});
expect(mockPrisma.activityLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: { action: 'test_action', detail: 'detail' },
}),
);
});
it('getFeed: items + total 반환', async () => {

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Optional } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EventsGateway } from '../events/events.gateway';
export interface LogActivityDto {
sisterId?: number;
@@ -8,12 +9,47 @@ export interface LogActivityDto {
detail?: string;
}
function sanitizeActivityDetail(detail?: string | null): string | undefined {
if (!detail) return detail ?? undefined;
const raw = detail.trim();
// 내부 stderr/명령문 직접 노출 금지
if (/stderr:/i.test(raw) || /command not found/i.test(raw) || /bash:\s*line/i.test(raw)) {
return '내부 작업 중 오류가 발생했어. 자세한 시스템 로그는 관리자 로그에서 확인할 수 있어.';
}
if (/^ssh failed/i.test(raw) || /ssh connection failed/i.test(raw)) {
return '원격 노드 연결에 실패했어.';
}
return raw;
}
@Injectable()
export class ActivityService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
@Optional() private readonly events?: EventsGateway,
) {}
async log(dto: LogActivityDto) {
return this.prisma.activityLog.create({ data: dto });
const record = await this.prisma.activityLog.create({
data: {
...dto,
detail: sanitizeActivityDetail(dto.detail),
},
include: {
sister: { select: { name: true } },
project: { select: { name: true } },
},
});
if (this.events) {
this.events.broadcastActivity(record);
}
return record;
}
async getFeed(limit = 50, offset = 0) {
@@ -29,7 +65,12 @@ export class ActivityService {
}),
this.prisma.activityLog.count(),
]);
return { items, total, limit, offset };
return {
items: items.map((item) => ({ ...item, detail: sanitizeActivityDetail(item.detail) })),
total,
limit,
offset,
};
}
async getProjectFeed(projectId: number, limit = 30, offset = 0) {
@@ -45,6 +86,11 @@ export class ActivityService {
}),
this.prisma.activityLog.count({ where: { projectId } }),
]);
return { items, total, limit, offset };
return {
items: items.map((item) => ({ ...item, detail: sanitizeActivityDetail(item.detail) })),
total,
limit,
offset,
};
}
}

View File

@@ -0,0 +1,68 @@
import {
Controller,
Post,
Get,
Put,
Param,
Body,
Query,
UseGuards,
} from '@nestjs/common';
import { AdminService } from './admin.service';
import { JwtGuard } from '../auth/jwt.guard';
import { RoleGuard, Roles } from '../auth/role.guard';
import { SisterNamePipe } from '../common/sister-name.pipe';
import type { SisterName } from '../common/sister-name.pipe';
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
class UpdateHarnessDto {
@IsString()
@IsNotEmpty()
@MaxLength(50000)
content!: string;
}
@Controller('api/admin')
@UseGuards(JwtGuard, RoleGuard)
@Roles('admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Post('sisters/:name/restart')
restartSister(@Param('name', SisterNamePipe) name: SisterName) {
return this.adminService.restartSister(name);
}
@Post('sisters/:name/reset')
resetSister(@Param('name', SisterNamePipe) name: SisterName) {
return this.adminService.resetSisterSession(name);
}
@Get('harness/:name/:file')
getHarness(
@Param('name', SisterNamePipe) name: SisterName,
@Param('file') file: string,
) {
return this.adminService.getHarnessFile(name, file);
}
@Put('harness/:name/:file')
updateHarness(
@Param('name', SisterNamePipe) name: SisterName,
@Param('file') file: string,
@Body() dto: UpdateHarnessDto,
) {
return this.adminService.updateHarnessFile(name, file, dto.content);
}
@Get('logs/:name')
getLogs(
@Param('name', SisterNamePipe) name: SisterName,
@Query('lines') lines?: string,
) {
const parsed = lines ? parseInt(lines, 10) : 100;
const clamped = Math.min(Math.max(isNaN(parsed) ? 100 : parsed, 1), 500);
return this.adminService.getSisterLogs(name, clamped);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { PrismaModule } from '../prisma/prisma.module';
import { SistersModule } from '../sisters/sisters.module';
import { ActivityModule } from '../activity/activity.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, SistersModule, ActivityModule, AuthModule],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,91 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AdminService } from './admin.service';
import { PrismaService } from '../prisma/prisma.service';
import { SshService } from '../sisters/ssh.service';
import { ActivityService } from '../activity/activity.service';
import { ConfigService } from '@nestjs/config';
import { BadRequestException } from '@nestjs/common';
describe('AdminService', () => {
let service: AdminService;
const mockSister = {
id: 2, name: 'narang', ip: '10.10.10.216', user: 'narang',
lxcId: 105, status: 'online', lastSeen: new Date(),
sshKeyPath: null, createdAt: new Date(), updatedAt: new Date(),
};
const mockPrisma = {
sisterConfig: { findUnique: jest.fn().mockResolvedValue(mockSister) },
};
const mockSsh = { executeCommand: jest.fn() };
const mockActivity = { log: jest.fn().mockResolvedValue({}) };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: SshService, useValue: mockSsh },
{ provide: ActivityService, useValue: mockActivity },
{
provide: ConfigService,
useValue: { get: jest.fn().mockReturnValue('/home/narang/.ssh/id_rsa') },
},
],
}).compile();
service = module.get<AdminService>(AdminService);
jest.clearAllMocks();
mockPrisma.sisterConfig.findUnique.mockResolvedValue(mockSister);
});
it('restartSister: SSH 성공 시 success=true', async () => {
mockSsh.executeCommand.mockResolvedValue({ stdout: 'RESTART_OK', stderr: '', code: 0 });
const result = await service.restartSister('narang');
expect(result.success).toBe(true);
expect(mockActivity.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'gateway_restart' }),
);
});
it('restartSister: SSH 실패 시 success=false graceful', async () => {
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
const result = await service.restartSister('narang');
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
it('getHarnessFile: 허용되지 않은 파일 → BadRequestException', async () => {
await expect(service.getHarnessFile('narang', 'passwd')).rejects.toThrow(
BadRequestException,
);
});
it('getHarnessFile: 허용된 파일 + SSH 실패 → 빈 content fallback', async () => {
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
const result = await service.getHarnessFile('narang', 'SOUL.md');
expect(result.file).toBe('SOUL.md');
expect(result.content).toBe('');
});
it('updateHarnessFile: 허용되지 않은 파일 → BadRequestException', async () => {
await expect(
service.updateHarnessFile('narang', '../../etc/passwd', 'bad'),
).rejects.toThrow(BadRequestException);
});
it('getSisterLogs: SSH 실패 시 fallback 메시지 반환', async () => {
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
const result = await service.getSisterLogs('narang');
expect(result.lines).toContain('(SSH 연결 불가)');
});
});

View File

@@ -0,0 +1,175 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { SshService } from '../sisters/ssh.service';
import { ActivityService } from '../activity/activity.service';
import { SisterName } from '../common/sister-name.pipe';
const ALLOWED_HARNESS_FILES = ['AGENTS.md', 'SOUL.md', 'PROTOCOL.md', 'TOOLS.md', 'HEARTBEAT.md'] as const;
type HarnessFile = (typeof ALLOWED_HARNESS_FILES)[number];
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(
private readonly prisma: PrismaService,
private readonly ssh: SshService,
private readonly activity: ActivityService,
private readonly config: ConfigService,
) {}
async restartSister(name: SisterName) {
const sister = await this.getSister(name);
const keyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
keyPath,
'openclaw gateway restart && echo "RESTART_OK"',
);
const success = result.stdout.includes('RESTART_OK');
await this.activity.log({
sisterId: sister.id,
action: 'gateway_restart',
detail: success ? 'Gateway restart successful' : `stderr: ${result.stderr}`,
});
return { success, output: result.stdout, error: result.stderr || null };
} catch (e) {
this.logger.warn(`Restart failed for ${name}`);
await this.activity.log({
sisterId: sister.id,
action: 'gateway_restart',
detail: `failed: ${(e as Error).message}`,
});
return { success: false, output: '', error: (e as Error).message };
}
}
async resetSisterSession(name: SisterName) {
const sister = await this.getSister(name);
const keyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
keyPath,
'rm -f ~/.openclaw/sessions/main.json && echo "RESET_OK"',
);
const success = result.stdout.includes('RESET_OK');
await this.activity.log({
sisterId: sister.id,
action: 'session_reset',
detail: success ? 'Session reset successful' : `stderr: ${result.stderr}`,
});
return { success, output: result.stdout, error: result.stderr || null };
} catch (e) {
this.logger.warn(`Reset failed for ${name}`);
return { success: false, output: '', error: (e as Error).message };
}
}
async getHarnessFile(name: SisterName, file: string) {
this.validateHarnessFile(file);
const sister = await this.getSister(name);
const keyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
keyPath,
`cat ~/.openclaw/workspace/${file} 2>/dev/null || echo ""`,
);
return { name, file, content: result.stdout };
} catch {
return { name, file, content: '' };
}
}
async updateHarnessFile(name: SisterName, file: string, content: string) {
this.validateHarnessFile(file);
const sister = await this.getSister(name);
const keyPath = this.getKeyPath();
// 내용에서 위험한 셸 escape + null byte 방지
const escaped = content.replace(/\x00/g, '').replace(/'/g, "'\\''");
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
keyPath,
[
`cd ~/.openclaw/workspace`,
`printf '%s' '${escaped}' > ${file}`,
`git add ${file} && git commit -m "admin: update ${file}" --allow-empty 2>&1`,
`echo "WRITE_OK"`,
].join(' && '),
);
const success = result.stdout.includes('WRITE_OK');
await this.activity.log({
sisterId: sister.id,
action: 'harness_updated',
detail: `${file} updated${success ? '' : ' (with errors)'}`,
});
return { success, output: result.stdout, error: result.stderr || null };
} catch (e) {
this.logger.warn(`Harness write failed for ${name}/${file}`);
return { success: false, output: '', error: (e as Error).message };
}
}
async getSisterLogs(name: SisterName, lines = 100) {
const sister = await this.getSister(name);
const keyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
keyPath,
`journalctl --user -u openclaw-gateway --no-pager -n ${lines} 2>/dev/null || tail -n ${lines} ~/.openclaw/logs/gateway.log 2>/dev/null || echo "(로그 없음)"`,
);
return {
name,
lines: result.stdout.split('\n').filter((l) => l),
total: result.stdout.split('\n').filter((l) => l).length,
};
} catch {
return { name, lines: ['(SSH 연결 불가)'], total: 1 };
}
}
private validateHarnessFile(file: string) {
if (!ALLOWED_HARNESS_FILES.includes(file as HarnessFile)) {
throw new BadRequestException(
`Invalid file. Allowed: ${ALLOWED_HARNESS_FILES.join(', ')}`,
);
}
}
private async getSister(name: SisterName) {
const sister = await this.prisma.sisterConfig.findUnique({ where: { name } });
if (!sister) throw new Error(`Sister ${name} not found`);
return sister;
}
private getKeyPath(): string {
const p = this.config.get<string>('SSH_KEY_PATH');
if (!p) throw new Error('SSH_KEY_PATH is not set');
return p;
}
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
@@ -9,10 +10,18 @@ import { GiteaModule } from './gitea/gitea.module';
import { ProjectsModule } from './projects/projects.module';
import { TasksModule } from './tasks/tasks.module';
import { ActivityModule } from './activity/activity.module';
import { OrgModule } from './org/org.module';
import { AuthModule } from './auth/auth.module';
import { AdminModule } from './admin/admin.module';
import { EventsModule } from './events/events.module';
import { CostsModule } from './costs/costs.module';
import { GiteaSyncModule } from './gitea-sync/gitea-sync.module';
import { SettingsModule } from './settings/settings.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
PrismaModule,
SistersModule,
HealthModule,
@@ -20,6 +29,13 @@ import { ActivityModule } from './activity/activity.module';
ProjectsModule,
TasksModule,
ActivityModule,
OrgModule,
AuthModule,
AdminModule,
EventsModule,
CostsModule,
GiteaSyncModule,
SettingsModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -0,0 +1,37 @@
import {
Controller,
Post,
Get,
Body,
Headers,
UseGuards,
Request,
BadRequestException,
} from '@nestjs/common';
import { AuthService, LoginDto } from './auth.service';
import { JwtGuard } from './jwt.guard';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
@Controller('api/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('refresh')
refresh(@Headers('x-refresh-token') token: string) {
if (!token) throw new BadRequestException('Refresh token required');
return this.authService.refresh(token);
}
@UseGuards(JwtGuard)
@Get('me')
getMe(@Request() req: any) {
return this.authService.getMe(req.user.userId);
}
}

View File

@@ -0,0 +1,35 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { JwtGuard } from './jwt.guard';
import { RoleGuard } from './role.guard';
import { PrismaModule } from '../prisma/prisma.module';
import { ThrottlerModule } from '@nestjs/throttler';
@Module({
imports: [
PrismaModule,
PassportModule,
ConfigModule,
ThrottlerModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: (() => {
const s = config.get<string>('JWT_SECRET');
if (!s) throw new Error('JWT_SECRET is not configured');
return s;
})(),
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtGuard, RoleGuard],
exports: [JwtGuard, RoleGuard, AuthService, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,50 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { UnauthorizedException } from '@nestjs/common';
describe('AuthService', () => {
let service: AuthService;
const mockPrisma = {
user: {
findUnique: jest.fn(),
create: jest.fn(),
},
};
const mockJwt = {
sign: jest.fn().mockReturnValue('mock.jwt.token'),
verify: jest.fn(),
};
const mockConfig = {
get: jest.fn().mockImplementation((key: string) => {
if (key === 'JWT_SECRET') return 'test-secret';
return undefined;
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwt },
{ provide: ConfigService, useValue: mockConfig },
],
}).compile();
service = module.get<AuthService>(AuthService);
jest.clearAllMocks();
});
it('login: 존재하지 않는 사용자 → UnauthorizedException', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
await expect(
service.login({ username: 'unknown', password: 'password123' }),
).rejects.toThrow(UnauthorizedException);
});
});

View File

@@ -0,0 +1,67 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
import { IsString, IsNotEmpty } from 'class-validator';
export class LoginDto {
@IsString() @IsNotEmpty()
username!: string;
@IsString() @IsNotEmpty()
password!: string;
}
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({ where: { username: dto.username } });
if (!user) throw new UnauthorizedException('Invalid credentials');
const ok = await bcrypt.compare(dto.password, user.password);
if (!ok) throw new UnauthorizedException('Invalid credentials');
return this.signTokens(user.id, user.username, user.role);
}
async refresh(refreshToken: string) {
try {
const payload = this.jwt.verify<{ sub: number; username: string; role: string }>(
refreshToken,
{ secret: this.config.get<string>('JWT_SECRET') + '_refresh' },
);
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
if (!user) throw new UnauthorizedException();
return this.signTokens(user.id, user.username, user.role);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
}
async getMe(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, username: true, role: true, createdAt: true },
});
if (!user) throw new UnauthorizedException();
return user;
}
private signTokens(userId: number, username: string, role: string) {
const secret = this.config.get<string>('JWT_SECRET');
if (!secret) throw new Error('JWT_SECRET is not configured');
const payload = { sub: userId, username, role };
const accessToken = this.jwt.sign(payload, { secret, expiresIn: '15m' });
const refreshToken = this.jwt.sign(payload, { secret: secret + '_refresh', expiresIn: '7d' });
return { accessToken, refreshToken, username, role };
}
}

View File

@@ -0,0 +1,9 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
export interface JwtPayload {
sub: number;
username: string;
role: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(config: ConfigService) {
const secret = config.get<string>('JWT_SECRET');
if (!secret) throw new Error('JWT_SECRET not set');
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: secret,
});
}
validate(payload: JwtPayload) {
if (!payload?.sub) throw new UnauthorizedException();
return { userId: payload.sub, username: payload.username, role: payload.role };
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
@Injectable()
export class RoleGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) throw new ForbiddenException('Authentication required');
const hasRole = requiredRoles.includes(user.role);
if (!hasRole) throw new ForbiddenException(`Role '${user.role}' not authorized. Required: ${requiredRoles.join(', ')}`);
return true;
}
}

View File

@@ -0,0 +1,16 @@
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
const VALID_SISTER_NAMES = ['harang', 'narang', 'darang', 'erang'] as const;
export type SisterName = (typeof VALID_SISTER_NAMES)[number];
@Injectable()
export class SisterNamePipe implements PipeTransform {
transform(value: string): SisterName {
if (!VALID_SISTER_NAMES.includes(value as SisterName)) {
throw new BadRequestException(
`Invalid sister name. Must be one of: ${VALID_SISTER_NAMES.join(', ')}`,
);
}
return value as SisterName;
}
}

View File

@@ -0,0 +1,25 @@
import { Controller, Get, Post, Query, Param, UseGuards } from '@nestjs/common';
import { CostsService, CostPeriod } from './costs.service';
import { JwtGuard } from '../auth/jwt.guard';
import { RoleGuard, Roles } from '../auth/role.guard';
import { SisterNamePipe } from '../common/sister-name.pipe';
import type { SisterName } from '../common/sister-name.pipe';
@Controller('api/admin/costs')
@UseGuards(JwtGuard, RoleGuard)
@Roles('admin')
export class CostsController {
constructor(private readonly costsService: CostsService) {}
@Get()
getCosts(@Query('period') period?: string) {
const validPeriods: CostPeriod[] = ['day', 'week', 'month'];
const p = validPeriods.includes(period as CostPeriod) ? (period as CostPeriod) : 'week';
return this.costsService.getCosts(p);
}
@Post('record/:name')
recordCosts(@Param('name', SisterNamePipe) name: SisterName) {
return this.costsService.recordCosts(name);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { CostsController } from './costs.controller';
import { CostsService } from './costs.service';
import { PrismaModule } from '../prisma/prisma.module';
import { SistersModule } from '../sisters/sisters.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, SistersModule, AuthModule],
controllers: [CostsController],
providers: [CostsService],
})
export class CostsModule {}

View File

@@ -0,0 +1,179 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { SshService } from '../sisters/ssh.service';
import { SisterName } from '../common/sister-name.pipe';
// Claude Sonnet 기준 가격 (USD per 1M tokens)
const MODEL_PRICING: Record<string, { input: number; output: number }> = {
'claude-sonnet-4': { input: 3.0, output: 15.0 },
'claude-sonnet-4.6': { input: 3.0, output: 15.0 },
'claude-3-5-sonnet': { input: 3.0, output: 15.0 },
'gpt-4o': { input: 2.5, output: 10.0 },
'gpt-4o-mini': { input: 0.15, output: 0.6 },
default: { input: 3.0, output: 15.0 },
};
const SISTER_NAMES: SisterName[] = ['harang', 'narang', 'darang', 'erang'];
export type CostPeriod = 'day' | 'week' | 'month';
@Injectable()
export class CostsService {
private readonly logger = new Logger(CostsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly ssh: SshService,
private readonly config: ConfigService,
) {}
async getCosts(period: CostPeriod = 'week') {
const since = this.getSince(period);
const [summary, bySister, byModel, timeline] = await Promise.all([
this.getSummary(since),
this.getBySister(since),
this.getByModel(since),
this.getTimeline(since, period),
]);
return { period, since: since.toISOString(), summary, bySister, byModel, timeline };
}
async recordCosts(sisterName: SisterName) {
const keyPath = this.config.get<string>('SSH_KEY_PATH');
if (!keyPath) return;
const sister = await this.prisma.sisterConfig.findUnique({ where: { name: sisterName } });
if (!sister) return;
try {
// OpenClaw 세션에서 토큰 사용량 파싱 (python3 → node.js → jq fallback)
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
keyPath,
`SESSION_FILE=~/.openclaw/sessions/main.json; \
if [ ! -f "$SESSION_FILE" ]; then echo "unknown 0 0"; \
elif command -v node >/dev/null 2>&1; then \
node -e "try{const d=require('fs').readFileSync(process.env.HOME+'/.openclaw/sessions/main.json','utf8');const j=JSON.parse(d);const u=j.usage||{};console.log((j.model||'unknown')+' '+(u.input_tokens||0)+' '+(u.output_tokens||0))}catch(e){console.log('unknown 0 0')}" 2>/dev/null; \
elif command -v python3 >/dev/null 2>&1; then \
cat $SESSION_FILE | python3 -c "import json,sys;d=json.load(sys.stdin);u=d.get('usage',{});print(d.get('model','unknown'),u.get('input_tokens',0),u.get('output_tokens',0))" 2>/dev/null; \
elif command -v jq >/dev/null 2>&1; then \
echo "$(jq -r '.model // "unknown"' $SESSION_FILE) $(jq -r '.usage.input_tokens // 0' $SESSION_FILE) $(jq -r '.usage.output_tokens // 0' $SESSION_FILE)" 2>/dev/null; \
else echo "unknown 0 0"; fi`,
);
const parts = result.stdout.trim().split(' ');
if (parts.length < 3) return;
const [model, inputStr, outputStr] = parts;
const inputTokens = parseInt(inputStr, 10) || 0;
const outputTokens = parseInt(outputStr, 10) || 0;
const totalTokens = inputTokens + outputTokens;
if (totalTokens === 0) return;
const pricing = MODEL_PRICING[model] ?? MODEL_PRICING.default;
const estimatedUsd =
(inputTokens / 1_000_000) * pricing.input +
(outputTokens / 1_000_000) * pricing.output;
await this.prisma.costLog.create({
data: {
sisterName,
model,
inputTokens,
outputTokens,
totalTokens,
estimatedUsd,
},
});
} catch {
this.logger.warn(`Cost recording failed for ${sisterName}`);
}
}
private async getSummary(since: Date) {
const result = await this.prisma.costLog.aggregate({
where: { recordedAt: { gte: since } },
_sum: { inputTokens: true, outputTokens: true, totalTokens: true, estimatedUsd: true },
_count: true,
});
return {
totalTokens: result._sum.totalTokens ?? 0,
inputTokens: result._sum.inputTokens ?? 0,
outputTokens: result._sum.outputTokens ?? 0,
estimatedUsd: Math.round((result._sum.estimatedUsd ?? 0) * 10000) / 10000,
recordCount: result._count,
};
}
private async getBySister(since: Date) {
const rows = await this.prisma.costLog.groupBy({
by: ['sisterName'],
where: { recordedAt: { gte: since } },
_sum: { totalTokens: true, estimatedUsd: true },
orderBy: { _sum: { estimatedUsd: 'desc' } },
});
return rows.map((r) => ({
name: r.sisterName,
totalTokens: r._sum.totalTokens ?? 0,
estimatedUsd: Math.round((r._sum.estimatedUsd ?? 0) * 10000) / 10000,
}));
}
private async getByModel(since: Date) {
const rows = await this.prisma.costLog.groupBy({
by: ['model'],
where: { recordedAt: { gte: since } },
_sum: { totalTokens: true, estimatedUsd: true },
_count: true,
orderBy: { _sum: { estimatedUsd: 'desc' } },
});
return rows.map((r) => ({
model: r.model,
totalTokens: r._sum.totalTokens ?? 0,
estimatedUsd: Math.round((r._sum.estimatedUsd ?? 0) * 10000) / 10000,
count: r._count,
}));
}
private async getTimeline(since: Date, period: CostPeriod) {
const logs = await this.prisma.costLog.findMany({
where: { recordedAt: { gte: since } },
orderBy: { recordedAt: 'asc' },
select: { sisterName: true, totalTokens: true, estimatedUsd: true, recordedAt: true },
});
// 날짜별 집계
const grouped: Record<string, { date: string; totalTokens: number; estimatedUsd: number }> = {};
for (const log of logs) {
const dateKey = log.recordedAt.toISOString().slice(0, 10);
if (!grouped[dateKey]) {
grouped[dateKey] = { date: dateKey, totalTokens: 0, estimatedUsd: 0 };
}
grouped[dateKey].totalTokens += log.totalTokens;
grouped[dateKey].estimatedUsd += log.estimatedUsd;
}
return Object.values(grouped).map((d) => ({
...d,
estimatedUsd: Math.round(d.estimatedUsd * 10000) / 10000,
}));
}
private getSince(period: CostPeriod): Date {
const now = new Date();
switch (period) {
case 'day': return new Date(now.getTime() - 24 * 60 * 60 * 1000);
case 'week': return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
case 'month': return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
}
}
}

View File

@@ -0,0 +1,91 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@WebSocketGateway({
cors: {
origin: (process.env.CORS_ORIGINS ?? 'http://localhost:3004')
.split(',')
.map((o) => o.trim()),
credentials: true,
},
namespace: '/ws',
})
export class EventsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(EventsGateway.name);
constructor(
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}
afterInit() {
this.logger.log('WebSocket Gateway initialized');
}
handleConnection(client: Socket) {
// JWT 인증 필수 — 토큰 없거나 유효하지 않으면 disconnect
const token =
(client.handshake.auth?.token as string) ??
(client.handshake.headers.authorization as string)?.replace('Bearer ', '');
if (!token) {
this.logger.debug(`WS rejected (no token): ${client.id}`);
client.disconnect();
return;
}
const secret = this.config.get<string>('JWT_SECRET');
if (!secret) {
client.disconnect();
return;
}
try {
const payload = this.jwtService.verify(token, { secret });
(client as any).user = payload;
this.logger.debug(`WS client connected: ${client.id} (${payload.username})`);
} catch {
this.logger.debug(`WS rejected (invalid token): ${client.id}`);
client.disconnect();
}
}
handleDisconnect(client: Socket) {
this.logger.debug(`Client disconnected: ${client.id}`);
}
@SubscribeMessage('ping')
handlePing(client: Socket) {
client.emit('pong', { ts: Date.now() });
}
/** 자매 상태 전체 브로드캐스트 */
broadcastSisterStatus(sisters: unknown[]) {
this.server.emit('sisters:update', { sisters, ts: Date.now() });
}
/** 새 활동 로그 브로드캐스트 */
broadcastActivity(item: unknown) {
this.server.emit('activity:new', { item, ts: Date.now() });
}
/** 연결된 클라이언트 수 */
getClientCount(): number {
return this.server?.sockets?.sockets?.size ?? 0;
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventsGateway } from './events.gateway';
import { EventsScheduler } from './events.scheduler';
import { SistersModule } from '../sisters/sisters.module';
@Module({
imports: [
SistersModule,
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: (() => { const s = config.get<string>('JWT_SECRET'); if (!s) throw new Error('JWT_SECRET is not configured'); return s; })(),
}),
}),
],
providers: [EventsGateway, EventsScheduler],
exports: [EventsGateway],
})
export class EventsModule {}

View File

@@ -0,0 +1,35 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { SistersService } from '../sisters/sisters.service';
import { EventsGateway } from './events.gateway';
@Injectable()
export class EventsScheduler implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(EventsScheduler.name);
private timer: NodeJS.Timeout | null = null;
private readonly INTERVAL_MS = parseInt(process.env.WS_POLL_INTERVAL_MS ?? '30000', 10);
constructor(
private readonly sistersService: SistersService,
private readonly gateway: EventsGateway,
) {}
onModuleInit() {
this.timer = setInterval(() => this.tick(), this.INTERVAL_MS);
this.logger.log(`WebSocket scheduler started (interval: ${this.INTERVAL_MS}ms)`);
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
private async tick() {
if (this.gateway.getClientCount() === 0) return;
try {
const sisters = await this.sistersService.getAllSistersStatus();
this.gateway.broadcastSisterStatus(sisters);
} catch (e) {
this.logger.warn(`Scheduler tick failed: ${(e as Error).message}`);
}
}
}

View File

@@ -0,0 +1,26 @@
import { Injectable, Logger } from '@nestjs/common';
import { SistersService } from '../sisters/sisters.service';
import { EventsGateway } from './events.gateway';
@Injectable()
export class SistersScheduler {
private readonly logger = new Logger(SistersScheduler.name);
private timer: NodeJS.Timeout | null = null;
constructor(
private readonly sistersService: SistersService,
private readonly eventsGateway: EventsGateway,
) {}
start() {
if (this.timer) return;
this.timer = setInterval(async () => {
try {
const sisters = await this.sistersService.getAllSistersStatus();
this.eventsGateway.broadcastSisterStatus(sisters);
} catch {
this.logger.warn('Failed to broadcast sisters update');
}
}, 30000);
}
}

View File

@@ -0,0 +1,21 @@
import { Controller, Post, Get, UseGuards } from '@nestjs/common';
import { GiteaSyncService } from './gitea-sync.service';
import { JwtGuard } from '../auth/jwt.guard';
import { RoleGuard, Roles } from '../auth/role.guard';
@Controller('api/admin/gitea')
@UseGuards(JwtGuard, RoleGuard)
@Roles('admin')
export class GiteaSyncController {
constructor(private readonly giteaSyncService: GiteaSyncService) {}
@Post('sync')
sync() {
return this.giteaSyncService.syncRepos();
}
@Get('status')
status() {
return { lastSyncAt: this.giteaSyncService.getLastSyncAt() };
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { GiteaSyncController } from './gitea-sync.controller';
import { GiteaSyncService } from './gitea-sync.service';
import { PrismaModule } from '../prisma/prisma.module';
import { GiteaModule } from '../gitea/gitea.module';
import { ActivityModule } from '../activity/activity.module';
import { AuthModule } from '../auth/auth.module';
import { ProjectsModule } from '../projects/projects.module';
@Module({
imports: [PrismaModule, GiteaModule, ActivityModule, AuthModule, ProjectsModule],
controllers: [GiteaSyncController],
providers: [GiteaSyncService],
exports: [GiteaSyncService],
})
export class GiteaSyncModule {}

View File

@@ -0,0 +1,87 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { GiteaService } from '../gitea/gitea.service';
import { ActivityService } from '../activity/activity.service';
import { SprintSyncService } from '../projects/sprint-sync.service';
@Injectable()
export class GiteaSyncService {
private readonly logger = new Logger(GiteaSyncService.name);
private lastSyncAt: Date | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly gitea: GiteaService,
private readonly activity: ActivityService,
private readonly sprintSync: SprintSyncService,
) {}
async syncRepos(): Promise<{ synced: number; created: number; updated: number }> {
const repos = await this.gitea.getOrgRepos();
if (!repos.length) {
this.logger.warn('Gitea returned 0 repos (unavailable or empty org)');
return { synced: 0, created: 0, updated: 0 };
}
let created = 0;
let updated = 0;
for (const repo of repos) {
const existing = await this.prisma.project.findUnique({
where: { giteaId: repo.id },
});
if (!existing) {
await this.prisma.project.create({
data: {
giteaId: repo.id,
name: repo.name,
repoUrl: repo.html_url,
description: repo.description ?? null,
status: 'active',
},
});
created++;
await this.activity.log({
action: 'gitea_sync_new_repo',
detail: `New project synced from Gitea: [${repo.name}]`,
});
} else {
await this.prisma.project.update({
where: { giteaId: repo.id },
data: {
name: repo.name,
repoUrl: repo.html_url,
description: repo.description ?? null,
},
});
updated++;
}
}
this.lastSyncAt = new Date();
if (created > 0 || updated > 0) {
await this.activity.log({
action: 'gitea_sync_complete',
detail: `Gitea sync: ${created} created, ${updated} updated`,
});
}
this.logger.log(`Gitea sync: ${repos.length} repos, ${created} new, ${updated} updated`);
// Sprint 동기화도 함께 수행
await this.sprintSync.syncAllProjectSprints().catch((e) => {
this.logger.warn(`Sprint sync failed: ${e.message}`);
});
return { synced: repos.length, created, updated };
}
getLastSyncAt() {
return this.lastSyncAt;
}
}

View File

@@ -22,6 +22,24 @@ export interface GiteaPR {
html_url: string;
user: { login: string };
created_at: string;
merged: boolean;
merged_at: string | null;
}
export interface GiteaCommit {
sha: string;
commit: {
message: string;
author: { name: string; date: string };
};
author?: { login: string; avatar_url: string } | null;
html_url: string;
}
export interface GiteaBranch {
name: string;
commit: { id: string; created: string };
protected: boolean;
}
@Injectable()
@@ -87,4 +105,70 @@ export class GiteaService {
return [];
}
}
async getCommits(repoName: string, limit = 20): Promise<GiteaCommit[]> {
if (!this.isAvailable()) return [];
try {
const { data } = await this.client.get<GiteaCommit[]>(
`/repos/${this.org}/${repoName}/commits`,
{ params: { limit } },
);
return data;
} catch {
this.logger.warn(`Failed to fetch commits for ${repoName}`);
return [];
}
}
async getBranches(repoName: string): Promise<GiteaBranch[]> {
if (!this.isAvailable()) return [];
try {
const { data } = await this.client.get<GiteaBranch[]>(
`/repos/${this.org}/${repoName}/branches`,
{ params: { limit: 50 } },
);
return data;
} catch {
return [];
}
}
async getPulls(repoName: string, state: 'open' | 'closed' | 'all' = 'open'): Promise<GiteaPR[]> {
if (!this.isAvailable()) return [];
try {
const { data } = await this.client.get<GiteaPR[]>(
`/repos/${this.org}/${repoName}/pulls`,
{ params: { state, limit: 30, type: 'pulls' } },
);
return data;
} catch {
return [];
}
}
async getRepoTree(repoName: string, treePath: string): Promise<string[]> {
if (!this.isAvailable()) return [];
try {
const { data } = await this.client.get<{ tree: { path: string; type: string }[] }>(
`/repos/${this.org}/${repoName}/git/trees/HEAD`,
{ params: { recursive: true } },
);
return (data.tree ?? []).filter((e) => e.path.startsWith(treePath) && e.type === 'blob').map((e) => e.path);
} catch {
return [];
}
}
async getRawFile(repoName: string, filePath: string): Promise<string | null> {
if (!this.isAvailable()) return null;
try {
const { data } = await this.client.get<string>(
`/repos/${this.org}/${repoName}/raw/${filePath}`,
{ params: { ref: 'main' }, responseType: 'text' },
);
return typeof data === 'string' ? data : null;
} catch {
return null;
}
}
}

View File

@@ -1,10 +1,13 @@
import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(helmet());
const allowedOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:3004')
.split(',')
.map((o) => o.trim())
@@ -18,7 +21,7 @@ async function bootstrap() {
callback(new Error(`CORS: ${origin} not allowed`));
}
},
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
});

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { SisterDetailService } from '../sisters/sister-detail.service';
@Controller('api/org')
export class OrgController {
constructor(private readonly sisterDetail: SisterDetailService) {}
@Get()
getOrgData() {
return this.sisterDetail.getOrgData();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { OrgController } from './org.controller';
import { SistersModule } from '../sisters/sisters.module';
@Module({
imports: [SistersModule],
controllers: [OrgController],
})
export class OrgModule {}

View File

@@ -1,9 +1,15 @@
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
import { Controller, Get, Param, Query, ParseIntPipe, UseGuards, Post } from '@nestjs/common';
import { ProjectsService } from './projects.service';
import { SprintSyncService } from './sprint-sync.service';
import { JwtGuard } from '../auth/jwt.guard';
import { RoleGuard, Roles } from '../auth/role.guard';
@Controller('api/projects')
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}
constructor(
private readonly projectsService: ProjectsService,
private readonly sprintSyncService: SprintSyncService,
) {}
@Get()
getProjects() {
@@ -14,4 +20,46 @@ export class ProjectsController {
getProjectById(@Param('id', ParseIntPipe) id: number) {
return this.projectsService.getProjectById(id);
}
@Get(':id/commits')
getCommits(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
) {
const parsed = limit ? parseInt(limit, 10) : 20;
const clamped = Math.min(Math.max(isNaN(parsed) ? 20 : parsed, 1), 100);
return this.projectsService.getProjectCommits(id, clamped);
}
@Get(':id/branches')
getBranches(@Param('id', ParseIntPipe) id: number) {
return this.projectsService.getProjectBranches(id);
}
@Get(':id/pulls')
getPulls(
@Param('id', ParseIntPipe) id: number,
@Query('state') state?: 'open' | 'closed' | 'all',
) {
return this.projectsService.getProjectPulls(id, state ?? 'open');
}
@Get(':id/history')
getHistory(@Param('id', ParseIntPipe) id: number) {
return this.projectsService.getProjectHistory(id);
}
@Post(':id/sync-sprints')
@UseGuards(JwtGuard, RoleGuard)
@Roles('admin')
syncSprints(@Param('id', ParseIntPipe) id: number) {
return this.sprintSyncService.syncProjectSprints(id);
}
@Post('sync-all-sprints')
@UseGuards(JwtGuard, RoleGuard)
@Roles('admin')
syncAllSprints() {
return this.sprintSyncService.syncAllProjectSprints();
}
}

View File

@@ -1,13 +1,16 @@
import { Module } from '@nestjs/common';
import { ProjectsController } from './projects.controller';
import { ProjectsService } from './projects.service';
import { SprintSyncService } from './sprint-sync.service';
import { PrismaModule } from '../prisma/prisma.module';
import { GiteaModule } from '../gitea/gitea.module';
import { ActivityModule } from '../activity/activity.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, GiteaModule, ActivityModule],
imports: [PrismaModule, GiteaModule, ActivityModule, AuthModule],
controllers: [ProjectsController],
providers: [ProjectsService],
providers: [ProjectsService, SprintSyncService],
exports: [ProjectsService, SprintSyncService],
})
export class ProjectsModule {}

View File

@@ -34,8 +34,13 @@ export class ProjectsService {
}
return dbProjects.map((p) => {
const totalTasks = p.sprints.flatMap((s) => s.tasks).length;
const doneTasks = p.sprints.flatMap((s) => s.tasks).filter((t) => t.status === 'done').length;
// Sprint 기반 진행률 (백엔드 task 없어도 동작)
const totalSprints = p.sprints.length;
const doneSprints = p.sprints.filter((s) => s.status === 'done').length;
const inProgressSprint = p.sprints.find((s) => s.status === 'in_progress');
const ownerSister = p.sprints.flatMap((s) => s.tasks).find((t) => t.assignee)?.assignee ?? 'narang';
const progress = totalSprints > 0 ? Math.round((doneSprints / totalSprints) * 100) : 0;
const gitea = giteaRepos[p.giteaId];
return {
@@ -45,13 +50,14 @@ export class ProjectsService {
repoUrl: p.repoUrl,
description: p.description,
status: p.status,
progress: totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0,
totalTasks,
doneTasks,
progress,
totalSprints,
doneSprints,
ownerSister,
openPRs: gitea?.openPRs ?? 0,
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
sprintCount: p.sprints.length,
currentSprint: p.sprints.find((s) => s.status === 'in_progress')?.name ?? null,
sprintCount: totalSprints,
currentSprint: inProgressSprint?.name ?? (doneSprints === totalSprints && totalSprints > 0 ? 'COMPLETED' : null),
};
});
}
@@ -79,4 +85,47 @@ export class ProjectsService {
return { ...project, openPRs: giteaPRs };
}
async getProjectCommits(id: number, limit = 20) {
const project = await this.getProjectMeta(id);
const repoName = project.repoUrl.split('/').pop() ?? '';
return this.gitea.getCommits(repoName, limit);
}
async getProjectBranches(id: number) {
const project = await this.getProjectMeta(id);
const repoName = project.repoUrl.split('/').pop() ?? '';
return this.gitea.getBranches(repoName);
}
async getProjectPulls(id: number, state: 'open' | 'closed' | 'all' = 'open') {
const project = await this.getProjectMeta(id);
const repoName = project.repoUrl.split('/').pop() ?? '';
return this.gitea.getPulls(repoName, state);
}
async getProjectHistory(id: number) {
const project = await this.getProjectMeta(id);
const repoName = project.repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
const files = await this.gitea.getRepoTree(repoName, '.plans/sprints/');
return files
.filter((f) => /(SPRINT-\d+|HOTFIX-\d+)\.md$/i.test(f))
.map((f) => {
const name = f.split('/').pop() ?? '';
const sprint = name.match(/SPRINT-(\d+)/i);
const hotfix = name.match(/HOTFIX-(\d+)/i);
return {
kind: sprint ? 'sprint' : 'hotfix',
order: sprint ? parseInt(sprint[1], 10) : 1000 + parseInt(hotfix?.[1] ?? '0', 10),
label: name.replace('.md', ''),
};
})
.sort((a, b) => a.order - b.order);
}
private async getProjectMeta(id: number) {
const project = await this.prisma.project.findUnique({ where: { id }, select: { id: true, repoUrl: true } });
if (!project) throw new NotFoundException(`Project ${id} not found`);
return project;
}
}

View File

@@ -0,0 +1,212 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { GiteaService } from '../gitea/gitea.service';
interface ParsedSprint {
number: number;
name: string;
status: 'pending' | 'in_progress' | 'done';
}
interface ParsedTask {
taskId: string;
title: string;
status: 'pending' | 'done';
}
function parseSprintName(content: string, filename: string, number: number): string {
// 첫 번째 heading에서 이름 추출: # Sprint XXX — 이름 / # SPRINT-XXX: 이름
const headingMatch = content.match(/^#\s+(?:SPRINT-\d+|Sprint\s+\d+)[:\s—\-]+(.+)/m);
if (headingMatch) return headingMatch[1].trim();
return `SPRINT-${String(number).padStart(3, '0')}`;
}
function inferStatusFromQaContent(qaContent: string): 'done' | null {
// QA 파일에 "결과: ✅ PASSED" 또는 "PASSED" 포함 시 done
if (/PASSED/i.test(qaContent)) return 'done';
return null;
}
function parseTasks(content: string, sprintStatus: 'pending' | 'in_progress' | 'done'): ParsedTask[] {
const lines = content.split(/\r?\n/);
const seen = new Set<string>();
const tasks: ParsedTask[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
let match: RegExpMatchArray | null = null;
let status: 'pending' | 'done' | null = null;
// ### TASK-001: title
match = line.match(/^#{2,6}\s+(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
if (match) {
status = sprintStatus === 'done' ? 'done' : 'pending';
}
// - [x] TASK-001 title / - [ ] TASK-001 title
if (!match) {
match = line.match(/^-\s*\[([ xX])\]\s*(TASK-\d+[A-Z]?)\s*[:\-]?\s*(.+)$/i);
if (match) {
status = /x/i.test(match[1]) ? 'done' : 'pending';
}
}
// - TASK-001: title
if (!match) {
match = line.match(/^-\s*(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
if (match) {
status = sprintStatus === 'done' ? 'done' : 'pending';
}
}
// TASK-001: title
if (!match) {
match = line.match(/^(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
if (match) {
status = sprintStatus === 'done' ? 'done' : 'pending';
}
}
if (!match || !status) continue;
const taskId = (match[1] ?? match[2]).toUpperCase();
const title = (match[2] ?? match[3] ?? '').trim();
if (!taskId || seen.has(taskId)) continue;
seen.add(taskId);
tasks.push({ taskId, title: title || taskId, status });
}
return tasks;
}
@Injectable()
export class SprintSyncService {
private readonly logger = new Logger(SprintSyncService.name);
constructor(
private readonly prisma: PrismaService,
private readonly gitea: GiteaService,
) {}
async syncProjectSprints(projectId: number): Promise<{ synced: number; created: number; updated: number }> {
const project = await this.prisma.project.findUnique({ where: { id: projectId } });
if (!project) return { synced: 0, created: 0, updated: 0 };
const repoName = project.repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
return this.syncRepo(projectId, repoName);
}
async syncAllProjectSprints(): Promise<{ projects: number; synced: number }> {
const projects = await this.prisma.project.findMany({ select: { id: true, repoUrl: true } });
let totalSynced = 0;
for (const project of projects) {
const repoName = project.repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
const r = await this.syncRepo(project.id, repoName);
totalSynced += r.synced;
}
return { projects: projects.length, synced: totalSynced };
}
private async syncRepo(projectId: number, repoName: string): Promise<{ synced: number; created: number; updated: number }> {
const [sprintFilePaths, qaFilePaths] = await Promise.all([
this.gitea.getRepoTree(repoName, '.plans/sprints/'),
this.gitea.getRepoTree(repoName, '.qa/'),
]);
const sprintFiles = sprintFilePaths.filter((f) => /SPRINT-\d+\.md$/i.test(f));
const qaFiles = qaFilePaths.filter((f) => /SPRINT-\d+-review-.*\.md$/i.test(f));
if (!sprintFiles.length) {
this.logger.warn(`No sprint files found in ${repoName}/.plans/sprints/`);
return { synced: 0, created: 0, updated: 0 };
}
// QA 파일에서 PASSED 여부 수집: number → boolean
const passedMap = new Map<number, boolean>();
for (const qaPath of qaFiles) {
const numMatch = qaPath.match(/SPRINT-(\d+)-review/i);
if (!numMatch) continue;
const num = parseInt(numMatch[1], 10);
if (passedMap.get(num)) continue; // 이미 통과 확인됨
const qaContent = await this.gitea.getRawFile(repoName, qaPath);
if (!qaContent) continue;
if (inferStatusFromQaContent(qaContent)) {
passedMap.set(num, true);
}
}
let created = 0;
let updated = 0;
for (const filePath of sprintFiles) {
const content = await this.gitea.getRawFile(repoName, filePath);
if (!content) continue;
const filename = filePath.split('/').pop() ?? '';
const numMatch = filename.match(/SPRINT-(\d+)/i);
if (!numMatch) continue;
const number = parseInt(numMatch[1], 10);
const name = parseSprintName(content, filename, number);
// QA 통과 여부로 status 결정 (본문 전체 키워드 검색 금지)
const status: 'pending' | 'done' = passedMap.get(number) ? 'done' : 'pending';
const parsedTasks = parseTasks(content, status);
const existing = await this.prisma.sprint.findFirst({
where: { projectId, number },
});
let sprintId: number;
if (!existing) {
const createdSprint = await this.prisma.sprint.create({
data: { projectId, number, name, status },
});
sprintId = createdSprint.id;
created++;
} else {
const updatedSprint = await this.prisma.sprint.update({
where: { id: existing.id },
data: {
name,
status: existing.status === 'done' ? 'done' : status,
},
});
sprintId = updatedSprint.id;
updated++;
}
// Sprint 문서 기준 Task upsert
for (const task of parsedTasks) {
const existingTask = await this.prisma.task.findFirst({
where: { sprintId, taskId: task.taskId },
});
if (!existingTask) {
await this.prisma.task.create({
data: {
sprintId,
taskId: task.taskId,
title: task.title,
assignee: 'narang',
status: task.status,
},
});
} else {
await this.prisma.task.update({
where: { id: existingTask.id },
data: {
title: task.title,
status: existingTask.status === 'done' ? 'done' : task.status,
},
});
}
}
}
this.logger.log(`${repoName}: ${sprintFiles.length} sprints, ${passedMap.size} QA passed → ${created} created, ${updated} updated`);
return { synced: sprintFiles.length, created, updated };
}
}

View File

@@ -0,0 +1,26 @@
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
import { SettingsService } from './settings.service';
import { JwtGuard } from '../auth/jwt.guard';
import { RoleGuard, Roles } from '../auth/role.guard';
@Controller('api/admin/settings')
@UseGuards(JwtGuard, RoleGuard)
@Roles('admin')
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get()
getAll() {
return this.settingsService.getAll();
}
@Get('defaults')
getDefaults() {
return this.settingsService.getDefaults();
}
@Put()
saveAll(@Body() body: Record<string, string>) {
return this.settingsService.saveAll(body ?? {});
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
import { PrismaModule } from '../prisma/prisma.module';
import { ActivityModule } from '../activity/activity.module';
@Module({
imports: [PrismaModule, ActivityModule],
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View File

@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ActivityService } from '../activity/activity.service';
const DEFAULT_SETTINGS: Record<string, string> = {
force2fa: 'true',
sessionTimeoutMinutes: '15',
ipWhitelistEnabled: 'false',
autoBackupEnabled: 'true',
backupSchedule: '08:00',
retentionDays: '30',
queueWarningThreshold: '500',
autoScalingEnabled: 'true',
cpuThresholdPercent: '90',
latencyAlertMs: '250',
nodeOfflineAlertEnabled: 'true',
};
@Injectable()
export class SettingsService {
constructor(
private readonly prisma: PrismaService,
private readonly activity: ActivityService,
) {}
async getAll() {
const rows = await this.prisma.systemSettings.findMany({ orderBy: { key: 'asc' } });
const merged = { ...DEFAULT_SETTINGS };
for (const row of rows) merged[row.key] = row.value;
return merged;
}
async saveAll(payload: Record<string, string>) {
// DEFAULT_SETTINGS에 있는 키만 허용
const allowed = Object.keys(DEFAULT_SETTINGS);
const entries = Object.entries(payload).filter(([key]) => allowed.includes(key));
await this.prisma.$transaction(
entries.map(([key, value]) =>
this.prisma.systemSettings.upsert({
where: { key },
update: { value: String(value) },
create: { key, value: String(value) },
}),
),
);
await this.activity.log({
action: 'settings_updated',
detail: `System settings updated: ${entries.length} keys`,
});
return this.getAll();
}
getDefaults() {
return { ...DEFAULT_SETTINGS };
}
}

View File

@@ -0,0 +1,77 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SshService } from './ssh.service';
interface AvatarCacheItem {
expiresAt: number;
contentType: string;
data: Buffer;
}
@Injectable()
export class AvatarService {
private readonly logger = new Logger(AvatarService.name);
private readonly cache = new Map<string, AvatarCacheItem>();
private readonly ttlMs = 5 * 60 * 1000;
constructor(
private readonly ssh: SshService,
private readonly config: ConfigService,
) {}
async getAvatar(sister: { name: string; ip: string; user: string }) {
const cached = this.cache.get(sister.name);
if (cached && cached.expiresAt > Date.now()) {
return cached;
}
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
if (!sshKeyPath) {
return this.getFallback(sister.name);
}
const files = [
{ path: '~/.openclaw/avatar.png', type: 'image/png' },
{ path: '~/.openclaw/avatar.jpg', type: 'image/jpeg' },
{ path: '~/.openclaw/avatar.jpeg', type: 'image/jpeg' },
{ path: '~/.openclaw/avatar.webp', type: 'image/webp' },
];
for (const file of files) {
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
sshKeyPath,
`if [ -f ${file.path} ]; then base64 -w0 ${file.path}; fi`,
);
if (result.stdout.trim()) {
const item = {
contentType: file.type,
data: Buffer.from(result.stdout.trim(), 'base64'),
expiresAt: Date.now() + this.ttlMs,
};
this.cache.set(sister.name, item);
return item;
}
} catch {
this.logger.warn(`Avatar fetch failed for ${sister.name}`);
// 실패 시 오래된 캐시 삭제 (다음 요청에서 재시도)
this.cache.delete(sister.name);
}
}
return this.getFallback(sister.name);
}
private getFallback(name: string) {
const initial = name.slice(0, 1).toUpperCase();
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="100%" height="100%" fill="#111"/><text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="56" fill="#f5f5f5">${initial}</text></svg>`;
return {
contentType: 'image/svg+xml',
data: Buffer.from(svg),
expiresAt: Date.now() + this.ttlMs,
};
}
}

View File

@@ -0,0 +1,90 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SisterDetailService } from './sister-detail.service';
import { SshService } from './ssh.service';
import { PrismaService } from '../prisma/prisma.service';
import { ConfigService } from '@nestjs/config';
import { NotFoundException } from '@nestjs/common';
describe('SisterDetailService', () => {
let service: SisterDetailService;
const mockSister = {
id: 2, name: 'narang', ip: '10.10.10.216', user: 'narang',
lxcId: 105, status: 'online', lastSeen: new Date(),
sshKeyPath: null, createdAt: new Date(), updatedAt: new Date(),
};
const mockPrisma = {
sisterConfig: {
findUnique: jest.fn(),
findMany: jest.fn().mockResolvedValue([mockSister]),
},
activityLog: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
};
const mockSsh = { executeCommand: jest.fn() };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SisterDetailService,
{ provide: SshService, useValue: mockSsh },
{ provide: PrismaService, useValue: mockPrisma },
{
provide: ConfigService,
useValue: { get: jest.fn().mockReturnValue('/home/narang/.ssh/id_rsa') },
},
],
}).compile();
service = module.get<SisterDetailService>(SisterDetailService);
jest.clearAllMocks();
});
it('getSisterConfig: SSH 실패 시 fallback 반환', async () => {
mockPrisma.sisterConfig.findUnique.mockResolvedValue(mockSister);
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
const result = await service.getSisterConfig('narang');
expect(result.name).toBe('narang');
expect(result.role).toBe('Generator');
expect(result.raw).toContain('SSH 연결 불가');
});
it('getSisterConfig: sister 없으면 NotFoundException', async () => {
mockPrisma.sisterConfig.findUnique.mockResolvedValue(null);
await expect(service.getSisterConfig('narang')).rejects.toThrow(NotFoundException);
});
it('getSisterSessions: SSH 실패 시 빈 배열', async () => {
mockPrisma.sisterConfig.findUnique.mockResolvedValue(mockSister);
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
const result = await service.getSisterSessions('narang');
expect(result).toEqual([]);
});
it('getOrgData: 파이프라인 포함 조직도 반환', async () => {
const result = await service.getOrgData();
expect(result.owner.name).toBe('자기야');
expect(result.sisters).toHaveLength(1);
expect(result.pipeline.length).toBeGreaterThan(0);
});
it('getSisterActivityLog: sisterId 필터 적용', async () => {
mockPrisma.sisterConfig.findUnique.mockResolvedValue(mockSister);
mockPrisma.activityLog.findMany.mockResolvedValue([]);
await service.getSisterActivityLog('narang');
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { sisterId: mockSister.id } }),
);
});
});

View File

@@ -0,0 +1,153 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SshService } from './ssh.service';
import { ConfigService } from '@nestjs/config';
const SISTER_NAMES = ['harang', 'narang', 'darang', 'erang'] as const;
type SisterName = (typeof SISTER_NAMES)[number];
const SISTER_ROLES: Record<SisterName, string> = {
harang: 'Orchestrator',
narang: 'Generator',
darang: 'Evaluator',
erang: 'Infra Manager',
};
const SISTER_DESCRIPTIONS: Record<SisterName, string> = {
harang: '기획과 오케스트레이션. 스프린트를 설계하고 자매들에게 작업을 배분한다.',
narang: '코드 생성과 개발. 스프린트를 구현하고 PR을 만든다.',
darang: '품질 검증. 코드 리뷰와 보안 감사를 수행한다.',
erang: '인프라와 배포. 서버 관리, merge, 프로덕션 배포를 담당한다.',
};
@Injectable()
export class SisterDetailService {
private readonly logger = new Logger(SisterDetailService.name);
constructor(
private readonly prisma: PrismaService,
private readonly ssh: SshService,
private readonly config: ConfigService,
) {}
async getSisterConfig(name: SisterName) {
const sister = await this.getSisterRecord(name);
const sshKeyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
sshKeyPath,
'cat ~/.openclaw/workspace/SOUL.md 2>/dev/null | head -60; echo "---AGENTS---"; cat ~/.openclaw/workspace/AGENTS.md 2>/dev/null | head -40',
);
return {
name,
role: SISTER_ROLES[name],
description: SISTER_DESCRIPTIONS[name],
raw: result.stdout || '(설정 파일 없음)',
};
} catch {
return {
name,
role: SISTER_ROLES[name],
description: SISTER_DESCRIPTIONS[name],
raw: '(SSH 연결 불가)',
};
}
}
async getSisterSessions(name: SisterName) {
const sister = await this.getSisterRecord(name);
const sshKeyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
sshKeyPath,
'ls -lt ~/.openclaw/sessions/ 2>/dev/null | head -20 || echo ""',
);
const lines = result.stdout.split('\n').filter((l) => l && !l.startsWith('total'));
return lines.map((line) => {
const parts = line.trim().split(/\s+/);
return {
name: parts[parts.length - 1] ?? '',
modified: parts.slice(5, 8).join(' '),
size: parts[4] ?? '0',
};
}).filter((s) => s.name && s.name !== '');
} catch {
return [];
}
}
async getSisterSubagents(name: SisterName) {
const sister = await this.getSisterRecord(name);
const sshKeyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
sshKeyPath,
'ls ~/.openclaw/workspace/agents/ 2>/dev/null || echo ""',
);
const agents = result.stdout.split('\n').filter((l) => l.trim().endsWith('.md'));
return agents.map((a) => ({ name: a.trim().replace('.md', ''), file: a.trim() }));
} catch {
return [];
}
}
async getSisterActivityLog(name: SisterName) {
const sister = await this.getSisterRecord(name);
const [logs, total] = await Promise.all([
this.prisma.activityLog.findMany({
where: { sisterId: sister.id },
orderBy: { createdAt: 'desc' },
take: 20,
}),
this.prisma.activityLog.count({ where: { sisterId: sister.id } }),
]);
return { items: logs, total };
}
async getOrgData() {
const sisters = await this.prisma.sisterConfig.findMany();
return {
owner: { name: '자기야', role: 'Owner' },
sisters: sisters.map((s) => ({
id: s.id,
name: s.name,
role: SISTER_ROLES[s.name as SisterName] ?? 'Unknown',
description: SISTER_DESCRIPTIONS[s.name as SisterName] ?? '',
status: s.status,
lastSeen: s.lastSeen,
lxcId: s.lxcId,
})),
pipeline: [
{ from: 'harang', to: 'narang', label: '핸드오프' },
{ from: 'narang', to: 'darang', label: '리뷰 요청' },
{ from: 'darang', to: 'narang', label: '수정 요청' },
{ from: 'darang', to: 'harang', label: '검증 완료' },
{ from: 'harang', to: 'erang', label: '배포 요청' },
],
};
}
private async getSisterRecord(name: SisterName) {
const sister = await this.prisma.sisterConfig.findUnique({ where: { name } });
if (!sister) throw new NotFoundException(`Sister ${name} not found`);
return sister;
}
private getKeyPath(): string {
const p = this.config.get<string>('SSH_KEY_PATH');
if (!p) throw new Error('SSH_KEY_PATH is not set');
return p;
}
}

View File

@@ -1,12 +1,62 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
import { SistersService } from './sisters.service';
import { SisterDetailService } from './sister-detail.service';
import { SisterNamePipe } from '../common/sister-name.pipe';
import type { SisterName } from '../common/sister-name.pipe';
import { AvatarService } from './avatar.service';
@Controller('api/sisters')
export class SistersController {
constructor(private readonly sistersService: SistersService) {}
constructor(
private readonly sistersService: SistersService,
private readonly sisterDetail: SisterDetailService,
private readonly avatarService: AvatarService,
) {}
@Get()
async getSistersStatus() {
return this.sistersService.getAllSistersStatus();
}
@Get(':name/system')
async getSystemInfo(@Param('name', SisterNamePipe) name: SisterName) {
return this.sistersService.getSystemInfo(name as SisterName);
}
@Get(':name/avatar')
async getAvatar(
@Param('name', SisterNamePipe) name: SisterName,
@Res() res: Response,
) {
const sister = await this.sistersService.findByName(name as SisterName);
if (!sister) {
return res.status(404).json({ message: 'Sister not found' });
}
const avatar = await this.avatarService.getAvatar(sister);
res.setHeader('Content-Type', avatar.contentType);
res.setHeader('Cache-Control', 'public, max-age=300');
return res.send(avatar.data);
}
@Get(':name/config')
async getSisterConfig(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterConfig(name as SisterName);
}
@Get(':name/sessions')
async getSisterSessions(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterSessions(name as SisterName);
}
@Get(':name/subagents')
async getSisterSubagents(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterSubagents(name as SisterName);
}
@Get(':name/activity')
async getSisterActivity(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterActivityLog(name as SisterName);
}
}

View File

@@ -1,12 +1,16 @@
import { Module } from '@nestjs/common';
import { SistersController } from './sisters.controller';
import { SistersService } from './sisters.service';
import { SisterDetailService } from './sister-detail.service';
import { SshService } from './ssh.service';
import { PrismaModule } from '../prisma/prisma.module';
import { ConfigModule } from '@nestjs/config';
import { AvatarService } from './avatar.service';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, ConfigModule],
controllers: [SistersController],
providers: [SistersService, SshService],
providers: [SistersService, SisterDetailService, SshService, AvatarService],
exports: [SistersService, SisterDetailService, SshService, AvatarService],
})
export class SistersModule {}

View File

@@ -1,7 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SshService } from './ssh.service';
import { ConfigService } from '@nestjs/config';
import { ActivityService } from '../activity/activity.service';
export interface SisterStatus {
id: number;
@@ -29,6 +30,7 @@ export class SistersService {
private readonly prisma: PrismaService,
private readonly ssh: SshService,
private readonly config: ConfigService,
@Optional() private readonly activity?: ActivityService,
) {}
async getAllSistersStatus(): Promise<SisterStatus[]> {
@@ -46,7 +48,6 @@ export class SistersService {
if (result.status === 'fulfilled') {
return result.value;
}
// graceful fallback
const sister = sisters[index];
return {
id: sister.id,
@@ -61,8 +62,33 @@ export class SistersService {
});
}
async findByName(name: string) {
return this.prisma.sisterConfig.findUnique({ where: { name } });
}
async getSystemInfo(name: string) {
const sister = await this.prisma.sisterConfig.findUnique({ where: { name } });
if (!sister) return null;
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
if (!sshKeyPath) return null;
const [uptimeRes, cpuRes, memRes, diskRes] = await Promise.all([
this.ssh.executeCommand(sister.ip, sister.user, sshKeyPath, 'cat /proc/uptime 2>/dev/null || echo "0 0"').catch(() => ({ stdout: '0 0', stderr: '', code: 0 })),
this.ssh.executeCommand(sister.ip, sister.user, sshKeyPath, "top -bn1 | grep Cpu || echo '%Cpu(s): 0.0 us, 0.0 sy, 100.0 id'").catch(() => ({ stdout: '%Cpu(s): 0.0 us, 0.0 sy, 100.0 id', stderr: '', code: 0 })),
this.ssh.executeCommand(sister.ip, sister.user, sshKeyPath, 'free -m | grep Mem || echo "Mem: 0 0 0 0 0 0"').catch(() => ({ stdout: 'Mem: 0 0 0 0 0 0', stderr: '', code: 0 })),
this.ssh.executeCommand(sister.ip, sister.user, sshKeyPath, "df -h / | tail -1 || echo '/dev/root 0G 0G 0G 0% /'").catch(() => ({ stdout: '/dev/root 0G 0G 0G 0% /', stderr: '', code: 0 })),
]);
return {
uptime: this.parseUptime(uptimeRes.stdout),
cpu: this.parseCpu(cpuRes.stdout),
memory: this.parseMemory(memRes.stdout),
disk: this.parseDisk(diskRes.stdout),
};
}
private async checkSisterStatus(
sister: { id: number; name: string; ip: string; user: string; lxcId: number; lastSeen: Date | null },
sister: { id: number; name: string; ip: string; user: string; lxcId: number; lastSeen: Date | null; status: string },
sshKeyPath: string,
): Promise<SisterStatus> {
try {
@@ -75,13 +101,20 @@ export class SistersService {
const isActive = result.stdout.trim() === 'active';
const status: 'online' | 'offline' = isActive ? 'online' : 'offline';
const prevStatus = sister.status;
const now = new Date();
if (isActive) {
// 온라인이면 lastSeen 업데이트
await this.prisma.sisterConfig.update({
where: { id: sister.id },
data: { lastSeen: new Date(), status },
});
await this.prisma.sisterConfig.update({
where: { id: sister.id },
data: { lastSeen: isActive ? now : sister.lastSeen, status },
});
if (prevStatus !== status && this.activity) {
await this.activity.log({
sisterId: sister.id,
action: 'status_changed',
detail: `[${sister.name}] status: ${prevStatus}${status}`,
}).catch(() => {});
}
return {
@@ -91,7 +124,7 @@ export class SistersService {
lxcId: sister.lxcId,
role: SISTER_ROLES[sister.name] ?? 'Unknown',
status,
lastSeen: isActive ? new Date() : sister.lastSeen,
lastSeen: isActive ? now : sister.lastSeen,
currentTask: null,
};
} catch {
@@ -108,4 +141,30 @@ export class SistersService {
};
}
}
private parseUptime(stdout: string) {
const sec = Math.floor(parseFloat(stdout.split(' ')[0] || '0'));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
private parseCpu(stdout: string) {
const idleMatch = stdout.match(/([0-9]+(?:\.[0-9]+)?)\s*id/);
const idle = idleMatch ? parseFloat(idleMatch[1]) : 100;
return Math.max(0, Math.min(100, Number((100 - idle).toFixed(1))));
}
private parseMemory(stdout: string) {
const parts = stdout.trim().split(/\s+/);
const total = parseInt(parts[1] || '0', 10);
const used = parseInt(parts[2] || '0', 10);
return { used, total };
}
private parseDisk(stdout: string) {
const parts = stdout.trim().split(/\s+/);
return { used: parts[2] || '0G', total: parts[1] || '0G' };
}
}

View File

@@ -0,0 +1,303 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
import { API_URL } from '@/lib/config';
// ─── Styled ───
const Header = styled.div`
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-md);
margin-bottom: var(--space-md);
`;
const HeaderMeta = styled.div`
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
`;
const LogControls = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-lg);
margin-bottom: var(--space-md);
flex-wrap: wrap;
`;
const FilterGroup = styled.div`
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
`;
const SearchBox = styled.div`
position: relative;
flex-grow: 1;
max-width: 300px;
`;
const SearchInput = styled.input`
width: 100%;
background: #1a1a1a;
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 12px;
outline: none;
&:focus { border-color: var(--border-hover); }
&::placeholder { color: var(--text-secondary); }
`;
const LogContainer = styled.div`
border: 1px solid var(--border-color);
background: #0d0d0d;
font-family: var(--font-mono);
overflow-x: auto;
`;
const LogTable = styled.table`
width: 100%;
border-collapse: collapse;
`;
const Th = styled.th`
text-align: left;
padding: 10px 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
white-space: nowrap;
`;
const Tr = styled.tr`
&:hover td { background: #1a1a1a; }
border-bottom: 1px solid #1a1a1a;
`;
const Td = styled.td`
padding: 10px 12px;
font-size: 12px;
color: var(--text-secondary);
vertical-align: top;
`;
const TdTime = styled(Td)`
white-space: nowrap;
font-size: 11px;
`;
const TdEvent = styled(Td)`
color: var(--text-primary);
`;
const Tag = styled.span<{ $type: string }>`
display: inline-block;
padding: 2px 6px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
background: #1a1a1a;
border: 1px solid;
white-space: nowrap;
${({ $type }) => {
switch ($type) {
case 'SECURITY': return 'color: #ff5f5f; border-color: #ff5f5f44;';
case 'ORG': return 'color: #5fafff; border-color: #5fafff44;';
case 'BACKUP': return 'color: #5fff8a; border-color: #5fff8a44;';
default: return 'color: #888; border-color: #44444444;';
}
}}
`;
const BracketLabel = styled.span`
color: var(--text-primary);
font-weight: 600;
`;
const Pagination = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
border-top: 1px solid var(--border-color);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
`;
const PaginationBtns = styled.div`
display: flex;
gap: var(--space-sm);
`;
const PageBtn = styled.button<{ $active?: boolean }>`
background: ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
border: 1px solid var(--border-color);
color: ${({ $active }) => $active ? '#000' : 'var(--text-secondary)'};
padding: 4px 10px;
font-size: 11px;
cursor: pointer;
font-family: var(--font-mono);
transition: all 0.15s;
&:hover {
border-color: var(--border-hover);
color: ${({ $active }) => $active ? '#000' : 'var(--text-primary)'};
}
`;
// ─── Tag helpers ───
function getTagType(action: string): string {
if (/security|auth|key|guard|block/i.test(action)) return 'SECURITY';
if (/org|sister|node|role/i.test(action)) return 'ORG';
if (/backup|snapshot|save/i.test(action)) return 'BACKUP';
return 'SYSTEM';
}
function formatDetail(detail: string | null, action: string): React.ReactNode {
if (!detail) return action;
// bracket [텍스트] 하이라이트
const parts = detail.split(/(\[.*?\])/g);
return parts.map((part, i) =>
part.startsWith('[') ? <BracketLabel key={i}>{part}</BracketLabel> : part,
);
}
const PAGE_SIZE = 20;
const ALL_TYPES = ['ALL', 'SYSTEM', 'SECURITY', 'ORG', 'BACKUP'];
export default function ActivitiesPage() {
const [items, setItems] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [filter, setFilter] = useState('ALL');
const [search, setSearch] = useState('');
useEffect(() => {
const load = async () => {
try {
const res = await fetch(`${API_URL}/api/activity?limit=200&offset=0`);
if (res.ok) {
const d = await res.json();
setItems(d.items ?? []);
setTotal(d.total ?? 0);
}
} catch { /* silent */ }
};
load();
const iv = setInterval(load, 30000);
return () => clearInterval(iv);
}, []);
const filtered = items.filter((item) => {
const tagType = getTagType(item.action);
if (filter !== 'ALL' && tagType !== filter) return false;
if (search) {
const q = search.toLowerCase();
const text = `${item.action} ${item.detail ?? ''} ${item.sister?.name ?? ''}`.toLowerCase();
if (!text.includes(q)) return false;
}
return true;
});
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paged = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const runtimeStr = (() => {
const h = Math.floor(total * 0.5);
return `SYS_LOG_V.4.2 // ENTRIES: ${total}`;
})();
return (
<>
<Header>
<PageTitle>
<span style={{ color: 'var(--text-secondary)', fontSize: '18px' }}>/ ACTIVITY LOG</span>
</PageTitle>
<HeaderMeta>{runtimeStr}</HeaderMeta>
</Header>
<LogControls>
<FilterGroup>
{ALL_TYPES.map((t) => (
<BtnToggle key={t} $active={filter === t} onClick={() => { setFilter(t); setPage(0); }}>
{t}
</BtnToggle>
))}
</FilterGroup>
<SearchBox>
<SearchInput
placeholder="SEARCH LOGS..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
</SearchBox>
</LogControls>
<LogContainer>
<LogTable>
<thead>
<tr>
<Th>TIME</Th>
<Th>TYPE</Th>
<Th>EVENT</Th>
<Th>CODE</Th>
</tr>
</thead>
<tbody>
{paged.length === 0 ? (
<Tr>
<Td colSpan={4} style={{ textAlign: 'center', padding: 'var(--space-xl)', color: 'var(--text-secondary)' }}>
</Td>
</Tr>
) : (
paged.map((item) => {
const tagType = getTagType(item.action);
const code = `0x${item.id.toString(16).toUpperCase().padStart(3, '0')}`;
return (
<Tr key={item.id}>
<TdTime>
{new Date(item.createdAt).toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
})}
</TdTime>
<Td><Tag $type={tagType}>{tagType}</Tag></Td>
<TdEvent>{formatDetail(item.detail, item.action)}</TdEvent>
<Td style={{ fontFamily: 'var(--font-mono)', fontSize: '11px' }}>{code}</Td>
</Tr>
);
})
)}
</tbody>
</LogTable>
<Pagination>
<span>SHOWING {paged.length} / {filtered.length} ENTRIES</span>
<PaginationBtns>
<PageBtn onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}>PREV</PageBtn>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => (
<PageBtn key={i} $active={page === i} onClick={() => setPage(i)}>{i + 1}</PageBtn>
))}
{totalPages > 5 && <span>...</span>}
<PageBtn onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}>NEXT</PageBtn>
</PaginationBtns>
</Pagination>
</LogContainer>
</>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import BarChart from '@/components/admin/BarChart';
import { adminFetch } from '@/lib/adminFetch';
type Period = 'day' | 'week' | 'month';
interface CostData {
period: string;
since: string;
summary: {
totalTokens: number;
inputTokens: number;
outputTokens: number;
estimatedUsd: number;
recordCount: number;
};
bySister: Array<{ name: string; totalTokens: number; estimatedUsd: number }>;
byModel: Array<{ model: string; totalTokens: number; estimatedUsd: number; count: number }>;
timeline: Array<{ date: string; totalTokens: number; estimatedUsd: number }>;
}
const Controls = styled.div`
display: flex;
gap: 8px;
margin-bottom: 24px;
`;
const PeriodBtn = styled.button<{ $active: boolean }>`
padding: 6px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: ${({ $active }) => $active ? '600' : '400'};
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.15)' : 'transparent'};
border: 1px solid ${({ $active }) => $active ? 'rgba(88,166,255,0.4)' : 'var(--border-color)'};
color: ${({ $active }) => $active ? '#58A6FF' : 'var(--text-secondary)'};
cursor: pointer;
transition: all 0.15s;
&:hover { color: #58A6FF; border-color: rgba(88,166,255,0.3); }
`;
const SummaryGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
margin-bottom: 28px;
`;
const SummaryCard = styled.div`
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 16px 18px;
`;
const SummaryLabel = styled.div`
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
`;
const SummaryValue = styled.div`
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
`;
const SummaryUnit = styled.span`
font-size: 12px;
color: var(--text-secondary);
margin-left: 4px;
`;
const ChartsGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 28px;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
`;
const Card = styled.div`
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px 22px;
`;
const RecordBtn = styled.button`
padding: 8px 16px;
border-radius: 7px;
font-size: 12px;
font-weight: 600;
background: rgba(88,166,255,0.08);
border: 1px solid rgba(88,166,255,0.25);
color: #58A6FF;
cursor: pointer;
transition: background 0.15s;
&:hover { background: rgba(88,166,255,0.18); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
`;
const EmptyNote = styled.div`
padding: 32px;
text-align: center;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
`;
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
export default function CostsPage() {
const [period, setPeriod] = useState<Period>('week');
const [data, setData] = useState<CostData | null>(null);
const [loading, setLoading] = useState(true);
const [recording, setRecording] = useState(false);
const load = async (p: Period) => {
setLoading(true);
try {
const res = await adminFetch(`/api/admin/costs?period=${p}`);
if (res.ok) setData(await res.json());
} catch {
// silent
} finally {
setLoading(false);
}
};
useEffect(() => { load(period); }, [period]);
const recordAll = async () => {
setRecording(true);
try {
await Promise.all(['harang', 'narang', 'darang', 'erang'].map((name) =>
adminFetch(`/api/admin/costs/record/${name}`, { method: 'POST' }),
));
await load(period);
} finally {
setRecording(false);
}
};
const sisterChartData = data?.bySister.map((s) => ({
label: { harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이' }[s.name] ?? s.name,
value: s.estimatedUsd,
})) ?? [];
const modelChartData = data?.byModel.map((m) => ({
label: m.model.split('/').pop() ?? m.model,
value: m.totalTokens,
})) ?? [];
const timelineChartData = data?.timeline.map((t) => ({
label: t.date.slice(5),
value: t.estimatedUsd,
})) ?? [];
return (
<>
<Controls>
{(['day', 'week', 'month'] as Period[]).map((p) => (
<PeriodBtn key={p} $active={period === p} onClick={() => setPeriod(p)}>
{p === 'day' ? '오늘' : p === 'week' ? '7일' : '30일'}
</PeriodBtn>
))}
<RecordBtn onClick={recordAll} disabled={recording} style={{ marginLeft: 'auto' }}>
{recording ? '수집 중...' : '📊 토큰 수집'}
</RecordBtn>
</Controls>
{loading ? (
<div style={{ color: 'var(--text-secondary)', fontSize: '14px' }}> ...</div>
) : !data || data.summary.recordCount === 0 ? (
<EmptyNote>
.<br />
"토큰 수집" .
</EmptyNote>
) : (
<>
<SummaryGrid>
<SummaryCard>
<SummaryLabel> </SummaryLabel>
<SummaryValue>{formatTokens(data.summary.totalTokens)}<SummaryUnit>tokens</SummaryUnit></SummaryValue>
</SummaryCard>
<SummaryCard>
<SummaryLabel> </SummaryLabel>
<SummaryValue>${data.summary.estimatedUsd.toFixed(4)}<SummaryUnit>USD</SummaryUnit></SummaryValue>
</SummaryCard>
<SummaryCard>
<SummaryLabel> </SummaryLabel>
<SummaryValue>{formatTokens(data.summary.inputTokens)}</SummaryValue>
</SummaryCard>
<SummaryCard>
<SummaryLabel> </SummaryLabel>
<SummaryValue>{formatTokens(data.summary.outputTokens)}</SummaryValue>
</SummaryCard>
</SummaryGrid>
<ChartsGrid>
<Card>
<BarChart data={sisterChartData} unit="$" title="자매별 비용 (USD)" />
</Card>
<Card>
<BarChart data={modelChartData} unit=" tok" title="모델별 토큰 사용량" />
</Card>
</ChartsGrid>
{timelineChartData.length > 0 && (
<Card>
<BarChart data={timelineChartData} unit="$" title="일별 비용 추이" />
</Card>
)}
</>
)}
</>
);
}

View File

@@ -0,0 +1,209 @@
'use client';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import CodeEditor from '@/components/admin/CodeEditor';
import { adminFetch } from '@/lib/adminFetch';
const SISTERS = ['harang', 'narang', 'darang', 'erang'];
const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
const DISPLAY_NAMES: Record<string, string> = {
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
};
const Layout = styled.div`
display: grid;
grid-template-columns: 180px 1fr;
gap: 20px;
`;
const FileTree = styled.div`
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 10px;
overflow: hidden;
`;
const TreeSection = styled.div`
border-bottom: 1px solid var(--border-color);
padding: 6px;
&:last-child { border-bottom: none; }
`;
const TreeLabel = styled.div`
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 6px 8px 4px;
`;
const TreeItem = styled.button<{ $active: boolean }>`
width: 100%;
text-align: left;
padding: 6px 10px;
font-size: 12px;
border: none;
border-radius: 5px;
cursor: pointer;
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
color: ${({ $active }) => $active ? '#58A6FF' : 'var(--text-secondary)'};
transition: background 0.15s;
&:hover { background: rgba(240,246,252,0.06); color: var(--text-primary); }
`;
const EditorPane = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const Toolbar = styled.div`
display: flex;
align-items: center;
gap: 10px;
`;
const PathLabel = styled.span`
font-size: 13px;
color: var(--text-secondary);
font-family: monospace;
flex: 1;
`;
const Btn = styled.button<{ $primary?: boolean; $danger?: boolean }>`
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid;
transition: all 0.15s;
disabled: ${({ disabled }) => disabled ? 'not-allowed' : 'pointer'};
${({ $primary }) =>
$primary &&
`background: rgba(88,166,255,0.12); border-color: rgba(88,166,255,0.4); color: #58A6FF;
&:hover { background: rgba(88,166,255,0.22); }`}
${({ $danger }) =>
$danger &&
`background: transparent; border-color: rgba(240,246,252,0.15); color: #8B949E;
&:hover { color: #E6EDF3; }`}
${({ $primary, $danger }) =>
!$primary && !$danger &&
`background: transparent; border-color: rgba(240,246,252,0.15); color: #8B949E;
&:hover { color: #E6EDF3; }`}
`;
const ResultMsg = styled.div<{ $success: boolean }>`
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-family: monospace;
background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};
color: ${({ $success }) => $success ? '#00FF00' : '#FF1744'};
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
`;
export default function HarnessPage() {
const [selected, setSelected] = useState({ sister: 'narang', file: 'SOUL.md' });
const [content, setContent] = useState('');
const [saved, setSaved] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [result, setResult] = useState<{ success: boolean; msg: string } | null>(null);
useEffect(() => {
setContent('');
setSaved('');
setResult(null);
setLoading(true);
adminFetch(`/api/admin/harness/${selected.sister}/${selected.file}`)
.then((r) => r.json())
.then((d) => { setContent(d.content ?? ''); setSaved(d.content ?? ''); })
.catch(() => setContent('(로드 실패)'))
.finally(() => setLoading(false));
}, [selected.sister, selected.file]);
const handleSave = async () => {
setSaving(true);
setResult(null);
try {
const res = await adminFetch(`/api/admin/harness/${selected.sister}/${selected.file}`, {
method: 'PUT',
body: JSON.stringify({ content }),
});
if (res.status === 401) {
setResult({ success: false, msg: '인증 실패 — API Key 또는 JWT 토큰을 확인해' });
return;
}
if (!res.ok) {
const text = await res.text();
setResult({ success: false, msg: `서버 오류 ${res.status}: ${text.slice(0, 100)}` });
return;
}
const d = await res.json();
if (d.success) {
setSaved(content);
setResult({ success: true, msg: '저장 완료' });
} else {
setResult({ success: false, msg: d.error ?? '저장 실패' });
}
} catch (e) {
setResult({ success: false, msg: (e as Error).message });
} finally {
setSaving(false);
}
};
const isDirty = content !== saved;
return (
<Layout>
<FileTree>
{SISTERS.map((s) => (
<TreeSection key={s}>
<TreeLabel>{DISPLAY_NAMES[s] ?? s}</TreeLabel>
{FILES.map((f) => (
<TreeItem
key={f}
$active={selected.sister === s && selected.file === f}
onClick={() => setSelected({ sister: s, file: f })}
>
{f}
</TreeItem>
))}
</TreeSection>
))}
</FileTree>
<EditorPane>
<Toolbar>
<PathLabel>
~/.openclaw/workspace/{selected.file} ({DISPLAY_NAMES[selected.sister]})
</PathLabel>
{isDirty && (
<span style={{ fontSize: '11px', color: '#FF9800' }}> </span>
)}
<Btn onClick={() => { setContent(saved); setResult(null); }} $danger></Btn>
<Btn $primary onClick={handleSave} disabled={!isDirty || saving}>
{saving ? '저장 중...' : '💾 저장'}
</Btn>
</Toolbar>
{result && <ResultMsg $success={result.success}>{result.msg}</ResultMsg>}
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '13px' }}>
...
</div>
) : (
<CodeEditor value={content} onChange={setContent} />
)}
</EditorPane>
</Layout>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const ADMIN_TABS = [
{ href: '/admin', label: '자매 관리' },
{ href: '/admin/logs', label: '로그 뷰어' },
{ href: '/admin/costs', label: '비용 모니터' },
{ href: '/admin/repos', label: '저장소' },
];
const Wrapper = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-lg);
`;
const AdminHeader = styled.div`
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-md);
`;
const TabNav = styled.div`
display: flex;
gap: var(--space-lg);
flex-wrap: wrap;
`;
const TabLink = styled(Link)<{ $active: boolean }>`
font-size: 13px;
font-weight: 500;
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
text-decoration: none;
padding: var(--space-sm) 0;
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
transition: color 0.15s, border-color 0.15s;
&:hover { color: var(--text-primary); }
`;
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<Wrapper>
<AdminHeader>
<div style={{ fontSize: '28px', fontWeight: 600, letterSpacing: '-0.02em', marginBottom: 'var(--space-md)' }}>
</div>
<TabNav>
{ADMIN_TABS.map((tab) => (
<TabLink key={tab.href} href={tab.href} $active={pathname === tab.href}>
{tab.label}
</TabLink>
))}
</TabNav>
</AdminHeader>
{children}
</Wrapper>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import React, { useState, useCallback, useEffect } from 'react';
import styled from 'styled-components';
import LogTerminal from '@/components/admin/LogTerminal';
import { adminFetch } from '@/lib/adminFetch';
const SISTERS = [
{ value: 'harang', label: '하랑이' },
{ value: 'narang', label: '나랑이' },
{ value: 'darang', label: '다랑이' },
{ value: 'erang', label: '이랑이' },
];
const Controls = styled.div`
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 16px;
`;
const Select = styled.select`
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 7px;
color: var(--text-primary);
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
outline: none;
&:focus { border-color: #58A6FF; }
option { background: #1a1f2a; }
`;
const LinesInput = styled.input`
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 7px;
color: var(--text-primary);
padding: 8px 12px;
font-size: 13px;
width: 80px;
outline: none;
&:focus { border-color: #58A6FF; }
`;
const FetchBtn = styled.button`
padding: 8px 18px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
background: rgba(88,166,255,0.1);
border: 1px solid rgba(88,166,255,0.35);
color: #58A6FF;
cursor: pointer;
transition: background 0.15s;
&:hover { background: rgba(88,166,255,0.2); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
`;
const Label = styled.span`
font-size: 12px;
color: var(--text-secondary);
`;
export default function LogsPage() {
const [sister, setSister] = useState('narang');
const [lines, setLines] = useState(100);
const [logLines, setLogLines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const res = await adminFetch(`/api/admin/logs/${sister}?lines=${lines}`);
const d = await res.json();
setLogLines(d.lines ?? []);
} catch {
setLogLines(['(로그 로드 실패)']);
} finally {
setLoading(false);
}
}, [sister, lines]);
// 마운트 시 자동 로드
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
return (
<>
<Controls>
<Select value={sister} onChange={(e) => setSister(e.target.value)}>
{SISTERS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</Select>
<Label></Label>
<LinesInput
type="number"
value={lines}
onChange={(e) => setLines(parseInt(e.target.value, 10) || 100)}
min={10}
max={500}
/>
<Label></Label>
<FetchBtn onClick={fetchLogs} disabled={loading}>
{loading ? '로딩 중...' : '📋 로그 가져오기'}
</FetchBtn>
</Controls>
<LogTerminal lines={logLines} />
</>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import StatusBadge from '@/components/common/StatusBadge';
import ConfirmModal from '@/components/admin/ConfirmModal';
import { API_URL } from '@/lib/config';
import { adminFetch } from '@/lib/adminFetch';
import { SISTER_DISPLAY_NAMES } from '@/lib/sisters';
import SisterAvatar from '@/components/common/SisterAvatar';
type Status = 'online' | 'offline' | 'working' | 'unknown';
interface ActionResult {
success: boolean;
output: string;
error: string | null;
}
const Table = styled.div`display:flex;flex-direction:column;gap:8px;user-select:none;`;
const Row = styled.div`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:10px;padding:16px 20px;display:flex;align-items:center;gap:16px;`;
const SisterInfo = styled.div`display:flex;align-items:center;gap:10px;flex:1;`;
const Name = styled.span`font-size:15px;font-weight:600;color:var(--text-primary);min-width:70px;`;
const Actions = styled.div`display:flex;gap:8px;`;
const ActionBtn = styled.button<{ $danger?: boolean }>`padding:6px 14px;border-radius:7px;font-size:13px;font-weight:600;cursor:pointer;border:1px solid;transition:all .15s;user-select:none;${({ $danger }) => $danger ? `background: rgba(255,23,68,0.08); border-color: rgba(255,23,68,0.3); color: #FF1744; &:hover { background: rgba(255,23,68,0.18); }` : `background: rgba(88,166,255,0.08); border-color: rgba(88,166,255,0.3); color: #58A6FF; &:hover { background: rgba(88,166,255,0.18); }`}`;
const ResultBanner = styled.div<{ $success: boolean }>`margin-top:8px;padding:8px 12px;background:${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};border:1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};border-radius:6px;font-size:12px;color:${({ $success }) => $success ? '#00FF00' : '#FF1744'};font-family:monospace;white-space:pre-wrap;`;
export default function AdminSistersPage() {
const [sisters, setSisters] = useState<any[]>([]);
const [modal, setModal] = useState<{ action: 'restart' | 'reset'; name: string } | null>(null);
const [results, setResults] = useState<Record<string, ActionResult>>({});
const [loading, setLoading] = useState<Record<string, boolean>>({});
useEffect(() => {
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
const iv = setInterval(() => { fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {}); }, 15000);
return () => clearInterval(iv);
}, []);
const doAction = async (action: 'restart' | 'reset', name: string) => {
setModal(null);
setLoading((p) => ({ ...p, [`${action}:${name}`]: true }));
try {
const res = await adminFetch(`/api/admin/sisters/${name}/${action}`, { method: 'POST' });
const data: ActionResult = await res.json();
setResults((p) => ({ ...p, [`${action}:${name}`]: data }));
} catch (e) {
setResults((p) => ({ ...p, [`${action}:${name}`]: { success: false, output: '', error: (e as Error).message } }));
} finally {
setLoading((p) => ({ ...p, [`${action}:${name}`]: false }));
}
};
return (
<>
<Table>
{sisters.map((s) => {
const restartResult = results[`restart:${s.name}`];
const resetResult = results[`reset:${s.name}`];
return (
<div key={s.id}>
<Row>
<SisterInfo>
<SisterAvatar name={s.name} size={28} />
<Name>{SISTER_DISPLAY_NAMES[s.name] ?? s.name}</Name>
<StatusBadge status={s.status as Status} />
</SisterInfo>
<Actions>
<ActionBtn onClick={() => setModal({ action: 'restart', name: s.name })} disabled={loading[`restart:${s.name}`]}>{loading[`restart:${s.name}`] ? '...' : '재시작'}</ActionBtn>
<ActionBtn $danger onClick={() => setModal({ action: 'reset', name: s.name })} disabled={loading[`reset:${s.name}`]}>{loading[`reset:${s.name}`] ? '...' : '리셋'}</ActionBtn>
</Actions>
</Row>
{restartResult && <ResultBanner $success={restartResult.success}>: {restartResult.success ? '✅ 성공' : `${restartResult.error}`}{restartResult.output && `\n${restartResult.output}`}</ResultBanner>}
{resetResult && <ResultBanner $success={resetResult.success}>: {resetResult.success ? '✅ 성공' : `${resetResult.error}`}</ResultBanner>}
</div>
);
})}
</Table>
{modal && <ConfirmModal title={modal.action === 'restart' ? '게이트웨이 재시작' : '세션 리셋'} message={`${SISTER_DISPLAY_NAMES[modal.name] ?? modal.name}${modal.action === 'restart' ? 'OpenClaw Gateway를 재시작' : '메인 세션을 초기화'}하시겠어요?`} confirmLabel={modal.action === 'restart' ? '재시작' : '리셋'} danger={modal.action === 'reset'} onConfirm={() => doAction(modal.action, modal.name)} onCancel={() => setModal(null)} />}
</>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { API_URL } from '@/lib/config';
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
`;
const Card = styled.a`
display: block;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 18px 20px;
text-decoration: none;
transition: transform 0.15s, border-color 0.15s;
&:hover {
transform: translateY(-2px);
border-color: rgba(88,166,255,0.3);
}
`;
const RepoName = styled.div`
font-size: 14px;
font-weight: 700;
color: #58A6FF;
margin-bottom: 6px;
`;
const RepoDesc = styled.div`
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const Stats = styled.div`
display: flex;
gap: 14px;
`;
const Stat = styled.span`
font-size: 12px;
color: var(--text-secondary);
`;
const PRBadge = styled.span<{ $count: number }>`
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'};
color: ${({ $count }) => $count > 0 ? '#58A6FF' : 'var(--text-secondary)'};
`;
const NoProjects = styled.div`
padding: 32px;
text-align: center;
color: var(--text-secondary);
font-size: 13px;
`;
export default function ReposPage() {
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_URL}/api/projects`)
.then((r) => r.json())
.then(setProjects)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <div style={{ color: 'var(--text-secondary)', fontSize: '14px' }}> ...</div>;
if (!projects.length) return <NoProjects> </NoProjects>;
return (
<Grid>
{projects.map((p) => (
<Card key={p.id} href={p.repoUrl} target="_blank" rel="noopener">
<RepoName>📁 {p.name}</RepoName>
{p.description && <RepoDesc>{p.description}</RepoDesc>}
<Stats>
<PRBadge $count={p.openPRs}>PR {p.openPRs}</PRBadge>
<Stat>Sprint {p.sprintCount}</Stat>
<Stat> {p.progress}%</Stat>
</Stats>
</Card>
))}
</Grid>
);
}

Some files were not shown because too many files have changed in this diff Show More