- 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 성공
104 lines
7.3 KiB
TypeScript
104 lines
7.3 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState, useCallback } from 'react';
|
|
import styled, { keyframes } from 'styled-components';
|
|
import Link from 'next/link';
|
|
import {
|
|
PageHeader, PageTitle, Card, BracketValue, LabelMeta,
|
|
TechBar, TechBarFill, SectionTitle, Timeline, TimelineItem,
|
|
TimeStamp, TimelineContent,
|
|
} from '@/components/ui/base';
|
|
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
|
|
import { useSocket } from '@/lib/useSocket';
|
|
|
|
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);`;
|
|
|
|
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[]>([]);
|
|
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 { connected } = useSocket({ onSistersUpdate: handleSistersUpdate, onActivityNew: handleActivityNew });
|
|
|
|
useEffect(() => {
|
|
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) setActivityItems((await aRes.value.json()).items ?? []);
|
|
setError(null);
|
|
} catch {
|
|
if (active) setError('데이터를 불러올 수 없습니다');
|
|
} finally {
|
|
if (active) setLoading(false);
|
|
}
|
|
};
|
|
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>
|
|
{error && <ErrorBox>{error}</ErrorBox>}
|
|
<StatusGrid>
|
|
{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>{sisterSubLabel(s)}</LabelMeta></div>
|
|
<TechBar><TechBarFill $width={sisterBarWidth(s)} /></TechBar>
|
|
</Card>
|
|
))}
|
|
</StatusGrid>
|
|
<DataColumns>
|
|
<section>
|
|
<SectionTitle><span>ONGOING PROJECTS</span><LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta></SectionTitle>
|
|
<ProjectList>
|
|
{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>
|
|
<section>
|
|
<SectionTitle><span>ACTIVITY FEED</span><LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta></SectionTitle>
|
|
<Timeline>
|
|
{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>
|
|
</>
|
|
);
|
|
}
|