Compare commits
6 Commits
feature/sp
...
feature/ho
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d8a1efa2a | |||
| 033c142c0e | |||
|
|
4e5a077d40 | ||
| 86751a4879 | |||
| 4015e5f22f | |||
|
|
dd6a2595f7 |
71
.qa/SPRINT-003-review-1.md
Normal file
71
.qa/SPRINT-003-review-1.md
Normal 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 구현 양호
|
||||||
168
backend/package-lock.json
generated
168
backend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
59
backend/src/admin/admin.controller.ts
Normal file
59
backend/src/admin/admin.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/admin/admin.module.ts
Normal file
14
backend/src/admin/admin.module.ts
Normal 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 {}
|
||||||
91
backend/src/admin/admin.service.spec.ts
Normal file
91
backend/src/admin/admin.service.spec.ts
Normal 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 연결 불가)');
|
||||||
|
});
|
||||||
|
});
|
||||||
175
backend/src/admin/admin.service.ts
Normal file
175
backend/src/admin/admin.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
|||||||
5
backend/src/auth/api-key.guard.ts
Normal file
5
backend/src/auth/api-key.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeyGuard extends AuthGuard('api-key') {}
|
||||||
30
backend/src/auth/api-key.strategy.ts
Normal file
30
backend/src/auth/api-key.strategy.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/auth/auth.module.ts
Normal file
12
backend/src/auth/auth.module.ts
Normal 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 {}
|
||||||
16
backend/src/common/sister-name.pipe.ts
Normal file
16
backend/src/common/sister-name.pipe.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
12
backend/src/org/org.controller.ts
Normal file
12
backend/src/org/org.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/org/org.module.ts
Normal file
9
backend/src/org/org.module.ts
Normal 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 {}
|
||||||
90
backend/src/sisters/sister-detail.service.spec.ts
Normal file
90
backend/src/sisters/sister-detail.service.spec.ts
Normal 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 } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
153
backend/src/sisters/sister-detail.service.ts
Normal file
153
backend/src/sisters/sister-detail.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
201
frontend/app/admin/harness/page.tsx
Normal file
201
frontend/app/admin/harness/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
frontend/app/admin/layout.tsx
Normal file
128
frontend/app/admin/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/app/admin/logs/page.tsx
Normal file
111
frontend/app/admin/logs/page.tsx
Normal 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
175
frontend/app/admin/page.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
frontend/app/admin/repos/page.tsx
Normal file
102
frontend/app/admin/repos/page.tsx
Normal 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
103
frontend/app/org/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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: '📁' },
|
||||||
|
|||||||
160
frontend/app/settings/page.tsx
Normal file
160
frontend/app/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
frontend/app/sisters/[name]/page.tsx
Normal file
195
frontend/app/sisters/[name]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
frontend/app/sisters/page.tsx
Normal file
172
frontend/app/sisters/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
frontend/components/admin/CodeEditor.tsx
Normal file
44
frontend/components/admin/CodeEditor.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
frontend/components/admin/ConfirmModal.tsx
Normal file
115
frontend/components/admin/ConfirmModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
frontend/components/admin/LogTerminal.tsx
Normal file
120
frontend/components/admin/LogTerminal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
287
frontend/components/org/OrgTree.tsx
Normal file
287
frontend/components/org/OrgTree.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/components/sisters/ConfigViewer.tsx
Normal file
40
frontend/components/sisters/ConfigViewer.tsx
Normal 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>;
|
||||||
|
}
|
||||||
77
frontend/components/sisters/SessionList.tsx
Normal file
77
frontend/components/sisters/SessionList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
frontend/components/sisters/SisterHeader.tsx
Normal file
131
frontend/components/sisters/SisterHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
frontend/components/sisters/SubagentList.tsx
Normal file
79
frontend/components/sisters/SubagentList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/lib/adminFetch.ts
Normal file
22
frontend/lib/adminFetch.ts
Normal 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
16
frontend/lib/config.ts
Normal 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
32
frontend/lib/sisters.ts
Normal 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)}일 전`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user