merge: hotfix-006 dashboard copy and live fidelity
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
|
||||
## 활성 작업
|
||||
- **SPRINT-015**: Master Dashboard v3 정보 구조 리디자인
|
||||
- **HOTFIX-006**: 대시보드 문구 절제 + 실시간성 정합성 보정
|
||||
|
||||
## 이번 Sprint 핵심 요구사항
|
||||
1. 메인 대시보드를 4자매 운영 관제의 대표 화면으로 재정의
|
||||
@@ -35,5 +36,7 @@
|
||||
- 구조 기준: `../ARCHITECTURE.md`
|
||||
- 디자인 인덱스: `./design/index.md`
|
||||
- Sprint 계획: `./sprints/SPRINT-015.md`
|
||||
- 나랑 handoff: `./sprints/SPRINT-015-NARANG-HANDOFF.md`
|
||||
- Sprint handoff: `./sprints/SPRINT-015-NARANG-HANDOFF.md`
|
||||
- Hotfix 계획: `./hotfix/HOTFIX-006.md`
|
||||
- Hotfix handoff: `./hotfix/HOTFIX-006-NARANG-HANDOFF.md`
|
||||
- 배포 플로우: `./deploy/main-release-flow.md`
|
||||
|
||||
@@ -204,6 +204,20 @@ User → Harang → Narang → Darang → Irang 흐름과, maker-checker loop, e
|
||||
- mock 텍스트/숫자로 고정하지 말고 실제 데이터가 들어올 자리를 먼저 설계할 것
|
||||
- 값이 없을 때는 `No recent activity`, `No active deployment`, `No harness rules yet`처럼 조용한 empty state 제공
|
||||
- offline / missing / unavailable 상태는 숨기지 말고 명확히 드러낼 것
|
||||
- websocket 연결 상태와 데이터 freshness를 같은 의미로 쓰지 말 것
|
||||
- 각 섹션은 가능하면 아래 source 중 하나로 구분할 것
|
||||
- `runtime live`
|
||||
- `event mirrored`
|
||||
- `polling snapshot`
|
||||
- `doc derived`
|
||||
- `empty / unavailable`
|
||||
- 추정/합성값이면 사실처럼 단정하지 말고 neutral wording 사용
|
||||
|
||||
## 카피 원칙
|
||||
- 설명문보다 라벨형 문구 우선
|
||||
- 첫 화면 문구는 짧고 건조해야 함
|
||||
- `~만들었어`, `~정리했어`, `~읽히게` 같은 메타 설명 금지
|
||||
- 보드/패널 body는 원문 요약 또는 상태 문구 위주로 짧게 유지
|
||||
|
||||
## 인터랙션 원칙
|
||||
- hover 없어도 핵심 정보가 읽혀야 함
|
||||
|
||||
44
.plans/hotfix/HOTFIX-006-NARANG-HANDOFF.md
Normal file
44
.plans/hotfix/HOTFIX-006-NARANG-HANDOFF.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# HOTFIX-006 Narang Handoff
|
||||
|
||||
## repo / branch
|
||||
- repo: `https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard`
|
||||
- branch: `feature/hotfix-006-dashboard-copy-live-fidelity`
|
||||
|
||||
## 읽을 문서
|
||||
1. `.plans/hotfix/HOTFIX-006.md`
|
||||
2. `.plans/design/ui/dashboard-design.md`
|
||||
|
||||
## 이번 수정 핵심
|
||||
자기야 피드백은 명확해.
|
||||
|
||||
1. 문구가 너무 길고 쓸데없는 말이 많아
|
||||
2. 실시간처럼 보이는데 실제론 아닌 부분이 있어
|
||||
|
||||
이 두 개를 바로 고쳐.
|
||||
|
||||
## 꼭 손볼 것
|
||||
- 상단 headline / summary
|
||||
- current focus 설명문
|
||||
- operator meta
|
||||
- pipeline node detail / fallback
|
||||
- infra/meta card detail
|
||||
- board 카드 body 문구
|
||||
- `socket live`, `LIVE ON` 같은 라벨의 의미
|
||||
|
||||
## 절대 기준
|
||||
- 설명문 쓰지 마
|
||||
- UI 자기소개하지 마
|
||||
- 실시간 아닌 걸 실시간처럼 포장하지 마
|
||||
- source-of-truth 애매하면 neutral wording으로 낮춰
|
||||
- 문구는 짧고 건조하게
|
||||
|
||||
## 구현 목표
|
||||
- `ACTIVE PIPELINE`은 진짜 이벤트 기반이면 그렇게 보이고
|
||||
- 아니면 `SNAPSHOT`, `DOC-DERIVED`, `NO ACTIVE HANDOFF`처럼 정직하게 보여
|
||||
- `MISTAKE LOG & HARNESS`도 원문/출처/시각 중심으로 짧게 정리
|
||||
- freshness 메타를 넣어서 stale 여부를 읽을 수 있게 해
|
||||
|
||||
## QA 전 체크
|
||||
- 자기야가 봤을 때 `주구절절하다`는 느낌이 없어야 해
|
||||
- `실시간인가? 아닌가?`가 헷갈리면 실패야
|
||||
- build 통과는 기본
|
||||
142
.plans/hotfix/HOTFIX-006.md
Normal file
142
.plans/hotfix/HOTFIX-006.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# HOTFIX-006: 대시보드 문구 절제 + 실시간성 정합성 보정
|
||||
|
||||
## 목표
|
||||
Master Dashboard v3에서 주구절절한 설명형 문구를 운영 대시보드다운 짧은 문장으로 정리하고, `실시간처럼 보이지만 실제로는 폴링/요약/문서 기반인 정보`를 정확한 source-of-truth 기준으로 다시 맞춘다.
|
||||
|
||||
## 왜 이걸 고치나
|
||||
현재 메인 대시보드는 레이아웃은 좋아졌는데 두 가지 문제가 남아 있어.
|
||||
|
||||
1. **문구가 길다**
|
||||
- 상단 Headline / Summary
|
||||
- OperatorMeta
|
||||
- pipeline/detail/fallback 문장
|
||||
- harness/qa 보드 설명
|
||||
이런 부분이 대시보드보다 설명문처럼 읽혀.
|
||||
|
||||
2. **실시간성 표현이 과하다**
|
||||
- `socket live`는 웹소켓 연결 상태일 뿐, 모든 카드/보드가 실시간 원본 반영이라는 뜻은 아니야.
|
||||
- `ACTIVE PIPELINE`, `MISTAKE LOG & HARNESS`, 일부 infra/meta는 최근 activity / QA 문서 / polling snapshot을 조합해 만든 값이 섞여 있어.
|
||||
- 따라서 `실시간 반영`처럼 보이는 문구와 라벨은 정정해야 해.
|
||||
|
||||
## 범위
|
||||
- 대시보드 메인(`/`) 문구 전반 절제
|
||||
- source-of-truth 명시 정리
|
||||
- 실시간 / 폴링 / 문서기반 / 추정치 상태 구분
|
||||
- 필요 시 백엔드 dashboard ops 응답에 freshness/source 메타 추가
|
||||
- 프론트에서 misleading wording 제거
|
||||
|
||||
## 태스크
|
||||
|
||||
### TASK-078: 대시보드 카피를 운영형 짧은 문구로 전면 정리
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/page.tsx`
|
||||
- `frontend/components/dashboard/ActivePipeline.tsx`
|
||||
- **설명:**
|
||||
- 문장형 설명을 줄이고 짧은 운영 문구로 교체
|
||||
- 섹션 제목/보조 문구/fallback/empty state를 모두 다시 본다
|
||||
- **문구 기준:**
|
||||
- 길게 설명하지 말 것
|
||||
- 1문장보다 라벨형/상태형 표현 우선
|
||||
- `~읽히게 만들었어`, `~정리했어` 같은 메타 설명 금지
|
||||
- **완료 기준:**
|
||||
- 첫 화면 문구가 대시보드 UI 문장처럼 짧고 건조함
|
||||
- 자기소개/설계 설명 문장이 제거됨
|
||||
|
||||
### TASK-079: source-of-truth 기준표 정리
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/dashboard/dashboard.service.ts`
|
||||
- 필요 시 dashboard DTO/타입 정의
|
||||
- `.plans/design/ui/dashboard-design.md`
|
||||
- **설명:** 각 섹션 값이 어디서 왔는지 명확히 구분한다.
|
||||
- **최소 구분:**
|
||||
- runtime live
|
||||
- websocket event mirrored
|
||||
- polling snapshot
|
||||
- repo/qa doc derived
|
||||
- fallback/empty
|
||||
- **완료 기준:**
|
||||
- 주요 카드/보드/파이프라인 값에 원본 기준을 설명할 수 있음
|
||||
- 합성값을 실시간 원본처럼 표시하지 않음
|
||||
|
||||
### TASK-080: `LIVE` 표현 정합성 보정
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/page.tsx`
|
||||
- `frontend/lib/useSocket.ts`
|
||||
- **설명:**
|
||||
- 현재 `socket live`는 websocket 연결 상태만 뜻하므로 표현을 더 정확히 바꾼다.
|
||||
- 예: `SOCKET ON`, `EVENT STREAM ON`, `SNAPSHOT`, `STALE`, `SYNCED 12s AGO` 같은 운영형 라벨로 정리
|
||||
- **완료 기준:**
|
||||
- websocket 연결 여부와 데이터 최신성이 혼동되지 않음
|
||||
- LIVE ON/OFF 하나만 보고 전체가 실시간이라고 오해되지 않음
|
||||
|
||||
### TASK-081: `ACTIVE PIPELINE` 실데이터/추정치 경계 보정
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/dashboard/dashboard.service.ts`
|
||||
- `frontend/components/dashboard/ActivePipeline.tsx`
|
||||
- **설명:**
|
||||
- 노드 상태/active task/focus가 최근 activity 기반 추론이면 그 사실을 숨기지 말 것
|
||||
- 명시적 handoff/event가 없으면 억지 문장 대신 짧은 neutral fallback 사용
|
||||
- **완료 기준:**
|
||||
- `ACTIVE PIPELINE`이 추정치를 확정 사실처럼 말하지 않음
|
||||
- 이벤트 없음/불충분 상태가 자연스럽게 보임
|
||||
|
||||
### TASK-082: `MISTAKE LOG & HARNESS` / 보드 카드 원문성 보강
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/dashboard/dashboard.service.ts`
|
||||
- `frontend/app/page.tsx`
|
||||
- **설명:**
|
||||
- `관리자 harness 편집 로그에서 직접 가져온 기록이야.` 같은 설명형 body 제거
|
||||
- 가능하면 원문 제목/원문 요약/출처/시각만 짧게 노출
|
||||
- **완료 기준:**
|
||||
- 보드 카드 문구가 덜 소설 같고 더 기록물처럼 읽힘
|
||||
- source 라벨/시각이 더 명확함
|
||||
|
||||
### TASK-083: freshness 메타 추가 및 노출
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/dashboard/dashboard.controller.ts`
|
||||
- `backend/src/dashboard/dashboard.service.ts`
|
||||
- `frontend/app/page.tsx`
|
||||
- **설명:** dashboard ops 응답에 최소 freshness 메타를 추가한다.
|
||||
- **예시:**
|
||||
- generatedAt
|
||||
- activityLatestAt
|
||||
- sistersLatestAt
|
||||
- qaDocLatestAt
|
||||
- **완료 기준:**
|
||||
- UI에서 `last sync`, `activity lag`, `runtime lag` 같은 판단이 가능함
|
||||
- stale 상태를 눈치채기 쉬움
|
||||
|
||||
## 문구 톤 기준 예시
|
||||
### 금지
|
||||
- "지금 어디가 움직이고 있는지, 첫 화면에서 바로 읽히게 만들었어."
|
||||
- "장식보다 handoff 판단이 먼저 읽히게 정리했어."
|
||||
- "현재 오너 기준은 ... 최근 handoff와 phase를 같이 보고 있어."
|
||||
- "관리자 harness 편집 로그에서 직접 가져온 기록이야."
|
||||
|
||||
### 권장
|
||||
- `MASTER DASHBOARD`
|
||||
- `CURRENT FOCUS`
|
||||
- `SOCKET ON`
|
||||
- `SNAPSHOT`
|
||||
- `NO ACTIVE HANDOFF`
|
||||
- `NO RECENT QA`
|
||||
- `LAST EVENT 2m AGO`
|
||||
- `DOC-DERIVED`
|
||||
|
||||
## 완료 기준
|
||||
- 문구가 짧고 운영 대시보드답게 바뀜
|
||||
- 실시간/폴링/문서유도 값이 구분됨
|
||||
- misleading `live` 표현이 제거됨
|
||||
- 자기야가 봤을 때 `실시간 아닌데 실시간처럼 보인다`는 느낌이 사라짐
|
||||
@@ -23,6 +23,13 @@ interface OpsBoardItem {
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface FreshnessMeta {
|
||||
generatedAt: string;
|
||||
activityLatestAt: string | null;
|
||||
sistersLatestAt: string | null;
|
||||
qaDocLatestAt: string | null;
|
||||
}
|
||||
|
||||
const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/'];
|
||||
const SISTER_ROLES: Record<string, string> = {
|
||||
harang: 'Plan & Assign',
|
||||
@@ -64,7 +71,7 @@ function nodeStateFromEvidence(params: {
|
||||
|
||||
function summarizeActivity(
|
||||
activity?: ActivityRecord | null,
|
||||
fallback = '최근 이벤트 없음',
|
||||
fallback = 'NO EVENT',
|
||||
) {
|
||||
return (
|
||||
activity?.detail?.trim() || activity?.action?.replace(/_/g, ' ') || fallback
|
||||
@@ -149,8 +156,8 @@ export class DashboardService {
|
||||
activity: latestProjectActivity,
|
||||
}),
|
||||
detail: latestProjectActivity
|
||||
? `${latestProjectActivity.project?.name ?? 'project'} · ${summarizeActivity(latestProjectActivity, '최근 프로젝트 이벤트 없음')}`
|
||||
: '명시적으로 기록된 handoff / request 로그가 아직 없어.',
|
||||
? `${latestProjectActivity.project?.name ?? 'project'} · ${summarizeActivity(latestProjectActivity, 'NO EVENT')}`
|
||||
: 'NO ACTIVE HANDOFF',
|
||||
},
|
||||
...sisters.map((sister) => {
|
||||
const activity = latestBySister.get(sister.name) ?? null;
|
||||
@@ -169,10 +176,10 @@ export class DashboardService {
|
||||
detail: activity
|
||||
? summarizeActivity(activity)
|
||||
: sister.status === 'offline'
|
||||
? '최근 런타임 응답이 없어서 offline 상태야.'
|
||||
? 'RUNTIME OFFLINE'
|
||||
: sister.lastSeen
|
||||
? `최근 활동 기록은 ${new Date(sister.lastSeen).toISOString()} 체크 기준이야.`
|
||||
: '최근 활동 로그가 아직 없어.',
|
||||
? 'SNAPSHOT ONLY'
|
||||
: 'NO EVENT',
|
||||
};
|
||||
}),
|
||||
];
|
||||
@@ -184,8 +191,8 @@ export class DashboardService {
|
||||
(item) =>
|
||||
({
|
||||
id: `harness-${item.id}`,
|
||||
title: item.detail ?? 'Harness updated',
|
||||
body: '관리자 harness 편집 로그에서 직접 가져온 기록이야.',
|
||||
title: item.detail ?? 'HARNESS UPDATED',
|
||||
body: item.detail ?? 'HARNESS UPDATED',
|
||||
tone: toneFromText(`${item.action} ${item.detail ?? ''}`),
|
||||
author: item.sister?.name
|
||||
? this.toDisplayName(item.sister.name)
|
||||
@@ -208,6 +215,23 @@ export class DashboardService {
|
||||
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
|
||||
.slice(0, 6);
|
||||
|
||||
const freshness: FreshnessMeta = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activityLatestAt: activities[0]?.createdAt?.toISOString() ?? null,
|
||||
sistersLatestAt:
|
||||
sisters
|
||||
.map((sister) => sister.lastSeen)
|
||||
.filter((value): value is Date => value instanceof Date)
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
?.toISOString() ?? null,
|
||||
qaDocLatestAt:
|
||||
board
|
||||
.filter((item) => item.category === 'DOC-DERIVED')
|
||||
.map((item) => item.time)
|
||||
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0] ??
|
||||
null,
|
||||
};
|
||||
|
||||
return {
|
||||
focusProject: focusProject
|
||||
? {
|
||||
@@ -222,19 +246,17 @@ export class DashboardService {
|
||||
pipeline: {
|
||||
activeTask: focusProject
|
||||
? `${focusProject.name} · ${focusProject.currentSprint ?? focusProject.phase}`
|
||||
: 'No active pipeline',
|
||||
: 'NO ACTIVE PIPELINE',
|
||||
focus: latestProjectActivity
|
||||
? summarizeActivity(
|
||||
latestProjectActivity,
|
||||
'최근 프로젝트 이벤트 없음',
|
||||
)
|
||||
: '최근 프로젝트 activity 기준으로 확정된 handoff가 아직 없어.',
|
||||
? summarizeActivity(latestProjectActivity, 'NO EVENT')
|
||||
: 'SNAPSHOT / NO ACTIVE HANDOFF',
|
||||
reviewLoopCount,
|
||||
escalationCount,
|
||||
deployState: focusProject?.deployStatus ?? 'standby',
|
||||
deployState: focusProject?.deployStatus ?? 'STANDBY',
|
||||
nodes,
|
||||
},
|
||||
board,
|
||||
freshness,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -283,7 +305,7 @@ export class DashboardService {
|
||||
tone,
|
||||
author: inferQaAuthor(latestPath, content),
|
||||
time: project.updatedAt,
|
||||
category: 'qa-log',
|
||||
category: 'DOC-DERIVED',
|
||||
source: `${project.repoName}:${latestPath}`,
|
||||
} satisfies OpsBoardItem,
|
||||
];
|
||||
|
||||
@@ -79,6 +79,12 @@ interface DashboardOpsData {
|
||||
nodes: PipelineNode[];
|
||||
};
|
||||
board: BoardItem[];
|
||||
freshness: {
|
||||
generatedAt: string;
|
||||
activityLatestAt: string | null;
|
||||
sistersLatestAt: string | null;
|
||||
qaDocLatestAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
||||
@@ -724,6 +730,18 @@ function formatTime(iso?: string | null): string {
|
||||
return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function formatSyncLabel(iso?: string | null): string {
|
||||
if (!iso) return 'NO SYNC';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (!Number.isFinite(diff) || diff < 0) return 'SYNC UNKNOWN';
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `SYNC ${sec}s AGO`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `SYNC ${min}m AGO`;
|
||||
const hr = Math.floor(min / 60);
|
||||
return `SYNC ${hr}h AGO`;
|
||||
}
|
||||
|
||||
function normalizePercent(value?: number | null): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
@@ -749,10 +767,10 @@ function buildSisterSummary(sister: SisterItem, activityItems: ActivityItem[], p
|
||||
if (latestActivity?.detail) return latestActivity.detail;
|
||||
const ownerProject = projects.find((project) => project.ownerSister === sister.name && project.phase !== 'DEPLOYED');
|
||||
if (ownerProject) return `${ownerProject.name} · ${ownerProject.phase}`;
|
||||
if (sister.status === 'offline') return '최근 런타임 응답 없음';
|
||||
if (sister.status === 'working') return 'active pipeline 처리 중';
|
||||
if (sister.status === 'online') return '새 handoff 대기 중';
|
||||
return '운영 상태 확인 중';
|
||||
if (sister.status === 'offline') return 'RUNTIME OFFLINE';
|
||||
if (sister.status === 'working') return 'ACTIVE SNAPSHOT';
|
||||
if (sister.status === 'online') return 'RUNTIME READY';
|
||||
return 'SNAPSHOT ONLY';
|
||||
}
|
||||
|
||||
function actionTone(action?: string, detail?: string | null): 'default' | 'warn' | 'ok' | 'active' {
|
||||
@@ -837,6 +855,12 @@ export default function DashboardPage() {
|
||||
}, []);
|
||||
|
||||
const derived = useMemo(() => {
|
||||
const freshness = opsData?.freshness ?? {
|
||||
generatedAt: null,
|
||||
activityLatestAt: null,
|
||||
sistersLatestAt: null,
|
||||
qaDocLatestAt: null,
|
||||
};
|
||||
const onlineCount = sisters.filter((item) => item.status === 'online' || item.status === 'working').length;
|
||||
const offlineCount = sisters.filter((item) => item.status === 'offline').length;
|
||||
const activeProject = opsData?.focusProject
|
||||
@@ -878,24 +902,24 @@ export default function DashboardPage() {
|
||||
|
||||
const infraCards = [
|
||||
{
|
||||
title: 'Gateway / Runtime',
|
||||
meta: connected ? 'Socket live · runtime heartbeat 수신 중' : 'Polling mode · websocket 연결 재시도 중',
|
||||
detail: `${onlineCount}/${Math.max(sisters.length, 4)} sisters active`,
|
||||
title: 'Runtime',
|
||||
meta: connected ? 'SOCKET / EVENT MIRRORED' : 'POLLING SNAPSHOT',
|
||||
detail: `${onlineCount}/${Math.max(sisters.length, 4)} ACTIVE · ${formatSyncLabel(freshness.sistersLatestAt)}`,
|
||||
},
|
||||
{
|
||||
title: 'Project Surface',
|
||||
meta: `${projects.length} projects tracked · ${totalSprints} sprints indexed`,
|
||||
detail: readyDeploy > 0 ? `${readyDeploy} ready for deploy` : 'deploy-ready backlog 없음',
|
||||
title: 'Projects',
|
||||
meta: `SNAPSHOT · ${projects.length} PROJECTS`,
|
||||
detail: readyDeploy > 0 ? `${readyDeploy} READY FOR DEPLOY` : 'NO DEPLOY QUEUE',
|
||||
},
|
||||
{
|
||||
title: 'Review Loop',
|
||||
meta: `${reviewProjects} QA queue · first-pass ${firstPassRate}%`,
|
||||
detail: escalationCount > 0 ? `blocker project ${escalationCount}건` : 'escalation 없음',
|
||||
title: 'QA',
|
||||
meta: `DOC-DERIVED · ${firstPassRate}%`,
|
||||
detail: escalationCount > 0 ? `${escalationCount} BLOCKER PROJECT` : 'NO BLOCKER',
|
||||
},
|
||||
{
|
||||
title: 'Activity Feed',
|
||||
meta: `${activityItems.length} recent events mirrored`,
|
||||
detail: activityItems[0]?.detail ?? activityItems[0]?.action ?? '최근 이벤트 없음',
|
||||
title: 'Events',
|
||||
meta: `EVENTS ${activityItems.length}`,
|
||||
detail: formatSyncLabel(freshness.activityLatestAt),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -916,8 +940,9 @@ export default function DashboardPage() {
|
||||
pipelineNodes,
|
||||
infraCards,
|
||||
board: opsData?.board ?? [],
|
||||
activeTask: opsData?.pipeline.activeTask ?? (activeProject ? `${activeProject.name} · ${activeProject.currentSprint ?? activeProject.phase}` : 'No active pipeline'),
|
||||
deployState: opsData?.pipeline.deployState ?? (activeProject?.deployStatus ?? 'standby'),
|
||||
activeTask: opsData?.pipeline.activeTask ?? (activeProject ? `${activeProject.name} · ${activeProject.currentSprint ?? activeProject.phase}` : 'NO ACTIVE PIPELINE'),
|
||||
deployState: opsData?.pipeline.deployState ?? (activeProject?.deployStatus ?? 'STANDBY'),
|
||||
freshness,
|
||||
};
|
||||
}, [activityItems, connected, opsData, projects, sisters]);
|
||||
|
||||
@@ -928,24 +953,22 @@ export default function DashboardPage() {
|
||||
<TopBar>
|
||||
<TopBlock>
|
||||
<div>
|
||||
<Eyebrow>Hanarang / master dashboard v3</Eyebrow>
|
||||
<Headline>지금 어디가 움직이고 있는지, 첫 화면에서 바로 읽히게 만들었어.</Headline>
|
||||
<Eyebrow>Master dashboard</Eyebrow>
|
||||
<Headline>MASTER PIPELINE BOARD</Headline>
|
||||
</div>
|
||||
<Summary>
|
||||
4자매 운영 상태, active pipeline, activity, metrics, infra, 운영 학습 자산까지 한 화면에 모았어. 장식보다 handoff 판단이 먼저 읽히게 정리했어.
|
||||
</Summary>
|
||||
<Summary>RUNTIME / EVENTS / DOCS</Summary>
|
||||
<InlineStats>
|
||||
<Chip $tone={connected ? 'active' : 'muted'}>{connected ? 'socket live' : 'polling mode'}</Chip>
|
||||
<Chip $tone="default">projects {String(projects.length).padStart(2, '0')}</Chip>
|
||||
<Chip $tone={derived.reviewProjects > 0 ? 'warning' : 'default'}>qa queue {String(derived.reviewProjects).padStart(2, '0')}</Chip>
|
||||
<Chip $tone={derived.readyDeploy > 0 ? 'active' : 'muted'}>ready deploy {String(derived.readyDeploy).padStart(2, '0')}</Chip>
|
||||
<Chip $tone={connected ? 'active' : 'muted'}>{connected ? 'socket on' : 'socket off'}</Chip>
|
||||
<Chip $tone="default">snapshot {formatSyncLabel(derived.freshness.generatedAt)}</Chip>
|
||||
<Chip $tone={derived.reviewProjects > 0 ? 'warning' : 'default'}>qa {String(derived.reviewProjects).padStart(2, '0')}</Chip>
|
||||
<Chip $tone={derived.readyDeploy > 0 ? 'active' : 'muted'}>deploy {String(derived.readyDeploy).padStart(2, '0')}</Chip>
|
||||
</InlineStats>
|
||||
</TopBlock>
|
||||
|
||||
<FocusPanel>
|
||||
<div>
|
||||
<Eyebrow>Current operating focus</Eyebrow>
|
||||
<FocusTitle>{derived.activeProject?.name ?? 'No active sprint'}</FocusTitle>
|
||||
<Eyebrow>Current focus</Eyebrow>
|
||||
<FocusTitle>{derived.activeProject?.name ?? 'NO ACTIVE SPRINT'}</FocusTitle>
|
||||
</div>
|
||||
<FocusBody>{derived.focusSummary}</FocusBody>
|
||||
<TechBar>
|
||||
@@ -954,11 +977,11 @@ export default function DashboardPage() {
|
||||
<FocusMetaGrid>
|
||||
<MetaCard>
|
||||
<MetaLabel>current sprint</MetaLabel>
|
||||
<MetaValue>{derived.activeProject?.currentSprint ?? 'backlog watch'}</MetaValue>
|
||||
<MetaValue>{derived.activeProject?.currentSprint ?? 'SNAPSHOT'}</MetaValue>
|
||||
</MetaCard>
|
||||
<MetaCard>
|
||||
<MetaLabel>phase</MetaLabel>
|
||||
<MetaValue>{derived.activeProject?.phase ?? 'monitoring'}</MetaValue>
|
||||
<MetaValue>{derived.activeProject?.phase ?? 'SNAPSHOT'}</MetaValue>
|
||||
</MetaCard>
|
||||
<MetaCard>
|
||||
<MetaLabel>sprint progress</MetaLabel>
|
||||
@@ -966,7 +989,7 @@ export default function DashboardPage() {
|
||||
</MetaCard>
|
||||
<MetaCard>
|
||||
<MetaLabel>deploy state</MetaLabel>
|
||||
<MetaValue>{derived.activeProject?.deployStatus ?? 'no deploy signal'}</MetaValue>
|
||||
<MetaValue>{derived.activeProject?.deployStatus ?? 'NO SIGNAL'}</MetaValue>
|
||||
</MetaCard>
|
||||
</FocusMetaGrid>
|
||||
</FocusPanel>
|
||||
@@ -976,11 +999,11 @@ export default function DashboardPage() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<ConnectionDot $connected={connected} />
|
||||
<div>
|
||||
<Eyebrow>Runtime link</Eyebrow>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{connected ? 'Discord / runtime connected' : 'Polling fallback active'}</div>
|
||||
<Eyebrow>Event stream</Eyebrow>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{connected ? 'SOCKET ON / EVENT MIRRORED' : 'SOCKET OFF / POLLING'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<LabelMeta><span>LIVE:</span>{connected ? 'ON' : 'OFF'}</LabelMeta>
|
||||
<LabelMeta><span>SYNC:</span>{formatSyncLabel(derived.freshness.activityLatestAt)}</LabelMeta>
|
||||
</ConnectionCard>
|
||||
|
||||
<OperatorRow>
|
||||
@@ -988,7 +1011,7 @@ export default function DashboardPage() {
|
||||
<OperatorText>
|
||||
<OperatorName>{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.label ?? '나랑이'}</OperatorName>
|
||||
<OperatorMeta>
|
||||
현재 오너 기준은 {SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.role ?? 'Implementation'}야. 최근 handoff와 phase를 같이 보고 있어.
|
||||
{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.role ?? 'Implementation'} / SNAPSHOT OWNER
|
||||
</OperatorMeta>
|
||||
</OperatorText>
|
||||
</OperatorRow>
|
||||
@@ -1018,7 +1041,7 @@ export default function DashboardPage() {
|
||||
{loading
|
||||
? Array.from({ length: 4 }).map((_, index) => <SkeletonCard key={index} />)
|
||||
: sisters.length === 0
|
||||
? <EmptyState>자매 런타임 데이터가 아직 없어.</EmptyState>
|
||||
? <EmptyState>NO RUNTIME DATA.</EmptyState>
|
||||
: sisters.map((sister) => {
|
||||
const sisterMeta = SISTER_META[sister.name] ?? { label: sister.name, role: sister.role ?? 'unknown', accent: '#888888' };
|
||||
return (
|
||||
@@ -1080,8 +1103,8 @@ export default function DashboardPage() {
|
||||
<PanelHeader>
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Activity feed</Eyebrow>
|
||||
<PanelTitle>최근 handoff / review / deploy 흐름</PanelTitle>
|
||||
<PanelDesc>이벤트 타입, 주체, 대상 문맥이 바로 읽히게 텍스트 로그 대신 운영 카드로 정리했어.</PanelDesc>
|
||||
<PanelTitle>EVENT MIRROR</PanelTitle>
|
||||
<PanelDesc>WEBSOCKET + POLLING SNAPSHOT</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>EVENT:</span>{String(activityItems.length).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
@@ -1089,7 +1112,7 @@ export default function DashboardPage() {
|
||||
{loading ? (
|
||||
<SkeletonCard />
|
||||
) : activityItems.length === 0 ? (
|
||||
<EmptyState>No recent activity.</EmptyState>
|
||||
<EmptyState>NO RECENT EVENT.</EmptyState>
|
||||
) : (
|
||||
<ActivityList>
|
||||
{activityItems.slice(0, 6).map((item) => (
|
||||
@@ -1117,8 +1140,8 @@ export default function DashboardPage() {
|
||||
<PanelHeader>
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Sprint metrics</Eyebrow>
|
||||
<PanelTitle>정량 지표로 지금 속도를 확인</PanelTitle>
|
||||
<PanelDesc>fake 숫자처럼 보이지 않게 프로젝트/QA/activity에서 바로 읽을 수 있는 값만 남겼어.</PanelDesc>
|
||||
<PanelTitle>SPRINT METRICS</PanelTitle>
|
||||
<PanelDesc>POLLING SNAPSHOT</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>SPRINT:</span>{String(derived.totalSprints).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
@@ -1127,22 +1150,22 @@ export default function DashboardPage() {
|
||||
<MetricCard>
|
||||
<MetaLabel>total tasks view</MetaLabel>
|
||||
<MetricValue>{derived.totalSprints}</MetricValue>
|
||||
<MetricHelp>현재 인덱싱된 sprint 기준 총 작업량이야.</MetricHelp>
|
||||
<MetricHelp>INDEXED SPRINT COUNT</MetricHelp>
|
||||
</MetricCard>
|
||||
<MetricCard>
|
||||
<MetaLabel>done sprint</MetaLabel>
|
||||
<MetricValue>{derived.doneSprints}</MetricValue>
|
||||
<MetricHelp>완료된 sprint 누적 수치야.</MetricHelp>
|
||||
<MetricHelp>DONE SPRINTS</MetricHelp>
|
||||
</MetricCard>
|
||||
<MetricCard>
|
||||
<MetaLabel>first-pass rate</MetaLabel>
|
||||
<MetricValue>{derived.firstPassRate}%</MetricValue>
|
||||
<MetricHelp>최근 QA 결과 중 blocker 없이 통과한 비율이야.</MetricHelp>
|
||||
<MetricHelp>QA DOC DERIVED</MetricHelp>
|
||||
</MetricCard>
|
||||
<MetricCard>
|
||||
<MetaLabel>escalations</MetaLabel>
|
||||
<MetricValue>{derived.escalationCount}</MetricValue>
|
||||
<MetricHelp>blocker 존재 프로젝트 수를 escalation 신호로 잡았어.</MetricHelp>
|
||||
<MetricHelp>BLOCKER PROJECTS</MetricHelp>
|
||||
</MetricCard>
|
||||
</MetricsGrid>
|
||||
|
||||
@@ -1163,8 +1186,8 @@ export default function DashboardPage() {
|
||||
<PanelHeader>
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Infrastructure overview</Eyebrow>
|
||||
<PanelTitle>gateway / sisters / deploy surface</PanelTitle>
|
||||
<PanelDesc>과장된 네트워크 다이어그램 대신 실제 상태 요약을 운영 맥락으로 묶었어.</PanelDesc>
|
||||
<PanelTitle>RUNTIME SURFACE</PanelTitle>
|
||||
<PanelDesc>LIVE / SNAPSHOT / DOC-DERIVED</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>NODE:</span>{String(sisters.length).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
@@ -1174,7 +1197,7 @@ export default function DashboardPage() {
|
||||
<InfraCard key={card.title}>
|
||||
<InfraTitle>{card.title}</InfraTitle>
|
||||
<InfraMeta>{card.meta}</InfraMeta>
|
||||
<EventTag $tone={card.title === 'Gateway / Runtime' && connected ? 'ok' : card.detail.includes('없음') ? 'default' : 'active'}>{card.detail}</EventTag>
|
||||
<EventTag $tone={card.title === 'Runtime' && connected ? 'ok' : card.detail.startsWith('NO ') ? 'default' : 'active'}>{card.detail}</EventTag>
|
||||
</InfraCard>
|
||||
))}
|
||||
</InfraMap>
|
||||
@@ -1184,14 +1207,14 @@ export default function DashboardPage() {
|
||||
<PanelHeader>
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Mistake log & harness</Eyebrow>
|
||||
<PanelTitle>실수와 운영 규칙도 홈에서 읽히게</PanelTitle>
|
||||
<PanelDesc>합성 요약 대신 QA 문서와 harness 변경 로그에서 직접 읽은 항목만 보드로 보여줘.</PanelDesc>
|
||||
<PanelTitle>RULE / LOG BOARD</PanelTitle>
|
||||
<PanelDesc>DOC-DERIVED / ACTIVITY</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>RULE:</span>{String(derived.board.length).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
|
||||
{derived.board.length === 0 ? (
|
||||
<EmptyState>아직 표시할 QA / harness 원본 기록이 없어.</EmptyState>
|
||||
<EmptyState>NO RULE LOG.</EmptyState>
|
||||
) : (
|
||||
<LessonList>
|
||||
{derived.board.map((item) => (
|
||||
@@ -1203,7 +1226,11 @@ export default function DashboardPage() {
|
||||
<EventTag>{formatRelativeTime(item.time)}</EventTag>
|
||||
</LessonMeta>
|
||||
<LessonBody>{item.body}</LessonBody>
|
||||
<LessonBody>Source · {item.source}</LessonBody>
|
||||
<LessonMeta>
|
||||
<EventTag>SRC</EventTag>
|
||||
<EventTag>{item.source}</EventTag>
|
||||
<EventTag>{formatTime(item.time)}</EventTag>
|
||||
</LessonMeta>
|
||||
</LessonCard>
|
||||
))}
|
||||
</LessonList>
|
||||
|
||||
@@ -64,6 +64,7 @@ const Summary = styled.p`
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
max-width: 780px;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const HeaderStats = styled.div`
|
||||
@@ -248,7 +249,7 @@ export default function ActivePipeline({
|
||||
<PipelineCard>
|
||||
<Header>
|
||||
<div>
|
||||
<Eyebrow>Master flow / active pipeline</Eyebrow>
|
||||
<Eyebrow>Active pipeline / snapshot</Eyebrow>
|
||||
<Title>{activeTask}</Title>
|
||||
<Summary>{focus}</Summary>
|
||||
</div>
|
||||
@@ -282,7 +283,7 @@ export default function ActivePipeline({
|
||||
<StateBadge $state={node.state}>{stateText[node.state]}</StateBadge>
|
||||
</NodeTop>
|
||||
<NodeDetail>{node.detail}</NodeDetail>
|
||||
<NodeHint>stage {String(index + 1).padStart(2, '0')}</NodeHint>
|
||||
<NodeHint>snapshot {String(index + 1).padStart(2, '0')}</NodeHint>
|
||||
</NodeCard>
|
||||
{index < nodes.length - 1 && <Connector $active={connectorActive} />}
|
||||
</NodeWrap>
|
||||
|
||||
Reference in New Issue
Block a user