fix: source dashboard ops from real logs
This commit is contained in:
@@ -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],
|
||||
|
||||
12
backend/src/dashboard/dashboard.controller.ts
Normal file
12
backend/src/dashboard/dashboard.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
14
backend/src/dashboard/dashboard.module.ts
Normal file
14
backend/src/dashboard/dashboard.module.ts
Normal 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 {}
|
||||
248
backend/src/dashboard/dashboard.service.ts
Normal file
248
backend/src/dashboard/dashboard.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
8
qa/test-plan.md
Normal 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
|
||||
Reference in New Issue
Block a user