Files
hanarang-dashboard/frontend/app/settings/page.tsx
나랑이 abef8576fe 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 성공
2026-04-04 15:56:12 +09:00

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>
</>
)}
</>
);
}