Compare commits

..

1 Commits

Author SHA1 Message Date
42397fa84e qa: add hotfix-006 review iteration 4 2026-04-05 17:06:16 +09:00
43 changed files with 1253 additions and 1981 deletions

View File

@@ -1,38 +1,45 @@
# 하나랑 대시보드 — 실행 개요 # 하나랑 대시보드 — 프로젝트 개요
## 프로젝트 목표 ## 목표
- 4자매 운영 상태를 실시간으로 보여준다 4자매 멀티에이전트 파이프라인 관제 대시보드.
- 프로젝트/Sprint/Hotfix/QA/Deploy 흐름을 시각화한다 자기야가 한눈에 전체 현황을 파악하고 관리할 수 있는 화면.
- `main`이 항상 배포 가능 상태라는 원칙을 UI와 운영에 함께 반영한다
## 현재 표준 구조 ## 기술 스택
- 루트: `README.md`, `ARCHITECTURE.md`
- 실행 문서: `.plans/`
- `design/`
- `sprints/`
- `hotfix/`
- `qa/`
- `deploy/`
- 장기 문서: `docs/`
## 활성 작업 | 레이어 | 기술 | 비고 |
- **SPRINT-014**: Hermes 전환 + 프로젝트 IA/파일 구조 리뉴얼 |--------|------|------|
| Frontend | Next.js + styled-components | styled-components는 return 아래에 적재 |
| Backend | Nest.js + Prisma | REST API |
| DB | MariaDB | Docker LXC 103 (10.10.10.146:33006) |
| 도메인 (FE) | `hanarang.nabomhalang.co.kr` | Nginx LXC 101에서 프록시 |
| 도메인 (BE) | `hanarang-api.nabomhalang.co.kr` | Nginx LXC 101에서 프록시 |
## 이번 Sprint 핵심 요구사항 ## 아키텍처
1. OpenClaw → Hermes 전환 후 자매 상태가 전부 offline으로 보이는 문제 해결
2. 자매 프로필 사진 탐색 경로/fallback 재정비
3. 변경된 파일 구조에 맞춘 프로젝트 진행률 / Sprint / Hotfix UI 개편
4. 프로젝트를 네비게이션 두 번째 페이지로 고정
5. 다랑 QA 완료 후 `main`이 배포 상태가 되고, 이랑은 최신 `main`을 pull해서 재배포만 수행하도록 흐름 고정
## 이행 전략 ```
- 코드에서는 구 구조와 신 구조를 일정 기간 동시 지원 [사용자] → hanarang.nabomhalang.co.kr → [Nginx LXC 101] → [Next.js FE :3004]
- 문서는 신 구조를 기준으로 선반영 ↓ API 호출
- 신규 QA 문서는 `.plans/qa/` 기준 [사용자] → hanarang-api.nabomhalang.co.kr → [Nginx LXC 101] → [Nest.js BE :3005]
- 신규 Hotfix 문서는 `.plans/hotfix/` 기준 ↓ SSH
[4자매 LXC 서버]
↓ API
[Gitea API]
[MariaDB LXC 103]
```
## 문서 맵 ## 자매 서버 정보
- 구조 기준: `../ARCHITECTURE.md` | 이름 | IP | 사용자 | LXC |
- 디자인 인덱스: `./design/index.md` |------|-----|--------|-----|
- Sprint 계획: `./sprints/SPRINT-014.md` | 하랑이 | 10.10.10.112 | harang | 104 |
- 배포 플로우: `./deploy/main-release-flow.md` | 나랑이 | 10.10.10.216 | narang | 105 |
| 다랑이 | 10.10.10.136 | darang | 106 |
| 이랑이 | 10.10.10.163 | erang | 107 |
## 디자인 방향
- 다크 테마 관제 화면
- 카드 기반 레이아웃
- 상태 색상: 온라인=초록(#00E676), 오프라인=빨강(#FF1744), 작업중=파랑(#2979FF)
- glassmorphism 포인트
- 미니멀 아이콘
- 정보 밀도 높은 UI

View File

@@ -1,40 +0,0 @@
# Main Release Flow
## 원칙
- `main` = 배포 가능 상태
- 다랑 QA가 끝나기 전에는 `main` merge 금지
- 이랑은 feature branch를 배포하지 않고, QA 통과 후 merge된 최신 `main`만 배포
## 역할별 절차
### 1. 하랑
- 문서 작성 및 선 push
- 나랑/다랑/이랑 핸드오프 관리
- QA 결과 확인
- `main` merge 최종 판단
### 2. 나랑
- feature/hotfix branch에서 구현
- 구현 후 원격 branch push
- 다랑에게 QA 요청
### 3. 다랑
- 나랑 branch pull
- QA 수행
- `.plans/qa/`에 결과 md 작성
- `passed/failed + errors[]`를 하랑에게 전달
### 4. 하랑
- `passed = true` 확인 후 `main` merge
- merge 완료 시점부터 `main`은 배포 상태
- 이랑에게 재배포 요청
### 5. 이랑
- 서버에서 최신 `main` pull
- install/build/restart 등 재배포만 수행
- 추가 수정 없이 운영 반영
## 금지
- QA 전 `main` merge
- 이랑이 feature branch 직접 배포
- QA 결과 없는 배포
- 문서 없이 긴급 hotfix 진행

View File

@@ -1,23 +0,0 @@
# 디자인 문서 인덱스
## 시스템
- `DESIGN-SYSTEM.md`
- `architecture.md`
- `api-design.md`
- `db-schema.md`
## 페이지별 UI
- `ui/dashboard-design.md`
- `ui/projects-page-design.md`
- `ui/project-detail-design.md`
- `ui/sister-detail-design.md`
- `ui/activity-log-design.md`
- `ui/org-design.md`
- `ui/settings-design.md`
- `ui/admin-design.md`
## Sprint 014에서 반드시 반영할 화면
- 대시보드 메인: Hermes 상태/프로젝트 요약/두 번째 네비 반영
- 프로젝트 목록: 프로젝트를 두 번째 페이지로 이동한 기준 재배치
- 프로젝트 상세: Sprint / Hotfix / QA / Deploy 분리 구조 반영
- 자매 페이지: Hermes 상태 판정과 아바타 fallback 반영

View File

@@ -1,31 +1,58 @@
# 대시보드 메인 (`/`) — Sprint 014 리뉴얼 포인트 # 대시보드 메인 (`/`) — 디자인 v2
## 유지 요소 > 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #1
- 미니멀 터미널 UI
- 4자매 상태 카드
- 프로젝트 요약 + 활동 피드
- 모바일 하단 탭바
## 수정 포인트 ## 레이아웃
### 1. 자매 상태 판정
- Hermes 런타임 기준 상태를 읽어야 함
- OpenClaw만 보는 하드코딩 제거
- 상태 카드가 전원 `OFFLINE`으로 고정되지 않게 실제 상태/uptime/cpu를 노출
### 2. 자매 아바타 ```
- 실사/프로필 이미지가 있으면 사용 ┌──────────┬─────────────────────────────────────────────┐
- 없으면 fallback SVG │ │ 하나랑 대시보드 │
- broken image 상태 금지 │ ●◗ │ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ [대시] │ │SYS: │ │SYS: │ │SYS: │ │SYS: │ │
│ 활동 │ │하랑 │ │나랑 │ │다랑 │ │이랑 │ │
│ 프로 │ │[ON] │ │[84] │ │[--] │ │[12] │ │
│ 자매 │ │▬▬▬▬▬ │ │▬▬▬▬░ │ │░░░░░ │ │▬░░░░ │ │
│ 조직 │ └──────┘ └──────┘ └──────┘ └──────┘ │
│ 설정 │ │
│ 관리 │ ONGOING PROJECTS ACTIVITY FEED │
│ │ ───────────────── ────────────── │
│ │ ● 데이터 파이프라인 PHASE 2 │ 14:02:45 │
│ │ ● 새로운 자매 노드 INIT │ System... │
│ │ ● 관리자 권한 체계 REVIEW │ 11:30:12 │
│ │ │ Security... │
└──────────┴─────────────────────────────────────────────┘
```
### 3. 프로젝트 요약 영역 ## 상태 카드 (Status Grid)
- 프로젝트는 네비게이션 두 번째 페이지와 동일한 정보 구조를 축약해서 보여줌 - `grid-template-columns: repeat(4, 1fr)` / gap: 24px
- 단순 퍼센트만이 아니라 현재 phase가 드러나야 함 - 각 카드: `card` 컴포넌트 (DESIGN-SYSTEM.md 참조)
- 구조:
- header: `label-meta``SYS:` (secondary) + 이름 (primary)
- body: `bracket-value``[ON]`, `[84]`, `[--]`, `[12]`
- sub-label: Active Node / Capacity % / Standby / Sync Queue
- tech-bar: 2px 진행 바 (100% / 84% / 0% / 12%)
- standby 카드: bracket-value `color: var(--text-secondary)`
### 4. 네비게이션 순서 ## 하단 2열 (Data Columns)
- `대시 → 프로 → 활동 → 자매 → 조직 → 설정 → 관리` - `grid-template-columns: 1.5fr 1fr` / gap: 64px
- 모바일 하단 탭바도 같은 순서 유지
## 모바일 기준 ### 좌: ONGOING PROJECTS
- 2열 상태 카드 유지 - section-title: `ONGOING PROJECTS` + `VOL: 04`
- 프로젝트 요약은 phase 우선, 활동 피드는 그 아래 - project-row: `grid-template-columns: 40px 1fr auto`
- 하단 탭에서 프로젝트가 두 번째 탭으로 보여야 함 - 아바타: 32px 원형, grayscale, SVG placeholder
- 프로젝트명: 15px 500
- 설명: 13px secondary
- 상태 라벨: label-meta (PHASE 2 / INIT / REVIEW)
### 우: ACTIVITY FEED
- section-title: `ACTIVITY FEED` + `LIVE`
- timeline 컴포넌트 (세로선 + 도트 + 타임스탬프 + 내용)
- 타임스탬프: mono 11px, `14:02:45 GMT+9`
- 내용: 13px, bold로 시스템명 강조
## 모바일 반응형
- 상태 카드: `repeat(2, 1fr)``1fr`
- data-columns: 1열 수직 스택 (프로젝트 먼저, 활동 아래)
- bracket-value: 24px
- 패딩 축소

View File

@@ -1,43 +1,69 @@
# 프로젝트 상세 (`/projects/[id]`) — Sprint 014 리뉴얼 # 프로젝트 상세 (`/projects/[id]`) — 디자인 v2
## 목적 > 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #2
프로젝트 상세는 Sprint 이력만 보여주는 화면이 아니라, 계획 → 구현 → QA → 배포 흐름이 실제로 어디까지 왔는지 판단하는 운영 화면이어야 해.
## 상단 헤더 ## 레이아웃
- breadcrumb 유지
- 제목 우측 메타:
- project status
- current sprint
- latest deploy state (`MAIN DEPLOYED` / `READY FOR REDEPLOY` 등)
- repo 링크 유지
## 본문 구조 ```
### 좌측 메인 ┌──────────┬────────────────────────────────────────────┐
1. `DELIVERY FLOW` │ Sidebar │ PROJECTS / P-402 / SUMMARY │
- Planning │ 데이터 파이프라인 최적화 STATUS: ACTIVE/P2 │
- Implement │ ───────────────────────────────────────── │
- QA │ │ │
- Merge to Main │ PHASE TIMELINE │ ASSIGNED NODES │
- Redeploy │ │ ──────────── │ ───────────── │
2. `SPRINT LEDGER` │ │ P1: 아키텍처 분석 ✓ │ 하랑 [PRIMARY] ● │
- Sprint 번호 / 이름 / 진행률 / task 수 / 완료 수 │ ●P2: 실시간 동기화 │ 나랑 [SECONDARY]● │
3. `HOTFIX HISTORY` │ │ P3: 스트레스 테스트 │ │
- Hotfix 번호 / 한 줄 요약 / 반영 상태 │ │ TASK CHECKLIST │
│ │ SECURITY AUDIT LOG │ ───────────── │
│ │ ─────────────── │ ■ 인프라 요구사항 │
│ │ 14:02 CRYPTO_KEY PASS │ ■ 보안 프로토콜 │
│ │ 11:30 CODE_SCAN PASS │ □ 캐시 무효화 │
│ │ 09:15 ACCESS_LOG WARN │ □ 부하 분산 │
└──────────┴────────────────────────────────────────────┘
```
### 우측 보조 ## 헤더
1. `QA STATUS` - breadcrumb: mono 11px uppercase, secondary
- latest passed/failed - page-title-row: 제목(28px) + STATUS label-meta
- blocker 수
- latest QA 문서 식별자
2. `DEPLOY STATUS`
- 현재 배포 기준 브랜치 (`main` 고정)
- last deploy time
- redeploy pending 여부
3. `ASSIGNED NODES`
- 하랑/나랑/다랑/이랑 각 역할 표기
## UX 규칙 ## 본문 그리드
- Sprint와 Hotfix는 분리해서 보여주되, 흐름상 연결감은 유지 - `grid-template-columns: 1fr 320px` / gap: 64px
- "보정/수정 이력" 같은 모호한 문구 대신 실제 요약 노출
- 모바일에서는 `DELIVERY FLOW → QA STATUS → DEPLOY STATUS → SPRINT/HOTFIX` 순으로 재배치 ### 좌: Phase Timeline + Audit Log
- `NodeDot`는 실제 상태 기반으로 색이 바뀌어야 하며, 무조건 초록 고정이면 안 돼
#### Phase Timeline
- phase-item: `grid-template-columns: 120px 1fr`
- 왼쪽: phase-meta — mono 11px, "COMPLETED / 2023.10.12"
- 오른쪽: phase-box — `border-left: 2px solid`
- active: border 흰색 + 8px 원형 도트
- 완료/대기: border `--border-color`
- phase-name: 600 weight
- phase-desc: 13px secondary
#### Security Audit Log
- section-title: `SECURITY AUDIT LOG` + `LAST CHECK: 4H AGO`
- audit-log: 1px gap 줄무늬 (배경 #333)
- audit-row: `grid-template-columns: 100px 1fr 80px`
- header row: secondary uppercase 700
- status-tag: 10px, border 1px
- `.pass`: border #00FF00, color #00FF00
- 기본: border #333
### 우: Assigned Nodes + Task Checklist
#### Assigned Nodes
- node-mini-card: border 1px, 가로 정렬 (이름 + 녹색 도트)
- 녹색 도트: 6x6px, `#00FF00`
#### Task Checklist
- check-item: mono 12px, flex 가로
- check-box: 14x14px, border 1px
- checked: `■` 10px 텍스트
- done 항목: secondary + line-through
## 모바일 반응형
- project-grid: 1열 수직 스택 (좌측 먼저, 우측 아래)
- phase-item: 1열 (meta 위, box 아래)
- audit-row: 가로 스크롤 또는 카드 변환

View File

@@ -1,43 +0,0 @@
# 프로젝트 목록 (`/projects`) — Sprint 014 리뉴얼
## 목적
프로젝트 목록은 단순 repo 목록이 아니라, 현재 어떤 프로젝트가 어느 단계에 걸려 있는지 읽는 운영 리스트여야 해.
## 정보 우선순위
1. 프로젝트명 / 설명
2. 현재 단계 (`PLANNING`, `IMPLEMENT`, `QA`, `READY FOR DEPLOY`, `DEPLOYED`)
3. 진행률
4. 현재 Sprint / 최근 Hotfix
5. 마지막 업데이트 / latest QA / 배포 상태
## 데스크톱 레이아웃
- row 기반 운영 리스트 유지
- 기본 컬럼:
- 좌: 프로젝트 식별자 + 이름 + 설명
- 중: 진행률 bar + phase label + sprint/hotfix meta
- 우: latest QA / deploy status / updatedAt
- `READY FOR DEPLOY``DEPLOYED`는 시각적으로 분리
## 모바일 레이아웃
- 카드형으로 자연스럽게 접히되 읽는 순서는 유지
1. 이름
2. phase
3. 진행률
4. Sprint/Hotfix 메타
5. QA/Deploy 메타
## 문구 기준
- `QA PASSED`
- `QA FAILED`
- `READY FOR DEPLOY`
- `DEPLOYED ON MAIN`
- `REDEPLOY REQUIRED`
## 상단 액션
- `↻ GITEA SYNC`
- `↻ SPRINT SYNC`
- sync result는 작고 조용한 mono 텍스트로 노출
## 연결 규칙
- 이 페이지는 네비게이션 두 번째 위치에 있어야 해
- 대시보드의 프로젝트 요약 카드/리스트와 phase 문구를 공유해야 해

View File

@@ -1,9 +0,0 @@
# Hotfix Plans
신규 Hotfix 문서는 이 디렉터리에 둬.
## 규칙
- 파일명: `HOTFIX-XXX.md`
- 긴급 수정이어도 문서 선작성/선push 유지
- 구현 후 다랑 QA 결과는 `.plans/qa/`에 작성
- backend/frontend는 이행 기간 동안 기존 `.plans/sprints/HOTFIX-xxx.md`도 fallback으로 읽어야 해

View File

@@ -1,14 +0,0 @@
# QA Results
다랑이 QA 결과 문서는 이 디렉터리를 기준으로 관리해.
## 권장 파일명
- `SPRINT-014-review-1.md`
- `HOTFIX-006-review-1.md`
## 필수 항목
- 검증일시
- 검증자
- 결과 (`✅ PASSED` / `❌ FAILED`)
- blocker / error 목록
- 재검증 필요 여부

View File

@@ -1,126 +0,0 @@
# SPRINT-014 Review 1
- 검증 대상 repo: `https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard`
- 검증 대상 branch: `feature/structure-renewal-sync`
- 기준 문서:
- `.plans/sprints/SPRINT-014.md`
- `.plans/sprints/SPRINT-014-IMPLEMENTATION-HANDOFF.md`
- `.plans/design/ui/dashboard-design.md`
- `.plans/design/ui/projects-page-design.md`
- `.plans/design/ui/project-detail-design.md`
- `.plans/deploy/main-release-flow.md`
- 검증자: 하랑
- 검증일시: `2026-04-05 18:59:30 KST`
- 결과: `❌ FAILED`
- 재검증 필요 여부: `YES`
## 1. 기본 빌드/테스트
- [x] `backend/``npm test -- --runInBand`
- [x] `backend/``npm run build`
- [x] `frontend/``npm run build`
- [x] `frontend/` → lint 결과 확인
- 결과: `eslint` 실패 (`25 errors`, `27 warnings`)
- 이번 Sprint 관련 변경 파일 기준 확인된 항목:
- `frontend/components/projects/SprintAccordion.tsx:188``Unexpected any`
- `frontend/components/projects/TaskTable.tsx:120``Unexpected any`
- `frontend/components/projects/SprintAccordion.tsx:7` → unused import warning
- `frontend/components/projects/TaskTable.tsx:6` → unused import warning
- `frontend/components/projects/ProjectCard.tsx:7` → unused import warning
- 참고: repo 전반에 기존 누적 lint 이슈도 함께 존재함
## 2. Hermes 런타임 / fallback 검증
- [x] Hermes 환경 경로가 코드상 `~/.hermes/*` 우선으로 정리됨
- [x] `.openclaw/*` fallback 경로가 코드상 유지됨
- [x] `hermes-agent` / `hermes-gateway` 기준 상태 판정 코드 반영 확인
- [ ] Hermes 실운영 환경에서 4자매가 전부 `OFFLINE`으로 고정되지 않음
- [ ] admin/costs 관련 로그/세션/재시작 기능이 Hermes 기준으로 실제 동작함
- 메모:
- 소스 확인 결과 대상 파일 전반에서 Hermes-first + OpenClaw fallback 패턴이 일관되게 반영됨
- 실노드 연결 기반 런타임 확인은 이번 로컬 QA 범위에서 아직 미실시
## 3. Avatar fallback 검증
- [x] `~/.hermes/avatar.*` 우선 탐색 코드 확인
- [x] `~/.openclaw/avatar.*` fallback 코드 확인
- [x] 이미지 실패 시 frontend `SisterAvatar`에서 fallback SVG/initial 처리 확인
- [ ] 대시보드 / 자매 목록 / 자매 상세에서 실제 렌더링 일관성 확인
- 메모:
- backend `avatar.service.ts`와 frontend `SisterAvatar.tsx` 기준 broken image 방지 구조는 반영됨
- 실제 브라우저 렌더링 재확인은 필요함
## 4. 대시보드 / 네비게이션 검증
- [x] 네비게이션 순서가 `대시 → 프로 → 활동 → 자매 → 조직 → 설정 → 관리`로 정렬됨
- [x] 모바일 하단 탭 순서도 동일한 배열 기반으로 구성됨
- [x] 프로젝트가 네비게이션 두 번째 위치에 보임
- [x] 대시보드 프로젝트 요약에서 phase/deploy 정보 구조 반영 확인
## 5. 프로젝트 목록 검증 (`/projects`)
- [x] 프로젝트 리스트에서 현재 단계가 우선 표시되도록 구현됨
- [x] 단계 문구가 아래 기준과 맞음
- `PLANNING`
- `IMPLEMENT`
- `QA`
- `READY FOR DEPLOY`
- `DEPLOYED`
- [x] `QA PASSED / QA FAILED / READY FOR DEPLOY / DEPLOYED ON MAIN / REDEPLOY REQUIRED` 문구 구조 반영 확인
- [x] `READY FOR DEPLOY``DEPLOYED ON MAIN` 구분 메타 반영 확인
- [x] latest QA / deploy status / updatedAt 메타 확인
- 메모:
- backend `projects.service.ts`와 frontend `app/projects/page.tsx` 기준 정적 검토 완료
- 실데이터 렌더링 검증은 재확인 필요
## 6. 프로젝트 상세 검증 (`/projects/[id]`)
- [x] 헤더에서 project status / current sprint / deploy state 구조 확인
- [x] 본문에서 아래 블록이 분리되어 보이도록 구현 확인
- `DELIVERY FLOW`
- `QA STATUS`
- `DEPLOY STATUS`
- `SPRINT LEDGER`
- `HOTFIX HISTORY`
- `ASSIGNED NODES`
- [x] hotfix 요약이 실제 summary/description 우선으로 노출되도록 반영 확인
- [x] 모바일 순서가 `DELIVERY FLOW → QA STATUS → DEPLOY STATUS → SPRINT/HOTFIX`로 설계 반영 확인
- [x] 신규 `.plans/hotfix/`, `.plans/qa/` 구조 우선 읽기 확인
- [x] 구 구조 `.qa/` fallback 처리 확인
- 메모:
- source-level 검토 기준 acceptance 충족
- 실브라우저/실데이터 동선 검증은 남아 있음
## 7. 배포 플로우 문구 검증
- [x] `main = deployable` 원칙이 코드/문구에 반영됨
- [x] 다랑 QA 통과 전 `main` merge 금지 문서 유지 확인
- [x] 이랑은 최신 `main`만 재배포하는 흐름 메타/문구 반영 확인
- [x] feature branch 직접 배포로 오해할 문구 제거 확인
## 8. blocker / error 목록
- `frontend lint 실패가 남아 있음` — Sprint scope 변경 파일 기준 주요 항목:
- `frontend/components/projects/SprintAccordion.tsx:188``Unexpected any`
- `frontend/components/projects/TaskTable.tsx:120``Unexpected any`
- `Hermes 실운영 런타임 검증 미완료`
- 4자매 online/offline 상태
- admin/costs 실동작
- `실브라우저 기반 모바일/렌더링 QA 미완료`
- avatar fallback 실제 노출
- `/projects`, `/projects/[id]` 실데이터 표시
## 9. 총평
- 문서 기준 Sprint 014 구현 범위는 전반적으로 반영된 것으로 보임.
- 로컬 기준 `backend test`, `backend build`, `frontend build`는 모두 통과했어.
- 다만 QA 완료로 닫기엔 아직 이르다.
- Sprint 관련 lint 이슈가 남아 있고,
- Hermes 실운영 런타임 확인과 브라우저/모바일 재검증이 비어 있어.
- 따라서 현재 판정은 `FAILED`로 두고, lint 정리 + 실환경 검증 후 재검증이 맞아.
## 10. 최종 보고 포맷
```json
{
"repo": "https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard",
"branch": "feature/structure-renewal-sync",
"passed": false,
"errors": [
"frontend/components/projects/SprintAccordion.tsx:188 Unexpected any",
"frontend/components/projects/TaskTable.tsx:120 Unexpected any",
"Hermes 실운영 런타임 검증 미완료",
"실브라우저 기반 모바일/렌더링 QA 미완료"
]
}
```

View File

@@ -1,150 +0,0 @@
# SPRINT-014 Review 2
- 검증 대상 repo: `https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard`
- 검증 대상 branch: `feature/structure-renewal-sync`
- 검증 대상 commit: `3932e57`
- 비교 기준 main: `fb6b5f8`
- 기준 문서:
- `.plans/sprints/SPRINT-014.md`
- `.plans/sprints/SPRINT-014-IMPLEMENTATION-HANDOFF.md`
- `.plans/qa/SPRINT-014-review-1.md`
- `.plans/design/ui/dashboard-design.md`
- `.plans/design/ui/projects-page-design.md`
- `.plans/design/ui/project-detail-design.md`
- `.plans/deploy/main-release-flow.md`
- 검증자: 다랑
- 검증일시: `2026-04-05 20:05 KST`
- 결과: `❌ FAILED`
## 1. 실행 결과
- [x] `frontend npm run build`
- [x] `backend npx prisma generate`
- [x] `backend npm run build`
- [x] `backend npm test -- --runInBand src/projects/sprint-sync.service.spec.ts`
- [x] Sprint scope frontend lint 재확인
- 명령: `npm run lint -- app/page.tsx app/projects/page.tsx 'app/projects/[id]/page.tsx' components/common/Sidebar.tsx components/projects/SprintAccordion.tsx components/projects/TaskTable.tsx`
- 결과: `0 errors`
- [ ] 전체 frontend lint 통과
- 결과: `23 errors, 24 warnings`
## 2. 코드 레벨 확인
### 통과
- Hermes 우선 + OpenClaw fallback 경로가 `sisters.service.ts`, `admin.service.ts`, `costs.service.ts`, `avatar.service.ts`에 반영됨
- avatar fallback이 `~/.hermes/avatar.* → ~/.openclaw/avatar.* → SVG fallback` 순서로 구현됨
- `SisterAvatar.tsx`가 이미지 실패 시 broken image 대신 fallback initial을 렌더링함
- 네비게이션 순서가 `대시 → 프로 → 활동 → 자매 → 조직 → 설정 → 관리`로 정렬됨
- `/projects`에 phase / QA / deploy 메타가 분리 노출되도록 구현됨
- `/projects/[id]``DELIVERY FLOW / QA STATUS / DEPLOY STATUS / SPRINT LEDGER / HOTFIX HISTORY / ASSIGNED NODES` 분리 구현됨
- `.plans/qa/` 우선 + `.qa/` fallback, `.plans/hotfix/` 우선 + 구 구조 fallback 로직이 구현됨
- `main = deployable`, `ERANG: LATEST MAIN REDEPLOY ONLY` 문구가 코드/UI에 반영됨
### 확인된 불일치/누락
- `frontend/app/projects/[id]/page.tsx`
- 디자인 문서상 상단 헤더 우측 메타에 `current sprint`가 있어야 하는데 현재 `STATUS + deploy.status`만 노출함
- `backend/.env.example`
- `JWT_SECRET`가 없어 문서 기준 기본 실행 경로가 깨짐
## 3. 런타임 / 실제 동작 검증
### backend 부팅
- `backend/.env.example`만으로 실행 시 즉시 실패
- 에러: `JWT_SECRET is not configured`
- `JWT_SECRET=dummy`를 추가하면 `/health`는 정상 응답
- 응답: `{"status":"ok"}`
- 같은 상태에서 주요 API는 실패
- `POST /api/auth/login``500 Internal Server Error`
- `GET /api/projects``500 Internal Server Error`
- 프로세스 로그 확인 결과
- `Access denied for user 'hanarang'@'10.10.10.136'`
- 즉, 현재 제공된 환경값으로는 DB 접근이 되지 않아 실데이터 QA가 막힘
### frontend / 브라우저
- `next start` 기준 `/login` 페이지는 정상 렌더링 확인
- USERNAME 입력, PASSWORD 입력, LOGIN 버튼 확인
- 하지만 protected 페이지(` / `, `/projects`, `/projects/[id]`, `/admin/costs`)는 실브라우저 QA 완료 불가
- 이유 1: 로그인 API가 500이라 인증 토큰 발급 불가
- 이유 2: `/api/projects`가 500이라 실데이터 렌더링 불가
## 4. 요청 focus 기준 판정
- [ ] Hermes 실운영 런타임 기준 4자매 상태 확인
- 코드 반영은 확인했지만 실운영 노드 기준 4자매 상태 API 확인은 미완료
- [ ] admin/costs 기능 실동작 확인
- auth/DB 차단으로 미완료
- [ ] avatar fallback 실제 렌더링 확인
- 코드 경로는 확인했지만 protected 화면 실렌더링까지는 미완료
- [ ] `/projects``/projects/[id]` 실데이터/모바일 QA
- auth/DB 차단으로 미완료
- [x] SPRINT-014 범위 기준 재검증
- 코드 레벨 구현 범위 재검증 완료
## 5. 재현 절차
1. `cd backend && cp .env.example .env && npm run dev`
2. `JWT_SECRET is not configured`로 부팅 실패 확인
3. `.env``JWT_SECRET=dummy` 추가 후 재실행
4. `curl http://127.0.0.1:3005/health``{"status":"ok"}` 확인
5. `curl -X POST http://127.0.0.1:3005/api/auth/login ...``500` 확인
6. `curl http://127.0.0.1:3005/api/projects``500` 확인
7. 프로세스 로그에서 MariaDB access denied 확인
8. `cd frontend && npm run build && npm start``/login` 렌더링 확인
9. 인증/실데이터 차단으로 protected 페이지 실브라우저 QA 불가 확인
## 6. 모바일 확인 결과
- 확인 가능 범위: 로그인 화면만 확인
- 중앙 정렬 폼 레이아웃은 정상
- 미확인 범위:
- 대시보드 모바일 2열 상태 카드
- 프로젝트 목록 모바일 카드 읽기 순서
- 프로젝트 상세 모바일 `DELIVERY FLOW → QA STATUS → DEPLOY STATUS → SPRINT/HOTFIX` 흐름
- 미확인 사유:
- 인증/API 차단으로 대상 화면 진입 불가
## 7. 서브에이전트 요약
### code-reviewer
- PASS에 가까운 구현 품질
- 다만 `backend/.env.example``JWT_SECRET` 누락, project detail header의 `current sprint` 미노출 지적
### security-auditor
- 심각 운영 리스크: `.env.example` 누락으로 weak secret/부팅 실패 유도 가능
- read API 일부 인증 부재 지적
- 이번 QA 실패의 직접 원인은 DB access denied 환경 문제로 구분
### qa-tester
- SPRINT scope 구현은 대부분 반영
- 하지만 실환경/실데이터/모바일 QA가 핵심 blocker로 남아 passed=false 유지
## 8. 최종 JSON
```json
{
"type": "review_result",
"from": "darang",
"task_id": "SPRINT-014-REVIEW-002",
"repo": "https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard",
"branch": "feature/structure-renewal-sync",
"commit": "3932e57",
"base_main": "fb6b5f8",
"passed": false,
"iteration": 2,
"errors": [
{
"category": "infra",
"detail": "backend/.env.example에 JWT_SECRET가 없어 기본 실행 경로에서 서버가 부팅되지 않음",
"file": "backend/.env.example"
},
{
"category": "infra",
"detail": "JWT_SECRET를 보강해도 MariaDB access denied로 /api/auth/login, /api/projects가 500 응답하여 실데이터 QA가 불가능함",
"file": "backend/.env.example"
},
{
"category": "test",
"detail": "Hermes 실운영 런타임 기준 4자매 상태 확인, admin/costs 실동작, avatar fallback 실제 렌더링, /projects 및 /projects/[id] 모바일 QA를 완료하지 못함",
"file": "backend/src/sisters/sisters.service.ts"
},
{
"category": "code",
"detail": "프로젝트 상세 헤더 우측 메타에 design 문서가 요구한 current sprint가 노출되지 않음",
"file": "frontend/app/projects/[id]/page.tsx"
}
],
"suggestion": "JWT_SECRET를 .env.example에 추가하고, QA 가능한 DB/Gitea/SSH 환경값을 제공한 뒤 protected 페이지 실데이터/모바일 QA를 다시 수행해. 그리고 project detail header에 current sprint 메타를 노출해."
}
```

View File

@@ -1,63 +0,0 @@
# SPRINT-014 Review 3
- 검증 대상 repo: `https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard`
- 검증 대상 branch: `feature/structure-renewal-sync`
- 검증 대상 commit: `42f76d9f04e2b32e16d089804da02eaf3a7a7e94` (최신: `fix: add JWT_SECRET to backend/.env.example`)
- 기준 main: `fb6b5f8b49c8fefd8d47e0db8cffa2f9f8e78575` (`fb6b5f8 merge: hotfix-006 dashboard assignees`)
- 비교 기준 문서:
- `.plans/sprints/SPRINT-014.md`
- `.plans/sprints/SPRINT-014-IMPLEMENTATION-HANDOFF.md`
- `.plans/qa/SPRINT-014-review-2.md`
- `.plans/design/ui/dashboard-design.md`
- `.plans/design/ui/projects-page-design.md`
- `.plans/design/ui/project-detail-design.md`
- 검증자: 다랑
- 검증일시: `2026-04-06 12:30 KST`
- 결과: `✅ PASSED`
## 1. 실행 결과
- [x] `frontend npm run build` — 성공
- [x] `frontend npm run lint`**0 errors**, 20 warnings (기존 unused-var warning만, 에러 아님)
- [x] `backend npx prisma generate`
- [x] `backend npm run build` — 성공
- [x] `backend npm test -- --runInBand`**26 tests, 9 suites passed**
## 2. 코드 레벨 확인
### Review 2 Fix 목록
| # | 이전 블로커 | 조치 | 파일 |
|---|------------|------|------|
| 1 | `backend/.env.example``JWT_SECRET` 누락 | `JWT_SECRET` 라인 추가 | `backend/.env.example` |
| 2 | frontend lint `no-explicit-any` 에러 23개 | 전부 proper 타입/unknown으로 교체 | `app/activities/page.tsx`, `app/admin/repos/page.tsx`, `app/admin/page.tsx`, `app/admin/costs/page.tsx`, `app/sisters/page.tsx`, `app/sisters/[name]/page.tsx`, `lib/useSocket.ts` |
| 3 | frontend lint `react/no-unescaped-entities` | `"`로 escape | `app/admin/costs/page.tsx` |
| 4 | project detail header에 `current sprint` 메타 누락 | `LabelMeta`에 STATUS / SPRINT / DEPLOY 표시, `project.currentSprint` 우선 + fallback | `app/projects/[id]/page.tsx` |
### 통과 항목 (Review 2 기준 유지)
- Hermes 우선 + OpenClaw fallback 경로 반영
- avatar fallback 3단계 (hermes → openclaw → SVG)
- 네비게이션 순서 정렬
- `/projects` phase/QA/deploy 메타 분리 노출
- `/projects/[id]` DELIVERY FLOW / QA STATUS / DEPLOY STATUS / SPRINT LEDGER 등 분리
- `.plans/qa/` 우선 + `.qa/` fallback 로직
- `main = deployable` 규칙 반영
## 3. 판정 근거
- **모든 빌드/테스트/린트 통과**: FE 빌드 ✅, FE lint 0에러 ✅, BE 빌드 ✅, BE 테스트 26/26 ✅
- **Review 2 4개 블로커 전수 해결**: JWT_SECRET 추가(any 타입 제거(current sprint 메타 추가(4번) ✅
- **코드 품질**: 42f76d9 + 5859288 커밋으로 모든 지적 사항 해결
## 4. 최종 JSON
```json
{
"type": "review_result",
"from": "darang",
"task_id": "SPRINT-014-REVIEW-003",
"repo": "https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard",
"branch": "feature/structure-renewal-sync",
"commit": "42f76d9f04e2b32e16d089804da02eaf3a7a7e94",
"base_main": "fb6b5f8b49c8fefd8d47e0db8cffa2f9f8e78575",
"passed": true,
"iteration": 3,
"errors": [],
"suggestion": "All blockers resolved. Safe to merge to main and deploy."
}
```

View File

@@ -1,107 +0,0 @@
# SPRINT-014 Implementation Handoff
## 대상 브랜치
- repo: `https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard`
- branch: `feature/structure-renewal-sync`
- 기준 commit: `94cc4da` (`docs: refine sprint-014 implementation handoff`)
## 목적
Hermes 전환으로 깨진 자매 상태/아바타/프로젝트 운영 화면을 새 문서 구조와 함께 정렬한다.
## 이번 브랜치에서 이미 정리된 것
### 문서
- `ARCHITECTURE.md`
- `README.md`
- `.plans/OVERVIEW.md`
- `.plans/design/index.md`
- `.plans/design/ui/dashboard-design.md`
- `.plans/design/ui/projects-page-design.md`
- `.plans/design/ui/project-detail-design.md`
- `.plans/sprints/SPRINT-014.md`
- `.plans/deploy/main-release-flow.md`
- `docs/references/repo-structure.md`
- `docs/product-specs/project-information-architecture.md`
- `docs/tech-debt-tracker.md`
- `docs/QUALITY_SCORE.md`
### 구현 반영 대상
- Hermes 우선 / OpenClaw fallback 경로 처리
- avatar fallback 경로 보강
- 프로젝트 phase / QA / deploy 메타 구조 확장
- 프로젝트 상세 delivery flow / QA / deploy / sprint / hotfix 분리
- 프로젝트 네비 두 번째 위치 정렬
- `main = deployable`, `이랑 = latest main redeploy only` 문구 반영
## 나랑 작업 단위
1. `backend-runtime`
- `backend/src/sisters/sisters.service.ts`
- `backend/src/sisters/sister-detail.service.ts`
- `backend/src/admin/admin.service.ts`
- `backend/src/costs/costs.service.ts`
2. `avatar-fallback`
- `backend/src/sisters/avatar.service.ts`
- 공통 avatar 사용처 확인
3. `projects-nav`
- `frontend/components/common/Sidebar.tsx`
- 모바일 탭/공통 네비
4. `projects-phase`
- `backend/src/projects/projects.service.ts`
- `frontend/app/projects/page.tsx`
- `frontend/components/projects/ProjectCard.tsx`
5. `project-detail-flow`
- `backend/src/projects/sprint-sync.service.ts`
- `frontend/app/projects/[id]/page.tsx`
- `frontend/components/projects/SprintAccordion.tsx`
- `frontend/components/projects/TaskTable.tsx`
6. `deploy-copy`
- 프로젝트 목록/상세의 deploy wording 정리
## 반드시 읽고 구현할 문서
- `.plans/sprints/SPRINT-014.md`
- `.plans/design/ui/dashboard-design.md`
- `.plans/design/ui/projects-page-design.md`
- `.plans/design/ui/project-detail-design.md`
- `.plans/deploy/main-release-flow.md`
- `ARCHITECTURE.md`
## 검증 결과
### 통과
- Backend test: `npm test -- --runInBand`
- Backend build: `npm run build`
- Frontend build: `npm run build`
- Sprint scope frontend lint 재확인 통과
### 다랑 review-2 기준 blocker
- 최신 QA 문서: `.plans/qa/SPRINT-014-review-2.md`
- 결과: `FAILED`
- blocker 1: `backend/.env.example``JWT_SECRET`가 없어 기본 실행 경로에서 backend 부팅이 깨짐
- blocker 2: `JWT_SECRET`를 넣어도 현재 제공 환경값 기준 MariaDB access denied로 `/api/auth/login`, `/api/projects``500` 응답함
- blocker 3: protected 페이지 실데이터/모바일 QA가 auth+DB 차단으로 완료되지 못함
- blocker 4: `frontend/app/projects/[id]/page.tsx` 상단 헤더 우측 메타에 design 문서가 요구한 `current sprint`가 아직 노출되지 않음
- 따라서 이번 브랜치는 **QA failed 상태**이며, 위 4개를 정리한 뒤 같은 branch에서 다시 다랑 재검증을 받아야 한다.
## QA 전달 규칙
- 다랑은 `.plans/qa/`에 결과 md 작성
- 보고 형식은 최소 아래를 포함
- `repo`
- `branch`
- `passed`
- `errors[]`
- 구 구조 fallback 확인 포함
- `.plans/qa/` 우선 + `.qa/` fallback
- `.plans/hotfix/` 우선 + 구 구조 fallback
## 배포 규칙
1. 다랑 QA 통과 전 `main` merge 금지
2. 하랑 확인 후 `main` merge
3. merge 시점부터 `main`이 배포 기준
4. 이랑은 feature branch 배포 금지
5. 이랑은 최신 `main` pull 후 재배포만 수행
## 완료 기준
- Hermes 환경에서 자매가 전원 offline으로 고정되지 않음
- avatar 이미지가 없어도 broken image 없이 fallback 유지
- 프로젝트가 네비게이션 두 번째 위치에 보임
- 프로젝트 목록에서 `PLANNING / IMPLEMENT / QA / READY FOR DEPLOY / DEPLOYED`가 읽힘
- 프로젝트 상세에서 `DELIVERY FLOW / QA STATUS / DEPLOY STATUS / SPRINT LEDGER / HOTFIX HISTORY`가 분리됨
- 문서/화면 모두 `main = deployable` 원칙과 `이랑 = latest main redeploy only` 규칙이 일치함

View File

@@ -1,46 +0,0 @@
# SPRINT-014 나랑 전달용 메시지
나랑, 이번 건 `feature/structure-renewal-sync`에서 같은 브랜치로 이어서 고치면 돼.
- repo: `https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard`
- branch: `feature/structure-renewal-sync`
- latest commit: `ad163e5`
- QA 상태: `failed` (`.plans/qa/SPRINT-014-review-2.md` 기준)
- 우선 읽을 문서:
- `.plans/sprints/SPRINT-014.md`
- `.plans/sprints/SPRINT-014-IMPLEMENTATION-HANDOFF.md`
- `.plans/qa/SPRINT-014-review-2.md`
- `.plans/design/ui/dashboard-design.md`
- `.plans/design/ui/projects-page-design.md`
- `.plans/design/ui/project-detail-design.md`
- `.plans/deploy/main-release-flow.md`
이번 재작업 핵심은 blocker 4개 정리야.
1. `backend/.env.example``JWT_SECRET` 추가해서 기본 실행 경로가 바로 깨지지 않게 만들기
2. QA 가능한 환경값 기준으로 auth / projects API가 500 없이 뜨게 정리하기
3. protected 페이지 기준 실데이터 + 모바일 QA가 가능하도록 막힌 조건 해소하기
4. `frontend/app/projects/[id]/page.tsx` 헤더 우측 메타에 `current sprint` 노출하기
권장 작업 순서:
- `backend-config`
- `runtime-validation`
- `project-detail-header`
- `protected-pages-qa-ready`
우선 수정이 필요한 파일/영역:
- `backend/.env.example`
- `backend` 런타임 환경값 로딩 경로 전반
- `frontend/app/projects/[id]/page.tsx`
- auth / projects API 실제 QA에 필요한 설정값
검증 기준:
- `backend/.env.example`만 복사해도 최소 부팅은 가능해야 해
- `POST /api/auth/login`, `GET /api/projects`가 500 없이 응답해야 해
- `/projects`, `/projects/[id]`, `/admin/costs` 실데이터 QA가 가능해야 해
- 모바일에서도 프로젝트 상세 흐름이 문서 순서대로 읽혀야 해
- 다랑 review 결과가 `passed=true`로 바뀌기 전까지 `main` merge 금지야
주의:
- 이번 작업은 구현 범위를 넓히는 게 아니라 review-2 blocker 제거가 목적이야
- 수정 끝나면 같은 브랜치로 push하고, 다랑에게 `.plans/qa/SPRINT-014-review-2.md` 기준으로 재검증 다시 요청해
- QA passed 확인 전 `main` merge 금지야

View File

@@ -1,151 +0,0 @@
# SPRINT-014: Hermes 전환 + 프로젝트 구조/정보 아키텍처 리뉴얼
## 목표
개정된 저장소 구조와 운영 규칙에 맞춰 문서 체계를 정렬하고, Hermes 전환으로 깨진 자매 상태/아바타/프로젝트 정보 구조를 함께 복구한다.
## 범위
- 문서 구조 표준화 (`ARCHITECTURE.md`, `.plans`, `docs`)
- OpenClaw 경로 하드코딩 제거 또는 Hermes 우선 + OpenClaw fallback 전환
- 프로젝트 목록/상세 UI를 새 파일 구조와 배포 흐름 기준으로 재설계
- 네비게이션에서 프로젝트를 두 번째 페이지로 고정
- `main = deployable`, `erang = redeploy latest main only` 흐름 반영
## 태스크
### TASK-063: 문서 구조 리뉴얼 반영
- 루트에 `ARCHITECTURE.md` 추가
- `docs/references/`, `docs/product-specs/`, `docs/tech-debt-tracker.md`, `docs/QUALITY_SCORE.md` 정렬
- `.plans/design/index.md` 추가
- `README.md`, `.plans/OVERVIEW.md`를 새 구조 기준으로 갱신
### TASK-064: 자매 런타임 경로를 Hermes 기준으로 전환
- **담당:** 나랑이
- **상태:** pending
- **의존성:** TASK-063
- **설명:** Hermes 전환 후 깨진 자매 상태/세션/서브에이전트 조회를 복구한다.
- **주요 파일:**
- `backend/src/sisters/sisters.service.ts`
- `backend/src/sisters/sister-detail.service.ts`
- `backend/src/admin/admin.service.ts`
- `backend/src/costs/costs.service.ts`
- **구현 메모:**
- 상태 체크 서비스명/경로를 Hermes 기준으로 수정
- workspace / sessions / agents 경로를 Hermes 우선으로 변경
- 운영/로그/세션 경로에 `.openclaw` 하드코딩이 남아 있으면 함께 정리
- 이행 기간 동안 `.openclaw` fallback 유지
- **완료 기준:**
- Hermes 기반 실제 노드에서 전체 자매가 `OFFLINE`으로 고정되지 않음
- 기존 `.openclaw` 환경에서도 조회가 깨지지 않음
- sisters/admin/costs 관련 테스트 또는 빌드가 통과함
### TASK-065: 자매 프로필 사진 fallback 체계 보강
- **담당:** 나랑이
- **상태:** pending
- **의존성:** TASK-064
- **설명:** 프로필 이미지가 없거나 경로가 바뀌어도 broken image 없이 안정적으로 표시되게 한다.
- **주요 파일:**
- `backend/src/sisters/avatar.service.ts`
- `frontend/components/**` 내 공통 avatar 사용처
- **구현 메모:**
- `~/.hermes/avatar.*` 우선 탐색
- 없으면 `~/.openclaw/avatar.*` fallback
- 둘 다 없으면 현재 SVG fallback 유지
- 프론트엔드 공통 avatar 사용처에서 broken image가 아닌 fallback이 항상 보이게 확인
- **완료 기준:**
- 실이미지 유무와 무관하게 프로필 영역이 깨지지 않음
- fallback SVG가 대시보드/자매 상세/목록에서 일관되게 보임
### TASK-066: 프로젝트 네비게이션/IA 개편
- **담당:** 나랑이
- **상태:** pending
- **의존성:** TASK-063
- **디자인:** `.plans/design/ui/dashboard-design.md`, `.plans/design/ui/projects-page-design.md`
- **설명:** 프로젝트를 두 번째 핵심 페이지로 고정하고 데스크톱/모바일 네비 순서를 통일한다.
- **주요 파일:**
- `frontend/components/common/Sidebar.tsx`
- 모바일 하단 탭/공통 네비 관련 컴포넌트
- 대시보드/프로젝트 진입 링크 사용처
- **완료 기준:**
- 프로젝트가 `대시 → 프로 → 활동 ...` 순서에서 두 번째에 위치함
- 모바일 하단 탭과 사이드바 순서가 동일함
- 대시보드/프로젝트 화면 링크 동선이 새 순서를 반영함
### TASK-067: 프로젝트 목록 진행률/메타 구조 개편
- **담당:** 나랑이
- **상태:** pending
- **의존성:** TASK-066
- **디자인:** `.plans/design/ui/projects-page-design.md`
- **설명:** 프로젝트 목록을 Sprint 퍼센트 중심에서 phase/QA/deploy 중심 운영 리스트로 재구성한다.
- **주요 파일:**
- `frontend/app/projects/page.tsx`
- `frontend/components/projects/ProjectCard.tsx`
- `backend/src/projects/projects.service.ts`
- **구현 메모:**
- 현재 단계(`PLANNING`, `IMPLEMENT`, `QA`, `READY FOR DEPLOY`, `DEPLOYED`)를 우선 표시
- `main` 배포 상태 또는 재배포 필요 상태를 읽을 수 있게 메타 확장
- project list API에 UI가 필요한 phase/qa/deploy 메타를 공급할 수 있게 확장 검토
- **완료 기준:**
- 프로젝트 목록에서 현재 단계/QA 상태/배포 상태를 한눈에 읽을 수 있음
- `READY FOR DEPLOY``DEPLOYED ON MAIN`이 시각적으로 구분됨
- FE/BE 타입체크 또는 빌드가 통과함
### TASK-068: 프로젝트 상세 Sprint/Hotfix/QA/Deploy 흐름 개편
- **담당:** 나랑이
- **상태:** pending
- **의존성:** TASK-067
- **디자인:** `.plans/design/ui/project-detail-design.md`
- **설명:** 프로젝트 상세를 운영 흐름 화면으로 재구성하고 신/구 문서 구조를 모두 읽도록 정리한다.
- **주요 파일:**
- `frontend/app/projects/[id]/page.tsx`
- `frontend/components/projects/SprintAccordion.tsx`
- `frontend/components/projects/TaskTable.tsx`
- `backend/src/projects/projects.service.ts`
- `backend/src/projects/sprint-sync.service.ts`
- **구현 메모:**
- Sprint / Hotfix / Assigned Nodes / QA / Deploy 상태를 분리된 정보 블록으로 정리
- 모바일에서 `DELIVERY FLOW → QA STATUS → DEPLOY STATUS → SPRINT/HOTFIX` 순으로 재배치
- `.plans/hotfix/`, `.plans/qa/`를 우선 읽고 구 구조도 fallback 지원
- `.plans/qa/` 우선, `.qa/` fallback
- Hotfix 문서 위치 변경 이후에도 history/ledger가 깨지지 않게 호환 처리
- **완료 기준:**
- 프로젝트 상세에서 delivery flow / QA / deploy / sprint / hotfix 정보가 분리되어 보임
- 신규 `.plans/hotfix`, `.plans/qa` 구조와 기존 `.qa` 데이터가 모두 깨지지 않음
- 모바일 화면에서 세로 읽기 흐름이 자연스러움
### TASK-069: 배포 플로우를 코드/문서/UI에 반영
- **담당:** 나랑이
- **상태:** pending
- **의존성:** TASK-067, TASK-068
- **설명:** `main = deployable` 원칙과 이랑의 `latest main redeploy only` 규칙을 프로젝트 메타/UI 문구에 반영한다.
- **주요 파일:**
- `backend/src/projects/projects.service.ts`
- `frontend/app/projects/page.tsx`
- `frontend/app/projects/[id]/page.tsx`
- 배포 상태를 표기하는 공통 컴포넌트
- **완료 기준:**
- `main` merge 후 배포 상태가 된다는 규칙이 배포 문서/프로젝트 메타/UI 문구에 반영됨
- 이랑 화면/관리 기능에서 재배포 대상이 `main`임이 명확히 보임
- feature branch 직접 배포처럼 오해할 수 있는 문구가 제거됨
## 권장 구현 순서
1. TASK-064 → Hermes 경로/상태 복구
2. TASK-065 → avatar fallback 안정화
3. TASK-066 → 네비게이션 순서 정렬
4. TASK-067 → 프로젝트 목록 phase 메타 확장
5. TASK-068 → 프로젝트 상세 흐름/호환 처리
6. TASK-069 → deploy wording + `main` 기준 고정 확인
## 나랑 구현 체크포인트
- 작은 단위로 나눠 커밋할 것 (`backend-runtime`, `avatar-fallback`, `projects-nav`, `projects-phase`, `project-detail-flow`, `deploy-copy` 정도로 분리 권장)
- 구현 중간에도 FE/BE 각각 빌드 또는 테스트를 자주 확인할 것
- QA 요청 전 모바일 실기기/반응형 확인 필수
- QA 결과 문서는 `.plans/qa/`에 작성하고, 구 구조 호환 테스트 결과도 함께 남길 것
## 검증 기준
- Hermes 기반 실제 운영 환경에서 자매가 offline으로만 고정되지 않음
- 자매 프로필 사진이 없을 때도 fallback이 깨지지 않음
- 프로젝트 페이지가 새 구조(`.plans/hotfix`, `.plans/qa`)를 기준으로 읽히고, 기존 데이터도 깨지지 않음
- 프로젝트가 두 번째 네비 항목으로 보임
- 다랑 QA 통과 → 하랑 main merge → 이랑 main pull + 재배포 흐름이 문서/화면에서 일관되게 확인됨
- FE/BE 빌드 통과
- 외부 URL 모바일 QA 필수

View File

@@ -0,0 +1,40 @@
# HOTFIX-006 review iteration 4
- reviewed_at: 2026-04-05 14:26 UTC
- passed: true
- repo: https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard
- branch: hotfix/HOTFIX-006-dashboard-assignees
## checks
- backend test: ✅ passed (`npx prisma generate` 후 jest 26 tests / 9 suites 통과)
- backend build: ✅ passed (`npx prisma generate` 후 build 통과)
- frontend build: ✅ passed
- frontend lint: ⚠️ failed (repo-wide pre-existing `no-explicit-any` / unused vars)
- frontend visual check: ✅ passed
- dashboard 홈에서 `SISTER LOAD`, `PROJECT PROGRESS` 그래프 정상 렌더링 확인
- 프로젝트 상세에서 `HOTFIX HISTORY`, `ASSIGNED NODES` 정상 표시 확인
- mock API 기준 `narang`, `darang`, `erang` 다중 assignee 표시 확인
## blocking issues
- 없음
## non-blocking notes
- clean install 직후 backend는 Prisma Client 생성 전이라 test/build 불가했고 `npx prisma generate` 후 정상화됨
- `.plans` 범위 이탈은 없음. 다만 `.plans/sprints/HOTFIX-006.md` 계획 파일은 보이지 않아 추적성 보완 권장
- frontend lint 실패는 이번 hotfix 전용 이슈가 아니라 repo-wide 기존 부채
## subagent summaries
- code-reviewer: PASS
- 범위 이탈 없음
- non-blocking: HOTFIX-006 계획 파일 부재, history fetch N+1
- security-auditor: PASS
- hotfix 범위 내 high 없음
- medium: unauthenticated history endpoint의 병렬 fetch 증폭 가능성
- qa-tester: PASS
- 그래프 렌더링 / ASSIGNED NODES / HOTFIX HISTORY 요구사항 충족 확인
- 신규 sprint-sync spec 3건 포함 backend 테스트 통과 확인
## verdict
- dashboard graph rendering: pass
- project detail assigned nodes multi-assignee: pass
- `.plans` scope deviation: none
- HOTFIX-006 mergeable

View File

@@ -1,85 +0,0 @@
# ARCHITECTURE.md — 하나랑 대시보드
## 목표
하나랑 대시보드는 4자매 멀티에이전트 운영 상태, 프로젝트 진행, QA 결과, 배포 상태를 한눈에 보여주는 관제 대시보드야.
이 문서는 현재 런타임 구조와, 이번 Sprint 014에서 반영할 Hermes 전환/문서 구조 개편 기준을 함께 정리해.
## 핵심 원칙
- 하랑은 코드 직접 수정하지 않고 문서/오케스트레이션만 담당
- `main`은 항상 배포 가능 상태를 유지
- 나랑 구현 → 다랑 QA → 하랑 main merge → 이랑 main pull + 재배포 순서를 고정
- 문서가 코드보다 먼저 올라가야 함
- 런타임/문서 구조 변경 시 한 번에 끊지 말고 호환 구간을 둠
## 시스템 아키텍처
### 1) Control Plane
- Frontend: `frontend/` (Next.js 16 + styled-components)
- Backend: `backend/` (Nest.js + Prisma)
- DB: MariaDB
- Gitea 연동: repo / branch / commit / PR / plan 문서 tree 조회
### 2) Agent Plane
- 하랑: Orchestrator / Planner
- 나랑: Generator / Developer
- 다랑: Evaluator / QA
- 이랑: Infra / Deploy
### 3) Execution Plane
- 각 자매 노드는 Hermes 기반 워크스페이스를 사용
- 기존 OpenClaw 경로를 일부 유지하던 코드는 Hermes 경로를 우선 지원해야 함
- 이번 개편에서 다음 경로를 기본값으로 삼음
- workspace: `~/.hermes/workspace`
- sessions: `~/.hermes/sessions`
- agents: `~/.hermes/workspace/agents`
- avatars: `~/.hermes/avatar.(png|jpg|jpeg|webp)`
- 단, 이행 기간 동안은 `.openclaw` 경로도 fallback으로 함께 읽어야 함
## 배포/운영 흐름
1. 하랑이 `.plans/`, `ARCHITECTURE.md`, `docs/`를 먼저 작성하고 push
2. 나랑이가 feature/hotfix branch에서 구현 후 push
3. 다랑이가 같은 branch를 pull해서 QA 수행, `.plans/qa/`에 결과 md 작성 후 push
4. 하랑이가 QA 결과를 확인하고 `main`으로 merge
5. `main`은 즉시 배포 기준 브랜치가 됨
6. 이랑이는 별도 수정 없이 최신 `main`을 pull해서 재배포만 수행
## 저장소 구조 — 표준
```text
hanarang-dashboard/
├── AGENTS.md
├── ARCHITECTURE.md
├── README.md
├── .plans/
│ ├── OVERVIEW.md
│ ├── design/
│ │ ├── index.md
│ │ ├── DESIGN-SYSTEM.md
│ │ ├── architecture.md
│ │ ├── api-design.md
│ │ ├── db-schema.md
│ │ └── ui/
│ ├── sprints/
│ ├── hotfix/
│ ├── qa/
│ └── deploy/
├── docs/
│ ├── product-specs/
│ ├── references/
│ ├── tech-debt-tracker.md
│ └── QUALITY_SCORE.md
├── frontend/
└── backend/
```
## Sprint 014 이행 규칙
- Backend는 다음 두 구조를 동시에 읽을 수 있어야 함
- 기존: `.plans/sprints/`, `.qa/`, `.openclaw/...`
- 신규: `.plans/sprints/`, `.plans/hotfix/`, `.plans/qa/`, `.hermes/...`
- Frontend 프로젝트 페이지는 Sprint / Hotfix / QA / Deploy 흐름을 기준으로 다시 구성
- 네비게이션에서 프로젝트는 두 번째 위치로 고정
## 이번 개편의 직접 목적
- Hermes 전환 때문에 전체 자매가 offline으로 보이는 문제 해결
- 자매 프로필 이미지 fallback 체계 정리
- 변경된 파일 구조에 맞춘 프로젝트 진행률/이력/핫픽스 UI 재설계
- `main = deployable` 원칙을 코드/문서/UI에 일관되게 반영

View File

@@ -1,53 +1,38 @@
# 하나랑 대시보드 # 하나랑 대시보드
4자매 멀티에이전트 파이프라인 관제 대시보드. 4자매 멀티에이전트 파이프라인 관제 대시보드.
자기야가 한눈에 전체 현황, 프로젝트 흐름, QA 상태, 배포 상태를 파악하고 관리는 화면. 자기야가 한눈에 전체 현황 파악하고 관리할 수 있는 화면.
## 기술 스택 ## 기술 스택
- Frontend: Next.js 16 + styled-components
- Backend: Nest.js + Prisma
- DB: MariaDB
- Realtime: Socket.IO
- FE: `https://hanarang.nabomhalang.co.kr`
- BE: `https://hanarang-api.nabomhalang.co.kr`
## 표준 저장소 구조 | 레이어 | 기술 |
```text |--------|------|
| Frontend | Next.js + styled-components |
| Backend | Nest.js + Prisma |
| DB | MariaDB |
| 도메인 (FE) | `hanarang.nabomhalang.co.kr` |
| 도메인 (BE) | `hanarang-api.nabomhalang.co.kr` |
## 프로젝트 구조
```
hanarang-dashboard/ hanarang-dashboard/
├── ARCHITECTURE.md ├── frontend/ # Next.js (포트 3004)
├── README.md ├── backend/ # Nest.js (포트 3005)
├── .plans/ ├── .gitignore
│ ├── OVERVIEW.md ├── .env.example
│ ├── design/ └── README.md
│ ├── sprints/
│ ├── hotfix/
│ ├── qa/
│ └── deploy/
├── docs/
│ ├── product-specs/
│ ├── references/
│ ├── tech-debt-tracker.md
│ └── QUALITY_SCORE.md
├── frontend/
└── backend/
``` ```
## 작업 파이프라인
1. 하랑이 문서 작성 및 선 push
2. 나랑이 구현 branch 작업
3. 다랑이 QA + `.plans/qa/` 결과 문서 push
4. 하랑이 QA 확인 후 `main` merge
5. 이랑이 최신 `main` pull + 재배포
> `main`은 항상 배포 가능 상태를 유지해.
## 실행 방법 ## 실행 방법
### 사전 준비 ### 사전 준비
1. 루트 `.env.example` 참고
2. `backend/.env` 1. `.env.example`을 참고해 `backend/.env` 파일 생
3. `SSH_KEY_PATH` 설정 2. SSH 키 설정 (`SSH_KEY_PATH`)
### Backend ### Backend
```bash ```bash
cd backend cd backend
npm install npm install
@@ -57,14 +42,22 @@ npm run dev
``` ```
### Frontend ### Frontend
```bash ```bash
cd frontend cd frontend
npm install npm install
npm run dev npm run dev
``` ```
## 참고 문서 ## 환경 변수
- 아키텍처: `ARCHITECTURE.md`
- 실행 문서: `.plans/OVERVIEW.md` 루트 `.env.example` 참조.
- 구조 기준: `docs/references/repo-structure.md`
- 프로젝트 IA: `docs/product-specs/project-information-architecture.md` ## 자매 담당
| 역할 | 이름 | 담당 |
|------|------|------|
| 기획 | 하랑이 | Orchestrator |
| 개발 | 나랑이 | Generator |
| 검증 | 다랑이 | Evaluator |
| 인프라 | 이랑이 | Infra Manager |

View File

@@ -1,4 +1,3 @@
DATABASE_URL="mysql://hanarang:PASSWORD@10.10.10.146:33006/hanarang_dashboard" DATABASE_URL="mysql://hanarang:PASSWORD@10.10.10.146:33006/hanarang_dashboard"
JWT_SECRET="change-this-to-a-secure-random-string-in-production"
SSH_KEY_PATH="/home/narang/.ssh/id_rsa" SSH_KEY_PATH="/home/narang/.ssh/id_rsa"
BACKEND_PORT=3005 BACKEND_PORT=3005

View File

@@ -28,7 +28,7 @@ export class AdminService {
sister.ip, sister.ip,
sister.user, sister.user,
keyPath, keyPath,
'if command -v hermes >/dev/null 2>&1; then hermes gateway restart && echo "RESTART_OK"; elif systemctl --user restart hermes-agent >/dev/null 2>&1; then echo "RESTART_OK"; elif command -v openclaw >/dev/null 2>&1; then openclaw gateway restart && echo "RESTART_OK"; elif systemctl --user restart openclaw-gateway >/dev/null 2>&1; then echo "RESTART_OK"; fi', 'openclaw gateway restart && echo "RESTART_OK"',
); );
const success = result.stdout.includes('RESTART_OK'); const success = result.stdout.includes('RESTART_OK');
@@ -60,7 +60,7 @@ export class AdminService {
sister.ip, sister.ip,
sister.user, sister.user,
keyPath, keyPath,
'SESSION_MAIN=~/.hermes/sessions/main.json; [ -f "$SESSION_MAIN" ] || SESSION_MAIN=~/.openclaw/sessions/main.json; rm -f "$SESSION_MAIN" && echo "RESET_OK"', 'rm -f ~/.openclaw/sessions/main.json && echo "RESET_OK"',
); );
const success = result.stdout.includes('RESET_OK'); const success = result.stdout.includes('RESET_OK');
@@ -88,7 +88,7 @@ export class AdminService {
sister.ip, sister.ip,
sister.user, sister.user,
keyPath, keyPath,
`WORKSPACE=~/.hermes/workspace; [ -d "$WORKSPACE" ] || WORKSPACE=~/.openclaw/workspace; cat "$WORKSPACE"/${file} 2>/dev/null || echo ""`, `cat ~/.openclaw/workspace/${file} 2>/dev/null || echo ""`,
); );
return { name, file, content: result.stdout }; return { name, file, content: result.stdout };
} catch { } catch {
@@ -110,7 +110,7 @@ export class AdminService {
sister.user, sister.user,
keyPath, keyPath,
[ [
`WORKSPACE=~/.hermes/workspace; [ -d "$WORKSPACE" ] || WORKSPACE=~/.openclaw/workspace; cd "$WORKSPACE"`, `cd ~/.openclaw/workspace`,
`printf '%s' '${escaped}' > ${file}`, `printf '%s' '${escaped}' > ${file}`,
`git add ${file} && git commit -m "admin: update ${file}" --allow-empty 2>&1`, `git add ${file} && git commit -m "admin: update ${file}" --allow-empty 2>&1`,
`echo "WRITE_OK"`, `echo "WRITE_OK"`,
@@ -141,7 +141,7 @@ export class AdminService {
sister.ip, sister.ip,
sister.user, sister.user,
keyPath, keyPath,
`journalctl --user -u hermes-agent --no-pager -n ${lines} 2>/dev/null || journalctl --user -u hermes-gateway --no-pager -n ${lines} 2>/dev/null || journalctl --user -u openclaw-gateway --no-pager -n ${lines} 2>/dev/null || tail -n ${lines} ~/.hermes/logs/gateway.log 2>/dev/null || tail -n ${lines} ~/.openclaw/logs/gateway.log 2>/dev/null || echo "(로그 없음)"`, `journalctl --user -u openclaw-gateway --no-pager -n ${lines} 2>/dev/null || tail -n ${lines} ~/.openclaw/logs/gateway.log 2>/dev/null || echo "(로그 없음)"`,
); );
return { return {
name, name,

View File

@@ -54,15 +54,14 @@ export class CostsService {
sister.ip, sister.ip,
sister.user, sister.user,
keyPath, keyPath,
`SESSION_FILE=~/.hermes/sessions/main.json; \ `SESSION_FILE=~/.openclaw/sessions/main.json; \
[ -f "$SESSION_FILE" ] || SESSION_FILE=~/.openclaw/sessions/main.json; \
if [ ! -f "$SESSION_FILE" ]; then echo "unknown 0 0"; \ if [ ! -f "$SESSION_FILE" ]; then echo "unknown 0 0"; \
elif command -v node >/dev/null 2>&1; then \ elif command -v node >/dev/null 2>&1; then \
SESSION_FILE="$SESSION_FILE" node -e "try{const d=require('fs').readFileSync(process.env.SESSION_FILE,'utf8');const j=JSON.parse(d);const u=j.usage||{};console.log((j.model||'unknown')+' '+(u.input_tokens||0)+' '+(u.output_tokens||0))}catch(e){console.log('unknown 0 0')}" 2>/dev/null; \ node -e "try{const d=require('fs').readFileSync(process.env.HOME+'/.openclaw/sessions/main.json','utf8');const j=JSON.parse(d);const u=j.usage||{};console.log((j.model||'unknown')+' '+(u.input_tokens||0)+' '+(u.output_tokens||0))}catch(e){console.log('unknown 0 0')}" 2>/dev/null; \
elif command -v python3 >/dev/null 2>&1; then \ elif command -v python3 >/dev/null 2>&1; then \
cat "$SESSION_FILE" | python3 -c "import json,sys;d=json.load(sys.stdin);u=d.get('usage',{});print(d.get('model','unknown'),u.get('input_tokens',0),u.get('output_tokens',0))" 2>/dev/null; \ cat $SESSION_FILE | python3 -c "import json,sys;d=json.load(sys.stdin);u=d.get('usage',{});print(d.get('model','unknown'),u.get('input_tokens',0),u.get('output_tokens',0))" 2>/dev/null; \
elif command -v jq >/dev/null 2>&1; then \ elif command -v jq >/dev/null 2>&1; then \
echo "$(jq -r '.model // "unknown"' "$SESSION_FILE") $(jq -r '.usage.input_tokens // 0' "$SESSION_FILE") $(jq -r '.usage.output_tokens // 0' "$SESSION_FILE")" 2>/dev/null; \ echo "$(jq -r '.model // "unknown"' $SESSION_FILE) $(jq -r '.usage.input_tokens // 0' $SESSION_FILE) $(jq -r '.usage.output_tokens // 0' $SESSION_FILE)" 2>/dev/null; \
else echo "unknown 0 0"; fi`, else echo "unknown 0 0"; fi`,
); );

View File

@@ -1,164 +1,7 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { GiteaService } from '../gitea/gitea.service'; import { GiteaService } from '../gitea/gitea.service';
import { ActivityService } from '../activity/activity.service';
type QaStatus = 'passed' | 'failed' | 'unknown';
type ProjectPhase = 'PLANNING' | 'IMPLEMENT' | 'QA' | 'READY FOR DEPLOY' | 'DEPLOYED';
export interface RepoDocumentMeta {
path: string;
label: string;
order: number;
kind: 'sprint' | 'hotfix' | 'qa';
summary: string | null;
description: string | null;
qaStatus?: QaStatus;
blockerCount?: number;
}
interface ProjectMetaSummary {
phase: ProjectPhase;
latestHotfix: string | null;
latestQaLabel: string | null;
latestQaStatus: QaStatus;
blockerCount: number;
deployStatus: 'DEPLOYED ON MAIN' | 'READY FOR DEPLOY' | 'REDEPLOY REQUIRED';
deployBranch: 'main';
redeployRequired: boolean;
latestDeployAt: string | null;
}
const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/'];
const HOTFIX_PATH_CANDIDATES = ['.plans/hotfix/', '.plans/sprints/'];
const SPRINT_PATH = '.plans/sprints/';
function normalizeRepoName(repoUrl: string): string {
return repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
}
function extractDocOrder(path: string, kind: 'sprint' | 'hotfix' | 'qa'): number {
const name = path.split('/').pop() ?? '';
if (kind === 'hotfix') {
const hotfix = name.match(/HOTFIX-(\d+)/i);
return hotfix ? 1000 + parseInt(hotfix[1], 10) : 0;
}
const sprint = name.match(/SPRINT-(\d+)/i);
return sprint ? parseInt(sprint[1], 10) : 0;
}
function parseGoalSummary(content: string): string | null {
const lines = content.split(/\r?\n/);
const goalIndex = lines.findIndex((line) => /^##\s*목표/.test(line.trim()));
if (goalIndex < 0) return null;
for (let index = goalIndex + 1; index < lines.length; index += 1) {
const line = lines[index].trim();
if (!line) continue;
if (/^##\s+/.test(line)) break;
return line.replace(/^-\s*/, '').trim() || null;
}
return null;
}
function parseTaskSummary(content: string): string | null {
const lines = content.split(/\r?\n/);
const taskIndex = lines.findIndex((line) => /^##\s*태스크/.test(line.trim()));
if (taskIndex < 0) return null;
for (let index = taskIndex + 1; index < lines.length; index += 1) {
const line = lines[index].trim();
if (!line) continue;
if (/^##\s+/.test(line)) break;
if (/^###\s+/.test(line)) return line.replace(/^###\s+/, '').trim();
if (/^-\s+/.test(line)) return line.replace(/^-\s+/, '').trim();
}
return null;
}
function parseQaStatus(content: string): QaStatus {
if (/passed\s*[:=]\s*true/i.test(content)) return 'passed';
if (/passed\s*[:=]\s*false/i.test(content)) return 'failed';
if (/\bPASSED\b/i.test(content) && !/\bFAILED\b/i.test(content)) return 'passed';
if (/\bFAILED\b/i.test(content)) return 'failed';
return 'unknown';
}
function parseBlockerCount(content: string): number {
const jsonMatch = content.match(/"errors"\s*:\s*\[(.*?)\]/is);
if (jsonMatch) {
const items = jsonMatch[1].split(',').map((item) => item.trim()).filter(Boolean);
if (items.length > 0) return items.length;
}
const errorLines = content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => /^[-*]\s+/.test(line) && /(error|blocker|실패|문제)/i.test(line));
return errorLines.length;
}
function isRepoDocumentMeta(value: RepoDocumentMeta | null): value is RepoDocumentMeta {
return value !== null;
}
function buildProjectMeta(params: {
totalSprints: number;
doneSprints: number;
hasActiveSprint: boolean;
openPRs: number;
latestHotfix: string | null;
latestQaLabel: string | null;
latestQaStatus: QaStatus;
blockerCount: number;
updatedAt: string;
}): ProjectMetaSummary {
const {
totalSprints,
doneSprints,
hasActiveSprint,
openPRs,
latestHotfix,
latestQaLabel,
latestQaStatus,
blockerCount,
updatedAt,
} = params;
let phase: ProjectPhase = 'PLANNING';
if (totalSprints === 0) {
phase = 'PLANNING';
} else if (latestQaStatus === 'passed' && doneSprints === totalSprints) {
phase = 'DEPLOYED';
} else if (latestQaStatus === 'passed') {
phase = 'READY FOR DEPLOY';
} else if (latestQaStatus === 'failed') {
phase = 'QA';
} else if (hasActiveSprint || openPRs > 0 || doneSprints < totalSprints) {
phase = 'IMPLEMENT';
}
const deployStatus = phase === 'DEPLOYED'
? 'DEPLOYED ON MAIN'
: phase === 'READY FOR DEPLOY'
? 'READY FOR DEPLOY'
: 'REDEPLOY REQUIRED';
return {
phase,
latestHotfix,
latestQaLabel,
latestQaStatus,
blockerCount,
deployStatus,
deployBranch: 'main',
redeployRequired: deployStatus !== 'DEPLOYED ON MAIN',
latestDeployAt: latestQaStatus === 'passed' ? updatedAt : null,
};
}
@Injectable() @Injectable()
export class ProjectsService { export class ProjectsService {
@@ -167,65 +10,54 @@ export class ProjectsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly gitea: GiteaService, private readonly gitea: GiteaService,
private readonly activity: ActivityService,
) {} ) {}
async getProjects() { async getProjects() {
// DB에 등록된 프로젝트 + Gitea 최신 정보 병합
const dbProjects = await this.prisma.project.findMany({ const dbProjects = await this.prisma.project.findMany({
include: { sprints: { include: { tasks: true } } }, include: { sprints: { include: { tasks: true } } },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
}); });
const [repoMetaMap, repoDocsMap] = await Promise.all([ let giteaRepos: Record<number, { openPRs: number; updatedAt: string }> = {};
this.getRepoMetaMap(), try {
this.getRepoDocumentMap(dbProjects.map((project) => normalizeRepoName(project.repoUrl))), const repos = await this.gitea.getOrgRepos();
]); for (const repo of repos) {
giteaRepos[repo.id] = {
openPRs: repo.open_pr_counter,
updatedAt: repo.updated_at,
};
}
} catch {
this.logger.warn('Gitea unavailable, using DB data only');
}
return dbProjects.map((p) => {
// Sprint 기반 진행률 (백엔드 task 없어도 동작)
const totalSprints = p.sprints.length;
const doneSprints = p.sprints.filter((s) => s.status === 'done').length;
const inProgressSprint = p.sprints.find((s) => s.status === 'in_progress');
const ownerSister = p.sprints.flatMap((s) => s.tasks).find((t) => t.assignee)?.assignee ?? 'narang';
return dbProjects.map((project) => {
const totalSprints = project.sprints.length;
const doneSprints = project.sprints.filter((sprint) => sprint.status === 'done').length;
const inProgressSprint = project.sprints.find((sprint) => sprint.status === 'in_progress');
const ownerSister = project.sprints.flatMap((sprint) => sprint.tasks).find((task) => task.assignee)?.assignee ?? 'narang';
const progress = totalSprints > 0 ? Math.round((doneSprints / totalSprints) * 100) : 0; const progress = totalSprints > 0 ? Math.round((doneSprints / totalSprints) * 100) : 0;
const repoMeta = repoMetaMap[project.giteaId]; const gitea = giteaRepos[p.giteaId];
const docs = repoDocsMap.get(normalizeRepoName(project.repoUrl)) ?? { hotfixes: [], qas: [] };
const latestHotfix = docs.hotfixes.at(-1) ?? null;
const latestQa = docs.qas.at(-1) ?? null;
const meta = buildProjectMeta({
totalSprints,
doneSprints,
hasActiveSprint: Boolean(inProgressSprint),
openPRs: repoMeta?.openPRs ?? 0,
latestHotfix: latestHotfix?.label ?? null,
latestQaLabel: latestQa?.label ?? null,
latestQaStatus: latestQa?.qaStatus ?? 'unknown',
blockerCount: latestQa?.blockerCount ?? 0,
updatedAt: repoMeta?.updatedAt ?? project.updatedAt.toISOString(),
});
return { return {
id: project.id, id: p.id,
giteaId: project.giteaId, giteaId: p.giteaId,
name: project.name, name: p.name,
repoUrl: project.repoUrl, repoUrl: p.repoUrl,
description: project.description, description: p.description,
status: project.status, status: p.status,
progress, progress,
totalSprints, totalSprints,
doneSprints, doneSprints,
ownerSister,
openPRs: gitea?.openPRs ?? 0,
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
sprintCount: totalSprints, sprintCount: totalSprints,
currentSprint: inProgressSprint?.name ?? (doneSprints === totalSprints && totalSprints > 0 ? 'COMPLETED' : null), currentSprint: inProgressSprint?.name ?? (doneSprints === totalSprints && totalSprints > 0 ? 'COMPLETED' : null),
ownerSister,
openPRs: repoMeta?.openPRs ?? 0,
updatedAt: repoMeta?.updatedAt ?? project.updatedAt.toISOString(),
latestHotfix: meta.latestHotfix,
latestQaLabel: meta.latestQaLabel,
latestQaStatus: meta.latestQaStatus,
blockerCount: meta.blockerCount,
phase: meta.phase,
deployStatus: meta.deployStatus,
deployBranch: meta.deployBranch,
redeployRequired: meta.redeployRequired,
latestDeployAt: meta.latestDeployAt,
}; };
}); });
} }
@@ -243,74 +75,76 @@ export class ProjectsService {
if (!project) throw new NotFoundException(`Project ${id} not found`); if (!project) throw new NotFoundException(`Project ${id} not found`);
const repoName = normalizeRepoName(project.repoUrl); let giteaPRs: ReturnType<typeof Array.prototype.map> = [];
const [openPRs, docs] = await Promise.all([ try {
this.gitea.getOpenPRs(repoName).catch(() => { const repoName = project.repoUrl.split('/').pop() ?? '';
giteaPRs = await this.gitea.getOpenPRs(repoName);
} catch {
this.logger.warn(`Gitea PR fetch failed for project ${id}`); this.logger.warn(`Gitea PR fetch failed for project ${id}`);
return [];
}),
this.collectRepoDocuments(repoName),
]);
const totalSprints = project.sprints.length;
const doneSprints = project.sprints.filter((sprint) => sprint.status === 'done').length;
const activeSprint = project.sprints.find((sprint) => sprint.status === 'in_progress');
const meta = buildProjectMeta({
totalSprints,
doneSprints,
hasActiveSprint: Boolean(activeSprint),
openPRs: openPRs.length,
latestHotfix: docs.hotfixes.at(-1)?.label ?? null,
latestQaLabel: docs.qas.at(-1)?.label ?? null,
latestQaStatus: docs.qas.at(-1)?.qaStatus ?? 'unknown',
blockerCount: docs.qas.at(-1)?.blockerCount ?? 0,
updatedAt: project.updatedAt.toISOString(),
});
return {
...project,
openPRs,
phase: meta.phase,
latestQa: docs.qas.at(-1)
? {
label: docs.qas.at(-1)?.label ?? null,
status: docs.qas.at(-1)?.qaStatus ?? 'unknown',
blockerCount: docs.qas.at(-1)?.blockerCount ?? 0,
summary: docs.qas.at(-1)?.summary ?? docs.qas.at(-1)?.description ?? null,
} }
: null,
deploy: { return { ...project, openPRs: giteaPRs };
branch: 'main',
status: meta.deployStatus,
latestDeployAt: meta.latestDeployAt,
redeployRequired: meta.redeployRequired,
note: 'ERANG: LATEST MAIN REDEPLOY ONLY',
},
latestHotfix: docs.hotfixes.at(-1) ?? null,
};
} }
async getProjectCommits(id: number, limit = 20) { async getProjectCommits(id: number, limit = 20) {
const project = await this.getProjectMeta(id); const project = await this.getProjectMeta(id);
return this.gitea.getCommits(normalizeRepoName(project.repoUrl), limit); const repoName = project.repoUrl.split('/').pop() ?? '';
return this.gitea.getCommits(repoName, limit);
} }
async getProjectBranches(id: number) { async getProjectBranches(id: number) {
const project = await this.getProjectMeta(id); const project = await this.getProjectMeta(id);
return this.gitea.getBranches(normalizeRepoName(project.repoUrl)); const repoName = project.repoUrl.split('/').pop() ?? '';
return this.gitea.getBranches(repoName);
} }
async getProjectPulls(id: number, state: 'open' | 'closed' | 'all' = 'open') { async getProjectPulls(id: number, state: 'open' | 'closed' | 'all' = 'open') {
const project = await this.getProjectMeta(id); const project = await this.getProjectMeta(id);
return this.gitea.getPulls(normalizeRepoName(project.repoUrl), state); const repoName = project.repoUrl.split('/').pop() ?? '';
return this.gitea.getPulls(repoName, state);
} }
async getProjectHistory(id: number) { async getProjectHistory(id: number) {
const project = await this.getProjectMeta(id); const project = await this.getProjectMeta(id);
const repoName = normalizeRepoName(project.repoUrl); const repoName = project.repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
const docs = await this.collectRepoDocuments(repoName); const files = await this.gitea.getRepoTree(repoName, '.plans/sprints/');
return await Promise.all(
files
.filter((f) => /(SPRINT-\d+|HOTFIX-\d+)\.md$/i.test(f))
.map(async (f) => {
const name = f.split('/').pop() ?? '';
const sprint = name.match(/SPRINT-(\d+)/i);
const hotfix = name.match(/HOTFIX-(\d+)/i);
let summary: string | null = null;
let description: string | null = null;
return [...docs.sprints, ...docs.hotfixes].filter(isRepoDocumentMeta).sort((a, b) => a.order - b.order); try {
const content = await this.gitea.getRawFile(repoName, f);
const lines = (content ?? '').split('\n');
const goalLine = lines.find((line) => line.startsWith('## 목표'));
summary = goalLine?.replace('## 목표', '').trim() || null;
const taskIdx = lines.findIndex((line) => line.startsWith('## 태스크'));
if (taskIdx >= 0) {
description = lines
.slice(taskIdx + 1)
.find((line) => line.trim().startsWith('- '))
?.replace(/^\s*-\s*/, '')
.trim() ?? null;
}
} catch {
this.logger.warn(`History content fetch failed for ${f}`);
}
return {
kind: sprint ? 'sprint' : 'hotfix',
order: sprint ? parseInt(sprint[1], 10) : 1000 + parseInt(hotfix?.[1] ?? '0', 10),
label: name.replace('.md', ''),
summary,
description,
};
}),
).then((items) => items.sort((a, b) => a.order - b.order));
} }
private async getProjectMeta(id: number) { private async getProjectMeta(id: number) {
@@ -318,67 +152,4 @@ export class ProjectsService {
if (!project) throw new NotFoundException(`Project ${id} not found`); if (!project) throw new NotFoundException(`Project ${id} not found`);
return project; return project;
} }
private async getRepoMetaMap() {
const repoMetaMap: Record<number, { openPRs: number; updatedAt: string }> = {};
try {
const repos = await this.gitea.getOrgRepos();
for (const repo of repos) {
repoMetaMap[repo.id] = {
openPRs: repo.open_pr_counter,
updatedAt: repo.updated_at,
};
}
} catch {
this.logger.warn('Gitea unavailable, using DB data only');
}
return repoMetaMap;
}
private async getRepoDocumentMap(repoNames: string[]) {
const uniqueRepoNames = Array.from(new Set(repoNames.filter(Boolean)));
const entries = await Promise.all(uniqueRepoNames.map(async (repoName) => [repoName, await this.collectRepoDocuments(repoName)] as const));
return new Map(entries);
}
private async collectRepoDocuments(repoName: string) {
const [sprintPaths, hotfixPathGroups, qaPathGroups] = await Promise.all([
this.gitea.getRepoTree(repoName, SPRINT_PATH),
Promise.all(HOTFIX_PATH_CANDIDATES.map((path) => this.gitea.getRepoTree(repoName, path))),
Promise.all(QA_PATH_CANDIDATES.map((path) => this.gitea.getRepoTree(repoName, path))),
]);
const sprintFiles = sprintPaths.filter((path) => /SPRINT-\d+\.md$/i.test(path));
const hotfixFiles = Array.from(new Set(hotfixPathGroups.flat().filter((path) => /HOTFIX-\d+\.md$/i.test(path))));
const qaFiles = Array.from(new Set(qaPathGroups.flat().filter((path) => /(?:SPRINT|HOTFIX)-\d+.*(?:review|qa).*\.md$/i.test(path))));
const [sprints, hotfixes, qas] = await Promise.all([
Promise.all(sprintFiles.map((path) => this.readRepoDocument(repoName, path, 'sprint'))),
Promise.all(hotfixFiles.map((path) => this.readRepoDocument(repoName, path, 'hotfix'))),
Promise.all(qaFiles.map((path) => this.readRepoDocument(repoName, path, 'qa'))),
]);
return {
sprints: sprints.filter(isRepoDocumentMeta).sort((a, b) => a.order - b.order),
hotfixes: hotfixes.filter(isRepoDocumentMeta).sort((a, b) => a.order - b.order),
qas: qas.filter(isRepoDocumentMeta).sort((a, b) => a.order - b.order),
};
}
private async readRepoDocument(repoName: string, path: string, kind: 'sprint' | 'hotfix' | 'qa'): Promise<RepoDocumentMeta | null> {
const content = await this.gitea.getRawFile(repoName, path);
if (!content) return null;
const label = (path.split('/').pop() ?? '').replace('.md', '');
return {
path,
label,
order: extractDocOrder(path, kind),
kind,
summary: parseGoalSummary(content),
description: parseTaskSummary(content),
qaStatus: kind === 'qa' ? parseQaStatus(content) : undefined,
blockerCount: kind === 'qa' ? parseBlockerCount(content) : undefined,
};
}
} }

View File

@@ -7,9 +7,19 @@ describe('SprintSyncService', () => {
let service: SprintSyncService; let service: SprintSyncService;
const mockPrisma = { const mockPrisma = {
project: { findUnique: jest.fn(), findMany: jest.fn() }, project: {
sprint: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() }, findUnique: jest.fn(),
task: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() }, },
sprint: {
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
task: {
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
}; };
const mockGitea = { const mockGitea = {
@@ -29,8 +39,10 @@ describe('SprintSyncService', () => {
service = module.get<SprintSyncService>(SprintSyncService); service = module.get<SprintSyncService>(SprintSyncService);
jest.clearAllMocks(); jest.clearAllMocks();
mockPrisma.project.findUnique.mockResolvedValue({ id: 1, repoUrl: 'https://gitea.example.com/hanarang/project-alpha' }); mockPrisma.project.findUnique.mockResolvedValue({
mockPrisma.project.findMany.mockResolvedValue([{ id: 1, repoUrl: 'https://gitea.example.com/hanarang/project-alpha' }]); id: 1,
repoUrl: 'https://gitea.example.com/hanarang/project-alpha',
});
mockPrisma.sprint.findFirst.mockResolvedValue(null); mockPrisma.sprint.findFirst.mockResolvedValue(null);
mockPrisma.sprint.create.mockResolvedValue({ id: 101, status: 'pending' }); mockPrisma.sprint.create.mockResolvedValue({ id: 101, status: 'pending' });
mockPrisma.sprint.update.mockResolvedValue({ id: 101, status: 'pending' }); mockPrisma.sprint.update.mockResolvedValue({ id: 101, status: 'pending' });
@@ -39,64 +51,54 @@ describe('SprintSyncService', () => {
mockPrisma.task.update.mockResolvedValue({}); mockPrisma.task.update.mockResolvedValue({});
mockGitea.getRepoTree.mockImplementation(async (_repoName: string, path: string) => { mockGitea.getRepoTree.mockImplementation(async (_repoName: string, path: string) => {
if (path === '.plans/sprints/') return ['.plans/sprints/SPRINT-005.md', '.plans/sprints/HOTFIX-001.md']; if (path === '.plans/sprints/') return ['.plans/sprints/SPRINT-005.md'];
if (path === '.plans/qa/') return ['.plans/qa/SPRINT-005-review-darang.md'];
if (path === '.qa/') return []; if (path === '.qa/') return [];
if (path === '.plans/hotfix/') return ['.plans/hotfix/HOTFIX-001.md'];
return []; return [];
}); });
}); });
it('담당 라인을 읽어 assignee를 저장한다', async () => { it('syncProjectSprints: 담당 라인 기준 assignee를 저장한다', async () => {
mockGitea.getRawFile.mockImplementation(async (_repoName: string, path: string) => { mockGitea.getRawFile.mockResolvedValue(`# SPRINT-005: Infra\n\n## 목표\n테스트\n\n## 태스크\n\n### TASK-019: 인프라 배포\n- **담당:** 이랑이\n- **상태:** pending\n- **설명:** Nginx 프록시 + PM2 + SSL\n\n### TASK-020: QA 자동화\n- **담당:** 다랑이\n- **상태:** pending\n- **설명:** smoke test\n`);
if (path.includes('SPRINT-005.md')) {
return `# SPRINT-005: Infra\n\n## 목표\n테스트\n\n## 태스크\n\n### TASK-019: 인프라 배포\n- **담당:** 이랑이\n- **설명:** deploy\n\n### TASK-020: QA 자동화\n- **담당:** darang\n- **설명:** qa\n`;
}
if (path.includes('review')) return 'passed: true';
return '# HOTFIX-001: sample';
});
await service.syncProjectSprints(1); await service.syncProjectSprints(1);
expect(mockPrisma.task.create).toHaveBeenNthCalledWith(1, expect.objectContaining({ expect(mockPrisma.task.create).toHaveBeenNthCalledWith(1, expect.objectContaining({
data: expect.objectContaining({ taskId: 'TASK-019', assignee: 'erang', status: 'done' }), data: expect.objectContaining({
taskId: 'TASK-019',
assignee: 'erang',
}),
})); }));
expect(mockPrisma.task.create).toHaveBeenNthCalledWith(2, expect.objectContaining({ expect(mockPrisma.task.create).toHaveBeenNthCalledWith(2, expect.objectContaining({
data: expect.objectContaining({ taskId: 'TASK-020', assignee: 'darang', status: 'done' }), data: expect.objectContaining({
taskId: 'TASK-020',
assignee: 'darang',
}),
})); }));
}); });
it('기존 task도 assignee를 갱신한다', async () => { it('syncProjectSprints: 기존 task도 assignee를 갱신한다', async () => {
mockPrisma.task.findFirst.mockResolvedValue({ id: 500, assignee: 'narang', status: 'pending' }); mockPrisma.task.findFirst.mockResolvedValue({ id: 501, assignee: 'narang', status: 'pending' });
mockGitea.getRawFile.mockImplementation(async (_repoName: string, path: string) => { mockGitea.getRawFile.mockResolvedValue(`# SPRINT-005: Infra\n\n## 목표\n테스트\n\n## 태스크\n\n### TASK-019: 인프라 배포\n- **담당:** 하랑이\n- **상태:** pending\n- **설명:** sync\n`);
if (path.includes('SPRINT-005.md')) {
return `# SPRINT-005: Infra\n\n## 목표\n테스트\n\n## 태스크\n\n### TASK-019: 인프라 배포\n- **담당:** 하랑이\n`;
}
if (path.includes('review')) return 'passed: false';
return '# HOTFIX-001: sample';
});
await service.syncProjectSprints(1); await service.syncProjectSprints(1);
expect(mockPrisma.task.update).toHaveBeenCalledWith(expect.objectContaining({ expect(mockPrisma.task.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 500 }, where: { id: 501 },
data: expect.objectContaining({ assignee: 'harang' }), data: expect.objectContaining({
assignee: 'harang',
}),
})); }));
}); });
it('담당 라인이 없으면 narang fallback', async () => { it('syncProjectSprints: 담당 라인이 없으면 narang으로 fallback한다', async () => {
mockGitea.getRawFile.mockImplementation(async (_repoName: string, path: string) => { mockGitea.getRawFile.mockResolvedValue(`# SPRINT-005: Infra\n\n## 목표\n테스트\n\n## 태스크\n\n### TASK-019: 인프라 배포\n- **상태:** pending\n- **설명:** sync\n`);
if (path.includes('SPRINT-005.md')) {
return `# SPRINT-005: Infra\n\n## 목표\n테스트\n\n## 태스크\n\n### TASK-019: 인프라 배포\n- 설명만 있음\n`;
}
if (path.includes('review')) return 'FAILED';
return '# HOTFIX-001: sample';
});
await service.syncProjectSprints(1); await service.syncProjectSprints(1);
expect(mockPrisma.task.create).toHaveBeenCalledWith(expect.objectContaining({ expect(mockPrisma.task.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ assignee: 'narang', status: 'pending' }), data: expect.objectContaining({
assignee: 'narang',
}),
})); }));
}); });
}); });

View File

@@ -2,6 +2,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { GiteaService } from '../gitea/gitea.service'; import { GiteaService } from '../gitea/gitea.service';
interface ParsedSprint {
number: number;
name: string;
status: 'pending' | 'in_progress' | 'done';
}
interface ParsedTask { interface ParsedTask {
taskId: string; taskId: string;
title: string; title: string;
@@ -9,8 +15,6 @@ interface ParsedTask {
status: 'pending' | 'done'; status: 'pending' | 'done';
} }
const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/'];
const HOTFIX_PATH_CANDIDATES = ['.plans/hotfix/', '.plans/sprints/'];
const SISTER_NAME_MAP: Record<string, string> = { const SISTER_NAME_MAP: Record<string, string> = {
harang: 'harang', harang: 'harang',
'하랑': 'harang', '하랑': 'harang',
@@ -26,12 +30,6 @@ const SISTER_NAME_MAP: Record<string, string> = {
'이랑이': 'erang', '이랑이': 'erang',
}; };
function parseSprintName(content: string, filename: string, number: number): string {
const headingMatch = content.match(/^#\s+(?:SPRINT-\d+|Sprint\s+\d+)[:\s—\-]+(.+)/m);
if (headingMatch) return headingMatch[1].trim();
return filename.replace('.md', '') || `SPRINT-${String(number).padStart(3, '0')}`;
}
function parseTaskAssignee(taskLines: string[]): string { function parseTaskAssignee(taskLines: string[]): string {
for (const rawLine of taskLines) { for (const rawLine of taskLines) {
const line = rawLine.trim(); const line = rawLine.trim();
@@ -43,19 +41,29 @@ function parseTaskAssignee(taskLines: string[]): string {
.replace(/[()\[\],]/g, ' ') .replace(/[()\[\],]/g, ' ')
.split(/\s+/) .split(/\s+/)
.find((token) => SISTER_NAME_MAP[token.toLowerCase()] ?? SISTER_NAME_MAP[token]); .find((token) => SISTER_NAME_MAP[token.toLowerCase()] ?? SISTER_NAME_MAP[token]);
if (assignee) return SISTER_NAME_MAP[assignee.toLowerCase()] ?? SISTER_NAME_MAP[assignee];
if (assignee) {
return SISTER_NAME_MAP[assignee.toLowerCase()] ?? SISTER_NAME_MAP[assignee];
}
} }
return 'narang'; return 'narang';
} }
function parseTasks(content: string, sprintStatus: 'pending' | 'done'): ParsedTask[] { 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 lines = content.split(/\r?\n/);
const tasks: ParsedTask[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const tasks: ParsedTask[] = [];
for (let index = 0; index < lines.length; index += 1) { for (let index = 0; index < lines.length; index += 1) {
const line = lines[index].trim(); const line = lines[index].trim();
@@ -63,21 +71,29 @@ function parseTasks(content: string, sprintStatus: 'pending' | 'done'): ParsedTa
let status: 'pending' | 'done' | null = null; let status: 'pending' | 'done' | null = null;
match = line.match(/^#{2,6}\s+(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i); match = line.match(/^#{2,6}\s+(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
if (match) status = sprintStatus; if (match) {
status = sprintStatus === 'done' ? 'done' : 'pending';
}
if (!match) { if (!match) {
match = line.match(/^-\s*\[([ xX])\]\s*(TASK-\d+[A-Z]?)\s*[:\-]?\s*(.+)$/i); match = line.match(/^-\s*\[([ xX])\]\s*(TASK-\d+[A-Z]?)\s*[:\-]?\s*(.+)$/i);
if (match) status = /x/i.test(match[1]) ? 'done' : 'pending'; if (match) {
status = /x/i.test(match[1]) ? 'done' : 'pending';
}
} }
if (!match) { if (!match) {
match = line.match(/^-\s*(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i); match = line.match(/^-\s*(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
if (match) status = sprintStatus; if (match) {
status = sprintStatus === 'done' ? 'done' : 'pending';
}
} }
if (!match) { if (!match) {
match = line.match(/^(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i); match = line.match(/^(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
if (match) status = sprintStatus; if (match) {
status = sprintStatus === 'done' ? 'done' : 'pending';
}
} }
if (!match || !status) continue; if (!match || !status) continue;
@@ -99,24 +115,12 @@ function parseTasks(content: string, sprintStatus: 'pending' | 'done'): ParsedTa
} }
seen.add(taskId); seen.add(taskId);
tasks.push({ tasks.push({ taskId, title: title || taskId, assignee: parseTaskAssignee(taskBlock), status });
taskId,
title: title || taskId,
assignee: parseTaskAssignee(taskBlock),
status,
});
} }
return tasks; return tasks;
} }
function inferPassed(content: string | null): boolean {
if (!content) return false;
if (/passed\s*[:=]\s*true/i.test(content)) return true;
if (/passed\s*[:=]\s*false/i.test(content)) return false;
return /\bPASSED\b/i.test(content) && !/\bFAILED\b/i.test(content);
}
@Injectable() @Injectable()
export class SprintSyncService { export class SprintSyncService {
private readonly logger = new Logger(SprintSyncService.name); private readonly logger = new Logger(SprintSyncService.name);
@@ -140,37 +144,40 @@ export class SprintSyncService {
for (const project of projects) { for (const project of projects) {
const repoName = project.repoUrl.replace(/\/+$/, '').split('/').pop() ?? ''; const repoName = project.repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
const result = await this.syncRepo(project.id, repoName); const r = await this.syncRepo(project.id, repoName);
totalSynced += result.synced; totalSynced += r.synced;
} }
return { projects: projects.length, synced: totalSynced }; return { projects: projects.length, synced: totalSynced };
} }
private async syncRepo(projectId: number, repoName: string): Promise<{ synced: number; created: number; updated: number }> { private async syncRepo(projectId: number, repoName: string): Promise<{ synced: number; created: number; updated: number }> {
const [sprintFilePaths, qaGroups, hotfixGroups] = await Promise.all([ const [sprintFilePaths, qaFilePaths] = await Promise.all([
this.gitea.getRepoTree(repoName, '.plans/sprints/'), this.gitea.getRepoTree(repoName, '.plans/sprints/'),
Promise.all(QA_PATH_CANDIDATES.map((path) => this.gitea.getRepoTree(repoName, path))), this.gitea.getRepoTree(repoName, '.qa/'),
Promise.all(HOTFIX_PATH_CANDIDATES.map((path) => this.gitea.getRepoTree(repoName, path))),
]); ]);
const sprintFiles = sprintFilePaths.filter((path) => /SPRINT-\d+\.md$/i.test(path)); const sprintFiles = sprintFilePaths.filter((f) => /SPRINT-\d+\.md$/i.test(f));
const qaFiles = Array.from(new Set(qaGroups.flat().filter((path) => /SPRINT-\d+.*(?:review|qa).*\.md$/i.test(path)))); const qaFiles = qaFilePaths.filter((f) => /SPRINT-\d+-review-.*\.md$/i.test(f));
const hotfixFiles = Array.from(new Set(hotfixGroups.flat().filter((path) => /HOTFIX-\d+\.md$/i.test(path))));
if (!sprintFiles.length) { if (!sprintFiles.length) {
this.logger.warn(`No sprint files found in ${repoName}/.plans/sprints/`); this.logger.warn(`No sprint files found in ${repoName}/.plans/sprints/`);
return { synced: 0, created: 0, updated: 0 }; return { synced: 0, created: 0, updated: 0 };
} }
// QA 파일에서 PASSED 여부 수집: number → boolean
const passedMap = new Map<number, boolean>(); const passedMap = new Map<number, boolean>();
for (const qaPath of qaFiles) { for (const qaPath of qaFiles) {
const numberMatch = qaPath.match(/SPRINT-(\d+)/i); const numMatch = qaPath.match(/SPRINT-(\d+)-review/i);
if (!numberMatch) continue; if (!numMatch) continue;
const sprintNumber = parseInt(numberMatch[1], 10); const num = parseInt(numMatch[1], 10);
if (passedMap.get(sprintNumber)) continue; if (passedMap.get(num)) continue; // 이미 통과 확인됨
const qaContent = await this.gitea.getRawFile(repoName, qaPath); const qaContent = await this.gitea.getRawFile(repoName, qaPath);
if (inferPassed(qaContent)) passedMap.set(sprintNumber, true); if (!qaContent) continue;
if (inferStatusFromQaContent(qaContent)) {
passedMap.set(num, true);
}
} }
let created = 0; let created = 0;
@@ -181,35 +188,44 @@ export class SprintSyncService {
if (!content) continue; if (!content) continue;
const filename = filePath.split('/').pop() ?? ''; const filename = filePath.split('/').pop() ?? '';
const numberMatch = filename.match(/SPRINT-(\d+)/i); const numMatch = filename.match(/SPRINT-(\d+)/i);
if (!numberMatch) continue; if (!numMatch) continue;
const number = parseInt(numMatch[1], 10);
const number = parseInt(numberMatch[1], 10);
const name = parseSprintName(content, filename, number); const name = parseSprintName(content, filename, number);
// QA 통과 여부로 status 결정 (본문 전체 키워드 검색 금지)
const status: 'pending' | 'done' = passedMap.get(number) ? 'done' : 'pending'; const status: 'pending' | 'done' = passedMap.get(number) ? 'done' : 'pending';
const parsedTasks = parseTasks(content, status); const parsedTasks = parseTasks(content, status);
const existingSprint = await this.prisma.sprint.findFirst({ where: { projectId, number } }); const existing = await this.prisma.sprint.findFirst({
let sprintId = existingSprint?.id ?? 0; where: { projectId, number },
});
if (!existingSprint) { let sprintId: number;
const createdSprint = await this.prisma.sprint.create({ data: { projectId, number, name, status } }); if (!existing) {
const createdSprint = await this.prisma.sprint.create({
data: { projectId, number, name, status },
});
sprintId = createdSprint.id; sprintId = createdSprint.id;
created += 1; created++;
} else { } else {
const updatedSprint = await this.prisma.sprint.update({ const updatedSprint = await this.prisma.sprint.update({
where: { id: existingSprint.id }, where: { id: existing.id },
data: { data: {
name, name,
status: existingSprint.status === 'done' ? 'done' : status, status: existing.status === 'done' ? 'done' : status,
}, },
}); });
sprintId = updatedSprint.id; sprintId = updatedSprint.id;
updated += 1; updated++;
} }
// Sprint 문서 기준 Task upsert
for (const task of parsedTasks) { for (const task of parsedTasks) {
const existingTask = await this.prisma.task.findFirst({ where: { sprintId, taskId: task.taskId } }); const existingTask = await this.prisma.task.findFirst({
where: { sprintId, taskId: task.taskId },
});
if (!existingTask) { if (!existingTask) {
await this.prisma.task.create({ await this.prisma.task.create({
data: { data: {
@@ -233,7 +249,7 @@ export class SprintSyncService {
} }
} }
this.logger.log(`${repoName}: ${sprintFiles.length} sprints, ${hotfixFiles.length} hotfix docs, ${passedMap.size} QA passed → ${created} created, ${updated} updated`); this.logger.log(`${repoName}: ${sprintFiles.length} sprints, ${passedMap.size} QA passed → ${created} created, ${updated} updated`);
return { synced: sprintFiles.length, created, updated }; return { synced: sprintFiles.length, created, updated };
} }
} }

View File

@@ -31,10 +31,6 @@ export class AvatarService {
} }
const files = [ const files = [
{ path: '~/.hermes/avatar.png', type: 'image/png' },
{ path: '~/.hermes/avatar.jpg', type: 'image/jpeg' },
{ path: '~/.hermes/avatar.jpeg', type: 'image/jpeg' },
{ path: '~/.hermes/avatar.webp', type: 'image/webp' },
{ path: '~/.openclaw/avatar.png', type: 'image/png' }, { path: '~/.openclaw/avatar.png', type: 'image/png' },
{ path: '~/.openclaw/avatar.jpg', type: 'image/jpeg' }, { path: '~/.openclaw/avatar.jpg', type: 'image/jpeg' },
{ path: '~/.openclaw/avatar.jpeg', type: 'image/jpeg' }, { path: '~/.openclaw/avatar.jpeg', type: 'image/jpeg' },

View File

@@ -39,7 +39,7 @@ export class SisterDetailService {
sister.ip, sister.ip,
sister.user, sister.user,
sshKeyPath, sshKeyPath,
'WORKSPACE=~/.hermes/workspace; [ -d "$WORKSPACE" ] || WORKSPACE=~/.openclaw/workspace; cat "$WORKSPACE"/SOUL.md 2>/dev/null | head -60; echo "---AGENTS---"; cat "$WORKSPACE"/AGENTS.md 2>/dev/null | head -40', 'cat ~/.openclaw/workspace/SOUL.md 2>/dev/null | head -60; echo "---AGENTS---"; cat ~/.openclaw/workspace/AGENTS.md 2>/dev/null | head -40',
); );
return { return {
@@ -67,7 +67,7 @@ export class SisterDetailService {
sister.ip, sister.ip,
sister.user, sister.user,
sshKeyPath, sshKeyPath,
'SESSION_DIR=~/.hermes/sessions; [ -d "$SESSION_DIR" ] || SESSION_DIR=~/.openclaw/sessions; ls -lt "$SESSION_DIR"/ 2>/dev/null | head -20 || echo ""', 'ls -lt ~/.openclaw/sessions/ 2>/dev/null | head -20 || echo ""',
); );
const lines = result.stdout.split('\n').filter((l) => l && !l.startsWith('total')); const lines = result.stdout.split('\n').filter((l) => l && !l.startsWith('total'));
@@ -93,7 +93,7 @@ export class SisterDetailService {
sister.ip, sister.ip,
sister.user, sister.user,
sshKeyPath, sshKeyPath,
'AGENT_DIR=~/.hermes/workspace/agents; [ -d "$AGENT_DIR" ] || AGENT_DIR=~/.openclaw/workspace/agents; ls "$AGENT_DIR"/ 2>/dev/null || echo ""', 'ls ~/.openclaw/workspace/agents/ 2>/dev/null || echo ""',
); );
const agents = result.stdout.split('\n').filter((l) => l.trim().endsWith('.md')); const agents = result.stdout.split('\n').filter((l) => l.trim().endsWith('.md'));

View File

@@ -96,7 +96,7 @@ export class SistersService {
sister.ip, sister.ip,
sister.user, sister.user,
sshKeyPath, sshKeyPath,
'if systemctl --user is-active hermes-agent >/dev/null 2>&1; then echo active; elif systemctl --user is-active hermes-gateway >/dev/null 2>&1; then echo active; elif pgrep -f "hermes.*gateway|hermes.*agent" >/dev/null 2>&1; then echo active; elif systemctl --user is-active openclaw-gateway >/dev/null 2>&1; then echo active; else echo inactive; fi', 'systemctl --user is-active openclaw-gateway 2>/dev/null || echo "inactive"',
); );
const isActive = result.stdout.trim() === 'active'; const isActive = result.stdout.trim() === 'active';

View File

@@ -1,15 +0,0 @@
# QUALITY SCORE
## 현재 기준
- 문서 선작성/선push: 필수
- 나랑 구현 브랜치 분리: 필수
- 다랑 QA 문서화: 필수
- 검증 후 main merge: 필수
- main 배포 가능 상태 유지: 필수
## Sprint 014 집중 항목
- Hermes 경로 반영 정확도
- 프로젝트 진행률 계산 정확도
- 모바일 프로젝트/자매 페이지 가독성
- 아바타 fallback 안정성
- 배포 플로우 표현 일관성

View File

@@ -1,24 +0,0 @@
# Project Information Architecture
## 목적
프로젝트 화면은 단순 repo 목록이 아니라, 하나랑 파이프라인의 상태를 읽는 운영 화면이어야 해.
## 프로젝트 정보 구조
1. Overview
- 프로젝트명 / 상태 / repo 링크 / 마지막 업데이트
2. Delivery Flow
- Planning → Implementation → QA → Merge → Deploy
3. Sprint Ledger
- Sprint별 진행률 / 태스크 수 / 완료 수 / 현재 단계
4. Hotfix History
- Hotfix 번호 / 요약 / 반영 상태 / 적용 시점
5. QA State
- latest passed/failed / blocker 수 / 최근 QA 문서 링크
6. Deployment State
- `main` 배포 여부 / 마지막 배포 시각 / 재배포 필요 여부
## UX 원칙
- 숫자보다 상태 전이가 먼저 읽혀야 함
- Sprint와 Hotfix는 같은 영역에 섞지 말고, 관계는 보여주되 리스트는 분리
- 모바일에서도 단계 흐름이 먼저 보이게 구성
- 프로젝트 목록은 카드보다 운영 row에 가깝게 유지

View File

@@ -1,20 +0,0 @@
# Repo Structure Reference
## 현재 표준
- 루트 문서: `README.md`, `ARCHITECTURE.md`, `AGENTS.md`
- 실행 문서: `.plans/`
- 장기 문서: `docs/`
## 문서 역할
- `.plans/OVERVIEW.md`: 현재 활성 Sprint/Hotfix와 전체 진행 개요
- `.plans/design/`: 시스템/도메인/페이지 디자인
- `.plans/sprints/`: Sprint 단위 실행 계획
- `.plans/hotfix/`: Hotfix 단위 실행 계획
- `.plans/qa/`: 다랑이 QA 결과와 체크리스트
- `.plans/deploy/`: 배포/롤백/운영 플로우
- `docs/product-specs/`: 제품 기능 정책과 IA
- `docs/references/`: 구조/규칙/참고 자료
## 호환성 메모
Sprint 014 이전 데이터는 일부가 `.qa/`, `.plans/sprints/HOTFIX-xxx.md`, `.openclaw/...` 기반이야.
이번 작업에서는 기존 데이터를 읽을 수 있게 유지하면서 신규 입력은 새 구조로 정렬하는 걸 기본 원칙으로 둬.

View File

@@ -1,14 +0,0 @@
# Tech Debt Tracker
## Sprint 014 이전 누적 부채
- OpenClaw 전용 경로 하드코딩 잔존
- `.qa/``.plans/qa/` 이중 구조
- Hotfix 문서가 `.plans/sprints/`에 섞여 있음
- 프로젝트 진행률 계산이 Sprint 완료 비율 중심이라 Hotfix/QA/배포 상태를 충분히 반영하지 못함
- 자매 아바타 탐색 경로가 실제 운영 파일 위치와 어긋날 수 있음
## Sprint 014 목표
- Hermes 우선 + OpenClaw fallback 이행
- 프로젝트 문서 구조를 신규 표준으로 정렬
- 프로젝트 페이지 정보 구조 재설계
- main deployable / erang redeploy-only 흐름 명문화

View File

@@ -2,7 +2,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { PageTitle, 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';
// ─── Styled ─── // ─── Styled ───
@@ -179,17 +179,8 @@ function formatDetail(detail: string | null, action: string): React.ReactNode {
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const ALL_TYPES = ['ALL', 'SYSTEM', 'SECURITY', 'ORG', 'BACKUP']; const ALL_TYPES = ['ALL', 'SYSTEM', 'SECURITY', 'ORG', 'BACKUP'];
interface ActivityItem {
id: number;
action: string;
detail: string | null;
createdAt: string;
sister?: { name: string } | null;
project?: { name: string } | null;
}
export default function ActivitiesPage() { export default function ActivitiesPage() {
const [items, setItems] = useState<ActivityItem[]>([]); const [items, setItems] = useState<any[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [filter, setFilter] = useState('ALL'); const [filter, setFilter] = useState('ALL');
@@ -226,6 +217,7 @@ export default function ActivitiesPage() {
const paged = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); const paged = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const runtimeStr = (() => { const runtimeStr = (() => {
const h = Math.floor(total * 0.5);
return `SYS_LOG_V.4.2 // ENTRIES: ${total}`; return `SYS_LOG_V.4.2 // ENTRIES: ${total}`;
})(); })();

View File

@@ -186,7 +186,7 @@ export default function CostsPage() {
) : !data || data.summary.recordCount === 0 ? ( ) : !data || data.summary.recordCount === 0 ? (
<EmptyNote> <EmptyNote>
.<br /> .<br />
&quot; &quot; . "토큰 수집" .
</EmptyNote> </EmptyNote>
) : ( ) : (
<> <>

View File

@@ -17,12 +17,6 @@ interface ActionResult {
error: string | null; error: string | null;
} }
interface SisterAdminItem {
id?: number;
name: string;
status: Status;
}
const Table = styled.div`display:flex;flex-direction:column;gap:8px;user-select:none;`; const Table = styled.div`display:flex;flex-direction:column;gap:8px;user-select:none;`;
const Row = styled.div`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:10px;padding:16px 20px;display:flex;align-items:center;gap:16px;`; const Row = styled.div`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:10px;padding:16px 20px;display:flex;align-items:center;gap:16px;`;
const SisterInfo = styled.div`display:flex;align-items:center;gap:10px;flex:1;`; const SisterInfo = styled.div`display:flex;align-items:center;gap:10px;flex:1;`;
@@ -32,7 +26,7 @@ const ActionBtn = styled.button<{ $danger?: boolean }>`padding:6px 14px;border-r
const ResultBanner = styled.div<{ $success: boolean }>`margin-top:8px;padding:8px 12px;background:${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};border:1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};border-radius:6px;font-size:12px;color:${({ $success }) => $success ? '#00FF00' : '#FF1744'};font-family:monospace;white-space:pre-wrap;`; const ResultBanner = styled.div<{ $success: boolean }>`margin-top:8px;padding:8px 12px;background:${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};border:1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};border-radius:6px;font-size:12px;color:${({ $success }) => $success ? '#00FF00' : '#FF1744'};font-family:monospace;white-space:pre-wrap;`;
export default function AdminSistersPage() { export default function AdminSistersPage() {
const [sisters, setSisters] = useState<SisterAdminItem[]>([]); const [sisters, setSisters] = useState<any[]>([]);
const [modal, setModal] = useState<{ action: 'restart' | 'reset'; name: string } | null>(null); const [modal, setModal] = useState<{ action: 'restart' | 'reset'; name: string } | null>(null);
const [results, setResults] = useState<Record<string, ActionResult>>({}); const [results, setResults] = useState<Record<string, ActionResult>>({});
const [loading, setLoading] = useState<Record<string, boolean>>({}); const [loading, setLoading] = useState<Record<string, boolean>>({});

View File

@@ -67,18 +67,8 @@ const NoProjects = styled.div`
font-size: 13px; font-size: 13px;
`; `;
interface RepoProjectItem {
id: number;
name: string;
description: string | null;
repoUrl: string;
openPRs: number;
sprintCount: number;
progress: number;
}
export default function ReposPage() { export default function ReposPage() {
const [projects, setProjects] = useState<RepoProjectItem[]>([]); const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {

View File

@@ -3,22 +3,22 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import styled, { keyframes } from 'styled-components'; import styled, { keyframes } from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader, PageTitle, Card, BracketValue, LabelMeta, TechBar, TechBarFill, SectionTitle, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base'; import {
PageHeader, PageTitle, Card, BracketValue, LabelMeta,
TechBar, TechBarFill, SectionTitle, Timeline, TimelineItem,
TimeStamp, TimelineContent,
} 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';
interface SisterItem { id?: number; name: string; status: string; cpu?: number; uptime?: string; }
interface ProjectItem { id: number; name: string; description: string | null; phase: string; progress: number; deployStatus: string; ownerSister?: string; }
interface ActivityItem { id: number; createdAt: string; detail?: string; action?: string; sister?: { name: string } | null; }
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`; const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
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)}`; 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)}`;
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);}`; 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)}}`;
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;}`; 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;}@media (max-width:767px){gap:var(--space-lg);}`;
const ChartCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);min-width:0;overflow:hidden;`; const ChartCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);min-width:0;overflow:hidden;`;
const ProjectList = styled.div`display:flex;flex-direction:column;`; 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 .15s;&:last-child{border-bottom:none}&:hover{opacity:.8}`; 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}`;
const ProjectMark = styled.div`width:32px;height:32px;border-radius:50%;border:1px solid var(--border-color);display:flex;align-items:center;justify-content:center;background:#111;color:var(--text-secondary);font-family:var(--font-mono);font-size:12px;flex-shrink:0;`;
const ProjectDetails = styled.div`display:flex;flex-direction:column;gap:2px;min-width:0;`; 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 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 ProjectDesc = styled.div`font-size:13px;color:var(--text-secondary);line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
@@ -31,40 +31,20 @@ const BarCol = styled.div`display:flex;flex-direction:column;align-items:center;
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'};`; 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'};`;
const BarLabel = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`; const BarLabel = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const SISTER_SHORT: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' }; const SISTER_SHORT: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
function sisterBracketValue(s: any): string { if (s.status === 'online') return '[ON]'; if (s.status === 'working') return '[RUN]'; if (s.status === 'offline') return '[--]'; return '[??]'; }
function sisterBracketValue(sister: SisterItem): string { 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; }
if (sister.status === 'online') return '[ON]'; function sisterSubLabel(s: any): string { if (s.uptime) return `Uptime ${s.uptime}`; return s.status; }
if (sister.status === 'working') return '[RUN]'; function formatTimestamp(iso: string): string { const d = new Date(iso); return d.toLocaleTimeString('ko-KR', { hour12: false }) + ' GMT+9'; }
if (sister.status === 'offline') return '[--]';
return '[??]';
}
function sisterBarWidth(sister: SisterItem): number {
if (typeof sister.cpu === 'number') return Math.max(0, Math.min(100, sister.cpu));
if (sister.status === 'online') return 100;
if (sister.status === 'working') return 84;
if (sister.status === 'offline') return 0;
return 12;
}
function sisterSubLabel(sister: SisterItem): string {
return sister.uptime ? `Hermes ${sister.uptime}` : sister.status;
}
function formatTimestamp(iso: string): string {
const date = new Date(iso);
return `${date.toLocaleTimeString('ko-KR', { hour12: false })} GMT+9`;
}
export default function DashboardPage() { export default function DashboardPage() {
const [sisters, setSisters] = useState<SisterItem[]>([]); const [sisters, setSisters] = useState<any[]>([]);
const [projects, setProjects] = useState<ProjectItem[]>([]); const [projects, setProjects] = useState<any[]>([]);
const [activityItems, setActivityItems] = useState<ActivityItem[]>([]); const [activityItems, setActivityItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleSistersUpdate = useCallback((items: SisterItem[]) => setSisters(items), []); const handleSistersUpdate = useCallback((s: any[]) => setSisters(s), []);
const handleActivityNew = useCallback((item: ActivityItem) => setActivityItems((prev) => [item, ...prev].slice(0, 10)), []); const handleActivityNew = useCallback((item: any) => setActivityItems((prev) => [item, ...prev].slice(0, 10)), []);
const { connected } = useSocket({ onSistersUpdate: handleSistersUpdate, onActivityNew: handleActivityNew }); const { connected } = useSocket({ onSistersUpdate: handleSistersUpdate, onActivityNew: handleActivityNew });
useEffect(() => { useEffect(() => {
@@ -97,52 +77,39 @@ export default function DashboardPage() {
<PageHeader><PageTitle> <WsIndicator $on={connected}> {connected ? 'LIVE' : 'POLL'}</WsIndicator></PageTitle></PageHeader> <PageHeader><PageTitle> <WsIndicator $on={connected}> {connected ? 'LIVE' : 'POLL'}</WsIndicator></PageTitle></PageHeader>
{error && <ErrorBox>{error}</ErrorBox>} {error && <ErrorBox>{error}</ErrorBox>}
<StatusGrid> <StatusGrid>
{loading ? Array.from({ length: 4 }).map((_, index) => <SkeletonCard key={index} />) : sisters.map((sister) => ( {loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />) : sisters.map((s) => (
<Card key={sister.id ?? sister.name} as={Link} href={`/sisters/${sister.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', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta><LabelMeta>{String(Math.round(Number(s.cpu ?? sisterBarWidth(s)))).padStart(2, '0')}%</LabelMeta></div>
<LabelMeta><span>SYS:</span>{SISTER_SHORT[sister.name] ?? sister.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>
<SisterAvatar name={sister.name} size={24} /> <TechBar><TechBarFill $width={sisterBarWidth(s)} /></TechBar>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}>
<BracketValue $dimmed={sister.status === 'offline'}>{sisterBracketValue(sister)}</BracketValue>
<LabelMeta>{sisterSubLabel(sister)}</LabelMeta>
</div>
<TechBar><TechBarFill $width={sisterBarWidth(sister)} /></TechBar>
</Card> </Card>
))} ))}
</StatusGrid> </StatusGrid>
<ChartGrid> <ChartGrid>
<ChartCard> <ChartCard>
<SectionTitle><span>HERMES NODE LOAD</span><LabelMeta>CPU %</LabelMeta></SectionTitle> <SectionTitle><span>SISTER LOAD</span><LabelMeta>CPU %</LabelMeta></SectionTitle>
<BarChart>{(sisters.length > 0 ? sisters : [{ name: 'harang', status: 'offline' }, { name: 'narang', status: 'offline' }, { name: 'darang', status: 'offline' }, { name: 'erang', status: 'offline' }]).map((sister) => <BarCol key={sister.name}><Bar $height={Math.max(4, Number(sister.cpu ?? 0))} /><BarLabel>{SISTER_SHORT[sister.name] ?? sister.name}</BarLabel></BarCol>)}</BarChart> <BarChart>{(sisters.length > 0 ? sisters : [{ name: 'harang', cpu: 0 }, { name: 'narang', cpu: 0 }, { name: 'darang', cpu: 0 }, { name: 'erang', cpu: 0 }]).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>
<ChartCard> <ChartCard>
<SectionTitle><span>PROJECT PHASE</span><LabelMeta>TOP {projects.length}</LabelMeta></SectionTitle> <SectionTitle><span>PROJECT PROGRESS</span><LabelMeta>TOP {projects.length}</LabelMeta></SectionTitle>
<BarChart>{(projects.length > 0 ? projects.slice(0, 6) : [{ id: 0, name: 'EMPTY', phase: 'PLANNING', progress: 0, deployStatus: 'REDEPLOY REQUIRED', description: null }]).map((project) => <BarCol key={project.id}><Bar $height={Math.max(4, Number(project.progress ?? 0))} /><BarLabel>{project.phase.slice(0, 6)}</BarLabel></BarCol>)}</BarChart> <BarChart>{(projects.length > 0 ? projects.slice(0, 6) : [{ id: 'empty-1', name: 'EMPTY', progress: 0 }]).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> </ChartCard>
</ChartGrid> </ChartGrid>
<DataColumns> <DataColumns>
<section> <section>
<SectionTitle><span>ONGOING PROJECTS</span><LabelMeta>PROJECT = 2ND NAV</LabelMeta></SectionTitle> <SectionTitle><span>ONGOING PROJECTS</span><LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta></SectionTitle>
<ProjectList> <ProjectList>
{loading ? Array.from({ length: 4 }).map((_, index) => <SkeletonLine key={index} />) : projects.length === 0 ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}> </div> : projects.map((project) => ( {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) => (
<ProjectRow key={project.id} href={`/projects/${project.id}`}> <ProjectRow key={p.id} href={`/projects/${p.id}`}><ProjectMark>{String(p.name ?? 'P').slice(0, 1).toUpperCase()}</ProjectMark><ProjectDetails><ProjectName>{p.name}</ProjectName><ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc></ProjectDetails><LabelMeta>{p.progress ?? 0}%</LabelMeta></ProjectRow>
<SisterAvatar name={project.ownerSister ?? 'narang'} size={32} />
<ProjectDetails>
<ProjectName>{project.name}</ProjectName>
<ProjectDesc>{project.phase} · {project.deployStatus}</ProjectDesc>
</ProjectDetails>
<LabelMeta>{project.progress ?? 0}%</LabelMeta>
</ProjectRow>
))} ))}
</ProjectList> </ProjectList>
</section> </section>
<section> <section>
<SectionTitle><span>ACTIVITY FEED</span><LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta></SectionTitle> <SectionTitle><span>ACTIVITY FEED</span><LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta></SectionTitle>
<Timeline> <Timeline>
{loading ? Array.from({ length: 5 }).map((_, index) => <SkeletonLine key={index} />) : activityItems.length === 0 ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', paddingLeft: 'var(--space-lg)' }}> </div> : activityItems.map((item) => ( {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) => (
<TimelineItem key={item.id}><TimeStamp>{formatTimestamp(item.createdAt)}</TimeStamp><TimelineContent>{item.sister && <strong>{item.sister.name} </strong>}{item.detail ?? item.action}</TimelineContent></TimelineItem> <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>

View File

@@ -1,265 +1,608 @@
'use client'; 'use client';
import React, { useEffect, useMemo, 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, BtnToggle } 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';
import SisterAvatar from '@/components/common/SisterAvatar'; import SisterAvatar from '@/components/common/SisterAvatar';
interface TaskItem { id: number; taskId: string; title: string; assignee: string; status: string; } // ─── Styled ───
interface SprintItem { id: number; number: number; name: string; status: string; progress: number; tasks: TaskItem[]; } const Breadcrumb = styled.div`
interface HistoryItem { label: string; kind: 'sprint' | 'hotfix'; summary?: string | null; description?: string | null; } font-family: var(--font-mono);
interface SisterState { name: string; status: string; } font-size: 11px;
interface CommitItem { sha?: string; html_url?: string; commit?: { message?: string; author?: { date?: string; name?: string } }; author?: { login?: string } | null; } color: var(--text-secondary);
interface BranchItem { name?: string; protected?: boolean; commit?: { id?: string } } margin-bottom: var(--space-md);
interface PullItem { id?: number; number?: number; title?: string; html_url?: string; state?: string; merged?: boolean; user?: { login?: string }; created_at?: string; } a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
interface ProjectDetail { `;
id: number;
name: string; const PageTitleRow = styled.div`
repoUrl: string; display: flex;
status: string; justify-content: space-between;
phase: string; align-items: flex-start;
currentSprint?: string | null; gap: var(--space-lg);
latestQa: { label: string | null; status: 'passed' | 'failed' | 'unknown'; blockerCount: number; summary: string | null } | null; margin-bottom: var(--space-xl);
deploy: { branch: string; status: string; latestDeployAt: string | null; redeployRequired: boolean; note: string }; 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`
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--space-xxl);
align-items: start;
@media (max-width: 1199px) { grid-template-columns: 1fr; gap: var(--space-xl); }
`;
const PhaseTimeline = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-lg);
margin-bottom: var(--space-xl);
`;
const PhaseItem = styled.div`
display: grid;
grid-template-columns: 100px 1fr;
gap: var(--space-lg);
align-items: flex-start;
`;
const PhaseMeta = styled.div`
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
line-height: 1.6;
padding-top: var(--space-sm);
`;
const PhaseBox = styled.div<{ $active?: boolean }>`
border: 1px solid ${({ $active }) => $active ? 'var(--border-hover)' : 'var(--border-color)'};
padding: var(--space-md) var(--space-lg);
background: ${({ $active }) => $active ? '#1a1a1a' : 'transparent'};
`;
const PhaseName = styled.div`
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-xs);
`;
const PhaseDesc = styled.div`
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
`;
const NodeStack = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-bottom: var(--space-xl);
`;
const NodeMiniCard = styled.div`
border: 1px solid var(--border-color);
padding: var(--space-sm) var(--space-md);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
`;
const NodeInfo = styled.div`
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
`;
const NodeLabel = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
`;
const NodeName = styled.div`
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
`;
const NodeRole = styled.div`
font-size: 10px;
color: var(--text-secondary);
font-family: var(--font-mono);
`;
const NodeDot = styled.div`
width: 6px; height: 6px;
background: #00FF00;
border-radius: 50%;
`;
const Checklist = styled.ul`
list-style: none;
display: flex;
flex-direction: column;
gap: 0;
`;
const CheckItem = styled.li<{ $done?: boolean }>`
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) 0;
border-bottom: 1px solid #1a1a1a;
font-size: 13px;
color: ${({ $done }) => $done ? 'var(--text-secondary)' : 'var(--text-primary)'};
text-decoration: ${({ $done }) => $done ? 'line-through' : 'none'};
&:last-child { border-bottom: none; }
`;
const CheckBox = styled.div<{ $checked?: boolean }>`
width: 14px; height: 14px;
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
flex-shrink: 0;
background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'transparent'};
position: relative;
${({ $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 } {
if (sprint.status === 'done') return { label: `COMPLETED`, isActive: false };
if (sprint.status === 'in_progress') return { label: 'IN PROGRESS', isActive: true };
return { label: 'PENDING', isActive: false };
} }
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);}}`; function formatDate(str: string): string {
const PageTitleRow = styled.div`display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-lg);margin-bottom:var(--space-xl);flex-wrap:wrap;`; return new Date(str).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
const ProjectGrid = styled.div`display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:var(--space-xl);align-items:start;@media (max-width:1199px){display:flex;flex-direction:column;}`;
const Panel = styled.section<{ $column?: 'main' | 'side'; $mobileOrder?: number }>`border:1px solid var(--border-color);padding:var(--space-lg);background:var(--bg-surface);${({$column})=>$column==='side'?'grid-column:2;':'grid-column:1;'}@media (max-width:1199px){order:${({$mobileOrder=0})=>$mobileOrder};}`;
const FlowList = styled.div`display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--space-sm);@media (max-width:767px){grid-template-columns:1fr;}`;
const FlowItem = styled.div<{ $active:boolean }>`border:1px solid ${({$active})=>$active?'var(--text-primary)':'var(--border-color)'};padding:var(--space-md);background:${({$active})=>$active?'#111':'transparent'};display:flex;flex-direction:column;gap:4px;`;
const LedgerList = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`;
const LedgerItem = styled.div`display:flex;flex-direction:column;gap:8px;border-top:1px solid #1a1a1a;padding-top:var(--space-md);&:first-child{border-top:none;padding-top:0;}`;
const Meta = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`;
const NodeStack = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`;
const NodeMiniCard = styled.div`border:1px solid var(--border-color);padding:var(--space-sm) var(--space-md);display:flex;justify-content:space-between;align-items:center;gap:var(--space-md);`;
const NodeInfo = styled.div`display:flex;align-items:center;gap:var(--space-sm);`;
const NodeDot = styled.div<{ $status:string }>`width:8px;height:8px;border-radius:50%;background:${({$status})=>$status==='online'?'#00ff88':$status==='working'?'#5fafff':'#555'};`;
const Empty = styled.div`padding:var(--space-xl) 0;color:var(--text-secondary);font-size:13px;font-family:var(--font-mono);`;
const TabBar = styled.div`display:flex;gap:var(--space-sm);margin-bottom:var(--space-xl);flex-wrap:wrap;`;
const GitList = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`;
const GitItem = styled.a`border:1px solid var(--border-color);padding:12px var(--space-lg);text-decoration:none;color:var(--text-primary);display:flex;justify-content:space-between;gap:var(--space-md);align-items:center;`;
const GitPrimary = styled.div`display:flex;flex-direction:column;gap:4px;min-width:0;`;
const GitTitle = styled.div`font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const ROLE_LABEL: Record<string, string> = { harang: 'ORCHESTRATOR', narang: 'GENERATOR', darang: 'EVALUATOR', erang: 'INFRA' };
const ROLE_NAME: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
function formatDate(value?: string | null): string {
if (!value) return '-';
return new Date(value).toLocaleString('ko-KR');
} }
const NODE_META = {
harang: { label: '하랑', role: 'PRIMARY' },
narang: { label: '나랑', role: 'GEN' },
darang: { label: '다랑', role: 'EVAL' },
erang: { label: '이랑', role: 'INFRA' },
} as const;
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<ProjectDetail | null>(null); const [project, setProject] = useState<any>(null);
const [tasks, setTasks] = useState<SprintItem[]>([]); const [tasks, setTasks] = useState<any[]>([]);
const [history, setHistory] = useState<HistoryItem[]>([]); const [history, setHistory] = useState<any[]>([]);
const [sisters, setSisters] = useState<SisterState[]>([]); const [commits, setCommits] = useState<any[]>([]);
const [commits, setCommits] = useState<CommitItem[]>([]); const [branches, setBranches] = useState<any[]>([]);
const [branches, setBranches] = useState<BranchItem[]>([]); const [pulls, setPulls] = useState<any[]>([]);
const [pulls, setPulls] = useState<PullItem[]>([]); const [prFilter, setPrFilter] = useState<'open' | 'closed' | 'all'>('open');
const [tab, setTab] = useState<'overview'|'commits'|'branches'|'prs'>('overview'); 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([
fetch(`${API_URL}/api/projects/${id}`), fetch(`${API_URL}/api/projects/${id}`),
fetch(`${API_URL}/api/projects/${id}/tasks`), fetch(`${API_URL}/api/projects/${id}/tasks`),
fetch(`${API_URL}/api/projects/${id}/history`), fetch(`${API_URL}/api/projects/${id}/history`),
fetch(`${API_URL}/api/sisters`), ]).then(([pRes, tRes, hRes]) => {
]).then(async ([projectRes, tasksRes, historyRes, sistersRes]) => { if (pRes.status === 'fulfilled' && pRes.value.ok) pRes.value.json().then(setProject);
if (projectRes.status === 'fulfilled' && projectRes.value.ok) setProject(await projectRes.value.json()); if (tRes.status === 'fulfilled' && tRes.value.ok) tRes.value.json().then(setTasks);
if (tasksRes.status === 'fulfilled' && tasksRes.value.ok) setTasks(await tasksRes.value.json()); if (hRes.status === 'fulfilled' && hRes.value.ok) hRes.value.json().then(setHistory);
if (historyRes.status === 'fulfilled' && historyRes.value.ok) setHistory(await historyRes.value.json());
if (sistersRes.status === 'fulfilled' && sistersRes.value.ok) setSisters(await sistersRes.value.json());
}).finally(() => setLoading(false)); }).finally(() => setLoading(false));
}, [id]); }, [id]);
const loadCommits = useCallback(async () => {
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(() => { useEffect(() => {
if (tab === 'commits' && commits.length === 0) fetch(`${API_URL}/api/projects/${id}/commits?limit=20`).then((r) => r.ok ? r.json() : []).then(setCommits).catch(() => {}); if (tab === 'commits' && commits.length === 0) loadCommits();
if (tab === 'branches' && branches.length === 0) fetch(`${API_URL}/api/projects/${id}/branches`).then((r) => r.ok ? r.json() : []).then(setBranches).catch(() => {}); if (tab === 'branches' && branches.length === 0) loadBranches();
if (tab === 'prs' && pulls.length === 0) fetch(`${API_URL}/api/projects/${id}/pulls?state=all`).then((r) => r.ok ? r.json() : []).then(setPulls).catch(() => {}); if (tab === 'prs' && pulls.length === 0) loadPulls(prFilter);
}, [tab, id, commits.length, branches.length, pulls.length]); }, [tab]);
const allTasks = useMemo(() => tasks.flatMap((sprint) => sprint.tasks ?? []), [tasks]); useEffect(() => {
const assignedNodes = useMemo(() => { if (tab === 'prs') loadPulls(prFilter);
const names = Array.from(new Set(allTasks.map((task) => task.assignee).filter(Boolean))); }, [prFilter]);
return (names.length > 0 ? names : ['harang', 'narang', 'darang', 'erang']).map((name) => ({
const allTasks = tasks.flatMap((s: any) => s.tasks ?? []);
const activeSprint = tasks.find((s: any) => s.status === 'in_progress');
const assignedNodeNames = Array.from(new Set(allTasks.map((task: any) => task.assignee).filter(Boolean)));
const assignedNodes = (assignedNodeNames.length > 0 ? assignedNodeNames : ['harang', 'narang', 'darang', 'erang']).map((name) => ({
name, name,
status: sisters.find((sister) => sister.name === name)?.status ?? 'offline', label: NODE_META[name as keyof typeof NODE_META]?.label ?? name,
role: NODE_META[name as keyof typeof NODE_META]?.role ?? 'NODE',
})); }));
}, [allTasks, sisters]); const hotfixEntries = history.filter((entry: any) => entry.kind === 'hotfix');
const hotfixes = history.filter((entry) => entry.kind === 'hotfix');
const currentSprintMeta = useMemo(() => {
if (project?.currentSprint) return project.currentSprint;
const activeSprint = tasks.find((sprint) => sprint.status === 'in_progress');
if (activeSprint) return activeSprint.name || String(activeSprint.number).padStart(2, '0');
const latestSprint = tasks.at(-1);
if (latestSprint?.status === 'done') return 'COMPLETED';
if (latestSprint) return latestSprint.name || String(latestSprint.number).padStart(2, '0');
return 'BACKLOG';
}, [project, tasks]);
if (loading) return <Empty>LOADING...</Empty>; if (loading) return <Empty>LOADING...</Empty>;
if (!project) return <Empty>PROJECT NOT FOUND</Empty>; if (!project) return <Empty>PROJECT NOT FOUND</Empty>;
const flowStates = [
{ key: 'PLANNING', title: 'Planning', active: project.phase === 'PLANNING' },
{ key: 'IMPLEMENT', title: 'Implement', active: project.phase === 'IMPLEMENT' },
{ key: 'QA', title: 'QA', active: project.phase === 'QA' },
{ key: 'READY FOR DEPLOY', title: 'Merge to Main', active: project.phase === 'READY FOR DEPLOY' },
{ key: 'DEPLOYED', title: 'Redeploy', active: project.phase === 'DEPLOYED' },
];
return ( return (
<> <>
<Breadcrumb><Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / {project.name}</Breadcrumb> <Breadcrumb>
<Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / {project.name}
</Breadcrumb>
<PageTitleRow> <PageTitleRow>
<PageTitle> <PageTitle>
{project.name} {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>} {project.repoUrl && (
<a href={project.repoUrl} target="_blank" rel="noopener"
style={{ fontSize: '12px', color: '#5fafff', marginLeft: 'var(--space-md)', fontWeight: 400 }}>
GITEA
</a>
)}
</PageTitle> </PageTitle>
<LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / <span>SPRINT:</span>{currentSprintMeta} / <span>DEPLOY:</span>{project.deploy.status}</LabelMeta> <LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / {activeSprint?.name ?? 'PLANNING'}</LabelMeta>
</PageTitleRow> </PageTitleRow>
<TabBar> <TabBar>
<BtnToggle $active={tab === 'overview'} onClick={() => setTab('overview')}>Overview</BtnToggle> {TABS.map((t) => (
<BtnToggle $active={tab === 'commits'} onClick={() => setTab('commits')}>Commits</BtnToggle> <TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
<BtnToggle $active={tab === 'branches'} onClick={() => setTab('branches')}>Branches</BtnToggle> {t.label}
<BtnToggle $active={tab === 'prs'} onClick={() => setTab('prs')}>Pull Requests</BtnToggle> </TabBtn>
))}
</TabBar> </TabBar>
{/* OVERVIEW */}
{tab === 'overview' && ( {tab === 'overview' && (
<ProjectGrid> <ProjectGrid>
<Panel $column="main" $mobileOrder={1}>
<SectionTitle><span>DELIVERY FLOW</span><LabelMeta>{project.phase}</LabelMeta></SectionTitle>
<FlowList>
{flowStates.map((item) => (
<FlowItem key={item.key} $active={item.active}>
<LabelMeta>{item.title}</LabelMeta>
<Meta>{item.key}</Meta>
</FlowItem>
))}
</FlowList>
</Panel>
<Panel $column="side" $mobileOrder={2}>
<SectionTitle><span>QA STATUS</span><LabelMeta>{project.latestQa?.status ?? 'unknown'}</LabelMeta></SectionTitle>
<Meta>{project.latestQa?.label ?? 'QA 문서 없음'}</Meta>
<div style={{ marginTop: 'var(--space-md)', fontSize: '13px', color: 'var(--text-primary)' }}>{project.latestQa?.summary ?? 'latest QA 없음'}</div>
<Meta style={{ marginTop: 'var(--space-md)' }}>{project.latestQa?.status === 'passed' ? 'QA PASSED' : project.latestQa?.status === 'failed' ? `QA FAILED · ${project.latestQa.blockerCount} BLOCKERS` : 'QA PENDING'}</Meta>
</Panel>
<Panel $column="side" $mobileOrder={3}>
<SectionTitle><span>DEPLOY STATUS</span><LabelMeta>{project.deploy.branch}</LabelMeta></SectionTitle>
<Meta>{project.deploy.status}</Meta>
<Meta style={{ marginTop: 'var(--space-sm)' }}>{project.deploy.note}</Meta>
<Meta style={{ marginTop: 'var(--space-sm)' }}>{project.deploy.latestDeployAt ? formatDate(project.deploy.latestDeployAt) : 'LAST DEPLOY UNKNOWN'}</Meta>
</Panel>
<Panel $column="main" $mobileOrder={4}>
<SectionTitle><span>SPRINT LEDGER</span><LabelMeta>{tasks.length} SPRINTS</LabelMeta></SectionTitle>
<LedgerList>
{tasks.map((sprint) => (
<LedgerItem key={sprint.id}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 'var(--space-md)', flexWrap: 'wrap' }}>
<div> <div>
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>SPRINT {String(sprint.number).padStart(2, '0')} · {sprint.name}</div> <SectionTitle>
<Meta>{sprint.tasks?.length ?? 0} TASKS · {sprint.tasks?.filter((task) => task.status === 'done').length ?? 0} DONE</Meta> <span>PHASE TIMELINE</span>
<LabelMeta>CURRENT: {activeSprint ? `S${activeSprint.number}` : 'N/A'}</LabelMeta>
</SectionTitle>
<PhaseTimeline>
{tasks.map((sprint: any) => {
const { label, isActive } = getSprintMeta(sprint);
return (
<PhaseItem key={sprint.id}>
<PhaseMeta>{label}</PhaseMeta>
<PhaseBox $active={isActive}>
<PhaseName>SPRINT {String(sprint.number).padStart(2, '0')}: {sprint.name}</PhaseName>
<PhaseDesc> {sprint.tasks?.length ?? 0} · {sprint.tasks?.filter((t: any) => t.status === 'done').length ?? 0}</PhaseDesc>
<TechBar style={{ marginTop: 'var(--space-sm)' }}>
<TechBarFill $width={sprint.progress ?? 0} />
</TechBar>
</PhaseBox>
</PhaseItem>
);
})}
{tasks.length === 0 && history.filter((entry: any) => entry.kind !== 'hotfix').length === 0 && <PhaseItem><PhaseMeta>PENDING</PhaseMeta><PhaseBox><PhaseName>Sprint </PhaseName></PhaseBox></PhaseItem>}
</PhaseTimeline>
</div> </div>
<LabelMeta>{sprint.status}</LabelMeta>
</div>
<TechBar><TechBarFill $width={sprint.progress ?? 0} /></TechBar>
</LedgerItem>
))}
{tasks.length === 0 && <Empty>NO SPRINTS</Empty>}
</LedgerList>
</Panel>
<Panel $column="main" $mobileOrder={5}> <div>
<SectionTitle><span>HOTFIX HISTORY</span><LabelMeta>{hotfixes.length} ITEMS</LabelMeta></SectionTitle> {hotfixEntries.length > 0 && (
<LedgerList> <>
{hotfixes.map((entry) => ( <SectionTitle style={{ marginBottom: 'var(--space-md)' }}>HOTFIX HISTORY</SectionTitle>
<LedgerItem key={entry.label}> <PhaseTimeline style={{ marginBottom: 'var(--space-xl)' }}>
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>{entry.label}</div> {hotfixEntries.map((entry: any) => (
<Meta>{entry.summary ?? entry.description ?? 'hotfix summary 없음'}</Meta> <PhaseItem key={entry.label}>
</LedgerItem> <PhaseMeta>HOTFIX</PhaseMeta>
<PhaseBox $active={false}>
<PhaseName>{entry.label}</PhaseName>
<PhaseDesc>{entry.summary ?? entry.description ?? '보정/수정 이력'}</PhaseDesc>
</PhaseBox>
</PhaseItem>
))} ))}
{hotfixes.length === 0 && <Empty>NO HOTFIX HISTORY</Empty>} </PhaseTimeline>
</LedgerList> </>
</Panel> )}
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle>
<Panel $column="side" $mobileOrder={6}>
<SectionTitle><span>ASSIGNED NODES</span><LabelMeta>LIVE STATUS</LabelMeta></SectionTitle>
<NodeStack> <NodeStack>
{assignedNodes.map((node) => ( {assignedNodes.map((node) => (
<NodeMiniCard key={node.name}> <NodeMiniCard key={node.name}>
<NodeInfo> <NodeInfo>
<SisterAvatar name={node.name} size={28} /> <SisterAvatar name={node.name} size={28} />
<div> <NodeLabel>
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[node.name] ?? node.name}</div> <NodeName>{node.label}</NodeName>
<Meta>{ROLE_LABEL[node.name] ?? 'NODE'}</Meta> <NodeRole>[{node.role}]</NodeRole>
</div> </NodeLabel>
</NodeInfo> </NodeInfo>
<NodeDot $status={node.status} /> <NodeDot />
</NodeMiniCard> </NodeMiniCard>
))} ))}
</NodeStack> </NodeStack>
</Panel>
<SectionTitle>TASK CHECKLIST</SectionTitle>
<Checklist>
{allTasks.slice(0, 8).map((task: any) => (
<CheckItem key={task.id} $done={task.status === 'done'}>
<CheckBox $checked={task.status === 'done'} />
<span>[{task.taskId}] {task.title}</span>
</CheckItem>
))}
{allTasks.length === 0 && <CheckItem><CheckBox /><span style={{ color: 'var(--text-secondary)' }}> </span></CheckItem>}
</Checklist>
</div>
</ProjectGrid> </ProjectGrid>
)} )}
{/* COMMITS */}
{tab === 'commits' && ( {tab === 'commits' && (
<GitList> <>
{commits.map((commit) => ( {tabLoading ? <Empty>LOADING COMMITS...</Empty> : (
<GitItem key={commit.sha} href={commit.html_url ?? '#'} target="_blank" rel="noopener"> <>
<GitPrimary> <CommitTable>
<GitTitle>{commit.commit?.message?.split('\n')[0] ?? commit.sha ?? 'UNKNOWN COMMIT'}</GitTitle> {commits.length === 0 ? (
<Meta>{commit.commit?.author?.name ?? commit.author?.login ?? '-'}</Meta> <div style={{ padding: 'var(--space-xl)', color: 'var(--text-secondary)', fontSize: '12px', textAlign: 'center', fontFamily: 'var(--font-mono)' }}>NO COMMITS</div>
</GitPrimary> ) : (
<Meta>{commit.sha?.slice(0, 7) ?? '-'} · {formatDate(commit.commit?.author?.date)}</Meta> commits.map((c: any) => (
</GitItem> <CommitRow key={c.sha} href={c.html_url} target="_blank" rel="noopener">
))} <CommitHash>{c.sha?.slice(0, 7)}</CommitHash>
{commits.length === 0 && <Empty>NO COMMITS</Empty>} <CommitMsg>{c.commit?.message?.split('\n')[0]}</CommitMsg>
</GitList> <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' && ( {tab === 'branches' && (
<GitList> <>
{branches.map((branch) => ( {tabLoading ? <Empty>LOADING BRANCHES...</Empty> : (
<GitItem key={branch.name} href={project.repoUrl} target="_blank" rel="noopener"> <BranchGrid>
<GitPrimary> {branches.length === 0 ? (
<GitTitle>{branch.name ?? 'UNKNOWN BRANCH'}</GitTitle> <Empty>NO BRANCHES</Empty>
<Meta>{branch.protected ? 'PROTECTED' : 'ACTIVE BRANCH'}</Meta> ) : (
</GitPrimary> branches.map((b: any) => (
<Meta>{branch.commit?.id?.slice(0, 7) ?? '-'}</Meta> <BranchCard key={b.name}>
</GitItem> <BranchName>{b.name}</BranchName>
))} <BranchMeta>
{branches.length === 0 && <Empty>NO BRANCHES</Empty>} {b.commit?.id?.slice(0, 7) ?? '-'}
</GitList> {b.protected && ' · PROTECTED'}
</BranchMeta>
</BranchCard>
))
)}
</BranchGrid>
)}
</>
)} )}
{/* PULL REQUESTS */}
{tab === 'prs' && ( {tab === 'prs' && (
<GitList> <>
{pulls.map((pull) => ( <FilterRow>
<GitItem key={pull.id} href={pull.html_url ?? '#'} target="_blank" rel="noopener"> {(['open', 'closed', 'all'] as const).map((s) => (
<GitPrimary> <FilterBtn key={s} $active={prFilter === s} onClick={() => setPrFilter(s)}>
<GitTitle>#{pull.number ?? '-'} {pull.title ?? 'UNKNOWN PR'}</GitTitle> {s.toUpperCase()}
<Meta>{pull.user?.login ?? '-'} · {pull.merged ? 'MERGED' : pull.state?.toUpperCase() ?? 'UNKNOWN'}</Meta> </FilterBtn>
</GitPrimary>
<Meta>{formatDate(pull.created_at)}</Meta>
</GitItem>
))} ))}
{pulls.length === 0 && <Empty>NO PULL REQUESTS</Empty>} </FilterRow>
</GitList> {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

@@ -7,40 +7,21 @@ import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn } from '@/components/ui
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
import { adminFetch } from '@/lib/adminFetch'; import { adminFetch } from '@/lib/adminFetch';
interface ProjectListItem {
id: number;
name: string;
description: string | null;
progress: number;
phase: string;
currentSprint: string | null;
latestHotfix: string | null;
latestQaLabel: string | null;
latestQaStatus: 'passed' | 'failed' | 'unknown';
blockerCount: number;
deployStatus: string;
updatedAt: string;
}
const ProjectList = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`; const ProjectList = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`;
const ProjectRow = styled(Link)`display:grid;grid-template-columns:1.2fr 1fr 220px;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;}`; 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;}`;
const Left = styled.div`display:flex;flex-direction:column;gap:6px;min-width:0;`; const Left = styled.div`display:flex;align-items:flex-start;gap:var(--space-md);min-width:0;`;
const ProjectName = styled.div`font-size:15px;font-weight:600;color:var(--text-primary);`; const ProjectMark = styled.div`width:40px;height:40px;border-radius:10px;border:1px solid var(--border-color);display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-size:14px;color:var(--text-secondary);background:#111;flex-shrink: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 ProjectDesc = styled.div`font-size:12px;color:var(--text-secondary);line-height:1.5;`; const ProjectDesc = styled.div`font-size:12px;color:var(--text-secondary);line-height:1.5;`;
const Middle = styled.div`display:flex;flex-direction:column;gap:10px;`; const Middle = styled.div`display:flex;flex-direction:column;gap:8px;`;
const Right = styled.div`display:flex;flex-direction:column;gap:8px;align-items:flex-end;@media (max-width:1199px){align-items:flex-start;}`; const ProgressRow = styled.div`display:flex;align-items:center;gap:var(--space-sm);`;
const Right = styled.div`display:flex;flex-direction:column;align-items:flex-end;gap:8px;@media (max-width:1199px){align-items:flex-start;}`;
const Meta = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`; const Meta = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`;
const PhaseBadge = styled.div<{ $phase:string }>`font-size:11px;font-family:var(--font-mono);padding:4px 8px;border:1px solid ${({$phase})=>$phase==='DEPLOYED'?'#5fafff44':$phase==='READY FOR DEPLOY'?'#5fff8a44':$phase==='QA'?'#ffcc6644':'var(--border-color)'};color:${({$phase})=>$phase==='DEPLOYED'?'#5fafff':$phase==='READY FOR DEPLOY'?'#5fff8a':$phase==='QA'?'#ffcc66':'var(--text-primary)'};white-space:nowrap;`;
const EmptyState = styled.div`padding:var(--space-xl) 0;color:var(--text-secondary);font-size:13px;font-family:var(--font-mono);`; const EmptyState = styled.div`padding:var(--space-xl) 0;color:var(--text-secondary);font-size:13px;font-family:var(--font-mono);`;
function qaLabel(project: ProjectListItem): string {
if (project.latestQaStatus === 'passed') return 'QA PASSED';
if (project.latestQaStatus === 'failed') return `QA FAILED${project.blockerCount > 0 ? ` · ${project.blockerCount} BLOCKERS` : ''}`;
return 'QA PENDING';
}
export default function ProjectsPage() { export default function ProjectsPage() {
const [projects, setProjects] = useState<ProjectListItem[]>([]); const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null); const [syncResult, setSyncResult] = useState<string | null>(null);
@@ -50,59 +31,61 @@ export default function ProjectsPage() {
fetch(`${API_URL}/api/projects`).then((r) => r.json()).then(setProjects).catch(() => {}).finally(() => setLoading(false)); fetch(`${API_URL}/api/projects`).then((r) => r.json()).then(setProjects).catch(() => {}).finally(() => setLoading(false));
}; };
const runSync = async (path: string, label: string) => { const handleSync = async () => {
setSyncing(true); setSyncing(true); setSyncResult(null);
setSyncResult(null);
try { try {
const res = await adminFetch(path, { method: 'POST' }); const res = await adminFetch('/api/admin/gitea/sync', { method: 'POST' });
const data = await res.json(); const d = await res.json();
setSyncResult(`${label}: ${JSON.stringify(data)}`); setSyncResult(`sync: ${d.created ?? 0} new, ${d.updated ?? 0} updated`);
loadProjects(); loadProjects();
} catch { } catch { setSyncResult('sync failed'); } finally { setSyncing(false); }
setSyncResult(`${label}: 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(); }, []); useEffect(() => { loadProjects(); }, []);
return ( return (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-md)', flexWrap: 'wrap', 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)', flexWrap: 'wrap' }}> <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>} {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={() => runSync('/api/admin/gitea/sync', 'gitea sync')} disabled={syncing}>{syncing ? 'SYNCING...' : '↻ GITEA SYNC'}</Btn> <Btn onClick={handleSync} disabled={syncing}>{syncing ? 'SYNCING...' : '↻ GITEA SYNC'}</Btn>
<Btn onClick={() => runSync('/api/projects/sync-all-sprints', 'sprint sync')} disabled={syncing}>{syncing ? '...' : '↻ SPRINT SYNC'}</Btn> <Btn onClick={handleSprintSync} disabled={syncing}>{syncing ? '...' : '↻ SPRINT SYNC'}</Btn>
</div> </div>
</div> </div>
{loading ? <EmptyState>LOADING...</EmptyState> : projects.length === 0 ? <EmptyState>NO PROJECTS REGISTERED</EmptyState> : ( {loading ? <EmptyState>LOADING...</EmptyState> : projects.length === 0 ? <EmptyState>NO PROJECTS REGISTERED</EmptyState> : (
<ProjectList> <ProjectList>
{projects.map((project) => ( {projects.map((p) => (
<ProjectRow key={project.id} href={`/projects/${project.id}`}> <ProjectRow key={p.id} href={`/projects/${p.id}`}>
<Left> <Left>
<Meta>P-{String(project.id).padStart(3, '0')}</Meta> <ProjectMark>{String(p.name ?? 'P').slice(0, 1).toUpperCase()}</ProjectMark>
<ProjectName>{project.name}</ProjectName> <NameBlock>
<ProjectDesc>{project.description ?? '설명 없음'}</ProjectDesc> <ProjectName>{p.name}</ProjectName>
<ProjectDesc>{p.description ?? '설명 없음'}</ProjectDesc>
</NameBlock>
</Left> </Left>
<Middle> <Middle>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)', flexWrap: 'wrap' }}> <ProgressRow>
<PhaseBadge $phase={project.phase}>{project.phase}</PhaseBadge> <TechBar style={{ flex: 1 }}><TechBarFill $width={p.progress ?? 0} /></TechBar>
<Meta>{project.currentSprint ? `SPRINT ${project.currentSprint}` : 'PLANNING BACKLOG'}</Meta> <LabelMeta style={{ minWidth: 44, textAlign: 'right' }}>{p.progress ?? 0}%</LabelMeta>
{project.latestHotfix && <Meta>HOTFIX {project.latestHotfix}</Meta>} </ProgressRow>
</div> <Meta>{p.doneSprints ?? 0} / {p.totalSprints ?? 0} SPRINTS DONE</Meta>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
<TechBar style={{ flex: 1 }}><TechBarFill $width={project.progress ?? 0} /></TechBar>
<LabelMeta style={{ minWidth: 44, textAlign: 'right' }}>{project.progress ?? 0}%</LabelMeta>
</div>
</Middle> </Middle>
<Right> <Right>
<Meta>{qaLabel(project)}</Meta> <LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta>
<Meta>{project.deployStatus}</Meta> <Meta>{new Date(p.updatedAt).toLocaleDateString('ko-KR')} UPDATED</Meta>
<Meta>{new Date(project.updatedAt).toLocaleDateString('ko-KR')} UPDATED</Meta>
</Right> </Right>
</ProjectRow> </ProjectRow>
))} ))}

View File

@@ -4,7 +4,7 @@ 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, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent, Btn } 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 SisterAvatar from '@/components/common/SisterAvatar';
import { SISTER_ROLES } from '@/lib/sisters'; import { SISTER_ROLES } from '@/lib/sisters';
@@ -26,6 +26,7 @@ const StatValue = styled.div`font-size:18px;color:var(--text-primary);font-famil
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);}`; 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);}`;
const MetaPanel = styled.div`display:flex;flex-direction:column;gap:var(--space-lg);`; 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);`; const MetaCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);`;
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);overflow:auto;`; const TabBar = styled.div`display:flex;gap:var(--space-lg);border-bottom:1px solid var(--border-color);margin-bottom:var(--space-lg);overflow:auto;`;
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 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 Toolbar = styled.div`display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;`;
@@ -36,69 +37,13 @@ const SessionCard = styled.div`border:1px solid var(--border-color);padding:var(
const SISTER_DISPLAY: Record<string, string> = { harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이' }; const SISTER_DISPLAY: Record<string, string> = { harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이' };
const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md']; const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
interface SisterInfo {
id?: number;
name: string;
status?: string;
lxcId?: string | number | null;
lastSeen?: string | null;
user?: string | null;
}
interface SystemInfo {
uptime?: string;
cpu?: number;
memory?: { used?: number; total?: number };
disk?: { used?: string; total?: string };
}
interface ConfigData {
description?: string | null;
}
interface ActivityItem {
id: number;
createdAt: string;
action?: string;
detail?: string | null;
}
interface ActivityResponse {
items?: ActivityItem[];
}
interface SessionItem {
id?: string | number;
title?: string;
label?: string;
status?: string;
}
interface SessionsResponse {
sessions?: SessionItem[];
items?: SessionItem[];
}
interface HarnessResponse {
content?: string;
}
interface SaveHarnessResponse {
success?: boolean;
error?: string;
}
interface LogResponse {
lines?: string[];
}
export default function SisterDetailPage() { export default function SisterDetailPage() {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
const [sisterInfo, setSisterInfo] = useState<SisterInfo | null>(null); const [sisterInfo, setSisterInfo] = useState<any>(null);
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null); const [systemInfo, setSystemInfo] = useState<any>(null);
const [configData, setConfigData] = useState<ConfigData | null>(null); const [configData, setConfigData] = useState<any>(null);
const [activity, setActivity] = useState<ActivityItem[]>([]); const [activity, setActivity] = useState<any[]>([]);
const [sessions, setSessions] = useState<SessionItem[]>([]); 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 [logLines, setLogLines] = useState<string[]>([]);
@@ -117,11 +62,11 @@ export default function SisterDetailPage() {
fetch(`${API_URL}/api/sisters/${name}/system`), fetch(`${API_URL}/api/sisters/${name}/system`),
fetch(`${API_URL}/api/sisters/${name}/sessions`), fetch(`${API_URL}/api/sisters/${name}/sessions`),
]).then(([sRes, cRes, aRes, syRes, ssRes]) => { ]).then(([sRes, cRes, aRes, syRes, ssRes]) => {
if (sRes.status === 'fulfilled' && sRes.value.ok) sRes.value.json().then((d: SisterInfo[]) => setSisterInfo(d.find((s) => s.name === name) ?? null)); 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((data: ConfigData) => setConfigData(data)); if (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then(setConfigData);
if (aRes.status === 'fulfilled' && aRes.value.ok) aRes.value.json().then((d: ActivityResponse) => setActivity(d.items ?? [])); if (aRes.status === 'fulfilled' && aRes.value.ok) aRes.value.json().then((d: any) => setActivity(d.items ?? []));
if (syRes.status === 'fulfilled' && syRes.value.ok) syRes.value.json().then((data: SystemInfo) => setSystemInfo(data)); if (syRes.status === 'fulfilled' && syRes.value.ok) syRes.value.json().then(setSystemInfo);
if (ssRes.status === 'fulfilled' && ssRes.value.ok) ssRes.value.json().then((d: SessionsResponse) => setSessions(d.sessions ?? d.items ?? [])); 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]);
@@ -129,7 +74,7 @@ export default function SisterDetailPage() {
setLogLoading(true); setLogLoading(true);
try { try {
const res = await adminFetch(`/api/admin/logs/${name}?lines=${lines}`); const res = await adminFetch(`/api/admin/logs/${name}?lines=${lines}`);
const d: LogResponse = await res.json(); const d = await res.json();
setLogLines(d.lines ?? []); setLogLines(d.lines ?? []);
} catch { } catch {
setLogLines(['(로그 로드 실패)']); setLogLines(['(로그 로드 실패)']);
@@ -145,7 +90,7 @@ export default function SisterDetailPage() {
setSaveResult(null); setSaveResult(null);
adminFetch(`/api/admin/harness/${name}/${selectedFile}`) adminFetch(`/api/admin/harness/${name}/${selectedFile}`)
.then((r) => r.json()) .then((r) => r.json())
.then((d: HarnessResponse) => { setContent(d.content ?? ''); setSaved(d.content ?? ''); }) .then((d) => { setContent(d.content ?? ''); setSaved(d.content ?? ''); })
.catch(() => setContent('(로드 실패)')); .catch(() => setContent('(로드 실패)'));
}, [tab, name, selectedFile]); }, [tab, name, selectedFile]);
@@ -153,7 +98,7 @@ export default function SisterDetailPage() {
setSaveResult(null); setSaveResult(null);
try { try {
const res = await adminFetch(`/api/admin/harness/${name}/${selectedFile}`, { method: 'PUT', body: JSON.stringify({ content }) }); const res = await adminFetch(`/api/admin/harness/${name}/${selectedFile}`, { method: 'PUT', body: JSON.stringify({ content }) });
const d: SaveHarnessResponse = await res.json(); const d = await res.json();
if (d.success) { setSaved(content); setSaveResult({ success: true, msg: '저장 완료' }); } if (d.success) { setSaved(content); setSaveResult({ success: true, msg: '저장 완료' }); }
else setSaveResult({ success: false, msg: d.error ?? '저장 실패' }); else setSaveResult({ success: false, msg: d.error ?? '저장 실패' });
} catch (e) { } catch (e) {
@@ -206,7 +151,7 @@ export default function SisterDetailPage() {
</TabBar> </TabBar>
{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 === '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((session, i: number) => <SessionCard key={session.id ?? i}><div style={{ color: 'var(--text-primary)', fontSize: '13px' }}>{session.title ?? session.label ?? session.id ?? 'session'}</div><div style={{ color: 'var(--text-secondary)', fontSize: '11px', fontFamily: 'var(--font-mono)' }}>{session.status ?? ''}</div></SessionCard>)}</>} {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>)}</>}
{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>} {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>}
{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} /></>} {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} /></>}
{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} /></>} {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} /></>}

View File

@@ -9,20 +9,6 @@ import SisterAvatar from '@/components/common/SisterAvatar';
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`; const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
const SISTER_ROLES: Record<string, string> = { harang: 'Primary', narang: 'Secondary', darang: 'Standby', erang: 'Sync' }; const SISTER_ROLES: Record<string, string> = { harang: 'Primary', narang: 'Secondary', darang: 'Standby', erang: 'Sync' };
interface SisterListItem {
id?: number;
name: string;
status: string;
uptime?: string;
lastSeen?: string | null;
cpu?: number;
lxcId?: string | number | null;
currentTask?: string | null;
memory?: { used?: number; total?: number };
disk?: { used?: string; total?: string };
}
const NodeGrid = styled.section`display:flex;flex-direction:column;gap:var(--space-lg);`; const NodeGrid = styled.section`display:flex;flex-direction:column;gap:var(--space-lg);`;
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}`; 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}`;
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 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)}`;
@@ -41,9 +27,9 @@ const MetaRow = styled.div`display:flex;justify-content:space-between;font-famil
const Skeleton = styled.div`height:120px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`; 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 ErrorBox = styled.div`padding:var(--space-lg);border:1px solid #5a2a2a;color:#ff9b9b;`;
function getNodeStatus(s: SisterListItem): boolean { return s.status === 'online' || s.status === 'working'; } function getNodeStatus(s: any): boolean { return s.status === 'online' || s.status === 'working'; }
function getStatusBtns(s: SisterListItem): [string, string] { if (s.status === 'offline') return ['STANDBY', 'ACTIVATE']; return ['ACTIVE', 'REBOOT']; } function getStatusBtns(s: any): [string, string] { if (s.status === 'offline') return ['STANDBY', 'ACTIVATE']; return ['ACTIVE', 'REBOOT']; }
function diskPercent(disk?: SisterListItem['disk']): number { function diskPercent(disk: any): number {
if (!disk?.used || !disk?.total) return 0; if (!disk?.used || !disk?.total) return 0;
const parse = (v: string) => { const parse = (v: string) => {
const m = String(v).match(/([0-9.]+)\s*([KMGTP]?)/i); const m = String(v).match(/([0-9.]+)\s*([KMGTP]?)/i);
@@ -60,7 +46,7 @@ function diskPercent(disk?: SisterListItem['disk']): number {
} }
export default function SistersPage() { export default function SistersPage() {
const [sisters, setSisters] = useState<SisterListItem[]>([]); const [sisters, setSisters] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -68,8 +54,8 @@ export default function SistersPage() {
let active = true; let active = true;
const load = async () => { const load = async () => {
try { try {
const base: SisterListItem[] = await fetch(`${API_URL}/api/sisters`).then((r) => r.ok ? r.json() : Promise.reject()); const base = await fetch(`${API_URL}/api/sisters`).then((r) => r.ok ? r.json() : Promise.reject());
const withSystem = await Promise.all(base.map(async (s: SisterListItem) => { const withSystem = await Promise.all(base.map(async (s: any) => {
try { try {
const system = await fetch(`${API_URL}/api/sisters/${s.name}/system`).then((r) => r.ok ? r.json() : null); const system = await fetch(`${API_URL}/api/sisters/${s.name}/system`).then((r) => r.ok ? r.json() : null);
return { ...s, ...(system ?? {}) }; return { ...s, ...(system ?? {}) };
@@ -115,7 +101,7 @@ export default function SistersPage() {
<CapacitySection> <CapacitySection>
<BarWrap> <BarWrap>
<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> <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>
<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 ?? 0) / s.memory.total) * 100))) : 0} /></Bar></BarRow> <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>
<BarRow><span>DSK</span><span>{s.disk?.used ?? '0G'}/{s.disk?.total ?? '0G'}</span><Bar><Fill $width={diskPercent(s.disk)} /></Bar></BarRow> <BarRow><span>DSK</span><span>{s.disk?.used ?? '0G'}/{s.disk?.total ?? '0G'}</span><Bar><Fill $width={diskPercent(s.disk)} /></Bar></BarRow>
</BarWrap> </BarWrap>
</CapacitySection> </CapacitySection>

View File

@@ -5,58 +5,195 @@ 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 { useAuth } from '@/lib/AuthContext'; import { useAuth } from '@/lib/AuthContext';
import { useSidebar } from '@/lib/SidebarContext';
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: '/', label: '대시' }, { href: '/', label: '대시' },
{ href: '/projects', label: '프로' },
{ href: '/activities', label: '활동' }, { href: '/activities', label: '활동' },
{ href: '/projects', label: '프로' },
{ href: '/sisters', label: '자매' }, { href: '/sisters', label: '자매' },
{ href: '/org', label: '조직' }, { href: '/org', label: '조직' },
{ href: '/settings', label: '설정' }, { href: '/settings', label: '설정' },
{ href: '/admin', label: '관리' }, { href: '/admin', label: '관리' },
]; ];
// ─── Desktop Sidebar ───
const SidebarWrapper = styled.aside` const SidebarWrapper = styled.aside`
width: 240px;flex-shrink: 0;border-right: 1px solid var(--border-color);display: flex;flex-direction: column;padding: var(--space-xl) var(--space-lg);position: sticky;top: 0;height: 100vh;overflow-y: auto; width: 240px;
@media (min-width: 768px) and (max-width: 1199px) { width: 64px;padding: var(--space-xl) var(--space-md);align-items: center; } flex-shrink: 0;
@media (max-width: 767px) { display: none; } border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: var(--space-xl) var(--space-lg);
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
@media (min-width: 768px) and (max-width: 1199px) {
width: 64px;
padding: var(--space-xl) var(--space-md);
align-items: center;
}
@media (max-width: 767px) {
display: none;
}
`;
const LogoContainer = styled.div`
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
@media (min-width: 768px) and (max-width: 1199px) {
margin-bottom: var(--space-xl);
}
`;
const CircleFull = styled.div`
width: 20px;
height: 20px;
background-color: var(--text-primary);
border-radius: 50%;
flex-shrink: 0;
`;
const CircleHalf = styled.div`
width: 10px;
height: 20px;
background-color: var(--text-primary);
border-radius: 0 20px 20px 0;
flex-shrink: 0;
`;
const NavMenu = styled.ul`
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
width: 100%;
`;
const NavItem = styled.li``;
const NavLink = styled(Link)<{ $active: boolean }>`
color: ${({ $active }) => $active ? 'var(--accent-hover)' : 'var(--text-secondary)'};
text-decoration: none;
font-size: 14px;
font-weight: 500;
padding: var(--space-sm) 0;
transition: color 0.2s ease;
display: block;
width: 100%;
&:hover {
color: var(--accent-hover);
}
${({ $active }) => $active && `
&::before { content: "["; margin-right: 4px; color: var(--text-secondary); }
&::after { content: "]"; margin-left: 4px; color: var(--text-secondary); }
`}
@media (min-width: 768px) and (max-width: 1199px) {
font-size: 11px;
text-align: center;
padding: 6px 0;
}
`;
// ─── Mobile Bottom Tab Bar ───
const BottomTabBar = styled.nav`
display: none;
@media (max-width: 767px) {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 56px;
background: var(--bg-main);
border-top: 1px solid var(--border-color);
z-index: 100;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
`;
const TabItem = styled(Link)<{ $active: boolean }>`
flex: 1;
min-width: 48px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
text-decoration: none;
padding: 6px 4px;
transition: color 0.15s;
border-top: ${({ $active }) => $active ? '1px solid var(--text-primary)' : '1px solid transparent'};
&:hover { color: var(--text-primary); }
`; `;
const LogoContainer = styled.div`display:flex;align-items:center;gap:4px;margin-bottom:var(--space-xxl);@media (min-width:768px) and (max-width:1199px){margin-bottom:var(--space-xl);}`;
const CircleFull = styled.div`width:20px;height:20px;background-color:var(--text-primary);border-radius:50%;flex-shrink:0;`;
const CircleHalf = styled.div`width:10px;height:20px;background-color:var(--text-primary);border-radius:0 20px 20px 0;flex-shrink:0;`;
const NavMenu = styled.ul`list-style:none;display:flex;flex-direction:column;gap:var(--space-sm);width:100%;`;
const NavLink = styled(Link)<{ $active: boolean }>`color:${({ $active }) => $active ? 'var(--accent-hover)' : 'var(--text-secondary)'};text-decoration:none;font-size:14px;font-weight:500;padding:var(--space-sm) 0;transition:color .2s ease;display:block;width:100%;&:hover{color:var(--accent-hover)}${({ $active }) => $active && `&::before { content: "["; margin-right: 4px; color: var(--text-secondary); } &::after { content: "]"; margin-left: 4px; color: var(--text-secondary); }`}@media (min-width:768px) and (max-width:1199px){font-size:11px;text-align:center;padding:6px 0;}`;
const BottomTabBar = styled.nav`display:none;@media (max-width:767px){display:flex;position:fixed;bottom:0;left:0;right:0;height:56px;background:var(--bg-main);border-top:1px solid var(--border-color);z-index:100;overflow-x:auto;scrollbar-width:none;&::-webkit-scrollbar{display:none;}}`;
const TabItem = styled(Link)<{ $active: boolean }>`flex:1;min-width:48px;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:10px;font-weight:600;text-transform:uppercase;color:${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};text-decoration:none;padding:6px 4px;transition:color .15s;border-top:${({ $active }) => $active ? '1px solid var(--text-primary)' : '1px solid transparent'};&:hover{color:var(--text-primary)}`;
export default function Sidebar() { export default function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const isActive = (href: string) => href === '/' ? pathname === '/' : pathname.startsWith(href);
const isActive = (href: string) =>
href === '/' ? pathname === '/' : pathname.startsWith(href);
return ( return (
<> <>
<SidebarWrapper> <SidebarWrapper>
<LogoContainer><CircleFull /><CircleHalf /></LogoContainer> <LogoContainer>
<CircleFull />
<CircleHalf />
</LogoContainer>
<nav> <nav>
<NavMenu> <NavMenu>
{NAV_ITEMS.map((item) => ( {NAV_ITEMS.map((item) => (
<li key={item.href}> <NavItem key={item.href}>
<NavLink href={item.href} $active={isActive(item.href)}>{item.label}</NavLink> <NavLink href={item.href} $active={isActive(item.href)}>
</li> {item.label}
</NavLink>
</NavItem>
))} ))}
</NavMenu> </NavMenu>
</nav> </nav>
{user && ( {user && (
<div style={{ marginTop: 'auto', paddingTop: 'var(--space-xl)' }}> <div style={{ marginTop: 'auto', paddingTop: 'var(--space-xl)' }}>
<div style={{ fontSize: '11px', color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)', marginBottom: 'var(--space-sm)' }}>[{user.username}]</div> <div style={{ fontSize: '11px', color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)', marginBottom: 'var(--space-sm)' }}>
<button onClick={logout} style={{ background: 'transparent', border: 'none', color: 'var(--text-secondary)', fontSize: '13px', cursor: 'pointer', padding: 0, textAlign: 'left', transition: 'color 0.15s', fontFamily: 'inherit' }}></button> [{user.username}]
</div>
<button
onClick={logout}
style={{
background: 'transparent', border: 'none', color: 'var(--text-secondary)',
fontSize: '13px', cursor: 'pointer', padding: 0, textAlign: 'left',
transition: 'color 0.15s', fontFamily: 'inherit',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
>
</button>
</div> </div>
)} )}
</SidebarWrapper> </SidebarWrapper>
<BottomTabBar> <BottomTabBar>
{NAV_ITEMS.map((item) => ( {NAV_ITEMS.map((item) => (
<TabItem key={item.href} href={item.href} $active={isActive(item.href)}>{item.label}</TabItem> <TabItem key={item.href} href={item.href} $active={isActive(item.href)}>
{item.label}
</TabItem>
))} ))}
</BottomTabBar> </BottomTabBar>
</> </>

View File

@@ -4,15 +4,14 @@ import React, { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import TaskBadge from '../common/TaskBadge'; import TaskBadge from '../common/TaskBadge';
import ProgressBar from '../common/ProgressBar'; import ProgressBar from '../common/ProgressBar';
import { theme } from '@/styles/theme';
type TaskStatus = 'pending' | 'in_progress' | 'review' | 'done' | 'failed' | 'blocked' | 'escalated';
interface Task { interface Task {
id: number; id: number;
taskId: string; taskId: string;
title: string; title: string;
assignee: string; assignee: string;
status: TaskStatus; status: string;
iteration: number; iteration: number;
} }
@@ -155,8 +154,7 @@ export default function SprintAccordion({ sprints }: { sprints: Sprint[] }) {
const toggle = (id: number) => { const toggle = (id: number) => {
setOpenIds((prev) => { setOpenIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); next.has(id) ? next.delete(id) : next.add(id);
else next.add(id);
return next; return next;
}); });
}; };
@@ -187,7 +185,7 @@ export default function SprintAccordion({ sprints }: { sprints: Sprint[] }) {
<TaskId>{task.taskId}</TaskId> <TaskId>{task.taskId}</TaskId>
<TaskTitle>{task.title}</TaskTitle> <TaskTitle>{task.title}</TaskTitle>
<Assignee>{assigneeEmojis[task.assignee] ?? '🤖'} {task.assignee}</Assignee> <Assignee>{assigneeEmojis[task.assignee] ?? '🤖'} {task.assignee}</Assignee>
<TaskBadge status={task.status} /> <TaskBadge status={task.status as any} />
<Iteration $count={task.iteration}> <Iteration $count={task.iteration}>
{task.iteration > 0 ? `×${task.iteration}` : ''} {task.iteration > 0 ? `×${task.iteration}` : ''}
</Iteration> </Iteration>

View File

@@ -3,15 +3,14 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import TaskBadge from '../common/TaskBadge'; import TaskBadge from '../common/TaskBadge';
import { theme } from '@/styles/theme';
type TaskStatus = 'pending' | 'in_progress' | 'review' | 'done' | 'failed' | 'blocked' | 'escalated';
interface Task { interface Task {
id: number; id: number;
taskId: string; taskId: string;
title: string; title: string;
assignee: string; assignee: string;
status: TaskStatus; status: string;
iteration: number; iteration: number;
createdAt: string; createdAt: string;
} }
@@ -118,7 +117,7 @@ export default function TaskTable({ tasks }: { tasks: Task[] }) {
<Td><TaskIdCell>{task.taskId}</TaskIdCell></Td> <Td><TaskIdCell>{task.taskId}</TaskIdCell></Td>
<Td>{task.title}</Td> <Td>{task.title}</Td>
<Td>{task.assignee}</Td> <Td>{task.assignee}</Td>
<Td><TaskBadge status={task.status} /></Td> <Td><TaskBadge status={task.status as any} /></Td>
<Td><IterationCell $count={task.iteration}>{task.iteration || '-'}</IterationCell></Td> <Td><IterationCell $count={task.iteration}>{task.iteration || '-'}</IterationCell></Td>
</Tr> </Tr>
))} ))}

View File

@@ -3,20 +3,12 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client'; import { io, Socket } from 'socket.io-client';
interface UseSocketOptions<Sister = unknown, Activity = unknown> { interface UseSocketOptions {
onSistersUpdate?: (sisters: Sister[]) => void; onSistersUpdate?: (sisters: any[]) => void;
onActivityNew?: (item: Activity) => void; onActivityNew?: (item: any) => void;
} }
interface SistersUpdatePayload<Sister = unknown> { export function useSocket(options: UseSocketOptions = {}) {
sisters: Sister[];
}
interface ActivityNewPayload<Activity = unknown> {
item: Activity;
}
export function useSocket<Sister = unknown, Activity = unknown>(options: UseSocketOptions<Sister, Activity> = {}) {
const socketRef = useRef<Socket | null>(null); const socketRef = useRef<Socket | null>(null);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
@@ -42,11 +34,11 @@ export function useSocket<Sister = unknown, Activity = unknown>(options: UseSock
setConnected(false); setConnected(false);
}); });
socket.on('sisters:update', (data: SistersUpdatePayload<Sister>) => { socket.on('sisters:update', (data: { sisters: any[] }) => {
options.onSistersUpdate?.(data.sisters); options.onSistersUpdate?.(data.sisters);
}); });
socket.on('activity:new', (data: ActivityNewPayload<Activity>) => { socket.on('activity:new', (data: { item: any }) => {
options.onActivityNew?.(data.item); options.onActivityNew?.(data.item);
}); });