fix: source dashboard ops from real logs

This commit is contained in:
2026-04-06 20:04:20 +09:00
parent 5b7e4ec50d
commit 90ab555915
6 changed files with 372 additions and 96 deletions

View File

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

View File

@@ -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<any> {
return this.dashboardService.getOpsBoard();
}
}

View File

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

View File

@@ -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<string, string> = {
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<string, ActivityRecord>();
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;
}
}

View File

@@ -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<SisterItem[]>([]);
const [projects, setProjects] = useState<ProjectItem[]>([]);
const [activityItems, setActivityItems] = useState<ActivityItem[]>([]);
const [opsData, setOpsData] = useState<DashboardOpsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Shell>
@@ -1084,11 +1066,11 @@ export default function DashboardPage() {
<SectionSkeleton />
) : (
<ActivePipeline
activeTask={derived.activeProject ? `${derived.activeProject.name} · ${derived.activeProject.currentSprint ?? derived.activeProject.phase}` : 'No active pipeline'}
activeTask={derived.activeTask}
focus={derived.focusSummary}
reviewLoopCount={derived.reviewLoopCount}
escalationCount={derived.escalationCount}
deployState={derived.activeProject?.deployStatus ?? 'standby'}
deployState={derived.deployState}
nodes={derived.pipelineNodes}
/>
)}
@@ -1203,19 +1185,29 @@ export default function DashboardPage() {
<PanelTitleBlock>
<Eyebrow>Mistake log & harness</Eyebrow>
<PanelTitle> </PanelTitle>
<PanelDesc> runtime과 QA .</PanelDesc>
<PanelDesc> QA harness .</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>RULE:</span>{String(derived.lessons.length).padStart(2, '0')}</LabelMeta>
<LabelMeta><span>RULE:</span>{String(derived.board.length).padStart(2, '0')}</LabelMeta>
</PanelHeader>
<LessonList>
{derived.lessons.map((lesson) => (
<LessonCard key={lesson.title} $tone={lesson.tone}>
<LessonTitle>{lesson.title}</LessonTitle>
<LessonBody>{lesson.body}</LessonBody>
</LessonCard>
))}
</LessonList>
{derived.board.length === 0 ? (
<EmptyState> QA / harness .</EmptyState>
) : (
<LessonList>
{derived.board.map((item) => (
<LessonCard key={item.id} $tone={item.tone}>
<LessonTitle>{item.title}</LessonTitle>
<LessonMeta>
<EventTag $tone={item.tone}>{item.category}</EventTag>
<EventTag>{item.author}</EventTag>
<EventTag>{formatRelativeTime(item.time)}</EventTag>
</LessonMeta>
<LessonBody>{item.body}</LessonBody>
<LessonBody>Source · {item.source}</LessonBody>
</LessonCard>
))}
</LessonList>
)}
</Panel>
</SectionGrid>
</Shell>

8
qa/test-plan.md Normal file
View File

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