Compare commits
10 Commits
hotfix/tas
...
9f6bba9aeb
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f6bba9aeb | |||
| 7a91589ded | |||
| 5c810b03c6 | |||
| 2cacdcf54e | |||
| 59bf0f38de | |||
| b342d6be52 | |||
| 89a590839d | |||
| 16660e5611 | |||
| fcdac04481 | |||
| f7f0d050ec |
34
.plans/sprints/HOTFIX-003.md
Normal file
34
.plans/sprints/HOTFIX-003.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# HOTFIX-003: 자매 상세 프로필 헤더/개요 레이아웃 개선 + Activity 로그 정리
|
||||
|
||||
## 목표
|
||||
자매 상세 페이지에서 프로필 사진이 너무 작고 개요 정보 우선순위가 어색한 문제, Activity 로그에 raw stderr가 그대로 노출되는 문제를 개선
|
||||
|
||||
## 태스크
|
||||
|
||||
### TASK-046: 자매 상세 프로필 헤더 확대
|
||||
- `/sisters/[name]` 상단 프로필 영역 재디자인
|
||||
- 프로필 사진 크기 확대 (최소 96px~128px 권장)
|
||||
- 이름 / 역할 / 상태 배지 / 핵심 메타를 프로필 헤더에 재배치
|
||||
- “프로필 페이지” 느낌이 나도록 상단 hero 영역 구성
|
||||
|
||||
### TASK-047: 개요 레이아웃 우선순위 조정
|
||||
- 현재 시스템 카드/활동 로그/개요의 시각적 비중 재조정
|
||||
- 프로필 헤더 → 핵심 상태 요약 → 활동 로그 순으로 위계 정리
|
||||
- 시스템 정보 카드(UPTIME, CPU, MEM, DISK)는 유지하되 보조 정보로 배치
|
||||
- 모바일에서도 헤더/요약/로그 순서가 자연스럽게 보이도록 조정
|
||||
|
||||
### TASK-048: Activity 로그 사용자 친화화
|
||||
- raw stderr / 내부 명령문 / `bash: ... command not found` 같은 내부 오류 문자열 직접 노출 금지
|
||||
- ActivityLog 저장 시 또는 렌더 시 필터링/정규화:
|
||||
- 내부 stderr는 숨기거나
|
||||
- 사람이 읽을 수 있는 문장으로 변환
|
||||
- 사용자용 feed에는 의미 있는 이벤트만 노출
|
||||
- 필요하면 내부 디버그 로그와 사용자 표시 로그를 분리
|
||||
|
||||
## 검증 기준
|
||||
- 자매 상세 페이지 프로필 사진이 현재보다 명확히 크게 표시
|
||||
- 개요 탭 시각적 우선순위가 자연스러움
|
||||
- 모바일에서도 레이아웃 깨짐 없음
|
||||
- activity feed에 raw stderr 직접 노출 0건
|
||||
- npm run build 성공 + 테스트 통과
|
||||
- 외부 URL QA 필수
|
||||
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 필수
|
||||
25
.qa/HOTFIX-003-review-1.md
Normal file
25
.qa/HOTFIX-003-review-1.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# HOTFIX-003 QA Review — Iteration 1
|
||||
|
||||
- **검증일시:** 2026-04-04 19:48 KST
|
||||
- **검증자:** 다랑이 (Evaluator)
|
||||
- **결과:** ✅ PASSED
|
||||
|
||||
## 검증 항목
|
||||
|
||||
| 항목 | 결과 |
|
||||
|------|------|
|
||||
| npm test | ✅ 23/23 pass |
|
||||
| npm run build (BE) | ✅ |
|
||||
| npm run build (FE) | ✅ 16 routes |
|
||||
|
||||
## 변경 확인
|
||||
|
||||
### 프로필 hero (TASK-046/047)
|
||||
- ✅ SisterAvatar size=120 (기존 36px → 120px 확대)
|
||||
- ✅ 상단 프로필 hero 재디자인
|
||||
|
||||
### Activity stderr 정리 (TASK-048)
|
||||
- ✅ `sanitizeActivityDetail()` 함수 추가
|
||||
- ✅ stderr/bash/command not found → 사용자 친화 메시지 변환
|
||||
- ✅ SSH 연결 실패 → "원격 노드 연결에 실패했어" 변환
|
||||
- ✅ log() + getFeed() + getProjectFeed() 전부 적용
|
||||
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 |
|
||||
@@ -9,6 +9,23 @@ export interface LogActivityDto {
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
function sanitizeActivityDetail(detail?: string | null): string | undefined {
|
||||
if (!detail) return detail ?? undefined;
|
||||
|
||||
const raw = detail.trim();
|
||||
|
||||
// 내부 stderr/명령문 직접 노출 금지
|
||||
if (/stderr:/i.test(raw) || /command not found/i.test(raw) || /bash:\s*line/i.test(raw)) {
|
||||
return '내부 작업 중 오류가 발생했어. 자세한 시스템 로그는 관리자 로그에서 확인할 수 있어.';
|
||||
}
|
||||
|
||||
if (/^ssh failed/i.test(raw) || /ssh connection failed/i.test(raw)) {
|
||||
return '원격 노드 연결에 실패했어.';
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ActivityService {
|
||||
constructor(
|
||||
@@ -18,14 +35,16 @@ export class ActivityService {
|
||||
|
||||
async log(dto: LogActivityDto) {
|
||||
const record = await this.prisma.activityLog.create({
|
||||
data: dto,
|
||||
data: {
|
||||
...dto,
|
||||
detail: sanitizeActivityDetail(dto.detail),
|
||||
},
|
||||
include: {
|
||||
sister: { select: { name: true } },
|
||||
project: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// 실시간 브로드캐스트 (gateway 사용 가능한 경우)
|
||||
if (this.events) {
|
||||
this.events.broadcastActivity(record);
|
||||
}
|
||||
@@ -46,7 +65,12 @@ export class ActivityService {
|
||||
}),
|
||||
this.prisma.activityLog.count(),
|
||||
]);
|
||||
return { items, total, limit, offset };
|
||||
return {
|
||||
items: items.map((item) => ({ ...item, detail: sanitizeActivityDetail(item.detail) })),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
async getProjectFeed(projectId: number, limit = 30, offset = 0) {
|
||||
@@ -62,6 +86,11 @@ export class ActivityService {
|
||||
}),
|
||||
this.prisma.activityLog.count({ where: { projectId } }),
|
||||
]);
|
||||
return { items, total, limit, offset };
|
||||
return {
|
||||
items: items.map((item) => ({ ...item, detail: sanitizeActivityDetail(item.detail) })),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import { PageTitle, LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base';
|
||||
import { LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
import { SISTER_ROLES } from '@/lib/sisters';
|
||||
@@ -17,9 +17,87 @@ const Breadcrumb = styled.div`
|
||||
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
const HeroMeta = styled.div`
|
||||
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: 280px 1fr;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: var(--space-xxl);
|
||||
align-items: start;
|
||||
|
||||
@@ -40,20 +118,6 @@ const MetaCard = styled.div`
|
||||
padding: var(--space-lg);
|
||||
`;
|
||||
|
||||
const StatusRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
`;
|
||||
|
||||
const StatusDot = styled.div<{ $on: boolean }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $on }) => $on ? '#FFF' : '#555'};
|
||||
${({ $on }) => $on && `box-shadow: 0 0 8px rgba(255,255,255,0.3);`}
|
||||
`;
|
||||
|
||||
const ConfigPre = styled.pre`
|
||||
background: #0d0d0d;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -85,7 +149,6 @@ const TabBtn = styled.button<{ $active: boolean }>`
|
||||
padding: var(--space-sm) 0;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover { color: var(--text-primary); }
|
||||
`;
|
||||
|
||||
@@ -127,95 +190,98 @@ export default function SisterDetailPage() {
|
||||
<Breadcrumb>
|
||||
<Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}
|
||||
</Breadcrumb>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
|
||||
<PageTitle>
|
||||
{SISTER_DISPLAY[name] ?? name}
|
||||
<SisterAvatar name={name} size={36} style={{ marginLeft: 'var(--space-md)', verticalAlign: 'middle' }} />
|
||||
<span style={{ fontSize: '16px', color: 'var(--text-secondary)', fontWeight: 400, marginLeft: 'var(--space-sm)' }}>
|
||||
/ {SISTER_ROLES[name] ?? 'UNKNOWN'}
|
||||
</span>
|
||||
</PageTitle>
|
||||
<LabelMeta><span>STATUS:</span>{sisterInfo?.status?.toUpperCase() ?? 'UNKNOWN'}</LabelMeta>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div>
|
||||
) : (
|
||||
<DetailGrid>
|
||||
<MetaPanel>
|
||||
<MetaCard>
|
||||
<StatusRow>
|
||||
<>
|
||||
<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>
|
||||
</StatusRow>
|
||||
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||
LXC: {sisterInfo?.lxcId ?? '--'}<br />
|
||||
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
|
||||
ROLE: {SISTER_ROLES[name] ?? '--'}<br />
|
||||
{systemInfo && (<>
|
||||
UPTIME: {systemInfo.uptime}<br />
|
||||
CPU: {systemInfo.cpu?.toFixed?.(1)}%<br />
|
||||
MEM: {systemInfo.memory?.used}/{systemInfo.memory?.total}MB<br />
|
||||
DISK: {systemInfo.disk?.used}/{systemInfo.disk?.total}
|
||||
</>)}
|
||||
</div>
|
||||
<TechBar>
|
||||
<TechBarFill $width={isActive ? 100 : 0} />
|
||||
</TechBar>
|
||||
</MetaCard>
|
||||
<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>
|
||||
|
||||
{configData?.description && (
|
||||
<DetailGrid>
|
||||
<MetaPanel>
|
||||
<MetaCard>
|
||||
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||
{configData.description}
|
||||
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta>
|
||||
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
|
||||
ROLE: {SISTER_ROLES[name] ?? '--'}<br />
|
||||
STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()}<br />
|
||||
HOST_USER: {sisterInfo?.user ?? '--'}
|
||||
</div>
|
||||
<TechBar>
|
||||
<TechBarFill $width={isActive ? 100 : 0} />
|
||||
</TechBar>
|
||||
</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>
|
||||
{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>
|
||||
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
<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 === 'config' && (
|
||||
<>
|
||||
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle>
|
||||
<ConfigPre>{configData?.raw || '(설정 파일 없음)'}</ConfigPre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DetailGrid>
|
||||
{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 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 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 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 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);`;
|
||||
@@ -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 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 Bar = styled.div`flex:1;height:8px;background:#151515;border:1px solid #222;`;
|
||||
const Fill = styled.div<{ $width:number }>`height:100%;width:${({$width})=>$width}%;background:var(--text-primary);`;
|
||||
const SyncHistory = 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 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);min-width:${({$width})=>$width > 0 ? '2px' : '0'};`;
|
||||
const OpsMeta = styled.div`padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-sm);@media (max-width:1199px){display:none}`;
|
||||
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 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 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() {
|
||||
const [sisters, setSisters] = useState<any[]>([]);
|
||||
@@ -72,9 +87,9 @@ export default function SistersPage() {
|
||||
const isActive = getNodeStatus(s);
|
||||
const [btn1, btn2] = getStatusBtns(s);
|
||||
return (
|
||||
<NodeEntry key={s.id ?? s.name}>
|
||||
<NodeEntry key={s.id ?? s.name} href={`/sisters/${s.name}`}>
|
||||
<NodeInfo>
|
||||
<NodeName href={`/sisters/${s.name}`}>
|
||||
<NodeName>
|
||||
<StatusIndicator $active={isActive} />
|
||||
<SisterAvatar name={s.name} size={28} />
|
||||
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'} ({SISTER_ROLES[s.name] ?? s.name})
|
||||
@@ -86,16 +101,17 @@ export default function SistersPage() {
|
||||
<CapacitySection>
|
||||
<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>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>DSK</span><span>{s.disk?.used ?? '0G'}/{s.disk?.total ?? '0G'}</span><Bar><Fill $width={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={diskPercent(s.disk)} /></Bar></BarRow>
|
||||
</BarWrap>
|
||||
</CapacitySection>
|
||||
<SyncHistory>
|
||||
<SyncRow><span>STATUS</span><span>{String(s.status).toUpperCase()}</span></SyncRow>
|
||||
<SyncRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span></SyncRow>
|
||||
<SyncRow><span>MEMORY</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span></SyncRow>
|
||||
<SyncRow><span>DISK</span><span>{s.disk?.used ?? '0G'} / {s.disk?.total ?? '0G'}</span></SyncRow>
|
||||
</SyncHistory>
|
||||
<OpsMeta>
|
||||
<MetaRow><span>STATUS</span><span>{String(s.status).toUpperCase()}</span></MetaRow>
|
||||
<MetaRow><span>ROLE</span><span>{String(SISTER_ROLES[s.name] ?? s.name).toUpperCase()}</span></MetaRow>
|
||||
<MetaRow><span>LXC ID</span><span>{s.lxcId ?? '--'}</span></MetaRow>
|
||||
<MetaRow><span>LAST CHECK</span><span>{s.lastSeen ? 'just synced' : '--'}</span></MetaRow>
|
||||
<MetaRow><span>CURRENT TASK</span><span>{s.currentTask ?? '-'}</span></MetaRow>
|
||||
</OpsMeta>
|
||||
</NodeEntry>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user