feat(sprint-008): 실시간 자매 정보/아바타/설정 DB 연동

- TASK-028: /api/sisters/:name/system 추가, 자매 페이지 uptime/cpu/memory/disk 실시간화
- TASK-029: /api/sisters/:name/avatar 추가, SSH avatar fetch + 5분 캐시 + SVG fallback
- TASK-030: SystemSettings 모델 + /api/admin/settings GET/PUT + FE settings DB 연동/토스트
- TASK-031 일부: 대시보드/자매/설정 스켈레톤 UI + 에러 fallback
- 30초 자매 상태 websocket 브로드캐스트 시작

테스트 26/26 pass, build 성공
This commit is contained in:
2026-04-04 15:56:12 +09:00
parent 194bc5a91c
commit abef8576fe
15 changed files with 524 additions and 689 deletions

View File

@@ -0,0 +1,75 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SshService } from './ssh.service';
interface AvatarCacheItem {
expiresAt: number;
contentType: string;
data: Buffer;
}
@Injectable()
export class AvatarService {
private readonly logger = new Logger(AvatarService.name);
private readonly cache = new Map<string, AvatarCacheItem>();
private readonly ttlMs = 5 * 60 * 1000;
constructor(
private readonly ssh: SshService,
private readonly config: ConfigService,
) {}
async getAvatar(sister: { name: string; ip: string; user: string }) {
const cached = this.cache.get(sister.name);
if (cached && cached.expiresAt > Date.now()) {
return cached;
}
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
if (!sshKeyPath) {
return this.getFallback(sister.name);
}
const files = [
{ path: '~/.openclaw/avatar.png', type: 'image/png' },
{ path: '~/.openclaw/avatar.jpg', type: 'image/jpeg' },
{ path: '~/.openclaw/avatar.jpeg', type: 'image/jpeg' },
{ path: '~/.openclaw/avatar.webp', type: 'image/webp' },
];
for (const file of files) {
try {
const result = await this.ssh.executeCommand(
sister.ip,
sister.user,
sshKeyPath,
`if [ -f ${file.path} ]; then base64 -w0 ${file.path}; fi`,
);
if (result.stdout.trim()) {
const item = {
contentType: file.type,
data: Buffer.from(result.stdout.trim(), 'base64'),
expiresAt: Date.now() + this.ttlMs,
};
this.cache.set(sister.name, item);
return item;
}
} catch {
this.logger.warn(`Avatar fetch failed for ${sister.name}`);
}
}
return this.getFallback(sister.name);
}
private getFallback(name: string) {
const initial = name.slice(0, 1).toUpperCase();
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="100%" height="100%" fill="#111"/><text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="56" fill="#f5f5f5">${initial}</text></svg>`;
return {
contentType: 'image/svg+xml',
data: Buffer.from(svg),
expiresAt: Date.now() + this.ttlMs,
};
}
}

View File

@@ -1,14 +1,17 @@
import { Controller, Get, Param } from '@nestjs/common';
import { Controller, Get, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
import { SistersService } from './sisters.service';
import { SisterDetailService } from './sister-detail.service';
import { SisterNamePipe } from '../common/sister-name.pipe';
import type { SisterName } from '../common/sister-name.pipe';
import { AvatarService } from './avatar.service';
@Controller('api/sisters')
export class SistersController {
constructor(
private readonly sistersService: SistersService,
private readonly sisterDetail: SisterDetailService,
private readonly avatarService: AvatarService,
) {}
@Get()
@@ -16,6 +19,27 @@ export class SistersController {
return this.sistersService.getAllSistersStatus();
}
@Get(':name/system')
async getSystemInfo(@Param('name', SisterNamePipe) name: SisterName) {
return this.sistersService.getSystemInfo(name as SisterName);
}
@Get(':name/avatar')
async getAvatar(
@Param('name', SisterNamePipe) name: SisterName,
@Res() res: Response,
) {
const sister = await this.sistersService.findByName(name as SisterName);
if (!sister) {
return res.status(404).json({ message: 'Sister not found' });
}
const avatar = await this.avatarService.getAvatar(sister);
res.setHeader('Content-Type', avatar.contentType);
res.setHeader('Cache-Control', 'public, max-age=300');
return res.send(avatar.data);
}
@Get(':name/config')
async getSisterConfig(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterConfig(name as SisterName);

View File

@@ -4,11 +4,13 @@ import { SistersService } from './sisters.service';
import { SisterDetailService } from './sister-detail.service';
import { SshService } from './ssh.service';
import { PrismaModule } from '../prisma/prisma.module';
import { ConfigModule } from '@nestjs/config';
import { AvatarService } from './avatar.service';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, ConfigModule],
controllers: [SistersController],
providers: [SistersService, SisterDetailService, SshService],
exports: [SistersService, SisterDetailService, SshService],
providers: [SistersService, SisterDetailService, SshService, AvatarService],
exports: [SistersService, SisterDetailService, SshService, AvatarService],
})
export class SistersModule {}

View File

@@ -48,7 +48,6 @@ export class SistersService {
if (result.status === 'fulfilled') {
return result.value;
}
// graceful fallback
const sister = sisters[index];
return {
id: sister.id,
@@ -63,6 +62,31 @@ export class SistersService {
});
}
async findByName(name: string) {
return this.prisma.sisterConfig.findUnique({ where: { name } });
}
async getSystemInfo(name: string) {
const sister = await this.prisma.sisterConfig.findUnique({ where: { name } });
if (!sister) return null;
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
if (!sshKeyPath) return null;
const [uptimeRes, cpuRes, memRes, diskRes] = await Promise.all([
this.ssh.executeCommand(sister.ip, sister.user, sshKeyPath, 'cat /proc/uptime 2>/dev/null || echo "0 0"').catch(() => ({ stdout: '0 0', stderr: '', code: 0 })),
this.ssh.executeCommand(sister.ip, sister.user, sshKeyPath, "top -bn1 | grep Cpu || echo '%Cpu(s): 0.0 us, 0.0 sy, 100.0 id'").catch(() => ({ stdout: '%Cpu(s): 0.0 us, 0.0 sy, 100.0 id', stderr: '', code: 0 })),
this.ssh.executeCommand(sister.ip, sister.user, sshKeyPath, 'free -m | grep Mem || echo "Mem: 0 0 0 0 0 0"').catch(() => ({ stdout: 'Mem: 0 0 0 0 0 0', stderr: '', code: 0 })),
this.ssh.executeCommand(sister.ip, sister.user, sshKeyPath, "df -h / | tail -1 || echo '/dev/root 0G 0G 0G 0% /'").catch(() => ({ stdout: '/dev/root 0G 0G 0G 0% /', stderr: '', code: 0 })),
]);
return {
uptime: this.parseUptime(uptimeRes.stdout),
cpu: this.parseCpu(cpuRes.stdout),
memory: this.parseMemory(memRes.stdout),
disk: this.parseDisk(diskRes.stdout),
};
}
private async checkSisterStatus(
sister: { id: number; name: string; ip: string; user: string; lxcId: number; lastSeen: Date | null; status: string },
sshKeyPath: string,
@@ -77,22 +101,20 @@ export class SistersService {
const isActive = result.stdout.trim() === 'active';
const status: 'online' | 'offline' = isActive ? 'online' : 'offline';
const prevStatus = sister.status;
const now = new Date();
if (isActive) {
// 온라인이면 lastSeen 업데이트 + 상태 변경 감지
const prevStatus = sister.status;
await this.prisma.sisterConfig.update({
where: { id: sister.id },
data: { lastSeen: new Date(), status },
});
// 상태 변경 시 ActivityLog 기록
if (prevStatus !== status && this.activity) {
await this.activity.log({
sisterId: sister.id,
action: 'status_changed',
detail: `[${sister.name}] status: ${prevStatus}${status}`,
}).catch(() => {}); // 비동기 오류 무시
}
await this.prisma.sisterConfig.update({
where: { id: sister.id },
data: { lastSeen: isActive ? now : sister.lastSeen, status },
});
if (prevStatus !== status && this.activity) {
await this.activity.log({
sisterId: sister.id,
action: 'status_changed',
detail: `[${sister.name}] status: ${prevStatus}${status}`,
}).catch(() => {});
}
return {
@@ -102,7 +124,7 @@ export class SistersService {
lxcId: sister.lxcId,
role: SISTER_ROLES[sister.name] ?? 'Unknown',
status,
lastSeen: isActive ? new Date() : sister.lastSeen,
lastSeen: isActive ? now : sister.lastSeen,
currentTask: null,
};
} catch {
@@ -119,4 +141,30 @@ export class SistersService {
};
}
}
private parseUptime(stdout: string) {
const sec = Math.floor(parseFloat(stdout.split(' ')[0] || '0'));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
private parseCpu(stdout: string) {
const idleMatch = stdout.match(/([0-9]+(?:\.[0-9]+)?)\s*id/);
const idle = idleMatch ? parseFloat(idleMatch[1]) : 100;
return Math.max(0, Math.min(100, Number((100 - idle).toFixed(1))));
}
private parseMemory(stdout: string) {
const parts = stdout.trim().split(/\s+/);
const total = parseInt(parts[1] || '0', 10);
const used = parseInt(parts[2] || '0', 10);
return { used, total };
}
private parseDisk(stdout: string) {
const parts = stdout.trim().split(/\s+/);
return { used: parts[2] || '0G', total: parts[1] || '0G' };
}
}