Compare commits

..

3 Commits

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

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

Sprint 004 본문:
- TASK-012: POST /api/admin/sisters/:name/{restart,reset} (ApiKeyGuard 적용)
- TASK-013: GET/PUT /api/admin/harness/:name/:file (허용 파일 allowlist)
- TASK-015: GET /api/admin/logs/:name
- 관리자 전 API @UseGuards(ApiKeyGuard)
- TASK-014: /admin 자매 관리 (재시작/리셋 + ConfirmModal)
- TASK-014: /admin/harness 하네스 편집 (CodeEditor + 파일트리)
- TASK-015: /admin/logs 로그 뷰어 (LogTerminal + 검색/에러필터)
- /admin/repos 저장소 목록
- AdminLayout (API Key 로컬스토리지 저장)
- 테스트 22/22 pass, FE 11 routes build 성공
2026-04-04 12:11:06 +09:00
nabomhalang
4e5a077d40 Merge feature/sprint-003 into main (SPRINT-003) 2026-04-04 03:02:36 +00:00
26 changed files with 1661 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ 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 { OrgModule } from './org/org.module';
import { AuthModule } from './auth/auth.module';
import { AdminModule } from './admin/admin.module';
@Module({ @Module({
imports: [ imports: [
@@ -22,6 +24,8 @@ import { OrgModule } from './org/org.module';
TasksModule, TasksModule,
ActivityModule, ActivityModule,
OrgModule, OrgModule,
AuthModule,
AdminModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
import { Controller, Get, Param } 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 { SisterDetailService } from './sister-detail.service';
import { SisterNamePipe } from '../common/sister-name.pipe';
const VALID_NAMES = ['harang', 'narang', 'darang', 'erang'] as const; import type { SisterName } from '../common/sister-name.pipe';
type SisterName = (typeof VALID_NAMES)[number];
@Controller('api/sisters') @Controller('api/sisters')
export class SistersController { export class SistersController {
@@ -18,22 +17,22 @@ export class SistersController {
} }
@Get(':name/config') @Get(':name/config')
async getSisterConfig(@Param('name') name: string) { async getSisterConfig(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterConfig(name as SisterName); return this.sisterDetail.getSisterConfig(name as SisterName);
} }
@Get(':name/sessions') @Get(':name/sessions')
async getSisterSessions(@Param('name') name: string) { async getSisterSessions(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterSessions(name as SisterName); return this.sisterDetail.getSisterSessions(name as SisterName);
} }
@Get(':name/subagents') @Get(':name/subagents')
async getSisterSubagents(@Param('name') name: string) { async getSisterSubagents(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterSubagents(name as SisterName); return this.sisterDetail.getSisterSubagents(name as SisterName);
} }
@Get(':name/activity') @Get(':name/activity')
async getSisterActivity(@Param('name') name: string) { async getSisterActivity(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterActivityLog(name as SisterName); return this.sisterDetail.getSisterActivityLog(name as SisterName);
} }
} }

View File

@@ -9,6 +9,6 @@ import { PrismaModule } from '../prisma/prisma.module';
imports: [PrismaModule], imports: [PrismaModule],
controllers: [SistersController], controllers: [SistersController],
providers: [SistersService, SisterDetailService, SshService], providers: [SistersService, SisterDetailService, SshService],
exports: [SisterDetailService], exports: [SisterDetailService, SshService],
}) })
export class SistersModule {} export class SistersModule {}

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,15 @@
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005'; /**
* 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( export const POLL_INTERVAL_MS = parseInt(
process.env.NEXT_PUBLIC_POLL_INTERVAL_MS ?? '30000', process.env.NEXT_PUBLIC_POLL_INTERVAL_MS ?? '30000',
10, 10,

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

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

View File

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