Compare commits

...

6 Commits

Author SHA1 Message Date
3d8a1efa2a fix(hotfix-001): NEXT_PUBLIC_API_URL env 의존 제거 → Next.js rewrites 프록시
문제: FE 빌드 시 NEXT_PUBLIC_API_URL env 없으면 localhost:3005로 하드베이킹됨
→ 배포 환경에서 API 요청이 localhost로 가는 버그

해결:
- next.config.ts: /api/* → BE(API_URL) rewrites 추가
- lib/config.ts: CSR에서 API_URL='' (상대경로) 사용
  SSR에서는 process.env.API_URL ?? NEXT_PUBLIC_API_URL fallback
- lib/adminFetch.ts: 상대경로 직접 사용
- 모든 페이지가 use client이므로 CSR 상대경로 + rewrites로 완전 처리
- .env.local.example 업데이트 (API_URL 서버사이드용 추가)
2026-04-04 12:13:23 +09:00
033c142c0e feat(sprint-004): admin features + API auth guard
Sprint 003 non-blocking 처리:
- API Key Guard (passport-http-bearer) + AuthModule
- Helmet 적용 (main.ts)
- SisterNamePipe - name 파라미터 Controller 검증
- lib/sisters.ts - formatLastSeen/sisterEmojis/sisterDisplayNames 공통 추출

Sprint 004 본문:
- TASK-012: POST /api/admin/sisters/:name/{restart,reset} (ApiKeyGuard 적용)
- TASK-013: GET/PUT /api/admin/harness/:name/:file (허용 파일 allowlist)
- TASK-015: GET /api/admin/logs/:name
- 관리자 전 API @UseGuards(ApiKeyGuard)
- TASK-014: /admin 자매 관리 (재시작/리셋 + ConfirmModal)
- TASK-014: /admin/harness 하네스 편집 (CodeEditor + 파일트리)
- TASK-015: /admin/logs 로그 뷰어 (LogTerminal + 검색/에러필터)
- /admin/repos 저장소 목록
- AdminLayout (API Key 로컬스토리지 저장)
- 테스트 22/22 pass, FE 11 routes build 성공
2026-04-04 12:11:06 +09:00
nabomhalang
4e5a077d40 Merge feature/sprint-003 into main (SPRINT-003) 2026-04-04 03:02:36 +00:00
86751a4879 QA: SPRINT-003 review iteration 1 — PASSED 2026-04-04 12:01:47 +09:00
4015e5f22f feat(sprint-003): sister detail + org chart + settings viewer
Sprint 002 non-blocking:
- API_URL/POLL_INTERVAL lib/config.ts 공통 추출
- 폴링 간격 NEXT_PUBLIC_POLL_INTERVAL_MS env화

Sprint 003 본문:
- TASK-008: SisterDetailService (config/sessions/subagents/activity 조회)
- TASK-008: GET /api/sisters/:name/{config,sessions,subagents,activity}
- TASK-008: GET /api/org → 조직도 + 파이프라인 데이터
- TASK-009: /sisters 목록 페이지 (카드 클릭 → 상세 이동)
- TASK-009: /sisters/[name] 상세 페이지 (탭: 개요/설정/세션/서브에이전트)
- TASK-009: SisterCard → /sisters/:name 링크 연결
- TASK-010: /org 조직도 페이지 (OrgTree 컴포넌트, 호버 팝업)
- TASK-011: /settings 설정 뷰어 페이지 (자매 선택 드롭다운)
- 테스트 16/16 pass, FE build 성공 (7 routes)
2026-04-04 11:55:14 +09:00
nabomhalang
dd6a2595f7 Merge feature/sprint-002 into main (SPRINT-002) 2026-04-04 02:45:41 +00:00
43 changed files with 3276 additions and 8 deletions

View File

@@ -0,0 +1,71 @@
# SPRINT-003 QA Review — Iteration 1
- **검증일시:** 2026-04-04 03:00 UTC
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| git clone + checkout | ✅ |
| npm install (BE/FE) | ✅ |
| prisma generate | ✅ |
| npm test (backend) | ✅ 16/16 pass |
| npm run build (backend) | ✅ |
| npm run build (frontend) | ✅ 7 routes |
## 설계 대조
| 설계 항목 | 일치 |
|-----------|------|
| SisterDetailService (SSH config/sessions/subagents) | ✅ |
| GET /api/sisters/:name/config | ⚠️ (SOUL.md+AGENTS.md, 설계는 openclaw.json — 문서 업데이트 필요) |
| GET /api/sisters/:name/sessions | ✅ |
| GET /api/sisters/:name/subagents | ✅ |
| GET /api/sisters/:name/activity | ✅ |
| GET /api/org | ✅ |
| /sisters 목록 페이지 | ✅ |
| /sisters/[name] 상세 (4탭) | ✅ |
| /org 조직도 (트리, 호버 팝업) | ✅ |
| /settings 설정 뷰어 (드롭다운) | ✅ |
| SSH graceful fallback | ✅ |
## Sprint 002 non-blocking 처리 확인
| 항목 | 상태 |
|------|------|
| API_URL/POLL_INTERVAL lib/config.ts 공통 추출 | ✅ |
| 폴링 간격 env화 | ✅ |
## Non-blocking 이슈
### 코드 품질 (code-reviewer)
| ID | 이슈 |
|----|------|
| N1 | name 파라미터 Controller 검증 추가 권장 (BadRequestException) |
| N2 | /api/sisters/:name/config — 설계 문서(openclaw.json) vs 구현(SOUL.md+AGENTS.md) 불일치 → api-design.md 업데이트 필요 |
| N3 | useState<any> 다수 — types/ 공유 인터페이스 정의 권장 |
| N4 | ls -lt 파싱 취약 — find/stat 기반으로 개선 권장 |
| N5 | OrgTree에서 API pipeline 데이터 미사용 (하드코딩 라벨) |
| N6 | formatLastSeen / sisterEmojis / sisterDisplayNames 3곳 중복 → lib/utils.ts, lib/constants.ts 추출 |
| N7 | SisterDetailPage 폴링 없음 |
| N8 | catch 블록 silent 처리 — 최소 에러 상태 표시 필요 |
### 보안 감사 (security-auditor)
| ID | 이슈 | 심각도 |
|----|------|--------|
| H-1 | name 파라미터 입력 검증 없음 | High (non-blocking) |
| H-2 | API 인증/인가 전면 부재 — Sprint-004 필수 | High (non-blocking) |
| M-1 | SOUL.md 원문 60줄 노출 — 프롬프트 인젝션 활용 가능 | Medium |
| M-2 | lxcId 내부 인프라 정보 노출 | Medium |
| M-3 | CORS 에러에 origin 노출 | Medium |
| M-4 | Helmet 미적용 | Medium |
| L-1 | npm audit 7건 (dev dependency) | Low |
## 판정 근거
- 테스트/빌드 전부 통과
- 설계 대조 일치 (config 엔드포인트만 문서 업데이트 필요)
- blocking 0건 (code-reviewer B1,B2 재검증 후 non-blocking 하향)
- SSH graceful fallback 일관적
- OrgTree 순수 CSS 구현 양호

View File

@@ -12,6 +12,7 @@
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@prisma/adapter-mariadb": "^7.6.0", "@prisma/adapter-mariadb": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.6.0",
@@ -19,8 +20,11 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"dotenv": "^17.4.0", "dotenv": "^17.4.0",
"helmet": "^8.1.0",
"mariadb": "^3.5.2", "mariadb": "^3.5.2",
"node-ssh": "^13.2.1", "node-ssh": "^13.2.1",
"passport": "^0.7.0",
"passport-http-bearer": "^1.0.1",
"prisma": "^7.6.0", "prisma": "^7.6.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
@@ -34,6 +38,7 @@
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/passport-http-bearer": "^1.0.42",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
@@ -2280,6 +2285,16 @@
} }
} }
}, },
"node_modules/@nestjs/passport": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
"integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"passport": "^0.5.0 || ^0.6.0 || ^0.7.0"
}
},
"node_modules/@nestjs/platform-express": { "node_modules/@nestjs/platform-express": {
"version": "11.1.18", "version": "11.1.18",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.18.tgz", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.18.tgz",
@@ -2989,6 +3004,16 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/accepts": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz",
"integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3055,6 +3080,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/content-disposition": {
"version": "0.5.9",
"resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz",
"integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cookiejar": { "node_modules/@types/cookiejar": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -3062,6 +3094,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/cookies": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.2.tgz",
"integrity": "sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/express": "*",
"@types/keygrip": "*",
"@types/node": "*"
}
},
"node_modules/@types/eslint": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -3122,6 +3167,13 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/http-assert": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz",
"integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -3174,6 +3226,40 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/keygrip": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz",
"integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/koa": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/koa/-/koa-3.0.2.tgz",
"integrity": "sha512-7TRzVOBcH/q8CfPh9AmHBQ8TZtimT4Sn+rw8//hXveI6+F41z93W8a+0B0O8L7apKQv+vKBIEZSECiL0Oo1JFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/accepts": "*",
"@types/content-disposition": "*",
"@types/cookies": "*",
"@types/http-assert": "*",
"@types/http-errors": "^2",
"@types/keygrip": "*",
"@types/koa-compose": "*",
"@types/node": "*"
}
},
"node_modules/@types/koa-compose": {
"version": "3.2.9",
"resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.9.tgz",
"integrity": "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/koa": "*"
}
},
"node_modules/@types/methods": { "node_modules/@types/methods": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -3190,6 +3276,28 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/passport": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/passport-http-bearer": {
"version": "1.0.42",
"resolved": "https://registry.npmjs.org/@types/passport-http-bearer/-/passport-http-bearer-1.0.42.tgz",
"integrity": "sha512-cGezyf9hy3Cth+zWS779FR9XYhIX/DExsVZURqcSeUU/nhj0Aw8PUhvCyfS35ScwOSd5AFiFhtfWmqHa/2aYZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/koa": "*",
"@types/passport": "*"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -6677,6 +6785,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/hono": { "node_modules/hono": {
"version": "4.12.10", "version": "4.12.10",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz",
@@ -8818,6 +8935,43 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/passport": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
"utils-merge": "^1.0.1"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-http-bearer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/passport-http-bearer/-/passport-http-bearer-1.0.1.tgz",
"integrity": "sha512-SELQM+dOTuMigr9yu8Wo4Fm3ciFfkMq5h/ZQ8ffi4ELgZrX1xh9PlglqZdcUZ1upzJD/whVyt+YWF62s3U6Ipw==",
"dependencies": {
"passport-strategy": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -8900,6 +9054,11 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pause": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/perfect-debounce": { "node_modules/perfect-debounce": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -10755,6 +10914,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/v8-compile-cache-lib": { "node_modules/v8-compile-cache-lib": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",

View File

@@ -24,6 +24,7 @@
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@prisma/adapter-mariadb": "^7.6.0", "@prisma/adapter-mariadb": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.6.0",
@@ -31,8 +32,11 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"dotenv": "^17.4.0", "dotenv": "^17.4.0",
"helmet": "^8.1.0",
"mariadb": "^3.5.2", "mariadb": "^3.5.2",
"node-ssh": "^13.2.1", "node-ssh": "^13.2.1",
"passport": "^0.7.0",
"passport-http-bearer": "^1.0.1",
"prisma": "^7.6.0", "prisma": "^7.6.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
@@ -49,6 +53,7 @@
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/passport-http-bearer": "^1.0.42",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",

View File

@@ -0,0 +1,59 @@
import {
Controller,
Post,
Get,
Put,
Param,
Body,
Query,
UseGuards,
} from '@nestjs/common';
import { AdminService } from './admin.service';
import { ApiKeyGuard } from '../auth/api-key.guard';
import { SisterNamePipe } from '../common/sister-name.pipe';
import type { SisterName } from '../common/sister-name.pipe';
class UpdateHarnessDto {
content!: string;
}
@Controller('api/admin')
@UseGuards(ApiKeyGuard)
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Post('sisters/:name/restart')
restartSister(@Param('name', SisterNamePipe) name: SisterName) {
return this.adminService.restartSister(name);
}
@Post('sisters/:name/reset')
resetSister(@Param('name', SisterNamePipe) name: SisterName) {
return this.adminService.resetSisterSession(name);
}
@Get('harness/:name/:file')
getHarness(
@Param('name', SisterNamePipe) name: SisterName,
@Param('file') file: string,
) {
return this.adminService.getHarnessFile(name, file);
}
@Put('harness/:name/:file')
updateHarness(
@Param('name', SisterNamePipe) name: SisterName,
@Param('file') file: string,
@Body() dto: UpdateHarnessDto,
) {
return this.adminService.updateHarnessFile(name, file, dto.content);
}
@Get('logs/:name')
getLogs(
@Param('name', SisterNamePipe) name: SisterName,
@Query('lines') lines?: string,
) {
return this.adminService.getSisterLogs(name, lines ? parseInt(lines, 10) : 100);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { PrismaModule } from '../prisma/prisma.module';
import { SistersModule } from '../sisters/sisters.module';
import { ActivityModule } from '../activity/activity.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, SistersModule, ActivityModule, AuthModule],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,91 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AdminService } from './admin.service';
import { PrismaService } from '../prisma/prisma.service';
import { SshService } from '../sisters/ssh.service';
import { ActivityService } from '../activity/activity.service';
import { ConfigService } from '@nestjs/config';
import { BadRequestException } from '@nestjs/common';
describe('AdminService', () => {
let service: AdminService;
const mockSister = {
id: 2, name: 'narang', ip: '10.10.10.216', user: 'narang',
lxcId: 105, status: 'online', lastSeen: new Date(),
sshKeyPath: null, createdAt: new Date(), updatedAt: new Date(),
};
const mockPrisma = {
sisterConfig: { findUnique: jest.fn().mockResolvedValue(mockSister) },
};
const mockSsh = { executeCommand: jest.fn() };
const mockActivity = { log: jest.fn().mockResolvedValue({}) };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: SshService, useValue: mockSsh },
{ provide: ActivityService, useValue: mockActivity },
{
provide: ConfigService,
useValue: { get: jest.fn().mockReturnValue('/home/narang/.ssh/id_rsa') },
},
],
}).compile();
service = module.get<AdminService>(AdminService);
jest.clearAllMocks();
mockPrisma.sisterConfig.findUnique.mockResolvedValue(mockSister);
});
it('restartSister: SSH 성공 시 success=true', async () => {
mockSsh.executeCommand.mockResolvedValue({ stdout: 'RESTART_OK', stderr: '', code: 0 });
const result = await service.restartSister('narang');
expect(result.success).toBe(true);
expect(mockActivity.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'gateway_restart' }),
);
});
it('restartSister: SSH 실패 시 success=false graceful', async () => {
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
const result = await service.restartSister('narang');
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
it('getHarnessFile: 허용되지 않은 파일 → BadRequestException', async () => {
await expect(service.getHarnessFile('narang', 'passwd')).rejects.toThrow(
BadRequestException,
);
});
it('getHarnessFile: 허용된 파일 + SSH 실패 → 빈 content fallback', async () => {
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
const result = await service.getHarnessFile('narang', 'SOUL.md');
expect(result.file).toBe('SOUL.md');
expect(result.content).toBe('');
});
it('updateHarnessFile: 허용되지 않은 파일 → BadRequestException', async () => {
await expect(
service.updateHarnessFile('narang', '../../etc/passwd', 'bad'),
).rejects.toThrow(BadRequestException);
});
it('getSisterLogs: SSH 실패 시 fallback 메시지 반환', async () => {
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
const result = await service.getSisterLogs('narang');
expect(result.lines).toContain('(SSH 연결 불가)');
});
});

View File

@@ -0,0 +1,175 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { SshService } from '../sisters/ssh.service';
import { ActivityService } from '../activity/activity.service';
import { SisterName } from '../common/sister-name.pipe';
const ALLOWED_HARNESS_FILES = ['AGENTS.md', 'SOUL.md', 'PROTOCOL.md', 'TOOLS.md', 'HEARTBEAT.md'] as const;
type HarnessFile = (typeof ALLOWED_HARNESS_FILES)[number];
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(
private readonly prisma: PrismaService,
private readonly ssh: SshService,
private readonly activity: ActivityService,
private readonly config: ConfigService,
) {}
async restartSister(name: SisterName) {
const sister = await this.getSister(name);
const keyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
keyPath,
'openclaw gateway restart && echo "RESTART_OK"',
);
const success = result.stdout.includes('RESTART_OK');
await this.activity.log({
sisterId: sister.id,
action: 'gateway_restart',
detail: success ? 'Gateway restart successful' : `stderr: ${result.stderr}`,
});
return { success, output: result.stdout, error: result.stderr || null };
} catch (e) {
this.logger.warn(`Restart failed for ${name}`);
await this.activity.log({
sisterId: sister.id,
action: 'gateway_restart',
detail: `failed: ${(e as Error).message}`,
});
return { success: false, output: '', error: (e as Error).message };
}
}
async resetSisterSession(name: SisterName) {
const sister = await this.getSister(name);
const keyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
keyPath,
'rm -f ~/.openclaw/sessions/main.json && echo "RESET_OK"',
);
const success = result.stdout.includes('RESET_OK');
await this.activity.log({
sisterId: sister.id,
action: 'session_reset',
detail: success ? 'Session reset successful' : `stderr: ${result.stderr}`,
});
return { success, output: result.stdout, error: result.stderr || null };
} catch (e) {
this.logger.warn(`Reset failed for ${name}`);
return { success: false, output: '', error: (e as Error).message };
}
}
async getHarnessFile(name: SisterName, file: string) {
this.validateHarnessFile(file);
const sister = await this.getSister(name);
const keyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
keyPath,
`cat ~/.openclaw/workspace/${file} 2>/dev/null || echo ""`,
);
return { name, file, content: result.stdout };
} catch {
return { name, file, content: '' };
}
}
async updateHarnessFile(name: SisterName, file: string, content: string) {
this.validateHarnessFile(file);
const sister = await this.getSister(name);
const keyPath = this.getKeyPath();
// 내용에서 위험한 셸 escape 방지
const escaped = content.replace(/'/g, "'\\''");
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
keyPath,
[
`cd ~/.openclaw/workspace`,
`printf '%s' '${escaped}' > ${file}`,
`git add ${file} && git commit -m "admin: update ${file}" --allow-empty 2>&1`,
`echo "WRITE_OK"`,
].join(' && '),
);
const success = result.stdout.includes('WRITE_OK');
await this.activity.log({
sisterId: sister.id,
action: 'harness_updated',
detail: `${file} updated${success ? '' : ' (with errors)'}`,
});
return { success, output: result.stdout, error: result.stderr || null };
} catch (e) {
this.logger.warn(`Harness write failed for ${name}/${file}`);
return { success: false, output: '', error: (e as Error).message };
}
}
async getSisterLogs(name: SisterName, lines = 100) {
const sister = await this.getSister(name);
const keyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
keyPath,
`journalctl --user -u openclaw-gateway --no-pager -n ${lines} 2>/dev/null || tail -n ${lines} ~/.openclaw/logs/gateway.log 2>/dev/null || echo "(로그 없음)"`,
);
return {
name,
lines: result.stdout.split('\n').filter((l) => l),
total: result.stdout.split('\n').filter((l) => l).length,
};
} catch {
return { name, lines: ['(SSH 연결 불가)'], total: 1 };
}
}
private validateHarnessFile(file: string) {
if (!ALLOWED_HARNESS_FILES.includes(file as HarnessFile)) {
throw new BadRequestException(
`Invalid file. Allowed: ${ALLOWED_HARNESS_FILES.join(', ')}`,
);
}
}
private async getSister(name: SisterName) {
const sister = await this.prisma.sisterConfig.findUnique({ where: { name } });
if (!sister) throw new Error(`Sister ${name} not found`);
return sister;
}
private getKeyPath(): string {
const p = this.config.get<string>('SSH_KEY_PATH');
if (!p) throw new Error('SSH_KEY_PATH is not set');
return p;
}
}

View File

@@ -9,6 +9,9 @@ import { GiteaModule } from './gitea/gitea.module';
import { ProjectsModule } from './projects/projects.module'; import { ProjectsModule } from './projects/projects.module';
import { TasksModule } from './tasks/tasks.module'; import { TasksModule } from './tasks/tasks.module';
import { ActivityModule } from './activity/activity.module'; import { ActivityModule } from './activity/activity.module';
import { OrgModule } from './org/org.module';
import { AuthModule } from './auth/auth.module';
import { AdminModule } from './admin/admin.module';
@Module({ @Module({
imports: [ imports: [
@@ -20,6 +23,9 @@ import { ActivityModule } from './activity/activity.module';
ProjectsModule, ProjectsModule,
TasksModule, TasksModule,
ActivityModule, ActivityModule,
OrgModule,
AuthModule,
AdminModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class ApiKeyGuard extends AuthGuard('api-key') {}

View File

@@ -0,0 +1,30 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-http-bearer';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class ApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') {
private readonly validKeys: Set<string>;
constructor(config: ConfigService) {
super();
const raw = config.get<string>('ADMIN_API_KEYS') ?? '';
this.validKeys = new Set(
raw
.split(',')
.map((k) => k.trim())
.filter(Boolean),
);
}
validate(token: string): boolean {
if (!this.validKeys.size) {
throw new UnauthorizedException('ADMIN_API_KEYS not configured');
}
if (!this.validKeys.has(token)) {
throw new UnauthorizedException('Invalid API key');
}
return true;
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule } from '@nestjs/config';
import { ApiKeyStrategy } from './api-key.strategy';
import { ApiKeyGuard } from './api-key.guard';
@Module({
imports: [PassportModule, ConfigModule],
providers: [ApiKeyStrategy, ApiKeyGuard],
exports: [ApiKeyGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,16 @@
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
const VALID_SISTER_NAMES = ['harang', 'narang', 'darang', 'erang'] as const;
export type SisterName = (typeof VALID_SISTER_NAMES)[number];
@Injectable()
export class SisterNamePipe implements PipeTransform {
transform(value: string): SisterName {
if (!VALID_SISTER_NAMES.includes(value as SisterName)) {
throw new BadRequestException(
`Invalid sister name. Must be one of: ${VALID_SISTER_NAMES.join(', ')}`,
);
}
return value as SisterName;
}
}

View File

@@ -1,10 +1,13 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common'; import { Logger, ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import helmet from 'helmet';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.use(helmet());
const allowedOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:3004') const allowedOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:3004')
.split(',') .split(',')
.map((o) => o.trim()) .map((o) => o.trim())

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { SisterDetailService } from '../sisters/sister-detail.service';
@Controller('api/org')
export class OrgController {
constructor(private readonly sisterDetail: SisterDetailService) {}
@Get()
getOrgData() {
return this.sisterDetail.getOrgData();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { OrgController } from './org.controller';
import { SistersModule } from '../sisters/sisters.module';
@Module({
imports: [SistersModule],
controllers: [OrgController],
})
export class OrgModule {}

View File

@@ -0,0 +1,90 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SisterDetailService } from './sister-detail.service';
import { SshService } from './ssh.service';
import { PrismaService } from '../prisma/prisma.service';
import { ConfigService } from '@nestjs/config';
import { NotFoundException } from '@nestjs/common';
describe('SisterDetailService', () => {
let service: SisterDetailService;
const mockSister = {
id: 2, name: 'narang', ip: '10.10.10.216', user: 'narang',
lxcId: 105, status: 'online', lastSeen: new Date(),
sshKeyPath: null, createdAt: new Date(), updatedAt: new Date(),
};
const mockPrisma = {
sisterConfig: {
findUnique: jest.fn(),
findMany: jest.fn().mockResolvedValue([mockSister]),
},
activityLog: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
};
const mockSsh = { executeCommand: jest.fn() };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SisterDetailService,
{ provide: SshService, useValue: mockSsh },
{ provide: PrismaService, useValue: mockPrisma },
{
provide: ConfigService,
useValue: { get: jest.fn().mockReturnValue('/home/narang/.ssh/id_rsa') },
},
],
}).compile();
service = module.get<SisterDetailService>(SisterDetailService);
jest.clearAllMocks();
});
it('getSisterConfig: SSH 실패 시 fallback 반환', async () => {
mockPrisma.sisterConfig.findUnique.mockResolvedValue(mockSister);
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
const result = await service.getSisterConfig('narang');
expect(result.name).toBe('narang');
expect(result.role).toBe('Generator');
expect(result.raw).toContain('SSH 연결 불가');
});
it('getSisterConfig: sister 없으면 NotFoundException', async () => {
mockPrisma.sisterConfig.findUnique.mockResolvedValue(null);
await expect(service.getSisterConfig('narang')).rejects.toThrow(NotFoundException);
});
it('getSisterSessions: SSH 실패 시 빈 배열', async () => {
mockPrisma.sisterConfig.findUnique.mockResolvedValue(mockSister);
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
const result = await service.getSisterSessions('narang');
expect(result).toEqual([]);
});
it('getOrgData: 파이프라인 포함 조직도 반환', async () => {
const result = await service.getOrgData();
expect(result.owner.name).toBe('자기야');
expect(result.sisters).toHaveLength(1);
expect(result.pipeline.length).toBeGreaterThan(0);
});
it('getSisterActivityLog: sisterId 필터 적용', async () => {
mockPrisma.sisterConfig.findUnique.mockResolvedValue(mockSister);
mockPrisma.activityLog.findMany.mockResolvedValue([]);
await service.getSisterActivityLog('narang');
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { sisterId: mockSister.id } }),
);
});
});

View File

@@ -0,0 +1,153 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SshService } from './ssh.service';
import { ConfigService } from '@nestjs/config';
const SISTER_NAMES = ['harang', 'narang', 'darang', 'erang'] as const;
type SisterName = (typeof SISTER_NAMES)[number];
const SISTER_ROLES: Record<SisterName, string> = {
harang: 'Orchestrator',
narang: 'Generator',
darang: 'Evaluator',
erang: 'Infra Manager',
};
const SISTER_DESCRIPTIONS: Record<SisterName, string> = {
harang: '기획과 오케스트레이션. 스프린트를 설계하고 자매들에게 작업을 배분한다.',
narang: '코드 생성과 개발. 스프린트를 구현하고 PR을 만든다.',
darang: '품질 검증. 코드 리뷰와 보안 감사를 수행한다.',
erang: '인프라와 배포. 서버 관리, merge, 프로덕션 배포를 담당한다.',
};
@Injectable()
export class SisterDetailService {
private readonly logger = new Logger(SisterDetailService.name);
constructor(
private readonly prisma: PrismaService,
private readonly ssh: SshService,
private readonly config: ConfigService,
) {}
async getSisterConfig(name: SisterName) {
const sister = await this.getSisterRecord(name);
const sshKeyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
sshKeyPath,
'cat ~/.openclaw/workspace/SOUL.md 2>/dev/null | head -60; echo "---AGENTS---"; cat ~/.openclaw/workspace/AGENTS.md 2>/dev/null | head -40',
);
return {
name,
role: SISTER_ROLES[name],
description: SISTER_DESCRIPTIONS[name],
raw: result.stdout || '(설정 파일 없음)',
};
} catch {
return {
name,
role: SISTER_ROLES[name],
description: SISTER_DESCRIPTIONS[name],
raw: '(SSH 연결 불가)',
};
}
}
async getSisterSessions(name: SisterName) {
const sister = await this.getSisterRecord(name);
const sshKeyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
sshKeyPath,
'ls -lt ~/.openclaw/sessions/ 2>/dev/null | head -20 || echo ""',
);
const lines = result.stdout.split('\n').filter((l) => l && !l.startsWith('total'));
return lines.map((line) => {
const parts = line.trim().split(/\s+/);
return {
name: parts[parts.length - 1] ?? '',
modified: parts.slice(5, 8).join(' '),
size: parts[4] ?? '0',
};
}).filter((s) => s.name && s.name !== '');
} catch {
return [];
}
}
async getSisterSubagents(name: SisterName) {
const sister = await this.getSisterRecord(name);
const sshKeyPath = this.getKeyPath();
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
sshKeyPath,
'ls ~/.openclaw/workspace/agents/ 2>/dev/null || echo ""',
);
const agents = result.stdout.split('\n').filter((l) => l.trim().endsWith('.md'));
return agents.map((a) => ({ name: a.trim().replace('.md', ''), file: a.trim() }));
} catch {
return [];
}
}
async getSisterActivityLog(name: SisterName) {
const sister = await this.getSisterRecord(name);
const [logs, total] = await Promise.all([
this.prisma.activityLog.findMany({
where: { sisterId: sister.id },
orderBy: { createdAt: 'desc' },
take: 20,
}),
this.prisma.activityLog.count({ where: { sisterId: sister.id } }),
]);
return { items: logs, total };
}
async getOrgData() {
const sisters = await this.prisma.sisterConfig.findMany();
return {
owner: { name: '자기야', role: 'Owner' },
sisters: sisters.map((s) => ({
id: s.id,
name: s.name,
role: SISTER_ROLES[s.name as SisterName] ?? 'Unknown',
description: SISTER_DESCRIPTIONS[s.name as SisterName] ?? '',
status: s.status,
lastSeen: s.lastSeen,
lxcId: s.lxcId,
})),
pipeline: [
{ from: 'harang', to: 'narang', label: '핸드오프' },
{ from: 'narang', to: 'darang', label: '리뷰 요청' },
{ from: 'darang', to: 'narang', label: '수정 요청' },
{ from: 'darang', to: 'harang', label: '검증 완료' },
{ from: 'harang', to: 'erang', label: '배포 요청' },
],
};
}
private async getSisterRecord(name: SisterName) {
const sister = await this.prisma.sisterConfig.findUnique({ where: { name } });
if (!sister) throw new NotFoundException(`Sister ${name} not found`);
return sister;
}
private getKeyPath(): string {
const p = this.config.get<string>('SSH_KEY_PATH');
if (!p) throw new Error('SSH_KEY_PATH is not set');
return p;
}
}

View File

@@ -1,12 +1,38 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get, Param } from '@nestjs/common';
import { SistersService } from './sisters.service'; import { SistersService } from './sisters.service';
import { SisterDetailService } from './sister-detail.service';
import { SisterNamePipe } from '../common/sister-name.pipe';
import type { SisterName } from '../common/sister-name.pipe';
@Controller('api/sisters') @Controller('api/sisters')
export class SistersController { export class SistersController {
constructor(private readonly sistersService: SistersService) {} constructor(
private readonly sistersService: SistersService,
private readonly sisterDetail: SisterDetailService,
) {}
@Get() @Get()
async getSistersStatus() { async getSistersStatus() {
return this.sistersService.getAllSistersStatus(); return this.sistersService.getAllSistersStatus();
} }
@Get(':name/config')
async getSisterConfig(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterConfig(name as SisterName);
}
@Get(':name/sessions')
async getSisterSessions(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterSessions(name as SisterName);
}
@Get(':name/subagents')
async getSisterSubagents(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterSubagents(name as SisterName);
}
@Get(':name/activity')
async getSisterActivity(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterActivityLog(name as SisterName);
}
} }

View File

@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SistersController } from './sisters.controller'; import { SistersController } from './sisters.controller';
import { SistersService } from './sisters.service'; import { SistersService } from './sisters.service';
import { SisterDetailService } from './sister-detail.service';
import { SshService } from './ssh.service'; import { SshService } from './ssh.service';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule],
controllers: [SistersController], controllers: [SistersController],
providers: [SistersService, SshService], providers: [SistersService, SisterDetailService, SshService],
exports: [SisterDetailService, SshService],
}) })
export class SistersModule {} export class SistersModule {}

View File

@@ -0,0 +1,201 @@
'use client';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import CodeEditor from '@/components/admin/CodeEditor';
import { theme } from '@/styles/theme';
import { adminFetch } from '@/lib/adminFetch';
const SISTERS = ['harang', 'narang', 'darang', 'erang'];
const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
const DISPLAY_NAMES: Record<string, string> = {
harang: '🦊 하랑이', narang: '🦊 나랑이', darang: '🐱 다랑이', erang: '🐺 이랑이',
};
const Layout = styled.div`
display: grid;
grid-template-columns: 180px 1fr;
gap: 20px;
`;
const FileTree = styled.div`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 10px;
overflow: hidden;
`;
const TreeSection = styled.div`
border-bottom: 1px solid ${theme.colors.border};
padding: 6px;
&:last-child { border-bottom: none; }
`;
const TreeLabel = styled.div`
font-size: 11px;
font-weight: 700;
color: ${theme.colors.textSecondary};
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 6px 8px 4px;
`;
const TreeItem = styled.button<{ $active: boolean }>`
width: 100%;
text-align: left;
padding: 6px 10px;
font-size: 12px;
border: none;
border-radius: 5px;
cursor: pointer;
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
color: ${({ $active }) => $active ? theme.colors.accent : theme.colors.textSecondary};
transition: background 0.15s;
&:hover { background: rgba(240,246,252,0.06); color: ${theme.colors.textPrimary}; }
`;
const EditorPane = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const Toolbar = styled.div`
display: flex;
align-items: center;
gap: 10px;
`;
const PathLabel = styled.span`
font-size: 13px;
color: ${theme.colors.textSecondary};
font-family: monospace;
flex: 1;
`;
const Btn = styled.button<{ $primary?: boolean; $danger?: boolean }>`
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid;
transition: all 0.15s;
disabled: ${({ disabled }) => disabled ? 'not-allowed' : 'pointer'};
${({ $primary }) =>
$primary &&
`background: rgba(88,166,255,0.12); border-color: rgba(88,166,255,0.4); color: #58A6FF;
&:hover { background: rgba(88,166,255,0.22); }`}
${({ $danger }) =>
$danger &&
`background: transparent; border-color: rgba(240,246,252,0.15); color: #8B949E;
&:hover { color: #E6EDF3; }`}
${({ $primary, $danger }) =>
!$primary && !$danger &&
`background: transparent; border-color: rgba(240,246,252,0.15); color: #8B949E;
&:hover { color: #E6EDF3; }`}
`;
const ResultMsg = styled.div<{ $success: boolean }>`
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-family: monospace;
background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};
color: ${({ $success }) => $success ? theme.colors.online : theme.colors.offline};
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
`;
export default function HarnessPage() {
const [selected, setSelected] = useState({ sister: 'narang', file: 'SOUL.md' });
const [content, setContent] = useState('');
const [saved, setSaved] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [result, setResult] = useState<{ success: boolean; msg: string } | null>(null);
useEffect(() => {
setContent('');
setSaved('');
setResult(null);
setLoading(true);
adminFetch(`/api/admin/harness/${selected.sister}/${selected.file}`)
.then((r) => r.json())
.then((d) => { setContent(d.content ?? ''); setSaved(d.content ?? ''); })
.catch(() => setContent('(로드 실패)'))
.finally(() => setLoading(false));
}, [selected.sister, selected.file]);
const handleSave = async () => {
setSaving(true);
setResult(null);
try {
const res = await adminFetch(`/api/admin/harness/${selected.sister}/${selected.file}`, {
method: 'PUT',
body: JSON.stringify({ content }),
});
const d = await res.json();
if (d.success) {
setSaved(content);
setResult({ success: true, msg: '저장 완료' });
} else {
setResult({ success: false, msg: d.error ?? '저장 실패' });
}
} catch (e) {
setResult({ success: false, msg: (e as Error).message });
} finally {
setSaving(false);
}
};
const isDirty = content !== saved;
return (
<Layout>
<FileTree>
{SISTERS.map((s) => (
<TreeSection key={s}>
<TreeLabel>{DISPLAY_NAMES[s] ?? s}</TreeLabel>
{FILES.map((f) => (
<TreeItem
key={f}
$active={selected.sister === s && selected.file === f}
onClick={() => setSelected({ sister: s, file: f })}
>
{f}
</TreeItem>
))}
</TreeSection>
))}
</FileTree>
<EditorPane>
<Toolbar>
<PathLabel>
~/.openclaw/workspace/{selected.file} ({DISPLAY_NAMES[selected.sister]})
</PathLabel>
{isDirty && (
<span style={{ fontSize: '11px', color: '#FF9800' }}> </span>
)}
<Btn onClick={() => { setContent(saved); setResult(null); }} $danger></Btn>
<Btn $primary onClick={handleSave} disabled={!isDirty || saving}>
{saving ? '저장 중...' : '💾 저장'}
</Btn>
</Toolbar>
{result && <ResultMsg $success={result.success}>{result.msg}</ResultMsg>}
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: theme.colors.textSecondary, fontSize: '13px' }}>
...
</div>
) : (
<CodeEditor value={content} onChange={setContent} />
)}
</EditorPane>
</Layout>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { theme } from '@/styles/theme';
const ADMIN_TABS = [
{ href: '/admin', label: '자매 관리', icon: '⚙️' },
{ href: '/admin/harness', label: '하네스 편집', icon: '✏️' },
{ href: '/admin/logs', label: '로그 뷰어', icon: '📋' },
{ href: '/admin/repos', label: '저장소', icon: '📁' },
];
const Wrapper = styled.div`
padding: 32px;
min-height: 100vh;
background: ${theme.colors.bg};
`;
const Header = styled.div`
margin-bottom: 24px;
`;
const Title = styled.h1`
font-size: 22px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 4px;
`;
const Subtitle = styled.p`
font-size: 13px;
color: ${theme.colors.textSecondary};
`;
const TabRow = styled.div`
display: flex;
gap: 4px;
border-bottom: 1px solid ${theme.colors.border};
margin-bottom: 24px;
`;
const Tab = styled(Link)<{ $active: boolean }>`
padding: 10px 16px;
font-size: 13px;
font-weight: ${({ $active }) => $active ? '600' : '400'};
color: ${({ $active }) => $active ? theme.colors.textPrimary : theme.colors.textSecondary};
border-bottom: 2px solid ${({ $active }) => $active ? theme.colors.accent : 'transparent'};
transition: color 0.15s, border-color 0.15s;
display: flex;
align-items: center;
gap: 6px;
text-decoration: none;
&:hover { color: ${theme.colors.textPrimary}; }
`;
const ApiKeyRow = styled.div`
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 20px;
padding: 10px 14px;
background: rgba(88,166,255,0.06);
border: 1px solid rgba(88,166,255,0.15);
border-radius: 8px;
`;
const ApiKeyInput = styled.input`
background: transparent;
border: none;
color: ${theme.colors.textPrimary};
font-size: 13px;
font-family: monospace;
outline: none;
flex: 1;
`;
const ApiKeyLabel = styled.span`
font-size: 12px;
color: ${theme.colors.textSecondary};
white-space: nowrap;
`;
export default function AdminLayout({ children }: { children: React.ReactNode }) {
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 (
<Wrapper>
<Header>
<Title> </Title>
<Subtitle> , , </Subtitle>
</Header>
<ApiKeyRow>
<ApiKeyLabel>🔑 Admin Key:</ApiKeyLabel>
<ApiKeyInput
type="password"
placeholder="ADMIN_API_KEYS 값 입력"
value={apiKey}
onChange={(e) => saveKey(e.target.value)}
/>
</ApiKeyRow>
<TabRow>
{ADMIN_TABS.map((tab) => (
<Tab key={tab.href} href={tab.href} $active={pathname === tab.href}>
{tab.icon} {tab.label}
</Tab>
))}
</TabRow>
{children}
</Wrapper>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import React, { useState, useCallback } from 'react';
import styled from 'styled-components';
import LogTerminal from '@/components/admin/LogTerminal';
import { theme } from '@/styles/theme';
import { adminFetch } from '@/lib/adminFetch';
const SISTERS = [
{ value: 'harang', label: '🦊 하랑이' },
{ value: 'narang', label: '🦊 나랑이' },
{ value: 'darang', label: '🐱 다랑이' },
{ value: 'erang', label: '🐺 이랑이' },
];
const Controls = styled.div`
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 16px;
`;
const Select = styled.select`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 7px;
color: ${theme.colors.textPrimary};
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
outline: none;
&:focus { border-color: ${theme.colors.accent}; }
option { background: #1a1f2a; }
`;
const LinesInput = styled.input`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 7px;
color: ${theme.colors.textPrimary};
padding: 8px 12px;
font-size: 13px;
width: 80px;
outline: none;
&:focus { border-color: ${theme.colors.accent}; }
`;
const FetchBtn = styled.button`
padding: 8px 18px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
background: rgba(88,166,255,0.1);
border: 1px solid rgba(88,166,255,0.35);
color: ${theme.colors.accent};
cursor: pointer;
transition: background 0.15s;
&:hover { background: rgba(88,166,255,0.2); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
`;
const Label = styled.span`
font-size: 12px;
color: ${theme.colors.textSecondary};
`;
export default function LogsPage() {
const [sister, setSister] = useState('narang');
const [lines, setLines] = useState(100);
const [logLines, setLogLines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const res = await adminFetch(`/api/admin/logs/${sister}?lines=${lines}`);
const d = await res.json();
setLogLines(d.lines ?? []);
} catch {
setLogLines(['(로그 로드 실패)']);
} finally {
setLoading(false);
}
}, [sister, lines]);
return (
<>
<Controls>
<Select value={sister} onChange={(e) => setSister(e.target.value)}>
{SISTERS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</Select>
<Label></Label>
<LinesInput
type="number"
value={lines}
onChange={(e) => setLines(parseInt(e.target.value, 10) || 100)}
min={10}
max={500}
/>
<Label></Label>
<FetchBtn onClick={fetchLogs} disabled={loading}>
{loading ? '로딩 중...' : '📋 로그 가져오기'}
</FetchBtn>
</Controls>
<LogTerminal lines={logLines} />
</>
);
}

175
frontend/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,175 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import StatusBadge from '@/components/common/StatusBadge';
import ConfirmModal from '@/components/admin/ConfirmModal';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config';
import { adminFetch } from '@/lib/adminFetch';
import { SISTER_EMOJIS, SISTER_DISPLAY_NAMES } from '@/lib/sisters';
type Status = 'online' | 'offline' | 'working' | 'unknown';
interface ActionResult {
success: boolean;
output: string;
error: string | null;
}
const Table = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const Row = styled.div`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 10px;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 16px;
`;
const SisterInfo = styled.div`
display: flex;
align-items: center;
gap: 10px;
flex: 1;
`;
const Emoji = styled.span`font-size: 22px;`;
const Name = styled.span`
font-size: 15px;
font-weight: 600;
color: ${theme.colors.textPrimary};
min-width: 70px;
`;
const Actions = styled.div`
display: flex;
gap: 8px;
`;
const ActionBtn = styled.button<{ $danger?: boolean }>`
padding: 6px 14px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid;
transition: all 0.15s;
${({ $danger }) =>
$danger
? `background: rgba(255,23,68,0.08); border-color: rgba(255,23,68,0.3); color: #FF1744;
&:hover { background: rgba(255,23,68,0.18); }`
: `background: rgba(88,166,255,0.08); border-color: rgba(88,166,255,0.3); color: #58A6FF;
&:hover { background: rgba(88,166,255,0.18); }`}
`;
const ResultBanner = styled.div<{ $success: boolean }>`
margin-top: 8px;
padding: 8px 12px;
background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
border-radius: 6px;
font-size: 12px;
color: ${({ $success }) => $success ? theme.colors.online : theme.colors.offline};
font-family: monospace;
white-space: pre-wrap;
`;
export default function AdminSistersPage() {
const [sisters, setSisters] = useState<any[]>([]);
const [modal, setModal] = useState<{ action: 'restart' | 'reset'; name: string } | null>(null);
const [results, setResults] = useState<Record<string, ActionResult>>({});
const [loading, setLoading] = useState<Record<string, boolean>>({});
useEffect(() => {
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
const iv = setInterval(() => {
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
}, 15000);
return () => clearInterval(iv);
}, []);
const doAction = async (action: 'restart' | 'reset', name: string) => {
setModal(null);
setLoading((p) => ({ ...p, [`${action}:${name}`]: true }));
try {
const res = await adminFetch(`/api/admin/sisters/${name}/${action}`, { method: 'POST' });
const data: ActionResult = await res.json();
setResults((p) => ({ ...p, [`${action}:${name}`]: data }));
} catch (e) {
setResults((p) => ({
...p,
[`${action}:${name}`]: { success: false, output: '', error: (e as Error).message },
}));
} finally {
setLoading((p) => ({ ...p, [`${action}:${name}`]: false }));
}
};
return (
<>
<Table>
{sisters.map((s) => {
const restartResult = results[`restart:${s.name}`];
const resetResult = results[`reset:${s.name}`];
return (
<div key={s.id}>
<Row>
<SisterInfo>
<Emoji>{SISTER_EMOJIS[s.name] ?? '🤖'}</Emoji>
<Name>{SISTER_DISPLAY_NAMES[s.name] ?? s.name}</Name>
<StatusBadge status={s.status as Status} />
</SisterInfo>
<Actions>
<ActionBtn
onClick={() => setModal({ action: 'restart', name: s.name })}
disabled={loading[`restart:${s.name}`]}
>
{loading[`restart:${s.name}`] ? '...' : '🔄 재시작'}
</ActionBtn>
<ActionBtn
$danger
onClick={() => setModal({ action: 'reset', name: s.name })}
disabled={loading[`reset:${s.name}`]}
>
{loading[`reset:${s.name}`] ? '...' : '🗑️ 리셋'}
</ActionBtn>
</Actions>
</Row>
{restartResult && (
<ResultBanner $success={restartResult.success}>
: {restartResult.success ? '✅ 성공' : `${restartResult.error}`}
{restartResult.output && `\n${restartResult.output}`}
</ResultBanner>
)}
{resetResult && (
<ResultBanner $success={resetResult.success}>
: {resetResult.success ? '✅ 성공' : `${resetResult.error}`}
</ResultBanner>
)}
</div>
);
})}
</Table>
{modal && (
<ConfirmModal
title={modal.action === 'restart' ? '게이트웨이 재시작' : '세션 리셋'}
message={`${SISTER_DISPLAY_NAMES[modal.name] ?? modal.name}${modal.action === 'restart' ? 'OpenClaw Gateway를 재시작' : '메인 세션을 초기화'}하시겠어요?`}
confirmLabel={modal.action === 'restart' ? '재시작' : '리셋'}
danger={modal.action === 'reset'}
onConfirm={() => doAction(modal.action, modal.name)}
onCancel={() => setModal(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config';
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
`;
const Card = styled.a`
display: block;
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 10px;
padding: 18px 20px;
text-decoration: none;
transition: transform 0.15s, border-color 0.15s;
&:hover {
transform: translateY(-2px);
border-color: rgba(88,166,255,0.3);
}
`;
const RepoName = styled.div`
font-size: 14px;
font-weight: 700;
color: ${theme.colors.accent};
margin-bottom: 6px;
`;
const RepoDesc = styled.div`
font-size: 12px;
color: ${theme.colors.textSecondary};
margin-bottom: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const Stats = styled.div`
display: flex;
gap: 14px;
`;
const Stat = styled.span`
font-size: 12px;
color: ${theme.colors.textSecondary};
`;
const PRBadge = styled.span<{ $count: number }>`
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'};
color: ${({ $count }) => $count > 0 ? theme.colors.accent : theme.colors.textSecondary};
`;
const NoProjects = styled.div`
padding: 32px;
text-align: center;
color: ${theme.colors.textSecondary};
font-size: 13px;
`;
export default function ReposPage() {
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_URL}/api/projects`)
.then((r) => r.json())
.then(setProjects)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <div style={{ color: theme.colors.textSecondary, fontSize: '14px' }}> ...</div>;
if (!projects.length) return <NoProjects> </NoProjects>;
return (
<Grid>
{projects.map((p) => (
<Card key={p.id} href={p.repoUrl} target="_blank" rel="noopener">
<RepoName>📁 {p.name}</RepoName>
{p.description && <RepoDesc>{p.description}</RepoDesc>}
<Stats>
<PRBadge $count={p.openPRs}>PR {p.openPRs}</PRBadge>
<Stat>Sprint {p.sprintCount}</Stat>
<Stat> {p.progress}%</Stat>
</Stats>
</Card>
))}
</Grid>
);
}

103
frontend/app/org/page.tsx Normal file
View File

@@ -0,0 +1,103 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import OrgTree from '@/components/org/OrgTree';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config';
const Page = styled.div`
padding: 32px;
min-height: 100vh;
background: ${theme.colors.bg};
`;
const PageTitle = styled.h1`
font-size: 22px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 6px;
`;
const PageSubtitle = styled.p`
font-size: 14px;
color: ${theme.colors.textSecondary};
margin-bottom: 28px;
`;
const Card = styled.div`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 16px;
padding: 32px;
max-width: 800px;
margin: 0 auto;
`;
const Legend = styled.div`
display: flex;
gap: 20px;
justify-content: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid ${theme.colors.border};
flex-wrap: wrap;
`;
const LegendItem = styled.div`
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: ${theme.colors.textSecondary};
`;
const Dot = styled.div<{ $color: string }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $color }) => $color};
`;
export default function OrgPage() {
const [orgData, setOrgData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
try {
const res = await fetch(`${API_URL}/api/org`);
if (res.ok) setOrgData(await res.json());
} catch {
// silent
} finally {
setLoading(false);
}
};
load();
}, []);
return (
<Page>
<PageTitle></PageTitle>
<PageSubtitle> 4 </PageSubtitle>
<Card>
{loading ? (
<div style={{ textAlign: 'center', color: theme.colors.textSecondary, padding: '40px' }}>
...
</div>
) : (
<OrgTree sisters={orgData?.sisters ?? []} />
)}
<Legend>
<LegendItem><Dot $color={theme.colors.online} /> </LegendItem>
<LegendItem><Dot $color={theme.colors.working} /> </LegendItem>
<LegendItem><Dot $color={theme.colors.offline} /> </LegendItem>
<LegendItem><Dot $color={theme.colors.textSecondary} /> </LegendItem>
</Legend>
</Card>
</Page>
);
}

View File

@@ -27,7 +27,7 @@ const MOCK_SISTERS: SisterStatus[] = [
{ id: 4, name: 'erang', user: 'erang', lxcId: 107, role: 'Infra Manager', status: 'online', lastSeen: new Date().toISOString(), currentTask: null }, { id: 4, name: 'erang', user: 'erang', lxcId: 107, role: 'Infra Manager', status: 'online', lastSeen: new Date().toISOString(), currentTask: null },
]; ];
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005'; import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
const PageWrapper = styled.div` const PageWrapper = styled.div`
padding: 32px; padding: 32px;
@@ -149,7 +149,7 @@ export default function DashboardPage() {
}; };
fetchAll(); fetchAll();
const interval = setInterval(fetchAll, 30000); const interval = setInterval(fetchAll, POLL_INTERVAL_MS);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);

View File

@@ -11,7 +11,7 @@ import ActivityFeed from '@/components/dashboard/ActivityFeed';
import ProgressBar from '@/components/common/ProgressBar'; import ProgressBar from '@/components/common/ProgressBar';
import { theme } from '@/styles/theme'; import { theme } from '@/styles/theme';
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005'; import { API_URL } from '@/lib/config';
const TABS = [ const TABS = [
{ id: 'sprints', label: 'Sprints', icon: '📁' }, { id: 'sprints', label: 'Sprints', icon: '📁' },

View File

@@ -0,0 +1,160 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import ConfigViewer from '@/components/sisters/ConfigViewer';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config';
const SISTERS = [
{ value: 'harang', label: '🦊 하랑이 (Orchestrator)' },
{ value: 'narang', label: '🦊 나랑이 (Generator)' },
{ value: 'darang', label: '🐱 다랑이 (Evaluator)' },
{ value: 'erang', label: '🐺 이랑이 (Infra Manager)' },
];
const Page = styled.div`
padding: 32px;
min-height: 100vh;
background: ${theme.colors.bg};
`;
const PageTitle = styled.h1`
font-size: 22px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 6px;
`;
const PageSubtitle = styled.p`
font-size: 14px;
color: ${theme.colors.textSecondary};
margin-bottom: 28px;
`;
const Controls = styled.div`
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
`;
const Select = styled.select`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 8px;
color: ${theme.colors.textPrimary};
padding: 8px 12px;
font-size: 14px;
cursor: pointer;
outline: none;
transition: border-color 0.15s;
&:focus { border-color: ${theme.colors.accent}; }
option {
background: #1a1f2a;
}
`;
const Card = styled.div`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 12px;
padding: 24px;
`;
const InfoRow = styled.div`
display: flex;
gap: 16px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid ${theme.colors.border};
`;
const InfoItem = styled.div`
font-size: 13px;
color: ${theme.colors.textSecondary};
strong {
color: ${theme.colors.textPrimary};
display: block;
font-size: 14px;
margin-bottom: 2px;
}
`;
const SectionTitle = styled.h3`
font-size: 12px;
font-weight: 600;
color: ${theme.colors.textSecondary};
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 12px;
`;
export default function SettingsPage() {
const [selected, setSelected] = useState('narang');
const [configData, setConfigData] = useState<any>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const load = async () => {
setLoading(true);
setConfigData(null);
try {
const res = await fetch(`${API_URL}/api/sisters/${selected}/config`);
if (res.ok) setConfigData(await res.json());
} catch {
// silent
} finally {
setLoading(false);
}
};
load();
}, [selected]);
return (
<Page>
<PageTitle> </PageTitle>
<PageSubtitle> SOUL.md / AGENTS.md </PageSubtitle>
<Controls>
<Select value={selected} onChange={(e) => setSelected(e.target.value)}>
{SISTERS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</Select>
</Controls>
<Card>
{configData && (
<InfoRow>
<InfoItem>
<strong>{configData.name}</strong>
</InfoItem>
<InfoItem>
<strong>{configData.role}</strong>
</InfoItem>
<InfoItem style={{ flex: 1 }}>
<strong>{configData.description}</strong>
</InfoItem>
</InfoRow>
)}
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle>
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: theme.colors.textSecondary, fontSize: '13px' }}>
...
</div>
) : (
<ConfigViewer content={configData?.raw ?? ''} />
)}
</Card>
</Page>
);
}

View File

@@ -0,0 +1,195 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import styled from 'styled-components';
import Link from 'next/link';
import SisterHeader from '@/components/sisters/SisterHeader';
import ConfigViewer from '@/components/sisters/ConfigViewer';
import SessionList from '@/components/sisters/SessionList';
import SubagentList from '@/components/sisters/SubagentList';
import ActivityFeed from '@/components/dashboard/ActivityFeed';
import TabNav from '@/components/common/TabNav';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config';
type Status = 'online' | 'offline' | 'working' | 'unknown';
const TABS = [
{ id: 'overview', label: '개요', icon: '👤' },
{ id: 'config', label: '설정', icon: '⚙️' },
{ id: 'sessions', label: '세션', icon: '💬' },
{ id: 'subagents', label: '서브에이전트', icon: '🤖' },
];
const Page = styled.div`
padding: 32px;
min-height: 100vh;
background: ${theme.colors.bg};
`;
const BackLink = styled(Link)`
font-size: 13px;
color: ${theme.colors.textSecondary};
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 20px;
transition: color 0.15s;
&:hover { color: ${theme.colors.textPrimary}; }
`;
const Card = styled.div`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 12px;
padding: 24px;
`;
const OverviewGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
`;
const SectionTitle = styled.h3`
font-size: 13px;
font-weight: 600;
color: ${theme.colors.textSecondary};
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 14px;
`;
const CurrentTaskBox = styled.div`
padding: 12px 16px;
background: rgba(88,166,255,0.06);
border: 1px solid rgba(88,166,255,0.2);
border-radius: 8px;
font-size: 13px;
color: ${theme.colors.accent};
margin-bottom: 20px;
`;
const ErrorBanner = styled.div`
padding: 10px 14px;
background: rgba(255,23,68,0.08);
border: 1px solid rgba(255,23,68,0.3);
border-radius: 8px;
font-size: 13px;
color: ${theme.colors.offline};
margin-bottom: 20px;
`;
export default function SisterDetailPage() {
const { name } = useParams<{ name: string }>();
const [sisters, setSisters] = useState<any[]>([]);
const [configData, setConfigData] = useState<any>(null);
const [sessions, setSessions] = useState<any[]>([]);
const [subagents, setSubagents] = useState<any[]>([]);
const [activity, setActivity] = useState<any[]>([]);
const [tab, setTab] = useState('overview');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const load = async () => {
try {
const [sistersRes, configRes, sessionsRes, subagentsRes, activityRes] = await Promise.allSettled([
fetch(`${API_URL}/api/sisters`),
fetch(`${API_URL}/api/sisters/${name}/config`),
fetch(`${API_URL}/api/sisters/${name}/sessions`),
fetch(`${API_URL}/api/sisters/${name}/subagents`),
fetch(`${API_URL}/api/sisters/${name}/activity`),
]);
if (sistersRes.status === 'fulfilled' && sistersRes.value.ok) {
setSisters(await sistersRes.value.json());
}
if (configRes.status === 'fulfilled' && configRes.value.ok) {
setConfigData(await configRes.value.json());
}
if (sessionsRes.status === 'fulfilled' && sessionsRes.value.ok) {
setSessions(await sessionsRes.value.json());
}
if (subagentsRes.status === 'fulfilled' && subagentsRes.value.ok) {
setSubagents(await subagentsRes.value.json());
}
if (activityRes.status === 'fulfilled' && activityRes.value.ok) {
const d = await activityRes.value.json();
setActivity(d.items ?? []);
}
setError(null);
} catch {
setError('API 연결 실패');
} finally {
setLoading(false);
}
};
load();
}, [name]);
const sisterInfo = sisters.find((s) => s.name === name);
return (
<Page>
<BackLink href="/"> </BackLink>
{error && <ErrorBanner> {error}</ErrorBanner>}
{loading ? (
<div style={{ color: theme.colors.textSecondary, fontSize: '14px' }}> ...</div>
) : (
<>
<SisterHeader
name={name}
role={configData?.role ?? ''}
description={configData?.description ?? ''}
status={(sisterInfo?.status ?? 'unknown') as Status}
lastSeen={sisterInfo?.lastSeen ?? null}
lxcId={sisterInfo?.lxcId ?? 0}
/>
<Card>
<TabNav tabs={TABS} active={tab} onChange={setTab} />
{tab === 'overview' && (
<>
{sisterInfo?.currentTask && (
<CurrentTaskBox>📌 : {sisterInfo.currentTask}</CurrentTaskBox>
)}
<SectionTitle> </SectionTitle>
<ActivityFeed items={activity.slice(0, 5)} />
</>
)}
{tab === 'config' && (
<>
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle>
<ConfigViewer content={configData?.raw ?? ''} />
</>
)}
{tab === 'sessions' && (
<>
<SectionTitle> </SectionTitle>
<SessionList sessions={sessions} />
</>
)}
{tab === 'subagents' && (
<>
<SectionTitle> </SectionTitle>
<SubagentList agents={subagents} />
</>
)}
</Card>
</>
)}
</Page>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import StatusBadge from '@/components/common/StatusBadge';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config';
const sisterEmojis: Record<string, string> = {
harang: '🦊', narang: '🦊', darang: '🐱', erang: '🐺',
};
const sisterDisplayNames: Record<string, string> = {
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
};
const Page = styled.div`
padding: 32px;
min-height: 100vh;
background: ${theme.colors.bg};
`;
const PageTitle = styled.h1`
font-size: 22px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 6px;
`;
const PageSubtitle = styled.p`
font-size: 14px;
color: ${theme.colors.textSecondary};
margin-bottom: 28px;
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
`;
const Card = styled(Link)`
display: block;
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 14px;
padding: 24px;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
text-decoration: none;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 28px rgba(0,0,0,0.4);
border-color: rgba(88,166,255,0.3);
}
`;
const CardTop = styled.div`
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
`;
const Avatar = styled.div`
font-size: 36px;
line-height: 1;
`;
const NameBlock = styled.div``;
const Name = styled.div`
font-size: 18px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 4px;
`;
const Role = styled.div`
font-size: 12px;
color: ${theme.colors.textSecondary};
`;
const Divider = styled.div`
height: 1px;
background: ${theme.colors.border};
margin: 12px 0;
`;
const MetaItem = styled.div`
font-size: 12px;
color: ${theme.colors.textSecondary};
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
`;
const TaskBox = styled.div`
margin-top: 10px;
padding: 8px 12px;
background: rgba(88,166,255,0.06);
border: 1px solid rgba(88,166,255,0.15);
border-radius: 6px;
font-size: 12px;
color: ${theme.colors.accent};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
function formatLastSeen(lastSeen: string | null): string {
if (!lastSeen) return '기록 없음';
const diff = Date.now() - new Date(lastSeen).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return '방금 전';
if (min < 60) return `${min}분 전`;
const hr = Math.floor(min / 60);
return hr < 24 ? `${hr}시간 전` : `${Math.floor(hr / 24)}일 전`;
}
export default function SistersListPage() {
const [sisters, setSisters] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
try {
const res = await fetch(`${API_URL}/api/sisters`);
if (res.ok) setSisters(await res.json());
} catch {
// silent
} finally {
setLoading(false);
}
};
load();
const interval = setInterval(load, 30000);
return () => clearInterval(interval);
}, []);
return (
<Page>
<PageTitle></PageTitle>
<PageSubtitle> 4 . .</PageSubtitle>
{loading ? (
<div style={{ color: theme.colors.textSecondary, fontSize: '14px' }}> ...</div>
) : (
<Grid>
{sisters.map((s) => (
<Card key={s.id} href={`/sisters/${s.name}`}>
<CardTop>
<Avatar>{sisterEmojis[s.name] ?? '🤖'}</Avatar>
<NameBlock>
<Name>{sisterDisplayNames[s.name] ?? s.name}</Name>
<Role>{s.role}</Role>
</NameBlock>
</CardTop>
<StatusBadge status={s.status} />
<Divider />
<MetaItem>🕐 : {formatLastSeen(s.lastSeen)}</MetaItem>
<MetaItem>🖥 LXC {s.lxcId}</MetaItem>
{s.currentTask && <TaskBox>📌 {s.currentTask}</TaskBox>}
</Card>
))}
</Grid>
)}
</Page>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import React from 'react';
import styled from 'styled-components';
const Textarea = styled.textarea`
width: 100%;
min-height: 400px;
background: #0a0e14;
border: 1px solid #1e2430;
border-radius: 8px;
color: #a8c4e0;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 13px;
line-height: 1.6;
padding: 16px;
resize: vertical;
outline: none;
tab-size: 2;
&:focus {
border-color: #58A6FF;
}
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #1e2430; border-radius: 2px; }
`;
interface CodeEditorProps {
value: string;
onChange: (v: string) => void;
readOnly?: boolean;
}
export default function CodeEditor({ value, onChange, readOnly }: CodeEditorProps) {
return (
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
readOnly={readOnly}
spellCheck={false}
/>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
interface ConfirmModalProps {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
const Overlay = styled.div`
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
`;
const Modal = styled.div`
background: #161b22;
border: 1px solid ${theme.colors.border};
border-radius: 12px;
padding: 28px;
min-width: 320px;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
`;
const Title = styled.h2`
font-size: 16px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 10px;
`;
const Message = styled.p`
font-size: 14px;
color: ${theme.colors.textSecondary};
line-height: 1.6;
margin-bottom: 24px;
`;
const Buttons = styled.div`
display: flex;
gap: 10px;
justify-content: flex-end;
`;
const Btn = styled.button<{ $danger?: boolean; $primary?: boolean }>`
padding: 8px 18px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: 1px solid;
transition: all 0.15s;
${({ $danger }) =>
$danger &&
`
background: rgba(255,23,68,0.12);
border-color: rgba(255,23,68,0.4);
color: #FF1744;
&:hover { background: rgba(255,23,68,0.2); }
`}
${({ $primary }) =>
$primary &&
`
background: rgba(88,166,255,0.12);
border-color: rgba(88,166,255,0.4);
color: #58A6FF;
&:hover { background: rgba(88,166,255,0.2); }
`}
${({ $danger, $primary }) =>
!$danger && !$primary &&
`
background: transparent;
border-color: rgba(240,246,252,0.15);
color: #8B949E;
&:hover { background: rgba(240,246,252,0.06); color: #E6EDF3; }
`}
`;
export default function ConfirmModal({
title,
message,
confirmLabel = '확인',
danger,
onConfirm,
onCancel,
}: ConfirmModalProps) {
return (
<Overlay onClick={onCancel}>
<Modal onClick={(e) => e.stopPropagation()}>
<Title>{title}</Title>
<Message>{message}</Message>
<Buttons>
<Btn onClick={onCancel}></Btn>
<Btn $danger={danger} $primary={!danger} onClick={onConfirm}>
{confirmLabel}
</Btn>
</Buttons>
</Modal>
</Overlay>
);
}

View File

@@ -0,0 +1,120 @@
'use client';
import React, { useRef, useEffect, useState } from 'react';
import styled from 'styled-components';
const Wrapper = styled.div`
background: #0a0e14;
border: 1px solid #1e2430;
border-radius: 8px;
overflow: hidden;
`;
const Toolbar = styled.div`
background: #0d1117;
border-bottom: 1px solid #1e2430;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 12px;
`;
const SearchInput = styled.input`
background: rgba(255,255,255,0.05);
border: 1px solid #1e2430;
border-radius: 5px;
color: #8B949E;
font-size: 12px;
padding: 4px 10px;
outline: none;
width: 200px;
&:focus { border-color: #58A6FF; color: #E6EDF3; }
`;
const FilterBtn = styled.button<{ $active: boolean }>`
padding: 3px 10px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
border: 1px solid ${({ $active }) => $active ? 'rgba(255,23,68,0.5)' : '#1e2430'};
background: ${({ $active }) => $active ? 'rgba(255,23,68,0.1)' : 'transparent'};
color: ${({ $active }) => $active ? '#FF1744' : '#8B949E'};
transition: all 0.15s;
`;
const Terminal = styled.div`
height: 420px;
overflow-y: auto;
padding: 12px;
font-family: 'Fira Code', 'Cascadia Code', monospace;
font-size: 12px;
line-height: 1.7;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #1e2430; border-radius: 2px; }
`;
const LogLine = styled.div<{ $isError: boolean }>`
color: ${({ $isError }) => $isError ? '#FF1744' : '#a8c4e0'};
white-space: pre-wrap;
word-break: break-all;
&:hover { background: rgba(255,255,255,0.03); }
`;
const LineNum = styled.span`
color: #3d4f5a;
margin-right: 12px;
user-select: none;
min-width: 32px;
display: inline-block;
text-align: right;
`;
interface LogTerminalProps {
lines: string[];
}
export default function LogTerminal({ lines }: LogTerminalProps) {
const [search, setSearch] = useState('');
const [errorsOnly, setErrorsOnly] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [lines]);
const filtered = lines.filter((line) => {
if (errorsOnly && !/error|fail|exception|warn/i.test(line)) return false;
if (search && !line.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
return (
<Wrapper>
<Toolbar>
<SearchInput
placeholder="검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<FilterBtn $active={errorsOnly} onClick={() => setErrorsOnly(!errorsOnly)}>
🔴
</FilterBtn>
<span style={{ fontSize: '11px', color: '#3d4f5a', marginLeft: 'auto' }}>
{filtered.length}/{lines.length} lines
</span>
</Toolbar>
<Terminal>
{filtered.map((line, i) => (
<LogLine key={i} $isError={/error|fail|exception/i.test(line)}>
<LineNum>{i + 1}</LineNum>
{line}
</LogLine>
))}
<div ref={bottomRef} />
</Terminal>
</Wrapper>
);
}

View File

@@ -2,6 +2,7 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link';
import StatusBadge from '../common/StatusBadge'; import StatusBadge from '../common/StatusBadge';
import { theme } from '@/styles/theme'; import { theme } from '@/styles/theme';
@@ -36,7 +37,7 @@ const sisterDisplayNames: Record<string, string> = {
erang: '이랑이', erang: '이랑이',
}; };
const Card = styled.div<{ $status: Status }>` const Card = styled(Link)<{ $status: Status }>`
background: rgba(22, 27, 34, 0.8); background: rgba(22, 27, 34, 0.8);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
@@ -132,7 +133,7 @@ export default function SisterCard({
currentTask, currentTask,
}: SisterCardProps) { }: SisterCardProps) {
return ( return (
<Card $status={status}> <Card href={`/sisters/${name}`} $status={status}>
<CardHeader> <CardHeader>
<Emoji>{sisterEmojis[name] ?? '🤖'}</Emoji> <Emoji>{sisterEmojis[name] ?? '🤖'}</Emoji>
<NameBlock> <NameBlock>

View File

@@ -0,0 +1,287 @@
'use client';
import React, { useState } from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import StatusBadge from '../common/StatusBadge';
import { theme } from '@/styles/theme';
type Status = 'online' | 'offline' | 'working' | 'unknown';
interface OrgNodeData {
id?: number;
name: string;
role: string;
description?: string;
status?: Status;
lastSeen?: string | null;
lxcId?: number;
isOwner?: boolean;
}
interface PopupState {
node: OrgNodeData;
x: number;
y: number;
}
const sisterEmojis: Record<string, string> = {
harang: '🦊', narang: '🦊', darang: '🐱', erang: '🐺', : '👤',
};
const sisterDisplayNames: Record<string, string> = {
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
};
const statusColors: Record<Status, string> = {
online: theme.colors.online,
offline: theme.colors.offline,
working: theme.colors.working,
unknown: theme.colors.textSecondary,
};
// ─── 조직도 레이아웃 ───
const Tree = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 20px 0;
position: relative;
overflow-x: auto;
`;
const Level = styled.div`
display: flex;
justify-content: center;
gap: 24px;
position: relative;
`;
const ConnectorV = styled.div`
width: 2px;
height: 32px;
background: linear-gradient(to bottom, ${theme.colors.border}, rgba(88,166,255,0.4));
margin: 0 auto;
`;
const ConnectorH = styled.div`
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
height: 2px;
background: rgba(88,166,255,0.3);
width: calc(100% - 100px);
`;
const BranchWrap = styled.div`
position: relative;
display: flex;
gap: 24px;
align-items: flex-start;
`;
const NodeCard = styled.div<{ $status?: Status; $isOwner?: boolean }>`
background: ${theme.colors.cardBg};
backdrop-filter: blur(12px);
border: 1px solid ${({ $status }) =>
$status ? `${statusColors[$status]}66` : theme.colors.border};
border-radius: 12px;
padding: ${({ $isOwner }) => $isOwner ? '14px 24px' : '12px 18px'};
min-width: ${({ $isOwner }) => $isOwner ? '140px' : '130px'};
text-align: center;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
position: relative;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
border-color: ${({ $status }) => $status ? statusColors[$status] : theme.colors.accent};
}
`;
const NodeEmoji = styled.div`
font-size: 22px;
margin-bottom: 4px;
`;
const NodeName = styled.div`
font-size: 14px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 2px;
`;
const NodeRole = styled.div`
font-size: 11px;
color: ${theme.colors.textSecondary};
`;
const StatusDot = styled.div<{ $status: Status }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $status }) => statusColors[$status]};
position: absolute;
top: 8px;
right: 8px;
${({ $status }) => $status === 'online' && `
box-shadow: 0 0 6px ${theme.colors.online};
`}
`;
const PipelineLabel = styled.div`
font-size: 10px;
color: rgba(88,166,255,0.6);
text-align: center;
margin-top: 2px;
`;
// Popup
const Popup = styled.div<{ $x: number; $y: number }>`
position: fixed;
left: ${({ $x }) => $x}px;
top: ${({ $y }) => $y}px;
z-index: 200;
background: rgba(22,27,34,0.97);
border: 1px solid ${theme.colors.border};
border-radius: 10px;
padding: 14px 16px;
min-width: 200px;
max-width: 260px;
pointer-events: none;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
`;
const PopupName = styled.div`
font-size: 14px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 4px;
`;
const PopupRole = styled.div`
font-size: 12px;
color: ${theme.colors.accent};
margin-bottom: 8px;
`;
const PopupDesc = styled.div`
font-size: 12px;
color: ${theme.colors.textSecondary};
line-height: 1.5;
`;
function formatLastSeen(lastSeen: string | null | undefined): string {
if (!lastSeen) return '기록 없음';
const diff = Date.now() - new Date(lastSeen).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return '방금 전';
if (min < 60) return `${min}분 전`;
return `${Math.floor(min / 60)}시간 전`;
}
interface OrgTreeProps {
sisters: OrgNodeData[];
}
export default function OrgTree({ sisters }: OrgTreeProps) {
const [popup, setPopup] = useState<PopupState | null>(null);
const harang = sisters.find((s) => s.name === 'harang');
const narang = sisters.find((s) => s.name === 'narang');
const darang = sisters.find((s) => s.name === 'darang');
const erang = sisters.find((s) => s.name === 'erang');
const handleHover = (node: OrgNodeData, e: React.MouseEvent) => {
const rect = (e.target as HTMLElement).getBoundingClientRect();
setPopup({ node, x: rect.right + 10, y: rect.top });
};
const renderNode = (node: OrgNodeData | undefined, isOwner = false) => {
if (!node) return null;
const displayName = sisterDisplayNames[node.name] ?? node.name;
const status = node.status ?? 'unknown';
const isClickable = !isOwner;
return (
<NodeCard
as={isClickable ? Link : 'div'}
{...(isClickable ? { href: `/sisters/${node.name}` } : {})}
$status={isOwner ? undefined : status as Status}
$isOwner={isOwner}
onMouseEnter={(e: React.MouseEvent) => handleHover(node, e)}
onMouseLeave={() => setPopup(null)}
>
{!isOwner && <StatusDot $status={status as Status} />}
<NodeEmoji>{sisterEmojis[node.name] ?? '🤖'}</NodeEmoji>
<NodeName>{displayName}</NodeName>
<NodeRole>{node.role}</NodeRole>
</NodeCard>
);
};
return (
<>
<Tree>
{/* 자기야 */}
<Level>
{renderNode({ name: '자기야', role: 'Owner', isOwner: true }, true)}
</Level>
<ConnectorV />
{/* 하랑이 */}
<Level>
{renderNode(harang)}
</Level>
{/* 파이프라인 라벨 */}
<div style={{ display: 'flex', gap: '80px', marginTop: '4px' }}>
<PipelineLabel> </PipelineLabel>
<PipelineLabel> </PipelineLabel>
</div>
<ConnectorV />
{/* 나랑이 / 다랑이 / 이랑이 */}
<div style={{ position: 'relative' }}>
<Level>
{renderNode(narang)}
{renderNode(darang)}
{renderNode(erang)}
</Level>
<ConnectorH />
</div>
{/* 파이프라인 설명 */}
<div style={{ display: 'flex', gap: '16px', marginTop: '12px', justifyContent: 'center' }}>
<PipelineLabel>Generator</PipelineLabel>
<PipelineLabel> / </PipelineLabel>
<PipelineLabel>Evaluator</PipelineLabel>
<PipelineLabel style={{ marginLeft: '8px' }}>Infra</PipelineLabel>
</div>
</Tree>
{popup && (
<Popup $x={popup.x} $y={popup.y}>
<PopupName>
{sisterEmojis[popup.node.name] ?? '🤖'}{' '}
{sisterDisplayNames[popup.node.name] ?? popup.node.name}
</PopupName>
<PopupRole>{popup.node.role}</PopupRole>
{popup.node.description && (
<PopupDesc>{popup.node.description}</PopupDesc>
)}
{!popup.node.isOwner && (
<PopupDesc style={{ marginTop: '8px' }}>
: {popup.node.status ?? 'unknown'}<br />
: {formatLastSeen(popup.node.lastSeen)}
</PopupDesc>
)}
</Popup>
)}
</>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
const Pre = styled.pre`
background: rgba(0, 0, 0, 0.3);
border: 1px solid ${theme.colors.border};
border-radius: 8px;
padding: 16px;
font-size: 12px;
line-height: 1.6;
color: #a8c4e0;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 480px;
overflow-y: auto;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: rgba(240,246,252,0.15); border-radius: 2px; }
`;
const Empty = styled.div`
padding: 32px;
text-align: center;
color: ${theme.colors.textSecondary};
font-size: 13px;
`;
interface ConfigViewerProps {
content: string;
}
export default function ConfigViewer({ content }: ConfigViewerProps) {
if (!content || content.trim() === '') return <Empty> </Empty>;
return <Pre>{content}</Pre>;
}

View File

@@ -0,0 +1,77 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
interface Session {
name: string;
modified: string;
size: string;
}
const Table = styled.table`
width: 100%;
border-collapse: collapse;
`;
const Th = styled.th`
text-align: left;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: ${theme.colors.textSecondary};
border-bottom: 1px solid ${theme.colors.border};
text-transform: uppercase;
letter-spacing: 0.05em;
`;
const Td = styled.td`
padding: 10px 12px;
font-size: 13px;
color: ${theme.colors.textPrimary};
border-bottom: 1px solid ${theme.colors.border};
`;
const Tr = styled.tr`
&:last-child td { border-bottom: none; }
&:hover td { background: rgba(240,246,252,0.03); }
`;
const SessionName = styled.code`
color: ${theme.colors.accent};
font-family: monospace;
font-size: 12px;
`;
const Empty = styled.div`
padding: 32px;
text-align: center;
color: ${theme.colors.textSecondary};
font-size: 13px;
`;
export default function SessionList({ sessions }: { sessions: Session[] }) {
if (!sessions.length) return <Empty> </Empty>;
return (
<Table>
<thead>
<tr>
<Th> ID</Th>
<Th> </Th>
<Th></Th>
</tr>
</thead>
<tbody>
{sessions.map((s, i) => (
<Tr key={i}>
<Td><SessionName>{s.name}</SessionName></Td>
<Td>{s.modified}</Td>
<Td>{s.size}B</Td>
</Tr>
))}
</tbody>
</Table>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import StatusBadge from '../common/StatusBadge';
import { theme } from '@/styles/theme';
type Status = 'online' | 'offline' | 'working' | 'unknown';
interface SisterHeaderProps {
name: string;
role: string;
description: string;
status: Status;
lastSeen: string | null;
lxcId: number;
}
const sisterEmojis: Record<string, string> = {
harang: '🦊', narang: '🦊', darang: '🐱', erang: '🐺',
};
const sisterDisplayNames: Record<string, string> = {
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
};
const Wrapper = styled.div`
margin-bottom: 28px;
`;
const Top = styled.div`
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
`;
const Avatar = styled.div<{ $status: Status }>`
width: 56px;
height: 56px;
border-radius: 50%;
background: ${theme.colors.cardBg};
border: 2px solid ${({ $status }) =>
$status === 'online' ? theme.colors.online :
$status === 'working' ? theme.colors.working :
$status === 'offline' ? theme.colors.offline :
theme.colors.border};
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
flex-shrink: 0;
`;
const Info = styled.div`
flex: 1;
`;
const Name = styled.h1`
font-size: 20px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 10px;
`;
const Role = styled.span`
font-size: 13px;
color: ${theme.colors.textSecondary};
display: block;
margin-bottom: 6px;
`;
const Description = styled.p`
font-size: 13px;
color: ${theme.colors.textSecondary};
line-height: 1.6;
max-width: 600px;
`;
const MetaRow = styled.div`
display: flex;
gap: 16px;
margin-top: 10px;
flex-wrap: wrap;
`;
const MetaItem = styled.span`
font-size: 12px;
color: ${theme.colors.textSecondary};
display: flex;
align-items: center;
gap: 4px;
`;
function formatLastSeen(lastSeen: string | null): string {
if (!lastSeen) return '기록 없음';
const diff = Date.now() - new Date(lastSeen).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return '방금 전';
if (min < 60) return `${min}분 전`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}시간 전`;
return `${Math.floor(hr / 24)}일 전`;
}
export default function SisterHeader({ name, role, description, status, lastSeen, lxcId }: SisterHeaderProps) {
return (
<Wrapper>
<Top>
<Avatar $status={status}>
{sisterEmojis[name] ?? '🤖'}
</Avatar>
<Info>
<Name>
{sisterDisplayNames[name] ?? name}
<StatusBadge status={status} />
</Name>
<Role>{role}</Role>
</Info>
</Top>
<Description>{description}</Description>
<MetaRow>
<MetaItem>🖥 LXC {lxcId}</MetaItem>
<MetaItem>🕐 : {formatLastSeen(lastSeen)}</MetaItem>
</MetaRow>
</Wrapper>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
interface Subagent {
name: string;
file: string;
}
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
`;
const Card = styled.div`
background: rgba(0,0,0,0.2);
border: 1px solid ${theme.colors.border};
border-radius: 8px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
`;
const Icon = styled.span`
font-size: 20px;
`;
const Info = styled.div``;
const Name = styled.div`
font-size: 13px;
font-weight: 600;
color: ${theme.colors.textPrimary};
margin-bottom: 2px;
`;
const File = styled.div`
font-size: 11px;
color: ${theme.colors.textSecondary};
font-family: monospace;
`;
const Empty = styled.div`
padding: 32px;
text-align: center;
color: ${theme.colors.textSecondary};
font-size: 13px;
`;
const agentIcons: Record<string, string> = {
coder: '👨‍💻',
'test-writer': '🧪',
'db-designer': '🗄️',
'code-reviewer': '🔍',
'security-auditor': '🔒',
'nginx-manager': '🌐',
};
export default function SubagentList({ agents }: { agents: Subagent[] }) {
if (!agents.length) return <Empty> </Empty>;
return (
<Grid>
{agents.map((a) => (
<Card key={a.name}>
<Icon>{agentIcons[a.name] ?? '🤖'}</Icon>
<Info>
<Name>{a.name}</Name>
<File>{a.file}</File>
</Info>
</Card>
))}
</Grid>
);
}

View File

@@ -0,0 +1,22 @@
/**
* 관리자 API 호출 유틸.
* API_URL이 빈 문자열이면 상대경로 → Next.js rewrites → BE 프록시.
* Authorization 헤더에 localStorage의 admin key 삽입.
*/
export function adminFetch(path: string, options?: RequestInit) {
const apiKey =
typeof window !== 'undefined'
? (localStorage.getItem('hanarang_admin_key') ?? '')
: '';
// CSR: path가 /api/... 이면 상대경로 그대로 사용
// SSR에서는 adminFetch 미사용 (모든 admin 페이지가 'use client')
return fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...(options?.headers ?? {}),
},
});
}

16
frontend/lib/config.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* API_URL: 프론트엔드에서 API 호출 시 사용하는 기본 URL.
*
* - 브라우저(CSR): 빈 문자열 → `/api/...` 상대경로 → Next.js rewrites → BE로 프록시
* - 서버사이드(SSR): 서버에서 직접 BE 호출 필요. API_URL_SERVER 사용.
* - Next.js rewrites는 브라우저 요청만 처리하므로 SSR에서는 절대 URL 필요.
*/
export const API_URL =
typeof window === 'undefined'
? (process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005')
: '';
export const POLL_INTERVAL_MS = parseInt(
process.env.NEXT_PUBLIC_POLL_INTERVAL_MS ?? '30000',
10,
);

32
frontend/lib/sisters.ts Normal file
View File

@@ -0,0 +1,32 @@
export const SISTER_EMOJIS: Record<string, string> = {
harang: '🦊',
narang: '🦊',
darang: '🐱',
erang: '🐺',
};
export const SISTER_DISPLAY_NAMES: Record<string, string> = {
harang: '하랑이',
narang: '나랑이',
darang: '다랑이',
erang: '이랑이',
};
export const SISTER_ROLES: Record<string, string> = {
harang: 'Orchestrator',
narang: 'Generator',
darang: 'Evaluator',
erang: 'Infra Manager',
};
export function formatLastSeen(lastSeen: string | Date | null | undefined): string {
if (!lastSeen) return '기록 없음';
const d = typeof lastSeen === 'string' ? new Date(lastSeen) : lastSeen;
const diff = Date.now() - d.getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return '방금 전';
if (min < 60) return `${min}분 전`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}시간 전`;
return `${Math.floor(hr / 24)}일 전`;
}

View File

@@ -1,9 +1,19 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? process.env.API_URL ?? 'http://localhost:3005';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
compiler: { compiler: {
styledComponents: true, styledComponents: true,
}, },
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${apiUrl}/api/:path*`,
},
];
},
}; };
export default nextConfig; export default nextConfig;