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:
2026-04-04 11:06:44 +09:00
parent 930d84d5c4
commit 79135f24b0
56 changed files with 19108 additions and 2 deletions

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

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

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

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

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