feat(sprint-005): cost monitor + WebSocket realtime
Sprint 004 non-blocking: - null byte 방지 (harness file write) Sprint 005 본문: - CostLog Prisma 모델 추가 - TASK-016: GET /api/admin/costs + POST /api/admin/costs/record/:name 자매별/모델별/일별 토큰 + 예상 비용 (USD), 기간 필터(day/week/month) - TASK-018: WebSocket Gateway (@WebSocketGateway /ws namespace) sisters:update, activity:new 브로드캐스트 EventsScheduler: 30초 주기 자매 상태 체크 + 브로드캐스트 ActivityService: 새 로그 생성 시 실시간 브로드캐스트 - TASK-017: /admin/costs 비용 대시보드 (BarChart + 요약 카드) - 메인 대시보드 useSocket 훅 + 실시간 연결 상태 표시 - 테스트 22/22 pass, FE 12 routes build 성공 - NEXT_PUBLIC_WS_URL env (.env.local.example 업데이트)
This commit is contained in:
267
backend/package-lock.json
generated
267
backend/package-lock.json
generated
@@ -14,6 +14,8 @@
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-socket.io": "^11.1.18",
|
||||
"@nestjs/websockets": "^11.1.18",
|
||||
"@prisma/adapter-mariadb": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"axios": "^1.14.0",
|
||||
@@ -27,7 +29,8 @@
|
||||
"passport-http-bearer": "^1.0.1",
|
||||
"prisma": "^7.6.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@@ -2316,6 +2319,25 @@
|
||||
"@nestjs/core": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-socket.io": {
|
||||
"version": "11.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.18.tgz",
|
||||
"integrity": "sha512-DiFRpMIdFaHqZQFwqLqGHMdNurrKVkRkMHxIrecjooPHJNNIMgrbpYZ+oJW8hpwifUyZUL4r4uXXRy4+yFEMjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"socket.io": "4.8.3",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/nest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.0",
|
||||
"rxjs": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "11.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.10.tgz",
|
||||
@@ -2455,6 +2477,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/websockets": {
|
||||
"version": "11.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.18.tgz",
|
||||
"integrity": "sha512-HLO/QGlJoJaMMDphgjbcIwW7/8EA8nnFQjf+qPvA+wWLLfPKoiFPUezc5m9YgN2A7jmzwo6YmEAXeHyqO9tvTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iterare": "1.2.1",
|
||||
"object-hash": "3.0.0",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-socket.io": "^11.0.0",
|
||||
"reflect-metadata": "^0.1.12 || ^0.2.0",
|
||||
"rxjs": "^7.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nestjs/platform-socket.io": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@@ -2936,6 +2981,12 @@
|
||||
"@sinonjs/commons": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -3107,6 +3158,15 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -3380,6 +3440,15 @@
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
@@ -4546,6 +4615,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64id": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
||||
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^4.5.0 || >= 5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.14",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz",
|
||||
@@ -5605,6 +5683,79 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.6",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz",
|
||||
"integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/node": ">=10.0.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "2.0.0",
|
||||
"cookie": "~0.7.2",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||
@@ -8748,6 +8899,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-hash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -9860,6 +10020,90 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
|
||||
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "~2.0.0",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io": "~6.6.0",
|
||||
"socket.io-adapter": "~2.5.2",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
||||
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
||||
@@ -11288,6 +11532,27 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-socket.io": "^11.1.18",
|
||||
"@nestjs/websockets": "^11.1.18",
|
||||
"@prisma/adapter-mariadb": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"axios": "^1.14.0",
|
||||
@@ -39,7 +41,8 @@
|
||||
"passport-http-bearer": "^1.0.1",
|
||||
"prisma": "^7.6.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.3"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
|
||||
@@ -78,3 +78,18 @@ model ActivityLog {
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model CostLog {
|
||||
id Int @id @default(autoincrement())
|
||||
sisterName String // harang, narang, darang, erang
|
||||
model String // model name
|
||||
inputTokens Int @default(0)
|
||||
outputTokens Int @default(0)
|
||||
totalTokens Int @default(0)
|
||||
estimatedUsd Float @default(0)
|
||||
sessionId String? @db.VarChar(128)
|
||||
recordedAt DateTime @default(now())
|
||||
|
||||
@@index([sisterName, recordedAt])
|
||||
@@index([recordedAt])
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { ActivityController } from './activity.controller';
|
||||
import { ActivityService } from './activity.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { EventsModule } from '../events/events.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, EventsModule],
|
||||
controllers: [ActivityController],
|
||||
providers: [ActivityService],
|
||||
exports: [ActivityService],
|
||||
|
||||
@@ -27,9 +27,11 @@ describe('ActivityService', () => {
|
||||
|
||||
it('log: activityLog.create 호출', async () => {
|
||||
await service.log({ action: 'test_action', detail: 'detail' });
|
||||
expect(mockPrisma.activityLog.create).toHaveBeenCalledWith({
|
||||
data: { action: 'test_action', detail: 'detail' },
|
||||
});
|
||||
expect(mockPrisma.activityLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { action: 'test_action', detail: 'detail' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('getFeed: items + total 반환', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EventsGateway } from '../events/events.gateway';
|
||||
|
||||
export interface LogActivityDto {
|
||||
sisterId?: number;
|
||||
@@ -10,10 +11,26 @@ export interface LogActivityDto {
|
||||
|
||||
@Injectable()
|
||||
export class ActivityService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Optional() private readonly events?: EventsGateway,
|
||||
) {}
|
||||
|
||||
async log(dto: LogActivityDto) {
|
||||
return this.prisma.activityLog.create({ data: dto });
|
||||
const record = await this.prisma.activityLog.create({
|
||||
data: dto,
|
||||
include: {
|
||||
sister: { select: { name: true } },
|
||||
project: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// 실시간 브로드캐스트 (gateway 사용 가능한 경우)
|
||||
if (this.events) {
|
||||
this.events.broadcastActivity(record);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async getFeed(limit = 50, offset = 0) {
|
||||
|
||||
@@ -101,8 +101,8 @@ export class AdminService {
|
||||
const sister = await this.getSister(name);
|
||||
const keyPath = this.getKeyPath();
|
||||
|
||||
// 내용에서 위험한 셸 escape 방지
|
||||
const escaped = content.replace(/'/g, "'\\''");
|
||||
// 내용에서 위험한 셸 escape + null byte 방지
|
||||
const escaped = content.replace(/\x00/g, '').replace(/'/g, "'\\''");
|
||||
|
||||
try {
|
||||
const result = await this.ssh.executeCommand(
|
||||
|
||||
@@ -12,6 +12,8 @@ import { ActivityModule } from './activity/activity.module';
|
||||
import { OrgModule } from './org/org.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { EventsModule } from './events/events.module';
|
||||
import { CostsModule } from './costs/costs.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,6 +28,8 @@ import { AdminModule } from './admin/admin.module';
|
||||
OrgModule,
|
||||
AuthModule,
|
||||
AdminModule,
|
||||
EventsModule,
|
||||
CostsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
23
backend/src/costs/costs.controller.ts
Normal file
23
backend/src/costs/costs.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Controller, Get, Post, Query, Param, UseGuards } from '@nestjs/common';
|
||||
import { CostsService, CostPeriod } from './costs.service';
|
||||
import { ApiKeyGuard } from '../auth/api-key.guard';
|
||||
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||
import type { SisterName } from '../common/sister-name.pipe';
|
||||
|
||||
@Controller('api/admin/costs')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
export class CostsController {
|
||||
constructor(private readonly costsService: CostsService) {}
|
||||
|
||||
@Get()
|
||||
getCosts(@Query('period') period?: string) {
|
||||
const validPeriods: CostPeriod[] = ['day', 'week', 'month'];
|
||||
const p = validPeriods.includes(period as CostPeriod) ? (period as CostPeriod) : 'week';
|
||||
return this.costsService.getCosts(p);
|
||||
}
|
||||
|
||||
@Post('record/:name')
|
||||
recordCosts(@Param('name', SisterNamePipe) name: SisterName) {
|
||||
return this.costsService.recordCosts(name);
|
||||
}
|
||||
}
|
||||
13
backend/src/costs/costs.module.ts
Normal file
13
backend/src/costs/costs.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CostsController } from './costs.controller';
|
||||
import { CostsService } from './costs.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SistersModule } from '../sisters/sisters.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, SistersModule, AuthModule],
|
||||
controllers: [CostsController],
|
||||
providers: [CostsService],
|
||||
})
|
||||
export class CostsModule {}
|
||||
178
backend/src/costs/costs.service.ts
Normal file
178
backend/src/costs/costs.service.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SshService } from '../sisters/ssh.service';
|
||||
import { SisterName } from '../common/sister-name.pipe';
|
||||
|
||||
// Claude Sonnet 기준 가격 (USD per 1M tokens)
|
||||
const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
||||
'claude-sonnet-4': { input: 3.0, output: 15.0 },
|
||||
'claude-sonnet-4.6': { input: 3.0, output: 15.0 },
|
||||
'claude-3-5-sonnet': { input: 3.0, output: 15.0 },
|
||||
'gpt-4o': { input: 2.5, output: 10.0 },
|
||||
'gpt-4o-mini': { input: 0.15, output: 0.6 },
|
||||
default: { input: 3.0, output: 15.0 },
|
||||
};
|
||||
|
||||
const SISTER_NAMES: SisterName[] = ['harang', 'narang', 'darang', 'erang'];
|
||||
|
||||
export type CostPeriod = 'day' | 'week' | 'month';
|
||||
|
||||
@Injectable()
|
||||
export class CostsService {
|
||||
private readonly logger = new Logger(CostsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ssh: SshService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async getCosts(period: CostPeriod = 'week') {
|
||||
const since = this.getSince(period);
|
||||
|
||||
const [summary, bySister, byModel, timeline] = await Promise.all([
|
||||
this.getSummary(since),
|
||||
this.getBySister(since),
|
||||
this.getByModel(since),
|
||||
this.getTimeline(since, period),
|
||||
]);
|
||||
|
||||
return { period, since: since.toISOString(), summary, bySister, byModel, timeline };
|
||||
}
|
||||
|
||||
async recordCosts(sisterName: SisterName) {
|
||||
const keyPath = this.config.get<string>('SSH_KEY_PATH');
|
||||
if (!keyPath) return;
|
||||
|
||||
const sister = await this.prisma.sisterConfig.findUnique({ where: { name: sisterName } });
|
||||
if (!sister) return;
|
||||
|
||||
try {
|
||||
// OpenClaw 세션에서 토큰 사용량 파싱
|
||||
const result = await this.ssh.executeCommand(
|
||||
sister.ip,
|
||||
sister.user,
|
||||
keyPath,
|
||||
`cat ~/.openclaw/sessions/main.json 2>/dev/null | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
usage=d.get('usage',{})
|
||||
print(d.get('model','unknown'), usage.get('input_tokens',0), usage.get('output_tokens',0))
|
||||
except: print('unknown 0 0')
|
||||
" 2>/dev/null || echo "unknown 0 0"`,
|
||||
);
|
||||
|
||||
const parts = result.stdout.trim().split(' ');
|
||||
if (parts.length < 3) return;
|
||||
|
||||
const [model, inputStr, outputStr] = parts;
|
||||
const inputTokens = parseInt(inputStr, 10) || 0;
|
||||
const outputTokens = parseInt(outputStr, 10) || 0;
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
|
||||
if (totalTokens === 0) return;
|
||||
|
||||
const pricing = MODEL_PRICING[model] ?? MODEL_PRICING.default;
|
||||
const estimatedUsd =
|
||||
(inputTokens / 1_000_000) * pricing.input +
|
||||
(outputTokens / 1_000_000) * pricing.output;
|
||||
|
||||
await this.prisma.costLog.create({
|
||||
data: {
|
||||
sisterName,
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
estimatedUsd,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
this.logger.warn(`Cost recording failed for ${sisterName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getSummary(since: Date) {
|
||||
const result = await this.prisma.costLog.aggregate({
|
||||
where: { recordedAt: { gte: since } },
|
||||
_sum: { inputTokens: true, outputTokens: true, totalTokens: true, estimatedUsd: true },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
return {
|
||||
totalTokens: result._sum.totalTokens ?? 0,
|
||||
inputTokens: result._sum.inputTokens ?? 0,
|
||||
outputTokens: result._sum.outputTokens ?? 0,
|
||||
estimatedUsd: Math.round((result._sum.estimatedUsd ?? 0) * 10000) / 10000,
|
||||
recordCount: result._count,
|
||||
};
|
||||
}
|
||||
|
||||
private async getBySister(since: Date) {
|
||||
const rows = await this.prisma.costLog.groupBy({
|
||||
by: ['sisterName'],
|
||||
where: { recordedAt: { gte: since } },
|
||||
_sum: { totalTokens: true, estimatedUsd: true },
|
||||
orderBy: { _sum: { estimatedUsd: 'desc' } },
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
name: r.sisterName,
|
||||
totalTokens: r._sum.totalTokens ?? 0,
|
||||
estimatedUsd: Math.round((r._sum.estimatedUsd ?? 0) * 10000) / 10000,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getByModel(since: Date) {
|
||||
const rows = await this.prisma.costLog.groupBy({
|
||||
by: ['model'],
|
||||
where: { recordedAt: { gte: since } },
|
||||
_sum: { totalTokens: true, estimatedUsd: true },
|
||||
_count: true,
|
||||
orderBy: { _sum: { estimatedUsd: 'desc' } },
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
model: r.model,
|
||||
totalTokens: r._sum.totalTokens ?? 0,
|
||||
estimatedUsd: Math.round((r._sum.estimatedUsd ?? 0) * 10000) / 10000,
|
||||
count: r._count,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getTimeline(since: Date, period: CostPeriod) {
|
||||
const logs = await this.prisma.costLog.findMany({
|
||||
where: { recordedAt: { gte: since } },
|
||||
orderBy: { recordedAt: 'asc' },
|
||||
select: { sisterName: true, totalTokens: true, estimatedUsd: true, recordedAt: true },
|
||||
});
|
||||
|
||||
// 날짜별 집계
|
||||
const grouped: Record<string, { date: string; totalTokens: number; estimatedUsd: number }> = {};
|
||||
|
||||
for (const log of logs) {
|
||||
const dateKey = log.recordedAt.toISOString().slice(0, 10);
|
||||
if (!grouped[dateKey]) {
|
||||
grouped[dateKey] = { date: dateKey, totalTokens: 0, estimatedUsd: 0 };
|
||||
}
|
||||
grouped[dateKey].totalTokens += log.totalTokens;
|
||||
grouped[dateKey].estimatedUsd += log.estimatedUsd;
|
||||
}
|
||||
|
||||
return Object.values(grouped).map((d) => ({
|
||||
...d,
|
||||
estimatedUsd: Math.round(d.estimatedUsd * 10000) / 10000,
|
||||
}));
|
||||
}
|
||||
|
||||
private getSince(period: CostPeriod): Date {
|
||||
const now = new Date();
|
||||
switch (period) {
|
||||
case 'day': return new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
case 'week': return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
case 'month': return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
backend/src/events/events.gateway.ts
Normal file
60
backend/src/events/events.gateway.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
OnGatewayInit,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: (process.env.CORS_ORIGINS ?? 'http://localhost:3004')
|
||||
.split(',')
|
||||
.map((o) => o.trim()),
|
||||
credentials: true,
|
||||
},
|
||||
namespace: '/ws',
|
||||
})
|
||||
export class EventsGateway
|
||||
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||
{
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
private readonly logger = new Logger(EventsGateway.name);
|
||||
|
||||
afterInit() {
|
||||
this.logger.log('WebSocket Gateway initialized');
|
||||
}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
this.logger.debug(`Client connected: ${client.id}`);
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
this.logger.debug(`Client disconnected: ${client.id}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('ping')
|
||||
handlePing(client: Socket) {
|
||||
client.emit('pong', { ts: Date.now() });
|
||||
}
|
||||
|
||||
/** 자매 상태 전체 브로드캐스트 */
|
||||
broadcastSisterStatus(sisters: unknown[]) {
|
||||
this.server.emit('sisters:update', { sisters, ts: Date.now() });
|
||||
}
|
||||
|
||||
/** 새 활동 로그 브로드캐스트 */
|
||||
broadcastActivity(item: unknown) {
|
||||
this.server.emit('activity:new', { item, ts: Date.now() });
|
||||
}
|
||||
|
||||
/** 연결된 클라이언트 수 */
|
||||
getClientCount(): number {
|
||||
return this.server.sockets.sockets.size;
|
||||
}
|
||||
}
|
||||
11
backend/src/events/events.module.ts
Normal file
11
backend/src/events/events.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EventsGateway } from './events.gateway';
|
||||
import { EventsScheduler } from './events.scheduler';
|
||||
import { SistersModule } from '../sisters/sisters.module';
|
||||
|
||||
@Module({
|
||||
imports: [SistersModule],
|
||||
providers: [EventsGateway, EventsScheduler],
|
||||
exports: [EventsGateway],
|
||||
})
|
||||
export class EventsModule {}
|
||||
35
backend/src/events/events.scheduler.ts
Normal file
35
backend/src/events/events.scheduler.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { SistersService } from '../sisters/sisters.service';
|
||||
import { EventsGateway } from './events.gateway';
|
||||
|
||||
@Injectable()
|
||||
export class EventsScheduler implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(EventsScheduler.name);
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private readonly INTERVAL_MS = parseInt(process.env.WS_POLL_INTERVAL_MS ?? '30000', 10);
|
||||
|
||||
constructor(
|
||||
private readonly sistersService: SistersService,
|
||||
private readonly gateway: EventsGateway,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.timer = setInterval(() => this.tick(), this.INTERVAL_MS);
|
||||
this.logger.log(`WebSocket scheduler started (interval: ${this.INTERVAL_MS}ms)`);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
private async tick() {
|
||||
if (this.gateway.getClientCount() === 0) return;
|
||||
|
||||
try {
|
||||
const sisters = await this.sistersService.getAllSistersStatus();
|
||||
this.gateway.broadcastSisterStatus(sisters);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Scheduler tick failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
231
frontend/app/admin/costs/page.tsx
Normal file
231
frontend/app/admin/costs/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import BarChart from '@/components/admin/BarChart';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { adminFetch } from '@/lib/adminFetch';
|
||||
|
||||
type Period = 'day' | 'week' | 'month';
|
||||
|
||||
interface CostData {
|
||||
period: string;
|
||||
since: string;
|
||||
summary: {
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
estimatedUsd: number;
|
||||
recordCount: number;
|
||||
};
|
||||
bySister: Array<{ name: string; totalTokens: number; estimatedUsd: number }>;
|
||||
byModel: Array<{ model: string; totalTokens: number; estimatedUsd: number; count: number }>;
|
||||
timeline: Array<{ date: string; totalTokens: number; estimatedUsd: number }>;
|
||||
}
|
||||
|
||||
const Controls = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const PeriodBtn = styled.button<{ $active: boolean }>`
|
||||
padding: 6px 16px;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: ${({ $active }) => $active ? '600' : '400'};
|
||||
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.15)' : 'transparent'};
|
||||
border: 1px solid ${({ $active }) => $active ? 'rgba(88,166,255,0.4)' : theme.colors.border};
|
||||
color: ${({ $active }) => $active ? theme.colors.accent : theme.colors.textSecondary};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
&:hover { color: ${theme.colors.accent}; border-color: rgba(88,166,255,0.3); }
|
||||
`;
|
||||
|
||||
const SummaryGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
`;
|
||||
|
||||
const SummaryCard = styled.div`
|
||||
background: ${theme.colors.cardBg};
|
||||
border: 1px solid ${theme.colors.border};
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
`;
|
||||
|
||||
const SummaryLabel = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${theme.colors.textSecondary};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const SummaryValue = styled.div`
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: ${theme.colors.textPrimary};
|
||||
`;
|
||||
|
||||
const SummaryUnit = styled.span`
|
||||
font-size: 12px;
|
||||
color: ${theme.colors.textSecondary};
|
||||
margin-left: 4px;
|
||||
`;
|
||||
|
||||
const ChartsGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 28px;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const Card = styled.div`
|
||||
background: ${theme.colors.cardBg};
|
||||
border: 1px solid ${theme.colors.border};
|
||||
border-radius: 12px;
|
||||
padding: 20px 22px;
|
||||
`;
|
||||
|
||||
const RecordBtn = styled.button`
|
||||
padding: 8px 16px;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: rgba(88,166,255,0.08);
|
||||
border: 1px solid rgba(88,166,255,0.25);
|
||||
color: ${theme.colors.accent};
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: rgba(88,166,255,0.18); }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
`;
|
||||
|
||||
const EmptyNote = styled.div`
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: ${theme.colors.textSecondary};
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export default function CostsPage() {
|
||||
const [period, setPeriod] = useState<Period>('week');
|
||||
const [data, setData] = useState<CostData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [recording, setRecording] = useState(false);
|
||||
|
||||
const load = async (p: Period) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminFetch(`/api/admin/costs?period=${p}`);
|
||||
if (res.ok) setData(await res.json());
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(period); }, [period]);
|
||||
|
||||
const recordAll = async () => {
|
||||
setRecording(true);
|
||||
try {
|
||||
await Promise.all(['harang', 'narang', 'darang', 'erang'].map((name) =>
|
||||
adminFetch(`/api/admin/costs/record/${name}`, { method: 'POST' }),
|
||||
));
|
||||
await load(period);
|
||||
} finally {
|
||||
setRecording(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sisterChartData = data?.bySister.map((s) => ({
|
||||
label: { harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이' }[s.name] ?? s.name,
|
||||
value: s.estimatedUsd,
|
||||
})) ?? [];
|
||||
|
||||
const modelChartData = data?.byModel.map((m) => ({
|
||||
label: m.model.split('/').pop() ?? m.model,
|
||||
value: m.totalTokens,
|
||||
})) ?? [];
|
||||
|
||||
const timelineChartData = data?.timeline.map((t) => ({
|
||||
label: t.date.slice(5),
|
||||
value: t.estimatedUsd,
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls>
|
||||
{(['day', 'week', 'month'] as Period[]).map((p) => (
|
||||
<PeriodBtn key={p} $active={period === p} onClick={() => setPeriod(p)}>
|
||||
{p === 'day' ? '오늘' : p === 'week' ? '7일' : '30일'}
|
||||
</PeriodBtn>
|
||||
))}
|
||||
<RecordBtn onClick={recordAll} disabled={recording} style={{ marginLeft: 'auto' }}>
|
||||
{recording ? '수집 중...' : '📊 토큰 수집'}
|
||||
</RecordBtn>
|
||||
</Controls>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ color: theme.colors.textSecondary, fontSize: '14px' }}>로딩 중...</div>
|
||||
) : !data || data.summary.recordCount === 0 ? (
|
||||
<EmptyNote>
|
||||
아직 비용 데이터가 없어.<br />
|
||||
위 "토큰 수집" 버튼으로 자매들의 세션 토큰을 수집해.
|
||||
</EmptyNote>
|
||||
) : (
|
||||
<>
|
||||
<SummaryGrid>
|
||||
<SummaryCard>
|
||||
<SummaryLabel>총 토큰</SummaryLabel>
|
||||
<SummaryValue>{formatTokens(data.summary.totalTokens)}<SummaryUnit>tokens</SummaryUnit></SummaryValue>
|
||||
</SummaryCard>
|
||||
<SummaryCard>
|
||||
<SummaryLabel>예상 비용</SummaryLabel>
|
||||
<SummaryValue>${data.summary.estimatedUsd.toFixed(4)}<SummaryUnit>USD</SummaryUnit></SummaryValue>
|
||||
</SummaryCard>
|
||||
<SummaryCard>
|
||||
<SummaryLabel>입력 토큰</SummaryLabel>
|
||||
<SummaryValue>{formatTokens(data.summary.inputTokens)}</SummaryValue>
|
||||
</SummaryCard>
|
||||
<SummaryCard>
|
||||
<SummaryLabel>출력 토큰</SummaryLabel>
|
||||
<SummaryValue>{formatTokens(data.summary.outputTokens)}</SummaryValue>
|
||||
</SummaryCard>
|
||||
</SummaryGrid>
|
||||
|
||||
<ChartsGrid>
|
||||
<Card>
|
||||
<BarChart data={sisterChartData} unit="$" title="자매별 비용 (USD)" />
|
||||
</Card>
|
||||
<Card>
|
||||
<BarChart data={modelChartData} unit=" tok" title="모델별 토큰 사용량" />
|
||||
</Card>
|
||||
</ChartsGrid>
|
||||
|
||||
{timelineChartData.length > 0 && (
|
||||
<Card>
|
||||
<BarChart data={timelineChartData} unit="$" title="일별 비용 추이" />
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const ADMIN_TABS = [
|
||||
{ href: '/admin', label: '자매 관리', icon: '⚙️' },
|
||||
{ href: '/admin/harness', label: '하네스 편집', icon: '✏️' },
|
||||
{ href: '/admin/logs', label: '로그 뷰어', icon: '📋' },
|
||||
{ href: '/admin/costs', label: '비용 모니터', icon: '💰' },
|
||||
{ href: '/admin/repos', label: '저장소', icon: '📁' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import SisterCard from '@/components/dashboard/SisterCard';
|
||||
import ProjectCard from '@/components/projects/ProjectCard';
|
||||
import ActivityFeed from '@/components/dashboard/ActivityFeed';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { useSocket } from '@/lib/useSocket';
|
||||
|
||||
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
||||
|
||||
@@ -123,6 +124,19 @@ export default function DashboardPage() {
|
||||
const [activityItems, setActivityItems] = useState<any[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSistersUpdate = useCallback((updatedSisters: any[]) => {
|
||||
setSisters(updatedSisters);
|
||||
}, []);
|
||||
|
||||
const handleActivityNew = useCallback((item: any) => {
|
||||
setActivityItems((prev) => [item, ...prev].slice(0, 20));
|
||||
}, []);
|
||||
|
||||
const { connected } = useSocket({
|
||||
onSistersUpdate: handleSistersUpdate,
|
||||
onActivityNew: handleActivityNew,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAll = async () => {
|
||||
try {
|
||||
@@ -157,7 +171,11 @@ export default function DashboardPage() {
|
||||
<PageWrapper>
|
||||
<PageHeader>
|
||||
<PageTitle>하나랑 대시보드</PageTitle>
|
||||
<PageSubtitle>4자매 멀티에이전트 파이프라인 관제 현황</PageSubtitle>
|
||||
<PageSubtitle>
|
||||
4자매 멀티에이전트 파이프라인 관제 현황
|
||||
{connected && <span style={{ marginLeft: '10px', fontSize: '11px', color: theme.colors.online }}>● 실시간 연결됨</span>}
|
||||
{!connected && <span style={{ marginLeft: '10px', fontSize: '11px', color: theme.colors.textSecondary }}>○ 폴링 모드</span>}
|
||||
</PageSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
{error && <ErrorBanner>⚠️ {error}</ErrorBanner>}
|
||||
|
||||
113
frontend/components/admin/BarChart.tsx
Normal file
113
frontend/components/admin/BarChart.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
interface BarChartProps {
|
||||
data: Array<{ label: string; value: number; color?: string }>;
|
||||
maxValue?: number;
|
||||
unit?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: ${theme.colors.textSecondary};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const ChartArea = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
height: 140px;
|
||||
`;
|
||||
|
||||
const BarWrapper = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const Bar = styled.div<{ $height: number; $color: string }>`
|
||||
width: 100%;
|
||||
height: ${({ $height }) => $height}%;
|
||||
min-height: 2px;
|
||||
background: ${({ $color }) => $color};
|
||||
border-radius: 4px 4px 0 0;
|
||||
transition: height 0.4s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover::after {
|
||||
content: attr(data-value);
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
color: ${theme.colors.textPrimary};
|
||||
white-space: nowrap;
|
||||
background: rgba(22,27,34,0.95);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${theme.colors.border};
|
||||
}
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
font-size: 10px;
|
||||
color: ${theme.colors.textSecondary};
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 60px;
|
||||
`;
|
||||
|
||||
const Empty = styled.div`
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: ${theme.colors.textSecondary};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const COLORS = [theme.colors.accent, theme.colors.online, '#FF9800', '#E040FB', '#00BCD4'];
|
||||
|
||||
export default function BarChart({ data, maxValue, unit = '', title }: BarChartProps) {
|
||||
if (!data.length) return <Empty>데이터 없음</Empty>;
|
||||
|
||||
const max = maxValue ?? Math.max(...data.map((d) => d.value), 1);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
{title && <Title>{title}</Title>}
|
||||
<ChartArea>
|
||||
{data.map((item, i) => {
|
||||
const height = Math.max((item.value / max) * 100, item.value > 0 ? 2 : 0);
|
||||
const color = item.color ?? COLORS[i % COLORS.length];
|
||||
const displayValue = unit === '$'
|
||||
? `$${item.value.toFixed(4)}`
|
||||
: `${item.value.toLocaleString()}${unit}`;
|
||||
return (
|
||||
<BarWrapper key={item.label}>
|
||||
<Bar $height={height} $color={color} data-value={displayValue} />
|
||||
<Label title={item.label}>{item.label}</Label>
|
||||
</BarWrapper>
|
||||
);
|
||||
})}
|
||||
</ChartArea>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
55
frontend/lib/useSocket.ts
Normal file
55
frontend/lib/useSocket.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
interface UseSocketOptions {
|
||||
onSistersUpdate?: (sisters: any[]) => void;
|
||||
onActivityNew?: (item: any) => void;
|
||||
}
|
||||
|
||||
export function useSocket(options: UseSocketOptions = {}) {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// next.config.ts rewrites가 없는 경우를 위해 빈 origin 처리
|
||||
// 브라우저에서 WebSocket은 rewrites 대상이 아니므로 직접 BE URL 필요
|
||||
const wsUrl = process.env.NEXT_PUBLIC_WS_URL ?? window.location.origin;
|
||||
|
||||
const socket = io(`${wsUrl}/ws`, {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 3000,
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
setConnected(false);
|
||||
});
|
||||
|
||||
socket.on('sisters:update', (data: { sisters: any[] }) => {
|
||||
options.onSistersUpdate?.(data.sisters);
|
||||
});
|
||||
|
||||
socket.on('activity:new', (data: { item: any }) => {
|
||||
options.onActivityNew?.(data.item);
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const ping = useCallback(() => {
|
||||
socketRef.current?.emit('ping');
|
||||
}, []);
|
||||
|
||||
return { connected, ping };
|
||||
}
|
||||
86
frontend/package-lock.json
generated
86
frontend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"styled-components": "^6.3.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1282,6 +1283,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -2845,6 +2852,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.1",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
||||
@@ -6372,6 +6401,34 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -7283,6 +7340,35 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"styled-components": "^6.3.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Reference in New Issue
Block a user