Compare commits

...

3 Commits

Author SHA1 Message Date
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
4 changed files with 251 additions and 97 deletions

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

@@ -9,6 +9,23 @@ export interface LogActivityDto {
detail?: string; 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() @Injectable()
export class ActivityService { export class ActivityService {
constructor( constructor(
@@ -18,14 +35,16 @@ export class ActivityService {
async log(dto: LogActivityDto) { async log(dto: LogActivityDto) {
const record = await this.prisma.activityLog.create({ const record = await this.prisma.activityLog.create({
data: dto, data: {
...dto,
detail: sanitizeActivityDetail(dto.detail),
},
include: { include: {
sister: { select: { name: true } }, sister: { select: { name: true } },
project: { select: { name: true } }, project: { select: { name: true } },
}, },
}); });
// 실시간 브로드캐스트 (gateway 사용 가능한 경우)
if (this.events) { if (this.events) {
this.events.broadcastActivity(record); this.events.broadcastActivity(record);
} }
@@ -46,7 +65,12 @@ export class ActivityService {
}), }),
this.prisma.activityLog.count(), 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) { async getProjectFeed(projectId: number, limit = 30, offset = 0) {
@@ -62,6 +86,11 @@ export class ActivityService {
}), }),
this.prisma.activityLog.count({ where: { projectId } }), 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

@@ -4,7 +4,7 @@ import React, { useEffect, useState } 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 { 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 { 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';
@@ -17,9 +17,87 @@ const Breadcrumb = styled.div`
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } } 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` const DetailGrid = styled.div`
display: grid; display: grid;
grid-template-columns: 280px 1fr; grid-template-columns: 320px 1fr;
gap: var(--space-xxl); gap: var(--space-xxl);
align-items: start; align-items: start;
@@ -40,20 +118,6 @@ const MetaCard = styled.div`
padding: var(--space-lg); 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` const ConfigPre = styled.pre`
background: #0d0d0d; background: #0d0d0d;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -85,7 +149,6 @@ const TabBtn = styled.button<{ $active: boolean }>`
padding: var(--space-sm) 0; padding: var(--space-sm) 0;
cursor: pointer; cursor: pointer;
transition: color 0.15s, border-color 0.15s; transition: color 0.15s, border-color 0.15s;
&:hover { color: var(--text-primary); } &:hover { color: var(--text-primary); }
`; `;
@@ -127,95 +190,98 @@ export default function SisterDetailPage() {
<Breadcrumb> <Breadcrumb>
<Link href="/sisters"> </Link> / {SISTER_DISPLAY[name] ?? name} <Link href="/sisters"> </Link> / {SISTER_DISPLAY[name] ?? name}
</Breadcrumb> </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 ? ( {loading ? (
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div> <div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div>
) : ( ) : (
<DetailGrid> <>
<MetaPanel> <Hero>
<MetaCard> <SisterAvatar name={name} size={120} />
<StatusRow> <HeroMeta>
<HeroName>{SISTER_DISPLAY[name] ?? name}</HeroName>
<HeroRole>{SISTER_ROLES[name] ?? 'UNKNOWN'}</HeroRole>
<HeroStatus>
<StatusDot $on={isActive} /> <StatusDot $on={isActive} />
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta> <LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
</StatusRow> <LabelMeta><span>LXC:</span>{sisterInfo?.lxcId ?? '--'}</LabelMeta>
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.6 }}> </HeroStatus>
LXC: {sisterInfo?.lxcId ?? '--'}<br /> <HeroStats>
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br /> <StatCard><StatLabel>UPTIME</StatLabel><StatValue>{systemInfo?.uptime ?? '--:--:--'}</StatValue></StatCard>
ROLE: {SISTER_ROLES[name] ?? '--'}<br /> <StatCard><StatLabel>CPU</StatLabel><StatValue>{systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}%</StatValue></StatCard>
{systemInfo && (<> <StatCard><StatLabel>MEM</StatLabel><StatValue>{systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB</StatValue></StatCard>
UPTIME: {systemInfo.uptime}<br /> <StatCard><StatLabel>DISK</StatLabel><StatValue>{systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'}</StatValue></StatCard>
CPU: {systemInfo.cpu?.toFixed?.(1)}%<br /> </HeroStats>
MEM: {systemInfo.memory?.used}/{systemInfo.memory?.total}MB<br /> </HeroMeta>
DISK: {systemInfo.disk?.used}/{systemInfo.disk?.total} </Hero>
</>)}
</div>
<TechBar>
<TechBarFill $width={isActive ? 100 : 0} />
</TechBar>
</MetaCard>
{configData?.description && ( <DetailGrid>
<MetaPanel>
<MetaCard> <MetaCard>
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta> <LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>SYSTEM SUMMARY</LabelMeta>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}> <div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{configData.description} 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> </div>
<TechBar>
<TechBarFill $width={isActive ? 100 : 0} />
</TechBar>
</MetaCard> </MetaCard>
)}
</MetaPanel>
<div> {configData?.description && (
<TabBar> <MetaCard>
{[ <LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta>
{ id: 'overview', label: '개요' }, <div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
{ id: 'config', label: '설정' }, {configData.description}
].map((t) => ( </div>
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}> </MetaCard>
{t.label} )}
</TabBtn> </MetaPanel>
))}
</TabBar>
{tab === 'overview' && ( <div>
<> <TabBar>
<SectionTitle> {[
<span>RECENT ACTIVITY</span> { id: 'overview', label: '개요' },
<LabelMeta>{activity.length} ENTRIES</LabelMeta> { id: 'config', label: '설정' },
</SectionTitle> ].map((t) => (
<Timeline> <TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
{activity.slice(0, 8).map((item) => ( {t.label}
<TimelineItem key={item.id}> </TabBtn>
<TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp> ))}
<TimelineContent>{item.detail ?? item.action}</TimelineContent> </TabBar>
</TimelineItem>
))}
{activity.length === 0 && (
<TimelineItem>
<TimelineContent style={{ color: 'var(--text-secondary)' }}> </TimelineContent>
</TimelineItem>
)}
</Timeline>
</>
)}
{tab === 'config' && ( {tab === 'overview' && (
<> <>
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle> <SectionTitle>
<ConfigPre>{configData?.raw || '(설정 파일 없음)'}</ConfigPre> <span>RECENT ACTIVITY</span>
</> <LabelMeta>{activity.length} ENTRIES</LabelMeta>
)} </SectionTitle>
</div> <Timeline>
</DetailGrid> {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>
</>
)} )}
</> </>
); );