Compare commits
15 Commits
feature/sp
...
hotfix/pro
| Author | SHA1 | Date | |
|---|---|---|---|
| 89a590839d | |||
| 16660e5611 | |||
| fcdac04481 | |||
| f7f0d050ec | |||
| 49bba64f1f | |||
| 3ce7169c21 | |||
| 1a5ad12e54 | |||
| b5d7746e36 | |||
| ff3a057b28 | |||
| b85ca55482 | |||
| 8a59b63d9f | |||
| 7aab46bae9 | |||
| b4855accee | |||
| 176cb51f8a | |||
| 784000ebba |
31
.plans/sprints/HOTFIX-002.md
Normal file
31
.plans/sprints/HOTFIX-002.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# HOTFIX-002: Sprint 태스크 수/완료 수 파싱 보정
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
프로젝트 상세 화면에서 Sprint 상태는 맞게 보이지만 `태스크 0개 / 완료 0개`로 표시되는 문제 수정
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-044: Sprint task count 파싱 수정
|
||||||
|
- `.plans/sprints/SPRINT-XXX.md`에서 실제 태스크 개수를 파싱
|
||||||
|
- 지원 포맷:
|
||||||
|
- `TASK-001: 설명`
|
||||||
|
- `- TASK-001 설명`
|
||||||
|
- `- [ ] TASK-001 설명`
|
||||||
|
- `- [x] TASK-001 설명`
|
||||||
|
- 중복 카운트 방지
|
||||||
|
- heading/본문에 포함된 단순 문자열 오탐 방지
|
||||||
|
|
||||||
|
### TASK-045: 완료 task count 계산 보정
|
||||||
|
- 완료 태스크 수 계산 규칙 정의
|
||||||
|
- 우선순위:
|
||||||
|
1. 체크박스 `[x]` / `[X]`
|
||||||
|
2. 명시 status (`done`, `completed`, `passed` 등 정해진 패턴)
|
||||||
|
3. Sprint 자체가 done이면, 태스크 status 정보가 없을 경우 전체 완료 처리 여부 검토
|
||||||
|
- UI에 `총 태스크 수 / 완료 태스크 수` 정상 반영
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 프로젝트 상세 Sprint 섹션에서 `0개 / 0개` 고정 해소
|
||||||
|
- 실제 Sprint 문서 기준으로 task total 표시
|
||||||
|
- 완료 태스크 수가 포맷에 맞게 계산
|
||||||
|
- 빌드/테스트 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
34
.plans/sprints/HOTFIX-003.md
Normal file
34
.plans/sprints/HOTFIX-003.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# HOTFIX-003: 자매 상세 프로필 헤더/개요 레이아웃 개선 + Activity 로그 정리
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
자매 상세 페이지에서 프로필 사진이 너무 작고 개요 정보 우선순위가 어색한 문제, Activity 로그에 raw stderr가 그대로 노출되는 문제를 개선
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-046: 자매 상세 프로필 헤더 확대
|
||||||
|
- `/sisters/[name]` 상단 프로필 영역 재디자인
|
||||||
|
- 프로필 사진 크기 확대 (최소 96px~128px 권장)
|
||||||
|
- 이름 / 역할 / 상태 배지 / 핵심 메타를 프로필 헤더에 재배치
|
||||||
|
- “프로필 페이지” 느낌이 나도록 상단 hero 영역 구성
|
||||||
|
|
||||||
|
### TASK-047: 개요 레이아웃 우선순위 조정
|
||||||
|
- 현재 시스템 카드/활동 로그/개요의 시각적 비중 재조정
|
||||||
|
- 프로필 헤더 → 핵심 상태 요약 → 활동 로그 순으로 위계 정리
|
||||||
|
- 시스템 정보 카드(UPTIME, CPU, MEM, DISK)는 유지하되 보조 정보로 배치
|
||||||
|
- 모바일에서도 헤더/요약/로그 순서가 자연스럽게 보이도록 조정
|
||||||
|
|
||||||
|
### TASK-048: Activity 로그 사용자 친화화
|
||||||
|
- raw stderr / 내부 명령문 / `bash: ... command not found` 같은 내부 오류 문자열 직접 노출 금지
|
||||||
|
- ActivityLog 저장 시 또는 렌더 시 필터링/정규화:
|
||||||
|
- 내부 stderr는 숨기거나
|
||||||
|
- 사람이 읽을 수 있는 문장으로 변환
|
||||||
|
- 사용자용 feed에는 의미 있는 이벤트만 노출
|
||||||
|
- 필요하면 내부 디버그 로그와 사용자 표시 로그를 분리
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 자매 상세 페이지 프로필 사진이 현재보다 명확히 크게 표시
|
||||||
|
- 개요 탭 시각적 우선순위가 자연스러움
|
||||||
|
- 모바일에서도 레이아웃 깨짐 없음
|
||||||
|
- activity feed에 raw stderr 직접 노출 0건
|
||||||
|
- npm run build 성공 + 테스트 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
56
.plans/sprints/SPRINT-011.md
Normal file
56
.plans/sprints/SPRINT-011.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# SPRINT-011: 진행률/프로필 사진 실제 반영 핫픽스
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
프로젝트 진행률이 0%로 보이는 문제와 자매 프로필 사진이 fallback으로만 보이는 문제를 실제 데이터 기준으로 수정
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-040: Sprint status 동기화 수정
|
||||||
|
- 현재 `Status: value` 단일 줄 파싱이 실제 `.plans/sprints/*.md` 포맷과 맞지 않는 문제 수정
|
||||||
|
- 실제 Sprint 문서 포맷 조사 후 파싱 규칙 재정의
|
||||||
|
- 우선순위:
|
||||||
|
1. 명시 메타 블록 / heading 기반 status 파싱
|
||||||
|
2. QA 결과 파일(`.qa/SPRINT-XXX-review-*.md`) 기반 완료 여부 보조 판정
|
||||||
|
3. 없으면 pending
|
||||||
|
- 오탐 방지: 본문 전체 키워드 검색 금지
|
||||||
|
|
||||||
|
### TASK-041: 프로젝트 진행률 계산 수정
|
||||||
|
- Sprint status가 실제 반영되도록 프로젝트 진행률 계산 로직 수정
|
||||||
|
- 표시 기준:
|
||||||
|
- `done` Sprint 수 / 전체 Sprint 수 → %
|
||||||
|
- 필요 시 `review`, `in_progress`는 별도 상태 태그 표시
|
||||||
|
- `/projects` 목록: 퍼센트 + progress bar 정상 반영
|
||||||
|
- `/projects/[id]` 상세: Sprint 상태 목록 정확 표시
|
||||||
|
- “0%만 뜨는” 현상 해소
|
||||||
|
|
||||||
|
### TASK-042: 프로필 사진 실제 노출 수정
|
||||||
|
- avatar API 응답 200이어도 fallback만 보이는 원인 파악
|
||||||
|
- 점검 항목:
|
||||||
|
- FE 이미지 src 연결 여부
|
||||||
|
- `onError`가 과도하게 fallback으로 전환되는지
|
||||||
|
- `next/image` / `img` 캐시 문제
|
||||||
|
- content-type/image 응답 정상 여부
|
||||||
|
- avatar cache miss 후 stale fallback 유지 여부
|
||||||
|
- 실제 파일 경로 재확인:
|
||||||
|
- `~/.openclaw/avatar.png`
|
||||||
|
- `~/.openclaw/avatar.jpg`
|
||||||
|
- `~/.openclaw/avatar.webp`
|
||||||
|
|
||||||
|
### TASK-043: 아바타 UI 일관화
|
||||||
|
- 대시보드, `/sisters`, `/sisters/[name]`, `/admin` 전부 같은 Avatar 컴포넌트 사용
|
||||||
|
- 실제 이미지 우선 렌더
|
||||||
|
- 실패 시에만 fallback (이니셜 or SVG)
|
||||||
|
- 관리자 페이지에 남은 이모지 fallback 제거
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
- TASK-040 → TASK-041 순서 필수
|
||||||
|
- TASK-042, TASK-043은 병렬 가능하나 최종적으로 Avatar 공통 컴포넌트로 수렴
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 프로젝트 진행률이 0% 고정이 아닌 실제 값으로 표시
|
||||||
|
- Sprint 상세 상태가 실제 문서/QA 결과와 일치
|
||||||
|
- 자매 프로필 사진이 실제 이미지로 표시
|
||||||
|
- 관리자/자매/대시보드 전부 동일한 사진 표시 정책 사용
|
||||||
|
- fallback은 이미지 실패 시에만 동작
|
||||||
|
- npm run build 성공 + 테스트 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
42
.qa/HOTFIX-002-review-1.md
Normal file
42
.qa/HOTFIX-002-review-1.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# HOTFIX-002 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 18:35 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## Task 파싱 확인 (TASK-044)
|
||||||
|
|
||||||
|
| 포맷 | 지원 |
|
||||||
|
|------|------|
|
||||||
|
| `### TASK-001: title` | ✅ |
|
||||||
|
| `- [x] TASK-001: title` (done) | ✅ |
|
||||||
|
| `- [ ] TASK-001: title` (pending) | ✅ |
|
||||||
|
| `- TASK-001: title` | ✅ |
|
||||||
|
| `TASK-001: title` | ✅ |
|
||||||
|
| 중복 taskId 방지 (seen Set) | ✅ |
|
||||||
|
|
||||||
|
## 완료 count 계산 (TASK-045)
|
||||||
|
|
||||||
|
| 규칙 | 확인 |
|
||||||
|
|------|------|
|
||||||
|
| Sprint done → task (status 없으면) done fallback | ✅ |
|
||||||
|
| `[x]` → done | ✅ |
|
||||||
|
| `[ ]` → pending | ✅ |
|
||||||
|
| 기존 done task 보호 | ✅ |
|
||||||
|
| Task 테이블 upsert | ✅ |
|
||||||
|
|
||||||
|
## Non-blocking
|
||||||
|
|
||||||
|
| ID | 이슈 |
|
||||||
|
|----|------|
|
||||||
|
| NB1 | task assignee 하드코딩 'narang' — 문서 파싱으로 개선 권장 |
|
||||||
25
.qa/HOTFIX-003-review-1.md
Normal file
25
.qa/HOTFIX-003-review-1.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# HOTFIX-003 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 19:48 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 변경 확인
|
||||||
|
|
||||||
|
### 프로필 hero (TASK-046/047)
|
||||||
|
- ✅ SisterAvatar size=120 (기존 36px → 120px 확대)
|
||||||
|
- ✅ 상단 프로필 hero 재디자인
|
||||||
|
|
||||||
|
### Activity stderr 정리 (TASK-048)
|
||||||
|
- ✅ `sanitizeActivityDetail()` 함수 추가
|
||||||
|
- ✅ stderr/bash/command not found → 사용자 친화 메시지 변환
|
||||||
|
- ✅ SSH 연결 실패 → "원격 노드 연결에 실패했어" 변환
|
||||||
|
- ✅ log() + getFeed() + getProjectFeed() 전부 적용
|
||||||
51
.qa/SPRINT-010-review-1.md
Normal file
51
.qa/SPRINT-010-review-1.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# SPRINT-010 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 17:25 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## API Key 완전 제거 확인
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| api-key.strategy.ts 파일 삭제 | ✅ |
|
||||||
|
| api-key.guard.ts 파일 삭제 | ✅ |
|
||||||
|
| CompositeGuard 코드 0건 (grep) | ✅ |
|
||||||
|
| ADMIN_API_KEYS 참조 0건 | ✅ |
|
||||||
|
| hanarang_admin_key 참조 0건 | ✅ |
|
||||||
|
| admin layout API Key UI 제거 | ✅ |
|
||||||
|
| adminFetch JWT 전용 단순화 | ✅ |
|
||||||
|
|
||||||
|
## JWT 전환 확인
|
||||||
|
|
||||||
|
| Controller | Guard |
|
||||||
|
|------------|-------|
|
||||||
|
| AdminController | JwtGuard + RoleGuard ✅ |
|
||||||
|
| CostsController | JwtGuard + RoleGuard ✅ |
|
||||||
|
| GiteaSyncController | JwtGuard + RoleGuard ✅ |
|
||||||
|
| SettingsController | JwtGuard + RoleGuard ✅ |
|
||||||
|
|
||||||
|
## Dead Code 정리 확인
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| INVITE_CODE BE 코드 | ✅ 0건 |
|
||||||
|
| register BE 엔드포인트 | ✅ 제거됨 |
|
||||||
|
| PUBLIC_PATHS /register | ✅ 제거됨 |
|
||||||
|
|
||||||
|
## 판정 근거
|
||||||
|
- 코드 정리/제거 스프린트 — 새 기능 없이 기존 코드 축소
|
||||||
|
- API Key 관련 파일/코드 완전 제거 확인 (grep 0건)
|
||||||
|
- JWT 전환 일관 적용
|
||||||
|
- 테스트/빌드 통과
|
||||||
|
- blocking 0건
|
||||||
37
.qa/SPRINT-011-review-1.md
Normal file
37
.qa/SPRINT-011-review-1.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# SPRINT-011 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 17:45 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 핵심 변경 확인
|
||||||
|
|
||||||
|
### Sprint status 파싱 (TASK-040)
|
||||||
|
- ✅ `.qa/SPRINT-XXX-review-*.md`에서 PASSED 여부로 status 결정
|
||||||
|
- ✅ 본문 전체 키워드 검색 완전 제거
|
||||||
|
- ✅ `inferStatusFromQaContent()`로 PASSED regex 체크
|
||||||
|
- ✅ passedMap으로 Sprint number → boolean 매핑
|
||||||
|
|
||||||
|
### 진행률 계산 (TASK-041)
|
||||||
|
- ✅ `doneSprints / totalSprints * 100` — Sprint done 기반 진행률
|
||||||
|
|
||||||
|
### SisterAvatar 공통 컴포넌트 (TASK-042/043)
|
||||||
|
- ✅ `components/common/SisterAvatar.tsx` 생성
|
||||||
|
- ✅ API URL → 이미지 로드 → onError → 이니셜 fallback
|
||||||
|
- ✅ 대시보드(/) + 자매(/sisters) + 상세(/sisters/[name]) 전부 적용
|
||||||
|
- ✅ 이모지 fallback 대신 이니셜 텍스트 fallback
|
||||||
|
|
||||||
|
## 판정 근거
|
||||||
|
- 자기야가 직접 지적한 2가지 문제(진행률 0%, 프로필 사진 미노출) 핵심 원인 수정됨
|
||||||
|
- 테스트/빌드 전부 통과
|
||||||
|
- blocking 0건
|
||||||
@@ -9,6 +9,23 @@ export interface LogActivityDto {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeActivityDetail(detail?: string | null): string | undefined {
|
||||||
|
if (!detail) return detail ?? undefined;
|
||||||
|
|
||||||
|
const raw = detail.trim();
|
||||||
|
|
||||||
|
// 내부 stderr/명령문 직접 노출 금지
|
||||||
|
if (/stderr:/i.test(raw) || /command not found/i.test(raw) || /bash:\s*line/i.test(raw)) {
|
||||||
|
return '내부 작업 중 오류가 발생했어. 자세한 시스템 로그는 관리자 로그에서 확인할 수 있어.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^ssh failed/i.test(raw) || /ssh connection failed/i.test(raw)) {
|
||||||
|
return '원격 노드 연결에 실패했어.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ActivityService {
|
export class ActivityService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -18,14 +35,16 @@ export class ActivityService {
|
|||||||
|
|
||||||
async log(dto: LogActivityDto) {
|
async log(dto: LogActivityDto) {
|
||||||
const record = await this.prisma.activityLog.create({
|
const record = await this.prisma.activityLog.create({
|
||||||
data: dto,
|
data: {
|
||||||
|
...dto,
|
||||||
|
detail: sanitizeActivityDetail(dto.detail),
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
sister: { select: { name: true } },
|
sister: { select: { name: true } },
|
||||||
project: { select: { name: true } },
|
project: { select: { name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 실시간 브로드캐스트 (gateway 사용 가능한 경우)
|
|
||||||
if (this.events) {
|
if (this.events) {
|
||||||
this.events.broadcastActivity(record);
|
this.events.broadcastActivity(record);
|
||||||
}
|
}
|
||||||
@@ -46,7 +65,12 @@ export class ActivityService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.activityLog.count(),
|
this.prisma.activityLog.count(),
|
||||||
]);
|
]);
|
||||||
return { items, total, limit, offset };
|
return {
|
||||||
|
items: items.map((item) => ({ ...item, detail: sanitizeActivityDetail(item.detail) })),
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProjectFeed(projectId: number, limit = 30, offset = 0) {
|
async getProjectFeed(projectId: number, limit = 30, offset = 0) {
|
||||||
@@ -62,6 +86,11 @@ export class ActivityService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.activityLog.count({ where: { projectId } }),
|
this.prisma.activityLog.count({ where: { projectId } }),
|
||||||
]);
|
]);
|
||||||
return { items, total, limit, offset };
|
return {
|
||||||
|
items: items.map((item) => ({ ...item, detail: sanitizeActivityDetail(item.detail) })),
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { CompositeGuard } from '../auth/jwt.guard';
|
import { JwtGuard } from '../auth/jwt.guard';
|
||||||
import { RoleGuard, Roles } from '../auth/role.guard';
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
import { SisterNamePipe } from '../common/sister-name.pipe';
|
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||||
import type { SisterName } from '../common/sister-name.pipe';
|
import type { SisterName } from '../common/sister-name.pipe';
|
||||||
@@ -24,7 +24,7 @@ class UpdateHarnessDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Controller('api/admin')
|
@Controller('api/admin')
|
||||||
@UseGuards(CompositeGuard, RoleGuard)
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(private readonly adminService: AdminService) {}
|
constructor(private readonly adminService: AdminService) {}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ApiKeyGuard extends AuthGuard('api-key') {}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { Strategy } from 'passport-http-bearer';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') {
|
|
||||||
private readonly validKeys: Set<string>;
|
|
||||||
|
|
||||||
constructor(config: ConfigService) {
|
|
||||||
super();
|
|
||||||
const raw = config.get<string>('ADMIN_API_KEYS') ?? '';
|
|
||||||
this.validKeys = new Set(
|
|
||||||
raw
|
|
||||||
.split(',')
|
|
||||||
.map((k) => k.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
validate(token: string): { role: string; username: string } {
|
|
||||||
if (!this.validKeys.size) {
|
|
||||||
throw new UnauthorizedException('ADMIN_API_KEYS not configured');
|
|
||||||
}
|
|
||||||
if (!this.validKeys.has(token)) {
|
|
||||||
throw new UnauthorizedException('Invalid API key');
|
|
||||||
}
|
|
||||||
return { role: "admin", username: "api-key" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,35 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { ApiKeyStrategy } from './api-key.strategy';
|
import { JwtGuard } from './jwt.guard';
|
||||||
import { JwtGuard, CompositeGuard } from './jwt.guard';
|
|
||||||
import { ApiKeyGuard } from './api-key.guard';
|
|
||||||
import { RoleGuard } from './role.guard';
|
import { RoleGuard } from './role.guard';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
PassportModule,
|
PassportModule,
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
PrismaModule,
|
ThrottlerModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
secret: (() => { const s = config.get<string>('JWT_SECRET'); if (!s) throw new Error('JWT_SECRET is not configured'); return s; })(),
|
secret: (() => {
|
||||||
signOptions: { expiresIn: '15m' },
|
const s = config.get<string>('JWT_SECRET');
|
||||||
|
if (!s) throw new Error('JWT_SECRET is not configured');
|
||||||
|
return s;
|
||||||
|
})(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy, ApiKeyStrategy, JwtGuard, CompositeGuard, ApiKeyGuard, RoleGuard],
|
providers: [AuthService, JwtStrategy, JwtGuard, RoleGuard],
|
||||||
exports: [JwtGuard, CompositeGuard, ApiKeyGuard, RoleGuard, AuthService],
|
exports: [JwtGuard, RoleGuard, AuthService, JwtModule],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,22 +1,9 @@
|
|||||||
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtGuard extends AuthGuard('jwt') {
|
export class JwtGuard extends AuthGuard('jwt') {
|
||||||
handleRequest<T>(err: Error, user: T): T {
|
canActivate(context: ExecutionContext) {
|
||||||
if (err || !user) throw new UnauthorizedException('JWT authentication required');
|
return super.canActivate(context);
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT 먼저 시도 → 실패 시 API Key fallback
|
|
||||||
* Sprint 008까지 하위호환 유지
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class CompositeGuard extends AuthGuard(['jwt', 'api-key']) {
|
|
||||||
handleRequest<T>(err: Error, user: T): T {
|
|
||||||
if (err || !user) throw new UnauthorizedException('Authentication required');
|
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Controller, Get, Post, Query, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Query, Param, UseGuards } from '@nestjs/common';
|
||||||
import { CostsService, CostPeriod } from './costs.service';
|
import { CostsService, CostPeriod } from './costs.service';
|
||||||
import { CompositeGuard } from '../auth/jwt.guard';
|
import { JwtGuard } from '../auth/jwt.guard';
|
||||||
import { RoleGuard, Roles } from '../auth/role.guard';
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
import { SisterNamePipe } from '../common/sister-name.pipe';
|
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||||
import type { SisterName } from '../common/sister-name.pipe';
|
import type { SisterName } from '../common/sister-name.pipe';
|
||||||
|
|
||||||
@Controller('api/admin/costs')
|
@Controller('api/admin/costs')
|
||||||
@UseGuards(CompositeGuard, RoleGuard)
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
export class CostsController {
|
export class CostsController {
|
||||||
constructor(private readonly costsService: CostsService) {}
|
constructor(private readonly costsService: CostsService) {}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Controller, Post, Get, UseGuards } from '@nestjs/common';
|
import { Controller, Post, Get, UseGuards } from '@nestjs/common';
|
||||||
import { GiteaSyncService } from './gitea-sync.service';
|
import { GiteaSyncService } from './gitea-sync.service';
|
||||||
import { CompositeGuard } from '../auth/jwt.guard';
|
import { JwtGuard } from '../auth/jwt.guard';
|
||||||
import { RoleGuard, Roles } from '../auth/role.guard';
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
|
|
||||||
@Controller('api/admin/gitea')
|
@Controller('api/admin/gitea')
|
||||||
@UseGuards(CompositeGuard, RoleGuard)
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
export class GiteaSyncController {
|
export class GiteaSyncController {
|
||||||
constructor(private readonly giteaSyncService: GiteaSyncService) {}
|
constructor(private readonly giteaSyncService: GiteaSyncService) {}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller, Get, Param, Query, ParseIntPipe, UseGuards, Post } from '@nestjs/common';
|
import { Controller, Get, Param, Query, ParseIntPipe, UseGuards, Post } from '@nestjs/common';
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
import { SprintSyncService } from './sprint-sync.service';
|
import { SprintSyncService } from './sprint-sync.service';
|
||||||
import { CompositeGuard } from '../auth/jwt.guard';
|
import { JwtGuard } from '../auth/jwt.guard';
|
||||||
import { RoleGuard, Roles } from '../auth/role.guard';
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
|
|
||||||
@Controller('api/projects')
|
@Controller('api/projects')
|
||||||
@@ -45,14 +45,14 @@ export class ProjectsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/sync-sprints')
|
@Post(':id/sync-sprints')
|
||||||
@UseGuards(CompositeGuard, RoleGuard)
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
syncSprints(@Param('id', ParseIntPipe) id: number) {
|
syncSprints(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.sprintSyncService.syncProjectSprints(id);
|
return this.sprintSyncService.syncProjectSprints(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync-all-sprints')
|
@Post('sync-all-sprints')
|
||||||
@UseGuards(CompositeGuard, RoleGuard)
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
syncAllSprints() {
|
syncAllSprints() {
|
||||||
return this.sprintSyncService.syncAllProjectSprints();
|
return this.sprintSyncService.syncAllProjectSprints();
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ export class ProjectsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return dbProjects.map((p) => {
|
return dbProjects.map((p) => {
|
||||||
const totalTasks = p.sprints.flatMap((s) => s.tasks).length;
|
// Sprint 기반 진행률 (백엔드 task 없어도 동작)
|
||||||
const doneTasks = p.sprints.flatMap((s) => s.tasks).filter((t) => t.status === 'done').length;
|
const totalSprints = p.sprints.length;
|
||||||
|
const doneSprints = p.sprints.filter((s) => s.status === 'done').length;
|
||||||
|
const inProgressSprint = p.sprints.find((s) => s.status === 'in_progress');
|
||||||
|
|
||||||
|
const progress = totalSprints > 0 ? Math.round((doneSprints / totalSprints) * 100) : 0;
|
||||||
const gitea = giteaRepos[p.giteaId];
|
const gitea = giteaRepos[p.giteaId];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -45,13 +49,13 @@ export class ProjectsService {
|
|||||||
repoUrl: p.repoUrl,
|
repoUrl: p.repoUrl,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
progress: totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0,
|
progress,
|
||||||
totalTasks,
|
totalSprints,
|
||||||
doneTasks,
|
doneSprints,
|
||||||
openPRs: gitea?.openPRs ?? 0,
|
openPRs: gitea?.openPRs ?? 0,
|
||||||
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
|
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
|
||||||
sprintCount: p.sprints.length,
|
sprintCount: totalSprints,
|
||||||
currentSprint: p.sprints.find((s) => s.status === 'in_progress')?.name ?? null,
|
currentSprint: inProgressSprint?.name ?? (doneSprints === totalSprints && totalSprints > 0 ? 'COMPLETED' : null),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,26 +8,75 @@ interface ParsedSprint {
|
|||||||
status: 'pending' | 'in_progress' | 'done';
|
status: 'pending' | 'in_progress' | 'done';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSprintMd(content: string, filename: string): ParsedSprint | null {
|
interface ParsedTask {
|
||||||
const numMatch = filename.match(/SPRINT-(\d+)/i);
|
taskId: string;
|
||||||
if (!numMatch) return null;
|
title: string;
|
||||||
const number = parseInt(numMatch[1], 10);
|
status: 'pending' | 'done';
|
||||||
|
}
|
||||||
|
|
||||||
// 첫 번째 heading에서 이름 추출
|
function parseSprintName(content: string, filename: string, number: number): string {
|
||||||
const headingMatch = content.match(/^#\s+SPRINT-\d+[:\s]+(.+)/m);
|
// 첫 번째 heading에서 이름 추출: # Sprint XXX — 이름 / # SPRINT-XXX: 이름
|
||||||
const name = headingMatch ? headingMatch[1].trim() : `SPRINT-${String(number).padStart(3, '0')}`;
|
const headingMatch = content.match(/^#\s+(?:SPRINT-\d+|Sprint\s+\d+)[:\s—\-]+(.+)/m);
|
||||||
|
if (headingMatch) return headingMatch[1].trim();
|
||||||
|
return `SPRINT-${String(number).padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 상태 파싱: `Status: done|in_progress|pending` 명시 줄만 인식
|
function inferStatusFromQaContent(qaContent: string): 'done' | null {
|
||||||
// 예) Status: done / status: in_progress
|
// QA 파일에 "결과: ✅ PASSED" 또는 "PASSED" 포함 시 done
|
||||||
let status: 'pending' | 'in_progress' | 'done' = 'pending';
|
if (/PASSED/i.test(qaContent)) return 'done';
|
||||||
const statusLineMatch = content.match(/^\s*status\s*:\s*(\S+)/im);
|
return null;
|
||||||
if (statusLineMatch) {
|
}
|
||||||
const val = statusLineMatch[1].toLowerCase().replace(/[^a-z_]/g, '');
|
|
||||||
if (val === 'done' || val === 'completed') status = 'done';
|
function parseTasks(content: string, sprintStatus: 'pending' | 'in_progress' | 'done'): ParsedTask[] {
|
||||||
else if (val === 'in_progress' || val === 'inprogress' || val === 'active') status = 'in_progress';
|
const lines = content.split(/\r?\n/);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const tasks: ParsedTask[] = [];
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
let match: RegExpMatchArray | null = null;
|
||||||
|
let status: 'pending' | 'done' | null = null;
|
||||||
|
|
||||||
|
// ### TASK-001: title
|
||||||
|
match = line.match(/^#{2,6}\s+(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
|
||||||
|
if (match) {
|
||||||
|
status = sprintStatus === 'done' ? 'done' : 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
return { number, name, status };
|
// - [x] TASK-001 title / - [ ] TASK-001 title
|
||||||
|
if (!match) {
|
||||||
|
match = line.match(/^-\s*\[([ xX])\]\s*(TASK-\d+[A-Z]?)\s*[:\-]?\s*(.+)$/i);
|
||||||
|
if (match) {
|
||||||
|
status = /x/i.test(match[1]) ? 'done' : 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// - TASK-001: title
|
||||||
|
if (!match) {
|
||||||
|
match = line.match(/^-\s*(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
|
||||||
|
if (match) {
|
||||||
|
status = sprintStatus === 'done' ? 'done' : 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TASK-001: title
|
||||||
|
if (!match) {
|
||||||
|
match = line.match(/^(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
|
||||||
|
if (match) {
|
||||||
|
status = sprintStatus === 'done' ? 'done' : 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match || !status) continue;
|
||||||
|
|
||||||
|
const taskId = (match[1] ?? match[2]).toUpperCase();
|
||||||
|
const title = (match[2] ?? match[3] ?? '').trim();
|
||||||
|
if (!taskId || seen.has(taskId)) continue;
|
||||||
|
seen.add(taskId);
|
||||||
|
tasks.push({ taskId, title: title || taskId, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -61,16 +110,34 @@ export class SprintSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async syncRepo(projectId: number, repoName: string): Promise<{ synced: number; created: number; updated: number }> {
|
private async syncRepo(projectId: number, repoName: string): Promise<{ synced: number; created: number; updated: number }> {
|
||||||
// .plans/sprints/ 하위 파일 목록 조회
|
const [sprintFilePaths, qaFilePaths] = await Promise.all([
|
||||||
const files = await this.gitea.getRepoTree(repoName, '.plans/sprints/');
|
this.gitea.getRepoTree(repoName, '.plans/sprints/'),
|
||||||
|
this.gitea.getRepoTree(repoName, '.qa/'),
|
||||||
|
]);
|
||||||
|
|
||||||
const sprintFiles = files.filter((f) => /SPRINT-\d+\.md$/i.test(f));
|
const sprintFiles = sprintFilePaths.filter((f) => /SPRINT-\d+\.md$/i.test(f));
|
||||||
|
const qaFiles = qaFilePaths.filter((f) => /SPRINT-\d+-review-.*\.md$/i.test(f));
|
||||||
|
|
||||||
if (!sprintFiles.length) {
|
if (!sprintFiles.length) {
|
||||||
this.logger.warn(`No sprint files found in ${repoName}/.plans/sprints/`);
|
this.logger.warn(`No sprint files found in ${repoName}/.plans/sprints/`);
|
||||||
return { synced: 0, created: 0, updated: 0 };
|
return { synced: 0, created: 0, updated: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QA 파일에서 PASSED 여부 수집: number → boolean
|
||||||
|
const passedMap = new Map<number, boolean>();
|
||||||
|
for (const qaPath of qaFiles) {
|
||||||
|
const numMatch = qaPath.match(/SPRINT-(\d+)-review/i);
|
||||||
|
if (!numMatch) continue;
|
||||||
|
const num = parseInt(numMatch[1], 10);
|
||||||
|
if (passedMap.get(num)) continue; // 이미 통과 확인됨
|
||||||
|
|
||||||
|
const qaContent = await this.gitea.getRawFile(repoName, qaPath);
|
||||||
|
if (!qaContent) continue;
|
||||||
|
if (inferStatusFromQaContent(qaContent)) {
|
||||||
|
passedMap.set(num, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
|
||||||
@@ -79,41 +146,67 @@ export class SprintSyncService {
|
|||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
|
|
||||||
const filename = filePath.split('/').pop() ?? '';
|
const filename = filePath.split('/').pop() ?? '';
|
||||||
const parsed = parseSprintMd(content, filename);
|
const numMatch = filename.match(/SPRINT-(\d+)/i);
|
||||||
if (!parsed) continue;
|
if (!numMatch) continue;
|
||||||
|
const number = parseInt(numMatch[1], 10);
|
||||||
|
const name = parseSprintName(content, filename, number);
|
||||||
|
|
||||||
|
// QA 통과 여부로 status 결정 (본문 전체 키워드 검색 금지)
|
||||||
|
const status: 'pending' | 'done' = passedMap.get(number) ? 'done' : 'pending';
|
||||||
|
const parsedTasks = parseTasks(content, status);
|
||||||
|
|
||||||
const existing = await this.prisma.sprint.findFirst({
|
const existing = await this.prisma.sprint.findFirst({
|
||||||
where: { projectId, number: parsed.number },
|
where: { projectId, number },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let sprintId: number;
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
await this.prisma.sprint.create({
|
const createdSprint = await this.prisma.sprint.create({
|
||||||
data: {
|
data: { projectId, number, name, status },
|
||||||
projectId,
|
|
||||||
number: parsed.number,
|
|
||||||
name: parsed.name,
|
|
||||||
status: parsed.status,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
sprintId = createdSprint.id;
|
||||||
created++;
|
created++;
|
||||||
} else {
|
} else {
|
||||||
// 기존이 pending이면 파일 기반으로 업데이트, 이미 done이면 유지
|
const updatedSprint = await this.prisma.sprint.update({
|
||||||
if (existing.status === 'pending' || parsed.status === 'done') {
|
|
||||||
await this.prisma.sprint.update({
|
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { name: parsed.name, status: parsed.status },
|
data: {
|
||||||
|
name,
|
||||||
|
status: existing.status === 'done' ? 'done' : status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
sprintId = updatedSprint.id;
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sprint 문서 기준 Task upsert
|
||||||
|
for (const task of parsedTasks) {
|
||||||
|
const existingTask = await this.prisma.task.findFirst({
|
||||||
|
where: { sprintId, taskId: task.taskId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingTask) {
|
||||||
|
await this.prisma.task.create({
|
||||||
|
data: {
|
||||||
|
sprintId,
|
||||||
|
taskId: task.taskId,
|
||||||
|
title: task.title,
|
||||||
|
assignee: 'narang',
|
||||||
|
status: task.status,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.prisma.sprint.update({
|
await this.prisma.task.update({
|
||||||
where: { id: existing.id },
|
where: { id: existingTask.id },
|
||||||
data: { name: parsed.name },
|
data: {
|
||||||
|
title: task.title,
|
||||||
|
status: existingTask.status === 'done' ? 'done' : task.status,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
updated++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`${repoName}: ${sprintFiles.length} sprint files → ${created} created, ${updated} updated`);
|
this.logger.log(`${repoName}: ${sprintFiles.length} sprints, ${passedMap.size} QA passed → ${created} created, ${updated} updated`);
|
||||||
return { synced: sprintFiles.length, created, updated };
|
return { synced: sprintFiles.length, created, updated };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
import { CompositeGuard } from '../auth/jwt.guard';
|
import { JwtGuard } from '../auth/jwt.guard';
|
||||||
import { RoleGuard, Roles } from '../auth/role.guard';
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
|
|
||||||
@Controller('api/admin/settings')
|
@Controller('api/admin/settings')
|
||||||
@UseGuards(CompositeGuard, RoleGuard)
|
@UseGuards(JwtGuard, RoleGuard)
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
export class SettingsController {
|
export class SettingsController {
|
||||||
constructor(private readonly settingsService: SettingsService) {}
|
constructor(private readonly settingsService: SettingsService) {}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { LabelMeta, SectionTitle } from '@/components/ui/base';
|
|
||||||
|
|
||||||
const ADMIN_TABS = [
|
const ADMIN_TABS = [
|
||||||
{ href: '/admin', label: '자매 관리' },
|
{ href: '/admin', label: '자매 관리' },
|
||||||
@@ -39,46 +38,11 @@ const TabLink = styled(Link)<{ $active: boolean }>`
|
|||||||
padding: var(--space-sm) 0;
|
padding: var(--space-sm) 0;
|
||||||
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
||||||
transition: color 0.15s, border-color 0.15s;
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
|
||||||
&:hover { color: var(--text-primary); }
|
&:hover { color: var(--text-primary); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ApiKeyRow = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ApiKeyInput = styled.input`
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
outline: none;
|
|
||||||
flex: 1;
|
|
||||||
max-width: 300px;
|
|
||||||
padding: 2px 0;
|
|
||||||
|
|
||||||
&:focus { border-bottom-color: var(--text-primary); }
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [apiKey, setApiKey] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setApiKey(localStorage.getItem('hanarang_admin_key') ?? '');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const saveKey = (v: string) => {
|
|
||||||
setApiKey(v);
|
|
||||||
localStorage.setItem('hanarang_admin_key', v);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
@@ -94,18 +58,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
))}
|
))}
|
||||||
</TabNav>
|
</TabNav>
|
||||||
</AdminHeader>
|
</AdminHeader>
|
||||||
|
|
||||||
<ApiKeyRow>
|
|
||||||
<span>KEY [</span>
|
|
||||||
<ApiKeyInput
|
|
||||||
type="password"
|
|
||||||
placeholder="ADMIN_API_KEY"
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => saveKey(e.target.value)}
|
|
||||||
/>
|
|
||||||
<span>]</span>
|
|
||||||
</ApiKeyRow>
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
|
||||||
import { useAuth } from '@/lib/AuthContext';
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
@@ -111,16 +110,6 @@ const ErrorMsg = styled.div`
|
|||||||
background: rgba(255,95,95,0.05);
|
background: rgba(255,95,95,0.05);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const FooterLink = styled.div`
|
|
||||||
margin-top: var(--space-lg);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
a { color: var(--text-primary); text-decoration: none;
|
|
||||||
&:hover { text-decoration: underline; } }
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/components/ui/base';
|
} from '@/components/ui/base';
|
||||||
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
|
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
|
||||||
import { useSocket } from '@/lib/useSocket';
|
import { useSocket } from '@/lib/useSocket';
|
||||||
|
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||||
|
|
||||||
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
||||||
const StatusGrid = styled.section`display:grid;grid-template-columns:repeat(4,1fr);gap:var(--space-lg);@media (min-width:768px) and (max-width:1199px){grid-template-columns:repeat(2,1fr)}@media (max-width:767px){grid-template-columns:repeat(2,1fr);gap:var(--space-md)}`;
|
const StatusGrid = styled.section`display:grid;grid-template-columns:repeat(4,1fr);gap:var(--space-lg);@media (min-width:768px) and (max-width:1199px){grid-template-columns:repeat(2,1fr)}@media (max-width:767px){grid-template-columns:repeat(2,1fr);gap:var(--space-md)}`;
|
||||||
@@ -74,7 +75,10 @@ export default function DashboardPage() {
|
|||||||
<StatusGrid>
|
<StatusGrid>
|
||||||
{loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />) : sisters.map((s) => (
|
{loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />) : sisters.map((s) => (
|
||||||
<Card key={s.id ?? s.name} as={Link} href={`/sisters/${s.name}`} style={{ textDecoration: 'none' }}>
|
<Card key={s.id ?? s.name} as={Link} href={`/sisters/${s.name}`} style={{ textDecoration: 'none' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}><LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta></div>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta>
|
||||||
|
<SisterAvatar name={s.name} size={24} />
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}><BracketValue $dimmed={s.status === 'offline'}>{sisterBracketValue(s)}</BracketValue><LabelMeta>{sisterSubLabel(s)}</LabelMeta></div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}><BracketValue $dimmed={s.status === 'offline'}>{sisterBracketValue(s)}</BracketValue><LabelMeta>{sisterSubLabel(s)}</LabelMeta></div>
|
||||||
<TechBar><TechBarFill $width={sisterBarWidth(s)} /></TechBar>
|
<TechBar><TechBarFill $width={sisterBarWidth(s)} /></TechBar>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PageTitle, LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base';
|
import { LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base';
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
|
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||||
import { SISTER_ROLES } from '@/lib/sisters';
|
import { SISTER_ROLES } from '@/lib/sisters';
|
||||||
|
|
||||||
const Breadcrumb = styled.div`
|
const Breadcrumb = styled.div`
|
||||||
@@ -16,9 +17,87 @@ const Breadcrumb = styled.div`
|
|||||||
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
|
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const Hero = styled.section`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 128px 1fr;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeroMeta = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeroName = styled.div`
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeroRole = styled.div`
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeroStatus = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatusDot = styled.div<{ $on: boolean }>`
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${({ $on }) => $on ? '#FFF' : '#555'};
|
||||||
|
${({ $on }) => $on && `box-shadow: 0 0 8px rgba(255,255,255,0.3);`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeroStats = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatCard = styled.div`
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-md);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatLabel = styled.div`
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatValue = styled.div`
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
`;
|
||||||
|
|
||||||
const DetailGrid = styled.div`
|
const DetailGrid = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px 1fr;
|
grid-template-columns: 320px 1fr;
|
||||||
gap: var(--space-xxl);
|
gap: var(--space-xxl);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
|
||||||
@@ -39,20 +118,6 @@ const MetaCard = styled.div`
|
|||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StatusRow = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StatusDot = styled.div<{ $on: boolean }>`
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: ${({ $on }) => $on ? '#FFF' : '#555'};
|
|
||||||
${({ $on }) => $on && `box-shadow: 0 0 8px rgba(255,255,255,0.3);`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConfigPre = styled.pre`
|
const ConfigPre = styled.pre`
|
||||||
background: #0d0d0d;
|
background: #0d0d0d;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -84,7 +149,6 @@ const TabBtn = styled.button<{ $active: boolean }>`
|
|||||||
padding: var(--space-sm) 0;
|
padding: var(--space-sm) 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.15s, border-color 0.15s;
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
|
||||||
&:hover { color: var(--text-primary); }
|
&:hover { color: var(--text-primary); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -126,37 +190,39 @@ export default function SisterDetailPage() {
|
|||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}
|
<Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
|
|
||||||
<PageTitle>
|
|
||||||
{SISTER_DISPLAY[name] ?? name}
|
|
||||||
<img src={`${API_URL}/api/sisters/${name}/avatar`} alt={name} style={{ width: 36, height: 36, borderRadius: '50%', objectFit: 'cover', border: '1px solid var(--border-color)', marginLeft: 'var(--space-md)', verticalAlign: 'middle' }} />
|
|
||||||
<span style={{ fontSize: '16px', color: 'var(--text-secondary)', fontWeight: 400, marginLeft: 'var(--space-sm)' }}>
|
|
||||||
/ {SISTER_ROLES[name] ?? 'UNKNOWN'}
|
|
||||||
</span>
|
|
||||||
</PageTitle>
|
|
||||||
<LabelMeta><span>STATUS:</span>{sisterInfo?.status?.toUpperCase() ?? 'UNKNOWN'}</LabelMeta>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div>
|
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<Hero>
|
||||||
|
<SisterAvatar name={name} size={120} />
|
||||||
|
<HeroMeta>
|
||||||
|
<HeroName>{SISTER_DISPLAY[name] ?? name}</HeroName>
|
||||||
|
<HeroRole>{SISTER_ROLES[name] ?? 'UNKNOWN'}</HeroRole>
|
||||||
|
<HeroStatus>
|
||||||
|
<StatusDot $on={isActive} />
|
||||||
|
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
|
||||||
|
<LabelMeta><span>LXC:</span>{sisterInfo?.lxcId ?? '--'}</LabelMeta>
|
||||||
|
</HeroStatus>
|
||||||
|
<HeroStats>
|
||||||
|
<StatCard><StatLabel>UPTIME</StatLabel><StatValue>{systemInfo?.uptime ?? '--:--:--'}</StatValue></StatCard>
|
||||||
|
<StatCard><StatLabel>CPU</StatLabel><StatValue>{systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}%</StatValue></StatCard>
|
||||||
|
<StatCard><StatLabel>MEM</StatLabel><StatValue>{systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB</StatValue></StatCard>
|
||||||
|
<StatCard><StatLabel>DISK</StatLabel><StatValue>{systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'}</StatValue></StatCard>
|
||||||
|
</HeroStats>
|
||||||
|
</HeroMeta>
|
||||||
|
</Hero>
|
||||||
|
|
||||||
<DetailGrid>
|
<DetailGrid>
|
||||||
<MetaPanel>
|
<MetaPanel>
|
||||||
<MetaCard>
|
<MetaCard>
|
||||||
<StatusRow>
|
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta>
|
||||||
<StatusDot $on={isActive} />
|
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||||
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
|
|
||||||
</StatusRow>
|
|
||||||
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
|
||||||
LXC: {sisterInfo?.lxcId ?? '--'}<br />
|
|
||||||
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
|
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
|
||||||
ROLE: {SISTER_ROLES[name] ?? '--'}<br />
|
ROLE: {SISTER_ROLES[name] ?? '--'}<br />
|
||||||
{systemInfo && (<>
|
STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()}<br />
|
||||||
UPTIME: {systemInfo.uptime}<br />
|
HOST_USER: {sisterInfo?.user ?? '--'}
|
||||||
CPU: {systemInfo.cpu?.toFixed?.(1)}%<br />
|
|
||||||
MEM: {systemInfo.memory?.used}/{systemInfo.memory?.total}MB<br />
|
|
||||||
DISK: {systemInfo.disk?.used}/{systemInfo.disk?.total}
|
|
||||||
</>)}
|
|
||||||
</div>
|
</div>
|
||||||
<TechBar>
|
<TechBar>
|
||||||
<TechBarFill $width={isActive ? 100 : 0} />
|
<TechBarFill $width={isActive ? 100 : 0} />
|
||||||
@@ -215,6 +281,7 @@ export default function SisterDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DetailGrid>
|
</DetailGrid>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import styled, { keyframes } from 'styled-components';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
|
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
|
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||||
|
|
||||||
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
||||||
const SISTER_ROLES: Record<string, string> = { harang: 'Primary', narang: 'Secondary', darang: 'Standby', erang: 'Sync' };
|
const SISTER_ROLES: Record<string, string> = { harang: 'Primary', narang: 'Secondary', darang: 'Standby', erang: 'Sync' };
|
||||||
@@ -13,7 +14,6 @@ const NodeEntry = styled.div`display:grid;grid-template-columns:220px 1fr 160px;
|
|||||||
const NodeInfo = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:767px){border-right:none;border-bottom:1px solid var(--border-color)}`;
|
const NodeInfo = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:767px){border-right:none;border-bottom:1px solid var(--border-color)}`;
|
||||||
const NodeName = styled(Link)`display:flex;align-items:center;gap:var(--space-sm);font-size:16px;font-weight:600;color:var(--text-primary);text-decoration:none;transition:color .15s;&:hover{color:var(--accent-hover)}`;
|
const NodeName = styled(Link)`display:flex;align-items:center;gap:var(--space-sm);font-size:16px;font-weight:600;color:var(--text-primary);text-decoration:none;transition:color .15s;&:hover{color:var(--accent-hover)}`;
|
||||||
const StatusIndicator = styled.div<{ $active: boolean }>`width:8px;height:8px;border-radius:50%;background:${({$active})=>$active?'#FFF':'#555'};flex-shrink:0;${({$active})=>$active&&`box-shadow:0 0 8px rgba(255,255,255,.3);`}`;
|
const StatusIndicator = styled.div<{ $active: boolean }>`width:8px;height:8px;border-radius:50%;background:${({$active})=>$active?'#FFF':'#555'};flex-shrink:0;${({$active})=>$active&&`box-shadow:0 0 8px rgba(255,255,255,.3);`}`;
|
||||||
const AvatarImg = styled.img`width:28px;height:28px;border-radius:50%;object-fit:cover;border:1px solid var(--border-color);background:#111;`;
|
|
||||||
const MetaPair = styled.div`display:flex;flex-direction:column;gap:2px;`;
|
const MetaPair = styled.div`display:flex;flex-direction:column;gap:2px;`;
|
||||||
const MetaVal = styled.div`font-size:13px;color:var(--text-primary);font-family:var(--font-mono);`;
|
const MetaVal = styled.div`font-size:13px;color:var(--text-primary);font-family:var(--font-mono);`;
|
||||||
const NodeControls = styled.div`display:flex;gap:var(--space-sm);flex-wrap:wrap;`;
|
const NodeControls = styled.div`display:flex;gap:var(--space-sm);flex-wrap:wrap;`;
|
||||||
@@ -76,11 +76,7 @@ export default function SistersPage() {
|
|||||||
<NodeInfo>
|
<NodeInfo>
|
||||||
<NodeName href={`/sisters/${s.name}`}>
|
<NodeName href={`/sisters/${s.name}`}>
|
||||||
<StatusIndicator $active={isActive} />
|
<StatusIndicator $active={isActive} />
|
||||||
<AvatarImg
|
<SisterAvatar name={s.name} size={28} />
|
||||||
src={`${API_URL}/api/sisters/${s.name}/avatar`}
|
|
||||||
alt={s.name}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
|
||||||
/>
|
|
||||||
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'} ({SISTER_ROLES[s.name] ?? s.name})
|
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'} ({SISTER_ROLES[s.name] ?? s.name})
|
||||||
</NodeName>
|
</NodeName>
|
||||||
<MetaPair><LabelMeta>Uptime</LabelMeta><MetaVal>{s.uptime ?? '00:00:00'}</MetaVal></MetaPair>
|
<MetaPair><LabelMeta>Uptime</LabelMeta><MetaVal>{s.uptime ?? '00:00:00'}</MetaVal></MetaPair>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter, usePathname } from 'next/navigation';
|
|||||||
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
||||||
import LayoutShell from './LayoutShell';
|
import LayoutShell from './LayoutShell';
|
||||||
|
|
||||||
const PUBLIC_PATHS = ['/login', '/register'];
|
const PUBLIC_PATHS = ['/login'];
|
||||||
|
|
||||||
function AuthGate({ children }: { children: React.ReactNode }) {
|
function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, loading } = useAuth();
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
|||||||
66
frontend/components/common/SisterAvatar.tsx
Normal file
66
frontend/components/common/SisterAvatar.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { API_URL } from '@/lib/config';
|
||||||
|
|
||||||
|
const SISTER_INITIALS: Record<string, string> = {
|
||||||
|
harang: '하',
|
||||||
|
narang: '나',
|
||||||
|
darang: '다',
|
||||||
|
erang: '이',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImgEl = styled.img<{ $size: number }>`
|
||||||
|
width: ${({ $size }) => $size}px;
|
||||||
|
height: ${({ $size }) => $size}px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: #111;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Fallback = styled.div<{ $size: number }>`
|
||||||
|
width: ${({ $size }) => $size}px;
|
||||||
|
height: ${({ $size }) => $size}px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #222;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: ${({ $size }) => Math.round($size * 0.4)}px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface SisterAvatarProps {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SisterAvatar({ name, size = 32, className, style }: SisterAvatarProps) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const initial = SISTER_INITIALS[name] ?? name.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return <Fallback $size={size} className={className} style={style}>{initial}</Fallback>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImgEl
|
||||||
|
$size={size}
|
||||||
|
src={`${API_URL}/api/sisters/${name}/avatar`}
|
||||||
|
alt={name}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* 관리자 API 호출 유틸.
|
* 관리자 API 호출 유틸 — JWT 전용.
|
||||||
* - CSR: 상대경로 /api/... → Next.js rewrites → BE
|
* 로그인 후 localStorage의 access token을 Authorization 헤더에 설정한다.
|
||||||
* - JWT access token (우선) 또는 API Key fallback으로 Authorization 헤더 설정
|
|
||||||
*/
|
*/
|
||||||
export function getAdminToken(): string {
|
export function getAdminToken(): string {
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
return (
|
return localStorage.getItem('hanarang_access_token') ?? '';
|
||||||
localStorage.getItem('hanarang_access_token') ??
|
|
||||||
localStorage.getItem('hanarang_admin_key') ??
|
|
||||||
''
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adminFetch(path: string, options?: RequestInit) {
|
export function adminFetch(path: string, options?: RequestInit) {
|
||||||
const token = getAdminToken();
|
const token = getAdminToken();
|
||||||
|
|
||||||
// Authorization: options?.headers보다 우선 (덮어쓰기 방지)
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(options?.headers as Record<string, string> ?? {}),
|
...(options?.headers as Record<string, string> ?? {}),
|
||||||
|
|||||||
Reference in New Issue
Block a user