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:
2026-04-04 12:28:25 +09:00
parent 929b4e0745
commit 914903466c
21 changed files with 1145 additions and 13 deletions

View File

@@ -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",

View File

@@ -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"

View File

@@ -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])
}

View File

@@ -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],

View File

@@ -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 () => {

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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],

View 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);
}
}

View 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 {}

View 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);
}
}
}

View 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;
}
}

View 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 {}

View 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}`);
}
}
}

View 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>
)}
</>
)}
</>
);
}

View File

@@ -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: '📁' },
];

View File

@@ -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>}

View 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
View 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 };
}

View File

@@ -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",

View File

@@ -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": {