Compare commits
8 Commits
768f9fdc20
...
feature/ho
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d851a3b14 | |||
| 6f0ed33424 | |||
| 1af31cbe89 | |||
| 93b92a41ec | |||
| 68c69f6dfa | |||
| 441f590989 | |||
| 6596090677 | |||
| b47248b2be |
@@ -17,6 +17,7 @@
|
||||
|
||||
## 활성 작업
|
||||
- **SPRINT-015**: Master Dashboard v3 정보 구조 리디자인
|
||||
- **HOTFIX-007**: 프로젝트/자매 상세/메인 운영면 보정
|
||||
|
||||
## 이번 Sprint 핵심 요구사항
|
||||
1. 메인 대시보드를 4자매 운영 관제의 대표 화면으로 재정의
|
||||
@@ -35,5 +36,7 @@
|
||||
- 구조 기준: `../ARCHITECTURE.md`
|
||||
- 디자인 인덱스: `./design/index.md`
|
||||
- Sprint 계획: `./sprints/SPRINT-015.md`
|
||||
- 나랑 handoff: `./sprints/SPRINT-015-NARANG-HANDOFF.md`
|
||||
- Sprint handoff: `./sprints/SPRINT-015-NARANG-HANDOFF.md`
|
||||
- Hotfix 계획: `./hotfix/HOTFIX-007.md`
|
||||
- Hotfix handoff: `./hotfix/HOTFIX-007-NARANG-HANDOFF.md`
|
||||
- 배포 플로우: `./deploy/main-release-flow.md`
|
||||
|
||||
@@ -204,6 +204,20 @@ User → Harang → Narang → Darang → Irang 흐름과, maker-checker loop, e
|
||||
- mock 텍스트/숫자로 고정하지 말고 실제 데이터가 들어올 자리를 먼저 설계할 것
|
||||
- 값이 없을 때는 `No recent activity`, `No active deployment`, `No harness rules yet`처럼 조용한 empty state 제공
|
||||
- offline / missing / unavailable 상태는 숨기지 말고 명확히 드러낼 것
|
||||
- websocket 연결 상태와 데이터 freshness를 같은 의미로 쓰지 말 것
|
||||
- 각 섹션은 가능하면 아래 source 중 하나로 구분할 것
|
||||
- `runtime live`
|
||||
- `event mirrored`
|
||||
- `polling snapshot`
|
||||
- `doc derived`
|
||||
- `empty / unavailable`
|
||||
- 추정/합성값이면 사실처럼 단정하지 말고 neutral wording 사용
|
||||
|
||||
## 카피 원칙
|
||||
- 설명문보다 라벨형 문구 우선
|
||||
- 첫 화면 문구는 짧고 건조해야 함
|
||||
- `~만들었어`, `~정리했어`, `~읽히게` 같은 메타 설명 금지
|
||||
- 보드/패널 body는 원문 요약 또는 상태 문구 위주로 짧게 유지
|
||||
|
||||
## 인터랙션 원칙
|
||||
- hover 없어도 핵심 정보가 읽혀야 함
|
||||
|
||||
44
.plans/hotfix/HOTFIX-006-NARANG-HANDOFF.md
Normal file
44
.plans/hotfix/HOTFIX-006-NARANG-HANDOFF.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# HOTFIX-006 Narang Handoff
|
||||
|
||||
## repo / branch
|
||||
- repo: `https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard`
|
||||
- branch: `feature/hotfix-006-dashboard-copy-live-fidelity`
|
||||
|
||||
## 읽을 문서
|
||||
1. `.plans/hotfix/HOTFIX-006.md`
|
||||
2. `.plans/design/ui/dashboard-design.md`
|
||||
|
||||
## 이번 수정 핵심
|
||||
자기야 피드백은 명확해.
|
||||
|
||||
1. 문구가 너무 길고 쓸데없는 말이 많아
|
||||
2. 실시간처럼 보이는데 실제론 아닌 부분이 있어
|
||||
|
||||
이 두 개를 바로 고쳐.
|
||||
|
||||
## 꼭 손볼 것
|
||||
- 상단 headline / summary
|
||||
- current focus 설명문
|
||||
- operator meta
|
||||
- pipeline node detail / fallback
|
||||
- infra/meta card detail
|
||||
- board 카드 body 문구
|
||||
- `socket live`, `LIVE ON` 같은 라벨의 의미
|
||||
|
||||
## 절대 기준
|
||||
- 설명문 쓰지 마
|
||||
- UI 자기소개하지 마
|
||||
- 실시간 아닌 걸 실시간처럼 포장하지 마
|
||||
- source-of-truth 애매하면 neutral wording으로 낮춰
|
||||
- 문구는 짧고 건조하게
|
||||
|
||||
## 구현 목표
|
||||
- `ACTIVE PIPELINE`은 진짜 이벤트 기반이면 그렇게 보이고
|
||||
- 아니면 `SNAPSHOT`, `DOC-DERIVED`, `NO ACTIVE HANDOFF`처럼 정직하게 보여
|
||||
- `MISTAKE LOG & HARNESS`도 원문/출처/시각 중심으로 짧게 정리
|
||||
- freshness 메타를 넣어서 stale 여부를 읽을 수 있게 해
|
||||
|
||||
## QA 전 체크
|
||||
- 자기야가 봤을 때 `주구절절하다`는 느낌이 없어야 해
|
||||
- `실시간인가? 아닌가?`가 헷갈리면 실패야
|
||||
- build 통과는 기본
|
||||
142
.plans/hotfix/HOTFIX-006.md
Normal file
142
.plans/hotfix/HOTFIX-006.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# HOTFIX-006: 대시보드 문구 절제 + 실시간성 정합성 보정
|
||||
|
||||
## 목표
|
||||
Master Dashboard v3에서 주구절절한 설명형 문구를 운영 대시보드다운 짧은 문장으로 정리하고, `실시간처럼 보이지만 실제로는 폴링/요약/문서 기반인 정보`를 정확한 source-of-truth 기준으로 다시 맞춘다.
|
||||
|
||||
## 왜 이걸 고치나
|
||||
현재 메인 대시보드는 레이아웃은 좋아졌는데 두 가지 문제가 남아 있어.
|
||||
|
||||
1. **문구가 길다**
|
||||
- 상단 Headline / Summary
|
||||
- OperatorMeta
|
||||
- pipeline/detail/fallback 문장
|
||||
- harness/qa 보드 설명
|
||||
이런 부분이 대시보드보다 설명문처럼 읽혀.
|
||||
|
||||
2. **실시간성 표현이 과하다**
|
||||
- `socket live`는 웹소켓 연결 상태일 뿐, 모든 카드/보드가 실시간 원본 반영이라는 뜻은 아니야.
|
||||
- `ACTIVE PIPELINE`, `MISTAKE LOG & HARNESS`, 일부 infra/meta는 최근 activity / QA 문서 / polling snapshot을 조합해 만든 값이 섞여 있어.
|
||||
- 따라서 `실시간 반영`처럼 보이는 문구와 라벨은 정정해야 해.
|
||||
|
||||
## 범위
|
||||
- 대시보드 메인(`/`) 문구 전반 절제
|
||||
- source-of-truth 명시 정리
|
||||
- 실시간 / 폴링 / 문서기반 / 추정치 상태 구분
|
||||
- 필요 시 백엔드 dashboard ops 응답에 freshness/source 메타 추가
|
||||
- 프론트에서 misleading wording 제거
|
||||
|
||||
## 태스크
|
||||
|
||||
### TASK-078: 대시보드 카피를 운영형 짧은 문구로 전면 정리
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/page.tsx`
|
||||
- `frontend/components/dashboard/ActivePipeline.tsx`
|
||||
- **설명:**
|
||||
- 문장형 설명을 줄이고 짧은 운영 문구로 교체
|
||||
- 섹션 제목/보조 문구/fallback/empty state를 모두 다시 본다
|
||||
- **문구 기준:**
|
||||
- 길게 설명하지 말 것
|
||||
- 1문장보다 라벨형/상태형 표현 우선
|
||||
- `~읽히게 만들었어`, `~정리했어` 같은 메타 설명 금지
|
||||
- **완료 기준:**
|
||||
- 첫 화면 문구가 대시보드 UI 문장처럼 짧고 건조함
|
||||
- 자기소개/설계 설명 문장이 제거됨
|
||||
|
||||
### TASK-079: source-of-truth 기준표 정리
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/dashboard/dashboard.service.ts`
|
||||
- 필요 시 dashboard DTO/타입 정의
|
||||
- `.plans/design/ui/dashboard-design.md`
|
||||
- **설명:** 각 섹션 값이 어디서 왔는지 명확히 구분한다.
|
||||
- **최소 구분:**
|
||||
- runtime live
|
||||
- websocket event mirrored
|
||||
- polling snapshot
|
||||
- repo/qa doc derived
|
||||
- fallback/empty
|
||||
- **완료 기준:**
|
||||
- 주요 카드/보드/파이프라인 값에 원본 기준을 설명할 수 있음
|
||||
- 합성값을 실시간 원본처럼 표시하지 않음
|
||||
|
||||
### TASK-080: `LIVE` 표현 정합성 보정
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/page.tsx`
|
||||
- `frontend/lib/useSocket.ts`
|
||||
- **설명:**
|
||||
- 현재 `socket live`는 websocket 연결 상태만 뜻하므로 표현을 더 정확히 바꾼다.
|
||||
- 예: `SOCKET ON`, `EVENT STREAM ON`, `SNAPSHOT`, `STALE`, `SYNCED 12s AGO` 같은 운영형 라벨로 정리
|
||||
- **완료 기준:**
|
||||
- websocket 연결 여부와 데이터 최신성이 혼동되지 않음
|
||||
- LIVE ON/OFF 하나만 보고 전체가 실시간이라고 오해되지 않음
|
||||
|
||||
### TASK-081: `ACTIVE PIPELINE` 실데이터/추정치 경계 보정
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/dashboard/dashboard.service.ts`
|
||||
- `frontend/components/dashboard/ActivePipeline.tsx`
|
||||
- **설명:**
|
||||
- 노드 상태/active task/focus가 최근 activity 기반 추론이면 그 사실을 숨기지 말 것
|
||||
- 명시적 handoff/event가 없으면 억지 문장 대신 짧은 neutral fallback 사용
|
||||
- **완료 기준:**
|
||||
- `ACTIVE PIPELINE`이 추정치를 확정 사실처럼 말하지 않음
|
||||
- 이벤트 없음/불충분 상태가 자연스럽게 보임
|
||||
|
||||
### TASK-082: `MISTAKE LOG & HARNESS` / 보드 카드 원문성 보강
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/dashboard/dashboard.service.ts`
|
||||
- `frontend/app/page.tsx`
|
||||
- **설명:**
|
||||
- `관리자 harness 편집 로그에서 직접 가져온 기록이야.` 같은 설명형 body 제거
|
||||
- 가능하면 원문 제목/원문 요약/출처/시각만 짧게 노출
|
||||
- **완료 기준:**
|
||||
- 보드 카드 문구가 덜 소설 같고 더 기록물처럼 읽힘
|
||||
- source 라벨/시각이 더 명확함
|
||||
|
||||
### TASK-083: freshness 메타 추가 및 노출
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/dashboard/dashboard.controller.ts`
|
||||
- `backend/src/dashboard/dashboard.service.ts`
|
||||
- `frontend/app/page.tsx`
|
||||
- **설명:** dashboard ops 응답에 최소 freshness 메타를 추가한다.
|
||||
- **예시:**
|
||||
- generatedAt
|
||||
- activityLatestAt
|
||||
- sistersLatestAt
|
||||
- qaDocLatestAt
|
||||
- **완료 기준:**
|
||||
- UI에서 `last sync`, `activity lag`, `runtime lag` 같은 판단이 가능함
|
||||
- stale 상태를 눈치채기 쉬움
|
||||
|
||||
## 문구 톤 기준 예시
|
||||
### 금지
|
||||
- "지금 어디가 움직이고 있는지, 첫 화면에서 바로 읽히게 만들었어."
|
||||
- "장식보다 handoff 판단이 먼저 읽히게 정리했어."
|
||||
- "현재 오너 기준은 ... 최근 handoff와 phase를 같이 보고 있어."
|
||||
- "관리자 harness 편집 로그에서 직접 가져온 기록이야."
|
||||
|
||||
### 권장
|
||||
- `MASTER DASHBOARD`
|
||||
- `CURRENT FOCUS`
|
||||
- `SOCKET ON`
|
||||
- `SNAPSHOT`
|
||||
- `NO ACTIVE HANDOFF`
|
||||
- `NO RECENT QA`
|
||||
- `LAST EVENT 2m AGO`
|
||||
- `DOC-DERIVED`
|
||||
|
||||
## 완료 기준
|
||||
- 문구가 짧고 운영 대시보드답게 바뀜
|
||||
- 실시간/폴링/문서유도 값이 구분됨
|
||||
- misleading `live` 표현이 제거됨
|
||||
- 자기야가 봤을 때 `실시간 아닌데 실시간처럼 보인다`는 느낌이 사라짐
|
||||
35
.plans/hotfix/HOTFIX-007-NARANG-HANDOFF.md
Normal file
35
.plans/hotfix/HOTFIX-007-NARANG-HANDOFF.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# HOTFIX-007 Narang Handoff
|
||||
|
||||
## repo / branch
|
||||
- repo: `https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard`
|
||||
- branch: `feature/hotfix-007-projects-sisters-surface-fixes`
|
||||
|
||||
## 읽을 문서
|
||||
1. `.plans/hotfix/HOTFIX-007.md`
|
||||
2. `.plans/design/ui/projects-page-design.md`
|
||||
3. `.plans/design/ui/project-detail-design.md`
|
||||
4. 필요하면 `.plans/sprints/SPRINT-014.md`
|
||||
|
||||
## 이번 수정 핵심
|
||||
자기야가 바로 짚은 실제 사용감 문제를 고쳐.
|
||||
|
||||
- `/projects`에 `전체 | public | private` 필터 추가
|
||||
- 프로젝트 상세 진행 상황 박스 배치 보정
|
||||
- `ASSIGNED NODES`를 4자매 기준으로 복구
|
||||
- `.plans/qa`, `.plans/hotfix`, 구 구조 fallback 다시 점검
|
||||
- 메인 우측 나랑이 단독 박스 제거 또는 실제 operator 기준으로 교체
|
||||
- `/sisters/[name]` 개요 / 세션 / 활동 / 로그 복구
|
||||
|
||||
## 절대 기준
|
||||
- 보여주기용 고정값 넣지 마
|
||||
- 문서 구조가 바뀌었으면 새 구조 우선 + 구 구조 fallback 유지
|
||||
- 자매 상세에서 데이터 없음과 조회 실패를 섞지 마
|
||||
- `ASSIGNED NODES`가 다시 나랑이 하나처럼 보이면 실패야
|
||||
- 메인 우측 박스는 더 이상 단일 프로필 카드처럼 보이면 안 돼
|
||||
|
||||
## QA 전 필수
|
||||
- `frontend npm run build`
|
||||
- `backend npm run lint`
|
||||
- `backend npm test -- --runInBand`
|
||||
- `backend npm run build`
|
||||
- 실브라우저 기준 `/`, `/projects`, `/projects/[id]`, `/sisters/[name]` 확인 포인트 정리
|
||||
137
.plans/hotfix/HOTFIX-007.md
Normal file
137
.plans/hotfix/HOTFIX-007.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# HOTFIX-007: 프로젝트/자매 상세/메인 운영면 보정
|
||||
|
||||
## 목표
|
||||
자기야가 바로 짚은 실제 사용감 문제를 한 번에 정리한다.
|
||||
|
||||
- 프로젝트 목록에 `전체 | public | private` 필터 추가
|
||||
- 프로젝트 상세 진행 상황 박스 배치 보정
|
||||
- `ASSIGNED NODES` / 에이전트 노드 구성이 `나랑이만 있는 것처럼` 보이는 문제 수정
|
||||
- 바뀐 파일 구조 기준으로 QA / Hotfix 문서 탐색 경로 재점검
|
||||
- 메인 대시보드 우측 오퍼레이터 박스 정리 (`나랑이 사진만 보이는 박스` 제거 또는 실제 operator 기준 반영)
|
||||
- 자매 상세의 `개요 / 세션 / 활동 / 로그`가 비거나 조회되지 않는 문제 복구
|
||||
|
||||
## 왜 이걸 고치나
|
||||
지금 상태는 일부 화면이 기능은 있지만 운영 판단에는 어색하거나, 아예 실데이터가 안 뜨는 구간이 있어.
|
||||
|
||||
특히 이건 실제 사용성 문제야.
|
||||
1. `/projects`에서 프로젝트 구분 필터가 부족함
|
||||
2. `/projects/[id]` 진행 현황 박스 배치가 어색함
|
||||
3. `ASSIGNED NODES`가 실제 4자매 구조를 못 보여줌
|
||||
4. `.plans/qa`, `.plans/hotfix` 구조 변경 이후 문서 탐색 누락 가능성이 남아 있음
|
||||
5. 메인 우측 operator 영역이 지금은 나랑이 사진만 보이는 식으로 오해를 줌
|
||||
6. `/sisters/[name]`의 overview / sessions / activity / logs가 비면 대시보드 핵심 기능이 무너짐
|
||||
|
||||
## 범위
|
||||
- 프로젝트 목록 페이지
|
||||
- 프로젝트 상세 페이지
|
||||
- 메인 대시보드 우측 operator 영역
|
||||
- backend project detail / dashboard / sisters / logs / sessions 관련 서비스
|
||||
- 문서 탐색 fallback (`.plans/qa`, `.plans/hotfix`, 구 구조 fallback) 재검증
|
||||
|
||||
## 태스크
|
||||
|
||||
### TASK-084: 프로젝트 목록 visibility 필터 추가
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/projects/page.tsx`
|
||||
- 필요 시 `backend/src/projects/projects.service.ts`
|
||||
- **설명:** `/projects` 상단에 `전체 | public | private` 필터를 추가한다.
|
||||
- **구현 메모:**
|
||||
- 현재 데이터 모델에 public/private 구분 필드가 있으면 그걸 그대로 사용
|
||||
- 없으면 우선 source-of-truth 확인 후 안전한 derived rule을 쓰되, 임시 추론이면 문서에 남길 것
|
||||
- **완료 기준:**
|
||||
- 사용자가 프로젝트 목록을 visibility 기준으로 바로 걸러 볼 수 있음
|
||||
- 필터 상태가 UI에서 분명히 보임
|
||||
|
||||
### TASK-085: 프로젝트 상세 진행 현황 박스 레이아웃 보정
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/projects/[id]/page.tsx`
|
||||
- 관련 공통 컴포넌트
|
||||
- **설명:** 진행 상황 / QA / Deploy / Sprint / Hotfix 박스 배치가 더 자연스럽게 읽히도록 재배치한다.
|
||||
- **완료 기준:**
|
||||
- 정보 블록 간 위계가 자연스러움
|
||||
- 데스크톱/모바일에서 박스가 어색하게 끊기거나 밀리지 않음
|
||||
- `DELIVERY FLOW → QA STATUS → DEPLOY STATUS → SPRINT/HOTFIX` 읽기 흐름이 더 선명함
|
||||
|
||||
### TASK-086: `ASSIGNED NODES` 4자매 기준 복구
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/projects/projects.service.ts`
|
||||
- `frontend/app/projects/[id]/page.tsx`
|
||||
- 필요 시 관련 타입/DTO
|
||||
- **설명:** 프로젝트 상세의 노드/에이전트 구성이 나랑이만 있는 것처럼 보이지 않게 실제 4자매 역할 기준으로 복구한다.
|
||||
- **구현 메모:**
|
||||
- 최소한 하랑/나랑/다랑/이랑 역할이 모두 보이게 할 것
|
||||
- 실할당 데이터가 있으면 우선 사용, 없으면 역할 기반 기본 노드라도 4개가 보여야 함
|
||||
- **완료 기준:**
|
||||
- `ASSIGNED NODES`가 4자매 구조를 제대로 보여줌
|
||||
- 단일 나랑 노드처럼 오해되지 않음
|
||||
|
||||
### TASK-087: QA / Hotfix 문서 탐색 경로 재점검
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/projects/projects.service.ts`
|
||||
- `backend/src/projects/sprint-sync.service.ts`
|
||||
- 관련 문서 탐색 유틸
|
||||
- **설명:** 파일 구조가 조금 바뀐 현재 기준으로 `.plans/qa`, `.plans/hotfix`, 구 구조 fallback이 실제로 다 읽히는지 다시 확인하고 보정한다.
|
||||
- **완료 기준:**
|
||||
- 새 구조 문서가 우선 반영됨
|
||||
- 구 구조 fallback도 깨지지 않음
|
||||
- QA / Hotfix history가 누락 없이 노출됨
|
||||
|
||||
### TASK-088: 메인 우측 operator 영역 정리
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/page.tsx`
|
||||
- **설명:** 메인 우측 상자에서 나랑이 사진만 떠 있는 어색한 구성을 제거한다.
|
||||
- **선택지:**
|
||||
1. 박스 자체 제거
|
||||
2. 실제 current operator/focus owner 기준으로 표시
|
||||
3. 단일 프로필 대신 운영 메타 카드로 교체
|
||||
- **완료 기준:**
|
||||
- 우측 상자가 더 이상 특정 자매 단독 프로필처럼 오해되지 않음
|
||||
- 메인 화면 정보 위계가 더 깔끔함
|
||||
|
||||
### TASK-089: 자매 상세 `개요 / 세션 / 활동 / 로그` 데이터 복구
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/sisters/[name]/page.tsx`
|
||||
- `backend/src/sisters/sister-detail.service.ts`
|
||||
- `backend/src/sisters/sisters.service.ts`
|
||||
- `backend/src/admin/admin.service.ts`
|
||||
- 관련 controller / DTO / 경로 탐색 유틸
|
||||
- **설명:** 자매 상세 페이지에서 개요/세션/활동이 비고 로그 조회가 안 되는 문제를 실제 데이터 기준으로 복구한다.
|
||||
- **구현 메모:**
|
||||
- Hermes 우선 + `.openclaw` fallback 경로 다시 확인
|
||||
- sessions / logs 경로 변경 이후 깨진 지점 점검
|
||||
- empty state와 진짜 조회 실패를 구분
|
||||
- **완료 기준:**
|
||||
- `/sisters/[name]`에서 overview / sessions / activity / logs가 실제 데이터로 노출됨
|
||||
- 데이터가 없을 때와 조회 실패일 때 UI가 다르게 보임
|
||||
|
||||
### TASK-090: 실브라우저 기준 확인 포인트 정리
|
||||
- **담당:** 나랑이 → 다랑이
|
||||
- **상태:** pending
|
||||
- **설명:** 이번 hotfix는 화면/구조/실데이터가 섞여 있으니 실브라우저 확인 포인트를 함께 남긴다.
|
||||
- **완료 기준:**
|
||||
- `/projects`
|
||||
- `/projects/[id]`
|
||||
- `/sisters/[name]`
|
||||
- 메인 `/`
|
||||
이 네 화면의 확인 포인트가 QA에 전달됨
|
||||
|
||||
## QA 핵심 체크
|
||||
- `/projects`에서 `전체 | public | private` 필터 동작
|
||||
- 프로젝트 상세 박스 배치 개선 확인
|
||||
- `ASSIGNED NODES`가 4자매 구조로 보임
|
||||
- QA / Hotfix 문서 구조 변경 이후도 history 누락 없음
|
||||
- 메인 우측 상자 어색함 해소
|
||||
- `/sisters/[name]` overview / sessions / activity / logs 복구
|
||||
- 외부 URL + 모바일 포함 확인 필수
|
||||
@@ -23,7 +23,14 @@ interface OpsBoardItem {
|
||||
source: string;
|
||||
}
|
||||
|
||||
const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/'];
|
||||
interface FreshnessMeta {
|
||||
generatedAt: string;
|
||||
activityLatestAt: string | null;
|
||||
sistersLatestAt: string | null;
|
||||
qaDocLatestAt: string | null;
|
||||
}
|
||||
|
||||
const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/', 'qa/'];
|
||||
const SISTER_ROLES: Record<string, string> = {
|
||||
harang: 'Plan & Assign',
|
||||
narang: 'Implement',
|
||||
@@ -64,7 +71,7 @@ function nodeStateFromEvidence(params: {
|
||||
|
||||
function summarizeActivity(
|
||||
activity?: ActivityRecord | null,
|
||||
fallback = '최근 이벤트 없음',
|
||||
fallback = 'NO EVENT',
|
||||
) {
|
||||
return (
|
||||
activity?.detail?.trim() || activity?.action?.replace(/_/g, ' ') || fallback
|
||||
@@ -149,8 +156,8 @@ export class DashboardService {
|
||||
activity: latestProjectActivity,
|
||||
}),
|
||||
detail: latestProjectActivity
|
||||
? `${latestProjectActivity.project?.name ?? 'project'} · ${summarizeActivity(latestProjectActivity, '최근 프로젝트 이벤트 없음')}`
|
||||
: '명시적으로 기록된 handoff / request 로그가 아직 없어.',
|
||||
? `${latestProjectActivity.project?.name ?? 'project'} · ${summarizeActivity(latestProjectActivity, 'NO EVENT')}`
|
||||
: 'NO ACTIVE HANDOFF',
|
||||
},
|
||||
...sisters.map((sister) => {
|
||||
const activity = latestBySister.get(sister.name) ?? null;
|
||||
@@ -169,10 +176,10 @@ export class DashboardService {
|
||||
detail: activity
|
||||
? summarizeActivity(activity)
|
||||
: sister.status === 'offline'
|
||||
? '최근 런타임 응답이 없어서 offline 상태야.'
|
||||
? 'RUNTIME OFFLINE'
|
||||
: sister.lastSeen
|
||||
? `최근 활동 기록은 ${new Date(sister.lastSeen).toISOString()} 체크 기준이야.`
|
||||
: '최근 활동 로그가 아직 없어.',
|
||||
? 'SNAPSHOT ONLY'
|
||||
: 'NO EVENT',
|
||||
};
|
||||
}),
|
||||
];
|
||||
@@ -184,8 +191,8 @@ export class DashboardService {
|
||||
(item) =>
|
||||
({
|
||||
id: `harness-${item.id}`,
|
||||
title: item.detail ?? 'Harness updated',
|
||||
body: '관리자 harness 편집 로그에서 직접 가져온 기록이야.',
|
||||
title: item.detail ?? 'HARNESS UPDATED',
|
||||
body: item.detail ?? 'HARNESS UPDATED',
|
||||
tone: toneFromText(`${item.action} ${item.detail ?? ''}`),
|
||||
author: item.sister?.name
|
||||
? this.toDisplayName(item.sister.name)
|
||||
@@ -208,6 +215,23 @@ export class DashboardService {
|
||||
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
|
||||
.slice(0, 6);
|
||||
|
||||
const freshness: FreshnessMeta = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activityLatestAt: activities[0]?.createdAt?.toISOString() ?? null,
|
||||
sistersLatestAt:
|
||||
sisters
|
||||
.map((sister) => sister.lastSeen)
|
||||
.filter((value): value is Date => value instanceof Date)
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
?.toISOString() ?? null,
|
||||
qaDocLatestAt:
|
||||
board
|
||||
.filter((item) => item.category === 'DOC-DERIVED')
|
||||
.map((item) => item.time)
|
||||
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0] ??
|
||||
null,
|
||||
};
|
||||
|
||||
return {
|
||||
focusProject: focusProject
|
||||
? {
|
||||
@@ -222,19 +246,17 @@ export class DashboardService {
|
||||
pipeline: {
|
||||
activeTask: focusProject
|
||||
? `${focusProject.name} · ${focusProject.currentSprint ?? focusProject.phase}`
|
||||
: 'No active pipeline',
|
||||
: 'NO ACTIVE PIPELINE',
|
||||
focus: latestProjectActivity
|
||||
? summarizeActivity(
|
||||
latestProjectActivity,
|
||||
'최근 프로젝트 이벤트 없음',
|
||||
)
|
||||
: '최근 프로젝트 activity 기준으로 확정된 handoff가 아직 없어.',
|
||||
? summarizeActivity(latestProjectActivity, 'NO EVENT')
|
||||
: 'SNAPSHOT / NO ACTIVE HANDOFF',
|
||||
reviewLoopCount,
|
||||
escalationCount,
|
||||
deployState: focusProject?.deployStatus ?? 'standby',
|
||||
deployState: focusProject?.deployStatus ?? 'STANDBY',
|
||||
nodes,
|
||||
},
|
||||
board,
|
||||
freshness,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -283,7 +305,7 @@ export class DashboardService {
|
||||
tone,
|
||||
author: inferQaAuthor(latestPath, content),
|
||||
time: project.updatedAt,
|
||||
category: 'qa-log',
|
||||
category: 'DOC-DERIVED',
|
||||
source: `${project.repoName}:${latestPath}`,
|
||||
} satisfies OpsBoardItem,
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface GiteaRepo {
|
||||
open_issues_count: number;
|
||||
open_pr_counter: number;
|
||||
updated_at: string;
|
||||
private?: boolean;
|
||||
}
|
||||
|
||||
export interface GiteaPR {
|
||||
|
||||
@@ -20,8 +20,8 @@ export class ProjectsController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
getProjects() {
|
||||
return this.projectsService.getProjects();
|
||||
getProjects(@Query('visibility') visibility?: 'public' | 'private') {
|
||||
return this.projectsService.getProjects(visibility);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { GiteaService } from '../gitea/gitea.service';
|
||||
|
||||
type QaStatus = 'passed' | 'failed' | 'unknown';
|
||||
type ProjectVisibility = 'public' | 'private';
|
||||
type ProjectPhase =
|
||||
| 'PLANNING'
|
||||
| 'IMPLEMENT'
|
||||
@@ -33,9 +34,14 @@ interface ProjectMetaSummary {
|
||||
latestDeployAt: string | null;
|
||||
}
|
||||
|
||||
const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/'];
|
||||
const HOTFIX_PATH_CANDIDATES = ['.plans/hotfix/', '.plans/sprints/'];
|
||||
const SPRINT_PATH = '.plans/sprints/';
|
||||
const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/', 'qa/'];
|
||||
const HOTFIX_PATH_CANDIDATES = [
|
||||
'.plans/hotfix/',
|
||||
'.hotfix/',
|
||||
'.plans/sprints/',
|
||||
];
|
||||
const SPRINT_PATH_CANDIDATES = ['.plans/sprints/', 'sprints/'];
|
||||
const ASSIGNED_NODE_ORDER = ['harang', 'narang', 'darang', 'erang'] as const;
|
||||
|
||||
function normalizeRepoName(repoUrl: string): string {
|
||||
return repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
|
||||
@@ -187,82 +193,95 @@ export class ProjectsService {
|
||||
private readonly gitea: GiteaService,
|
||||
) {}
|
||||
|
||||
async getProjects() {
|
||||
async getProjects(visibilityFilter?: ProjectVisibility) {
|
||||
const dbProjects = await this.prisma.project.findMany({
|
||||
include: { sprints: { include: { tasks: true } } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
const [repoMetaMap, repoDocsMap] = await Promise.all([
|
||||
const [{ repoMetaMap, sourceAvailable }, repoDocsMap] = await Promise.all([
|
||||
this.getRepoMetaMap(),
|
||||
this.getRepoDocumentMap(
|
||||
dbProjects.map((project) => normalizeRepoName(project.repoUrl)),
|
||||
),
|
||||
]);
|
||||
|
||||
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 repoMeta = repoMetaMap[project.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 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 repoMeta = repoMetaMap[project.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 {
|
||||
id: project.id,
|
||||
giteaId: project.giteaId,
|
||||
name: project.name,
|
||||
repoUrl: project.repoUrl,
|
||||
description: project.description,
|
||||
status: project.status,
|
||||
progress,
|
||||
totalSprints,
|
||||
doneSprints,
|
||||
sprintCount: totalSprints,
|
||||
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,
|
||||
};
|
||||
});
|
||||
if (visibilityFilter) {
|
||||
if (!sourceAvailable || !repoMeta?.visibility) {
|
||||
return null;
|
||||
}
|
||||
if (repoMeta.visibility !== visibilityFilter) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
giteaId: project.giteaId,
|
||||
name: project.name,
|
||||
repoUrl: project.repoUrl,
|
||||
description: project.description,
|
||||
status: project.status,
|
||||
progress,
|
||||
totalSprints,
|
||||
doneSprints,
|
||||
sprintCount: totalSprints,
|
||||
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,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(project): project is NonNullable<typeof project> => project !== null,
|
||||
);
|
||||
}
|
||||
|
||||
async getProjectById(id: number) {
|
||||
@@ -327,6 +346,15 @@ export class ProjectsService {
|
||||
note: 'ERANG: LATEST MAIN REDEPLOY ONLY',
|
||||
},
|
||||
latestHotfix: docs.hotfixes.at(-1) ?? null,
|
||||
assignedNodes: ASSIGNED_NODE_ORDER.map((name) => ({
|
||||
name,
|
||||
role: name,
|
||||
assigned:
|
||||
activeSprint?.tasks.some((task) => task.assignee === name) ??
|
||||
project.sprints.some((sprint) =>
|
||||
sprint.tasks.some((task) => task.assignee === name),
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -350,7 +378,7 @@ export class ProjectsService {
|
||||
const repoName = normalizeRepoName(project.repoUrl);
|
||||
const docs = await this.collectRepoDocuments(repoName);
|
||||
|
||||
return [...docs.sprints, ...docs.hotfixes]
|
||||
return [...docs.sprints, ...docs.hotfixes, ...docs.qas]
|
||||
.filter(isRepoDocumentMeta)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
@@ -365,20 +393,27 @@ export class ProjectsService {
|
||||
}
|
||||
|
||||
private async getRepoMetaMap() {
|
||||
const repoMetaMap: Record<number, { openPRs: number; updatedAt: string }> =
|
||||
{};
|
||||
const repoMetaMap: Record<
|
||||
number,
|
||||
{ openPRs: number; updatedAt: string; visibility: ProjectVisibility }
|
||||
> = {};
|
||||
let sourceAvailable = false;
|
||||
|
||||
try {
|
||||
const repos = await this.gitea.getOrgRepos();
|
||||
sourceAvailable = repos.length > 0;
|
||||
for (const repo of repos) {
|
||||
repoMetaMap[repo.id] = {
|
||||
openPRs: repo.open_pr_counter,
|
||||
updatedAt: repo.updated_at,
|
||||
visibility: repo.private ? 'private' : 'public',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn('Gitea unavailable, using DB data only');
|
||||
}
|
||||
return repoMetaMap;
|
||||
|
||||
return { repoMetaMap, sourceAvailable };
|
||||
}
|
||||
|
||||
private async getRepoDocumentMap(repoNames: string[]) {
|
||||
@@ -393,22 +428,29 @@ export class ProjectsService {
|
||||
}
|
||||
|
||||
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),
|
||||
const [sprintPathGroups, hotfixPathGroups, qaPathGroups] =
|
||||
await Promise.all([
|
||||
Promise.all(
|
||||
SPRINT_PATH_CANDIDATES.map((path) =>
|
||||
this.gitea.getRepoTree(repoName, path),
|
||||
),
|
||||
),
|
||||
),
|
||||
Promise.all(
|
||||
QA_PATH_CANDIDATES.map((path) =>
|
||||
this.gitea.getRepoTree(repoName, 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 sprintFiles = Array.from(
|
||||
new Set(
|
||||
sprintPathGroups.flat().filter((path) => /SPRINT-\d+\.md$/i.test(path)),
|
||||
),
|
||||
);
|
||||
const hotfixFiles = Array.from(
|
||||
new Set(
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('SisterDetailService', () => {
|
||||
|
||||
expect(result.name).toBe('narang');
|
||||
expect(result.role).toBe('Generator');
|
||||
expect(result.raw).toContain('SSH 연결 불가');
|
||||
expect(result.configAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('getSisterConfig: sister 없으면 NotFoundException', async () => {
|
||||
|
||||
@@ -46,14 +46,14 @@ export class SisterDetailService {
|
||||
name,
|
||||
role: SISTER_ROLES[name],
|
||||
description: SISTER_DESCRIPTIONS[name],
|
||||
raw: result.stdout || '(설정 파일 없음)',
|
||||
configAvailable: Boolean(result.stdout.trim()),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
name,
|
||||
role: SISTER_ROLES[name],
|
||||
description: SISTER_DESCRIPTIONS[name],
|
||||
raw: '(SSH 연결 불가)',
|
||||
configAvailable: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -67,22 +67,21 @@ export class SisterDetailService {
|
||||
sister.ip,
|
||||
sister.user,
|
||||
sshKeyPath,
|
||||
'SESSION_DIR=~/.hermes/sessions; [ -d "$SESSION_DIR" ] || SESSION_DIR=~/.openclaw/sessions; ls -lt "$SESSION_DIR"/ 2>/dev/null | head -20 || echo ""',
|
||||
'SESSION_DIR=~/.hermes/sessions; [ -d "$SESSION_DIR" ] || SESSION_DIR=~/.openclaw/sessions; find "$SESSION_DIR" -maxdepth 1 -type f -printf "%TY-%Tm-%Td %TH:%TM|%s|%f\n" 2>/dev/null | sort -r | head -20 || echo ""',
|
||||
);
|
||||
|
||||
const lines = result.stdout
|
||||
.split('\n')
|
||||
.filter((l) => l && !l.startsWith('total'));
|
||||
const lines = result.stdout.split('\n').filter((line) => line.trim());
|
||||
return lines
|
||||
.map((line) => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const [modified = '', size = '0', file = ''] = line.split('|');
|
||||
return {
|
||||
name: parts[parts.length - 1] ?? '',
|
||||
modified: parts.slice(5, 8).join(' '),
|
||||
size: parts[4] ?? '0',
|
||||
id: file,
|
||||
label: file,
|
||||
title: file,
|
||||
status: `${size}B · ${modified}`,
|
||||
};
|
||||
})
|
||||
.filter((s) => s.name && s.name !== '');
|
||||
.filter((session) => session.id && session.id !== '');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -125,6 +124,33 @@ export class SisterDetailService {
|
||||
return { items: logs, total };
|
||||
}
|
||||
|
||||
async getSisterLogs(name: SisterName, lines = 100) {
|
||||
const sister = await this.getSisterRecord(name);
|
||||
const sshKeyPath = this.getKeyPath();
|
||||
|
||||
try {
|
||||
const result = await this.ssh.executeCommand(
|
||||
sister.ip,
|
||||
sister.user,
|
||||
sshKeyPath,
|
||||
`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 "(로그 없음)"`,
|
||||
);
|
||||
|
||||
const output = result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
return {
|
||||
name,
|
||||
lines: output.length > 0 ? output : ['(로그 없음)'],
|
||||
total: output.length > 0 ? output.length : 1,
|
||||
};
|
||||
} catch {
|
||||
return { name, lines: ['(로그 조회 실패)'], total: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
async getOrgData() {
|
||||
const sisters = await this.prisma.sisterConfig.findMany();
|
||||
return {
|
||||
|
||||
@@ -79,6 +79,12 @@ interface DashboardOpsData {
|
||||
nodes: PipelineNode[];
|
||||
};
|
||||
board: BoardItem[];
|
||||
freshness: {
|
||||
generatedAt: string;
|
||||
activityLatestAt: string | null;
|
||||
sistersLatestAt: string | null;
|
||||
qaDocLatestAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
||||
@@ -267,21 +273,22 @@ const ConnectionDot = styled.span<{ $connected: boolean }>`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const OperatorRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
const OperatorGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const OperatorText = styled.div`
|
||||
const OperatorCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const OperatorName = styled.div`
|
||||
font-size: 15px;
|
||||
const OperatorValue = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
`;
|
||||
@@ -724,6 +731,18 @@ function formatTime(iso?: string | null): string {
|
||||
return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function formatSyncLabel(iso?: string | null): string {
|
||||
if (!iso) return 'NO SYNC';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (!Number.isFinite(diff) || diff < 0) return 'SYNC UNKNOWN';
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `SYNC ${sec}s AGO`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `SYNC ${min}m AGO`;
|
||||
const hr = Math.floor(min / 60);
|
||||
return `SYNC ${hr}h AGO`;
|
||||
}
|
||||
|
||||
function normalizePercent(value?: number | null): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
@@ -749,10 +768,10 @@ function buildSisterSummary(sister: SisterItem, activityItems: ActivityItem[], p
|
||||
if (latestActivity?.detail) return latestActivity.detail;
|
||||
const ownerProject = projects.find((project) => project.ownerSister === sister.name && project.phase !== 'DEPLOYED');
|
||||
if (ownerProject) return `${ownerProject.name} · ${ownerProject.phase}`;
|
||||
if (sister.status === 'offline') return '최근 런타임 응답 없음';
|
||||
if (sister.status === 'working') return 'active pipeline 처리 중';
|
||||
if (sister.status === 'online') return '새 handoff 대기 중';
|
||||
return '운영 상태 확인 중';
|
||||
if (sister.status === 'offline') return 'RUNTIME OFFLINE';
|
||||
if (sister.status === 'working') return 'ACTIVE SNAPSHOT';
|
||||
if (sister.status === 'online') return 'RUNTIME READY';
|
||||
return 'SNAPSHOT ONLY';
|
||||
}
|
||||
|
||||
function actionTone(action?: string, detail?: string | null): 'default' | 'warn' | 'ok' | 'active' {
|
||||
@@ -837,6 +856,12 @@ export default function DashboardPage() {
|
||||
}, []);
|
||||
|
||||
const derived = useMemo(() => {
|
||||
const freshness = opsData?.freshness ?? {
|
||||
generatedAt: null,
|
||||
activityLatestAt: null,
|
||||
sistersLatestAt: null,
|
||||
qaDocLatestAt: null,
|
||||
};
|
||||
const onlineCount = sisters.filter((item) => item.status === 'online' || item.status === 'working').length;
|
||||
const offlineCount = sisters.filter((item) => item.status === 'offline').length;
|
||||
const activeProject = opsData?.focusProject
|
||||
@@ -878,24 +903,24 @@ export default function DashboardPage() {
|
||||
|
||||
const infraCards = [
|
||||
{
|
||||
title: 'Gateway / Runtime',
|
||||
meta: connected ? 'Socket live · runtime heartbeat 수신 중' : 'Polling mode · websocket 연결 재시도 중',
|
||||
detail: `${onlineCount}/${Math.max(sisters.length, 4)} sisters active`,
|
||||
title: 'Runtime',
|
||||
meta: connected ? 'SOCKET / EVENT MIRRORED' : 'POLLING SNAPSHOT',
|
||||
detail: `${onlineCount}/${Math.max(sisters.length, 4)} ACTIVE · ${formatSyncLabel(freshness.sistersLatestAt)}`,
|
||||
},
|
||||
{
|
||||
title: 'Project Surface',
|
||||
meta: `${projects.length} projects tracked · ${totalSprints} sprints indexed`,
|
||||
detail: readyDeploy > 0 ? `${readyDeploy} ready for deploy` : 'deploy-ready backlog 없음',
|
||||
title: 'Projects',
|
||||
meta: `SNAPSHOT · ${projects.length} PROJECTS`,
|
||||
detail: readyDeploy > 0 ? `${readyDeploy} READY FOR DEPLOY` : 'NO DEPLOY QUEUE',
|
||||
},
|
||||
{
|
||||
title: 'Review Loop',
|
||||
meta: `${reviewProjects} QA queue · first-pass ${firstPassRate}%`,
|
||||
detail: escalationCount > 0 ? `blocker project ${escalationCount}건` : 'escalation 없음',
|
||||
title: 'QA',
|
||||
meta: `DOC-DERIVED · ${firstPassRate}%`,
|
||||
detail: escalationCount > 0 ? `${escalationCount} BLOCKER PROJECT` : 'NO BLOCKER',
|
||||
},
|
||||
{
|
||||
title: 'Activity Feed',
|
||||
meta: `${activityItems.length} recent events mirrored`,
|
||||
detail: activityItems[0]?.detail ?? activityItems[0]?.action ?? '최근 이벤트 없음',
|
||||
title: 'Events',
|
||||
meta: `EVENTS ${activityItems.length}`,
|
||||
detail: formatSyncLabel(freshness.activityLatestAt),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -916,8 +941,9 @@ export default function DashboardPage() {
|
||||
pipelineNodes,
|
||||
infraCards,
|
||||
board: opsData?.board ?? [],
|
||||
activeTask: opsData?.pipeline.activeTask ?? (activeProject ? `${activeProject.name} · ${activeProject.currentSprint ?? activeProject.phase}` : 'No active pipeline'),
|
||||
deployState: opsData?.pipeline.deployState ?? (activeProject?.deployStatus ?? 'standby'),
|
||||
activeTask: opsData?.pipeline.activeTask ?? (activeProject ? `${activeProject.name} · ${activeProject.currentSprint ?? activeProject.phase}` : 'NO ACTIVE PIPELINE'),
|
||||
deployState: opsData?.pipeline.deployState ?? (activeProject?.deployStatus ?? 'STANDBY'),
|
||||
freshness,
|
||||
};
|
||||
}, [activityItems, connected, opsData, projects, sisters]);
|
||||
|
||||
@@ -928,24 +954,22 @@ export default function DashboardPage() {
|
||||
<TopBar>
|
||||
<TopBlock>
|
||||
<div>
|
||||
<Eyebrow>Hanarang / master dashboard v3</Eyebrow>
|
||||
<Headline>지금 어디가 움직이고 있는지, 첫 화면에서 바로 읽히게 만들었어.</Headline>
|
||||
<Eyebrow>Master dashboard</Eyebrow>
|
||||
<Headline>MASTER PIPELINE BOARD</Headline>
|
||||
</div>
|
||||
<Summary>
|
||||
4자매 운영 상태, active pipeline, activity, metrics, infra, 운영 학습 자산까지 한 화면에 모았어. 장식보다 handoff 판단이 먼저 읽히게 정리했어.
|
||||
</Summary>
|
||||
<Summary>RUNTIME / EVENTS / DOCS</Summary>
|
||||
<InlineStats>
|
||||
<Chip $tone={connected ? 'active' : 'muted'}>{connected ? 'socket live' : 'polling mode'}</Chip>
|
||||
<Chip $tone="default">projects {String(projects.length).padStart(2, '0')}</Chip>
|
||||
<Chip $tone={derived.reviewProjects > 0 ? 'warning' : 'default'}>qa queue {String(derived.reviewProjects).padStart(2, '0')}</Chip>
|
||||
<Chip $tone={derived.readyDeploy > 0 ? 'active' : 'muted'}>ready deploy {String(derived.readyDeploy).padStart(2, '0')}</Chip>
|
||||
<Chip $tone={connected ? 'active' : 'muted'}>{connected ? 'socket on' : 'socket off'}</Chip>
|
||||
<Chip $tone="default">snapshot {formatSyncLabel(derived.freshness.generatedAt)}</Chip>
|
||||
<Chip $tone={derived.reviewProjects > 0 ? 'warning' : 'default'}>qa {String(derived.reviewProjects).padStart(2, '0')}</Chip>
|
||||
<Chip $tone={derived.readyDeploy > 0 ? 'active' : 'muted'}>deploy {String(derived.readyDeploy).padStart(2, '0')}</Chip>
|
||||
</InlineStats>
|
||||
</TopBlock>
|
||||
|
||||
<FocusPanel>
|
||||
<div>
|
||||
<Eyebrow>Current operating focus</Eyebrow>
|
||||
<FocusTitle>{derived.activeProject?.name ?? 'No active sprint'}</FocusTitle>
|
||||
<Eyebrow>Current focus</Eyebrow>
|
||||
<FocusTitle>{derived.activeProject?.name ?? 'NO ACTIVE SPRINT'}</FocusTitle>
|
||||
</div>
|
||||
<FocusBody>{derived.focusSummary}</FocusBody>
|
||||
<TechBar>
|
||||
@@ -954,11 +978,11 @@ export default function DashboardPage() {
|
||||
<FocusMetaGrid>
|
||||
<MetaCard>
|
||||
<MetaLabel>current sprint</MetaLabel>
|
||||
<MetaValue>{derived.activeProject?.currentSprint ?? 'backlog watch'}</MetaValue>
|
||||
<MetaValue>{derived.activeProject?.currentSprint ?? 'SNAPSHOT'}</MetaValue>
|
||||
</MetaCard>
|
||||
<MetaCard>
|
||||
<MetaLabel>phase</MetaLabel>
|
||||
<MetaValue>{derived.activeProject?.phase ?? 'monitoring'}</MetaValue>
|
||||
<MetaValue>{derived.activeProject?.phase ?? 'SNAPSHOT'}</MetaValue>
|
||||
</MetaCard>
|
||||
<MetaCard>
|
||||
<MetaLabel>sprint progress</MetaLabel>
|
||||
@@ -966,7 +990,7 @@ export default function DashboardPage() {
|
||||
</MetaCard>
|
||||
<MetaCard>
|
||||
<MetaLabel>deploy state</MetaLabel>
|
||||
<MetaValue>{derived.activeProject?.deployStatus ?? 'no deploy signal'}</MetaValue>
|
||||
<MetaValue>{derived.activeProject?.deployStatus ?? 'NO SIGNAL'}</MetaValue>
|
||||
</MetaCard>
|
||||
</FocusMetaGrid>
|
||||
</FocusPanel>
|
||||
@@ -976,22 +1000,35 @@ export default function DashboardPage() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<ConnectionDot $connected={connected} />
|
||||
<div>
|
||||
<Eyebrow>Runtime link</Eyebrow>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{connected ? 'Discord / runtime connected' : 'Polling fallback active'}</div>
|
||||
<Eyebrow>Event stream</Eyebrow>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{connected ? 'SOCKET ON / EVENT MIRRORED' : 'SOCKET OFF / POLLING'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<LabelMeta><span>LIVE:</span>{connected ? 'ON' : 'OFF'}</LabelMeta>
|
||||
<LabelMeta><span>SYNC:</span>{formatSyncLabel(derived.freshness.activityLatestAt)}</LabelMeta>
|
||||
</ConnectionCard>
|
||||
|
||||
<OperatorRow>
|
||||
<SisterAvatar name={derived.activeProject?.ownerSister ?? 'narang'} size={44} />
|
||||
<OperatorText>
|
||||
<OperatorName>{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.label ?? '나랑이'}</OperatorName>
|
||||
<OperatorMeta>
|
||||
현재 오너 기준은 {SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.role ?? 'Implementation'}야. 최근 handoff와 phase를 같이 보고 있어.
|
||||
</OperatorMeta>
|
||||
</OperatorText>
|
||||
</OperatorRow>
|
||||
<OperatorGrid>
|
||||
<OperatorCard>
|
||||
<MetaLabel>focus owner</MetaLabel>
|
||||
<OperatorValue>{derived.activeProject?.ownerSister ? (SISTER_META[derived.activeProject.ownerSister]?.label ?? derived.activeProject.ownerSister) : 'UNASSIGNED'}</OperatorValue>
|
||||
<OperatorMeta>SNAPSHOT OWNER</OperatorMeta>
|
||||
</OperatorCard>
|
||||
<OperatorCard>
|
||||
<MetaLabel>runtime lag</MetaLabel>
|
||||
<OperatorValue>{formatSyncLabel(derived.freshness.sistersLatestAt)}</OperatorValue>
|
||||
<OperatorMeta>RUNTIME SNAPSHOT</OperatorMeta>
|
||||
</OperatorCard>
|
||||
<OperatorCard>
|
||||
<MetaLabel>activity lag</MetaLabel>
|
||||
<OperatorValue>{formatSyncLabel(derived.freshness.activityLatestAt)}</OperatorValue>
|
||||
<OperatorMeta>EVENT MIRROR</OperatorMeta>
|
||||
</OperatorCard>
|
||||
<OperatorCard>
|
||||
<MetaLabel>qa doc lag</MetaLabel>
|
||||
<OperatorValue>{formatSyncLabel(derived.freshness.qaDocLatestAt)}</OperatorValue>
|
||||
<OperatorMeta>DOC-DERIVED</OperatorMeta>
|
||||
</OperatorCard>
|
||||
</OperatorGrid>
|
||||
|
||||
<QuickLinks>
|
||||
<QuickLink href="/projects">
|
||||
@@ -1018,7 +1055,7 @@ export default function DashboardPage() {
|
||||
{loading
|
||||
? Array.from({ length: 4 }).map((_, index) => <SkeletonCard key={index} />)
|
||||
: sisters.length === 0
|
||||
? <EmptyState>자매 런타임 데이터가 아직 없어.</EmptyState>
|
||||
? <EmptyState>NO RUNTIME DATA.</EmptyState>
|
||||
: sisters.map((sister) => {
|
||||
const sisterMeta = SISTER_META[sister.name] ?? { label: sister.name, role: sister.role ?? 'unknown', accent: '#888888' };
|
||||
return (
|
||||
@@ -1080,8 +1117,8 @@ export default function DashboardPage() {
|
||||
<PanelHeader>
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Activity feed</Eyebrow>
|
||||
<PanelTitle>최근 handoff / review / deploy 흐름</PanelTitle>
|
||||
<PanelDesc>이벤트 타입, 주체, 대상 문맥이 바로 읽히게 텍스트 로그 대신 운영 카드로 정리했어.</PanelDesc>
|
||||
<PanelTitle>EVENT MIRROR</PanelTitle>
|
||||
<PanelDesc>WEBSOCKET + POLLING SNAPSHOT</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>EVENT:</span>{String(activityItems.length).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
@@ -1089,7 +1126,7 @@ export default function DashboardPage() {
|
||||
{loading ? (
|
||||
<SkeletonCard />
|
||||
) : activityItems.length === 0 ? (
|
||||
<EmptyState>No recent activity.</EmptyState>
|
||||
<EmptyState>NO RECENT EVENT.</EmptyState>
|
||||
) : (
|
||||
<ActivityList>
|
||||
{activityItems.slice(0, 6).map((item) => (
|
||||
@@ -1117,8 +1154,8 @@ export default function DashboardPage() {
|
||||
<PanelHeader>
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Sprint metrics</Eyebrow>
|
||||
<PanelTitle>정량 지표로 지금 속도를 확인</PanelTitle>
|
||||
<PanelDesc>fake 숫자처럼 보이지 않게 프로젝트/QA/activity에서 바로 읽을 수 있는 값만 남겼어.</PanelDesc>
|
||||
<PanelTitle>SPRINT METRICS</PanelTitle>
|
||||
<PanelDesc>POLLING SNAPSHOT</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>SPRINT:</span>{String(derived.totalSprints).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
@@ -1127,22 +1164,22 @@ export default function DashboardPage() {
|
||||
<MetricCard>
|
||||
<MetaLabel>total tasks view</MetaLabel>
|
||||
<MetricValue>{derived.totalSprints}</MetricValue>
|
||||
<MetricHelp>현재 인덱싱된 sprint 기준 총 작업량이야.</MetricHelp>
|
||||
<MetricHelp>INDEXED SPRINT COUNT</MetricHelp>
|
||||
</MetricCard>
|
||||
<MetricCard>
|
||||
<MetaLabel>done sprint</MetaLabel>
|
||||
<MetricValue>{derived.doneSprints}</MetricValue>
|
||||
<MetricHelp>완료된 sprint 누적 수치야.</MetricHelp>
|
||||
<MetricHelp>DONE SPRINTS</MetricHelp>
|
||||
</MetricCard>
|
||||
<MetricCard>
|
||||
<MetaLabel>first-pass rate</MetaLabel>
|
||||
<MetricValue>{derived.firstPassRate}%</MetricValue>
|
||||
<MetricHelp>최근 QA 결과 중 blocker 없이 통과한 비율이야.</MetricHelp>
|
||||
<MetricHelp>QA DOC DERIVED</MetricHelp>
|
||||
</MetricCard>
|
||||
<MetricCard>
|
||||
<MetaLabel>escalations</MetaLabel>
|
||||
<MetricValue>{derived.escalationCount}</MetricValue>
|
||||
<MetricHelp>blocker 존재 프로젝트 수를 escalation 신호로 잡았어.</MetricHelp>
|
||||
<MetricHelp>BLOCKER PROJECTS</MetricHelp>
|
||||
</MetricCard>
|
||||
</MetricsGrid>
|
||||
|
||||
@@ -1163,8 +1200,8 @@ export default function DashboardPage() {
|
||||
<PanelHeader>
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Infrastructure overview</Eyebrow>
|
||||
<PanelTitle>gateway / sisters / deploy surface</PanelTitle>
|
||||
<PanelDesc>과장된 네트워크 다이어그램 대신 실제 상태 요약을 운영 맥락으로 묶었어.</PanelDesc>
|
||||
<PanelTitle>RUNTIME SURFACE</PanelTitle>
|
||||
<PanelDesc>LIVE / SNAPSHOT / DOC-DERIVED</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>NODE:</span>{String(sisters.length).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
@@ -1174,7 +1211,7 @@ export default function DashboardPage() {
|
||||
<InfraCard key={card.title}>
|
||||
<InfraTitle>{card.title}</InfraTitle>
|
||||
<InfraMeta>{card.meta}</InfraMeta>
|
||||
<EventTag $tone={card.title === 'Gateway / Runtime' && connected ? 'ok' : card.detail.includes('없음') ? 'default' : 'active'}>{card.detail}</EventTag>
|
||||
<EventTag $tone={card.title === 'Runtime' && connected ? 'ok' : card.detail.startsWith('NO ') ? 'default' : 'active'}>{card.detail}</EventTag>
|
||||
</InfraCard>
|
||||
))}
|
||||
</InfraMap>
|
||||
@@ -1184,14 +1221,14 @@ export default function DashboardPage() {
|
||||
<PanelHeader>
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Mistake log & harness</Eyebrow>
|
||||
<PanelTitle>실수와 운영 규칙도 홈에서 읽히게</PanelTitle>
|
||||
<PanelDesc>합성 요약 대신 QA 문서와 harness 변경 로그에서 직접 읽은 항목만 보드로 보여줘.</PanelDesc>
|
||||
<PanelTitle>RULE / LOG BOARD</PanelTitle>
|
||||
<PanelDesc>DOC-DERIVED / ACTIVITY</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>RULE:</span>{String(derived.board.length).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
|
||||
{derived.board.length === 0 ? (
|
||||
<EmptyState>아직 표시할 QA / harness 원본 기록이 없어.</EmptyState>
|
||||
<EmptyState>NO RULE LOG.</EmptyState>
|
||||
) : (
|
||||
<LessonList>
|
||||
{derived.board.map((item) => (
|
||||
@@ -1203,7 +1240,11 @@ export default function DashboardPage() {
|
||||
<EventTag>{formatRelativeTime(item.time)}</EventTag>
|
||||
</LessonMeta>
|
||||
<LessonBody>{item.body}</LessonBody>
|
||||
<LessonBody>Source · {item.source}</LessonBody>
|
||||
<LessonMeta>
|
||||
<EventTag>SRC</EventTag>
|
||||
<EventTag>{item.source}</EventTag>
|
||||
<EventTag>{formatTime(item.time)}</EventTag>
|
||||
</LessonMeta>
|
||||
</LessonCard>
|
||||
))}
|
||||
</LessonList>
|
||||
|
||||
@@ -4,17 +4,78 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill, BtnToggle } from '@/components/ui/base';
|
||||
import {
|
||||
LabelMeta,
|
||||
PageTitle,
|
||||
SectionTitle,
|
||||
TechBar,
|
||||
TechBarFill,
|
||||
BtnToggle,
|
||||
} from '@/components/ui/base';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
|
||||
interface TaskItem { id: number; taskId: string; title: string; assignee: string; status: string; }
|
||||
interface SprintItem { id: number; number: number; name: string; status: string; progress: number; tasks: TaskItem[]; }
|
||||
interface HistoryItem { label: string; kind: 'sprint' | 'hotfix'; summary?: string | null; description?: string | null; }
|
||||
interface SisterState { name: string; status: string; }
|
||||
interface CommitItem { sha?: string; html_url?: string; commit?: { message?: string; author?: { date?: string; name?: string } }; author?: { login?: string } | null; }
|
||||
interface BranchItem { name?: string; protected?: boolean; commit?: { id?: string } }
|
||||
interface PullItem { id?: number; number?: number; title?: string; html_url?: string; state?: string; merged?: boolean; user?: { login?: string }; created_at?: string; }
|
||||
interface TaskItem {
|
||||
id: number;
|
||||
taskId: string;
|
||||
title: string;
|
||||
assignee: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface SprintItem {
|
||||
id: number;
|
||||
number: number;
|
||||
name: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
tasks: TaskItem[];
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
label: string;
|
||||
kind: 'sprint' | 'hotfix' | 'qa';
|
||||
summary?: string | null;
|
||||
description?: string | null;
|
||||
qaStatus?: 'passed' | 'failed' | 'unknown';
|
||||
blockerCount?: number;
|
||||
}
|
||||
|
||||
interface SisterState {
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface CommitItem {
|
||||
sha?: string;
|
||||
html_url?: string;
|
||||
commit?: { message?: string; author?: { date?: string; name?: string } };
|
||||
author?: { login?: string } | null;
|
||||
}
|
||||
|
||||
interface BranchItem {
|
||||
name?: string;
|
||||
protected?: boolean;
|
||||
commit?: { id?: string };
|
||||
}
|
||||
|
||||
interface PullItem {
|
||||
id?: number;
|
||||
number?: number;
|
||||
title?: string;
|
||||
html_url?: string;
|
||||
state?: string;
|
||||
merged?: boolean;
|
||||
user?: { login?: string };
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface AssignedNode {
|
||||
name: string;
|
||||
role?: string;
|
||||
assigned?: boolean;
|
||||
}
|
||||
|
||||
interface ProjectDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -22,32 +83,215 @@ interface ProjectDetail {
|
||||
status: string;
|
||||
phase: string;
|
||||
currentSprint?: string | null;
|
||||
latestQa: { label: string | null; status: 'passed' | 'failed' | 'unknown'; blockerCount: number; summary: string | null } | null;
|
||||
deploy: { branch: string; status: string; latestDeployAt: string | null; redeployRequired: boolean; note: string };
|
||||
latestQa: {
|
||||
label: string | null;
|
||||
status: 'passed' | 'failed' | 'unknown';
|
||||
blockerCount: number;
|
||||
summary: string | null;
|
||||
} | null;
|
||||
deploy: {
|
||||
branch: string;
|
||||
status: string;
|
||||
latestDeployAt: string | null;
|
||||
redeployRequired: boolean;
|
||||
note: string;
|
||||
};
|
||||
assignedNodes?: AssignedNode[];
|
||||
}
|
||||
|
||||
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);}}`;
|
||||
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;`;
|
||||
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 Breadcrumb = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = { harang: 'ORCHESTRATOR', narang: 'GENERATOR', darang: 'EVALUATOR', erang: 'INFRA' };
|
||||
const ROLE_NAME: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
|
||||
a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
`;
|
||||
|
||||
const TabBar = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-xl);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const OverviewGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: var(--space-xl);
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const MainColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
`;
|
||||
|
||||
const SideColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
`;
|
||||
|
||||
const Panel = styled.section`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-surface);
|
||||
`;
|
||||
|
||||
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 StatusStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
`;
|
||||
|
||||
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 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: '이랑',
|
||||
};
|
||||
|
||||
const DEFAULT_NODE_ORDER = ['harang', 'narang', 'darang', 'erang'] as const;
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
@@ -63,7 +307,7 @@ export default function ProjectDetailPage() {
|
||||
const [commits, setCommits] = useState<CommitItem[]>([]);
|
||||
const [branches, setBranches] = useState<BranchItem[]>([]);
|
||||
const [pulls, setPulls] = useState<PullItem[]>([]);
|
||||
const [tab, setTab] = useState<'overview'|'commits'|'branches'|'prs'>('overview');
|
||||
const [tab, setTab] = useState<'overview' | 'commits' | 'branches' | 'prs'>('overview');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,29 +316,48 @@ export default function ProjectDetailPage() {
|
||||
fetch(`${API_URL}/api/projects/${id}/tasks`),
|
||||
fetch(`${API_URL}/api/projects/${id}/history`),
|
||||
fetch(`${API_URL}/api/sisters`),
|
||||
]).then(async ([projectRes, tasksRes, historyRes, sistersRes]) => {
|
||||
if (projectRes.status === 'fulfilled' && projectRes.value.ok) setProject(await projectRes.value.json());
|
||||
if (tasksRes.status === 'fulfilled' && tasksRes.value.ok) setTasks(await tasksRes.value.json());
|
||||
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));
|
||||
])
|
||||
.then(async ([projectRes, tasksRes, historyRes, sistersRes]) => {
|
||||
if (projectRes.status === 'fulfilled' && projectRes.value.ok) {
|
||||
setProject(await projectRes.value.json());
|
||||
}
|
||||
if (tasksRes.status === 'fulfilled' && tasksRes.value.ok) {
|
||||
setTasks(await tasksRes.value.json());
|
||||
}
|
||||
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));
|
||||
}, [id]);
|
||||
|
||||
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 === 'branches' && branches.length === 0) fetch(`${API_URL}/api/projects/${id}/branches`).then((r) => r.ok ? r.json() : []).then(setBranches).catch(() => {});
|
||||
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 === 'commits' && commits.length === 0) {
|
||||
fetch(`${API_URL}/api/projects/${id}/commits?limit=20`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then(setCommits)
|
||||
.catch(() => {});
|
||||
}
|
||||
if (tab === 'branches' && branches.length === 0) {
|
||||
fetch(`${API_URL}/api/projects/${id}/branches`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then(setBranches)
|
||||
.catch(() => {});
|
||||
}
|
||||
if (tab === 'prs' && pulls.length === 0) {
|
||||
fetch(`${API_URL}/api/projects/${id}/pulls?state=all`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then(setPulls)
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [tab, id, commits.length, branches.length, pulls.length]);
|
||||
|
||||
const allTasks = useMemo(() => tasks.flatMap((sprint) => sprint.tasks ?? []), [tasks]);
|
||||
const assignedNodes = useMemo(() => {
|
||||
const names = Array.from(new Set(allTasks.map((task) => task.assignee).filter(Boolean)));
|
||||
return (names.length > 0 ? names : ['harang', 'narang', 'darang', 'erang']).map((name) => ({
|
||||
name,
|
||||
status: sisters.find((sister) => sister.name === name)?.status ?? 'offline',
|
||||
}));
|
||||
}, [allTasks, sisters]);
|
||||
const hotfixes = history.filter((entry) => entry.kind === 'hotfix');
|
||||
const qaHistory = history.filter((entry) => entry.kind === 'qa');
|
||||
|
||||
const currentSprintMeta = useMemo(() => {
|
||||
if (project?.currentSprint) return project.currentSprint;
|
||||
|
||||
@@ -108,6 +371,15 @@ export default function ProjectDetailPage() {
|
||||
return 'BACKLOG';
|
||||
}, [project, tasks]);
|
||||
|
||||
const assignedNodes = useMemo(() => {
|
||||
const apiNodes = project?.assignedNodes ?? [];
|
||||
return DEFAULT_NODE_ORDER.map((name) => ({
|
||||
name,
|
||||
assigned: apiNodes.find((node) => node.name === name)?.assigned ?? false,
|
||||
status: sisters.find((sister) => sister.name === name)?.status ?? 'offline',
|
||||
}));
|
||||
}, [project, sisters]);
|
||||
|
||||
if (loading) return <Empty>LOADING...</Empty>;
|
||||
if (!project) return <Empty>PROJECT NOT FOUND</Empty>;
|
||||
|
||||
@@ -121,100 +393,179 @@ export default function ProjectDetailPage() {
|
||||
|
||||
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>
|
||||
<PageTitle>
|
||||
{project.name}
|
||||
{project.repoUrl && <a href={project.repoUrl} target="_blank" rel="noopener" style={{ fontSize: '12px', color: '#5fafff', marginLeft: 'var(--space-md)', fontWeight: 400 }}>↗ GITEA</a>}
|
||||
{project.repoUrl && (
|
||||
<a
|
||||
href={project.repoUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style={{ fontSize: '12px', color: '#5fafff', marginLeft: 'var(--space-md)', fontWeight: 400 }}
|
||||
>
|
||||
↗ GITEA
|
||||
</a>
|
||||
)}
|
||||
</PageTitle>
|
||||
<LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / <span>SPRINT:</span>{currentSprintMeta} / <span>DEPLOY:</span>{project.deploy.status}</LabelMeta>
|
||||
<LabelMeta>
|
||||
<span>STATUS:</span>
|
||||
{project.status?.toUpperCase()} / <span>SPRINT:</span>
|
||||
{currentSprintMeta} / <span>DEPLOY:</span>
|
||||
{project.deploy.status}
|
||||
</LabelMeta>
|
||||
</PageTitleRow>
|
||||
|
||||
<TabBar>
|
||||
<BtnToggle $active={tab === 'overview'} onClick={() => setTab('overview')}>Overview</BtnToggle>
|
||||
<BtnToggle $active={tab === 'commits'} onClick={() => setTab('commits')}>Commits</BtnToggle>
|
||||
<BtnToggle $active={tab === 'branches'} onClick={() => setTab('branches')}>Branches</BtnToggle>
|
||||
<BtnToggle $active={tab === 'prs'} onClick={() => setTab('prs')}>Pull Requests</BtnToggle>
|
||||
<BtnToggle $active={tab === 'overview'} onClick={() => setTab('overview')}>
|
||||
Overview
|
||||
</BtnToggle>
|
||||
<BtnToggle $active={tab === 'commits'} onClick={() => setTab('commits')}>
|
||||
Commits
|
||||
</BtnToggle>
|
||||
<BtnToggle $active={tab === 'branches'} onClick={() => setTab('branches')}>
|
||||
Branches
|
||||
</BtnToggle>
|
||||
<BtnToggle $active={tab === 'prs'} onClick={() => setTab('prs')}>
|
||||
Pull Requests
|
||||
</BtnToggle>
|
||||
</TabBar>
|
||||
|
||||
{tab === 'overview' && (
|
||||
<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>
|
||||
<OverviewGrid>
|
||||
<MainColumn>
|
||||
<Panel>
|
||||
<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 style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>SPRINT {String(sprint.number).padStart(2, '0')} · {sprint.name}</div>
|
||||
<Meta>{sprint.tasks?.length ?? 0} TASKS · {sprint.tasks?.filter((task) => task.status === 'done').length ?? 0} DONE</Meta>
|
||||
<Panel>
|
||||
<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 style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
SPRINT {String(sprint.number).padStart(2, '0')} · {sprint.name}
|
||||
</div>
|
||||
<Meta>
|
||||
{sprint.tasks?.length ?? 0} TASKS · {sprint.tasks?.filter((task) => task.status === 'done').length ?? 0} DONE
|
||||
</Meta>
|
||||
</div>
|
||||
<LabelMeta>{sprint.status}</LabelMeta>
|
||||
</div>
|
||||
<LabelMeta>{sprint.status}</LabelMeta>
|
||||
</div>
|
||||
<TechBar><TechBarFill $width={sprint.progress ?? 0} /></TechBar>
|
||||
</LedgerItem>
|
||||
))}
|
||||
{tasks.length === 0 && <Empty>NO SPRINTS</Empty>}
|
||||
</LedgerList>
|
||||
</Panel>
|
||||
<TechBar>
|
||||
<TechBarFill $width={sprint.progress ?? 0} />
|
||||
</TechBar>
|
||||
</LedgerItem>
|
||||
))}
|
||||
{tasks.length === 0 && <Empty>NO SPRINTS</Empty>}
|
||||
</LedgerList>
|
||||
</Panel>
|
||||
|
||||
<Panel $column="main" $mobileOrder={5}>
|
||||
<SectionTitle><span>HOTFIX HISTORY</span><LabelMeta>{hotfixes.length} ITEMS</LabelMeta></SectionTitle>
|
||||
<LedgerList>
|
||||
{hotfixes.map((entry) => (
|
||||
<LedgerItem key={entry.label}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>{entry.label}</div>
|
||||
<Meta>{entry.summary ?? entry.description ?? 'hotfix summary 없음'}</Meta>
|
||||
</LedgerItem>
|
||||
))}
|
||||
{hotfixes.length === 0 && <Empty>NO HOTFIX HISTORY</Empty>}
|
||||
</LedgerList>
|
||||
</Panel>
|
||||
|
||||
<Panel $column="side" $mobileOrder={6}>
|
||||
<SectionTitle><span>ASSIGNED NODES</span><LabelMeta>LIVE STATUS</LabelMeta></SectionTitle>
|
||||
<NodeStack>
|
||||
{assignedNodes.map((node) => (
|
||||
<NodeMiniCard key={node.name}>
|
||||
<NodeInfo>
|
||||
<SisterAvatar name={node.name} size={28} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[node.name] ?? node.name}</div>
|
||||
<Meta>{ROLE_LABEL[node.name] ?? 'NODE'}</Meta>
|
||||
<Panel>
|
||||
<SectionTitle>
|
||||
<span>QA / HOTFIX HISTORY</span>
|
||||
<LabelMeta>{qaHistory.length + hotfixes.length} ITEMS</LabelMeta>
|
||||
</SectionTitle>
|
||||
<LedgerList>
|
||||
{qaHistory.map((entry) => (
|
||||
<LedgerItem key={`qa-${entry.label}`}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 'var(--space-md)', flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>{entry.label}</div>
|
||||
<LabelMeta>{entry.qaStatus ?? 'unknown'}</LabelMeta>
|
||||
</div>
|
||||
</NodeInfo>
|
||||
<NodeDot $status={node.status} />
|
||||
</NodeMiniCard>
|
||||
))}
|
||||
</NodeStack>
|
||||
</Panel>
|
||||
</ProjectGrid>
|
||||
<Meta>{entry.summary ?? entry.description ?? 'qa summary 없음'}</Meta>
|
||||
{typeof entry.blockerCount === 'number' && <Meta>{entry.blockerCount} BLOCKERS</Meta>}
|
||||
</LedgerItem>
|
||||
))}
|
||||
{hotfixes.map((entry) => (
|
||||
<LedgerItem key={`hotfix-${entry.label}`}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>{entry.label}</div>
|
||||
<Meta>{entry.summary ?? entry.description ?? 'hotfix summary 없음'}</Meta>
|
||||
</LedgerItem>
|
||||
))}
|
||||
{qaHistory.length === 0 && hotfixes.length === 0 && <Empty>NO QA / HOTFIX HISTORY</Empty>}
|
||||
</LedgerList>
|
||||
</Panel>
|
||||
</MainColumn>
|
||||
|
||||
<SideColumn>
|
||||
<StatusStack>
|
||||
<Panel>
|
||||
<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>
|
||||
<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>
|
||||
<SectionTitle>
|
||||
<span>ASSIGNED NODES</span>
|
||||
<LabelMeta>4 SISTERS</LabelMeta>
|
||||
</SectionTitle>
|
||||
<NodeStack>
|
||||
{assignedNodes.map((node) => (
|
||||
<NodeMiniCard key={node.name}>
|
||||
<NodeInfo>
|
||||
<SisterAvatar name={node.name} size={28} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||
{ROLE_NAME[node.name] ?? node.name}
|
||||
</div>
|
||||
<Meta>
|
||||
{ROLE_LABEL[node.name] ?? 'NODE'} · {node.assigned ? 'ASSIGNED' : 'STANDBY'}
|
||||
</Meta>
|
||||
</div>
|
||||
</NodeInfo>
|
||||
<NodeDot $status={node.status} />
|
||||
</NodeMiniCard>
|
||||
))}
|
||||
</NodeStack>
|
||||
</Panel>
|
||||
</StatusStack>
|
||||
</SideColumn>
|
||||
</OverviewGrid>
|
||||
)}
|
||||
|
||||
{tab === 'commits' && (
|
||||
@@ -225,7 +576,9 @@ export default function ProjectDetailPage() {
|
||||
<GitTitle>{commit.commit?.message?.split('\n')[0] ?? commit.sha ?? 'UNKNOWN COMMIT'}</GitTitle>
|
||||
<Meta>{commit.commit?.author?.name ?? commit.author?.login ?? '-'}</Meta>
|
||||
</GitPrimary>
|
||||
<Meta>{commit.sha?.slice(0, 7) ?? '-'} · {formatDate(commit.commit?.author?.date)}</Meta>
|
||||
<Meta>
|
||||
{commit.sha?.slice(0, 7) ?? '-'} · {formatDate(commit.commit?.author?.date)}
|
||||
</Meta>
|
||||
</GitItem>
|
||||
))}
|
||||
{commits.length === 0 && <Empty>NO COMMITS</Empty>}
|
||||
@@ -252,8 +605,12 @@ export default function ProjectDetailPage() {
|
||||
{pulls.map((pull) => (
|
||||
<GitItem key={pull.id} href={pull.html_url ?? '#'} target="_blank" rel="noopener">
|
||||
<GitPrimary>
|
||||
<GitTitle>#{pull.number ?? '-'} {pull.title ?? 'UNKNOWN PR'}</GitTitle>
|
||||
<Meta>{pull.user?.login ?? '-'} · {pull.merged ? 'MERGED' : pull.state?.toUpperCase() ?? 'UNKNOWN'}</Meta>
|
||||
<GitTitle>
|
||||
#{pull.number ?? '-'} {pull.title ?? 'UNKNOWN PR'}
|
||||
</GitTitle>
|
||||
<Meta>
|
||||
{pull.user?.login ?? '-'} · {pull.merged ? 'MERGED' : pull.state?.toUpperCase() ?? 'UNKNOWN'}
|
||||
</Meta>
|
||||
</GitPrimary>
|
||||
<Meta>{formatDate(pull.created_at)}</Meta>
|
||||
</GitItem>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn } from '@/components/ui/base';
|
||||
import {
|
||||
PageTitle,
|
||||
LabelMeta,
|
||||
TechBar,
|
||||
TechBarFill,
|
||||
Btn,
|
||||
BtnToggle,
|
||||
} from '@/components/ui/base';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import { adminFetch } from '@/lib/adminFetch';
|
||||
|
||||
@@ -22,20 +29,142 @@ interface ProjectListItem {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
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 Left = styled.div`display:flex;flex-direction:column;gap:6px;min-width:0;`;
|
||||
const ProjectName = styled.div`font-size:15px;font-weight:600;color:var(--text-primary);`;
|
||||
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 Right = styled.div`display:flex;flex-direction:column;gap:8px;align-items:flex-end;@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 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);`;
|
||||
type VisibilityFilter = 'all' | 'public' | 'private';
|
||||
|
||||
const Toolbar = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-lg);
|
||||
`;
|
||||
|
||||
const ToolbarGroup = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const FilterGroup = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const ProjectList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const ProjectRow = styled(Link)`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) 240px;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, background 0.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 ProjectName = styled.div`
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
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 Right = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
|
||||
@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 MetaRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
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);
|
||||
`;
|
||||
|
||||
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` : ''}`;
|
||||
if (project.latestQaStatus === 'failed') {
|
||||
return `QA FAILED${project.blockerCount > 0 ? ` · ${project.blockerCount} BLOCKERS` : ''}`;
|
||||
}
|
||||
return 'QA PENDING';
|
||||
}
|
||||
|
||||
@@ -44,10 +173,16 @@ export default function ProjectsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||
const [visibility, setVisibility] = useState<VisibilityFilter>('all');
|
||||
|
||||
const loadProjects = () => {
|
||||
const loadProjects = (filter: VisibilityFilter = visibility) => {
|
||||
setLoading(true);
|
||||
fetch(`${API_URL}/api/projects`).then((r) => r.json()).then(setProjects).catch(() => {}).finally(() => setLoading(false));
|
||||
const query = filter === 'all' ? '' : `?visibility=${filter}`;
|
||||
fetch(`${API_URL}/api/projects${query}`)
|
||||
.then((r) => r.json())
|
||||
.then(setProjects)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const runSync = async (path: string, label: string) => {
|
||||
@@ -65,40 +200,74 @@ export default function ProjectsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadProjects(); }, []);
|
||||
useEffect(() => {
|
||||
loadProjects(visibility);
|
||||
}, [visibility]);
|
||||
|
||||
const filteredProjects = useMemo(() => projects, [projects]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-md)', flexWrap: 'wrap', marginBottom: 'var(--space-lg)' }}>
|
||||
<PageTitle>프로젝트</PageTitle>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)', flexWrap: 'wrap' }}>
|
||||
{syncResult && <span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)' }}>{syncResult}</span>}
|
||||
<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={() => runSync('/api/projects/sync-all-sprints', 'sprint sync')} disabled={syncing}>{syncing ? '...' : '↻ SPRINT SYNC'}</Btn>
|
||||
</div>
|
||||
</div>
|
||||
<Toolbar>
|
||||
<ToolbarGroup>
|
||||
<PageTitle>프로젝트</PageTitle>
|
||||
<FilterGroup>
|
||||
{([
|
||||
{ id: 'all', label: '전체' },
|
||||
{ id: 'public', label: 'public' },
|
||||
{ id: 'private', label: 'private' },
|
||||
] as const).map((item) => (
|
||||
<BtnToggle key={item.id} $active={visibility === item.id} onClick={() => setVisibility(item.id)}>
|
||||
{item.label}
|
||||
</BtnToggle>
|
||||
))}
|
||||
</FilterGroup>
|
||||
</ToolbarGroup>
|
||||
|
||||
{loading ? <EmptyState>LOADING...</EmptyState> : projects.length === 0 ? <EmptyState>NO PROJECTS REGISTERED</EmptyState> : (
|
||||
<ToolbarGroup>
|
||||
{syncResult && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)' }}>
|
||||
{syncResult}
|
||||
</span>
|
||||
)}
|
||||
<LabelMeta>VOL: {String(filteredProjects.length).padStart(2, '0')}</LabelMeta>
|
||||
<Btn onClick={() => runSync('/api/admin/gitea/sync', 'gitea sync')} disabled={syncing}>
|
||||
{syncing ? 'SYNCING...' : '↻ GITEA SYNC'}
|
||||
</Btn>
|
||||
<Btn onClick={() => runSync('/api/projects/sync-all-sprints', 'sprint sync')} disabled={syncing}>
|
||||
{syncing ? '...' : '↻ SPRINT SYNC'}
|
||||
</Btn>
|
||||
</ToolbarGroup>
|
||||
</Toolbar>
|
||||
|
||||
{loading ? (
|
||||
<EmptyState>LOADING...</EmptyState>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<EmptyState>NO PROJECTS</EmptyState>
|
||||
) : (
|
||||
<ProjectList>
|
||||
{projects.map((project) => (
|
||||
{filteredProjects.map((project) => (
|
||||
<ProjectRow key={project.id} href={`/projects/${project.id}`}>
|
||||
<Left>
|
||||
<Meta>P-{String(project.id).padStart(3, '0')}</Meta>
|
||||
<ProjectName>{project.name}</ProjectName>
|
||||
<ProjectDesc>{project.description ?? '설명 없음'}</ProjectDesc>
|
||||
</Left>
|
||||
|
||||
<Middle>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)', flexWrap: 'wrap' }}>
|
||||
<MetaRow>
|
||||
<PhaseBadge $phase={project.phase}>{project.phase}</PhaseBadge>
|
||||
<Meta>{project.currentSprint ? `SPRINT ${project.currentSprint}` : 'PLANNING BACKLOG'}</Meta>
|
||||
{project.latestHotfix && <Meta>HOTFIX {project.latestHotfix}</Meta>}
|
||||
</div>
|
||||
</MetaRow>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
||||
<TechBar style={{ flex: 1 }}><TechBarFill $width={project.progress ?? 0} /></TechBar>
|
||||
<TechBar style={{ flex: 1 }}>
|
||||
<TechBarFill $width={project.progress ?? 0} />
|
||||
</TechBar>
|
||||
<LabelMeta style={{ minWidth: 44, textAlign: 'right' }}>{project.progress ?? 0}%</LabelMeta>
|
||||
</div>
|
||||
</Middle>
|
||||
|
||||
<Right>
|
||||
<Meta>{qaLabel(project)}</Meta>
|
||||
<Meta>{project.deployStatus}</Meta>
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import { LabelMeta, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent, Btn } from '@/components/ui/base';
|
||||
import {
|
||||
LabelMeta,
|
||||
TechBar,
|
||||
TechBarFill,
|
||||
Timeline,
|
||||
TimelineItem,
|
||||
TimeStamp,
|
||||
TimelineContent,
|
||||
Btn,
|
||||
} from '@/components/ui/base';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
import { SISTER_ROLES } from '@/lib/sisters';
|
||||
@@ -12,28 +21,222 @@ import { adminFetch } from '@/lib/adminFetch';
|
||||
import CodeEditor from '@/components/admin/CodeEditor';
|
||||
import LogTerminal from '@/components/admin/LogTerminal';
|
||||
|
||||
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); } }`;
|
||||
const Hero = styled.section`display:grid;grid-template-columns:128px 1fr;gap:var(--space-xl);align-items:center;padding:var(--space-xl);border:1px solid var(--border-color);margin-bottom:var(--space-xl);@media (max-width:767px){grid-template-columns:1fr;justify-items:start;}`;
|
||||
const HeroMeta = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`;
|
||||
const HeroName = styled.div`font-size:36px;font-weight:700;letter-spacing:-0.03em;color:var(--text-primary);`;
|
||||
const HeroRole = styled.div`font-size:14px;color:var(--text-secondary);font-family:var(--font-mono);`;
|
||||
const HeroStatus = styled.div`display:flex;align-items:center;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap;`;
|
||||
const StatusDot = styled.div<{ $on: boolean }>`width:10px;height:10px;border-radius:50%;background:${({$on})=>$on?'#FFF':'#555'};${({$on})=>$on&&`box-shadow:0 0 8px rgba(255,255,255,0.3);`}`;
|
||||
const HeroStats = styled.div`display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--space-md);margin-top:var(--space-lg);@media (max-width:767px){grid-template-columns:repeat(2,minmax(0,1fr));}`;
|
||||
const StatCard = styled.div`border:1px solid var(--border-color);padding:var(--space-md);`;
|
||||
const StatLabel = styled.div`font-size:10px;color:var(--text-secondary);font-family:var(--font-mono);margin-bottom:6px;`;
|
||||
const StatValue = styled.div`font-size:18px;color:var(--text-primary);font-family:var(--font-mono);`;
|
||||
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 MetaCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);`;
|
||||
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 Toolbar = styled.div`display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;`;
|
||||
const Select = styled.select`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:7px;color:var(--text-primary);padding:8px 12px;font-size:13px;cursor:pointer;outline:none;option{background:#1a1f2a;}`;
|
||||
const LinesInput = styled.input`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:7px;color:var(--text-primary);padding:8px 12px;font-size:13px;width:80px;outline:none;`;
|
||||
const ResultMsg = styled.div<{ $success: boolean }>`padding:8px 12px;border-radius:6px;font-size:12px;font-family:monospace;background:${({$success})=>$success?'rgba(0,230,118,0.08)':'rgba(255,23,68,0.08)'};color:${({$success})=>$success?'#00FF00':'#FF1744'};border:1px solid ${({$success})=>$success?'rgba(0,230,118,0.3)':'rgba(255,23,68,0.3)'};margin-bottom:12px;`;
|
||||
const SessionCard = styled.div`border:1px solid var(--border-color);padding:var(--space-md);margin-bottom:var(--space-sm);`;
|
||||
const SISTER_DISPLAY: Record<string, string> = { harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이' };
|
||||
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);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Hero = styled.section`
|
||||
display: grid;
|
||||
grid-template-columns: 128px 1fr;
|
||||
gap: var(--space-xl);
|
||||
align-items: center;
|
||||
padding: var(--space-xl);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: var(--space-xl);
|
||||
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: start;
|
||||
}
|
||||
`;
|
||||
|
||||
const HeroMeta = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
`;
|
||||
|
||||
const HeroName = styled.div`
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
const HeroRole = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
const HeroStatus = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const StatusDot = styled.div<{ $on: boolean }>`
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $on }) => ($on ? '#FFF' : '#555')};
|
||||
${({ $on }) => $on && 'box-shadow:0 0 8px rgba(255,255,255,0.3);'}
|
||||
`;
|
||||
|
||||
const HeroStats = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-lg);
|
||||
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
`;
|
||||
|
||||
const StatCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-md);
|
||||
`;
|
||||
|
||||
const StatLabel = styled.div`
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const StatValue = styled.div`
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
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 MetaCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-lg);
|
||||
`;
|
||||
|
||||
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 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
`;
|
||||
|
||||
const Toolbar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Select = styled.select`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 7px;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
option {
|
||||
background: #1a1f2a;
|
||||
}
|
||||
`;
|
||||
|
||||
const LinesInput = styled.input`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 7px;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
width: 80px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const ResultMsg = styled.div<{ $success: boolean }>`
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
background: ${({ $success }) => ($success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)')};
|
||||
color: ${({ $success }) => ($success ? '#00FF00' : '#FF1744')};
|
||||
border: 1px solid ${({ $success }) => ($success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)')};
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const SessionCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
`;
|
||||
|
||||
const OverviewBlock = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
padding: var(--space-lg) 0;
|
||||
`;
|
||||
|
||||
const SISTER_DISPLAY: Record<string, string> = {
|
||||
harang: '하랑이',
|
||||
narang: '나랑이',
|
||||
darang: '다랑이',
|
||||
erang: '이랑이',
|
||||
};
|
||||
|
||||
const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
|
||||
|
||||
interface SisterInfo {
|
||||
@@ -54,6 +257,8 @@ interface SystemInfo {
|
||||
|
||||
interface ConfigData {
|
||||
description?: string | null;
|
||||
role?: string;
|
||||
configAvailable?: boolean;
|
||||
}
|
||||
|
||||
interface ActivityItem {
|
||||
@@ -74,11 +279,6 @@ interface SessionItem {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface SessionsResponse {
|
||||
sessions?: SessionItem[];
|
||||
items?: SessionItem[];
|
||||
}
|
||||
|
||||
interface HarnessResponse {
|
||||
content?: string;
|
||||
}
|
||||
@@ -108,6 +308,8 @@ export default function SisterDetailPage() {
|
||||
const [content, setContent] = useState('');
|
||||
const [saved, setSaved] = useState('');
|
||||
const [saveResult, setSaveResult] = useState<{ success: boolean; msg: string } | null>(null);
|
||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||
const [activityError, setActivityError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
@@ -116,46 +318,80 @@ export default function SisterDetailPage() {
|
||||
fetch(`${API_URL}/api/sisters/${name}/activity`),
|
||||
fetch(`${API_URL}/api/sisters/${name}/system`),
|
||||
fetch(`${API_URL}/api/sisters/${name}/sessions`),
|
||||
]).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 (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then((data: ConfigData) => setConfigData(data));
|
||||
if (aRes.status === 'fulfilled' && aRes.value.ok) aRes.value.json().then((d: ActivityResponse) => setActivity(d.items ?? []));
|
||||
if (syRes.status === 'fulfilled' && syRes.value.ok) syRes.value.json().then((data: SystemInfo) => setSystemInfo(data));
|
||||
if (ssRes.status === 'fulfilled' && ssRes.value.ok) ssRes.value.json().then((d: SessionsResponse) => setSessions(d.sessions ?? d.items ?? []));
|
||||
}).finally(() => setLoading(false));
|
||||
])
|
||||
.then(async ([sRes, cRes, aRes, syRes, ssRes]) => {
|
||||
if (sRes.status === 'fulfilled' && sRes.value.ok) {
|
||||
const sisters = (await sRes.value.json()) as SisterInfo[];
|
||||
setSisterInfo(sisters.find((s) => s.name === name) ?? null);
|
||||
}
|
||||
if (cRes.status === 'fulfilled' && cRes.value.ok) {
|
||||
setConfigData(await cRes.value.json());
|
||||
}
|
||||
if (aRes.status === 'fulfilled' && aRes.value.ok) {
|
||||
const data = (await aRes.value.json()) as ActivityResponse;
|
||||
setActivity(data.items ?? []);
|
||||
} else {
|
||||
setActivityError('활동 조회 실패');
|
||||
}
|
||||
if (syRes.status === 'fulfilled' && syRes.value.ok) {
|
||||
setSystemInfo(await syRes.value.json());
|
||||
}
|
||||
if (ssRes.status === 'fulfilled' && ssRes.value.ok) {
|
||||
const raw = (await ssRes.value.json()) as SessionItem[] | { items?: SessionItem[]; sessions?: SessionItem[] };
|
||||
const items = Array.isArray(raw) ? raw : raw.sessions ?? raw.items ?? [];
|
||||
setSessions(items);
|
||||
} else {
|
||||
setSessionError('세션 조회 실패');
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [name]);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLogLoading(true);
|
||||
try {
|
||||
const res = await adminFetch(`/api/admin/logs/${name}?lines=${lines}`);
|
||||
const d: LogResponse = await res.json();
|
||||
setLogLines(d.lines ?? []);
|
||||
const data = (await res.json()) as LogResponse;
|
||||
setLogLines(data.lines ?? ['(로그 없음)']);
|
||||
} catch {
|
||||
setLogLines(['(로그 로드 실패)']);
|
||||
setLogLines(['(로그 조회 실패)']);
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
}, [name, lines]);
|
||||
|
||||
useEffect(() => { if (tab === 'logs') fetchLogs(); }, [tab, fetchLogs]);
|
||||
useEffect(() => {
|
||||
if (tab === 'logs') {
|
||||
void fetchLogs();
|
||||
}
|
||||
}, [tab, fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'harness') return;
|
||||
setSaveResult(null);
|
||||
adminFetch(`/api/admin/harness/${name}/${selectedFile}`)
|
||||
.then((r) => r.json())
|
||||
.then((d: HarnessResponse) => { setContent(d.content ?? ''); setSaved(d.content ?? ''); })
|
||||
.then((d: HarnessResponse) => {
|
||||
setContent(d.content ?? '');
|
||||
setSaved(d.content ?? '');
|
||||
})
|
||||
.catch(() => setContent('(로드 실패)'));
|
||||
}, [tab, name, selectedFile]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveResult(null);
|
||||
try {
|
||||
const res = await adminFetch(`/api/admin/harness/${name}/${selectedFile}`, { method: 'PUT', body: JSON.stringify({ content }) });
|
||||
const d: SaveHarnessResponse = await res.json();
|
||||
if (d.success) { setSaved(content); setSaveResult({ success: true, msg: '저장 완료' }); }
|
||||
else setSaveResult({ success: false, msg: d.error ?? '저장 실패' });
|
||||
const res = await adminFetch(`/api/admin/harness/${name}/${selectedFile}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
const d = (await res.json()) as SaveHarnessResponse;
|
||||
if (d.success) {
|
||||
setSaved(content);
|
||||
setSaveResult({ success: true, msg: '저장 완료' });
|
||||
} else {
|
||||
setSaveResult({ success: false, msg: d.error ?? '저장 실패' });
|
||||
}
|
||||
} catch (e) {
|
||||
setSaveResult({ success: false, msg: (e as Error).message });
|
||||
}
|
||||
@@ -165,54 +401,200 @@ export default function SisterDetailPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb><Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}</Breadcrumb>
|
||||
{loading ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div> : <>
|
||||
<Hero>
|
||||
<SisterAvatar name={name} size={120} />
|
||||
<HeroMeta>
|
||||
<HeroName>{SISTER_DISPLAY[name] ?? name}</HeroName>
|
||||
<HeroRole>{SISTER_ROLES[name] ?? 'UNKNOWN'}</HeroRole>
|
||||
<HeroStatus>
|
||||
<StatusDot $on={isActive} />
|
||||
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
|
||||
<LabelMeta><span>LXC:</span>{sisterInfo?.lxcId ?? '--'}</LabelMeta>
|
||||
</HeroStatus>
|
||||
<HeroStats>
|
||||
<StatCard><StatLabel>UPTIME</StatLabel><StatValue>{systemInfo?.uptime ?? '--:--:--'}</StatValue></StatCard>
|
||||
<StatCard><StatLabel>CPU</StatLabel><StatValue>{systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}%</StatValue></StatCard>
|
||||
<StatCard><StatLabel>MEM</StatLabel><StatValue>{systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB</StatValue></StatCard>
|
||||
<StatCard><StatLabel>DISK</StatLabel><StatValue>{systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'}</StatValue></StatCard>
|
||||
</HeroStats>
|
||||
</HeroMeta>
|
||||
</Hero>
|
||||
<Breadcrumb>
|
||||
<Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}
|
||||
</Breadcrumb>
|
||||
{loading ? (
|
||||
<EmptyState>LOADING...</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<Hero>
|
||||
<SisterAvatar name={name} size={120} />
|
||||
<HeroMeta>
|
||||
<HeroName>{SISTER_DISPLAY[name] ?? name}</HeroName>
|
||||
<HeroRole>{SISTER_ROLES[name] ?? configData?.role ?? 'UNKNOWN'}</HeroRole>
|
||||
<HeroStatus>
|
||||
<StatusDot $on={isActive} />
|
||||
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
|
||||
<LabelMeta>
|
||||
<span>LXC:</span>
|
||||
{sisterInfo?.lxcId ?? '--'}
|
||||
</LabelMeta>
|
||||
</HeroStatus>
|
||||
<HeroStats>
|
||||
<StatCard>
|
||||
<StatLabel>UPTIME</StatLabel>
|
||||
<StatValue>{systemInfo?.uptime ?? '--:--:--'}</StatValue>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatLabel>CPU</StatLabel>
|
||||
<StatValue>{systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}%</StatValue>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatLabel>MEM</StatLabel>
|
||||
<StatValue>
|
||||
{systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB
|
||||
</StatValue>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatLabel>DISK</StatLabel>
|
||||
<StatValue>
|
||||
{systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'}
|
||||
</StatValue>
|
||||
</StatCard>
|
||||
</HeroStats>
|
||||
</HeroMeta>
|
||||
</Hero>
|
||||
|
||||
<DetailGrid>
|
||||
<MetaPanel>
|
||||
<MetaCard>
|
||||
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta>
|
||||
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
|
||||
ROLE: {SISTER_ROLES[name] ?? '--'}<br />
|
||||
STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()}<br />
|
||||
HOST_USER: {sisterInfo?.user ?? '--'}
|
||||
</div>
|
||||
<TechBar><TechBarFill $width={isActive ? 100 : 0} /></TechBar>
|
||||
</MetaCard>
|
||||
{configData?.description && <MetaCard><LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta><div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>{configData.description}</div></MetaCard>}
|
||||
</MetaPanel>
|
||||
<div>
|
||||
<TabBar>
|
||||
{[{ id: 'overview', label: '개요' }, { id: 'sessions', label: '세션' }, { id: 'activity', label: '활동' }, { id: 'harness', label: '하네스' }, { id: 'logs', label: '로그' }].map((t) => <TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>{t.label}</TabBtn>)}
|
||||
</TabBar>
|
||||
<DetailGrid>
|
||||
<MetaPanel>
|
||||
<MetaCard>
|
||||
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta>
|
||||
<div
|
||||
style={{
|
||||
margin: 'var(--space-md) 0',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}
|
||||
<br />
|
||||
ROLE: {SISTER_ROLES[name] ?? '--'}
|
||||
<br />
|
||||
STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()}
|
||||
<br />
|
||||
HOST_USER: {sisterInfo?.user ?? '--'}
|
||||
</div>
|
||||
<TechBar>
|
||||
<TechBarFill $width={isActive ? 100 : 0} />
|
||||
</TechBar>
|
||||
</MetaCard>
|
||||
<MetaCard>
|
||||
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>OVERVIEW</LabelMeta>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||
{configData?.description ?? '설명 없음'}
|
||||
</div>
|
||||
</MetaCard>
|
||||
</MetaPanel>
|
||||
|
||||
{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 === '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 === '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} /></>}
|
||||
</div>
|
||||
</DetailGrid>
|
||||
</>}
|
||||
<div>
|
||||
<TabBar>
|
||||
{[
|
||||
{ id: 'overview', label: '개요' },
|
||||
{ id: 'sessions', label: '세션' },
|
||||
{ id: 'activity', label: '활동' },
|
||||
{ id: 'harness', label: '하네스' },
|
||||
{ id: 'logs', label: '로그' },
|
||||
].map((item) => (
|
||||
<TabBtn key={item.id} $active={tab === item.id} onClick={() => setTab(item.id)}>
|
||||
{item.label}
|
||||
</TabBtn>
|
||||
))}
|
||||
</TabBar>
|
||||
|
||||
{tab === 'overview' && (
|
||||
<OverviewBlock>
|
||||
<LabelMeta>OVERVIEW</LabelMeta>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.7 }}>
|
||||
{configData?.description ?? '설명 없음'}
|
||||
</div>
|
||||
<MetaCard style={{ padding: '12px', marginTop: '4px' }}>
|
||||
<LabelMeta style={{ display: 'block', marginBottom: '6px' }}>CONFIG STATUS</LabelMeta>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||
{configData?.configAvailable ? '설정 파일 확인됨' : '설정 파일 없음 또는 조회 불가'}
|
||||
</div>
|
||||
</MetaCard>
|
||||
</OverviewBlock>
|
||||
)}
|
||||
|
||||
{tab === 'sessions' && (
|
||||
<>
|
||||
{sessionError ? (
|
||||
<EmptyState>{sessionError}</EmptyState>
|
||||
) : sessions.length === 0 ? (
|
||||
<EmptyState>세션 없음</EmptyState>
|
||||
) : (
|
||||
sessions.map((session, index) => (
|
||||
<SessionCard key={session.id ?? index}>
|
||||
<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 === 'activity' && (
|
||||
<>
|
||||
{activityError ? (
|
||||
<EmptyState>{activityError}</EmptyState>
|
||||
) : activity.length === 0 ? (
|
||||
<EmptyState>활동 없음</EmptyState>
|
||||
) : (
|
||||
<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((file) => (
|
||||
<option key={file} value={file}>
|
||||
{file}
|
||||
</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={() => void fetchLogs()}>{logLoading ? '로딩 중...' : '로그 가져오기'}</Btn>
|
||||
</Toolbar>
|
||||
<LogTerminal lines={logLines} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DetailGrid>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ const Summary = styled.p`
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
max-width: 780px;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const HeaderStats = styled.div`
|
||||
@@ -248,7 +249,7 @@ export default function ActivePipeline({
|
||||
<PipelineCard>
|
||||
<Header>
|
||||
<div>
|
||||
<Eyebrow>Master flow / active pipeline</Eyebrow>
|
||||
<Eyebrow>Active pipeline / snapshot</Eyebrow>
|
||||
<Title>{activeTask}</Title>
|
||||
<Summary>{focus}</Summary>
|
||||
</div>
|
||||
@@ -282,7 +283,7 @@ export default function ActivePipeline({
|
||||
<StateBadge $state={node.state}>{stateText[node.state]}</StateBadge>
|
||||
</NodeTop>
|
||||
<NodeDetail>{node.detail}</NodeDetail>
|
||||
<NodeHint>stage {String(index + 1).padStart(2, '0')}</NodeHint>
|
||||
<NodeHint>snapshot {String(index + 1).padStart(2, '0')}</NodeHint>
|
||||
</NodeCard>
|
||||
{index < nodes.length - 1 && <Connector $active={connectorActive} />}
|
||||
</NodeWrap>
|
||||
|
||||
Reference in New Issue
Block a user