Files
hanarang-dashboard/frontend/app/page.tsx

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