Compare commits
23 Commits
hotfix/pro
...
hotfix/pro
| Author | SHA1 | Date | |
|---|---|---|---|
| 41edaec62e | |||
| 09162ee42d | |||
| 58b86148f5 | |||
| 91e3165dd3 | |||
| 01262b9d4e | |||
| 246a712aff | |||
| c7724b9cd6 | |||
| 64515333ae | |||
| 09dbb57cd7 | |||
| b34da9a288 | |||
| 78ca4a72bb | |||
| c8f82ba9a8 | |||
| a40b6eda32 | |||
| c2a60f910b | |||
| fb21678870 | |||
| a4f391d15b | |||
| ad0b489633 | |||
| 9f6bba9aeb | |||
| 7a91589ded | |||
| 5c810b03c6 | |||
| 2cacdcf54e | |||
| 59bf0f38de | |||
| b342d6be52 |
34
.plans/sprints/HOTFIX-004.md
Normal file
34
.plans/sprints/HOTFIX-004.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# HOTFIX-004: 자매 상세 카드 UI 보정
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
자매 상세 화면에서 DISK bar 미노출, 이름만 클릭되는 UX, 리소스 중복 표시 문제를 짧게 정리
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-049: DISK progress bar 렌더 수정
|
||||||
|
- DISK 사용률 계산/퍼센트 변환/width 스타일 점검
|
||||||
|
- 0 또는 NaN으로 들어가는 경우 fallback 처리
|
||||||
|
- CPU/MEM/DISK 3개 모두 bar 렌더 일관성 확인
|
||||||
|
|
||||||
|
### TASK-050: 카드 전체 클릭 가능화
|
||||||
|
- 현재 이름 텍스트만 링크인 부분을 카드 전체 링크/클릭 처리로 변경
|
||||||
|
- hover/active 상태도 카드 단위로 적용
|
||||||
|
- 접근성: button/link semantics 유지
|
||||||
|
|
||||||
|
### TASK-051: 우측 중복 메타 정보 교체
|
||||||
|
- CPU/MEM/DISK 텍스트 중복 제거
|
||||||
|
- 우측 영역은 운영 메타 정보로 교체:
|
||||||
|
- STATUS
|
||||||
|
- ROLE
|
||||||
|
- LXC ID
|
||||||
|
- LAST CHECK
|
||||||
|
- CURRENT TASK
|
||||||
|
- 가운데 그래프는 리소스 시각화 전용으로 유지
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- DISK bar 정상 노출
|
||||||
|
- 카드 전체 클릭 시 상세 페이지 이동
|
||||||
|
- 리소스 정보 중복 제거
|
||||||
|
- 우측 메타가 운영 정보로 자연스럽게 표시
|
||||||
|
- 빌드/테스트 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
24
.plans/sprints/HOTFIX-005.md
Normal file
24
.plans/sprints/HOTFIX-005.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# HOTFIX-005: 프로젝트 카드/이력 UI 보정
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
프로젝트 목록의 잘못된 자매 프로필 사진 사용을 제거하고, 프로젝트 상세에서 Sprint timeline과 Hotfix history를 분리해 정보 구조를 자연스럽게 정리
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-061: 프로젝트 카드 대표 아바타 제거
|
||||||
|
- 프로젝트 목록에서 자매 프로필 사진 제거
|
||||||
|
- 프로젝트 전용 placeholder / initial / neutral symbol로 교체
|
||||||
|
- 자매 프로필 사진은 자매 관련 화면에만 유지
|
||||||
|
|
||||||
|
### TASK-062: 프로젝트 상세 이력 구조 분리
|
||||||
|
- Sprint timeline은 Sprint만 표시
|
||||||
|
- Hotfix는 별도 `HOTFIX HISTORY` 섹션으로 분리
|
||||||
|
- 현재 `TIMELINE` 라벨 반복/중복 구조 제거
|
||||||
|
- Hotfix는 별도 태그/스타일로 간결하게 표시
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 프로젝트 목록에서 자매 프로필 사진 제거 확인
|
||||||
|
- 프로젝트 상세에서 Sprint timeline과 Hotfix history 분리 확인
|
||||||
|
- 반복되는 `TIMELINE` 라벨 제거
|
||||||
|
- 빌드/테스트 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
59
.plans/sprints/SPRINT-012.md
Normal file
59
.plans/sprints/SPRINT-012.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# SPRINT-012: 정보 구조 재배치 + 대시보드 시각화 강화
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
대시보드다운 정보 구조로 재정리. 프로젝트 목록 진행률 UI 개선, 자매 프로필 사진 활용, 하네스/로그를 자매 상세로 이동, 실데이터 기반 그래프 강화
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-052: 프로젝트 목록 UI 재설계
|
||||||
|
- 현재 진행률 UI(설명 옆 퍼센트/바) 제거
|
||||||
|
- 프로젝트 행/카드를 3구역으로 재배치:
|
||||||
|
1. 왼쪽: 대표 자매 사진 + 프로젝트 이름 + 설명
|
||||||
|
2. 가운데: 진행률 바 + 퍼센트 + Sprint counts
|
||||||
|
3. 오른쪽: 상태 태그 + 최근 업데이트
|
||||||
|
- 진행률 바 시각적 강화
|
||||||
|
- 프로젝트 상세와 시각적으로 연결감 있게 정리
|
||||||
|
|
||||||
|
### TASK-053: 프로젝트 대표 아바타 개선
|
||||||
|
- 기본 아이콘 제거
|
||||||
|
- 프로젝트 담당 자매 또는 최근 활동 자매의 실제 프로필 사진 사용
|
||||||
|
- 프로젝트 카드/리스트에 아바타 적용
|
||||||
|
- 이미지 없을 때만 fallback
|
||||||
|
|
||||||
|
### TASK-054: 자매 상세에 하네스 편집/로그 뷰어 통합
|
||||||
|
- `/sisters/[name]`에 새 탭 추가:
|
||||||
|
- 개요
|
||||||
|
- 세션
|
||||||
|
- 활동
|
||||||
|
- 하네스
|
||||||
|
- 로그
|
||||||
|
- 기존 관리자 페이지의 자매별 하네스/로그 기능을 자매 상세로 이동
|
||||||
|
- 전체 로그 페이지(`/activities`, `/admin/logs`)는 유지
|
||||||
|
|
||||||
|
### TASK-055: 대시보드 그래프 추가
|
||||||
|
- 실데이터 기반 그래프 2~3개 추가
|
||||||
|
- 후보:
|
||||||
|
- 자매 활동량 막대그래프 (최근 24h)
|
||||||
|
- 프로젝트 진행률 차트
|
||||||
|
- 자매 리소스 스파크라인
|
||||||
|
- 메인 대시보드에서 “관제/모니터링” 느낌 강화
|
||||||
|
- 가짜 그래프 금지, 실제 API 데이터만 사용
|
||||||
|
|
||||||
|
### TASK-056: 관리자 페이지 단순화
|
||||||
|
- 자매 상세로 이동한 기능만큼 관리자 페이지 정리
|
||||||
|
- 관리자 페이지는 시스템 전역 기능 위주로 축소
|
||||||
|
- 중복 진입점 제거
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
- TASK-052 ↔ TASK-053 함께 진행 가능
|
||||||
|
- TASK-054 후 TASK-056 정리
|
||||||
|
- TASK-055는 병렬 가능
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 프로젝트 목록에서 진행률 UI가 더 자연스럽고 명확하게 보임
|
||||||
|
- 프로젝트 목록에 실제 자매 프로필 사진 반영
|
||||||
|
- 자매 상세에서 하네스 편집/로그 뷰어 접근 가능
|
||||||
|
- 전체 로그 페이지는 유지
|
||||||
|
- 대시보드에 실제 데이터 기반 그래프 추가
|
||||||
|
- 빌드/테스트 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
50
.plans/sprints/SPRINT-013.md
Normal file
50
.plans/sprints/SPRINT-013.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# SPRINT-013: 자매 관리/타임라인/선택 UX 정리
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
자매 상세 탭 단순화, 관리자 자매 관리 카드의 이모지 제거, 프로젝트 진행 이력에 Hotfix 표시, 불필요한 텍스트 드래그 방지
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-057: 자매 상세 탭 정리
|
||||||
|
- `/sisters/[name]` 탭에서 `설정` 제거
|
||||||
|
- 유지 탭:
|
||||||
|
- 개요
|
||||||
|
- 세션
|
||||||
|
- 활동
|
||||||
|
- 하네스
|
||||||
|
- 로그
|
||||||
|
- 탭 순서/간격 정리
|
||||||
|
|
||||||
|
### TASK-058: 관리자 자매 관리 카드 아이콘 교체
|
||||||
|
- 관리 > 자매 관리 페이지의 여우/고양이/늑대 이모지 제거
|
||||||
|
- 각 자매 실제 프로필 사진 사용
|
||||||
|
- 이미지 없을 때만 fallback
|
||||||
|
- 자매 관리 카드와 자매 상세의 시각적 일관성 맞추기
|
||||||
|
|
||||||
|
### TASK-059: 프로젝트 진행 이력에 Hotfix 표시
|
||||||
|
- 프로젝트 상세의 timeline/phase/history에 Sprint뿐 아니라 Hotfix도 표시
|
||||||
|
- 예시:
|
||||||
|
- Sprint 001
|
||||||
|
- Sprint 002
|
||||||
|
- HOTFIX-001
|
||||||
|
- Sprint 003
|
||||||
|
- HOTFIX-002
|
||||||
|
- Sprint/Hotfix를 함께 보여 프로젝트 실제 진행 이력 반영
|
||||||
|
- UI에서 Hotfix는 별도 태그/스타일로 구분
|
||||||
|
|
||||||
|
### TASK-060: user-select 정책 정리
|
||||||
|
- 기본적으로 텍스트 드래그 방지 (`user-select: none`)
|
||||||
|
- 예외 허용 영역:
|
||||||
|
- 로그 뷰어
|
||||||
|
- 코드/하네스 편집기
|
||||||
|
- input / textarea
|
||||||
|
- 복사 가능한 코드 블록
|
||||||
|
- 버튼/카드/탭/라벨/네비는 드래그 방지
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 자매 상세 탭에서 설정 제거 확인
|
||||||
|
- 관리자 자매 관리 카드에 실제 프로필 사진 반영
|
||||||
|
- 프로젝트 상세에서 Hotfix 포함 이력 표시
|
||||||
|
- 로그/편집칸 외 텍스트 드래그 불가
|
||||||
|
- 빌드/테스트 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
21
.qa/HOTFIX-004-review-1.md
Normal file
21
.qa/HOTFIX-004-review-1.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# HOTFIX-004 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 20:17 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 변경 확인
|
||||||
|
|
||||||
|
| Task | 내용 | 확인 |
|
||||||
|
|------|------|------|
|
||||||
|
| TASK-049 | DISK bar 퍼센트 계산 | ✅ diskPercent() — G/M/K/T 변환, NaN/0 fallback |
|
||||||
|
| TASK-050 | 카드 전체 클릭 | ✅ NodeEntry = styled(Link) + href |
|
||||||
|
| TASK-051 | 우측 운영 메타 | ✅ STATUS/ROLE/LXC ID/LAST CHECK/CURRENT TASK |
|
||||||
21
.qa/HOTFIX-005-review-1.md
Normal file
21
.qa/HOTFIX-005-review-1.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# HOTFIX-005 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 21:34 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 변경 확인
|
||||||
|
|
||||||
|
| Task | 내용 | 확인 |
|
||||||
|
|------|------|------|
|
||||||
|
| TASK-061 | 프로젝트 목록 자매 프로필 사진 제거 | ✅ `ProjectMark` neutral placeholder로 교체 |
|
||||||
|
| TASK-062 | Sprint timeline / Hotfix history 분리 | ✅ `PHASE TIMELINE` + `HOTFIX HISTORY` 분리 |
|
||||||
|
| TASK-062 | TIMELINE 반복 라벨 제거 | ✅ Sprint 영역은 sprint label만 유지 |
|
||||||
14
.qa/HOTFIX-006-review-1.md
Normal file
14
.qa/HOTFIX-006-review-1.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# HOTFIX-006 review iteration 1
|
||||||
|
- reviewed_at: 2026-04-05 09:31 KST
|
||||||
|
- passed: false
|
||||||
|
- blocking:
|
||||||
|
- frontend/app/projects/[id]/page.tsx: HOTFIX HISTORY가 label만 렌더링하고 summary/description 데이터를 전혀 표시하지 않음
|
||||||
|
- frontend/app/projects/[id]/page.tsx: ASSIGNED NODES가 실제 assignee 기반이 아니라 4자매 전체 하드코딩
|
||||||
|
- backend build 실패: PrismaService 타입 에러 다수로 `npm run build` 불통
|
||||||
|
- non_blocking:
|
||||||
|
- frontend/app/page.tsx: 모바일 차트는 min-height만 추가됐고 가로 스크롤/최소 폭 보장이 없어 설계의 scroll-safe 대응이 불충분함
|
||||||
|
- backend/src/projects/projects.service.ts: history 응답이 label만 내려줘 상세 설명 렌더링 요구를 충족하지 못함
|
||||||
|
- subagents:
|
||||||
|
- code-reviewer: not run (agent unavailable in current environment)
|
||||||
|
- security-auditor: not run (agent unavailable in current environment)
|
||||||
|
- qa-tester: not run (agent unavailable in current environment)
|
||||||
11
.qa/HOTFIX-006-review-2.md
Normal file
11
.qa/HOTFIX-006-review-2.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# HOTFIX-006 review iteration 2
|
||||||
|
- reviewed_at: 2026-04-05 09:33 KST
|
||||||
|
- passed: false
|
||||||
|
- blocking:
|
||||||
|
- backend build still fails with PrismaService type errors (`this.prisma.project/sprint/task/sisterConfig` missing), so build-pass claim is still false on latest branch state
|
||||||
|
- non_blocking:
|
||||||
|
- frontend fixes requested in review #1 are reflected: project avatar removed, mobile charts use overflow-x/min-width, hotfix history renders summary/description fallback, assigned nodes derive from assignees with avatars
|
||||||
|
- subagents:
|
||||||
|
- code-reviewer: not run (agent unavailable in current environment)
|
||||||
|
- security-auditor: not run (agent unavailable in current environment)
|
||||||
|
- qa-tester: not run (agent unavailable in current environment)
|
||||||
16
.qa/HOTFIX-006-review-3.md
Normal file
16
.qa/HOTFIX-006-review-3.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# HOTFIX-006 review iteration 3
|
||||||
|
- reviewed_at: 2026-04-05 09:35 KST
|
||||||
|
- passed: true
|
||||||
|
- blocking: none
|
||||||
|
- non_blocking:
|
||||||
|
- backend build depends on Prisma Client regeneration; local clean/CI 환경이면 `npx prisma generate` 선행 보장 필요
|
||||||
|
- backend npm audit still reports 7 vulnerabilities (4 moderate, 3 high) in dependency tree
|
||||||
|
- verification:
|
||||||
|
- frontend npm run build: passed
|
||||||
|
- backend npx prisma generate: passed
|
||||||
|
- backend npm run build: passed
|
||||||
|
- frontend fixes retained: project neutral mark, mobile chart horizontal scroll, hotfix summary/description rendering, assignee-based assigned nodes
|
||||||
|
- subagents:
|
||||||
|
- code-reviewer: not run (agent unavailable in current environment)
|
||||||
|
- security-auditor: not run (agent unavailable in current environment)
|
||||||
|
- qa-tester: not run (agent unavailable in current environment)
|
||||||
23
.qa/SPRINT-012-review-1.md
Normal file
23
.qa/SPRINT-012-review-1.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# SPRINT-012 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 20:55 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 변경 확인
|
||||||
|
|
||||||
|
| Task | 내용 | 확인 |
|
||||||
|
|------|------|------|
|
||||||
|
| TASK-052 | 프로젝트 목록 3구역 재배치 | ✅ `1.4fr 1fr 180px` grid |
|
||||||
|
| TASK-053 | 프로젝트 아바타 → 자매 프로필 사진 | ✅ SisterAvatar(ownerSister) |
|
||||||
|
| TASK-054 | 자매 상세 탭 통합 (하네스/로그) | ✅ 6탭: 개요/세션/활동/하네스/로그/설정 |
|
||||||
|
| TASK-055 | 대시보드 그래프 2종 | ✅ Sister CPU load + Project progress BarChart |
|
||||||
|
| TASK-056 | 관리자 탭 단순화 | ✅ |
|
||||||
33
.qa/SPRINT-013-review-1.md
Normal file
33
.qa/SPRINT-013-review-1.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# SPRINT-013 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 21:15 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ❌ FAILED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 확인된 사항
|
||||||
|
|
||||||
|
| Task | 내용 | 확인 |
|
||||||
|
|------|------|------|
|
||||||
|
| TASK-057 | 자매 상세 설정 탭 제거 | ✅ |
|
||||||
|
| TASK-059 | 프로젝트 이력에 Sprint + Hotfix 표시 | ✅ `/api/projects/:id/history` + timeline UI |
|
||||||
|
| TASK-060 | user-select 정책 정리 | ✅ GlobalStyle 기본 none + 예외 text |
|
||||||
|
|
||||||
|
## Blocking
|
||||||
|
|
||||||
|
| ID | 이슈 |
|
||||||
|
|----|------|
|
||||||
|
| B1 | **관리 페이지 이모지 제거 미완료** — `frontend/app/admin/logs/page.tsx`에 `🦊/🐱/🐺` label 잔존 |
|
||||||
|
| B2 | **관리 페이지 이모지 제거 미완료** — `frontend/app/admin/harness/page.tsx`에 `🦊/🐱/🐺` label 잔존 |
|
||||||
|
|
||||||
|
## 판정
|
||||||
|
- 자기야가 정정한 요구는 "프로젝트 아이콘"이 아니라 **관리 쪽 자매 아이콘(여우/고양이/늑대) 제거**였어.
|
||||||
|
- admin 메인 카드에는 SisterAvatar가 적용됐지만, **admin logs / admin harness selector에는 여전히 이모지 label이 남아 있음**.
|
||||||
|
- 즉 TASK-058 요구 반영이 완결되지 않았어.
|
||||||
17
.qa/SPRINT-013-review-2.md
Normal file
17
.qa/SPRINT-013-review-2.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# SPRINT-013 QA Review — Iteration 2
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 21:18 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 재검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| B1 | `frontend/app/admin/logs/page.tsx` 이모지 제거 | ✅ |
|
||||||
|
| B2 | `frontend/app/admin/harness/page.tsx` 이모지 제거 | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 판정
|
||||||
|
- admin logs / admin harness selector의 `🦊/🐱/🐺` 이모지 잔존 문제 해결됨
|
||||||
|
- SPRINT-013 전체 요구사항 기준 blocking 0건
|
||||||
@@ -44,6 +44,11 @@ export class ProjectsController {
|
|||||||
return this.projectsService.getProjectPulls(id, state ?? 'open');
|
return this.projectsService.getProjectPulls(id, state ?? 'open');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/history')
|
||||||
|
getHistory(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.projectsService.getProjectHistory(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/sync-sprints')
|
@Post(':id/sync-sprints')
|
||||||
@UseGuards(JwtGuard, RoleGuard)
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export class ProjectsService {
|
|||||||
const totalSprints = p.sprints.length;
|
const totalSprints = p.sprints.length;
|
||||||
const doneSprints = p.sprints.filter((s) => s.status === 'done').length;
|
const doneSprints = p.sprints.filter((s) => s.status === 'done').length;
|
||||||
const inProgressSprint = p.sprints.find((s) => s.status === 'in_progress');
|
const inProgressSprint = p.sprints.find((s) => s.status === 'in_progress');
|
||||||
|
const ownerSister = p.sprints.flatMap((s) => s.tasks).find((t) => t.assignee)?.assignee ?? 'narang';
|
||||||
|
|
||||||
const progress = totalSprints > 0 ? Math.round((doneSprints / totalSprints) * 100) : 0;
|
const progress = totalSprints > 0 ? Math.round((doneSprints / totalSprints) * 100) : 0;
|
||||||
const gitea = giteaRepos[p.giteaId];
|
const gitea = giteaRepos[p.giteaId];
|
||||||
@@ -52,6 +53,7 @@ export class ProjectsService {
|
|||||||
progress,
|
progress,
|
||||||
totalSprints,
|
totalSprints,
|
||||||
doneSprints,
|
doneSprints,
|
||||||
|
ownerSister,
|
||||||
openPRs: gitea?.openPRs ?? 0,
|
openPRs: gitea?.openPRs ?? 0,
|
||||||
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
|
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
|
||||||
sprintCount: totalSprints,
|
sprintCount: totalSprints,
|
||||||
@@ -102,6 +104,49 @@ export class ProjectsService {
|
|||||||
return this.gitea.getPulls(repoName, state);
|
return this.gitea.getPulls(repoName, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProjectHistory(id: number) {
|
||||||
|
const project = await this.getProjectMeta(id);
|
||||||
|
const repoName = project.repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
|
||||||
|
const files = await this.gitea.getRepoTree(repoName, '.plans/sprints/');
|
||||||
|
return await Promise.all(
|
||||||
|
files
|
||||||
|
.filter((f) => /(SPRINT-\d+|HOTFIX-\d+)\.md$/i.test(f))
|
||||||
|
.map(async (f) => {
|
||||||
|
const name = f.split('/').pop() ?? '';
|
||||||
|
const sprint = name.match(/SPRINT-(\d+)/i);
|
||||||
|
const hotfix = name.match(/HOTFIX-(\d+)/i);
|
||||||
|
let summary: string | null = null;
|
||||||
|
let description: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await this.gitea.getRawFile(repoName, f);
|
||||||
|
const lines = (content ?? '').split('\n');
|
||||||
|
const goalLine = lines.find((line) => line.startsWith('## 목표'));
|
||||||
|
summary = goalLine?.replace('## 목표', '').trim() || null;
|
||||||
|
|
||||||
|
const taskIdx = lines.findIndex((line) => line.startsWith('## 태스크'));
|
||||||
|
if (taskIdx >= 0) {
|
||||||
|
description = lines
|
||||||
|
.slice(taskIdx + 1)
|
||||||
|
.find((line) => line.trim().startsWith('- '))
|
||||||
|
?.replace(/^\s*-\s*/, '')
|
||||||
|
.trim() ?? null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`History content fetch failed for ${f}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: sprint ? 'sprint' : 'hotfix',
|
||||||
|
order: sprint ? parseInt(sprint[1], 10) : 1000 + parseInt(hotfix?.[1] ?? '0', 10),
|
||||||
|
label: name.replace('.md', ''),
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
).then((items) => items.sort((a, b) => a.order - b.order));
|
||||||
|
}
|
||||||
|
|
||||||
private async getProjectMeta(id: number) {
|
private async getProjectMeta(id: number) {
|
||||||
const project = await this.prisma.project.findUnique({ where: { id }, select: { id: true, repoUrl: true } });
|
const project = await this.prisma.project.findUnique({ where: { id }, select: { id: true, repoUrl: true } });
|
||||||
if (!project) throw new NotFoundException(`Project ${id} not found`);
|
if (!project) throw new NotFoundException(`Project ${id} not found`);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const SISTERS = ['harang', 'narang', 'darang', 'erang'];
|
|||||||
const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
|
const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
|
||||||
|
|
||||||
const DISPLAY_NAMES: Record<string, string> = {
|
const DISPLAY_NAMES: Record<string, string> = {
|
||||||
harang: '🦊 하랑이', narang: '🦊 나랑이', darang: '🐱 다랑이', erang: '🐺 이랑이',
|
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
|
||||||
};
|
};
|
||||||
|
|
||||||
const Layout = styled.div`
|
const Layout = styled.div`
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { usePathname } from 'next/navigation';
|
|||||||
|
|
||||||
const ADMIN_TABS = [
|
const ADMIN_TABS = [
|
||||||
{ href: '/admin', label: '자매 관리' },
|
{ href: '/admin', label: '자매 관리' },
|
||||||
{ href: '/admin/harness', label: '하네스 편집' },
|
|
||||||
{ href: '/admin/logs', label: '로그 뷰어' },
|
{ href: '/admin/logs', label: '로그 뷰어' },
|
||||||
{ href: '/admin/costs', label: '비용 모니터' },
|
{ href: '/admin/costs', label: '비용 모니터' },
|
||||||
{ href: '/admin/repos', label: '저장소' },
|
{ href: '/admin/repos', label: '저장소' },
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import LogTerminal from '@/components/admin/LogTerminal';
|
|||||||
import { adminFetch } from '@/lib/adminFetch';
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
|
|
||||||
const SISTERS = [
|
const SISTERS = [
|
||||||
{ value: 'harang', label: '🦊 하랑이' },
|
{ value: 'harang', label: '하랑이' },
|
||||||
{ value: 'narang', label: '🦊 나랑이' },
|
{ value: 'narang', label: '나랑이' },
|
||||||
{ value: 'darang', label: '🐱 다랑이' },
|
{ value: 'darang', label: '다랑이' },
|
||||||
{ value: 'erang', label: '🐺 이랑이' },
|
{ value: 'erang', label: '이랑이' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const Controls = styled.div`
|
const Controls = styled.div`
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import StatusBadge from '@/components/common/StatusBadge';
|
|||||||
import ConfirmModal from '@/components/admin/ConfirmModal';
|
import ConfirmModal from '@/components/admin/ConfirmModal';
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
import { adminFetch } from '@/lib/adminFetch';
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
import { SISTER_EMOJIS, SISTER_DISPLAY_NAMES } from '@/lib/sisters';
|
import { SISTER_DISPLAY_NAMES } from '@/lib/sisters';
|
||||||
|
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||||
|
|
||||||
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
||||||
|
|
||||||
@@ -16,71 +17,13 @@ interface ActionResult {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Table = styled.div`
|
const Table = styled.div`display:flex;flex-direction:column;gap:8px;user-select:none;`;
|
||||||
display: flex;
|
const Row = styled.div`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:10px;padding:16px 20px;display:flex;align-items:center;gap:16px;`;
|
||||||
flex-direction: column;
|
const SisterInfo = styled.div`display:flex;align-items:center;gap:10px;flex:1;`;
|
||||||
gap: 8px;
|
const Name = styled.span`font-size:15px;font-weight:600;color:var(--text-primary);min-width:70px;`;
|
||||||
`;
|
const Actions = styled.div`display:flex;gap:8px;`;
|
||||||
|
const ActionBtn = styled.button<{ $danger?: boolean }>`padding:6px 14px;border-radius:7px;font-size:13px;font-weight:600;cursor:pointer;border:1px solid;transition:all .15s;user-select:none;${({ $danger }) => $danger ? `background: rgba(255,23,68,0.08); border-color: rgba(255,23,68,0.3); color: #FF1744; &:hover { background: rgba(255,23,68,0.18); }` : `background: rgba(88,166,255,0.08); border-color: rgba(88,166,255,0.3); color: #58A6FF; &:hover { background: rgba(88,166,255,0.18); }`}`;
|
||||||
const Row = styled.div`
|
const ResultBanner = styled.div<{ $success: boolean }>`margin-top:8px;padding:8px 12px;background:${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};border:1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};border-radius:6px;font-size:12px;color:${({ $success }) => $success ? '#00FF00' : '#FF1744'};font-family:monospace;white-space:pre-wrap;`;
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SisterInfo = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex: 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Emoji = styled.span`font-size: 22px;`;
|
|
||||||
|
|
||||||
const Name = styled.span`
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
min-width: 70px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Actions = styled.div`
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ActionBtn = styled.button<{ $danger?: boolean }>`
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: 7px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid;
|
|
||||||
transition: all 0.15s;
|
|
||||||
|
|
||||||
${({ $danger }) =>
|
|
||||||
$danger
|
|
||||||
? `background: rgba(255,23,68,0.08); border-color: rgba(255,23,68,0.3); color: #FF1744;
|
|
||||||
&:hover { background: rgba(255,23,68,0.18); }`
|
|
||||||
: `background: rgba(88,166,255,0.08); border-color: rgba(88,166,255,0.3); color: #58A6FF;
|
|
||||||
&:hover { background: rgba(88,166,255,0.18); }`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ResultBanner = styled.div<{ $success: boolean }>`
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};
|
|
||||||
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: ${({ $success }) => $success ? '#00FF00' : '#FF1744'};
|
|
||||||
font-family: monospace;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function AdminSistersPage() {
|
export default function AdminSistersPage() {
|
||||||
const [sisters, setSisters] = useState<any[]>([]);
|
const [sisters, setSisters] = useState<any[]>([]);
|
||||||
@@ -90,9 +33,7 @@ export default function AdminSistersPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
|
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
|
||||||
const iv = setInterval(() => {
|
const iv = setInterval(() => { fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {}); }, 15000);
|
||||||
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
|
|
||||||
}, 15000);
|
|
||||||
return () => clearInterval(iv);
|
return () => clearInterval(iv);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -104,10 +45,7 @@ export default function AdminSistersPage() {
|
|||||||
const data: ActionResult = await res.json();
|
const data: ActionResult = await res.json();
|
||||||
setResults((p) => ({ ...p, [`${action}:${name}`]: data }));
|
setResults((p) => ({ ...p, [`${action}:${name}`]: data }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setResults((p) => ({
|
setResults((p) => ({ ...p, [`${action}:${name}`]: { success: false, output: '', error: (e as Error).message } }));
|
||||||
...p,
|
|
||||||
[`${action}:${name}`]: { success: false, output: '', error: (e as Error).message },
|
|
||||||
}));
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading((p) => ({ ...p, [`${action}:${name}`]: false }));
|
setLoading((p) => ({ ...p, [`${action}:${name}`]: false }));
|
||||||
}
|
}
|
||||||
@@ -123,52 +61,23 @@ export default function AdminSistersPage() {
|
|||||||
<div key={s.id}>
|
<div key={s.id}>
|
||||||
<Row>
|
<Row>
|
||||||
<SisterInfo>
|
<SisterInfo>
|
||||||
<Emoji>{SISTER_EMOJIS[s.name] ?? '🤖'}</Emoji>
|
<SisterAvatar name={s.name} size={28} />
|
||||||
<Name>{SISTER_DISPLAY_NAMES[s.name] ?? s.name}</Name>
|
<Name>{SISTER_DISPLAY_NAMES[s.name] ?? s.name}</Name>
|
||||||
<StatusBadge status={s.status as Status} />
|
<StatusBadge status={s.status as Status} />
|
||||||
</SisterInfo>
|
</SisterInfo>
|
||||||
<Actions>
|
<Actions>
|
||||||
<ActionBtn
|
<ActionBtn onClick={() => setModal({ action: 'restart', name: s.name })} disabled={loading[`restart:${s.name}`]}>{loading[`restart:${s.name}`] ? '...' : '재시작'}</ActionBtn>
|
||||||
onClick={() => setModal({ action: 'restart', name: s.name })}
|
<ActionBtn $danger onClick={() => setModal({ action: 'reset', name: s.name })} disabled={loading[`reset:${s.name}`]}>{loading[`reset:${s.name}`] ? '...' : '리셋'}</ActionBtn>
|
||||||
disabled={loading[`restart:${s.name}`]}
|
|
||||||
>
|
|
||||||
{loading[`restart:${s.name}`] ? '...' : '🔄 재시작'}
|
|
||||||
</ActionBtn>
|
|
||||||
<ActionBtn
|
|
||||||
$danger
|
|
||||||
onClick={() => setModal({ action: 'reset', name: s.name })}
|
|
||||||
disabled={loading[`reset:${s.name}`]}
|
|
||||||
>
|
|
||||||
{loading[`reset:${s.name}`] ? '...' : '🗑️ 리셋'}
|
|
||||||
</ActionBtn>
|
|
||||||
</Actions>
|
</Actions>
|
||||||
</Row>
|
</Row>
|
||||||
{restartResult && (
|
{restartResult && <ResultBanner $success={restartResult.success}>재시작: {restartResult.success ? '✅ 성공' : `❌ ${restartResult.error}`}{restartResult.output && `\n${restartResult.output}`}</ResultBanner>}
|
||||||
<ResultBanner $success={restartResult.success}>
|
{resetResult && <ResultBanner $success={resetResult.success}>리셋: {resetResult.success ? '✅ 성공' : `❌ ${resetResult.error}`}</ResultBanner>}
|
||||||
재시작: {restartResult.success ? '✅ 성공' : `❌ ${restartResult.error}`}
|
|
||||||
{restartResult.output && `\n${restartResult.output}`}
|
|
||||||
</ResultBanner>
|
|
||||||
)}
|
|
||||||
{resetResult && (
|
|
||||||
<ResultBanner $success={resetResult.success}>
|
|
||||||
리셋: {resetResult.success ? '✅ 성공' : `❌ ${resetResult.error}`}
|
|
||||||
</ResultBanner>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{modal && (
|
{modal && <ConfirmModal title={modal.action === 'restart' ? '게이트웨이 재시작' : '세션 리셋'} message={`${SISTER_DISPLAY_NAMES[modal.name] ?? modal.name}의 ${modal.action === 'restart' ? 'OpenClaw Gateway를 재시작' : '메인 세션을 초기화'}하시겠어요?`} confirmLabel={modal.action === 'restart' ? '재시작' : '리셋'} danger={modal.action === 'reset'} onConfirm={() => doAction(modal.action, modal.name)} onCancel={() => setModal(null)} />}
|
||||||
<ConfirmModal
|
|
||||||
title={modal.action === 'restart' ? '게이트웨이 재시작' : '세션 리셋'}
|
|
||||||
message={`${SISTER_DISPLAY_NAMES[modal.name] ?? modal.name}의 ${modal.action === 'restart' ? 'OpenClaw Gateway를 재시작' : '메인 세션을 초기화'}하시겠어요?`}
|
|
||||||
confirmLabel={modal.action === 'restart' ? '재시작' : '리셋'}
|
|
||||||
danger={modal.action === 'reset'}
|
|
||||||
onConfirm={() => doAction(modal.action, modal.name)}
|
|
||||||
onCancel={() => setModal(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ import {
|
|||||||
} from '@/components/ui/base';
|
} from '@/components/ui/base';
|
||||||
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
|
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
|
||||||
import { useSocket } from '@/lib/useSocket';
|
import { useSocket } from '@/lib/useSocket';
|
||||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
|
||||||
|
|
||||||
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
||||||
const StatusGrid = styled.section`display:grid;grid-template-columns:repeat(4,1fr);gap:var(--space-lg);@media (min-width:768px) and (max-width:1199px){grid-template-columns:repeat(2,1fr)}@media (max-width:767px){grid-template-columns:repeat(2,1fr);gap:var(--space-md)}`;
|
const StatusGrid = styled.section`display:grid;grid-template-columns:repeat(4,1fr);gap:var(--space-lg);@media (min-width:768px) and (max-width:1199px){grid-template-columns:repeat(2,1fr)}@media (max-width:767px){grid-template-columns:repeat(2,1fr);gap:var(--space-md)}`;
|
||||||
const DataColumns = styled.div`display:grid;grid-template-columns:1.5fr 1fr;gap:var(--space-xxl);align-items:start;@media (max-width:1199px){grid-template-columns:1fr;gap:var(--space-xl)}}`;
|
const DataColumns = styled.div`display:grid;grid-template-columns:1.5fr 1fr;gap:var(--space-xxl);align-items:start;@media (max-width:1199px){grid-template-columns:1fr;gap:var(--space-xl)}}`;
|
||||||
|
const ChartGrid = styled.section`display:grid;grid-template-columns:1fr 1fr;gap:var(--space-xl);margin-bottom:var(--space-xxl);@media (max-width:1199px){grid-template-columns:1fr;}@media (max-width:767px){gap:var(--space-lg);}`;
|
||||||
|
const ChartCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);min-width:0;overflow:hidden;`;
|
||||||
const ProjectList = styled.div`display:flex;flex-direction:column;`;
|
const ProjectList = styled.div`display:flex;flex-direction:column;`;
|
||||||
const ProjectRow = styled(Link)`display:grid;grid-template-columns:40px 1fr auto;gap:var(--space-md);padding:var(--space-md) 0;border-bottom:1px solid #222;align-items:center;text-decoration:none;transition:opacity .15s;&:last-child{border-bottom:none}&:hover{opacity:.8}`;
|
const ProjectRow = styled(Link)`display:grid;grid-template-columns:40px 1fr auto;gap:var(--space-md);padding:var(--space-md) 0;border-bottom:1px solid #222;align-items:center;text-decoration:none;transition:opacity .15s;&:last-child{border-bottom:none}&:hover{opacity:.8}`;
|
||||||
const Avatar = styled.div`width:32px;height:32px;border-radius:50%;background-color:#333;overflow:hidden;filter:grayscale(100%) contrast(120%);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:14px;`;
|
const ProjectMark = styled.div`width:32px;height:32px;border-radius:50%;border:1px solid var(--border-color);display:flex;align-items:center;justify-content:center;background:#111;color:var(--text-secondary);font-family:var(--font-mono);font-size:12px;flex-shrink:0;`;
|
||||||
const ProjectDetails = styled.div`display:flex;flex-direction:column;gap:2px;min-width:0;`;
|
const ProjectDetails = styled.div`display:flex;flex-direction:column;gap:2px;min-width:0;`;
|
||||||
const ProjectName = styled.div`font-size:15px;font-weight:500;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
const ProjectName = styled.div`font-size:15px;font-weight:500;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
||||||
const ProjectDesc = styled.div`font-size:13px;color:var(--text-secondary);line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
const ProjectDesc = styled.div`font-size:13px;color:var(--text-secondary);line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
||||||
@@ -25,7 +26,10 @@ const WsIndicator = styled.span<{ $on:boolean }>`font-size:11px;font-family:var(
|
|||||||
const SkeletonCard = styled.div`height:142px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`;
|
const SkeletonCard = styled.div`height:142px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`;
|
||||||
const SkeletonLine = styled.div`height:14px;background:#111;animation:${pulse} 1.4s ease-in-out infinite;margin:10px 0;`;
|
const SkeletonLine = styled.div`height:14px;background:#111;animation:${pulse} 1.4s ease-in-out infinite;margin:10px 0;`;
|
||||||
const ErrorBox = styled.div`padding:var(--space-md);border:1px solid #5a2a2a;color:#ff9b9b;font-size:13px;margin-bottom:var(--space-lg);`;
|
const ErrorBox = styled.div`padding:var(--space-md);border:1px solid #5a2a2a;color:#ff9b9b;font-size:13px;margin-bottom:var(--space-lg);`;
|
||||||
|
const BarChart = styled.div`display:flex;align-items:flex-end;gap:10px;height:160px;margin-top:var(--space-lg);min-width:0;overflow-x:auto;overflow-y:hidden;padding-bottom:var(--space-xs);-webkit-overflow-scrolling:touch;scrollbar-width:thin;@media (max-width:767px){gap:8px;height:140px;scrollbar-width:none;&::-webkit-scrollbar{display:none;}}`;
|
||||||
|
const BarCol = styled.div`display:flex;flex-direction:column;align-items:center;gap:8px;flex:0 0 52px;min-width:52px;@media (max-width:767px){flex-basis:48px;min-width:48px;}`;
|
||||||
|
const Bar = styled.div<{ $height:number }>`width:100%;max-width:48px;height:${({$height})=>$height}%;background:var(--text-primary);min-height:${({$height})=>$height > 0 ? '6px' : '0'};`;
|
||||||
|
const BarLabel = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
||||||
const SISTER_SHORT: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
|
const SISTER_SHORT: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
|
||||||
function sisterBracketValue(s: any): string { if (s.status === 'online') return '[ON]'; if (s.status === 'working') return '[RUN]'; if (s.status === 'offline') return '[--]'; return '[??]'; }
|
function sisterBracketValue(s: any): string { if (s.status === 'online') return '[ON]'; if (s.status === 'working') return '[RUN]'; if (s.status === 'offline') return '[--]'; return '[??]'; }
|
||||||
function sisterBarWidth(s: any): number { if (typeof s.cpu === 'number') return Math.max(0, Math.min(100, s.cpu)); if (s.status === 'online') return 100; if (s.status === 'working') return 84; if (s.status === 'offline') return 0; return 12; }
|
function sisterBarWidth(s: any): number { if (typeof s.cpu === 'number') return Math.max(0, Math.min(100, s.cpu)); if (s.status === 'online') return 100; if (s.status === 'working') return 84; if (s.status === 'offline') return 0; return 12; }
|
||||||
@@ -75,21 +79,30 @@ export default function DashboardPage() {
|
|||||||
<StatusGrid>
|
<StatusGrid>
|
||||||
{loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />) : sisters.map((s) => (
|
{loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />) : sisters.map((s) => (
|
||||||
<Card key={s.id ?? s.name} as={Link} href={`/sisters/${s.name}`} style={{ textDecoration: 'none' }}>
|
<Card key={s.id ?? s.name} as={Link} href={`/sisters/${s.name}`} style={{ textDecoration: 'none' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta><LabelMeta>{String(Math.round(Number(s.cpu ?? sisterBarWidth(s)))).padStart(2, '0')}%</LabelMeta></div>
|
||||||
<LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta>
|
|
||||||
<SisterAvatar name={s.name} size={24} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}><BracketValue $dimmed={s.status === 'offline'}>{sisterBracketValue(s)}</BracketValue><LabelMeta>{sisterSubLabel(s)}</LabelMeta></div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}><BracketValue $dimmed={s.status === 'offline'}>{sisterBracketValue(s)}</BracketValue><LabelMeta>{sisterSubLabel(s)}</LabelMeta></div>
|
||||||
<TechBar><TechBarFill $width={sisterBarWidth(s)} /></TechBar>
|
<TechBar><TechBarFill $width={sisterBarWidth(s)} /></TechBar>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</StatusGrid>
|
</StatusGrid>
|
||||||
|
|
||||||
|
<ChartGrid>
|
||||||
|
<ChartCard>
|
||||||
|
<SectionTitle><span>SISTER LOAD</span><LabelMeta>CPU %</LabelMeta></SectionTitle>
|
||||||
|
<BarChart>{(sisters.length > 0 ? sisters : [{ name: 'harang', cpu: 0 }, { name: 'narang', cpu: 0 }, { name: 'darang', cpu: 0 }, { name: 'erang', cpu: 0 }]).map((s) => <BarCol key={s.name}><Bar $height={Math.max(4, Number(s.cpu ?? 0))} /><BarLabel>{SISTER_SHORT[s.name] ?? s.name}</BarLabel></BarCol>)}</BarChart>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard>
|
||||||
|
<SectionTitle><span>PROJECT PROGRESS</span><LabelMeta>TOP {projects.length}</LabelMeta></SectionTitle>
|
||||||
|
<BarChart>{(projects.length > 0 ? projects.slice(0, 6) : [{ id: 'empty-1', name: 'EMPTY', progress: 0 }]).map((p) => <BarCol key={p.id}><Bar $height={Math.max(4, Number(p.progress ?? 0))} /><BarLabel>{p.name.slice(0, 6)}</BarLabel></BarCol>)}</BarChart>
|
||||||
|
</ChartCard>
|
||||||
|
</ChartGrid>
|
||||||
|
|
||||||
<DataColumns>
|
<DataColumns>
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle><span>ONGOING PROJECTS</span><LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta></SectionTitle>
|
<SectionTitle><span>ONGOING PROJECTS</span><LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta></SectionTitle>
|
||||||
<ProjectList>
|
<ProjectList>
|
||||||
{loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonLine key={i} />) : projects.length === 0 ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}>등록된 프로젝트 없음</div> : projects.map((p) => (
|
{loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonLine key={i} />) : projects.length === 0 ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}>등록된 프로젝트 없음</div> : projects.map((p) => (
|
||||||
<ProjectRow key={p.id} href={`/projects/${p.id}`}><Avatar>📁</Avatar><ProjectDetails><ProjectName>{p.name}</ProjectName><ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc></ProjectDetails><LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta></ProjectRow>
|
<ProjectRow key={p.id} href={`/projects/${p.id}`}><ProjectMark>{String(p.name ?? 'P').slice(0, 1).toUpperCase()}</ProjectMark><ProjectDetails><ProjectName>{p.name}</ProjectName><ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc></ProjectDetails><LabelMeta>{p.progress ?? 0}%</LabelMeta></ProjectRow>
|
||||||
))}
|
))}
|
||||||
</ProjectList>
|
</ProjectList>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import styled from 'styled-components';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill } from '@/components/ui/base';
|
import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill } from '@/components/ui/base';
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
|
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||||
|
|
||||||
// ─── Styled ───
|
// ─── Styled ───
|
||||||
const Breadcrumb = styled.div`
|
const Breadcrumb = styled.div`
|
||||||
@@ -262,6 +263,33 @@ const NodeMiniCard = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeInfo = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
min-width: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeLabel = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeName = styled.div`
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeRole = styled.div`
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NodeDot = styled.div`
|
const NodeDot = styled.div`
|
||||||
@@ -309,6 +337,13 @@ function formatDate(str: string): string {
|
|||||||
return new Date(str).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
return new Date(str).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NODE_META = {
|
||||||
|
harang: { label: '하랑', role: 'PRIMARY' },
|
||||||
|
narang: { label: '나랑', role: 'GEN' },
|
||||||
|
darang: { label: '다랑', role: 'EVAL' },
|
||||||
|
erang: { label: '이랑', role: 'INFRA' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'overview', label: 'Overview' },
|
{ id: 'overview', label: 'Overview' },
|
||||||
{ id: 'commits', label: 'Commits' },
|
{ id: 'commits', label: 'Commits' },
|
||||||
@@ -320,6 +355,7 @@ export default function ProjectDetailPage() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [project, setProject] = useState<any>(null);
|
const [project, setProject] = useState<any>(null);
|
||||||
const [tasks, setTasks] = useState<any[]>([]);
|
const [tasks, setTasks] = useState<any[]>([]);
|
||||||
|
const [history, setHistory] = useState<any[]>([]);
|
||||||
const [commits, setCommits] = useState<any[]>([]);
|
const [commits, setCommits] = useState<any[]>([]);
|
||||||
const [branches, setBranches] = useState<any[]>([]);
|
const [branches, setBranches] = useState<any[]>([]);
|
||||||
const [pulls, setPulls] = useState<any[]>([]);
|
const [pulls, setPulls] = useState<any[]>([]);
|
||||||
@@ -332,9 +368,11 @@ export default function ProjectDetailPage() {
|
|||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
fetch(`${API_URL}/api/projects/${id}`),
|
fetch(`${API_URL}/api/projects/${id}`),
|
||||||
fetch(`${API_URL}/api/projects/${id}/tasks`),
|
fetch(`${API_URL}/api/projects/${id}/tasks`),
|
||||||
]).then(([pRes, tRes]) => {
|
fetch(`${API_URL}/api/projects/${id}/history`),
|
||||||
|
]).then(([pRes, tRes, hRes]) => {
|
||||||
if (pRes.status === 'fulfilled' && pRes.value.ok) pRes.value.json().then(setProject);
|
if (pRes.status === 'fulfilled' && pRes.value.ok) pRes.value.json().then(setProject);
|
||||||
if (tRes.status === 'fulfilled' && tRes.value.ok) tRes.value.json().then(setTasks);
|
if (tRes.status === 'fulfilled' && tRes.value.ok) tRes.value.json().then(setTasks);
|
||||||
|
if (hRes.status === 'fulfilled' && hRes.value.ok) hRes.value.json().then(setHistory);
|
||||||
}).finally(() => setLoading(false));
|
}).finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
@@ -373,8 +411,14 @@ export default function ProjectDetailPage() {
|
|||||||
}, [prFilter]);
|
}, [prFilter]);
|
||||||
|
|
||||||
const allTasks = tasks.flatMap((s: any) => s.tasks ?? []);
|
const allTasks = tasks.flatMap((s: any) => s.tasks ?? []);
|
||||||
const doneTasks = allTasks.filter((t: any) => t.status === 'done');
|
|
||||||
const activeSprint = tasks.find((s: any) => s.status === 'in_progress');
|
const activeSprint = tasks.find((s: any) => s.status === 'in_progress');
|
||||||
|
const assignedNodeNames = Array.from(new Set(allTasks.map((task: any) => task.assignee).filter(Boolean)));
|
||||||
|
const assignedNodes = (assignedNodeNames.length > 0 ? assignedNodeNames : ['harang', 'narang', 'darang', 'erang']).map((name) => ({
|
||||||
|
name,
|
||||||
|
label: NODE_META[name as keyof typeof NODE_META]?.label ?? name,
|
||||||
|
role: NODE_META[name as keyof typeof NODE_META]?.role ?? 'NODE',
|
||||||
|
}));
|
||||||
|
const hotfixEntries = history.filter((entry: any) => entry.kind === 'hotfix');
|
||||||
|
|
||||||
if (loading) return <Empty>LOADING...</Empty>;
|
if (loading) return <Empty>LOADING...</Empty>;
|
||||||
if (!project) return <Empty>PROJECT NOT FOUND</Empty>;
|
if (!project) return <Empty>PROJECT NOT FOUND</Empty>;
|
||||||
@@ -429,16 +473,38 @@ export default function ProjectDetailPage() {
|
|||||||
</PhaseItem>
|
</PhaseItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{tasks.length === 0 && <PhaseItem><PhaseMeta>PENDING</PhaseMeta><PhaseBox><PhaseName>Sprint 없음</PhaseName></PhaseBox></PhaseItem>}
|
{tasks.length === 0 && history.filter((entry: any) => entry.kind !== 'hotfix').length === 0 && <PhaseItem><PhaseMeta>PENDING</PhaseMeta><PhaseBox><PhaseName>Sprint 없음</PhaseName></PhaseBox></PhaseItem>}
|
||||||
</PhaseTimeline>
|
</PhaseTimeline>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
{hotfixEntries.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>HOTFIX HISTORY</SectionTitle>
|
||||||
|
<PhaseTimeline style={{ marginBottom: 'var(--space-xl)' }}>
|
||||||
|
{hotfixEntries.map((entry: any) => (
|
||||||
|
<PhaseItem key={entry.label}>
|
||||||
|
<PhaseMeta>HOTFIX</PhaseMeta>
|
||||||
|
<PhaseBox $active={false}>
|
||||||
|
<PhaseName>{entry.label}</PhaseName>
|
||||||
|
<PhaseDesc>{entry.summary ?? entry.description ?? '보정/수정 이력'}</PhaseDesc>
|
||||||
|
</PhaseBox>
|
||||||
|
</PhaseItem>
|
||||||
|
))}
|
||||||
|
</PhaseTimeline>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle>
|
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle>
|
||||||
<NodeStack>
|
<NodeStack>
|
||||||
{['harang', 'narang', 'darang', 'erang'].map((name) => (
|
{assignedNodes.map((node) => (
|
||||||
<NodeMiniCard key={name}>
|
<NodeMiniCard key={node.name}>
|
||||||
<LabelMeta>{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'} [{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'GEN' : name === 'darang' ? 'EVAL' : 'INFRA'}]</LabelMeta>
|
<NodeInfo>
|
||||||
|
<SisterAvatar name={node.name} size={28} />
|
||||||
|
<NodeLabel>
|
||||||
|
<NodeName>{node.label}</NodeName>
|
||||||
|
<NodeRole>[{node.role}]</NodeRole>
|
||||||
|
</NodeLabel>
|
||||||
|
</NodeInfo>
|
||||||
<NodeDot />
|
<NodeDot />
|
||||||
</NodeMiniCard>
|
</NodeMiniCard>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,85 +3,22 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn, BtnPrimary } from '@/components/ui/base';
|
import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn } from '@/components/ui/base';
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
import { adminFetch } from '@/lib/adminFetch';
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
|
|
||||||
const ProjectList = styled.div`
|
const ProjectList = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`;
|
||||||
display: flex;
|
const ProjectRow = styled(Link)`display:grid;grid-template-columns:1.4fr 1fr 180px;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;}`;
|
||||||
flex-direction: column;
|
const Left = styled.div`display:flex;align-items:flex-start;gap:var(--space-md);min-width:0;`;
|
||||||
`;
|
const ProjectMark = styled.div`width:40px;height:40px;border-radius:10px;border:1px solid var(--border-color);display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-size:14px;color:var(--text-secondary);background:#111;flex-shrink:0;`;
|
||||||
|
const NameBlock = styled.div`display:flex;flex-direction:column;gap:4px;min-width:0;`;
|
||||||
const ProjectRow = styled(Link)`
|
const ProjectName = styled.div`font-size:15px;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
||||||
display: grid;
|
const ProjectDesc = styled.div`font-size:12px;color:var(--text-secondary);line-height:1.5;`;
|
||||||
grid-template-columns: 40px 1fr auto;
|
const Middle = styled.div`display:flex;flex-direction:column;gap:8px;`;
|
||||||
gap: var(--space-md);
|
const ProgressRow = styled.div`display:flex;align-items:center;gap:var(--space-sm);`;
|
||||||
padding: var(--space-md) 0;
|
const Right = styled.div`display:flex;flex-direction:column;align-items:flex-end;gap:8px;@media (max-width:1199px){align-items:flex-start;}`;
|
||||||
border-bottom: 1px solid #222;
|
const Meta = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`;
|
||||||
align-items: center;
|
const EmptyState = styled.div`padding:var(--space-xl) 0;color:var(--text-secondary);font-size:13px;font-family:var(--font-mono);`;
|
||||||
text-decoration: none;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
|
|
||||||
&:last-child { border-bottom: none; }
|
|
||||||
&:hover { opacity: 0.75; }
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
grid-template-columns: 32px 1fr auto;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Avatar = styled.div`
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #333;
|
|
||||||
filter: grayscale(100%) contrast(120%);
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 13px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectDetails = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectName = styled.div`
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectMeta = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectDesc = styled.div`
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 400px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const EmptyState = styled.div`
|
|
||||||
padding: var(--space-xl) 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const [projects, setProjects] = useState<any[]>([]);
|
const [projects, setProjects] = useState<any[]>([]);
|
||||||
@@ -91,41 +28,27 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
const loadProjects = () => {
|
const loadProjects = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`${API_URL}/api/projects`)
|
fetch(`${API_URL}/api/projects`).then((r) => r.json()).then(setProjects).catch(() => {}).finally(() => setLoading(false));
|
||||||
.then((r) => r.json())
|
|
||||||
.then(setProjects)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
setSyncing(true);
|
setSyncing(true); setSyncResult(null);
|
||||||
setSyncResult(null);
|
|
||||||
try {
|
try {
|
||||||
const res = await adminFetch('/api/admin/gitea/sync', { method: 'POST' });
|
const res = await adminFetch('/api/admin/gitea/sync', { method: 'POST' });
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
setSyncResult(`sync: ${d.created ?? 0} new, ${d.updated ?? 0} updated`);
|
setSyncResult(`sync: ${d.created ?? 0} new, ${d.updated ?? 0} updated`);
|
||||||
loadProjects();
|
loadProjects();
|
||||||
} catch (e) {
|
} catch { setSyncResult('sync failed'); } finally { setSyncing(false); }
|
||||||
setSyncResult('sync failed');
|
|
||||||
} finally {
|
|
||||||
setSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSprintSync = async () => {
|
const handleSprintSync = async () => {
|
||||||
setSyncing(true);
|
setSyncing(true); setSyncResult(null);
|
||||||
setSyncResult(null);
|
|
||||||
try {
|
try {
|
||||||
const res = await adminFetch('/api/projects/sync-all-sprints', { method: 'POST' });
|
const res = await adminFetch('/api/projects/sync-all-sprints', { method: 'POST' });
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
setSyncResult(`sprint sync: ${d.synced ?? 0} synced across ${d.projects ?? 0} projects`);
|
setSyncResult(`sprint sync: ${d.synced ?? 0} synced across ${d.projects ?? 0} projects`);
|
||||||
loadProjects();
|
loadProjects();
|
||||||
} catch (e) {
|
} catch { setSyncResult('sprint sync failed'); } finally { setSyncing(false); }
|
||||||
setSyncResult('sprint sync failed');
|
|
||||||
} finally {
|
|
||||||
setSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { loadProjects(); }, []);
|
useEffect(() => { loadProjects(); }, []);
|
||||||
@@ -135,41 +58,35 @@ export default function ProjectsPage() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}>
|
||||||
<PageTitle>프로젝트</PageTitle>
|
<PageTitle>프로젝트</PageTitle>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
|
||||||
{syncResult && (
|
{syncResult && <span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)' }}>{syncResult}</span>}
|
||||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)' }}>
|
|
||||||
{syncResult}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
|
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
|
||||||
<Btn onClick={handleSync} disabled={syncing}>
|
<Btn onClick={handleSync} disabled={syncing}>{syncing ? 'SYNCING...' : '↻ GITEA SYNC'}</Btn>
|
||||||
{syncing ? 'SYNCING...' : '↻ GITEA SYNC'}
|
<Btn onClick={handleSprintSync} disabled={syncing}>{syncing ? '...' : '↻ SPRINT SYNC'}</Btn>
|
||||||
</Btn>
|
|
||||||
<Btn onClick={handleSprintSync} disabled={syncing}>
|
|
||||||
{syncing ? '...' : '↻ SPRINT SYNC'}
|
|
||||||
</Btn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? <EmptyState>LOADING...</EmptyState> : projects.length === 0 ? <EmptyState>NO PROJECTS REGISTERED</EmptyState> : (
|
||||||
<EmptyState>LOADING...</EmptyState>
|
|
||||||
) : projects.length === 0 ? (
|
|
||||||
<EmptyState>NO PROJECTS REGISTERED</EmptyState>
|
|
||||||
) : (
|
|
||||||
<ProjectList>
|
<ProjectList>
|
||||||
{projects.map((p) => (
|
{projects.map((p) => (
|
||||||
<ProjectRow key={p.id} href={`/projects/${p.id}`}>
|
<ProjectRow key={p.id} href={`/projects/${p.id}`}>
|
||||||
<Avatar>📁</Avatar>
|
<Left>
|
||||||
<ProjectDetails>
|
<ProjectMark>{String(p.name ?? 'P').slice(0, 1).toUpperCase()}</ProjectMark>
|
||||||
<ProjectName>{p.name}</ProjectName>
|
<NameBlock>
|
||||||
<ProjectMeta>
|
<ProjectName>{p.name}</ProjectName>
|
||||||
<ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc>
|
<ProjectDesc>{p.description ?? '설명 없음'}</ProjectDesc>
|
||||||
<TechBar style={{ width: '80px', flexShrink: 0 }}>
|
</NameBlock>
|
||||||
<TechBarFill $width={p.progress ?? 0} />
|
</Left>
|
||||||
</TechBar>
|
<Middle>
|
||||||
<LabelMeta style={{ flexShrink: 0, fontSize: '10px' }}>{p.progress ?? 0}%</LabelMeta>
|
<ProgressRow>
|
||||||
</ProjectMeta>
|
<TechBar style={{ flex: 1 }}><TechBarFill $width={p.progress ?? 0} /></TechBar>
|
||||||
</ProjectDetails>
|
<LabelMeta style={{ minWidth: 44, textAlign: 'right' }}>{p.progress ?? 0}%</LabelMeta>
|
||||||
<LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta>
|
</ProgressRow>
|
||||||
|
<Meta>{p.doneSprints ?? 0} / {p.totalSprints ?? 0} SPRINTS DONE</Meta>
|
||||||
|
</Middle>
|
||||||
|
<Right>
|
||||||
|
<LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta>
|
||||||
|
<Meta>{new Date(p.updatedAt).toLocaleDateString('ko-KR')} UPDATED</Meta>
|
||||||
|
</Right>
|
||||||
</ProjectRow>
|
</ProjectRow>
|
||||||
))}
|
))}
|
||||||
</ProjectList>
|
</ProjectList>
|
||||||
|
|||||||
@@ -1,160 +1,41 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base';
|
import { LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent, Btn } from '@/components/ui/base';
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||||
import { SISTER_ROLES } from '@/lib/sisters';
|
import { SISTER_ROLES } from '@/lib/sisters';
|
||||||
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
|
import CodeEditor from '@/components/admin/CodeEditor';
|
||||||
|
import LogTerminal from '@/components/admin/LogTerminal';
|
||||||
|
|
||||||
const Breadcrumb = styled.div`
|
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); } }`;
|
||||||
font-family: var(--font-mono);
|
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;}`;
|
||||||
font-size: 11px;
|
const HeroMeta = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`;
|
||||||
color: var(--text-secondary);
|
const HeroName = styled.div`font-size:36px;font-weight:700;letter-spacing:-0.03em;color:var(--text-primary);`;
|
||||||
margin-bottom: var(--space-md);
|
const HeroRole = styled.div`font-size:14px;color:var(--text-secondary);font-family:var(--font-mono);`;
|
||||||
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
|
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 Hero = styled.section`
|
const StatCard = styled.div`border:1px solid var(--border-color);padding:var(--space-md);`;
|
||||||
display: grid;
|
const StatLabel = styled.div`font-size:10px;color:var(--text-secondary);font-family:var(--font-mono);margin-bottom:6px;`;
|
||||||
grid-template-columns: 128px 1fr;
|
const StatValue = styled.div`font-size:18px;color:var(--text-primary);font-family:var(--font-mono);`;
|
||||||
gap: var(--space-xl);
|
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);}`;
|
||||||
align-items: center;
|
const MetaPanel = styled.div`display:flex;flex-direction:column;gap:var(--space-lg);`;
|
||||||
padding: var(--space-xl);
|
const MetaCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);`;
|
||||||
border: 1px solid var(--border-color);
|
const ConfigPre = styled.pre`background:#0d0d0d;border:1px solid var(--border-color);padding:var(--space-md);font-family:var(--font-mono);font-size:11px;color:#8888aa;overflow:auto;max-height:400px;white-space:pre-wrap;word-break:break-word;line-height:1.6;`;
|
||||||
margin-bottom: var(--space-xl);
|
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);}`;
|
||||||
@media (max-width: 767px) {
|
const Toolbar = styled.div`display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;`;
|
||||||
grid-template-columns: 1fr;
|
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;}`;
|
||||||
justify-items: start;
|
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 HeroMeta = styled.div`
|
const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
|
||||||
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);
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 ConfigPre = styled.pre`
|
|
||||||
background: #0d0d0d;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: var(--space-md);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: #8888aa;
|
|
||||||
overflow: auto;
|
|
||||||
max-height: 400px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
line-height: 1.6;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TabBar = styled.div`
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
`;
|
|
||||||
|
|
||||||
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;
|
|
||||||
&:hover { color: var(--text-primary); }
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SISTER_DISPLAY: Record<string, string> = {
|
|
||||||
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SisterDetailPage() {
|
export default function SisterDetailPage() {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
@@ -162,8 +43,16 @@ export default function SisterDetailPage() {
|
|||||||
const [systemInfo, setSystemInfo] = useState<any>(null);
|
const [systemInfo, setSystemInfo] = useState<any>(null);
|
||||||
const [configData, setConfigData] = useState<any>(null);
|
const [configData, setConfigData] = useState<any>(null);
|
||||||
const [activity, setActivity] = useState<any[]>([]);
|
const [activity, setActivity] = useState<any[]>([]);
|
||||||
|
const [sessions, setSessions] = useState<any[]>([]);
|
||||||
const [tab, setTab] = useState('overview');
|
const [tab, setTab] = useState('overview');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [logLines, setLogLines] = useState<string[]>([]);
|
||||||
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
|
const [lines, setLines] = useState(100);
|
||||||
|
const [selectedFile, setSelectedFile] = useState('SOUL.md');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [saved, setSaved] = useState('');
|
||||||
|
const [saveResult, setSaveResult] = useState<{ success: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
@@ -171,118 +60,104 @@ export default function SisterDetailPage() {
|
|||||||
fetch(`${API_URL}/api/sisters/${name}/config`),
|
fetch(`${API_URL}/api/sisters/${name}/config`),
|
||||||
fetch(`${API_URL}/api/sisters/${name}/activity`),
|
fetch(`${API_URL}/api/sisters/${name}/activity`),
|
||||||
fetch(`${API_URL}/api/sisters/${name}/system`),
|
fetch(`${API_URL}/api/sisters/${name}/system`),
|
||||||
]).then(([sRes, cRes, aRes, syRes]) => {
|
fetch(`${API_URL}/api/sisters/${name}/sessions`),
|
||||||
if (sRes.status === 'fulfilled' && sRes.value.ok) {
|
]).then(([sRes, cRes, aRes, syRes, ssRes]) => {
|
||||||
sRes.value.json().then((d: any[]) => setSisterInfo(d.find((s) => s.name === name)));
|
if (sRes.status === 'fulfilled' && sRes.value.ok) sRes.value.json().then((d: any[]) => setSisterInfo(d.find((s) => s.name === name)));
|
||||||
}
|
|
||||||
if (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then(setConfigData);
|
if (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then(setConfigData);
|
||||||
if (aRes.status === 'fulfilled' && aRes.value.ok) {
|
if (aRes.status === 'fulfilled' && aRes.value.ok) aRes.value.json().then((d: any) => setActivity(d.items ?? []));
|
||||||
aRes.value.json().then((d: any) => setActivity(d.items ?? []));
|
|
||||||
}
|
|
||||||
if (syRes.status === 'fulfilled' && syRes.value.ok) syRes.value.json().then(setSystemInfo);
|
if (syRes.status === 'fulfilled' && syRes.value.ok) syRes.value.json().then(setSystemInfo);
|
||||||
|
if (ssRes.status === 'fulfilled' && ssRes.value.ok) ssRes.value.json().then((d: any) => setSessions(d.sessions ?? d.items ?? []));
|
||||||
}).finally(() => setLoading(false));
|
}).finally(() => setLoading(false));
|
||||||
}, [name]);
|
}, [name]);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLogLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(`/api/admin/logs/${name}?lines=${lines}`);
|
||||||
|
const d = await res.json();
|
||||||
|
setLogLines(d.lines ?? []);
|
||||||
|
} catch {
|
||||||
|
setLogLines(['(로그 로드 실패)']);
|
||||||
|
} finally {
|
||||||
|
setLogLoading(false);
|
||||||
|
}
|
||||||
|
}, [name, lines]);
|
||||||
|
|
||||||
|
useEffect(() => { if (tab === 'logs') fetchLogs(); }, [tab, fetchLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== 'harness') return;
|
||||||
|
setSaveResult(null);
|
||||||
|
adminFetch(`/api/admin/harness/${name}/${selectedFile}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => { 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 = await res.json();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isActive = sisterInfo?.status === 'online' || sisterInfo?.status === 'working';
|
const isActive = sisterInfo?.status === 'online' || sisterInfo?.status === 'working';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumb>
|
<Breadcrumb><Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}</Breadcrumb>
|
||||||
<Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}
|
{loading ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div> : <>
|
||||||
</Breadcrumb>
|
<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>
|
||||||
|
|
||||||
{loading ? (
|
<DetailGrid>
|
||||||
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div>
|
<MetaPanel>
|
||||||
) : (
|
<MetaCard>
|
||||||
<>
|
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta>
|
||||||
<Hero>
|
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||||
<SisterAvatar name={name} size={120} />
|
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
|
||||||
<HeroMeta>
|
ROLE: {SISTER_ROLES[name] ?? '--'}<br />
|
||||||
<HeroName>{SISTER_DISPLAY[name] ?? name}</HeroName>
|
STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()}<br />
|
||||||
<HeroRole>{SISTER_ROLES[name] ?? 'UNKNOWN'}</HeroRole>
|
HOST_USER: {sisterInfo?.user ?? '--'}
|
||||||
<HeroStatus>
|
</div>
|
||||||
<StatusDot $on={isActive} />
|
<TechBar><TechBarFill $width={isActive ? 100 : 0} /></TechBar>
|
||||||
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
|
</MetaCard>
|
||||||
<LabelMeta><span>LXC:</span>{sisterInfo?.lxcId ?? '--'}</LabelMeta>
|
{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>}
|
||||||
</HeroStatus>
|
</MetaPanel>
|
||||||
<HeroStats>
|
<div>
|
||||||
<StatCard><StatLabel>UPTIME</StatLabel><StatValue>{systemInfo?.uptime ?? '--:--:--'}</StatValue></StatCard>
|
<TabBar>
|
||||||
<StatCard><StatLabel>CPU</StatLabel><StatValue>{systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}%</StatValue></StatCard>
|
{[{ 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>)}
|
||||||
<StatCard><StatLabel>MEM</StatLabel><StatValue>{systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB</StatValue></StatCard>
|
</TabBar>
|
||||||
<StatCard><StatLabel>DISK</StatLabel><StatValue>{systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'}</StatValue></StatCard>
|
|
||||||
</HeroStats>
|
|
||||||
</HeroMeta>
|
|
||||||
</Hero>
|
|
||||||
|
|
||||||
<DetailGrid>
|
{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>}
|
||||||
<MetaPanel>
|
{tab === 'sessions' && <>{sessions.length === 0 ? <div style={{ color: 'var(--text-secondary)' }}>세션 없음</div> : sessions.map((s: any, i: number) => <SessionCard key={i}><div style={{ color: 'var(--text-primary)', fontSize: '13px' }}>{s.title ?? s.label ?? s.id ?? 'session'}</div><div style={{ color: 'var(--text-secondary)', fontSize: '11px', fontFamily: 'var(--font-mono)' }}>{s.status ?? ''}</div></SessionCard>)}</>}
|
||||||
<MetaCard>
|
{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>}
|
||||||
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta>
|
{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} /></>}
|
||||||
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
{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} /></>}
|
||||||
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
|
</div>
|
||||||
ROLE: {SISTER_ROLES[name] ?? '--'}<br />
|
</DetailGrid>
|
||||||
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: 'config', label: '설정' },
|
|
||||||
].map((t) => (
|
|
||||||
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
|
|
||||||
{t.label}
|
|
||||||
</TabBtn>
|
|
||||||
))}
|
|
||||||
</TabBar>
|
|
||||||
|
|
||||||
{tab === 'overview' && (
|
|
||||||
<>
|
|
||||||
<SectionTitle>
|
|
||||||
<span>RECENT ACTIVITY</span>
|
|
||||||
<LabelMeta>{activity.length} ENTRIES</LabelMeta>
|
|
||||||
</SectionTitle>
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
{activity.length === 0 && (
|
|
||||||
<TimelineItem>
|
|
||||||
<TimelineContent style={{ color: 'var(--text-secondary)' }}>활동 기록 없음</TimelineContent>
|
|
||||||
</TimelineItem>
|
|
||||||
)}
|
|
||||||
</Timeline>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'config' && (
|
|
||||||
<>
|
|
||||||
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle>
|
|
||||||
<ConfigPre>{configData?.raw || '(설정 파일 없음)'}</ConfigPre>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DetailGrid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import SisterAvatar from '@/components/common/SisterAvatar';
|
|||||||
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
||||||
const SISTER_ROLES: Record<string, string> = { harang: 'Primary', narang: 'Secondary', darang: 'Standby', erang: 'Sync' };
|
const SISTER_ROLES: Record<string, string> = { harang: 'Primary', narang: 'Secondary', darang: 'Standby', erang: 'Sync' };
|
||||||
const NodeGrid = styled.section`display:flex;flex-direction:column;gap:var(--space-lg);`;
|
const NodeGrid = styled.section`display:flex;flex-direction:column;gap:var(--space-lg);`;
|
||||||
const NodeEntry = styled.div`display:grid;grid-template-columns:220px 1fr 160px;gap:0;border:1px solid var(--border-color);transition:border-color .2s;&:hover{border-color:var(--border-hover)}@media (max-width:1199px){grid-template-columns:200px 1fr}@media (max-width:767px){grid-template-columns:1fr}`;
|
const NodeEntry = styled(Link)`display:grid;grid-template-columns:220px 1fr 180px;gap:0;border:1px solid var(--border-color);transition:border-color .2s, background .15s;text-decoration:none;&:hover{border-color:var(--border-hover);background:#111}@media (max-width:1199px){grid-template-columns:200px 1fr}@media (max-width:767px){grid-template-columns:1fr}`;
|
||||||
const NodeInfo = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:767px){border-right:none;border-bottom:1px solid var(--border-color)}`;
|
const NodeInfo = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:767px){border-right:none;border-bottom:1px solid var(--border-color)}`;
|
||||||
const NodeName = styled(Link)`display:flex;align-items:center;gap:var(--space-sm);font-size:16px;font-weight:600;color:var(--text-primary);text-decoration:none;transition:color .15s;&:hover{color:var(--accent-hover)}`;
|
const NodeName = styled.div`display:flex;align-items:center;gap:var(--space-sm);font-size:16px;font-weight:600;color:var(--text-primary);`;
|
||||||
const StatusIndicator = styled.div<{ $active: boolean }>`width:8px;height:8px;border-radius:50%;background:${({$active})=>$active?'#FFF':'#555'};flex-shrink:0;${({$active})=>$active&&`box-shadow:0 0 8px rgba(255,255,255,.3);`}`;
|
const StatusIndicator = styled.div<{ $active: boolean }>`width:8px;height:8px;border-radius:50%;background:${({$active})=>$active?'#FFF':'#555'};flex-shrink:0;${({$active})=>$active&&`box-shadow:0 0 8px rgba(255,255,255,.3);`}`;
|
||||||
const MetaPair = styled.div`display:flex;flex-direction:column;gap:2px;`;
|
const MetaPair = styled.div`display:flex;flex-direction:column;gap:2px;`;
|
||||||
const MetaVal = styled.div`font-size:13px;color:var(--text-primary);font-family:var(--font-mono);`;
|
const MetaVal = styled.div`font-size:13px;color:var(--text-primary);font-family:var(--font-mono);`;
|
||||||
@@ -20,15 +20,30 @@ const NodeControls = styled.div`display:flex;gap:var(--space-sm);flex-wrap:wrap;
|
|||||||
const CapacitySection = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:1199px){border-right:none}@media (max-width:767px){border-bottom:1px solid var(--border-color)}`;
|
const CapacitySection = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:1199px){border-right:none}@media (max-width:767px){border-bottom:1px solid var(--border-color)}`;
|
||||||
const BarWrap = styled.div`display:flex;flex-direction:column;gap:8px;`;
|
const BarWrap = styled.div`display:flex;flex-direction:column;gap:8px;`;
|
||||||
const BarRow = styled.div`display:flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);`;
|
const BarRow = styled.div`display:flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);`;
|
||||||
const Bar = styled.div`flex:1;height:8px;background:#151515;border:1px solid #222;`;
|
const Bar = styled.div`flex:1;height:8px;background:#151515;border:1px solid #222;overflow:hidden;`;
|
||||||
const Fill = styled.div<{ $width:number }>`height:100%;width:${({$width})=>$width}%;background:var(--text-primary);`;
|
const Fill = styled.div<{ $width:number }>`height:100%;width:${({$width})=>$width}%;background:var(--text-primary);min-width:${({$width})=>$width > 0 ? '2px' : '0'};`;
|
||||||
const SyncHistory = styled.div`padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-sm);@media (max-width:1199px){display:none}`;
|
const OpsMeta = styled.div`padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-sm);@media (max-width:1199px){display:none}`;
|
||||||
const SyncRow = styled.div`display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);padding:2px 0;`;
|
const MetaRow = styled.div`display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);padding:2px 0;gap:var(--space-md);`;
|
||||||
const Skeleton = styled.div`height:120px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`;
|
const Skeleton = styled.div`height:120px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`;
|
||||||
const ErrorBox = styled.div`padding:var(--space-lg);border:1px solid #5a2a2a;color:#ff9b9b;`;
|
const ErrorBox = styled.div`padding:var(--space-lg);border:1px solid #5a2a2a;color:#ff9b9b;`;
|
||||||
|
|
||||||
function getNodeStatus(s: any): boolean { return s.status === 'online' || s.status === 'working'; }
|
function getNodeStatus(s: any): boolean { return s.status === 'online' || s.status === 'working'; }
|
||||||
function getStatusBtns(s: any): [string, string] { if (s.status === 'offline') return ['STANDBY', 'ACTIVATE']; return ['ACTIVE', 'REBOOT']; }
|
function getStatusBtns(s: any): [string, string] { if (s.status === 'offline') return ['STANDBY', 'ACTIVATE']; return ['ACTIVE', 'REBOOT']; }
|
||||||
|
function diskPercent(disk: any): number {
|
||||||
|
if (!disk?.used || !disk?.total) return 0;
|
||||||
|
const parse = (v: string) => {
|
||||||
|
const m = String(v).match(/([0-9.]+)\s*([KMGTP]?)/i);
|
||||||
|
if (!m) return 0;
|
||||||
|
const n = parseFloat(m[1]);
|
||||||
|
const unit = (m[2] || 'G').toUpperCase();
|
||||||
|
const scale: Record<string, number> = { K: 1/1024/1024, M: 1/1024, G: 1, T: 1024, P: 1024*1024, '': 1 };
|
||||||
|
return n * (scale[unit] ?? 1);
|
||||||
|
};
|
||||||
|
const used = parse(disk.used);
|
||||||
|
const total = parse(disk.total);
|
||||||
|
if (!isFinite(used) || !isFinite(total) || total <= 0) return 0;
|
||||||
|
return Math.max(0, Math.min(100, (used / total) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
export default function SistersPage() {
|
export default function SistersPage() {
|
||||||
const [sisters, setSisters] = useState<any[]>([]);
|
const [sisters, setSisters] = useState<any[]>([]);
|
||||||
@@ -72,9 +87,9 @@ export default function SistersPage() {
|
|||||||
const isActive = getNodeStatus(s);
|
const isActive = getNodeStatus(s);
|
||||||
const [btn1, btn2] = getStatusBtns(s);
|
const [btn1, btn2] = getStatusBtns(s);
|
||||||
return (
|
return (
|
||||||
<NodeEntry key={s.id ?? s.name}>
|
<NodeEntry key={s.id ?? s.name} href={`/sisters/${s.name}`}>
|
||||||
<NodeInfo>
|
<NodeInfo>
|
||||||
<NodeName href={`/sisters/${s.name}`}>
|
<NodeName>
|
||||||
<StatusIndicator $active={isActive} />
|
<StatusIndicator $active={isActive} />
|
||||||
<SisterAvatar name={s.name} size={28} />
|
<SisterAvatar name={s.name} size={28} />
|
||||||
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'} ({SISTER_ROLES[s.name] ?? s.name})
|
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'} ({SISTER_ROLES[s.name] ?? s.name})
|
||||||
@@ -86,16 +101,17 @@ export default function SistersPage() {
|
|||||||
<CapacitySection>
|
<CapacitySection>
|
||||||
<BarWrap>
|
<BarWrap>
|
||||||
<BarRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span><Bar><Fill $width={Math.max(0, Math.min(100, Number(s.cpu ?? 0)))} /></Bar></BarRow>
|
<BarRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span><Bar><Fill $width={Math.max(0, Math.min(100, Number(s.cpu ?? 0)))} /></Bar></BarRow>
|
||||||
<BarRow><span>MEM</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span><Bar><Fill $width={s.memory?.total ? (s.memory.used / s.memory.total) * 100 : 0} /></Bar></BarRow>
|
<BarRow><span>MEM</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span><Bar><Fill $width={s.memory?.total ? Math.max(0, Math.min(100, (s.memory.used / s.memory.total) * 100)) : 0} /></Bar></BarRow>
|
||||||
<BarRow><span>DSK</span><span>{s.disk?.used ?? '0G'}/{s.disk?.total ?? '0G'}</span><Bar><Fill $width={0} /></Bar></BarRow>
|
<BarRow><span>DSK</span><span>{s.disk?.used ?? '0G'}/{s.disk?.total ?? '0G'}</span><Bar><Fill $width={diskPercent(s.disk)} /></Bar></BarRow>
|
||||||
</BarWrap>
|
</BarWrap>
|
||||||
</CapacitySection>
|
</CapacitySection>
|
||||||
<SyncHistory>
|
<OpsMeta>
|
||||||
<SyncRow><span>STATUS</span><span>{String(s.status).toUpperCase()}</span></SyncRow>
|
<MetaRow><span>STATUS</span><span>{String(s.status).toUpperCase()}</span></MetaRow>
|
||||||
<SyncRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span></SyncRow>
|
<MetaRow><span>ROLE</span><span>{String(SISTER_ROLES[s.name] ?? s.name).toUpperCase()}</span></MetaRow>
|
||||||
<SyncRow><span>MEMORY</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span></SyncRow>
|
<MetaRow><span>LXC ID</span><span>{s.lxcId ?? '--'}</span></MetaRow>
|
||||||
<SyncRow><span>DISK</span><span>{s.disk?.used ?? '0G'} / {s.disk?.total ?? '0G'}</span></SyncRow>
|
<MetaRow><span>LAST CHECK</span><span>{s.lastSeen ? 'just synced' : '--'}</span></MetaRow>
|
||||||
</SyncHistory>
|
<MetaRow><span>CURRENT TASK</span><span>{s.currentTask ?? '-'}</span></MetaRow>
|
||||||
|
</OpsMeta>
|
||||||
</NodeEntry>
|
</NodeEntry>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -48,8 +48,17 @@ const GlobalStyle = createGlobalStyle`
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, textarea, select {
|
/* 기본적으로 관제 UI 텍스트 드래그 방지 */
|
||||||
|
body {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 편집/로그/입력/코드 영역은 선택 허용 */
|
||||||
|
input, textarea, select, pre, code, [contenteditable="true"], .cm-editor, .cm-content {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
|
|||||||
Reference in New Issue
Block a user