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

@@ -102,3 +102,10 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SystemSettings {
id Int @id @default(autoincrement())
key String @unique
value String @db.Text
updatedAt DateTime @updatedAt
}

View File

@@ -16,6 +16,7 @@ import { AdminModule } from './admin/admin.module';
import { EventsModule } from './events/events.module';
import { CostsModule } from './costs/costs.module';
import { GiteaSyncModule } from './gitea-sync/gitea-sync.module';
import { SettingsModule } from './settings/settings.module';
@Module({
imports: [
@@ -34,6 +35,7 @@ import { GiteaSyncModule } from './gitea-sync/gitea-sync.module';
EventsModule,
CostsModule,
GiteaSyncModule,
SettingsModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -8,6 +8,7 @@ import {
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
import { SistersScheduler } from './sisters.scheduler';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@@ -31,10 +32,12 @@ export class EventsGateway
constructor(
private readonly jwtService: JwtService,
private readonly config: ConfigService,
private readonly sistersScheduler: SistersScheduler,
) {}
afterInit() {
this.logger.log('WebSocket Gateway initialized');
this.sistersScheduler.start();
}
handleConnection(client: Socket) {

View File

@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventsGateway } from './events.gateway';
import { EventsScheduler } from './events.scheduler';
import { SistersScheduler } from './sisters.scheduler';
import { SistersModule } from '../sisters/sisters.module';
@Module({
@@ -17,7 +18,7 @@ import { SistersModule } from '../sisters/sisters.module';
}),
}),
],
providers: [EventsGateway, EventsScheduler],
exports: [EventsGateway],
providers: [EventsGateway, EventsScheduler, SistersScheduler],
exports: [EventsGateway, SistersScheduler],
})
export class EventsModule {}

View File

@@ -0,0 +1,26 @@
import { Injectable, Logger } from '@nestjs/common';
import { SistersService } from '../sisters/sisters.service';
import { EventsGateway } from './events.gateway';
@Injectable()
export class SistersScheduler {
private readonly logger = new Logger(SistersScheduler.name);
private timer: NodeJS.Timeout | null = null;
constructor(
private readonly sistersService: SistersService,
private readonly eventsGateway: EventsGateway,
) {}
start() {
if (this.timer) return;
this.timer = setInterval(async () => {
try {
const sisters = await this.sistersService.getAllSistersStatus();
this.eventsGateway.broadcastSisterStatus(sisters);
} catch {
this.logger.warn('Failed to broadcast sisters update');
}
}, 30000);
}
}

View File

@@ -0,0 +1,26 @@
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
import { SettingsService } from './settings.service';
import { CompositeGuard } from '../auth/jwt.guard';
import { RoleGuard, Roles } from '../auth/role.guard';
@Controller('api/admin/settings')
@UseGuards(CompositeGuard, RoleGuard)
@Roles('admin')
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get()
getAll() {
return this.settingsService.getAll();
}
@Get('defaults')
getDefaults() {
return this.settingsService.getDefaults();
}
@Put()
saveAll(@Body() body: Record<string, string>) {
return this.settingsService.saveAll(body ?? {});
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
import { PrismaModule } from '../prisma/prisma.module';
import { ActivityModule } from '../activity/activity.module';
@Module({
imports: [PrismaModule, ActivityModule],
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View File

@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ActivityService } from '../activity/activity.service';
const DEFAULT_SETTINGS: Record<string, string> = {
force2fa: 'true',
sessionTimeoutMinutes: '15',
ipWhitelistEnabled: 'false',
autoBackupEnabled: 'true',
backupSchedule: '08:00',
retentionDays: '30',
queueWarningThreshold: '500',
autoScalingEnabled: 'true',
cpuThresholdPercent: '90',
latencyAlertMs: '250',
nodeOfflineAlertEnabled: 'true',
};
@Injectable()
export class SettingsService {
constructor(
private readonly prisma: PrismaService,
private readonly activity: ActivityService,
) {}
async getAll() {
const rows = await this.prisma.systemSettings.findMany({ orderBy: { key: 'asc' } });
const merged = { ...DEFAULT_SETTINGS };
for (const row of rows) merged[row.key] = row.value;
return merged;
}
async saveAll(payload: Record<string, string>) {
const entries = Object.entries(payload);
await this.prisma.$transaction(
entries.map(([key, value]) =>
this.prisma.systemSettings.upsert({
where: { key },
update: { value: String(value) },
create: { key, value: String(value) },
}),
),
);
await this.activity.log({
action: 'settings_updated',
detail: `System settings updated: ${entries.length} keys`,
});
return this.getAll();
}
getDefaults() {
return { ...DEFAULT_SETTINGS };
}
}

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' };
}
}

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import styled from 'styled-components';
import styled, { keyframes } from 'styled-components';
import Link from 'next/link';
import {
PageHeader, PageTitle, Card, BracketValue, LabelMeta,
@@ -11,248 +11,90 @@ import {
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
import { useSocket } from '@/lib/useSocket';
// ─── Styled ───
const StatusGrid = styled.section`
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-lg);
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
const StatusGrid = styled.section`display:grid;grid-template-columns:repeat(4,1fr);gap:var(--space-lg);@media (min-width:768px) and (max-width:1199px){grid-template-columns:repeat(2,1fr)}@media (max-width:767px){grid-template-columns:repeat(2,1fr);gap:var(--space-md)}`;
const DataColumns = styled.div`display:grid;grid-template-columns:1.5fr 1fr;gap:var(--space-xxl);align-items:start;@media (max-width:1199px){grid-template-columns:1fr;gap:var(--space-xl)}}`;
const ProjectList = styled.div`display:flex;flex-direction:column;`;
const ProjectRow = styled(Link)`display:grid;grid-template-columns:40px 1fr auto;gap:var(--space-md);padding:var(--space-md) 0;border-bottom:1px solid #222;align-items:center;text-decoration:none;transition:opacity .15s;&:last-child{border-bottom:none}&:hover{opacity:.8}`;
const Avatar = styled.div`width:32px;height:32px;border-radius:50%;background-color:#333;overflow:hidden;filter:grayscale(100%) contrast(120%);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:14px;`;
const ProjectDetails = styled.div`display:flex;flex-direction:column;gap:2px;min-width:0;`;
const ProjectName = styled.div`font-size:15px;font-weight:500;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const ProjectDesc = styled.div`font-size:13px;color:var(--text-secondary);line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const WsIndicator = styled.span<{ $on:boolean }>`font-size:11px;font-family:var(--font-mono);color:${({$on})=>$on?'var(--text-secondary)':'#333'};margin-left:var(--space-md);`;
const SkeletonCard = styled.div`height:142px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`;
const SkeletonLine = styled.div`height:14px;background:#111;animation:${pulse} 1.4s ease-in-out infinite;margin:10px 0;`;
const ErrorBox = styled.div`padding:var(--space-md);border:1px solid #5a2a2a;color:#ff9b9b;font-size:13px;margin-bottom:var(--space-lg);`;
@media (min-width: 768px) and (max-width: 1199px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 767px) {
grid-template-columns: repeat(2, 1fr);
gap: var(--space-md);
}
`;
const DataColumns = styled.div`
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: var(--space-xxl);
align-items: start;
@media (max-width: 1199px) {
grid-template-columns: 1fr;
gap: var(--space-xl);
}
`;
const ProjectList = styled.div`
display: flex;
flex-direction: column;
`;
const ProjectRow = styled(Link)`
display: grid;
grid-template-columns: 40px 1fr auto;
gap: var(--space-md);
padding: var(--space-md) 0;
border-bottom: 1px solid #222;
align-items: center;
text-decoration: none;
transition: opacity 0.15s;
&:last-child { border-bottom: none; }
&:hover { opacity: 0.8; }
`;
const Avatar = styled.div`
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #333;
overflow: hidden;
filter: grayscale(100%) contrast(120%);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
`;
const ProjectDetails = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
`;
const ProjectName = styled.div`
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const ProjectDesc = styled.div`
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const WsIndicator = styled.span<{ $on: boolean }>`
font-size: 11px;
font-family: var(--font-mono);
color: ${({ $on }) => $on ? 'var(--text-secondary)' : '#333'};
margin-left: var(--space-md);
`;
// ─── Helpers ───
const SISTER_SHORT: Record<string, string> = {
harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑',
};
const SISTER_SUB_LABEL: Record<string, string> = {
harang: 'Active Node',
narang: 'Capacity %',
darang: 'Standby',
erang: 'Sync Queue',
};
function sisterBracketValue(s: any): string {
if (s.status === 'online') return '[ON]';
if (s.status === 'working') return '[RUN]';
if (s.status === 'offline') return '[--]';
return '[??]';
}
function sisterBarWidth(s: any): number {
if (s.status === 'online') return 100;
if (s.status === 'working') return 84;
if (s.status === 'offline') return 0;
return 12;
}
function formatTimestamp(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString('ko-KR', { hour12: false }) + ' GMT+9';
}
const MOCK_SISTERS = [
{ id: 1, name: 'harang', status: 'online', lastSeen: null, currentTask: null },
{ id: 2, name: 'narang', status: 'working', lastSeen: null, currentTask: null },
{ id: 3, name: 'darang', status: 'offline', lastSeen: null, currentTask: null },
{ id: 4, name: 'erang', status: 'online', lastSeen: null, currentTask: null },
];
const SISTER_SHORT: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
function sisterBracketValue(s: any): string { if (s.status === 'online') return '[ON]'; if (s.status === 'working') return '[RUN]'; if (s.status === 'offline') return '[--]'; return '[??]'; }
function sisterBarWidth(s: any): number { if (typeof s.cpu === 'number') return Math.max(0, Math.min(100, s.cpu)); if (s.status === 'online') return 100; if (s.status === 'working') return 84; if (s.status === 'offline') return 0; return 12; }
function sisterSubLabel(s: any): string { if (s.uptime) return `Uptime ${s.uptime}`; return s.status; }
function formatTimestamp(iso: string): string { const d = new Date(iso); return d.toLocaleTimeString('ko-KR', { hour12: false }) + ' GMT+9'; }
export default function DashboardPage() {
const [sisters, setSisters] = useState<any[]>(MOCK_SISTERS);
const [sisters, setSisters] = useState<any[]>([]);
const [projects, setProjects] = useState<any[]>([]);
const [activityItems, setActivityItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const handleSistersUpdate = useCallback((s: any[]) => setSisters(s), []);
const handleActivityNew = useCallback((item: any) => {
setActivityItems((prev) => [item, ...prev].slice(0, 10));
}, []);
const handleActivityNew = useCallback((item: any) => setActivityItems((prev) => [item, ...prev].slice(0, 10)), []);
const { connected } = useSocket({ onSistersUpdate: handleSistersUpdate, onActivityNew: handleActivityNew });
useEffect(() => {
const fetch_ = async () => {
let active = true;
const fetchAll = async () => {
try {
const [sRes, pRes, aRes] = await Promise.allSettled([
fetch(`${API_URL}/api/sisters`),
fetch(`${API_URL}/api/projects`),
fetch(`${API_URL}/api/activity?limit=8`),
]);
if (!active) return;
if (sRes.status === 'fulfilled' && sRes.value.ok) setSisters(await sRes.value.json());
if (pRes.status === 'fulfilled' && pRes.value.ok) setProjects(await pRes.value.json());
if (aRes.status === 'fulfilled' && aRes.value.ok) {
const d = await aRes.value.json();
setActivityItems(d.items ?? []);
}
} catch { /* silent */ }
if (aRes.status === 'fulfilled' && aRes.value.ok) setActivityItems((await aRes.value.json()).items ?? []);
setError(null);
} catch {
if (active) setError('데이터를 불러올 수 없습니다');
} finally {
if (active) setLoading(false);
}
};
fetch_();
const iv = setInterval(fetch_, POLL_INTERVAL_MS);
return () => clearInterval(iv);
fetchAll();
const iv = setInterval(fetchAll, POLL_INTERVAL_MS);
return () => { active = false; clearInterval(iv); };
}, []);
return (
<>
<PageHeader>
<PageTitle>
<WsIndicator $on={connected}> {connected ? 'LIVE' : 'POLL'}</WsIndicator>
</PageTitle>
</PageHeader>
{/* 상태 카드 */}
<PageHeader><PageTitle> <WsIndicator $on={connected}> {connected ? 'LIVE' : 'POLL'}</WsIndicator></PageTitle></PageHeader>
{error && <ErrorBox>{error}</ErrorBox>}
<StatusGrid>
{sisters.map((s) => (
{loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />) : sisters.map((s) => (
<Card key={s.id ?? s.name} as={Link} href={`/sisters/${s.name}`} style={{ textDecoration: 'none' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}>
<BracketValue $dimmed={s.status === 'offline'}>
{sisterBracketValue(s)}
</BracketValue>
<LabelMeta>{SISTER_SUB_LABEL[s.name] ?? s.status}</LabelMeta>
</div>
<TechBar>
<TechBarFill $width={sisterBarWidth(s)} />
</TechBar>
<div style={{ display: 'flex', justifyContent: 'space-between' }}><LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}><BracketValue $dimmed={s.status === 'offline'}>{sisterBracketValue(s)}</BracketValue><LabelMeta>{sisterSubLabel(s)}</LabelMeta></div>
<TechBar><TechBarFill $width={sisterBarWidth(s)} /></TechBar>
</Card>
))}
</StatusGrid>
{/* 하단 2열 */}
<DataColumns>
{/* ONGOING PROJECTS */}
<section>
<SectionTitle>
<span>ONGOING PROJECTS</span>
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
</SectionTitle>
<SectionTitle><span>ONGOING PROJECTS</span><LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta></SectionTitle>
<ProjectList>
{projects.length === 0 ? (
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}>
</div>
) : (
projects.map((p) => (
<ProjectRow key={p.id} href={`/projects/${p.id}`}>
<Avatar>📁</Avatar>
<ProjectDetails>
<ProjectName>{p.name}</ProjectName>
<ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc>
</ProjectDetails>
<LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta>
</ProjectRow>
))
)}
{loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonLine key={i} />) : projects.length === 0 ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}> </div> : projects.map((p) => (
<ProjectRow key={p.id} href={`/projects/${p.id}`}><Avatar>📁</Avatar><ProjectDetails><ProjectName>{p.name}</ProjectName><ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc></ProjectDetails><LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta></ProjectRow>
))}
</ProjectList>
</section>
{/* ACTIVITY FEED */}
<section>
<SectionTitle>
<span>ACTIVITY FEED</span>
<LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta>
</SectionTitle>
<SectionTitle><span>ACTIVITY FEED</span><LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta></SectionTitle>
<Timeline>
{activityItems.length === 0 ? (
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', paddingLeft: 'var(--space-lg)' }}>
</div>
) : (
activityItems.map((item) => (
<TimelineItem key={item.id}>
<TimeStamp>{formatTimestamp(item.createdAt)}</TimeStamp>
<TimelineContent>
{item.sister && <strong>{item.sister.name} </strong>}
{item.detail ?? item.action}
</TimelineContent>
</TimelineItem>
))
)}
{loading ? Array.from({ length: 5 }).map((_, i) => <SkeletonLine key={i} />) : activityItems.length === 0 ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', paddingLeft: 'var(--space-lg)' }}> </div> : activityItems.map((item) => (
<TimelineItem key={item.id}><TimeStamp>{formatTimestamp(item.createdAt)}</TimeStamp><TimelineContent>{item.sister && <strong>{item.sister.name} </strong>}{item.detail ?? item.action}</TimelineContent></TimelineItem>
))}
</Timeline>
</section>
</DataColumns>

View File

@@ -1,23 +1,37 @@
'use client';
import React, { useState } from 'react';
import styled from 'styled-components';
import React, { useEffect, useState } from 'react';
import styled, { keyframes } from 'styled-components';
import { PageTitle, LabelMeta, SectionTitle, Btn, BtnPrimary } from '@/components/ui/base';
import { adminFetch } from '@/lib/adminFetch';
const DEFAULT_SETTINGS = {
force2fa: 'true',
sessionTimeoutMinutes: '15',
ipWhitelistEnabled: 'false',
autoBackupEnabled: 'true',
backupSchedule: '08:00',
retentionDays: '30',
queueWarningThreshold: '500',
autoScalingEnabled: 'true',
cpuThresholdPercent: '90',
latencyAlertMs: '250',
nodeOfflineAlertEnabled: 'true',
};
const pulse = keyframes`
0% { opacity: 0.35; }
50% { opacity: 0.7; }
100% { opacity: 0.35; }
`;
// ─── Styled ───
const SettingsGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-xl);
@media (max-width: 767px) {
grid-template-columns: 1fr;
gap: var(--space-lg);
}
@media (max-width: 767px) { grid-template-columns: 1fr; gap: var(--space-lg); }
`;
const SettingsSection = styled.section``;
const SettingsRow = styled.div`
display: flex;
justify-content: space-between;
@@ -25,257 +39,112 @@ const SettingsRow = styled.div`
gap: var(--space-lg);
padding: var(--space-md) 0;
border-bottom: 1px solid #1f1f1f;
&:last-child { border-bottom: none; }
`;
const SettingInfo = styled.div`
flex: 1;
min-width: 0;
`;
const SettingLabel = styled.div`
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
`;
const SettingDesc = styled.div`
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
`;
// Toggle Switch
const SwitchWrapper = styled.label`
position: relative;
display: inline-block;
width: 34px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
`;
const SwitchInput = styled.input`
opacity: 0;
width: 0;
height: 0;
position: absolute;
`;
const SettingInfo = styled.div`flex: 1; min-width: 0;`;
const SettingLabel = styled.div`font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 2px;`;
const SettingDesc = styled.div`font-size: 12px; color: var(--text-secondary); line-height: 1.4;`;
const SwitchWrapper = styled.label`position: relative; display: inline-block; width: 34px; height: 18px; flex-shrink: 0; cursor: pointer;`;
const SwitchInput = styled.input`opacity: 0; width: 0; height: 0; position: absolute;`;
const Slider = styled.span<{ $checked: boolean }>`
position: absolute;
inset: 0;
position: absolute; inset: 0;
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
background: transparent;
transition: border-color 0.2s;
cursor: pointer;
background: transparent; transition: border-color 0.2s; cursor: pointer;
&::after {
content: '';
position: absolute;
left: ${({ $checked }) => $checked ? '16px' : '2px'};
top: 2px;
width: 12px;
height: 12px;
background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--text-secondary)'};
content: ''; position: absolute; left: ${({ $checked }) => $checked ? '16px' : '2px'}; top: 2px;
width: 12px; height: 12px; background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--text-secondary)'};
transition: left 0.2s, background 0.2s;
}
`;
const BracketInputGroup = styled.div`
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-mono);
font-size: 14px;
color: var(--text-secondary);
flex-shrink: 0;
`;
const BracketInputGroup = styled.div`display: flex; align-items: center; gap: 4px; font-family: var(--font-mono); font-size: 14px; color: var(--text-secondary); flex-shrink: 0;`;
const BracketInput = styled.input`
background: transparent;
border: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 14px;
text-align: center;
width: 60px;
outline: none;
padding: 2px 0;
border-bottom: 1px solid var(--border-color);
background: transparent; border: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 14px; text-align: center;
width: 72px; outline: none; padding: 2px 0; border-bottom: 1px solid var(--border-color);
&:focus { border-bottom-color: var(--text-primary); }
`;
const ActionBar = styled.div`
display: flex;
justify-content: flex-end;
gap: var(--space-md);
padding-top: var(--space-xl);
border-top: 1px solid var(--border-color);
`;
const ActionBar = styled.div`display: flex; justify-content: flex-end; gap: var(--space-md); padding-top: var(--space-xl); border-top: 1px solid var(--border-color);`;
const Toast = styled.div`margin-bottom: var(--space-lg); padding: var(--space-sm) var(--space-md); border: 1px solid var(--border-color); color: var(--text-secondary); font-size: 12px; font-family: var(--font-mono);`;
const ErrorBox = styled.div`padding: var(--space-lg); border: 1px solid #5a2a2a; color: #ff9b9b; font-size: 13px; margin-bottom: var(--space-lg);`;
const SkeletonBox = styled.div`height: 16px; background: #1a1a1a; animation: ${pulse} 1.4s ease-in-out infinite;`;
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
return (
<SwitchWrapper onClick={onChange}>
<SwitchInput type="checkbox" checked={checked} onChange={() => {}} />
<Slider $checked={checked} />
</SwitchWrapper>
);
return <SwitchWrapper onClick={onChange}><SwitchInput type="checkbox" checked={checked} onChange={() => {}} /><Slider $checked={checked} /></SwitchWrapper>;
}
function BInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<BracketInputGroup>
[<BracketInput value={value} onChange={(e) => onChange(e.target.value)} />]
</BracketInputGroup>
);
return <BracketInputGroup>[<BracketInput value={value} onChange={(e) => onChange(e.target.value)} />]</BracketInputGroup>;
}
export default function SettingsPage() {
const [settings, setSettings] = useState({
twofa: true,
sessionTimeout: '15',
ipWhitelist: false,
autoBackup: true,
backupInterval: '08:00',
backupRetention: '30',
queueThreshold: '500',
autoScaling: true,
cpuThreshold: '90',
latencyThreshold: '250',
nodeOfflineAlert: true,
});
const [settings, setSettings] = useState<Record<string, string>>(DEFAULT_SETTINGS);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const toggle = (key: keyof typeof settings) =>
setSettings((p) => ({ ...p, [key]: !p[key] }));
const setVal = (key: keyof typeof settings, val: string) =>
setSettings((p) => ({ ...p, [key]: val }));
useEffect(() => {
let active = true;
adminFetch('/api/admin/settings')
.then((r) => r.ok ? r.json() : Promise.reject(new Error('load failed')))
.then((d) => { if (active) setSettings({ ...DEFAULT_SETTINGS, ...d }); })
.catch(() => { if (active) setError('데이터를 불러올 수 없습니다'); })
.finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, []);
const toggle = (key: string) => setSettings((p) => ({ ...p, [key]: p[key] === 'true' ? 'false' : 'true' }));
const setVal = (key: string, val: string) => setSettings((p) => ({ ...p, [key]: val }));
const save = async () => {
setSaving(true); setToast(null);
try {
const res = await adminFetch('/api/admin/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) });
if (!res.ok) throw new Error();
const d = await res.json();
setSettings(d);
setToast('설정 저장됨');
} catch {
setError('저장 실패');
} finally { setSaving(false); }
};
const reset = () => { setSettings(DEFAULT_SETTINGS); setToast('기본값 복원'); };
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 'var(--space-lg)' }}>
<PageTitle> </PageTitle>
<LabelMeta><span>CONFIG:</span>v2.0.4-STABLE</LabelMeta>
<LabelMeta><span>CONFIG:</span>DB-LIVE</LabelMeta>
</div>
{toast && <Toast>{toast}</Toast>}
{error && <ErrorBox>{error}</ErrorBox>}
<SettingsGrid>
{/* SEC 01 */}
<SettingsSection>
<SectionTitle>
<span>ADMIN PROTOCOL RULES</span>
<LabelMeta>SEC: 01</LabelMeta>
</SectionTitle>
<SettingsRow>
<SettingInfo>
<SettingLabel> 2 (2FA)</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.twofa} onChange={() => toggle('twofa')} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> ().</SettingDesc>
</SettingInfo>
<BInput value={settings.sessionTimeout} onChange={(v) => setVal('sessionTimeout', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel>IP </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.ipWhitelist} onChange={() => toggle('ipWhitelist')} />
</SettingsRow>
</SettingsSection>
{/* SEC 02 */}
<SettingsSection>
<SectionTitle>
<span>BACKUP &amp; REDUNDANCY</span>
<LabelMeta>SEC: 02</LabelMeta>
</SectionTitle>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.autoBackup} onChange={() => toggle('autoBackup')} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> ()</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.backupInterval} onChange={(v) => setVal('backupInterval', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> ()</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.backupRetention} onChange={(v) => setVal('backupRetention', v)} />
</SettingsRow>
</SettingsSection>
{/* SEC 03 */}
<SettingsSection>
<SectionTitle>
<span>SYNC QUEUE THRESHOLDS</span>
<LabelMeta>SEC: 03</LabelMeta>
</SectionTitle>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.queueThreshold} onChange={(v) => setVal('queueThreshold', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.autoScaling} onChange={() => toggle('autoScaling')} />
</SettingsRow>
</SettingsSection>
{/* SEC 04 */}
<SettingsSection>
<SectionTitle>
<span>NODE ALERT TRIGGERS</span>
<LabelMeta>SEC: 04</LabelMeta>
</SectionTitle>
<SettingsRow>
<SettingInfo>
<SettingLabel>CPU (%)</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.cpuThreshold} onChange={(v) => setVal('cpuThreshold', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> (ms)</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.latencyThreshold} onChange={(v) => setVal('latencyThreshold', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.nodeOfflineAlert} onChange={() => toggle('nodeOfflineAlert')} />
</SettingsRow>
</SettingsSection>
</SettingsGrid>
<ActionBar>
<Btn onClick={() => setSettings((p) => ({ ...p }))}></Btn>
<BtnPrimary> </BtnPrimary>
</ActionBar>
{loading ? (
<SettingsGrid>
<SettingsSection><SectionTitle><span>LOADING</span></SectionTitle><SkeletonBox /><div style={{ height: 12 }} /><SkeletonBox /><div style={{ height: 12 }} /><SkeletonBox /></SettingsSection>
<SettingsSection><SectionTitle><span>LOADING</span></SectionTitle><SkeletonBox /><div style={{ height: 12 }} /><SkeletonBox /><div style={{ height: 12 }} /><SkeletonBox /></SettingsSection>
</SettingsGrid>
) : (
<>
<SettingsGrid>
<SettingsSection>
<SectionTitle><span>ADMIN PROTOCOL RULES</span><LabelMeta>SEC: 01</LabelMeta></SectionTitle>
<SettingsRow><SettingInfo><SettingLabel> 2 (2FA)</SettingLabel><SettingDesc> .</SettingDesc></SettingInfo><Toggle checked={settings.force2fa === 'true'} onChange={() => toggle('force2fa')} /></SettingsRow>
<SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> ().</SettingDesc></SettingInfo><BInput value={settings.sessionTimeoutMinutes} onChange={(v) => setVal('sessionTimeoutMinutes', v)} /></SettingsRow>
<SettingsRow><SettingInfo><SettingLabel>IP </SettingLabel><SettingDesc> .</SettingDesc></SettingInfo><Toggle checked={settings.ipWhitelistEnabled === 'true'} onChange={() => toggle('ipWhitelistEnabled')} /></SettingsRow>
<SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> .</SettingDesc></SettingInfo><Toggle checked={settings.nodeOfflineAlertEnabled === 'true'} onChange={() => toggle('nodeOfflineAlertEnabled')} /></SettingsRow>
</SettingsSection>
<SettingsSection>
<SectionTitle><span>BACKUP & THRESHOLDS</span><LabelMeta>SEC: 02</LabelMeta></SectionTitle>
<SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> .</SettingDesc></SettingInfo><Toggle checked={settings.autoBackupEnabled === 'true'} onChange={() => toggle('autoBackupEnabled')} /></SettingsRow>
<SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> (HH:mm).</SettingDesc></SettingInfo><BInput value={settings.backupSchedule} onChange={(v) => setVal('backupSchedule', v)} /></SettingsRow>
<SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> .</SettingDesc></SettingInfo><BInput value={settings.retentionDays} onChange={(v) => setVal('retentionDays', v)} /></SettingsRow>
<SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> .</SettingDesc></SettingInfo><BInput value={settings.queueWarningThreshold} onChange={(v) => setVal('queueWarningThreshold', v)} /></SettingsRow>
<SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc>CPU .</SettingDesc></SettingInfo><Toggle checked={settings.autoScalingEnabled === 'true'} onChange={() => toggle('autoScalingEnabled')} /></SettingsRow>
<SettingsRow><SettingInfo><SettingLabel>CPU </SettingLabel><SettingDesc> CPU %.</SettingDesc></SettingInfo><BInput value={settings.cpuThresholdPercent} onChange={(v) => setVal('cpuThresholdPercent', v)} /></SettingsRow>
<SettingsRow><SettingInfo><SettingLabel> </SettingLabel><SettingDesc> ms.</SettingDesc></SettingInfo><BInput value={settings.latencyAlertMs} onChange={(v) => setVal('latencyAlertMs', v)} /></SettingsRow>
</SettingsSection>
</SettingsGrid>
<ActionBar><Btn onClick={reset}></Btn><BtnPrimary onClick={save} disabled={saving}>{saving ? '저장 중...' : '설정 저장'}</BtnPrimary></ActionBar>
</>
)}
</>
);
}

View File

@@ -1,191 +1,63 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import styled, { keyframes } from 'styled-components';
import Link from 'next/link';
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
import { API_URL } from '@/lib/config';
const SISTER_ROLES: Record<string, string> = {
harang: 'Primary',
narang: 'Secondary',
darang: 'Standby',
erang: 'Sync',
};
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
const SISTER_ROLES: Record<string, string> = { harang: 'Primary', narang: 'Secondary', darang: 'Standby', erang: 'Sync' };
const NodeGrid = styled.section`display:flex;flex-direction:column;gap:var(--space-lg);`;
const NodeEntry = styled.div`display:grid;grid-template-columns:220px 1fr 160px;gap:0;border:1px solid var(--border-color);transition:border-color .2s;&:hover{border-color:var(--border-hover)}@media (max-width:1199px){grid-template-columns:200px 1fr}@media (max-width:767px){grid-template-columns:1fr}`;
const NodeInfo = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:767px){border-right:none;border-bottom:1px solid var(--border-color)}`;
const NodeName = styled(Link)`display:flex;align-items:center;gap:var(--space-sm);font-size:16px;font-weight:600;color:var(--text-primary);text-decoration:none;transition:color .15s;&:hover{color:var(--accent-hover)}`;
const StatusIndicator = styled.div<{ $active: boolean }>`width:8px;height:8px;border-radius:50%;background:${({$active})=>$active?'#FFF':'#555'};flex-shrink:0;${({$active})=>$active&&`box-shadow:0 0 8px rgba(255,255,255,.3);`}`;
const AvatarImg = styled.img`width:28px;height:28px;border-radius:50%;object-fit:cover;border:1px solid var(--border-color);background:#111;`;
const MetaPair = styled.div`display:flex;flex-direction:column;gap:2px;`;
const MetaVal = styled.div`font-size:13px;color:var(--text-primary);font-family:var(--font-mono);`;
const NodeControls = styled.div`display:flex;gap:var(--space-sm);flex-wrap:wrap;`;
const CapacitySection = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:1199px){border-right:none}@media (max-width:767px){border-bottom:1px solid var(--border-color)}`;
const BarWrap = styled.div`display:flex;flex-direction:column;gap:8px;`;
const BarRow = styled.div`display:flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);`;
const Bar = styled.div`flex:1;height:8px;background:#151515;border:1px solid #222;`;
const Fill = styled.div<{ $width:number }>`height:100%;width:${({$width})=>$width}%;background:var(--text-primary);`;
const SyncHistory = styled.div`padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-sm);@media (max-width:1199px){display:none}`;
const SyncRow = styled.div`display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);padding:2px 0;`;
const Skeleton = styled.div`height:120px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`;
const ErrorBox = styled.div`padding:var(--space-lg);border:1px solid #5a2a2a;color:#ff9b9b;`;
const GRAPH_BARS: Record<string, number[]> = {
harang: [40, 45, 60, 55, 70, 85, 92, 84],
narang: [30, 32, 35, 40, 38, 45, 50, 48],
darang: [10, 10, 5, 5, 5, 5, 2, 2],
erang: [20, 25, 15, 30, 40, 35, 20, 15],
};
const NodeGrid = styled.section`
display: flex;
flex-direction: column;
gap: var(--space-lg);
`;
const NodeEntry = styled.div`
display: grid;
grid-template-columns: 220px 1fr 160px;
gap: 0;
border: 1px solid var(--border-color);
transition: border-color 0.2s;
&:hover { border-color: var(--border-hover); }
@media (max-width: 1199px) {
grid-template-columns: 200px 1fr;
}
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
`;
const NodeInfo = styled.div`
border-right: 1px solid var(--border-color);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
@media (max-width: 767px) {
border-right: none;
border-bottom: 1px solid var(--border-color);
}
`;
const NodeName = styled(Link)`
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
text-decoration: none;
transition: color 0.15s;
&:hover { color: var(--accent-hover); }
`;
const StatusIndicator = styled.div<{ $active: boolean }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $active }) => $active ? '#FFF' : '#555'};
flex-shrink: 0;
${({ $active }) => $active && `box-shadow: 0 0 8px rgba(255,255,255,0.3);`}
`;
const MetaPair = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
`;
const MetaVal = styled.div`
font-size: 13px;
color: var(--text-primary);
font-family: var(--font-mono);
`;
const NodeControls = styled.div`
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
`;
const CapacitySection = styled.div`
border-right: 1px solid var(--border-color);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
@media (max-width: 1199px) {
border-right: none;
}
@media (max-width: 767px) {
border-bottom: 1px solid var(--border-color);
}
`;
const GraphContainer = styled.div`
display: flex;
align-items: flex-end;
gap: 3px;
height: 48px;
`;
const GraphBar = styled.div<{ $height: number; $highlight?: boolean }>`
flex: 1;
height: ${({ $height }) => $height}%;
min-height: 2px;
background: ${({ $highlight }) => $highlight ? 'var(--text-primary)' : 'var(--border-hover)'};
transition: height 0.3s;
`;
const SyncRow = styled.div`
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
padding: 2px 0;
`;
const SyncHistory = styled.div`
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-sm);
@media (max-width: 1199px) {
display: none;
}
`;
function getNodeStatus(s: any): boolean {
return s.status === 'online' || s.status === 'working';
}
function getStatusBtns(s: any): [string, string] {
if (s.status === 'offline') return ['STANDBY', 'ACTIVATE'];
if (s.status === 'working') return ['ACTIVE', 'REBOOT'];
return ['ACTIVE', 'REBOOT'];
}
function formatLastSeen(lastSeen: string | null): string {
if (!lastSeen) return '—';
const diff = Date.now() - new Date(lastSeen).getTime();
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ago`;
}
const MOCK_SISTERS = [
{ id: 1, name: 'harang', status: 'online', lastSeen: new Date(Date.now() - 3600000).toISOString() },
{ id: 2, name: 'narang', status: 'working', lastSeen: new Date(Date.now() - 1800000).toISOString() },
{ id: 3, name: 'darang', status: 'offline', lastSeen: new Date(Date.now() - 7200000).toISOString() },
{ id: 4, name: 'erang', status: 'online', lastSeen: new Date(Date.now() - 900000).toISOString() },
];
function getNodeStatus(s: any): boolean { return s.status === 'online' || s.status === 'working'; }
function getStatusBtns(s: any): [string, string] { if (s.status === 'offline') return ['STANDBY', 'ACTIVATE']; return ['ACTIVE', 'REBOOT']; }
export default function SistersPage() {
const [sisters, setSisters] = useState<any[]>(MOCK_SISTERS);
const [sisters, setSisters] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`${API_URL}/api/sisters`)
.then((r) => r.json())
.then(setSisters)
.catch(() => {});
const iv = setInterval(() => {
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
}, 15000);
return () => clearInterval(iv);
let active = true;
const load = async () => {
try {
const base = await fetch(`${API_URL}/api/sisters`).then((r) => r.ok ? r.json() : Promise.reject());
const withSystem = await Promise.all(base.map(async (s: any) => {
try {
const system = await fetch(`${API_URL}/api/sisters/${s.name}/system`).then((r) => r.ok ? r.json() : null);
return { ...s, ...(system ?? {}) };
} catch {
return { ...s, uptime: '00:00:00', cpu: 0, memory: { used: 0, total: 0 }, disk: { used: '0G', total: '0G' } };
}
}));
if (active) { setSisters(withSystem); setError(null); }
} catch {
if (active) setError('데이터를 불러올 수 없습니다');
} finally {
if (active) setLoading(false);
}
};
load();
const iv = setInterval(load, 15000);
return () => { active = false; clearInterval(iv); };
}, []);
return (
@@ -194,66 +66,35 @@ export default function SistersPage() {
<PageTitle> </PageTitle>
<LabelMeta><span>TOTAL NODES:</span> {String(sisters.length).padStart(2, '0')}</LabelMeta>
</div>
{error && <ErrorBox>{error}</ErrorBox>}
<NodeGrid>
{sisters.map((s) => {
{loading ? Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} />) : sisters.map((s) => {
const isActive = getNodeStatus(s);
const [btn1, btn2] = getStatusBtns(s);
const bars = GRAPH_BARS[s.name] ?? [5, 5, 5, 5, 5, 5, 5, 5];
const loadIndex = isActive
? `${(bars[bars.length - 1]).toFixed(1)}%`
: '0.0%';
const metaLabel = s.status === 'offline' ? 'Last Active' : s.name === 'erang' ? 'Queue Depth' : 'Uptime';
const metaVal = s.status === 'offline'
? formatLastSeen(s.lastSeen)
: s.name === 'erang' ? '12 PKTS'
: formatLastSeen(s.lastSeen).replace(' ago', '');
return (
<NodeEntry key={s.id ?? s.name}>
<NodeInfo>
<NodeName href={`/sisters/${s.name}`}>
<StatusIndicator $active={isActive} />
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'}{' '}
({SISTER_ROLES[s.name] ?? s.name})
<AvatarImg src={`${API_URL}/api/sisters/${s.name}/avatar`} alt={s.name} />
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'} ({SISTER_ROLES[s.name] ?? s.name})
</NodeName>
<MetaPair>
<LabelMeta>{metaLabel}</LabelMeta>
<MetaVal>{metaVal}</MetaVal>
</MetaPair>
<NodeControls>
<BtnToggle $active>{btn1}</BtnToggle>
<BtnToggle>{btn2}</BtnToggle>
</NodeControls>
<MetaPair><LabelMeta>Uptime</LabelMeta><MetaVal>{s.uptime ?? '00:00:00'}</MetaVal></MetaPair>
<MetaPair><LabelMeta>Last Seen</LabelMeta><MetaVal>{s.lastSeen ? new Date(s.lastSeen).toLocaleString('ko-KR') : '—'}</MetaVal></MetaPair>
<NodeControls><BtnToggle>{btn1}</BtnToggle><BtnToggle>{btn2}</BtnToggle></NodeControls>
</NodeInfo>
<CapacitySection>
<LabelMeta>CAPACITY HISTORY (24H)</LabelMeta>
<GraphContainer>
{bars.map((h, i) => (
<GraphBar key={i} $height={h} $highlight={i >= bars.length - 2} />
))}
</GraphContainer>
<SyncRow>
<span>LOAD INDEX</span>
<span>{loadIndex}</span>
</SyncRow>
<BarWrap>
<BarRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span><Bar><Fill $width={Math.max(0, Math.min(100, Number(s.cpu ?? 0)))} /></Bar></BarRow>
<BarRow><span>MEM</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span><Bar><Fill $width={s.memory?.total ? (s.memory.used / s.memory.total) * 100 : 0} /></Bar></BarRow>
<BarRow><span>DSK</span><span>{s.disk?.used ?? '0G'}/{s.disk?.total ?? '0G'}</span><Bar><Fill $width={0} /></Bar></BarRow>
</BarWrap>
</CapacitySection>
<SyncHistory>
<LabelMeta>SYNC LOG</LabelMeta>
{[
{ time: '14:05:22', status: isActive ? 'SUCCESS' : 'IDLE' },
{ time: '13:55:01', status: isActive ? 'SUCCESS' : 'IDLE' },
{ time: '13:44:10', status: isActive ? 'SUCCESS' : 'IDLE' },
].map((row) => (
<SyncRow key={row.time}>
<span>{row.time}</span>
<span style={{ color: row.status === 'SUCCESS' ? '#00FF00' : 'var(--text-secondary)' }}>
{row.status}
</span>
</SyncRow>
))}
<SyncRow><span>STATUS</span><span>{String(s.status).toUpperCase()}</span></SyncRow>
<SyncRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span></SyncRow>
<SyncRow><span>MEMORY</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span></SyncRow>
<SyncRow><span>DISK</span><span>{s.disk?.used ?? '0G'} / {s.disk?.total ?? '0G'}</span></SyncRow>
</SyncHistory>
</NodeEntry>
);