Compare commits

..

14 Commits

Author SHA1 Message Date
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
5c810b03c6 QA: HOTFIX-004 review iteration 1 — PASSED 2026-04-04 20:17:54 +09:00
2cacdcf54e fix(hotfix-004): DISK bar + 카드 전체 클릭 + 운영 메타 교체
TASK-049: DISK 사용률 퍼센트 계산 추가, NaN/0 fallback 처리
TASK-050: 자매 카드 전체를 Link로 전환
TASK-051: 우측 영역을 리소스 중복 텍스트에서 운영 메타(STATUS/ROLE/LXC/LAST CHECK/CURRENT TASK)로 교체

테스트 23/23 pass, FE 16 routes build 성공
2026-04-04 20:16:35 +09:00
59bf0f38de 📋 HOTFIX-004 계획 추가
- DISK progress bar 렌더 수정
- 카드 전체 클릭 가능화
- 우측 메타 중복 제거
2026-04-04 20:14:39 +09:00
b342d6be52 Merge pull request 'HOTFIX-003: 자매 상세 프로필 hero 확대 + activity stderr 정리' (#9) from hotfix/profile-header into main 2026-04-04 19:49:43 +09:00
89a590839d QA: HOTFIX-003 review iteration 1 — PASSED 2026-04-04 19:48:20 +09:00
16660e5611 fix(hotfix-003): 자매 상세 hero 확대 + activity raw stderr 사용자 친화화
TASK-046: /sisters/[name] 상단 프로필 hero 재디자인, avatar 120px 확대
TASK-047: 개요 레이아웃 우선순위 재정렬 (hero → system summary → activity)
TASK-048: ActivityService sanitize 추가
  - stderr/bash command not found/raw 내부 오류 직접 노출 금지
  - 원격 노드 연결 실패 메시지 사용자 친화화

테스트 23/23 pass, FE 16 routes build 성공
2026-04-04 19:46:58 +09:00
fcdac04481 📋 HOTFIX-003 계획 추가
- 자매 상세 프로필 헤더 확대
- 개요 레이아웃 위계 조정
- activity raw stderr 노출 정리
2026-04-04 19:44:34 +09:00
f7f0d050ec Merge pull request 'HOTFIX-002: Sprint task count + 완료 count 계산 보정' (#8) from hotfix/task-count into main 2026-04-04 18:37:52 +09:00
49bba64f1f QA: HOTFIX-002 review iteration 1 — PASSED 2026-04-04 18:36:26 +09:00
3ce7169c21 fix(hotfix-002): Sprint task count/완료 수 파싱 보정
TASK-044: sprint 문서에서 실제 task heading/list 파싱
  - ### TASK-001: ...
  - - [ ] TASK-001 ...
  - - [x] TASK-001 ...
  - - TASK-001: ...
  - TASK-001: ...
  - 중복 taskId 방지
TASK-045: Sprint status done이면 task status 정보 없을 때 done fallback
  - parsed task를 DB Task 테이블 upsert
  - 프로젝트 상세의 태스크 수/완료 수 정상 반영 기반 마련

테스트 23/23 pass, FE 16 routes build 성공
2026-04-04 18:34:38 +09:00
1a5ad12e54 📋 HOTFIX-002 계획 추가
- Sprint task count 파싱 수정
- 완료 task count 계산 보정
2026-04-04 18:32:26 +09:00
b5d7746e36 Merge pull request 'SPRINT-011: 진행률 계산 수정 + SisterAvatar 공통 컴포넌트' (#7) from feature/sprint-011 into main 2026-04-04 17:47:10 +09:00
11 changed files with 561 additions and 116 deletions

View File

@@ -0,0 +1,31 @@
# HOTFIX-002: Sprint 태스크 수/완료 수 파싱 보정
## 목표
프로젝트 상세 화면에서 Sprint 상태는 맞게 보이지만 `태스크 0개 / 완료 0개`로 표시되는 문제 수정
## 태스크
### TASK-044: Sprint task count 파싱 수정
- `.plans/sprints/SPRINT-XXX.md`에서 실제 태스크 개수를 파싱
- 지원 포맷:
- `TASK-001: 설명`
- `- TASK-001 설명`
- `- [ ] TASK-001 설명`
- `- [x] TASK-001 설명`
- 중복 카운트 방지
- heading/본문에 포함된 단순 문자열 오탐 방지
### TASK-045: 완료 task count 계산 보정
- 완료 태스크 수 계산 규칙 정의
- 우선순위:
1. 체크박스 `[x]` / `[X]`
2. 명시 status (`done`, `completed`, `passed` 등 정해진 패턴)
3. Sprint 자체가 done이면, 태스크 status 정보가 없을 경우 전체 완료 처리 여부 검토
- UI에 `총 태스크 수 / 완료 태스크 수` 정상 반영
## 검증 기준
- 프로젝트 상세 Sprint 섹션에서 `0개 / 0개` 고정 해소
- 실제 Sprint 문서 기준으로 task total 표시
- 완료 태스크 수가 포맷에 맞게 계산
- 빌드/테스트 통과
- 외부 URL QA 필수

View 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 필수

View 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 필수

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,42 @@
# HOTFIX-002 QA Review — Iteration 1
- **검증일시:** 2026-04-04 18:35 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| prisma generate | ✅ |
| npm test | ✅ 23/23 pass |
| npm run build (BE) | ✅ |
| npm run build (FE) | ✅ 16 routes |
## Task 파싱 확인 (TASK-044)
| 포맷 | 지원 |
|------|------|
| `### TASK-001: title` | ✅ |
| `- [x] TASK-001: title` (done) | ✅ |
| `- [ ] TASK-001: title` (pending) | ✅ |
| `- TASK-001: title` | ✅ |
| `TASK-001: title` | ✅ |
| 중복 taskId 방지 (seen Set) | ✅ |
## 완료 count 계산 (TASK-045)
| 규칙 | 확인 |
|------|------|
| Sprint done → task (status 없으면) done fallback | ✅ |
| `[x]` → done | ✅ |
| `[ ]` → pending | ✅ |
| 기존 done task 보호 | ✅ |
| Task 테이블 upsert | ✅ |
## Non-blocking
| ID | 이슈 |
|----|------|
| NB1 | task assignee 하드코딩 'narang' — 문서 파싱으로 개선 권장 |

View 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() 전부 적용

View 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 |

View File

@@ -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,
};
}
}

View File

@@ -8,6 +8,12 @@ interface ParsedSprint {
status: 'pending' | 'in_progress' | 'done';
}
interface ParsedTask {
taskId: string;
title: string;
status: 'pending' | 'done';
}
function parseSprintName(content: string, filename: string, number: number): string {
// 첫 번째 heading에서 이름 추출: # Sprint XXX — 이름 / # SPRINT-XXX: 이름
const headingMatch = content.match(/^#\s+(?:SPRINT-\d+|Sprint\s+\d+)[:\s—\-]+(.+)/m);
@@ -21,6 +27,58 @@ function inferStatusFromQaContent(qaContent: string): 'done' | null {
return null;
}
function parseTasks(content: string, sprintStatus: 'pending' | 'in_progress' | 'done'): ParsedTask[] {
const lines = content.split(/\r?\n/);
const seen = new Set<string>();
const tasks: ParsedTask[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
let match: RegExpMatchArray | null = null;
let status: 'pending' | 'done' | null = null;
// ### TASK-001: title
match = line.match(/^#{2,6}\s+(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
if (match) {
status = sprintStatus === 'done' ? 'done' : 'pending';
}
// - [x] TASK-001 title / - [ ] TASK-001 title
if (!match) {
match = line.match(/^-\s*\[([ xX])\]\s*(TASK-\d+[A-Z]?)\s*[:\-]?\s*(.+)$/i);
if (match) {
status = /x/i.test(match[1]) ? 'done' : 'pending';
}
}
// - TASK-001: title
if (!match) {
match = line.match(/^-\s*(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
if (match) {
status = sprintStatus === 'done' ? 'done' : 'pending';
}
}
// TASK-001: title
if (!match) {
match = line.match(/^(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
if (match) {
status = sprintStatus === 'done' ? 'done' : 'pending';
}
}
if (!match || !status) continue;
const taskId = (match[1] ?? match[2]).toUpperCase();
const title = (match[2] ?? match[3] ?? '').trim();
if (!taskId || seen.has(taskId)) continue;
seen.add(taskId);
tasks.push({ taskId, title: title || taskId, status });
}
return tasks;
}
@Injectable()
export class SprintSyncService {
private readonly logger = new Logger(SprintSyncService.name);
@@ -95,27 +153,57 @@ export class SprintSyncService {
// QA 통과 여부로 status 결정 (본문 전체 키워드 검색 금지)
const status: 'pending' | 'done' = passedMap.get(number) ? 'done' : 'pending';
const parsedTasks = parseTasks(content, status);
const existing = await this.prisma.sprint.findFirst({
where: { projectId, number },
});
let sprintId: number;
if (!existing) {
await this.prisma.sprint.create({
const createdSprint = await this.prisma.sprint.create({
data: { projectId, number, name, status },
});
sprintId = createdSprint.id;
created++;
} else {
// 항상 name 업데이트, status는 pending이면 파일 기준, 이미 done이면 유지
await this.prisma.sprint.update({
const updatedSprint = await this.prisma.sprint.update({
where: { id: existing.id },
data: {
name,
status: existing.status === 'done' ? 'done' : status,
},
});
sprintId = updatedSprint.id;
updated++;
}
// Sprint 문서 기준 Task upsert
for (const task of parsedTasks) {
const existingTask = await this.prisma.task.findFirst({
where: { sprintId, taskId: task.taskId },
});
if (!existingTask) {
await this.prisma.task.create({
data: {
sprintId,
taskId: task.taskId,
title: task.title,
assignee: 'narang',
status: task.status,
},
});
} else {
await this.prisma.task.update({
where: { id: existingTask.id },
data: {
title: task.title,
status: existingTask.status === 'done' ? 'done' : task.status,
},
});
}
}
}
this.logger.log(`${repoName}: ${sprintFiles.length} sprints, ${passedMap.size} QA passed → ${created} created, ${updated} updated`);

View File

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

View File

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