feat(sprint-001): backend Nest.js + Prisma7 adapter setup, frontend Next.js UI
- Backend: NestJS + Prisma 7 (MariaDB adapter) scaffold - PrismaService with @prisma/adapter-mariadb driver - SistersService: SSH 상태 체크 with graceful fallback - HealthController: GET /health - 시드 스크립트: 자매 4명 초기 데이터 - 테스트 5/5 pass - Frontend: Next.js 16 + styled-components - styled-components SSR registry (next.config 컴파일러) - 다크 테마 글로벌 스타일 + 테마 토큰 - SisterCard: glassmorphism 상태 카드 (온라인 pulse 애니메이션) - StatusBadge: 상태 표시 컴포넌트 - Sidebar: 접이식 네비게이션 - 대시보드 메인 페이지 (API 미연결 시 mock 데이터 fallback) - build 성공 확인 - DB credential 이랑이 대기 중
This commit is contained in:
12
backend/src/sisters/sisters.controller.ts
Normal file
12
backend/src/sisters/sisters.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { SistersService } from './sisters.service';
|
||||
|
||||
@Controller('api/sisters')
|
||||
export class SistersController {
|
||||
constructor(private readonly sistersService: SistersService) {}
|
||||
|
||||
@Get()
|
||||
async getSistersStatus() {
|
||||
return this.sistersService.getAllSistersStatus();
|
||||
}
|
||||
}
|
||||
12
backend/src/sisters/sisters.module.ts
Normal file
12
backend/src/sisters/sisters.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SistersController } from './sisters.controller';
|
||||
import { SistersService } from './sisters.service';
|
||||
import { SshService } from './ssh.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [SistersController],
|
||||
providers: [SistersService, SshService],
|
||||
})
|
||||
export class SistersModule {}
|
||||
82
backend/src/sisters/sisters.service.spec.ts
Normal file
82
backend/src/sisters/sisters.service.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SistersService } from './sisters.service';
|
||||
import { SshService } from './ssh.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
describe('SistersService', () => {
|
||||
let service: SistersService;
|
||||
let sshService: jest.Mocked<SshService>;
|
||||
let prismaService: jest.Mocked<PrismaService>;
|
||||
|
||||
const mockSisters = [
|
||||
{ id: 1, name: 'harang', ip: '10.10.10.112', user: 'harang', lxcId: 104, sshKeyPath: null, lastSeen: null, status: 'unknown', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 2, name: 'narang', ip: '10.10.10.216', user: 'narang', lxcId: 105, sshKeyPath: null, lastSeen: null, status: 'unknown', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 3, name: 'darang', ip: '10.10.10.136', user: 'darang', lxcId: 106, sshKeyPath: null, lastSeen: null, status: 'unknown', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 4, name: 'erang', ip: '10.10.10.163', user: 'erang', lxcId: 107, sshKeyPath: null, lastSeen: null, status: 'unknown', createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockSsh = {
|
||||
executeCommand: jest.fn(),
|
||||
};
|
||||
|
||||
const mockPrisma = {
|
||||
sisterConfig: {
|
||||
findMany: jest.fn().mockResolvedValue(mockSisters),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SistersService,
|
||||
{ provide: SshService, useValue: mockSsh },
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: { get: jest.fn().mockReturnValue('/home/narang/.ssh/id_rsa') },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SistersService>(SistersService);
|
||||
sshService = module.get(SshService);
|
||||
prismaService = module.get(PrismaService);
|
||||
});
|
||||
|
||||
it('SSH 성공 시 online 상태 반환', async () => {
|
||||
sshService.executeCommand.mockResolvedValue({ stdout: 'active', stderr: '', code: 0 });
|
||||
|
||||
const result = await service.getAllSistersStatus();
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0].status).toBe('online');
|
||||
expect(result[0].name).toBe('harang');
|
||||
expect(result[0].role).toBe('Orchestrator');
|
||||
});
|
||||
|
||||
it('SSH 실패 시 offline graceful fallback', async () => {
|
||||
sshService.executeCommand.mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
const result = await service.getAllSistersStatus();
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
result.forEach((s) => expect(s.status).toBe('offline'));
|
||||
});
|
||||
|
||||
it('일부 SSH 실패 시 실패한 자매만 offline', async () => {
|
||||
sshService.executeCommand
|
||||
.mockResolvedValueOnce({ stdout: 'active', stderr: '', code: 0 }) // harang
|
||||
.mockRejectedValueOnce(new Error('timeout')) // narang
|
||||
.mockResolvedValueOnce({ stdout: 'inactive', stderr: '', code: 1 }) // darang
|
||||
.mockRejectedValueOnce(new Error('timeout')); // erang
|
||||
|
||||
const result = await service.getAllSistersStatus();
|
||||
|
||||
expect(result[0].status).toBe('online'); // harang
|
||||
expect(result[1].status).toBe('offline'); // narang (SSH 실패)
|
||||
expect(result[2].status).toBe('offline'); // darang (inactive)
|
||||
expect(result[3].status).toBe('offline'); // erang (SSH 실패)
|
||||
});
|
||||
});
|
||||
112
backend/src/sisters/sisters.service.ts
Normal file
112
backend/src/sisters/sisters.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SshService } from './ssh.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface SisterStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
user: string;
|
||||
lxcId: number;
|
||||
role: string;
|
||||
status: 'online' | 'offline' | 'working' | 'unknown';
|
||||
lastSeen: Date | null;
|
||||
currentTask: string | null;
|
||||
}
|
||||
|
||||
const SISTER_ROLES: Record<string, string> = {
|
||||
harang: 'Orchestrator',
|
||||
narang: 'Generator',
|
||||
darang: 'Evaluator',
|
||||
erang: 'Infra Manager',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SistersService {
|
||||
private readonly logger = new Logger(SistersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ssh: SshService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async getAllSistersStatus(): Promise<SisterStatus[]> {
|
||||
const sisters = await this.prisma.sisterConfig.findMany();
|
||||
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH') ?? '/home/narang/.ssh/id_rsa';
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
sisters.map((sister) => this.checkSisterStatus(sister, sshKeyPath)),
|
||||
);
|
||||
|
||||
return results.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value;
|
||||
}
|
||||
// graceful fallback
|
||||
const sister = sisters[index];
|
||||
return {
|
||||
id: sister.id,
|
||||
name: sister.name,
|
||||
ip: sister.ip,
|
||||
user: sister.user,
|
||||
lxcId: sister.lxcId,
|
||||
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
||||
status: 'offline' as const,
|
||||
lastSeen: sister.lastSeen,
|
||||
currentTask: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async checkSisterStatus(
|
||||
sister: { id: number; name: string; ip: string; user: string; lxcId: number; lastSeen: Date | null },
|
||||
sshKeyPath: string,
|
||||
): Promise<SisterStatus> {
|
||||
try {
|
||||
const result = await this.ssh.executeCommand(
|
||||
sister.ip,
|
||||
sister.user,
|
||||
sshKeyPath,
|
||||
'systemctl --user is-active openclaw-gateway 2>/dev/null || echo "inactive"',
|
||||
);
|
||||
|
||||
const isActive = result.stdout.trim() === 'active';
|
||||
const status: 'online' | 'offline' = isActive ? 'online' : 'offline';
|
||||
|
||||
if (isActive) {
|
||||
// 온라인이면 lastSeen 업데이트
|
||||
await this.prisma.sisterConfig.update({
|
||||
where: { id: sister.id },
|
||||
data: { lastSeen: new Date(), status },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: sister.id,
|
||||
name: sister.name,
|
||||
ip: sister.ip,
|
||||
user: sister.user,
|
||||
lxcId: sister.lxcId,
|
||||
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
||||
status,
|
||||
lastSeen: isActive ? new Date() : sister.lastSeen,
|
||||
currentTask: null,
|
||||
};
|
||||
} catch {
|
||||
this.logger.warn(`Failed to check status for ${sister.name} (${sister.ip})`);
|
||||
return {
|
||||
id: sister.id,
|
||||
name: sister.name,
|
||||
ip: sister.ip,
|
||||
user: sister.user,
|
||||
lxcId: sister.lxcId,
|
||||
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
||||
status: 'offline',
|
||||
lastSeen: sister.lastSeen,
|
||||
currentTask: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
43
backend/src/sisters/ssh.service.ts
Normal file
43
backend/src/sisters/ssh.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { NodeSSH } from 'node-ssh';
|
||||
|
||||
export interface SshCommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SshService {
|
||||
private readonly logger = new Logger(SshService.name);
|
||||
|
||||
async executeCommand(
|
||||
host: string,
|
||||
username: string,
|
||||
privateKeyPath: string,
|
||||
command: string,
|
||||
): Promise<SshCommandResult> {
|
||||
const ssh = new NodeSSH();
|
||||
|
||||
try {
|
||||
await ssh.connect({
|
||||
host,
|
||||
username,
|
||||
privateKeyPath,
|
||||
readyTimeout: 5000,
|
||||
});
|
||||
|
||||
const result = await ssh.execCommand(command);
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
code: result.code,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(`SSH connection failed to ${host}: ${(error as Error).message}`);
|
||||
throw error;
|
||||
} finally {
|
||||
ssh.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user