merge: 에이전트 횡단 오버사이트 타임라인 (agent-office 우측 기본 패널)
- agentActivity API 헬퍼 + useActivityFeed 훅 (필터/페이지네이션/WS refresh/stale 가드) - ActivityItem/ActivityFilters/ActivityTimeline 컴포넌트 - AgentOffice 우측 기본 패널을 횡단 타임라인으로 교체 - 15 테스트 추가 (총 97 통과) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
src/api.js
11
src/api.js
@@ -594,6 +594,17 @@ export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/age
|
|||||||
export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
|
export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
|
||||||
export const getAgentStates = () => apiGet('/api/agent-office/states');
|
export const getAgentStates = () => apiGet('/api/agent-office/states');
|
||||||
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
|
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
|
||||||
|
// 횡단 오버사이트 타임라인용 — 빈 값은 쿼리에서 제외(백엔드 브랜치 선택).
|
||||||
|
export const agentActivity = ({ agent_id, type, status, days, limit = 30, offset = 0 } = {}) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (agent_id) p.set('agent_id', agent_id);
|
||||||
|
if (type) p.set('type', type);
|
||||||
|
if (status) p.set('status', status);
|
||||||
|
if (days) p.set('days', String(days));
|
||||||
|
p.set('limit', String(limit));
|
||||||
|
p.set('offset', String(offset));
|
||||||
|
return apiGet(`/api/agent-office/activity?${p.toString()}`);
|
||||||
|
};
|
||||||
export const getAgentTokenUsage = (id, days=1) => apiGet(`/api/agent-office/agents/${id}/token-usage?days=${days}`);
|
export const getAgentTokenUsage = (id, days=1) => apiGet(`/api/agent-office/agents/${id}/token-usage?days=${days}`);
|
||||||
|
|
||||||
// --- Lotto Briefing ---
|
// --- Lotto Briefing ---
|
||||||
|
|||||||
@@ -447,3 +447,102 @@
|
|||||||
padding-bottom: env(safe-area-inset-bottom, 16px);
|
padding-bottom: env(safe-area-inset-bottom, 16px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 횡단 오버사이트 타임라인 (mission-control activity log) ── */
|
||||||
|
.ao-activity { display: flex; flex-direction: column; min-height: 0; height: 100%; }
|
||||||
|
|
||||||
|
/* 헤더 — 섹션 타이틀 톤 (퍼플 액센트 + 트래킹) */
|
||||||
|
.ao-activity-header { align-items: center; }
|
||||||
|
.ao-activity-header .ao-sidepanel-name {
|
||||||
|
color: #8b5cf6; letter-spacing: 0.6px; text-transform: uppercase; font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 필터 바 — 다크 슬레이트 셀렉트 */
|
||||||
|
.ao-activity-filters {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px;
|
||||||
|
padding: 8px 12px; border-bottom: 1px solid #333;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
}
|
||||||
|
.ao-activity-select {
|
||||||
|
background: #1e293b; color: #e2e8f0;
|
||||||
|
border: 1px solid #334155; border-radius: 4px;
|
||||||
|
padding: 4px 8px; font-family: inherit; font-size: 11px; cursor: pointer;
|
||||||
|
transition: border-color .12s, box-shadow .12s;
|
||||||
|
}
|
||||||
|
.ao-activity-select:hover { border-color: #475569; }
|
||||||
|
.ao-activity-select:focus { outline: none; border-color: #8b5cf6; box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3); }
|
||||||
|
.ao-activity-select:disabled { opacity: .35; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.ao-activity-content { flex: 1; overflow-y: auto; min-height: 0; padding: 0; }
|
||||||
|
|
||||||
|
/* 활동 행 — 타임라인 스파인(수직 레일) + 신호등 도트 */
|
||||||
|
.ao-activity-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
|
padding: 10px 12px; border-bottom: 1px solid #1a2233;
|
||||||
|
cursor: pointer; transition: background .12s;
|
||||||
|
animation: ao-activity-in .18s ease-out both;
|
||||||
|
}
|
||||||
|
.ao-activity-item::before {
|
||||||
|
content: ''; position: absolute; left: 16px; top: 0; bottom: 0;
|
||||||
|
width: 1px; background: #1e293b; z-index: 0;
|
||||||
|
}
|
||||||
|
.ao-activity-item:hover { background: #161b2e; }
|
||||||
|
.ao-activity-item:focus-visible { outline: none; background: #161b2e; box-shadow: inset 2px 0 0 #8b5cf6; }
|
||||||
|
|
||||||
|
/* 진행/대기 강조 — 앰버 인셋 + 도트 펄스 */
|
||||||
|
.ao-activity-item.is-highlight { background: rgba(245, 158, 11, 0.06); box-shadow: inset 2px 0 0 #f59e0b; }
|
||||||
|
.ao-activity-item.is-highlight .ao-activity-dot { animation: ao-pulse 1.6s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* 에이전트 색 = 신호등. 링(#111)으로 뒤 레일을 끊어 점처럼 떠 보이게 */
|
||||||
|
.ao-activity-dot {
|
||||||
|
position: relative; z-index: 1; flex: 0 0 auto;
|
||||||
|
width: 9px; height: 9px; border-radius: 50%; margin-top: 4px;
|
||||||
|
box-shadow: 0 0 0 3px #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-activity-body { flex: 1; min-width: 0; }
|
||||||
|
.ao-activity-line { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.ao-activity-agent { font-size: 11px; font-weight: bold; letter-spacing: 0.3px; }
|
||||||
|
|
||||||
|
/* 상태 뱃지 — 터미널 톤(각진 모서리, 모노) */
|
||||||
|
.ao-activity-badge {
|
||||||
|
font-size: 10px; font-weight: bold; letter-spacing: 0.3px;
|
||||||
|
padding: 1px 7px; border-radius: 4px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 로그 레벨 표식 */
|
||||||
|
.ao-activity-level { font-size: 12px; line-height: 1; }
|
||||||
|
.ao-activity-level.level-info { color: #475569; font-size: 15px; font-weight: bold; }
|
||||||
|
.ao-activity-level.level-warning,
|
||||||
|
.ao-activity-level.level-error { font-size: 12px; }
|
||||||
|
|
||||||
|
.ao-activity-msg {
|
||||||
|
font-size: 12.5px; color: #cbd5e1; margin-top: 3px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ao-activity-item.is-log .ao-activity-msg { color: #94a3b8; }
|
||||||
|
|
||||||
|
.ao-activity-meta { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
||||||
|
.ao-activity-time { font-size: 10px; color: #64748b; }
|
||||||
|
.ao-activity-dur { font-size: 10px; color: #475569; }
|
||||||
|
|
||||||
|
.ao-activity-loading,
|
||||||
|
.ao-activity-end {
|
||||||
|
text-align: center; padding: 12px; font-size: 10px;
|
||||||
|
color: #475569; letter-spacing: 0.6px; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.ao-activity-sentinel { height: 1px; }
|
||||||
|
|
||||||
|
.ao-activity-error { padding: 12px; font-size: 12px; color: #fca5a5; }
|
||||||
|
.ao-activity-error button {
|
||||||
|
margin-left: 8px; background: #2a2a4e; color: #8b5cf6;
|
||||||
|
border: 1px solid #4c1d95; border-radius: 4px;
|
||||||
|
padding: 3px 10px; font-family: inherit; font-size: 11px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.ao-activity-error button:hover { background: #3a3a5e; }
|
||||||
|
|
||||||
|
@keyframes ao-activity-in {
|
||||||
|
from { opacity: 0; transform: translateY(2px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import TopBar from './components/TopBar.jsx';
|
|||||||
import AgentGrid from './components/AgentGrid.jsx';
|
import AgentGrid from './components/AgentGrid.jsx';
|
||||||
import SidePanel from './components/SidePanel.jsx';
|
import SidePanel from './components/SidePanel.jsx';
|
||||||
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
|
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
|
||||||
|
import ActivityTimeline from './components/ActivityTimeline.jsx';
|
||||||
import './AgentOffice.css';
|
import './AgentOffice.css';
|
||||||
|
|
||||||
export default function AgentOffice() {
|
export default function AgentOffice() {
|
||||||
@@ -36,7 +37,12 @@ export default function AgentOffice() {
|
|||||||
|
|
||||||
let rightPanel;
|
let rightPanel;
|
||||||
if (selectedAgent === null) {
|
if (selectedAgent === null) {
|
||||||
rightPanel = <EmptyDetailPanel variant="initial" />;
|
rightPanel = (
|
||||||
|
<ActivityTimeline
|
||||||
|
refreshTrigger={refreshTrigger}
|
||||||
|
onSelectAgent={handleSelectAgent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (selectedAgent.startsWith('placeholder-')) {
|
} else if (selectedAgent.startsWith('placeholder-')) {
|
||||||
rightPanel = <EmptyDetailPanel variant="placeholder" onClose={handleClose} />;
|
rightPanel = <EmptyDetailPanel variant="placeholder" onClose={handleClose} />;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
64
src/pages/agent-office/components/ActivityFilters.jsx
Normal file
64
src/pages/agent-office/components/ActivityFilters.jsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// src/pages/agent-office/components/ActivityFilters.jsx
|
||||||
|
import { ACTIVE_AGENT_IDS, AGENT_META } from '../constants.js';
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
{ value: '', label: '전체' },
|
||||||
|
{ value: 'task', label: 'Task' },
|
||||||
|
{ value: 'log', label: 'Log' },
|
||||||
|
];
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: '전체' },
|
||||||
|
{ value: 'succeeded', label: '완료' },
|
||||||
|
{ value: 'failed', label: '실패' },
|
||||||
|
{ value: 'pending', label: '대기' },
|
||||||
|
];
|
||||||
|
const DAYS_OPTIONS = [
|
||||||
|
{ value: 1, label: '1일' },
|
||||||
|
{ value: 7, label: '7일' },
|
||||||
|
{ value: 30, label: '30일' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ActivityFilters({ filters, onChange }) {
|
||||||
|
const set = (patch) => onChange({ ...filters, ...patch });
|
||||||
|
const statusDisabled = filters.type === 'log';
|
||||||
|
return (
|
||||||
|
<div className="ao-activity-filters">
|
||||||
|
<select
|
||||||
|
className="ao-activity-select"
|
||||||
|
aria-label="에이전트 필터"
|
||||||
|
value={filters.agent_id || ''}
|
||||||
|
onChange={e => set({ agent_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">모든 에이전트</option>
|
||||||
|
{ACTIVE_AGENT_IDS.map(id => (
|
||||||
|
<option key={id} value={id}>{AGENT_META[id]?.displayName || id}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="ao-activity-select"
|
||||||
|
aria-label="타입 필터"
|
||||||
|
value={filters.type || ''}
|
||||||
|
onChange={e => set(e.target.value === 'log' ? { type: 'log', status: '' } : { type: e.target.value })}
|
||||||
|
>
|
||||||
|
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="ao-activity-select"
|
||||||
|
aria-label="상태 필터"
|
||||||
|
value={filters.status || ''}
|
||||||
|
disabled={statusDisabled}
|
||||||
|
onChange={e => set({ status: e.target.value })}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="ao-activity-select"
|
||||||
|
aria-label="기간 필터"
|
||||||
|
value={filters.days}
|
||||||
|
onChange={e => set({ days: Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
{DAYS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/pages/agent-office/components/ActivityFilters.test.jsx
Normal file
26
src/pages/agent-office/components/ActivityFilters.test.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import ActivityFilters from './ActivityFilters.jsx';
|
||||||
|
|
||||||
|
const base = { agent_id: '', type: '', status: '', days: 7 };
|
||||||
|
|
||||||
|
describe('ActivityFilters', () => {
|
||||||
|
it('type=log이면 상태 필터가 비활성화된다', () => {
|
||||||
|
render(<ActivityFilters filters={{ ...base, type: 'log' }} onChange={() => {}} />);
|
||||||
|
expect(screen.getByLabelText('상태 필터')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('기간 변경 시 onChange가 days와 함께 호출된다', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ActivityFilters filters={base} onChange={onChange} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('기간 필터'), { target: { value: '30' } });
|
||||||
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ days: 30 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('type을 log로 바꾸면 status를 비운다', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ActivityFilters filters={{ ...base, status: 'succeeded' }} onChange={onChange} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('타입 필터'), { target: { value: 'log' } });
|
||||||
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ type: 'log', status: '' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
60
src/pages/agent-office/components/ActivityItem.jsx
Normal file
60
src/pages/agent-office/components/ActivityItem.jsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// src/pages/agent-office/components/ActivityItem.jsx
|
||||||
|
import { AGENT_META } from '../constants.js';
|
||||||
|
|
||||||
|
const STATUS_STYLE = {
|
||||||
|
succeeded: { bg: '#065f46', fg: '#34d399', label: '✓ 완료' },
|
||||||
|
failed: { bg: '#7f1d1d', fg: '#fca5a5', label: '✗ 실패' },
|
||||||
|
working: { bg: '#1e3a5f', fg: '#60a5fa', label: '⏳ 진행' },
|
||||||
|
pending: { bg: '#92400e', fg: '#fbbf24', label: '⏳ 대기' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_STYLE = {
|
||||||
|
error: { icon: '❌', cls: 'level-error' },
|
||||||
|
warning: { icon: '⚠️', cls: 'level-warning' },
|
||||||
|
info: { icon: '·', cls: 'level-info' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
const d = new Date(ts);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = d.toDateString() === now.toDateString();
|
||||||
|
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityItem({ item, onSelectAgent }) {
|
||||||
|
const meta = AGENT_META[item.agent_id];
|
||||||
|
const color = meta?.color || '#6b7280';
|
||||||
|
const name = meta?.displayName || item.agent_id;
|
||||||
|
const isTask = item.type === 'task';
|
||||||
|
const status = STATUS_STYLE[item.status] || STATUS_STYLE.pending;
|
||||||
|
const level = LEVEL_STYLE[item.level] || LEVEL_STYLE.info;
|
||||||
|
const highlight = isTask && (item.status === 'pending' || item.status === 'working');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`ao-activity-item ${isTask ? 'is-task' : 'is-log'} ${highlight ? 'is-highlight' : ''}`}
|
||||||
|
onClick={() => onSelectAgent(item.agent_id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<span className="ao-activity-dot" style={{ background: color }} aria-hidden="true" />
|
||||||
|
<div className="ao-activity-body">
|
||||||
|
<div className="ao-activity-line">
|
||||||
|
<span className="ao-activity-agent" style={{ color }}>{name}</span>
|
||||||
|
{isTask
|
||||||
|
? <span className="ao-activity-badge" style={{ background: status.bg, color: status.fg }}>{status.label}</span>
|
||||||
|
: <span className={`ao-activity-level ${level.cls}`}>{level.icon}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="ao-activity-msg">{item.message}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ao-activity-meta">
|
||||||
|
<span className="ao-activity-time">{formatTime(item.created_at)}</span>
|
||||||
|
{isTask && item.duration_seconds != null && (
|
||||||
|
<span className="ao-activity-dur">{item.duration_seconds}s</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/pages/agent-office/components/ActivityItem.test.jsx
Normal file
30
src/pages/agent-office/components/ActivityItem.test.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import ActivityItem from './ActivityItem.jsx';
|
||||||
|
|
||||||
|
describe('ActivityItem', () => {
|
||||||
|
it('task 항목은 상태 뱃지와 duration을 렌더한다', () => {
|
||||||
|
render(<ActivityItem item={{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }} onSelectAgent={() => {}} />);
|
||||||
|
expect(screen.getByText('holdings_brief')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/완료/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2s')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('log 항목은 level 아이콘을 렌더한다', () => {
|
||||||
|
render(<ActivityItem item={{ type: 'log', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', level: 'error' }} onSelectAgent={() => {}} />);
|
||||||
|
expect(screen.getByText('signal_check')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('❌')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('클릭 시 onSelectAgent(agent_id)를 호출한다', () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(<ActivityItem item={{ type: 'task', agent_id: 'insta', task_id: 'c', message: '발급', created_at: '2026-06-10T23:00:00Z', status: 'pending' }} onSelectAgent={onSelect} />);
|
||||||
|
fireEvent.click(screen.getByText('발급').closest('.ao-activity-item'));
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('insta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('미지정 agent_id는 id를 그대로 표시한다', () => {
|
||||||
|
render(<ActivityItem item={{ type: 'log', agent_id: 'unknown', task_id: 'd', message: 'x', created_at: '2026-06-10T23:00:00Z', level: 'info' }} onSelectAgent={() => {}} />);
|
||||||
|
expect(screen.getByText('unknown')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
53
src/pages/agent-office/components/ActivityTimeline.jsx
Normal file
53
src/pages/agent-office/components/ActivityTimeline.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// src/pages/agent-office/components/ActivityTimeline.jsx
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useActivityFeed } from '../hooks/useActivityFeed.js';
|
||||||
|
import ActivityFilters from './ActivityFilters.jsx';
|
||||||
|
import ActivityItem from './ActivityItem.jsx';
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS = { agent_id: '', type: '', status: '', days: 7 };
|
||||||
|
|
||||||
|
export default function ActivityTimeline({ refreshTrigger, onSelectAgent }) {
|
||||||
|
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||||
|
const { items, total, loading, error, hasMore, loadMore, retry } = useActivityFeed(filters, refreshTrigger);
|
||||||
|
|
||||||
|
const sentinelRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const io = new IntersectionObserver(entries => {
|
||||||
|
if (entries[0].isIntersecting) loadMore();
|
||||||
|
}, { rootMargin: '120px' });
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [loadMore, items.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-sidepanel ao-activity">
|
||||||
|
<div className="ao-sidepanel-header ao-activity-header">
|
||||||
|
<div className="ao-sidepanel-name">팀 활동 ({total})</div>
|
||||||
|
</div>
|
||||||
|
<ActivityFilters filters={filters} onChange={setFilters} />
|
||||||
|
<div className="ao-sidepanel-content ao-activity-content">
|
||||||
|
{error && (
|
||||||
|
<div className="ao-activity-error">
|
||||||
|
불러오기 실패: {error}
|
||||||
|
<button type="button" onClick={retry}>재시도</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!error && items.length === 0 && !loading && (
|
||||||
|
<div className="ao-empty">최근 {filters.days}일 활동 없음</div>
|
||||||
|
)}
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<ActivityItem
|
||||||
|
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
|
||||||
|
item={item}
|
||||||
|
onSelectAgent={onSelectAgent}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{loading && <div className="ao-activity-loading">불러오는 중…</div>}
|
||||||
|
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
|
||||||
|
{!hasMore && items.length > 0 && <div className="ao-activity-end">더 이상 활동 없음</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/pages/agent-office/components/ActivityTimeline.test.jsx
Normal file
45
src/pages/agent-office/components/ActivityTimeline.test.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
import ActivityTimeline from './ActivityTimeline.jsx';
|
||||||
|
|
||||||
|
// jsdom IntersectionObserver stub
|
||||||
|
beforeEach(() => {
|
||||||
|
global.IntersectionObserver = class {
|
||||||
|
observe() {} unobserve() {} disconnect() {}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAgentActivity = vi.fn();
|
||||||
|
vi.mock('../../../api', () => ({
|
||||||
|
agentActivity: (...args) => mockAgentActivity(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ActivityTimeline', () => {
|
||||||
|
it('항목을 렌더하고 헤더에 total을 표시한다', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({
|
||||||
|
items: [{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||||
|
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('빈 결과면 안내 문구를 표시한다', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
|
||||||
|
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||||
|
await waitFor(() => expect(screen.getByText(/활동 없음/)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('항목 클릭 시 onSelectAgent가 호출된다', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({
|
||||||
|
items: [{ type: 'task', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', status: 'succeeded' }],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
|
||||||
|
const row = await screen.findByText('signal_check');
|
||||||
|
fireEvent.click(row.closest('.ao-activity-item'));
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('lotto');
|
||||||
|
});
|
||||||
|
});
|
||||||
64
src/pages/agent-office/hooks/useActivityFeed.js
Normal file
64
src/pages/agent-office/hooks/useActivityFeed.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// src/pages/agent-office/hooks/useActivityFeed.js
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { agentActivity } from '../../../api';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
export function useActivityFeed(filters, refreshTrigger = 0) {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const offsetRef = useRef(0);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
const filtersRef = useRef(filters);
|
||||||
|
filtersRef.current = filters;
|
||||||
|
|
||||||
|
const filterKey = JSON.stringify(filters);
|
||||||
|
|
||||||
|
const fetchPage = useCallback(async (offset, replace) => {
|
||||||
|
// append(loadMore)만 중복 방지. replace(필터/refresh 재조회)는 항상 우선 진행.
|
||||||
|
if (!replace && loadingRef.current) return;
|
||||||
|
const reqId = ++requestIdRef.current;
|
||||||
|
loadingRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await agentActivity({ ...filtersRef.current, limit: PAGE_SIZE, offset });
|
||||||
|
if (reqId !== requestIdRef.current) return; // 더 새로운 요청이 시작됨 → stale 응답 무시
|
||||||
|
const newItems = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
setTotal(data?.total || 0);
|
||||||
|
setItems(prev => (replace ? newItems : [...prev, ...newItems]));
|
||||||
|
offsetRef.current = offset + newItems.length;
|
||||||
|
} catch (e) {
|
||||||
|
if (reqId !== requestIdRef.current) return;
|
||||||
|
setError(e.message || '불러오기 실패');
|
||||||
|
} finally {
|
||||||
|
if (reqId === requestIdRef.current) {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
offsetRef.current = 0;
|
||||||
|
fetchPage(0, true);
|
||||||
|
}, [filterKey, refreshTrigger, fetchPage]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
if (offsetRef.current >= total) return;
|
||||||
|
fetchPage(offsetRef.current, false);
|
||||||
|
}, [fetchPage, total]);
|
||||||
|
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
offsetRef.current = 0;
|
||||||
|
fetchPage(0, true);
|
||||||
|
}, [fetchPage]);
|
||||||
|
|
||||||
|
const hasMore = items.length < total;
|
||||||
|
return { items, total, loading, error, hasMore, loadMore, retry };
|
||||||
|
}
|
||||||
73
src/pages/agent-office/hooks/useActivityFeed.test.js
Normal file
73
src/pages/agent-office/hooks/useActivityFeed.test.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
|
import { useActivityFeed } from './useActivityFeed.js';
|
||||||
|
|
||||||
|
const mockAgentActivity = vi.fn();
|
||||||
|
vi.mock('../../../api', () => ({
|
||||||
|
agentActivity: (...args) => mockAgentActivity(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => mockAgentActivity.mockReset());
|
||||||
|
|
||||||
|
const item = (over = {}) => ({ type: 'task', task_id: 'a', agent_id: 'stock', created_at: 't1', ...over });
|
||||||
|
|
||||||
|
describe('useActivityFeed', () => {
|
||||||
|
it('마운트 시 offset=0으로 첫 페이지를 불러온다', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 1 });
|
||||||
|
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||||
|
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||||
|
expect(mockAgentActivity).toHaveBeenCalledWith(expect.objectContaining({ days: 7, offset: 0 }));
|
||||||
|
expect(result.current.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadMore는 다음 offset으로 append한다', async () => {
|
||||||
|
mockAgentActivity
|
||||||
|
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 2 })
|
||||||
|
.mockResolvedValueOnce({ items: [item({ task_id: 'b', agent_id: 'lotto', created_at: 't2' })], total: 2 });
|
||||||
|
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||||
|
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||||
|
await act(async () => { result.current.loadMore(); });
|
||||||
|
await waitFor(() => expect(result.current.items).toHaveLength(2));
|
||||||
|
expect(mockAgentActivity).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('필터 변경 시 offset 리셋 + items 교체', async () => {
|
||||||
|
mockAgentActivity
|
||||||
|
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 1 })
|
||||||
|
.mockResolvedValueOnce({ items: [item({ type: 'log', task_id: 'c', agent_id: 'insta', created_at: 't3', level: 'info' })], total: 1 });
|
||||||
|
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
|
||||||
|
await waitFor(() => expect(result.current.items[0].task_id).toBe('a'));
|
||||||
|
rerender({ f: { days: 7, agent_id: 'insta' } });
|
||||||
|
await waitFor(() => expect(result.current.items[0].task_id).toBe('c'));
|
||||||
|
expect(result.current.items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshTrigger 변경 시 첫 페이지 재조회', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValue({ items: [item()], total: 1 });
|
||||||
|
const { rerender } = renderHook(({ rt }) => useActivityFeed({ days: 7 }, rt), { initialProps: { rt: 0 } });
|
||||||
|
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(1));
|
||||||
|
rerender({ rt: 1 });
|
||||||
|
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasMore는 items.length < total', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 5 });
|
||||||
|
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||||
|
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||||
|
expect(result.current.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('필터 변경 중이던 이전(stale) 요청 응답은 무시된다', async () => {
|
||||||
|
let resolveFirst;
|
||||||
|
const firstPromise = new Promise(r => { resolveFirst = r; });
|
||||||
|
mockAgentActivity
|
||||||
|
.mockReturnValueOnce(firstPromise) // 초기 요청 — 느리게 resolve
|
||||||
|
.mockResolvedValueOnce({ items: [item({ task_id: 'fresh', agent_id: 'insta' })], total: 1 });
|
||||||
|
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
|
||||||
|
rerender({ f: { days: 7, agent_id: 'insta' } }); // 첫 요청 resolve 전에 필터 변경
|
||||||
|
await waitFor(() => expect(result.current.items[0]?.task_id).toBe('fresh'));
|
||||||
|
await act(async () => { resolveFirst({ items: [item({ task_id: 'stale' })], total: 99 }); });
|
||||||
|
expect(result.current.items[0].task_id).toBe('fresh'); // stale이 덮어쓰지 않음
|
||||||
|
expect(result.current.total).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user