Compare commits
4 Commits
main
...
feature/ho
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d851a3b14 | |||
| 6f0ed33424 | |||
| 1af31cbe89 | |||
| 93b92a41ec |
@@ -17,7 +17,7 @@
|
||||
|
||||
## 활성 작업
|
||||
- **SPRINT-015**: Master Dashboard v3 정보 구조 리디자인
|
||||
- **HOTFIX-006**: 대시보드 문구 절제 + 실시간성 정합성 보정
|
||||
- **HOTFIX-007**: 프로젝트/자매 상세/메인 운영면 보정
|
||||
|
||||
## 이번 Sprint 핵심 요구사항
|
||||
1. 메인 대시보드를 4자매 운영 관제의 대표 화면으로 재정의
|
||||
@@ -37,6 +37,6 @@
|
||||
- 디자인 인덱스: `./design/index.md`
|
||||
- Sprint 계획: `./sprints/SPRINT-015.md`
|
||||
- Sprint handoff: `./sprints/SPRINT-015-NARANG-HANDOFF.md`
|
||||
- Hotfix 계획: `./hotfix/HOTFIX-006.md`
|
||||
- Hotfix handoff: `./hotfix/HOTFIX-006-NARANG-HANDOFF.md`
|
||||
- Hotfix 계획: `./hotfix/HOTFIX-007.md`
|
||||
- Hotfix handoff: `./hotfix/HOTFIX-007-NARANG-HANDOFF.md`
|
||||
- 배포 플로우: `./deploy/main-release-flow.md`
|
||||
|
||||
35
.plans/hotfix/HOTFIX-007-NARANG-HANDOFF.md
Normal file
35
.plans/hotfix/HOTFIX-007-NARANG-HANDOFF.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# HOTFIX-007 Narang Handoff
|
||||
|
||||
## repo / branch
|
||||
- repo: `https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard`
|
||||
- branch: `feature/hotfix-007-projects-sisters-surface-fixes`
|
||||
|
||||
## 읽을 문서
|
||||
1. `.plans/hotfix/HOTFIX-007.md`
|
||||
2. `.plans/design/ui/projects-page-design.md`
|
||||
3. `.plans/design/ui/project-detail-design.md`
|
||||
4. 필요하면 `.plans/sprints/SPRINT-014.md`
|
||||
|
||||
## 이번 수정 핵심
|
||||
자기야가 바로 짚은 실제 사용감 문제를 고쳐.
|
||||
|
||||
- `/projects`에 `전체 | public | private` 필터 추가
|
||||
- 프로젝트 상세 진행 상황 박스 배치 보정
|
||||
- `ASSIGNED NODES`를 4자매 기준으로 복구
|
||||
- `.plans/qa`, `.plans/hotfix`, 구 구조 fallback 다시 점검
|
||||
- 메인 우측 나랑이 단독 박스 제거 또는 실제 operator 기준으로 교체
|
||||
- `/sisters/[name]` 개요 / 세션 / 활동 / 로그 복구
|
||||
|
||||
## 절대 기준
|
||||
- 보여주기용 고정값 넣지 마
|
||||
- 문서 구조가 바뀌었으면 새 구조 우선 + 구 구조 fallback 유지
|
||||
- 자매 상세에서 데이터 없음과 조회 실패를 섞지 마
|
||||
- `ASSIGNED NODES`가 다시 나랑이 하나처럼 보이면 실패야
|
||||
- 메인 우측 박스는 더 이상 단일 프로필 카드처럼 보이면 안 돼
|
||||
|
||||
## QA 전 필수
|
||||
- `frontend npm run build`
|
||||
- `backend npm run lint`
|
||||
- `backend npm test -- --runInBand`
|
||||
- `backend npm run build`
|
||||
- 실브라우저 기준 `/`, `/projects`, `/projects/[id]`, `/sisters/[name]` 확인 포인트 정리
|
||||
137
.plans/hotfix/HOTFIX-007.md
Normal file
137
.plans/hotfix/HOTFIX-007.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# HOTFIX-007: 프로젝트/자매 상세/메인 운영면 보정
|
||||
|
||||
## 목표
|
||||
자기야가 바로 짚은 실제 사용감 문제를 한 번에 정리한다.
|
||||
|
||||
- 프로젝트 목록에 `전체 | public | private` 필터 추가
|
||||
- 프로젝트 상세 진행 상황 박스 배치 보정
|
||||
- `ASSIGNED NODES` / 에이전트 노드 구성이 `나랑이만 있는 것처럼` 보이는 문제 수정
|
||||
- 바뀐 파일 구조 기준으로 QA / Hotfix 문서 탐색 경로 재점검
|
||||
- 메인 대시보드 우측 오퍼레이터 박스 정리 (`나랑이 사진만 보이는 박스` 제거 또는 실제 operator 기준 반영)
|
||||
- 자매 상세의 `개요 / 세션 / 활동 / 로그`가 비거나 조회되지 않는 문제 복구
|
||||
|
||||
## 왜 이걸 고치나
|
||||
지금 상태는 일부 화면이 기능은 있지만 운영 판단에는 어색하거나, 아예 실데이터가 안 뜨는 구간이 있어.
|
||||
|
||||
특히 이건 실제 사용성 문제야.
|
||||
1. `/projects`에서 프로젝트 구분 필터가 부족함
|
||||
2. `/projects/[id]` 진행 현황 박스 배치가 어색함
|
||||
3. `ASSIGNED NODES`가 실제 4자매 구조를 못 보여줌
|
||||
4. `.plans/qa`, `.plans/hotfix` 구조 변경 이후 문서 탐색 누락 가능성이 남아 있음
|
||||
5. 메인 우측 operator 영역이 지금은 나랑이 사진만 보이는 식으로 오해를 줌
|
||||
6. `/sisters/[name]`의 overview / sessions / activity / logs가 비면 대시보드 핵심 기능이 무너짐
|
||||
|
||||
## 범위
|
||||
- 프로젝트 목록 페이지
|
||||
- 프로젝트 상세 페이지
|
||||
- 메인 대시보드 우측 operator 영역
|
||||
- backend project detail / dashboard / sisters / logs / sessions 관련 서비스
|
||||
- 문서 탐색 fallback (`.plans/qa`, `.plans/hotfix`, 구 구조 fallback) 재검증
|
||||
|
||||
## 태스크
|
||||
|
||||
### TASK-084: 프로젝트 목록 visibility 필터 추가
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/projects/page.tsx`
|
||||
- 필요 시 `backend/src/projects/projects.service.ts`
|
||||
- **설명:** `/projects` 상단에 `전체 | public | private` 필터를 추가한다.
|
||||
- **구현 메모:**
|
||||
- 현재 데이터 모델에 public/private 구분 필드가 있으면 그걸 그대로 사용
|
||||
- 없으면 우선 source-of-truth 확인 후 안전한 derived rule을 쓰되, 임시 추론이면 문서에 남길 것
|
||||
- **완료 기준:**
|
||||
- 사용자가 프로젝트 목록을 visibility 기준으로 바로 걸러 볼 수 있음
|
||||
- 필터 상태가 UI에서 분명히 보임
|
||||
|
||||
### TASK-085: 프로젝트 상세 진행 현황 박스 레이아웃 보정
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/projects/[id]/page.tsx`
|
||||
- 관련 공통 컴포넌트
|
||||
- **설명:** 진행 상황 / QA / Deploy / Sprint / Hotfix 박스 배치가 더 자연스럽게 읽히도록 재배치한다.
|
||||
- **완료 기준:**
|
||||
- 정보 블록 간 위계가 자연스러움
|
||||
- 데스크톱/모바일에서 박스가 어색하게 끊기거나 밀리지 않음
|
||||
- `DELIVERY FLOW → QA STATUS → DEPLOY STATUS → SPRINT/HOTFIX` 읽기 흐름이 더 선명함
|
||||
|
||||
### TASK-086: `ASSIGNED NODES` 4자매 기준 복구
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/projects/projects.service.ts`
|
||||
- `frontend/app/projects/[id]/page.tsx`
|
||||
- 필요 시 관련 타입/DTO
|
||||
- **설명:** 프로젝트 상세의 노드/에이전트 구성이 나랑이만 있는 것처럼 보이지 않게 실제 4자매 역할 기준으로 복구한다.
|
||||
- **구현 메모:**
|
||||
- 최소한 하랑/나랑/다랑/이랑 역할이 모두 보이게 할 것
|
||||
- 실할당 데이터가 있으면 우선 사용, 없으면 역할 기반 기본 노드라도 4개가 보여야 함
|
||||
- **완료 기준:**
|
||||
- `ASSIGNED NODES`가 4자매 구조를 제대로 보여줌
|
||||
- 단일 나랑 노드처럼 오해되지 않음
|
||||
|
||||
### TASK-087: QA / Hotfix 문서 탐색 경로 재점검
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/projects/projects.service.ts`
|
||||
- `backend/src/projects/sprint-sync.service.ts`
|
||||
- 관련 문서 탐색 유틸
|
||||
- **설명:** 파일 구조가 조금 바뀐 현재 기준으로 `.plans/qa`, `.plans/hotfix`, 구 구조 fallback이 실제로 다 읽히는지 다시 확인하고 보정한다.
|
||||
- **완료 기준:**
|
||||
- 새 구조 문서가 우선 반영됨
|
||||
- 구 구조 fallback도 깨지지 않음
|
||||
- QA / Hotfix history가 누락 없이 노출됨
|
||||
|
||||
### TASK-088: 메인 우측 operator 영역 정리
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/page.tsx`
|
||||
- **설명:** 메인 우측 상자에서 나랑이 사진만 떠 있는 어색한 구성을 제거한다.
|
||||
- **선택지:**
|
||||
1. 박스 자체 제거
|
||||
2. 실제 current operator/focus owner 기준으로 표시
|
||||
3. 단일 프로필 대신 운영 메타 카드로 교체
|
||||
- **완료 기준:**
|
||||
- 우측 상자가 더 이상 특정 자매 단독 프로필처럼 오해되지 않음
|
||||
- 메인 화면 정보 위계가 더 깔끔함
|
||||
|
||||
### TASK-089: 자매 상세 `개요 / 세션 / 활동 / 로그` 데이터 복구
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/sisters/[name]/page.tsx`
|
||||
- `backend/src/sisters/sister-detail.service.ts`
|
||||
- `backend/src/sisters/sisters.service.ts`
|
||||
- `backend/src/admin/admin.service.ts`
|
||||
- 관련 controller / DTO / 경로 탐색 유틸
|
||||
- **설명:** 자매 상세 페이지에서 개요/세션/활동이 비고 로그 조회가 안 되는 문제를 실제 데이터 기준으로 복구한다.
|
||||
- **구현 메모:**
|
||||
- Hermes 우선 + `.openclaw` fallback 경로 다시 확인
|
||||
- sessions / logs 경로 변경 이후 깨진 지점 점검
|
||||
- empty state와 진짜 조회 실패를 구분
|
||||
- **완료 기준:**
|
||||
- `/sisters/[name]`에서 overview / sessions / activity / logs가 실제 데이터로 노출됨
|
||||
- 데이터가 없을 때와 조회 실패일 때 UI가 다르게 보임
|
||||
|
||||
### TASK-090: 실브라우저 기준 확인 포인트 정리
|
||||
- **담당:** 나랑이 → 다랑이
|
||||
- **상태:** pending
|
||||
- **설명:** 이번 hotfix는 화면/구조/실데이터가 섞여 있으니 실브라우저 확인 포인트를 함께 남긴다.
|
||||
- **완료 기준:**
|
||||
- `/projects`
|
||||
- `/projects/[id]`
|
||||
- `/sisters/[name]`
|
||||
- 메인 `/`
|
||||
이 네 화면의 확인 포인트가 QA에 전달됨
|
||||
|
||||
## QA 핵심 체크
|
||||
- `/projects`에서 `전체 | public | private` 필터 동작
|
||||
- 프로젝트 상세 박스 배치 개선 확인
|
||||
- `ASSIGNED NODES`가 4자매 구조로 보임
|
||||
- QA / Hotfix 문서 구조 변경 이후도 history 누락 없음
|
||||
- 메인 우측 상자 어색함 해소
|
||||
- `/sisters/[name]` overview / sessions / activity / logs 복구
|
||||
- 외부 URL + 모바일 포함 확인 필수
|
||||
@@ -30,7 +30,7 @@ interface FreshnessMeta {
|
||||
qaDocLatestAt: string | null;
|
||||
}
|
||||
|
||||
const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/'];
|
||||
const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/', 'qa/'];
|
||||
const SISTER_ROLES: Record<string, string> = {
|
||||
harang: 'Plan & Assign',
|
||||
narang: 'Implement',
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface GiteaRepo {
|
||||
open_issues_count: number;
|
||||
open_pr_counter: number;
|
||||
updated_at: string;
|
||||
private?: boolean;
|
||||
}
|
||||
|
||||
export interface GiteaPR {
|
||||
|
||||
@@ -20,8 +20,8 @@ export class ProjectsController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
getProjects() {
|
||||
return this.projectsService.getProjects();
|
||||
getProjects(@Query('visibility') visibility?: 'public' | 'private') {
|
||||
return this.projectsService.getProjects(visibility);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { GiteaService } from '../gitea/gitea.service';
|
||||
|
||||
type QaStatus = 'passed' | 'failed' | 'unknown';
|
||||
type ProjectVisibility = 'public' | 'private';
|
||||
type ProjectPhase =
|
||||
| 'PLANNING'
|
||||
| 'IMPLEMENT'
|
||||
@@ -33,9 +34,14 @@ interface ProjectMetaSummary {
|
||||
latestDeployAt: string | null;
|
||||
}
|
||||
|
||||
const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/'];
|
||||
const HOTFIX_PATH_CANDIDATES = ['.plans/hotfix/', '.plans/sprints/'];
|
||||
const SPRINT_PATH = '.plans/sprints/';
|
||||
const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/', 'qa/'];
|
||||
const HOTFIX_PATH_CANDIDATES = [
|
||||
'.plans/hotfix/',
|
||||
'.hotfix/',
|
||||
'.plans/sprints/',
|
||||
];
|
||||
const SPRINT_PATH_CANDIDATES = ['.plans/sprints/', 'sprints/'];
|
||||
const ASSIGNED_NODE_ORDER = ['harang', 'narang', 'darang', 'erang'] as const;
|
||||
|
||||
function normalizeRepoName(repoUrl: string): string {
|
||||
return repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
|
||||
@@ -187,82 +193,95 @@ export class ProjectsService {
|
||||
private readonly gitea: GiteaService,
|
||||
) {}
|
||||
|
||||
async getProjects() {
|
||||
async getProjects(visibilityFilter?: ProjectVisibility) {
|
||||
const dbProjects = await this.prisma.project.findMany({
|
||||
include: { sprints: { include: { tasks: true } } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
const [repoMetaMap, repoDocsMap] = await Promise.all([
|
||||
const [{ repoMetaMap, sourceAvailable }, repoDocsMap] = await Promise.all([
|
||||
this.getRepoMetaMap(),
|
||||
this.getRepoDocumentMap(
|
||||
dbProjects.map((project) => normalizeRepoName(project.repoUrl)),
|
||||
),
|
||||
]);
|
||||
|
||||
return dbProjects.map((project) => {
|
||||
const totalSprints = project.sprints.length;
|
||||
const doneSprints = project.sprints.filter(
|
||||
(sprint) => sprint.status === 'done',
|
||||
).length;
|
||||
const inProgressSprint = project.sprints.find(
|
||||
(sprint) => sprint.status === 'in_progress',
|
||||
);
|
||||
const ownerSister =
|
||||
project.sprints
|
||||
.flatMap((sprint) => sprint.tasks)
|
||||
.find((task) => task.assignee)?.assignee ?? 'narang';
|
||||
const progress =
|
||||
totalSprints > 0 ? Math.round((doneSprints / totalSprints) * 100) : 0;
|
||||
const repoMeta = repoMetaMap[project.giteaId];
|
||||
const docs = repoDocsMap.get(normalizeRepoName(project.repoUrl)) ?? {
|
||||
hotfixes: [],
|
||||
qas: [],
|
||||
};
|
||||
const latestHotfix = docs.hotfixes.at(-1) ?? null;
|
||||
const latestQa = docs.qas.at(-1) ?? null;
|
||||
const meta = buildProjectMeta({
|
||||
totalSprints,
|
||||
doneSprints,
|
||||
hasActiveSprint: Boolean(inProgressSprint),
|
||||
openPRs: repoMeta?.openPRs ?? 0,
|
||||
latestHotfix: latestHotfix?.label ?? null,
|
||||
latestQaLabel: latestQa?.label ?? null,
|
||||
latestQaStatus: latestQa?.qaStatus ?? 'unknown',
|
||||
blockerCount: latestQa?.blockerCount ?? 0,
|
||||
updatedAt: repoMeta?.updatedAt ?? project.updatedAt.toISOString(),
|
||||
});
|
||||
return dbProjects
|
||||
.map((project) => {
|
||||
const totalSprints = project.sprints.length;
|
||||
const doneSprints = project.sprints.filter(
|
||||
(sprint) => sprint.status === 'done',
|
||||
).length;
|
||||
const inProgressSprint = project.sprints.find(
|
||||
(sprint) => sprint.status === 'in_progress',
|
||||
);
|
||||
const ownerSister =
|
||||
project.sprints
|
||||
.flatMap((sprint) => sprint.tasks)
|
||||
.find((task) => task.assignee)?.assignee ?? 'narang';
|
||||
const progress =
|
||||
totalSprints > 0 ? Math.round((doneSprints / totalSprints) * 100) : 0;
|
||||
const repoMeta = repoMetaMap[project.giteaId];
|
||||
const docs = repoDocsMap.get(normalizeRepoName(project.repoUrl)) ?? {
|
||||
hotfixes: [],
|
||||
qas: [],
|
||||
};
|
||||
const latestHotfix = docs.hotfixes.at(-1) ?? null;
|
||||
const latestQa = docs.qas.at(-1) ?? null;
|
||||
const meta = buildProjectMeta({
|
||||
totalSprints,
|
||||
doneSprints,
|
||||
hasActiveSprint: Boolean(inProgressSprint),
|
||||
openPRs: repoMeta?.openPRs ?? 0,
|
||||
latestHotfix: latestHotfix?.label ?? null,
|
||||
latestQaLabel: latestQa?.label ?? null,
|
||||
latestQaStatus: latestQa?.qaStatus ?? 'unknown',
|
||||
blockerCount: latestQa?.blockerCount ?? 0,
|
||||
updatedAt: repoMeta?.updatedAt ?? project.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
giteaId: project.giteaId,
|
||||
name: project.name,
|
||||
repoUrl: project.repoUrl,
|
||||
description: project.description,
|
||||
status: project.status,
|
||||
progress,
|
||||
totalSprints,
|
||||
doneSprints,
|
||||
sprintCount: totalSprints,
|
||||
currentSprint:
|
||||
inProgressSprint?.name ??
|
||||
(doneSprints === totalSprints && totalSprints > 0
|
||||
? 'COMPLETED'
|
||||
: null),
|
||||
ownerSister,
|
||||
openPRs: repoMeta?.openPRs ?? 0,
|
||||
updatedAt: repoMeta?.updatedAt ?? project.updatedAt.toISOString(),
|
||||
latestHotfix: meta.latestHotfix,
|
||||
latestQaLabel: meta.latestQaLabel,
|
||||
latestQaStatus: meta.latestQaStatus,
|
||||
blockerCount: meta.blockerCount,
|
||||
phase: meta.phase,
|
||||
deployStatus: meta.deployStatus,
|
||||
deployBranch: meta.deployBranch,
|
||||
redeployRequired: meta.redeployRequired,
|
||||
latestDeployAt: meta.latestDeployAt,
|
||||
};
|
||||
});
|
||||
if (visibilityFilter) {
|
||||
if (!sourceAvailable || !repoMeta?.visibility) {
|
||||
return null;
|
||||
}
|
||||
if (repoMeta.visibility !== visibilityFilter) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
giteaId: project.giteaId,
|
||||
name: project.name,
|
||||
repoUrl: project.repoUrl,
|
||||
description: project.description,
|
||||
status: project.status,
|
||||
progress,
|
||||
totalSprints,
|
||||
doneSprints,
|
||||
sprintCount: totalSprints,
|
||||
currentSprint:
|
||||
inProgressSprint?.name ??
|
||||
(doneSprints === totalSprints && totalSprints > 0
|
||||
? 'COMPLETED'
|
||||
: null),
|
||||
ownerSister,
|
||||
openPRs: repoMeta?.openPRs ?? 0,
|
||||
updatedAt: repoMeta?.updatedAt ?? project.updatedAt.toISOString(),
|
||||
latestHotfix: meta.latestHotfix,
|
||||
latestQaLabel: meta.latestQaLabel,
|
||||
latestQaStatus: meta.latestQaStatus,
|
||||
blockerCount: meta.blockerCount,
|
||||
phase: meta.phase,
|
||||
deployStatus: meta.deployStatus,
|
||||
deployBranch: meta.deployBranch,
|
||||
redeployRequired: meta.redeployRequired,
|
||||
latestDeployAt: meta.latestDeployAt,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(project): project is NonNullable<typeof project> => project !== null,
|
||||
);
|
||||
}
|
||||
|
||||
async getProjectById(id: number) {
|
||||
@@ -327,6 +346,15 @@ export class ProjectsService {
|
||||
note: 'ERANG: LATEST MAIN REDEPLOY ONLY',
|
||||
},
|
||||
latestHotfix: docs.hotfixes.at(-1) ?? null,
|
||||
assignedNodes: ASSIGNED_NODE_ORDER.map((name) => ({
|
||||
name,
|
||||
role: name,
|
||||
assigned:
|
||||
activeSprint?.tasks.some((task) => task.assignee === name) ??
|
||||
project.sprints.some((sprint) =>
|
||||
sprint.tasks.some((task) => task.assignee === name),
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -350,7 +378,7 @@ export class ProjectsService {
|
||||
const repoName = normalizeRepoName(project.repoUrl);
|
||||
const docs = await this.collectRepoDocuments(repoName);
|
||||
|
||||
return [...docs.sprints, ...docs.hotfixes]
|
||||
return [...docs.sprints, ...docs.hotfixes, ...docs.qas]
|
||||
.filter(isRepoDocumentMeta)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
@@ -365,20 +393,27 @@ export class ProjectsService {
|
||||
}
|
||||
|
||||
private async getRepoMetaMap() {
|
||||
const repoMetaMap: Record<number, { openPRs: number; updatedAt: string }> =
|
||||
{};
|
||||
const repoMetaMap: Record<
|
||||
number,
|
||||
{ openPRs: number; updatedAt: string; visibility: ProjectVisibility }
|
||||
> = {};
|
||||
let sourceAvailable = false;
|
||||
|
||||
try {
|
||||
const repos = await this.gitea.getOrgRepos();
|
||||
sourceAvailable = repos.length > 0;
|
||||
for (const repo of repos) {
|
||||
repoMetaMap[repo.id] = {
|
||||
openPRs: repo.open_pr_counter,
|
||||
updatedAt: repo.updated_at,
|
||||
visibility: repo.private ? 'private' : 'public',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn('Gitea unavailable, using DB data only');
|
||||
}
|
||||
return repoMetaMap;
|
||||
|
||||
return { repoMetaMap, sourceAvailable };
|
||||
}
|
||||
|
||||
private async getRepoDocumentMap(repoNames: string[]) {
|
||||
@@ -393,22 +428,29 @@ export class ProjectsService {
|
||||
}
|
||||
|
||||
private async collectRepoDocuments(repoName: string) {
|
||||
const [sprintPaths, hotfixPathGroups, qaPathGroups] = await Promise.all([
|
||||
this.gitea.getRepoTree(repoName, SPRINT_PATH),
|
||||
Promise.all(
|
||||
HOTFIX_PATH_CANDIDATES.map((path) =>
|
||||
this.gitea.getRepoTree(repoName, path),
|
||||
const [sprintPathGroups, hotfixPathGroups, qaPathGroups] =
|
||||
await Promise.all([
|
||||
Promise.all(
|
||||
SPRINT_PATH_CANDIDATES.map((path) =>
|
||||
this.gitea.getRepoTree(repoName, path),
|
||||
),
|
||||
),
|
||||
),
|
||||
Promise.all(
|
||||
QA_PATH_CANDIDATES.map((path) =>
|
||||
this.gitea.getRepoTree(repoName, path),
|
||||
Promise.all(
|
||||
HOTFIX_PATH_CANDIDATES.map((path) =>
|
||||
this.gitea.getRepoTree(repoName, path),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
Promise.all(
|
||||
QA_PATH_CANDIDATES.map((path) =>
|
||||
this.gitea.getRepoTree(repoName, path),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const sprintFiles = sprintPaths.filter((path) =>
|
||||
/SPRINT-\d+\.md$/i.test(path),
|
||||
const sprintFiles = Array.from(
|
||||
new Set(
|
||||
sprintPathGroups.flat().filter((path) => /SPRINT-\d+\.md$/i.test(path)),
|
||||
),
|
||||
);
|
||||
const hotfixFiles = Array.from(
|
||||
new Set(
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('SisterDetailService', () => {
|
||||
|
||||
expect(result.name).toBe('narang');
|
||||
expect(result.role).toBe('Generator');
|
||||
expect(result.raw).toContain('SSH 연결 불가');
|
||||
expect(result.configAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('getSisterConfig: sister 없으면 NotFoundException', async () => {
|
||||
|
||||
@@ -46,14 +46,14 @@ export class SisterDetailService {
|
||||
name,
|
||||
role: SISTER_ROLES[name],
|
||||
description: SISTER_DESCRIPTIONS[name],
|
||||
raw: result.stdout || '(설정 파일 없음)',
|
||||
configAvailable: Boolean(result.stdout.trim()),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
name,
|
||||
role: SISTER_ROLES[name],
|
||||
description: SISTER_DESCRIPTIONS[name],
|
||||
raw: '(SSH 연결 불가)',
|
||||
configAvailable: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -67,22 +67,21 @@ export class SisterDetailService {
|
||||
sister.ip,
|
||||
sister.user,
|
||||
sshKeyPath,
|
||||
'SESSION_DIR=~/.hermes/sessions; [ -d "$SESSION_DIR" ] || SESSION_DIR=~/.openclaw/sessions; ls -lt "$SESSION_DIR"/ 2>/dev/null | head -20 || echo ""',
|
||||
'SESSION_DIR=~/.hermes/sessions; [ -d "$SESSION_DIR" ] || SESSION_DIR=~/.openclaw/sessions; find "$SESSION_DIR" -maxdepth 1 -type f -printf "%TY-%Tm-%Td %TH:%TM|%s|%f\n" 2>/dev/null | sort -r | head -20 || echo ""',
|
||||
);
|
||||
|
||||
const lines = result.stdout
|
||||
.split('\n')
|
||||
.filter((l) => l && !l.startsWith('total'));
|
||||
const lines = result.stdout.split('\n').filter((line) => line.trim());
|
||||
return lines
|
||||
.map((line) => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const [modified = '', size = '0', file = ''] = line.split('|');
|
||||
return {
|
||||
name: parts[parts.length - 1] ?? '',
|
||||
modified: parts.slice(5, 8).join(' '),
|
||||
size: parts[4] ?? '0',
|
||||
id: file,
|
||||
label: file,
|
||||
title: file,
|
||||
status: `${size}B · ${modified}`,
|
||||
};
|
||||
})
|
||||
.filter((s) => s.name && s.name !== '');
|
||||
.filter((session) => session.id && session.id !== '');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -125,6 +124,33 @@ export class SisterDetailService {
|
||||
return { items: logs, total };
|
||||
}
|
||||
|
||||
async getSisterLogs(name: SisterName, lines = 100) {
|
||||
const sister = await this.getSisterRecord(name);
|
||||
const sshKeyPath = this.getKeyPath();
|
||||
|
||||
try {
|
||||
const result = await this.ssh.executeCommand(
|
||||
sister.ip,
|
||||
sister.user,
|
||||
sshKeyPath,
|
||||
`journalctl --user -u hermes-agent --no-pager -n ${lines} 2>/dev/null || journalctl --user -u hermes-gateway --no-pager -n ${lines} 2>/dev/null || journalctl --user -u openclaw-gateway --no-pager -n ${lines} 2>/dev/null || tail -n ${lines} ~/.hermes/logs/gateway.log 2>/dev/null || tail -n ${lines} ~/.openclaw/logs/gateway.log 2>/dev/null || echo "(로그 없음)"`,
|
||||
);
|
||||
|
||||
const output = result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
return {
|
||||
name,
|
||||
lines: output.length > 0 ? output : ['(로그 없음)'],
|
||||
total: output.length > 0 ? output.length : 1,
|
||||
};
|
||||
} catch {
|
||||
return { name, lines: ['(로그 조회 실패)'], total: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
async getOrgData() {
|
||||
const sisters = await this.prisma.sisterConfig.findMany();
|
||||
return {
|
||||
|
||||
@@ -273,21 +273,22 @@ const ConnectionDot = styled.span<{ $connected: boolean }>`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const OperatorRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
const OperatorGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const OperatorText = styled.div`
|
||||
const OperatorCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const OperatorName = styled.div`
|
||||
font-size: 15px;
|
||||
const OperatorValue = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
`;
|
||||
@@ -1006,15 +1007,28 @@ export default function DashboardPage() {
|
||||
<LabelMeta><span>SYNC:</span>{formatSyncLabel(derived.freshness.activityLatestAt)}</LabelMeta>
|
||||
</ConnectionCard>
|
||||
|
||||
<OperatorRow>
|
||||
<SisterAvatar name={derived.activeProject?.ownerSister ?? 'narang'} size={44} />
|
||||
<OperatorText>
|
||||
<OperatorName>{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.label ?? '나랑이'}</OperatorName>
|
||||
<OperatorMeta>
|
||||
{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.role ?? 'Implementation'} / SNAPSHOT OWNER
|
||||
</OperatorMeta>
|
||||
</OperatorText>
|
||||
</OperatorRow>
|
||||
<OperatorGrid>
|
||||
<OperatorCard>
|
||||
<MetaLabel>focus owner</MetaLabel>
|
||||
<OperatorValue>{derived.activeProject?.ownerSister ? (SISTER_META[derived.activeProject.ownerSister]?.label ?? derived.activeProject.ownerSister) : 'UNASSIGNED'}</OperatorValue>
|
||||
<OperatorMeta>SNAPSHOT OWNER</OperatorMeta>
|
||||
</OperatorCard>
|
||||
<OperatorCard>
|
||||
<MetaLabel>runtime lag</MetaLabel>
|
||||
<OperatorValue>{formatSyncLabel(derived.freshness.sistersLatestAt)}</OperatorValue>
|
||||
<OperatorMeta>RUNTIME SNAPSHOT</OperatorMeta>
|
||||
</OperatorCard>
|
||||
<OperatorCard>
|
||||
<MetaLabel>activity lag</MetaLabel>
|
||||
<OperatorValue>{formatSyncLabel(derived.freshness.activityLatestAt)}</OperatorValue>
|
||||
<OperatorMeta>EVENT MIRROR</OperatorMeta>
|
||||
</OperatorCard>
|
||||
<OperatorCard>
|
||||
<MetaLabel>qa doc lag</MetaLabel>
|
||||
<OperatorValue>{formatSyncLabel(derived.freshness.qaDocLatestAt)}</OperatorValue>
|
||||
<OperatorMeta>DOC-DERIVED</OperatorMeta>
|
||||
</OperatorCard>
|
||||
</OperatorGrid>
|
||||
|
||||
<QuickLinks>
|
||||
<QuickLink href="/projects">
|
||||
|
||||
@@ -4,17 +4,78 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill, BtnToggle } from '@/components/ui/base';
|
||||
import {
|
||||
LabelMeta,
|
||||
PageTitle,
|
||||
SectionTitle,
|
||||
TechBar,
|
||||
TechBarFill,
|
||||
BtnToggle,
|
||||
} from '@/components/ui/base';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
|
||||
interface TaskItem { id: number; taskId: string; title: string; assignee: string; status: string; }
|
||||
interface SprintItem { id: number; number: number; name: string; status: string; progress: number; tasks: TaskItem[]; }
|
||||
interface HistoryItem { label: string; kind: 'sprint' | 'hotfix'; summary?: string | null; description?: string | null; }
|
||||
interface SisterState { name: string; status: string; }
|
||||
interface CommitItem { sha?: string; html_url?: string; commit?: { message?: string; author?: { date?: string; name?: string } }; author?: { login?: string } | null; }
|
||||
interface BranchItem { name?: string; protected?: boolean; commit?: { id?: string } }
|
||||
interface PullItem { id?: number; number?: number; title?: string; html_url?: string; state?: string; merged?: boolean; user?: { login?: string }; created_at?: string; }
|
||||
interface TaskItem {
|
||||
id: number;
|
||||
taskId: string;
|
||||
title: string;
|
||||
assignee: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface SprintItem {
|
||||
id: number;
|
||||
number: number;
|
||||
name: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
tasks: TaskItem[];
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
label: string;
|
||||
kind: 'sprint' | 'hotfix' | 'qa';
|
||||
summary?: string | null;
|
||||
description?: string | null;
|
||||
qaStatus?: 'passed' | 'failed' | 'unknown';
|
||||
blockerCount?: number;
|
||||
}
|
||||
|
||||
interface SisterState {
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface CommitItem {
|
||||
sha?: string;
|
||||
html_url?: string;
|
||||
commit?: { message?: string; author?: { date?: string; name?: string } };
|
||||
author?: { login?: string } | null;
|
||||
}
|
||||
|
||||
interface BranchItem {
|
||||
name?: string;
|
||||
protected?: boolean;
|
||||
commit?: { id?: string };
|
||||
}
|
||||
|
||||
interface PullItem {
|
||||
id?: number;
|
||||
number?: number;
|
||||
title?: string;
|
||||
html_url?: string;
|
||||
state?: string;
|
||||
merged?: boolean;
|
||||
user?: { login?: string };
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface AssignedNode {
|
||||
name: string;
|
||||
role?: string;
|
||||
assigned?: boolean;
|
||||
}
|
||||
|
||||
interface ProjectDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -22,32 +83,215 @@ interface ProjectDetail {
|
||||
status: string;
|
||||
phase: string;
|
||||
currentSprint?: string | null;
|
||||
latestQa: { label: string | null; status: 'passed' | 'failed' | 'unknown'; blockerCount: number; summary: string | null } | null;
|
||||
deploy: { branch: string; status: string; latestDeployAt: string | null; redeployRequired: boolean; note: string };
|
||||
latestQa: {
|
||||
label: string | null;
|
||||
status: 'passed' | 'failed' | 'unknown';
|
||||
blockerCount: number;
|
||||
summary: string | null;
|
||||
} | null;
|
||||
deploy: {
|
||||
branch: string;
|
||||
status: string;
|
||||
latestDeployAt: string | null;
|
||||
redeployRequired: boolean;
|
||||
note: string;
|
||||
};
|
||||
assignedNodes?: AssignedNode[];
|
||||
}
|
||||
|
||||
const Breadcrumb = styled.div`font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);margin-bottom:var(--space-md);a{color:var(--text-secondary);text-decoration:none;&:hover{color:var(--text-primary);}}`;
|
||||
const PageTitleRow = styled.div`display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-lg);margin-bottom:var(--space-xl);flex-wrap:wrap;`;
|
||||
const ProjectGrid = styled.div`display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:var(--space-xl);align-items:start;@media (max-width:1199px){display:flex;flex-direction:column;}`;
|
||||
const Panel = styled.section<{ $column?: 'main' | 'side'; $mobileOrder?: number }>`border:1px solid var(--border-color);padding:var(--space-lg);background:var(--bg-surface);${({$column})=>$column==='side'?'grid-column:2;':'grid-column:1;'}@media (max-width:1199px){order:${({$mobileOrder=0})=>$mobileOrder};}`;
|
||||
const FlowList = styled.div`display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--space-sm);@media (max-width:767px){grid-template-columns:1fr;}`;
|
||||
const FlowItem = styled.div<{ $active:boolean }>`border:1px solid ${({$active})=>$active?'var(--text-primary)':'var(--border-color)'};padding:var(--space-md);background:${({$active})=>$active?'#111':'transparent'};display:flex;flex-direction:column;gap:4px;`;
|
||||
const LedgerList = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`;
|
||||
const LedgerItem = styled.div`display:flex;flex-direction:column;gap:8px;border-top:1px solid #1a1a1a;padding-top:var(--space-md);&:first-child{border-top:none;padding-top:0;}`;
|
||||
const Meta = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`;
|
||||
const NodeStack = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`;
|
||||
const NodeMiniCard = styled.div`border:1px solid var(--border-color);padding:var(--space-sm) var(--space-md);display:flex;justify-content:space-between;align-items:center;gap:var(--space-md);`;
|
||||
const NodeInfo = styled.div`display:flex;align-items:center;gap:var(--space-sm);`;
|
||||
const NodeDot = styled.div<{ $status:string }>`width:8px;height:8px;border-radius:50%;background:${({$status})=>$status==='online'?'#00ff88':$status==='working'?'#5fafff':'#555'};`;
|
||||
const Empty = styled.div`padding:var(--space-xl) 0;color:var(--text-secondary);font-size:13px;font-family:var(--font-mono);`;
|
||||
const TabBar = styled.div`display:flex;gap:var(--space-sm);margin-bottom:var(--space-xl);flex-wrap:wrap;`;
|
||||
const GitList = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`;
|
||||
const GitItem = styled.a`border:1px solid var(--border-color);padding:12px var(--space-lg);text-decoration:none;color:var(--text-primary);display:flex;justify-content:space-between;gap:var(--space-md);align-items:center;`;
|
||||
const GitPrimary = styled.div`display:flex;flex-direction:column;gap:4px;min-width:0;`;
|
||||
const GitTitle = styled.div`font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
||||
const Breadcrumb = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = { harang: 'ORCHESTRATOR', narang: 'GENERATOR', darang: 'EVALUATOR', erang: 'INFRA' };
|
||||
const ROLE_NAME: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
|
||||
a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const PageTitleRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-xl);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const TabBar = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-xl);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const OverviewGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: var(--space-xl);
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const MainColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
`;
|
||||
|
||||
const SideColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
`;
|
||||
|
||||
const Panel = styled.section`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-surface);
|
||||
`;
|
||||
|
||||
const FlowList = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const FlowItem = styled.div<{ $active: boolean }>`
|
||||
border: 1px solid ${({ $active }) => ($active ? 'var(--text-primary)' : 'var(--border-color)')};
|
||||
padding: var(--space-md);
|
||||
background: ${({ $active }) => ($active ? '#111' : 'transparent')};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const StatusStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
`;
|
||||
|
||||
const LedgerList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const LedgerItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
padding-top: var(--space-md);
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Meta = styled.div`
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
const NodeStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
`;
|
||||
|
||||
const NodeMiniCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const NodeInfo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
`;
|
||||
|
||||
const NodeDot = styled.div<{ $status: string }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $status }) => ($status === 'online' ? '#00ff88' : $status === 'working' ? '#5fafff' : '#555')};
|
||||
`;
|
||||
|
||||
const Empty = styled.div`
|
||||
padding: var(--space-xl) 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
const GitList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
`;
|
||||
|
||||
const GitItem = styled.a`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 12px var(--space-lg);
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const GitPrimary = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const GitTitle = styled.div`
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
harang: 'ORCHESTRATOR',
|
||||
narang: 'GENERATOR',
|
||||
darang: 'EVALUATOR',
|
||||
erang: 'INFRA',
|
||||
};
|
||||
|
||||
const ROLE_NAME: Record<string, string> = {
|
||||
harang: '하랑',
|
||||
narang: '나랑',
|
||||
darang: '다랑',
|
||||
erang: '이랑',
|
||||
};
|
||||
|
||||
const DEFAULT_NODE_ORDER = ['harang', 'narang', 'darang', 'erang'] as const;
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
@@ -63,7 +307,7 @@ export default function ProjectDetailPage() {
|
||||
const [commits, setCommits] = useState<CommitItem[]>([]);
|
||||
const [branches, setBranches] = useState<BranchItem[]>([]);
|
||||
const [pulls, setPulls] = useState<PullItem[]>([]);
|
||||
const [tab, setTab] = useState<'overview'|'commits'|'branches'|'prs'>('overview');
|
||||
const [tab, setTab] = useState<'overview' | 'commits' | 'branches' | 'prs'>('overview');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,29 +316,48 @@ export default function ProjectDetailPage() {
|
||||
fetch(`${API_URL}/api/projects/${id}/tasks`),
|
||||
fetch(`${API_URL}/api/projects/${id}/history`),
|
||||
fetch(`${API_URL}/api/sisters`),
|
||||
]).then(async ([projectRes, tasksRes, historyRes, sistersRes]) => {
|
||||
if (projectRes.status === 'fulfilled' && projectRes.value.ok) setProject(await projectRes.value.json());
|
||||
if (tasksRes.status === 'fulfilled' && tasksRes.value.ok) setTasks(await tasksRes.value.json());
|
||||
if (historyRes.status === 'fulfilled' && historyRes.value.ok) setHistory(await historyRes.value.json());
|
||||
if (sistersRes.status === 'fulfilled' && sistersRes.value.ok) setSisters(await sistersRes.value.json());
|
||||
}).finally(() => setLoading(false));
|
||||
])
|
||||
.then(async ([projectRes, tasksRes, historyRes, sistersRes]) => {
|
||||
if (projectRes.status === 'fulfilled' && projectRes.value.ok) {
|
||||
setProject(await projectRes.value.json());
|
||||
}
|
||||
if (tasksRes.status === 'fulfilled' && tasksRes.value.ok) {
|
||||
setTasks(await tasksRes.value.json());
|
||||
}
|
||||
if (historyRes.status === 'fulfilled' && historyRes.value.ok) {
|
||||
setHistory(await historyRes.value.json());
|
||||
}
|
||||
if (sistersRes.status === 'fulfilled' && sistersRes.value.ok) {
|
||||
setSisters(await sistersRes.value.json());
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'commits' && commits.length === 0) fetch(`${API_URL}/api/projects/${id}/commits?limit=20`).then((r) => r.ok ? r.json() : []).then(setCommits).catch(() => {});
|
||||
if (tab === 'branches' && branches.length === 0) fetch(`${API_URL}/api/projects/${id}/branches`).then((r) => r.ok ? r.json() : []).then(setBranches).catch(() => {});
|
||||
if (tab === 'prs' && pulls.length === 0) fetch(`${API_URL}/api/projects/${id}/pulls?state=all`).then((r) => r.ok ? r.json() : []).then(setPulls).catch(() => {});
|
||||
if (tab === 'commits' && commits.length === 0) {
|
||||
fetch(`${API_URL}/api/projects/${id}/commits?limit=20`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then(setCommits)
|
||||
.catch(() => {});
|
||||
}
|
||||
if (tab === 'branches' && branches.length === 0) {
|
||||
fetch(`${API_URL}/api/projects/${id}/branches`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then(setBranches)
|
||||
.catch(() => {});
|
||||
}
|
||||
if (tab === 'prs' && pulls.length === 0) {
|
||||
fetch(`${API_URL}/api/projects/${id}/pulls?state=all`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then(setPulls)
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [tab, id, commits.length, branches.length, pulls.length]);
|
||||
|
||||
const allTasks = useMemo(() => tasks.flatMap((sprint) => sprint.tasks ?? []), [tasks]);
|
||||
const assignedNodes = useMemo(() => {
|
||||
const names = Array.from(new Set(allTasks.map((task) => task.assignee).filter(Boolean)));
|
||||
return (names.length > 0 ? names : ['harang', 'narang', 'darang', 'erang']).map((name) => ({
|
||||
name,
|
||||
status: sisters.find((sister) => sister.name === name)?.status ?? 'offline',
|
||||
}));
|
||||
}, [allTasks, sisters]);
|
||||
const hotfixes = history.filter((entry) => entry.kind === 'hotfix');
|
||||
const qaHistory = history.filter((entry) => entry.kind === 'qa');
|
||||
|
||||
const currentSprintMeta = useMemo(() => {
|
||||
if (project?.currentSprint) return project.currentSprint;
|
||||
|
||||
@@ -108,6 +371,15 @@ export default function ProjectDetailPage() {
|
||||
return 'BACKLOG';
|
||||
}, [project, tasks]);
|
||||
|
||||
const assignedNodes = useMemo(() => {
|
||||
const apiNodes = project?.assignedNodes ?? [];
|
||||
return DEFAULT_NODE_ORDER.map((name) => ({
|
||||
name,
|
||||
assigned: apiNodes.find((node) => node.name === name)?.assigned ?? false,
|
||||
status: sisters.find((sister) => sister.name === name)?.status ?? 'offline',
|
||||
}));
|
||||
}, [project, sisters]);
|
||||
|
||||
if (loading) return <Empty>LOADING...</Empty>;
|
||||
if (!project) return <Empty>PROJECT NOT FOUND</Empty>;
|
||||
|
||||
@@ -121,100 +393,179 @@ export default function ProjectDetailPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb><Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / {project.name}</Breadcrumb>
|
||||
<Breadcrumb>
|
||||
<Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / {project.name}
|
||||
</Breadcrumb>
|
||||
|
||||
<PageTitleRow>
|
||||
<PageTitle>
|
||||
{project.name}
|
||||
{project.repoUrl && <a href={project.repoUrl} target="_blank" rel="noopener" style={{ fontSize: '12px', color: '#5fafff', marginLeft: 'var(--space-md)', fontWeight: 400 }}>↗ GITEA</a>}
|
||||
{project.repoUrl && (
|
||||
<a
|
||||
href={project.repoUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style={{ fontSize: '12px', color: '#5fafff', marginLeft: 'var(--space-md)', fontWeight: 400 }}
|
||||
>
|
||||
↗ GITEA
|
||||
</a>
|
||||
)}
|
||||
</PageTitle>
|
||||
<LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / <span>SPRINT:</span>{currentSprintMeta} / <span>DEPLOY:</span>{project.deploy.status}</LabelMeta>
|
||||
<LabelMeta>
|
||||
<span>STATUS:</span>
|
||||
{project.status?.toUpperCase()} / <span>SPRINT:</span>
|
||||
{currentSprintMeta} / <span>DEPLOY:</span>
|
||||
{project.deploy.status}
|
||||
</LabelMeta>
|
||||
</PageTitleRow>
|
||||
|
||||
<TabBar>
|
||||
<BtnToggle $active={tab === 'overview'} onClick={() => setTab('overview')}>Overview</BtnToggle>
|
||||
<BtnToggle $active={tab === 'commits'} onClick={() => setTab('commits')}>Commits</BtnToggle>
|
||||
<BtnToggle $active={tab === 'branches'} onClick={() => setTab('branches')}>Branches</BtnToggle>
|
||||
<BtnToggle $active={tab === 'prs'} onClick={() => setTab('prs')}>Pull Requests</BtnToggle>
|
||||
<BtnToggle $active={tab === 'overview'} onClick={() => setTab('overview')}>
|
||||
Overview
|
||||
</BtnToggle>
|
||||
<BtnToggle $active={tab === 'commits'} onClick={() => setTab('commits')}>
|
||||
Commits
|
||||
</BtnToggle>
|
||||
<BtnToggle $active={tab === 'branches'} onClick={() => setTab('branches')}>
|
||||
Branches
|
||||
</BtnToggle>
|
||||
<BtnToggle $active={tab === 'prs'} onClick={() => setTab('prs')}>
|
||||
Pull Requests
|
||||
</BtnToggle>
|
||||
</TabBar>
|
||||
|
||||
{tab === 'overview' && (
|
||||
<ProjectGrid>
|
||||
<Panel $column="main" $mobileOrder={1}>
|
||||
<SectionTitle><span>DELIVERY FLOW</span><LabelMeta>{project.phase}</LabelMeta></SectionTitle>
|
||||
<FlowList>
|
||||
{flowStates.map((item) => (
|
||||
<FlowItem key={item.key} $active={item.active}>
|
||||
<LabelMeta>{item.title}</LabelMeta>
|
||||
<Meta>{item.key}</Meta>
|
||||
</FlowItem>
|
||||
))}
|
||||
</FlowList>
|
||||
</Panel>
|
||||
<OverviewGrid>
|
||||
<MainColumn>
|
||||
<Panel>
|
||||
<SectionTitle>
|
||||
<span>DELIVERY FLOW</span>
|
||||
<LabelMeta>{project.phase}</LabelMeta>
|
||||
</SectionTitle>
|
||||
<FlowList>
|
||||
{flowStates.map((item) => (
|
||||
<FlowItem key={item.key} $active={item.active}>
|
||||
<LabelMeta>{item.title}</LabelMeta>
|
||||
<Meta>{item.key}</Meta>
|
||||
</FlowItem>
|
||||
))}
|
||||
</FlowList>
|
||||
</Panel>
|
||||
|
||||
<Panel $column="side" $mobileOrder={2}>
|
||||
<SectionTitle><span>QA STATUS</span><LabelMeta>{project.latestQa?.status ?? 'unknown'}</LabelMeta></SectionTitle>
|
||||
<Meta>{project.latestQa?.label ?? 'QA 문서 없음'}</Meta>
|
||||
<div style={{ marginTop: 'var(--space-md)', fontSize: '13px', color: 'var(--text-primary)' }}>{project.latestQa?.summary ?? 'latest QA 없음'}</div>
|
||||
<Meta style={{ marginTop: 'var(--space-md)' }}>{project.latestQa?.status === 'passed' ? 'QA PASSED' : project.latestQa?.status === 'failed' ? `QA FAILED · ${project.latestQa.blockerCount} BLOCKERS` : 'QA PENDING'}</Meta>
|
||||
</Panel>
|
||||
|
||||
<Panel $column="side" $mobileOrder={3}>
|
||||
<SectionTitle><span>DEPLOY STATUS</span><LabelMeta>{project.deploy.branch}</LabelMeta></SectionTitle>
|
||||
<Meta>{project.deploy.status}</Meta>
|
||||
<Meta style={{ marginTop: 'var(--space-sm)' }}>{project.deploy.note}</Meta>
|
||||
<Meta style={{ marginTop: 'var(--space-sm)' }}>{project.deploy.latestDeployAt ? formatDate(project.deploy.latestDeployAt) : 'LAST DEPLOY UNKNOWN'}</Meta>
|
||||
</Panel>
|
||||
|
||||
<Panel $column="main" $mobileOrder={4}>
|
||||
<SectionTitle><span>SPRINT LEDGER</span><LabelMeta>{tasks.length} SPRINTS</LabelMeta></SectionTitle>
|
||||
<LedgerList>
|
||||
{tasks.map((sprint) => (
|
||||
<LedgerItem key={sprint.id}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 'var(--space-md)', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>SPRINT {String(sprint.number).padStart(2, '0')} · {sprint.name}</div>
|
||||
<Meta>{sprint.tasks?.length ?? 0} TASKS · {sprint.tasks?.filter((task) => task.status === 'done').length ?? 0} DONE</Meta>
|
||||
<Panel>
|
||||
<SectionTitle>
|
||||
<span>SPRINT LEDGER</span>
|
||||
<LabelMeta>{tasks.length} SPRINTS</LabelMeta>
|
||||
</SectionTitle>
|
||||
<LedgerList>
|
||||
{tasks.map((sprint) => (
|
||||
<LedgerItem key={sprint.id}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 'var(--space-md)', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
SPRINT {String(sprint.number).padStart(2, '0')} · {sprint.name}
|
||||
</div>
|
||||
<Meta>
|
||||
{sprint.tasks?.length ?? 0} TASKS · {sprint.tasks?.filter((task) => task.status === 'done').length ?? 0} DONE
|
||||
</Meta>
|
||||
</div>
|
||||
<LabelMeta>{sprint.status}</LabelMeta>
|
||||
</div>
|
||||
<LabelMeta>{sprint.status}</LabelMeta>
|
||||
</div>
|
||||
<TechBar><TechBarFill $width={sprint.progress ?? 0} /></TechBar>
|
||||
</LedgerItem>
|
||||
))}
|
||||
{tasks.length === 0 && <Empty>NO SPRINTS</Empty>}
|
||||
</LedgerList>
|
||||
</Panel>
|
||||
<TechBar>
|
||||
<TechBarFill $width={sprint.progress ?? 0} />
|
||||
</TechBar>
|
||||
</LedgerItem>
|
||||
))}
|
||||
{tasks.length === 0 && <Empty>NO SPRINTS</Empty>}
|
||||
</LedgerList>
|
||||
</Panel>
|
||||
|
||||
<Panel $column="main" $mobileOrder={5}>
|
||||
<SectionTitle><span>HOTFIX HISTORY</span><LabelMeta>{hotfixes.length} ITEMS</LabelMeta></SectionTitle>
|
||||
<LedgerList>
|
||||
{hotfixes.map((entry) => (
|
||||
<LedgerItem key={entry.label}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>{entry.label}</div>
|
||||
<Meta>{entry.summary ?? entry.description ?? 'hotfix summary 없음'}</Meta>
|
||||
</LedgerItem>
|
||||
))}
|
||||
{hotfixes.length === 0 && <Empty>NO HOTFIX HISTORY</Empty>}
|
||||
</LedgerList>
|
||||
</Panel>
|
||||
|
||||
<Panel $column="side" $mobileOrder={6}>
|
||||
<SectionTitle><span>ASSIGNED NODES</span><LabelMeta>LIVE STATUS</LabelMeta></SectionTitle>
|
||||
<NodeStack>
|
||||
{assignedNodes.map((node) => (
|
||||
<NodeMiniCard key={node.name}>
|
||||
<NodeInfo>
|
||||
<SisterAvatar name={node.name} size={28} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[node.name] ?? node.name}</div>
|
||||
<Meta>{ROLE_LABEL[node.name] ?? 'NODE'}</Meta>
|
||||
<Panel>
|
||||
<SectionTitle>
|
||||
<span>QA / HOTFIX HISTORY</span>
|
||||
<LabelMeta>{qaHistory.length + hotfixes.length} ITEMS</LabelMeta>
|
||||
</SectionTitle>
|
||||
<LedgerList>
|
||||
{qaHistory.map((entry) => (
|
||||
<LedgerItem key={`qa-${entry.label}`}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 'var(--space-md)', flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>{entry.label}</div>
|
||||
<LabelMeta>{entry.qaStatus ?? 'unknown'}</LabelMeta>
|
||||
</div>
|
||||
</NodeInfo>
|
||||
<NodeDot $status={node.status} />
|
||||
</NodeMiniCard>
|
||||
))}
|
||||
</NodeStack>
|
||||
</Panel>
|
||||
</ProjectGrid>
|
||||
<Meta>{entry.summary ?? entry.description ?? 'qa summary 없음'}</Meta>
|
||||
{typeof entry.blockerCount === 'number' && <Meta>{entry.blockerCount} BLOCKERS</Meta>}
|
||||
</LedgerItem>
|
||||
))}
|
||||
{hotfixes.map((entry) => (
|
||||
<LedgerItem key={`hotfix-${entry.label}`}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>{entry.label}</div>
|
||||
<Meta>{entry.summary ?? entry.description ?? 'hotfix summary 없음'}</Meta>
|
||||
</LedgerItem>
|
||||
))}
|
||||
{qaHistory.length === 0 && hotfixes.length === 0 && <Empty>NO QA / HOTFIX HISTORY</Empty>}
|
||||
</LedgerList>
|
||||
</Panel>
|
||||
</MainColumn>
|
||||
|
||||
<SideColumn>
|
||||
<StatusStack>
|
||||
<Panel>
|
||||
<SectionTitle>
|
||||
<span>QA STATUS</span>
|
||||
<LabelMeta>{project.latestQa?.status ?? 'unknown'}</LabelMeta>
|
||||
</SectionTitle>
|
||||
<Meta>{project.latestQa?.label ?? 'QA 문서 없음'}</Meta>
|
||||
<div style={{ marginTop: 'var(--space-md)', fontSize: '13px', color: 'var(--text-primary)' }}>
|
||||
{project.latestQa?.summary ?? 'latest QA 없음'}
|
||||
</div>
|
||||
<Meta style={{ marginTop: 'var(--space-md)' }}>
|
||||
{project.latestQa?.status === 'passed'
|
||||
? 'QA PASSED'
|
||||
: project.latestQa?.status === 'failed'
|
||||
? `QA FAILED · ${project.latestQa.blockerCount} BLOCKERS`
|
||||
: 'QA PENDING'}
|
||||
</Meta>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SectionTitle>
|
||||
<span>DEPLOY STATUS</span>
|
||||
<LabelMeta>{project.deploy.branch}</LabelMeta>
|
||||
</SectionTitle>
|
||||
<Meta>{project.deploy.status}</Meta>
|
||||
<Meta style={{ marginTop: 'var(--space-sm)' }}>{project.deploy.note}</Meta>
|
||||
<Meta style={{ marginTop: 'var(--space-sm)' }}>
|
||||
{project.deploy.latestDeployAt ? formatDate(project.deploy.latestDeployAt) : 'LAST DEPLOY UNKNOWN'}
|
||||
</Meta>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SectionTitle>
|
||||
<span>ASSIGNED NODES</span>
|
||||
<LabelMeta>4 SISTERS</LabelMeta>
|
||||
</SectionTitle>
|
||||
<NodeStack>
|
||||
{assignedNodes.map((node) => (
|
||||
<NodeMiniCard key={node.name}>
|
||||
<NodeInfo>
|
||||
<SisterAvatar name={node.name} size={28} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||
{ROLE_NAME[node.name] ?? node.name}
|
||||
</div>
|
||||
<Meta>
|
||||
{ROLE_LABEL[node.name] ?? 'NODE'} · {node.assigned ? 'ASSIGNED' : 'STANDBY'}
|
||||
</Meta>
|
||||
</div>
|
||||
</NodeInfo>
|
||||
<NodeDot $status={node.status} />
|
||||
</NodeMiniCard>
|
||||
))}
|
||||
</NodeStack>
|
||||
</Panel>
|
||||
</StatusStack>
|
||||
</SideColumn>
|
||||
</OverviewGrid>
|
||||
)}
|
||||
|
||||
{tab === 'commits' && (
|
||||
@@ -225,7 +576,9 @@ export default function ProjectDetailPage() {
|
||||
<GitTitle>{commit.commit?.message?.split('\n')[0] ?? commit.sha ?? 'UNKNOWN COMMIT'}</GitTitle>
|
||||
<Meta>{commit.commit?.author?.name ?? commit.author?.login ?? '-'}</Meta>
|
||||
</GitPrimary>
|
||||
<Meta>{commit.sha?.slice(0, 7) ?? '-'} · {formatDate(commit.commit?.author?.date)}</Meta>
|
||||
<Meta>
|
||||
{commit.sha?.slice(0, 7) ?? '-'} · {formatDate(commit.commit?.author?.date)}
|
||||
</Meta>
|
||||
</GitItem>
|
||||
))}
|
||||
{commits.length === 0 && <Empty>NO COMMITS</Empty>}
|
||||
@@ -252,8 +605,12 @@ export default function ProjectDetailPage() {
|
||||
{pulls.map((pull) => (
|
||||
<GitItem key={pull.id} href={pull.html_url ?? '#'} target="_blank" rel="noopener">
|
||||
<GitPrimary>
|
||||
<GitTitle>#{pull.number ?? '-'} {pull.title ?? 'UNKNOWN PR'}</GitTitle>
|
||||
<Meta>{pull.user?.login ?? '-'} · {pull.merged ? 'MERGED' : pull.state?.toUpperCase() ?? 'UNKNOWN'}</Meta>
|
||||
<GitTitle>
|
||||
#{pull.number ?? '-'} {pull.title ?? 'UNKNOWN PR'}
|
||||
</GitTitle>
|
||||
<Meta>
|
||||
{pull.user?.login ?? '-'} · {pull.merged ? 'MERGED' : pull.state?.toUpperCase() ?? 'UNKNOWN'}
|
||||
</Meta>
|
||||
</GitPrimary>
|
||||
<Meta>{formatDate(pull.created_at)}</Meta>
|
||||
</GitItem>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn } from '@/components/ui/base';
|
||||
import {
|
||||
PageTitle,
|
||||
LabelMeta,
|
||||
TechBar,
|
||||
TechBarFill,
|
||||
Btn,
|
||||
BtnToggle,
|
||||
} from '@/components/ui/base';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import { adminFetch } from '@/lib/adminFetch';
|
||||
|
||||
@@ -22,20 +29,142 @@ interface ProjectListItem {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const ProjectList = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`;
|
||||
const ProjectRow = styled(Link)`display:grid;grid-template-columns:1.2fr 1fr 220px;gap:var(--space-lg);padding:var(--space-lg);border:1px solid var(--border-color);align-items:center;text-decoration:none;transition:border-color .15s, background .15s;&:hover{border-color:var(--border-hover);background:#111}@media (max-width:1199px){grid-template-columns:1fr;}`;
|
||||
const Left = styled.div`display:flex;flex-direction:column;gap:6px;min-width:0;`;
|
||||
const ProjectName = styled.div`font-size:15px;font-weight:600;color:var(--text-primary);`;
|
||||
const ProjectDesc = styled.div`font-size:12px;color:var(--text-secondary);line-height:1.5;`;
|
||||
const Middle = styled.div`display:flex;flex-direction:column;gap:10px;`;
|
||||
const Right = styled.div`display:flex;flex-direction:column;gap:8px;align-items:flex-end;@media (max-width:1199px){align-items:flex-start;}`;
|
||||
const Meta = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`;
|
||||
const PhaseBadge = styled.div<{ $phase:string }>`font-size:11px;font-family:var(--font-mono);padding:4px 8px;border:1px solid ${({$phase})=>$phase==='DEPLOYED'?'#5fafff44':$phase==='READY FOR DEPLOY'?'#5fff8a44':$phase==='QA'?'#ffcc6644':'var(--border-color)'};color:${({$phase})=>$phase==='DEPLOYED'?'#5fafff':$phase==='READY FOR DEPLOY'?'#5fff8a':$phase==='QA'?'#ffcc66':'var(--text-primary)'};white-space:nowrap;`;
|
||||
const EmptyState = styled.div`padding:var(--space-xl) 0;color:var(--text-secondary);font-size:13px;font-family:var(--font-mono);`;
|
||||
type VisibilityFilter = 'all' | 'public' | 'private';
|
||||
|
||||
const Toolbar = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-lg);
|
||||
`;
|
||||
|
||||
const ToolbarGroup = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const FilterGroup = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const ProjectList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const ProjectRow = styled(Link)`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) 240px;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-hover);
|
||||
background: #111;
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const Left = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const ProjectName = styled.div`
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
const ProjectDesc = styled.div`
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const Middle = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const Right = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
const Meta = styled.div`
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
const MetaRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const PhaseBadge = styled.div<{ $phase: string }>`
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 4px 8px;
|
||||
border: 1px solid
|
||||
${({ $phase }) =>
|
||||
$phase === 'DEPLOYED'
|
||||
? '#5fafff44'
|
||||
: $phase === 'READY FOR DEPLOY'
|
||||
? '#5fff8a44'
|
||||
: $phase === 'QA'
|
||||
? '#ffcc6644'
|
||||
: 'var(--border-color)'};
|
||||
color:
|
||||
${({ $phase }) =>
|
||||
$phase === 'DEPLOYED'
|
||||
? '#5fafff'
|
||||
: $phase === 'READY FOR DEPLOY'
|
||||
? '#5fff8a'
|
||||
: $phase === 'QA'
|
||||
? '#ffcc66'
|
||||
: 'var(--text-primary)'};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
padding: var(--space-xl) 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
function qaLabel(project: ProjectListItem): string {
|
||||
if (project.latestQaStatus === 'passed') return 'QA PASSED';
|
||||
if (project.latestQaStatus === 'failed') return `QA FAILED${project.blockerCount > 0 ? ` · ${project.blockerCount} BLOCKERS` : ''}`;
|
||||
if (project.latestQaStatus === 'failed') {
|
||||
return `QA FAILED${project.blockerCount > 0 ? ` · ${project.blockerCount} BLOCKERS` : ''}`;
|
||||
}
|
||||
return 'QA PENDING';
|
||||
}
|
||||
|
||||
@@ -44,10 +173,16 @@ export default function ProjectsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||
const [visibility, setVisibility] = useState<VisibilityFilter>('all');
|
||||
|
||||
const loadProjects = () => {
|
||||
const loadProjects = (filter: VisibilityFilter = visibility) => {
|
||||
setLoading(true);
|
||||
fetch(`${API_URL}/api/projects`).then((r) => r.json()).then(setProjects).catch(() => {}).finally(() => setLoading(false));
|
||||
const query = filter === 'all' ? '' : `?visibility=${filter}`;
|
||||
fetch(`${API_URL}/api/projects${query}`)
|
||||
.then((r) => r.json())
|
||||
.then(setProjects)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const runSync = async (path: string, label: string) => {
|
||||
@@ -65,40 +200,74 @@ export default function ProjectsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadProjects(); }, []);
|
||||
useEffect(() => {
|
||||
loadProjects(visibility);
|
||||
}, [visibility]);
|
||||
|
||||
const filteredProjects = useMemo(() => projects, [projects]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-md)', flexWrap: 'wrap', marginBottom: 'var(--space-lg)' }}>
|
||||
<PageTitle>프로젝트</PageTitle>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)', flexWrap: 'wrap' }}>
|
||||
{syncResult && <span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)' }}>{syncResult}</span>}
|
||||
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
|
||||
<Btn onClick={() => runSync('/api/admin/gitea/sync', 'gitea sync')} disabled={syncing}>{syncing ? 'SYNCING...' : '↻ GITEA SYNC'}</Btn>
|
||||
<Btn onClick={() => runSync('/api/projects/sync-all-sprints', 'sprint sync')} disabled={syncing}>{syncing ? '...' : '↻ SPRINT SYNC'}</Btn>
|
||||
</div>
|
||||
</div>
|
||||
<Toolbar>
|
||||
<ToolbarGroup>
|
||||
<PageTitle>프로젝트</PageTitle>
|
||||
<FilterGroup>
|
||||
{([
|
||||
{ id: 'all', label: '전체' },
|
||||
{ id: 'public', label: 'public' },
|
||||
{ id: 'private', label: 'private' },
|
||||
] as const).map((item) => (
|
||||
<BtnToggle key={item.id} $active={visibility === item.id} onClick={() => setVisibility(item.id)}>
|
||||
{item.label}
|
||||
</BtnToggle>
|
||||
))}
|
||||
</FilterGroup>
|
||||
</ToolbarGroup>
|
||||
|
||||
{loading ? <EmptyState>LOADING...</EmptyState> : projects.length === 0 ? <EmptyState>NO PROJECTS REGISTERED</EmptyState> : (
|
||||
<ToolbarGroup>
|
||||
{syncResult && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)' }}>
|
||||
{syncResult}
|
||||
</span>
|
||||
)}
|
||||
<LabelMeta>VOL: {String(filteredProjects.length).padStart(2, '0')}</LabelMeta>
|
||||
<Btn onClick={() => runSync('/api/admin/gitea/sync', 'gitea sync')} disabled={syncing}>
|
||||
{syncing ? 'SYNCING...' : '↻ GITEA SYNC'}
|
||||
</Btn>
|
||||
<Btn onClick={() => runSync('/api/projects/sync-all-sprints', 'sprint sync')} disabled={syncing}>
|
||||
{syncing ? '...' : '↻ SPRINT SYNC'}
|
||||
</Btn>
|
||||
</ToolbarGroup>
|
||||
</Toolbar>
|
||||
|
||||
{loading ? (
|
||||
<EmptyState>LOADING...</EmptyState>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<EmptyState>NO PROJECTS</EmptyState>
|
||||
) : (
|
||||
<ProjectList>
|
||||
{projects.map((project) => (
|
||||
{filteredProjects.map((project) => (
|
||||
<ProjectRow key={project.id} href={`/projects/${project.id}`}>
|
||||
<Left>
|
||||
<Meta>P-{String(project.id).padStart(3, '0')}</Meta>
|
||||
<ProjectName>{project.name}</ProjectName>
|
||||
<ProjectDesc>{project.description ?? '설명 없음'}</ProjectDesc>
|
||||
</Left>
|
||||
|
||||
<Middle>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)', flexWrap: 'wrap' }}>
|
||||
<MetaRow>
|
||||
<PhaseBadge $phase={project.phase}>{project.phase}</PhaseBadge>
|
||||
<Meta>{project.currentSprint ? `SPRINT ${project.currentSprint}` : 'PLANNING BACKLOG'}</Meta>
|
||||
{project.latestHotfix && <Meta>HOTFIX {project.latestHotfix}</Meta>}
|
||||
</div>
|
||||
</MetaRow>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
||||
<TechBar style={{ flex: 1 }}><TechBarFill $width={project.progress ?? 0} /></TechBar>
|
||||
<TechBar style={{ flex: 1 }}>
|
||||
<TechBarFill $width={project.progress ?? 0} />
|
||||
</TechBar>
|
||||
<LabelMeta style={{ minWidth: 44, textAlign: 'right' }}>{project.progress ?? 0}%</LabelMeta>
|
||||
</div>
|
||||
</Middle>
|
||||
|
||||
<Right>
|
||||
<Meta>{qaLabel(project)}</Meta>
|
||||
<Meta>{project.deployStatus}</Meta>
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import { LabelMeta, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent, Btn } from '@/components/ui/base';
|
||||
import {
|
||||
LabelMeta,
|
||||
TechBar,
|
||||
TechBarFill,
|
||||
Timeline,
|
||||
TimelineItem,
|
||||
TimeStamp,
|
||||
TimelineContent,
|
||||
Btn,
|
||||
} from '@/components/ui/base';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
import { SISTER_ROLES } from '@/lib/sisters';
|
||||
@@ -12,28 +21,222 @@ import { adminFetch } from '@/lib/adminFetch';
|
||||
import CodeEditor from '@/components/admin/CodeEditor';
|
||||
import LogTerminal from '@/components/admin/LogTerminal';
|
||||
|
||||
const Breadcrumb = styled.div`font-family: var(--font-mono);font-size: 11px;color: var(--text-secondary);margin-bottom: var(--space-md);a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }`;
|
||||
const Hero = styled.section`display:grid;grid-template-columns:128px 1fr;gap:var(--space-xl);align-items:center;padding:var(--space-xl);border:1px solid var(--border-color);margin-bottom:var(--space-xl);@media (max-width:767px){grid-template-columns:1fr;justify-items:start;}`;
|
||||
const HeroMeta = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`;
|
||||
const HeroName = styled.div`font-size:36px;font-weight:700;letter-spacing:-0.03em;color:var(--text-primary);`;
|
||||
const HeroRole = styled.div`font-size:14px;color:var(--text-secondary);font-family:var(--font-mono);`;
|
||||
const HeroStatus = styled.div`display:flex;align-items:center;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap;`;
|
||||
const StatusDot = styled.div<{ $on: boolean }>`width:10px;height:10px;border-radius:50%;background:${({$on})=>$on?'#FFF':'#555'};${({$on})=>$on&&`box-shadow:0 0 8px rgba(255,255,255,0.3);`}`;
|
||||
const HeroStats = styled.div`display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--space-md);margin-top:var(--space-lg);@media (max-width:767px){grid-template-columns:repeat(2,minmax(0,1fr));}`;
|
||||
const StatCard = styled.div`border:1px solid var(--border-color);padding:var(--space-md);`;
|
||||
const StatLabel = styled.div`font-size:10px;color:var(--text-secondary);font-family:var(--font-mono);margin-bottom:6px;`;
|
||||
const StatValue = styled.div`font-size:18px;color:var(--text-primary);font-family:var(--font-mono);`;
|
||||
const DetailGrid = styled.div`display:grid;grid-template-columns:320px 1fr;gap:var(--space-xxl);align-items:start;@media (max-width:1199px){grid-template-columns:1fr;gap:var(--space-xl);}`;
|
||||
const MetaPanel = styled.div`display:flex;flex-direction:column;gap:var(--space-lg);`;
|
||||
const MetaCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);`;
|
||||
const TabBar = styled.div`display:flex;gap:var(--space-lg);border-bottom:1px solid var(--border-color);margin-bottom:var(--space-lg);overflow:auto;`;
|
||||
const TabBtn = styled.button<{ $active: boolean }>`font-size:13px;font-weight:500;color:${({$active})=>$active?'var(--text-primary)':'var(--text-secondary)'};background:transparent;border:none;border-bottom:1px solid ${({$active})=>$active?'var(--text-primary)':'transparent'};padding:var(--space-sm) 0;cursor:pointer;transition:color .15s,border-color .15s;white-space:nowrap;&:hover{color:var(--text-primary);}`;
|
||||
const Toolbar = styled.div`display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;`;
|
||||
const Select = styled.select`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:7px;color:var(--text-primary);padding:8px 12px;font-size:13px;cursor:pointer;outline:none;option{background:#1a1f2a;}`;
|
||||
const LinesInput = styled.input`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:7px;color:var(--text-primary);padding:8px 12px;font-size:13px;width:80px;outline:none;`;
|
||||
const ResultMsg = styled.div<{ $success: boolean }>`padding:8px 12px;border-radius:6px;font-size:12px;font-family:monospace;background:${({$success})=>$success?'rgba(0,230,118,0.08)':'rgba(255,23,68,0.08)'};color:${({$success})=>$success?'#00FF00':'#FF1744'};border:1px solid ${({$success})=>$success?'rgba(0,230,118,0.3)':'rgba(255,23,68,0.3)'};margin-bottom:12px;`;
|
||||
const SessionCard = styled.div`border:1px solid var(--border-color);padding:var(--space-md);margin-bottom:var(--space-sm);`;
|
||||
const SISTER_DISPLAY: Record<string, string> = { harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이' };
|
||||
const Breadcrumb = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Hero = styled.section`
|
||||
display: grid;
|
||||
grid-template-columns: 128px 1fr;
|
||||
gap: var(--space-xl);
|
||||
align-items: center;
|
||||
padding: var(--space-xl);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: var(--space-xl);
|
||||
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: start;
|
||||
}
|
||||
`;
|
||||
|
||||
const HeroMeta = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
`;
|
||||
|
||||
const HeroName = styled.div`
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
const HeroRole = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
const HeroStatus = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const StatusDot = styled.div<{ $on: boolean }>`
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $on }) => ($on ? '#FFF' : '#555')};
|
||||
${({ $on }) => $on && 'box-shadow:0 0 8px rgba(255,255,255,0.3);'}
|
||||
`;
|
||||
|
||||
const HeroStats = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-lg);
|
||||
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
`;
|
||||
|
||||
const StatCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-md);
|
||||
`;
|
||||
|
||||
const StatLabel = styled.div`
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const StatValue = styled.div`
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
const DetailGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: var(--space-xxl);
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
`;
|
||||
|
||||
const MetaPanel = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
`;
|
||||
|
||||
const MetaCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-lg);
|
||||
`;
|
||||
|
||||
const TabBar = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: var(--space-lg);
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
const TabBtn = styled.button<{ $active: boolean }>`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${({ $active }) => ($active ? 'var(--text-primary)' : 'var(--text-secondary)')};
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid ${({ $active }) => ($active ? 'var(--text-primary)' : 'transparent')};
|
||||
padding: var(--space-sm) 0;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
`;
|
||||
|
||||
const Toolbar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Select = styled.select`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 7px;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
option {
|
||||
background: #1a1f2a;
|
||||
}
|
||||
`;
|
||||
|
||||
const LinesInput = styled.input`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 7px;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
width: 80px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const ResultMsg = styled.div<{ $success: boolean }>`
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
background: ${({ $success }) => ($success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)')};
|
||||
color: ${({ $success }) => ($success ? '#00FF00' : '#FF1744')};
|
||||
border: 1px solid ${({ $success }) => ($success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)')};
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const SessionCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
`;
|
||||
|
||||
const OverviewBlock = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
padding: var(--space-lg) 0;
|
||||
`;
|
||||
|
||||
const SISTER_DISPLAY: Record<string, string> = {
|
||||
harang: '하랑이',
|
||||
narang: '나랑이',
|
||||
darang: '다랑이',
|
||||
erang: '이랑이',
|
||||
};
|
||||
|
||||
const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
|
||||
|
||||
interface SisterInfo {
|
||||
@@ -54,6 +257,8 @@ interface SystemInfo {
|
||||
|
||||
interface ConfigData {
|
||||
description?: string | null;
|
||||
role?: string;
|
||||
configAvailable?: boolean;
|
||||
}
|
||||
|
||||
interface ActivityItem {
|
||||
@@ -74,11 +279,6 @@ interface SessionItem {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface SessionsResponse {
|
||||
sessions?: SessionItem[];
|
||||
items?: SessionItem[];
|
||||
}
|
||||
|
||||
interface HarnessResponse {
|
||||
content?: string;
|
||||
}
|
||||
@@ -108,6 +308,8 @@ export default function SisterDetailPage() {
|
||||
const [content, setContent] = useState('');
|
||||
const [saved, setSaved] = useState('');
|
||||
const [saveResult, setSaveResult] = useState<{ success: boolean; msg: string } | null>(null);
|
||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||
const [activityError, setActivityError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
@@ -116,46 +318,80 @@ export default function SisterDetailPage() {
|
||||
fetch(`${API_URL}/api/sisters/${name}/activity`),
|
||||
fetch(`${API_URL}/api/sisters/${name}/system`),
|
||||
fetch(`${API_URL}/api/sisters/${name}/sessions`),
|
||||
]).then(([sRes, cRes, aRes, syRes, ssRes]) => {
|
||||
if (sRes.status === 'fulfilled' && sRes.value.ok) sRes.value.json().then((d: SisterInfo[]) => setSisterInfo(d.find((s) => s.name === name) ?? null));
|
||||
if (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then((data: ConfigData) => setConfigData(data));
|
||||
if (aRes.status === 'fulfilled' && aRes.value.ok) aRes.value.json().then((d: ActivityResponse) => setActivity(d.items ?? []));
|
||||
if (syRes.status === 'fulfilled' && syRes.value.ok) syRes.value.json().then((data: SystemInfo) => setSystemInfo(data));
|
||||
if (ssRes.status === 'fulfilled' && ssRes.value.ok) ssRes.value.json().then((d: SessionsResponse) => setSessions(d.sessions ?? d.items ?? []));
|
||||
}).finally(() => setLoading(false));
|
||||
])
|
||||
.then(async ([sRes, cRes, aRes, syRes, ssRes]) => {
|
||||
if (sRes.status === 'fulfilled' && sRes.value.ok) {
|
||||
const sisters = (await sRes.value.json()) as SisterInfo[];
|
||||
setSisterInfo(sisters.find((s) => s.name === name) ?? null);
|
||||
}
|
||||
if (cRes.status === 'fulfilled' && cRes.value.ok) {
|
||||
setConfigData(await cRes.value.json());
|
||||
}
|
||||
if (aRes.status === 'fulfilled' && aRes.value.ok) {
|
||||
const data = (await aRes.value.json()) as ActivityResponse;
|
||||
setActivity(data.items ?? []);
|
||||
} else {
|
||||
setActivityError('활동 조회 실패');
|
||||
}
|
||||
if (syRes.status === 'fulfilled' && syRes.value.ok) {
|
||||
setSystemInfo(await syRes.value.json());
|
||||
}
|
||||
if (ssRes.status === 'fulfilled' && ssRes.value.ok) {
|
||||
const raw = (await ssRes.value.json()) as SessionItem[] | { items?: SessionItem[]; sessions?: SessionItem[] };
|
||||
const items = Array.isArray(raw) ? raw : raw.sessions ?? raw.items ?? [];
|
||||
setSessions(items);
|
||||
} else {
|
||||
setSessionError('세션 조회 실패');
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [name]);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLogLoading(true);
|
||||
try {
|
||||
const res = await adminFetch(`/api/admin/logs/${name}?lines=${lines}`);
|
||||
const d: LogResponse = await res.json();
|
||||
setLogLines(d.lines ?? []);
|
||||
const data = (await res.json()) as LogResponse;
|
||||
setLogLines(data.lines ?? ['(로그 없음)']);
|
||||
} catch {
|
||||
setLogLines(['(로그 로드 실패)']);
|
||||
setLogLines(['(로그 조회 실패)']);
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
}, [name, lines]);
|
||||
|
||||
useEffect(() => { if (tab === 'logs') fetchLogs(); }, [tab, fetchLogs]);
|
||||
useEffect(() => {
|
||||
if (tab === 'logs') {
|
||||
void fetchLogs();
|
||||
}
|
||||
}, [tab, fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'harness') return;
|
||||
setSaveResult(null);
|
||||
adminFetch(`/api/admin/harness/${name}/${selectedFile}`)
|
||||
.then((r) => r.json())
|
||||
.then((d: HarnessResponse) => { setContent(d.content ?? ''); setSaved(d.content ?? ''); })
|
||||
.then((d: HarnessResponse) => {
|
||||
setContent(d.content ?? '');
|
||||
setSaved(d.content ?? '');
|
||||
})
|
||||
.catch(() => setContent('(로드 실패)'));
|
||||
}, [tab, name, selectedFile]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveResult(null);
|
||||
try {
|
||||
const res = await adminFetch(`/api/admin/harness/${name}/${selectedFile}`, { method: 'PUT', body: JSON.stringify({ content }) });
|
||||
const d: SaveHarnessResponse = await res.json();
|
||||
if (d.success) { setSaved(content); setSaveResult({ success: true, msg: '저장 완료' }); }
|
||||
else setSaveResult({ success: false, msg: d.error ?? '저장 실패' });
|
||||
const res = await adminFetch(`/api/admin/harness/${name}/${selectedFile}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
const d = (await res.json()) as SaveHarnessResponse;
|
||||
if (d.success) {
|
||||
setSaved(content);
|
||||
setSaveResult({ success: true, msg: '저장 완료' });
|
||||
} else {
|
||||
setSaveResult({ success: false, msg: d.error ?? '저장 실패' });
|
||||
}
|
||||
} catch (e) {
|
||||
setSaveResult({ success: false, msg: (e as Error).message });
|
||||
}
|
||||
@@ -165,54 +401,200 @@ export default function SisterDetailPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb><Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}</Breadcrumb>
|
||||
{loading ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div> : <>
|
||||
<Hero>
|
||||
<SisterAvatar name={name} size={120} />
|
||||
<HeroMeta>
|
||||
<HeroName>{SISTER_DISPLAY[name] ?? name}</HeroName>
|
||||
<HeroRole>{SISTER_ROLES[name] ?? 'UNKNOWN'}</HeroRole>
|
||||
<HeroStatus>
|
||||
<StatusDot $on={isActive} />
|
||||
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
|
||||
<LabelMeta><span>LXC:</span>{sisterInfo?.lxcId ?? '--'}</LabelMeta>
|
||||
</HeroStatus>
|
||||
<HeroStats>
|
||||
<StatCard><StatLabel>UPTIME</StatLabel><StatValue>{systemInfo?.uptime ?? '--:--:--'}</StatValue></StatCard>
|
||||
<StatCard><StatLabel>CPU</StatLabel><StatValue>{systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}%</StatValue></StatCard>
|
||||
<StatCard><StatLabel>MEM</StatLabel><StatValue>{systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB</StatValue></StatCard>
|
||||
<StatCard><StatLabel>DISK</StatLabel><StatValue>{systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'}</StatValue></StatCard>
|
||||
</HeroStats>
|
||||
</HeroMeta>
|
||||
</Hero>
|
||||
<Breadcrumb>
|
||||
<Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}
|
||||
</Breadcrumb>
|
||||
{loading ? (
|
||||
<EmptyState>LOADING...</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<Hero>
|
||||
<SisterAvatar name={name} size={120} />
|
||||
<HeroMeta>
|
||||
<HeroName>{SISTER_DISPLAY[name] ?? name}</HeroName>
|
||||
<HeroRole>{SISTER_ROLES[name] ?? configData?.role ?? 'UNKNOWN'}</HeroRole>
|
||||
<HeroStatus>
|
||||
<StatusDot $on={isActive} />
|
||||
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
|
||||
<LabelMeta>
|
||||
<span>LXC:</span>
|
||||
{sisterInfo?.lxcId ?? '--'}
|
||||
</LabelMeta>
|
||||
</HeroStatus>
|
||||
<HeroStats>
|
||||
<StatCard>
|
||||
<StatLabel>UPTIME</StatLabel>
|
||||
<StatValue>{systemInfo?.uptime ?? '--:--:--'}</StatValue>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatLabel>CPU</StatLabel>
|
||||
<StatValue>{systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}%</StatValue>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatLabel>MEM</StatLabel>
|
||||
<StatValue>
|
||||
{systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB
|
||||
</StatValue>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatLabel>DISK</StatLabel>
|
||||
<StatValue>
|
||||
{systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'}
|
||||
</StatValue>
|
||||
</StatCard>
|
||||
</HeroStats>
|
||||
</HeroMeta>
|
||||
</Hero>
|
||||
|
||||
<DetailGrid>
|
||||
<MetaPanel>
|
||||
<MetaCard>
|
||||
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta>
|
||||
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
|
||||
ROLE: {SISTER_ROLES[name] ?? '--'}<br />
|
||||
STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()}<br />
|
||||
HOST_USER: {sisterInfo?.user ?? '--'}
|
||||
</div>
|
||||
<TechBar><TechBarFill $width={isActive ? 100 : 0} /></TechBar>
|
||||
</MetaCard>
|
||||
{configData?.description && <MetaCard><LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta><div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>{configData.description}</div></MetaCard>}
|
||||
</MetaPanel>
|
||||
<div>
|
||||
<TabBar>
|
||||
{[{ id: 'overview', label: '개요' }, { id: 'sessions', label: '세션' }, { id: 'activity', label: '활동' }, { id: 'harness', label: '하네스' }, { id: 'logs', label: '로그' }].map((t) => <TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>{t.label}</TabBtn>)}
|
||||
</TabBar>
|
||||
<DetailGrid>
|
||||
<MetaPanel>
|
||||
<MetaCard>
|
||||
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta>
|
||||
<div
|
||||
style={{
|
||||
margin: 'var(--space-md) 0',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}
|
||||
<br />
|
||||
ROLE: {SISTER_ROLES[name] ?? '--'}
|
||||
<br />
|
||||
STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()}
|
||||
<br />
|
||||
HOST_USER: {sisterInfo?.user ?? '--'}
|
||||
</div>
|
||||
<TechBar>
|
||||
<TechBarFill $width={isActive ? 100 : 0} />
|
||||
</TechBar>
|
||||
</MetaCard>
|
||||
<MetaCard>
|
||||
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>OVERVIEW</LabelMeta>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||
{configData?.description ?? '설명 없음'}
|
||||
</div>
|
||||
</MetaCard>
|
||||
</MetaPanel>
|
||||
|
||||
{tab === 'overview' && <Timeline>{activity.slice(0, 8).map((item) => <TimelineItem key={item.id}><TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp><TimelineContent>{item.detail ?? item.action}</TimelineContent></TimelineItem>)}</Timeline>}
|
||||
{tab === 'sessions' && <>{sessions.length === 0 ? <div style={{ color: 'var(--text-secondary)' }}>세션 없음</div> : sessions.map((session, i: number) => <SessionCard key={session.id ?? i}><div style={{ color: 'var(--text-primary)', fontSize: '13px' }}>{session.title ?? session.label ?? session.id ?? 'session'}</div><div style={{ color: 'var(--text-secondary)', fontSize: '11px', fontFamily: 'var(--font-mono)' }}>{session.status ?? ''}</div></SessionCard>)}</>}
|
||||
{tab === 'activity' && <Timeline>{activity.map((item) => <TimelineItem key={item.id}><TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp><TimelineContent>{item.detail ?? item.action}</TimelineContent></TimelineItem>)}</Timeline>}
|
||||
{tab === 'harness' && <><Toolbar><Select value={selectedFile} onChange={(e) => setSelectedFile(e.target.value)}>{FILES.map((f) => <option key={f} value={f}>{f}</option>)}</Select><Btn onClick={() => { setContent(saved); setSaveResult(null); }}>되돌리기</Btn><Btn onClick={handleSave}>저장</Btn></Toolbar>{saveResult && <ResultMsg $success={saveResult.success}>{saveResult.msg}</ResultMsg>}<CodeEditor value={content} onChange={setContent} /></>}
|
||||
{tab === 'logs' && <><Toolbar><span style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>최근</span><LinesInput type="number" value={lines} onChange={(e) => setLines(parseInt(e.target.value, 10) || 100)} min={10} max={500} /><span style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>줄</span><Btn onClick={fetchLogs}>{logLoading ? '로딩 중...' : '로그 가져오기'}</Btn></Toolbar><LogTerminal lines={logLines} /></>}
|
||||
</div>
|
||||
</DetailGrid>
|
||||
</>}
|
||||
<div>
|
||||
<TabBar>
|
||||
{[
|
||||
{ id: 'overview', label: '개요' },
|
||||
{ id: 'sessions', label: '세션' },
|
||||
{ id: 'activity', label: '활동' },
|
||||
{ id: 'harness', label: '하네스' },
|
||||
{ id: 'logs', label: '로그' },
|
||||
].map((item) => (
|
||||
<TabBtn key={item.id} $active={tab === item.id} onClick={() => setTab(item.id)}>
|
||||
{item.label}
|
||||
</TabBtn>
|
||||
))}
|
||||
</TabBar>
|
||||
|
||||
{tab === 'overview' && (
|
||||
<OverviewBlock>
|
||||
<LabelMeta>OVERVIEW</LabelMeta>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.7 }}>
|
||||
{configData?.description ?? '설명 없음'}
|
||||
</div>
|
||||
<MetaCard style={{ padding: '12px', marginTop: '4px' }}>
|
||||
<LabelMeta style={{ display: 'block', marginBottom: '6px' }}>CONFIG STATUS</LabelMeta>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||
{configData?.configAvailable ? '설정 파일 확인됨' : '설정 파일 없음 또는 조회 불가'}
|
||||
</div>
|
||||
</MetaCard>
|
||||
</OverviewBlock>
|
||||
)}
|
||||
|
||||
{tab === 'sessions' && (
|
||||
<>
|
||||
{sessionError ? (
|
||||
<EmptyState>{sessionError}</EmptyState>
|
||||
) : sessions.length === 0 ? (
|
||||
<EmptyState>세션 없음</EmptyState>
|
||||
) : (
|
||||
sessions.map((session, index) => (
|
||||
<SessionCard key={session.id ?? index}>
|
||||
<div style={{ color: 'var(--text-primary)', fontSize: '13px' }}>
|
||||
{session.title ?? session.label ?? session.id ?? 'session'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '11px', fontFamily: 'var(--font-mono)' }}>
|
||||
{session.status ?? ''}
|
||||
</div>
|
||||
</SessionCard>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'activity' && (
|
||||
<>
|
||||
{activityError ? (
|
||||
<EmptyState>{activityError}</EmptyState>
|
||||
) : activity.length === 0 ? (
|
||||
<EmptyState>활동 없음</EmptyState>
|
||||
) : (
|
||||
<Timeline>
|
||||
{activity.map((item) => (
|
||||
<TimelineItem key={item.id}>
|
||||
<TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp>
|
||||
<TimelineContent>{item.detail ?? item.action}</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'harness' && (
|
||||
<>
|
||||
<Toolbar>
|
||||
<Select value={selectedFile} onChange={(e) => setSelectedFile(e.target.value)}>
|
||||
{FILES.map((file) => (
|
||||
<option key={file} value={file}>
|
||||
{file}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Btn
|
||||
onClick={() => {
|
||||
setContent(saved);
|
||||
setSaveResult(null);
|
||||
}}
|
||||
>
|
||||
되돌리기
|
||||
</Btn>
|
||||
<Btn onClick={handleSave}>저장</Btn>
|
||||
</Toolbar>
|
||||
{saveResult && <ResultMsg $success={saveResult.success}>{saveResult.msg}</ResultMsg>}
|
||||
<CodeEditor value={content} onChange={setContent} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'logs' && (
|
||||
<>
|
||||
<Toolbar>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>최근</span>
|
||||
<LinesInput
|
||||
type="number"
|
||||
value={lines}
|
||||
onChange={(e) => setLines(parseInt(e.target.value, 10) || 100)}
|
||||
min={10}
|
||||
max={500}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>줄</span>
|
||||
<Btn onClick={() => void fetchLogs()}>{logLoading ? '로딩 중...' : '로그 가져오기'}</Btn>
|
||||
</Toolbar>
|
||||
<LogTerminal lines={logLines} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DetailGrid>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user