feat(control): add live workboard v0
This commit is contained in:
547
.plans/rebuild-from-scratch/PLAN.md
Normal file
547
.plans/rebuild-from-scratch/PLAN.md
Normal file
@@ -0,0 +1,547 @@
|
||||
# 하나랑 대시보드 Rebuild From Scratch Implementation Plan
|
||||
|
||||
> **For Hermes:** 이 문서는 하랑이 planner role 산출물이다. 승인 전 구현/배포 금지. 승인 후 나랑이 builder 또는 Codex가 task 단위로 실행하고, 다랑이 reviewer가 spec/code quality를 검증한다.
|
||||
|
||||
**Goal:** 기존 하나랑 대시보드를 “자기야가 4자매가 작업하는 과정을 실시간으로 보는 서비스”로 처음부터 다시 설계하고 구현한다. 메인 화면의 핵심은 이랑이/하랑이/나랑이/다랑이가 PLAN → BUILD → REVIEW → DEPLOY 흐름에서 무엇을 하고 있는지 live timeline/board/scene으로 보여주는 것이다.
|
||||
|
||||
**Architecture:** 기존 `hanarang-dashboard` repo는 유지하되, 제품/정보구조/UI를 새로 잡는다. Frontend는 Next.js 16 + styled-components를 유지하고, Backend는 NestJS + Prisma/MariaDB + Socket.IO를 유지한다. 메인 서비스는 `4 Sisters Live Workboard`이며, v0는 mock event stream으로 “작업 과정이 보이는 화면”을 먼저 완성한다. v1에서 NestJS Socket.IO event stream과 workflow state API를 붙인다. 기존 코드는 삭제보다 `legacy` 보존/점진 교체를 우선한다.
|
||||
|
||||
**Tech Stack:** Next.js 16, React 19, styled-components, NestJS 11, Prisma 7, MariaDB, Socket.IO, Hermes role skills, Proxmox read-only API.
|
||||
|
||||
---
|
||||
|
||||
## 0. 배경
|
||||
|
||||
기존 대시보드는 다음 전제를 갖고 있었다.
|
||||
|
||||
- 하랑/나랑/다랑/이랑이 각각 독립 gateway로 상시 동작
|
||||
- 4자매 서버 상태를 곧 agent 상태로 봄
|
||||
- OpenClaw/Lobster workflow 흔적이 UI에 남아 있음
|
||||
- 실시간 office/metaphor와 운영 패널이 섞여 있음
|
||||
|
||||
현재 운영 방향은 바뀌었다.
|
||||
|
||||
- 자기야의 기본 진입점은 이랑이 하나
|
||||
- 하랑=planner, 나랑=builder, 다랑=reviewer는 role skill로 호출
|
||||
- 하랑/나랑/다랑 gateway는 나중에 standby 전환 가능
|
||||
- Proxmox 서버 상태와 role runnable 상태를 분리해서 봐야 함
|
||||
- 하네스 엔지니어링 원칙: PLAN → 승인 → 구현 → QA → 배포 게이트
|
||||
|
||||
그래서 단순 리팩토링보다 “처음부터 다시 만드는” 수준의 IA/UI 재설계가 맞다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 제품 방향
|
||||
|
||||
### 한 줄 정의
|
||||
|
||||
하나랑 대시보드는 자기야가 **4자매가 작업하는 과정을 실시간으로 보는 서비스**다. 단순 관제센터가 아니라, 하랑이의 기획, 나랑이의 구현, 다랑이의 리뷰, 이랑이의 인프라/배포 판단이 어떤 순서로 흘러가는지 메인 화면에서 바로 보여준다.
|
||||
|
||||
### 핵심 사용자
|
||||
|
||||
- 자기야: 최종 사용자/승인자
|
||||
- 이랑이: 메인 비서, 오케스트레이터, 인프라 책임자
|
||||
- 하랑이 role: planner
|
||||
- 나랑이 role: builder
|
||||
- 다랑이 role: reviewer
|
||||
|
||||
### 새 핵심 은유
|
||||
|
||||
기존 “정적인 관제 카드 모음”이 아니라 **4자매 작업실을 실시간으로 보는 화면**이 메인이다.
|
||||
|
||||
```text
|
||||
4 Sisters Live Workboard
|
||||
├─ Live Work Scene: 지금 누가 어떤 작업 중인지
|
||||
├─ Workflow Timeline: PLAN → BUILD → REVIEW → DEPLOY 이벤트 흐름
|
||||
├─ Active Task Focus: 현재 선택된 작업의 상세 상태
|
||||
├─ Sister Activity Lanes: 하랑/나랑/다랑/이랑 역할별 lane
|
||||
└─ Infra/Approval Side Panel: 막힌 이유, 승인 필요, 인프라 위험
|
||||
```
|
||||
|
||||
이랑이는 여전히 단일 진입점이지만, 화면의 주인공은 “이랑이 혼자”가 아니라 **4자매가 함께 일하는 과정**이다. 하랑/나랑/다랑이 gateway로 상시 떠 있지 않아도, role execution/event log를 통해 작업 과정이 보여야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope
|
||||
|
||||
### 포함 — v0
|
||||
|
||||
- 메인 화면을 `4 Sisters Live Workboard`로 설계
|
||||
- 하랑/나랑/다랑/이랑 activity lane
|
||||
- PLAN → BUILD → REVIEW → DEPLOY live timeline
|
||||
- 현재 작업 focus panel
|
||||
- approval/blocker side panel
|
||||
- mock event stream으로 실시간처럼 흐르는 UI
|
||||
- 새 AppShell/Navigation
|
||||
- 4자매 role status 카드
|
||||
- Proxmox monitoring link/panel placeholder
|
||||
- 프로젝트 list placeholder
|
||||
- mobile-first layout
|
||||
- mock/static data로 UI 완성
|
||||
- 기존 route와 충돌하지 않는 방식으로 새 route 구성
|
||||
|
||||
### 포함 — v1
|
||||
|
||||
- Backend API 계약 연결
|
||||
- role agent health endpoint 연결
|
||||
- Proxmox v0 live inventory 연결
|
||||
- project/workflow API 연결
|
||||
- Socket.IO live update 일부 적용
|
||||
|
||||
### 포함 — v2
|
||||
|
||||
- 기존 legacy 화면 정리
|
||||
- 인증/권한 흐름 재검토
|
||||
- 실제 action gate UI
|
||||
- deployment/QA report UI
|
||||
|
||||
### 제외 — 당장 하지 않음
|
||||
|
||||
- 기존 DB schema 삭제
|
||||
- 기존 production deploy
|
||||
- 하랑/나랑/다랑 gateway 중지
|
||||
- Proxmox write action
|
||||
- DB migration
|
||||
- 도메인/Nginx 변경
|
||||
|
||||
---
|
||||
|
||||
## 3. Product IA
|
||||
|
||||
### Primary Navigation
|
||||
|
||||
1. **Live** — 4자매 작업 과정 실시간 메인 화면
|
||||
2. **Harness** — PLAN/Build/Review/Deploy 흐름 상세
|
||||
3. **Projects** — repo/project/sprint/task
|
||||
4. **Infra** — Proxmox/server/service health
|
||||
5. **Agents** — 이랑이 + 하랑/나랑/다랑 role health
|
||||
6. **Settings** — 토큰/환경/연동 상태. secret 값 표시 금지
|
||||
|
||||
### Live 메인 화면
|
||||
|
||||
메인 화면은 대시보드의 핵심 서비스다.
|
||||
|
||||
상단:
|
||||
- 현재 active workflow 이름
|
||||
- live/snapshot/mock/fallback source badge
|
||||
- 전체 phase: Planning / Building / Reviewing / Waiting Approval / Deploying
|
||||
- pending approvals / blockers
|
||||
|
||||
중앙:
|
||||
- **4 Sisters Live Workboard**
|
||||
- 하랑 lane: 요구사항 정리, PLAN 작성, handoff
|
||||
- 나랑 lane: 구현 task, test/build, diff 생성
|
||||
- 다랑 lane: spec review, code review, QA 결과
|
||||
- 이랑 lane: 인프라 확인, 배포 게이트, 위험 차단
|
||||
- 각 lane에는 현재 상태, 마지막 이벤트, 진행 중 작업, 다음 handoff를 표시한다.
|
||||
|
||||
우측:
|
||||
- Active Task Focus
|
||||
- 지금 선택한 이벤트/작업 상세
|
||||
- 관련 파일/plan/QA report 링크
|
||||
- tool call 또는 verification command 상태
|
||||
|
||||
하단:
|
||||
- Workflow Timeline
|
||||
- event stream 형태
|
||||
- PLAN_CREATED, BUILD_STARTED, TEST_PASSED, REVIEW_REQUESTED, QA_FAILED, APPROVAL_NEEDED 같은 이벤트
|
||||
- Infra/Approval compact panel
|
||||
|
||||
### Harness 화면
|
||||
|
||||
Live 화면보다 상세한 작업 단위 board다.
|
||||
|
||||
- workflow list
|
||||
- stage별 task cards
|
||||
- approval gates
|
||||
- QA report links
|
||||
- build/test logs
|
||||
|
||||
### Agents 화면
|
||||
|
||||
agent 상태를 4축으로 표시한다.
|
||||
|
||||
| 항목 | 의미 |
|
||||
|---|---|
|
||||
| role | erang-main, harang-planner, narang-builder, darang-reviewer |
|
||||
| mode | main, role-skill, profile, gateway |
|
||||
| runnable | 현재 호출 가능한지 |
|
||||
| gateway | active, standby, failed, unknown |
|
||||
| server | LXC running 여부 |
|
||||
|
||||
### Infra 화면
|
||||
|
||||
처음엔 Proxmox monitoring PLAN과 연결한다.
|
||||
|
||||
- `/infra/proxmox`
|
||||
- live-only v0
|
||||
- DB 없음
|
||||
- write action 없음
|
||||
|
||||
---
|
||||
|
||||
## 4. Design Principles
|
||||
|
||||
1. **운영 판단이 먼저**
|
||||
- 예쁜 오피스보다 “지금 위험한가?”를 먼저 보여준다.
|
||||
2. **상태의 출처를 숨기지 않음**
|
||||
- live / snapshot / mock / fallback 구분.
|
||||
3. **상시 gateway와 role runnable을 분리**
|
||||
- gateway standby는 장애가 아니다.
|
||||
4. **모바일 우선**
|
||||
- 자기야 iPhone에서 빠르게 봐야 함.
|
||||
5. **과한 애니메이션 금지**
|
||||
- 상태 파악 방해 금지.
|
||||
6. **하네스 원칙 내장**
|
||||
- PLAN 승인 없이는 Build가 시작되지 않는 UI.
|
||||
|
||||
---
|
||||
|
||||
## 5. Repo Strategy
|
||||
|
||||
### 기본 전략
|
||||
|
||||
기존 repo 유지:
|
||||
|
||||
```text
|
||||
/home/erang/hanarang-dashboard
|
||||
```
|
||||
|
||||
새 구현은 기존 route를 바로 갈아엎지 않고, feature branch에서 새 구조를 만든다.
|
||||
|
||||
추천 branch:
|
||||
|
||||
```bash
|
||||
git checkout -b feature/rebuild-control-center
|
||||
```
|
||||
|
||||
### 파일 구조 제안
|
||||
|
||||
Frontend:
|
||||
|
||||
```text
|
||||
frontend/app/(control)/page.tsx
|
||||
frontend/app/(control)/harness/page.tsx
|
||||
frontend/app/(control)/projects/page.tsx
|
||||
frontend/app/(control)/infra/page.tsx
|
||||
frontend/app/(control)/infra/proxmox/page.tsx
|
||||
frontend/app/(control)/agents/page.tsx
|
||||
frontend/app/(control)/settings/page.tsx
|
||||
|
||||
frontend/components/control/AppFrame.tsx
|
||||
frontend/components/control/TopStatusBar.tsx
|
||||
frontend/components/control/NavigationRail.tsx
|
||||
frontend/components/control/StatusCard.tsx
|
||||
frontend/components/control/SourceBadge.tsx
|
||||
|
||||
frontend/components/harness/HarnessBoard.tsx
|
||||
frontend/components/harness/PipelineStageCard.tsx
|
||||
frontend/components/harness/ApprovalQueue.tsx
|
||||
|
||||
frontend/components/agents/AgentRoleCard.tsx
|
||||
frontend/components/agents/AgentRoleMap.tsx
|
||||
|
||||
frontend/components/infra/InfraSummaryPanel.tsx
|
||||
frontend/components/infra/ProxmoxSummaryCard.tsx
|
||||
|
||||
frontend/lib/control/mock-data.ts
|
||||
frontend/lib/control/types.ts
|
||||
frontend/lib/control/api.ts
|
||||
```
|
||||
|
||||
Backend v0/v1:
|
||||
|
||||
```text
|
||||
backend/src/infra/agents/agent-role-health.controller.ts
|
||||
backend/src/infra/agents/agent-role-health.service.ts
|
||||
backend/src/infra/proxmox/*
|
||||
backend/src/control/control.controller.ts
|
||||
backend/src/control/control.service.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Tasks
|
||||
|
||||
### Task 1: 현재 구조 조사
|
||||
|
||||
**Objective:** 새 대시보드를 얹을 수 있는 실제 route/component/API 구조를 확인한다.
|
||||
|
||||
**Files:**
|
||||
- Read: `frontend/app/**`
|
||||
- Read: `frontend/components/**`
|
||||
- Read: `frontend/AGENTS.md`
|
||||
- Read: `backend/src/app.module.ts`
|
||||
- Read: `backend/src/dashboard/**`
|
||||
- Read: `backend/src/events/**`
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
cd /home/erang/hanarang-dashboard
|
||||
find frontend/app -maxdepth 4 -type f | sort
|
||||
find frontend/components -maxdepth 3 -type f | sort
|
||||
find backend/src -maxdepth 3 -type f | sort
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
- Next.js route 구조 확인
|
||||
- styled-components 사용 패턴 확인
|
||||
- 기존 API/gateway 패턴 확인
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 새 제품 스펙 작성
|
||||
|
||||
**Objective:** 기존 PRD를 새 운영 구조에 맞게 대체한다.
|
||||
|
||||
**Files:**
|
||||
- Create: `.plans/rebuild-from-scratch/product-spec.md`
|
||||
- Modify: `docs/product-specs/openclaw-office-dashboard-prd.md` 또는 새 파일 생성
|
||||
|
||||
**Acceptance criteria:**
|
||||
- 이랑이 단일 진입점 명시
|
||||
- 하랑/나랑/다랑 role agent 모델 명시
|
||||
- gateway standby != down 명시
|
||||
- Proxmox monitoring 연결 명시
|
||||
|
||||
**Verification:**
|
||||
- 다랑이 review에서 scope ambiguity 없음
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Design system 초안
|
||||
|
||||
**Objective:** 새 dashboard의 UI 토큰과 컴포넌트 원칙을 정한다.
|
||||
|
||||
**Files:**
|
||||
- Create: `.plans/rebuild-from-scratch/design-system.md`
|
||||
- Create: `frontend/components/control/README.md`
|
||||
|
||||
**Design tokens:**
|
||||
- background: dark infra control center
|
||||
- status colors: ok/warning/critical/standby/unknown
|
||||
- density: desktop 3-column, mobile 1-column
|
||||
- source badges: live/snapshot/mock/fallback
|
||||
|
||||
**Verification:**
|
||||
- 모든 상태가 색상만이 아니라 text label도 가짐
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend live workboard skeleton
|
||||
|
||||
**Objective:** mock event stream만으로 4자매가 작업하는 과정이 메인 화면에서 실시간처럼 보이게 만든다.
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/app/(control)/page.tsx`
|
||||
- Create: `frontend/components/control/AppFrame.tsx`
|
||||
- Create: `frontend/components/control/TopStatusBar.tsx`
|
||||
- Create: `frontend/components/control/NavigationRail.tsx`
|
||||
- Create: `frontend/components/live/LiveWorkboard.tsx`
|
||||
- Create: `frontend/components/live/SisterActivityLane.tsx`
|
||||
- Create: `frontend/components/live/WorkflowTimeline.tsx`
|
||||
- Create: `frontend/components/live/ActiveTaskFocus.tsx`
|
||||
- Create: `frontend/lib/control/types.ts`
|
||||
- Create: `frontend/lib/control/mock-events.ts`
|
||||
|
||||
**Rules:**
|
||||
- 기존 `frontend/app/page.tsx`를 바로 삭제하지 않는다.
|
||||
- 새 route가 충돌하면 `/control` 아래에 먼저 만든다.
|
||||
- mock data는 `source: 'mock'`을 반드시 표시한다.
|
||||
- 메인 화면의 첫 impression은 “4자매가 일하고 있다”여야 한다. 인프라/프로젝트 카드는 보조 패널로 둔다.
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Harness detail board UI
|
||||
|
||||
**Objective:** 메인 Live Workboard에서 선택한 workflow를 더 자세히 보는 상세 board를 만든다.
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/harness/HarnessBoard.tsx`
|
||||
- Create: `frontend/components/harness/PipelineStageCard.tsx`
|
||||
- Create: `frontend/components/harness/ApprovalQueue.tsx`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- PLAN_REQUIRED, BUILDING, REVIEWING, WAITING_APPROVAL, DEPLOY_READY 상태 표시
|
||||
- 각 단계에 responsible role 표시
|
||||
- 승인 대기 항목이 명확함
|
||||
- Live 화면의 event와 같은 workflow id로 연결됨
|
||||
|
||||
**Verification:**
|
||||
- mock pipeline 3개 렌더링
|
||||
- 모바일 1열 전환
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Agent role map UI
|
||||
|
||||
**Objective:** 이랑이 중심 role agent 상태를 표시한다.
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/agents/AgentRoleMap.tsx`
|
||||
- Create: `frontend/components/agents/AgentRoleCard.tsx`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- erang-main = active main
|
||||
- harang-planner/narang-builder/darang-reviewer = role-skill runnable
|
||||
- gateway active/standby/failed와 runnable 분리
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Infra summary placeholder
|
||||
|
||||
**Objective:** Proxmox monitoring v0와 연결될 홈 요약 영역을 만든다.
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/infra/InfraSummaryPanel.tsx`
|
||||
- Create: `frontend/components/infra/ProxmoxSummaryCard.tsx`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- live/snapshot/mock source badge
|
||||
- critical 우선 정렬
|
||||
- `/infra/proxmox`로 이동 link
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Backend live workflow summary API
|
||||
|
||||
**Objective:** frontend mock event stream을 backend live/snapshot API 계약으로 전환할 수 있게 한다.
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/src/control/control.module.ts`
|
||||
- Create: `backend/src/control/control.controller.ts`
|
||||
- Create: `backend/src/control/control.service.ts`
|
||||
- Create: `backend/src/control/workflow-events.gateway.ts`
|
||||
- Modify: `backend/src/app.module.ts`
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
```text
|
||||
GET /control/summary
|
||||
GET /control/workflows/active
|
||||
WS /control/events
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```ts
|
||||
type ControlSummary = {
|
||||
activeWorkflow: WorkflowSummary | null;
|
||||
sisters: SisterActivityLaneState[];
|
||||
events: WorkflowEvent[];
|
||||
infra: InfraSummary;
|
||||
approvals: ApprovalItem[];
|
||||
source: 'live' | 'snapshot' | 'mock' | 'fallback';
|
||||
updatedAt: string;
|
||||
};
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm test -- control
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: API client 연결
|
||||
|
||||
**Objective:** frontend가 `/control/summary`를 읽고 실패 시 fallback mock을 쓴다.
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/lib/control/api.ts`
|
||||
- Modify: `frontend/app/(control)/page.tsx`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- API 실패 시 fallback 표시
|
||||
- fallback임을 UI에 명확히 표시
|
||||
- loading/error/empty 상태 있음
|
||||
|
||||
---
|
||||
|
||||
### Task 10: QA 문서 작성
|
||||
|
||||
**Objective:** 다랑이가 검증할 QA checklist를 만든다.
|
||||
|
||||
**Files:**
|
||||
- Create: `.plans/rebuild-from-scratch/qa-checklist.md`
|
||||
|
||||
**Checks:**
|
||||
- iPhone viewport
|
||||
- source badge 정확성
|
||||
- gateway standby != down
|
||||
- mock/fallback 구분
|
||||
- PLAN approval gate 표시
|
||||
- build/lint pass
|
||||
|
||||
---
|
||||
|
||||
## 7. Approval Gates
|
||||
|
||||
### Gate A — Rebuild plan approval
|
||||
|
||||
지금 단계. 자기야가 이 PLAN을 승인해야 구현 시작 가능.
|
||||
|
||||
### Gate B — Frontend v0 implementation
|
||||
|
||||
허용:
|
||||
- frontend route/component 추가
|
||||
- mock/static data
|
||||
- no DB migration
|
||||
- no deploy
|
||||
|
||||
### Gate C — Backend API v0
|
||||
|
||||
허용:
|
||||
- read-only summary endpoint
|
||||
- mock/fallback service
|
||||
- no DB migration
|
||||
|
||||
### Gate D — Proxmox integration
|
||||
|
||||
별도 Proxmox monitoring PLAN과 연결.
|
||||
|
||||
### Gate E — Deploy
|
||||
|
||||
L3. 별도 승인 필요.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rollback Plan
|
||||
|
||||
- feature branch에서 작업한다.
|
||||
- 기존 `main`과 production route는 건드리지 않는다.
|
||||
- 새 route가 문제면 branch 폐기 또는 route 제거.
|
||||
- 배포 전까지 기존 사이트 영향 없음.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
1. 이름을 계속 `하나랑 대시보드`로 둘지, `하나랑 Control Center`로 바꿀지
|
||||
2. 첫 화면을 `/`로 교체할지, `/control`에서 먼저 검증할지
|
||||
3. 기존 오피스/등축 UI를 완전히 버릴지, 나중에 `Agents` 화면의 보조 시각화로 남길지
|
||||
4. 로그인/권한을 유지할지, 자기야 개인망 전용으로 단순화할지
|
||||
|
||||
## 10. 이랑이 추천
|
||||
|
||||
- 이름: `하나랑 Live Workboard` 또는 `하나랑 Control Center`
|
||||
- 메인 서비스: 4자매 작업 과정 실시간 화면
|
||||
- 구현 시작 route: `/control` 또는 `/live`
|
||||
- 기존 `/`는 당장 유지
|
||||
- v0는 mock event stream으로 “작업 과정이 보이는 화면”부터 완성
|
||||
- 이후 backend `/control/summary`, `/control/events` 연결
|
||||
- 마지막에 `/`를 새 live 화면으로 교체
|
||||
|
||||
이 방식이면 자기야가 원하는 “4자매가 일하는 걸 실시간으로 보는 메인 서비스”를 먼저 만들고, 인프라/프로젝트 관제는 그 옆에 붙일 수 있어.
|
||||
57
.plans/rebuild-from-scratch/builder-report-v0.md
Normal file
57
.plans/rebuild-from-scratch/builder-report-v0.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Narang Builder Report — Live Workboard v0
|
||||
|
||||
## Scope Executed
|
||||
|
||||
- Plan: `.plans/rebuild-from-scratch/PLAN.md`
|
||||
- Task: Frontend live workboard skeleton
|
||||
- Scope: mock event stream 기반 4자매 작업 과정 메인 화면 구현
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `frontend/app/page.tsx`
|
||||
- 기존 사무실 메인 화면을 Live Workboard 메인 화면으로 교체
|
||||
- `frontend/app/control/page.tsx`
|
||||
- `/control` route에서도 같은 Live Workboard 확인 가능
|
||||
- `frontend/components/common/Sidebar.tsx`
|
||||
- nav에 `Live`, `Control` 항목 추가
|
||||
- `frontend/components/live/LiveWorkboard.tsx`
|
||||
- 메인 Live Workboard container
|
||||
- phase strip, 4자매 lane, active focus, timeline, v0 scope note 구성
|
||||
- `frontend/components/live/SisterActivityLane.tsx`
|
||||
- 하랑/나랑/다랑/이랑 activity lane card
|
||||
- `frontend/components/live/WorkflowTimeline.tsx`
|
||||
- mock event stream timeline
|
||||
- `frontend/components/live/ActiveTaskFocus.tsx`
|
||||
- 선택 이벤트 상세와 approval/risk panel
|
||||
- `frontend/lib/control/types.ts`
|
||||
- Live Workboard 타입 정의
|
||||
- `frontend/lib/control/mock-events.ts`
|
||||
- v0 mock workflow/sister/event/approval data
|
||||
|
||||
## Verification
|
||||
|
||||
- `npm run lint -- components/live app/control app/page.tsx lib/control components/common/Sidebar.tsx`: PASS
|
||||
- `npm run build`: PASS
|
||||
- `curl -I http://localhost:3004/`: HTTP 200 during dev server smoke test
|
||||
- Browser accessibility snapshot: `/`에 Live Workboard, 4자매 lane, Workflow Timeline, Active Task Focus 확인
|
||||
|
||||
## Known Existing Issues
|
||||
|
||||
- `npm run lint` 전체 실행은 기존 파일들의 React 19 lint rule 위반 때문에 FAIL.
|
||||
- 새로 추가/수정한 Live Workboard 관련 파일만 대상으로 한 lint는 PASS.
|
||||
- 기존 오류 위치 예시:
|
||||
- `components/office-room/SisterDetailPanel.tsx`
|
||||
- `components/rails/FileViewerModal.tsx`
|
||||
- `components/rails/SubTaskDetailDrawer.tsx`
|
||||
- `components/rails/TransitionsTimeline.tsx`
|
||||
|
||||
## Risks / Notes
|
||||
|
||||
- 현재 데이터는 `source: mock`으로 명확히 표시됨.
|
||||
- 아직 real Socket.IO event stream은 연결하지 않음.
|
||||
- production deploy는 수행하지 않음.
|
||||
- DB/Prisma/Nginx/systemd 변경 없음.
|
||||
|
||||
## Ready for Darang Review
|
||||
|
||||
yes
|
||||
59
.plans/rebuild-from-scratch/darang-review-v0.md
Normal file
59
.plans/rebuild-from-scratch/darang-review-v0.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Darang Review Report — Live Workboard v0
|
||||
|
||||
## Verdict
|
||||
|
||||
PASS WITH IMPORTANT NOTES
|
||||
|
||||
## Spec Compliance
|
||||
|
||||
- [x] 메인 화면의 핵심을 4자매 작업 과정 실시간 시각화로 변경했다.
|
||||
- [x] 하랑/나랑/다랑/이랑 activity lane을 구현했다.
|
||||
- [x] Workflow Timeline을 구현했다.
|
||||
- [x] Active Task Focus를 구현했다.
|
||||
- [x] Approval / Risk side panel을 구현했다.
|
||||
- [x] `source: mock · v0`를 UI에 명확히 표시했다.
|
||||
- [x] `/` 메인 화면과 `/control` route에서 Live Workboard를 볼 수 있다.
|
||||
- [x] DB migration, 배포, 인프라 변경 없이 frontend v0 범위만 수행했다.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
없음.
|
||||
|
||||
## Important Issues
|
||||
|
||||
1. 전체 `npm run lint`는 기존 legacy 파일 오류 때문에 실패한다.
|
||||
- 새 파일/수정 파일 대상 lint는 PASS.
|
||||
- build는 PASS.
|
||||
2. 브라우저 vision 검증은 인증 상태 차이 때문에 로그인 화면을 보기도 했다.
|
||||
- accessibility snapshot과 curl smoke에서는 `/` route가 정상 응답하고 Live Workboard 구조가 확인됐다.
|
||||
- 실제 운영 계정 로그인 후 시각 QA가 한 번 더 필요하다.
|
||||
|
||||
## Minor Issues
|
||||
|
||||
- `/control`과 `/`가 같은 화면을 보여준다. v1에서 `/control`을 상세 관제 route로 분리할지 결정 필요.
|
||||
- mock event 자동 회전은 데모용이다. 실제 event stream 연결 시 선택 이벤트 유지 정책을 다시 정해야 한다.
|
||||
|
||||
## Verification Checked
|
||||
|
||||
- `npm run lint -- components/live app/control app/page.tsx lib/control components/common/Sidebar.tsx`: PASS
|
||||
- `npm run build`: PASS
|
||||
- `curl -I http://localhost:3004/`: PASS, HTTP 200
|
||||
- browser snapshot: PASS, Live Workboard 주요 섹션 확인
|
||||
|
||||
## Deployment Risk
|
||||
|
||||
Low for local/frontend code.
|
||||
|
||||
이유:
|
||||
- frontend-only 변경
|
||||
- DB/인프라/배포 없음
|
||||
- 기존 office 컴포넌트 삭제 없음
|
||||
- production 반영은 아직 별도 승인 필요
|
||||
|
||||
## Required Fixes Before Proceeding
|
||||
|
||||
없음.
|
||||
|
||||
## Recommended Next Step
|
||||
|
||||
v1 전에 먼저 자기야가 화면 방향을 확인한다. 방향이 맞으면 다음 task는 backend event contract (`GET /control/summary`, `WS /control/events`) 설계/구현이다.
|
||||
7
frontend/app/control/page.tsx
Normal file
7
frontend/app/control/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import LiveWorkboard from '@/components/live/LiveWorkboard';
|
||||
|
||||
export default function ControlPage() {
|
||||
return <LiveWorkboard />;
|
||||
}
|
||||
@@ -1,103 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import OfficeRoom from '@/components/office-room/OfficeRoom';
|
||||
import ActivePipelineStrip from '@/components/dashboard/ActivePipelineStrip';
|
||||
|
||||
const PageRoot = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
`;
|
||||
|
||||
const PageHeader = styled.header`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const PageTitle = styled.h1`
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
const PageSub = styled.p`
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
type Stage = 'plan' | 'implement' | 'review' | 'deploy' | null;
|
||||
|
||||
const STATE_TO_STAGE: Record<string, Stage> = {
|
||||
planning: 'plan',
|
||||
implementing: 'implement',
|
||||
reviewing: 'review',
|
||||
deploying: 'deploy',
|
||||
};
|
||||
import LiveWorkboard from '@/components/live/LiveWorkboard';
|
||||
|
||||
export default function HomePage() {
|
||||
const [activeStage, setActiveStage] = useState<Stage>(null);
|
||||
|
||||
// Poll the rails pipelines list every 4s and pick the most recently
|
||||
// updated non-terminal pipeline. Map its currentState to a stage so the
|
||||
// matching sister card pulses.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/rails/pipelines?limit=10`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as {
|
||||
pipelines?: Array<{ currentState: string; updatedAt: string }>;
|
||||
};
|
||||
if (cancelled) return;
|
||||
const live = (data.pipelines ?? []).filter((p) =>
|
||||
['planning', 'implementing', 'reviewing', 'deploying'].includes(
|
||||
p.currentState,
|
||||
),
|
||||
);
|
||||
if (live.length === 0) {
|
||||
setActiveStage(null);
|
||||
return;
|
||||
}
|
||||
// Most recently updated wins
|
||||
const sorted = live.slice().sort((a, b) =>
|
||||
(b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''),
|
||||
);
|
||||
const top = sorted[0];
|
||||
if (top) setActiveStage(STATE_TO_STAGE[top.currentState] ?? null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void load();
|
||||
const t = setInterval(load, 4000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageRoot>
|
||||
<PageHeader>
|
||||
<PageTitle>🏢 사무실</PageTitle>
|
||||
<PageSub>
|
||||
4 자매가 일하는 가상 사무실 — 클릭으로 상세 정보, /rails 에서 파이프라인 모니터링
|
||||
</PageSub>
|
||||
</PageHeader>
|
||||
|
||||
<OfficeRoom activeStage={activeStage} />
|
||||
|
||||
<ActivePipelineStrip />
|
||||
</PageRoot>
|
||||
);
|
||||
return <LiveWorkboard />;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { usePathname } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/', label: '🏢 사무실' },
|
||||
{ href: '/', label: '🟠 Live' },
|
||||
{ href: '/control', label: '🧭 Control' },
|
||||
{ href: '/rails', label: '🚦 레일' },
|
||||
{ href: '/settings', label: '⚙️ 설정' },
|
||||
];
|
||||
|
||||
136
frontend/components/live/ActiveTaskFocus.tsx
Normal file
136
frontend/components/live/ActiveTaskFocus.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import type { ApprovalItem, WorkflowEvent } from '@/lib/control/types';
|
||||
|
||||
const Panel = styled.aside`
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
`;
|
||||
|
||||
const Card = styled.section`
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.018));
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
const Eyebrow = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const EventTitle = styled.h3`
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
`;
|
||||
|
||||
const Detail = styled.p`
|
||||
margin: 12px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const MetaGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
const Meta = styled.div`
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.18);
|
||||
`;
|
||||
|
||||
const MetaValue = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
`;
|
||||
|
||||
const ApprovalList = styled.div`
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ApprovalRow = styled.div<{ $status: ApprovalItem['status'] }>`
|
||||
display: grid;
|
||||
grid-template-columns: 42px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border: 1px solid ${({ $status }) => {
|
||||
if ($status === 'blocked') return 'rgba(255, 112, 112, 0.42)';
|
||||
if ($status === 'waiting') return 'rgba(255, 204, 102, 0.38)';
|
||||
return 'rgba(139, 211, 255, 0.28)';
|
||||
}};
|
||||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.14);
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const Risk = styled.span`
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const Status = styled.span`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
export default function ActiveTaskFocus({
|
||||
selectedEvent,
|
||||
approvals,
|
||||
}: {
|
||||
selectedEvent: WorkflowEvent;
|
||||
approvals: ApprovalItem[];
|
||||
}) {
|
||||
return (
|
||||
<Panel>
|
||||
<Card>
|
||||
<Title>Active Task Focus</Title>
|
||||
<Eyebrow>{selectedEvent.sister} · {selectedEvent.type}</Eyebrow>
|
||||
<EventTitle>{selectedEvent.title}</EventTitle>
|
||||
<Detail>{selectedEvent.detail}</Detail>
|
||||
<MetaGrid>
|
||||
<Meta>
|
||||
<Eyebrow>workflow</Eyebrow>
|
||||
<MetaValue>{selectedEvent.workflowId}</MetaValue>
|
||||
</Meta>
|
||||
<Meta>
|
||||
<Eyebrow>source</Eyebrow>
|
||||
<MetaValue>{selectedEvent.source}</MetaValue>
|
||||
</Meta>
|
||||
</MetaGrid>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Title>Approval / Risk</Title>
|
||||
<ApprovalList>
|
||||
{approvals.map((item) => (
|
||||
<ApprovalRow key={item.id} $status={item.status}>
|
||||
<Risk>{item.risk}</Risk>
|
||||
<span>{item.title}</span>
|
||||
<Status>{item.status}</Status>
|
||||
</ApprovalRow>
|
||||
))}
|
||||
</ApprovalList>
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
223
frontend/components/live/LiveWorkboard.tsx
Normal file
223
frontend/components/live/LiveWorkboard.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import SisterActivityLane from './SisterActivityLane';
|
||||
import WorkflowTimeline from './WorkflowTimeline';
|
||||
import ActiveTaskFocus from './ActiveTaskFocus';
|
||||
import { mockLiveWorkboard, rotateEvents } from '@/lib/control/mock-events';
|
||||
|
||||
const Root = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
`;
|
||||
|
||||
const Hero = styled.header`
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 28px;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at 16% 18%, rgba(255,143,112,0.18), transparent 34%),
|
||||
radial-gradient(circle at 82% 12%, rgba(139,211,255,0.16), transparent 28%),
|
||||
linear-gradient(135deg, rgba(255,255,255,0.075), rgba(255,255,255,0.02));
|
||||
`;
|
||||
|
||||
const HeroTop = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
const TitleBlock = styled.div`
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 5vw, 58px);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.055em;
|
||||
`;
|
||||
|
||||
const Summary = styled.p`
|
||||
margin: 0;
|
||||
max-width: 760px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const SourceBadge = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(255, 204, 102, 0.35);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
color: #ffcc66;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const Dot = styled.span`
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #ffcc66;
|
||||
box-shadow: 0 0 18px rgba(255, 204, 102, 0.95);
|
||||
`;
|
||||
|
||||
const PhaseGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 22px;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const PhaseCell = styled.div<{ $active: boolean }>`
|
||||
border: 1px solid ${({ $active }) => ($active ? 'rgba(255,255,255,0.36)' : 'rgba(255,255,255,0.09)')};
|
||||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
color: ${({ $active }) => ($active ? 'var(--text-primary)' : 'var(--text-secondary)')};
|
||||
background: ${({ $active }) => ($active ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.16)')};
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
`;
|
||||
|
||||
const MainGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: var(--space-lg);
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const LanesGrid = styled.section`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-md);
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const BottomGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr);
|
||||
gap: var(--space-lg);
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const NoteCard = styled.section`
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
background: rgba(255,255,255,0.025);
|
||||
`;
|
||||
|
||||
const NoteTitle = styled.h2`
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
const NoteText = styled.p`
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const phases = [
|
||||
['planning', 'PLAN'],
|
||||
['building', 'BUILD'],
|
||||
['reviewing', 'REVIEW'],
|
||||
['waiting_approval', 'APPROVAL'],
|
||||
['deploying', 'DEPLOY'],
|
||||
] as const;
|
||||
|
||||
export default function LiveWorkboard() {
|
||||
const [tick, setTick] = useState(0);
|
||||
const [selectedId, setSelectedId] = useState(mockLiveWorkboard.events[0]?.id ?? '');
|
||||
|
||||
const events = useMemo(() => rotateEvents(mockLiveWorkboard.events, tick), [tick]);
|
||||
const selected = events.find((event) => event.id === selectedId) ?? events[0];
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setTick((value) => value + 1);
|
||||
}, 4200);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<Hero>
|
||||
<HeroTop>
|
||||
<TitleBlock>
|
||||
<Label>4 Sisters Live Workboard</Label>
|
||||
<Title>지금 4자매가 어떻게 일하는지</Title>
|
||||
<Summary>{mockLiveWorkboard.workflow.summary}</Summary>
|
||||
</TitleBlock>
|
||||
<SourceBadge aria-label="data source mock">
|
||||
<Dot /> source: {mockLiveWorkboard.source} · v0
|
||||
</SourceBadge>
|
||||
</HeroTop>
|
||||
|
||||
<PhaseGrid>
|
||||
{phases.map(([phase, label]) => (
|
||||
<PhaseCell key={phase} $active={mockLiveWorkboard.workflow.phase === phase}>
|
||||
{label}
|
||||
</PhaseCell>
|
||||
))}
|
||||
</PhaseGrid>
|
||||
</Hero>
|
||||
|
||||
<MainGrid>
|
||||
<LanesGrid>
|
||||
{mockLiveWorkboard.sisters.map((lane) => (
|
||||
<SisterActivityLane key={lane.id} lane={lane} />
|
||||
))}
|
||||
</LanesGrid>
|
||||
<ActiveTaskFocus selectedEvent={selected} approvals={mockLiveWorkboard.approvals} />
|
||||
</MainGrid>
|
||||
|
||||
<BottomGrid>
|
||||
<WorkflowTimeline
|
||||
events={events}
|
||||
selectedId={selected.id}
|
||||
onSelect={(event) => setSelectedId(event.id)}
|
||||
/>
|
||||
<NoteCard>
|
||||
<NoteTitle>v0 범위</NoteTitle>
|
||||
<NoteText>
|
||||
이 화면은 아직 mock event stream이야. 목적은 먼저 “4자매가 작업하는 과정이 보이는 경험”을 검증하는 것. 다음 단계에서 Socket.IO live event와 `/control/summary`를 붙이면 돼.
|
||||
</NoteText>
|
||||
</NoteCard>
|
||||
</BottomGrid>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
149
frontend/components/live/SisterActivityLane.tsx
Normal file
149
frontend/components/live/SisterActivityLane.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
import type { SisterLaneState } from '@/lib/control/types';
|
||||
|
||||
const pulse = keyframes`
|
||||
0%, 100% { box-shadow: 0 0 0 rgba(255, 255, 255, 0); }
|
||||
50% { box-shadow: 0 0 24px rgba(255, 255, 255, 0.08); }
|
||||
`;
|
||||
|
||||
const LaneCard = styled.article<{ $color: string; $active: boolean }>`
|
||||
position: relative;
|
||||
min-height: 258px;
|
||||
border: 1px solid color-mix(in srgb, ${({ $color }) => $color} 42%, var(--border-color));
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top right, color-mix(in srgb, ${({ $color }) => $color} 18%, transparent), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.015));
|
||||
animation: ${({ $active }) => ($active ? pulse : 'none')} 2.8s ease-in-out infinite;
|
||||
`;
|
||||
|
||||
const TopRow = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
`;
|
||||
|
||||
const Identity = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const Name = styled.h3`
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
`;
|
||||
|
||||
const Role = styled.span`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const StatusPill = styled.span<{ $color: string }>`
|
||||
border: 1px solid color-mix(in srgb, ${({ $color }) => $color} 56%, var(--border-color));
|
||||
color: ${({ $color }) => $color};
|
||||
border-radius: 999px;
|
||||
padding: 5px 9px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
`;
|
||||
|
||||
const TaskBlock = styled.div`
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const Body = styled.p`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
const Muted = styled.p`
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const ProgressTrack = styled.div`
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
const ProgressFill = styled.div<{ $progress: number; $color: string }>`
|
||||
width: ${({ $progress }) => $progress}%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, ${({ $color }) => $color}, color-mix(in srgb, ${({ $color }) => $color} 54%, white));
|
||||
`;
|
||||
|
||||
const MetaRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
export default function SisterActivityLane({ lane }: { lane: SisterLaneState }) {
|
||||
const active = lane.status === 'working' || lane.status === 'thinking' || lane.status === 'reviewing';
|
||||
|
||||
return (
|
||||
<LaneCard $color={lane.color} $active={active}>
|
||||
<TopRow>
|
||||
<Identity>
|
||||
<Name>{lane.name}</Name>
|
||||
<Role>{lane.role} · {lane.mode}</Role>
|
||||
</Identity>
|
||||
<StatusPill $color={lane.color}>{lane.status}</StatusPill>
|
||||
</TopRow>
|
||||
|
||||
<TaskBlock>
|
||||
<div>
|
||||
<Label>Current task</Label>
|
||||
<Body>{lane.currentTask}</Body>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Last event</Label>
|
||||
<Muted>{lane.lastEvent}</Muted>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Next handoff</Label>
|
||||
<Muted>{lane.nextHandoff}</Muted>
|
||||
</div>
|
||||
</TaskBlock>
|
||||
|
||||
<ProgressTrack aria-label={`${lane.name} progress`}>
|
||||
<ProgressFill $progress={lane.progress} $color={lane.color} />
|
||||
</ProgressTrack>
|
||||
<MetaRow>
|
||||
<span>{lane.phase}</span>
|
||||
<span>{lane.progress}%</span>
|
||||
</MetaRow>
|
||||
</LaneCard>
|
||||
);
|
||||
}
|
||||
123
frontend/components/live/WorkflowTimeline.tsx
Normal file
123
frontend/components/live/WorkflowTimeline.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import type { WorkflowEvent } from '@/lib/control/types';
|
||||
|
||||
const TimelineRoot = styled.section`
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
padding: 18px;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const LiveBadge = styled.span`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: #ffcc66;
|
||||
border: 1px solid rgba(255, 204, 102, 0.35);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
`;
|
||||
|
||||
const EventList = styled.ol`
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const EventItem = styled.li<{ $selected: boolean }>`
|
||||
display: grid;
|
||||
grid-template-columns: 54px 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid ${({ $selected }) => ($selected ? 'rgba(255,255,255,0.34)' : 'rgba(255,255,255,0.08)')};
|
||||
background: ${({ $selected }) => ($selected ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.16)')};
|
||||
cursor: pointer;
|
||||
transition: border-color .15s ease, background .15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255,255,255,0.26);
|
||||
}
|
||||
`;
|
||||
|
||||
const EventTime = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const EventBody = styled.div`
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const EventTitle = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const EventDetail = styled.p`
|
||||
margin: 5px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const TypeBadge = styled.span`
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
export default function WorkflowTimeline({
|
||||
events,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
events: WorkflowEvent[];
|
||||
selectedId: string;
|
||||
onSelect: (event: WorkflowEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<TimelineRoot>
|
||||
<Header>
|
||||
<Title>Workflow Timeline</Title>
|
||||
<LiveBadge>mock stream</LiveBadge>
|
||||
</Header>
|
||||
<EventList>
|
||||
{events.map((event) => (
|
||||
<EventItem
|
||||
key={event.id}
|
||||
$selected={event.id === selectedId}
|
||||
onClick={() => onSelect(event)}
|
||||
>
|
||||
<EventTime>{event.at}</EventTime>
|
||||
<EventBody>
|
||||
<EventTitle>
|
||||
<span>{event.title}</span>
|
||||
<TypeBadge>{event.type}</TypeBadge>
|
||||
</EventTitle>
|
||||
<EventDetail>{event.detail}</EventDetail>
|
||||
</EventBody>
|
||||
</EventItem>
|
||||
))}
|
||||
</EventList>
|
||||
</TimelineRoot>
|
||||
);
|
||||
}
|
||||
146
frontend/lib/control/mock-events.ts
Normal file
146
frontend/lib/control/mock-events.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { LiveWorkboardState, WorkflowEvent } from './types';
|
||||
|
||||
export const mockLiveWorkboard: LiveWorkboardState = {
|
||||
source: 'mock',
|
||||
workflow: {
|
||||
id: 'wf-hanarang-live-workboard-v0',
|
||||
title: '하나랑 Live Workboard v0',
|
||||
phase: 'building',
|
||||
summary: '4자매가 작업하는 과정을 메인 화면에서 실시간으로 보여주는 첫 버전',
|
||||
planPath: '.plans/rebuild-from-scratch/PLAN.md',
|
||||
updatedAt: '방금 전',
|
||||
source: 'mock',
|
||||
},
|
||||
sisters: [
|
||||
{
|
||||
id: 'harang',
|
||||
name: '하랑이',
|
||||
role: 'Planner',
|
||||
color: '#8bd3ff',
|
||||
phase: 'planning',
|
||||
status: 'waiting',
|
||||
currentTask: 'PLAN.md 승인 범위 정리 완료',
|
||||
lastEvent: '나랑이에게 v0 구현 handoff 전달',
|
||||
nextHandoff: '구현 중 scope 변경 생기면 플랜 보강',
|
||||
progress: 100,
|
||||
mode: 'role-skill',
|
||||
},
|
||||
{
|
||||
id: 'narang',
|
||||
name: '나랑이',
|
||||
role: 'Builder',
|
||||
color: '#ffcc66',
|
||||
phase: 'building',
|
||||
status: 'working',
|
||||
currentTask: 'Live Workboard mock UI 구현',
|
||||
lastEvent: 'Sister activity lane 컴포넌트 생성',
|
||||
nextHandoff: 'lint/build 결과를 다랑이에게 전달',
|
||||
progress: 58,
|
||||
mode: 'role-skill',
|
||||
},
|
||||
{
|
||||
id: 'darang',
|
||||
name: '다랑이',
|
||||
role: 'Reviewer',
|
||||
color: '#c6a5ff',
|
||||
phase: 'reviewing',
|
||||
status: 'idle',
|
||||
currentTask: '구현 완료 대기',
|
||||
lastEvent: 'QA checklist 준비',
|
||||
nextHandoff: 'spec compliance review 시작',
|
||||
progress: 20,
|
||||
mode: 'role-skill',
|
||||
},
|
||||
{
|
||||
id: 'erang',
|
||||
name: '이랑이',
|
||||
role: 'Infra / Orchestrator',
|
||||
color: '#ff8f70',
|
||||
phase: 'waiting_approval',
|
||||
status: 'thinking',
|
||||
currentTask: '승인 게이트와 배포 위험 분리',
|
||||
lastEvent: 'DB/배포 변경 없음 확인',
|
||||
nextHandoff: '다랑이 PASS 후 자기야에게 배포 승인 요청',
|
||||
progress: 42,
|
||||
mode: 'main',
|
||||
},
|
||||
],
|
||||
approvals: [
|
||||
{
|
||||
id: 'approval-v0-local',
|
||||
title: 'v0 로컬 UI 구현',
|
||||
risk: 'L2',
|
||||
status: 'not-required',
|
||||
},
|
||||
{
|
||||
id: 'approval-deploy',
|
||||
title: 'production 배포',
|
||||
risk: 'L3',
|
||||
status: 'waiting',
|
||||
},
|
||||
{
|
||||
id: 'approval-proxmox-write',
|
||||
title: 'Proxmox write API / restart action',
|
||||
risk: 'L4',
|
||||
status: 'blocked',
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
id: 'evt-001',
|
||||
at: '13:04',
|
||||
sister: 'harang',
|
||||
type: 'PLAN_CREATED',
|
||||
title: '재구축 PLAN 작성',
|
||||
detail: '메인 서비스를 4 Sisters Live Workboard로 재정의',
|
||||
workflowId: 'wf-hanarang-live-workboard-v0',
|
||||
source: 'mock',
|
||||
},
|
||||
{
|
||||
id: 'evt-002',
|
||||
at: '13:09',
|
||||
sister: 'erang',
|
||||
type: 'APPROVAL_NEEDED',
|
||||
title: 'Gate A 승인 확인',
|
||||
detail: '자기야가 “그거 먼저 만들어보자”로 v0 구현 승인',
|
||||
workflowId: 'wf-hanarang-live-workboard-v0',
|
||||
source: 'mock',
|
||||
},
|
||||
{
|
||||
id: 'evt-003',
|
||||
at: '13:12',
|
||||
sister: 'narang',
|
||||
type: 'BUILD_STARTED',
|
||||
title: 'Frontend v0 구현 시작',
|
||||
detail: 'mock event stream 기반 lane/timeline/focus UI 생성',
|
||||
workflowId: 'wf-hanarang-live-workboard-v0',
|
||||
source: 'mock',
|
||||
},
|
||||
{
|
||||
id: 'evt-004',
|
||||
at: '13:15',
|
||||
sister: 'narang',
|
||||
type: 'FILES_CHANGED',
|
||||
title: 'Live Workboard 컴포넌트 추가',
|
||||
detail: 'SisterActivityLane, WorkflowTimeline, ActiveTaskFocus',
|
||||
workflowId: 'wf-hanarang-live-workboard-v0',
|
||||
source: 'mock',
|
||||
},
|
||||
{
|
||||
id: 'evt-005',
|
||||
at: 'next',
|
||||
sister: 'darang',
|
||||
type: 'REVIEW_REQUESTED',
|
||||
title: '다랑이 리뷰 예정',
|
||||
detail: 'lint/build 결과 확인 후 spec compliance review',
|
||||
workflowId: 'wf-hanarang-live-workboard-v0',
|
||||
source: 'mock',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function rotateEvents(events: WorkflowEvent[], tick: number): WorkflowEvent[] {
|
||||
if (events.length === 0) return events;
|
||||
const offset = tick % events.length;
|
||||
return [...events.slice(offset), ...events.slice(0, offset)];
|
||||
}
|
||||
75
frontend/lib/control/types.ts
Normal file
75
frontend/lib/control/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export type SourceKind = 'live' | 'snapshot' | 'mock' | 'fallback';
|
||||
|
||||
export type SisterId = 'harang' | 'narang' | 'darang' | 'erang';
|
||||
|
||||
export type WorkflowPhase =
|
||||
| 'planning'
|
||||
| 'building'
|
||||
| 'reviewing'
|
||||
| 'waiting_approval'
|
||||
| 'deploying'
|
||||
| 'blocked'
|
||||
| 'done';
|
||||
|
||||
export type WorkflowEventType =
|
||||
| 'PLAN_CREATED'
|
||||
| 'HANDOFF_SENT'
|
||||
| 'BUILD_STARTED'
|
||||
| 'FILES_CHANGED'
|
||||
| 'TEST_RUNNING'
|
||||
| 'TEST_PASSED'
|
||||
| 'REVIEW_REQUESTED'
|
||||
| 'QA_BLOCKED'
|
||||
| 'APPROVAL_NEEDED'
|
||||
| 'INFRA_CHECKED'
|
||||
| 'DEPLOY_READY';
|
||||
|
||||
export type SisterLaneState = {
|
||||
id: SisterId;
|
||||
name: string;
|
||||
role: string;
|
||||
color: string;
|
||||
phase: WorkflowPhase;
|
||||
status: 'idle' | 'thinking' | 'working' | 'reviewing' | 'blocked' | 'waiting';
|
||||
currentTask: string;
|
||||
lastEvent: string;
|
||||
nextHandoff: string;
|
||||
progress: number;
|
||||
mode: 'main' | 'role-skill' | 'gateway' | 'standby';
|
||||
};
|
||||
|
||||
export type WorkflowEvent = {
|
||||
id: string;
|
||||
at: string;
|
||||
sister: SisterId;
|
||||
type: WorkflowEventType;
|
||||
title: string;
|
||||
detail: string;
|
||||
workflowId: string;
|
||||
source: SourceKind;
|
||||
};
|
||||
|
||||
export type ActiveWorkflow = {
|
||||
id: string;
|
||||
title: string;
|
||||
phase: WorkflowPhase;
|
||||
summary: string;
|
||||
planPath: string;
|
||||
updatedAt: string;
|
||||
source: SourceKind;
|
||||
};
|
||||
|
||||
export type ApprovalItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
risk: 'L1' | 'L2' | 'L3' | 'L4';
|
||||
status: 'waiting' | 'not-required' | 'blocked';
|
||||
};
|
||||
|
||||
export type LiveWorkboardState = {
|
||||
workflow: ActiveWorkflow;
|
||||
sisters: SisterLaneState[];
|
||||
events: WorkflowEvent[];
|
||||
approvals: ApprovalItem[];
|
||||
source: SourceKind;
|
||||
};
|
||||
Reference in New Issue
Block a user