Compare commits

..

5 Commits

Author SHA1 Message Date
nabomhalang
ba76345958 fix: API Key strategy returns admin role for RoleGuard compatibility 2026-04-04 06:23:35 +00:00
ae628bc797 QA: SPRINT-006 review iteration 2 — PASSED 2026-04-04 15:17:35 +09:00
bd2f52ddcd fix(sprint-006): Critical 보안 이슈 5건 수정
B1(Critical): JWT_SECRET 'changeme' fallback 3곳 전부 제거
- auth.service.ts / auth.module.ts / events.module.ts: 미설정 시 throw
- events.gateway.ts: 미설정 시 client.disconnect()

B2(Critical): User.role 기본값 'admin' → 'viewer'
- Prisma schema @default('viewer')
- RoleGuard 신규 생성 (Roles 데코레이터)
- AdminController / CostsController @Roles('admin') 적용

B3: WebSocket JWT 실패/미제공 시 client.disconnect()
- 토큰 없으면 즉시 disconnect
- 토큰 있지만 검증 실패해도 disconnect
- FE useSocket: access token을 auth.token으로 전달

B4: refresh endpoint throw Error → BadRequestException (400)

B5: register Throttle 추가 (3회/분, 초대코드 brute-force 방지)
2026-04-04 15:16:12 +09:00
1294987314 QA: SPRINT-006 review iteration 1 — FAILED (blocking 5, 2 critical) 2026-04-04 15:11:34 +09:00
6c518bb231 feat(sprint-006): 버그 수정 3건 + JWT 인증 시스템
TASK-020 버그 수정:
- BUG-1: adminFetch Authorization 헤더 우선순위 수정 (JWT > API Key)
  하네스 저장 실패 에러 핸들링 개선 (401/non-ok 명시적 처리)
- BUG-2: /admin/logs useEffect 초기 로드 추가 (마운트 시 자동 조회)
- BUG-3: costs/record python3 → node/python3/jq fallback 체인

TASK-021 JWT BE:
- User 모델 추가 (Prisma schema)
- AuthService: register(초대코드 검증+bcrypt) / login / refresh / getMe
- JwtStrategy (passport-jwt), JwtGuard, CompositeGuard (JWT+API Key)
- AuthController: /api/auth/{register,login,refresh,me}
- ThrottlerModule: 로그인 5회/분 Rate limiting
- CompositeGuard로 AdminController, CostsController Guard 전환
- EventsGateway: Socket.IO handshake JWT 검증 추가
- 테스트 26/26 pass

TASK-022 FE:
- AuthContext (AuthProvider + useAuth)
- AppShell (AuthProvider + AuthGate 라우트 보호)
- /login 페이지 (터미널 UI)
- /register 페이지 (초대 코드 필드)
- Sidebar: 로그인 사용자명 + 로그아웃 버튼
- FE 16 routes build 성공
2026-04-04 15:02:40 +09:00
17 changed files with 166 additions and 1043 deletions

View File

@@ -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 성공 + 테스트 통과

View File

@@ -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 성공 + 테스트 통과

View File

@@ -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 필수**

View File

@@ -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 불완전

View File

@@ -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 |

View File

@@ -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 전환 완료
- 백엔드 변경 없어 기능 회귀 위험 없음

View File

@@ -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],

View File

@@ -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() };
}
}

View File

@@ -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 {}

View File

@@ -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;
}
}

View File

@@ -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 [];
}
}
} }

View File

@@ -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');
}
} }

View File

@@ -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;
}
} }

View File

@@ -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 {

View File

@@ -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>
</> </>
); );
} }

View File

@@ -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 ? (

View File

@@ -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 });
}; };