Compare commits
5 Commits
194bc5a91c
...
feature/sp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba76345958 | ||
| ae628bc797 | |||
| bd2f52ddcd | |||
| 1294987314 | |||
| 6c518bb231 |
@@ -1,72 +0,0 @@
|
|||||||
# 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 성공 + 테스트 통과
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# 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 성공 + 테스트 통과
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# 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 필수**
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 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 불완전
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# SPRINT-REDESIGN QA Review — Iteration 1
|
|
||||||
|
|
||||||
- **검증일시:** 2026-04-04 04:13 UTC
|
|
||||||
- **검증자:** 다랑이 (Evaluator)
|
|
||||||
- **결과:** ✅ PASSED
|
|
||||||
|
|
||||||
## 검증 항목
|
|
||||||
|
|
||||||
| 항목 | 결과 |
|
|
||||||
|------|------|
|
|
||||||
| npm install | ✅ |
|
|
||||||
| npm run build | ✅ 14 routes |
|
|
||||||
| 백엔드 변경 | 없음 (FE only) |
|
|
||||||
|
|
||||||
## DESIGN-SYSTEM.md 충실도
|
|
||||||
|
|
||||||
| 디자인 토큰 | 코드 일치 |
|
|
||||||
|-------------|-----------|
|
|
||||||
| 색상 (--bg-main, --text-primary, --border-color 등) | ✅ |
|
|
||||||
| 폰트 (--font-sans, --font-mono) | ✅ |
|
|
||||||
| 스페이싱 (--space-xs ~ --space-xxl) | ✅ |
|
|
||||||
| Sidebar 240px / 64px / 하단 탭바 | ✅ |
|
|
||||||
| 로고 ● + ◗ | ✅ |
|
|
||||||
| 네비 [브라켓] active 표기 | ✅ |
|
|
||||||
| Card 1px border + hover | ✅ |
|
|
||||||
| TechBar 2px | ✅ |
|
|
||||||
| Timeline 세로선 + 7px dot | ✅ |
|
|
||||||
| BracketValue 32px → 24px 모바일 | ✅ |
|
|
||||||
| LabelMeta 11px 700 uppercase | ✅ |
|
|
||||||
|
|
||||||
## 반응형 3단계
|
|
||||||
|
|
||||||
| Breakpoint | 확인 |
|
|
||||||
|------------|------|
|
|
||||||
| ≥1200px Desktop | ✅ 기본 레이아웃 |
|
|
||||||
| 768-1199px Tablet | ✅ Sidebar 64px 아이콘, 카드 2열 |
|
|
||||||
| ≤767px Mobile | ✅ 하단 탭바 56px, 카드 1열(2→1), padding-bottom 72px |
|
|
||||||
|
|
||||||
## 새 페이지/컴포넌트
|
|
||||||
|
|
||||||
| 항목 | 확인 |
|
|
||||||
|------|------|
|
|
||||||
| /activities 페이지 | ✅ |
|
|
||||||
| /projects 목록 페이지 | ✅ |
|
|
||||||
| components/ui/base.tsx 공통 컴포넌트 | ✅ |
|
|
||||||
| CSS vars 전면 적용 (theme.ts 하드코딩 대체) | ✅ |
|
|
||||||
|
|
||||||
## Non-blocking 이슈
|
|
||||||
|
|
||||||
| ID | 이슈 |
|
|
||||||
|----|------|
|
|
||||||
| N1 | 일부 admin 컴포넌트(BarChart, CodeEditor 등)에서 old theme import 잔존 가능 — 빌드 통과했으므로 미사용 import만 정리하면 됨 |
|
|
||||||
| N2 | SidebarContext가 아직 존재하나 LayoutShell에서 Provider 감싸지 않음 — 사용 안 되면 삭제 권장 |
|
|
||||||
|
|
||||||
## 판정 근거
|
|
||||||
- FE 빌드 14 routes 전부 통과
|
|
||||||
- DESIGN-SYSTEM.md 디자인 토큰 전부 충실히 구현
|
|
||||||
- 반응형 3단계 breakpoint 코드 확인
|
|
||||||
- glassmorphism → 미니멀 터미널 UI 전환 완료
|
|
||||||
- 백엔드 변경 없어 기능 회귀 위험 없음
|
|
||||||
@@ -15,7 +15,6 @@ 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';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -33,7 +32,6 @@ import { GiteaSyncModule } from './gitea-sync/gitea-sync.module';
|
|||||||
AdminModule,
|
AdminModule,
|
||||||
EventsModule,
|
EventsModule,
|
||||||
CostsModule,
|
CostsModule,
|
||||||
GiteaSyncModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Controller, Post, Get, UseGuards } from '@nestjs/common';
|
|
||||||
import { GiteaSyncService } from './gitea-sync.service';
|
|
||||||
import { CompositeGuard } from '../auth/jwt.guard';
|
|
||||||
import { RoleGuard, Roles } from '../auth/role.guard';
|
|
||||||
|
|
||||||
@Controller('api/admin/gitea')
|
|
||||||
@UseGuards(CompositeGuard, 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() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule, GiteaModule, ActivityModule, AuthModule],
|
|
||||||
controllers: [GiteaSyncController],
|
|
||||||
providers: [GiteaSyncService],
|
|
||||||
exports: [GiteaSyncService],
|
|
||||||
})
|
|
||||||
export class GiteaSyncModule {}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
@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,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
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`);
|
|
||||||
return { synced: repos.length, created, updated };
|
|
||||||
}
|
|
||||||
|
|
||||||
getLastSyncAt() {
|
|
||||||
return this.lastSyncAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,24 +22,6 @@ 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()
|
||||||
@@ -105,44 +87,4 @@ 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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Controller, Get, Param, Query, ParseIntPipe, UseGuards, Post } from '@nestjs/common';
|
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
import { CompositeGuard } from '../auth/jwt.guard';
|
|
||||||
import { RoleGuard, Roles } from '../auth/role.guard';
|
|
||||||
|
|
||||||
@Controller('api/projects')
|
@Controller('api/projects')
|
||||||
export class ProjectsController {
|
export class ProjectsController {
|
||||||
@@ -16,27 +14,4 @@ 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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,28 +79,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Injectable, Logger, Optional } from '@nestjs/common';
|
import { Injectable, Logger } 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;
|
||||||
@@ -30,7 +29,6 @@ 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[]> {
|
||||||
@@ -64,7 +62,7 @@ export class SistersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkSisterStatus(
|
private async checkSisterStatus(
|
||||||
sister: { id: number; name: string; ip: string; user: string; lxcId: number; lastSeen: Date | null; status: string },
|
sister: { id: number; name: string; ip: string; user: string; lxcId: number; lastSeen: Date | null },
|
||||||
sshKeyPath: string,
|
sshKeyPath: string,
|
||||||
): Promise<SisterStatus> {
|
): Promise<SisterStatus> {
|
||||||
try {
|
try {
|
||||||
@@ -79,20 +77,11 @@ export class SistersService {
|
|||||||
const status: 'online' | 'offline' = isActive ? 'online' : 'offline';
|
const status: 'online' | 'offline' = isActive ? 'online' : 'offline';
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
// 온라인이면 lastSeen 업데이트 + 상태 변경 감지
|
// 온라인이면 lastSeen 업데이트
|
||||||
const prevStatus = sister.status;
|
|
||||||
await this.prisma.sisterConfig.update({
|
await this.prisma.sisterConfig.update({
|
||||||
where: { id: sister.id },
|
where: { id: sister.id },
|
||||||
data: { lastSeen: new Date(), status },
|
data: { lastSeen: new Date(), status },
|
||||||
});
|
});
|
||||||
// 상태 변경 시 ActivityLog 기록
|
|
||||||
if (prevStatus !== status && this.activity) {
|
|
||||||
await this.activity.log({
|
|
||||||
sisterId: sister.id,
|
|
||||||
action: 'status_changed',
|
|
||||||
detail: `[${sister.name}] status: ${prevStatus} → ${status}`,
|
|
||||||
}).catch(() => {}); // 비동기 오류 무시
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
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 { 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,188 +25,19 @@ 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;
|
||||||
@@ -249,6 +80,39 @@ 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;
|
||||||
@@ -262,10 +126,13 @@ 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 NodeDot = styled.div`
|
const NodeStatusDot = styled.div`
|
||||||
width: 6px; height: 6px;
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
background: #00FF00;
|
background: #00FF00;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
`;
|
`;
|
||||||
@@ -286,47 +153,43 @@ 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; height: 14px;
|
width: 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`, isActive: false };
|
if (sprint.status === 'done') return { label: `COMPLETED\n${sprint.completedAt ? new Date(sprint.completedAt).toLocaleDateString() : ''}`, isActive: false };
|
||||||
if (sprint.status === 'in_progress') return { label: 'IN PROGRESS', isActive: true };
|
if (sprint.status === 'in_progress') return { label: 'IN PROGRESS\nEST: TBD', isActive: true };
|
||||||
return { label: 'PENDING', isActive: false };
|
return { label: 'PENDING\nTBD', 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([
|
||||||
@@ -338,206 +201,112 @@ export default function ProjectDetailPage() {
|
|||||||
}).finally(() => setLoading(false));
|
}).finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const loadCommits = useCallback(async () => {
|
if (loading) return <div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>로딩 중...</div>;
|
||||||
setTabLoading(true);
|
if (!project) return <div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>프로젝트 없음</div>;
|
||||||
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')} / {project.name}
|
<Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / SUMMARY
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
<PageTitleRow>
|
<PageTitleRow>
|
||||||
<PageTitle>
|
<PageTitle>{project.name}</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>
|
||||||
|
|
||||||
<TabBar>
|
<ProjectGrid>
|
||||||
{TABS.map((t) => (
|
<div>
|
||||||
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
|
{/* Phase Timeline */}
|
||||||
{t.label}
|
<SectionTitle>
|
||||||
</TabBtn>
|
<span>PHASE TIMELINE</span>
|
||||||
))}
|
<LabelMeta>CURRENT: {activeSprint ? `S${activeSprint.number}` : 'N/A'}</LabelMeta>
|
||||||
</TabBar>
|
</SectionTitle>
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* OVERVIEW */}
|
{/* Audit Log (활동 로그) */}
|
||||||
{tab === 'overview' && (
|
<SectionTitle>
|
||||||
<ProjectGrid>
|
<span>SECURITY AUDIT LOG</span>
|
||||||
<div>
|
<LabelMeta>LAST CHECK: LIVE</LabelMeta>
|
||||||
<SectionTitle>
|
</SectionTitle>
|
||||||
<span>PHASE TIMELINE</span>
|
<AuditLog>
|
||||||
<LabelMeta>CURRENT: {activeSprint ? `S${activeSprint.number}` : 'N/A'}</LabelMeta>
|
<AuditRow $header>
|
||||||
</SectionTitle>
|
<div>TIMESTAMP</div>
|
||||||
<PhaseTimeline>
|
<div>ACTION / RESOURCE</div>
|
||||||
{tasks.map((sprint: any) => {
|
<div>RESULT</div>
|
||||||
const { label, isActive } = getSprintMeta(sprint);
|
</AuditRow>
|
||||||
return (
|
{allTasks.slice(0, 5).map((task: any) => (
|
||||||
<PhaseItem key={sprint.id}>
|
<AuditRow key={task.id}>
|
||||||
<PhaseMeta>{label}</PhaseMeta>
|
<div>{new Date(task.createdAt).toLocaleTimeString('ko-KR', { hour12: false })}</div>
|
||||||
<PhaseBox $active={isActive}>
|
<div>{task.taskId} / {task.assignee?.toUpperCase()}</div>
|
||||||
<PhaseName>SPRINT {String(sprint.number).padStart(2, '0')}: {sprint.name}</PhaseName>
|
<StatusTag $pass={task.status === 'done'}>
|
||||||
<PhaseDesc>태스크 {sprint.tasks?.length ?? 0}개 · 완료 {sprint.tasks?.filter((t: any) => t.status === 'done').length ?? 0}개</PhaseDesc>
|
{task.status === 'done' ? 'PASS' : task.status === 'failed' ? 'FAIL' : 'PEND'}
|
||||||
<TechBar style={{ marginTop: 'var(--space-sm)' }}>
|
</StatusTag>
|
||||||
<TechBarFill $width={sprint.progress ?? 0} />
|
</AuditRow>
|
||||||
</TechBar>
|
|
||||||
</PhaseBox>
|
|
||||||
</PhaseItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{tasks.length === 0 && <PhaseItem><PhaseMeta>PENDING</PhaseMeta><PhaseBox><PhaseName>Sprint 없음</PhaseName></PhaseBox></PhaseItem>}
|
|
||||||
</PhaseTimeline>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle>
|
|
||||||
<NodeStack>
|
|
||||||
{['harang', 'narang', 'darang', 'erang'].map((name) => (
|
|
||||||
<NodeMiniCard key={name}>
|
|
||||||
<LabelMeta>{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'} [{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'GEN' : name === 'darang' ? 'EVAL' : 'INFRA'}]</LabelMeta>
|
|
||||||
<NodeDot />
|
|
||||||
</NodeMiniCard>
|
|
||||||
))}
|
|
||||||
</NodeStack>
|
|
||||||
|
|
||||||
<SectionTitle>TASK CHECKLIST</SectionTitle>
|
|
||||||
<Checklist>
|
|
||||||
{allTasks.slice(0, 8).map((task: any) => (
|
|
||||||
<CheckItem key={task.id} $done={task.status === 'done'}>
|
|
||||||
<CheckBox $checked={task.status === 'done'} />
|
|
||||||
<span>[{task.taskId}] {task.title}</span>
|
|
||||||
</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>
|
|
||||||
))}
|
))}
|
||||||
</FilterRow>
|
</AuditLog>
|
||||||
{tabLoading ? <Empty>LOADING PULL REQUESTS...</Empty> : (
|
</div>
|
||||||
<PRList>
|
|
||||||
{pulls.length === 0 ? (
|
<div>
|
||||||
<Empty>NO PULL REQUESTS ({prFilter})</Empty>
|
{/* Assigned Nodes */}
|
||||||
) : (
|
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle>
|
||||||
pulls.map((pr: any) => (
|
<NodeStack>
|
||||||
<PRItem key={pr.id} href={pr.html_url} target="_blank" rel="noopener">
|
{['harang', 'narang', 'darang', 'erang'].map((name) => (
|
||||||
<PRState $state={pr.merged ? 'merged' : pr.state}>
|
<NodeMiniCard key={name}>
|
||||||
{pr.merged ? 'MERGED' : pr.state?.toUpperCase()}
|
<LabelMeta>
|
||||||
</PRState>
|
{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'}{' '}
|
||||||
<PRTitle>#{pr.number} {pr.title}</PRTitle>
|
[{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'SECONDARY' : name === 'darang' ? 'EVALUATOR' : 'INFRA'}]
|
||||||
<PRMeta>{pr.user?.login ?? '-'} · {pr.created_at ? formatDate(pr.created_at) : '-'}</PRMeta>
|
</LabelMeta>
|
||||||
</PRItem>
|
<NodeStatusDot />
|
||||||
))
|
</NodeMiniCard>
|
||||||
)}
|
))}
|
||||||
</PRList>
|
</NodeStack>
|
||||||
)}
|
|
||||||
</>
|
{/* Task Checklist */}
|
||||||
)}
|
<SectionTitle>TASK CHECKLIST</SectionTitle>
|
||||||
|
<Checklist>
|
||||||
|
{allTasks.slice(0, 8).map((task: any) => (
|
||||||
|
<CheckItem key={task.id} $done={task.status === 'done'}>
|
||||||
|
<CheckBox $checked={task.status === 'done'} />
|
||||||
|
<span>[{task.taskId}] {task.title}</span>
|
||||||
|
</CheckItem>
|
||||||
|
))}
|
||||||
|
{allTasks.length === 0 && (
|
||||||
|
<CheckItem>
|
||||||
|
<CheckBox />
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>태스크 없음</span>
|
||||||
|
</CheckItem>
|
||||||
|
)}
|
||||||
|
</Checklist>
|
||||||
|
</div>
|
||||||
|
</ProjectGrid>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn, BtnPrimary } from '@/components/ui/base';
|
import { PageTitle, LabelMeta, TechBar, TechBarFill } 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;
|
||||||
@@ -86,50 +85,20 @@ 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);
|
|
||||||
|
|
||||||
const loadProjects = () => {
|
useEffect(() => {
|
||||||
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} created, ${d.updated ?? 0} updated`);
|
|
||||||
loadProjects();
|
|
||||||
} catch (e) {
|
|
||||||
setSyncResult('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>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
|
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
|
||||||
{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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -41,28 +41,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setUser({ userId: data.id, username: data.username, role: data.role });
|
setUser({ userId: data.id, username: data.username, role: data.role });
|
||||||
} else if (res.status === 401) {
|
} else {
|
||||||
// access token 만료 → refresh 시도
|
localStorage.removeItem('hanarang_access_token');
|
||||||
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 {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
@@ -90,17 +70,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (d.refreshToken) {
|
if (d.refreshToken) {
|
||||||
localStorage.setItem('hanarang_refresh_token', 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 });
|
setUser({ userId: 0, username: d.username, role: d.role });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user