Compare commits

..

8 Commits

17 changed files with 1857 additions and 441 deletions

View File

@@ -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`

View File

@@ -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 없어도 핵심 정보가 읽혀야 함

View 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
View 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` 표현이 제거됨
- 자기야가 봤을 때 `실시간 아닌데 실시간처럼 보인다`는 느낌이 사라짐

View 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
View 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 + 모바일 포함 확인 필수

View File

@@ -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,
];

View File

@@ -12,6 +12,7 @@ export interface GiteaRepo {
open_issues_count: number;
open_pr_counter: number;
updated_at: string;
private?: boolean;
}
export interface GiteaPR {

View File

@@ -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')

View File

@@ -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(

View File

@@ -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 () => {

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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>
</>
)}
</>
);
}

View File

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