- 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 성공
151 lines
9.6 KiB
TypeScript
151 lines
9.6 KiB
TypeScript
'use client';
|
|
|
|
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; }
|
|
`;
|
|
|
|
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); }
|
|
`;
|
|
const SettingsSection = styled.section``;
|
|
const SettingsRow = styled.div`
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
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;`;
|
|
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;
|
|
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
|
|
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)'};
|
|
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 BracketInput = styled.input`
|
|
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 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>;
|
|
}
|
|
function BInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
return <BracketInputGroup>[<BracketInput value={value} onChange={(e) => onChange(e.target.value)} />]</BracketInputGroup>;
|
|
}
|
|
|
|
export default function SettingsPage() {
|
|
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);
|
|
|
|
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>DB-LIVE</LabelMeta>
|
|
</div>
|
|
{toast && <Toast>{toast}</Toast>}
|
|
{error && <ErrorBox>{error}</ErrorBox>}
|
|
|
|
{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>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|