diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 29ae368..2770d66 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -17,6 +17,7 @@ import { EventsModule } from './events/events.module'; 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'; @Module({ imports: [ @@ -36,6 +37,7 @@ import { SettingsModule } from './settings/settings.module'; CostsModule, GiteaSyncModule, SettingsModule, + DashboardModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/dashboard/dashboard.controller.ts b/backend/src/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..055d0e1 --- /dev/null +++ b/backend/src/dashboard/dashboard.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { DashboardService } from './dashboard.service'; + +@Controller('api/dashboard') +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + @Get('ops') + getOpsBoard(): Promise { + return this.dashboardService.getOpsBoard(); + } +} diff --git a/backend/src/dashboard/dashboard.module.ts b/backend/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..59439ad --- /dev/null +++ b/backend/src/dashboard/dashboard.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ProjectsModule } from '../projects/projects.module'; +import { GiteaModule } from '../gitea/gitea.module'; + +@Module({ + imports: [PrismaModule, ProjectsModule, GiteaModule], + controllers: [DashboardController], + providers: [DashboardService], + exports: [DashboardService], +}) +export class DashboardModule {} diff --git a/backend/src/dashboard/dashboard.service.ts b/backend/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..b61d7e7 --- /dev/null +++ b/backend/src/dashboard/dashboard.service.ts @@ -0,0 +1,248 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { ProjectsService } from '../projects/projects.service'; +import { GiteaService } from '../gitea/gitea.service'; + +interface ActivityRecord { + id: number; + action: string; + detail: string | null; + createdAt: Date; + sister: { name: string } | null; + project: { name: string } | null; +} + +interface OpsBoardItem { + id: string; + title: string; + body: string; + tone: 'default' | 'warn' | 'ok' | 'active'; + author: string; + time: string; + category: string; + source: string; +} + +const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/']; +const SISTER_ROLES: Record = { + harang: 'Plan & Assign', + narang: 'Implement', + darang: 'Review / QA', + erang: 'Deploy / Infra', +}; + +function toneFromText(text: string): 'default' | 'warn' | 'ok' | 'active' { + const normalized = text.toLowerCase(); + if (/(fail|error|blocker|offline|invalid|drift)/.test(normalized)) return 'warn'; + if (/(passed|deploy|merged|restart_ok|write_ok|successful)/.test(normalized)) return 'ok'; + if (/(review|qa|handoff|sync|update)/.test(normalized)) return 'active'; + return 'default'; +} + +function nodeStateFromEvidence(params: { + sisterStatus?: string; + activity?: ActivityRecord | null; + name: string; +}): 'idle' | 'active' | 'review' | 'blocked' | 'ready' { + const { sisterStatus, activity, name } = params; + const text = `${activity?.action ?? ''} ${activity?.detail ?? ''}`.toLowerCase(); + + if (name !== 'user' && sisterStatus === 'offline') return 'blocked'; + if (/(review|qa)/.test(text)) return 'review'; + if (name === 'erang' && /(deploy|merged|ready for deploy|deployed)/.test(text)) return 'ready'; + if (sisterStatus === 'working') return 'active'; + if (activity) return 'ready'; + return 'idle'; +} + +function summarizeActivity(activity?: ActivityRecord | null, fallback = '최근 이벤트 없음') { + return activity?.detail?.trim() || activity?.action?.replace(/_/g, ' ') || fallback; +} + +function parseQaSummary(content: string): string { + const lines = content.split(/\r?\n/).map((line) => line.trim()); + const taskLine = lines.find((line) => /^###\s+/.test(line)); + if (taskLine) return taskLine.replace(/^###\s+/, '').trim(); + const bullet = lines.find((line) => /^-\s+/.test(line)); + if (bullet) return bullet.replace(/^-\s+/, '').trim(); + return lines.find((line) => line.length > 0 && !line.startsWith('#')) ?? 'QA note'; +} + +function inferQaAuthor(path: string, content: string): string { + if (/darang/i.test(path) || /다랑/i.test(content)) return '다랑이'; + if (/harang/i.test(path) || /하랑/i.test(content)) return '하랑이'; + return 'QA Doc'; +} + +function extractRepoName(repoUrl: string): string { + return repoUrl.replace(/\/+$/, '').split('/').pop() ?? ''; +} + +@Injectable() +export class DashboardService { + constructor( + private readonly prisma: PrismaService, + private readonly projectsService: ProjectsService, + private readonly gitea: GiteaService, + ) {} + + async getOpsBoard() { + const [projects, sisters, rawActivities] = await Promise.all([ + this.projectsService.getProjects(), + this.prisma.sisterConfig.findMany({ orderBy: { id: 'asc' } }), + this.prisma.activityLog.findMany({ + orderBy: { createdAt: 'desc' }, + take: 40, + include: { + sister: { select: { name: true } }, + project: { select: { name: true } }, + }, + }), + ]); + + const activities = rawActivities as ActivityRecord[]; + const latestProjectActivity = activities.find((item) => item.project?.name) ?? null; + const latestBySister = new Map(); + for (const item of activities) { + if (item.sister?.name && !latestBySister.has(item.sister.name)) { + latestBySister.set(item.sister.name, item); + } + } + + const focusProject = latestProjectActivity?.project?.name + ? projects.find((project) => project.name === latestProjectActivity.project?.name) ?? null + : projects.find((project) => ['IMPLEMENT', 'QA', 'READY FOR DEPLOY'].includes(project.phase)) ?? null; + + const reviewLoopCount = activities.filter((item) => /(review|qa)/i.test(`${item.action} ${item.detail ?? ''}`)).length; + const escalationCount = projects.filter((project) => (project.blockerCount ?? 0) > 0).length; + + const nodes = [ + { + id: 'user', + label: 'User', + role: 'Request / Approval', + state: nodeStateFromEvidence({ name: 'user', activity: latestProjectActivity }), + detail: latestProjectActivity + ? `${latestProjectActivity.project?.name ?? 'project'} · ${summarizeActivity(latestProjectActivity, '최근 프로젝트 이벤트 없음')}` + : '명시적으로 기록된 handoff / request 로그가 아직 없어.', + }, + ...sisters.map((sister) => { + const activity = latestBySister.get(sister.name) ?? null; + return { + id: sister.name, + label: sister.name === 'erang' ? 'Irang' : `${sister.name.slice(0, 1).toUpperCase()}${sister.name.slice(1)}`, + role: SISTER_ROLES[sister.name] ?? 'Unknown', + state: nodeStateFromEvidence({ name: sister.name, sisterStatus: sister.status, activity }), + detail: activity + ? summarizeActivity(activity) + : sister.status === 'offline' + ? '최근 런타임 응답이 없어서 offline 상태야.' + : sister.lastSeen + ? `최근 활동 기록은 ${new Date(sister.lastSeen).toISOString()} 체크 기준이야.` + : '최근 활동 로그가 아직 없어.', + }; + }), + ]; + + const harnessItems = activities + .filter((item) => item.action === 'harness_updated') + .slice(0, 4) + .map((item) => ({ + id: `harness-${item.id}`, + title: item.detail ?? 'Harness updated', + body: '관리자 harness 편집 로그에서 직접 가져온 기록이야.', + tone: toneFromText(`${item.action} ${item.detail ?? ''}`), + author: item.sister?.name ? this.toDisplayName(item.sister.name) : 'System', + time: item.createdAt.toISOString(), + category: 'harness', + source: `activity:${item.action}`, + } satisfies OpsBoardItem)); + + const qaItems = await this.getLatestQaBoardItems(projects.map((project) => ({ + name: project.name, + repoName: extractRepoName(project.repoUrl), + updatedAt: project.updatedAt, + }))); + + const board = [...harnessItems, ...qaItems] + .sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()) + .slice(0, 6); + + return { + focusProject: focusProject + ? { + name: focusProject.name, + ownerSister: focusProject.ownerSister ?? null, + phase: focusProject.phase, + currentSprint: focusProject.currentSprint ?? null, + progress: focusProject.progress ?? 0, + deployStatus: focusProject.deployStatus, + } + : null, + pipeline: { + activeTask: focusProject + ? `${focusProject.name} · ${focusProject.currentSprint ?? focusProject.phase}` + : 'No active pipeline', + focus: latestProjectActivity + ? summarizeActivity(latestProjectActivity, '최근 프로젝트 이벤트 없음') + : '최근 프로젝트 activity 기준으로 확정된 handoff가 아직 없어.', + reviewLoopCount, + escalationCount, + deployState: focusProject?.deployStatus ?? 'standby', + nodes, + }, + board, + }; + } + + private async getLatestQaBoardItems(projects: { name: string; repoName: string; updatedAt: string }[]) { + const docs = await Promise.all(projects.map(async (project) => { + if (!project.repoName) return [] as OpsBoardItem[]; + + const pathGroups = await Promise.all( + QA_PATH_CANDIDATES.map((path) => this.gitea.getRepoTree(project.repoName, path)), + ); + + const qaFiles = Array.from( + new Set( + pathGroups + .flat() + .filter((path) => /(?:SPRINT|HOTFIX)-\d+.*(?:review|qa).*\.md$/i.test(path)), + ), + ); + + const latestPath = qaFiles.sort().at(-1); + if (!latestPath) return [] as OpsBoardItem[]; + + const content = await this.gitea.getRawFile(project.repoName, latestPath); + if (!content) return [] as OpsBoardItem[]; + + const summary = parseQaSummary(content); + const label = latestPath.split('/').pop()?.replace(/\.md$/i, '') ?? latestPath; + const tone = toneFromText(content); + + return [ + { + id: `qa-${project.repoName}-${label}`, + title: label, + body: summary, + tone, + author: inferQaAuthor(latestPath, content), + time: project.updatedAt, + category: 'qa-log', + source: `${project.repoName}:${latestPath}`, + } satisfies OpsBoardItem, + ]; + })); + + return docs.flat(); + } + + private toDisplayName(name: string) { + if (name === 'harang') return '하랑이'; + if (name === 'narang') return '나랑이'; + if (name === 'darang') return '다랑이'; + if (name === 'erang') return '이랑이'; + return name; + } +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index ba2266c..442300e 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -50,6 +50,37 @@ interface ActivityItem { project?: { name: string } | null; } +interface BoardItem { + id: string; + title: string; + body: string; + tone: 'default' | 'warn' | 'ok' | 'active'; + author: string; + time: string; + category: string; + source: string; +} + +interface DashboardOpsData { + focusProject: { + name: string; + ownerSister?: string | null; + phase: string; + currentSprint?: string | null; + progress: number; + deployStatus: string; + } | null; + pipeline: { + activeTask: string; + focus: string; + reviewLoopCount: number; + escalationCount: number; + deployState: string; + nodes: PipelineNode[]; + }; + board: BoardItem[]; +} + const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`; const Shell = styled.div` @@ -634,6 +665,12 @@ const LessonTitle = styled.div` font-weight: 600; `; +const LessonMeta = styled.div` + display: flex; + gap: 8px; + flex-wrap: wrap; +`; + const LessonBody = styled.div` font-size: 12px; line-height: 1.7; @@ -735,6 +772,7 @@ export default function DashboardPage() { const [sisters, setSisters] = useState([]); const [projects, setProjects] = useState([]); const [activityItems, setActivityItems] = useState([]); + const [opsData, setOpsData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -749,18 +787,20 @@ export default function DashboardPage() { const fetchAll = async () => { try { - const [sRes, pRes, aRes] = await Promise.all([ + const [sRes, pRes, aRes, dRes] = await Promise.all([ fetch(`${API_URL}/api/sisters`), fetch(`${API_URL}/api/projects`), fetch(`${API_URL}/api/activity?limit=10`), + fetch(`${API_URL}/api/dashboard/ops`), ]); if (!active) return; - if (!sRes.ok || !pRes.ok || !aRes.ok) throw new Error('fetch_failed'); + if (!sRes.ok || !pRes.ok || !aRes.ok || !dRes.ok) throw new Error('fetch_failed'); const sistersBase = (await sRes.json()) as SisterItem[]; const projectsData = (await pRes.json()) as ProjectItem[]; const activityData = (await aRes.json()) as { items?: ActivityItem[] }; + const dashboardData = (await dRes.json()) as DashboardOpsData; const sistersWithSystem = await Promise.all( sistersBase.map(async (sister) => { @@ -779,6 +819,7 @@ export default function DashboardPage() { setSisters(sistersWithSystem); setProjects(projectsData); setActivityItems(activityData.items ?? []); + setOpsData(dashboardData); setError(null); } catch { if (active) setError('대시보드 데이터를 불러오지 못했어.'); @@ -798,8 +839,12 @@ export default function DashboardPage() { const derived = useMemo(() => { const onlineCount = sisters.filter((item) => item.status === 'online' || item.status === 'working').length; const offlineCount = sisters.filter((item) => item.status === 'offline').length; - const activeProject = - projects.find((project) => ['IMPLEMENT', 'QA', 'READY FOR DEPLOY'].includes(project.phase)) ?? projects[0] ?? null; + const activeProject = opsData?.focusProject + ? { + ...opsData.focusProject, + ownerSister: opsData.focusProject.ownerSister ?? undefined, + } + : projects.find((project) => ['IMPLEMENT', 'QA', 'READY FOR DEPLOY'].includes(project.phase)) ?? projects[0] ?? null; const totalSprints = projects.reduce((sum, project) => sum + (project.sprintCount ?? project.totalSprints ?? 0), 0); const doneSprints = projects.reduce((sum, project) => sum + (project.doneSprints ?? 0), 0); const reviewProjects = projects.filter((project) => project.phase === 'QA').length; @@ -808,12 +853,13 @@ export default function DashboardPage() { const firstPassBase = projects.filter((project) => project.latestQaStatus === 'passed' || project.latestQaStatus === 'failed'); const firstPassWins = firstPassBase.filter((project) => project.latestQaStatus === 'passed' && (project.blockerCount ?? 0) === 0).length; const firstPassRate = firstPassBase.length ? Math.round((firstPassWins / firstPassBase.length) * 100) : 0; - const escalationCount = projects.reduce((sum, project) => sum + ((project.blockerCount ?? 0) > 0 ? 1 : 0), 0); - const reviewLoopCount = - activityItems.filter((item) => /review|qa/i.test(`${item.action ?? ''} ${item.detail ?? ''}`)).length + reviewProjects; - const focusSummary = activeProject - ? `${activeProject.name} 기준으로 ${activeProject.phase} 구간을 보고 있어. ${activeProject.currentSprint ? `${activeProject.currentSprint} 진행 중이고,` : ''} deploy 상태는 ${activeProject.deployStatus}야.` - : '지금은 활성 프로젝트가 없어서 전체 운영 상태만 조용히 감시 중이야.'; + const escalationCount = opsData?.pipeline.escalationCount ?? projects.reduce((sum, project) => sum + ((project.blockerCount ?? 0) > 0 ? 1 : 0), 0); + const reviewLoopCount = opsData?.pipeline.reviewLoopCount + ?? activityItems.filter((item) => /review|qa/i.test(`${item.action ?? ''} ${item.detail ?? ''}`)).length; + const focusSummary = opsData?.pipeline.focus + ?? (activeProject + ? `${activeProject.name} 기준으로 ${activeProject.phase} 구간을 보고 있어. ${activeProject.currentSprint ? `${activeProject.currentSprint} 진행 중이고,` : ''} deploy 상태는 ${activeProject.deployStatus}야.` + : '지금은 활성 프로젝트가 없어서 전체 운영 상태만 조용히 감시 중이야.'); const projectDistribution = [ { label: 'planning', value: projects.filter((project) => project.phase === 'PLANNING').length }, @@ -822,42 +868,12 @@ export default function DashboardPage() { { label: 'deploy', value: readyDeploy + deployed }, ]; - const pipelineNodes: PipelineNode[] = [ - { - id: 'user', - label: 'User', - role: 'Request / Approval', - state: activeProject ? 'active' : 'idle', - detail: activeProject ? `${activeProject.name} · ${activeProject.currentSprint ?? 'backlog'} 요청 기준` : '새 작업 입력 대기 중', - }, - { - id: 'harang', - label: 'Harang', - role: 'Plan & Assign', - state: activeProject ? 'ready' : 'idle', - detail: activeProject ? `${activeProject.phase} 기준 문서/동선 정렬 완료` : '기획 브리프 대기 중', - }, - { - id: 'narang', - label: 'Narang', - role: 'Implement', - state: activeProject?.phase === 'IMPLEMENT' ? 'active' : activeProject ? 'ready' : 'idle', - detail: activeProject ? `${activeProject.name} 구현 진행 · ${activeProject.progress}%` : '구현 queue 비어 있음', - }, - { - id: 'darang', - label: 'Darang', - role: 'Review / QA', - state: activeProject?.phase === 'QA' ? 'review' : reviewProjects > 0 ? 'review' : activeProject ? 'idle' : 'idle', - detail: reviewProjects > 0 ? `QA 대기 ${reviewProjects}건 · loop ${reviewLoopCount}회` : '현재 리뷰 큐는 조용함', - }, - { - id: 'erang', - label: 'Irang', - role: 'Deploy / Infra', - state: readyDeploy > 0 ? 'ready' : offlineCount > 0 ? 'blocked' : 'idle', - detail: readyDeploy > 0 ? `배포 가능 ${readyDeploy}건` : offlineCount > 0 ? `오프라인 노드 ${offlineCount}건 확인 필요` : '새 배포 입력 대기 중', - }, + const pipelineNodes: PipelineNode[] = opsData?.pipeline.nodes ?? [ + { id: 'user', label: 'User', role: 'Request / Approval', state: 'idle', detail: '최근 handoff 로그 없음' }, + { id: 'harang', label: 'Harang', role: 'Plan & Assign', state: 'idle', detail: '최근 planning 로그 없음' }, + { id: 'narang', label: 'Narang', role: 'Implement', state: 'idle', detail: '최근 implementation 로그 없음' }, + { id: 'darang', label: 'Darang', role: 'Review / QA', state: 'idle', detail: '최근 QA 로그 없음' }, + { id: 'erang', label: 'Irang', role: 'Deploy / Infra', state: 'idle', detail: '최근 deploy 로그 없음' }, ]; const infraCards = [ @@ -883,42 +899,6 @@ export default function DashboardPage() { }, ]; - const lessons = [ - escalationCount > 0 - ? { - tone: 'warn' as const, - title: 'QA blocker 먼저 줄여야 해', - body: `현재 blocker가 남아 있는 프로젝트가 ${escalationCount}건이야. 배포 속도보다 review loop 정리부터 보는 게 맞아.`, - } - : { - tone: 'ok' as const, - title: 'QA blocker는 아직 조용해', - body: '치명 blocker로 잡힌 프로젝트는 없어. 이번 스프린트는 구현/배포 리듬 유지에 더 집중하면 돼.', - }, - offlineCount > 0 - ? { - tone: 'warn' as const, - title: '오프라인 노드는 숨기지 말고 바로 드러내', - body: `현재 ${offlineCount}개 노드가 응답하지 않아. 운영 화면에서는 offline 상태를 조용히 감추지 않고 바로 보여주는 게 맞아.`, - } - : { - tone: 'active' as const, - title: '노드 상태는 안정적이야', - body: '자매 런타임은 전부 응답 중이야. 지금은 파이프라인 흐름과 handoff 품질을 더 신경 쓰면 돼.', - }, - readyDeploy > 0 - ? { - tone: 'active' as const, - title: 'Deploy-ready 큐가 생겼어', - body: `${readyDeploy}개 프로젝트가 배포 가능 상태야. 하단 infra 패널에서 배포 전 상태를 바로 판단할 수 있어야 해.`, - } - : { - tone: 'default' as const, - title: '배포는 아직 준비 단계야', - body: '지금은 main 재배포보다 구현/QA 흐름 확인이 우선이야. deploy 패널은 대기 상태를 조용히 보여주면 돼.', - }, - ]; - return { onlineCount, offlineCount, @@ -935,9 +915,11 @@ export default function DashboardPage() { projectDistribution, pipelineNodes, infraCards, - lessons, + board: opsData?.board ?? [], + activeTask: opsData?.pipeline.activeTask ?? (activeProject ? `${activeProject.name} · ${activeProject.currentSprint ?? activeProject.phase}` : 'No active pipeline'), + deployState: opsData?.pipeline.deployState ?? (activeProject?.deployStatus ?? 'standby'), }; - }, [activityItems, connected, projects, sisters]); + }, [activityItems, connected, opsData, projects, sisters]); return ( @@ -1084,11 +1066,11 @@ export default function DashboardPage() { ) : ( )} @@ -1203,19 +1185,29 @@ export default function DashboardPage() { Mistake log & harness 실수와 운영 규칙도 홈에서 읽히게 - 전용 문서가 없어도 현재 runtime과 QA 상태에서 바로 파생되는 운영 학습 포인트를 보드처럼 보여줘. + 합성 요약 대신 QA 문서와 harness 변경 로그에서 직접 읽은 항목만 보드로 보여줘. - RULE:{String(derived.lessons.length).padStart(2, '0')} + RULE:{String(derived.board.length).padStart(2, '0')} - - {derived.lessons.map((lesson) => ( - - {lesson.title} - {lesson.body} - - ))} - + {derived.board.length === 0 ? ( + 아직 표시할 QA / harness 원본 기록이 없어. + ) : ( + + {derived.board.map((item) => ( + + {item.title} + + {item.category} + {item.author} + {formatRelativeTime(item.time)} + + {item.body} + Source · {item.source} + + ))} + + )} diff --git a/qa/test-plan.md b/qa/test-plan.md new file mode 100644 index 0000000..3f8ced0 --- /dev/null +++ b/qa/test-plan.md @@ -0,0 +1,8 @@ +# QA Test Plan + +실제 기준 문서는 `.plans/qa/test-plan.md`야. + +이 파일은 QA 도구/사람이 루트 `qa/test-plan.md`를 찾을 때 바로 보이게 둔 미러 엔트리야. + +- canonical: `../.plans/qa/test-plan.md` +- purpose: QA lookup compatibility