Compare commits
4 Commits
hotfix/tas
...
hotfix/pro
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 필수
|
||||
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() 전부 적용
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user