Compare commits
8 Commits
hotfix/pro
...
feature/sp
| Author | SHA1 | Date | |
|---|---|---|---|
| a4f391d15b | |||
| ad0b489633 | |||
| 9f6bba9aeb | |||
| 7a91589ded | |||
| 5c810b03c6 | |||
| 2cacdcf54e | |||
| 59bf0f38de | |||
| b342d6be52 |
34
.plans/sprints/HOTFIX-004.md
Normal file
34
.plans/sprints/HOTFIX-004.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# HOTFIX-004: 자매 상세 카드 UI 보정
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
자매 상세 화면에서 DISK bar 미노출, 이름만 클릭되는 UX, 리소스 중복 표시 문제를 짧게 정리
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-049: DISK progress bar 렌더 수정
|
||||||
|
- DISK 사용률 계산/퍼센트 변환/width 스타일 점검
|
||||||
|
- 0 또는 NaN으로 들어가는 경우 fallback 처리
|
||||||
|
- CPU/MEM/DISK 3개 모두 bar 렌더 일관성 확인
|
||||||
|
|
||||||
|
### TASK-050: 카드 전체 클릭 가능화
|
||||||
|
- 현재 이름 텍스트만 링크인 부분을 카드 전체 링크/클릭 처리로 변경
|
||||||
|
- hover/active 상태도 카드 단위로 적용
|
||||||
|
- 접근성: button/link semantics 유지
|
||||||
|
|
||||||
|
### TASK-051: 우측 중복 메타 정보 교체
|
||||||
|
- CPU/MEM/DISK 텍스트 중복 제거
|
||||||
|
- 우측 영역은 운영 메타 정보로 교체:
|
||||||
|
- STATUS
|
||||||
|
- ROLE
|
||||||
|
- LXC ID
|
||||||
|
- LAST CHECK
|
||||||
|
- CURRENT TASK
|
||||||
|
- 가운데 그래프는 리소스 시각화 전용으로 유지
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- DISK bar 정상 노출
|
||||||
|
- 카드 전체 클릭 시 상세 페이지 이동
|
||||||
|
- 리소스 정보 중복 제거
|
||||||
|
- 우측 메타가 운영 정보로 자연스럽게 표시
|
||||||
|
- 빌드/테스트 통과
|
||||||
|
- 외부 URL QA 필수
|
||||||
59
.plans/sprints/SPRINT-012.md
Normal file
59
.plans/sprints/SPRINT-012.md
Normal 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 필수
|
||||||
21
.qa/HOTFIX-004-review-1.md
Normal file
21
.qa/HOTFIX-004-review-1.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# HOTFIX-004 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 20:17 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm test | ✅ 23/23 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 변경 확인
|
||||||
|
|
||||||
|
| Task | 내용 | 확인 |
|
||||||
|
|------|------|------|
|
||||||
|
| TASK-049 | DISK bar 퍼센트 계산 | ✅ diskPercent() — G/M/K/T 변환, NaN/0 fallback |
|
||||||
|
| TASK-050 | 카드 전체 클릭 | ✅ NodeEntry = styled(Link) + href |
|
||||||
|
| TASK-051 | 우측 운영 메타 | ✅ STATUS/ROLE/LXC ID/LAST CHECK/CURRENT TASK |
|
||||||
23
.qa/SPRINT-012-review-1.md
Normal file
23
.qa/SPRINT-012-review-1.md
Normal 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 | 관리자 탭 단순화 | ✅ |
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: '저장소' },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ 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 SISTER_ROLES: Record<string, string> = { harang: 'Primary', narang: 'Secondary', darang: 'Standby', erang: 'Sync' };
|
const SISTER_ROLES: Record<string, string> = { harang: 'Primary', narang: 'Secondary', darang: 'Standby', erang: 'Sync' };
|
||||||
const NodeGrid = styled.section`display:flex;flex-direction:column;gap:var(--space-lg);`;
|
const NodeGrid = styled.section`display:flex;flex-direction:column;gap:var(--space-lg);`;
|
||||||
const NodeEntry = styled.div`display:grid;grid-template-columns:220px 1fr 160px;gap:0;border:1px solid var(--border-color);transition:border-color .2s;&:hover{border-color:var(--border-hover)}@media (max-width:1199px){grid-template-columns:200px 1fr}@media (max-width:767px){grid-template-columns:1fr}`;
|
const NodeEntry = styled(Link)`display:grid;grid-template-columns:220px 1fr 180px;gap:0;border:1px solid var(--border-color);transition:border-color .2s, background .15s;text-decoration:none;&:hover{border-color:var(--border-hover);background:#111}@media (max-width:1199px){grid-template-columns:200px 1fr}@media (max-width:767px){grid-template-columns:1fr}`;
|
||||||
const NodeInfo = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:767px){border-right:none;border-bottom:1px solid var(--border-color)}`;
|
const NodeInfo = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:767px){border-right:none;border-bottom:1px solid var(--border-color)}`;
|
||||||
const NodeName = styled(Link)`display:flex;align-items:center;gap:var(--space-sm);font-size:16px;font-weight:600;color:var(--text-primary);text-decoration:none;transition:color .15s;&:hover{color:var(--accent-hover)}`;
|
const NodeName = styled.div`display:flex;align-items:center;gap:var(--space-sm);font-size:16px;font-weight:600;color:var(--text-primary);`;
|
||||||
const StatusIndicator = styled.div<{ $active: boolean }>`width:8px;height:8px;border-radius:50%;background:${({$active})=>$active?'#FFF':'#555'};flex-shrink:0;${({$active})=>$active&&`box-shadow:0 0 8px rgba(255,255,255,.3);`}`;
|
const StatusIndicator = styled.div<{ $active: boolean }>`width:8px;height:8px;border-radius:50%;background:${({$active})=>$active?'#FFF':'#555'};flex-shrink:0;${({$active})=>$active&&`box-shadow:0 0 8px rgba(255,255,255,.3);`}`;
|
||||||
const MetaPair = styled.div`display:flex;flex-direction:column;gap:2px;`;
|
const MetaPair = styled.div`display:flex;flex-direction:column;gap:2px;`;
|
||||||
const MetaVal = styled.div`font-size:13px;color:var(--text-primary);font-family:var(--font-mono);`;
|
const MetaVal = styled.div`font-size:13px;color:var(--text-primary);font-family:var(--font-mono);`;
|
||||||
@@ -20,15 +20,30 @@ const NodeControls = styled.div`display:flex;gap:var(--space-sm);flex-wrap:wrap;
|
|||||||
const CapacitySection = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:1199px){border-right:none}@media (max-width:767px){border-bottom:1px solid var(--border-color)}`;
|
const CapacitySection = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:1199px){border-right:none}@media (max-width:767px){border-bottom:1px solid var(--border-color)}`;
|
||||||
const BarWrap = styled.div`display:flex;flex-direction:column;gap:8px;`;
|
const BarWrap = styled.div`display:flex;flex-direction:column;gap:8px;`;
|
||||||
const BarRow = styled.div`display:flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);`;
|
const BarRow = styled.div`display:flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);`;
|
||||||
const Bar = styled.div`flex:1;height:8px;background:#151515;border:1px solid #222;`;
|
const Bar = styled.div`flex:1;height:8px;background:#151515;border:1px solid #222;overflow:hidden;`;
|
||||||
const Fill = styled.div<{ $width:number }>`height:100%;width:${({$width})=>$width}%;background:var(--text-primary);`;
|
const Fill = styled.div<{ $width:number }>`height:100%;width:${({$width})=>$width}%;background:var(--text-primary);min-width:${({$width})=>$width > 0 ? '2px' : '0'};`;
|
||||||
const SyncHistory = styled.div`padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-sm);@media (max-width:1199px){display:none}`;
|
const OpsMeta = styled.div`padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-sm);@media (max-width:1199px){display:none}`;
|
||||||
const SyncRow = styled.div`display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);padding:2px 0;`;
|
const MetaRow = styled.div`display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);padding:2px 0;gap:var(--space-md);`;
|
||||||
const Skeleton = styled.div`height:120px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`;
|
const Skeleton = styled.div`height:120px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`;
|
||||||
const ErrorBox = styled.div`padding:var(--space-lg);border:1px solid #5a2a2a;color:#ff9b9b;`;
|
const ErrorBox = styled.div`padding:var(--space-lg);border:1px solid #5a2a2a;color:#ff9b9b;`;
|
||||||
|
|
||||||
function getNodeStatus(s: any): boolean { return s.status === 'online' || s.status === 'working'; }
|
function getNodeStatus(s: any): boolean { return s.status === 'online' || s.status === 'working'; }
|
||||||
function getStatusBtns(s: any): [string, string] { if (s.status === 'offline') return ['STANDBY', 'ACTIVATE']; return ['ACTIVE', 'REBOOT']; }
|
function getStatusBtns(s: any): [string, string] { if (s.status === 'offline') return ['STANDBY', 'ACTIVATE']; return ['ACTIVE', 'REBOOT']; }
|
||||||
|
function diskPercent(disk: any): number {
|
||||||
|
if (!disk?.used || !disk?.total) return 0;
|
||||||
|
const parse = (v: string) => {
|
||||||
|
const m = String(v).match(/([0-9.]+)\s*([KMGTP]?)/i);
|
||||||
|
if (!m) return 0;
|
||||||
|
const n = parseFloat(m[1]);
|
||||||
|
const unit = (m[2] || 'G').toUpperCase();
|
||||||
|
const scale: Record<string, number> = { K: 1/1024/1024, M: 1/1024, G: 1, T: 1024, P: 1024*1024, '': 1 };
|
||||||
|
return n * (scale[unit] ?? 1);
|
||||||
|
};
|
||||||
|
const used = parse(disk.used);
|
||||||
|
const total = parse(disk.total);
|
||||||
|
if (!isFinite(used) || !isFinite(total) || total <= 0) return 0;
|
||||||
|
return Math.max(0, Math.min(100, (used / total) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
export default function SistersPage() {
|
export default function SistersPage() {
|
||||||
const [sisters, setSisters] = useState<any[]>([]);
|
const [sisters, setSisters] = useState<any[]>([]);
|
||||||
@@ -72,9 +87,9 @@ export default function SistersPage() {
|
|||||||
const isActive = getNodeStatus(s);
|
const isActive = getNodeStatus(s);
|
||||||
const [btn1, btn2] = getStatusBtns(s);
|
const [btn1, btn2] = getStatusBtns(s);
|
||||||
return (
|
return (
|
||||||
<NodeEntry key={s.id ?? s.name}>
|
<NodeEntry key={s.id ?? s.name} href={`/sisters/${s.name}`}>
|
||||||
<NodeInfo>
|
<NodeInfo>
|
||||||
<NodeName href={`/sisters/${s.name}`}>
|
<NodeName>
|
||||||
<StatusIndicator $active={isActive} />
|
<StatusIndicator $active={isActive} />
|
||||||
<SisterAvatar name={s.name} size={28} />
|
<SisterAvatar name={s.name} size={28} />
|
||||||
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'} ({SISTER_ROLES[s.name] ?? s.name})
|
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'} ({SISTER_ROLES[s.name] ?? s.name})
|
||||||
@@ -86,16 +101,17 @@ export default function SistersPage() {
|
|||||||
<CapacitySection>
|
<CapacitySection>
|
||||||
<BarWrap>
|
<BarWrap>
|
||||||
<BarRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span><Bar><Fill $width={Math.max(0, Math.min(100, Number(s.cpu ?? 0)))} /></Bar></BarRow>
|
<BarRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span><Bar><Fill $width={Math.max(0, Math.min(100, Number(s.cpu ?? 0)))} /></Bar></BarRow>
|
||||||
<BarRow><span>MEM</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span><Bar><Fill $width={s.memory?.total ? (s.memory.used / s.memory.total) * 100 : 0} /></Bar></BarRow>
|
<BarRow><span>MEM</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span><Bar><Fill $width={s.memory?.total ? Math.max(0, Math.min(100, (s.memory.used / s.memory.total) * 100)) : 0} /></Bar></BarRow>
|
||||||
<BarRow><span>DSK</span><span>{s.disk?.used ?? '0G'}/{s.disk?.total ?? '0G'}</span><Bar><Fill $width={0} /></Bar></BarRow>
|
<BarRow><span>DSK</span><span>{s.disk?.used ?? '0G'}/{s.disk?.total ?? '0G'}</span><Bar><Fill $width={diskPercent(s.disk)} /></Bar></BarRow>
|
||||||
</BarWrap>
|
</BarWrap>
|
||||||
</CapacitySection>
|
</CapacitySection>
|
||||||
<SyncHistory>
|
<OpsMeta>
|
||||||
<SyncRow><span>STATUS</span><span>{String(s.status).toUpperCase()}</span></SyncRow>
|
<MetaRow><span>STATUS</span><span>{String(s.status).toUpperCase()}</span></MetaRow>
|
||||||
<SyncRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span></SyncRow>
|
<MetaRow><span>ROLE</span><span>{String(SISTER_ROLES[s.name] ?? s.name).toUpperCase()}</span></MetaRow>
|
||||||
<SyncRow><span>MEMORY</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span></SyncRow>
|
<MetaRow><span>LXC ID</span><span>{s.lxcId ?? '--'}</span></MetaRow>
|
||||||
<SyncRow><span>DISK</span><span>{s.disk?.used ?? '0G'} / {s.disk?.total ?? '0G'}</span></SyncRow>
|
<MetaRow><span>LAST CHECK</span><span>{s.lastSeen ? 'just synced' : '--'}</span></MetaRow>
|
||||||
</SyncHistory>
|
<MetaRow><span>CURRENT TASK</span><span>{s.currentTask ?? '-'}</span></MetaRow>
|
||||||
|
</OpsMeta>
|
||||||
</NodeEntry>
|
</NodeEntry>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user