Files
hanarang-dashboard/backend/src/admin/admin.service.ts
narang-ai 914903466c 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 업데이트)
2026-04-04 12:28:25 +09:00

176 lines
5.5 KiB
TypeScript

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 + null byte 방지
const escaped = content.replace(/\x00/g, '').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;
}
}