Compare commits

...

5 Commits

Author SHA1 Message Date
fb21678870 Merge pull request 'SPRINT-012: 정보 구조 재배치 + 대시보드 그래프 강화' (#11) from feature/sprint-012 into main 2026-04-04 20:57:34 +09:00
a4f391d15b QA: SPRINT-012 review iteration 1 — PASSED 2026-04-04 20:56:10 +09:00
ad0b489633 feat(sprint-012): 프로젝트 UI 재설계 + 자매 상세 탭 통합 + 대시보드 그래프
TASK-052: 프로젝트 목록 3구역 구조로 재배치 (avatar/name-desc | progress+sprint counts | status+updated)
TASK-053: 프로젝트 대표 avatar를 자매 프로필 사진으로 교체 (ownerSister 기반)
TASK-054: 자매 상세 탭에 세션/활동/하네스/로그 통합
TASK-055: 메인 대시보드에 실데이터 막대 그래프 2종 추가 (sister load, project progress)
TASK-056: 관리자 상단 탭에서 하네스 제거 (자매 상세로 이동)

테스트 23/23 pass, FE 16 routes build 성공
2026-04-04 20:54:45 +09:00
9f6bba9aeb 📋 Sprint 012 계획 추가
- 프로젝트 진행률 UI 재설계
- 자매 프로필 사진 활용
- 하네스/로그를 자매 상세로 통합
- 대시보드 실데이터 그래프 강화
2026-04-04 20:49:59 +09:00
7a91589ded Merge pull request 'HOTFIX-004: DISK bar + 카드 전체 클릭 + 운영 메타 교체' (#10) from hotfix/sisters-card-ui into main 2026-04-04 20:19:18 +09:00
7 changed files with 269 additions and 380 deletions

View File

@@ -0,0 +1,59 @@
# SPRINT-012: 정보 구조 재배치 + 대시보드 시각화 강화
## 목표
대시보드다운 정보 구조로 재정리. 프로젝트 목록 진행률 UI 개선, 자매 프로필 사진 활용, 하네스/로그를 자매 상세로 이동, 실데이터 기반 그래프 강화
## 태스크
### TASK-052: 프로젝트 목록 UI 재설계
- 현재 진행률 UI(설명 옆 퍼센트/바) 제거
- 프로젝트 행/카드를 3구역으로 재배치:
1. 왼쪽: 대표 자매 사진 + 프로젝트 이름 + 설명
2. 가운데: 진행률 바 + 퍼센트 + Sprint counts
3. 오른쪽: 상태 태그 + 최근 업데이트
- 진행률 바 시각적 강화
- 프로젝트 상세와 시각적으로 연결감 있게 정리
### TASK-053: 프로젝트 대표 아바타 개선
- 기본 아이콘 제거
- 프로젝트 담당 자매 또는 최근 활동 자매의 실제 프로필 사진 사용
- 프로젝트 카드/리스트에 아바타 적용
- 이미지 없을 때만 fallback
### TASK-054: 자매 상세에 하네스 편집/로그 뷰어 통합
- `/sisters/[name]`에 새 탭 추가:
- 개요
- 세션
- 활동
- 하네스
- 로그
- 기존 관리자 페이지의 자매별 하네스/로그 기능을 자매 상세로 이동
- 전체 로그 페이지(`/activities`, `/admin/logs`)는 유지
### TASK-055: 대시보드 그래프 추가
- 실데이터 기반 그래프 2~3개 추가
- 후보:
- 자매 활동량 막대그래프 (최근 24h)
- 프로젝트 진행률 차트
- 자매 리소스 스파크라인
- 메인 대시보드에서 “관제/모니터링” 느낌 강화
- 가짜 그래프 금지, 실제 API 데이터만 사용
### TASK-056: 관리자 페이지 단순화
- 자매 상세로 이동한 기능만큼 관리자 페이지 정리
- 관리자 페이지는 시스템 전역 기능 위주로 축소
- 중복 진입점 제거
## 의존성
- TASK-052 ↔ TASK-053 함께 진행 가능
- TASK-054 후 TASK-056 정리
- TASK-055는 병렬 가능
## 검증 기준
- 프로젝트 목록에서 진행률 UI가 더 자연스럽고 명확하게 보임
- 프로젝트 목록에 실제 자매 프로필 사진 반영
- 자매 상세에서 하네스 편집/로그 뷰어 접근 가능
- 전체 로그 페이지는 유지
- 대시보드에 실제 데이터 기반 그래프 추가
- 빌드/테스트 통과
- 외부 URL QA 필수

View File

@@ -0,0 +1,23 @@
# SPRINT-012 QA Review — Iteration 1
- **검증일시:** 2026-04-04 20:55 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm test | ✅ 23/23 pass |
| npm run build (BE) | ✅ |
| npm run build (FE) | ✅ 16 routes |
## 변경 확인
| Task | 내용 | 확인 |
|------|------|------|
| TASK-052 | 프로젝트 목록 3구역 재배치 | ✅ `1.4fr 1fr 180px` grid |
| TASK-053 | 프로젝트 아바타 → 자매 프로필 사진 | ✅ SisterAvatar(ownerSister) |
| TASK-054 | 자매 상세 탭 통합 (하네스/로그) | ✅ 6탭: 개요/세션/활동/하네스/로그/설정 |
| TASK-055 | 대시보드 그래프 2종 | ✅ Sister CPU load + Project progress BarChart |
| TASK-056 | 관리자 탭 단순화 | ✅ |

View File

@@ -38,6 +38,7 @@ export class ProjectsService {
const totalSprints = p.sprints.length; const totalSprints = p.sprints.length;
const doneSprints = p.sprints.filter((s) => s.status === 'done').length; const doneSprints = p.sprints.filter((s) => s.status === 'done').length;
const inProgressSprint = p.sprints.find((s) => s.status === 'in_progress'); const inProgressSprint = p.sprints.find((s) => s.status === 'in_progress');
const ownerSister = p.sprints.flatMap((s) => s.tasks).find((t) => t.assignee)?.assignee ?? 'narang';
const progress = totalSprints > 0 ? Math.round((doneSprints / totalSprints) * 100) : 0; const progress = totalSprints > 0 ? Math.round((doneSprints / totalSprints) * 100) : 0;
const gitea = giteaRepos[p.giteaId]; const gitea = giteaRepos[p.giteaId];
@@ -52,6 +53,7 @@ export class ProjectsService {
progress, progress,
totalSprints, totalSprints,
doneSprints, doneSprints,
ownerSister,
openPRs: gitea?.openPRs ?? 0, openPRs: gitea?.openPRs ?? 0,
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(), updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
sprintCount: totalSprints, sprintCount: totalSprints,

View File

@@ -7,7 +7,6 @@ import { usePathname } from 'next/navigation';
const ADMIN_TABS = [ const ADMIN_TABS = [
{ href: '/admin', label: '자매 관리' }, { href: '/admin', label: '자매 관리' },
{ href: '/admin/harness', label: '하네스 편집' },
{ href: '/admin/logs', label: '로그 뷰어' }, { href: '/admin/logs', label: '로그 뷰어' },
{ href: '/admin/costs', label: '비용 모니터' }, { href: '/admin/costs', label: '비용 모니터' },
{ href: '/admin/repos', label: '저장소' }, { href: '/admin/repos', label: '저장소' },

View File

@@ -15,9 +15,10 @@ import SisterAvatar from '@/components/common/SisterAvatar';
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`; 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 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 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);`;
const ProjectList = styled.div`display:flex;flex-direction:column;`; 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 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 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 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 ProjectDesc = styled.div`font-size:13px;color:var(--text-secondary);line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
@@ -25,7 +26,10 @@ const WsIndicator = styled.span<{ $on:boolean }>`font-size:11px;font-family:var(
const SkeletonCard = styled.div`height:142px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`; 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 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 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:flex-end;gap:10px;height:160px;margin-top:var(--space-lg);`;
const BarCol = styled.div`display:flex;flex-direction:column;align-items:center;gap:8px;flex:1;`;
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);`;
const SISTER_SHORT: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' }; 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 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 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; }
@@ -75,21 +79,30 @@ export default function DashboardPage() {
<StatusGrid> <StatusGrid>
{loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />) : sisters.map((s) => ( {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' }}> <Card key={s.id ?? s.name} as={Link} href={`/sisters/${s.name}`} style={{ textDecoration: 'none' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta><SisterAvatar name={s.name} size={24} /></div>
<LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta>
<SisterAvatar name={s.name} size={24} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}><BracketValue $dimmed={s.status === 'offline'}>{sisterBracketValue(s)}</BracketValue><LabelMeta>{sisterSubLabel(s)}</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> <TechBar><TechBarFill $width={sisterBarWidth(s)} /></TechBar>
</Card> </Card>
))} ))}
</StatusGrid> </StatusGrid>
<ChartGrid>
<ChartCard>
<SectionTitle><span>SISTER LOAD</span><LabelMeta>CPU %</LabelMeta></SectionTitle>
<BarChart>{sisters.map((s) => <BarCol key={s.name}><Bar $height={Math.max(4, Number(s.cpu ?? 0))} /><BarLabel>{SISTER_SHORT[s.name] ?? s.name}</BarLabel></BarCol>)}</BarChart>
</ChartCard>
<ChartCard>
<SectionTitle><span>PROJECT PROGRESS</span><LabelMeta>TOP {projects.length}</LabelMeta></SectionTitle>
<BarChart>{projects.slice(0, 6).map((p) => <BarCol key={p.id}><Bar $height={Math.max(4, Number(p.progress ?? 0))} /><BarLabel>{p.name.slice(0, 6)}</BarLabel></BarCol>)}</BarChart>
</ChartCard>
</ChartGrid>
<DataColumns> <DataColumns>
<section> <section>
<SectionTitle><span>ONGOING PROJECTS</span><LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta></SectionTitle> <SectionTitle><span>ONGOING PROJECTS</span><LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta></SectionTitle>
<ProjectList> <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) => ( {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> <ProjectRow key={p.id} href={`/projects/${p.id}`}><SisterAvatar name={p.ownerSister ?? 'narang'} size={32} /><ProjectDetails><ProjectName>{p.name}</ProjectName><ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc></ProjectDetails><LabelMeta>{p.progress ?? 0}%</LabelMeta></ProjectRow>
))} ))}
</ProjectList> </ProjectList>
</section> </section>

View File

@@ -3,85 +3,22 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn, BtnPrimary } from '@/components/ui/base'; import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn } from '@/components/ui/base';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
import { adminFetch } from '@/lib/adminFetch'; import { adminFetch } from '@/lib/adminFetch';
import SisterAvatar from '@/components/common/SisterAvatar';
const ProjectList = styled.div` const ProjectList = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`;
display: flex; const ProjectRow = styled(Link)`display:grid;grid-template-columns:1.4fr 1fr 180px;gap:var(--space-lg);padding:var(--space-lg);border:1px solid var(--border-color);align-items:center;text-decoration:none;transition:border-color .15s, background .15s;&:hover{border-color:var(--border-hover);background:#111}@media (max-width:1199px){grid-template-columns:1fr;}`;
flex-direction: column; const Left = styled.div`display:flex;align-items:flex-start;gap:var(--space-md);min-width:0;`;
`; const NameBlock = styled.div`display:flex;flex-direction:column;gap:4px;min-width:0;`;
const ProjectName = styled.div`font-size:15px;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const ProjectRow = styled(Link)` const ProjectDesc = styled.div`font-size:12px;color:var(--text-secondary);line-height:1.5;`;
display: grid; const Middle = styled.div`display:flex;flex-direction:column;gap:8px;`;
grid-template-columns: 40px 1fr auto; const ProgressRow = styled.div`display:flex;align-items:center;gap:var(--space-sm);`;
gap: var(--space-md); const Right = styled.div`display:flex;flex-direction:column;align-items:flex-end;gap:8px;@media (max-width:1199px){align-items:flex-start;}`;
padding: var(--space-md) 0; const Meta = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`;
border-bottom: 1px solid #222; const EmptyState = styled.div`padding:var(--space-xl) 0;color:var(--text-secondary);font-size:13px;font-family:var(--font-mono);`;
align-items: center;
text-decoration: none;
transition: opacity 0.15s;
&:last-child { border-bottom: none; }
&:hover { opacity: 0.75; }
@media (max-width: 767px) {
grid-template-columns: 32px 1fr auto;
gap: var(--space-sm);
}
`;
const Avatar = styled.div`
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #333;
filter: grayscale(100%) contrast(120%);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
border: 1px solid var(--border-color);
`;
const ProjectDetails = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
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 ProjectMeta = styled.div`
display: flex;
align-items: center;
gap: var(--space-sm);
`;
const ProjectDesc = styled.div`
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 400px;
`;
const EmptyState = styled.div`
padding: var(--space-xl) 0;
color: var(--text-secondary);
font-size: 13px;
font-family: var(--font-mono);
`;
export default function ProjectsPage() { export default function ProjectsPage() {
const [projects, setProjects] = useState<any[]>([]); const [projects, setProjects] = useState<any[]>([]);
@@ -91,41 +28,27 @@ export default function ProjectsPage() {
const loadProjects = () => { const loadProjects = () => {
setLoading(true); setLoading(true);
fetch(`${API_URL}/api/projects`) fetch(`${API_URL}/api/projects`).then((r) => r.json()).then(setProjects).catch(() => {}).finally(() => setLoading(false));
.then((r) => r.json())
.then(setProjects)
.catch(() => {})
.finally(() => setLoading(false));
}; };
const handleSync = async () => { const handleSync = async () => {
setSyncing(true); setSyncing(true); setSyncResult(null);
setSyncResult(null);
try { try {
const res = await adminFetch('/api/admin/gitea/sync', { method: 'POST' }); const res = await adminFetch('/api/admin/gitea/sync', { method: 'POST' });
const d = await res.json(); const d = await res.json();
setSyncResult(`sync: ${d.created ?? 0} new, ${d.updated ?? 0} updated`); setSyncResult(`sync: ${d.created ?? 0} new, ${d.updated ?? 0} updated`);
loadProjects(); loadProjects();
} catch (e) { } catch { setSyncResult('sync failed'); } finally { setSyncing(false); }
setSyncResult('sync failed');
} finally {
setSyncing(false);
}
}; };
const handleSprintSync = async () => { const handleSprintSync = async () => {
setSyncing(true); setSyncing(true); setSyncResult(null);
setSyncResult(null);
try { try {
const res = await adminFetch('/api/projects/sync-all-sprints', { method: 'POST' }); const res = await adminFetch('/api/projects/sync-all-sprints', { method: 'POST' });
const d = await res.json(); const d = await res.json();
setSyncResult(`sprint sync: ${d.synced ?? 0} synced across ${d.projects ?? 0} projects`); setSyncResult(`sprint sync: ${d.synced ?? 0} synced across ${d.projects ?? 0} projects`);
loadProjects(); loadProjects();
} catch (e) { } catch { setSyncResult('sprint sync failed'); } finally { setSyncing(false); }
setSyncResult('sprint sync failed');
} finally {
setSyncing(false);
}
}; };
useEffect(() => { loadProjects(); }, []); useEffect(() => { loadProjects(); }, []);
@@ -135,41 +58,35 @@ export default function ProjectsPage() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}>
<PageTitle></PageTitle> <PageTitle></PageTitle>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
{syncResult && ( {syncResult && <span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)' }}>{syncResult}</span>}
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)' }}>
{syncResult}
</span>
)}
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta> <LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
<Btn onClick={handleSync} disabled={syncing}> <Btn onClick={handleSync} disabled={syncing}>{syncing ? 'SYNCING...' : '↻ GITEA SYNC'}</Btn>
{syncing ? 'SYNCING...' : '↻ GITEA SYNC'} <Btn onClick={handleSprintSync} disabled={syncing}>{syncing ? '...' : '↻ SPRINT SYNC'}</Btn>
</Btn>
<Btn onClick={handleSprintSync} disabled={syncing}>
{syncing ? '...' : '↻ SPRINT SYNC'}
</Btn>
</div> </div>
</div> </div>
{loading ? ( {loading ? <EmptyState>LOADING...</EmptyState> : projects.length === 0 ? <EmptyState>NO PROJECTS REGISTERED</EmptyState> : (
<EmptyState>LOADING...</EmptyState>
) : projects.length === 0 ? (
<EmptyState>NO PROJECTS REGISTERED</EmptyState>
) : (
<ProjectList> <ProjectList>
{projects.map((p) => ( {projects.map((p) => (
<ProjectRow key={p.id} href={`/projects/${p.id}`}> <ProjectRow key={p.id} href={`/projects/${p.id}`}>
<Avatar>📁</Avatar> <Left>
<ProjectDetails> <SisterAvatar name={p.ownerSister ?? 'narang'} size={40} />
<ProjectName>{p.name}</ProjectName> <NameBlock>
<ProjectMeta> <ProjectName>{p.name}</ProjectName>
<ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc> <ProjectDesc>{p.description ?? '설명 없음'}</ProjectDesc>
<TechBar style={{ width: '80px', flexShrink: 0 }}> </NameBlock>
<TechBarFill $width={p.progress ?? 0} /> </Left>
</TechBar> <Middle>
<LabelMeta style={{ flexShrink: 0, fontSize: '10px' }}>{p.progress ?? 0}%</LabelMeta> <ProgressRow>
</ProjectMeta> <TechBar style={{ flex: 1 }}><TechBarFill $width={p.progress ?? 0} /></TechBar>
</ProjectDetails> <LabelMeta style={{ minWidth: 44, textAlign: 'right' }}>{p.progress ?? 0}%</LabelMeta>
<LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta> </ProgressRow>
<Meta>{p.doneSprints ?? 0} / {p.totalSprints ?? 0} SPRINTS DONE</Meta>
</Middle>
<Right>
<LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta>
<Meta>{new Date(p.updatedAt).toLocaleDateString('ko-KR')} UPDATED</Meta>
</Right>
</ProjectRow> </ProjectRow>
))} ))}
</ProjectList> </ProjectList>

View File

@@ -1,160 +1,41 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base'; import { LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent, Btn } from '@/components/ui/base';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
import SisterAvatar from '@/components/common/SisterAvatar'; import SisterAvatar from '@/components/common/SisterAvatar';
import { SISTER_ROLES } from '@/lib/sisters'; import { SISTER_ROLES } from '@/lib/sisters';
import { adminFetch } from '@/lib/adminFetch';
import CodeEditor from '@/components/admin/CodeEditor';
import LogTerminal from '@/components/admin/LogTerminal';
const Breadcrumb = styled.div` const Breadcrumb = styled.div`font-family: var(--font-mono);font-size: 11px;color: var(--text-secondary);margin-bottom: var(--space-md);a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }`;
font-family: var(--font-mono); const Hero = styled.section`display:grid;grid-template-columns:128px 1fr;gap:var(--space-xl);align-items:center;padding:var(--space-xl);border:1px solid var(--border-color);margin-bottom:var(--space-xl);@media (max-width:767px){grid-template-columns:1fr;justify-items:start;}`;
font-size: 11px; const HeroMeta = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`;
color: var(--text-secondary); const HeroName = styled.div`font-size:36px;font-weight:700;letter-spacing:-0.03em;color:var(--text-primary);`;
margin-bottom: var(--space-md); const HeroRole = styled.div`font-size:14px;color:var(--text-secondary);font-family:var(--font-mono);`;
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } } const HeroStatus = styled.div`display:flex;align-items:center;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap;`;
`; const StatusDot = styled.div<{ $on: boolean }>`width:10px;height:10px;border-radius:50%;background:${({$on})=>$on?'#FFF':'#555'};${({$on})=>$on&&`box-shadow:0 0 8px rgba(255,255,255,0.3);`}`;
const HeroStats = styled.div`display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--space-md);margin-top:var(--space-lg);@media (max-width:767px){grid-template-columns:repeat(2,minmax(0,1fr));}`;
const Hero = styled.section` const StatCard = styled.div`border:1px solid var(--border-color);padding:var(--space-md);`;
display: grid; const StatLabel = styled.div`font-size:10px;color:var(--text-secondary);font-family:var(--font-mono);margin-bottom:6px;`;
grid-template-columns: 128px 1fr; const StatValue = styled.div`font-size:18px;color:var(--text-primary);font-family:var(--font-mono);`;
gap: var(--space-xl); const DetailGrid = styled.div`display:grid;grid-template-columns:320px 1fr;gap:var(--space-xxl);align-items:start;@media (max-width:1199px){grid-template-columns:1fr;gap:var(--space-xl);}`;
align-items: center; const MetaPanel = styled.div`display:flex;flex-direction:column;gap:var(--space-lg);`;
padding: var(--space-xl); const MetaCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);`;
border: 1px solid var(--border-color); const ConfigPre = styled.pre`background:#0d0d0d;border:1px solid var(--border-color);padding:var(--space-md);font-family:var(--font-mono);font-size:11px;color:#8888aa;overflow:auto;max-height:400px;white-space:pre-wrap;word-break:break-word;line-height:1.6;`;
margin-bottom: var(--space-xl); const TabBar = styled.div`display:flex;gap:var(--space-lg);border-bottom:1px solid var(--border-color);margin-bottom:var(--space-lg);overflow:auto;`;
const TabBtn = styled.button<{ $active: boolean }>`font-size:13px;font-weight:500;color:${({$active})=>$active?'var(--text-primary)':'var(--text-secondary)'};background:transparent;border:none;border-bottom:1px solid ${({$active})=>$active?'var(--text-primary)':'transparent'};padding:var(--space-sm) 0;cursor:pointer;transition:color .15s,border-color .15s;white-space:nowrap;&:hover{color:var(--text-primary);}`;
@media (max-width: 767px) { const Toolbar = styled.div`display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;`;
grid-template-columns: 1fr; const Select = styled.select`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:7px;color:var(--text-primary);padding:8px 12px;font-size:13px;cursor:pointer;outline:none;option{background:#1a1f2a;}`;
justify-items: start; const LinesInput = styled.input`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:7px;color:var(--text-primary);padding:8px 12px;font-size:13px;width:80px;outline:none;`;
} const ResultMsg = styled.div<{ $success: boolean }>`padding:8px 12px;border-radius:6px;font-size:12px;font-family:monospace;background:${({$success})=>$success?'rgba(0,230,118,0.08)':'rgba(255,23,68,0.08)'};color:${({$success})=>$success?'#00FF00':'#FF1744'};border:1px solid ${({$success})=>$success?'rgba(0,230,118,0.3)':'rgba(255,23,68,0.3)'};margin-bottom:12px;`;
`; const SessionCard = styled.div`border:1px solid var(--border-color);padding:var(--space-md);margin-bottom:var(--space-sm);`;
const SISTER_DISPLAY: Record<string, string> = { harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이' };
const HeroMeta = styled.div` const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
display: flex;
flex-direction: column;
gap: var(--space-sm);
`;
const HeroName = styled.div`
font-size: 36px;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--text-primary);
`;
const HeroRole = styled.div`
font-size: 14px;
color: var(--text-secondary);
font-family: var(--font-mono);
`;
const HeroStatus = styled.div`
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-sm);
`;
const StatusDot = styled.div<{ $on: boolean }>`
width: 10px;
height: 10px;
border-radius: 50%;
background: ${({ $on }) => $on ? '#FFF' : '#555'};
${({ $on }) => $on && `box-shadow: 0 0 8px rgba(255,255,255,0.3);`}
`;
const HeroStats = styled.div`
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-md);
margin-top: var(--space-lg);
@media (max-width: 767px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
`;
const StatCard = styled.div`
border: 1px solid var(--border-color);
padding: var(--space-md);
`;
const StatLabel = styled.div`
font-size: 10px;
color: var(--text-secondary);
font-family: var(--font-mono);
margin-bottom: 6px;
`;
const StatValue = styled.div`
font-size: 18px;
color: var(--text-primary);
font-family: var(--font-mono);
`;
const DetailGrid = styled.div`
display: grid;
grid-template-columns: 320px 1fr;
gap: var(--space-xxl);
align-items: start;
@media (max-width: 1199px) {
grid-template-columns: 1fr;
gap: var(--space-xl);
}
`;
const MetaPanel = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-lg);
`;
const MetaCard = styled.div`
border: 1px solid var(--border-color);
padding: var(--space-lg);
`;
const ConfigPre = styled.pre`
background: #0d0d0d;
border: 1px solid var(--border-color);
padding: var(--space-md);
font-family: var(--font-mono);
font-size: 11px;
color: #8888aa;
overflow: auto;
max-height: 400px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
`;
const TabBar = styled.div`
display: flex;
gap: var(--space-lg);
border-bottom: 1px solid var(--border-color);
margin-bottom: var(--space-lg);
`;
const TabBtn = styled.button<{ $active: boolean }>`
font-size: 13px;
font-weight: 500;
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
background: transparent;
border: none;
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
padding: var(--space-sm) 0;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
&:hover { color: var(--text-primary); }
`;
const SISTER_DISPLAY: Record<string, string> = {
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
};
export default function SisterDetailPage() { export default function SisterDetailPage() {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
@@ -162,8 +43,16 @@ export default function SisterDetailPage() {
const [systemInfo, setSystemInfo] = useState<any>(null); const [systemInfo, setSystemInfo] = useState<any>(null);
const [configData, setConfigData] = useState<any>(null); const [configData, setConfigData] = useState<any>(null);
const [activity, setActivity] = useState<any[]>([]); const [activity, setActivity] = useState<any[]>([]);
const [sessions, setSessions] = useState<any[]>([]);
const [tab, setTab] = useState('overview'); const [tab, setTab] = useState('overview');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [logLines, setLogLines] = useState<string[]>([]);
const [logLoading, setLogLoading] = useState(false);
const [lines, setLines] = useState(100);
const [selectedFile, setSelectedFile] = useState('SOUL.md');
const [content, setContent] = useState('');
const [saved, setSaved] = useState('');
const [saveResult, setSaveResult] = useState<{ success: boolean; msg: string } | null>(null);
useEffect(() => { useEffect(() => {
Promise.allSettled([ Promise.allSettled([
@@ -171,118 +60,105 @@ export default function SisterDetailPage() {
fetch(`${API_URL}/api/sisters/${name}/config`), fetch(`${API_URL}/api/sisters/${name}/config`),
fetch(`${API_URL}/api/sisters/${name}/activity`), fetch(`${API_URL}/api/sisters/${name}/activity`),
fetch(`${API_URL}/api/sisters/${name}/system`), fetch(`${API_URL}/api/sisters/${name}/system`),
]).then(([sRes, cRes, aRes, syRes]) => { fetch(`${API_URL}/api/sisters/${name}/sessions`),
if (sRes.status === 'fulfilled' && sRes.value.ok) { ]).then(([sRes, cRes, aRes, syRes, ssRes]) => {
sRes.value.json().then((d: any[]) => setSisterInfo(d.find((s) => s.name === name))); if (sRes.status === 'fulfilled' && sRes.value.ok) sRes.value.json().then((d: any[]) => setSisterInfo(d.find((s) => s.name === name)));
}
if (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then(setConfigData); if (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then(setConfigData);
if (aRes.status === 'fulfilled' && aRes.value.ok) { if (aRes.status === 'fulfilled' && aRes.value.ok) aRes.value.json().then((d: any) => setActivity(d.items ?? []));
aRes.value.json().then((d: any) => setActivity(d.items ?? []));
}
if (syRes.status === 'fulfilled' && syRes.value.ok) syRes.value.json().then(setSystemInfo); if (syRes.status === 'fulfilled' && syRes.value.ok) syRes.value.json().then(setSystemInfo);
if (ssRes.status === 'fulfilled' && ssRes.value.ok) ssRes.value.json().then((d: any) => setSessions(d.sessions ?? d.items ?? []));
}).finally(() => setLoading(false)); }).finally(() => setLoading(false));
}, [name]); }, [name]);
const fetchLogs = useCallback(async () => {
setLogLoading(true);
try {
const res = await adminFetch(`/api/admin/logs/${name}?lines=${lines}`);
const d = await res.json();
setLogLines(d.lines ?? []);
} catch {
setLogLines(['(로그 로드 실패)']);
} finally {
setLogLoading(false);
}
}, [name, lines]);
useEffect(() => { if (tab === 'logs') fetchLogs(); }, [tab, fetchLogs]);
useEffect(() => {
if (tab !== 'harness') return;
setSaveResult(null);
adminFetch(`/api/admin/harness/${name}/${selectedFile}`)
.then((r) => r.json())
.then((d) => { setContent(d.content ?? ''); setSaved(d.content ?? ''); })
.catch(() => setContent('(로드 실패)'));
}, [tab, name, selectedFile]);
const handleSave = async () => {
setSaveResult(null);
try {
const res = await adminFetch(`/api/admin/harness/${name}/${selectedFile}`, { method: 'PUT', body: JSON.stringify({ content }) });
const d = await res.json();
if (d.success) { setSaved(content); setSaveResult({ success: true, msg: '저장 완료' }); }
else setSaveResult({ success: false, msg: d.error ?? '저장 실패' });
} catch (e) {
setSaveResult({ success: false, msg: (e as Error).message });
}
};
const isActive = sisterInfo?.status === 'online' || sisterInfo?.status === 'working'; const isActive = sisterInfo?.status === 'online' || sisterInfo?.status === 'working';
return ( return (
<> <>
<Breadcrumb> <Breadcrumb><Link href="/sisters"> </Link> / {SISTER_DISPLAY[name] ?? name}</Breadcrumb>
<Link href="/sisters"> </Link> / {SISTER_DISPLAY[name] ?? name} {loading ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div> : <>
</Breadcrumb> <Hero>
<SisterAvatar name={name} size={120} />
<HeroMeta>
<HeroName>{SISTER_DISPLAY[name] ?? name}</HeroName>
<HeroRole>{SISTER_ROLES[name] ?? 'UNKNOWN'}</HeroRole>
<HeroStatus>
<StatusDot $on={isActive} />
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
<LabelMeta><span>LXC:</span>{sisterInfo?.lxcId ?? '--'}</LabelMeta>
</HeroStatus>
<HeroStats>
<StatCard><StatLabel>UPTIME</StatLabel><StatValue>{systemInfo?.uptime ?? '--:--:--'}</StatValue></StatCard>
<StatCard><StatLabel>CPU</StatLabel><StatValue>{systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}%</StatValue></StatCard>
<StatCard><StatLabel>MEM</StatLabel><StatValue>{systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB</StatValue></StatCard>
<StatCard><StatLabel>DISK</StatLabel><StatValue>{systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'}</StatValue></StatCard>
</HeroStats>
</HeroMeta>
</Hero>
{loading ? ( <DetailGrid>
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div> <MetaPanel>
) : ( <MetaCard>
<> <LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta>
<Hero> <div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
<SisterAvatar name={name} size={120} /> LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
<HeroMeta> ROLE: {SISTER_ROLES[name] ?? '--'}<br />
<HeroName>{SISTER_DISPLAY[name] ?? name}</HeroName> STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()}<br />
<HeroRole>{SISTER_ROLES[name] ?? 'UNKNOWN'}</HeroRole> HOST_USER: {sisterInfo?.user ?? '--'}
<HeroStatus> </div>
<StatusDot $on={isActive} /> <TechBar><TechBarFill $width={isActive ? 100 : 0} /></TechBar>
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta> </MetaCard>
<LabelMeta><span>LXC:</span>{sisterInfo?.lxcId ?? '--'}</LabelMeta> {configData?.description && <MetaCard><LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta><div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>{configData.description}</div></MetaCard>}
</HeroStatus> </MetaPanel>
<HeroStats> <div>
<StatCard><StatLabel>UPTIME</StatLabel><StatValue>{systemInfo?.uptime ?? '--:--:--'}</StatValue></StatCard> <TabBar>
<StatCard><StatLabel>CPU</StatLabel><StatValue>{systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}%</StatValue></StatCard> {[{ id: 'overview', label: '개요' }, { id: 'sessions', label: '세션' }, { id: 'activity', label: '활동' }, { id: 'harness', label: '하네스' }, { id: 'logs', label: '로그' }, { id: 'config', label: '설정' }].map((t) => <TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>{t.label}</TabBtn>)}
<StatCard><StatLabel>MEM</StatLabel><StatValue>{systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB</StatValue></StatCard> </TabBar>
<StatCard><StatLabel>DISK</StatLabel><StatValue>{systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'}</StatValue></StatCard>
</HeroStats>
</HeroMeta>
</Hero>
<DetailGrid> {tab === 'overview' && <Timeline>{activity.slice(0, 8).map((item) => <TimelineItem key={item.id}><TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp><TimelineContent>{item.detail ?? item.action}</TimelineContent></TimelineItem>)}</Timeline>}
<MetaPanel> {tab === 'sessions' && <>{sessions.length === 0 ? <div style={{ color: 'var(--text-secondary)' }}> </div> : sessions.map((s: any, i: number) => <SessionCard key={i}><div style={{ color: 'var(--text-primary)', fontSize: '13px' }}>{s.title ?? s.label ?? s.id ?? 'session'}</div><div style={{ color: 'var(--text-secondary)', fontSize: '11px', fontFamily: 'var(--font-mono)' }}>{s.status ?? ''}</div></SessionCard>)}</>}
<MetaCard> {tab === 'activity' && <Timeline>{activity.map((item) => <TimelineItem key={item.id}><TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp><TimelineContent>{item.detail ?? item.action}</TimelineContent></TimelineItem>)}</Timeline>}
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta> {tab === 'harness' && <><Toolbar><Select value={selectedFile} onChange={(e) => setSelectedFile(e.target.value)}>{FILES.map((f) => <option key={f} value={f}>{f}</option>)}</Select><Btn onClick={() => { setContent(saved); setSaveResult(null); }}></Btn><Btn onClick={handleSave}></Btn></Toolbar>{saveResult && <ResultMsg $success={saveResult.success}>{saveResult.msg}</ResultMsg>}<CodeEditor value={content} onChange={setContent} /></>}
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.8 }}> {tab === 'logs' && <><Toolbar><span style={{ color: 'var(--text-secondary)', fontSize: '12px' }}></span><LinesInput type="number" value={lines} onChange={(e) => setLines(parseInt(e.target.value, 10) || 100)} min={10} max={500} /><span style={{ color: 'var(--text-secondary)', fontSize: '12px' }}></span><Btn onClick={fetchLogs}>{logLoading ? '로딩 중...' : '로그 가져오기'}</Btn></Toolbar><LogTerminal lines={logLines} /></>}
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br /> {tab === 'config' && <><SectionTitle>SOUL.md / AGENTS.md</SectionTitle><ConfigPre>{configData?.raw || '(설정 파일 없음)'}</ConfigPre></>}
ROLE: {SISTER_ROLES[name] ?? '--'}<br /> </div>
STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()}<br /> </DetailGrid>
HOST_USER: {sisterInfo?.user ?? '--'} </>}
</div>
<TechBar>
<TechBarFill $width={isActive ? 100 : 0} />
</TechBar>
</MetaCard>
{configData?.description && (
<MetaCard>
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
{configData.description}
</div>
</MetaCard>
)}
</MetaPanel>
<div>
<TabBar>
{[
{ id: 'overview', label: '개요' },
{ id: 'config', label: '설정' },
].map((t) => (
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
{t.label}
</TabBtn>
))}
</TabBar>
{tab === 'overview' && (
<>
<SectionTitle>
<span>RECENT ACTIVITY</span>
<LabelMeta>{activity.length} ENTRIES</LabelMeta>
</SectionTitle>
<Timeline>
{activity.slice(0, 8).map((item) => (
<TimelineItem key={item.id}>
<TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp>
<TimelineContent>{item.detail ?? item.action}</TimelineContent>
</TimelineItem>
))}
{activity.length === 0 && (
<TimelineItem>
<TimelineContent style={{ color: 'var(--text-secondary)' }}> </TimelineContent>
</TimelineItem>
)}
</Timeline>
</>
)}
{tab === 'config' && (
<>
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle>
<ConfigPre>{configData?.raw || '(설정 파일 없음)'}</ConfigPre>
</>
)}
</div>
</DetailGrid>
</>
)}
</> </>
); );
} }