Compare commits
40 Commits
feature/re
...
hotfix/pro
| Author | SHA1 | Date | |
|---|---|---|---|
| 89a590839d | |||
| 16660e5611 | |||
| fcdac04481 | |||
| f7f0d050ec | |||
| 49bba64f1f | |||
| 3ce7169c21 | |||
| 1a5ad12e54 | |||
| b5d7746e36 | |||
| ff3a057b28 | |||
| b85ca55482 | |||
| 8a59b63d9f | |||
| 7aab46bae9 | |||
| b4855accee | |||
| 176cb51f8a | |||
| 784000ebba | |||
| 489f7cd649 | |||
| 39c1cdd6f1 | |||
| 416c27518c | |||
| 9135dc59f9 | |||
| 988c6905ea | |||
| ce4f39ced7 | |||
| b836e8470d | |||
| cc5c74d75a | |||
| bc83c12e53 | |||
| ff76887c50 | |||
| abef8576fe | |||
| e74432c8b3 | |||
| ded66c5cb6 | |||
| 194bc5a91c | |||
| 339678b962 | |||
| 035ba96381 | |||
| ce323fdfc1 | |||
| a942f4e62d | |||
|
|
e0fd04b4f1 | ||
| b387c638c9 | |||
| 2089283386 | |||
| 286f472afd | |||
| 9a86d04731 | |||
| b469a1337a | |||
|
|
0ce70ec615 |
31
.plans/sprints/HOTFIX-002.md
Normal file
31
.plans/sprints/HOTFIX-002.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# HOTFIX-002: Sprint 태스크 수/완료 수 파싱 보정
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
프로젝트 상세 화면에서 Sprint 상태는 맞게 보이지만 `태스크 0개 / 완료 0개`로 표시되는 문제 수정
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-044: Sprint task count 파싱 수정
|
||||||
|
- `.plans/sprints/SPRINT-XXX.md`에서 실제 태스크 개수를 파싱
|
||||||
|
- 지원 포맷:
|
||||||
|
- `TASK-001: 설명`
|
||||||
|
- `- TASK-001 설명`
|
||||||
|
- `- [ ] TASK-001 설명`
|
||||||
|
- `- [x] TASK-001 설명`
|
||||||
|
- 중복 카운트 방지
|
||||||
|
- heading/본문에 포함된 단순 문자열 오탐 방지
|
||||||
|
|
||||||
|
### TASK-045: 완료 task count 계산 보정
|
||||||
|
- 완료 태스크 수 계산 규칙 정의
|
||||||
|
- 우선순위:
|
||||||
|
1. 체크박스 `[x]` / `[X]`
|
||||||
|
2. 명시 status (`done`, `completed`, `passed` 등 정해진 패턴)
|
||||||
|
3. Sprint 자체가 done이면, 태스크 status 정보가 없을 경우 전체 완료 처리 여부 검토
|
||||||
|
- UI에 `총 태스크 수 / 완료 태스크 수` 정상 반영
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 프로젝트 상세 Sprint 섹션에서 `0개 / 0개` 고정 해소
|
||||||
|
- 실제 Sprint 문서 기준으로 task total 표시
|
||||||
|
- 완료 태스크 수가 포맷에 맞게 계산
|
||||||
|
- 빌드/테스트 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
34
.plans/sprints/HOTFIX-003.md
Normal file
34
.plans/sprints/HOTFIX-003.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# HOTFIX-003: 자매 상세 프로필 헤더/개요 레이아웃 개선 + Activity 로그 정리
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
자매 상세 페이지에서 프로필 사진이 너무 작고 개요 정보 우선순위가 어색한 문제, Activity 로그에 raw stderr가 그대로 노출되는 문제를 개선
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-046: 자매 상세 프로필 헤더 확대
|
||||||
|
- `/sisters/[name]` 상단 프로필 영역 재디자인
|
||||||
|
- 프로필 사진 크기 확대 (최소 96px~128px 권장)
|
||||||
|
- 이름 / 역할 / 상태 배지 / 핵심 메타를 프로필 헤더에 재배치
|
||||||
|
- “프로필 페이지” 느낌이 나도록 상단 hero 영역 구성
|
||||||
|
|
||||||
|
### TASK-047: 개요 레이아웃 우선순위 조정
|
||||||
|
- 현재 시스템 카드/활동 로그/개요의 시각적 비중 재조정
|
||||||
|
- 프로필 헤더 → 핵심 상태 요약 → 활동 로그 순으로 위계 정리
|
||||||
|
- 시스템 정보 카드(UPTIME, CPU, MEM, DISK)는 유지하되 보조 정보로 배치
|
||||||
|
- 모바일에서도 헤더/요약/로그 순서가 자연스럽게 보이도록 조정
|
||||||
|
|
||||||
|
### TASK-048: Activity 로그 사용자 친화화
|
||||||
|
- raw stderr / 내부 명령문 / `bash: ... command not found` 같은 내부 오류 문자열 직접 노출 금지
|
||||||
|
- ActivityLog 저장 시 또는 렌더 시 필터링/정규화:
|
||||||
|
- 내부 stderr는 숨기거나
|
||||||
|
- 사람이 읽을 수 있는 문장으로 변환
|
||||||
|
- 사용자용 feed에는 의미 있는 이벤트만 노출
|
||||||
|
- 필요하면 내부 디버그 로그와 사용자 표시 로그를 분리
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 자매 상세 페이지 프로필 사진이 현재보다 명확히 크게 표시
|
||||||
|
- 개요 탭 시각적 우선순위가 자연스러움
|
||||||
|
- 모바일에서도 레이아웃 깨짐 없음
|
||||||
|
- activity feed에 raw stderr 직접 노출 0건
|
||||||
|
- npm run build 성공 + 테스트 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
72
.plans/sprints/SPRINT-006.md
Normal file
72
.plans/sprints/SPRINT-006.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# SPRINT-006: 버그 수정 + 인증 시스템
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
기존 버그 3건 수정 + JWT 기반 관리자 인증 시스템 구축
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-020: 버그 수정 3건
|
||||||
|
**우선순위: 최우선 (인증 전환 전에 기존 기능 안정화)**
|
||||||
|
|
||||||
|
#### BUG-1: 하네스 편집 저장 실패 (PUT /api/admin/harness/:name/:file)
|
||||||
|
- 증상: API Key 입력해도 저장 실패
|
||||||
|
- 원인 조사: CORS PUT 메서드, DTO validation, AdminService.updateHarnessFile() 로직 확인
|
||||||
|
- FE 측: Authorization 헤더 전달, Content-Type, 에러 핸들링 확인
|
||||||
|
|
||||||
|
#### BUG-2: 로그 뷰어 초기 로드 안 됨
|
||||||
|
- 증상: /admin/logs 페이지 진입 시 로그가 비어있음 (수동 새로고침 필요)
|
||||||
|
- 수정: 컴포넌트 마운트 시 자동 fetch (useEffect 초기 로드)
|
||||||
|
- 자매/lines 기본값 설정 후 자동 조회
|
||||||
|
|
||||||
|
#### BUG-3: /api/admin/costs/record/:name 작동 안 함
|
||||||
|
- 증상: POST 요청 시 응답 없거나 에러
|
||||||
|
- 원인 조사: SSH 연결, python3 의존성, main.json 경로, 파싱 로직 확인
|
||||||
|
- python3 미설치 시 fallback (jq 또는 Node.js 스크립트)
|
||||||
|
|
||||||
|
### TASK-021: JWT 인증 BE
|
||||||
|
- User 테이블 추가 (Prisma 마이그레이션)
|
||||||
|
```
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
password String // bcrypt hash
|
||||||
|
role String @default("admin")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 인증 API:
|
||||||
|
- `POST /api/auth/register` — 초대 코드 필수 (INVITE_CODE env), bcrypt hash
|
||||||
|
- `POST /api/auth/login` — JWT access token (15분) + refresh token (7일)
|
||||||
|
- `POST /api/auth/refresh` — refresh token으로 새 access token 발급
|
||||||
|
- `GET /api/auth/me` — 현재 사용자 정보
|
||||||
|
- JWT_SECRET, INVITE_CODE 환경변수
|
||||||
|
- Rate limiting: 로그인 엔드포인트 (5회/분)
|
||||||
|
- 테스트: auth 관련 유닛 테스트
|
||||||
|
|
||||||
|
### TASK-022: 로그인/회원가입 페이지 FE
|
||||||
|
- `/login` 페이지: username + password + 로그인 버튼
|
||||||
|
- `/register` 페이지: username + password + 초대 코드 + 가입 버튼
|
||||||
|
- 디자인: DESIGN-SYSTEM.md v2 준수 (미니멀 터미널 UI)
|
||||||
|
- 로그인 실패 시 에러 메시지 표시
|
||||||
|
- 토큰 저장: localStorage (access) + httpOnly cookie 검토
|
||||||
|
- 라우트 보호: 비로그인 시 `/login`으로 리다이렉트
|
||||||
|
- AuthContext/AuthProvider 구현
|
||||||
|
|
||||||
|
### TASK-023: API Key Guard → JWT Guard 전환
|
||||||
|
- JwtGuard 생성 (passport-jwt)
|
||||||
|
- 기존 ApiKeyGuard 유지 + JwtGuard 병행 (CompositeGuard: JWT 먼저 → API Key fallback)
|
||||||
|
- 하위호환: API Key는 Sprint 008까지 유지, 이후 제거 예정
|
||||||
|
- WebSocket 인증: Socket.IO handshake에 JWT 토큰 검증 추가
|
||||||
|
- 모든 @UseGuards 교체 (ApiKeyGuard → CompositeGuard)
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
- TASK-020 먼저 완료 → TASK-021 → TASK-022 → TASK-023
|
||||||
|
- TASK-022는 TASK-021 완료 필요 (API가 있어야 FE 연동)
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 버그 3건 모두 재현 → 수정 → 동작 확인
|
||||||
|
- 회원가입(초대 코드) → 로그인 → JWT 발급 → 보호된 API 접근 성공
|
||||||
|
- API Key로도 기존 방식 그대로 접근 가능 (하위호환)
|
||||||
|
- WebSocket 연결 시 JWT 검증
|
||||||
|
- npm run build 성공 + 테스트 통과
|
||||||
61
.plans/sprints/SPRINT-007.md
Normal file
61
.plans/sprints/SPRINT-007.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# SPRINT-007: Gitea 연동 + 활동 로그 실시간화
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
프로젝트 데이터를 Gitea 기반으로 전환 + 활동 로그 자동 기록
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-024: Gitea 동기화 API
|
||||||
|
- GiteaService 강화 (기존 optional 패턴 유지)
|
||||||
|
- API 엔드포인트:
|
||||||
|
- `POST /api/admin/gitea/sync` — Gitea org의 전체 repo를 DB에 동기화
|
||||||
|
- `GET /api/projects` — DB에서 프로젝트 목록 (Gitea 데이터 포함)
|
||||||
|
- 동기화 로직:
|
||||||
|
- Gitea API `/api/v1/orgs/hanarang/repos` 호출
|
||||||
|
- 각 repo → Project 테이블 upsert (giteaId, name, repoUrl, description)
|
||||||
|
- 마지막 동기화 시간 기록
|
||||||
|
- 자동 동기화: EventsScheduler에서 5분마다 폴링 (선택)
|
||||||
|
|
||||||
|
### TASK-025: Commit/Branch/PR 조회 API
|
||||||
|
- API 엔드포인트:
|
||||||
|
- `GET /api/projects/:id/commits` — 최근 커밋 목록 (Gitea API `/repos/:owner/:repo/commits`)
|
||||||
|
- `GET /api/projects/:id/branches` — 브랜치 목록
|
||||||
|
- `GET /api/projects/:id/pulls` — PR 목록 (open/closed)
|
||||||
|
- 응답 포맷: 커밋 해시, 메시지, 작성자, 날짜 / 브랜치명, 최신 커밋 / PR 제목, 상태
|
||||||
|
- 에러 핸들링: Gitea 연결 실패 시 graceful fallback
|
||||||
|
|
||||||
|
### TASK-026: 프로젝트 페이지 FE (Gitea 데이터)
|
||||||
|
- `/projects` 목록 페이지:
|
||||||
|
- DB 프로젝트 목록 표시 (이름, 설명, 상태, repo URL)
|
||||||
|
- 동기화 버튼 (관리자)
|
||||||
|
- `/projects/[id]` 상세 페이지:
|
||||||
|
- 탭: Overview / Commits / Branches / PRs
|
||||||
|
- Commits: 커밋 히스토리 테이블 (해시, 메시지, 작성자, 날짜)
|
||||||
|
- Branches: 브랜치 카드 목록
|
||||||
|
- PRs: PR 리스트 (상태 태그: open/merged/closed)
|
||||||
|
- 디자인: DESIGN-SYSTEM.md v2 준수 (project-detail-design.md 확장)
|
||||||
|
- 로딩: 스켈레톤 UI
|
||||||
|
|
||||||
|
### TASK-027: 활동 로그 자동 기록
|
||||||
|
- ActivityLog 모델 활용 (기존 테이블 사용)
|
||||||
|
- 자동 기록 트리거:
|
||||||
|
- 자매 상태 변경 시 (online ↔ offline)
|
||||||
|
- Gitea 동기화 시 (새 repo/커밋 감지)
|
||||||
|
- 관리자 액션 시 (재시작, 하네스 편집, 설정 변경)
|
||||||
|
- ActivityService에 `logActivity()` 메서드 추가 → 각 서비스에서 호출
|
||||||
|
- WebSocket으로 실시간 푸시 (`activity:new` 이벤트 활용)
|
||||||
|
- `/activities` 페이지: 실제 ActivityLog 데이터 표시 (하드코딩 제거)
|
||||||
|
- 필터/검색/페이지네이션 실제 동작
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
- Sprint 006 완료 필요 (JWT 인증)
|
||||||
|
- TASK-024 → TASK-025 (repo가 DB에 있어야 commit 조회 가능)
|
||||||
|
- TASK-026은 TASK-025 완료 후 연동
|
||||||
|
- TASK-027은 독립적 (다른 태스크와 병렬 가능)
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- Gitea 동기화 → DB에 프로젝트 생성 확인
|
||||||
|
- 커밋/브랜치/PR 조회 정상 동작
|
||||||
|
- 활동 로그 자동 기록 + WebSocket 실시간 푸시
|
||||||
|
- /activities 페이지에서 실제 데이터 표시 (필터/페이지네이션 동작)
|
||||||
|
- npm run build 성공 + 테스트 통과
|
||||||
99
.plans/sprints/SPRINT-008.md
Normal file
99
.plans/sprints/SPRINT-008.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# SPRINT-008: 실시간 전환 + 설정 + 자매 강화
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
모든 하드코딩 데이터를 실시간 전환 + 설정 페이지 구현 + 자매 프로필 강화
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-028: 자매 실시간 데이터 (Uptime/시스템 정보)
|
||||||
|
- SistersService 확장:
|
||||||
|
- SSH로 실제 uptime 조회: `cat /proc/uptime`
|
||||||
|
- CPU 사용률: `top -bn1 | grep Cpu`
|
||||||
|
- 메모리: `free -m`
|
||||||
|
- 디스크: `df -h /`
|
||||||
|
- API 응답에 시스템 정보 추가:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uptime": "342:12:05",
|
||||||
|
"cpu": 23.4,
|
||||||
|
"memory": { "used": 1024, "total": 4096 },
|
||||||
|
"disk": { "used": "12G", "total": "50G" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 자매 페이지(/sisters): 실제 Uptime 표시 (하드코딩 제거)
|
||||||
|
- 자매 카드: Capacity → 실제 CPU/메모리 기반
|
||||||
|
- WebSocket: 30초마다 자매 상태 업데이트에 시스템 정보 포함
|
||||||
|
|
||||||
|
### TASK-029: 자매 프로필 사진 API
|
||||||
|
- API: `GET /api/sisters/:name/avatar`
|
||||||
|
- SSH로 각 자매 서버에서 프로필 사진 가져오기:
|
||||||
|
- 경로: `~/.openclaw/avatar.png` 또는 `~/.openclaw/avatar.jpg`
|
||||||
|
- SCP/SSH cat으로 바이너리 전송
|
||||||
|
- 캐싱: 메모리 캐시 (5분 TTL) — 매번 SSH 호출 방지
|
||||||
|
- 대체(fallback): 프로필 사진 없으면 이름 이니셜 아바타 생성
|
||||||
|
- FE: 자매 카드/상세 페이지에서 아바타 이미지 교체
|
||||||
|
|
||||||
|
### TASK-030: 설정 페이지 BE + FE
|
||||||
|
- SystemSettings 테이블 (Prisma):
|
||||||
|
```
|
||||||
|
model SystemSettings {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
key String @unique
|
||||||
|
value String @db.Text
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- API:
|
||||||
|
- `GET /api/admin/settings` — 전체 설정 조회
|
||||||
|
- `PUT /api/admin/settings` — 설정 일괄 저장
|
||||||
|
- 설정 항목 (settings-design.md 기반):
|
||||||
|
- 강제 2FA (boolean)
|
||||||
|
- 세션 유효 시간 (number, 분)
|
||||||
|
- IP 화이트리스트 (boolean)
|
||||||
|
- 자동 백업 (boolean)
|
||||||
|
- 백업 주기 (string, HH:mm)
|
||||||
|
- 보관 주기 (number, 일)
|
||||||
|
- 대기열 경고 임계값 (number)
|
||||||
|
- 자동 스케일링 (boolean)
|
||||||
|
- CPU 임계값 (number, %)
|
||||||
|
- 레이턴시 경보 (number, ms)
|
||||||
|
- 노드 오프라인 알림 (boolean)
|
||||||
|
- FE /settings 페이지:
|
||||||
|
- 마운트 시 설정 로드 (API fetch)
|
||||||
|
- 토글/인풋 변경 → 로컬 state
|
||||||
|
- "설정 저장" → PUT API → 성공 토스트
|
||||||
|
- "초기화" → 기본값 복원
|
||||||
|
|
||||||
|
### TASK-031: 하드코딩 제거 + 스켈레톤 UI
|
||||||
|
**대상 목록:**
|
||||||
|
|
||||||
|
| 페이지 | 하드코딩 항목 | 전환 방식 |
|
||||||
|
|--------|-------------|-----------|
|
||||||
|
| `/` (대시보드) | 상태 카드 값 ([ON]/[84]/[--]/[12]) | → /api/sisters 실시간 |
|
||||||
|
| `/` (대시보드) | ONGOING PROJECTS 목록 | → /api/projects 실시간 |
|
||||||
|
| `/` (대시보드) | ACTIVITY FEED | → /api/activities 실시간 |
|
||||||
|
| `/sisters` | Uptime, Load Index, Sync Log | → TASK-028 API |
|
||||||
|
| `/org` | 조직도 데이터 | → /api/org (이미 연동, 확인만) |
|
||||||
|
| `/settings` | 토글/인풋 값 | → TASK-030 API |
|
||||||
|
| `/activities` | 전체 로그 테이블 | → /api/activities (TASK-027에서 연동) |
|
||||||
|
|
||||||
|
- 모든 API 호출에 **스켈레톤 UI** 추가:
|
||||||
|
- 카드 스켈레톤: 회색 박스 펄스 애니메이션
|
||||||
|
- 테이블 스켈레톤: 행 placeholder
|
||||||
|
- 타임라인 스켈레톤: 도트 + 라인 placeholder
|
||||||
|
- 에러 상태: "데이터를 불러올 수 없습니다" fallback UI
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
- Sprint 007 완료 필요 (Gitea 데이터 + 활동 로그가 있어야 실시간 전환 가능)
|
||||||
|
- TASK-028, TASK-029는 독립적 (병렬 가능)
|
||||||
|
- TASK-030은 독립적
|
||||||
|
- TASK-031은 TASK-028 + TASK-030 완료 후 최종 정리
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 자매 페이지에서 실제 Uptime/CPU/메모리 표시
|
||||||
|
- 프로필 사진 정상 렌더링 (없으면 이니셜 fallback)
|
||||||
|
- 설정 저장/로드/초기화 정상 동작 + 토스트
|
||||||
|
- 모든 페이지에서 하드코딩 0건 (curl/검색으로 확인)
|
||||||
|
- 스켈레톤 UI 동작 (느린 네트워크에서 확인)
|
||||||
|
- npm run build 성공 + 테스트 통과
|
||||||
|
- **외부 URL QA 필수**
|
||||||
63
.plans/sprints/SPRINT-009.md
Normal file
63
.plans/sprints/SPRINT-009.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# SPRINT-009: 관리자 단순화 + 프로젝트 진행률 정상화 + 프로필 사진 수정
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
관리자 전용 서비스에 맞게 회원가입 제거, 프로젝트 진행률 계산 정상화, 자매 프로필 사진 표시 버그 수정
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-032: 회원가입 제거
|
||||||
|
- `/register` 페이지 제거
|
||||||
|
- `POST /api/auth/register` 엔드포인트 제거 또는 비활성화
|
||||||
|
- `INVITE_CODE` 환경변수 및 초대코드 기반 가입 로직 제거
|
||||||
|
- 로그인 전용 플로우로 단순화
|
||||||
|
- FE: 로그인 페이지에서 회원가입 링크 제거
|
||||||
|
- UX: 비로그인 시 `/login`으로만 이동
|
||||||
|
|
||||||
|
### TASK-033: 프로젝트 Sprint 동기화
|
||||||
|
- 각 프로젝트 repo의 `.plans/sprints/` 디렉토리를 읽어 Sprint DB 레코드 생성/업데이트
|
||||||
|
- Gitea API로 repo tree 조회 또는 raw 파일 fetch
|
||||||
|
- 동기화 대상:
|
||||||
|
- `SPRINT-001.md` ~ `SPRINT-XXX.md`
|
||||||
|
- Sprint number, name, status(기본 pending) 추출
|
||||||
|
- API:
|
||||||
|
- `POST /api/admin/projects/:id/sync-sprints`
|
||||||
|
- `POST /api/admin/projects/sync-all-sprints`
|
||||||
|
- Gitea sync 시 Sprint 동기화도 함께 수행 가능하도록 확장
|
||||||
|
|
||||||
|
### TASK-034: 프로젝트 진행률 계산 수정
|
||||||
|
- 현재 “스프린트 없음” 문제 해결
|
||||||
|
- 진행률 계산 방식:
|
||||||
|
- 기본: `완료 Sprint 수 / 전체 Sprint 수`
|
||||||
|
- 가능하면 `.qa/` 또는 상태 기반으로 `done/review/in_progress` 반영
|
||||||
|
- 프로젝트 목록 페이지(`/projects`): 진행률 바/퍼센트 표시
|
||||||
|
- 프로젝트 상세 페이지(`/projects/[id]`): Sprint 목록 + 상태 표시
|
||||||
|
- “No sprint data” fallback 제거
|
||||||
|
|
||||||
|
### TASK-035: 프로필 사진 버그 수정
|
||||||
|
- 현재 아이콘 fallback만 보이는 문제 원인 파악
|
||||||
|
- 점검 항목:
|
||||||
|
- avatar API 경로 연결 여부
|
||||||
|
- FE 이미지 src 설정
|
||||||
|
- 캐시(5분 TTL) fallback 지속 문제
|
||||||
|
- 실제 서버 파일 경로 (`~/.openclaw/avatar.png|jpg|webp`) 확인
|
||||||
|
- 수정 목표:
|
||||||
|
- 실제 사진 있으면 항상 우선 표시
|
||||||
|
- 실패 시에만 SVG fallback
|
||||||
|
- 반영 위치:
|
||||||
|
- 대시보드 자매 카드
|
||||||
|
- `/sisters`
|
||||||
|
- `/sisters/[name]`
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
- TASK-032는 독립적
|
||||||
|
- TASK-033 → TASK-034 순서 필수
|
||||||
|
- TASK-035는 독립적, 병렬 가능
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- `/register` 접근 불가 또는 로그인으로 리다이렉트
|
||||||
|
- 회원가입 API 제거/비활성화 확인
|
||||||
|
- 프로젝트별 Sprint 동기화 후 진행률 표시 정상
|
||||||
|
- 프로젝트 목록/상세에서 Sprint 데이터 렌더링
|
||||||
|
- 자매 프로필 사진 실제 이미지 표시 확인
|
||||||
|
- npm run build 성공 + 테스트 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
56
.plans/sprints/SPRINT-010.md
Normal file
56
.plans/sprints/SPRINT-010.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# SPRINT-010: 관리자 코드 정리 + 인증 단일화
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
JWT 로그인 기반으로 완전히 정리하고, 불필요한 API Key/초대코드/죽은 코드를 제거
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-036: 관리 페이지 API Key UI 제거
|
||||||
|
- 관리자 페이지에서 API Key 입력 필드/상태 제거
|
||||||
|
- adminFetch가 JWT만 사용하도록 FE 정리
|
||||||
|
- AuthContext 기반 인증 상태만 사용
|
||||||
|
- 관련 안내 문구/placeholder 제거
|
||||||
|
|
||||||
|
### TASK-037: JWT 전용 FE 정리
|
||||||
|
- 로그인/토큰 관리 흐름 단순화
|
||||||
|
- CompositeGuard 하위호환 때문에 남긴 FE 분기 제거
|
||||||
|
- refresh/login/logout 로직 정리
|
||||||
|
- 불필요한 fallback 코드 제거
|
||||||
|
- 에러 처리 UX 정리 (401 → 로그인 페이지 이동)
|
||||||
|
|
||||||
|
### TASK-038: API Key 하위호환 제거 또는 축소
|
||||||
|
- 백엔드에서 API Key 지원 범위를 정리
|
||||||
|
- 원칙:
|
||||||
|
- 일반 웹 UI는 JWT만 사용
|
||||||
|
- 필요한 내부 자동화가 없다면 API Key 완전 제거
|
||||||
|
- 제거 대상 후보:
|
||||||
|
- ApiKeyStrategy
|
||||||
|
- CompositeGuard
|
||||||
|
- ADMIN_API_KEYS env
|
||||||
|
- 만약 완전 제거가 부담되면 “운영/내부 스크립트 전용 엔드포인트”만 남기고 UI 경로는 JWT 전용으로 제한
|
||||||
|
|
||||||
|
### TASK-039: 죽은 코드/불필요 코드 청소
|
||||||
|
- invite/register 관련 unused code 제거
|
||||||
|
- API Key 관련 사용 안 하는 util 제거
|
||||||
|
- old fallback 코드 정리
|
||||||
|
- 문서 업데이트:
|
||||||
|
- `.plans/design/` 변경 필요 시 반영
|
||||||
|
- README / 환경변수 설명 업데이트
|
||||||
|
- grep 기반 점검:
|
||||||
|
- `INVITE_CODE`
|
||||||
|
- `register`
|
||||||
|
- `API_KEY`
|
||||||
|
- `CompositeGuard`
|
||||||
|
- 사용 안 되는 state/props/components
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
- Sprint 009 완료 후 진행 권장
|
||||||
|
- TASK-036 → TASK-037 → TASK-038 → TASK-039 순서 권장
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 관리 페이지에서 API Key 입력 UI 0건
|
||||||
|
- 로그인 후 JWT만으로 관리자 기능 정상 동작
|
||||||
|
- `/register` 관련 코드/엔드포인트 정리 확인
|
||||||
|
- API Key 제거 또는 제한 후 기존 UI 정상 동작
|
||||||
|
- unused code 정리 후 build/test 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
56
.plans/sprints/SPRINT-011.md
Normal file
56
.plans/sprints/SPRINT-011.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# SPRINT-011: 진행률/프로필 사진 실제 반영 핫픽스
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
프로젝트 진행률이 0%로 보이는 문제와 자매 프로필 사진이 fallback으로만 보이는 문제를 실제 데이터 기준으로 수정
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-040: Sprint status 동기화 수정
|
||||||
|
- 현재 `Status: value` 단일 줄 파싱이 실제 `.plans/sprints/*.md` 포맷과 맞지 않는 문제 수정
|
||||||
|
- 실제 Sprint 문서 포맷 조사 후 파싱 규칙 재정의
|
||||||
|
- 우선순위:
|
||||||
|
1. 명시 메타 블록 / heading 기반 status 파싱
|
||||||
|
2. QA 결과 파일(`.qa/SPRINT-XXX-review-*.md`) 기반 완료 여부 보조 판정
|
||||||
|
3. 없으면 pending
|
||||||
|
- 오탐 방지: 본문 전체 키워드 검색 금지
|
||||||
|
|
||||||
|
### TASK-041: 프로젝트 진행률 계산 수정
|
||||||
|
- Sprint status가 실제 반영되도록 프로젝트 진행률 계산 로직 수정
|
||||||
|
- 표시 기준:
|
||||||
|
- `done` Sprint 수 / 전체 Sprint 수 → %
|
||||||
|
- 필요 시 `review`, `in_progress`는 별도 상태 태그 표시
|
||||||
|
- `/projects` 목록: 퍼센트 + progress bar 정상 반영
|
||||||
|
- `/projects/[id]` 상세: Sprint 상태 목록 정확 표시
|
||||||
|
- “0%만 뜨는” 현상 해소
|
||||||
|
|
||||||
|
### TASK-042: 프로필 사진 실제 노출 수정
|
||||||
|
- avatar API 응답 200이어도 fallback만 보이는 원인 파악
|
||||||
|
- 점검 항목:
|
||||||
|
- FE 이미지 src 연결 여부
|
||||||
|
- `onError`가 과도하게 fallback으로 전환되는지
|
||||||
|
- `next/image` / `img` 캐시 문제
|
||||||
|
- content-type/image 응답 정상 여부
|
||||||
|
- avatar cache miss 후 stale fallback 유지 여부
|
||||||
|
- 실제 파일 경로 재확인:
|
||||||
|
- `~/.openclaw/avatar.png`
|
||||||
|
- `~/.openclaw/avatar.jpg`
|
||||||
|
- `~/.openclaw/avatar.webp`
|
||||||
|
|
||||||
|
### TASK-043: 아바타 UI 일관화
|
||||||
|
- 대시보드, `/sisters`, `/sisters/[name]`, `/admin` 전부 같은 Avatar 컴포넌트 사용
|
||||||
|
- 실제 이미지 우선 렌더
|
||||||
|
- 실패 시에만 fallback (이니셜 or SVG)
|
||||||
|
- 관리자 페이지에 남은 이모지 fallback 제거
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
- TASK-040 → TASK-041 순서 필수
|
||||||
|
- TASK-042, TASK-043은 병렬 가능하나 최종적으로 Avatar 공통 컴포넌트로 수렴
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 프로젝트 진행률이 0% 고정이 아닌 실제 값으로 표시
|
||||||
|
- Sprint 상세 상태가 실제 문서/QA 결과와 일치
|
||||||
|
- 자매 프로필 사진이 실제 이미지로 표시
|
||||||
|
- 관리자/자매/대시보드 전부 동일한 사진 표시 정책 사용
|
||||||
|
- fallback은 이미지 실패 시에만 동작
|
||||||
|
- npm run build 성공 + 테스트 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
42
.qa/HOTFIX-002-review-1.md
Normal file
42
.qa/HOTFIX-002-review-1.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# HOTFIX-002 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 18:35 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## Task 파싱 확인 (TASK-044)
|
||||||
|
|
||||||
|
| 포맷 | 지원 |
|
||||||
|
|------|------|
|
||||||
|
| `### TASK-001: title` | ✅ |
|
||||||
|
| `- [x] TASK-001: title` (done) | ✅ |
|
||||||
|
| `- [ ] TASK-001: title` (pending) | ✅ |
|
||||||
|
| `- TASK-001: title` | ✅ |
|
||||||
|
| `TASK-001: title` | ✅ |
|
||||||
|
| 중복 taskId 방지 (seen Set) | ✅ |
|
||||||
|
|
||||||
|
## 완료 count 계산 (TASK-045)
|
||||||
|
|
||||||
|
| 규칙 | 확인 |
|
||||||
|
|------|------|
|
||||||
|
| Sprint done → task (status 없으면) done fallback | ✅ |
|
||||||
|
| `[x]` → done | ✅ |
|
||||||
|
| `[ ]` → pending | ✅ |
|
||||||
|
| 기존 done task 보호 | ✅ |
|
||||||
|
| Task 테이블 upsert | ✅ |
|
||||||
|
|
||||||
|
## Non-blocking
|
||||||
|
|
||||||
|
| ID | 이슈 |
|
||||||
|
|----|------|
|
||||||
|
| NB1 | task assignee 하드코딩 'narang' — 문서 파싱으로 개선 권장 |
|
||||||
25
.qa/HOTFIX-003-review-1.md
Normal file
25
.qa/HOTFIX-003-review-1.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# HOTFIX-003 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 19:48 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 변경 확인
|
||||||
|
|
||||||
|
### 프로필 hero (TASK-046/047)
|
||||||
|
- ✅ SisterAvatar size=120 (기존 36px → 120px 확대)
|
||||||
|
- ✅ 상단 프로필 hero 재디자인
|
||||||
|
|
||||||
|
### Activity stderr 정리 (TASK-048)
|
||||||
|
- ✅ `sanitizeActivityDetail()` 함수 추가
|
||||||
|
- ✅ stderr/bash/command not found → 사용자 친화 메시지 변환
|
||||||
|
- ✅ SSH 연결 실패 → "원격 노드 연결에 실패했어" 변환
|
||||||
|
- ✅ log() + getFeed() + getProjectFeed() 전부 적용
|
||||||
52
.qa/SPRINT-006-review-1.md
Normal file
52
.qa/SPRINT-006-review-1.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# SPRINT-006 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 15:10 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ❌ FAILED (blocking 5건)
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test (backend) | ✅ 26/26 pass |
|
||||||
|
| npm run build (backend) | ✅ |
|
||||||
|
| npm run build (frontend) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 🔴 Blocking 이슈
|
||||||
|
|
||||||
|
### B1: JWT_SECRET fallback 'changeme' — 3곳 (Critical)
|
||||||
|
- **파일:** auth.service.ts:85, auth.module.ts:22, events.gateway.ts:48
|
||||||
|
- **문제:** JWT_SECRET 미설정 시 'changeme'로 토큰 서명 → 공격자가 유효한 JWT 위조 가능
|
||||||
|
- **모순:** jwt.strategy.ts는 throw하는데 나머지는 fallback → 불일치
|
||||||
|
- **수정:** 3곳 모두 `?? 'changeme'` 제거, ConfigService.getOrThrow 또는 throw 처리
|
||||||
|
|
||||||
|
### B2: User.role 기본값 "admin" (Critical)
|
||||||
|
- **파일:** prisma/schema.prisma
|
||||||
|
- **문제:** 모든 가입자 자동 admin → 초대코드만 알면 SSH restart, 하네스 수정 등 전체 인프라 제어
|
||||||
|
- **수정:** `@default("viewer")` + admin 승격 별도 로직 + AdminController에 RoleGuard 추가
|
||||||
|
|
||||||
|
### B3: WebSocket JWT 실패 시 disconnect 안 함
|
||||||
|
- **파일:** events.gateway.ts:52-53
|
||||||
|
- **문제:** invalid token 시 연결 유지 → 미인증 실시간 데이터 수신
|
||||||
|
- **수정:** catch 블록에서 `client.disconnect()` 호출
|
||||||
|
|
||||||
|
### B4: `throw new Error` → HttpException
|
||||||
|
- **파일:** auth.controller.ts:32
|
||||||
|
- **문제:** refresh token 없을 때 `throw new Error` → 500 반환
|
||||||
|
- **수정:** `throw new BadRequestException('Refresh token required')`
|
||||||
|
|
||||||
|
### B5: register 엔드포인트 rate limit 없음
|
||||||
|
- **파일:** auth.controller.ts:18-20
|
||||||
|
- **문제:** 초대코드 brute-force 가능
|
||||||
|
- **수정:** `@UseGuards(ThrottlerGuard) @Throttle({ default: { limit: 3, ttl: 60000 } })` 추가
|
||||||
|
|
||||||
|
## Non-blocking 이슈 (7건)
|
||||||
|
- N1: refresh token localStorage 평문 (httpOnly cookie 전환 권장)
|
||||||
|
- N2: AuthProvider 토큰 만료 시 자동 refresh 안 함
|
||||||
|
- N3: register에서 refreshToken 미저장
|
||||||
|
- N4: login 후 userId: 0 하드코딩
|
||||||
|
- N5: DTO 서비스 파일 내 정의 (별도 파일 권장)
|
||||||
|
- N6: adminFetch 주석-코드 불일치
|
||||||
|
- N7: refresh secret suffix 방식 → 별도 env 권장
|
||||||
23
.qa/SPRINT-006-review-2.md
Normal file
23
.qa/SPRINT-006-review-2.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# SPRINT-006 QA Review — Iteration 2
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 15:17 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## Blocking 수정 확인
|
||||||
|
|
||||||
|
| ID | 이슈 | 수정 확인 |
|
||||||
|
|----|------|----------|
|
||||||
|
| B1 (Critical) | JWT_SECRET 'changeme' 4곳 | ✅ 전부 제거, 0건 grep 확인 |
|
||||||
|
| B2 (Critical) | User.role "admin" | ✅ @default("viewer") + RoleGuard + @Roles('admin') on Admin/Costs |
|
||||||
|
| B3 | WS disconnect | ✅ catch 블록에서 client.disconnect() 호출 |
|
||||||
|
| B4 | throw Error | ✅ BadRequestException 변경 |
|
||||||
|
| B5 | register Throttle | ✅ @Throttle 3회/분 적용 |
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| git fetch + reset | ✅ |
|
||||||
|
| npm test | ✅ 26/26 pass |
|
||||||
|
| npm run build | ✅ |
|
||||||
39
.qa/SPRINT-007-review-1.md
Normal file
39
.qa/SPRINT-007-review-1.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# SPRINT-007 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 15:38 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ❌ FAILED (blocking 1건)
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test | ✅ 26/26 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## Sprint 006 NB 처리 확인
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| 자동 refresh (401 시) | ✅ |
|
||||||
|
| refreshToken 저장 | ✅ |
|
||||||
|
|
||||||
|
## 🔴 Blocking
|
||||||
|
|
||||||
|
### B1: getProjectCommits limit 파라미터 NaN 미처리
|
||||||
|
- **파일:** projects.controller.ts:25
|
||||||
|
- **문제:** `parseInt(limit, 10)` → NaN 가능 → Gitea API에 `?limit=NaN`
|
||||||
|
- **수정:** `isNaN` 체크 + `Math.min(Math.max(값, 1), 100)` clamp
|
||||||
|
|
||||||
|
## Non-blocking (8건)
|
||||||
|
- N1: ProjectsController 인증 없음 — 공개 의도라면 주석 명시
|
||||||
|
- N2: userId: 0 하드코딩 (refresh 후) — 현재 FE에서 미참조이나 개선 권장
|
||||||
|
- N3: syncRepos N+1 쿼리 + 트랜잭션 미사용
|
||||||
|
- N4: login 후 /me 실패 시 userId: 0 fallback
|
||||||
|
- N5: fetch 에러 시 에러 상태 없이 빈 목록
|
||||||
|
- N6: lastSyncAt 메모리만 저장
|
||||||
|
- N7: getOpenPRs/getPulls 중복
|
||||||
|
- N8: useEffect deps 불완전
|
||||||
18
.qa/SPRINT-007-review-2.md
Normal file
18
.qa/SPRINT-007-review-2.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# SPRINT-007 QA Review — Iteration 2
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 15:41 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## Blocking 수정 확인
|
||||||
|
|
||||||
|
| ID | 이슈 | 수정 확인 |
|
||||||
|
|----|------|----------|
|
||||||
|
| B1 | commits limit NaN | ✅ isNaN 체크 + clamp(1~100) |
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| git fetch + reset | ✅ |
|
||||||
|
| npm test | ✅ 26/26 pass |
|
||||||
43
.qa/SPRINT-008-review-1.md
Normal file
43
.qa/SPRINT-008-review-1.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# SPRINT-008 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 16:35 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ❌ FAILED (blocking 3건)
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test | ✅ 26/26 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 🔴 Blocking
|
||||||
|
|
||||||
|
### B1: SettingsController PUT body 미검증 — 임의 키 주입
|
||||||
|
- **파일:** settings.controller.ts, settings.service.ts
|
||||||
|
- **문제:** `Record<string, string>` 그대로 upsert → DEFAULT_SETTINGS에 없는 키도 DB에 삽입
|
||||||
|
- **수정:** saveAll()에서 DEFAULT_SETTINGS 키만 필터링
|
||||||
|
|
||||||
|
### B2: EventsScheduler + SistersScheduler 중복 — SSH 2배 호출
|
||||||
|
- **파일:** events.module.ts, events.scheduler.ts, sisters.scheduler.ts
|
||||||
|
- **문제:** 둘 다 providers에 등록, 둘 다 30초마다 broadcastSisterStatus() 실행
|
||||||
|
- **수정:** EventsScheduler 제거 (SistersScheduler가 대체)
|
||||||
|
|
||||||
|
### B3: SistersScheduler에 OnModuleDestroy 없음 — 메모리 누수
|
||||||
|
- **파일:** sisters.scheduler.ts
|
||||||
|
- **문제:** clearInterval 없음 → 서버 종료/HMR 시 좀비 타이머
|
||||||
|
- **수정:** `implements OnModuleDestroy` + `clearInterval(this.timer)`
|
||||||
|
|
||||||
|
## Non-blocking (9건)
|
||||||
|
- N1: avatar @Res() → NotFoundException 대신 res.status(404)
|
||||||
|
- N2: getSystemInfo null 반환 → 컨트롤러 null 체크 없음
|
||||||
|
- N3: SSH 커넥션 풀링 없음 (30초×4자매=16개 핸드셰이크)
|
||||||
|
- N4: top/free/df 파싱 환경 의존성 + disk % 미파싱
|
||||||
|
- N5: sisters 페이지 15초 폴링 + WS 30초 브로드캐스트 중복
|
||||||
|
- N6: avatar 캐시 사이즈 제한 없음
|
||||||
|
- N7: DEFAULT_SETTINGS FE/BE 중복 정의
|
||||||
|
- N8: sisterEmojis/sisterDisplayNames 여전히 3곳 중복
|
||||||
|
- N9: SSH 4개 병렬 → 1개 연결 4개 명령으로 최적화 권장
|
||||||
21
.qa/SPRINT-008-review-2.md
Normal file
21
.qa/SPRINT-008-review-2.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# SPRINT-008 QA Review — Iteration 2
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 16:38 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## Blocking 수정 확인
|
||||||
|
|
||||||
|
| ID | 이슈 | 수정 확인 |
|
||||||
|
|----|------|----------|
|
||||||
|
| B1 | Settings 키 필터링 | ✅ saveAll()에서 DEFAULT_SETTINGS 키만 allowed.includes 필터 |
|
||||||
|
| B2 | Scheduler 중복 | ✅ SistersScheduler events.module에서 제거 → EventsScheduler만 동작 |
|
||||||
|
| B3 | OnModuleDestroy 누락 | ✅ SistersScheduler 제거로 해소, EventsScheduler에 이미 clearInterval |
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| git fetch + reset | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test | ✅ 26/26 pass |
|
||||||
37
.qa/SPRINT-009-review-1.md
Normal file
37
.qa/SPRINT-009-review-1.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# SPRINT-009 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 17:06 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ❌ FAILED (blocking 2건)
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 🔴 Blocking
|
||||||
|
|
||||||
|
### B1: repoUrl.split('/').pop() trailing slash 취약
|
||||||
|
- **파일:** sprint-sync.service.ts:42, 51
|
||||||
|
- **문제:** trailing slash URL → repoName 빈 문자열 → Gitea API 깨짐
|
||||||
|
- **수정:** `.replace(/\/+$/, '').split('/').pop()`
|
||||||
|
|
||||||
|
### B2: parseSprintMd status 전체 본문 키워드 검색 오탐
|
||||||
|
- **파일:** sprint-sync.service.ts:22-24
|
||||||
|
- **문제:** 본문에 "완료", "진행" 단어 포함 시 오탐 → DB status 부정확 → 진행률 계산 오류
|
||||||
|
- **수정:** 명시적 섹션(## Status 등)만 파싱하도록 범위 제한
|
||||||
|
|
||||||
|
## Non-blocking (8건)
|
||||||
|
- NB1: syncAllProjectSprints 순차 처리
|
||||||
|
- NB2: getRawFile ref='main' 하드코딩
|
||||||
|
- NB3: getRepoTree 전체 tree fetch 비효율
|
||||||
|
- NB4: /register redirect 방식은 적절 (OK)
|
||||||
|
- NB5: login page dead import/styled component
|
||||||
|
- NB6: auth spec mock 잔여
|
||||||
|
- NB7: sprint update 조건 코멘트 부족
|
||||||
|
- NB8: avatar onError display:none
|
||||||
20
.qa/SPRINT-009-review-2.md
Normal file
20
.qa/SPRINT-009-review-2.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# SPRINT-009 QA Review — Iteration 2
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 17:12 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## Blocking 수정 확인
|
||||||
|
|
||||||
|
| ID | 이슈 | 수정 확인 |
|
||||||
|
|----|------|----------|
|
||||||
|
| B1 | repoUrl trailing slash | ✅ `.replace(/\/+$/, '').split('/').pop()` |
|
||||||
|
| B2 | status 파싱 범위 | ✅ `Status: value` 명시 줄만 regex 파싱, 본문 전체 검색 제거 |
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| git fetch + reset | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
51
.qa/SPRINT-010-review-1.md
Normal file
51
.qa/SPRINT-010-review-1.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# SPRINT-010 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 17:25 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## API Key 완전 제거 확인
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| api-key.strategy.ts 파일 삭제 | ✅ |
|
||||||
|
| api-key.guard.ts 파일 삭제 | ✅ |
|
||||||
|
| CompositeGuard 코드 0건 (grep) | ✅ |
|
||||||
|
| ADMIN_API_KEYS 참조 0건 | ✅ |
|
||||||
|
| hanarang_admin_key 참조 0건 | ✅ |
|
||||||
|
| admin layout API Key UI 제거 | ✅ |
|
||||||
|
| adminFetch JWT 전용 단순화 | ✅ |
|
||||||
|
|
||||||
|
## JWT 전환 확인
|
||||||
|
|
||||||
|
| Controller | Guard |
|
||||||
|
|------------|-------|
|
||||||
|
| AdminController | JwtGuard + RoleGuard ✅ |
|
||||||
|
| CostsController | JwtGuard + RoleGuard ✅ |
|
||||||
|
| GiteaSyncController | JwtGuard + RoleGuard ✅ |
|
||||||
|
| SettingsController | JwtGuard + RoleGuard ✅ |
|
||||||
|
|
||||||
|
## Dead Code 정리 확인
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| INVITE_CODE BE 코드 | ✅ 0건 |
|
||||||
|
| register BE 엔드포인트 | ✅ 제거됨 |
|
||||||
|
| PUBLIC_PATHS /register | ✅ 제거됨 |
|
||||||
|
|
||||||
|
## 판정 근거
|
||||||
|
- 코드 정리/제거 스프린트 — 새 기능 없이 기존 코드 축소
|
||||||
|
- API Key 관련 파일/코드 완전 제거 확인 (grep 0건)
|
||||||
|
- JWT 전환 일관 적용
|
||||||
|
- 테스트/빌드 통과
|
||||||
|
- blocking 0건
|
||||||
37
.qa/SPRINT-011-review-1.md
Normal file
37
.qa/SPRINT-011-review-1.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# SPRINT-011 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 17:45 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 핵심 변경 확인
|
||||||
|
|
||||||
|
### Sprint status 파싱 (TASK-040)
|
||||||
|
- ✅ `.qa/SPRINT-XXX-review-*.md`에서 PASSED 여부로 status 결정
|
||||||
|
- ✅ 본문 전체 키워드 검색 완전 제거
|
||||||
|
- ✅ `inferStatusFromQaContent()`로 PASSED regex 체크
|
||||||
|
- ✅ passedMap으로 Sprint number → boolean 매핑
|
||||||
|
|
||||||
|
### 진행률 계산 (TASK-041)
|
||||||
|
- ✅ `doneSprints / totalSprints * 100` — Sprint done 기반 진행률
|
||||||
|
|
||||||
|
### SisterAvatar 공통 컴포넌트 (TASK-042/043)
|
||||||
|
- ✅ `components/common/SisterAvatar.tsx` 생성
|
||||||
|
- ✅ API URL → 이미지 로드 → onError → 이니셜 fallback
|
||||||
|
- ✅ 대시보드(/) + 자매(/sisters) + 상세(/sisters/[name]) 전부 적용
|
||||||
|
- ✅ 이모지 fallback 대신 이니셜 텍스트 fallback
|
||||||
|
|
||||||
|
## 판정 근거
|
||||||
|
- 자기야가 직접 지적한 2가지 문제(진행률 0%, 프로필 사진 미노출) 핵심 원인 수정됨
|
||||||
|
- 테스트/빌드 전부 통과
|
||||||
|
- blocking 0건
|
||||||
223
backend/package-lock.json
generated
223
backend/package-lock.json
generated
@@ -12,13 +12,16 @@
|
|||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.18",
|
"@nestjs/platform-socket.io": "^11.1.18",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.18",
|
"@nestjs/websockets": "^11.1.18",
|
||||||
"@prisma/adapter-mariadb": "^7.6.0",
|
"@prisma/adapter-mariadb": "^7.6.0",
|
||||||
"@prisma/client": "^7.6.0",
|
"@prisma/client": "^7.6.0",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.0",
|
||||||
@@ -27,6 +30,7 @@
|
|||||||
"node-ssh": "^13.2.1",
|
"node-ssh": "^13.2.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-http-bearer": "^1.0.1",
|
"passport-http-bearer": "^1.0.1",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"prisma": "^7.6.0",
|
"prisma": "^7.6.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
@@ -38,10 +42,12 @@
|
|||||||
"@nestjs/cli": "^11.0.17",
|
"@nestjs/cli": "^11.0.17",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-http-bearer": "^1.0.42",
|
"@types/passport-http-bearer": "^1.0.42",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
@@ -2288,6 +2294,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/jwt": {
|
||||||
|
"version": "11.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz",
|
||||||
|
"integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
|
"jsonwebtoken": "9.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/passport": {
|
"node_modules/@nestjs/passport": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
|
||||||
@@ -2477,6 +2496,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/throttler": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
|
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/websockets": {
|
"node_modules/@nestjs/websockets": {
|
||||||
"version": "11.1.18",
|
"version": "11.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.18.tgz",
|
||||||
@@ -3110,6 +3140,16 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -3286,6 +3326,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/keygrip": {
|
"node_modules/@types/keygrip": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz",
|
||||||
@@ -3327,6 +3377,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.12.2",
|
"version": "24.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||||
@@ -3358,6 +3414,28 @@
|
|||||||
"@types/passport": "*"
|
"@types/passport": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/passport-jwt": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/jsonwebtoken": "*",
|
||||||
|
"@types/passport-strategy": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/passport-strategy": {
|
||||||
|
"version": "0.2.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
|
||||||
|
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*",
|
||||||
|
"@types/passport": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -4637,6 +4715,20 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^8.3.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bcrypt-pbkdf": {
|
"node_modules/bcrypt-pbkdf": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||||
@@ -4800,6 +4892,12 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -5622,6 +5720,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -8212,6 +8319,49 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -8315,6 +8465,42 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.memoize": {
|
"node_modules/lodash.memoize": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||||
@@ -8329,6 +8515,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/log-symbols": {
|
"node_modules/log-symbols": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
||||||
@@ -8773,6 +8965,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||||
|
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-emoji": {
|
"node_modules/node-emoji": {
|
||||||
"version": "1.11.0",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
|
||||||
@@ -8789,6 +8990,17 @@
|
|||||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-int64": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
@@ -9124,6 +9336,16 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/passport-jwt": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"passport-strategy": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/passport-strategy": {
|
"node_modules/passport-strategy": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||||
@@ -9828,7 +10050,6 @@
|
|||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
|
|||||||
@@ -24,13 +24,16 @@
|
|||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.18",
|
"@nestjs/platform-socket.io": "^11.1.18",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.18",
|
"@nestjs/websockets": "^11.1.18",
|
||||||
"@prisma/adapter-mariadb": "^7.6.0",
|
"@prisma/adapter-mariadb": "^7.6.0",
|
||||||
"@prisma/client": "^7.6.0",
|
"@prisma/client": "^7.6.0",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.0",
|
||||||
@@ -39,6 +42,7 @@
|
|||||||
"node-ssh": "^13.2.1",
|
"node-ssh": "^13.2.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-http-bearer": "^1.0.1",
|
"passport-http-bearer": "^1.0.1",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"prisma": "^7.6.0",
|
"prisma": "^7.6.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
@@ -53,10 +57,12 @@
|
|||||||
"@nestjs/cli": "^11.0.17",
|
"@nestjs/cli": "^11.0.17",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-http-bearer": "^1.0.42",
|
"@types/passport-http-bearer": "^1.0.42",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
|||||||
@@ -93,3 +93,19 @@ model CostLog {
|
|||||||
@@index([sisterName, recordedAt])
|
@@index([sisterName, recordedAt])
|
||||||
@@index([recordedAt])
|
@@index([recordedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
password String // bcrypt hash
|
||||||
|
role String @default("viewer")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model SystemSettings {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
key String @unique
|
||||||
|
value String @db.Text
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ export interface LogActivityDto {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeActivityDetail(detail?: string | null): string | undefined {
|
||||||
|
if (!detail) return detail ?? undefined;
|
||||||
|
|
||||||
|
const raw = detail.trim();
|
||||||
|
|
||||||
|
// 내부 stderr/명령문 직접 노출 금지
|
||||||
|
if (/stderr:/i.test(raw) || /command not found/i.test(raw) || /bash:\s*line/i.test(raw)) {
|
||||||
|
return '내부 작업 중 오류가 발생했어. 자세한 시스템 로그는 관리자 로그에서 확인할 수 있어.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^ssh failed/i.test(raw) || /ssh connection failed/i.test(raw)) {
|
||||||
|
return '원격 노드 연결에 실패했어.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ActivityService {
|
export class ActivityService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -18,14 +35,16 @@ export class ActivityService {
|
|||||||
|
|
||||||
async log(dto: LogActivityDto) {
|
async log(dto: LogActivityDto) {
|
||||||
const record = await this.prisma.activityLog.create({
|
const record = await this.prisma.activityLog.create({
|
||||||
data: dto,
|
data: {
|
||||||
|
...dto,
|
||||||
|
detail: sanitizeActivityDetail(dto.detail),
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
sister: { select: { name: true } },
|
sister: { select: { name: true } },
|
||||||
project: { select: { name: true } },
|
project: { select: { name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 실시간 브로드캐스트 (gateway 사용 가능한 경우)
|
|
||||||
if (this.events) {
|
if (this.events) {
|
||||||
this.events.broadcastActivity(record);
|
this.events.broadcastActivity(record);
|
||||||
}
|
}
|
||||||
@@ -46,7 +65,12 @@ export class ActivityService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.activityLog.count(),
|
this.prisma.activityLog.count(),
|
||||||
]);
|
]);
|
||||||
return { items, total, limit, offset };
|
return {
|
||||||
|
items: items.map((item) => ({ ...item, detail: sanitizeActivityDetail(item.detail) })),
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProjectFeed(projectId: number, limit = 30, offset = 0) {
|
async getProjectFeed(projectId: number, limit = 30, offset = 0) {
|
||||||
@@ -62,6 +86,11 @@ export class ActivityService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.activityLog.count({ where: { projectId } }),
|
this.prisma.activityLog.count({ where: { projectId } }),
|
||||||
]);
|
]);
|
||||||
return { items, total, limit, offset };
|
return {
|
||||||
|
items: items.map((item) => ({ ...item, detail: sanitizeActivityDetail(item.detail) })),
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { ApiKeyGuard } from '../auth/api-key.guard';
|
import { JwtGuard } from '../auth/jwt.guard';
|
||||||
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
import { SisterNamePipe } from '../common/sister-name.pipe';
|
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||||
import type { SisterName } from '../common/sister-name.pipe';
|
import type { SisterName } from '../common/sister-name.pipe';
|
||||||
|
|
||||||
@@ -23,7 +24,8 @@ class UpdateHarnessDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Controller('api/admin')
|
@Controller('api/admin')
|
||||||
@UseGuards(ApiKeyGuard)
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
|
@Roles('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(private readonly adminService: AdminService) {}
|
constructor(private readonly adminService: AdminService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
@@ -14,10 +15,13 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { EventsModule } from './events/events.module';
|
import { EventsModule } from './events/events.module';
|
||||||
import { CostsModule } from './costs/costs.module';
|
import { CostsModule } from './costs/costs.module';
|
||||||
|
import { GiteaSyncModule } from './gitea-sync/gitea-sync.module';
|
||||||
|
import { SettingsModule } from './settings/settings.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
SistersModule,
|
SistersModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
@@ -30,6 +34,8 @@ import { CostsModule } from './costs/costs.module';
|
|||||||
AdminModule,
|
AdminModule,
|
||||||
EventsModule,
|
EventsModule,
|
||||||
CostsModule,
|
CostsModule,
|
||||||
|
GiteaSyncModule,
|
||||||
|
SettingsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ApiKeyGuard extends AuthGuard('api-key') {}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { Strategy } from 'passport-http-bearer';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') {
|
|
||||||
private readonly validKeys: Set<string>;
|
|
||||||
|
|
||||||
constructor(config: ConfigService) {
|
|
||||||
super();
|
|
||||||
const raw = config.get<string>('ADMIN_API_KEYS') ?? '';
|
|
||||||
this.validKeys = new Set(
|
|
||||||
raw
|
|
||||||
.split(',')
|
|
||||||
.map((k) => k.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
validate(token: string): boolean {
|
|
||||||
if (!this.validKeys.size) {
|
|
||||||
throw new UnauthorizedException('ADMIN_API_KEYS not configured');
|
|
||||||
}
|
|
||||||
if (!this.validKeys.has(token)) {
|
|
||||||
throw new UnauthorizedException('Invalid API key');
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
37
backend/src/auth/auth.controller.ts
Normal file
37
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Body,
|
||||||
|
Headers,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthService, LoginDto } from './auth.service';
|
||||||
|
import { JwtGuard } from './jwt.guard';
|
||||||
|
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
|
||||||
|
@Controller('api/auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@UseGuards(ThrottlerGuard)
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
|
@Post('login')
|
||||||
|
login(@Body() dto: LoginDto) {
|
||||||
|
return this.authService.login(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
refresh(@Headers('x-refresh-token') token: string) {
|
||||||
|
if (!token) throw new BadRequestException('Refresh token required');
|
||||||
|
return this.authService.refresh(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@Get('me')
|
||||||
|
getMe(@Request() req: any) {
|
||||||
|
return this.authService.getMe(req.user.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,35 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { ApiKeyStrategy } from './api-key.strategy';
|
import { AuthController } from './auth.controller';
|
||||||
import { ApiKeyGuard } from './api-key.guard';
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
|
import { JwtGuard } from './jwt.guard';
|
||||||
|
import { RoleGuard } from './role.guard';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PassportModule, ConfigModule],
|
imports: [
|
||||||
providers: [ApiKeyStrategy, ApiKeyGuard],
|
PrismaModule,
|
||||||
exports: [ApiKeyGuard],
|
PassportModule,
|
||||||
|
ConfigModule,
|
||||||
|
ThrottlerModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: (() => {
|
||||||
|
const s = config.get<string>('JWT_SECRET');
|
||||||
|
if (!s) throw new Error('JWT_SECRET is not configured');
|
||||||
|
return s;
|
||||||
|
})(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy, JwtGuard, RoleGuard],
|
||||||
|
exports: [JwtGuard, RoleGuard, AuthService, JwtModule],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
50
backend/src/auth/auth.service.spec.ts
Normal file
50
backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockJwt = {
|
||||||
|
sign: jest.fn().mockReturnValue('mock.jwt.token'),
|
||||||
|
verify: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
get: jest.fn().mockImplementation((key: string) => {
|
||||||
|
if (key === 'JWT_SECRET') return 'test-secret';
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: JwtService, useValue: mockJwt },
|
||||||
|
{ provide: ConfigService, useValue: mockConfig },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AuthService>(AuthService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('login: 존재하지 않는 사용자 → UnauthorizedException', async () => {
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.login({ username: 'unknown', password: 'password123' }),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
backend/src/auth/auth.service.ts
Normal file
67
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { IsString, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsString() @IsNotEmpty()
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@IsString() @IsNotEmpty()
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async login(dto: LoginDto) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { username: dto.username } });
|
||||||
|
if (!user) throw new UnauthorizedException('Invalid credentials');
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(dto.password, user.password);
|
||||||
|
if (!ok) throw new UnauthorizedException('Invalid credentials');
|
||||||
|
|
||||||
|
return this.signTokens(user.id, user.username, user.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(refreshToken: string) {
|
||||||
|
try {
|
||||||
|
const payload = this.jwt.verify<{ sub: number; username: string; role: string }>(
|
||||||
|
refreshToken,
|
||||||
|
{ secret: this.config.get<string>('JWT_SECRET') + '_refresh' },
|
||||||
|
);
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
|
||||||
|
if (!user) throw new UnauthorizedException();
|
||||||
|
return this.signTokens(user.id, user.username, user.role);
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMe(userId: number) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, username: true, role: true, createdAt: true },
|
||||||
|
});
|
||||||
|
if (!user) throw new UnauthorizedException();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private signTokens(userId: number, username: string, role: string) {
|
||||||
|
const secret = this.config.get<string>('JWT_SECRET');
|
||||||
|
if (!secret) throw new Error('JWT_SECRET is not configured');
|
||||||
|
const payload = { sub: userId, username, role };
|
||||||
|
|
||||||
|
const accessToken = this.jwt.sign(payload, { secret, expiresIn: '15m' });
|
||||||
|
const refreshToken = this.jwt.sign(payload, { secret: secret + '_refresh', expiresIn: '7d' });
|
||||||
|
|
||||||
|
return { accessToken, refreshToken, username, role };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/auth/jwt.guard.ts
Normal file
9
backend/src/auth/jwt.guard.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtGuard extends AuthGuard('jwt') {
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/auth/jwt.strategy.ts
Normal file
28
backend/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const secret = config.get<string>('JWT_SECRET');
|
||||||
|
if (!secret) throw new Error('JWT_SECRET not set');
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: secret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(payload: JwtPayload) {
|
||||||
|
if (!payload?.sub) throw new UnauthorizedException();
|
||||||
|
return { userId: payload.sub, username: payload.username, role: payload.role };
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/src/auth/role.guard.ts
Normal file
29
backend/src/auth/role.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RoleGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles || requiredRoles.length === 0) return true;
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user) throw new ForbiddenException('Authentication required');
|
||||||
|
|
||||||
|
const hasRole = requiredRoles.includes(user.role);
|
||||||
|
if (!hasRole) throw new ForbiddenException(`Role '${user.role}' not authorized. Required: ${requiredRoles.join(', ')}`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Controller, Get, Post, Query, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Query, Param, UseGuards } from '@nestjs/common';
|
||||||
import { CostsService, CostPeriod } from './costs.service';
|
import { CostsService, CostPeriod } from './costs.service';
|
||||||
import { ApiKeyGuard } from '../auth/api-key.guard';
|
import { JwtGuard } from '../auth/jwt.guard';
|
||||||
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
import { SisterNamePipe } from '../common/sister-name.pipe';
|
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||||
import type { SisterName } from '../common/sister-name.pipe';
|
import type { SisterName } from '../common/sister-name.pipe';
|
||||||
|
|
||||||
@Controller('api/admin/costs')
|
@Controller('api/admin/costs')
|
||||||
@UseGuards(ApiKeyGuard)
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
|
@Roles('admin')
|
||||||
export class CostsController {
|
export class CostsController {
|
||||||
constructor(private readonly costsService: CostsService) {}
|
constructor(private readonly costsService: CostsService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -49,19 +49,20 @@ export class CostsService {
|
|||||||
if (!sister) return;
|
if (!sister) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// OpenClaw 세션에서 토큰 사용량 파싱
|
// OpenClaw 세션에서 토큰 사용량 파싱 (python3 → node.js → jq fallback)
|
||||||
const result = await this.ssh.executeCommand(
|
const result = await this.ssh.executeCommand(
|
||||||
sister.ip,
|
sister.ip,
|
||||||
sister.user,
|
sister.user,
|
||||||
keyPath,
|
keyPath,
|
||||||
`cat ~/.openclaw/sessions/main.json 2>/dev/null | python3 -c "
|
`SESSION_FILE=~/.openclaw/sessions/main.json; \
|
||||||
import json,sys
|
if [ ! -f "$SESSION_FILE" ]; then echo "unknown 0 0"; \
|
||||||
try:
|
elif command -v node >/dev/null 2>&1; then \
|
||||||
d=json.load(sys.stdin)
|
node -e "try{const d=require('fs').readFileSync(process.env.HOME+'/.openclaw/sessions/main.json','utf8');const j=JSON.parse(d);const u=j.usage||{};console.log((j.model||'unknown')+' '+(u.input_tokens||0)+' '+(u.output_tokens||0))}catch(e){console.log('unknown 0 0')}" 2>/dev/null; \
|
||||||
usage=d.get('usage',{})
|
elif command -v python3 >/dev/null 2>&1; then \
|
||||||
print(d.get('model','unknown'), usage.get('input_tokens',0), usage.get('output_tokens',0))
|
cat $SESSION_FILE | python3 -c "import json,sys;d=json.load(sys.stdin);u=d.get('usage',{});print(d.get('model','unknown'),u.get('input_tokens',0),u.get('output_tokens',0))" 2>/dev/null; \
|
||||||
except: print('unknown 0 0')
|
elif command -v jq >/dev/null 2>&1; then \
|
||||||
" 2>/dev/null || echo "unknown 0 0"`,
|
echo "$(jq -r '.model // "unknown"' $SESSION_FILE) $(jq -r '.usage.input_tokens // 0' $SESSION_FILE) $(jq -r '.usage.output_tokens // 0' $SESSION_FILE)" 2>/dev/null; \
|
||||||
|
else echo "unknown 0 0"; fi`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const parts = result.stdout.trim().split(' ');
|
const parts = result.stdout.trim().split(' ');
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: {
|
cors: {
|
||||||
@@ -26,12 +28,41 @@ export class EventsGateway
|
|||||||
|
|
||||||
private readonly logger = new Logger(EventsGateway.name);
|
private readonly logger = new Logger(EventsGateway.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
afterInit() {
|
afterInit() {
|
||||||
this.logger.log('WebSocket Gateway initialized');
|
this.logger.log('WebSocket Gateway initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConnection(client: Socket) {
|
handleConnection(client: Socket) {
|
||||||
this.logger.debug(`Client connected: ${client.id}`);
|
// JWT 인증 필수 — 토큰 없거나 유효하지 않으면 disconnect
|
||||||
|
const token =
|
||||||
|
(client.handshake.auth?.token as string) ??
|
||||||
|
(client.handshake.headers.authorization as string)?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logger.debug(`WS rejected (no token): ${client.id}`);
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = this.config.get<string>('JWT_SECRET');
|
||||||
|
if (!secret) {
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = this.jwtService.verify(token, { secret });
|
||||||
|
(client as any).user = payload;
|
||||||
|
this.logger.debug(`WS client connected: ${client.id} (${payload.username})`);
|
||||||
|
} catch {
|
||||||
|
this.logger.debug(`WS rejected (invalid token): ${client.id}`);
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(client: Socket) {
|
handleDisconnect(client: Socket) {
|
||||||
@@ -55,6 +86,6 @@ export class EventsGateway
|
|||||||
|
|
||||||
/** 연결된 클라이언트 수 */
|
/** 연결된 클라이언트 수 */
|
||||||
getClientCount(): number {
|
getClientCount(): number {
|
||||||
return this.server.sockets.sockets.size;
|
return this.server?.sockets?.sockets?.size ?? 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { EventsGateway } from './events.gateway';
|
import { EventsGateway } from './events.gateway';
|
||||||
import { EventsScheduler } from './events.scheduler';
|
import { EventsScheduler } from './events.scheduler';
|
||||||
import { SistersModule } from '../sisters/sisters.module';
|
import { SistersModule } from '../sisters/sisters.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SistersModule],
|
imports: [
|
||||||
|
SistersModule,
|
||||||
|
ConfigModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: (() => { const s = config.get<string>('JWT_SECRET'); if (!s) throw new Error('JWT_SECRET is not configured'); return s; })(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
providers: [EventsGateway, EventsScheduler],
|
providers: [EventsGateway, EventsScheduler],
|
||||||
exports: [EventsGateway],
|
exports: [EventsGateway],
|
||||||
})
|
})
|
||||||
|
|||||||
26
backend/src/events/sisters.scheduler.ts
Normal file
26
backend/src/events/sisters.scheduler.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { SistersService } from '../sisters/sisters.service';
|
||||||
|
import { EventsGateway } from './events.gateway';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SistersScheduler {
|
||||||
|
private readonly logger = new Logger(SistersScheduler.name);
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sistersService: SistersService,
|
||||||
|
private readonly eventsGateway: EventsGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.timer) return;
|
||||||
|
this.timer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const sisters = await this.sistersService.getAllSistersStatus();
|
||||||
|
this.eventsGateway.broadcastSisterStatus(sisters);
|
||||||
|
} catch {
|
||||||
|
this.logger.warn('Failed to broadcast sisters update');
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/src/gitea-sync/gitea-sync.controller.ts
Normal file
21
backend/src/gitea-sync/gitea-sync.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Controller, Post, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { GiteaSyncService } from './gitea-sync.service';
|
||||||
|
import { JwtGuard } from '../auth/jwt.guard';
|
||||||
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
|
|
||||||
|
@Controller('api/admin/gitea')
|
||||||
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
export class GiteaSyncController {
|
||||||
|
constructor(private readonly giteaSyncService: GiteaSyncService) {}
|
||||||
|
|
||||||
|
@Post('sync')
|
||||||
|
sync() {
|
||||||
|
return this.giteaSyncService.syncRepos();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
status() {
|
||||||
|
return { lastSyncAt: this.giteaSyncService.getLastSyncAt() };
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/gitea-sync/gitea-sync.module.ts
Normal file
16
backend/src/gitea-sync/gitea-sync.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GiteaSyncController } from './gitea-sync.controller';
|
||||||
|
import { GiteaSyncService } from './gitea-sync.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { GiteaModule } from '../gitea/gitea.module';
|
||||||
|
import { ActivityModule } from '../activity/activity.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { ProjectsModule } from '../projects/projects.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, GiteaModule, ActivityModule, AuthModule, ProjectsModule],
|
||||||
|
controllers: [GiteaSyncController],
|
||||||
|
providers: [GiteaSyncService],
|
||||||
|
exports: [GiteaSyncService],
|
||||||
|
})
|
||||||
|
export class GiteaSyncModule {}
|
||||||
87
backend/src/gitea-sync/gitea-sync.service.ts
Normal file
87
backend/src/gitea-sync/gitea-sync.service.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { GiteaService } from '../gitea/gitea.service';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
import { SprintSyncService } from '../projects/sprint-sync.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GiteaSyncService {
|
||||||
|
private readonly logger = new Logger(GiteaSyncService.name);
|
||||||
|
private lastSyncAt: Date | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly gitea: GiteaService,
|
||||||
|
private readonly activity: ActivityService,
|
||||||
|
private readonly sprintSync: SprintSyncService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async syncRepos(): Promise<{ synced: number; created: number; updated: number }> {
|
||||||
|
const repos = await this.gitea.getOrgRepos();
|
||||||
|
|
||||||
|
if (!repos.length) {
|
||||||
|
this.logger.warn('Gitea returned 0 repos (unavailable or empty org)');
|
||||||
|
return { synced: 0, created: 0, updated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const repo of repos) {
|
||||||
|
const existing = await this.prisma.project.findUnique({
|
||||||
|
where: { giteaId: repo.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await this.prisma.project.create({
|
||||||
|
data: {
|
||||||
|
giteaId: repo.id,
|
||||||
|
name: repo.name,
|
||||||
|
repoUrl: repo.html_url,
|
||||||
|
description: repo.description ?? null,
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
|
||||||
|
await this.activity.log({
|
||||||
|
action: 'gitea_sync_new_repo',
|
||||||
|
detail: `New project synced from Gitea: [${repo.name}]`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.prisma.project.update({
|
||||||
|
where: { giteaId: repo.id },
|
||||||
|
data: {
|
||||||
|
name: repo.name,
|
||||||
|
repoUrl: repo.html_url,
|
||||||
|
description: repo.description ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastSyncAt = new Date();
|
||||||
|
|
||||||
|
if (created > 0 || updated > 0) {
|
||||||
|
await this.activity.log({
|
||||||
|
action: 'gitea_sync_complete',
|
||||||
|
detail: `Gitea sync: ${created} created, ${updated} updated`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Gitea sync: ${repos.length} repos, ${created} new, ${updated} updated`);
|
||||||
|
|
||||||
|
// Sprint 동기화도 함께 수행
|
||||||
|
await this.sprintSync.syncAllProjectSprints().catch((e) => {
|
||||||
|
this.logger.warn(`Sprint sync failed: ${e.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { synced: repos.length, created, updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastSyncAt() {
|
||||||
|
return this.lastSyncAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,24 @@ export interface GiteaPR {
|
|||||||
html_url: string;
|
html_url: string;
|
||||||
user: { login: string };
|
user: { login: string };
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
merged: boolean;
|
||||||
|
merged_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaCommit {
|
||||||
|
sha: string;
|
||||||
|
commit: {
|
||||||
|
message: string;
|
||||||
|
author: { name: string; date: string };
|
||||||
|
};
|
||||||
|
author?: { login: string; avatar_url: string } | null;
|
||||||
|
html_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaBranch {
|
||||||
|
name: string;
|
||||||
|
commit: { id: string; created: string };
|
||||||
|
protected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -87,4 +105,70 @@ export class GiteaService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCommits(repoName: string, limit = 20): Promise<GiteaCommit[]> {
|
||||||
|
if (!this.isAvailable()) return [];
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<GiteaCommit[]>(
|
||||||
|
`/repos/${this.org}/${repoName}/commits`,
|
||||||
|
{ params: { limit } },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Failed to fetch commits for ${repoName}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBranches(repoName: string): Promise<GiteaBranch[]> {
|
||||||
|
if (!this.isAvailable()) return [];
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<GiteaBranch[]>(
|
||||||
|
`/repos/${this.org}/${repoName}/branches`,
|
||||||
|
{ params: { limit: 50 } },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPulls(repoName: string, state: 'open' | 'closed' | 'all' = 'open'): Promise<GiteaPR[]> {
|
||||||
|
if (!this.isAvailable()) return [];
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<GiteaPR[]>(
|
||||||
|
`/repos/${this.org}/${repoName}/pulls`,
|
||||||
|
{ params: { state, limit: 30, type: 'pulls' } },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRepoTree(repoName: string, treePath: string): Promise<string[]> {
|
||||||
|
if (!this.isAvailable()) return [];
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<{ tree: { path: string; type: string }[] }>(
|
||||||
|
`/repos/${this.org}/${repoName}/git/trees/HEAD`,
|
||||||
|
{ params: { recursive: true } },
|
||||||
|
);
|
||||||
|
return (data.tree ?? []).filter((e) => e.path.startsWith(treePath) && e.type === 'blob').map((e) => e.path);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRawFile(repoName: string, filePath: string): Promise<string | null> {
|
||||||
|
if (!this.isAvailable()) return null;
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<string>(
|
||||||
|
`/repos/${this.org}/${repoName}/raw/${filePath}`,
|
||||||
|
{ params: { ref: 'main' }, responseType: 'text' },
|
||||||
|
);
|
||||||
|
return typeof data === 'string' ? data : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
|
import { Controller, Get, Param, Query, ParseIntPipe, UseGuards, Post } from '@nestjs/common';
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
|
import { SprintSyncService } from './sprint-sync.service';
|
||||||
|
import { JwtGuard } from '../auth/jwt.guard';
|
||||||
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
|
|
||||||
@Controller('api/projects')
|
@Controller('api/projects')
|
||||||
export class ProjectsController {
|
export class ProjectsController {
|
||||||
constructor(private readonly projectsService: ProjectsService) {}
|
constructor(
|
||||||
|
private readonly projectsService: ProjectsService,
|
||||||
|
private readonly sprintSyncService: SprintSyncService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
getProjects() {
|
getProjects() {
|
||||||
@@ -14,4 +20,41 @@ export class ProjectsController {
|
|||||||
getProjectById(@Param('id', ParseIntPipe) id: number) {
|
getProjectById(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.projectsService.getProjectById(id);
|
return this.projectsService.getProjectById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/commits')
|
||||||
|
getCommits(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
const parsed = limit ? parseInt(limit, 10) : 20;
|
||||||
|
const clamped = Math.min(Math.max(isNaN(parsed) ? 20 : parsed, 1), 100);
|
||||||
|
return this.projectsService.getProjectCommits(id, clamped);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/branches')
|
||||||
|
getBranches(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.projectsService.getProjectBranches(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/pulls')
|
||||||
|
getPulls(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('state') state?: 'open' | 'closed' | 'all',
|
||||||
|
) {
|
||||||
|
return this.projectsService.getProjectPulls(id, state ?? 'open');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/sync-sprints')
|
||||||
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
syncSprints(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.sprintSyncService.syncProjectSprints(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sync-all-sprints')
|
||||||
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
syncAllSprints() {
|
||||||
|
return this.sprintSyncService.syncAllProjectSprints();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ProjectsController } from './projects.controller';
|
import { ProjectsController } from './projects.controller';
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
|
import { SprintSyncService } from './sprint-sync.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { GiteaModule } from '../gitea/gitea.module';
|
import { GiteaModule } from '../gitea/gitea.module';
|
||||||
import { ActivityModule } from '../activity/activity.module';
|
import { ActivityModule } from '../activity/activity.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, GiteaModule, ActivityModule],
|
imports: [PrismaModule, GiteaModule, ActivityModule, AuthModule],
|
||||||
controllers: [ProjectsController],
|
controllers: [ProjectsController],
|
||||||
providers: [ProjectsService],
|
providers: [ProjectsService, SprintSyncService],
|
||||||
|
exports: [ProjectsService, SprintSyncService],
|
||||||
})
|
})
|
||||||
export class ProjectsModule {}
|
export class ProjectsModule {}
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ export class ProjectsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return dbProjects.map((p) => {
|
return dbProjects.map((p) => {
|
||||||
const totalTasks = p.sprints.flatMap((s) => s.tasks).length;
|
// Sprint 기반 진행률 (백엔드 task 없어도 동작)
|
||||||
const doneTasks = p.sprints.flatMap((s) => s.tasks).filter((t) => t.status === 'done').length;
|
const totalSprints = p.sprints.length;
|
||||||
|
const doneSprints = p.sprints.filter((s) => s.status === 'done').length;
|
||||||
|
const inProgressSprint = p.sprints.find((s) => s.status === 'in_progress');
|
||||||
|
|
||||||
|
const progress = totalSprints > 0 ? Math.round((doneSprints / totalSprints) * 100) : 0;
|
||||||
const gitea = giteaRepos[p.giteaId];
|
const gitea = giteaRepos[p.giteaId];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -45,13 +49,13 @@ export class ProjectsService {
|
|||||||
repoUrl: p.repoUrl,
|
repoUrl: p.repoUrl,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
progress: totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0,
|
progress,
|
||||||
totalTasks,
|
totalSprints,
|
||||||
doneTasks,
|
doneSprints,
|
||||||
openPRs: gitea?.openPRs ?? 0,
|
openPRs: gitea?.openPRs ?? 0,
|
||||||
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
|
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
|
||||||
sprintCount: p.sprints.length,
|
sprintCount: totalSprints,
|
||||||
currentSprint: p.sprints.find((s) => s.status === 'in_progress')?.name ?? null,
|
currentSprint: inProgressSprint?.name ?? (doneSprints === totalSprints && totalSprints > 0 ? 'COMPLETED' : null),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -79,4 +83,28 @@ export class ProjectsService {
|
|||||||
|
|
||||||
return { ...project, openPRs: giteaPRs };
|
return { ...project, openPRs: giteaPRs };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProjectCommits(id: number, limit = 20) {
|
||||||
|
const project = await this.getProjectMeta(id);
|
||||||
|
const repoName = project.repoUrl.split('/').pop() ?? '';
|
||||||
|
return this.gitea.getCommits(repoName, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectBranches(id: number) {
|
||||||
|
const project = await this.getProjectMeta(id);
|
||||||
|
const repoName = project.repoUrl.split('/').pop() ?? '';
|
||||||
|
return this.gitea.getBranches(repoName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectPulls(id: number, state: 'open' | 'closed' | 'all' = 'open') {
|
||||||
|
const project = await this.getProjectMeta(id);
|
||||||
|
const repoName = project.repoUrl.split('/').pop() ?? '';
|
||||||
|
return this.gitea.getPulls(repoName, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProjectMeta(id: number) {
|
||||||
|
const project = await this.prisma.project.findUnique({ where: { id }, select: { id: true, repoUrl: true } });
|
||||||
|
if (!project) throw new NotFoundException(`Project ${id} not found`);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
212
backend/src/projects/sprint-sync.service.ts
Normal file
212
backend/src/projects/sprint-sync.service.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { GiteaService } from '../gitea/gitea.service';
|
||||||
|
|
||||||
|
interface ParsedSprint {
|
||||||
|
number: number;
|
||||||
|
name: string;
|
||||||
|
status: 'pending' | 'in_progress' | 'done';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedTask {
|
||||||
|
taskId: string;
|
||||||
|
title: string;
|
||||||
|
status: 'pending' | 'done';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSprintName(content: string, filename: string, number: number): string {
|
||||||
|
// 첫 번째 heading에서 이름 추출: # Sprint XXX — 이름 / # SPRINT-XXX: 이름
|
||||||
|
const headingMatch = content.match(/^#\s+(?:SPRINT-\d+|Sprint\s+\d+)[:\s—\-]+(.+)/m);
|
||||||
|
if (headingMatch) return headingMatch[1].trim();
|
||||||
|
return `SPRINT-${String(number).padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferStatusFromQaContent(qaContent: string): 'done' | null {
|
||||||
|
// QA 파일에 "결과: ✅ PASSED" 또는 "PASSED" 포함 시 done
|
||||||
|
if (/PASSED/i.test(qaContent)) return 'done';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTasks(content: string, sprintStatus: 'pending' | 'in_progress' | 'done'): ParsedTask[] {
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const tasks: ParsedTask[] = [];
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
let match: RegExpMatchArray | null = null;
|
||||||
|
let status: 'pending' | 'done' | null = null;
|
||||||
|
|
||||||
|
// ### TASK-001: title
|
||||||
|
match = line.match(/^#{2,6}\s+(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
|
||||||
|
if (match) {
|
||||||
|
status = sprintStatus === 'done' ? 'done' : 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
// - [x] TASK-001 title / - [ ] TASK-001 title
|
||||||
|
if (!match) {
|
||||||
|
match = line.match(/^-\s*\[([ xX])\]\s*(TASK-\d+[A-Z]?)\s*[:\-]?\s*(.+)$/i);
|
||||||
|
if (match) {
|
||||||
|
status = /x/i.test(match[1]) ? 'done' : 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// - TASK-001: title
|
||||||
|
if (!match) {
|
||||||
|
match = line.match(/^-\s*(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
|
||||||
|
if (match) {
|
||||||
|
status = sprintStatus === 'done' ? 'done' : 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TASK-001: title
|
||||||
|
if (!match) {
|
||||||
|
match = line.match(/^(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
|
||||||
|
if (match) {
|
||||||
|
status = sprintStatus === 'done' ? 'done' : 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match || !status) continue;
|
||||||
|
|
||||||
|
const taskId = (match[1] ?? match[2]).toUpperCase();
|
||||||
|
const title = (match[2] ?? match[3] ?? '').trim();
|
||||||
|
if (!taskId || seen.has(taskId)) continue;
|
||||||
|
seen.add(taskId);
|
||||||
|
tasks.push({ taskId, title: title || taskId, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SprintSyncService {
|
||||||
|
private readonly logger = new Logger(SprintSyncService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly gitea: GiteaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async syncProjectSprints(projectId: number): Promise<{ synced: number; created: number; updated: number }> {
|
||||||
|
const project = await this.prisma.project.findUnique({ where: { id: projectId } });
|
||||||
|
if (!project) return { synced: 0, created: 0, updated: 0 };
|
||||||
|
|
||||||
|
const repoName = project.repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
|
||||||
|
return this.syncRepo(projectId, repoName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncAllProjectSprints(): Promise<{ projects: number; synced: number }> {
|
||||||
|
const projects = await this.prisma.project.findMany({ select: { id: true, repoUrl: true } });
|
||||||
|
let totalSynced = 0;
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
const repoName = project.repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
|
||||||
|
const r = await this.syncRepo(project.id, repoName);
|
||||||
|
totalSynced += r.synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { projects: projects.length, synced: totalSynced };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncRepo(projectId: number, repoName: string): Promise<{ synced: number; created: number; updated: number }> {
|
||||||
|
const [sprintFilePaths, qaFilePaths] = await Promise.all([
|
||||||
|
this.gitea.getRepoTree(repoName, '.plans/sprints/'),
|
||||||
|
this.gitea.getRepoTree(repoName, '.qa/'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sprintFiles = sprintFilePaths.filter((f) => /SPRINT-\d+\.md$/i.test(f));
|
||||||
|
const qaFiles = qaFilePaths.filter((f) => /SPRINT-\d+-review-.*\.md$/i.test(f));
|
||||||
|
|
||||||
|
if (!sprintFiles.length) {
|
||||||
|
this.logger.warn(`No sprint files found in ${repoName}/.plans/sprints/`);
|
||||||
|
return { synced: 0, created: 0, updated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// QA 파일에서 PASSED 여부 수집: number → boolean
|
||||||
|
const passedMap = new Map<number, boolean>();
|
||||||
|
for (const qaPath of qaFiles) {
|
||||||
|
const numMatch = qaPath.match(/SPRINT-(\d+)-review/i);
|
||||||
|
if (!numMatch) continue;
|
||||||
|
const num = parseInt(numMatch[1], 10);
|
||||||
|
if (passedMap.get(num)) continue; // 이미 통과 확인됨
|
||||||
|
|
||||||
|
const qaContent = await this.gitea.getRawFile(repoName, qaPath);
|
||||||
|
if (!qaContent) continue;
|
||||||
|
if (inferStatusFromQaContent(qaContent)) {
|
||||||
|
passedMap.set(num, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const filePath of sprintFiles) {
|
||||||
|
const content = await this.gitea.getRawFile(repoName, filePath);
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
const filename = filePath.split('/').pop() ?? '';
|
||||||
|
const numMatch = filename.match(/SPRINT-(\d+)/i);
|
||||||
|
if (!numMatch) continue;
|
||||||
|
const number = parseInt(numMatch[1], 10);
|
||||||
|
const name = parseSprintName(content, filename, number);
|
||||||
|
|
||||||
|
// QA 통과 여부로 status 결정 (본문 전체 키워드 검색 금지)
|
||||||
|
const status: 'pending' | 'done' = passedMap.get(number) ? 'done' : 'pending';
|
||||||
|
const parsedTasks = parseTasks(content, status);
|
||||||
|
|
||||||
|
const existing = await this.prisma.sprint.findFirst({
|
||||||
|
where: { projectId, number },
|
||||||
|
});
|
||||||
|
|
||||||
|
let sprintId: number;
|
||||||
|
if (!existing) {
|
||||||
|
const createdSprint = await this.prisma.sprint.create({
|
||||||
|
data: { projectId, number, name, status },
|
||||||
|
});
|
||||||
|
sprintId = createdSprint.id;
|
||||||
|
created++;
|
||||||
|
} else {
|
||||||
|
const updatedSprint = await this.prisma.sprint.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
status: existing.status === 'done' ? 'done' : status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
sprintId = updatedSprint.id;
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sprint 문서 기준 Task upsert
|
||||||
|
for (const task of parsedTasks) {
|
||||||
|
const existingTask = await this.prisma.task.findFirst({
|
||||||
|
where: { sprintId, taskId: task.taskId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingTask) {
|
||||||
|
await this.prisma.task.create({
|
||||||
|
data: {
|
||||||
|
sprintId,
|
||||||
|
taskId: task.taskId,
|
||||||
|
title: task.title,
|
||||||
|
assignee: 'narang',
|
||||||
|
status: task.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.prisma.task.update({
|
||||||
|
where: { id: existingTask.id },
|
||||||
|
data: {
|
||||||
|
title: task.title,
|
||||||
|
status: existingTask.status === 'done' ? 'done' : task.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`${repoName}: ${sprintFiles.length} sprints, ${passedMap.size} QA passed → ${created} created, ${updated} updated`);
|
||||||
|
return { synced: sprintFiles.length, created, updated };
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/settings/settings.controller.ts
Normal file
26
backend/src/settings/settings.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
import { JwtGuard } from '../auth/jwt.guard';
|
||||||
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
|
|
||||||
|
@Controller('api/admin/settings')
|
||||||
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
export class SettingsController {
|
||||||
|
constructor(private readonly settingsService: SettingsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getAll() {
|
||||||
|
return this.settingsService.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('defaults')
|
||||||
|
getDefaults() {
|
||||||
|
return this.settingsService.getDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
saveAll(@Body() body: Record<string, string>) {
|
||||||
|
return this.settingsService.saveAll(body ?? {});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/settings/settings.module.ts
Normal file
13
backend/src/settings/settings.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SettingsController } from './settings.controller';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { ActivityModule } from '../activity/activity.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, ActivityModule],
|
||||||
|
controllers: [SettingsController],
|
||||||
|
providers: [SettingsService],
|
||||||
|
exports: [SettingsService],
|
||||||
|
})
|
||||||
|
export class SettingsModule {}
|
||||||
58
backend/src/settings/settings.service.ts
Normal file
58
backend/src/settings/settings.service.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: Record<string, string> = {
|
||||||
|
force2fa: 'true',
|
||||||
|
sessionTimeoutMinutes: '15',
|
||||||
|
ipWhitelistEnabled: 'false',
|
||||||
|
autoBackupEnabled: 'true',
|
||||||
|
backupSchedule: '08:00',
|
||||||
|
retentionDays: '30',
|
||||||
|
queueWarningThreshold: '500',
|
||||||
|
autoScalingEnabled: 'true',
|
||||||
|
cpuThresholdPercent: '90',
|
||||||
|
latencyAlertMs: '250',
|
||||||
|
nodeOfflineAlertEnabled: 'true',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SettingsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly activity: ActivityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getAll() {
|
||||||
|
const rows = await this.prisma.systemSettings.findMany({ orderBy: { key: 'asc' } });
|
||||||
|
const merged = { ...DEFAULT_SETTINGS };
|
||||||
|
for (const row of rows) merged[row.key] = row.value;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAll(payload: Record<string, string>) {
|
||||||
|
// DEFAULT_SETTINGS에 있는 키만 허용
|
||||||
|
const allowed = Object.keys(DEFAULT_SETTINGS);
|
||||||
|
const entries = Object.entries(payload).filter(([key]) => allowed.includes(key));
|
||||||
|
await this.prisma.$transaction(
|
||||||
|
entries.map(([key, value]) =>
|
||||||
|
this.prisma.systemSettings.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value: String(value) },
|
||||||
|
create: { key, value: String(value) },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.activity.log({
|
||||||
|
action: 'settings_updated',
|
||||||
|
detail: `System settings updated: ${entries.length} keys`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaults() {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
77
backend/src/sisters/avatar.service.ts
Normal file
77
backend/src/sisters/avatar.service.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { SshService } from './ssh.service';
|
||||||
|
|
||||||
|
interface AvatarCacheItem {
|
||||||
|
expiresAt: number;
|
||||||
|
contentType: string;
|
||||||
|
data: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AvatarService {
|
||||||
|
private readonly logger = new Logger(AvatarService.name);
|
||||||
|
private readonly cache = new Map<string, AvatarCacheItem>();
|
||||||
|
private readonly ttlMs = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ssh: SshService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getAvatar(sister: { name: string; ip: string; user: string }) {
|
||||||
|
const cached = this.cache.get(sister.name);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
|
||||||
|
if (!sshKeyPath) {
|
||||||
|
return this.getFallback(sister.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
{ path: '~/.openclaw/avatar.png', type: 'image/png' },
|
||||||
|
{ path: '~/.openclaw/avatar.jpg', type: 'image/jpeg' },
|
||||||
|
{ path: '~/.openclaw/avatar.jpeg', type: 'image/jpeg' },
|
||||||
|
{ path: '~/.openclaw/avatar.webp', type: 'image/webp' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const result = await this.ssh.executeCommand(
|
||||||
|
sister.ip,
|
||||||
|
sister.user,
|
||||||
|
sshKeyPath,
|
||||||
|
`if [ -f ${file.path} ]; then base64 -w0 ${file.path}; fi`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.stdout.trim()) {
|
||||||
|
const item = {
|
||||||
|
contentType: file.type,
|
||||||
|
data: Buffer.from(result.stdout.trim(), 'base64'),
|
||||||
|
expiresAt: Date.now() + this.ttlMs,
|
||||||
|
};
|
||||||
|
this.cache.set(sister.name, item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Avatar fetch failed for ${sister.name}`);
|
||||||
|
// 실패 시 오래된 캐시 삭제 (다음 요청에서 재시도)
|
||||||
|
this.cache.delete(sister.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getFallback(sister.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFallback(name: string) {
|
||||||
|
const initial = name.slice(0, 1).toUpperCase();
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="100%" height="100%" fill="#111"/><text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="56" fill="#f5f5f5">${initial}</text></svg>`;
|
||||||
|
return {
|
||||||
|
contentType: 'image/svg+xml',
|
||||||
|
data: Buffer.from(svg),
|
||||||
|
expiresAt: Date.now() + this.ttlMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { Controller, Get, Param } from '@nestjs/common';
|
import { Controller, Get, Param, Res } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
import { SistersService } from './sisters.service';
|
import { SistersService } from './sisters.service';
|
||||||
import { SisterDetailService } from './sister-detail.service';
|
import { SisterDetailService } from './sister-detail.service';
|
||||||
import { SisterNamePipe } from '../common/sister-name.pipe';
|
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||||
import type { SisterName } from '../common/sister-name.pipe';
|
import type { SisterName } from '../common/sister-name.pipe';
|
||||||
|
import { AvatarService } from './avatar.service';
|
||||||
|
|
||||||
@Controller('api/sisters')
|
@Controller('api/sisters')
|
||||||
export class SistersController {
|
export class SistersController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sistersService: SistersService,
|
private readonly sistersService: SistersService,
|
||||||
private readonly sisterDetail: SisterDetailService,
|
private readonly sisterDetail: SisterDetailService,
|
||||||
|
private readonly avatarService: AvatarService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -16,6 +19,27 @@ export class SistersController {
|
|||||||
return this.sistersService.getAllSistersStatus();
|
return this.sistersService.getAllSistersStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':name/system')
|
||||||
|
async getSystemInfo(@Param('name', SisterNamePipe) name: SisterName) {
|
||||||
|
return this.sistersService.getSystemInfo(name as SisterName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':name/avatar')
|
||||||
|
async getAvatar(
|
||||||
|
@Param('name', SisterNamePipe) name: SisterName,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const sister = await this.sistersService.findByName(name as SisterName);
|
||||||
|
if (!sister) {
|
||||||
|
return res.status(404).json({ message: 'Sister not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatar = await this.avatarService.getAvatar(sister);
|
||||||
|
res.setHeader('Content-Type', avatar.contentType);
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||||
|
return res.send(avatar.data);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':name/config')
|
@Get(':name/config')
|
||||||
async getSisterConfig(@Param('name', SisterNamePipe) name: SisterName) {
|
async getSisterConfig(@Param('name', SisterNamePipe) name: SisterName) {
|
||||||
return this.sisterDetail.getSisterConfig(name as SisterName);
|
return this.sisterDetail.getSisterConfig(name as SisterName);
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { SistersService } from './sisters.service';
|
|||||||
import { SisterDetailService } from './sister-detail.service';
|
import { SisterDetailService } from './sister-detail.service';
|
||||||
import { SshService } from './ssh.service';
|
import { SshService } from './ssh.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { AvatarService } from './avatar.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule, ConfigModule],
|
||||||
controllers: [SistersController],
|
controllers: [SistersController],
|
||||||
providers: [SistersService, SisterDetailService, SshService],
|
providers: [SistersService, SisterDetailService, SshService, AvatarService],
|
||||||
exports: [SistersService, SisterDetailService, SshService],
|
exports: [SistersService, SisterDetailService, SshService, AvatarService],
|
||||||
})
|
})
|
||||||
export class SistersModule {}
|
export class SistersModule {}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, Optional } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SshService } from './ssh.service';
|
import { SshService } from './ssh.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
|
||||||
export interface SisterStatus {
|
export interface SisterStatus {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -29,6 +30,7 @@ export class SistersService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly ssh: SshService,
|
private readonly ssh: SshService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
@Optional() private readonly activity?: ActivityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAllSistersStatus(): Promise<SisterStatus[]> {
|
async getAllSistersStatus(): Promise<SisterStatus[]> {
|
||||||
@@ -46,7 +48,6 @@ export class SistersService {
|
|||||||
if (result.status === 'fulfilled') {
|
if (result.status === 'fulfilled') {
|
||||||
return result.value;
|
return result.value;
|
||||||
}
|
}
|
||||||
// graceful fallback
|
|
||||||
const sister = sisters[index];
|
const sister = sisters[index];
|
||||||
return {
|
return {
|
||||||
id: sister.id,
|
id: sister.id,
|
||||||
@@ -61,8 +62,33 @@ export class SistersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByName(name: string) {
|
||||||
|
return this.prisma.sisterConfig.findUnique({ where: { name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSystemInfo(name: string) {
|
||||||
|
const sister = await this.prisma.sisterConfig.findUnique({ where: { name } });
|
||||||
|
if (!sister) return null;
|
||||||
|
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
|
||||||
|
if (!sshKeyPath) return null;
|
||||||
|
|
||||||
|
const [uptimeRes, cpuRes, memRes, diskRes] = await Promise.all([
|
||||||
|
this.ssh.executeCommand(sister.ip, sister.user, sshKeyPath, 'cat /proc/uptime 2>/dev/null || echo "0 0"').catch(() => ({ stdout: '0 0', stderr: '', code: 0 })),
|
||||||
|
this.ssh.executeCommand(sister.ip, sister.user, sshKeyPath, "top -bn1 | grep Cpu || echo '%Cpu(s): 0.0 us, 0.0 sy, 100.0 id'").catch(() => ({ stdout: '%Cpu(s): 0.0 us, 0.0 sy, 100.0 id', stderr: '', code: 0 })),
|
||||||
|
this.ssh.executeCommand(sister.ip, sister.user, sshKeyPath, 'free -m | grep Mem || echo "Mem: 0 0 0 0 0 0"').catch(() => ({ stdout: 'Mem: 0 0 0 0 0 0', stderr: '', code: 0 })),
|
||||||
|
this.ssh.executeCommand(sister.ip, sister.user, sshKeyPath, "df -h / | tail -1 || echo '/dev/root 0G 0G 0G 0% /'").catch(() => ({ stdout: '/dev/root 0G 0G 0G 0% /', stderr: '', code: 0 })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uptime: this.parseUptime(uptimeRes.stdout),
|
||||||
|
cpu: this.parseCpu(cpuRes.stdout),
|
||||||
|
memory: this.parseMemory(memRes.stdout),
|
||||||
|
disk: this.parseDisk(diskRes.stdout),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async checkSisterStatus(
|
private async checkSisterStatus(
|
||||||
sister: { id: number; name: string; ip: string; user: string; lxcId: number; lastSeen: Date | null },
|
sister: { id: number; name: string; ip: string; user: string; lxcId: number; lastSeen: Date | null; status: string },
|
||||||
sshKeyPath: string,
|
sshKeyPath: string,
|
||||||
): Promise<SisterStatus> {
|
): Promise<SisterStatus> {
|
||||||
try {
|
try {
|
||||||
@@ -75,13 +101,20 @@ export class SistersService {
|
|||||||
|
|
||||||
const isActive = result.stdout.trim() === 'active';
|
const isActive = result.stdout.trim() === 'active';
|
||||||
const status: 'online' | 'offline' = isActive ? 'online' : 'offline';
|
const status: 'online' | 'offline' = isActive ? 'online' : 'offline';
|
||||||
|
const prevStatus = sister.status;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
if (isActive) {
|
await this.prisma.sisterConfig.update({
|
||||||
// 온라인이면 lastSeen 업데이트
|
where: { id: sister.id },
|
||||||
await this.prisma.sisterConfig.update({
|
data: { lastSeen: isActive ? now : sister.lastSeen, status },
|
||||||
where: { id: sister.id },
|
});
|
||||||
data: { lastSeen: new Date(), status },
|
|
||||||
});
|
if (prevStatus !== status && this.activity) {
|
||||||
|
await this.activity.log({
|
||||||
|
sisterId: sister.id,
|
||||||
|
action: 'status_changed',
|
||||||
|
detail: `[${sister.name}] status: ${prevStatus} → ${status}`,
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -91,7 +124,7 @@ export class SistersService {
|
|||||||
lxcId: sister.lxcId,
|
lxcId: sister.lxcId,
|
||||||
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
||||||
status,
|
status,
|
||||||
lastSeen: isActive ? new Date() : sister.lastSeen,
|
lastSeen: isActive ? now : sister.lastSeen,
|
||||||
currentTask: null,
|
currentTask: null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -108,4 +141,30 @@ export class SistersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseUptime(stdout: string) {
|
||||||
|
const sec = Math.floor(parseFloat(stdout.split(' ')[0] || '0'));
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCpu(stdout: string) {
|
||||||
|
const idleMatch = stdout.match(/([0-9]+(?:\.[0-9]+)?)\s*id/);
|
||||||
|
const idle = idleMatch ? parseFloat(idleMatch[1]) : 100;
|
||||||
|
return Math.max(0, Math.min(100, Number((100 - idle).toFixed(1))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseMemory(stdout: string) {
|
||||||
|
const parts = stdout.trim().split(/\s+/);
|
||||||
|
const total = parseInt(parts[1] || '0', 10);
|
||||||
|
const used = parseInt(parts[2] || '0', 10);
|
||||||
|
return { used, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDisk(stdout: string) {
|
||||||
|
const parts = stdout.trim().split(/\s+/);
|
||||||
|
return { used: parts[2] || '0G', total: parts[1] || '0G' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,15 @@ export default function HarnessPage() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify({ content }),
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
setResult({ success: false, msg: '인증 실패 — API Key 또는 JWT 토큰을 확인해' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
setResult({ success: false, msg: `서버 오류 ${res.status}: ${text.slice(0, 100)}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (d.success) {
|
if (d.success) {
|
||||||
setSaved(content);
|
setSaved(content);
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { LabelMeta, SectionTitle } from '@/components/ui/base';
|
|
||||||
|
|
||||||
const ADMIN_TABS = [
|
const ADMIN_TABS = [
|
||||||
{ href: '/admin', label: '자매 관리' },
|
{ href: '/admin', label: '자매 관리' },
|
||||||
@@ -39,46 +38,11 @@ const TabLink = styled(Link)<{ $active: boolean }>`
|
|||||||
padding: var(--space-sm) 0;
|
padding: var(--space-sm) 0;
|
||||||
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
||||||
transition: color 0.15s, border-color 0.15s;
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
|
||||||
&:hover { color: var(--text-primary); }
|
&:hover { color: var(--text-primary); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ApiKeyRow = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ApiKeyInput = styled.input`
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
outline: none;
|
|
||||||
flex: 1;
|
|
||||||
max-width: 300px;
|
|
||||||
padding: 2px 0;
|
|
||||||
|
|
||||||
&:focus { border-bottom-color: var(--text-primary); }
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [apiKey, setApiKey] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setApiKey(localStorage.getItem('hanarang_admin_key') ?? '');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const saveKey = (v: string) => {
|
|
||||||
setApiKey(v);
|
|
||||||
localStorage.setItem('hanarang_admin_key', v);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
@@ -94,18 +58,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
))}
|
))}
|
||||||
</TabNav>
|
</TabNav>
|
||||||
</AdminHeader>
|
</AdminHeader>
|
||||||
|
|
||||||
<ApiKeyRow>
|
|
||||||
<span>KEY [</span>
|
|
||||||
<ApiKeyInput
|
|
||||||
type="password"
|
|
||||||
placeholder="ADMIN_API_KEY"
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => saveKey(e.target.value)}
|
|
||||||
/>
|
|
||||||
<span>]</span>
|
|
||||||
</ApiKeyRow>
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import LogTerminal from '@/components/admin/LogTerminal';
|
import LogTerminal from '@/components/admin/LogTerminal';
|
||||||
import { adminFetch } from '@/lib/adminFetch';
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
@@ -82,6 +82,11 @@ export default function LogsPage() {
|
|||||||
}
|
}
|
||||||
}, [sister, lines]);
|
}, [sister, lines]);
|
||||||
|
|
||||||
|
// 마운트 시 자동 로드
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Controls>
|
<Controls>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import StyledComponentsRegistry from '@/lib/registry';
|
import StyledComponentsRegistry from '@/lib/registry';
|
||||||
import GlobalStyle from '@/styles/GlobalStyle';
|
import GlobalStyle from '@/styles/GlobalStyle';
|
||||||
import LayoutShell from '@/components/common/LayoutShell';
|
import AppShell from '@/components/common/AppShell';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: '하나랑 대시보드',
|
title: '하나랑 대시보드',
|
||||||
@@ -18,7 +18,7 @@ export default function RootLayout({
|
|||||||
<body>
|
<body>
|
||||||
<StyledComponentsRegistry>
|
<StyledComponentsRegistry>
|
||||||
<GlobalStyle />
|
<GlobalStyle />
|
||||||
<LayoutShell>{children}</LayoutShell>
|
<AppShell>{children}</AppShell>
|
||||||
</StyledComponentsRegistry>
|
</StyledComponentsRegistry>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
175
frontend/app/login/page.tsx
Normal file
175
frontend/app/login/page.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
const Page = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-main);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Box = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Logo = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: var(--space-xxl);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CircleFull = styled.div`
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
background: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CircleHalf = styled.div`
|
||||||
|
width: 10px; height: 20px;
|
||||||
|
background: var(--text-primary);
|
||||||
|
border-radius: 0 20px 20px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h1`
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Subtitle = styled.p`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Form = styled.form`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FieldLabel = styled.label`
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
display: block;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Input = styled.input`
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:focus { border-color: var(--border-hover); }
|
||||||
|
&::placeholder { color: var(--text-secondary); opacity: 0.5; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SubmitBtn = styled.button`
|
||||||
|
background: var(--text-primary);
|
||||||
|
border: 1px solid var(--text-primary);
|
||||||
|
color: #000;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
|
||||||
|
&:hover { background: transparent; color: var(--text-primary); }
|
||||||
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ErrorMsg = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: #ff5f5f;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border: 1px solid #ff5f5f44;
|
||||||
|
background: rgba(255,95,95,0.05);
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
router.push('/');
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message ?? '로그인 실패');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Box>
|
||||||
|
<Logo><CircleFull /><CircleHalf /></Logo>
|
||||||
|
<Title>하나랑 대시보드</Title>
|
||||||
|
<Subtitle>SYSTEM_ACCESS // AUTHENTICATE</Subtitle>
|
||||||
|
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>USERNAME</FieldLabel>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>PASSWORD</FieldLabel>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <ErrorMsg>ERR: {error}</ErrorMsg>}
|
||||||
|
<SubmitBtn type="submit" disabled={loading}>
|
||||||
|
{loading ? 'AUTHENTICATING...' : 'LOGIN'}
|
||||||
|
</SubmitBtn>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled, { keyframes } from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
PageHeader, PageTitle, Card, BracketValue, LabelMeta,
|
PageHeader, PageTitle, Card, BracketValue, LabelMeta,
|
||||||
@@ -10,249 +10,95 @@ 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';
|
||||||
|
|
||||||
// ─── Styled ───
|
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
||||||
const StatusGrid = styled.section`
|
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)}`;
|
||||||
display: grid;
|
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)}}`;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
const ProjectList = styled.div`display:flex;flex-direction:column;`;
|
||||||
gap: var(--space-lg);
|
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 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 ProjectDesc = styled.div`font-size:13px;color:var(--text-secondary);line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
||||||
|
const WsIndicator = styled.span<{ $on:boolean }>`font-size:11px;font-family:var(--font-mono);color:${({$on})=>$on?'var(--text-secondary)':'#333'};margin-left:var(--space-md);`;
|
||||||
|
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 ErrorBox = styled.div`padding:var(--space-md);border:1px solid #5a2a2a;color:#ff9b9b;font-size:13px;margin-bottom:var(--space-lg);`;
|
||||||
|
|
||||||
@media (min-width: 768px) and (max-width: 1199px) {
|
const SISTER_SHORT: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
|
||||||
grid-template-columns: repeat(2, 1fr);
|
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 sisterSubLabel(s: any): string { if (s.uptime) return `Uptime ${s.uptime}`; return s.status; }
|
||||||
@media (max-width: 767px) {
|
function formatTimestamp(iso: string): string { const d = new Date(iso); return d.toLocaleTimeString('ko-KR', { hour12: false }) + ' GMT+9'; }
|
||||||
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 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 0.15s;
|
|
||||||
|
|
||||||
&:last-child { border-bottom: none; }
|
|
||||||
&:hover { opacity: 0.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 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 ProjectDesc = styled.div`
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const WsIndicator = styled.span<{ $on: boolean }>`
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: ${({ $on }) => $on ? 'var(--text-secondary)' : '#333'};
|
|
||||||
margin-left: var(--space-md);
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ─── Helpers ───
|
|
||||||
const SISTER_SHORT: Record<string, string> = {
|
|
||||||
harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SISTER_SUB_LABEL: Record<string, string> = {
|
|
||||||
harang: 'Active Node',
|
|
||||||
narang: 'Capacity %',
|
|
||||||
darang: 'Standby',
|
|
||||||
erang: 'Sync Queue',
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (s.status === 'online') return 100;
|
|
||||||
if (s.status === 'working') return 84;
|
|
||||||
if (s.status === 'offline') return 0;
|
|
||||||
return 12;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleTimeString('ko-KR', { hour12: false }) + ' GMT+9';
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_SISTERS = [
|
|
||||||
{ id: 1, name: 'harang', status: 'online', lastSeen: null, currentTask: null },
|
|
||||||
{ id: 2, name: 'narang', status: 'working', lastSeen: null, currentTask: null },
|
|
||||||
{ id: 3, name: 'darang', status: 'offline', lastSeen: null, currentTask: null },
|
|
||||||
{ id: 4, name: 'erang', status: 'online', lastSeen: null, currentTask: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [sisters, setSisters] = useState<any[]>(MOCK_SISTERS);
|
const [sisters, setSisters] = useState<any[]>([]);
|
||||||
const [projects, setProjects] = useState<any[]>([]);
|
const [projects, setProjects] = useState<any[]>([]);
|
||||||
const [activityItems, setActivityItems] = useState<any[]>([]);
|
const [activityItems, setActivityItems] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSistersUpdate = useCallback((s: any[]) => setSisters(s), []);
|
const handleSistersUpdate = useCallback((s: any[]) => setSisters(s), []);
|
||||||
const handleActivityNew = useCallback((item: any) => {
|
const handleActivityNew = useCallback((item: any) => setActivityItems((prev) => [item, ...prev].slice(0, 10)), []);
|
||||||
setActivityItems((prev) => [item, ...prev].slice(0, 10));
|
|
||||||
}, []);
|
|
||||||
const { connected } = useSocket({ onSistersUpdate: handleSistersUpdate, onActivityNew: handleActivityNew });
|
const { connected } = useSocket({ onSistersUpdate: handleSistersUpdate, onActivityNew: handleActivityNew });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetch_ = async () => {
|
let active = true;
|
||||||
|
const fetchAll = async () => {
|
||||||
try {
|
try {
|
||||||
const [sRes, pRes, aRes] = await Promise.allSettled([
|
const [sRes, pRes, aRes] = await Promise.allSettled([
|
||||||
fetch(`${API_URL}/api/sisters`),
|
fetch(`${API_URL}/api/sisters`),
|
||||||
fetch(`${API_URL}/api/projects`),
|
fetch(`${API_URL}/api/projects`),
|
||||||
fetch(`${API_URL}/api/activity?limit=8`),
|
fetch(`${API_URL}/api/activity?limit=8`),
|
||||||
]);
|
]);
|
||||||
|
if (!active) return;
|
||||||
if (sRes.status === 'fulfilled' && sRes.value.ok) setSisters(await sRes.value.json());
|
if (sRes.status === 'fulfilled' && sRes.value.ok) setSisters(await sRes.value.json());
|
||||||
if (pRes.status === 'fulfilled' && pRes.value.ok) setProjects(await pRes.value.json());
|
if (pRes.status === 'fulfilled' && pRes.value.ok) setProjects(await pRes.value.json());
|
||||||
if (aRes.status === 'fulfilled' && aRes.value.ok) {
|
if (aRes.status === 'fulfilled' && aRes.value.ok) setActivityItems((await aRes.value.json()).items ?? []);
|
||||||
const d = await aRes.value.json();
|
setError(null);
|
||||||
setActivityItems(d.items ?? []);
|
} catch {
|
||||||
}
|
if (active) setError('데이터를 불러올 수 없습니다');
|
||||||
} catch { /* silent */ }
|
} finally {
|
||||||
|
if (active) setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
fetch_();
|
fetchAll();
|
||||||
const iv = setInterval(fetch_, POLL_INTERVAL_MS);
|
const iv = setInterval(fetchAll, POLL_INTERVAL_MS);
|
||||||
return () => clearInterval(iv);
|
return () => { active = false; clearInterval(iv); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader>
|
<PageHeader><PageTitle>하나랑 대시보드<WsIndicator $on={connected}>● {connected ? 'LIVE' : 'POLL'}</WsIndicator></PageTitle></PageHeader>
|
||||||
<PageTitle>
|
{error && <ErrorBox>{error}</ErrorBox>}
|
||||||
하나랑 대시보드
|
|
||||||
<WsIndicator $on={connected}>● {connected ? 'LIVE' : 'POLL'}</WsIndicator>
|
|
||||||
</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
{/* 상태 카드 */}
|
|
||||||
<StatusGrid>
|
<StatusGrid>
|
||||||
{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' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta>
|
<LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta>
|
||||||
|
<SisterAvatar name={s.name} size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}><BracketValue $dimmed={s.status === 'offline'}>{sisterBracketValue(s)}</BracketValue><LabelMeta>{sisterSubLabel(s)}</LabelMeta></div>
|
||||||
<BracketValue $dimmed={s.status === 'offline'}>
|
<TechBar><TechBarFill $width={sisterBarWidth(s)} /></TechBar>
|
||||||
{sisterBracketValue(s)}
|
|
||||||
</BracketValue>
|
|
||||||
<LabelMeta>{SISTER_SUB_LABEL[s.name] ?? s.status}</LabelMeta>
|
|
||||||
</div>
|
|
||||||
<TechBar>
|
|
||||||
<TechBarFill $width={sisterBarWidth(s)} />
|
|
||||||
</TechBar>
|
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</StatusGrid>
|
</StatusGrid>
|
||||||
|
|
||||||
{/* 하단 2열 */}
|
|
||||||
<DataColumns>
|
<DataColumns>
|
||||||
{/* ONGOING PROJECTS */}
|
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle>
|
<SectionTitle><span>ONGOING PROJECTS</span><LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta></SectionTitle>
|
||||||
<span>ONGOING PROJECTS</span>
|
|
||||||
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
|
|
||||||
</SectionTitle>
|
|
||||||
<ProjectList>
|
<ProjectList>
|
||||||
{projects.length === 0 ? (
|
{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) => (
|
||||||
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}>
|
<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>
|
||||||
등록된 프로젝트 없음
|
))}
|
||||||
</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>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ProjectList>
|
</ProjectList>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ACTIVITY FEED */}
|
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle>
|
<SectionTitle><span>ACTIVITY FEED</span><LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta></SectionTitle>
|
||||||
<span>ACTIVITY FEED</span>
|
|
||||||
<LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta>
|
|
||||||
</SectionTitle>
|
|
||||||
<Timeline>
|
<Timeline>
|
||||||
{activityItems.length === 0 ? (
|
{loading ? Array.from({ length: 5 }).map((_, i) => <SkeletonLine key={i} />) : activityItems.length === 0 ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', paddingLeft: 'var(--space-lg)' }}>활동 기록 없음</div> : activityItems.map((item) => (
|
||||||
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', paddingLeft: 'var(--space-lg)' }}>
|
<TimelineItem key={item.id}><TimeStamp>{formatTimestamp(item.createdAt)}</TimeStamp><TimelineContent>{item.sister && <strong>{item.sister.name} </strong>}{item.detail ?? item.action}</TimelineContent></TimelineItem>
|
||||||
활동 기록 없음
|
))}
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
activityItems.map((item) => (
|
|
||||||
<TimelineItem key={item.id}>
|
|
||||||
<TimeStamp>{formatTimestamp(item.createdAt)}</TimeStamp>
|
|
||||||
<TimelineContent>
|
|
||||||
{item.sister && <strong>{item.sister.name} </strong>}
|
|
||||||
{item.detail ?? item.action}
|
|
||||||
</TimelineContent>
|
|
||||||
</TimelineItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Timeline>
|
</Timeline>
|
||||||
</section>
|
</section>
|
||||||
</DataColumns>
|
</DataColumns>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
'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, 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';
|
||||||
|
|
||||||
|
// ─── Styled ───
|
||||||
const Breadcrumb = styled.div`
|
const Breadcrumb = styled.div`
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
|
|
||||||
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
|
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -25,19 +25,188 @@ const PageTitleRow = styled.div`
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const TabBar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
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;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
&:hover { color: var(--text-primary); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CommitTable = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-code);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CommitRow = styled.a`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr 120px 100px;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.1s;
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
&:hover { background: #1a1a1a; }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
grid-template-columns: 70px 1fr;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CommitHash = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
color: #5fafff;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CommitMsg = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CommitMeta = styled.span`
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BranchGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: var(--space-md);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BranchCard = styled.div`
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
&:hover { border-color: var(--border-hover); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BranchName = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BranchMeta = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRList = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRItem = styled.a`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: 12px var(--space-lg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
&:hover { border-color: var(--border-hover); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRState = styled.span<{ $state: string }>`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
${({ $state }) => {
|
||||||
|
switch ($state) {
|
||||||
|
case 'open': return "color: #5fff8a; border-color: #5fff8a44;";
|
||||||
|
case 'closed': return "color: var(--text-secondary); border-color: var(--border-color);";
|
||||||
|
case 'merged': return "color: #5fafff; border-color: #5fafff44;";
|
||||||
|
default: return "color: var(--text-secondary); border-color: var(--border-color);";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRTitle = styled.span`
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRMeta = styled.span`
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FilterRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FilterBtn = styled.button<{ $active: boolean }>`
|
||||||
|
background: ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: ${({ $active }) => $active ? '#000' : 'var(--text-secondary)'};
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
transition: all 0.15s;
|
||||||
|
&:hover { border-color: var(--border-hover); color: ${({ $active }) => $active ? '#000' : 'var(--text-primary)'}; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Empty = styled.div`
|
||||||
|
padding: var(--space-xl) 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Phase timeline 컴포넌트들
|
||||||
const ProjectGrid = styled.div`
|
const ProjectGrid = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 320px;
|
grid-template-columns: 1fr 320px;
|
||||||
gap: var(--space-xxl);
|
gap: var(--space-xxl);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
@media (max-width: 1199px) { grid-template-columns: 1fr; gap: var(--space-xl); }
|
||||||
@media (max-width: 1199px) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--space-xl);
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Phase Timeline
|
|
||||||
const PhaseTimeline = styled.div`
|
const PhaseTimeline = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -80,39 +249,6 @@ const PhaseDesc = styled.div`
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Audit Log
|
|
||||||
const AuditLog = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
background: var(--border-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const AuditRow = styled.div<{ $header?: boolean }>`
|
|
||||||
background: var(--bg-main);
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 100px 1fr 80px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: ${({ $header }) => $header ? 'var(--text-secondary)' : 'var(--text-primary)'};
|
|
||||||
font-weight: ${({ $header }) => $header ? '700' : '400'};
|
|
||||||
text-transform: ${({ $header }) => $header ? 'uppercase' : 'none'};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StatusTag = styled.span<{ $pass?: boolean }>`
|
|
||||||
padding: 2px 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
border: 1px solid ${({ $pass }) => $pass ? '#00FF00' : 'var(--border-color)'};
|
|
||||||
color: ${({ $pass }) => $pass ? '#00FF00' : 'var(--text-secondary)'};
|
|
||||||
text-align: center;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Right Panel
|
|
||||||
const NodeStack = styled.div`
|
const NodeStack = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -126,13 +262,10 @@ const NodeMiniCard = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: border-color 0.2s;
|
|
||||||
&:hover { border-color: var(--border-hover); }
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NodeStatusDot = styled.div`
|
const NodeDot = styled.div`
|
||||||
width: 6px;
|
width: 6px; height: 6px;
|
||||||
height: 6px;
|
|
||||||
background: #00FF00;
|
background: #00FF00;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
`;
|
`;
|
||||||
@@ -153,43 +286,47 @@ const CheckItem = styled.li<{ $done?: boolean }>`
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: ${({ $done }) => $done ? 'var(--text-secondary)' : 'var(--text-primary)'};
|
color: ${({ $done }) => $done ? 'var(--text-secondary)' : 'var(--text-primary)'};
|
||||||
text-decoration: ${({ $done }) => $done ? 'line-through' : 'none'};
|
text-decoration: ${({ $done }) => $done ? 'line-through' : 'none'};
|
||||||
|
|
||||||
&:last-child { border-bottom: none; }
|
&:last-child { border-bottom: none; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CheckBox = styled.div<{ $checked?: boolean }>`
|
const CheckBox = styled.div<{ $checked?: boolean }>`
|
||||||
width: 14px;
|
width: 14px; height: 14px;
|
||||||
height: 14px;
|
|
||||||
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
|
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'transparent'};
|
background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'transparent'};
|
||||||
position: relative;
|
position: relative;
|
||||||
|
${({ $checked }) => $checked && `&::after { content: '✓'; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 9px; color: #000; }`}
|
||||||
${({ $checked }) => $checked && `
|
|
||||||
&::after {
|
|
||||||
content: '✓';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 9px;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// ─── Helpers ───
|
||||||
function getSprintMeta(sprint: any): { label: string; isActive: boolean } {
|
function getSprintMeta(sprint: any): { label: string; isActive: boolean } {
|
||||||
if (sprint.status === 'done') return { label: `COMPLETED\n${sprint.completedAt ? new Date(sprint.completedAt).toLocaleDateString() : ''}`, isActive: false };
|
if (sprint.status === 'done') return { label: `COMPLETED`, isActive: false };
|
||||||
if (sprint.status === 'in_progress') return { label: 'IN PROGRESS\nEST: TBD', isActive: true };
|
if (sprint.status === 'in_progress') return { label: 'IN PROGRESS', isActive: true };
|
||||||
return { label: 'PENDING\nTBD', isActive: false };
|
return { label: 'PENDING', isActive: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'overview', label: 'Overview' },
|
||||||
|
{ id: 'commits', label: 'Commits' },
|
||||||
|
{ id: 'branches', label: 'Branches' },
|
||||||
|
{ id: 'prs', label: 'Pull Requests' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ProjectDetailPage() {
|
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 [commits, setCommits] = useState<any[]>([]);
|
||||||
|
const [branches, setBranches] = useState<any[]>([]);
|
||||||
|
const [pulls, setPulls] = useState<any[]>([]);
|
||||||
|
const [prFilter, setPrFilter] = useState<'open' | 'closed' | 'all'>('open');
|
||||||
|
const [tab, setTab] = useState('overview');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [tabLoading, setTabLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
@@ -201,112 +338,206 @@ export default function ProjectDetailPage() {
|
|||||||
}).finally(() => setLoading(false));
|
}).finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (loading) return <div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>로딩 중...</div>;
|
const loadCommits = useCallback(async () => {
|
||||||
if (!project) return <div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>프로젝트 없음</div>;
|
setTabLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/projects/${id}/commits?limit=30`);
|
||||||
|
if (res.ok) setCommits(await res.json());
|
||||||
|
} finally { setTabLoading(false); }
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadBranches = useCallback(async () => {
|
||||||
|
setTabLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/projects/${id}/branches`);
|
||||||
|
if (res.ok) setBranches(await res.json());
|
||||||
|
} finally { setTabLoading(false); }
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadPulls = useCallback(async (state: 'open' | 'closed' | 'all') => {
|
||||||
|
setTabLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/projects/${id}/pulls?state=${state}`);
|
||||||
|
if (res.ok) setPulls(await res.json());
|
||||||
|
} finally { setTabLoading(false); }
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'commits' && commits.length === 0) loadCommits();
|
||||||
|
if (tab === 'branches' && branches.length === 0) loadBranches();
|
||||||
|
if (tab === 'prs' && pulls.length === 0) loadPulls(prFilter);
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'prs') loadPulls(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 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');
|
||||||
|
|
||||||
|
if (loading) return <Empty>LOADING...</Empty>;
|
||||||
|
if (!project) return <Empty>PROJECT NOT FOUND</Empty>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / SUMMARY
|
<Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / {project.name}
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
<PageTitleRow>
|
<PageTitleRow>
|
||||||
<PageTitle>{project.name}</PageTitle>
|
<PageTitle>
|
||||||
|
{project.name}
|
||||||
|
{project.repoUrl && (
|
||||||
|
<a href={project.repoUrl} target="_blank" rel="noopener"
|
||||||
|
style={{ fontSize: '12px', color: '#5fafff', marginLeft: 'var(--space-md)', fontWeight: 400 }}>
|
||||||
|
↗ GITEA
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</PageTitle>
|
||||||
<LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / {activeSprint?.name ?? 'PLANNING'}</LabelMeta>
|
<LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / {activeSprint?.name ?? 'PLANNING'}</LabelMeta>
|
||||||
</PageTitleRow>
|
</PageTitleRow>
|
||||||
|
|
||||||
<ProjectGrid>
|
<TabBar>
|
||||||
<div>
|
{TABS.map((t) => (
|
||||||
{/* Phase Timeline */}
|
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
|
||||||
<SectionTitle>
|
{t.label}
|
||||||
<span>PHASE TIMELINE</span>
|
</TabBtn>
|
||||||
<LabelMeta>CURRENT: {activeSprint ? `S${activeSprint.number}` : 'N/A'}</LabelMeta>
|
))}
|
||||||
</SectionTitle>
|
</TabBar>
|
||||||
<PhaseTimeline>
|
|
||||||
{tasks.map((sprint: any) => {
|
|
||||||
const { label, isActive } = getSprintMeta(sprint);
|
|
||||||
return (
|
|
||||||
<PhaseItem key={sprint.id}>
|
|
||||||
<PhaseMeta style={{ whiteSpace: 'pre-line' }}>{label}</PhaseMeta>
|
|
||||||
<PhaseBox $active={isActive}>
|
|
||||||
<PhaseName>SPRINT {String(sprint.number).padStart(2, '0')}: {sprint.name}</PhaseName>
|
|
||||||
<PhaseDesc>
|
|
||||||
태스크 {sprint.tasks?.length ?? 0}개 · 완료 {sprint.tasks?.filter((t: any) => t.status === 'done').length ?? 0}개
|
|
||||||
</PhaseDesc>
|
|
||||||
<TechBar style={{ marginTop: 'var(--space-sm)', background: 'var(--border-color)' }}>
|
|
||||||
<TechBarFill $width={sprint.progress ?? 0} />
|
|
||||||
</TechBar>
|
|
||||||
</PhaseBox>
|
|
||||||
</PhaseItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{tasks.length === 0 && (
|
|
||||||
<PhaseItem>
|
|
||||||
<PhaseMeta>PENDING{'\n'}TBD</PhaseMeta>
|
|
||||||
<PhaseBox><PhaseName>Sprint 없음</PhaseName></PhaseBox>
|
|
||||||
</PhaseItem>
|
|
||||||
)}
|
|
||||||
</PhaseTimeline>
|
|
||||||
|
|
||||||
{/* Audit Log (활동 로그) */}
|
{/* OVERVIEW */}
|
||||||
<SectionTitle>
|
{tab === 'overview' && (
|
||||||
<span>SECURITY AUDIT LOG</span>
|
<ProjectGrid>
|
||||||
<LabelMeta>LAST CHECK: LIVE</LabelMeta>
|
<div>
|
||||||
</SectionTitle>
|
<SectionTitle>
|
||||||
<AuditLog>
|
<span>PHASE TIMELINE</span>
|
||||||
<AuditRow $header>
|
<LabelMeta>CURRENT: {activeSprint ? `S${activeSprint.number}` : 'N/A'}</LabelMeta>
|
||||||
<div>TIMESTAMP</div>
|
</SectionTitle>
|
||||||
<div>ACTION / RESOURCE</div>
|
<PhaseTimeline>
|
||||||
<div>RESULT</div>
|
{tasks.map((sprint: any) => {
|
||||||
</AuditRow>
|
const { label, isActive } = getSprintMeta(sprint);
|
||||||
{allTasks.slice(0, 5).map((task: any) => (
|
return (
|
||||||
<AuditRow key={task.id}>
|
<PhaseItem key={sprint.id}>
|
||||||
<div>{new Date(task.createdAt).toLocaleTimeString('ko-KR', { hour12: false })}</div>
|
<PhaseMeta>{label}</PhaseMeta>
|
||||||
<div>{task.taskId} / {task.assignee?.toUpperCase()}</div>
|
<PhaseBox $active={isActive}>
|
||||||
<StatusTag $pass={task.status === 'done'}>
|
<PhaseName>SPRINT {String(sprint.number).padStart(2, '0')}: {sprint.name}</PhaseName>
|
||||||
{task.status === 'done' ? 'PASS' : task.status === 'failed' ? 'FAIL' : 'PEND'}
|
<PhaseDesc>태스크 {sprint.tasks?.length ?? 0}개 · 완료 {sprint.tasks?.filter((t: any) => t.status === 'done').length ?? 0}개</PhaseDesc>
|
||||||
</StatusTag>
|
<TechBar style={{ marginTop: 'var(--space-sm)' }}>
|
||||||
</AuditRow>
|
<TechBarFill $width={sprint.progress ?? 0} />
|
||||||
))}
|
</TechBar>
|
||||||
</AuditLog>
|
</PhaseBox>
|
||||||
</div>
|
</PhaseItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{tasks.length === 0 && <PhaseItem><PhaseMeta>PENDING</PhaseMeta><PhaseBox><PhaseName>Sprint 없음</PhaseName></PhaseBox></PhaseItem>}
|
||||||
|
</PhaseTimeline>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{/* Assigned Nodes */}
|
<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) => (
|
||||||
{['harang', 'narang', 'darang', 'erang'].map((name) => (
|
<NodeMiniCard key={name}>
|
||||||
<NodeMiniCard key={name}>
|
<LabelMeta>{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'} [{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'GEN' : name === 'darang' ? 'EVAL' : 'INFRA'}]</LabelMeta>
|
||||||
<LabelMeta>
|
<NodeDot />
|
||||||
{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'}{' '}
|
</NodeMiniCard>
|
||||||
[{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'SECONDARY' : name === 'darang' ? 'EVALUATOR' : 'INFRA'}]
|
))}
|
||||||
</LabelMeta>
|
</NodeStack>
|
||||||
<NodeStatusDot />
|
|
||||||
</NodeMiniCard>
|
|
||||||
))}
|
|
||||||
</NodeStack>
|
|
||||||
|
|
||||||
{/* Task Checklist */}
|
<SectionTitle>TASK CHECKLIST</SectionTitle>
|
||||||
<SectionTitle>TASK CHECKLIST</SectionTitle>
|
<Checklist>
|
||||||
<Checklist>
|
{allTasks.slice(0, 8).map((task: any) => (
|
||||||
{allTasks.slice(0, 8).map((task: any) => (
|
<CheckItem key={task.id} $done={task.status === 'done'}>
|
||||||
<CheckItem key={task.id} $done={task.status === 'done'}>
|
<CheckBox $checked={task.status === 'done'} />
|
||||||
<CheckBox $checked={task.status === 'done'} />
|
<span>[{task.taskId}] {task.title}</span>
|
||||||
<span>[{task.taskId}] {task.title}</span>
|
</CheckItem>
|
||||||
</CheckItem>
|
))}
|
||||||
|
{allTasks.length === 0 && <CheckItem><CheckBox /><span style={{ color: 'var(--text-secondary)' }}>태스크 없음</span></CheckItem>}
|
||||||
|
</Checklist>
|
||||||
|
</div>
|
||||||
|
</ProjectGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* COMMITS */}
|
||||||
|
{tab === 'commits' && (
|
||||||
|
<>
|
||||||
|
{tabLoading ? <Empty>LOADING COMMITS...</Empty> : (
|
||||||
|
<>
|
||||||
|
<CommitTable>
|
||||||
|
{commits.length === 0 ? (
|
||||||
|
<div style={{ padding: 'var(--space-xl)', color: 'var(--text-secondary)', fontSize: '12px', textAlign: 'center', fontFamily: 'var(--font-mono)' }}>NO COMMITS</div>
|
||||||
|
) : (
|
||||||
|
commits.map((c: any) => (
|
||||||
|
<CommitRow key={c.sha} href={c.html_url} target="_blank" rel="noopener">
|
||||||
|
<CommitHash>{c.sha?.slice(0, 7)}</CommitHash>
|
||||||
|
<CommitMsg>{c.commit?.message?.split('\n')[0]}</CommitMsg>
|
||||||
|
<CommitMeta style={{ display: 'var(--media-hide, initial)' }}>
|
||||||
|
{c.commit?.author?.name ?? c.author?.login ?? '-'}
|
||||||
|
</CommitMeta>
|
||||||
|
<CommitMeta>
|
||||||
|
{c.commit?.author?.date ? formatDate(c.commit.author.date) : '-'}
|
||||||
|
</CommitMeta>
|
||||||
|
</CommitRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CommitTable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* BRANCHES */}
|
||||||
|
{tab === 'branches' && (
|
||||||
|
<>
|
||||||
|
{tabLoading ? <Empty>LOADING BRANCHES...</Empty> : (
|
||||||
|
<BranchGrid>
|
||||||
|
{branches.length === 0 ? (
|
||||||
|
<Empty>NO BRANCHES</Empty>
|
||||||
|
) : (
|
||||||
|
branches.map((b: any) => (
|
||||||
|
<BranchCard key={b.name}>
|
||||||
|
<BranchName>{b.name}</BranchName>
|
||||||
|
<BranchMeta>
|
||||||
|
{b.commit?.id?.slice(0, 7) ?? '-'}
|
||||||
|
{b.protected && ' · PROTECTED'}
|
||||||
|
</BranchMeta>
|
||||||
|
</BranchCard>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</BranchGrid>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PULL REQUESTS */}
|
||||||
|
{tab === 'prs' && (
|
||||||
|
<>
|
||||||
|
<FilterRow>
|
||||||
|
{(['open', 'closed', 'all'] as const).map((s) => (
|
||||||
|
<FilterBtn key={s} $active={prFilter === s} onClick={() => setPrFilter(s)}>
|
||||||
|
{s.toUpperCase()}
|
||||||
|
</FilterBtn>
|
||||||
))}
|
))}
|
||||||
{allTasks.length === 0 && (
|
</FilterRow>
|
||||||
<CheckItem>
|
{tabLoading ? <Empty>LOADING PULL REQUESTS...</Empty> : (
|
||||||
<CheckBox />
|
<PRList>
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>태스크 없음</span>
|
{pulls.length === 0 ? (
|
||||||
</CheckItem>
|
<Empty>NO PULL REQUESTS ({prFilter})</Empty>
|
||||||
)}
|
) : (
|
||||||
</Checklist>
|
pulls.map((pr: any) => (
|
||||||
</div>
|
<PRItem key={pr.id} href={pr.html_url} target="_blank" rel="noopener">
|
||||||
</ProjectGrid>
|
<PRState $state={pr.merged ? 'merged' : pr.state}>
|
||||||
|
{pr.merged ? 'MERGED' : pr.state?.toUpperCase()}
|
||||||
|
</PRState>
|
||||||
|
<PRTitle>#{pr.number} {pr.title}</PRTitle>
|
||||||
|
<PRMeta>{pr.user?.login ?? '-'} · {pr.created_at ? formatDate(pr.created_at) : '-'}</PRMeta>
|
||||||
|
</PRItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</PRList>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
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 } from '@/components/ui/base';
|
import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn, BtnPrimary } from '@/components/ui/base';
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
|
|
||||||
const ProjectList = styled.div`
|
const ProjectList = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -85,20 +86,68 @@ const EmptyState = styled.div`
|
|||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const [projects, setProjects] = useState<any[]>([]);
|
const [projects, setProjects] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadProjects = () => {
|
||||||
|
setLoading(true);
|
||||||
fetch(`${API_URL}/api/projects`)
|
fetch(`${API_URL}/api/projects`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(setProjects)
|
.then(setProjects)
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
setSyncResult(null);
|
||||||
|
try {
|
||||||
|
const res = await adminFetch('/api/admin/gitea/sync', { method: 'POST' });
|
||||||
|
const d = await res.json();
|
||||||
|
setSyncResult(`sync: ${d.created ?? 0} new, ${d.updated ?? 0} updated`);
|
||||||
|
loadProjects();
|
||||||
|
} catch (e) {
|
||||||
|
setSyncResult('sync failed');
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSprintSync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
setSyncResult(null);
|
||||||
|
try {
|
||||||
|
const res = await adminFetch('/api/projects/sync-all-sprints', { method: 'POST' });
|
||||||
|
const d = await res.json();
|
||||||
|
setSyncResult(`sprint sync: ${d.synced ?? 0} synced across ${d.projects ?? 0} projects`);
|
||||||
|
loadProjects();
|
||||||
|
} catch (e) {
|
||||||
|
setSyncResult('sprint sync failed');
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadProjects(); }, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<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>
|
||||||
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
|
||||||
|
{syncResult && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)' }}>
|
||||||
|
{syncResult}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
|
||||||
|
<Btn onClick={handleSync} disabled={syncing}>
|
||||||
|
{syncing ? 'SYNCING...' : '↻ GITEA SYNC'}
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={handleSprintSync} disabled={syncing}>
|
||||||
|
{syncing ? '...' : '↻ SPRINT SYNC'}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
9
frontend/app/register/page.tsx
Normal file
9
frontend/app/register/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
useEffect(() => { router.replace('/login'); }, [router]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,23 +1,37 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled, { keyframes } from 'styled-components';
|
||||||
import { PageTitle, LabelMeta, SectionTitle, Btn, BtnPrimary } from '@/components/ui/base';
|
import { PageTitle, LabelMeta, SectionTitle, Btn, BtnPrimary } from '@/components/ui/base';
|
||||||
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
force2fa: 'true',
|
||||||
|
sessionTimeoutMinutes: '15',
|
||||||
|
ipWhitelistEnabled: 'false',
|
||||||
|
autoBackupEnabled: 'true',
|
||||||
|
backupSchedule: '08:00',
|
||||||
|
retentionDays: '30',
|
||||||
|
queueWarningThreshold: '500',
|
||||||
|
autoScalingEnabled: 'true',
|
||||||
|
cpuThresholdPercent: '90',
|
||||||
|
latencyAlertMs: '250',
|
||||||
|
nodeOfflineAlertEnabled: 'true',
|
||||||
|
};
|
||||||
|
|
||||||
|
const pulse = keyframes`
|
||||||
|
0% { opacity: 0.35; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
100% { opacity: 0.35; }
|
||||||
|
`;
|
||||||
|
|
||||||
// ─── Styled ───
|
|
||||||
const SettingsGrid = styled.div`
|
const SettingsGrid = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: var(--space-xl);
|
gap: var(--space-xl);
|
||||||
|
@media (max-width: 767px) { grid-template-columns: 1fr; gap: var(--space-lg); }
|
||||||
@media (max-width: 767px) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SettingsSection = styled.section``;
|
const SettingsSection = styled.section``;
|
||||||
|
|
||||||
const SettingsRow = styled.div`
|
const SettingsRow = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -25,257 +39,112 @@ const SettingsRow = styled.div`
|
|||||||
gap: var(--space-lg);
|
gap: var(--space-lg);
|
||||||
padding: var(--space-md) 0;
|
padding: var(--space-md) 0;
|
||||||
border-bottom: 1px solid #1f1f1f;
|
border-bottom: 1px solid #1f1f1f;
|
||||||
|
|
||||||
&:last-child { border-bottom: none; }
|
&:last-child { border-bottom: none; }
|
||||||
`;
|
`;
|
||||||
|
const SettingInfo = styled.div`flex: 1; min-width: 0;`;
|
||||||
const SettingInfo = styled.div`
|
const SettingLabel = styled.div`font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 2px;`;
|
||||||
flex: 1;
|
const SettingDesc = styled.div`font-size: 12px; color: var(--text-secondary); line-height: 1.4;`;
|
||||||
min-width: 0;
|
const SwitchWrapper = styled.label`position: relative; display: inline-block; width: 34px; height: 18px; flex-shrink: 0; cursor: pointer;`;
|
||||||
`;
|
const SwitchInput = styled.input`opacity: 0; width: 0; height: 0; position: absolute;`;
|
||||||
|
|
||||||
const SettingLabel = styled.div`
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SettingDesc = styled.div`
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.4;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Toggle Switch
|
|
||||||
const SwitchWrapper = styled.label`
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 34px;
|
|
||||||
height: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SwitchInput = styled.input`
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
position: absolute;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Slider = styled.span<{ $checked: boolean }>`
|
const Slider = styled.span<{ $checked: boolean }>`
|
||||||
position: absolute;
|
position: absolute; inset: 0;
|
||||||
inset: 0;
|
|
||||||
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
|
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
|
||||||
background: transparent;
|
background: transparent; transition: border-color 0.2s; cursor: pointer;
|
||||||
transition: border-color 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: ''; position: absolute; left: ${({ $checked }) => $checked ? '16px' : '2px'}; top: 2px;
|
||||||
position: absolute;
|
width: 12px; height: 12px; background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--text-secondary)'};
|
||||||
left: ${({ $checked }) => $checked ? '16px' : '2px'};
|
|
||||||
top: 2px;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--text-secondary)'};
|
|
||||||
transition: left 0.2s, background 0.2s;
|
transition: left 0.2s, background 0.2s;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
const BracketInputGroup = styled.div`display: flex; align-items: center; gap: 4px; font-family: var(--font-mono); font-size: 14px; color: var(--text-secondary); flex-shrink: 0;`;
|
||||||
const BracketInputGroup = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const BracketInput = styled.input`
|
const BracketInput = styled.input`
|
||||||
background: transparent;
|
background: transparent; border: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 14px; text-align: center;
|
||||||
border: none;
|
width: 72px; outline: none; padding: 2px 0; border-bottom: 1px solid var(--border-color);
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 14px;
|
|
||||||
text-align: center;
|
|
||||||
width: 60px;
|
|
||||||
outline: none;
|
|
||||||
padding: 2px 0;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
|
|
||||||
&:focus { border-bottom-color: var(--text-primary); }
|
&:focus { border-bottom-color: var(--text-primary); }
|
||||||
`;
|
`;
|
||||||
|
const ActionBar = styled.div`display: flex; justify-content: flex-end; gap: var(--space-md); padding-top: var(--space-xl); border-top: 1px solid var(--border-color);`;
|
||||||
const ActionBar = styled.div`
|
const Toast = styled.div`margin-bottom: var(--space-lg); padding: var(--space-sm) var(--space-md); border: 1px solid var(--border-color); color: var(--text-secondary); font-size: 12px; font-family: var(--font-mono);`;
|
||||||
display: flex;
|
const ErrorBox = styled.div`padding: var(--space-lg); border: 1px solid #5a2a2a; color: #ff9b9b; font-size: 13px; margin-bottom: var(--space-lg);`;
|
||||||
justify-content: flex-end;
|
const SkeletonBox = styled.div`height: 16px; background: #1a1a1a; animation: ${pulse} 1.4s ease-in-out infinite;`;
|
||||||
gap: var(--space-md);
|
|
||||||
padding-top: var(--space-xl);
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
`;
|
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
|
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
|
||||||
return (
|
return <SwitchWrapper onClick={onChange}><SwitchInput type="checkbox" checked={checked} onChange={() => {}} /><Slider $checked={checked} /></SwitchWrapper>;
|
||||||
<SwitchWrapper onClick={onChange}>
|
|
||||||
<SwitchInput type="checkbox" checked={checked} onChange={() => {}} />
|
|
||||||
<Slider $checked={checked} />
|
|
||||||
</SwitchWrapper>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function BInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
function BInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
return (
|
return <BracketInputGroup>[<BracketInput value={value} onChange={(e) => onChange(e.target.value)} />]</BracketInputGroup>;
|
||||||
<BracketInputGroup>
|
|
||||||
[<BracketInput value={value} onChange={(e) => onChange(e.target.value)} />]
|
|
||||||
</BracketInputGroup>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [settings, setSettings] = useState({
|
const [settings, setSettings] = useState<Record<string, string>>(DEFAULT_SETTINGS);
|
||||||
twofa: true,
|
const [loading, setLoading] = useState(true);
|
||||||
sessionTimeout: '15',
|
const [saving, setSaving] = useState(false);
|
||||||
ipWhitelist: false,
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
autoBackup: true,
|
const [error, setError] = useState<string | null>(null);
|
||||||
backupInterval: '08:00',
|
|
||||||
backupRetention: '30',
|
|
||||||
queueThreshold: '500',
|
|
||||||
autoScaling: true,
|
|
||||||
cpuThreshold: '90',
|
|
||||||
latencyThreshold: '250',
|
|
||||||
nodeOfflineAlert: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggle = (key: keyof typeof settings) =>
|
useEffect(() => {
|
||||||
setSettings((p) => ({ ...p, [key]: !p[key] }));
|
let active = true;
|
||||||
const setVal = (key: keyof typeof settings, val: string) =>
|
adminFetch('/api/admin/settings')
|
||||||
setSettings((p) => ({ ...p, [key]: val }));
|
.then((r) => r.ok ? r.json() : Promise.reject(new Error('load failed')))
|
||||||
|
.then((d) => { if (active) setSettings({ ...DEFAULT_SETTINGS, ...d }); })
|
||||||
|
.catch(() => { if (active) setError('데이터를 불러올 수 없습니다'); })
|
||||||
|
.finally(() => { if (active) setLoading(false); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = (key: string) => setSettings((p) => ({ ...p, [key]: p[key] === 'true' ? 'false' : 'true' }));
|
||||||
|
const setVal = (key: string, val: string) => setSettings((p) => ({ ...p, [key]: val }));
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true); setToast(null);
|
||||||
|
try {
|
||||||
|
const res = await adminFetch('/api/admin/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const d = await res.json();
|
||||||
|
setSettings(d);
|
||||||
|
setToast('설정 저장됨');
|
||||||
|
} catch {
|
||||||
|
setError('저장 실패');
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
const reset = () => { setSettings(DEFAULT_SETTINGS); setToast('기본값 복원'); };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 'var(--space-lg)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 'var(--space-lg)' }}>
|
||||||
<PageTitle>시스템 설정</PageTitle>
|
<PageTitle>시스템 설정</PageTitle>
|
||||||
<LabelMeta><span>CONFIG:</span>v2.0.4-STABLE</LabelMeta>
|
<LabelMeta><span>CONFIG:</span>DB-LIVE</LabelMeta>
|
||||||
</div>
|
</div>
|
||||||
|
{toast && <Toast>{toast}</Toast>}
|
||||||
|
{error && <ErrorBox>{error}</ErrorBox>}
|
||||||
|
|
||||||
<SettingsGrid>
|
{loading ? (
|
||||||
{/* SEC 01 */}
|
<SettingsGrid>
|
||||||
<SettingsSection>
|
<SettingsSection><SectionTitle><span>LOADING</span></SectionTitle><SkeletonBox /><div style={{ height: 12 }} /><SkeletonBox /><div style={{ height: 12 }} /><SkeletonBox /></SettingsSection>
|
||||||
<SectionTitle>
|
<SettingsSection><SectionTitle><span>LOADING</span></SectionTitle><SkeletonBox /><div style={{ height: 12 }} /><SkeletonBox /><div style={{ height: 12 }} /><SkeletonBox /></SettingsSection>
|
||||||
<span>ADMIN PROTOCOL RULES</span>
|
</SettingsGrid>
|
||||||
<LabelMeta>SEC: 01</LabelMeta>
|
) : (
|
||||||
</SectionTitle>
|
<>
|
||||||
<SettingsRow>
|
<SettingsGrid>
|
||||||
<SettingInfo>
|
<SettingsSection>
|
||||||
<SettingLabel>강제 2단계 인증 (2FA)</SettingLabel>
|
<SectionTitle><span>ADMIN PROTOCOL RULES</span><LabelMeta>SEC: 01</LabelMeta></SectionTitle>
|
||||||
<SettingDesc>모든 관리자 계정에 대해 생체 인식 또는 하드웨어 키 요구.</SettingDesc>
|
<SettingsRow><SettingInfo><SettingLabel>강제 2단계 인증 (2FA)</SettingLabel><SettingDesc>모든 관리자 계정에 대해 추가 인증 요구.</SettingDesc></SettingInfo><Toggle checked={settings.force2fa === 'true'} onChange={() => toggle('force2fa')} /></SettingsRow>
|
||||||
</SettingInfo>
|
<SettingsRow><SettingInfo><SettingLabel>세션 유효 시간</SettingLabel><SettingDesc>비활동 시 자동 로그아웃 시간(분).</SettingDesc></SettingInfo><BInput value={settings.sessionTimeoutMinutes} onChange={(v) => setVal('sessionTimeoutMinutes', v)} /></SettingsRow>
|
||||||
<Toggle checked={settings.twofa} onChange={() => toggle('twofa')} />
|
<SettingsRow><SettingInfo><SettingLabel>IP 화이트리스트</SettingLabel><SettingDesc>지정 대역만 관리자 접근 허용.</SettingDesc></SettingInfo><Toggle checked={settings.ipWhitelistEnabled === 'true'} onChange={() => toggle('ipWhitelistEnabled')} /></SettingsRow>
|
||||||
</SettingsRow>
|
<SettingsRow><SettingInfo><SettingLabel>노드 오프라인 알림</SettingLabel><SettingDesc>자매 노드 오프라인 시 즉시 알림.</SettingDesc></SettingInfo><Toggle checked={settings.nodeOfflineAlertEnabled === 'true'} onChange={() => toggle('nodeOfflineAlertEnabled')} /></SettingsRow>
|
||||||
<SettingsRow>
|
</SettingsSection>
|
||||||
<SettingInfo>
|
<SettingsSection>
|
||||||
<SettingLabel>세션 유효 시간</SettingLabel>
|
<SectionTitle><span>BACKUP & THRESHOLDS</span><LabelMeta>SEC: 02</LabelMeta></SectionTitle>
|
||||||
<SettingDesc>비활동 시 관리 콘솔 자동 로그아웃 시간(분).</SettingDesc>
|
<SettingsRow><SettingInfo><SettingLabel>자동 백업</SettingLabel><SettingDesc>주기적 스냅샷 생성.</SettingDesc></SettingInfo><Toggle checked={settings.autoBackupEnabled === 'true'} onChange={() => toggle('autoBackupEnabled')} /></SettingsRow>
|
||||||
</SettingInfo>
|
<SettingsRow><SettingInfo><SettingLabel>백업 주기</SettingLabel><SettingDesc>백업 실행 시간(HH:mm).</SettingDesc></SettingInfo><BInput value={settings.backupSchedule} onChange={(v) => setVal('backupSchedule', v)} /></SettingsRow>
|
||||||
<BInput value={settings.sessionTimeout} onChange={(v) => setVal('sessionTimeout', v)} />
|
<SettingsRow><SettingInfo><SettingLabel>보관 주기</SettingLabel><SettingDesc>백업 보관 일수.</SettingDesc></SettingInfo><BInput value={settings.retentionDays} onChange={(v) => setVal('retentionDays', v)} /></SettingsRow>
|
||||||
</SettingsRow>
|
<SettingsRow><SettingInfo><SettingLabel>대기열 경고 임계값</SettingLabel><SettingDesc>경고 발생 기준.</SettingDesc></SettingInfo><BInput value={settings.queueWarningThreshold} onChange={(v) => setVal('queueWarningThreshold', v)} /></SettingsRow>
|
||||||
<SettingsRow>
|
<SettingsRow><SettingInfo><SettingLabel>자동 스케일링</SettingLabel><SettingDesc>CPU 부하 시 임시 리소스 확장.</SettingDesc></SettingInfo><Toggle checked={settings.autoScalingEnabled === 'true'} onChange={() => toggle('autoScalingEnabled')} /></SettingsRow>
|
||||||
<SettingInfo>
|
<SettingsRow><SettingInfo><SettingLabel>CPU 임계값</SettingLabel><SettingDesc>스케일링 기준 CPU %.</SettingDesc></SettingInfo><BInput value={settings.cpuThresholdPercent} onChange={(v) => setVal('cpuThresholdPercent', v)} /></SettingsRow>
|
||||||
<SettingLabel>IP 화이트리스트</SettingLabel>
|
<SettingsRow><SettingInfo><SettingLabel>레이턴시 경보</SettingLabel><SettingDesc>경보 발생 기준 ms.</SettingDesc></SettingInfo><BInput value={settings.latencyAlertMs} onChange={(v) => setVal('latencyAlertMs', v)} /></SettingsRow>
|
||||||
<SettingDesc>지정된 대역에서만 관리자 권한 접근 허용.</SettingDesc>
|
</SettingsSection>
|
||||||
</SettingInfo>
|
</SettingsGrid>
|
||||||
<Toggle checked={settings.ipWhitelist} onChange={() => toggle('ipWhitelist')} />
|
<ActionBar><Btn onClick={reset}>초기화</Btn><BtnPrimary onClick={save} disabled={saving}>{saving ? '저장 중...' : '설정 저장'}</BtnPrimary></ActionBar>
|
||||||
</SettingsRow>
|
</>
|
||||||
</SettingsSection>
|
)}
|
||||||
|
|
||||||
{/* SEC 02 */}
|
|
||||||
<SettingsSection>
|
|
||||||
<SectionTitle>
|
|
||||||
<span>BACKUP & REDUNDANCY</span>
|
|
||||||
<LabelMeta>SEC: 02</LabelMeta>
|
|
||||||
</SectionTitle>
|
|
||||||
<SettingsRow>
|
|
||||||
<SettingInfo>
|
|
||||||
<SettingLabel>자동 백업 활성화</SettingLabel>
|
|
||||||
<SettingDesc>전체 시스템 상태의 주기적인 스냅샷 생성.</SettingDesc>
|
|
||||||
</SettingInfo>
|
|
||||||
<Toggle checked={settings.autoBackup} onChange={() => toggle('autoBackup')} />
|
|
||||||
</SettingsRow>
|
|
||||||
<SettingsRow>
|
|
||||||
<SettingInfo>
|
|
||||||
<SettingLabel>백업 주기 (시간)</SettingLabel>
|
|
||||||
<SettingDesc>스냅샷 생성 간격 설정.</SettingDesc>
|
|
||||||
</SettingInfo>
|
|
||||||
<BInput value={settings.backupInterval} onChange={(v) => setVal('backupInterval', v)} />
|
|
||||||
</SettingsRow>
|
|
||||||
<SettingsRow>
|
|
||||||
<SettingInfo>
|
|
||||||
<SettingLabel>보관 주기 (일)</SettingLabel>
|
|
||||||
<SettingDesc>백업 데이터 자동 삭제 전 유지 기간.</SettingDesc>
|
|
||||||
</SettingInfo>
|
|
||||||
<BInput value={settings.backupRetention} onChange={(v) => setVal('backupRetention', v)} />
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{/* SEC 03 */}
|
|
||||||
<SettingsSection>
|
|
||||||
<SectionTitle>
|
|
||||||
<span>SYNC QUEUE THRESHOLDS</span>
|
|
||||||
<LabelMeta>SEC: 03</LabelMeta>
|
|
||||||
</SectionTitle>
|
|
||||||
<SettingsRow>
|
|
||||||
<SettingInfo>
|
|
||||||
<SettingLabel>대기열 경고 임계값</SettingLabel>
|
|
||||||
<SettingDesc>동기화 대기 항목이 설정치를 초과할 시 경보 발생.</SettingDesc>
|
|
||||||
</SettingInfo>
|
|
||||||
<BInput value={settings.queueThreshold} onChange={(v) => setVal('queueThreshold', v)} />
|
|
||||||
</SettingsRow>
|
|
||||||
<SettingsRow>
|
|
||||||
<SettingInfo>
|
|
||||||
<SettingLabel>자동 스케일링</SettingLabel>
|
|
||||||
<SettingDesc>부하 증가 시 임시 노드 리소스 할당.</SettingDesc>
|
|
||||||
</SettingInfo>
|
|
||||||
<Toggle checked={settings.autoScaling} onChange={() => toggle('autoScaling')} />
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{/* SEC 04 */}
|
|
||||||
<SettingsSection>
|
|
||||||
<SectionTitle>
|
|
||||||
<span>NODE ALERT TRIGGERS</span>
|
|
||||||
<LabelMeta>SEC: 04</LabelMeta>
|
|
||||||
</SectionTitle>
|
|
||||||
<SettingsRow>
|
|
||||||
<SettingInfo>
|
|
||||||
<SettingLabel>CPU 사용량 임계값 (%)</SettingLabel>
|
|
||||||
<SettingDesc>시스템 부하 경고 기준점.</SettingDesc>
|
|
||||||
</SettingInfo>
|
|
||||||
<BInput value={settings.cpuThreshold} onChange={(v) => setVal('cpuThreshold', v)} />
|
|
||||||
</SettingsRow>
|
|
||||||
<SettingsRow>
|
|
||||||
<SettingInfo>
|
|
||||||
<SettingLabel>레이턴시 경보 (ms)</SettingLabel>
|
|
||||||
<SettingDesc>응답 속도 지연에 대한 임계값 설정.</SettingDesc>
|
|
||||||
</SettingInfo>
|
|
||||||
<BInput value={settings.latencyThreshold} onChange={(v) => setVal('latencyThreshold', v)} />
|
|
||||||
</SettingsRow>
|
|
||||||
<SettingsRow>
|
|
||||||
<SettingInfo>
|
|
||||||
<SettingLabel>노드 오프라인 알림</SettingLabel>
|
|
||||||
<SettingDesc>활성 노드 연결 해제 시 즉시 알림.</SettingDesc>
|
|
||||||
</SettingInfo>
|
|
||||||
<Toggle checked={settings.nodeOfflineAlert} onChange={() => toggle('nodeOfflineAlert')} />
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsGrid>
|
|
||||||
|
|
||||||
<ActionBar>
|
|
||||||
<Btn onClick={() => setSettings((p) => ({ ...p }))}>초기화</Btn>
|
|
||||||
<BtnPrimary>설정 저장</BtnPrimary>
|
|
||||||
</ActionBar>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import React, { useEffect, useState } 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 { PageTitle, LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base';
|
import { LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base';
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
|
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||||
import { SISTER_ROLES } from '@/lib/sisters';
|
import { SISTER_ROLES } from '@/lib/sisters';
|
||||||
|
|
||||||
const Breadcrumb = styled.div`
|
const Breadcrumb = styled.div`
|
||||||
@@ -16,9 +17,87 @@ const Breadcrumb = styled.div`
|
|||||||
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
|
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const Hero = styled.section`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 128px 1fr;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeroMeta = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeroName = styled.div`
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeroRole = styled.div`
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeroStatus = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
`;
|
||||||
|
|
||||||
|
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`
|
const DetailGrid = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px 1fr;
|
grid-template-columns: 320px 1fr;
|
||||||
gap: var(--space-xxl);
|
gap: var(--space-xxl);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
|
||||||
@@ -39,20 +118,6 @@ const MetaCard = styled.div`
|
|||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StatusRow = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StatusDot = styled.div<{ $on: boolean }>`
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: ${({ $on }) => $on ? '#FFF' : '#555'};
|
|
||||||
${({ $on }) => $on && `box-shadow: 0 0 8px rgba(255,255,255,0.3);`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConfigPre = styled.pre`
|
const ConfigPre = styled.pre`
|
||||||
background: #0d0d0d;
|
background: #0d0d0d;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -84,7 +149,6 @@ const TabBtn = styled.button<{ $active: boolean }>`
|
|||||||
padding: var(--space-sm) 0;
|
padding: var(--space-sm) 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.15s, border-color 0.15s;
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
|
||||||
&:hover { color: var(--text-primary); }
|
&:hover { color: var(--text-primary); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -95,6 +159,7 @@ const SISTER_DISPLAY: Record<string, string> = {
|
|||||||
export default function SisterDetailPage() {
|
export default function SisterDetailPage() {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
const [sisterInfo, setSisterInfo] = useState<any>(null);
|
const [sisterInfo, setSisterInfo] = 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 [tab, setTab] = useState('overview');
|
const [tab, setTab] = useState('overview');
|
||||||
@@ -105,7 +170,8 @@ export default function SisterDetailPage() {
|
|||||||
fetch(`${API_URL}/api/sisters`),
|
fetch(`${API_URL}/api/sisters`),
|
||||||
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`),
|
||||||
]).then(([sRes, cRes, aRes]) => {
|
fetch(`${API_URL}/api/sisters/${name}/system`),
|
||||||
|
]).then(([sRes, cRes, aRes, syRes]) => {
|
||||||
if (sRes.status === 'fulfilled' && sRes.value.ok) {
|
if (sRes.status === 'fulfilled' && sRes.value.ok) {
|
||||||
sRes.value.json().then((d: any[]) => setSisterInfo(d.find((s) => s.name === name)));
|
sRes.value.json().then((d: any[]) => setSisterInfo(d.find((s) => s.name === name)));
|
||||||
}
|
}
|
||||||
@@ -113,6 +179,7 @@ export default function SisterDetailPage() {
|
|||||||
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);
|
||||||
}).finally(() => setLoading(false));
|
}).finally(() => setLoading(false));
|
||||||
}, [name]);
|
}, [name]);
|
||||||
|
|
||||||
@@ -123,88 +190,98 @@ export default function SisterDetailPage() {
|
|||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}
|
<Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
|
|
||||||
<PageTitle>
|
|
||||||
{SISTER_DISPLAY[name] ?? name}
|
|
||||||
<span style={{ fontSize: '16px', color: 'var(--text-secondary)', fontWeight: 400, marginLeft: 'var(--space-sm)' }}>
|
|
||||||
/ {SISTER_ROLES[name] ?? 'UNKNOWN'}
|
|
||||||
</span>
|
|
||||||
</PageTitle>
|
|
||||||
<LabelMeta><span>STATUS:</span>{sisterInfo?.status?.toUpperCase() ?? 'UNKNOWN'}</LabelMeta>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div>
|
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div>
|
||||||
) : (
|
) : (
|
||||||
<DetailGrid>
|
<>
|
||||||
<MetaPanel>
|
<Hero>
|
||||||
<MetaCard>
|
<SisterAvatar name={name} size={120} />
|
||||||
<StatusRow>
|
<HeroMeta>
|
||||||
|
<HeroName>{SISTER_DISPLAY[name] ?? name}</HeroName>
|
||||||
|
<HeroRole>{SISTER_ROLES[name] ?? 'UNKNOWN'}</HeroRole>
|
||||||
|
<HeroStatus>
|
||||||
<StatusDot $on={isActive} />
|
<StatusDot $on={isActive} />
|
||||||
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
|
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
|
||||||
</StatusRow>
|
<LabelMeta><span>LXC:</span>{sisterInfo?.lxcId ?? '--'}</LabelMeta>
|
||||||
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
</HeroStatus>
|
||||||
LXC: {sisterInfo?.lxcId ?? '--'}<br />
|
<HeroStats>
|
||||||
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
|
<StatCard><StatLabel>UPTIME</StatLabel><StatValue>{systemInfo?.uptime ?? '--:--:--'}</StatValue></StatCard>
|
||||||
ROLE: {SISTER_ROLES[name] ?? '--'}
|
<StatCard><StatLabel>CPU</StatLabel><StatValue>{systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}%</StatValue></StatCard>
|
||||||
</div>
|
<StatCard><StatLabel>MEM</StatLabel><StatValue>{systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB</StatValue></StatCard>
|
||||||
<TechBar>
|
<StatCard><StatLabel>DISK</StatLabel><StatValue>{systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'}</StatValue></StatCard>
|
||||||
<TechBarFill $width={isActive ? 100 : 0} />
|
</HeroStats>
|
||||||
</TechBar>
|
</HeroMeta>
|
||||||
</MetaCard>
|
</Hero>
|
||||||
|
|
||||||
{configData?.description && (
|
<DetailGrid>
|
||||||
|
<MetaPanel>
|
||||||
<MetaCard>
|
<MetaCard>
|
||||||
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta>
|
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||||
{configData.description}
|
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
|
||||||
|
ROLE: {SISTER_ROLES[name] ?? '--'}<br />
|
||||||
|
STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()}<br />
|
||||||
|
HOST_USER: {sisterInfo?.user ?? '--'}
|
||||||
</div>
|
</div>
|
||||||
|
<TechBar>
|
||||||
|
<TechBarFill $width={isActive ? 100 : 0} />
|
||||||
|
</TechBar>
|
||||||
</MetaCard>
|
</MetaCard>
|
||||||
)}
|
|
||||||
</MetaPanel>
|
|
||||||
|
|
||||||
<div>
|
{configData?.description && (
|
||||||
<TabBar>
|
<MetaCard>
|
||||||
{[
|
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta>
|
||||||
{ id: 'overview', label: '개요' },
|
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||||
{ id: 'config', label: '설정' },
|
{configData.description}
|
||||||
].map((t) => (
|
</div>
|
||||||
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
|
</MetaCard>
|
||||||
{t.label}
|
)}
|
||||||
</TabBtn>
|
</MetaPanel>
|
||||||
))}
|
|
||||||
</TabBar>
|
|
||||||
|
|
||||||
{tab === 'overview' && (
|
<div>
|
||||||
<>
|
<TabBar>
|
||||||
<SectionTitle>
|
{[
|
||||||
<span>RECENT ACTIVITY</span>
|
{ id: 'overview', label: '개요' },
|
||||||
<LabelMeta>{activity.length} ENTRIES</LabelMeta>
|
{ id: 'config', label: '설정' },
|
||||||
</SectionTitle>
|
].map((t) => (
|
||||||
<Timeline>
|
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
|
||||||
{activity.slice(0, 8).map((item) => (
|
{t.label}
|
||||||
<TimelineItem key={item.id}>
|
</TabBtn>
|
||||||
<TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp>
|
))}
|
||||||
<TimelineContent>{item.detail ?? item.action}</TimelineContent>
|
</TabBar>
|
||||||
</TimelineItem>
|
|
||||||
))}
|
|
||||||
{activity.length === 0 && (
|
|
||||||
<TimelineItem>
|
|
||||||
<TimelineContent style={{ color: 'var(--text-secondary)' }}>활동 기록 없음</TimelineContent>
|
|
||||||
</TimelineItem>
|
|
||||||
)}
|
|
||||||
</Timeline>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'config' && (
|
{tab === 'overview' && (
|
||||||
<>
|
<>
|
||||||
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle>
|
<SectionTitle>
|
||||||
<ConfigPre>{configData?.raw || '(설정 파일 없음)'}</ConfigPre>
|
<span>RECENT ACTIVITY</span>
|
||||||
</>
|
<LabelMeta>{activity.length} ENTRIES</LabelMeta>
|
||||||
)}
|
</SectionTitle>
|
||||||
</div>
|
<Timeline>
|
||||||
</DetailGrid>
|
{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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,191 +1,63 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled, { keyframes } from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
|
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
|
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||||
|
|
||||||
const SISTER_ROLES: Record<string, string> = {
|
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
||||||
harang: 'Primary',
|
const SISTER_ROLES: Record<string, string> = { harang: 'Primary', narang: 'Secondary', darang: 'Standby', erang: 'Sync' };
|
||||||
narang: 'Secondary',
|
const NodeGrid = styled.section`display:flex;flex-direction:column;gap:var(--space-lg);`;
|
||||||
darang: 'Standby',
|
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}`;
|
||||||
erang: 'Sync',
|
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 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 MetaVal = styled.div`font-size:13px;color:var(--text-primary);font-family:var(--font-mono);`;
|
||||||
|
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 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 Bar = styled.div`flex:1;height:8px;background:#151515;border:1px solid #222;`;
|
||||||
|
const Fill = styled.div<{ $width:number }>`height:100%;width:${({$width})=>$width}%;background:var(--text-primary);`;
|
||||||
|
const SyncHistory = 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 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 GRAPH_BARS: Record<string, number[]> = {
|
function getNodeStatus(s: any): boolean { return s.status === 'online' || s.status === 'working'; }
|
||||||
harang: [40, 45, 60, 55, 70, 85, 92, 84],
|
function getStatusBtns(s: any): [string, string] { if (s.status === 'offline') return ['STANDBY', 'ACTIVATE']; return ['ACTIVE', 'REBOOT']; }
|
||||||
narang: [30, 32, 35, 40, 38, 45, 50, 48],
|
|
||||||
darang: [10, 10, 5, 5, 5, 5, 2, 2],
|
|
||||||
erang: [20, 25, 15, 30, 40, 35, 20, 15],
|
|
||||||
};
|
|
||||||
|
|
||||||
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 0.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 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 0.15s;
|
|
||||||
|
|
||||||
&:hover { color: var(--accent-hover); }
|
|
||||||
`;
|
|
||||||
|
|
||||||
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,0.3);`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 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 GraphContainer = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 3px;
|
|
||||||
height: 48px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const GraphBar = styled.div<{ $height: number; $highlight?: boolean }>`
|
|
||||||
flex: 1;
|
|
||||||
height: ${({ $height }) => $height}%;
|
|
||||||
min-height: 2px;
|
|
||||||
background: ${({ $highlight }) => $highlight ? 'var(--text-primary)' : 'var(--border-hover)'};
|
|
||||||
transition: height 0.3s;
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 SyncHistory = styled.div`
|
|
||||||
padding: var(--space-lg);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
|
|
||||||
@media (max-width: 1199px) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
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'];
|
|
||||||
if (s.status === 'working') return ['ACTIVE', 'REBOOT'];
|
|
||||||
return ['ACTIVE', 'REBOOT'];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLastSeen(lastSeen: string | null): string {
|
|
||||||
if (!lastSeen) return '—';
|
|
||||||
const diff = Date.now() - new Date(lastSeen).getTime();
|
|
||||||
const h = Math.floor(diff / 3600000);
|
|
||||||
const m = Math.floor((diff % 3600000) / 60000);
|
|
||||||
const s = Math.floor((diff % 60000) / 1000);
|
|
||||||
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_SISTERS = [
|
|
||||||
{ id: 1, name: 'harang', status: 'online', lastSeen: new Date(Date.now() - 3600000).toISOString() },
|
|
||||||
{ id: 2, name: 'narang', status: 'working', lastSeen: new Date(Date.now() - 1800000).toISOString() },
|
|
||||||
{ id: 3, name: 'darang', status: 'offline', lastSeen: new Date(Date.now() - 7200000).toISOString() },
|
|
||||||
{ id: 4, name: 'erang', status: 'online', lastSeen: new Date(Date.now() - 900000).toISOString() },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SistersPage() {
|
export default function SistersPage() {
|
||||||
const [sisters, setSisters] = useState<any[]>(MOCK_SISTERS);
|
const [sisters, setSisters] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_URL}/api/sisters`)
|
let active = true;
|
||||||
.then((r) => r.json())
|
const load = async () => {
|
||||||
.then(setSisters)
|
try {
|
||||||
.catch(() => {});
|
const base = await fetch(`${API_URL}/api/sisters`).then((r) => r.ok ? r.json() : Promise.reject());
|
||||||
const iv = setInterval(() => {
|
const withSystem = await Promise.all(base.map(async (s: any) => {
|
||||||
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
|
try {
|
||||||
}, 15000);
|
const system = await fetch(`${API_URL}/api/sisters/${s.name}/system`).then((r) => r.ok ? r.json() : null);
|
||||||
return () => clearInterval(iv);
|
return { ...s, ...(system ?? {}) };
|
||||||
|
} catch {
|
||||||
|
return { ...s, uptime: '00:00:00', cpu: 0, memory: { used: 0, total: 0 }, disk: { used: '0G', total: '0G' } };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (active) { setSisters(withSystem); setError(null); }
|
||||||
|
} catch {
|
||||||
|
if (active) setError('데이터를 불러올 수 없습니다');
|
||||||
|
} finally {
|
||||||
|
if (active) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const iv = setInterval(load, 15000);
|
||||||
|
return () => { active = false; clearInterval(iv); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -194,66 +66,35 @@ export default function SistersPage() {
|
|||||||
<PageTitle>자매 노드 관리</PageTitle>
|
<PageTitle>자매 노드 관리</PageTitle>
|
||||||
<LabelMeta><span>TOTAL NODES:</span> {String(sisters.length).padStart(2, '0')}</LabelMeta>
|
<LabelMeta><span>TOTAL NODES:</span> {String(sisters.length).padStart(2, '0')}</LabelMeta>
|
||||||
</div>
|
</div>
|
||||||
|
{error && <ErrorBox>{error}</ErrorBox>}
|
||||||
<NodeGrid>
|
<NodeGrid>
|
||||||
{sisters.map((s) => {
|
{loading ? Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} />) : sisters.map((s) => {
|
||||||
const isActive = getNodeStatus(s);
|
const isActive = getNodeStatus(s);
|
||||||
const [btn1, btn2] = getStatusBtns(s);
|
const [btn1, btn2] = getStatusBtns(s);
|
||||||
const bars = GRAPH_BARS[s.name] ?? [5, 5, 5, 5, 5, 5, 5, 5];
|
|
||||||
const loadIndex = isActive
|
|
||||||
? `${(bars[bars.length - 1]).toFixed(1)}%`
|
|
||||||
: '0.0%';
|
|
||||||
const metaLabel = s.status === 'offline' ? 'Last Active' : s.name === 'erang' ? 'Queue Depth' : 'Uptime';
|
|
||||||
const metaVal = s.status === 'offline'
|
|
||||||
? formatLastSeen(s.lastSeen)
|
|
||||||
: s.name === 'erang' ? '12 PKTS'
|
|
||||||
: formatLastSeen(s.lastSeen).replace(' ago', '');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeEntry key={s.id ?? s.name}>
|
<NodeEntry key={s.id ?? s.name}>
|
||||||
<NodeInfo>
|
<NodeInfo>
|
||||||
<NodeName href={`/sisters/${s.name}`}>
|
<NodeName href={`/sisters/${s.name}`}>
|
||||||
<StatusIndicator $active={isActive} />
|
<StatusIndicator $active={isActive} />
|
||||||
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'}{' '}
|
<SisterAvatar name={s.name} size={28} />
|
||||||
({SISTER_ROLES[s.name] ?? s.name})
|
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'} ({SISTER_ROLES[s.name] ?? s.name})
|
||||||
</NodeName>
|
</NodeName>
|
||||||
<MetaPair>
|
<MetaPair><LabelMeta>Uptime</LabelMeta><MetaVal>{s.uptime ?? '00:00:00'}</MetaVal></MetaPair>
|
||||||
<LabelMeta>{metaLabel}</LabelMeta>
|
<MetaPair><LabelMeta>Last Seen</LabelMeta><MetaVal>{s.lastSeen ? new Date(s.lastSeen).toLocaleString('ko-KR') : '—'}</MetaVal></MetaPair>
|
||||||
<MetaVal>{metaVal}</MetaVal>
|
<NodeControls><BtnToggle>{btn1}</BtnToggle><BtnToggle>{btn2}</BtnToggle></NodeControls>
|
||||||
</MetaPair>
|
|
||||||
<NodeControls>
|
|
||||||
<BtnToggle $active>{btn1}</BtnToggle>
|
|
||||||
<BtnToggle>{btn2}</BtnToggle>
|
|
||||||
</NodeControls>
|
|
||||||
</NodeInfo>
|
</NodeInfo>
|
||||||
|
|
||||||
<CapacitySection>
|
<CapacitySection>
|
||||||
<LabelMeta>CAPACITY HISTORY (24H)</LabelMeta>
|
<BarWrap>
|
||||||
<GraphContainer>
|
<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>
|
||||||
{bars.map((h, i) => (
|
<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>
|
||||||
<GraphBar key={i} $height={h} $highlight={i >= bars.length - 2} />
|
<BarRow><span>DSK</span><span>{s.disk?.used ?? '0G'}/{s.disk?.total ?? '0G'}</span><Bar><Fill $width={0} /></Bar></BarRow>
|
||||||
))}
|
</BarWrap>
|
||||||
</GraphContainer>
|
|
||||||
<SyncRow>
|
|
||||||
<span>LOAD INDEX</span>
|
|
||||||
<span>{loadIndex}</span>
|
|
||||||
</SyncRow>
|
|
||||||
</CapacitySection>
|
</CapacitySection>
|
||||||
|
|
||||||
<SyncHistory>
|
<SyncHistory>
|
||||||
<LabelMeta>SYNC LOG</LabelMeta>
|
<SyncRow><span>STATUS</span><span>{String(s.status).toUpperCase()}</span></SyncRow>
|
||||||
{[
|
<SyncRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span></SyncRow>
|
||||||
{ time: '14:05:22', status: isActive ? 'SUCCESS' : 'IDLE' },
|
<SyncRow><span>MEMORY</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span></SyncRow>
|
||||||
{ time: '13:55:01', status: isActive ? 'SUCCESS' : 'IDLE' },
|
<SyncRow><span>DISK</span><span>{s.disk?.used ?? '0G'} / {s.disk?.total ?? '0G'}</span></SyncRow>
|
||||||
{ time: '13:44:10', status: isActive ? 'SUCCESS' : 'IDLE' },
|
|
||||||
].map((row) => (
|
|
||||||
<SyncRow key={row.time}>
|
|
||||||
<span>{row.time}</span>
|
|
||||||
<span style={{ color: row.status === 'SUCCESS' ? '#00FF00' : 'var(--text-secondary)' }}>
|
|
||||||
{row.status}
|
|
||||||
</span>
|
|
||||||
</SyncRow>
|
|
||||||
))}
|
|
||||||
</SyncHistory>
|
</SyncHistory>
|
||||||
</NodeEntry>
|
</NodeEntry>
|
||||||
);
|
);
|
||||||
|
|||||||
50
frontend/components/common/AppShell.tsx
Normal file
50
frontend/components/common/AppShell.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
||||||
|
import LayoutShell from './LayoutShell';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = ['/login'];
|
||||||
|
|
||||||
|
function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
const isPublic = PUBLIC_PATHS.includes(pathname);
|
||||||
|
if (!isAuthenticated && !isPublic) {
|
||||||
|
router.replace('/login');
|
||||||
|
}
|
||||||
|
if (isAuthenticated && isPublic) {
|
||||||
|
router.replace('/');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, loading, pathname, router]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
height: '100vh', background: '#151515', color: '#8A8A8A',
|
||||||
|
fontFamily: 'monospace', fontSize: '12px',
|
||||||
|
}}>
|
||||||
|
AUTHENTICATING...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPublic = PUBLIC_PATHS.includes(pathname);
|
||||||
|
if (isPublic) return <>{children}</>;
|
||||||
|
|
||||||
|
return <LayoutShell>{children}</LayoutShell>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthGate>{children}</AuthGate>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import React from 'react';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
import { useSidebar } from '@/lib/SidebarContext';
|
import { useSidebar } from '@/lib/SidebarContext';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@@ -144,6 +145,7 @@ const TabItem = styled(Link)<{ $active: boolean }>`
|
|||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
const isActive = (href: string) =>
|
const isActive = (href: string) =>
|
||||||
href === '/' ? pathname === '/' : pathname.startsWith(href);
|
href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||||
@@ -166,6 +168,25 @@ export default function Sidebar() {
|
|||||||
))}
|
))}
|
||||||
</NavMenu>
|
</NavMenu>
|
||||||
</nav>
|
</nav>
|
||||||
|
{user && (
|
||||||
|
<div style={{ marginTop: 'auto', paddingTop: 'var(--space-xl)' }}>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)', marginBottom: 'var(--space-sm)' }}>
|
||||||
|
[{user.username}]
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none', color: 'var(--text-secondary)',
|
||||||
|
fontSize: '13px', cursor: 'pointer', padding: 0, textAlign: 'left',
|
||||||
|
transition: 'color 0.15s', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SidebarWrapper>
|
</SidebarWrapper>
|
||||||
|
|
||||||
<BottomTabBar>
|
<BottomTabBar>
|
||||||
|
|||||||
66
frontend/components/common/SisterAvatar.tsx
Normal file
66
frontend/components/common/SisterAvatar.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { API_URL } from '@/lib/config';
|
||||||
|
|
||||||
|
const SISTER_INITIALS: Record<string, string> = {
|
||||||
|
harang: '하',
|
||||||
|
narang: '나',
|
||||||
|
darang: '다',
|
||||||
|
erang: '이',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImgEl = styled.img<{ $size: number }>`
|
||||||
|
width: ${({ $size }) => $size}px;
|
||||||
|
height: ${({ $size }) => $size}px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: #111;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Fallback = styled.div<{ $size: number }>`
|
||||||
|
width: ${({ $size }) => $size}px;
|
||||||
|
height: ${({ $size }) => $size}px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #222;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: ${({ $size }) => Math.round($size * 0.4)}px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface SisterAvatarProps {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SisterAvatar({ name, size = 32, className, style }: SisterAvatarProps) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const initial = SISTER_INITIALS[name] ?? name.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return <Fallback $size={size} className={className} style={style}>{initial}</Fallback>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImgEl
|
||||||
|
$size={size}
|
||||||
|
src={`${API_URL}/api/sisters/${name}/avatar`}
|
||||||
|
alt={name}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
frontend/lib/AuthContext.tsx
Normal file
122
frontend/lib/AuthContext.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { API_URL } from './config';
|
||||||
|
|
||||||
|
interface AuthUser {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: AuthUser | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue>({
|
||||||
|
user: null,
|
||||||
|
loading: true,
|
||||||
|
login: async () => {},
|
||||||
|
logout: () => {},
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadUser = useCallback(async () => {
|
||||||
|
const token = localStorage.getItem('hanarang_access_token');
|
||||||
|
if (!token) { setLoading(false); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setUser({ userId: data.id, username: data.username, role: data.role });
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
// access token 만료 → refresh 시도
|
||||||
|
const refreshToken = localStorage.getItem('hanarang_refresh_token');
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const rRes = await fetch(`${API_URL}/api/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-refresh-token': refreshToken },
|
||||||
|
});
|
||||||
|
if (rRes.ok) {
|
||||||
|
const d = await rRes.json();
|
||||||
|
localStorage.setItem('hanarang_access_token', d.accessToken);
|
||||||
|
if (d.refreshToken) localStorage.setItem('hanarang_refresh_token', d.refreshToken);
|
||||||
|
setUser({ userId: 0, username: d.username, role: d.role });
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('hanarang_access_token');
|
||||||
|
localStorage.removeItem('hanarang_refresh_token');
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('hanarang_access_token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadUser(); }, [loadUser]);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
const res = await fetch(`${API_URL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
throw new Error(d.message ?? '로그인 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = await res.json();
|
||||||
|
localStorage.setItem('hanarang_access_token', d.accessToken);
|
||||||
|
if (d.refreshToken) {
|
||||||
|
localStorage.setItem('hanarang_refresh_token', d.refreshToken);
|
||||||
|
}
|
||||||
|
// me API로 실제 userId 가져오기
|
||||||
|
try {
|
||||||
|
const meRes = await fetch(`${API_URL}/api/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${d.accessToken}` },
|
||||||
|
});
|
||||||
|
if (meRes.ok) {
|
||||||
|
const me = await meRes.json();
|
||||||
|
setUser({ userId: me.id, username: me.username, role: me.role });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
setUser({ userId: 0, username: d.username, role: d.role });
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('hanarang_access_token');
|
||||||
|
localStorage.removeItem('hanarang_refresh_token');
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, logout, isAuthenticated: !!user }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* 관리자 API 호출 유틸.
|
* 관리자 API 호출 유틸 — JWT 전용.
|
||||||
* API_URL이 빈 문자열이면 상대경로 → Next.js rewrites → BE 프록시.
|
* 로그인 후 localStorage의 access token을 Authorization 헤더에 설정한다.
|
||||||
* Authorization 헤더에 localStorage의 admin key 삽입.
|
|
||||||
*/
|
*/
|
||||||
export function adminFetch(path: string, options?: RequestInit) {
|
export function getAdminToken(): string {
|
||||||
const apiKey =
|
if (typeof window === 'undefined') return '';
|
||||||
typeof window !== 'undefined'
|
return localStorage.getItem('hanarang_access_token') ?? '';
|
||||||
? (localStorage.getItem('hanarang_admin_key') ?? '')
|
}
|
||||||
: '';
|
|
||||||
|
export function adminFetch(path: string, options?: RequestInit) {
|
||||||
// CSR: path가 /api/... 이면 상대경로 그대로 사용
|
const token = getAdminToken();
|
||||||
// SSR에서는 adminFetch 미사용 (모든 admin 페이지가 'use client')
|
|
||||||
return fetch(path, {
|
const headers: Record<string, string> = {
|
||||||
...options,
|
'Content-Type': 'application/json',
|
||||||
headers: {
|
...(options?.headers as Record<string, string> ?? {}),
|
||||||
'Content-Type': 'application/json',
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
};
|
||||||
...(options?.headers ?? {}),
|
|
||||||
},
|
return fetch(path, { ...options, headers });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ export function useSocket(options: UseSocketOptions = {}) {
|
|||||||
// next.config.ts rewrites가 없는 경우를 위해 빈 origin 처리
|
// next.config.ts rewrites가 없는 경우를 위해 빈 origin 처리
|
||||||
// 브라우저에서 WebSocket은 rewrites 대상이 아니므로 직접 BE URL 필요
|
// 브라우저에서 WebSocket은 rewrites 대상이 아니므로 직접 BE URL 필요
|
||||||
const wsUrl = process.env.NEXT_PUBLIC_WS_URL ?? window.location.origin;
|
const wsUrl = process.env.NEXT_PUBLIC_WS_URL ?? window.location.origin;
|
||||||
|
const token = localStorage.getItem('hanarang_access_token') ?? '';
|
||||||
|
|
||||||
const socket = io(`${wsUrl}/ws`, {
|
const socket = io(`${wsUrl}/ws`, {
|
||||||
path: '/socket.io',
|
path: '/socket.io',
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
reconnectionAttempts: 5,
|
reconnectionAttempts: 5,
|
||||||
reconnectionDelay: 3000,
|
reconnectionDelay: 3000,
|
||||||
|
auth: { token },
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user