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:
75
backend/src/sisters/avatar.service.ts
Normal file
75
backend/src/sisters/avatar.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user