Compare commits

..

49 Commits

Author SHA1 Message Date
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
65 changed files with 2809 additions and 1544 deletions

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,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,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,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,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

@@ -102,3 +102,10 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model SystemSettings {
id Int @id @default(autoincrement())
key String @unique
value String @db.Text
updatedAt DateTime @updatedAt
}

View File

@@ -9,6 +9,23 @@ export interface LogActivityDto {
detail?: string; 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() @Injectable()
export class ActivityService { export class ActivityService {
constructor( constructor(
@@ -18,14 +35,16 @@ export class ActivityService {
async log(dto: LogActivityDto) { async log(dto: LogActivityDto) {
const record = await this.prisma.activityLog.create({ const record = await this.prisma.activityLog.create({
data: dto, data: {
...dto,
detail: sanitizeActivityDetail(dto.detail),
},
include: { include: {
sister: { select: { name: true } }, sister: { select: { name: true } },
project: { select: { name: true } }, project: { select: { name: true } },
}, },
}); });
// 실시간 브로드캐스트 (gateway 사용 가능한 경우)
if (this.events) { if (this.events) {
this.events.broadcastActivity(record); this.events.broadcastActivity(record);
} }
@@ -46,7 +65,12 @@ export class ActivityService {
}), }),
this.prisma.activityLog.count(), 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) { async getProjectFeed(projectId: number, limit = 30, offset = 0) {
@@ -62,6 +86,11 @@ export class ActivityService {
}), }),
this.prisma.activityLog.count({ where: { projectId } }), 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

@@ -9,7 +9,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { CompositeGuard } from '../auth/jwt.guard'; import { JwtGuard } from '../auth/jwt.guard';
import { RoleGuard, Roles } from '../auth/role.guard'; import { RoleGuard, Roles } from '../auth/role.guard';
import { SisterNamePipe } from '../common/sister-name.pipe'; import { SisterNamePipe } from '../common/sister-name.pipe';
import type { SisterName } from '../common/sister-name.pipe'; import type { SisterName } from '../common/sister-name.pipe';
@@ -24,7 +24,7 @@ class UpdateHarnessDto {
} }
@Controller('api/admin') @Controller('api/admin')
@UseGuards(CompositeGuard, RoleGuard) @UseGuards(JwtGuard, RoleGuard)
@Roles('admin') @Roles('admin')
export class AdminController { export class AdminController {
constructor(private readonly adminService: AdminService) {} constructor(private readonly adminService: AdminService) {}

View File

@@ -15,6 +15,8 @@ import { AuthModule } from './auth/auth.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { EventsModule } from './events/events.module'; import { EventsModule } from './events/events.module';
import { CostsModule } from './costs/costs.module'; import { CostsModule } from './costs/costs.module';
import { GiteaSyncModule } from './gitea-sync/gitea-sync.module';
import { SettingsModule } from './settings/settings.module';
@Module({ @Module({
imports: [ imports: [
@@ -32,6 +34,8 @@ import { CostsModule } from './costs/costs.module';
AdminModule, AdminModule,
EventsModule, EventsModule,
CostsModule, CostsModule,
GiteaSyncModule,
SettingsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@@ -1,5 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class ApiKeyGuard extends AuthGuard('api-key') {}

View File

@@ -1,30 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-http-bearer';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class ApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') {
private readonly validKeys: Set<string>;
constructor(config: ConfigService) {
super();
const raw = config.get<string>('ADMIN_API_KEYS') ?? '';
this.validKeys = new Set(
raw
.split(',')
.map((k) => k.trim())
.filter(Boolean),
);
}
validate(token: string): { role: string; username: string } {
if (!this.validKeys.size) {
throw new UnauthorizedException('ADMIN_API_KEYS not configured');
}
if (!this.validKeys.has(token)) {
throw new UnauthorizedException('Invalid API key');
}
return { role: "admin", username: "api-key" };
}
}

View File

@@ -8,7 +8,7 @@ import {
Request, Request,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthService, RegisterDto, LoginDto } from './auth.service'; import { AuthService, LoginDto } from './auth.service';
import { JwtGuard } from './jwt.guard'; import { JwtGuard } from './jwt.guard';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
@@ -16,13 +16,6 @@ import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 3, ttl: 60000 } })
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@UseGuards(ThrottlerGuard) @UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } }) @Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('login') @Post('login')

View File

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

View File

@@ -3,7 +3,7 @@ import { AuthService } from './auth.service';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { BadRequestException, ConflictException, UnauthorizedException } from '@nestjs/common'; import { UnauthorizedException } from '@nestjs/common';
describe('AuthService', () => { describe('AuthService', () => {
let service: AuthService; let service: AuthService;
@@ -23,7 +23,6 @@ describe('AuthService', () => {
const mockConfig = { const mockConfig = {
get: jest.fn().mockImplementation((key: string) => { get: jest.fn().mockImplementation((key: string) => {
if (key === 'JWT_SECRET') return 'test-secret'; if (key === 'JWT_SECRET') return 'test-secret';
if (key === 'INVITE_CODE') return 'VALID_CODE';
return undefined; return undefined;
}), }),
}; };
@@ -42,34 +41,6 @@ describe('AuthService', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
it('register: 잘못된 초대 코드 → BadRequestException', async () => {
await expect(
service.register({ username: 'test', password: 'password123', inviteCode: 'WRONG' }),
).rejects.toThrow(BadRequestException);
});
it('register: 중복 username → ConflictException', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ id: 1, username: 'test' });
await expect(
service.register({ username: 'test', password: 'password123', inviteCode: 'VALID_CODE' }),
).rejects.toThrow(ConflictException);
});
it('register: 성공 시 토큰 반환', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
mockPrisma.user.create.mockResolvedValue({ id: 1, username: 'newuser', role: 'admin' });
const result = await service.register({
username: 'newuser',
password: 'password123',
inviteCode: 'VALID_CODE',
});
expect(result).toHaveProperty('accessToken');
expect(result).toHaveProperty('refreshToken');
expect(result.username).toBe('newuser');
});
it('login: 존재하지 않는 사용자 → UnauthorizedException', async () => { it('login: 존재하지 않는 사용자 → UnauthorizedException', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null); mockPrisma.user.findUnique.mockResolvedValue(null);
await expect( await expect(

View File

@@ -1,20 +1,9 @@
import { Injectable, UnauthorizedException, BadRequestException, ConflictException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator'; import { IsString, IsNotEmpty } from 'class-validator';
export class RegisterDto {
@IsString() @IsNotEmpty() @MaxLength(32)
username!: string;
@IsString() @MinLength(8) @MaxLength(64)
password!: string;
@IsString() @IsNotEmpty()
inviteCode!: string;
}
export class LoginDto { export class LoginDto {
@IsString() @IsNotEmpty() @IsString() @IsNotEmpty()
@@ -32,22 +21,6 @@ export class AuthService {
private readonly config: ConfigService, private readonly config: ConfigService,
) {} ) {}
async register(dto: RegisterDto) {
const inviteCode = this.config.get<string>('INVITE_CODE');
if (!inviteCode) throw new BadRequestException('Invite code not configured');
if (dto.inviteCode !== inviteCode) throw new BadRequestException('Invalid invite code');
const existing = await this.prisma.user.findUnique({ where: { username: dto.username } });
if (existing) throw new ConflictException('Username already taken');
const hash = await bcrypt.hash(dto.password, 12);
const user = await this.prisma.user.create({
data: { username: dto.username, password: hash },
});
return this.signTokens(user.id, user.username, user.role);
}
async login(dto: LoginDto) { async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({ where: { username: dto.username } }); const user = await this.prisma.user.findUnique({ where: { username: dto.username } });
if (!user) throw new UnauthorizedException('Invalid credentials'); if (!user) throw new UnauthorizedException('Invalid credentials');
@@ -86,15 +59,8 @@ export class AuthService {
if (!secret) throw new Error('JWT_SECRET is not configured'); if (!secret) throw new Error('JWT_SECRET is not configured');
const payload = { sub: userId, username, role }; const payload = { sub: userId, username, role };
const accessToken = this.jwt.sign(payload, { const accessToken = this.jwt.sign(payload, { secret, expiresIn: '15m' });
secret, const refreshToken = this.jwt.sign(payload, { secret: secret + '_refresh', expiresIn: '7d' });
expiresIn: '15m',
});
const refreshToken = this.jwt.sign(payload, {
secret: secret + '_refresh',
expiresIn: '7d',
});
return { accessToken, refreshToken, username, role }; return { accessToken, refreshToken, username, role };
} }

View File

@@ -1,22 +1,9 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@Injectable() @Injectable()
export class JwtGuard extends AuthGuard('jwt') { export class JwtGuard extends AuthGuard('jwt') {
handleRequest<T>(err: Error, user: T): T { canActivate(context: ExecutionContext) {
if (err || !user) throw new UnauthorizedException('JWT authentication required'); return super.canActivate(context);
return user;
}
}
/**
* JWT 먼저 시도 → 실패 시 API Key fallback
* Sprint 008까지 하위호환 유지
*/
@Injectable()
export class CompositeGuard extends AuthGuard(['jwt', 'api-key']) {
handleRequest<T>(err: Error, user: T): T {
if (err || !user) throw new UnauthorizedException('Authentication required');
return user;
} }
} }

View File

@@ -1,12 +1,12 @@
import { Controller, Get, Post, Query, Param, UseGuards } from '@nestjs/common'; import { Controller, Get, Post, Query, Param, UseGuards } from '@nestjs/common';
import { CostsService, CostPeriod } from './costs.service'; import { CostsService, CostPeriod } from './costs.service';
import { CompositeGuard } from '../auth/jwt.guard'; import { JwtGuard } from '../auth/jwt.guard';
import { RoleGuard, Roles } from '../auth/role.guard'; import { RoleGuard, Roles } from '../auth/role.guard';
import { SisterNamePipe } from '../common/sister-name.pipe'; import { SisterNamePipe } from '../common/sister-name.pipe';
import type { SisterName } from '../common/sister-name.pipe'; import type { SisterName } from '../common/sister-name.pipe';
@Controller('api/admin/costs') @Controller('api/admin/costs')
@UseGuards(CompositeGuard, RoleGuard) @UseGuards(JwtGuard, RoleGuard)
@Roles('admin') @Roles('admin')
export class CostsController { export class CostsController {
constructor(private readonly costsService: CostsService) {} constructor(private readonly costsService: CostsService) {}

View File

@@ -86,6 +86,6 @@ export class EventsGateway
/** 연결된 클라이언트 수 */ /** 연결된 클라이언트 수 */
getClientCount(): number { getClientCount(): number {
return this.server.sockets.sockets.size; return this.server?.sockets?.sockets?.size ?? 0;
} }
} }

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; html_url: string;
user: { login: string }; user: { login: string };
created_at: 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() @Injectable()
@@ -87,4 +105,70 @@ export class GiteaService {
return []; 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,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 { 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') @Controller('api/projects')
export class ProjectsController { export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {} constructor(
private readonly projectsService: ProjectsService,
private readonly sprintSyncService: SprintSyncService,
) {}
@Get() @Get()
getProjects() { getProjects() {
@@ -14,4 +20,41 @@ export class ProjectsController {
getProjectById(@Param('id', ParseIntPipe) id: number) { getProjectById(@Param('id', ParseIntPipe) id: number) {
return this.projectsService.getProjectById(id); 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');
}
@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 { Module } from '@nestjs/common';
import { ProjectsController } from './projects.controller'; import { ProjectsController } from './projects.controller';
import { ProjectsService } from './projects.service'; import { ProjectsService } from './projects.service';
import { SprintSyncService } from './sprint-sync.service';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { GiteaModule } from '../gitea/gitea.module'; import { GiteaModule } from '../gitea/gitea.module';
import { ActivityModule } from '../activity/activity.module'; import { ActivityModule } from '../activity/activity.module';
import { AuthModule } from '../auth/auth.module';
@Module({ @Module({
imports: [PrismaModule, GiteaModule, ActivityModule], imports: [PrismaModule, GiteaModule, ActivityModule, AuthModule],
controllers: [ProjectsController], controllers: [ProjectsController],
providers: [ProjectsService], providers: [ProjectsService, SprintSyncService],
exports: [ProjectsService, SprintSyncService],
}) })
export class ProjectsModule {} export class ProjectsModule {}

View File

@@ -34,8 +34,13 @@ export class ProjectsService {
} }
return dbProjects.map((p) => { return dbProjects.map((p) => {
const totalTasks = p.sprints.flatMap((s) => s.tasks).length; // Sprint 기반 진행률 (백엔드 task 없어도 동작)
const doneTasks = p.sprints.flatMap((s) => s.tasks).filter((t) => t.status === 'done').length; 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]; const gitea = giteaRepos[p.giteaId];
return { return {
@@ -45,13 +50,14 @@ export class ProjectsService {
repoUrl: p.repoUrl, repoUrl: p.repoUrl,
description: p.description, description: p.description,
status: p.status, status: p.status,
progress: totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0, progress,
totalTasks, totalSprints,
doneTasks, doneSprints,
ownerSister,
openPRs: gitea?.openPRs ?? 0, openPRs: gitea?.openPRs ?? 0,
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(), updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
sprintCount: p.sprints.length, sprintCount: totalSprints,
currentSprint: p.sprints.find((s) => s.status === 'in_progress')?.name ?? null, currentSprint: inProgressSprint?.name ?? (doneSprints === totalSprints && totalSprints > 0 ? 'COMPLETED' : null),
}; };
}); });
} }
@@ -79,4 +85,28 @@ export class ProjectsService {
return { ...project, openPRs: giteaPRs }; 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);
}
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

@@ -1,14 +1,17 @@
import { Controller, Get, Param } from '@nestjs/common'; import { Controller, Get, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
import { SistersService } from './sisters.service'; import { SistersService } from './sisters.service';
import { SisterDetailService } from './sister-detail.service'; import { SisterDetailService } from './sister-detail.service';
import { SisterNamePipe } from '../common/sister-name.pipe'; import { SisterNamePipe } from '../common/sister-name.pipe';
import type { SisterName } from '../common/sister-name.pipe'; import type { SisterName } from '../common/sister-name.pipe';
import { AvatarService } from './avatar.service';
@Controller('api/sisters') @Controller('api/sisters')
export class SistersController { export class SistersController {
constructor( constructor(
private readonly sistersService: SistersService, private readonly sistersService: SistersService,
private readonly sisterDetail: SisterDetailService, private readonly sisterDetail: SisterDetailService,
private readonly avatarService: AvatarService,
) {} ) {}
@Get() @Get()
@@ -16,6 +19,27 @@ export class SistersController {
return this.sistersService.getAllSistersStatus(); 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') @Get(':name/config')
async getSisterConfig(@Param('name', SisterNamePipe) name: SisterName) { async getSisterConfig(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterConfig(name as SisterName); return this.sisterDetail.getSisterConfig(name as SisterName);

View File

@@ -4,11 +4,13 @@ import { SistersService } from './sisters.service';
import { SisterDetailService } from './sister-detail.service'; import { SisterDetailService } from './sister-detail.service';
import { SshService } from './ssh.service'; import { SshService } from './ssh.service';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { ConfigModule } from '@nestjs/config';
import { AvatarService } from './avatar.service';
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule, ConfigModule],
controllers: [SistersController], controllers: [SistersController],
providers: [SistersService, SisterDetailService, SshService], providers: [SistersService, SisterDetailService, SshService, AvatarService],
exports: [SistersService, SisterDetailService, SshService], exports: [SistersService, SisterDetailService, SshService, AvatarService],
}) })
export class SistersModule {} 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 { PrismaService } from '../prisma/prisma.service';
import { SshService } from './ssh.service'; import { SshService } from './ssh.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ActivityService } from '../activity/activity.service';
export interface SisterStatus { export interface SisterStatus {
id: number; id: number;
@@ -29,6 +30,7 @@ export class SistersService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly ssh: SshService, private readonly ssh: SshService,
private readonly config: ConfigService, private readonly config: ConfigService,
@Optional() private readonly activity?: ActivityService,
) {} ) {}
async getAllSistersStatus(): Promise<SisterStatus[]> { async getAllSistersStatus(): Promise<SisterStatus[]> {
@@ -46,7 +48,6 @@ export class SistersService {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
return result.value; return result.value;
} }
// graceful fallback
const sister = sisters[index]; const sister = sisters[index];
return { return {
id: sister.id, 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( 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, sshKeyPath: string,
): Promise<SisterStatus> { ): Promise<SisterStatus> {
try { try {
@@ -75,13 +101,20 @@ export class SistersService {
const isActive = result.stdout.trim() === 'active'; const isActive = result.stdout.trim() === 'active';
const status: 'online' | 'offline' = isActive ? 'online' : 'offline'; const status: 'online' | 'offline' = isActive ? 'online' : 'offline';
const prevStatus = sister.status;
const now = new Date();
if (isActive) {
// 온라인이면 lastSeen 업데이트
await this.prisma.sisterConfig.update({ await this.prisma.sisterConfig.update({
where: { id: sister.id }, where: { id: sister.id },
data: { lastSeen: new Date(), status }, 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 { return {
@@ -91,7 +124,7 @@ export class SistersService {
lxcId: sister.lxcId, lxcId: sister.lxcId,
role: SISTER_ROLES[sister.name] ?? 'Unknown', role: SISTER_ROLES[sister.name] ?? 'Unknown',
status, status,
lastSeen: isActive ? new Date() : sister.lastSeen, lastSeen: isActive ? now : sister.lastSeen,
currentTask: null, currentTask: null,
}; };
} catch { } 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

@@ -1,14 +1,12 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { LabelMeta, SectionTitle } from '@/components/ui/base';
const ADMIN_TABS = [ const ADMIN_TABS = [
{ href: '/admin', label: '자매 관리' }, { href: '/admin', label: '자매 관리' },
{ href: '/admin/harness', label: '하네스 편집' },
{ href: '/admin/logs', label: '로그 뷰어' }, { href: '/admin/logs', label: '로그 뷰어' },
{ href: '/admin/costs', label: '비용 모니터' }, { href: '/admin/costs', label: '비용 모니터' },
{ href: '/admin/repos', label: '저장소' }, { href: '/admin/repos', label: '저장소' },
@@ -39,46 +37,11 @@ const TabLink = styled(Link)<{ $active: boolean }>`
padding: var(--space-sm) 0; padding: var(--space-sm) 0;
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'}; border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
transition: color 0.15s, border-color 0.15s; transition: color 0.15s, border-color 0.15s;
&:hover { color: var(--text-primary); } &:hover { color: var(--text-primary); }
`; `;
const ApiKeyRow = styled.div`
display: flex;
align-items: center;
gap: var(--space-sm);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
`;
const ApiKeyInput = styled.input`
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
outline: none;
flex: 1;
max-width: 300px;
padding: 2px 0;
&:focus { border-bottom-color: var(--text-primary); }
`;
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const [apiKey, setApiKey] = useState('');
useEffect(() => {
setApiKey(localStorage.getItem('hanarang_admin_key') ?? '');
}, []);
const saveKey = (v: string) => {
setApiKey(v);
localStorage.setItem('hanarang_admin_key', v);
};
return ( return (
<Wrapper> <Wrapper>
@@ -94,18 +57,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
))} ))}
</TabNav> </TabNav>
</AdminHeader> </AdminHeader>
<ApiKeyRow>
<span>KEY [</span>
<ApiKeyInput
type="password"
placeholder="ADMIN_API_KEY"
value={apiKey}
onChange={(e) => saveKey(e.target.value)}
/>
<span>]</span>
</ApiKeyRow>
{children} {children}
</Wrapper> </Wrapper>
); );

View File

@@ -2,7 +2,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link';
import { useAuth } from '@/lib/AuthContext'; import { useAuth } from '@/lib/AuthContext';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -111,16 +110,6 @@ const ErrorMsg = styled.div`
background: rgba(255,95,95,0.05); background: rgba(255,95,95,0.05);
`; `;
const FooterLink = styled.div`
margin-top: var(--space-lg);
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
text-align: center;
a { color: var(--text-primary); text-decoration: none;
&:hover { text-decoration: underline; } }
`;
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -180,9 +169,6 @@ export default function LoginPage() {
</SubmitBtn> </SubmitBtn>
</Form> </Form>
<FooterLink>
? <Link href="/register">REGISTER</Link>
</FooterLink>
</Box> </Box>
</Page> </Page>
); );

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import styled from 'styled-components'; import styled, { keyframes } from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { import {
PageHeader, PageTitle, Card, BracketValue, LabelMeta, PageHeader, PageTitle, Card, BracketValue, LabelMeta,
@@ -10,249 +10,108 @@ import {
} from '@/components/ui/base'; } from '@/components/ui/base';
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config'; import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
import { useSocket } from '@/lib/useSocket'; import { useSocket } from '@/lib/useSocket';
import SisterAvatar from '@/components/common/SisterAvatar';
// ─── Styled ─── const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
const StatusGrid = styled.section` const StatusGrid = styled.section`display:grid;grid-template-columns:repeat(4,1fr);gap:var(--space-lg);@media (min-width:768px) and (max-width:1199px){grid-template-columns:repeat(2,1fr)}@media (max-width:767px){grid-template-columns:repeat(2,1fr);gap:var(--space-md)}`;
display: grid; const DataColumns = styled.div`display:grid;grid-template-columns:1.5fr 1fr;gap:var(--space-xxl);align-items:start;@media (max-width:1199px){grid-template-columns:1fr;gap:var(--space-xl)}}`;
grid-template-columns: repeat(4, 1fr); const ChartGrid = styled.section`display:grid;grid-template-columns:1fr 1fr;gap:var(--space-xl);margin-bottom:var(--space-xxl);@media (max-width:1199px){grid-template-columns:1fr;}`;
gap: var(--space-lg); const ChartCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);`;
const ProjectList = styled.div`display:flex;flex-direction:column;`;
@media (min-width: 768px) and (max-width: 1199px) { const ProjectRow = styled(Link)`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;text-decoration:none;transition:opacity .15s;&:last-child{border-bottom:none}&:hover{opacity:.8}`;
grid-template-columns: repeat(2, 1fr); const ProjectDetails = styled.div`display:flex;flex-direction:column;gap:2px;min-width:0;`;
} const ProjectName = styled.div`font-size:15px;font-weight:500;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const ProjectDesc = styled.div`font-size:13px;color:var(--text-secondary);line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
@media (max-width: 767px) { const WsIndicator = styled.span<{ $on:boolean }>`font-size:11px;font-family:var(--font-mono);color:${({$on})=>$on?'var(--text-secondary)':'#333'};margin-left:var(--space-md);`;
grid-template-columns: repeat(2, 1fr); const SkeletonCard = styled.div`height:142px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`;
gap: var(--space-md); const SkeletonLine = styled.div`height:14px;background:#111;animation:${pulse} 1.4s ease-in-out infinite;margin:10px 0;`;
} const ErrorBox = styled.div`padding:var(--space-md);border:1px solid #5a2a2a;color:#ff9b9b;font-size:13px;margin-bottom:var(--space-lg);`;
`; const BarChart = styled.div`display:flex;align-items:flex-end;gap:10px;height:160px;margin-top:var(--space-lg);`;
const BarCol = styled.div`display:flex;flex-direction:column;align-items:center;gap:8px;flex:1;`;
const DataColumns = styled.div` const Bar = styled.div<{ $height:number }>`width:100%;max-width:48px;height:${({$height})=>$height}%;background:var(--text-primary);min-height:${({$height})=>$height > 0 ? '6px' : '0'};`;
display: grid; const BarLabel = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`;
grid-template-columns: 1.5fr 1fr; const SISTER_SHORT: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
gap: var(--space-xxl); function sisterBracketValue(s: any): string { if (s.status === 'online') return '[ON]'; if (s.status === 'working') return '[RUN]'; if (s.status === 'offline') return '[--]'; return '[??]'; }
align-items: start; function sisterBarWidth(s: any): number { if (typeof s.cpu === 'number') return Math.max(0, Math.min(100, s.cpu)); if (s.status === 'online') return 100; if (s.status === 'working') return 84; if (s.status === 'offline') return 0; return 12; }
function sisterSubLabel(s: any): string { if (s.uptime) return `Uptime ${s.uptime}`; return s.status; }
@media (max-width: 1199px) { function formatTimestamp(iso: string): string { const d = new Date(iso); return d.toLocaleTimeString('ko-KR', { hour12: false }) + ' GMT+9'; }
grid-template-columns: 1fr;
gap: var(--space-xl);
}
`;
const ProjectList = styled.div`
display: flex;
flex-direction: column;
`;
const ProjectRow = styled(Link)`
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;
text-decoration: none;
transition: opacity 0.15s;
&:last-child { border-bottom: none; }
&:hover { opacity: 0.8; }
`;
const Avatar = styled.div`
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #333;
overflow: hidden;
filter: grayscale(100%) contrast(120%);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
`;
const ProjectDetails = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
`;
const ProjectName = styled.div`
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const ProjectDesc = styled.div`
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const WsIndicator = styled.span<{ $on: boolean }>`
font-size: 11px;
font-family: var(--font-mono);
color: ${({ $on }) => $on ? 'var(--text-secondary)' : '#333'};
margin-left: var(--space-md);
`;
// ─── Helpers ───
const SISTER_SHORT: Record<string, string> = {
harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑',
};
const SISTER_SUB_LABEL: Record<string, string> = {
harang: 'Active Node',
narang: 'Capacity %',
darang: 'Standby',
erang: 'Sync Queue',
};
function sisterBracketValue(s: any): string {
if (s.status === 'online') return '[ON]';
if (s.status === 'working') return '[RUN]';
if (s.status === 'offline') return '[--]';
return '[??]';
}
function sisterBarWidth(s: any): number {
if (s.status === 'online') return 100;
if (s.status === 'working') return 84;
if (s.status === 'offline') return 0;
return 12;
}
function formatTimestamp(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString('ko-KR', { hour12: false }) + ' GMT+9';
}
const MOCK_SISTERS = [
{ id: 1, name: 'harang', status: 'online', lastSeen: null, currentTask: null },
{ id: 2, name: 'narang', status: 'working', lastSeen: null, currentTask: null },
{ id: 3, name: 'darang', status: 'offline', lastSeen: null, currentTask: null },
{ id: 4, name: 'erang', status: 'online', lastSeen: null, currentTask: null },
];
export default function DashboardPage() { export default function DashboardPage() {
const [sisters, setSisters] = useState<any[]>(MOCK_SISTERS); const [sisters, setSisters] = useState<any[]>([]);
const [projects, setProjects] = useState<any[]>([]); const [projects, setProjects] = useState<any[]>([]);
const [activityItems, setActivityItems] = useState<any[]>([]); const [activityItems, setActivityItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const handleSistersUpdate = useCallback((s: any[]) => setSisters(s), []); const handleSistersUpdate = useCallback((s: any[]) => setSisters(s), []);
const handleActivityNew = useCallback((item: any) => { const handleActivityNew = useCallback((item: any) => setActivityItems((prev) => [item, ...prev].slice(0, 10)), []);
setActivityItems((prev) => [item, ...prev].slice(0, 10));
}, []);
const { connected } = useSocket({ onSistersUpdate: handleSistersUpdate, onActivityNew: handleActivityNew }); const { connected } = useSocket({ onSistersUpdate: handleSistersUpdate, onActivityNew: handleActivityNew });
useEffect(() => { useEffect(() => {
const fetch_ = async () => { let active = true;
const fetchAll = async () => {
try { try {
const [sRes, pRes, aRes] = await Promise.allSettled([ const [sRes, pRes, aRes] = await Promise.allSettled([
fetch(`${API_URL}/api/sisters`), fetch(`${API_URL}/api/sisters`),
fetch(`${API_URL}/api/projects`), fetch(`${API_URL}/api/projects`),
fetch(`${API_URL}/api/activity?limit=8`), fetch(`${API_URL}/api/activity?limit=8`),
]); ]);
if (!active) return;
if (sRes.status === 'fulfilled' && sRes.value.ok) setSisters(await sRes.value.json()); if (sRes.status === 'fulfilled' && sRes.value.ok) setSisters(await sRes.value.json());
if (pRes.status === 'fulfilled' && pRes.value.ok) setProjects(await pRes.value.json()); if (pRes.status === 'fulfilled' && pRes.value.ok) setProjects(await pRes.value.json());
if (aRes.status === 'fulfilled' && aRes.value.ok) { if (aRes.status === 'fulfilled' && aRes.value.ok) setActivityItems((await aRes.value.json()).items ?? []);
const d = await aRes.value.json(); setError(null);
setActivityItems(d.items ?? []); } catch {
if (active) setError('데이터를 불러올 수 없습니다');
} finally {
if (active) setLoading(false);
} }
} catch { /* silent */ }
}; };
fetch_(); fetchAll();
const iv = setInterval(fetch_, POLL_INTERVAL_MS); const iv = setInterval(fetchAll, POLL_INTERVAL_MS);
return () => clearInterval(iv); return () => { active = false; clearInterval(iv); };
}, []); }, []);
return ( return (
<> <>
<PageHeader> <PageHeader><PageTitle> <WsIndicator $on={connected}> {connected ? 'LIVE' : 'POLL'}</WsIndicator></PageTitle></PageHeader>
<PageTitle> {error && <ErrorBox>{error}</ErrorBox>}
<WsIndicator $on={connected}> {connected ? 'LIVE' : 'POLL'}</WsIndicator>
</PageTitle>
</PageHeader>
{/* 상태 카드 */}
<StatusGrid> <StatusGrid>
{sisters.map((s) => ( {loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />) : sisters.map((s) => (
<Card key={s.id ?? s.name} as={Link} href={`/sisters/${s.name}`} style={{ textDecoration: 'none' }}> <Card key={s.id ?? s.name} as={Link} href={`/sisters/${s.name}`} style={{ textDecoration: 'none' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta><SisterAvatar name={s.name} size={24} /></div>
<LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta> <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}><BracketValue $dimmed={s.status === 'offline'}>{sisterBracketValue(s)}</BracketValue><LabelMeta>{sisterSubLabel(s)}</LabelMeta></div>
</div> <TechBar><TechBarFill $width={sisterBarWidth(s)} /></TechBar>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}>
<BracketValue $dimmed={s.status === 'offline'}>
{sisterBracketValue(s)}
</BracketValue>
<LabelMeta>{SISTER_SUB_LABEL[s.name] ?? s.status}</LabelMeta>
</div>
<TechBar>
<TechBarFill $width={sisterBarWidth(s)} />
</TechBar>
</Card> </Card>
))} ))}
</StatusGrid> </StatusGrid>
{/* 하단 2열 */} <ChartGrid>
<ChartCard>
<SectionTitle><span>SISTER LOAD</span><LabelMeta>CPU %</LabelMeta></SectionTitle>
<BarChart>{sisters.map((s) => <BarCol key={s.name}><Bar $height={Math.max(4, Number(s.cpu ?? 0))} /><BarLabel>{SISTER_SHORT[s.name] ?? s.name}</BarLabel></BarCol>)}</BarChart>
</ChartCard>
<ChartCard>
<SectionTitle><span>PROJECT PROGRESS</span><LabelMeta>TOP {projects.length}</LabelMeta></SectionTitle>
<BarChart>{projects.slice(0, 6).map((p) => <BarCol key={p.id}><Bar $height={Math.max(4, Number(p.progress ?? 0))} /><BarLabel>{p.name.slice(0, 6)}</BarLabel></BarCol>)}</BarChart>
</ChartCard>
</ChartGrid>
<DataColumns> <DataColumns>
{/* ONGOING PROJECTS */}
<section> <section>
<SectionTitle> <SectionTitle><span>ONGOING PROJECTS</span><LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta></SectionTitle>
<span>ONGOING PROJECTS</span>
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
</SectionTitle>
<ProjectList> <ProjectList>
{projects.length === 0 ? ( {loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonLine key={i} />) : projects.length === 0 ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}> </div> : projects.map((p) => (
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}> <ProjectRow key={p.id} href={`/projects/${p.id}`}><SisterAvatar name={p.ownerSister ?? 'narang'} size={32} /><ProjectDetails><ProjectName>{p.name}</ProjectName><ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc></ProjectDetails><LabelMeta>{p.progress ?? 0}%</LabelMeta></ProjectRow>
))}
</div>
) : (
projects.map((p) => (
<ProjectRow key={p.id} href={`/projects/${p.id}`}>
<Avatar>📁</Avatar>
<ProjectDetails>
<ProjectName>{p.name}</ProjectName>
<ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc>
</ProjectDetails>
<LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta>
</ProjectRow>
))
)}
</ProjectList> </ProjectList>
</section> </section>
{/* ACTIVITY FEED */}
<section> <section>
<SectionTitle> <SectionTitle><span>ACTIVITY FEED</span><LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta></SectionTitle>
<span>ACTIVITY FEED</span>
<LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta>
</SectionTitle>
<Timeline> <Timeline>
{activityItems.length === 0 ? ( {loading ? Array.from({ length: 5 }).map((_, i) => <SkeletonLine key={i} />) : activityItems.length === 0 ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', paddingLeft: 'var(--space-lg)' }}> </div> : activityItems.map((item) => (
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', paddingLeft: 'var(--space-lg)' }}> <TimelineItem key={item.id}><TimeStamp>{formatTimestamp(item.createdAt)}</TimeStamp><TimelineContent>{item.sister && <strong>{item.sister.name} </strong>}{item.detail ?? item.action}</TimelineContent></TimelineItem>
))}
</div>
) : (
activityItems.map((item) => (
<TimelineItem key={item.id}>
<TimeStamp>{formatTimestamp(item.createdAt)}</TimeStamp>
<TimelineContent>
{item.sister && <strong>{item.sister.name} </strong>}
{item.detail ?? item.action}
</TimelineContent>
</TimelineItem>
))
)}
</Timeline> </Timeline>
</section> </section>
</DataColumns> </DataColumns>

View File

@@ -1,18 +1,18 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill } from '@/components/ui/base'; import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill } from '@/components/ui/base';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
// ─── Styled ───
const Breadcrumb = styled.div` const Breadcrumb = styled.div`
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } } a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
`; `;
@@ -25,19 +25,188 @@ const PageTitleRow = styled.div`
flex-wrap: wrap; flex-wrap: wrap;
`; `;
const TabBar = styled.div`
display: flex;
gap: var(--space-lg);
border-bottom: 1px solid var(--border-color);
margin-bottom: var(--space-xl);
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
`;
const TabBtn = styled.button<{ $active: boolean }>`
font-size: 13px;
font-weight: 500;
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
background: transparent;
border: none;
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
padding: var(--space-sm) 0;
cursor: pointer;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
&:hover { color: var(--text-primary); }
`;
const CommitTable = styled.div`
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
background: var(--bg-code);
font-family: var(--font-mono);
`;
const CommitRow = styled.a`
display: grid;
grid-template-columns: 80px 1fr 120px 100px;
gap: var(--space-md);
padding: 10px 12px;
border-bottom: 1px solid #1a1a1a;
text-decoration: none;
transition: background 0.1s;
&:last-child { border-bottom: none; }
&:hover { background: #1a1a1a; }
@media (max-width: 767px) {
grid-template-columns: 70px 1fr;
gap: var(--space-sm);
}
`;
const CommitHash = styled.span`
font-size: 12px;
color: #5fafff;
font-family: var(--font-mono);
`;
const CommitMsg = styled.span`
font-size: 12px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const CommitMeta = styled.span`
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const BranchGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-md);
`;
const BranchCard = styled.div`
border: 1px solid var(--border-color);
padding: var(--space-md) var(--space-lg);
transition: border-color 0.15s;
&:hover { border-color: var(--border-hover); }
`;
const BranchName = styled.div`
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
margin-bottom: 4px;
`;
const BranchMeta = styled.div`
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
`;
const PRList = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-sm);
`;
const PRItem = styled.a`
display: flex;
align-items: center;
gap: var(--space-md);
padding: 12px var(--space-lg);
border: 1px solid var(--border-color);
text-decoration: none;
transition: border-color 0.15s;
&:hover { border-color: var(--border-hover); }
`;
const PRState = styled.span<{ $state: string }>`
font-family: var(--font-mono);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
padding: 2px 6px;
border: 1px solid;
white-space: nowrap;
${({ $state }) => {
switch ($state) {
case 'open': return "color: #5fff8a; border-color: #5fff8a44;";
case 'closed': return "color: var(--text-secondary); border-color: var(--border-color);";
case 'merged': return "color: #5fafff; border-color: #5fafff44;";
default: return "color: var(--text-secondary); border-color: var(--border-color);";
}
}}
`;
const PRTitle = styled.span`
font-size: 13px;
color: var(--text-primary);
flex: 1;
`;
const PRMeta = styled.span`
font-size: 11px;
color: var(--text-secondary);
font-family: var(--font-mono);
white-space: nowrap;
`;
const FilterRow = styled.div`
display: flex;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
`;
const FilterBtn = styled.button<{ $active: boolean }>`
background: ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
border: 1px solid var(--border-color);
color: ${({ $active }) => $active ? '#000' : 'var(--text-secondary)'};
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
padding: 4px 12px;
cursor: pointer;
font-family: var(--font-mono);
transition: all 0.15s;
&:hover { border-color: var(--border-hover); color: ${({ $active }) => $active ? '#000' : 'var(--text-primary)'}; }
`;
const Empty = styled.div`
padding: var(--space-xl) 0;
color: var(--text-secondary);
font-size: 13px;
font-family: var(--font-mono);
`;
// Phase timeline 컴포넌트들
const ProjectGrid = styled.div` const ProjectGrid = styled.div`
display: grid; display: grid;
grid-template-columns: 1fr 320px; grid-template-columns: 1fr 320px;
gap: var(--space-xxl); gap: var(--space-xxl);
align-items: start; align-items: start;
@media (max-width: 1199px) { grid-template-columns: 1fr; gap: var(--space-xl); }
@media (max-width: 1199px) {
grid-template-columns: 1fr;
gap: var(--space-xl);
}
`; `;
// Phase Timeline
const PhaseTimeline = styled.div` const PhaseTimeline = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -80,39 +249,6 @@ const PhaseDesc = styled.div`
line-height: 1.5; line-height: 1.5;
`; `;
// Audit Log
const AuditLog = styled.div`
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border-color);
border: 1px solid var(--border-color);
font-family: var(--font-mono);
`;
const AuditRow = styled.div<{ $header?: boolean }>`
background: var(--bg-main);
padding: var(--space-sm) var(--space-md);
display: grid;
grid-template-columns: 100px 1fr 80px;
font-size: 11px;
color: ${({ $header }) => $header ? 'var(--text-secondary)' : 'var(--text-primary)'};
font-weight: ${({ $header }) => $header ? '700' : '400'};
text-transform: ${({ $header }) => $header ? 'uppercase' : 'none'};
`;
const StatusTag = styled.span<{ $pass?: boolean }>`
padding: 2px 6px;
font-size: 10px;
border: 1px solid ${({ $pass }) => $pass ? '#00FF00' : 'var(--border-color)'};
color: ${({ $pass }) => $pass ? '#00FF00' : 'var(--text-secondary)'};
text-align: center;
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
`;
// Right Panel
const NodeStack = styled.div` const NodeStack = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -126,13 +262,10 @@ const NodeMiniCard = styled.div`
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
transition: border-color 0.2s;
&:hover { border-color: var(--border-hover); }
`; `;
const NodeStatusDot = styled.div` const NodeDot = styled.div`
width: 6px; width: 6px; height: 6px;
height: 6px;
background: #00FF00; background: #00FF00;
border-radius: 50%; border-radius: 50%;
`; `;
@@ -153,43 +286,47 @@ const CheckItem = styled.li<{ $done?: boolean }>`
font-size: 13px; font-size: 13px;
color: ${({ $done }) => $done ? 'var(--text-secondary)' : 'var(--text-primary)'}; color: ${({ $done }) => $done ? 'var(--text-secondary)' : 'var(--text-primary)'};
text-decoration: ${({ $done }) => $done ? 'line-through' : 'none'}; text-decoration: ${({ $done }) => $done ? 'line-through' : 'none'};
&:last-child { border-bottom: none; } &:last-child { border-bottom: none; }
`; `;
const CheckBox = styled.div<{ $checked?: boolean }>` const CheckBox = styled.div<{ $checked?: boolean }>`
width: 14px; width: 14px; height: 14px;
height: 14px;
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'}; border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
flex-shrink: 0; flex-shrink: 0;
background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'transparent'}; background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'transparent'};
position: relative; position: relative;
${({ $checked }) => $checked && `&::after { content: '✓'; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 9px; color: #000; }`}
${({ $checked }) => $checked && `
&::after {
content: '✓';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: #000;
}
`}
`; `;
// ─── Helpers ───
function getSprintMeta(sprint: any): { label: string; isActive: boolean } { function getSprintMeta(sprint: any): { label: string; isActive: boolean } {
if (sprint.status === 'done') return { label: `COMPLETED\n${sprint.completedAt ? new Date(sprint.completedAt).toLocaleDateString() : ''}`, isActive: false }; if (sprint.status === 'done') return { label: `COMPLETED`, isActive: false };
if (sprint.status === 'in_progress') return { label: 'IN PROGRESS\nEST: TBD', isActive: true }; if (sprint.status === 'in_progress') return { label: 'IN PROGRESS', isActive: true };
return { label: 'PENDING\nTBD', isActive: false }; return { label: 'PENDING', isActive: false };
} }
function formatDate(str: string): string {
return new Date(str).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
}
const TABS = [
{ id: 'overview', label: 'Overview' },
{ id: 'commits', label: 'Commits' },
{ id: 'branches', label: 'Branches' },
{ id: 'prs', label: 'Pull Requests' },
];
export default function ProjectDetailPage() { export default function ProjectDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [project, setProject] = useState<any>(null); const [project, setProject] = useState<any>(null);
const [tasks, setTasks] = useState<any[]>([]); const [tasks, setTasks] = useState<any[]>([]);
const [commits, setCommits] = useState<any[]>([]);
const [branches, setBranches] = useState<any[]>([]);
const [pulls, setPulls] = useState<any[]>([]);
const [prFilter, setPrFilter] = useState<'open' | 'closed' | 'all'>('open');
const [tab, setTab] = useState('overview');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tabLoading, setTabLoading] = useState(false);
useEffect(() => { useEffect(() => {
Promise.allSettled([ Promise.allSettled([
@@ -201,26 +338,77 @@ export default function ProjectDetailPage() {
}).finally(() => setLoading(false)); }).finally(() => setLoading(false));
}, [id]); }, [id]);
if (loading) return <div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}> ...</div>; const loadCommits = useCallback(async () => {
if (!project) return <div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}> </div>; setTabLoading(true);
try {
const res = await fetch(`${API_URL}/api/projects/${id}/commits?limit=30`);
if (res.ok) setCommits(await res.json());
} finally { setTabLoading(false); }
}, [id]);
const loadBranches = useCallback(async () => {
setTabLoading(true);
try {
const res = await fetch(`${API_URL}/api/projects/${id}/branches`);
if (res.ok) setBranches(await res.json());
} finally { setTabLoading(false); }
}, [id]);
const loadPulls = useCallback(async (state: 'open' | 'closed' | 'all') => {
setTabLoading(true);
try {
const res = await fetch(`${API_URL}/api/projects/${id}/pulls?state=${state}`);
if (res.ok) setPulls(await res.json());
} finally { setTabLoading(false); }
}, [id]);
useEffect(() => {
if (tab === 'commits' && commits.length === 0) loadCommits();
if (tab === 'branches' && branches.length === 0) loadBranches();
if (tab === 'prs' && pulls.length === 0) loadPulls(prFilter);
}, [tab]);
useEffect(() => {
if (tab === 'prs') loadPulls(prFilter);
}, [prFilter]);
const allTasks = tasks.flatMap((s: any) => s.tasks ?? []); const allTasks = tasks.flatMap((s: any) => s.tasks ?? []);
const doneTasks = allTasks.filter((t: any) => t.status === 'done'); const doneTasks = allTasks.filter((t: any) => t.status === 'done');
const activeSprint = tasks.find((s: any) => s.status === 'in_progress'); const activeSprint = tasks.find((s: any) => s.status === 'in_progress');
if (loading) return <Empty>LOADING...</Empty>;
if (!project) return <Empty>PROJECT NOT FOUND</Empty>;
return ( return (
<> <>
<Breadcrumb> <Breadcrumb>
<Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / SUMMARY <Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / {project.name}
</Breadcrumb> </Breadcrumb>
<PageTitleRow> <PageTitleRow>
<PageTitle>{project.name}</PageTitle> <PageTitle>
{project.name}
{project.repoUrl && (
<a href={project.repoUrl} target="_blank" rel="noopener"
style={{ fontSize: '12px', color: '#5fafff', marginLeft: 'var(--space-md)', fontWeight: 400 }}>
GITEA
</a>
)}
</PageTitle>
<LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / {activeSprint?.name ?? 'PLANNING'}</LabelMeta> <LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / {activeSprint?.name ?? 'PLANNING'}</LabelMeta>
</PageTitleRow> </PageTitleRow>
<TabBar>
{TABS.map((t) => (
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
{t.label}
</TabBtn>
))}
</TabBar>
{/* OVERVIEW */}
{tab === 'overview' && (
<ProjectGrid> <ProjectGrid>
<div> <div>
{/* Phase Timeline */}
<SectionTitle> <SectionTitle>
<span>PHASE TIMELINE</span> <span>PHASE TIMELINE</span>
<LabelMeta>CURRENT: {activeSprint ? `S${activeSprint.number}` : 'N/A'}</LabelMeta> <LabelMeta>CURRENT: {activeSprint ? `S${activeSprint.number}` : 'N/A'}</LabelMeta>
@@ -230,66 +418,32 @@ export default function ProjectDetailPage() {
const { label, isActive } = getSprintMeta(sprint); const { label, isActive } = getSprintMeta(sprint);
return ( return (
<PhaseItem key={sprint.id}> <PhaseItem key={sprint.id}>
<PhaseMeta style={{ whiteSpace: 'pre-line' }}>{label}</PhaseMeta> <PhaseMeta>{label}</PhaseMeta>
<PhaseBox $active={isActive}> <PhaseBox $active={isActive}>
<PhaseName>SPRINT {String(sprint.number).padStart(2, '0')}: {sprint.name}</PhaseName> <PhaseName>SPRINT {String(sprint.number).padStart(2, '0')}: {sprint.name}</PhaseName>
<PhaseDesc> <PhaseDesc> {sprint.tasks?.length ?? 0} · {sprint.tasks?.filter((t: any) => t.status === 'done').length ?? 0}</PhaseDesc>
{sprint.tasks?.length ?? 0} · {sprint.tasks?.filter((t: any) => t.status === 'done').length ?? 0} <TechBar style={{ marginTop: 'var(--space-sm)' }}>
</PhaseDesc>
<TechBar style={{ marginTop: 'var(--space-sm)', background: 'var(--border-color)' }}>
<TechBarFill $width={sprint.progress ?? 0} /> <TechBarFill $width={sprint.progress ?? 0} />
</TechBar> </TechBar>
</PhaseBox> </PhaseBox>
</PhaseItem> </PhaseItem>
); );
})} })}
{tasks.length === 0 && ( {tasks.length === 0 && <PhaseItem><PhaseMeta>PENDING</PhaseMeta><PhaseBox><PhaseName>Sprint </PhaseName></PhaseBox></PhaseItem>}
<PhaseItem>
<PhaseMeta>PENDING{'\n'}TBD</PhaseMeta>
<PhaseBox><PhaseName>Sprint </PhaseName></PhaseBox>
</PhaseItem>
)}
</PhaseTimeline> </PhaseTimeline>
{/* Audit Log (활동 로그) */}
<SectionTitle>
<span>SECURITY AUDIT LOG</span>
<LabelMeta>LAST CHECK: LIVE</LabelMeta>
</SectionTitle>
<AuditLog>
<AuditRow $header>
<div>TIMESTAMP</div>
<div>ACTION / RESOURCE</div>
<div>RESULT</div>
</AuditRow>
{allTasks.slice(0, 5).map((task: any) => (
<AuditRow key={task.id}>
<div>{new Date(task.createdAt).toLocaleTimeString('ko-KR', { hour12: false })}</div>
<div>{task.taskId} / {task.assignee?.toUpperCase()}</div>
<StatusTag $pass={task.status === 'done'}>
{task.status === 'done' ? 'PASS' : task.status === 'failed' ? 'FAIL' : 'PEND'}
</StatusTag>
</AuditRow>
))}
</AuditLog>
</div> </div>
<div> <div>
{/* Assigned Nodes */}
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle> <SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle>
<NodeStack> <NodeStack>
{['harang', 'narang', 'darang', 'erang'].map((name) => ( {['harang', 'narang', 'darang', 'erang'].map((name) => (
<NodeMiniCard key={name}> <NodeMiniCard key={name}>
<LabelMeta> <LabelMeta>{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'} [{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'GEN' : name === 'darang' ? 'EVAL' : 'INFRA'}]</LabelMeta>
{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'}{' '} <NodeDot />
[{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'SECONDARY' : name === 'darang' ? 'EVALUATOR' : 'INFRA'}]
</LabelMeta>
<NodeStatusDot />
</NodeMiniCard> </NodeMiniCard>
))} ))}
</NodeStack> </NodeStack>
{/* Task Checklist */}
<SectionTitle>TASK CHECKLIST</SectionTitle> <SectionTitle>TASK CHECKLIST</SectionTitle>
<Checklist> <Checklist>
{allTasks.slice(0, 8).map((task: any) => ( {allTasks.slice(0, 8).map((task: any) => (
@@ -298,15 +452,92 @@ export default function ProjectDetailPage() {
<span>[{task.taskId}] {task.title}</span> <span>[{task.taskId}] {task.title}</span>
</CheckItem> </CheckItem>
))} ))}
{allTasks.length === 0 && ( {allTasks.length === 0 && <CheckItem><CheckBox /><span style={{ color: 'var(--text-secondary)' }}> </span></CheckItem>}
<CheckItem>
<CheckBox />
<span style={{ color: 'var(--text-secondary)' }}> </span>
</CheckItem>
)}
</Checklist> </Checklist>
</div> </div>
</ProjectGrid> </ProjectGrid>
)}
{/* COMMITS */}
{tab === 'commits' && (
<>
{tabLoading ? <Empty>LOADING COMMITS...</Empty> : (
<>
<CommitTable>
{commits.length === 0 ? (
<div style={{ padding: 'var(--space-xl)', color: 'var(--text-secondary)', fontSize: '12px', textAlign: 'center', fontFamily: 'var(--font-mono)' }}>NO COMMITS</div>
) : (
commits.map((c: any) => (
<CommitRow key={c.sha} href={c.html_url} target="_blank" rel="noopener">
<CommitHash>{c.sha?.slice(0, 7)}</CommitHash>
<CommitMsg>{c.commit?.message?.split('\n')[0]}</CommitMsg>
<CommitMeta style={{ display: 'var(--media-hide, initial)' }}>
{c.commit?.author?.name ?? c.author?.login ?? '-'}
</CommitMeta>
<CommitMeta>
{c.commit?.author?.date ? formatDate(c.commit.author.date) : '-'}
</CommitMeta>
</CommitRow>
))
)}
</CommitTable>
</>
)}
</>
)}
{/* BRANCHES */}
{tab === 'branches' && (
<>
{tabLoading ? <Empty>LOADING BRANCHES...</Empty> : (
<BranchGrid>
{branches.length === 0 ? (
<Empty>NO BRANCHES</Empty>
) : (
branches.map((b: any) => (
<BranchCard key={b.name}>
<BranchName>{b.name}</BranchName>
<BranchMeta>
{b.commit?.id?.slice(0, 7) ?? '-'}
{b.protected && ' · PROTECTED'}
</BranchMeta>
</BranchCard>
))
)}
</BranchGrid>
)}
</>
)}
{/* PULL REQUESTS */}
{tab === 'prs' && (
<>
<FilterRow>
{(['open', 'closed', 'all'] as const).map((s) => (
<FilterBtn key={s} $active={prFilter === s} onClick={() => setPrFilter(s)}>
{s.toUpperCase()}
</FilterBtn>
))}
</FilterRow>
{tabLoading ? <Empty>LOADING PULL REQUESTS...</Empty> : (
<PRList>
{pulls.length === 0 ? (
<Empty>NO PULL REQUESTS ({prFilter})</Empty>
) : (
pulls.map((pr: any) => (
<PRItem key={pr.id} href={pr.html_url} target="_blank" rel="noopener">
<PRState $state={pr.merged ? 'merged' : pr.state}>
{pr.merged ? 'MERGED' : pr.state?.toUpperCase()}
</PRState>
<PRTitle>#{pr.number} {pr.title}</PRTitle>
<PRMeta>{pr.user?.login ?? '-'} · {pr.created_at ? formatDate(pr.created_at) : '-'}</PRMeta>
</PRItem>
))
)}
</PRList>
)}
</>
)}
</> </>
); );
} }

View File

@@ -3,124 +3,90 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { PageTitle, LabelMeta, TechBar, TechBarFill } from '@/components/ui/base'; import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn } from '@/components/ui/base';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
import { adminFetch } from '@/lib/adminFetch';
import SisterAvatar from '@/components/common/SisterAvatar';
const ProjectList = styled.div` const ProjectList = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`;
display: flex; const ProjectRow = styled(Link)`display:grid;grid-template-columns:1.4fr 1fr 180px;gap:var(--space-lg);padding:var(--space-lg);border:1px solid var(--border-color);align-items:center;text-decoration:none;transition:border-color .15s, background .15s;&:hover{border-color:var(--border-hover);background:#111}@media (max-width:1199px){grid-template-columns:1fr;}`;
flex-direction: column; const Left = styled.div`display:flex;align-items:flex-start;gap:var(--space-md);min-width:0;`;
`; const NameBlock = styled.div`display:flex;flex-direction:column;gap:4px;min-width:0;`;
const ProjectName = styled.div`font-size:15px;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const ProjectRow = styled(Link)` const ProjectDesc = styled.div`font-size:12px;color:var(--text-secondary);line-height:1.5;`;
display: grid; const Middle = styled.div`display:flex;flex-direction:column;gap:8px;`;
grid-template-columns: 40px 1fr auto; const ProgressRow = styled.div`display:flex;align-items:center;gap:var(--space-sm);`;
gap: var(--space-md); const Right = styled.div`display:flex;flex-direction:column;align-items:flex-end;gap:8px;@media (max-width:1199px){align-items:flex-start;}`;
padding: var(--space-md) 0; const Meta = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`;
border-bottom: 1px solid #222; const EmptyState = styled.div`padding:var(--space-xl) 0;color:var(--text-secondary);font-size:13px;font-family:var(--font-mono);`;
align-items: center;
text-decoration: none;
transition: opacity 0.15s;
&:last-child { border-bottom: none; }
&:hover { opacity: 0.75; }
@media (max-width: 767px) {
grid-template-columns: 32px 1fr auto;
gap: var(--space-sm);
}
`;
const Avatar = styled.div`
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #333;
filter: grayscale(100%) contrast(120%);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
border: 1px solid var(--border-color);
`;
const ProjectDetails = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
`;
const ProjectName = styled.div`
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const ProjectMeta = styled.div`
display: flex;
align-items: center;
gap: var(--space-sm);
`;
const ProjectDesc = styled.div`
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 400px;
`;
const EmptyState = styled.div`
padding: var(--space-xl) 0;
color: var(--text-secondary);
font-size: 13px;
font-family: var(--font-mono);
`;
export default function ProjectsPage() { export default function ProjectsPage() {
const [projects, setProjects] = useState<any[]>([]); const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null);
useEffect(() => { const loadProjects = () => {
fetch(`${API_URL}/api/projects`) setLoading(true);
.then((r) => r.json()) fetch(`${API_URL}/api/projects`).then((r) => r.json()).then(setProjects).catch(() => {}).finally(() => setLoading(false));
.then(setProjects) };
.catch(() => {})
.finally(() => setLoading(false)); const handleSync = async () => {
}, []); setSyncing(true); setSyncResult(null);
try {
const res = await adminFetch('/api/admin/gitea/sync', { method: 'POST' });
const d = await res.json();
setSyncResult(`sync: ${d.created ?? 0} new, ${d.updated ?? 0} updated`);
loadProjects();
} catch { setSyncResult('sync failed'); } finally { setSyncing(false); }
};
const handleSprintSync = async () => {
setSyncing(true); setSyncResult(null);
try {
const res = await adminFetch('/api/projects/sync-all-sprints', { method: 'POST' });
const d = await res.json();
setSyncResult(`sprint sync: ${d.synced ?? 0} synced across ${d.projects ?? 0} projects`);
loadProjects();
} catch { setSyncResult('sprint sync failed'); } finally { setSyncing(false); }
};
useEffect(() => { loadProjects(); }, []);
return ( return (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}>
<PageTitle></PageTitle> <PageTitle></PageTitle>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
{syncResult && <span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)' }}>{syncResult}</span>}
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta> <LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
<Btn onClick={handleSync} disabled={syncing}>{syncing ? 'SYNCING...' : '↻ GITEA SYNC'}</Btn>
<Btn onClick={handleSprintSync} disabled={syncing}>{syncing ? '...' : '↻ SPRINT SYNC'}</Btn>
</div>
</div> </div>
{loading ? ( {loading ? <EmptyState>LOADING...</EmptyState> : projects.length === 0 ? <EmptyState>NO PROJECTS REGISTERED</EmptyState> : (
<EmptyState>LOADING...</EmptyState>
) : projects.length === 0 ? (
<EmptyState>NO PROJECTS REGISTERED</EmptyState>
) : (
<ProjectList> <ProjectList>
{projects.map((p) => ( {projects.map((p) => (
<ProjectRow key={p.id} href={`/projects/${p.id}`}> <ProjectRow key={p.id} href={`/projects/${p.id}`}>
<Avatar>📁</Avatar> <Left>
<ProjectDetails> <SisterAvatar name={p.ownerSister ?? 'narang'} size={40} />
<NameBlock>
<ProjectName>{p.name}</ProjectName> <ProjectName>{p.name}</ProjectName>
<ProjectMeta> <ProjectDesc>{p.description ?? '설명 없음'}</ProjectDesc>
<ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc> </NameBlock>
<TechBar style={{ width: '80px', flexShrink: 0 }}> </Left>
<TechBarFill $width={p.progress ?? 0} /> <Middle>
</TechBar> <ProgressRow>
<LabelMeta style={{ flexShrink: 0, fontSize: '10px' }}>{p.progress ?? 0}%</LabelMeta> <TechBar style={{ flex: 1 }}><TechBarFill $width={p.progress ?? 0} /></TechBar>
</ProjectMeta> <LabelMeta style={{ minWidth: 44, textAlign: 'right' }}>{p.progress ?? 0}%</LabelMeta>
</ProjectDetails> </ProgressRow>
<Meta>{p.doneSprints ?? 0} / {p.totalSprints ?? 0} SPRINTS DONE</Meta>
</Middle>
<Right>
<LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta> <LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta>
<Meta>{new Date(p.updatedAt).toLocaleDateString('ko-KR')} UPDATED</Meta>
</Right>
</ProjectRow> </ProjectRow>
))} ))}
</ProjectList> </ProjectList>

View File

@@ -1,215 +1,9 @@
'use client'; 'use client';
import { useEffect } from 'react';
import React, { useState } from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { API_URL } from '@/lib/config';
const Page = styled.div`
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-main);
`;
const Box = styled.div`
width: 100%;
max-width: 360px;
padding: var(--space-xl);
`;
const Logo = styled.div`
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
`;
const CircleFull = styled.div`
width: 20px; height: 20px;
background: var(--text-primary);
border-radius: 50%;
`;
const CircleHalf = styled.div`
width: 10px; height: 20px;
background: var(--text-primary);
border-radius: 0 20px 20px 0;
`;
const Title = styled.h1`
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-xs);
letter-spacing: -0.02em;
`;
const Subtitle = styled.p`
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
margin-bottom: var(--space-xl);
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: var(--space-md);
`;
const FieldLabel = styled.label`
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: var(--space-xs);
display: block;
`;
const Input = styled.input`
width: 100%;
background: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 12px;
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.15s;
&:focus { border-color: var(--border-hover); }
&::placeholder { color: var(--text-secondary); opacity: 0.5; }
`;
const SubmitBtn = styled.button`
background: var(--text-primary);
border: 1px solid var(--text-primary);
color: #000;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 12px;
cursor: pointer;
transition: all 0.15s;
margin-top: var(--space-sm);
&:hover { background: transparent; color: var(--text-primary); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
`;
const ErrorMsg = styled.div`
font-family: var(--font-mono);
font-size: 11px;
color: #ff5f5f;
padding: var(--space-sm);
border: 1px solid #ff5f5f44;
background: rgba(255,95,95,0.05);
`;
const SuccessMsg = styled.div`
font-family: var(--font-mono);
font-size: 11px;
color: #00FF00;
padding: var(--space-sm);
border: 1px solid rgba(0,255,0,0.3);
background: rgba(0,255,0,0.03);
`;
const FooterLink = styled.div`
margin-top: var(--space-lg);
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
text-align: center;
a { color: var(--text-primary); text-decoration: none;
&:hover { text-decoration: underline; } }
`;
const InviteNote = styled.div`
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
opacity: 0.6;
margin-top: 4px;
`;
export default function RegisterPage() { export default function RegisterPage() {
const [form, setForm] = useState({ username: '', password: '', inviteCode: '' });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter(); const router = useRouter();
useEffect(() => { router.replace('/login'); }, [router]);
const handleSubmit = async (e: React.FormEvent) => { return null;
e.preventDefault();
setError('');
setSuccess('');
setLoading(true);
try {
const res = await fetch(`${API_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
const d = await res.json();
if (!res.ok) {
setError(Array.isArray(d.message) ? d.message[0] : (d.message ?? '등록 실패'));
return;
}
localStorage.setItem('hanarang_access_token', d.accessToken);
setSuccess('등록 완료. 대시보드로 이동...');
setTimeout(() => router.push('/'), 1000);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const set = (key: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((p) => ({ ...p, [key]: e.target.value }));
return (
<Page>
<Box>
<Logo><CircleFull /><CircleHalf /></Logo>
<Title> </Title>
<Subtitle>SYSTEM_ACCESS // REGISTER_NEW_NODE</Subtitle>
<Form onSubmit={handleSubmit}>
<div>
<FieldLabel>USERNAME</FieldLabel>
<Input type="text" placeholder="username" value={form.username} onChange={set('username')} required />
</div>
<div>
<FieldLabel>PASSWORD</FieldLabel>
<Input type="password" placeholder="•••••••• (8자 이상)" value={form.password} onChange={set('password')} required minLength={8} />
</div>
<div>
<FieldLabel>INVITE CODE</FieldLabel>
<Input type="text" placeholder="초대 코드" value={form.inviteCode} onChange={set('inviteCode')} required />
<InviteNote> </InviteNote>
</div>
{error && <ErrorMsg>ERR: {error}</ErrorMsg>}
{success && <SuccessMsg> {success}</SuccessMsg>}
<SubmitBtn type="submit" disabled={loading}>
{loading ? 'REGISTERING...' : 'REGISTER'}
</SubmitBtn>
</Form>
<FooterLink>
? <Link href="/login">LOGIN</Link>
</FooterLink>
</Box>
</Page>
);
} }

View File

@@ -1,23 +1,37 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled, { keyframes } from 'styled-components';
import { PageTitle, LabelMeta, SectionTitle, Btn, BtnPrimary } from '@/components/ui/base'; import { PageTitle, LabelMeta, SectionTitle, Btn, BtnPrimary } from '@/components/ui/base';
import { adminFetch } from '@/lib/adminFetch';
const DEFAULT_SETTINGS = {
force2fa: 'true',
sessionTimeoutMinutes: '15',
ipWhitelistEnabled: 'false',
autoBackupEnabled: 'true',
backupSchedule: '08:00',
retentionDays: '30',
queueWarningThreshold: '500',
autoScalingEnabled: 'true',
cpuThresholdPercent: '90',
latencyAlertMs: '250',
nodeOfflineAlertEnabled: 'true',
};
const pulse = keyframes`
0% { opacity: 0.35; }
50% { opacity: 0.7; }
100% { opacity: 0.35; }
`;
// ─── Styled ───
const SettingsGrid = styled.div` const SettingsGrid = styled.div`
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: var(--space-xl); gap: var(--space-xl);
@media (max-width: 767px) { grid-template-columns: 1fr; gap: var(--space-lg); }
@media (max-width: 767px) {
grid-template-columns: 1fr;
gap: var(--space-lg);
}
`; `;
const SettingsSection = styled.section``; const SettingsSection = styled.section``;
const SettingsRow = styled.div` const SettingsRow = styled.div`
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -25,257 +39,112 @@ const SettingsRow = styled.div`
gap: var(--space-lg); gap: var(--space-lg);
padding: var(--space-md) 0; padding: var(--space-md) 0;
border-bottom: 1px solid #1f1f1f; border-bottom: 1px solid #1f1f1f;
&:last-child { border-bottom: none; } &:last-child { border-bottom: none; }
`; `;
const SettingInfo = styled.div`flex: 1; min-width: 0;`;
const SettingInfo = styled.div` const SettingLabel = styled.div`font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 2px;`;
flex: 1; const SettingDesc = styled.div`font-size: 12px; color: var(--text-secondary); line-height: 1.4;`;
min-width: 0; const SwitchWrapper = styled.label`position: relative; display: inline-block; width: 34px; height: 18px; flex-shrink: 0; cursor: pointer;`;
`; const SwitchInput = styled.input`opacity: 0; width: 0; height: 0; position: absolute;`;
const SettingLabel = styled.div`
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
`;
const SettingDesc = styled.div`
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
`;
// Toggle Switch
const SwitchWrapper = styled.label`
position: relative;
display: inline-block;
width: 34px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
`;
const SwitchInput = styled.input`
opacity: 0;
width: 0;
height: 0;
position: absolute;
`;
const Slider = styled.span<{ $checked: boolean }>` const Slider = styled.span<{ $checked: boolean }>`
position: absolute; position: absolute; inset: 0;
inset: 0;
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'}; border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
background: transparent; background: transparent; transition: border-color 0.2s; cursor: pointer;
transition: border-color 0.2s;
cursor: pointer;
&::after { &::after {
content: ''; content: ''; position: absolute; left: ${({ $checked }) => $checked ? '16px' : '2px'}; top: 2px;
position: absolute; width: 12px; height: 12px; background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--text-secondary)'};
left: ${({ $checked }) => $checked ? '16px' : '2px'};
top: 2px;
width: 12px;
height: 12px;
background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--text-secondary)'};
transition: left 0.2s, background 0.2s; transition: left 0.2s, background 0.2s;
} }
`; `;
const BracketInputGroup = styled.div`display: flex; align-items: center; gap: 4px; font-family: var(--font-mono); font-size: 14px; color: var(--text-secondary); flex-shrink: 0;`;
const BracketInputGroup = styled.div`
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-mono);
font-size: 14px;
color: var(--text-secondary);
flex-shrink: 0;
`;
const BracketInput = styled.input` const BracketInput = styled.input`
background: transparent; background: transparent; border: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 14px; text-align: center;
border: none; width: 72px; outline: none; padding: 2px 0; border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 14px;
text-align: center;
width: 60px;
outline: none;
padding: 2px 0;
border-bottom: 1px solid var(--border-color);
&:focus { border-bottom-color: var(--text-primary); } &:focus { border-bottom-color: var(--text-primary); }
`; `;
const ActionBar = styled.div`display: flex; justify-content: flex-end; gap: var(--space-md); padding-top: var(--space-xl); border-top: 1px solid var(--border-color);`;
const ActionBar = styled.div` const Toast = styled.div`margin-bottom: var(--space-lg); padding: var(--space-sm) var(--space-md); border: 1px solid var(--border-color); color: var(--text-secondary); font-size: 12px; font-family: var(--font-mono);`;
display: flex; const ErrorBox = styled.div`padding: var(--space-lg); border: 1px solid #5a2a2a; color: #ff9b9b; font-size: 13px; margin-bottom: var(--space-lg);`;
justify-content: flex-end; const SkeletonBox = styled.div`height: 16px; background: #1a1a1a; animation: ${pulse} 1.4s ease-in-out infinite;`;
gap: var(--space-md);
padding-top: var(--space-xl);
border-top: 1px solid var(--border-color);
`;
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) { function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
return ( return <SwitchWrapper onClick={onChange}><SwitchInput type="checkbox" checked={checked} onChange={() => {}} /><Slider $checked={checked} /></SwitchWrapper>;
<SwitchWrapper onClick={onChange}>
<SwitchInput type="checkbox" checked={checked} onChange={() => {}} />
<Slider $checked={checked} />
</SwitchWrapper>
);
} }
function BInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { function BInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return ( return <BracketInputGroup>[<BracketInput value={value} onChange={(e) => onChange(e.target.value)} />]</BracketInputGroup>;
<BracketInputGroup>
[<BracketInput value={value} onChange={(e) => onChange(e.target.value)} />]
</BracketInputGroup>
);
} }
export default function SettingsPage() { export default function SettingsPage() {
const [settings, setSettings] = useState({ const [settings, setSettings] = useState<Record<string, string>>(DEFAULT_SETTINGS);
twofa: true, const [loading, setLoading] = useState(true);
sessionTimeout: '15', const [saving, setSaving] = useState(false);
ipWhitelist: false, const [toast, setToast] = useState<string | null>(null);
autoBackup: true, const [error, setError] = useState<string | null>(null);
backupInterval: '08:00',
backupRetention: '30',
queueThreshold: '500',
autoScaling: true,
cpuThreshold: '90',
latencyThreshold: '250',
nodeOfflineAlert: true,
});
const toggle = (key: keyof typeof settings) => useEffect(() => {
setSettings((p) => ({ ...p, [key]: !p[key] })); let active = true;
const setVal = (key: keyof typeof settings, val: string) => adminFetch('/api/admin/settings')
setSettings((p) => ({ ...p, [key]: val })); .then((r) => r.ok ? r.json() : Promise.reject(new Error('load failed')))
.then((d) => { if (active) setSettings({ ...DEFAULT_SETTINGS, ...d }); })
.catch(() => { if (active) setError('데이터를 불러올 수 없습니다'); })
.finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, []);
const toggle = (key: string) => setSettings((p) => ({ ...p, [key]: p[key] === 'true' ? 'false' : 'true' }));
const setVal = (key: string, val: string) => setSettings((p) => ({ ...p, [key]: val }));
const save = async () => {
setSaving(true); setToast(null);
try {
const res = await adminFetch('/api/admin/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) });
if (!res.ok) throw new Error();
const d = await res.json();
setSettings(d);
setToast('설정 저장됨');
} catch {
setError('저장 실패');
} finally { setSaving(false); }
};
const reset = () => { setSettings(DEFAULT_SETTINGS); setToast('기본값 복원'); };
return ( return (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 'var(--space-lg)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 'var(--space-lg)' }}>
<PageTitle> </PageTitle> <PageTitle> </PageTitle>
<LabelMeta><span>CONFIG:</span>v2.0.4-STABLE</LabelMeta> <LabelMeta><span>CONFIG:</span>DB-LIVE</LabelMeta>
</div> </div>
{toast && <Toast>{toast}</Toast>}
{error && <ErrorBox>{error}</ErrorBox>}
{loading ? (
<SettingsGrid>
<SettingsSection><SectionTitle><span>LOADING</span></SectionTitle><SkeletonBox /><div style={{ height: 12 }} /><SkeletonBox /><div style={{ height: 12 }} /><SkeletonBox /></SettingsSection>
<SettingsSection><SectionTitle><span>LOADING</span></SectionTitle><SkeletonBox /><div style={{ height: 12 }} /><SkeletonBox /><div style={{ height: 12 }} /><SkeletonBox /></SettingsSection>
</SettingsGrid>
) : (
<>
<SettingsGrid> <SettingsGrid>
{/* SEC 01 */}
<SettingsSection> <SettingsSection>
<SectionTitle> <SectionTitle><span>ADMIN PROTOCOL RULES</span><LabelMeta>SEC: 01</LabelMeta></SectionTitle>
<span>ADMIN PROTOCOL RULES</span> <SettingsRow><SettingInfo><SettingLabel> 2 (2FA)</SettingLabel><SettingDesc> .</SettingDesc></SettingInfo><Toggle checked={settings.force2fa === 'true'} onChange={() => toggle('force2fa')} /></SettingsRow>
<LabelMeta>SEC: 01</LabelMeta> <SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> ().</SettingDesc></SettingInfo><BInput value={settings.sessionTimeoutMinutes} onChange={(v) => setVal('sessionTimeoutMinutes', v)} /></SettingsRow>
</SectionTitle> <SettingsRow><SettingInfo><SettingLabel>IP </SettingLabel><SettingDesc> .</SettingDesc></SettingInfo><Toggle checked={settings.ipWhitelistEnabled === 'true'} onChange={() => toggle('ipWhitelistEnabled')} /></SettingsRow>
<SettingsRow> <SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> .</SettingDesc></SettingInfo><Toggle checked={settings.nodeOfflineAlertEnabled === 'true'} onChange={() => toggle('nodeOfflineAlertEnabled')} /></SettingsRow>
<SettingInfo>
<SettingLabel> 2 (2FA)</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.twofa} onChange={() => toggle('twofa')} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> ().</SettingDesc>
</SettingInfo>
<BInput value={settings.sessionTimeout} onChange={(v) => setVal('sessionTimeout', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel>IP </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.ipWhitelist} onChange={() => toggle('ipWhitelist')} />
</SettingsRow>
</SettingsSection> </SettingsSection>
{/* SEC 02 */}
<SettingsSection> <SettingsSection>
<SectionTitle> <SectionTitle><span>BACKUP & THRESHOLDS</span><LabelMeta>SEC: 02</LabelMeta></SectionTitle>
<span>BACKUP &amp; REDUNDANCY</span> <SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> .</SettingDesc></SettingInfo><Toggle checked={settings.autoBackupEnabled === 'true'} onChange={() => toggle('autoBackupEnabled')} /></SettingsRow>
<LabelMeta>SEC: 02</LabelMeta> <SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> (HH:mm).</SettingDesc></SettingInfo><BInput value={settings.backupSchedule} onChange={(v) => setVal('backupSchedule', v)} /></SettingsRow>
</SectionTitle> <SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> .</SettingDesc></SettingInfo><BInput value={settings.retentionDays} onChange={(v) => setVal('retentionDays', v)} /></SettingsRow>
<SettingsRow> <SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> .</SettingDesc></SettingInfo><BInput value={settings.queueWarningThreshold} onChange={(v) => setVal('queueWarningThreshold', v)} /></SettingsRow>
<SettingInfo> <SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc>CPU .</SettingDesc></SettingInfo><Toggle checked={settings.autoScalingEnabled === 'true'} onChange={() => toggle('autoScalingEnabled')} /></SettingsRow>
<SettingLabel> </SettingLabel> <SettingsRow><SettingInfo><SettingLabel>CPU </SettingLabel><SettingDesc> CPU %.</SettingDesc></SettingInfo><BInput value={settings.cpuThresholdPercent} onChange={(v) => setVal('cpuThresholdPercent', v)} /></SettingsRow>
<SettingDesc> .</SettingDesc> <SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> ms.</SettingDesc></SettingInfo><BInput value={settings.latencyAlertMs} onChange={(v) => setVal('latencyAlertMs', v)} /></SettingsRow>
</SettingInfo>
<Toggle checked={settings.autoBackup} onChange={() => toggle('autoBackup')} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> ()</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.backupInterval} onChange={(v) => setVal('backupInterval', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> ()</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.backupRetention} onChange={(v) => setVal('backupRetention', v)} />
</SettingsRow>
</SettingsSection>
{/* SEC 03 */}
<SettingsSection>
<SectionTitle>
<span>SYNC QUEUE THRESHOLDS</span>
<LabelMeta>SEC: 03</LabelMeta>
</SectionTitle>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.queueThreshold} onChange={(v) => setVal('queueThreshold', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.autoScaling} onChange={() => toggle('autoScaling')} />
</SettingsRow>
</SettingsSection>
{/* SEC 04 */}
<SettingsSection>
<SectionTitle>
<span>NODE ALERT TRIGGERS</span>
<LabelMeta>SEC: 04</LabelMeta>
</SectionTitle>
<SettingsRow>
<SettingInfo>
<SettingLabel>CPU (%)</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.cpuThreshold} onChange={(v) => setVal('cpuThreshold', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> (ms)</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.latencyThreshold} onChange={(v) => setVal('latencyThreshold', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.nodeOfflineAlert} onChange={() => toggle('nodeOfflineAlert')} />
</SettingsRow>
</SettingsSection> </SettingsSection>
</SettingsGrid> </SettingsGrid>
<ActionBar><Btn onClick={reset}></Btn><BtnPrimary onClick={save} disabled={saving}>{saving ? '저장 중...' : '설정 저장'}</BtnPrimary></ActionBar>
<ActionBar> </>
<Btn onClick={() => setSettings((p) => ({ ...p }))}></Btn> )}
<BtnPrimary> </BtnPrimary>
</ActionBar>
</> </>
); );
} }

View File

@@ -1,211 +1,164 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { PageTitle, LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base'; import { LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent, Btn } from '@/components/ui/base';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
import SisterAvatar from '@/components/common/SisterAvatar';
import { SISTER_ROLES } from '@/lib/sisters'; import { SISTER_ROLES } from '@/lib/sisters';
import { adminFetch } from '@/lib/adminFetch';
import CodeEditor from '@/components/admin/CodeEditor';
import LogTerminal from '@/components/admin/LogTerminal';
const Breadcrumb = styled.div` const Breadcrumb = styled.div`font-family: var(--font-mono);font-size: 11px;color: var(--text-secondary);margin-bottom: var(--space-md);a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }`;
font-family: var(--font-mono); const Hero = styled.section`display:grid;grid-template-columns:128px 1fr;gap:var(--space-xl);align-items:center;padding:var(--space-xl);border:1px solid var(--border-color);margin-bottom:var(--space-xl);@media (max-width:767px){grid-template-columns:1fr;justify-items:start;}`;
font-size: 11px; const HeroMeta = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`;
color: var(--text-secondary); const HeroName = styled.div`font-size:36px;font-weight:700;letter-spacing:-0.03em;color:var(--text-primary);`;
margin-bottom: var(--space-md); const HeroRole = styled.div`font-size:14px;color:var(--text-secondary);font-family:var(--font-mono);`;
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } } const HeroStatus = styled.div`display:flex;align-items:center;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap;`;
`; const StatusDot = styled.div<{ $on: boolean }>`width:10px;height:10px;border-radius:50%;background:${({$on})=>$on?'#FFF':'#555'};${({$on})=>$on&&`box-shadow:0 0 8px rgba(255,255,255,0.3);`}`;
const HeroStats = styled.div`display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--space-md);margin-top:var(--space-lg);@media (max-width:767px){grid-template-columns:repeat(2,minmax(0,1fr));}`;
const DetailGrid = styled.div` const StatCard = styled.div`border:1px solid var(--border-color);padding:var(--space-md);`;
display: grid; const StatLabel = styled.div`font-size:10px;color:var(--text-secondary);font-family:var(--font-mono);margin-bottom:6px;`;
grid-template-columns: 280px 1fr; const StatValue = styled.div`font-size:18px;color:var(--text-primary);font-family:var(--font-mono);`;
gap: var(--space-xxl); const DetailGrid = styled.div`display:grid;grid-template-columns:320px 1fr;gap:var(--space-xxl);align-items:start;@media (max-width:1199px){grid-template-columns:1fr;gap:var(--space-xl);}`;
align-items: start; const MetaPanel = styled.div`display:flex;flex-direction:column;gap:var(--space-lg);`;
const MetaCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);`;
@media (max-width: 1199px) { const ConfigPre = styled.pre`background:#0d0d0d;border:1px solid var(--border-color);padding:var(--space-md);font-family:var(--font-mono);font-size:11px;color:#8888aa;overflow:auto;max-height:400px;white-space:pre-wrap;word-break:break-word;line-height:1.6;`;
grid-template-columns: 1fr; const TabBar = styled.div`display:flex;gap:var(--space-lg);border-bottom:1px solid var(--border-color);margin-bottom:var(--space-lg);overflow:auto;`;
gap: var(--space-xl); const TabBtn = styled.button<{ $active: boolean }>`font-size:13px;font-weight:500;color:${({$active})=>$active?'var(--text-primary)':'var(--text-secondary)'};background:transparent;border:none;border-bottom:1px solid ${({$active})=>$active?'var(--text-primary)':'transparent'};padding:var(--space-sm) 0;cursor:pointer;transition:color .15s,border-color .15s;white-space:nowrap;&:hover{color:var(--text-primary);}`;
} const Toolbar = styled.div`display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;`;
`; 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;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;`;
const MetaPanel = styled.div` 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)'};margin-bottom:12px;`;
display: flex; const SessionCard = styled.div`border:1px solid var(--border-color);padding:var(--space-md);margin-bottom:var(--space-sm);`;
flex-direction: column; const SISTER_DISPLAY: Record<string, string> = { harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이' };
gap: var(--space-lg); const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
`;
const MetaCard = styled.div`
border: 1px solid var(--border-color);
padding: var(--space-lg);
`;
const StatusRow = styled.div`
display: flex;
align-items: center;
gap: var(--space-sm);
`;
const StatusDot = styled.div<{ $on: boolean }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $on }) => $on ? '#FFF' : '#555'};
${({ $on }) => $on && `box-shadow: 0 0 8px rgba(255,255,255,0.3);`}
`;
const ConfigPre = styled.pre`
background: #0d0d0d;
border: 1px solid var(--border-color);
padding: var(--space-md);
font-family: var(--font-mono);
font-size: 11px;
color: #8888aa;
overflow: auto;
max-height: 400px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
`;
const TabBar = styled.div`
display: flex;
gap: var(--space-lg);
border-bottom: 1px solid var(--border-color);
margin-bottom: var(--space-lg);
`;
const TabBtn = styled.button<{ $active: boolean }>`
font-size: 13px;
font-weight: 500;
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
background: transparent;
border: none;
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
padding: var(--space-sm) 0;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
&:hover { color: var(--text-primary); }
`;
const SISTER_DISPLAY: Record<string, string> = {
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
};
export default function SisterDetailPage() { export default function SisterDetailPage() {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
const [sisterInfo, setSisterInfo] = useState<any>(null); const [sisterInfo, setSisterInfo] = useState<any>(null);
const [systemInfo, setSystemInfo] = useState<any>(null);
const [configData, setConfigData] = useState<any>(null); const [configData, setConfigData] = useState<any>(null);
const [activity, setActivity] = useState<any[]>([]); const [activity, setActivity] = useState<any[]>([]);
const [sessions, setSessions] = useState<any[]>([]);
const [tab, setTab] = useState('overview'); const [tab, setTab] = useState('overview');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [logLines, setLogLines] = useState<string[]>([]);
const [logLoading, setLogLoading] = useState(false);
const [lines, setLines] = useState(100);
const [selectedFile, setSelectedFile] = useState('SOUL.md');
const [content, setContent] = useState('');
const [saved, setSaved] = useState('');
const [saveResult, setSaveResult] = useState<{ success: boolean; msg: string } | null>(null);
useEffect(() => { useEffect(() => {
Promise.allSettled([ Promise.allSettled([
fetch(`${API_URL}/api/sisters`), fetch(`${API_URL}/api/sisters`),
fetch(`${API_URL}/api/sisters/${name}/config`), fetch(`${API_URL}/api/sisters/${name}/config`),
fetch(`${API_URL}/api/sisters/${name}/activity`), fetch(`${API_URL}/api/sisters/${name}/activity`),
]).then(([sRes, cRes, aRes]) => { fetch(`${API_URL}/api/sisters/${name}/system`),
if (sRes.status === 'fulfilled' && sRes.value.ok) { fetch(`${API_URL}/api/sisters/${name}/sessions`),
sRes.value.json().then((d: any[]) => setSisterInfo(d.find((s) => s.name === name))); ]).then(([sRes, cRes, aRes, syRes, ssRes]) => {
} if (sRes.status === 'fulfilled' && sRes.value.ok) sRes.value.json().then((d: any[]) => setSisterInfo(d.find((s) => s.name === name)));
if (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then(setConfigData); if (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then(setConfigData);
if (aRes.status === 'fulfilled' && aRes.value.ok) { if (aRes.status === 'fulfilled' && aRes.value.ok) aRes.value.json().then((d: any) => setActivity(d.items ?? []));
aRes.value.json().then((d: any) => setActivity(d.items ?? [])); if (syRes.status === 'fulfilled' && syRes.value.ok) syRes.value.json().then(setSystemInfo);
} if (ssRes.status === 'fulfilled' && ssRes.value.ok) ssRes.value.json().then((d: any) => setSessions(d.sessions ?? d.items ?? []));
}).finally(() => setLoading(false)); }).finally(() => setLoading(false));
}, [name]); }, [name]);
const fetchLogs = useCallback(async () => {
setLogLoading(true);
try {
const res = await adminFetch(`/api/admin/logs/${name}?lines=${lines}`);
const d = await res.json();
setLogLines(d.lines ?? []);
} catch {
setLogLines(['(로그 로드 실패)']);
} finally {
setLogLoading(false);
}
}, [name, lines]);
useEffect(() => { if (tab === 'logs') fetchLogs(); }, [tab, fetchLogs]);
useEffect(() => {
if (tab !== 'harness') return;
setSaveResult(null);
adminFetch(`/api/admin/harness/${name}/${selectedFile}`)
.then((r) => r.json())
.then((d) => { setContent(d.content ?? ''); setSaved(d.content ?? ''); })
.catch(() => setContent('(로드 실패)'));
}, [tab, name, selectedFile]);
const handleSave = async () => {
setSaveResult(null);
try {
const res = await adminFetch(`/api/admin/harness/${name}/${selectedFile}`, { method: 'PUT', body: JSON.stringify({ content }) });
const d = await res.json();
if (d.success) { setSaved(content); setSaveResult({ success: true, msg: '저장 완료' }); }
else setSaveResult({ success: false, msg: d.error ?? '저장 실패' });
} catch (e) {
setSaveResult({ success: false, msg: (e as Error).message });
}
};
const isActive = sisterInfo?.status === 'online' || sisterInfo?.status === 'working'; const isActive = sisterInfo?.status === 'online' || sisterInfo?.status === 'working';
return ( return (
<> <>
<Breadcrumb> <Breadcrumb><Link href="/sisters"> </Link> / {SISTER_DISPLAY[name] ?? name}</Breadcrumb>
<Link href="/sisters"> </Link> / {SISTER_DISPLAY[name] ?? name} {loading ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div> : <>
</Breadcrumb> <Hero>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}> <SisterAvatar name={name} size={120} />
<PageTitle> <HeroMeta>
{SISTER_DISPLAY[name] ?? name} <HeroName>{SISTER_DISPLAY[name] ?? name}</HeroName>
<span style={{ fontSize: '16px', color: 'var(--text-secondary)', fontWeight: 400, marginLeft: 'var(--space-sm)' }}> <HeroRole>{SISTER_ROLES[name] ?? 'UNKNOWN'}</HeroRole>
/ {SISTER_ROLES[name] ?? 'UNKNOWN'} <HeroStatus>
</span> <StatusDot $on={isActive} />
</PageTitle> <LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
<LabelMeta><span>STATUS:</span>{sisterInfo?.status?.toUpperCase() ?? 'UNKNOWN'}</LabelMeta> <LabelMeta><span>LXC:</span>{sisterInfo?.lxcId ?? '--'}</LabelMeta>
</div> </HeroStatus>
<HeroStats>
<StatCard><StatLabel>UPTIME</StatLabel><StatValue>{systemInfo?.uptime ?? '--:--:--'}</StatValue></StatCard>
<StatCard><StatLabel>CPU</StatLabel><StatValue>{systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}%</StatValue></StatCard>
<StatCard><StatLabel>MEM</StatLabel><StatValue>{systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB</StatValue></StatCard>
<StatCard><StatLabel>DISK</StatLabel><StatValue>{systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'}</StatValue></StatCard>
</HeroStats>
</HeroMeta>
</Hero>
{loading ? (
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div>
) : (
<DetailGrid> <DetailGrid>
<MetaPanel> <MetaPanel>
<MetaCard> <MetaCard>
<StatusRow> <LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta>
<StatusDot $on={isActive} /> <div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
</StatusRow>
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
LXC: {sisterInfo?.lxcId ?? '--'}<br />
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br /> LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
ROLE: {SISTER_ROLES[name] ?? '--'} ROLE: {SISTER_ROLES[name] ?? '--'}<br />
STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()}<br />
HOST_USER: {sisterInfo?.user ?? '--'}
</div> </div>
<TechBar> <TechBar><TechBarFill $width={isActive ? 100 : 0} /></TechBar>
<TechBarFill $width={isActive ? 100 : 0} />
</TechBar>
</MetaCard> </MetaCard>
{configData?.description && <MetaCard><LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta><div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>{configData.description}</div></MetaCard>}
{configData?.description && (
<MetaCard>
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
{configData.description}
</div>
</MetaCard>
)}
</MetaPanel> </MetaPanel>
<div> <div>
<TabBar> <TabBar>
{[ {[{ id: 'overview', label: '개요' }, { id: 'sessions', label: '세션' }, { id: 'activity', label: '활동' }, { id: 'harness', label: '하네스' }, { id: 'logs', label: '로그' }, { id: 'config', label: '설정' }].map((t) => <TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>{t.label}</TabBtn>)}
{ id: 'overview', label: '개요' },
{ id: 'config', label: '설정' },
].map((t) => (
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
{t.label}
</TabBtn>
))}
</TabBar> </TabBar>
{tab === 'overview' && ( {tab === 'overview' && <Timeline>{activity.slice(0, 8).map((item) => <TimelineItem key={item.id}><TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp><TimelineContent>{item.detail ?? item.action}</TimelineContent></TimelineItem>)}</Timeline>}
<> {tab === 'sessions' && <>{sessions.length === 0 ? <div style={{ color: 'var(--text-secondary)' }}> </div> : sessions.map((s: any, i: number) => <SessionCard key={i}><div style={{ color: 'var(--text-primary)', fontSize: '13px' }}>{s.title ?? s.label ?? s.id ?? 'session'}</div><div style={{ color: 'var(--text-secondary)', fontSize: '11px', fontFamily: 'var(--font-mono)' }}>{s.status ?? ''}</div></SessionCard>)}</>}
<SectionTitle> {tab === 'activity' && <Timeline>{activity.map((item) => <TimelineItem key={item.id}><TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp><TimelineContent>{item.detail ?? item.action}</TimelineContent></TimelineItem>)}</Timeline>}
<span>RECENT ACTIVITY</span> {tab === 'harness' && <><Toolbar><Select value={selectedFile} onChange={(e) => setSelectedFile(e.target.value)}>{FILES.map((f) => <option key={f} value={f}>{f}</option>)}</Select><Btn onClick={() => { setContent(saved); setSaveResult(null); }}></Btn><Btn onClick={handleSave}></Btn></Toolbar>{saveResult && <ResultMsg $success={saveResult.success}>{saveResult.msg}</ResultMsg>}<CodeEditor value={content} onChange={setContent} /></>}
<LabelMeta>{activity.length} ENTRIES</LabelMeta> {tab === 'logs' && <><Toolbar><span style={{ color: 'var(--text-secondary)', fontSize: '12px' }}></span><LinesInput type="number" value={lines} onChange={(e) => setLines(parseInt(e.target.value, 10) || 100)} min={10} max={500} /><span style={{ color: 'var(--text-secondary)', fontSize: '12px' }}></span><Btn onClick={fetchLogs}>{logLoading ? '로딩 중...' : '로그 가져오기'}</Btn></Toolbar><LogTerminal lines={logLines} /></>}
</SectionTitle> {tab === 'config' && <><SectionTitle>SOUL.md / AGENTS.md</SectionTitle><ConfigPre>{configData?.raw || '(설정 파일 없음)'}</ConfigPre></>}
<Timeline>
{activity.slice(0, 8).map((item) => (
<TimelineItem key={item.id}>
<TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp>
<TimelineContent>{item.detail ?? item.action}</TimelineContent>
</TimelineItem>
))}
{activity.length === 0 && (
<TimelineItem>
<TimelineContent style={{ color: 'var(--text-secondary)' }}> </TimelineContent>
</TimelineItem>
)}
</Timeline>
</>
)}
{tab === 'config' && (
<>
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle>
<ConfigPre>{configData?.raw || '(설정 파일 없음)'}</ConfigPre>
</>
)}
</div> </div>
</DetailGrid> </DetailGrid>
)} </>}
</> </>
); );
} }

View File

@@ -1,191 +1,78 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled, { keyframes } from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base'; import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
import SisterAvatar from '@/components/common/SisterAvatar';
const SISTER_ROLES: Record<string, string> = { const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
harang: 'Primary', const SISTER_ROLES: Record<string, string> = { harang: 'Primary', narang: 'Secondary', darang: 'Standby', erang: 'Sync' };
narang: 'Secondary', const NodeGrid = styled.section`display:flex;flex-direction:column;gap:var(--space-lg);`;
darang: 'Standby', const NodeEntry = styled(Link)`display:grid;grid-template-columns:220px 1fr 180px;gap:0;border:1px solid var(--border-color);transition:border-color .2s, background .15s;text-decoration:none;&:hover{border-color:var(--border-hover);background:#111}@media (max-width:1199px){grid-template-columns:200px 1fr}@media (max-width:767px){grid-template-columns:1fr}`;
erang: 'Sync', const NodeInfo = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:767px){border-right:none;border-bottom:1px solid var(--border-color)}`;
}; const NodeName = styled.div`display:flex;align-items:center;gap:var(--space-sm);font-size:16px;font-weight:600;color:var(--text-primary);`;
const StatusIndicator = styled.div<{ $active: boolean }>`width:8px;height:8px;border-radius:50%;background:${({$active})=>$active?'#FFF':'#555'};flex-shrink:0;${({$active})=>$active&&`box-shadow:0 0 8px rgba(255,255,255,.3);`}`;
const MetaPair = styled.div`display:flex;flex-direction:column;gap:2px;`;
const MetaVal = styled.div`font-size:13px;color:var(--text-primary);font-family:var(--font-mono);`;
const NodeControls = styled.div`display:flex;gap:var(--space-sm);flex-wrap:wrap;`;
const CapacitySection = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:1199px){border-right:none}@media (max-width:767px){border-bottom:1px solid var(--border-color)}`;
const BarWrap = styled.div`display:flex;flex-direction:column;gap:8px;`;
const BarRow = styled.div`display:flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);`;
const Bar = styled.div`flex:1;height:8px;background:#151515;border:1px solid #222;overflow:hidden;`;
const Fill = styled.div<{ $width:number }>`height:100%;width:${({$width})=>$width}%;background:var(--text-primary);min-width:${({$width})=>$width > 0 ? '2px' : '0'};`;
const OpsMeta = styled.div`padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-sm);@media (max-width:1199px){display:none}`;
const MetaRow = styled.div`display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);padding:2px 0;gap:var(--space-md);`;
const Skeleton = styled.div`height:120px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`;
const ErrorBox = styled.div`padding:var(--space-lg);border:1px solid #5a2a2a;color:#ff9b9b;`;
const GRAPH_BARS: Record<string, number[]> = { function getNodeStatus(s: any): boolean { return s.status === 'online' || s.status === 'working'; }
harang: [40, 45, 60, 55, 70, 85, 92, 84], function getStatusBtns(s: any): [string, string] { if (s.status === 'offline') return ['STANDBY', 'ACTIVATE']; return ['ACTIVE', 'REBOOT']; }
narang: [30, 32, 35, 40, 38, 45, 50, 48], function diskPercent(disk: any): number {
darang: [10, 10, 5, 5, 5, 5, 2, 2], if (!disk?.used || !disk?.total) return 0;
erang: [20, 25, 15, 30, 40, 35, 20, 15], const parse = (v: string) => {
}; const m = String(v).match(/([0-9.]+)\s*([KMGTP]?)/i);
if (!m) return 0;
const NodeGrid = styled.section` const n = parseFloat(m[1]);
display: flex; const unit = (m[2] || 'G').toUpperCase();
flex-direction: column; const scale: Record<string, number> = { K: 1/1024/1024, M: 1/1024, G: 1, T: 1024, P: 1024*1024, '': 1 };
gap: var(--space-lg); return n * (scale[unit] ?? 1);
`; };
const used = parse(disk.used);
const NodeEntry = styled.div` const total = parse(disk.total);
display: grid; if (!isFinite(used) || !isFinite(total) || total <= 0) return 0;
grid-template-columns: 220px 1fr 160px; return Math.max(0, Math.min(100, (used / total) * 100));
gap: 0;
border: 1px solid var(--border-color);
transition: border-color 0.2s;
&:hover { border-color: var(--border-hover); }
@media (max-width: 1199px) {
grid-template-columns: 200px 1fr;
}
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
`;
const NodeInfo = styled.div`
border-right: 1px solid var(--border-color);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
@media (max-width: 767px) {
border-right: none;
border-bottom: 1px solid var(--border-color);
}
`;
const NodeName = styled(Link)`
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
text-decoration: none;
transition: color 0.15s;
&:hover { color: var(--accent-hover); }
`;
const StatusIndicator = styled.div<{ $active: boolean }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $active }) => $active ? '#FFF' : '#555'};
flex-shrink: 0;
${({ $active }) => $active && `box-shadow: 0 0 8px rgba(255,255,255,0.3);`}
`;
const MetaPair = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
`;
const MetaVal = styled.div`
font-size: 13px;
color: var(--text-primary);
font-family: var(--font-mono);
`;
const NodeControls = styled.div`
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
`;
const CapacitySection = styled.div`
border-right: 1px solid var(--border-color);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
@media (max-width: 1199px) {
border-right: none;
}
@media (max-width: 767px) {
border-bottom: 1px solid var(--border-color);
}
`;
const GraphContainer = styled.div`
display: flex;
align-items: flex-end;
gap: 3px;
height: 48px;
`;
const GraphBar = styled.div<{ $height: number; $highlight?: boolean }>`
flex: 1;
height: ${({ $height }) => $height}%;
min-height: 2px;
background: ${({ $highlight }) => $highlight ? 'var(--text-primary)' : 'var(--border-hover)'};
transition: height 0.3s;
`;
const SyncRow = styled.div`
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
padding: 2px 0;
`;
const SyncHistory = styled.div`
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-sm);
@media (max-width: 1199px) {
display: none;
}
`;
function getNodeStatus(s: any): boolean {
return s.status === 'online' || s.status === 'working';
} }
function getStatusBtns(s: any): [string, string] {
if (s.status === 'offline') return ['STANDBY', 'ACTIVATE'];
if (s.status === 'working') return ['ACTIVE', 'REBOOT'];
return ['ACTIVE', 'REBOOT'];
}
function formatLastSeen(lastSeen: string | null): string {
if (!lastSeen) return '—';
const diff = Date.now() - new Date(lastSeen).getTime();
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ago`;
}
const MOCK_SISTERS = [
{ id: 1, name: 'harang', status: 'online', lastSeen: new Date(Date.now() - 3600000).toISOString() },
{ id: 2, name: 'narang', status: 'working', lastSeen: new Date(Date.now() - 1800000).toISOString() },
{ id: 3, name: 'darang', status: 'offline', lastSeen: new Date(Date.now() - 7200000).toISOString() },
{ id: 4, name: 'erang', status: 'online', lastSeen: new Date(Date.now() - 900000).toISOString() },
];
export default function SistersPage() { export default function SistersPage() {
const [sisters, setSisters] = useState<any[]>(MOCK_SISTERS); const [sisters, setSisters] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetch(`${API_URL}/api/sisters`) let active = true;
.then((r) => r.json()) const load = async () => {
.then(setSisters) try {
.catch(() => {}); const base = await fetch(`${API_URL}/api/sisters`).then((r) => r.ok ? r.json() : Promise.reject());
const iv = setInterval(() => { const withSystem = await Promise.all(base.map(async (s: any) => {
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {}); try {
}, 15000); const system = await fetch(`${API_URL}/api/sisters/${s.name}/system`).then((r) => r.ok ? r.json() : null);
return () => clearInterval(iv); return { ...s, ...(system ?? {}) };
} catch {
return { ...s, uptime: '00:00:00', cpu: 0, memory: { used: 0, total: 0 }, disk: { used: '0G', total: '0G' } };
}
}));
if (active) { setSisters(withSystem); setError(null); }
} catch {
if (active) setError('데이터를 불러올 수 없습니다');
} finally {
if (active) setLoading(false);
}
};
load();
const iv = setInterval(load, 15000);
return () => { active = false; clearInterval(iv); };
}, []); }, []);
return ( return (
@@ -194,67 +81,37 @@ export default function SistersPage() {
<PageTitle> </PageTitle> <PageTitle> </PageTitle>
<LabelMeta><span>TOTAL NODES:</span> {String(sisters.length).padStart(2, '0')}</LabelMeta> <LabelMeta><span>TOTAL NODES:</span> {String(sisters.length).padStart(2, '0')}</LabelMeta>
</div> </div>
{error && <ErrorBox>{error}</ErrorBox>}
<NodeGrid> <NodeGrid>
{sisters.map((s) => { {loading ? Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} />) : sisters.map((s) => {
const isActive = getNodeStatus(s); const isActive = getNodeStatus(s);
const [btn1, btn2] = getStatusBtns(s); const [btn1, btn2] = getStatusBtns(s);
const bars = GRAPH_BARS[s.name] ?? [5, 5, 5, 5, 5, 5, 5, 5];
const loadIndex = isActive
? `${(bars[bars.length - 1]).toFixed(1)}%`
: '0.0%';
const metaLabel = s.status === 'offline' ? 'Last Active' : s.name === 'erang' ? 'Queue Depth' : 'Uptime';
const metaVal = s.status === 'offline'
? formatLastSeen(s.lastSeen)
: s.name === 'erang' ? '12 PKTS'
: formatLastSeen(s.lastSeen).replace(' ago', '');
return ( return (
<NodeEntry key={s.id ?? s.name}> <NodeEntry key={s.id ?? s.name} href={`/sisters/${s.name}`}>
<NodeInfo> <NodeInfo>
<NodeName href={`/sisters/${s.name}`}> <NodeName>
<StatusIndicator $active={isActive} /> <StatusIndicator $active={isActive} />
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'}{' '} <SisterAvatar name={s.name} size={28} />
({SISTER_ROLES[s.name] ?? s.name}) {s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'} ({SISTER_ROLES[s.name] ?? s.name})
</NodeName> </NodeName>
<MetaPair> <MetaPair><LabelMeta>Uptime</LabelMeta><MetaVal>{s.uptime ?? '00:00:00'}</MetaVal></MetaPair>
<LabelMeta>{metaLabel}</LabelMeta> <MetaPair><LabelMeta>Last Seen</LabelMeta><MetaVal>{s.lastSeen ? new Date(s.lastSeen).toLocaleString('ko-KR') : '—'}</MetaVal></MetaPair>
<MetaVal>{metaVal}</MetaVal> <NodeControls><BtnToggle>{btn1}</BtnToggle><BtnToggle>{btn2}</BtnToggle></NodeControls>
</MetaPair>
<NodeControls>
<BtnToggle $active>{btn1}</BtnToggle>
<BtnToggle>{btn2}</BtnToggle>
</NodeControls>
</NodeInfo> </NodeInfo>
<CapacitySection> <CapacitySection>
<LabelMeta>CAPACITY HISTORY (24H)</LabelMeta> <BarWrap>
<GraphContainer> <BarRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span><Bar><Fill $width={Math.max(0, Math.min(100, Number(s.cpu ?? 0)))} /></Bar></BarRow>
{bars.map((h, i) => ( <BarRow><span>MEM</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span><Bar><Fill $width={s.memory?.total ? Math.max(0, Math.min(100, (s.memory.used / s.memory.total) * 100)) : 0} /></Bar></BarRow>
<GraphBar key={i} $height={h} $highlight={i >= bars.length - 2} /> <BarRow><span>DSK</span><span>{s.disk?.used ?? '0G'}/{s.disk?.total ?? '0G'}</span><Bar><Fill $width={diskPercent(s.disk)} /></Bar></BarRow>
))} </BarWrap>
</GraphContainer>
<SyncRow>
<span>LOAD INDEX</span>
<span>{loadIndex}</span>
</SyncRow>
</CapacitySection> </CapacitySection>
<OpsMeta>
<SyncHistory> <MetaRow><span>STATUS</span><span>{String(s.status).toUpperCase()}</span></MetaRow>
<LabelMeta>SYNC LOG</LabelMeta> <MetaRow><span>ROLE</span><span>{String(SISTER_ROLES[s.name] ?? s.name).toUpperCase()}</span></MetaRow>
{[ <MetaRow><span>LXC ID</span><span>{s.lxcId ?? '--'}</span></MetaRow>
{ time: '14:05:22', status: isActive ? 'SUCCESS' : 'IDLE' }, <MetaRow><span>LAST CHECK</span><span>{s.lastSeen ? 'just synced' : '--'}</span></MetaRow>
{ time: '13:55:01', status: isActive ? 'SUCCESS' : 'IDLE' }, <MetaRow><span>CURRENT TASK</span><span>{s.currentTask ?? '-'}</span></MetaRow>
{ time: '13:44:10', status: isActive ? 'SUCCESS' : 'IDLE' }, </OpsMeta>
].map((row) => (
<SyncRow key={row.time}>
<span>{row.time}</span>
<span style={{ color: row.status === 'SUCCESS' ? '#00FF00' : 'var(--text-secondary)' }}>
{row.status}
</span>
</SyncRow>
))}
</SyncHistory>
</NodeEntry> </NodeEntry>
); );
})} })}

View File

@@ -5,7 +5,7 @@ import { useRouter, usePathname } from 'next/navigation';
import { AuthProvider, useAuth } from '@/lib/AuthContext'; import { AuthProvider, useAuth } from '@/lib/AuthContext';
import LayoutShell from './LayoutShell'; import LayoutShell from './LayoutShell';
const PUBLIC_PATHS = ['/login', '/register']; const PUBLIC_PATHS = ['/login'];
function AuthGate({ children }: { children: React.ReactNode }) { function AuthGate({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth(); const { isAuthenticated, loading } = useAuth();

View File

@@ -0,0 +1,66 @@
'use client';
import React, { useState } from 'react';
import styled from 'styled-components';
import { API_URL } from '@/lib/config';
const SISTER_INITIALS: Record<string, string> = {
harang: '하',
narang: '나',
darang: '다',
erang: '이',
};
const ImgEl = styled.img<{ $size: number }>`
width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--border-color);
background: #111;
display: block;
flex-shrink: 0;
`;
const Fallback = styled.div<{ $size: number }>`
width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
border-radius: 50%;
background: #222;
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: ${({ $size }) => Math.round($size * 0.4)}px;
color: var(--text-secondary);
flex-shrink: 0;
user-select: none;
`;
interface SisterAvatarProps {
name: string;
size?: number;
className?: string;
style?: React.CSSProperties;
}
export default function SisterAvatar({ name, size = 32, className, style }: SisterAvatarProps) {
const [failed, setFailed] = useState(false);
const initial = SISTER_INITIALS[name] ?? name.slice(0, 1).toUpperCase();
if (failed) {
return <Fallback $size={size} className={className} style={style}>{initial}</Fallback>;
}
return (
<ImgEl
$size={size}
src={`${API_URL}/api/sisters/${name}/avatar`}
alt={name}
className={className}
style={style}
onError={() => setFailed(true)}
/>
);
}

View File

@@ -41,8 +41,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setUser({ userId: data.id, username: data.username, role: data.role }); setUser({ userId: data.id, username: data.username, role: data.role });
} else if (res.status === 401) {
// access token 만료 → refresh 시도
const refreshToken = localStorage.getItem('hanarang_refresh_token');
if (refreshToken) {
try {
const rRes = await fetch(`${API_URL}/api/auth/refresh`, {
method: 'POST',
headers: { 'x-refresh-token': refreshToken },
});
if (rRes.ok) {
const d = await rRes.json();
localStorage.setItem('hanarang_access_token', d.accessToken);
if (d.refreshToken) localStorage.setItem('hanarang_refresh_token', d.refreshToken);
setUser({ userId: 0, username: d.username, role: d.role });
} else { } else {
localStorage.removeItem('hanarang_access_token'); localStorage.removeItem('hanarang_access_token');
localStorage.removeItem('hanarang_refresh_token');
}
} catch { /* silent */ }
} else {
localStorage.removeItem('hanarang_access_token');
}
} }
} catch { } catch {
// silent // silent
@@ -70,6 +90,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (d.refreshToken) { if (d.refreshToken) {
localStorage.setItem('hanarang_refresh_token', d.refreshToken); localStorage.setItem('hanarang_refresh_token', d.refreshToken);
} }
// me API로 실제 userId 가져오기
try {
const meRes = await fetch(`${API_URL}/api/auth/me`, {
headers: { Authorization: `Bearer ${d.accessToken}` },
});
if (meRes.ok) {
const me = await meRes.json();
setUser({ userId: me.id, username: me.username, role: me.role });
return;
}
} catch { /* fallback */ }
setUser({ userId: 0, username: d.username, role: d.role }); setUser({ userId: 0, username: d.username, role: d.role });
}; };

View File

@@ -1,21 +1,15 @@
/** /**
* 관리자 API 호출 유틸. * 관리자 API 호출 유틸 — JWT 전용.
* - CSR: 상대경로 /api/... → Next.js rewrites → BE * 로그인 후 localStorage의 access token을 Authorization 헤더에 설정한다.
* - JWT access token (우선) 또는 API Key fallback으로 Authorization 헤더 설정
*/ */
export function getAdminToken(): string { export function getAdminToken(): string {
if (typeof window === 'undefined') return ''; if (typeof window === 'undefined') return '';
return ( return localStorage.getItem('hanarang_access_token') ?? '';
localStorage.getItem('hanarang_access_token') ??
localStorage.getItem('hanarang_admin_key') ??
''
);
} }
export function adminFetch(path: string, options?: RequestInit) { export function adminFetch(path: string, options?: RequestInit) {
const token = getAdminToken(); const token = getAdminToken();
// Authorization: options?.headers보다 우선 (덮어쓰기 방지)
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(options?.headers as Record<string, string> ?? {}), ...(options?.headers as Record<string, string> ?? {}),