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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
26
backend/src/events/sisters.scheduler.ts
Normal file
26
backend/src/events/sisters.scheduler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
26
backend/src/settings/settings.controller.ts
Normal file
26
backend/src/settings/settings.controller.ts
Normal 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 ?? {});
|
||||
}
|
||||
}
|
||||
13
backend/src/settings/settings.module.ts
Normal file
13
backend/src/settings/settings.module.ts
Normal 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 {}
|
||||
56
backend/src/settings/settings.service.ts
Normal file
56
backend/src/settings/settings.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user