154 lines
10 KiB
TypeScript
154 lines
10 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';
|
|
import SisterAvatar from '@/components/common/SisterAvatar';
|
|
|
|
interface SisterItem { id?: number; name: string; status: string; cpu?: number; uptime?: string; }
|
|
interface ProjectItem { id: number; name: string; description: string | null; phase: string; progress: number; deployStatus: string; ownerSister?: string; }
|
|
interface ActivityItem { id: number; createdAt: string; detail?: string; action?: string; sister?: { name: string } | null; }
|
|
|
|
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 ChartGrid = styled.section`display:grid;grid-template-columns:1fr 1fr;gap:var(--space-xl);margin-bottom:var(--space-xxl);@media (max-width:1199px){grid-template-columns:1fr;}`;
|
|
const ChartCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);min-width:0;overflow:hidden;`;
|
|
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 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 BarChart = styled.div`display:flex;align-items:stretch;gap:10px;height:160px;margin-top:var(--space-lg);min-width:0;overflow-x:auto;overflow-y:hidden;padding-bottom:var(--space-xs);-webkit-overflow-scrolling:touch;scrollbar-width:thin;@media (max-width:767px){gap:8px;height:140px;scrollbar-width:none;&::-webkit-scrollbar{display:none;}}`;
|
|
const BarCol = styled.div`display:flex;flex-direction:column;align-items:center;justify-content:flex-end;gap:8px;flex:0 0 52px;min-width:52px;height:100%;@media (max-width:767px){flex-basis:48px;min-width:48px;}`;
|
|
const Bar = styled.div<{ $height:number }>`width:100%;max-width:48px;height:${({$height})=>$height}%;background:var(--text-primary);min-height:${({$height})=>$height > 0 ? '6px' : '0'};`;
|
|
const BarLabel = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
|
const SISTER_SHORT: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
|
|
|
|
function sisterBracketValue(sister: SisterItem): string {
|
|
if (sister.status === 'online') return '[ON]';
|
|
if (sister.status === 'working') return '[RUN]';
|
|
if (sister.status === 'offline') return '[--]';
|
|
return '[??]';
|
|
}
|
|
|
|
function sisterBarWidth(sister: SisterItem): number {
|
|
if (typeof sister.cpu === 'number') return Math.max(0, Math.min(100, sister.cpu));
|
|
if (sister.status === 'online') return 100;
|
|
if (sister.status === 'working') return 84;
|
|
if (sister.status === 'offline') return 0;
|
|
return 12;
|
|
}
|
|
|
|
function sisterSubLabel(sister: SisterItem): string {
|
|
return sister.uptime ? `Hermes ${sister.uptime}` : sister.status;
|
|
}
|
|
|
|
function formatTimestamp(iso: string): string {
|
|
const date = new Date(iso);
|
|
return `${date.toLocaleTimeString('ko-KR', { hour12: false })} GMT+9`;
|
|
}
|
|
|
|
export default function DashboardPage() {
|
|
const [sisters, setSisters] = useState<SisterItem[]>([]);
|
|
const [projects, setProjects] = useState<ProjectItem[]>([]);
|
|
const [activityItems, setActivityItems] = useState<ActivityItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const handleSistersUpdate = useCallback((items: SisterItem[]) => setSisters(items), []);
|
|
const handleActivityNew = useCallback((item: ActivityItem) => 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((_, index) => <SkeletonCard key={index} />) : sisters.map((sister) => (
|
|
<Card key={sister.id ?? sister.name} as={Link} href={`/sisters/${sister.name}`} style={{ textDecoration: 'none' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<LabelMeta><span>SYS:</span>{SISTER_SHORT[sister.name] ?? sister.name}</LabelMeta>
|
|
<SisterAvatar name={sister.name} size={24} />
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}>
|
|
<BracketValue $dimmed={sister.status === 'offline'}>{sisterBracketValue(sister)}</BracketValue>
|
|
<LabelMeta>{sisterSubLabel(sister)}</LabelMeta>
|
|
</div>
|
|
<TechBar><TechBarFill $width={sisterBarWidth(sister)} /></TechBar>
|
|
</Card>
|
|
))}
|
|
</StatusGrid>
|
|
|
|
<ChartGrid>
|
|
<ChartCard>
|
|
<SectionTitle><span>HERMES NODE LOAD</span><LabelMeta>CPU %</LabelMeta></SectionTitle>
|
|
<BarChart>{(sisters.length > 0 ? sisters : [{ name: 'harang', status: 'offline' }, { name: 'narang', status: 'offline' }, { name: 'darang', status: 'offline' }, { name: 'erang', status: 'offline' }]).map((sister) => <BarCol key={sister.name}><Bar $height={Math.max(4, Number(sister.cpu ?? 0))} /><BarLabel>{SISTER_SHORT[sister.name] ?? sister.name}</BarLabel></BarCol>)}</BarChart>
|
|
</ChartCard>
|
|
<ChartCard>
|
|
<SectionTitle><span>PROJECT PHASE</span><LabelMeta>TOP {projects.length}</LabelMeta></SectionTitle>
|
|
<BarChart>{(projects.length > 0 ? projects.slice(0, 6) : [{ id: 0, name: 'EMPTY', phase: 'PLANNING', progress: 0, deployStatus: 'REDEPLOY REQUIRED', description: null }]).map((project) => <BarCol key={project.id}><Bar $height={Math.max(4, Number(project.progress ?? 0))} /><BarLabel>{project.phase.slice(0, 6)}</BarLabel></BarCol>)}</BarChart>
|
|
</ChartCard>
|
|
</ChartGrid>
|
|
|
|
<DataColumns>
|
|
<section>
|
|
<SectionTitle><span>ONGOING PROJECTS</span><LabelMeta>PROJECT = 2ND NAV</LabelMeta></SectionTitle>
|
|
<ProjectList>
|
|
{loading ? Array.from({ length: 4 }).map((_, index) => <SkeletonLine key={index} />) : projects.length === 0 ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}>등록된 프로젝트 없음</div> : projects.map((project) => (
|
|
<ProjectRow key={project.id} href={`/projects/${project.id}`}>
|
|
<SisterAvatar name={project.ownerSister ?? 'narang'} size={32} />
|
|
<ProjectDetails>
|
|
<ProjectName>{project.name}</ProjectName>
|
|
<ProjectDesc>{project.phase} · {project.deployStatus}</ProjectDesc>
|
|
</ProjectDetails>
|
|
<LabelMeta>{project.progress ?? 0}%</LabelMeta>
|
|
</ProjectRow>
|
|
))}
|
|
</ProjectList>
|
|
</section>
|
|
<section>
|
|
<SectionTitle><span>ACTIVITY FEED</span><LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta></SectionTitle>
|
|
<Timeline>
|
|
{loading ? Array.from({ length: 5 }).map((_, index) => <SkeletonLine key={index} />) : 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>
|
|
</>
|
|
);
|
|
}
|