Compare commits
2 Commits
feature/re
...
feature/sp
| Author | SHA1 | Date | |
|---|---|---|---|
| 16270e2661 | |||
| 92b33035b0 |
@@ -1 +0,0 @@
|
||||
1775812935
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,4 +44,3 @@ coverage/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
.claude/state/
|
||||
|
||||
@@ -1,70 +1,62 @@
|
||||
# 하나랑 대시보드 — 실행 개요
|
||||
|
||||
## 프로젝트 목표
|
||||
- 4자매 운영 상태를 실시간으로 보여준다
|
||||
- 프로젝트/Sprint/Hotfix/QA/Deploy 흐름을 시각화한다
|
||||
- `main`이 항상 배포 가능 상태라는 원칙을 UI와 운영에 함께 반영한다
|
||||
- 오피스 화면과 운영 패널이 분리되지 않고 하나의 관제 경험으로 이어지게 만든다
|
||||
- 4자매 운영 상태를 실제 source-of-truth 기준으로 보여준다
|
||||
- 프로젝트, Sprint, Hotfix, QA, Deploy 흐름을 한 운영 정보 구조로 연결한다
|
||||
- `main`이 항상 배포 가능 상태라는 원칙을 UI와 운영 문서에 같이 반영한다
|
||||
- `/office`, `/`, `/projects/[id]`, `/org`가 서로 다른 역할을 가지되 같은 상태 계약을 공유하게 만든다
|
||||
|
||||
## 저장소 / Git 기준
|
||||
- Repo: `hanarang-dashboard`
|
||||
- Git URL: `https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard`
|
||||
- 기본 브랜치: `main`
|
||||
- 현재 오피스 구현 기준 경로:
|
||||
- 현재 구현 기준 경로:
|
||||
- `frontend/app/office/page.tsx`
|
||||
- `frontend/components/office/OfficeScene.tsx`
|
||||
- `frontend/components/office/ContextPanel.tsx`
|
||||
- `frontend/components/office/ChatWorkspace.tsx`
|
||||
- `frontend/components/office/PipelinePanel.tsx`
|
||||
- `frontend/components/office/ServerHealthPanel.tsx`
|
||||
|
||||
## 현재 표준 구조
|
||||
- 루트: `README.md`, `ARCHITECTURE.md`
|
||||
- 실행 문서: `.plans/`
|
||||
- `design/`
|
||||
- `sprints/`
|
||||
- `hotfix/`
|
||||
- `qa/`
|
||||
- `deploy/`
|
||||
- 장기 문서: `docs/`
|
||||
- `frontend/app/page.tsx`
|
||||
- `frontend/app/projects/[id]/page.tsx`
|
||||
- `frontend/app/org/page.tsx`
|
||||
- `backend/src/sisters/sisters.service.ts`
|
||||
- `backend/src/sisters/sister-detail.service.ts`
|
||||
- `backend/src/dashboard/dashboard.service.ts`
|
||||
|
||||
## 현재 상태
|
||||
- **SPRINT-016**: `/office` 기본 화면과 핵심 컴포넌트가 `main`에 반영됨
|
||||
- **다음 활성 작업**: **SPRINT-017** `/office` 모바일 화면 전면 개편
|
||||
- **SPRINT-017**: `/office` 모바일 IA와 direct chat 중심 재배치 범위를 문서로 잠금
|
||||
- **HOTFIX-006**: 메인 대시보드 문구 절제와 source/freshness 정합성 보정 범위를 잠금
|
||||
- **다음 활성 작업**: **SPRINT-018** 대시보드 truth redesign
|
||||
|
||||
## SPRINT-017 active scope
|
||||
이번 Sprint는 안정화 전반이 아니라 **`/office` 모바일 정보 구조 재설계**에만 집중해.
|
||||
## SPRINT-018 active scope
|
||||
이번 Sprint는 단순 모바일 정리가 아니라, `/office`, `/`, `/projects/[id]`, `/org`를 **같은 운영 사실 계약**으로 다시 잠그는 작업이야.
|
||||
|
||||
### 이번에 반드시 해결할 것
|
||||
1. `360px`, `390px` 첫 viewport에서 아래 4가지를 한 번에 읽히게 만든다.
|
||||
- 4자매 상태
|
||||
- current focus
|
||||
- health summary
|
||||
- quick actions
|
||||
2. `mobile(<768)`에서는 데스크톱 씬 축소판을 금지하고, 모바일 전용 세로 흐름 IA로 바꾼다.
|
||||
3. `PipelinePanel`과 `ServerHealthPanel`을 가로 스크롤 없이 읽히는 카드 흐름으로 바꾼다.
|
||||
4. `ChatWorkspace`를 모바일 direct chat 기준으로 다시 정리하고, `JWT 없음 / empty / error / runtime 확인 중` 상태를 즉시 읽히게 만든다.
|
||||
5. `live / snapshot / fallback` 의미는 유지하되, 모바일에서 더 짧고 일관된 라벨로 통일한다.
|
||||
1. 페이지 역할을 고정한다.
|
||||
- `/office`: live operations workspace
|
||||
- `/`: executive snapshot
|
||||
- `/projects/[id]`: delivery detail
|
||||
- `/org`: company org map
|
||||
2. 공통 용어를 고정한다.
|
||||
- `connection`, `activity`, `working`, `owner`, `participant`, `assignee`, `freshness`, `source`
|
||||
3. `working`, `owner`, `participant`를 추정치와 확정치를 섞지 않게 다시 정의한다.
|
||||
4. `fallback`, `snapshot`, `live`가 같은 사실처럼 보이지 않게 규칙을 문서화한다.
|
||||
5. `360px`, `390px` 기준으로 긴 문장, 긴 badge, 카드 overflow, horizontal scroll을 금지한다.
|
||||
6. `/office`는 기존 office group mental model 장점을 살리되, 4개 긴 박스 분해형이 아니라 group/scene/context 중심으로 재배치한다.
|
||||
7. `/projects/[id]`의 `ASSIGNED NODES`를 truthful participant model로 교체한다.
|
||||
8. `/org`는 자매 트리 데모가 아니라 실제 회사형 조직도 + independent agents 구조로 재설계한다.
|
||||
|
||||
### 이번에 하지 않을 것
|
||||
- 새로운 백엔드 API 추가
|
||||
- ACP harness 자체 구조 변경
|
||||
- WebSocket 프로토콜 재설계
|
||||
- 별도 모바일 앱 설계
|
||||
- 3D/고해상도 오피스 씬 확장
|
||||
- 데스크톱 전체 IA 재작성
|
||||
- 실제 배포 자동화 플로우 재작성
|
||||
- 조직도용 신규 비주얼 엔진 도입
|
||||
|
||||
## 현재 main 구현에서 확인된 모바일 문제
|
||||
- `frontend/components/office/OfficeScene.tsx`
|
||||
- `aspect-ratio: 800 / 460` 고정 씬이라 모바일에서 데스크톱 축소판처럼 보임
|
||||
- `frontend/app/office/page.tsx`
|
||||
- 모바일 전용 summary hero가 없고, `ChatArea`가 `460px` 고정 높이에 의존함
|
||||
- 선택 전에는 `ContextPanel`과 chat이 핵심 정보 대신 빈 상태에 가까움
|
||||
- `frontend/components/office/ContextPanel.tsx`
|
||||
- 선택 의존 구조라 첫 진입 시 상세 정보가 비어 있음
|
||||
- `frontend/components/office/PipelinePanel.tsx`
|
||||
- `overflow-x: auto` 기반이라 모바일에서 가로 스크롤 전제가 생김
|
||||
- `frontend/components/office/ChatWorkspace.tsx`
|
||||
- 모바일에서 direct chat 맥락이 탭, 타임라인, composer, 상태 패널로 분산되고 보조 정보가 숨겨짐
|
||||
## truth redesign 원칙
|
||||
- `360px`, `390px`에서 카드와 badge는 줄바꿈되어야 하고 horizontal scroll이 없어야 한다
|
||||
- websocket 연결 여부와 데이터 최신성은 같은 뜻으로 쓰지 않는다
|
||||
- `working`은 단순 `thinking/tool_calling/speaking + gatewayConnected`의 프론트 추정치로 고정하지 않는다
|
||||
- `owner`, `participant`, `assignee`는 같은 사람 목록으로 재사용하지 않는다
|
||||
- fallback seed는 확정 owner, working, participant를 주입하지 않는다
|
||||
- null/empty 상태도 사실대로 보여준다
|
||||
|
||||
## breakpoint 기준
|
||||
- **Mobile compact:** `360px`
|
||||
@@ -72,26 +64,23 @@
|
||||
- **Tablet:** `768px`
|
||||
- **Desktop:** `1280px+`
|
||||
|
||||
## 이행 전략
|
||||
- 문서는 실제 `main` 구현 경로를 근거로만 갱신한다
|
||||
- SPRINT-017은 `mobile-first IA`와 `상태 라벨 통일`까지만 잠근다
|
||||
- 구현 작업은 `frontend/app/office/page.tsx`와 `frontend/components/office/*` 범위 안에서 끝내는 걸 기본으로 한다
|
||||
- QA는 `360 / 390 / 768 / 1280+` 실브라우저 확인을 기준으로 남긴다
|
||||
|
||||
## 문서 맵
|
||||
- 구조 기준: `../ARCHITECTURE.md`
|
||||
- 제품 PRD: `../docs/product-specs/openclaw-office-dashboard-prd.md`
|
||||
- 디자인 인덱스: `./design/index.md`
|
||||
- Sprint 016 비전: `./sprints/SPRINT-016.md`
|
||||
- Sprint 017 실행 계획: `./sprints/SPRINT-017.md`
|
||||
- 오피스 모바일 IA: `./design/ui/office-dashboard-design.md`
|
||||
- 오피스 direct chat 모바일 기준: `./design/ui/office-chat-design.md`
|
||||
- API / 실시간 모델 참고: `./design/api-design.md`
|
||||
- 배포 플로우: `./deploy/main-release-flow.md`
|
||||
- Sprint 017: `./sprints/SPRINT-017.md`
|
||||
- Sprint 018: `./sprints/SPRINT-018.md`
|
||||
- HOTFIX-006: `./hotfix/HOTFIX-006.md`
|
||||
- API / truth contract: `./design/api-design.md`
|
||||
- `/office` UI: `./design/ui/office-dashboard-design.md`
|
||||
- `/office` chat 기준: `./design/ui/office-chat-design.md`
|
||||
- `/` UI: `./design/ui/dashboard-design.md`
|
||||
- `/projects/[id]` UI: `./design/ui/project-detail-design.md`
|
||||
- `/org` UI: `./design/ui/org-design.md`
|
||||
- QA draft: `./qa/SPRINT-018-review-1.md`
|
||||
|
||||
## 교차 참조 규칙
|
||||
- Sprint 문서는 관련 design 문서를 반드시 링크한다
|
||||
- design 문서는 실제 Git 구현 경로와 breakpoint를 같이 적는다
|
||||
- QA 문서는 `360 / 390 / 768 / 1280+` 결과를 나눠 기록한다
|
||||
- `live / snapshot / fallback` 용어는 Sprint 문서와 UI 문서에서 동일하게 쓴다
|
||||
- 여기까지가 SPRINT-017 기준 scope야.
|
||||
- Sprint 문서는 관련 design, QA, hotfix 문서를 반드시 링크한다
|
||||
- design 문서는 실제 구현 파일 경로와 breakpoint를 함께 적는다
|
||||
- API, UI, QA 문서에서 `working / owner / participant / source / freshness` 의미를 동일하게 쓴다
|
||||
- `live / mirrored event / snapshot / doc-derived / fallback` source 라벨은 페이지 간 동일하게 쓴다
|
||||
- 여기까지가 SPRINT-018 기준 scope야.
|
||||
|
||||
@@ -1,178 +1,280 @@
|
||||
# API 설계
|
||||
# API 설계 — SPRINT-018 truth contract
|
||||
|
||||
## 목적
|
||||
SPRINT-018 기준 API 문서는 `/office`, `/`, `/projects/[id]`, `/org`가 같은 운영 사실 계약을 공유하도록 잠그는 문서야. 기존 SPRINT-017의 `REST snapshot + WebSocket push` 모델은 유지하지만, 이번에는 `connection`, `activity`, `working`, `freshness`, `source`를 분리해서 정의한다.
|
||||
|
||||
## 참고 문서
|
||||
- 실행 개요: `../OVERVIEW.md`
|
||||
- Sprint: `../sprints/SPRINT-018.md`
|
||||
- HOTFIX-006: `../hotfix/HOTFIX-006.md`
|
||||
- UI 문서:
|
||||
- `./ui/office-dashboard-design.md`
|
||||
- `./ui/dashboard-design.md`
|
||||
- `./ui/project-detail-design.md`
|
||||
- `./ui/org-design.md`
|
||||
|
||||
## 공통 규칙
|
||||
- Base URL: `https://hanarang-api.nabomhalang.co.kr`
|
||||
- 응답 형식: JSON
|
||||
- WS Namespace: `/ws`
|
||||
- 기본 에러 형식: `{ "statusCode": 400, "message": "...", "error": "Bad Request" }`
|
||||
- SPRINT-017 기준으로 오피스 화면은 `REST snapshot + WebSocket push` 혼합 모델을 사용한다.
|
||||
- 읽기 API와 쓰기 API 권한은 분리해서 다룬다
|
||||
|
||||
## 인증 규칙
|
||||
- 읽기 전용 상태 조회 API는 현재 공개 조회가 가능한 엔드포인트가 섞여 있어.
|
||||
- direct chat (`POST /api/sisters/:name/chat`) 은 JWT 필수야.
|
||||
- WebSocket 연결도 JWT 필수야. 토큰이 없거나 잘못되면 서버가 연결을 끊어.
|
||||
- 그래서 `/office`는 **읽기와 쓰기의 권한 상태를 분리해서** 다뤄야 해.
|
||||
|
||||
## 오피스 대시보드 핵심 소스
|
||||
- Git 구현 기준:
|
||||
- `frontend/app/office/page.tsx`
|
||||
- `backend/src/sisters/sisters.controller.ts`
|
||||
- `backend/src/events/events.gateway.ts`
|
||||
- `backend/src/events/events.scheduler.ts`
|
||||
- 관련 Sprint: `../sprints/SPRINT-017.md`
|
||||
- 관련 UI 문서:
|
||||
- `./ui/office-dashboard-design.md`
|
||||
- `./ui/office-chat-design.md`
|
||||
|
||||
## 상태 모델
|
||||
### Data Mode
|
||||
| mode | 의미 | UI 원칙 |
|
||||
## source taxonomy
|
||||
| source | 의미 | UI 규칙 |
|
||||
|---|---|---|
|
||||
| `live` | WebSocket 또는 최신 runtime 기준으로 실시간성이 유지되는 상태 | 가장 신뢰도 높은 상태로 표시 |
|
||||
| `snapshot` | REST polling 기준 최신 스냅샷 | live보다 약한 상태로 표시 |
|
||||
| `fallback` | runtime 또는 status 조회 실패 시 보여주는 보정 데이터 | 추정치임을 숨기지 않음 |
|
||||
| `live` | runtime 또는 websocket 기준 최신 상태 | 가장 강한 source |
|
||||
| `mirrored_event` | event stream을 dashboard용으로 미러링한 상태 | live처럼 과장하지 않음 |
|
||||
| `snapshot` | polling / REST 스냅샷 | 최신 시각과 같이 표시 |
|
||||
| `doc_derived` | QA/HOTFIX/문서에서 파생한 값 | 운영 기록으로 표시 |
|
||||
| `fallback` | 원본 없음, seed/default/보정 데이터 | 추정치임을 숨기지 않음 |
|
||||
|
||||
### Agent State
|
||||
| state | 의미 |
|
||||
|---|---|
|
||||
| `idle` | 대기 중 |
|
||||
| `thinking` | 작업 준비 / 추론 중 |
|
||||
| `tool_calling` | 외부 작업/도구 호출 중 |
|
||||
| `speaking` | 응답 생성 또는 대화 중 |
|
||||
| `error` | 연결 또는 런타임 이상 |
|
||||
## 용어 계약
|
||||
| 필드 | 의미 | 비고 |
|
||||
|---|---|---|
|
||||
| `connection` | 연결 상태 | socket/gateway/runtime 가능 여부 |
|
||||
| `activity` | 최근 활동 상태 | event/session/task evidence |
|
||||
| `working` | 명시적 운영 상태 | 추정치와 확정치를 분리 |
|
||||
| `freshness` | 생성/동기화 시각 | source와 별도 필드 |
|
||||
| `source` | 값의 출처 | field-level 또는 section-level |
|
||||
|
||||
## Sisters (오피스 화면 기준)
|
||||
## 공통 view-model 규칙
|
||||
### 1. 연결과 활동을 분리한다
|
||||
- `connection.connected=true`여도 `activity.state='idle'`일 수 있다
|
||||
- `connection.connected=false`여도 마지막 `snapshot`은 남아 있을 수 있다
|
||||
|
||||
| Method | Endpoint | 인증 | 설명 |
|
||||
|--------|----------|------|------|
|
||||
| GET | `/api/sisters` | 없음 | 4자매 상태 목록 |
|
||||
| GET | `/api/sisters/runtime` | 없음 | 4자매 runtime 스냅샷 |
|
||||
| GET | `/api/sisters/:name/runtime` | 없음 | 개별 자매 runtime |
|
||||
| GET | `/api/sisters/:name/system` | 없음 | 개별 자매 시스템 정보 |
|
||||
| GET | `/api/sisters/:name/avatar` | 없음 | 자매 아바타 이미지 |
|
||||
| GET | `/api/sisters/:name/config` | 없음 | openclaw 설정 조회 |
|
||||
| GET | `/api/sisters/:name/sessions` | 없음 | 최근 세션 목록 |
|
||||
| GET | `/api/sisters/:name/subagents` | 없음 | 서브에이전트 목록/현황 |
|
||||
| GET | `/api/sisters/:name/activity` | 없음 | 최근 활동 로그 |
|
||||
| POST | `/api/sisters/:name/chat` | JWT 필요 | direct chat 전송 |
|
||||
### 2. `working`은 별도 필드다
|
||||
`working`은 단순히 `thinking/tool_calling/speaking + gatewayConnected` 조합으로 확정하지 않는다.
|
||||
|
||||
### GET `/api/sisters/runtime`
|
||||
오피스 메인 화면의 상단 상태와 최근 메시지, 서브에이전트 상태를 구성하는 runtime source야.
|
||||
권장 형태:
|
||||
```json
|
||||
{
|
||||
"working": {
|
||||
"state": "active",
|
||||
"confidence": "confirmed",
|
||||
"reason": "active_session",
|
||||
"label": "PR review in progress"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
예시 필드:
|
||||
### 3. fallback은 확정 owner/participant를 주입하지 않는다
|
||||
- fallback은 `owner: null`, `participants: []`를 허용한다
|
||||
- UI는 empty 상태를 그대로 렌더링해야 한다
|
||||
|
||||
### 4. freshness는 최소 2종 이상 준다
|
||||
- `generatedAt`
|
||||
- `lastActiveAt` 또는 `latestEventAt`
|
||||
- 필요 시 `lastSnapshotAt`, `lastDocAt`
|
||||
|
||||
## Sisters / Office contract
|
||||
|
||||
### GET `/api/sisters`
|
||||
`/office`와 `/`가 함께 쓰는 snapshot source다.
|
||||
|
||||
권장 응답 shape:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "harang",
|
||||
"gatewayConnected": true,
|
||||
"mainState": "thinking",
|
||||
"currentTask": "SPRINT-017 scope 잠금",
|
||||
"activeSessionLabel": "main",
|
||||
"activeSessionUpdatedAt": 1775640000000,
|
||||
"controlSessionKey": "agent:harang:main",
|
||||
"recentMessages": [
|
||||
{
|
||||
"id": "msg_1",
|
||||
"role": "assistant",
|
||||
"content": "scope 정리 중",
|
||||
"ts": "2026-04-08T09:20:00Z"
|
||||
"role": "Orchestrator",
|
||||
"connection": {
|
||||
"gateway": "connected",
|
||||
"runtime": "connected"
|
||||
},
|
||||
"activity": {
|
||||
"state": "idle",
|
||||
"label": "No recent operator event",
|
||||
"lastActiveAt": "2026-04-09T01:20:00.000Z"
|
||||
},
|
||||
"working": {
|
||||
"state": "idle",
|
||||
"confidence": "confirmed",
|
||||
"reason": null,
|
||||
"label": null
|
||||
},
|
||||
"source": "snapshot",
|
||||
"freshness": {
|
||||
"lastSnapshotAt": "2026-04-09T01:20:10.000Z"
|
||||
}
|
||||
],
|
||||
"subagents": [
|
||||
{
|
||||
"name": "prd-writer",
|
||||
"state": "tool_calling",
|
||||
"updatedAt": 1775640000000,
|
||||
"currentTask": "SPRINT-017 작성",
|
||||
"sessionLabel": "main"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### POST `/api/sisters/:name/chat`
|
||||
```json
|
||||
// Request
|
||||
{ "message": "SPRINT-017 scope 확인해" }
|
||||
```
|
||||
### GET `/api/sisters/runtime`
|
||||
runtime 세부 source다.
|
||||
|
||||
최소 필드:
|
||||
- `gatewayConnected`
|
||||
- `mainState`
|
||||
- `currentTask`
|
||||
- `activeSessionLabel`
|
||||
- `activeSessionUpdatedAt`
|
||||
- `recentMessages`
|
||||
- `subagents`
|
||||
- `source`
|
||||
- `freshness`
|
||||
|
||||
### sister state mapping 규칙
|
||||
| UI 상태 | API 판단 기준 |
|
||||
|---|---|
|
||||
| `connected idle` | `connection.connected=true` + `working.state='idle'` |
|
||||
| `active working` | `working.state='active'` + reason 존재 |
|
||||
| `snapshot only` | `source='snapshot'` + live connection 없음 |
|
||||
| `fallback only` | `source='fallback'` |
|
||||
| `offline` | `connection.runtime='disconnected'` + snapshot도 stale |
|
||||
|
||||
## Dashboard ops contract
|
||||
|
||||
### GET `/api/dashboard/ops`
|
||||
`/`와 `/office` 상단 summary가 같이 보는 executive contract다.
|
||||
|
||||
최소 필드:
|
||||
```json
|
||||
// Response 200 example
|
||||
{
|
||||
"ok": true,
|
||||
"queued": true,
|
||||
"sessionKey": "agent:harang:main"
|
||||
"focusProject": {
|
||||
"name": "hanarang-dashboard",
|
||||
"phase": "IMPLEMENT",
|
||||
"progress": 62,
|
||||
"deployStatus": "READY FOR DEPLOY",
|
||||
"owner": {
|
||||
"name": null,
|
||||
"source": "snapshot",
|
||||
"whyVisible": null
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
"activeTask": "SPRINT-018",
|
||||
"focus": "dashboard truth redesign",
|
||||
"nodes": [],
|
||||
"source": "mirrored_event",
|
||||
"freshness": {
|
||||
"generatedAt": "2026-04-09T01:20:10.000Z",
|
||||
"latestEventAt": "2026-04-09T01:18:00.000Z"
|
||||
}
|
||||
},
|
||||
"board": [],
|
||||
"freshness": {
|
||||
"generatedAt": "2026-04-09T01:20:10.000Z",
|
||||
"activityLatestAt": "2026-04-09T01:18:00.000Z",
|
||||
"sistersLatestAt": "2026-04-09T01:20:00.000Z",
|
||||
"qaDocLatestAt": "2026-04-09T00:55:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Chat 실패 처리 원칙
|
||||
- JWT 없음 → 입력창 비활성화 또는 전송 실패 이유 명시
|
||||
- timeout → 전송은 재시도 가능 상태로 남김
|
||||
- 최근 메시지 없음 → empty state 문구 사용
|
||||
- tool 메시지와 assistant 메시지는 같은 bubble로 합치지 않음
|
||||
### dashboard 계약 규칙
|
||||
- `socket on/off`는 connection badge로만 쓴다
|
||||
- `freshness` badge는 별도로 보여준다
|
||||
- `owner`가 없으면 `null` 유지, fallback sister 고정 노출 금지
|
||||
- `focus`가 recent activity 기반 추정이면 `source='mirrored_event'` 또는 `snapshot`으로 표기한다
|
||||
|
||||
## WebSocket
|
||||
## Project detail contract
|
||||
|
||||
### 연결
|
||||
- Namespace: `/ws`
|
||||
- 인증 방식:
|
||||
- `handshake.auth.token`
|
||||
- 또는 `Authorization: Bearer <token>`
|
||||
- 토큰 없음/검증 실패 시 disconnect
|
||||
### GET `/api/projects/:id`
|
||||
프로젝트 상세 응답은 participant와 assignee를 분리한다.
|
||||
|
||||
### 서버 이벤트
|
||||
| Event | Payload | 설명 |
|
||||
|---|---|---|
|
||||
| `pong` | `{ ts }` | ping 응답 |
|
||||
| `sisters:update` | `{ sisters, ts }` | 4자매 상태 push |
|
||||
| `activity:new` | `{ item, ts }` | 새 활동 로그 push |
|
||||
권장 shape:
|
||||
```json
|
||||
{
|
||||
"id": 18,
|
||||
"name": "hanarang-dashboard",
|
||||
"phase": "QA",
|
||||
"participantSummary": {
|
||||
"currentOwners": [],
|
||||
"participants": [
|
||||
{
|
||||
"name": "darang",
|
||||
"whyVisible": ["reviewer", "latest_qa_author"],
|
||||
"source": "snapshot",
|
||||
"freshness": { "lastActiveAt": "2026-04-09T01:10:00.000Z" }
|
||||
}
|
||||
],
|
||||
"emptyState": "NO CONFIRMED ACTIVE PARTICIPANT"
|
||||
},
|
||||
"taskAssigneeAggregate": {
|
||||
"uniqueAssignees": ["narang", "darang"],
|
||||
"taskCountByAssignee": {
|
||||
"narang": 4,
|
||||
"darang": 1
|
||||
},
|
||||
"source": "snapshot"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 운영 규칙
|
||||
- WS는 가장 강한 source야.
|
||||
- WS가 끊겨도 마지막 성공 시각을 보존해 stale 여부를 판단해야 해.
|
||||
- scheduler polling 값이 더 오래된 경우 live 값을 덮어쓰면 안 돼.
|
||||
- SPRINT-017에서는 reconnect / stale / snapshot downgrade 규칙을 문서와 QA 기준으로 잠근다.
|
||||
### project participant 규칙
|
||||
- `participants`는 why-visible 없는 항목을 허용하지 않는다
|
||||
- `currentOwners`와 `taskAssigneeAggregate`는 별도 섹션으로 쓴다
|
||||
- 참여자 없음은 정상 상태다. `null`/empty를 숨기지 않는다
|
||||
|
||||
## Projects (프로젝트)
|
||||
## Org contract
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/projects` | 프로젝트 목록 (Gitea API 연동) |
|
||||
| GET | `/api/projects/:id` | 프로젝트 상세 + Sprint + Task |
|
||||
| GET | `/api/projects/:id/tasks` | Task Ledger |
|
||||
| GET | `/api/projects/:id/activity` | 활동 로그 |
|
||||
### GET `/api/org`
|
||||
단순 sister tree 대신 회사형 데이터 구조를 준다.
|
||||
|
||||
## Activity (활동 피드)
|
||||
권장 shape:
|
||||
```json
|
||||
{
|
||||
"orgUnits": [
|
||||
{
|
||||
"id": "hq",
|
||||
"name": "하나랑 HQ",
|
||||
"kind": "hq",
|
||||
"lead": "harang",
|
||||
"memberIds": ["harang"]
|
||||
},
|
||||
{
|
||||
"id": "delivery",
|
||||
"name": "Delivery",
|
||||
"kind": "functional_team",
|
||||
"lead": "narang",
|
||||
"memberIds": ["narang", "darang"]
|
||||
}
|
||||
],
|
||||
"members": [
|
||||
{
|
||||
"id": "harang",
|
||||
"type": "sister",
|
||||
"role": "Orchestrator",
|
||||
"status": { "connection": "connected", "working": "idle" },
|
||||
"source": "snapshot",
|
||||
"freshness": { "lastSnapshotAt": "2026-04-09T01:20:00.000Z" }
|
||||
}
|
||||
],
|
||||
"reportingLines": [
|
||||
{ "from": "hq", "to": "delivery", "kind": "manages" }
|
||||
],
|
||||
"independentAgents": [
|
||||
{
|
||||
"id": "security-auditor",
|
||||
"name": "security-auditor",
|
||||
"ownerSister": null,
|
||||
"source": "snapshot"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/activity` | 전체 최근 활동 피드 (limit, offset) |
|
||||
### org 계약 규칙
|
||||
- `independentAgents`와 `sisterOwnedAgents`를 구분한다
|
||||
- 특정 자매를 항상 root로 하드코딩하지 않는다
|
||||
- org 화면에서도 `status / source / freshness` 필드를 재사용한다
|
||||
|
||||
## Org (조직도)
|
||||
## 인증 규칙
|
||||
- 읽기 API는 현재 공개 조회와 관리자 조회가 섞여 있으므로 endpoint별로 명시한다
|
||||
- direct chat (`POST /api/sisters/:name/chat`) 은 JWT 필수
|
||||
- WebSocket 연결은 JWT 필수
|
||||
- JWT 없음 상태는 프론트에서 `send disabled reason`까지 표시해야 한다
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/org` | 조직도 데이터 (자매 + 서브에이전트 트리) |
|
||||
## QA 체크 포인트
|
||||
- `working`이 fallback seed에서 주입되지 않는지
|
||||
- `socket`과 `freshness`가 같은 label로 뭉치지 않는지
|
||||
- `owner`, `participant`, `assignee`가 서로 다른 필드로 내려오는지
|
||||
- `/api/org`가 independent agents를 누락하지 않는지
|
||||
- `360px`, `390px` UI에서 긴 source 문구가 줄바꿈되는지
|
||||
|
||||
## Admin (관리자)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/admin/harness/:sister/:file` | 하네스 파일 읽기 (AGENTS.md 등) |
|
||||
| PUT | `/api/admin/harness/:sister/:file` | 하네스 파일 수정 + SSOT push |
|
||||
| GET | `/api/admin/repos` | Gitea repo 목록 |
|
||||
| GET | `/api/admin/logs/:sister` | 세션 로그 |
|
||||
| GET | `/api/admin/costs` | 토큰 사용량/비용 |
|
||||
|
||||
## Health
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/health` | 서버 상태 확인 |
|
||||
|
||||
## SPRINT-017 문서 기준 정리
|
||||
- 오피스 화면은 읽기 API와 쓰기 API 권한을 분리해서 다룬다
|
||||
- `live / snapshot / fallback`은 API 문서, UI 문서, QA 문서에서 같은 의미로 쓴다
|
||||
- direct chat, WS disconnect, stale 상태는 정상 흐름만큼 중요하게 검증한다
|
||||
- 여기까지가 API 기준 scope야.
|
||||
## out-of-scope
|
||||
- 실제 DTO 구현 상세
|
||||
- activity evidence 저장소 개선
|
||||
- org 편집 API
|
||||
|
||||
@@ -1,239 +1,122 @@
|
||||
# 대시보드 메인 (`/`) — Sprint 015 Master Dashboard v3
|
||||
# `/` UI 기준 — SPRINT-018 executive snapshot
|
||||
|
||||
## 목적
|
||||
대시보드 메인은 단순 요약 화면이 아니라, 자기야가 들어오자마자 `지금 4자매 파이프라인이 어디까지 와 있는지` 즉시 판단할 수 있는 대표 운영 화면이어야 해.
|
||||
## 문서 목적
|
||||
메인 대시보드(`/`)를 가장 중요한 운영 변화만 짧게 읽는 **executive snapshot**으로 다시 잠근다. HOTFIX-006의 문구 절제와 source 정합성 보정을 포함하고, 이번 Sprint에서는 `socket`, `freshness`, `owner`, `source`를 오해 없이 분리한다.
|
||||
|
||||
이번 v3의 핵심은 **현행 미니멀 터미널 UI를 유지하면서, 더 강한 정보 구조와 흐름 시각화로 메인 화면을 재구성하는 것**이야.
|
||||
## 참고 문서
|
||||
- 실행 개요: `.plans/OVERVIEW.md`
|
||||
- Sprint: `.plans/sprints/SPRINT-018.md`
|
||||
- HOTFIX-006: `.plans/hotfix/HOTFIX-006.md`
|
||||
- API 설계: `.plans/design/api-design.md`
|
||||
|
||||
## 유지할 것
|
||||
- 현재 제품의 미니멀 터미널 톤
|
||||
- 짙은 배경 + 얇은 border + mono 보조 텍스트
|
||||
- 4자매 중심 세계관/역할 구분
|
||||
- 모바일 하단 탭바와 기존 페이지 동선
|
||||
## 기준 구현 파일
|
||||
- `frontend/app/page.tsx`
|
||||
- `backend/src/dashboard/dashboard.service.ts`
|
||||
- `backend/src/sisters/sisters.service.ts`
|
||||
|
||||
## 강하게 가져올 것
|
||||
- 상단 global status bar 구조
|
||||
- 4자매 카드 1행 배치
|
||||
- `ACTIVE PIPELINE` 중심 운영 다이어그램
|
||||
- `ACTIVITY FEED` + `SPRINT METRICS` 병렬 구조
|
||||
- `INFRASTRUCTURE OVERVIEW` + `MISTAKE LOG & HARNESS` 하단 운영 패널 구조
|
||||
## 페이지 역할
|
||||
`/`는 live intervention 화면이 아니야. 여기서는 운영자가 아래를 빠르게 판단해야 한다.
|
||||
- 지금 focus가 어디 있나
|
||||
- 최근 event는 언제 있었나
|
||||
- active owner가 있나 없나
|
||||
- 어떤 섹션이 live / mirrored event / snapshot / doc-derived / fallback 인가
|
||||
|
||||
## 줄일 것
|
||||
- 과한 glow
|
||||
- 장식성 애니메이션 남발
|
||||
- 의미 없이 많은 뱃지/배경 장식
|
||||
- mock 대시보드처럼 보이는 가짜 숫자 강조
|
||||
## 핵심 문제
|
||||
현재 구현에서 바로 고쳐야 하는 점:
|
||||
- `socket off`가 전체 오류처럼 읽힌다
|
||||
- snapshot freshness와 socket 연결 여부가 같은 줄에서 섞인다
|
||||
- owner가 없을 때 fallback sister가 고정 노출된다
|
||||
- focus summary와 panel copy가 길고 설명형이다
|
||||
|
||||
---
|
||||
## top bar 규칙
|
||||
### 좌측
|
||||
- `MASTER DASHBOARD` 또는 동급 짧은 label
|
||||
- focus headline 1개
|
||||
|
||||
## 전체 레이아웃
|
||||
### 1. Top Global Status Bar
|
||||
한 줄 또는 반응형 2단 구조.
|
||||
### 중앙
|
||||
- current project / current sprint / deploy state
|
||||
|
||||
#### 좌측
|
||||
- 브랜드/제품명
|
||||
- 현재 페이지 정체성 (`Master Dashboard`)
|
||||
### 우측
|
||||
- connection badge
|
||||
- freshness badge
|
||||
- active owner 또는 `NO ACTIVE OWNER`
|
||||
|
||||
#### 중앙
|
||||
- 현재 Sprint / Day / focus summary
|
||||
- 진행률 bar 또는 current cycle 요약
|
||||
## connection / freshness 분리 규칙
|
||||
| badge | 의미 | 예시 |
|
||||
|---|---|---|
|
||||
| connection | websocket/event stream 연결 상태 | `EVENT STREAM ON`, `EVENT STREAM OFF` |
|
||||
| freshness | 데이터 최신성 | `SYNC 18s AGO`, `SNAPSHOT 2m AGO` |
|
||||
| source | 값의 출처 | `MIRRORED EVENT`, `SNAPSHOT`, `DOC-DERIVED`, `FALLBACK` |
|
||||
|
||||
#### 우측
|
||||
- Discord/Runtime 연결 상태
|
||||
- 알림 진입점
|
||||
- operator/avatar 요약
|
||||
### 금지
|
||||
- `socket off` 하나만 보고 오류처럼 보이게 만들기
|
||||
- connection badge를 source badge 대신 쓰기
|
||||
- freshness 없는 `LIVE` 라벨 단독 사용
|
||||
|
||||
#### UX 기준
|
||||
- 상단에서 현재 운영 국면을 짧게 읽을 수 있어야 함
|
||||
- 모바일에서는 좌/중/우를 무리하게 한 줄에 우겨넣지 말고 2단으로 자연스럽게 접기
|
||||
## owner 표현 규칙
|
||||
- active owner는 source와 why-visible이 있을 때만 강하게 노출
|
||||
- owner가 없으면 `NO ACTIVE OWNER` 또는 `UNASSIGNED`로 표시
|
||||
- fallback sister를 기본 owner로 고정 노출하지 않는다
|
||||
- snapshot owner면 `SNAPSHOT OWNER`를 badge로 분리한다
|
||||
|
||||
---
|
||||
## information architecture
|
||||
### 1. top snapshot bar
|
||||
- focus headline
|
||||
- connection / freshness / source badges
|
||||
- active owner
|
||||
|
||||
### 2. Sister Status Cards Row
|
||||
4자매 카드는 1행 핵심 상태 영역.
|
||||
### 2. sister status row
|
||||
카드는 짧고 운영형으로 유지한다.
|
||||
|
||||
#### 공통 정보
|
||||
- 이름
|
||||
- 역할
|
||||
- 상태 (`IDLE`, `WORKING`, `REVIEWING`, `OFFLINE`, `DEPLOYING` 등)
|
||||
- CPU / RAM 또는 대응 리소스 지표
|
||||
- 현재 작업 한 줄 요약
|
||||
각 카드 최소 정보:
|
||||
- name
|
||||
- role
|
||||
- truthful state (`active working`, `connected idle`, `snapshot only`, `offline`)
|
||||
- short summary 1줄
|
||||
- source/freshness meta
|
||||
|
||||
#### 역할별 색 기준
|
||||
- Harang: amber
|
||||
- Narang: sky
|
||||
- Darang: pink
|
||||
- Irang: violet
|
||||
### 3. active pipeline
|
||||
- 실제 event mirrored인지, snapshot inference인지 표시
|
||||
- `NO ACTIVE HANDOFF`, `NO CONFIRMED REVIEW LOOP` 같은 neutral empty state 허용
|
||||
- 추정치 문장을 확정 사실처럼 쓰지 않는다
|
||||
|
||||
#### 상태 표현 규칙
|
||||
- 색은 역할 + 현재 상태를 같이 전달해야 함
|
||||
- working/reviewing처럼 active 상태일 때만 강한 강조
|
||||
- idle은 조용하게
|
||||
- offline은 회색/경고 계열로 분명히 구분
|
||||
### 4. activity feed / metrics
|
||||
- activity feed는 근거
|
||||
- metrics는 요약
|
||||
- source가 다른 값은 같은 카드 안에서 섞지 않는다
|
||||
|
||||
#### 카드 UX 기준
|
||||
- 네 카드의 높이/구조는 최대한 동일
|
||||
- 긴 텍스트는 1줄 또는 2줄 clamp
|
||||
- CPU/RAM은 숫자와 bar를 함께 보여줘도 되지만 과장하지 말 것
|
||||
- 아바타/아이콘은 보조 요소고, 상태 텍스트가 주인공이어야 함
|
||||
### 5. infra / board
|
||||
- infra: runtime/snapshot 상태
|
||||
- board: harness/qa 같은 doc-derived 또는 activity-derived 기록
|
||||
- 각 카드에 source badge를 유지한다
|
||||
|
||||
---
|
||||
## copy rules
|
||||
- 짧은 운영형 문장 우선
|
||||
- 설명문, 감상문, 구현 메타 문장 금지
|
||||
- `~정리했어`, `~보여준다` 같은 자기설명 금지
|
||||
- owner/focus/fallback 문구는 neutral tone 사용
|
||||
|
||||
### 3. `ACTIVE PIPELINE`
|
||||
이번 v3의 대표 섹션.
|
||||
권장 예시:
|
||||
- `EVENT STREAM OFF`
|
||||
- `SNAPSHOT 58s AGO`
|
||||
- `NO ACTIVE OWNER`
|
||||
- `DOC-DERIVED`
|
||||
- `NO RECENT QA`
|
||||
- `MIRRORED EVENT`
|
||||
|
||||
#### 목적
|
||||
User → Harang → Narang → Darang → Irang 흐름과, maker-checker loop, escalation 조건을 한눈에 보여준다.
|
||||
## mobile 360 / 390 rules
|
||||
- top bar chip은 wrap 허용
|
||||
- owner 영역은 avatar + label 고정폭 금지
|
||||
- focus summary는 1~2문장 대신 짧은 label + short detail 사용
|
||||
- sister card, pipeline card, board card, git/meta row에서 horizontal scroll 금지
|
||||
|
||||
#### 기본 노드
|
||||
- User
|
||||
- Harang (`Plan & Assign`)
|
||||
- Narang (`Implement` or active task id)
|
||||
- Darang (`Review / QA`)
|
||||
- Irang (`Deploy / Idle`)
|
||||
## acceptance
|
||||
- socket 연결 여부와 freshness가 별도 라벨로 보인다
|
||||
- `socket off`가 오류처럼 읽히지 않는다
|
||||
- owner가 없을 때 fallback owner 고정 노출이 사라진다
|
||||
- 카드 문구가 짧은 운영형 문장으로 정리된다
|
||||
- 주요 섹션이 `live / mirrored event / snapshot / doc-derived / fallback` 중 무엇인지 일관되게 보인다
|
||||
|
||||
#### 보여줄 정보
|
||||
- 현재 active task 또는 ticket id
|
||||
- Narang ↔ Darang review loop
|
||||
- escalation 조건 또는 누적 실패 횟수
|
||||
- Irang이 아직 idle인지, redeploy 대기인지
|
||||
|
||||
#### 구현 기준
|
||||
- SVG 기반 라인/화살표 허용
|
||||
- active state가 있을 때만 제한적 pulse/flow
|
||||
- 정적인 설명보다 실제 운영 상태가 우선
|
||||
- loop / escalation은 읽히되 과하게 복잡하면 안 됨
|
||||
|
||||
#### 금지
|
||||
- 예쁘기만 한 다이어그램
|
||||
- 현재 상태를 읽을 수 없는 장식형 선/화살표
|
||||
|
||||
---
|
||||
|
||||
### 4. Middle Row — `ACTIVITY FEED` + `SPRINT METRICS`
|
||||
운영 근거와 정량 판단을 나란히 배치한다.
|
||||
|
||||
#### A. Activity Feed
|
||||
운영 이벤트 타임라인.
|
||||
|
||||
##### 우선 정보
|
||||
- 시각
|
||||
- 주체/대상 (예: Narang → Darang)
|
||||
- 이벤트 타입 (`handoff`, `review_request`, `review_fail`, `deploy`, `hotfix` 등)
|
||||
- 관련 task/sprint/hotfix id
|
||||
- 실패/경고 시 짧은 원인
|
||||
|
||||
##### UX 기준
|
||||
- 텍스트 로그가 아니라 읽히는 이벤트 카드여야 함
|
||||
- event type 색은 통일감 있게 관리
|
||||
- 필요하면 details/JSON 결과는 접어서 보여줌
|
||||
|
||||
#### B. Sprint Metrics
|
||||
한 Sprint의 진행 체감치를 운영 지표로 압축.
|
||||
|
||||
##### 포함 후보
|
||||
- total tasks
|
||||
- done / in progress / review / planning 비중
|
||||
- sister별 completed count
|
||||
- avg iterations
|
||||
- first-pass rate
|
||||
- escalation count
|
||||
|
||||
##### UX 기준
|
||||
- 차트는 보조, 숫자 읽기가 우선
|
||||
- donut + mini bar + stat box 조합 허용
|
||||
- `활동 피드와 같은 행에서 비교`했을 때 자연스러워야 함
|
||||
|
||||
---
|
||||
|
||||
### 5. Bottom Row — `INFRASTRUCTURE OVERVIEW` + `MISTAKE LOG & HARNESS`
|
||||
운영 기반과 학습 자산을 하단에 둔다.
|
||||
|
||||
#### A. Infrastructure Overview
|
||||
##### 목적
|
||||
현재 시스템이 어디서 어떻게 돌아가는지 빠르게 파악.
|
||||
|
||||
##### 포함 요소
|
||||
- gateway
|
||||
- sisters runtime host/lxc/vm
|
||||
- deploy access path
|
||||
- health / load / uptime 요약
|
||||
|
||||
##### 표현 방식
|
||||
- 단순 카드 나열보다 연결 관계가 보이는 가벼운 맵 구조 선호
|
||||
- 다만 실제 데이터가 빈약하면 과장된 네트워크 다이어그램으로 만들지 말 것
|
||||
|
||||
#### B. Mistake Log & Harness
|
||||
##### 목적
|
||||
운영 중 쌓인 실수/규칙/학습 자산을 한 눈에 보여줌.
|
||||
|
||||
##### 포함 요소
|
||||
- 최신 mistake/harness rule 몇 개
|
||||
- 작성 시점
|
||||
- 누가 추가했는지
|
||||
- category (`DB Schema`, `Frontend`, `Deployment` 등)
|
||||
- 필요하면 작은 trend visualization
|
||||
|
||||
##### UX 기준
|
||||
- 단순 문서 링크가 아니라 운영 학습 보드처럼 보여야 함
|
||||
- `View Full` 또는 상세 문서 링크는 보조로 유지
|
||||
|
||||
---
|
||||
|
||||
## 데스크톱 정보 순서
|
||||
1. Top Global Status Bar
|
||||
2. Sister Status Cards
|
||||
3. Active Pipeline
|
||||
4. Activity Feed / Sprint Metrics
|
||||
5. Infrastructure Overview / Mistake Log & Harness
|
||||
|
||||
## 모바일 정보 순서
|
||||
1. Top Status Summary
|
||||
2. Sister Status Cards (2열 또는 1열)
|
||||
3. Active Pipeline
|
||||
4. Activity Feed
|
||||
5. Sprint Metrics
|
||||
6. Infrastructure Overview
|
||||
7. Mistake Log & Harness
|
||||
|
||||
모바일에서는 복잡한 수평 관계보다 세로 읽기 흐름이 우선이야.
|
||||
|
||||
---
|
||||
|
||||
## 데이터 연결 원칙
|
||||
- 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 없어도 핵심 정보가 읽혀야 함
|
||||
- 클릭 가능한 영역은 페이지 이동 또는 상세 패널 열기처럼 의미가 분명해야 함
|
||||
- notification / details / expand는 보조 수단
|
||||
|
||||
## 스타일 원칙
|
||||
- background는 현행 다크 톤 유지
|
||||
- border 1px 중심
|
||||
- 그림자/glow는 active state 강조용 최소치만 사용
|
||||
- 하드한 터미널 감성과 현대적 운영 패널 느낌 사이 균형 유지
|
||||
|
||||
## 연결 페이지
|
||||
- 프로젝트 페이지는 phase/QA/deploy 세부 판단용
|
||||
- 활동 페이지는 장기 로그 열람용
|
||||
- 자매 상세는 개별 runtime/세션 분석용
|
||||
- 관리 페이지는 조작/재시작/관리용
|
||||
|
||||
즉, 홈은 모든 걸 다 보여주는 곳이 아니라 **지금 어디를 눌러야 하는지 판단시키는 운영 출발점**이어야 해.
|
||||
## rollout note
|
||||
- HOTFIX-006에서 잠근 source/freshness 원칙을 홈 전체 IA에 확대 적용한다
|
||||
- `/office`와 달리 `/`는 intervention CTA보다 snapshot readability를 우선한다
|
||||
|
||||
@@ -1,207 +1,172 @@
|
||||
# 오피스 대시보드 UI 기준 — SPRINT-017 모바일 개편
|
||||
# `/office` UI 기준 — SPRINT-018 live operations workspace
|
||||
|
||||
## 문서 목적
|
||||
`/office` 메인 화면의 모바일 IA와 우선순위를 잠그는 문서야. 기준 Sprint는 `.plans/sprints/SPRINT-017.md`이고, active scope는 `.plans/OVERVIEW.md`를 따른다.
|
||||
`/office`를 4개 긴 박스 조합이 아니라 **group mental model 중심 live operations workspace**로 다시 잠그는 문서야. SPRINT-017의 mobile-first IA를 유지하되, 이번 Sprint에서는 `working`, `source`, `freshness`, `default context` 의미를 더 엄격하게 정의한다.
|
||||
|
||||
## 참고 문서
|
||||
- 실행 개요: `.plans/OVERVIEW.md`
|
||||
- Sprint: `.plans/sprints/SPRINT-018.md`
|
||||
- API 설계: `.plans/design/api-design.md`
|
||||
- 관련 chat 문서: `.plans/design/ui/office-chat-design.md`
|
||||
|
||||
## 기준 구현 파일
|
||||
- `frontend/app/office/page.tsx`
|
||||
- `frontend/components/office/OfficeScene.tsx`
|
||||
- `frontend/components/office/ContextPanel.tsx`
|
||||
- `frontend/components/office/ChatWorkspace.tsx`
|
||||
- `frontend/components/office/PipelinePanel.tsx`
|
||||
- `frontend/components/office/ServerHealthPanel.tsx`
|
||||
- 관련 chat 문서: `.plans/design/ui/office-chat-design.md`
|
||||
|
||||
## breakpoint 기준
|
||||
- `360px`: minimum mobile compact
|
||||
- `390px`: primary mobile baseline
|
||||
- `768px`: tablet transition
|
||||
- `1280px+`: desktop baseline
|
||||
## 페이지 역할
|
||||
`/office`는 executive summary가 아니라 **바로 개입하는 운영 화면**이야.
|
||||
|
||||
## 현재 main 구현에서 바꿔야 하는 점
|
||||
- `OfficeScene.tsx`는 `800 / 460` 비율 SVG 씬을 전제로 해서 모바일에서 정보보다 축소 그림이 먼저 보인다.
|
||||
- `page.tsx`는 모바일 전용 상단 summary가 없어서 첫 화면에서 핵심 운영 정보가 바로 안 잡힌다.
|
||||
- `ContextPanel.tsx`는 선택 전 빈 상태라 모바일 첫 진입에 불리하다.
|
||||
- `PipelinePanel.tsx`는 `overflow-x: auto`가 들어가 있어 모바일에서 읽기보다 옆으로 밀게 된다.
|
||||
- `ServerHealthPanel.tsx`는 카드 그리드는 있지만 상단 health summary 우선순위가 없다.
|
||||
핵심 질문:
|
||||
1. 누가 연결돼 있나
|
||||
2. 누가 실제로 working 중인가
|
||||
3. 어떤 group이 막혀 있나
|
||||
4. 지금 어디에 말 걸면 되나
|
||||
|
||||
## 모바일 핵심 원칙
|
||||
1. **데스크톱 축소판 금지**
|
||||
- 모바일은 desktop scene을 줄이는 방식이 아니라 모바일 전용 정보 구조를 쓴다.
|
||||
2. **첫 viewport 우선**
|
||||
- `360px`, `390px` 첫 화면에서 운영자가 바로 판단할 정보만 먼저 보여준다.
|
||||
3. **선택 전에도 정보가 보이게**
|
||||
- 자매를 누르기 전에도 상태, focus, health, action이 읽혀야 한다.
|
||||
4. **가로 스크롤 금지**
|
||||
- mobile에서는 모든 핵심 블록이 세로 흐름 안에서 끝나야 한다.
|
||||
5. **상태 의미 유지**
|
||||
- `live / snapshot / fallback`은 유지하되, 짧고 일관된 라벨로 통일한다.
|
||||
## 정보 구조 원칙
|
||||
1. **group mental model 유지**
|
||||
- 자매를 개별 카드 나열로만 보지 않고 `planning / delivery / review / deploy` group 흐름으로 묶는다
|
||||
2. **scene은 운영 지도여야 함**
|
||||
- 데스크톱 scene은 decorative office가 아니라 group 상태와 선택을 보여주는 요약 지도여야 한다
|
||||
3. **빈 context 금지**
|
||||
- 첫 진입 시 `선택하세요` 대신 기본 운영 summary를 보여준다
|
||||
4. **truthful status**
|
||||
- `connected idle`, `active working`, `snapshot only`, `fallback`, `offline`을 구분한다
|
||||
5. **mobile overflow 금지**
|
||||
- `360px`, `390px` 기준 긴 badge, 긴 detail, 긴 source 문구는 wrap되고 horizontal scroll이 없어야 한다
|
||||
|
||||
## mobile IA
|
||||
모바일(` <768`) 기본 순서는 아래로 고정해.
|
||||
## source / status 라벨
|
||||
| 라벨 | 의미 | 금지 |
|
||||
|---|---|---|
|
||||
| `live` | runtime 또는 ws 기반 최신 상태 | snapshot과 같은 톤으로 보이게 하기 |
|
||||
| `snapshot` | polling 기준 마지막 스냅샷 | live처럼 pulse 주기 |
|
||||
| `fallback` | 보정 데이터 | working으로 읽히는 색 주기 |
|
||||
| `connected idle` | 연결됨, 최근 작업 없음 | working 색 재사용 |
|
||||
| `active working` | 명시적 작업 중 | 단순 task string 존재만으로 확정하기 |
|
||||
| `offline` | 연결 불가 | stale snapshot과 같은 카드 처리 |
|
||||
|
||||
### 1. summary hero
|
||||
가장 위. 첫 진입 핵심 문장 1개와 source badge 1개를 보여준다.
|
||||
## desktop IA (`1280px+`)
|
||||
### 레이아웃
|
||||
`summary rail + group scene + context rail + bottom detail panels`
|
||||
|
||||
**포함 정보**
|
||||
- 현재 focus project 또는 active task
|
||||
- data source badge (`live`, `snapshot`, `fallback`)
|
||||
- 보조 문구 한 줄
|
||||
### 1. summary rail
|
||||
- 현재 focus
|
||||
- connection summary
|
||||
- active working count
|
||||
- source/freshness badges
|
||||
|
||||
**하지 않을 것**
|
||||
- 긴 설명문
|
||||
- 데스크톱용 메타 정보 여러 줄
|
||||
### 2. group scene
|
||||
기존 4개 긴 박스 분해형 대신 아래 group block을 scene 안에 배치한다.
|
||||
- Planning group: 하랑이 + planner/prd-writer
|
||||
- Delivery group: 나랑이 + implementation subagents
|
||||
- Review group: 다랑이 + reviewer/qa/security
|
||||
- Deploy group: 이랑이 + infra/deploy agents
|
||||
|
||||
### 2. compact sister status
|
||||
4자매 상태를 2x2 또는 1열 compact card로 보여준다.
|
||||
각 group 최소 정보:
|
||||
- group label
|
||||
- connected count / working count
|
||||
- primary blocker 또는 `all clear`
|
||||
- 대표 source badge
|
||||
|
||||
**각 카드 최소 정보**
|
||||
- 자매 이름
|
||||
- 상태색과 상태 라벨
|
||||
- current task 또는 active session 한 줄
|
||||
- runtime/source 힌트 한 줄
|
||||
### 3. context rail
|
||||
선택 상태와 무관하게 기본 summary가 먼저 보인다.
|
||||
|
||||
**행동**
|
||||
- 탭 또는 카드 선택 가능
|
||||
- 선택 시 인라인 상세가 펼쳐져도 첫 카드 밀도를 깨지 않게 유지
|
||||
|
||||
### 3. focus / health / quick action block
|
||||
첫 viewport 안에 반드시 들어와야 하는 운영 블록이야.
|
||||
|
||||
**focus block**
|
||||
- current focus
|
||||
- sprint / deploy state 중 하나의 핵심 값
|
||||
|
||||
**health block**
|
||||
- online count
|
||||
- 문제 있는 sister/server 요약
|
||||
- source badge
|
||||
|
||||
**quick action block**
|
||||
- direct chat 진입
|
||||
- 상세 보기 또는 관련 패널 점프
|
||||
|
||||
### 4. panel sections
|
||||
첫 viewport 이후 순차 노출.
|
||||
|
||||
모바일 추천 순서:
|
||||
1. direct chat
|
||||
2. pipeline
|
||||
3. health detail
|
||||
4. context detail
|
||||
|
||||
이 순서는 "지금 말 걸기 → 지금 뭐가 막혔는지 보기 → 상세 맥락 보기" 흐름을 따른다.
|
||||
|
||||
## first viewport priority
|
||||
`360px`, `390px`에서 아래 4개가 모두 한 번에 보여야 해.
|
||||
1. 4자매 상태
|
||||
2. current focus
|
||||
3. health summary
|
||||
기본 summary 우선순위:
|
||||
1. current focus
|
||||
2. current blocker
|
||||
3. active working group
|
||||
4. quick actions
|
||||
|
||||
### 우선순위 이유
|
||||
- 자매 상태가 먼저 안 보이면 운영 화면이 아니라 decorative scene이 된다.
|
||||
- focus가 없으면 무엇을 관제 중인지 설명이 안 된다.
|
||||
- health summary가 없으면 online/offline 판단이 늦어진다.
|
||||
- quick actions가 없으면 direct chat 진입이 숨는다.
|
||||
선택 후 context는 아래로 바뀐다.
|
||||
- selected sister summary
|
||||
- recent session / task evidence
|
||||
- why this looks active
|
||||
- open chat CTA
|
||||
|
||||
## compact sister status 설계
|
||||
### desktop와 다르게 볼 것
|
||||
- desktop(`1280px+`)은 scene 중심
|
||||
- tablet(`768px`)은 scene 축소 유지 가능하되 summary 보강 필요
|
||||
- mobile(` <768`)은 scene 대신 status card 중심
|
||||
### 4. bottom detail panels
|
||||
순서:
|
||||
1. chat
|
||||
2. pipeline
|
||||
3. server health
|
||||
|
||||
### 카드 규칙
|
||||
- 카드 높이는 task 한 줄, 상태 한 줄 기준으로 짧게 유지
|
||||
- 자매 4명을 한 화면 안에서 비교 가능해야 함
|
||||
- 선택된 자매는 인라인 확장이나 하단 sheet로 상세를 보여줄 수 있음
|
||||
- subagent 수나 role은 보조 정보로만 노출
|
||||
패널 역할:
|
||||
- chat: intervention
|
||||
- pipeline: delivery flow evidence
|
||||
- health: infra/runtime evidence
|
||||
|
||||
## scene 대체 전략
|
||||
### mobile에서 scene을 이렇게 바꿔
|
||||
`OfficeScene.tsx` 모바일 분기는 아래 둘 중 하나를 기준으로 구현해.
|
||||
## mobile IA (`<768`)
|
||||
### 고정 순서
|
||||
`summary → group blocks → detail flow`
|
||||
|
||||
#### 옵션 A. compact sister stack
|
||||
- 세로 카드 4개
|
||||
- 각 카드에서 상태, current task, quick action 제공
|
||||
- 선택 시 아래에 context summary 노출
|
||||
### 1. summary
|
||||
첫 viewport 안에 아래가 모두 들어와야 한다.
|
||||
- current focus
|
||||
- connection summary
|
||||
- active working summary
|
||||
- quick action
|
||||
|
||||
#### 옵션 B. selectable status cards
|
||||
- 2x2 grid 또는 가로 2열 카드
|
||||
- 선택 카드만 확장
|
||||
- 확장 영역에서 subagent / recent context / chat action 제공
|
||||
### 2. group blocks
|
||||
- 4자매 individual card보다 group block 우선
|
||||
- 각 block 안에서 대표 sister와 상태를 요약
|
||||
- 선택 시 inline expand 또는 accordion으로 detail 표시
|
||||
|
||||
### 반드시 지킬 것
|
||||
- `800x460` SVG를 그대로 줄여서 넣지 않는다
|
||||
- 회의실/존 은유는 모바일에서 필수 요소가 아니다
|
||||
- mobile에서 중요한 건 공간 은유보다 운영 정보의 순서다
|
||||
### 3. detail flow
|
||||
모바일에서는 `800x460` scene 축소판을 사용하지 않는다.
|
||||
세부 흐름 순서:
|
||||
1. chat
|
||||
2. pipeline
|
||||
3. health
|
||||
4. context detail
|
||||
|
||||
## context 흡수 전략
|
||||
`ContextPanel.tsx` 내용은 mobile에서 별도 우측 패널이 아니라 아래 중 하나로 흡수해.
|
||||
- selected sister 카드 안 인라인 상세
|
||||
- accordion section
|
||||
- bottom sheet
|
||||
## default context 규칙
|
||||
선택 전 기본 카드 예시:
|
||||
- `현재 focus`: active task / no active focus
|
||||
- `현재 가장 바쁜 그룹`: delivery / review / none
|
||||
- `개입 권장`: direct chat target 또는 `no direct target`
|
||||
- `data note`: live/snapshot/fallback 구분
|
||||
|
||||
### mobile 기본 상태
|
||||
- 아무 것도 선택되지 않아도 default context summary가 있어야 한다
|
||||
- 예: "현재 focus", "현재 제일 바쁜 자매", "바로 채팅할 자매"
|
||||
## ChatWorkspace 반영 규칙
|
||||
- JWT 없음, runtime 확인 중, empty, error, send disabled reason을 상단 또는 입력부 근처에 즉시 노출
|
||||
- chat이 열리지 않은 기본 상태에서도 요약 CTA는 보여야 한다
|
||||
- mobile에서 고정 높이 중심 설계 금지
|
||||
|
||||
## health block 기준
|
||||
`ServerHealthPanel.tsx` 전체를 첫 화면에 다 넣지 말고, 상단에는 summary만 먼저 둬.
|
||||
## PipelinePanel 반영 규칙
|
||||
- 가로 카드열 금지
|
||||
- 세로 단계 흐름 또는 responsive stack 사용
|
||||
- `focus`, `review loop`, `deploy state`, `source/freshness`를 상단 요약으로 먼저 보여준다
|
||||
|
||||
**상단 summary 최소 정보**
|
||||
- `online x/y`
|
||||
- 문제 상태 1건 요약 또는 `all clear`
|
||||
- source badge (`live`, `snapshot`, `fallback`)
|
||||
## ServerHealthPanel 반영 규칙
|
||||
- 상단에는 health summary만 먼저 노출
|
||||
- 긴 detail/source 문구는 wrap되어야 한다
|
||||
- `connected idle`, `snapshot only`, `fallback`, `offline`을 다른 상태로 읽히게 한다
|
||||
|
||||
**상세 패널에서 보여줄 것**
|
||||
- 자매/서버 카드 리스트
|
||||
- detail 문구
|
||||
- refreshed/generated 시각
|
||||
## 상태 표현 규칙
|
||||
| 상태 | 문구 예시 |
|
||||
|---|---|
|
||||
| loading | `데이터 확인 중` |
|
||||
| empty | `표시할 운영 항목 없음` |
|
||||
| error | `가져오지 못함` |
|
||||
| stale | `마지막 확인값 표시 중` |
|
||||
| jwt_missing | `JWT 없음, 전송 불가` |
|
||||
|
||||
## pipeline block 기준
|
||||
`PipelinePanel.tsx` 전체를 모바일 첫 viewport에 다 넣지 않는다.
|
||||
`empty`와 `error`는 절대 같은 문구를 쓰지 않는다.
|
||||
|
||||
**상단 summary 최소 정보**
|
||||
- active task
|
||||
- focus
|
||||
- review loop count 또는 deploy state
|
||||
## 360 / 390 responsive rules
|
||||
- 카드 title, badge, meta는 2줄까지 wrap 허용
|
||||
- source label은 short token 우선 (`live`, `snapshot`, `fallback`)
|
||||
- `generated 10:31:02` 같은 긴 freshness 문구는 한 줄 고정 금지
|
||||
- 전체 페이지와 하위 패널에서 horizontal scroll 금지
|
||||
|
||||
**상세 패널에서 보여줄 것**
|
||||
- 세로 단계 카드
|
||||
- node role / state / detail
|
||||
- snapshot freshness
|
||||
## acceptance
|
||||
- `/office` desktop이 4개 긴 박스 분해형이 아니라 group/scene 중심으로 재구성된다
|
||||
- mobile에서 scene 축소판이 사라지고 `summary → group blocks → detail flow`가 된다
|
||||
- 첫 진입 시 빈 context가 아니라 기본 운영 summary가 보인다
|
||||
- selected / unselected 상태 모두 `360px`, `390px`에서 overflow 없이 읽힌다
|
||||
- `working`은 source-of-truth 기준으로만 강조된다
|
||||
|
||||
## source badge 규칙
|
||||
모바일에서는 source 표현을 아래처럼 통일해.
|
||||
- `live`: 현재 runtime 또는 ws 기반 최신 상태
|
||||
- `snapshot`: polling 또는 마지막 스냅샷 기준 상태
|
||||
- `fallback`: 문서/기본값/보조 데이터 기준 상태
|
||||
|
||||
### 라벨 톤
|
||||
- 라벨은 짧게
|
||||
- 설명은 보조 문구 한 줄
|
||||
- 첫 화면과 하위 패널에서 같은 단어 사용
|
||||
|
||||
## 상태 문구 규칙
|
||||
- `loading`: 불러오는 중
|
||||
- `empty`: 아직 표시할 데이터 없음
|
||||
- `error`: 가져오지 못함 또는 전송 실패
|
||||
- `stale`: 최신 연결이 약해 마지막 확인값 표시 중
|
||||
|
||||
`empty`와 `error`는 절대 같은 문구로 처리하지 않아.
|
||||
|
||||
## desktop / tablet 유지 규칙
|
||||
### desktop (`1280px+`)
|
||||
- 기존 scene + context panel + bottom panels 구조 유지
|
||||
- 단, source badge와 상태 라벨은 새 기준으로 통일
|
||||
|
||||
### tablet (`768px`)
|
||||
- 데스크톱 구조를 유지해도 되지만 summary 우선순위를 보강해야 함
|
||||
- 첫 화면에서 핵심 정보가 씬 아래로 밀리면 안 됨
|
||||
|
||||
## QA 체크 포인트
|
||||
- `360px`, `390px`에서 첫 viewport에 핵심 정보 4종이 모두 보이는지
|
||||
- horizontal scroll이 없는지
|
||||
- 선택 전에도 default context가 읽히는지
|
||||
- sister 선택 후 상세 확인이 같은 세로 흐름 안에서 끝나는지
|
||||
- `live / snapshot / fallback` 라벨이 첫 화면과 패널에서 같은지
|
||||
## rollout note
|
||||
- SPRINT-017의 모바일 정보 순서는 유지한다
|
||||
- 이번 Sprint에서 바꾸는 건 scene semantics, default context, truthful state labels다
|
||||
|
||||
@@ -1,97 +1,121 @@
|
||||
# 조직도 (`/org`) — 디자인 v2
|
||||
# `/org` UI 기준 — SPRINT-018 company org map
|
||||
|
||||
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #6
|
||||
## 문서 목적
|
||||
`/org`를 자매 트리 데모가 아니라 **회사형 조직도 + independent agents map**으로 전면 재설계한다. 이번 문서는 하드코딩된 root tree 대신, 실제 데이터 계약으로 렌더링 가능한 org 화면 기준을 잠근다.
|
||||
|
||||
## 레이아웃
|
||||
## 참고 문서
|
||||
- 실행 개요: `.plans/OVERVIEW.md`
|
||||
- Sprint: `.plans/sprints/SPRINT-018.md`
|
||||
- API 설계: `.plans/design/api-design.md`
|
||||
|
||||
```
|
||||
┌─────┬──────────────────────────────────────────────────────┐
|
||||
│ 64px│ SYS_TYPE: CORE_ROOT_01 2023.10.27_14:42 │
|
||||
│ │ ────────────────────────────────────────────────── │
|
||||
│ │ │
|
||||
│ DASH│ ┌─────────────────┐ │
|
||||
│ PROJ│ │ [HQ] 하나랑 글로벌 │ │
|
||||
│◗ORG │ │ MEM:142 S_ADMIN │ │
|
||||
│ CONF│ └────────┬────────┘ │
|
||||
│ │ ┌───────────┼───────────┐ │
|
||||
│ │ ┌──────┴──────┐ ┌─┴────┐ ┌───┴───┐ │
|
||||
│ │ │[R&D] 기술연구│ │[OPS] │ │[EXT] │ │
|
||||
│ │ │ MEM:48 LEAD │ │운영팀 │ │외부협력│ │
|
||||
│ │ └──────┬──────┘ └──┬───┘ └───┬───┘ │
|
||||
│ │ ┌─────┼─────┐ │ │ │
|
||||
│ │ ┌──┴──┐ ┌┴─────┐ ┌──┴───┐ ┌───┴────┐ │
|
||||
│ │ │Core │ │Infra │ │Monitor│ │Security│ │
|
||||
│ │ │Eng. │ │struct│ │ing │ │Audit │ │
|
||||
│ │ └─────┘ └──────┘ └──────┘ └────────┘ │
|
||||
│ │ │
|
||||
│ │ TOTAL_NODES: 07 STATUS: SYNC_COMPLETE │
|
||||
└─────┴──────────────────────────────────────────────────────┘
|
||||
```
|
||||
## 기준 구현 파일
|
||||
- `frontend/app/org/page.tsx`
|
||||
- `backend/src/sisters/sister-detail.service.ts`
|
||||
|
||||
## 사이드바 (조직도 전용)
|
||||
- **64px 너비** (다른 페이지의 240px과 다름)
|
||||
- 세로 텍스트: `writing-mode: vertical-rl`
|
||||
- 아이콘 모드: DASH / PROJ / ORG_TREE / CONF
|
||||
- 로고: 세로 배치 (circle-full 위, circle-half 아래)
|
||||
## 현재 문제
|
||||
- 현재 화면은 하랑이 root 고정형 terminal tree다
|
||||
- 자매 외 조직 단위가 없다
|
||||
- independent agents가 표현되지 않는다
|
||||
- mobile에서는 tree 한 벌이 사실상 읽기 어렵다
|
||||
- source / freshness / status가 다른 페이지와 같은 의미로 보이지 않는다
|
||||
|
||||
## 메인 뷰포트
|
||||
- 배경: dot grid pattern — `radial-gradient(circle, #1a1a1a 1px, transparent 1px)` / 30px 30px
|
||||
- `overflow: auto` (스크롤 가능)
|
||||
- padding: 40px, 중앙 정렬
|
||||
## 페이지 역할
|
||||
핵심 질문:
|
||||
1. 회사 조직 단위는 어떻게 나뉘어 있나
|
||||
2. 각 unit lead와 member는 누구인가
|
||||
3. independent agents는 어느 영역에 속하지 않는가
|
||||
4. 현재 status / source / freshness는 무엇인가
|
||||
|
||||
## 헤더
|
||||
- 좌: `SYS_TYPE: CORE_ROOT_01` (mono, 값은 흰색)
|
||||
- 우: `TIMESTAMP: 2023.10.27_14:42` (mono secondary)
|
||||
- border-bottom 1px
|
||||
## 정보 구조
|
||||
### 1. org summary header
|
||||
- org generated time
|
||||
- source badge
|
||||
- total units
|
||||
- total members
|
||||
- independent agents count
|
||||
|
||||
## 트리 구조
|
||||
### 2. HQ section
|
||||
- HQ card
|
||||
- lead
|
||||
- 핵심 responsibility
|
||||
- direct reports summary
|
||||
|
||||
### Terminal Card
|
||||
- 200px 너비, padding 12px
|
||||
- `border: 1px solid var(--border-color)`
|
||||
- **border-left: 3px solid** (레벨별 색상)
|
||||
- high (`#f43f5e`): HQ
|
||||
- mid (`#3b82f6`): R&D, Core Engine, Infrastructure
|
||||
- low (`#10b981`): OPS, EXT, Monitoring, Security Audit
|
||||
- `box-shadow: 0 10px 30px rgba(0,0,0,0.5)`
|
||||
- node-label: mono 13px 600
|
||||
- 접두사 span: `[HQ]` `[R&D]` `[OPS]` `[EXT]` — secondary 11px
|
||||
- badge-row: flex wrap, gap 6px
|
||||
- badge: mono 9px, `#1a1a1a` bg, border 1px `#262626`
|
||||
- `MEM: 142` (값은 primary)
|
||||
- `S_ADMIN`, `LEAD`, `MGR`, `GUEST` 등
|
||||
- `LV_09`, `LV_07` 등
|
||||
### 3. functional teams
|
||||
예시:
|
||||
- Planning / Program
|
||||
- Delivery
|
||||
- QA / Review
|
||||
- Infra / Deploy
|
||||
|
||||
### 접기/펼치기 토글
|
||||
- `−` / `+` 아이콘, 16x16px
|
||||
- 카드 하단 중앙 absolute 배치
|
||||
- bg: card bg, border 1px
|
||||
각 team card 최소 정보:
|
||||
- team name
|
||||
- lead
|
||||
- member chips
|
||||
- active status summary
|
||||
- source/freshness
|
||||
|
||||
### 연결선
|
||||
- 세로선: `width: 1px; background: var(--border-color)`
|
||||
- 가로선 (형제 연결): children `::before` — 상단 -40px
|
||||
### 4. independent agents
|
||||
조직도 하단 또는 병렬 rail에 별도 배치한다.
|
||||
|
||||
### 트리 구조
|
||||
```
|
||||
하나랑 글로벌 (HQ, high)
|
||||
├── 기술연구소 (R&D, mid)
|
||||
│ ├── Core Engine (mid)
|
||||
│ └── Infrastructure (mid)
|
||||
├── 운영지원팀 (OPS, low)
|
||||
│ └── Monitoring (low)
|
||||
└── 외부 협력사 (EXT, low)
|
||||
└── Security Audit (low)
|
||||
```
|
||||
각 항목 최소 정보:
|
||||
- name
|
||||
- type (`independent` / `sister-owned`)
|
||||
- owner sister 또는 `none`
|
||||
- role / purpose
|
||||
- status / source / freshness
|
||||
|
||||
## 푸터
|
||||
- `margin-top: auto; border-top: 1px solid`
|
||||
- 좌: `TOTAL_NODES: 07`
|
||||
- 우: `STATUS: SYNC_COMPLETE`
|
||||
- mono 10px secondary
|
||||
### 5. reporting lines
|
||||
desktop에서는 라인/connector를 써도 되지만, line이 없어도 hierarchy를 읽을 수 있어야 한다. mobile은 connector보다 section order와 labels를 우선한다.
|
||||
|
||||
## 모바일 반응형
|
||||
- 사이드바: 하단 탭바로 전환
|
||||
- 트리: 수평 스크롤 컨테이너 + 핀치 줌
|
||||
- terminal-card: 너비 160px으로 축소
|
||||
- badge 폰트: 8px
|
||||
- dot grid: 20px 간격으로 축소
|
||||
## data contract rules
|
||||
- `orgUnits`, `members`, `reportingLines`, `independentAgents`로 렌더링 가능해야 한다
|
||||
- hard-coded `harang -> narang/darang/erang` 구조 금지
|
||||
- member status는 다른 페이지와 같은 vocabulary를 사용한다
|
||||
- `active working`
|
||||
- `connected idle`
|
||||
- `snapshot only`
|
||||
- `fallback`
|
||||
- `offline`
|
||||
|
||||
## layout rules
|
||||
### desktop (`1280px+`)
|
||||
- `summary header`
|
||||
- `HQ + top org units`
|
||||
- `functional teams grid`
|
||||
- `independent agents rail`
|
||||
- optional reporting lines overlay
|
||||
|
||||
### tablet (`768px`)
|
||||
- 2열 card grid 허용
|
||||
- connector 최소화
|
||||
|
||||
### mobile (`360px`, `390px`)
|
||||
- section stack
|
||||
- team card 내부 member chips wrap
|
||||
- independent agents 별도 section 유지
|
||||
- horizontal scroll 금지
|
||||
|
||||
## copy rules
|
||||
- `TOTAL_NODES` 같은 데모형 문구보다 실제 조직 label 사용
|
||||
- `SYNC_COMPLETE` 같은 과장 표현 금지
|
||||
- source와 freshness를 분리해서 표기
|
||||
- 없는 조직 단위는 억지 생성하지 않는다
|
||||
|
||||
## empty states
|
||||
| 상태 | 표현 |
|
||||
|---|---|
|
||||
| no org data | `ORG DATA 없음` |
|
||||
| no independent agents | `INDEPENDENT AGENTS 없음` |
|
||||
| no lead | `LEAD 미정` |
|
||||
| fallback only | `FALLBACK ORG SNAPSHOT` |
|
||||
|
||||
## acceptance
|
||||
- `/api/org` 계약만으로 회사형 조직도를 렌더링할 수 있다
|
||||
- independent agents와 sister-owned agents를 분리해서 표현할 수 있다
|
||||
- 특정 자매 top 고정 하드코딩 없이 렌더링 가능하다
|
||||
- org 화면에서도 `status / source / freshness` 의미를 재사용한다
|
||||
- `360px`, `390px` 기준 핵심 요약과 카드가 overflow 없이 읽힌다
|
||||
|
||||
## rollout note
|
||||
- 첫 구현은 connector보다 card semantics를 우선한다
|
||||
- terminal tree 미감은 보조 요소로만 남기고, hierarchy readability를 우선한다
|
||||
|
||||
@@ -1,43 +1,129 @@
|
||||
# 프로젝트 상세 (`/projects/[id]`) — Sprint 014 리뉴얼
|
||||
# `/projects/[id]` UI 기준 — SPRINT-018 delivery detail
|
||||
|
||||
## 목적
|
||||
프로젝트 상세는 Sprint 이력만 보여주는 화면이 아니라, 계획 → 구현 → QA → 배포 흐름이 실제로 어디까지 왔는지 판단하는 운영 화면이어야 해.
|
||||
## 문서 목적
|
||||
프로젝트 상세는 delivery flow를 읽는 화면이지, unique assignee 목록을 현재 참여자처럼 보여주는 화면이 아니야. 이번 Sprint에서는 `participants`, `current owners`, `task assignees`를 truthfully 분리한다.
|
||||
|
||||
## 상단 헤더
|
||||
- breadcrumb 유지
|
||||
- 제목 우측 메타:
|
||||
- project status
|
||||
- current sprint
|
||||
- latest deploy state (`MAIN DEPLOYED` / `READY FOR REDEPLOY` 등)
|
||||
- repo 링크 유지
|
||||
## 참고 문서
|
||||
- 실행 개요: `.plans/OVERVIEW.md`
|
||||
- Sprint: `.plans/sprints/SPRINT-018.md`
|
||||
- API 설계: `.plans/design/api-design.md`
|
||||
|
||||
## 본문 구조
|
||||
### 좌측 메인
|
||||
1. `DELIVERY FLOW`
|
||||
- Planning
|
||||
- Implement
|
||||
- QA
|
||||
- Merge to Main
|
||||
- Redeploy
|
||||
2. `SPRINT LEDGER`
|
||||
- Sprint 번호 / 이름 / 진행률 / task 수 / 완료 수
|
||||
3. `HOTFIX HISTORY`
|
||||
- Hotfix 번호 / 한 줄 요약 / 반영 상태
|
||||
## 기준 구현 파일
|
||||
- `frontend/app/projects/[id]/page.tsx`
|
||||
- `backend/src/projects/*`
|
||||
|
||||
### 우측 보조
|
||||
1. `QA STATUS`
|
||||
- latest passed/failed
|
||||
- blocker 수
|
||||
- latest QA 문서 식별자
|
||||
2. `DEPLOY STATUS`
|
||||
- 현재 배포 기준 브랜치 (`main` 고정)
|
||||
- last deploy time
|
||||
- redeploy pending 여부
|
||||
3. `ASSIGNED NODES`
|
||||
- 하랑/나랑/다랑/이랑 각 역할 표기
|
||||
## 현재 문제
|
||||
- `ASSIGNED NODES`가 실제 participant와 task assignee를 섞어 읽히게 한다
|
||||
- 나랑이 단독 노출이 실제 active participant 확정처럼 보일 수 있다
|
||||
- 상단 overview, QA, deploy, flow, participant 시선 흐름이 답답하다
|
||||
- mobile `360px`, `390px`에서 flow/meta/git row가 쉽게 넘친다
|
||||
|
||||
## UX 규칙
|
||||
- Sprint와 Hotfix는 분리해서 보여주되, 흐름상 연결감은 유지
|
||||
- "보정/수정 이력" 같은 모호한 문구 대신 실제 요약 노출
|
||||
- 모바일에서는 `DELIVERY FLOW → QA STATUS → DEPLOY STATUS → SPRINT/HOTFIX` 순으로 재배치
|
||||
- `NodeDot`는 실제 상태 기반으로 색이 바뀌어야 하며, 무조건 초록 고정이면 안 돼
|
||||
## 페이지 역할
|
||||
핵심 질문:
|
||||
1. 이 프로젝트는 delivery flow 어디에 있나
|
||||
2. QA와 deploy 준비 상태는 어떤가
|
||||
3. 지금 왜 이 사람이 participant로 보이나
|
||||
4. task assignee 집계와 current owner는 누구인가
|
||||
|
||||
## 정보 구조
|
||||
### 상단 overview
|
||||
- project title
|
||||
- phase
|
||||
- current sprint
|
||||
- deploy status
|
||||
- repo link
|
||||
|
||||
### 본문 1열 우선 순서
|
||||
1. delivery overview
|
||||
2. QA status
|
||||
3. deploy status
|
||||
4. participants / current owners
|
||||
5. sprint ledger
|
||||
6. hotfix history
|
||||
7. git tabs
|
||||
|
||||
desktop에서는 2열 배치를 허용하지만, participant 의미가 사이드카드 하나로 축소되면 안 된다.
|
||||
|
||||
## participant model
|
||||
### section naming
|
||||
- `CURRENT OWNERS`
|
||||
- `PARTICIPANTS`
|
||||
- `TASK ASSIGNEES`
|
||||
|
||||
`ASSIGNED NODES`는 사용하지 않는다.
|
||||
|
||||
### participant item 필수 정보
|
||||
- name
|
||||
- role label
|
||||
- why-visible badge
|
||||
- source badge
|
||||
- freshness or last-active label
|
||||
|
||||
예시 why-visible:
|
||||
- `owner`
|
||||
- `current_implementer`
|
||||
- `reviewer`
|
||||
- `deploy_owner`
|
||||
- `task_assignee`
|
||||
- `latest_qa_author`
|
||||
|
||||
### current owner 규칙
|
||||
- 확정 owner가 없으면 `NO ACTIVE OWNER`
|
||||
- fallback sister를 owner로 주입하지 않음
|
||||
|
||||
### participant 규칙
|
||||
- why-visible 없는 participant 금지
|
||||
- participant가 0명이면 empty state 허용
|
||||
- assignee aggregate는 participant와 별도 목록 또는 metric으로 노출
|
||||
|
||||
## layout rules
|
||||
### delivery flow
|
||||
- stage 5개를 유지해도 mobile에서는 1열 stack 사용
|
||||
- active stage만 강조
|
||||
- 긴 상태 키는 wrap 허용
|
||||
|
||||
### QA status
|
||||
- latest QA label
|
||||
- pass/fail/unknown
|
||||
- blocker count
|
||||
- short summary
|
||||
- source: `doc-derived` 또는 `snapshot`
|
||||
|
||||
### deploy status
|
||||
- branch
|
||||
- deploy state
|
||||
- latest deploy time
|
||||
- redeploy required
|
||||
|
||||
### participants area
|
||||
우선순위:
|
||||
1. current owners
|
||||
2. active participants
|
||||
3. task assignee aggregate
|
||||
|
||||
참여자 카드에는 `reason/source`가 보여야 하고, 단순 live dot만으로 참여 확정처럼 보이면 안 된다.
|
||||
|
||||
## empty / null states
|
||||
| 상태 | 표현 |
|
||||
|---|---|
|
||||
| no current owner | `NO ACTIVE OWNER` |
|
||||
| no active participant | `NO CONFIRMED ACTIVE PARTICIPANT` |
|
||||
| no task assignee | `NO TASK ASSIGNEE DATA` |
|
||||
| no QA | `QA DOC 없음` |
|
||||
|
||||
## mobile 360 / 390 rules
|
||||
- flow card는 1열 stack
|
||||
- label/meta/git row는 wrap 허용
|
||||
- participant badge row는 wrap 필수
|
||||
- name, role, reason, source가 같은 줄 고정폭으로 잘리지 않게 한다
|
||||
- horizontal scroll 금지
|
||||
|
||||
## acceptance
|
||||
- `ASSIGNED NODES`가 truthful section 이름으로 교체된다
|
||||
- participant, current owner, task assignee가 다른 데이터 의미로 분리된다
|
||||
- 나랑이만 보여도 reason/source badge가 함께 붙어 확정 참여처럼 읽히지 않는다
|
||||
- `360px`, `390px`에서 flow card, meta, git row가 넘치지 않는다
|
||||
|
||||
## rollout note
|
||||
- participant summary는 backend contract를 먼저 나누고 렌더링을 맞춘다
|
||||
- 기존 `allTasks.assignee` unique set은 `TASK ASSIGNEES` 섹션으로만 후퇴시킨다
|
||||
|
||||
92
.plans/qa/SPRINT-018-review-1.md
Normal file
92
.plans/qa/SPRINT-018-review-1.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# SPRINT-018 review-1 QA draft
|
||||
|
||||
## 목적
|
||||
SPRINT-018의 truth contract가 `/office`, `/`, `/projects/[id]`, `/org` 전반에서 같은 의미로 보이는지 확인하는 QA 초안이야.
|
||||
|
||||
## 참고 문서
|
||||
- `.plans/sprints/SPRINT-018.md`
|
||||
- `.plans/design/api-design.md`
|
||||
- `.plans/design/ui/office-dashboard-design.md`
|
||||
- `.plans/design/ui/dashboard-design.md`
|
||||
- `.plans/design/ui/project-detail-design.md`
|
||||
- `.plans/design/ui/org-design.md`
|
||||
|
||||
## 공통 확인 항목
|
||||
- `working`, `owner`, `participant`, `source`, `freshness` 의미가 페이지 간 동일한가
|
||||
- `360px`, `390px`, `768px`, `1280px+`에서 horizontal scroll이 없는가
|
||||
- 긴 badge, 긴 meta, 긴 source 문구가 wrap되는가
|
||||
- null/empty 상태가 fallback 고정값으로 대체되지 않는가
|
||||
|
||||
## 상태 시나리오 매트릭스
|
||||
| 시나리오 | `/office` | `/` | `/projects/[id]` | `/org` |
|
||||
|---|---|---|---|---|
|
||||
| socket disconnected | connection off + snapshot/fallback 유지 | event stream off, freshness 유지 | 영향 없음 또는 snapshot note | 영향 없음 |
|
||||
| snapshot only | `snapshot only` 상태 구분 | `SNAPSHOT n AGO` 표기 | participant/source snapshot 표기 | org snapshot 표기 |
|
||||
| fallback only | fallback seed가 working 주입 안 함 | owner null 유지 | participant empty 유지 | fallback org snapshot 표기 |
|
||||
| connected idle | working 강조 없음 | sister row idle 표기 | participant로 과장 노출 안 함 | member idle 표기 |
|
||||
| active working | why active 근거 노출 | active focus만 짧게 표시 | current owner/participant reason 표기 | member active badge 표기 |
|
||||
| no active owner | 기본 owner 없음 | `NO ACTIVE OWNER` | `NO ACTIVE OWNER` | unit lead 미정 가능 |
|
||||
| empty chat | empty state 즉시 노출 | N/A | N/A | N/A |
|
||||
| JWT missing | send disabled reason 노출 | N/A | N/A | N/A |
|
||||
|
||||
## breakpoint 체크
|
||||
|
||||
### 360px
|
||||
#### `/office`
|
||||
- [ ] 첫 viewport에 summary, group blocks, quick action이 보인다
|
||||
- [ ] scene 축소판이 없다
|
||||
- [ ] chat/pipeline/health에서 horizontal scroll이 없다
|
||||
|
||||
#### `/`
|
||||
- [ ] top chips가 wrap된다
|
||||
- [ ] owner 영역이 잘리지 않는다
|
||||
- [ ] sister/status/pipeline cards가 넘치지 않는다
|
||||
|
||||
#### `/projects/[id]`
|
||||
- [ ] flow card가 1열 stack으로 보인다
|
||||
- [ ] participant reason/source badge가 wrap된다
|
||||
- [ ] git row가 넘치지 않는다
|
||||
|
||||
#### `/org`
|
||||
- [ ] org summary와 team cards가 1열로 읽힌다
|
||||
- [ ] independent agents section이 누락되지 않는다
|
||||
|
||||
### 390px
|
||||
- [ ] 360px 체크를 모두 통과한다
|
||||
- [ ] 긴 한글 문장과 영문 source label이 wrap된다
|
||||
|
||||
### 768px
|
||||
- [ ] 2열 전환 시 시선 흐름이 깨지지 않는다
|
||||
- [ ] scene/context, pipeline, org units가 애매한 overflow 없이 재배치된다
|
||||
|
||||
### 1280px+
|
||||
- [ ] `/office` desktop group scene이 유지된다
|
||||
- [ ] `/` executive snapshot hierarchy가 분명하다
|
||||
- [ ] `/projects/[id]` participant 의미가 sidebar 축약으로 무너지지 않는다
|
||||
- [ ] `/org` functional teams + independent agents가 함께 읽힌다
|
||||
|
||||
## 페이지별 semantic checks
|
||||
### `/office`
|
||||
- [ ] `working`이 fallback seed에서 나오지 않는다
|
||||
- [ ] default context가 선택 전에도 보인다
|
||||
- [ ] `connected idle`, `active working`, `snapshot only`, `fallback`, `offline`이 구분된다
|
||||
|
||||
### `/`
|
||||
- [ ] connection badge와 freshness badge가 분리된다
|
||||
- [ ] owner null 상태에서 특정 자매 fallback 고정 노출이 없다
|
||||
- [ ] source badge가 `mirrored event`, `snapshot`, `doc-derived`, `fallback` 중 하나로 읽힌다
|
||||
|
||||
### `/projects/[id]`
|
||||
- [ ] `ASSIGNED NODES`가 제거된다
|
||||
- [ ] participant와 task assignee가 분리된다
|
||||
- [ ] why-visible 없는 participant가 없다
|
||||
|
||||
### `/org`
|
||||
- [ ] hard-coded root tree 의존이 없다
|
||||
- [ ] independent agents가 별도 그룹으로 보인다
|
||||
- [ ] status/source/freshness vocabulary가 다른 페이지와 같다
|
||||
|
||||
## 남은 follow-up / scope cut
|
||||
- 실제 DTO 구현 후 field naming 최종 점검 필요
|
||||
- activity evidence가 부족한 환경에서 `working confidence` 정책 추가 검토 필요
|
||||
- independent agents data sourcing이 부족하면 v1에서는 snapshot 기반으로 제한 가능
|
||||
202
.plans/sprints/SPRINT-018.md
Normal file
202
.plans/sprints/SPRINT-018.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# SPRINT-018: dashboard truth redesign
|
||||
|
||||
## 목표
|
||||
`/office`, `/`, `/projects/[id]`, `/org`를 하나의 운영 정보 구조로 재정렬하고, `connection`, `activity`, `participation`, `freshness`, `source`를 분리한 source-of-truth로 다시 잠근다.
|
||||
|
||||
## 한 줄 정의
|
||||
SPRINT-017의 모바일 개편 범위와 HOTFIX-006의 정합성 보정 범위를 이어서, 이번 Sprint는 **운영 의미를 다시 정의하는 문서 Sprint**다.
|
||||
|
||||
## 배경
|
||||
현재 구현은 화면마다 정보는 많지만, 운영 의미가 섞여 있다.
|
||||
- `/office`는 fallback seed가 `working`처럼 읽히는 구간이 있다
|
||||
- `/`는 `socket off`와 freshness가 분리되지 않아 전체 오류처럼 보일 수 있다
|
||||
- `/projects/[id]`는 unique assignee 목록을 실제 participant처럼 보여준다
|
||||
- `/org`는 회사형 조직도가 아니라 하드코딩된 자매 트리에 가깝다
|
||||
- 모바일 `360px`, `390px` 기준으로 긴 문장과 카드가 쉽게 넘친다
|
||||
|
||||
## 참고 문서
|
||||
- 실행 개요: `.plans/OVERVIEW.md`
|
||||
- 이전 Sprint: `.plans/sprints/SPRINT-017.md`
|
||||
- 관련 hotfix: `.plans/hotfix/HOTFIX-006.md`
|
||||
- API 설계: `.plans/design/api-design.md`
|
||||
- UI 설계:
|
||||
- `.plans/design/ui/office-dashboard-design.md`
|
||||
- `.plans/design/ui/dashboard-design.md`
|
||||
- `.plans/design/ui/project-detail-design.md`
|
||||
- `.plans/design/ui/org-design.md`
|
||||
- QA 초안: `.plans/qa/SPRINT-018-review-1.md`
|
||||
|
||||
## 페이지 역할 고정
|
||||
| 페이지 | 역할 | primary question | source priority |
|
||||
|---|---|---|---|
|
||||
| `/office` | live operations workspace | 지금 누가 연결되어 있고, 누가 실제로 일하고 있으며, 어디에 바로 개입해야 하나 | live → snapshot → fallback |
|
||||
| `/` | executive snapshot | 지금 운영 전체에서 가장 먼저 볼 변화가 무엇인가 | mirrored event + snapshot + doc-derived |
|
||||
| `/projects/[id]` | delivery detail | 이 프로젝트의 구현, QA, deploy, 참여 상태가 어디까지 왔나 | project snapshot + participant summary + task assignee aggregate |
|
||||
| `/org` | company org map | 누가 어떤 조직 단위에 속하고, independent agents는 어디에 위치하나 | org snapshot |
|
||||
|
||||
## 공통 용어 표
|
||||
| 용어 | 정의 | 금지 |
|
||||
|---|---|---|
|
||||
| `connection` | gateway/socket/runtime 연결 가능 여부 | activity와 같은 뜻으로 쓰기 |
|
||||
| `activity` | 최근 event, session, message, task evidence | connection만 보고 activity 추정 확정하기 |
|
||||
| `working` | 명시적 운영 상태. active task, current session, recent operator evidence가 있는 경우만 active 표시 | fallback seed나 단순 animated state로 주입하기 |
|
||||
| `owner` | 현재 책임자. 없으면 null 허용 | fallback 기본값으로 특정 자매 고정하기 |
|
||||
| `participant` | 현재 프로젝트/흐름에서 왜 보이는지 설명 가능한 사람 | unique assignee 목록을 그대로 participant로 표기하기 |
|
||||
| `assignee` | task 할당 기준 인원 | participant와 같은 섹션명 쓰기 |
|
||||
| `freshness` | 언제 생성, 동기화, 미러링되었는지 | socket on/off만으로 freshness 대체하기 |
|
||||
| `source` | 값이 어디서 왔는지 (`live`, `mirrored event`, `snapshot`, `doc-derived`, `fallback`) | 서로 다른 source를 같은 사실처럼 라벨링하기 |
|
||||
|
||||
## Sprint 원칙
|
||||
- `360px`, `390px` 기준 horizontal scroll 금지
|
||||
- 긴 badge, 긴 meta 문구, 긴 이유 문구는 wrap 기준으로 설계
|
||||
- fallback, snapshot, live를 같은 사실처럼 보이게 하지 않음
|
||||
- `working`, `owner`, `participant`는 추정치와 확정치를 분리해 표시
|
||||
- 페이지별 역할은 다르지만 source/freshness contract는 공유
|
||||
|
||||
## 범위
|
||||
### 포함
|
||||
- SPRINT-018 운영 정보 구조와 용어 잠금
|
||||
- sisters/dashboard/project/org API truth contract 재설계 문서화
|
||||
- `/office`, `/`, `/projects/[id]`, `/org` UI 정보 구조 재설계
|
||||
- source badge, freshness label, empty/null 표현 기준 통일
|
||||
- 교차 페이지 QA 시나리오 초안 작성
|
||||
|
||||
### 제외
|
||||
- 실제 API 구현
|
||||
- DB schema migration
|
||||
- websocket namespace 변경
|
||||
- 새 역할 체계 추가
|
||||
- 조직도 authoring 툴 구축
|
||||
|
||||
## 실행 계획
|
||||
|
||||
### T1. 운영 정보 구조 및 source-of-truth 명세 잠금
|
||||
**대상 문서**
|
||||
- `.plans/OVERVIEW.md`
|
||||
- `.plans/sprints/SPRINT-018.md`
|
||||
- `.plans/design/api-design.md`
|
||||
- `.plans/design/ui/office-dashboard-design.md`
|
||||
- `.plans/design/ui/dashboard-design.md`
|
||||
|
||||
**done when**
|
||||
- 페이지 역할이 고정된다
|
||||
- 공통 용어 표가 문서에 들어간다
|
||||
- 모바일 overflow 금지 규칙이 문서에 명시된다
|
||||
- fallback / null / empty 처리 기준이 문서에 들어간다
|
||||
|
||||
### T2. 자매 운영 상태 source-of-truth 계약 재설계
|
||||
**기준 구현 파일**
|
||||
- `backend/src/sisters/sisters.service.ts`
|
||||
- `backend/src/dashboard/dashboard.service.ts`
|
||||
- `frontend/app/office/page.tsx`
|
||||
- `frontend/app/page.tsx`
|
||||
|
||||
**핵심 결정**
|
||||
- `connection`, `activity`, `working`, `lastActiveAt`, `source`, `freshness`를 분리 반환한다
|
||||
- `connected but idle`, `snapshot only`, `fallback`, `offline`을 구분 가능한 상태로 만든다
|
||||
- 프론트 fallback seed는 `working`을 직접 주입하지 않는다
|
||||
|
||||
### T3. 프로젝트 참여자 계약 분리 설계
|
||||
**기준 구현 파일**
|
||||
- `frontend/app/projects/[id]/page.tsx`
|
||||
- `backend/src/projects/*`
|
||||
|
||||
**핵심 결정**
|
||||
- participant summary와 task assignee aggregate를 별도 필드로 분리한다
|
||||
- participant마다 `whyVisible`을 붙인다
|
||||
- active participant가 없으면 null/empty를 그대로 보여준다
|
||||
|
||||
### T4. 조직도 데이터 계약 재설계
|
||||
**기준 구현 파일**
|
||||
- `backend/src/sisters/sister-detail.service.ts`
|
||||
- `frontend/app/org/page.tsx`
|
||||
|
||||
**핵심 결정**
|
||||
- `/api/org`는 `orgUnits`, `members`, `reportingLines`, `independentAgents`를 반환한다
|
||||
- sister-owned agents와 independent agents를 분리한다
|
||||
- 특정 자매를 하드코딩한 고정 트리에 의존하지 않는다
|
||||
|
||||
### T5. `/office` shell, scene, context 재배치
|
||||
**기준 구현 파일**
|
||||
- `frontend/app/office/page.tsx`
|
||||
- `frontend/components/office/OfficeScene.tsx`
|
||||
- `frontend/components/office/ContextPanel.tsx`
|
||||
|
||||
**핵심 결정**
|
||||
- desktop은 group/scene 중심 운영 레이아웃으로 재구성한다
|
||||
- mobile(`<768`)은 `summary → group blocks → detail flow`로 바꾼다
|
||||
- 첫 진입 시 빈 context 대신 운영 summary를 보여준다
|
||||
|
||||
### T6. `/office` 하위 패널 모바일 정보 밀도 재정렬
|
||||
**기준 구현 파일**
|
||||
- `frontend/components/office/ChatWorkspace.tsx`
|
||||
- `frontend/components/office/PipelinePanel.tsx`
|
||||
- `frontend/components/office/ServerHealthPanel.tsx`
|
||||
|
||||
**핵심 결정**
|
||||
- horizontal scroll을 제거한다
|
||||
- `JWT missing`, `runtime checking`, `empty`, `error`, `send disabled`를 즉시 읽히게 한다
|
||||
- 상태 라벨 톤을 패널 간 통일한다
|
||||
|
||||
### T7. 메인 대시보드 운영 의미 재정렬
|
||||
**기준 구현 파일**
|
||||
- `frontend/app/page.tsx`
|
||||
|
||||
**핵심 결정**
|
||||
- socket connection과 freshness를 분리한다
|
||||
- active owner가 없을 때 fallback owner를 고정 노출하지 않는다
|
||||
- 주요 섹션에 source 라벨을 일관되게 붙인다
|
||||
|
||||
### T8. 프로젝트 상세 레이아웃과 참여자 표현 재설계
|
||||
**기준 구현 파일**
|
||||
- `frontend/app/projects/[id]/page.tsx`
|
||||
|
||||
**핵심 결정**
|
||||
- `ASSIGNED NODES`를 truthful section으로 교체한다
|
||||
- participant, current owner, task assignee를 분리한다
|
||||
- 나랑이 단독 노출도 확정 참여처럼 읽히지 않게 `reason/source`를 붙인다
|
||||
|
||||
### T9. 회사형 조직도 UI 전면 재설계
|
||||
**기준 구현 파일**
|
||||
- `frontend/app/org/page.tsx`
|
||||
|
||||
**핵심 결정**
|
||||
- HQ, functional teams, members, independent agents 구조로 재배치한다
|
||||
- terminal tree 한 벌이 아니라 mobile에서도 읽히는 카드형 org map을 만든다
|
||||
|
||||
### T10. 교차 페이지 QA 및 문서 마감
|
||||
**산출물**
|
||||
- `.plans/qa/SPRINT-018-review-1.md`
|
||||
- `.plans/OVERVIEW.md`
|
||||
- `.plans/sprints/SPRINT-018.md`
|
||||
|
||||
**done when**
|
||||
- `/office`, `/`, `/projects/[id]`, `/org`를 `360 / 390 / 768 / 1280+`로 분리 기록한다
|
||||
- `socket disconnected`, `snapshot only`, `fallback only`, `connected idle`, `active working`, `no active owner`, `empty chat`, `JWT missing`를 검증한다
|
||||
- `working / owner / participant / source badge` 의미가 페이지 간 같게 보이는지 확인한다
|
||||
|
||||
## acceptance
|
||||
- 각 페이지 역할과 공통 용어가 문서에 고정되어 있다
|
||||
- API 문서에 `connection / activity / working / freshness / source` 분리 계약이 있다
|
||||
- `/office`, `/`, `/projects/[id]`, `/org` UI 문서가 truthful empty/null 상태를 포함한다
|
||||
- 모바일 `360px`, `390px`에서 overflow 금지 규칙이 모든 관련 문서에 반영되어 있다
|
||||
- QA 문서가 breakpoint와 상태 시나리오를 함께 다룬다
|
||||
|
||||
## rollout
|
||||
1. 문서 기준 잠금
|
||||
2. backend contract 구현
|
||||
3. `/office`와 `/` state badge 정리
|
||||
4. project detail participant model 반영
|
||||
5. org contract + UI 반영
|
||||
6. 교차 페이지 QA
|
||||
|
||||
## out-of-scope follow-up
|
||||
- activity evidence를 더 세밀하게 저장하는 schema 정리
|
||||
- participant reason 자동 생성 규칙 정교화
|
||||
- org map edit UI
|
||||
- source badge analytics
|
||||
|
||||
## handoff 메모
|
||||
- SPRINT-017 문서는 `/office` mobile IA 기준으로 유지한다. SPRINT-018은 그 위에 cross-page truth contract를 추가하는 Sprint다.
|
||||
- HOTFIX-006의 `socket / freshness / doc-derived` 문제는 이번 Sprint에서 메인 대시보드 전체 계약으로 승격한다.
|
||||
- 구현 시 문서보다 더 센 추정치 표현이 생기면 문서 기준이 우선이다.
|
||||
@@ -1 +0,0 @@
|
||||
1 1775809467
|
||||
7705
backend/pnpm-lock.yaml
generated
7705
backend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,6 @@ import { CostsModule } from './costs/costs.module';
|
||||
import { GiteaSyncModule } from './gitea-sync/gitea-sync.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { RailsModule } from './rails/rails.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -41,7 +40,6 @@ import { RailsModule } from './rails/rails.module';
|
||||
GiteaSyncModule,
|
||||
SettingsModule,
|
||||
DashboardModule,
|
||||
RailsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -114,33 +114,6 @@ export class EventsGateway
|
||||
this.broadcastActivity(record);
|
||||
}
|
||||
|
||||
// ── Rails orchestrator events ──────────────────────────────────────
|
||||
|
||||
@OnEvent('rails.pipelines.snapshot')
|
||||
handleRailsPipelinesSnapshot(payload: { pipelines: unknown[] }) {
|
||||
this.server.emit('rails:pipelines', {
|
||||
pipelines: payload.pipelines,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('rails.pipeline.updated')
|
||||
handleRailsPipelineUpdated(payload: { pipeline: unknown }) {
|
||||
this.server.emit('rails:pipeline:updated', {
|
||||
pipeline: payload.pipeline,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('rails.subtasks.updated')
|
||||
handleRailsSubTasksUpdated(payload: { pipelineId: string; tree: unknown }) {
|
||||
this.server.emit('rails:subtasks', {
|
||||
pipelineId: payload.pipelineId,
|
||||
tree: payload.tree,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/** 연결된 클라이언트 수 */
|
||||
getClientCount(): number {
|
||||
return this.server?.sockets?.sockets?.size ?? 0;
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { RailsService } from './rails.service';
|
||||
import { JwtGuard } from '../auth/jwt.guard';
|
||||
|
||||
@Controller('api/rails')
|
||||
@UseGuards(JwtGuard)
|
||||
export class RailsController {
|
||||
constructor(private readonly rails: RailsService) {}
|
||||
|
||||
@Get('health')
|
||||
health() {
|
||||
return this.rails.health();
|
||||
}
|
||||
|
||||
@Get('pipelines')
|
||||
async listPipelines(@Query('limit') limit?: string) {
|
||||
const n = limit ? parseInt(limit, 10) : 20;
|
||||
const pipelines = await this.rails.listPipelines(Number.isFinite(n) ? n : 20);
|
||||
return { pipelines };
|
||||
}
|
||||
|
||||
@Get('pipelines/:id')
|
||||
async pipelineDetail(@Param('id') id: string) {
|
||||
const detail = await this.rails.getPipeline(id);
|
||||
if (!detail) {
|
||||
throw new HttpException('pipeline not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
@Get('pipelines/:id/sub-tasks')
|
||||
async subTaskTree(@Param('id') id: string) {
|
||||
const tree = await this.rails.getSubTaskTree(id);
|
||||
return { pipelineId: id, tree };
|
||||
}
|
||||
|
||||
@Get('sub-tasks/:id')
|
||||
async subTaskDetail(@Param('id') id: string) {
|
||||
const detail = await this.rails.getSubTaskDetail(id);
|
||||
if (!detail) {
|
||||
throw new HttpException('sub-task not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
@Get('transitions')
|
||||
async transitions(
|
||||
@Query('pipelineId') pipelineId?: string,
|
||||
@Query('eventType') eventType?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
const opts: { pipelineId?: string; eventType?: string; limit?: number } = {};
|
||||
if (pipelineId) opts.pipelineId = pipelineId;
|
||||
if (eventType) opts.eventType = eventType;
|
||||
if (limit) opts.limit = parseInt(limit, 10);
|
||||
const transitions = await this.rails.listTransitions(opts);
|
||||
return { transitions };
|
||||
}
|
||||
|
||||
@Get('escalations')
|
||||
async escalations(
|
||||
@Query('pipelineId') pipelineId?: string,
|
||||
@Query('resolved') resolved?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
const opts: { pipelineId?: string; resolved?: boolean; limit?: number } = {};
|
||||
if (pipelineId) opts.pipelineId = pipelineId;
|
||||
if (resolved === 'true') opts.resolved = true;
|
||||
else if (resolved === 'false') opts.resolved = false;
|
||||
if (limit) opts.limit = parseInt(limit, 10);
|
||||
const escalations = await this.rails.listEscalations(opts);
|
||||
return { escalations };
|
||||
}
|
||||
|
||||
@Post('pipelines/start')
|
||||
async start(
|
||||
@Body() body: { project: string; requirements: string },
|
||||
) {
|
||||
if (!body?.project || typeof body.project !== 'string') {
|
||||
throw new HttpException('project required', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return this.rails.startPipeline({
|
||||
project: body.project,
|
||||
requirements: body.requirements ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
@Post('pipelines/:id/abort')
|
||||
async abort(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { reason?: string },
|
||||
) {
|
||||
return this.rails.abortPipeline(id, body?.reason ?? 'aborted via dashboard');
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { RailsService } from './rails.service';
|
||||
import { RailsController } from './rails.controller';
|
||||
import { RailsScheduler } from './rails.scheduler';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, AuthModule],
|
||||
controllers: [RailsController],
|
||||
providers: [RailsService, RailsScheduler],
|
||||
exports: [RailsService],
|
||||
})
|
||||
export class RailsModule {}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { RailsService, type RailsPipelineSummary } from './rails.service';
|
||||
|
||||
/**
|
||||
* Polls rails every N seconds for active pipelines and emits events
|
||||
* that the EventsGateway broadcasts over Socket.IO.
|
||||
*
|
||||
* Events emitted (via EventEmitter2):
|
||||
* rails.pipeline.updated — single pipeline changed state
|
||||
* rails.pipelines.snapshot — full list snapshot
|
||||
* rails.subtasks.updated — sub-task tree for an active pipeline
|
||||
*/
|
||||
@Injectable()
|
||||
export class RailsScheduler implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(RailsScheduler.name);
|
||||
private readonly intervalMs = 2000;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private lastSnapshot = new Map<string, string>(); // id → state
|
||||
private activePipelines = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly rails: RailsService,
|
||||
private readonly emitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.logger.log(`Rails poller starting (interval ${this.intervalMs}ms)`);
|
||||
this.start();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
private start(): void {
|
||||
this.timer = setInterval(() => {
|
||||
this.tick().catch((err) => {
|
||||
this.logger.warn(`poll error: ${(err as Error).message}`);
|
||||
});
|
||||
}, this.intervalMs);
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
const pipelines = await this.rails.listPipelines(50);
|
||||
|
||||
// Detect changes
|
||||
const changed: RailsPipelineSummary[] = [];
|
||||
for (const p of pipelines) {
|
||||
const last = this.lastSnapshot.get(p.id);
|
||||
if (last !== p.currentState) {
|
||||
changed.push(p);
|
||||
this.lastSnapshot.set(p.id, p.currentState);
|
||||
}
|
||||
// Track active (non-terminal)
|
||||
if (!['done', 'aborted'].includes(p.currentState)) {
|
||||
this.activePipelines.add(p.id);
|
||||
} else {
|
||||
this.activePipelines.delete(p.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit full snapshot every tick (cheap, dashboards love fresh data)
|
||||
this.emitter.emit('rails.pipelines.snapshot', { pipelines });
|
||||
|
||||
// Emit per-pipeline updates for changed ones
|
||||
for (const p of changed) {
|
||||
this.emitter.emit('rails.pipeline.updated', { pipeline: p });
|
||||
}
|
||||
|
||||
// Fetch sub-task trees for active pipelines (throttled)
|
||||
for (const id of this.activePipelines) {
|
||||
try {
|
||||
const tree = await this.rails.getSubTaskTree(id);
|
||||
this.emitter.emit('rails.subtasks.updated', {
|
||||
pipelineId: id,
|
||||
tree,
|
||||
});
|
||||
} catch {
|
||||
// ignore transient errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface RailsPipelineSummary {
|
||||
id: string;
|
||||
projectName: string;
|
||||
currentState: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RailsSubTaskNode {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
role: string;
|
||||
agentName: string;
|
||||
title: string;
|
||||
state: string;
|
||||
complexityScore: number | null;
|
||||
complexityTier: string | null;
|
||||
model: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
children: RailsSubTaskNode[];
|
||||
}
|
||||
|
||||
export interface RailsPipelineDetail {
|
||||
state: string;
|
||||
context: Record<string, unknown>;
|
||||
transitions: Array<{
|
||||
fromState: string;
|
||||
toState: string;
|
||||
eventType: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin HTTP client that reads from hanarang-rails orchestrator API.
|
||||
* The dashboard is a read-only consumer — it never writes to rails DB directly.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RailsService {
|
||||
private readonly logger = new Logger(RailsService.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.baseUrl = this.config.get<string>('RAILS_API_URL') ?? 'http://127.0.0.1:18800';
|
||||
}
|
||||
|
||||
async listPipelines(limit = 20): Promise<RailsPipelineSummary[]> {
|
||||
const data = await this.fetchJson<{ pipelines: RailsPipelineSummary[] }>(
|
||||
`/pipelines?limit=${limit}`,
|
||||
);
|
||||
return data.pipelines ?? [];
|
||||
}
|
||||
|
||||
async getPipeline(id: string): Promise<RailsPipelineDetail | null> {
|
||||
try {
|
||||
return await this.fetchJson<RailsPipelineDetail>(`/pipelines/${id}`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`pipeline ${id} fetch failed: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getSubTaskTree(pipelineId: string): Promise<RailsSubTaskNode[]> {
|
||||
const data = await this.fetchJson<{ tree: RailsSubTaskNode[] }>(
|
||||
`/api/pipelines/${pipelineId}/sub-tasks`,
|
||||
);
|
||||
return data.tree ?? [];
|
||||
}
|
||||
|
||||
async getSubTaskDetail(id: string): Promise<unknown | null> {
|
||||
try {
|
||||
return await this.fetchJson(`/api/sub-tasks/${id}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async listTransitions(opts: {
|
||||
pipelineId?: string;
|
||||
eventType?: string;
|
||||
limit?: number;
|
||||
}): Promise<unknown[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.pipelineId) params.set('pipelineId', opts.pipelineId);
|
||||
if (opts.eventType) params.set('eventType', opts.eventType);
|
||||
params.set('limit', String(opts.limit ?? 100));
|
||||
const data = await this.fetchJson<{ transitions: unknown[] }>(
|
||||
`/api/transitions?${params.toString()}`,
|
||||
);
|
||||
return data.transitions ?? [];
|
||||
}
|
||||
|
||||
async listEscalations(opts: {
|
||||
pipelineId?: string;
|
||||
resolved?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<unknown[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.pipelineId) params.set('pipelineId', opts.pipelineId);
|
||||
if (opts.resolved !== undefined) params.set('resolved', String(opts.resolved));
|
||||
params.set('limit', String(opts.limit ?? 50));
|
||||
const data = await this.fetchJson<{ escalations: unknown[] }>(
|
||||
`/api/escalations?${params.toString()}`,
|
||||
);
|
||||
return data.escalations ?? [];
|
||||
}
|
||||
|
||||
async startPipeline(input: {
|
||||
project: string;
|
||||
requirements: string;
|
||||
}): Promise<{ pipelineId: string; finalState: string; transitions: number }> {
|
||||
return this.postJson('/pipelines/start', input);
|
||||
}
|
||||
|
||||
async abortPipeline(id: string, reason: string): Promise<{ id: string; state: string }> {
|
||||
return this.postJson(`/pipelines/${id}/abort`, { reason });
|
||||
}
|
||||
|
||||
async health(): Promise<{ ok: boolean; service?: string }> {
|
||||
try {
|
||||
return await this.fetchJson('/health');
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchJson<T>(path: string): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`rails GET ${path} → ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 600_000);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`rails POST ${path} → ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,30 @@ export interface SisterRuntimeSnapshot {
|
||||
subagents: RuntimeSubagent[];
|
||||
}
|
||||
|
||||
export interface SisterConnectionState {
|
||||
gateway: 'connected' | 'disconnected' | 'unknown';
|
||||
runtime: 'connected' | 'disconnected' | 'unknown';
|
||||
}
|
||||
|
||||
export interface SisterActivityState {
|
||||
state: 'active' | 'idle' | 'unknown';
|
||||
label: string | null;
|
||||
lastActiveAt: string | null;
|
||||
}
|
||||
|
||||
export interface SisterWorkingState {
|
||||
state: 'active' | 'idle' | 'unknown';
|
||||
confidence: 'confirmed' | 'inferred' | 'none';
|
||||
reason: string | null;
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface SisterFreshness {
|
||||
lastSnapshotAt: string | null;
|
||||
}
|
||||
|
||||
export type SisterSource = 'live' | 'snapshot' | 'fallback';
|
||||
|
||||
export interface SisterStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -56,6 +80,11 @@ export interface SisterStatus {
|
||||
activeSessionLabel: string | null;
|
||||
gatewayConnected: boolean;
|
||||
subagents: RuntimeSubagent[];
|
||||
connection: SisterConnectionState;
|
||||
activity: SisterActivityState;
|
||||
working: SisterWorkingState;
|
||||
source: SisterSource;
|
||||
freshness: SisterFreshness;
|
||||
}
|
||||
|
||||
interface RuntimeProbeResult {
|
||||
@@ -299,6 +328,8 @@ export class SistersService {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const isActive = liveState === 'thinking' || liveState === 'tool_calling' || liveState === 'speaking';
|
||||
|
||||
return {
|
||||
id: sister.id,
|
||||
name: sister.name,
|
||||
@@ -312,6 +343,25 @@ export class SistersService {
|
||||
activeSessionLabel: runtime.activeSessionLabel ?? null,
|
||||
gatewayConnected,
|
||||
subagents: runtime.subagents ?? [],
|
||||
connection: {
|
||||
gateway: gatewayConnected ? 'connected' : 'disconnected',
|
||||
runtime: gatewayConnected ? 'connected' : 'disconnected',
|
||||
},
|
||||
activity: {
|
||||
state: isActive ? 'active' : 'idle',
|
||||
label: runtime.activeSessionLabel ?? (isActive ? 'Active session' : 'No recent operator event'),
|
||||
lastActiveAt: now.toISOString(),
|
||||
},
|
||||
working: {
|
||||
state: isActive ? 'active' : 'idle',
|
||||
confidence: isActive ? 'confirmed' : 'none',
|
||||
reason: isActive ? 'active_session' : null,
|
||||
label: runtime.currentTask ?? null,
|
||||
},
|
||||
source: 'live' as SisterSource,
|
||||
freshness: {
|
||||
lastSnapshotAt: now.toISOString(),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
this.logger.warn(`Failed to check status for ${sister.name}`);
|
||||
@@ -381,6 +431,25 @@ export class SistersService {
|
||||
activeSessionLabel: null,
|
||||
gatewayConnected: false,
|
||||
subagents: [],
|
||||
connection: {
|
||||
gateway: 'disconnected',
|
||||
runtime: 'disconnected',
|
||||
},
|
||||
activity: {
|
||||
state: 'unknown',
|
||||
label: null,
|
||||
lastActiveAt: sister.lastSeen?.toISOString() ?? null,
|
||||
},
|
||||
working: {
|
||||
state: 'unknown',
|
||||
confidence: 'none',
|
||||
reason: null,
|
||||
label: null,
|
||||
},
|
||||
source: 'snapshot' as SisterSource,
|
||||
freshness: {
|
||||
lastSnapshotAt: sister.lastSeen?.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
1775809622
|
||||
@@ -1 +0,0 @@
|
||||
1 1775809627
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,278 +3,506 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
|
||||
// ─── Styled ───
|
||||
const MainViewport = styled.div`
|
||||
// ─── Types ───
|
||||
|
||||
interface OrgUnit {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'hq' | 'functional_team';
|
||||
lead: string | null;
|
||||
memberIds: string[];
|
||||
}
|
||||
|
||||
interface OrgMember {
|
||||
id: string;
|
||||
type: 'sister' | 'agent';
|
||||
role: string;
|
||||
status: { connection: string; working: string };
|
||||
source: string;
|
||||
freshness: { lastSnapshotAt: string | null };
|
||||
}
|
||||
|
||||
interface IndependentAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerSister: string | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface OrgApiData {
|
||||
owner?: { name: string; role: string };
|
||||
sisters?: Array<{
|
||||
name: string;
|
||||
role: string;
|
||||
status?: string;
|
||||
}>;
|
||||
orgUnits?: OrgUnit[];
|
||||
members?: OrgMember[];
|
||||
reportingLines?: Array<{ from: string; to: string; kind: string }>;
|
||||
independentAgents?: IndependentAgent[];
|
||||
}
|
||||
|
||||
// ─── Fallback org structure (data-driven, not hardcoded tree) ───
|
||||
|
||||
const FALLBACK_ORG_UNITS: OrgUnit[] = [
|
||||
{ id: 'hq', name: '하나랑 HQ', kind: 'hq', lead: 'harang', memberIds: ['harang'] },
|
||||
{ id: 'delivery', name: 'Delivery', kind: 'functional_team', lead: 'narang', memberIds: ['narang'] },
|
||||
{ id: 'review', name: 'QA / Review', kind: 'functional_team', lead: 'darang', memberIds: ['darang'] },
|
||||
{ id: 'infra', name: 'Infra / Deploy', kind: 'functional_team', lead: 'erang', memberIds: ['erang'] },
|
||||
];
|
||||
|
||||
const FALLBACK_MEMBERS: OrgMember[] = [
|
||||
{ id: 'harang', type: 'sister', role: 'Orchestrator', status: { connection: 'unknown', working: 'unknown' }, source: 'fallback', freshness: { lastSnapshotAt: null } },
|
||||
{ id: 'narang', type: 'sister', role: 'Generator', status: { connection: 'unknown', working: 'unknown' }, source: 'fallback', freshness: { lastSnapshotAt: null } },
|
||||
{ id: 'darang', type: 'sister', role: 'Evaluator', status: { connection: 'unknown', working: 'unknown' }, source: 'fallback', freshness: { lastSnapshotAt: null } },
|
||||
{ id: 'erang', type: 'sister', role: 'Infra Manager', status: { connection: 'unknown', working: 'unknown' }, source: 'fallback', freshness: { lastSnapshotAt: null } },
|
||||
];
|
||||
|
||||
const FALLBACK_INDEPENDENT_AGENTS: IndependentAgent[] = [
|
||||
{ id: 'security-auditor', name: 'security-auditor', ownerSister: 'darang', source: 'fallback' },
|
||||
{ id: 'monitoring', name: 'monitoring', ownerSister: 'erang', source: 'fallback' },
|
||||
];
|
||||
|
||||
const MEMBER_DISPLAY: Record<string, string> = {
|
||||
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
|
||||
};
|
||||
|
||||
const LXC_ID: Record<string, number> = {
|
||||
harang: 104, narang: 105, darang: 106, erang: 107,
|
||||
};
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
function statusLabel(status: OrgMember['status']): string {
|
||||
if (status.working === 'active') return 'active working';
|
||||
if (status.connection === 'connected') return 'connected idle';
|
||||
if (status.connection === 'unknown') return 'unknown';
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
function statusTone(status: OrgMember['status']): 'active' | 'idle' | 'offline' | 'unknown' {
|
||||
if (status.working === 'active') return 'active';
|
||||
if (status.connection === 'connected') return 'idle';
|
||||
if (status.connection === 'unknown') return 'unknown';
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
function formatFreshness(ts: string | null): string {
|
||||
if (!ts) return 'NO DATA';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (!Number.isFinite(diff) || diff < 0) return 'UNKNOWN';
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `${sec}s AGO`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m AGO`;
|
||||
return `${Math.floor(min / 60)}h AGO`;
|
||||
}
|
||||
|
||||
function buildOrgFromLegacy(data: OrgApiData): {
|
||||
orgUnits: OrgUnit[];
|
||||
members: OrgMember[];
|
||||
independentAgents: IndependentAgent[];
|
||||
} {
|
||||
if (data.orgUnits && data.members) {
|
||||
return {
|
||||
orgUnits: data.orgUnits,
|
||||
members: data.members,
|
||||
independentAgents: data.independentAgents ?? FALLBACK_INDEPENDENT_AGENTS,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform legacy /api/org response into org chart model
|
||||
const sisters = data.sisters ?? [];
|
||||
if (sisters.length === 0) {
|
||||
return { orgUnits: FALLBACK_ORG_UNITS, members: FALLBACK_MEMBERS, independentAgents: FALLBACK_INDEPENDENT_AGENTS };
|
||||
}
|
||||
|
||||
const ROLE_TO_TEAM: Record<string, string> = {
|
||||
Orchestrator: 'hq',
|
||||
Generator: 'delivery',
|
||||
Evaluator: 'review',
|
||||
'Infra Manager': 'infra',
|
||||
};
|
||||
|
||||
const orgUnits: OrgUnit[] = [
|
||||
{ id: 'hq', name: '하나랑 HQ', kind: 'hq', lead: null, memberIds: [] },
|
||||
{ id: 'delivery', name: 'Delivery', kind: 'functional_team', lead: null, memberIds: [] },
|
||||
{ id: 'review', name: 'QA / Review', kind: 'functional_team', lead: null, memberIds: [] },
|
||||
{ id: 'infra', name: 'Infra / Deploy', kind: 'functional_team', lead: null, memberIds: [] },
|
||||
];
|
||||
|
||||
const members: OrgMember[] = sisters.map((s) => {
|
||||
const teamId = ROLE_TO_TEAM[s.role] ?? 'delivery';
|
||||
const unit = orgUnits.find((u) => u.id === teamId);
|
||||
if (unit) {
|
||||
unit.memberIds.push(s.name);
|
||||
if (!unit.lead) unit.lead = s.name;
|
||||
}
|
||||
return {
|
||||
id: s.name,
|
||||
type: 'sister' as const,
|
||||
role: s.role,
|
||||
status: {
|
||||
connection: s.status === 'online' || s.status === 'working' ? 'connected' : s.status === 'offline' ? 'disconnected' : 'unknown',
|
||||
working: s.status === 'working' ? 'active' : 'idle',
|
||||
},
|
||||
source: 'snapshot',
|
||||
freshness: { lastSnapshotAt: new Date().toISOString() },
|
||||
};
|
||||
});
|
||||
|
||||
return { orgUnits, members, independentAgents: FALLBACK_INDEPENDENT_AGENTS };
|
||||
}
|
||||
|
||||
// ─── Styled Components ───
|
||||
|
||||
const Shell = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
min-height: 80vh;
|
||||
`;
|
||||
|
||||
const HeaderInfo = styled.div`
|
||||
const OrgHeader = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-xl);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: var(--space-md);
|
||||
|
||||
span { color: var(--text-primary); }
|
||||
`;
|
||||
|
||||
const TreeContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
padding: var(--space-xl) 0;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
const NodeGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TerminalCard = styled.div<{ $level: 'high' | 'mid' | 'low' | 'none' }>`
|
||||
border: 1px solid ${({ $level }) => ({
|
||||
high: '#f43f5e',
|
||||
mid: '#3b82f6',
|
||||
low: '#10b981',
|
||||
none: '#525252',
|
||||
})[$level]};
|
||||
border-left-width: 3px;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--bg-main);
|
||||
min-width: 200px;
|
||||
max-width: 260px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 36px rgba(0,0,0,0.7);
|
||||
}
|
||||
`;
|
||||
|
||||
const NodeLabel = styled.div`
|
||||
font-size: 14px;
|
||||
const OrgTitle = styled.h1`
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-sm);
|
||||
|
||||
span {
|
||||
color: var(--text-secondary);
|
||||
margin-right: var(--space-xs);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
letter-spacing: -0.01em;
|
||||
`;
|
||||
|
||||
const BadgeRow = styled.div`
|
||||
const OrgMeta = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Badge = styled.div`
|
||||
background: #1a1a1a;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 2px 6px;
|
||||
font-size: 9px;
|
||||
const OrgBadge = styled.span<{ $tone: 'default' | 'active' | 'fallback' }>`
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid ${({ $tone }) =>
|
||||
$tone === 'active' ? '#00BFA5' : $tone === 'fallback' ? '#777' : 'var(--border-color)'};
|
||||
color: ${({ $tone }) =>
|
||||
$tone === 'active' ? '#00BFA5' : $tone === 'fallback' ? '#777' : 'var(--text-secondary)'};
|
||||
`;
|
||||
|
||||
const SectionLabel = styled.h2`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
|
||||
b { color: var(--text-primary); font-weight: normal; }
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const TreeConnector = styled.div`
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const TreeBranch = styled.div`
|
||||
const HQCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
border-left-width: 3px;
|
||||
border-left-color: #f6b26b;
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const TreeChildren = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-xl);
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(var(--space-xl) / 2 + 100px);
|
||||
right: calc(var(--space-xl) / 2 + 100px);
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
flex-direction: column;
|
||||
const TeamGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
|
||||
&::before { display: none; }
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const TeamCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const TeamTitle = styled.div`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
const TeamLead = styled.div`
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const MemberChipRow = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const MemberChip = styled.div<{ $tone: 'active' | 'idle' | 'offline' | 'unknown' }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
border: 1px solid ${({ $tone }) =>
|
||||
$tone === 'active' ? '#2979FF' : $tone === 'idle' ? '#00BFA5' : $tone === 'offline' ? '#555' : '#444'};
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
const StatusDot = styled.span<{ $tone: 'active' | 'idle' | 'offline' | 'unknown' }>`
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $tone }) =>
|
||||
$tone === 'active' ? '#2979FF' : $tone === 'idle' ? '#00BFA5' : $tone === 'offline' ? '#FF1744' : '#555'};
|
||||
`;
|
||||
|
||||
const MetaRow = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
`;
|
||||
|
||||
const AgentSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const AgentCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 389px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
const AgentName = styled.div`
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
const AgentMeta = styled.div`
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: var(--space-md) 0;
|
||||
`;
|
||||
|
||||
const PageFooter = styled.footer`
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
padding: var(--space-lg) 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
`;
|
||||
|
||||
type Level = 'high' | 'mid' | 'low' | 'none';
|
||||
|
||||
interface OrgData {
|
||||
owner: { name: string; role: string };
|
||||
sisters: Array<{
|
||||
name: string;
|
||||
role: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
lxcId?: number;
|
||||
}>;
|
||||
pipeline: Array<{ from: string; to: string; label: string }>;
|
||||
}
|
||||
|
||||
const LEVEL_MAP: Record<string, Level> = {
|
||||
Orchestrator: 'high',
|
||||
Generator: 'mid',
|
||||
Evaluator: 'mid',
|
||||
'Infra Manager': 'low',
|
||||
};
|
||||
|
||||
const ROLE_TAG: Record<string, string> = {
|
||||
Orchestrator: 'ORCH',
|
||||
Generator: 'GEN',
|
||||
Evaluator: 'EVAL',
|
||||
'Infra Manager': 'INFRA',
|
||||
};
|
||||
|
||||
const LXC_ID: Record<string, number> = {
|
||||
harang: 104,
|
||||
narang: 105,
|
||||
darang: 106,
|
||||
erang: 107,
|
||||
};
|
||||
// ─── Component ───
|
||||
|
||||
export default function OrgPage() {
|
||||
const [orgData, setOrgData] = useState<OrgData | null>(null);
|
||||
const ts = new Date().toLocaleDateString('ko-KR').replace(/\. /g, '.').replace('.', '') + '_' +
|
||||
new Date().toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
const [orgData, setOrgData] = useState<OrgApiData | null>(null);
|
||||
const [dataSource, setDataSource] = useState<'live' | 'snapshot' | 'fallback'>('fallback');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/api/org`)
|
||||
.then((r) => r.json())
|
||||
.then(setOrgData)
|
||||
.catch(() => {});
|
||||
.then((data) => {
|
||||
setOrgData(data);
|
||||
setDataSource(data.orgUnits ? 'snapshot' : data.sisters?.length > 0 ? 'snapshot' : 'fallback');
|
||||
})
|
||||
.catch(() => {
|
||||
setDataSource('fallback');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sisters = orgData?.sisters ?? [];
|
||||
const { orgUnits, members, independentAgents } = orgData
|
||||
? buildOrgFromLegacy(orgData)
|
||||
: { orgUnits: FALLBACK_ORG_UNITS, members: FALLBACK_MEMBERS, independentAgents: FALLBACK_INDEPENDENT_AGENTS };
|
||||
|
||||
const memberMap = new Map(members.map((m) => [m.id, m]));
|
||||
const hqUnit = orgUnits.find((u) => u.kind === 'hq');
|
||||
const functionalTeams = orgUnits.filter((u) => u.kind === 'functional_team');
|
||||
|
||||
const totalMembers = members.length;
|
||||
const totalUnits = orgUnits.length;
|
||||
|
||||
return (
|
||||
<MainViewport>
|
||||
<HeaderInfo>
|
||||
<div>SYS_TYPE: <span>CORE_ROOT_01</span></div>
|
||||
<div>TIMESTAMP: <span style={{ color: 'var(--text-secondary)' }}>{ts}</span></div>
|
||||
</HeaderInfo>
|
||||
<Shell>
|
||||
<OrgHeader>
|
||||
<OrgTitle>조직도</OrgTitle>
|
||||
<OrgMeta>
|
||||
<OrgBadge $tone={dataSource === 'fallback' ? 'fallback' : 'default'}>
|
||||
source: {dataSource}
|
||||
</OrgBadge>
|
||||
<OrgBadge $tone="default">{totalUnits} units</OrgBadge>
|
||||
<OrgBadge $tone="default">{totalMembers} members</OrgBadge>
|
||||
<OrgBadge $tone="default">{independentAgents.length} agents</OrgBadge>
|
||||
</OrgMeta>
|
||||
</OrgHeader>
|
||||
|
||||
<TreeContainer>
|
||||
<NodeGroup>
|
||||
{/* HQ */}
|
||||
<TerminalCard $level="high">
|
||||
<NodeLabel><span>[HQ]</span> 하나랑 글로벌</NodeLabel>
|
||||
<BadgeRow>
|
||||
<Badge>MEM: <b>{sisters.length + 1}</b></Badge>
|
||||
<Badge>S_ADMIN</Badge>
|
||||
<Badge>LV_09</Badge>
|
||||
</BadgeRow>
|
||||
</TerminalCard>
|
||||
<TreeConnector />
|
||||
|
||||
{/* 자매 레벨 */}
|
||||
<TreeBranch>
|
||||
<TreeChildren>
|
||||
{/* 하랑이 (Orchestrator) */}
|
||||
{sisters.filter((s) => s.role === 'Orchestrator').map((s) => (
|
||||
<NodeGroup key={s.name}>
|
||||
<TerminalCard $level={LEVEL_MAP[s.role] ?? 'none'}>
|
||||
<NodeLabel>
|
||||
<span>[{ROLE_TAG[s.role] ?? s.role}]</span>
|
||||
{s.name === 'harang' ? '하랑이' : s.name}
|
||||
</NodeLabel>
|
||||
<BadgeRow>
|
||||
<Badge>LXC: <b>{LXC_ID[s.name] ?? '---'}</b></Badge>
|
||||
<Badge>{ROLE_TAG[s.role] ?? s.role}</Badge>
|
||||
<Badge>{s.status === 'online' ? 'ACTIVE' : s.status === 'offline' ? 'STBY' : 'RUN'}</Badge>
|
||||
</BadgeRow>
|
||||
</TerminalCard>
|
||||
<TreeConnector />
|
||||
<TreeBranch>
|
||||
<TreeChildren>
|
||||
{/* 나랑/다랑/이랑 */}
|
||||
{sisters.filter((s2) => s2.role !== 'Orchestrator').map((s2) => (
|
||||
<NodeGroup key={s2.name}>
|
||||
<TerminalCard $level={LEVEL_MAP[s2.role] ?? 'none'}>
|
||||
<NodeLabel>
|
||||
{s2.name === 'narang' ? '나랑이' : s2.name === 'darang' ? '다랑이' : '이랑이'}
|
||||
</NodeLabel>
|
||||
<BadgeRow>
|
||||
<Badge>LXC: <b>{LXC_ID[s2.name] ?? '---'}</b></Badge>
|
||||
<Badge>{ROLE_TAG[s2.role] ?? s2.role}</Badge>
|
||||
<Badge>{s2.status === 'online' ? 'ON' : s2.status === 'offline' ? '--' : 'RUN'}</Badge>
|
||||
</BadgeRow>
|
||||
</TerminalCard>
|
||||
</NodeGroup>
|
||||
))}
|
||||
</TreeChildren>
|
||||
</TreeBranch>
|
||||
</NodeGroup>
|
||||
))}
|
||||
|
||||
{/* Orchestrator 없을 때 fallback */}
|
||||
{sisters.filter((s) => s.role === 'Orchestrator').length === 0 && (
|
||||
<NodeGroup>
|
||||
<TerminalCard $level="none">
|
||||
<NodeLabel>데이터 없음</NodeLabel>
|
||||
<BadgeRow><Badge>N/A</Badge></BadgeRow>
|
||||
</TerminalCard>
|
||||
</NodeGroup>
|
||||
{/* HQ Section */}
|
||||
{hqUnit && (
|
||||
<>
|
||||
<SectionLabel>HQ</SectionLabel>
|
||||
<HQCard>
|
||||
<TeamTitle>{hqUnit.name}</TeamTitle>
|
||||
<TeamLead>
|
||||
{hqUnit.lead
|
||||
? `LEAD: ${MEMBER_DISPLAY[hqUnit.lead] ?? hqUnit.lead}`
|
||||
: 'LEAD 미정'}
|
||||
</TeamLead>
|
||||
<MemberChipRow>
|
||||
{hqUnit.memberIds.map((id) => {
|
||||
const member = memberMap.get(id);
|
||||
const tone = member ? statusTone(member.status) : 'unknown';
|
||||
return (
|
||||
<MemberChip key={id} $tone={tone}>
|
||||
<SisterAvatar name={id} size={20} />
|
||||
<StatusDot $tone={tone} />
|
||||
{MEMBER_DISPLAY[id] ?? id}
|
||||
</MemberChip>
|
||||
);
|
||||
})}
|
||||
</MemberChipRow>
|
||||
<MetaRow>
|
||||
{hqUnit.memberIds.map((id) => {
|
||||
const member = memberMap.get(id);
|
||||
return (
|
||||
<span key={id}>
|
||||
{MEMBER_DISPLAY[id] ?? id}: {member ? statusLabel(member.status) : 'unknown'}
|
||||
{LXC_ID[id] ? ` · LXC ${LXC_ID[id]}` : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</MetaRow>
|
||||
</HQCard>
|
||||
</>
|
||||
)}
|
||||
</TreeChildren>
|
||||
</TreeBranch>
|
||||
</NodeGroup>
|
||||
</TreeContainer>
|
||||
|
||||
{/* Functional Teams */}
|
||||
<SectionLabel>FUNCTIONAL TEAMS</SectionLabel>
|
||||
<TeamGrid>
|
||||
{functionalTeams.map((team) => (
|
||||
<TeamCard key={team.id}>
|
||||
<TeamTitle>{team.name}</TeamTitle>
|
||||
<TeamLead>
|
||||
{team.lead
|
||||
? `LEAD: ${MEMBER_DISPLAY[team.lead] ?? team.lead}`
|
||||
: 'LEAD 미정'}
|
||||
</TeamLead>
|
||||
<MemberChipRow>
|
||||
{team.memberIds.map((id) => {
|
||||
const member = memberMap.get(id);
|
||||
const tone = member ? statusTone(member.status) : 'unknown';
|
||||
return (
|
||||
<MemberChip key={id} $tone={tone}>
|
||||
<SisterAvatar name={id} size={20} />
|
||||
<StatusDot $tone={tone} />
|
||||
{MEMBER_DISPLAY[id] ?? id}
|
||||
</MemberChip>
|
||||
);
|
||||
})}
|
||||
</MemberChipRow>
|
||||
<MetaRow>
|
||||
{team.memberIds.map((id) => {
|
||||
const member = memberMap.get(id);
|
||||
return (
|
||||
<span key={id}>
|
||||
{member?.role ?? 'unknown'}
|
||||
{member ? ` · ${statusLabel(member.status)}` : ''}
|
||||
{member ? ` · ${member.source}` : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span>freshness: {formatFreshness(
|
||||
team.memberIds
|
||||
.map((id) => memberMap.get(id)?.freshness.lastSnapshotAt)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1) ?? null
|
||||
)}</span>
|
||||
</MetaRow>
|
||||
</TeamCard>
|
||||
))}
|
||||
</TeamGrid>
|
||||
|
||||
{/* Independent Agents */}
|
||||
<SectionLabel>INDEPENDENT AGENTS</SectionLabel>
|
||||
<AgentSection>
|
||||
{independentAgents.length === 0 ? (
|
||||
<EmptyState>INDEPENDENT AGENTS 없음</EmptyState>
|
||||
) : (
|
||||
independentAgents.map((agent) => (
|
||||
<AgentCard key={agent.id}>
|
||||
<div>
|
||||
<AgentName>{agent.name}</AgentName>
|
||||
<AgentMeta>
|
||||
{agent.ownerSister
|
||||
? `owner: ${MEMBER_DISPLAY[agent.ownerSister] ?? agent.ownerSister}`
|
||||
: 'independent'}
|
||||
</AgentMeta>
|
||||
</div>
|
||||
<MetaRow>
|
||||
<span>source: {agent.source}</span>
|
||||
</MetaRow>
|
||||
</AgentCard>
|
||||
))
|
||||
)}
|
||||
</AgentSection>
|
||||
|
||||
<PageFooter>
|
||||
<div>TOTAL_NODES: {String(sisters.length + 1).padStart(2, '0')}</div>
|
||||
<div>STATUS: SYNC_COMPLETE</div>
|
||||
<div>UNITS: {String(totalUnits).padStart(2, '0')}</div>
|
||||
<div>MEMBERS: {String(totalMembers).padStart(2, '0')}</div>
|
||||
<div>AGENTS: {String(independentAgents.length).padStart(2, '0')}</div>
|
||||
<div>SOURCE: {dataSource.toUpperCase()}</div>
|
||||
</PageFooter>
|
||||
</MainViewport>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ const ConnectionDot = styled.span<{ $connected: boolean }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $connected }) => ($connected ? '#8dffb2' : '#ff8d7a')};
|
||||
background: ${({ $connected }) => ($connected ? '#8dffb2' : '#888')};
|
||||
box-shadow: ${({ $connected }) => ($connected ? '0 0 8px rgba(141,255,178,0.45)' : 'none')};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
@@ -958,8 +958,8 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<Summary>RUNTIME / EVENTS / DOCS</Summary>
|
||||
<InlineStats>
|
||||
<Chip $tone={connected ? 'active' : 'muted'}>{connected ? 'socket on' : 'socket off'}</Chip>
|
||||
<Chip $tone="default">snapshot {formatSyncLabel(derived.freshness.generatedAt)}</Chip>
|
||||
<Chip $tone={connected ? 'active' : 'default'}>{connected ? 'EVENT STREAM ON' : 'EVENT STREAM OFF'}</Chip>
|
||||
<Chip $tone="default">{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>
|
||||
@@ -999,21 +999,33 @@ export default function DashboardPage() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<ConnectionDot $connected={connected} />
|
||||
<div>
|
||||
<Eyebrow>Event stream</Eyebrow>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{connected ? 'SOCKET ON / EVENT MIRRORED' : 'SOCKET OFF / POLLING'}</div>
|
||||
<Eyebrow>Connection</Eyebrow>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{connected ? 'EVENT STREAM ON' : 'EVENT STREAM OFF'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<LabelMeta><span>SYNC:</span>{formatSyncLabel(derived.freshness.activityLatestAt)}</LabelMeta>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
|
||||
<LabelMeta><span>FRESHNESS:</span>{formatSyncLabel(derived.freshness.activityLatestAt)}</LabelMeta>
|
||||
<LabelMeta><span>SOURCE:</span>{connected ? 'MIRRORED EVENT' : 'SNAPSHOT'}</LabelMeta>
|
||||
</div>
|
||||
</ConnectionCard>
|
||||
|
||||
<OperatorRow>
|
||||
<SisterAvatar name={derived.activeProject?.ownerSister ?? 'narang'} size={44} />
|
||||
{derived.activeProject?.ownerSister ? (
|
||||
<>
|
||||
<SisterAvatar name={derived.activeProject.ownerSister} size={44} />
|
||||
<OperatorText>
|
||||
<OperatorName>{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.label ?? '나랑이'}</OperatorName>
|
||||
<OperatorName>{SISTER_META[derived.activeProject.ownerSister]?.label ?? derived.activeProject.ownerSister}</OperatorName>
|
||||
<OperatorMeta>
|
||||
{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.role ?? 'Implementation'} / SNAPSHOT OWNER
|
||||
{SISTER_META[derived.activeProject.ownerSister]?.role ?? 'unknown'} / SNAPSHOT OWNER
|
||||
</OperatorMeta>
|
||||
</OperatorText>
|
||||
</>
|
||||
) : (
|
||||
<OperatorText>
|
||||
<OperatorName>NO ACTIVE OWNER</OperatorName>
|
||||
<OperatorMeta>UNASSIGNED</OperatorMeta>
|
||||
</OperatorText>
|
||||
)}
|
||||
</OperatorRow>
|
||||
|
||||
<QuickLinks>
|
||||
@@ -1104,7 +1116,7 @@ export default function DashboardPage() {
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Activity feed</Eyebrow>
|
||||
<PanelTitle>EVENT MIRROR</PanelTitle>
|
||||
<PanelDesc>WEBSOCKET + POLLING SNAPSHOT</PanelDesc>
|
||||
<PanelDesc>{connected ? 'SOURCE: MIRRORED EVENT' : 'SOURCE: SNAPSHOT'}</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>EVENT:</span>{String(activityItems.length).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
@@ -1141,7 +1153,7 @@ export default function DashboardPage() {
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Sprint metrics</Eyebrow>
|
||||
<PanelTitle>SPRINT METRICS</PanelTitle>
|
||||
<PanelDesc>POLLING SNAPSHOT</PanelDesc>
|
||||
<PanelDesc>SOURCE: SNAPSHOT</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>SPRINT:</span>{String(derived.totalSprints).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
@@ -1187,7 +1199,7 @@ export default function DashboardPage() {
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Infrastructure overview</Eyebrow>
|
||||
<PanelTitle>RUNTIME SURFACE</PanelTitle>
|
||||
<PanelDesc>LIVE / SNAPSHOT / DOC-DERIVED</PanelDesc>
|
||||
<PanelDesc>SOURCE: {connected ? 'LIVE' : 'SNAPSHOT'} / DOC-DERIVED</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>NODE:</span>{String(sisters.length).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
@@ -1208,7 +1220,7 @@ export default function DashboardPage() {
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Mistake log & harness</Eyebrow>
|
||||
<PanelTitle>RULE / LOG BOARD</PanelTitle>
|
||||
<PanelDesc>DOC-DERIVED / ACTIVITY</PanelDesc>
|
||||
<PanelDesc>SOURCE: DOC-DERIVED / ACTIVITY</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>RULE:</span>{String(derived.board.length).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
|
||||
@@ -46,6 +46,13 @@ const GitItem = styled.a`border:1px solid var(--border-color);padding:12px var(-
|
||||
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 ParticipantSection = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`;
|
||||
const ParticipantCard = styled.div`border:1px solid var(--border-color);padding:var(--space-sm) var(--space-md);display:flex;flex-direction:column;gap:6px;`;
|
||||
const ParticipantBadgeRow = styled.div`display:flex;gap:var(--space-xs);flex-wrap:wrap;`;
|
||||
const WhyBadge = styled.span`font-family:var(--font-mono);font-size:9px;text-transform:uppercase;letter-spacing:0.06em;padding:2px 6px;border:1px solid #2979FF;color:#2979FF;white-space:nowrap;`;
|
||||
const SourceBadge = styled.span`font-family:var(--font-mono);font-size:9px;text-transform:uppercase;letter-spacing:0.06em;padding:2px 6px;border:1px solid #777;color:#777;white-space:nowrap;`;
|
||||
const EmptyParticipant = styled.div`font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.06em;padding:var(--space-md) 0;`;
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = { harang: 'ORCHESTRATOR', narang: 'GENERATOR', darang: 'EVALUATOR', erang: 'INFRA' };
|
||||
const ROLE_NAME: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
|
||||
|
||||
@@ -87,12 +94,37 @@ export default function ProjectDetailPage() {
|
||||
}, [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) => ({
|
||||
const participantModel = useMemo(() => {
|
||||
const taskAssigneeNames = Array.from(new Set(allTasks.map((task) => task.assignee).filter(Boolean)));
|
||||
const taskCountByAssignee: Record<string, number> = {};
|
||||
for (const task of allTasks) {
|
||||
if (task.assignee) {
|
||||
taskCountByAssignee[task.assignee] = (taskCountByAssignee[task.assignee] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const participants: { name: string; whyVisible: string[]; source: string; status: string }[] = [];
|
||||
for (const name of taskAssigneeNames) {
|
||||
const reasons: string[] = [];
|
||||
if (taskCountByAssignee[name]) reasons.push('task_assignee');
|
||||
const sisterStatus = sisters.find((s) => s.name === name)?.status ?? 'offline';
|
||||
if (sisterStatus === 'working' || sisterStatus === 'online') reasons.push('current_implementer');
|
||||
participants.push({
|
||||
name,
|
||||
status: sisters.find((sister) => sister.name === name)?.status ?? 'offline',
|
||||
}));
|
||||
whyVisible: reasons.length > 0 ? reasons : ['task_assignee'],
|
||||
source: 'snapshot',
|
||||
status: sisterStatus,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currentOwners: [] as { name: string; source: string }[],
|
||||
participants,
|
||||
taskAssignees: taskAssigneeNames.map((name) => ({
|
||||
name,
|
||||
taskCount: taskCountByAssignee[name] ?? 0,
|
||||
})),
|
||||
};
|
||||
}, [allTasks, sisters]);
|
||||
const hotfixes = history.filter((entry) => entry.kind === 'hotfix');
|
||||
const currentSprintMeta = useMemo(() => {
|
||||
@@ -198,21 +230,72 @@ export default function ProjectDetailPage() {
|
||||
</Panel>
|
||||
|
||||
<Panel $column="side" $mobileOrder={6}>
|
||||
<SectionTitle><span>ASSIGNED NODES</span><LabelMeta>LIVE STATUS</LabelMeta></SectionTitle>
|
||||
<NodeStack>
|
||||
{assignedNodes.map((node) => (
|
||||
<NodeMiniCard key={node.name}>
|
||||
<SectionTitle><span>CURRENT OWNERS</span><LabelMeta>SNAPSHOT</LabelMeta></SectionTitle>
|
||||
<ParticipantSection>
|
||||
{participantModel.currentOwners.length === 0 ? (
|
||||
<EmptyParticipant>NO ACTIVE OWNER</EmptyParticipant>
|
||||
) : (
|
||||
participantModel.currentOwners.map((owner) => (
|
||||
<NodeMiniCard key={owner.name}>
|
||||
<NodeInfo>
|
||||
<SisterAvatar name={node.name} size={28} />
|
||||
<SisterAvatar name={owner.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>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[owner.name] ?? owner.name}</div>
|
||||
<Meta>{ROLE_LABEL[owner.name] ?? 'OWNER'}</Meta>
|
||||
</div>
|
||||
</NodeInfo>
|
||||
<NodeDot $status={node.status} />
|
||||
<SourceBadge>{owner.source}</SourceBadge>
|
||||
</NodeMiniCard>
|
||||
))
|
||||
)}
|
||||
</ParticipantSection>
|
||||
|
||||
<SectionTitle style={{ marginTop: 'var(--space-lg)' }}><span>PARTICIPANTS</span><LabelMeta>SNAPSHOT</LabelMeta></SectionTitle>
|
||||
<ParticipantSection>
|
||||
{participantModel.participants.length === 0 ? (
|
||||
<EmptyParticipant>NO CONFIRMED ACTIVE PARTICIPANT</EmptyParticipant>
|
||||
) : (
|
||||
participantModel.participants.map((p) => (
|
||||
<ParticipantCard key={p.name}>
|
||||
<NodeInfo>
|
||||
<SisterAvatar name={p.name} size={28} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[p.name] ?? p.name}</div>
|
||||
<Meta>{ROLE_LABEL[p.name] ?? 'PARTICIPANT'}</Meta>
|
||||
</div>
|
||||
</NodeInfo>
|
||||
<ParticipantBadgeRow>
|
||||
{p.whyVisible.map((reason) => (
|
||||
<WhyBadge key={reason}>{reason}</WhyBadge>
|
||||
))}
|
||||
<SourceBadge>{p.source}</SourceBadge>
|
||||
</ParticipantBadgeRow>
|
||||
</ParticipantCard>
|
||||
))
|
||||
)}
|
||||
</ParticipantSection>
|
||||
|
||||
<SectionTitle style={{ marginTop: 'var(--space-lg)' }}><span>TASK ASSIGNEES</span><LabelMeta>{participantModel.taskAssignees.length} ASSIGNEES</LabelMeta></SectionTitle>
|
||||
<ParticipantSection>
|
||||
{participantModel.taskAssignees.length === 0 ? (
|
||||
<EmptyParticipant>NO TASK ASSIGNEE DATA</EmptyParticipant>
|
||||
) : (
|
||||
<NodeStack>
|
||||
{participantModel.taskAssignees.map((a) => (
|
||||
<NodeMiniCard key={a.name}>
|
||||
<NodeInfo>
|
||||
<SisterAvatar name={a.name} size={28} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[a.name] ?? a.name}</div>
|
||||
<Meta>{a.taskCount} TASKS</Meta>
|
||||
</div>
|
||||
</NodeInfo>
|
||||
<SourceBadge>snapshot</SourceBadge>
|
||||
</NodeMiniCard>
|
||||
))}
|
||||
</NodeStack>
|
||||
)}
|
||||
</ParticipantSection>
|
||||
</Panel>
|
||||
</ProjectGrid>
|
||||
)}
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { API_URL } from '@/lib/config';
|
||||
|
||||
interface Escalation {
|
||||
id: string;
|
||||
pipelineId: string;
|
||||
reason: string;
|
||||
errorCategory: string;
|
||||
stage: string;
|
||||
attempts: number;
|
||||
contextSnapshot: string;
|
||||
resolvedAt: string | null;
|
||||
resolution: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const Page = styled.main`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 32px 36px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Header = styled.header`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: -0.01em;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Filters = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const FilterBtn = styled.button<{ $active: boolean }>`
|
||||
padding: 8px 16px;
|
||||
background: ${({ $active }) => ($active ? '#5fafff' : 'transparent')};
|
||||
color: ${({ $active }) => ($active ? '#0a0a0a' : 'var(--text-primary)')};
|
||||
border: 1px solid ${({ $active }) => ($active ? '#5fafff' : 'var(--border-color)')};
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Cards = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const Card = styled.div<{ $resolved: boolean }>`
|
||||
background: var(--bg-input);
|
||||
border: 1px solid ${({ $resolved }) => ($resolved ? 'var(--border-color)' : '#ef444460')};
|
||||
border-left: 4px solid ${({ $resolved }) => ($resolved ? '#525252' : '#ef4444')};
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const CardHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Reason = styled.h3`
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const Tags = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Tag = styled.span<{ $color: string }>`
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: 12px;
|
||||
background: ${({ $color }) => $color}20;
|
||||
color: ${({ $color }) => $color};
|
||||
border: 1px solid ${({ $color }) => $color}60;
|
||||
`;
|
||||
|
||||
const Meta = styled.div`
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Snapshot = styled.details`
|
||||
margin-top: 4px;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 8px 0 0;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const Empty = styled.div`
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
`;
|
||||
|
||||
function categoryColor(c: string): string {
|
||||
switch (c) {
|
||||
case 'timeout':
|
||||
return '#f97316';
|
||||
case 'rate_limit':
|
||||
return '#eab308';
|
||||
case 'network':
|
||||
return '#3b82f6';
|
||||
case 'permission':
|
||||
return '#ef4444';
|
||||
case 'config':
|
||||
return '#a855f7';
|
||||
case 'invariant':
|
||||
return '#ec4899';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
function relTime(iso: string): string {
|
||||
try {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s 전`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m 전`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}h 전`;
|
||||
return `${Math.floor(sec / 86400)}d 전`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function EscalationsPage() {
|
||||
const [escalations, setEscalations] = useState<Escalation[]>([]);
|
||||
const [filter, setFilter] = useState<'all' | 'unresolved' | 'resolved'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '100');
|
||||
if (filter === 'unresolved') params.set('resolved', 'false');
|
||||
if (filter === 'resolved') params.set('resolved', 'true');
|
||||
|
||||
fetch(`${API_URL}/api/rails/escalations?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data: { escalations: Escalation[] }) => setEscalations(data.escalations))
|
||||
.catch(() => setEscalations([]));
|
||||
}, [filter]);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Header>
|
||||
<div>
|
||||
<Title>Escalations</Title>
|
||||
<Subtitle>
|
||||
자동 재시도 후에도 실패한 파이프라인 — 사용자 개입 대기
|
||||
</Subtitle>
|
||||
</div>
|
||||
<Filters>
|
||||
<FilterBtn $active={filter === 'all'} onClick={() => setFilter('all')}>
|
||||
전체
|
||||
</FilterBtn>
|
||||
<FilterBtn
|
||||
$active={filter === 'unresolved'}
|
||||
onClick={() => setFilter('unresolved')}
|
||||
>
|
||||
미해결
|
||||
</FilterBtn>
|
||||
<FilterBtn
|
||||
$active={filter === 'resolved'}
|
||||
onClick={() => setFilter('resolved')}
|
||||
>
|
||||
해결됨
|
||||
</FilterBtn>
|
||||
</Filters>
|
||||
</Header>
|
||||
|
||||
{escalations.length === 0 ? (
|
||||
<Empty>
|
||||
{filter === 'unresolved' ? '미해결 에스컬레이션 없음 ✓' : '에스컬레이션 없음'}
|
||||
</Empty>
|
||||
) : (
|
||||
<Cards>
|
||||
{escalations.map((e) => (
|
||||
<Card key={e.id} $resolved={!!e.resolvedAt}>
|
||||
<CardHeader>
|
||||
<Reason>{e.reason}</Reason>
|
||||
<Tags>
|
||||
<Tag $color={categoryColor(e.errorCategory)}>
|
||||
{e.errorCategory}
|
||||
</Tag>
|
||||
{e.stage && <Tag $color="#6b7280">stage: {e.stage}</Tag>}
|
||||
<Tag $color="#a855f7">attempts: {e.attempts}</Tag>
|
||||
{e.resolvedAt ? (
|
||||
<Tag $color="#22c55e">{e.resolution ?? 'resolved'}</Tag>
|
||||
) : (
|
||||
<Tag $color="#ef4444">unresolved</Tag>
|
||||
)}
|
||||
</Tags>
|
||||
</CardHeader>
|
||||
<Meta>
|
||||
<span>id: {e.id}</span>
|
||||
<span>pipeline: {e.pipelineId.slice(0, 12)}...</span>
|
||||
<span>created: {relTime(e.createdAt)}</span>
|
||||
{e.resolvedAt && <span>resolved: {relTime(e.resolvedAt)}</span>}
|
||||
</Meta>
|
||||
{e.contextSnapshot && (
|
||||
<Snapshot>
|
||||
<summary>Context snapshot</summary>
|
||||
<pre>
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(e.contextSnapshot), null, 2);
|
||||
} catch {
|
||||
return e.contextSnapshot;
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</Snapshot>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</Cards>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import { useRailsSocket } from '@/lib/useRailsSocket';
|
||||
|
||||
interface Transition {
|
||||
id: number;
|
||||
pipelineId: string;
|
||||
fromState: string;
|
||||
toState: string;
|
||||
eventType: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const Page = styled.main`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 32px 36px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Header = styled.header`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: -0.01em;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Live = styled.span<{ $on: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: ${({ $on }) => ($on ? '#22c55e' : 'var(--text-secondary)')};
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $on }) => ($on ? '#22c55e' : '#525252')};
|
||||
box-shadow: ${({ $on }) =>
|
||||
$on ? '0 0 0 4px rgba(34, 197, 94, 0.15)' : 'none'};
|
||||
}
|
||||
`;
|
||||
|
||||
const Filters = styled.div`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
min-width: 280px;
|
||||
`;
|
||||
|
||||
const Select = styled.select`
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const ClearBtn = styled.button`
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: #5fafff;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
`;
|
||||
|
||||
const Counter = styled.div`
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const LogTable = styled.div`
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Row = styled.div<{ $type: string }>`
|
||||
display: grid;
|
||||
grid-template-columns: 180px 90px 130px minmax(200px, 1fr) 130px;
|
||||
gap: 16px;
|
||||
padding: 12px 18px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
align-items: center;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
`;
|
||||
|
||||
const HeaderRow = styled(Row)`
|
||||
background: var(--bg-surface);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const Time = styled.span`
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const EventBadge = styled.span<{ $type: string }>`
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
background: ${({ $type }) => eventColor($type)};
|
||||
`;
|
||||
|
||||
const StateChip = styled.span<{ $state: string }>`
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
background: ${({ $state }) => stateColor($state)}30;
|
||||
color: ${({ $state }) => stateColor($state)};
|
||||
border: 1px solid ${({ $state }) => stateColor($state)}60;
|
||||
`;
|
||||
|
||||
const Arrow = styled.span`
|
||||
color: var(--text-secondary);
|
||||
margin: 0 6px;
|
||||
`;
|
||||
|
||||
const PidChip = styled.button`
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: #5fafff;
|
||||
border-style: solid;
|
||||
}
|
||||
`;
|
||||
|
||||
const Empty = styled.div`
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
function eventColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'REQUEST':
|
||||
return '#8b5cf6';
|
||||
case 'PLAN_READY':
|
||||
case 'IMPL_DONE':
|
||||
case 'APPROVE':
|
||||
case 'DEPLOY_DONE':
|
||||
return '#22c55e';
|
||||
case 'REQUEST_CHANGES':
|
||||
return '#f97316';
|
||||
case 'ERROR':
|
||||
case 'TIMEOUT':
|
||||
return '#ef4444';
|
||||
case 'ABORT':
|
||||
return '#525252';
|
||||
case 'RESUME':
|
||||
case 'RETRY':
|
||||
return '#5fafff';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
function stateColor(state: string): string {
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
return '#6b7280';
|
||||
case 'planning':
|
||||
case 'implementing':
|
||||
case 'reviewing':
|
||||
case 'deploying':
|
||||
return '#f97316';
|
||||
case 'done':
|
||||
return '#22c55e';
|
||||
case 'escalated':
|
||||
return '#ef4444';
|
||||
case 'aborted':
|
||||
return '#525252';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
const EVENT_TYPES = [
|
||||
'',
|
||||
'REQUEST',
|
||||
'PLAN_READY',
|
||||
'IMPL_DONE',
|
||||
'APPROVE',
|
||||
'REQUEST_CHANGES',
|
||||
'DEPLOY_DONE',
|
||||
'ERROR',
|
||||
'TIMEOUT',
|
||||
'ABORT',
|
||||
'RESUME',
|
||||
];
|
||||
|
||||
export default function RailsLogPage() {
|
||||
const [transitions, setTransitions] = useState<Transition[]>([]);
|
||||
const [pipelineFilter, setPipelineFilter] = useState('');
|
||||
const [eventFilter, setEventFilter] = useState('');
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
const { connected } = useRailsSocket({
|
||||
onPipelineUpdated: () => setTick((n) => n + 1),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '300');
|
||||
if (pipelineFilter) params.set('pipelineId', pipelineFilter);
|
||||
if (eventFilter) params.set('eventType', eventFilter);
|
||||
fetch(`${API_URL}/api/rails/transitions?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data: { transitions: Transition[] }) => setTransitions(data.transitions))
|
||||
.catch(() => setTransitions([]));
|
||||
}, [pipelineFilter, eventFilter, tick]);
|
||||
|
||||
const visible = useMemo(() => transitions, [transitions]);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Header>
|
||||
<div>
|
||||
<Title>SIEM Log</Title>
|
||||
<Subtitle>Rails state transitions — 결정론적 이벤트 스트림</Subtitle>
|
||||
</div>
|
||||
<Live $on={connected}>{connected ? 'LIVE' : 'OFFLINE'}</Live>
|
||||
</Header>
|
||||
|
||||
<Filters>
|
||||
<Input
|
||||
placeholder="pipeline id 필터 (ULID)"
|
||||
value={pipelineFilter}
|
||||
onChange={(e) => setPipelineFilter(e.target.value.trim())}
|
||||
/>
|
||||
<Select
|
||||
value={eventFilter}
|
||||
onChange={(e) => setEventFilter(e.target.value)}
|
||||
>
|
||||
{EVENT_TYPES.map((t) => (
|
||||
<option key={t || 'all'} value={t}>
|
||||
{t || '— all events —'}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{(pipelineFilter || eventFilter) && (
|
||||
<ClearBtn
|
||||
onClick={() => {
|
||||
setPipelineFilter('');
|
||||
setEventFilter('');
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</ClearBtn>
|
||||
)}
|
||||
<Counter>{visible.length} entries</Counter>
|
||||
</Filters>
|
||||
|
||||
<LogTable>
|
||||
<HeaderRow $type="">
|
||||
<span>Timestamp</span>
|
||||
<span>Event</span>
|
||||
<span>Pipeline</span>
|
||||
<span>Transition</span>
|
||||
<span>—</span>
|
||||
</HeaderRow>
|
||||
{visible.length === 0 ? (
|
||||
<Empty>No transitions matching the filters.</Empty>
|
||||
) : (
|
||||
visible.map((t) => (
|
||||
<Row key={t.id} $type={t.eventType}>
|
||||
<Time>{new Date(t.timestamp).toLocaleString('ko-KR')}</Time>
|
||||
<EventBadge $type={t.eventType}>{t.eventType}</EventBadge>
|
||||
<PidChip onClick={() => setPipelineFilter(t.pipelineId)}>
|
||||
{t.pipelineId.slice(0, 12)}...
|
||||
</PidChip>
|
||||
<span>
|
||||
<StateChip $state={t.fromState}>{t.fromState}</StateChip>
|
||||
<Arrow>→</Arrow>
|
||||
<StateChip $state={t.toState}>{t.toState}</StateChip>
|
||||
</span>
|
||||
<span />
|
||||
</Row>
|
||||
))
|
||||
)}
|
||||
</LogTable>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import {
|
||||
useRailsSocket,
|
||||
type RailsPipelineSummary,
|
||||
type RailsSubTaskNode,
|
||||
} from '@/lib/useRailsSocket';
|
||||
import SubTaskTree from '@/components/rails/SubTaskTree';
|
||||
import SubTaskDetailDrawer from '@/components/rails/SubTaskDetailDrawer';
|
||||
|
||||
const Page = styled.main`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
padding: 32px 36px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Header = styled.header`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const TitleBlock = styled.div``;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: -0.01em;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Live = styled.span<{ $on: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: ${({ $on }) => ($on ? '#22c55e' : 'var(--text-secondary)')};
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $on }) => ($on ? '#22c55e' : '#525252')};
|
||||
box-shadow: ${({ $on }) =>
|
||||
$on ? '0 0 0 4px rgba(34, 197, 94, 0.15)' : 'none'};
|
||||
}
|
||||
`;
|
||||
|
||||
const StartCard = styled.section`
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 24px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const StartLabel = styled.div`
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const StartRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 240px) 1fr auto;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 700px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-sans);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #5fafff;
|
||||
box-shadow: 0 0 0 3px rgba(95, 175, 255, 0.15);
|
||||
}
|
||||
`;
|
||||
|
||||
const StartButton = styled.button`
|
||||
padding: 12px 28px;
|
||||
background: #5fafff;
|
||||
color: #0a0a0a;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const Layout = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 440px) 1fr;
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const Pane = styled.section`
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 24px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-height: 420px;
|
||||
`;
|
||||
|
||||
const PaneHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
`;
|
||||
|
||||
const PaneTitle = styled.h2`
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Counter = styled.span`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const PipelineList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const PipelineCard = styled.button<{ $selected: boolean; $state: string }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 18px 20px;
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? 'var(--bg-surface)' : 'transparent'};
|
||||
border: 1px solid ${({ $selected }) =>
|
||||
$selected ? '#5fafff' : 'var(--border-color)'};
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #5fafff;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
`;
|
||||
|
||||
const CardTop = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const ProjectName = styled.span`
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
`;
|
||||
|
||||
const StateBadge = styled.span<{ $state: string }>`
|
||||
padding: 4px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
background: ${({ $state }) => stateBg($state)};
|
||||
letter-spacing: 0.04em;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const CardMeta = styled.div`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
const Empty = styled.div`
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const DetailHeader = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
`;
|
||||
|
||||
const DetailTitle = styled.h3`
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
`;
|
||||
|
||||
const DetailMeta = styled.div`
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
function stateBg(state: string): string {
|
||||
switch (state) {
|
||||
case 'done':
|
||||
return '#22c55e';
|
||||
case 'escalated':
|
||||
return '#ef4444';
|
||||
case 'aborted':
|
||||
return '#525252';
|
||||
case 'planning':
|
||||
case 'implementing':
|
||||
case 'reviewing':
|
||||
case 'deploying':
|
||||
return '#f97316';
|
||||
default:
|
||||
return '#525252';
|
||||
}
|
||||
}
|
||||
|
||||
function relTime(iso: string): string {
|
||||
try {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s 전`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m 전`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}h 전`;
|
||||
return `${Math.floor(sec / 86400)}d 전`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function RailsPage() {
|
||||
const [pipelines, setPipelines] = useState<RailsPipelineSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [tree, setTree] = useState<RailsSubTaskNode[]>([]);
|
||||
const [projectInput, setProjectInput] = useState('');
|
||||
const [reqInput, setReqInput] = useState('');
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [detailNodeId, setDetailNodeId] = useState<string | null>(null);
|
||||
|
||||
const { connected } = useRailsSocket({
|
||||
onPipelinesSnapshot: (next) => {
|
||||
setPipelines(next);
|
||||
setSelectedId((prev) => prev ?? (next[0]?.id ?? null));
|
||||
},
|
||||
onSubTasksUpdated: (pipelineId, nextTree) => {
|
||||
setSelectedId((prev) => {
|
||||
if (pipelineId === prev) setTree(nextTree);
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/api/rails/pipelines`, { credentials: 'include' })
|
||||
.then((r) => r.json())
|
||||
.then((data: { pipelines: RailsPipelineSummary[] }) => {
|
||||
setPipelines(data.pipelines);
|
||||
if (data.pipelines.length > 0) setSelectedId(data.pipelines[0]!.id);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
fetch(`${API_URL}/api/rails/pipelines/${selectedId}/sub-tasks`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data: { tree: RailsSubTaskNode[] }) => setTree(data.tree))
|
||||
.catch(() => setTree([]));
|
||||
}, [selectedId]);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
if (!projectInput.trim()) return;
|
||||
setStarting(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/rails/pipelines/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
project: projectInput.trim(),
|
||||
requirements: reqInput.trim(),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { pipelineId: string };
|
||||
setSelectedId(data.pipelineId);
|
||||
setProjectInput('');
|
||||
setReqInput('');
|
||||
}
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}, [projectInput, reqInput]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => pipelines.find((p) => p.id === selectedId) ?? null,
|
||||
[pipelines, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Header>
|
||||
<TitleBlock>
|
||||
<Title>Rails Orchestrator</Title>
|
||||
<Subtitle>결정론적 4자매 파이프라인 관제</Subtitle>
|
||||
</TitleBlock>
|
||||
<Live $on={connected}>{connected ? 'LIVE' : 'OFFLINE'}</Live>
|
||||
</Header>
|
||||
|
||||
<StartCard>
|
||||
<StartLabel>새 파이프라인 시작</StartLabel>
|
||||
<StartRow>
|
||||
<Input
|
||||
placeholder="프로젝트 이름"
|
||||
value={projectInput}
|
||||
onChange={(e) => setProjectInput(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="요구사항 (예: TODO 앱 MVP, 로그인 추가)"
|
||||
value={reqInput}
|
||||
onChange={(e) => setReqInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void handleStart();
|
||||
}}
|
||||
/>
|
||||
<StartButton
|
||||
disabled={starting || !projectInput.trim()}
|
||||
onClick={handleStart}
|
||||
>
|
||||
{starting ? '시작 중...' : 'Start'}
|
||||
</StartButton>
|
||||
</StartRow>
|
||||
</StartCard>
|
||||
|
||||
<Layout>
|
||||
<Pane>
|
||||
<PaneHeader>
|
||||
<PaneTitle>Pipelines</PaneTitle>
|
||||
<Counter>{pipelines.length}건</Counter>
|
||||
</PaneHeader>
|
||||
|
||||
{pipelines.length === 0 ? (
|
||||
<Empty>파이프라인이 없어. 위에서 시작해봐.</Empty>
|
||||
) : (
|
||||
<PipelineList>
|
||||
{pipelines.map((p) => (
|
||||
<PipelineCard
|
||||
key={p.id}
|
||||
$selected={p.id === selectedId}
|
||||
$state={p.currentState}
|
||||
onClick={() => setSelectedId(p.id)}
|
||||
>
|
||||
<CardTop>
|
||||
<ProjectName>{p.projectName}</ProjectName>
|
||||
<StateBadge $state={p.currentState}>
|
||||
{p.currentState}
|
||||
</StateBadge>
|
||||
</CardTop>
|
||||
<CardMeta>
|
||||
<span>{p.id.slice(0, 12)}</span>
|
||||
<span>·</span>
|
||||
<span>{relTime(p.updatedAt)}</span>
|
||||
</CardMeta>
|
||||
</PipelineCard>
|
||||
))}
|
||||
</PipelineList>
|
||||
)}
|
||||
</Pane>
|
||||
|
||||
<Pane>
|
||||
{selected ? (
|
||||
<>
|
||||
<DetailHeader>
|
||||
<PaneTitle>Pipeline Detail</PaneTitle>
|
||||
<DetailTitle>{selected.projectName}</DetailTitle>
|
||||
<DetailMeta>
|
||||
<span>{selected.id}</span>
|
||||
<span>state: {selected.currentState}</span>
|
||||
<span>{new Date(selected.createdAt).toLocaleString('ko-KR')}</span>
|
||||
</DetailMeta>
|
||||
</DetailHeader>
|
||||
|
||||
{tree.length > 0 ? (
|
||||
<SubTaskTree tree={tree} onSelectNode={setDetailNodeId} />
|
||||
) : (
|
||||
<Empty>아직 sub-task 가 생성 안 됐어.</Empty>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Empty>왼쪽에서 파이프라인을 선택해.</Empty>
|
||||
)}
|
||||
</Pane>
|
||||
</Layout>
|
||||
|
||||
{detailNodeId && (
|
||||
<SubTaskDetailDrawer
|
||||
subTaskId={detailNodeId}
|
||||
onClose={() => setDetailNodeId(null)}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,6 @@ import { useAuth } from '@/lib/AuthContext';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/', label: '대시' },
|
||||
{ href: '/rails', label: '레일' },
|
||||
{ href: '/rails/log', label: '로그' },
|
||||
{ href: '/rails/escalations', label: '경보' },
|
||||
{ href: '/office', label: '오피스' },
|
||||
{ href: '/projects', label: '프로' },
|
||||
{ href: '/activities', label: '활동' },
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import styled, { keyframes, css } from 'styled-components';
|
||||
import type { RailsSubTaskNode, RailsPipelineSummary } from '@/lib/useRailsSocket';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
|
||||
const SISTERS = [
|
||||
{ key: 'harang', label: '하랑', role: 'Planner', color: '#3b82f6', accent: '#60a5fa', x: 0, y: 0 },
|
||||
{ key: 'narang', label: '나랑', role: 'Generator', color: '#22c55e', accent: '#4ade80', x: 1, y: 0 },
|
||||
{ key: 'darang', label: '다랑', role: 'Evaluator', color: '#f43f5e', accent: '#fb7185', x: 0, y: 1 },
|
||||
{ key: 'erang', label: '이랑', role: 'Infra', color: '#f97316', accent: '#fb923c', x: 1, y: 1 },
|
||||
] as const;
|
||||
|
||||
type SisterKey = (typeof SISTERS)[number]['key'];
|
||||
|
||||
interface OfficeFloorProps {
|
||||
pipelines: RailsPipelineSummary[];
|
||||
treesByPipeline: Map<string, RailsSubTaskNode[]>;
|
||||
selectedSister: SisterKey | null;
|
||||
onSelectSister: (key: SisterKey | null) => void;
|
||||
}
|
||||
|
||||
interface SisterStats {
|
||||
total: number;
|
||||
running: number;
|
||||
done: number;
|
||||
failed: number;
|
||||
models: Set<string>;
|
||||
}
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const Floor = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 28px;
|
||||
min-height: 540px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Overlay = styled.svg`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
`;
|
||||
|
||||
const flowDash = keyframes`
|
||||
to { stroke-dashoffset: -8; }
|
||||
`;
|
||||
|
||||
const FlowLine = styled.line<{ $flowing: boolean }>`
|
||||
stroke: ${({ $flowing }) =>
|
||||
$flowing ? '#5fafff' : 'rgba(95, 175, 255, 0.15)'};
|
||||
stroke-width: 0.4;
|
||||
stroke-dasharray: ${({ $flowing }) => ($flowing ? '1.6 1.2' : '0.6 0.6')};
|
||||
animation: ${({ $flowing }) =>
|
||||
$flowing
|
||||
? css`
|
||||
${flowDash} 1.2s linear infinite
|
||||
`
|
||||
: 'none'};
|
||||
`;
|
||||
|
||||
const pulse = keyframes`
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0.4); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(255, 165, 0, 0); }
|
||||
`;
|
||||
|
||||
const blink = keyframes`
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
`;
|
||||
|
||||
const Desk = styled.button<{ $color: string; $active: boolean; $running: boolean; $selected: boolean }>`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: ${({ $color }) => `${$color}10`};
|
||||
border: 1.5px solid ${({ $color, $selected }) => ($selected ? $color : `${$color}50`)};
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 220px;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
animation: ${({ $running }) => ($running ? pulse : 'none')} 1.6s ease-in-out infinite;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: ${({ $color }) => $color};
|
||||
}
|
||||
`;
|
||||
|
||||
const DeskHead = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const SisterBadge = styled.div<{ $color: string }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const AvatarFrame = styled.div<{ $color: string; $running: boolean }>`
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
background: linear-gradient(135deg, ${({ $color }) => $color}, ${({ $color }) => `${$color}60`});
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: ${({ $color, $running }) =>
|
||||
$running ? `0 4px 16px ${$color}60` : `0 2px 8px ${$color}30`};
|
||||
`;
|
||||
|
||||
const NameBlock = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Name = styled.span`
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const Role = styled.span`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
`;
|
||||
|
||||
const StatusDot = styled.div<{ $running: boolean }>`
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $running }) => ($running ? '#22c55e' : '#525252')};
|
||||
flex-shrink: 0;
|
||||
${({ $running }) =>
|
||||
$running &&
|
||||
`
|
||||
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.2);
|
||||
animation: ${blink} 1.4s ease-in-out infinite;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Stats = styled.div`
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
`;
|
||||
|
||||
const Stat = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
const StatNum = styled.span`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const StatLabel = styled.span`
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
`;
|
||||
|
||||
const Workers = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
`;
|
||||
|
||||
const Worker = styled.div<{ $role: string; $state: string }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: ${({ $role }) => roleColor($role)}20;
|
||||
border: 1px solid ${({ $role }) => roleColor($role)}60;
|
||||
color: ${({ $role }) => roleColor($role)};
|
||||
${({ $state }) =>
|
||||
$state === 'running' &&
|
||||
`animation: ${blink} 1.2s ease-in-out infinite;`}
|
||||
`;
|
||||
|
||||
const Empty = styled.span`
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
`;
|
||||
|
||||
function roleColor(role: string): string {
|
||||
switch (role) {
|
||||
case 'manager':
|
||||
return '#8b5cf6';
|
||||
case 'principal':
|
||||
return '#3b82f6';
|
||||
case 'lead':
|
||||
return '#f97316';
|
||||
case 'junior':
|
||||
return '#22c55e';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
function flattenWorkers(tree: RailsSubTaskNode[]): RailsSubTaskNode[] {
|
||||
const out: RailsSubTaskNode[] = [];
|
||||
const walk = (nodes: RailsSubTaskNode[]) => {
|
||||
for (const n of nodes) {
|
||||
out.push(n);
|
||||
if (n.children?.length) walk(n.children);
|
||||
}
|
||||
};
|
||||
walk(tree);
|
||||
return out;
|
||||
}
|
||||
|
||||
function statsFor(workers: RailsSubTaskNode[]): SisterStats {
|
||||
const stats: SisterStats = {
|
||||
total: workers.length,
|
||||
running: 0,
|
||||
done: 0,
|
||||
failed: 0,
|
||||
models: new Set(),
|
||||
};
|
||||
for (const w of workers) {
|
||||
if (w.state === 'running' || w.state === 'queued') stats.running += 1;
|
||||
else if (w.state === 'done') stats.done += 1;
|
||||
else if (w.state === 'failed' || w.state === 'escalated') stats.failed += 1;
|
||||
if (w.model) stats.models.add(w.model);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
export default function OfficeFloor({
|
||||
pipelines,
|
||||
treesByPipeline,
|
||||
selectedSister,
|
||||
onSelectSister,
|
||||
}: OfficeFloorProps) {
|
||||
// Aggregate workers per sister across active pipelines
|
||||
const workersBySister = useMemo(() => {
|
||||
const map = new Map<SisterKey, RailsSubTaskNode[]>();
|
||||
for (const sister of SISTERS) map.set(sister.key, []);
|
||||
|
||||
const activePipelines = pipelines.filter(
|
||||
(p) => !['done', 'aborted'].includes(p.currentState),
|
||||
);
|
||||
const sourcePipelines = activePipelines.length > 0 ? activePipelines : pipelines.slice(0, 4);
|
||||
|
||||
for (const pipeline of sourcePipelines) {
|
||||
const tree = treesByPipeline.get(pipeline.id) ?? [];
|
||||
const flat = flattenWorkers(tree);
|
||||
for (const node of flat) {
|
||||
const sisterKey = node.agentName as SisterKey;
|
||||
const target = map.get(sisterKey);
|
||||
if (target) target.push(node);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [pipelines, treesByPipeline]);
|
||||
|
||||
// Determine which stages are currently running for animation
|
||||
const activeStages = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const sister of SISTERS) {
|
||||
const stats = statsFor(workersBySister.get(sister.key) ?? []);
|
||||
if (stats.running > 0) set.add(sister.key);
|
||||
}
|
||||
return set;
|
||||
}, [workersBySister]);
|
||||
|
||||
// SVG flow lines: harang→narang→darang→erang following the pipeline order
|
||||
// Coordinates are normalized 0-100 (viewBox 100x100)
|
||||
const POS: Record<string, { x: number; y: number }> = {
|
||||
harang: { x: 25, y: 25 },
|
||||
narang: { x: 75, y: 25 },
|
||||
darang: { x: 25, y: 75 },
|
||||
erang: { x: 75, y: 75 },
|
||||
};
|
||||
// Order: plan→implement→review→deploy
|
||||
const FLOW: Array<[keyof typeof POS, keyof typeof POS]> = [
|
||||
['harang', 'narang'],
|
||||
['narang', 'darang'],
|
||||
['darang', 'erang'],
|
||||
];
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<Floor>
|
||||
<Overlay viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 5, 0 10" fill="#5fafff" />
|
||||
</marker>
|
||||
</defs>
|
||||
{FLOW.map(([from, to]) => {
|
||||
const fromActive = activeStages.has(from);
|
||||
const toActive = activeStages.has(to);
|
||||
const flowing = fromActive || toActive;
|
||||
const a = POS[from]!;
|
||||
const b = POS[to]!;
|
||||
return (
|
||||
<FlowLine
|
||||
key={`${from}-${to}`}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
$flowing={flowing}
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Overlay>
|
||||
{SISTERS.map((sister) => {
|
||||
const workers = workersBySister.get(sister.key) ?? [];
|
||||
const stats = statsFor(workers);
|
||||
const running = stats.running > 0;
|
||||
const isSelected = selectedSister === sister.key;
|
||||
|
||||
return (
|
||||
<Desk
|
||||
key={sister.key}
|
||||
$color={sister.color}
|
||||
$active={stats.total > 0}
|
||||
$running={running}
|
||||
$selected={isSelected}
|
||||
onClick={() => onSelectSister(isSelected ? null : sister.key)}
|
||||
>
|
||||
<DeskHead>
|
||||
<SisterBadge $color={sister.color}>
|
||||
<AvatarFrame $color={sister.color} $running={running}>
|
||||
<SisterAvatar name={sister.key} size={54} />
|
||||
</AvatarFrame>
|
||||
<NameBlock>
|
||||
<Name>{sister.label}</Name>
|
||||
<Role>{sister.role}</Role>
|
||||
</NameBlock>
|
||||
</SisterBadge>
|
||||
<StatusDot $running={running} />
|
||||
</DeskHead>
|
||||
|
||||
<Workers>
|
||||
{workers.length === 0 ? (
|
||||
<Empty>대기 중</Empty>
|
||||
) : (
|
||||
workers.slice(0, 12).map((w) => (
|
||||
<Worker key={w.id} $role={w.role} $state={w.state}>
|
||||
{w.role}
|
||||
</Worker>
|
||||
))
|
||||
)}
|
||||
{workers.length > 12 && (
|
||||
<Worker $role="" $state="">
|
||||
+{workers.length - 12}
|
||||
</Worker>
|
||||
)}
|
||||
</Workers>
|
||||
|
||||
<Stats>
|
||||
<Stat>
|
||||
<StatNum>{stats.total}</StatNum>
|
||||
<StatLabel>workers</StatLabel>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatNum style={{ color: '#22c55e' }}>{stats.running}</StatNum>
|
||||
<StatLabel>active</StatLabel>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatNum>{stats.done}</StatNum>
|
||||
<StatLabel>done</StatLabel>
|
||||
</Stat>
|
||||
{stats.failed > 0 && (
|
||||
<Stat>
|
||||
<StatNum style={{ color: '#ef4444' }}>{stats.failed}</StatNum>
|
||||
<StatLabel>fail</StatLabel>
|
||||
</Stat>
|
||||
)}
|
||||
</Stats>
|
||||
</Desk>
|
||||
);
|
||||
})}
|
||||
</Floor>
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { RailsPipelineSummary } from '@/lib/useRailsSocket';
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const Card = styled.button<{ $active: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: ${({ $active }) =>
|
||||
$active ? 'var(--bg-input)' : 'var(--bg-surface)'};
|
||||
border: 1px solid ${({ $active }) =>
|
||||
$active ? '#5fafff' : 'var(--border-color)'};
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #5fafff;
|
||||
}
|
||||
`;
|
||||
|
||||
const StateBadge = styled.span<{ $state: string }>`
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
background: ${({ $state }) => {
|
||||
switch ($state) {
|
||||
case 'done':
|
||||
return '#22c55e';
|
||||
case 'escalated':
|
||||
return '#ef4444';
|
||||
case 'aborted':
|
||||
return '#6b7280';
|
||||
case 'planning':
|
||||
case 'implementing':
|
||||
case 'reviewing':
|
||||
case 'deploying':
|
||||
return '#f97316';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
const Id = styled.span`
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
`;
|
||||
|
||||
const Name = styled.span`
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const Time = styled.span`
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
pipelines: RailsPipelineSummary[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function PipelineList({ pipelines, selectedId, onSelect }: Props) {
|
||||
if (pipelines.length === 0) {
|
||||
return <Wrap>No pipelines yet.</Wrap>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
{pipelines.map((p) => (
|
||||
<Card
|
||||
key={p.id}
|
||||
$active={p.id === selectedId}
|
||||
onClick={() => onSelect(p.id)}
|
||||
>
|
||||
<Id>{p.id.slice(0, 8)}</Id>
|
||||
<Name>{p.projectName}</Name>
|
||||
<StateBadge $state={p.currentState}>{p.currentState}</StateBadge>
|
||||
<Time>{formatTime(p.updatedAt)}</Time>
|
||||
</Card>
|
||||
))}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diffMs = now - d.getTime();
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}h`;
|
||||
return `${Math.floor(sec / 86400)}d`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
@@ -1,634 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
|
||||
const SISTER_NAMES = new Set(['harang', 'narang', 'darang', 'erang']);
|
||||
|
||||
function parseResult(
|
||||
raw: string | null,
|
||||
): { text: string; ok: boolean; extra?: Record<string, unknown> } | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const text = typeof parsed.text === 'string' ? parsed.text : '';
|
||||
const ok = parsed.ok !== false;
|
||||
const { text: _t, ok: _o, ...rest } = parsed as {
|
||||
text?: unknown;
|
||||
ok?: unknown;
|
||||
} & Record<string, unknown>;
|
||||
void _t;
|
||||
void _o;
|
||||
return { text, ok, extra: Object.keys(rest).length > 0 ? rest : undefined };
|
||||
}
|
||||
} catch {
|
||||
return { text: raw, ok: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SubTaskEvent {
|
||||
id: number;
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ParentLink {
|
||||
id: string;
|
||||
role: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ChildSummary {
|
||||
id: string;
|
||||
role: string;
|
||||
agentName: string;
|
||||
title: string;
|
||||
state: string;
|
||||
model: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
interface SubTaskDetail {
|
||||
id: string;
|
||||
pipelineId: string;
|
||||
parentId: string | null;
|
||||
role: string;
|
||||
agentName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
state: string;
|
||||
complexityScore: number | null;
|
||||
complexityTier: string | null;
|
||||
model: string;
|
||||
resultJson: string | null;
|
||||
errorReason: string | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
parents: ParentLink[];
|
||||
childrenList: ChildSummary[];
|
||||
events: SubTaskEvent[];
|
||||
}
|
||||
|
||||
const Backdrop = styled.div`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const Drawer = styled.div`
|
||||
width: min(640px, 95vw);
|
||||
height: 100%;
|
||||
background: var(--bg-main);
|
||||
border-left: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-main);
|
||||
padding: 24px 28px 18px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const TopRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const RoleBadge = styled.span<{ $role: string }>`
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
background: ${({ $role }) => roleColor($role)};
|
||||
`;
|
||||
|
||||
const Close = styled.button`
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
border-color: #5fafff;
|
||||
}
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const Breadcrumb = styled.div`
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Body = styled.div`
|
||||
padding: 24px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
`;
|
||||
|
||||
const Section = styled.section`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const SectionLabel = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const Field = styled.div`
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const FieldLabel = styled.span`
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const FieldValue = styled.span`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-all;
|
||||
`;
|
||||
|
||||
const StateBadge = styled.span<{ $state: string }>`
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
background: ${({ $state }) => stateColor($state)};
|
||||
`;
|
||||
|
||||
const Description = styled.p`
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const LlmOutput = styled.div<{ $ok: boolean }>`
|
||||
background: var(--bg-input);
|
||||
border: 1px solid ${({ $ok }) => ($ok ? 'var(--border-color)' : '#ef444460')};
|
||||
border-left: 4px solid ${({ $ok }) => ($ok ? '#5fafff' : '#ef4444')};
|
||||
padding: 18px 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
color: var(--text-primary);
|
||||
|
||||
& > *:first-child { margin-top: 0; }
|
||||
& > *:last-child { margin-bottom: 0; }
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin: 14px 0 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p { margin: 10px 0; }
|
||||
|
||||
ul, ol {
|
||||
margin: 10px 0;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
li { margin: 4px 0; }
|
||||
|
||||
code {
|
||||
background: var(--bg-surface);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: #5fafff;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
|
||||
code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--border-color);
|
||||
padding-left: 12px;
|
||||
margin: 10px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #5fafff;
|
||||
text-decoration: none;
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
strong { font-weight: 700; }
|
||||
`;
|
||||
|
||||
const OutputHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const OutputStatusDot = styled.span<{ $ok: boolean }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $ok }) => ($ok ? '#22c55e' : '#ef4444')};
|
||||
`;
|
||||
|
||||
const ChildList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ChildCard = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const ChildTitle = styled.span`
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const Events = styled.ol`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const EventRow = styled.li`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
`;
|
||||
|
||||
const EventType = styled.span<{ $type: string }>`
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: ${({ $type }) => eventColor($type)};
|
||||
flex-shrink: 0;
|
||||
width: 90px;
|
||||
`;
|
||||
|
||||
const EventTime = styled.span`
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const EventPayload = styled.pre`
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
opacity: 0.8;
|
||||
`;
|
||||
|
||||
const Loading = styled.div`
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
function roleColor(role: string): string {
|
||||
switch (role) {
|
||||
case 'manager':
|
||||
return '#8b5cf6';
|
||||
case 'principal':
|
||||
return '#3b82f6';
|
||||
case 'lead':
|
||||
return '#f97316';
|
||||
case 'junior':
|
||||
return '#22c55e';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
function stateColor(state: string): string {
|
||||
switch (state) {
|
||||
case 'done':
|
||||
return '#22c55e';
|
||||
case 'running':
|
||||
return '#f97316';
|
||||
case 'failed':
|
||||
case 'escalated':
|
||||
return '#ef4444';
|
||||
case 'queued':
|
||||
return '#6b7280';
|
||||
default:
|
||||
return '#525252';
|
||||
}
|
||||
}
|
||||
|
||||
function eventColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'spawned':
|
||||
return '#8b5cf6';
|
||||
case 'started':
|
||||
return '#f97316';
|
||||
case 'progress':
|
||||
case 'output':
|
||||
return '#5fafff';
|
||||
case 'completed':
|
||||
return '#22c55e';
|
||||
case 'failed':
|
||||
case 'escalated':
|
||||
return '#ef4444';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
function duration(start: string | null, end: string | null): string {
|
||||
if (!start) return '—';
|
||||
const s = new Date(start).getTime();
|
||||
const e = end ? new Date(end).getTime() : Date.now();
|
||||
const ms = e - s;
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
subTaskId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
|
||||
const [detail, setDetail] = useState<SubTaskDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const llmResult = useMemo(
|
||||
() => (detail ? parseResult(detail.resultJson) : null),
|
||||
[detail],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`${API_URL}/api/rails/sub-tasks/${subTaskId}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data: SubTaskDetail) => {
|
||||
setDetail(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [subTaskId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Backdrop onClick={onClose}>
|
||||
<Drawer onClick={(e) => e.stopPropagation()}>
|
||||
{loading || !detail ? (
|
||||
<Loading>로딩 중...</Loading>
|
||||
) : (
|
||||
<>
|
||||
<Header>
|
||||
<TopRow>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{SISTER_NAMES.has(detail.agentName) && (
|
||||
<SisterAvatar name={detail.agentName} size={40} />
|
||||
)}
|
||||
<RoleBadge $role={detail.role}>{detail.role}</RoleBadge>
|
||||
</div>
|
||||
<Close onClick={onClose}>닫기 ESC</Close>
|
||||
</TopRow>
|
||||
<Title>{detail.title}</Title>
|
||||
{detail.parents.length > 0 && (
|
||||
<Breadcrumb>
|
||||
{detail.parents.map((p, i) => (
|
||||
<React.Fragment key={p.id}>
|
||||
<span>{p.role}</span>
|
||||
<span>›</span>
|
||||
{i === detail.parents.length - 1 && <span>현재</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Breadcrumb>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
<Body>
|
||||
<Section>
|
||||
<SectionLabel>상태 / 모델</SectionLabel>
|
||||
<Grid>
|
||||
<Field>
|
||||
<FieldLabel>State</FieldLabel>
|
||||
<FieldValue>
|
||||
<StateBadge $state={detail.state}>{detail.state}</StateBadge>
|
||||
</FieldValue>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Agent</FieldLabel>
|
||||
<FieldValue>{detail.agentName}</FieldValue>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Model</FieldLabel>
|
||||
<FieldValue>{detail.model || '—'}</FieldValue>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Duration</FieldLabel>
|
||||
<FieldValue>
|
||||
{duration(detail.startedAt, detail.completedAt)}
|
||||
</FieldValue>
|
||||
</Field>
|
||||
{detail.complexityTier && (
|
||||
<Field>
|
||||
<FieldLabel>Complexity</FieldLabel>
|
||||
<FieldValue>
|
||||
{detail.complexityTier} ({detail.complexityScore})
|
||||
</FieldValue>
|
||||
</Field>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel>ID</FieldLabel>
|
||||
<FieldValue>{detail.id.slice(0, 16)}...</FieldValue>
|
||||
</Field>
|
||||
</Grid>
|
||||
</Section>
|
||||
|
||||
{detail.description && (
|
||||
<Section>
|
||||
<SectionLabel>설명</SectionLabel>
|
||||
<Description>{detail.description}</Description>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{detail.errorReason && (
|
||||
<Section>
|
||||
<SectionLabel>에러</SectionLabel>
|
||||
<Description>{detail.errorReason}</Description>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{llmResult && llmResult.text && (
|
||||
<Section>
|
||||
<SectionLabel>LLM 응답</SectionLabel>
|
||||
<LlmOutput $ok={llmResult.ok}>
|
||||
<OutputHeader>
|
||||
<OutputStatusDot $ok={llmResult.ok} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{llmResult.ok ? 'success' : 'failed'} · {detail.model || 'default'}
|
||||
</span>
|
||||
</OutputHeader>
|
||||
<ReactMarkdown>{llmResult.text}</ReactMarkdown>
|
||||
</LlmOutput>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{llmResult && !llmResult.text && detail.resultJson && (
|
||||
<Section>
|
||||
<SectionLabel>원본 결과 (텍스트 없음)</SectionLabel>
|
||||
<Description>{detail.resultJson}</Description>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{detail.childrenList.length > 0 && (
|
||||
<Section>
|
||||
<SectionLabel>
|
||||
하위 노드 ({detail.childrenList.length})
|
||||
</SectionLabel>
|
||||
<ChildList>
|
||||
{detail.childrenList.map((c) => (
|
||||
<ChildCard key={c.id}>
|
||||
<RoleBadge $role={c.role}>{c.role}</RoleBadge>
|
||||
<ChildTitle>{c.title}</ChildTitle>
|
||||
<StateBadge $state={c.state}>{c.state}</StateBadge>
|
||||
<span style={{ fontSize: 10, opacity: 0.6 }}>
|
||||
{duration(c.startedAt, c.completedAt)}
|
||||
</span>
|
||||
</ChildCard>
|
||||
))}
|
||||
</ChildList>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<SectionLabel>이벤트 ({detail.events.length})</SectionLabel>
|
||||
<Events>
|
||||
{detail.events.map((ev) => (
|
||||
<EventRow key={ev.id}>
|
||||
<EventType $type={ev.eventType}>{ev.eventType}</EventType>
|
||||
<EventTime>
|
||||
{new Date(ev.timestamp).toLocaleTimeString('ko-KR')}
|
||||
</EventTime>
|
||||
<EventPayload>
|
||||
{typeof ev.payload === 'string'
|
||||
? ev.payload
|
||||
: JSON.stringify(ev.payload)}
|
||||
</EventPayload>
|
||||
</EventRow>
|
||||
))}
|
||||
</Events>
|
||||
</Section>
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { RailsSubTaskNode } from '@/lib/useRailsSocket';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
|
||||
const SISTER_NAMES = new Set(['harang', 'narang', 'darang', 'erang']);
|
||||
|
||||
const Wrap = styled.div`
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const Node = styled.button<{ $state: string }>`
|
||||
padding: 12px 16px;
|
||||
border-left: 3px solid ${({ $state }) => stateColor($state)};
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
width: 100%;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-input);
|
||||
border-color: #5fafff;
|
||||
}
|
||||
`;
|
||||
|
||||
const RoleBadge = styled.span<{ $role: string }>`
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
margin-right: 10px;
|
||||
background: ${({ $role }) => roleColor($role)};
|
||||
`;
|
||||
|
||||
const StateDot = styled.span<{ $state: string }>`
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
background: ${({ $state }) => stateColor($state)};
|
||||
flex-shrink: 0;
|
||||
animation: ${({ $state }) => ($state === 'running' ? 'pulse 1.2s infinite' : 'none')};
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
`;
|
||||
|
||||
const Model = styled.span`
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
margin-left: 12px;
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
const Duration = styled.span`
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const Meta = styled.span`
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
margin-left: 22px;
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const Children = styled.div`
|
||||
margin-left: 28px;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-left: 1px dashed var(--border-color);
|
||||
padding-left: 16px;
|
||||
`;
|
||||
|
||||
function stateColor(state: string): string {
|
||||
switch (state) {
|
||||
case 'done':
|
||||
return '#22c55e';
|
||||
case 'running':
|
||||
return '#f97316';
|
||||
case 'failed':
|
||||
return '#ef4444';
|
||||
case 'escalated':
|
||||
return '#ef4444';
|
||||
case 'queued':
|
||||
return '#6b7280';
|
||||
default:
|
||||
return '#333333';
|
||||
}
|
||||
}
|
||||
|
||||
function roleColor(role: string): string {
|
||||
switch (role) {
|
||||
case 'manager':
|
||||
return '#8b5cf6';
|
||||
case 'principal':
|
||||
return '#3b82f6';
|
||||
case 'lead':
|
||||
return '#f97316';
|
||||
case 'junior':
|
||||
return '#6b7280';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
function duration(startedAt: string | null, completedAt: string | null): string {
|
||||
if (!startedAt) return '—';
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||
const ms = end - start;
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
}
|
||||
|
||||
function NodeRow({
|
||||
node,
|
||||
onSelect,
|
||||
}: {
|
||||
node: RailsSubTaskNode;
|
||||
onSelect?: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Node $state={node.state} onClick={() => onSelect?.(node.id)}>
|
||||
<Row>
|
||||
<StateDot $state={node.state} />
|
||||
{SISTER_NAMES.has(node.agentName) && (
|
||||
<SisterAvatar name={node.agentName} size={20} style={{ marginRight: 8 }} />
|
||||
)}
|
||||
<RoleBadge $role={node.role}>{node.role}</RoleBadge>
|
||||
<Title>{node.title.slice(0, 100)}</Title>
|
||||
<Model>{node.model || '—'}</Model>
|
||||
<Duration>{duration(node.startedAt, node.completedAt)}</Duration>
|
||||
</Row>
|
||||
{node.complexityTier && (
|
||||
<Meta>
|
||||
complexity: {node.complexityTier} ({node.complexityScore})
|
||||
</Meta>
|
||||
)}
|
||||
</Node>
|
||||
{node.children.length > 0 && (
|
||||
<Children>
|
||||
{node.children.map((c) => (
|
||||
<NodeRow key={c.id} node={c} onSelect={onSelect} />
|
||||
))}
|
||||
</Children>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tree: RailsSubTaskNode[];
|
||||
onSelectNode?: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function SubTaskTree({ tree, onSelectNode }: Props) {
|
||||
if (tree.length === 0) {
|
||||
return <Wrap>No sub-tasks yet.</Wrap>;
|
||||
}
|
||||
return (
|
||||
<Wrap>
|
||||
{tree.map((node) => (
|
||||
<NodeRow key={node.id} node={node} onSelect={onSelectNode} />
|
||||
))}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
export interface RailsPipelineSummary {
|
||||
id: string;
|
||||
projectName: string;
|
||||
currentState: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RailsSubTaskNode {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
role: string;
|
||||
agentName: string;
|
||||
title: string;
|
||||
state: string;
|
||||
complexityScore: number | null;
|
||||
complexityTier: string | null;
|
||||
model: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
children: RailsSubTaskNode[];
|
||||
}
|
||||
|
||||
interface UseRailsSocketOptions {
|
||||
onPipelinesSnapshot?: (pipelines: RailsPipelineSummary[]) => void;
|
||||
onPipelineUpdated?: (pipeline: RailsPipelineSummary) => void;
|
||||
onSubTasksUpdated?: (pipelineId: string, tree: RailsSubTaskNode[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens to rails.* events emitted by the backend EventsGateway.
|
||||
* Piggybacks on the existing /ws namespace — no new socket connection.
|
||||
*/
|
||||
export function useRailsSocket(options: UseRailsSocketOptions = {}) {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const wsUrl = process.env.NEXT_PUBLIC_WS_URL ?? window.location.origin;
|
||||
const token =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('hanarang_access_token') ?? ''
|
||||
: '';
|
||||
|
||||
const socket = io(`${wsUrl}/ws`, {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 3000,
|
||||
auth: { token },
|
||||
});
|
||||
|
||||
socket.on('connect', () => setConnected(true));
|
||||
socket.on('disconnect', () => setConnected(false));
|
||||
|
||||
if (options.onPipelinesSnapshot) {
|
||||
socket.on('rails:pipelines', (payload: { pipelines: RailsPipelineSummary[] }) => {
|
||||
options.onPipelinesSnapshot?.(payload.pipelines);
|
||||
});
|
||||
}
|
||||
|
||||
if (options.onPipelineUpdated) {
|
||||
socket.on('rails:pipeline:updated', (payload: { pipeline: RailsPipelineSummary }) => {
|
||||
options.onPipelineUpdated?.(payload.pipeline);
|
||||
});
|
||||
}
|
||||
|
||||
if (options.onSubTasksUpdated) {
|
||||
socket.on(
|
||||
'rails:subtasks',
|
||||
(payload: { pipelineId: string; tree: RailsSubTaskNode[] }) => {
|
||||
options.onSubTasksUpdated?.(payload.pipelineId, payload.tree);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return { connected, socket: socketRef.current };
|
||||
}
|
||||
4589
frontend/pnpm-lock.yaml
generated
4589
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user