Compare commits
4 Commits
0dce449124
...
ec5fee8429
| Author | SHA1 | Date | |
|---|---|---|---|
| ec5fee8429 | |||
| 96cc5e7839 | |||
| e6742e06ba | |||
| b713f00bf9 |
@@ -97,7 +97,6 @@
|
||||
.ao-card-dot.working { background: #22c55e; }
|
||||
.ao-card-dot.error { background: #ef4444; }
|
||||
.ao-card-dot.waiting_approval { background: #f59e0b; }
|
||||
.ao-card-dot.break { background: #94a3b8; }
|
||||
.ao-card-dot.pulse {
|
||||
animation: ao-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import './AgentOffice.css';
|
||||
|
||||
export default function AgentOffice() {
|
||||
const {
|
||||
agents, pendingTasks, notifications, connected,
|
||||
agents, pendingTasks, notifications, connected, reconnectAttempt,
|
||||
refreshTrigger, clearNotifications
|
||||
} = useAgentManager();
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function AgentOffice() {
|
||||
|
||||
return (
|
||||
<div className="ao-root">
|
||||
<TopBar connected={connected} />
|
||||
<TopBar connected={connected} reconnectAttempt={reconnectAttempt} />
|
||||
<div className="ao-main">
|
||||
<div className="ao-grid-wrap">
|
||||
<AgentGrid
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function LogTab({ agentId, refreshTrigger }) {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAgentLogs(agentId, 50).then(data => {
|
||||
if (!cancelled) setLogs(data || []);
|
||||
if (!cancelled) setLogs(Array.isArray(data) ? data : (data?.logs || []));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [agentId, refreshTrigger]);
|
||||
|
||||
@@ -11,6 +11,22 @@ const STATUS_STYLE = {
|
||||
rejected: { bg: '#7f1d1d', fg: '#fca5a5' }
|
||||
};
|
||||
|
||||
// result_data는 백엔드에서 dict 또는 string 둘 다 올 수 있다.
|
||||
// React child로 직접 못 그리는 객체는 stringify, string은 parse 시도 후 pretty,
|
||||
// 둘 다 안 되면 원본 문자열을 그대로 표시.
|
||||
function formatResultData(rd) {
|
||||
if (rd == null) return '';
|
||||
if (typeof rd === 'object') {
|
||||
try { return JSON.stringify(rd, null, 2); }
|
||||
catch { return String(rd); }
|
||||
}
|
||||
if (typeof rd === 'string') {
|
||||
try { return JSON.stringify(JSON.parse(rd), null, 2); }
|
||||
catch { return rd; }
|
||||
}
|
||||
return String(rd);
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
@@ -27,7 +43,7 @@ export default function TaskTab({ agentId, refreshTrigger }) {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAgentTasks(agentId, 20).then(data => {
|
||||
if (!cancelled) setTasks(data || []);
|
||||
if (!cancelled) setTasks(Array.isArray(data) ? data : (data?.tasks || []));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [agentId, refreshTrigger]);
|
||||
@@ -46,10 +62,7 @@ export default function TaskTab({ agentId, refreshTrigger }) {
|
||||
</div>
|
||||
{expanded === task.id && task.result_data && (
|
||||
<pre className="ao-task-result">
|
||||
{(() => {
|
||||
try { return JSON.stringify(JSON.parse(task.result_data), null, 2); }
|
||||
catch { return task.result_data; }
|
||||
})()}
|
||||
{formatResultData(task.result_data)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
87
src/pages/agent-office/components/TaskTab.test.jsx
Normal file
87
src/pages/agent-office/components/TaskTab.test.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import TaskTab from './TaskTab.jsx';
|
||||
|
||||
const mockGetAgentTasks = vi.fn();
|
||||
vi.mock('../../../api', () => ({
|
||||
getAgentTasks: (...args) => mockGetAgentTasks(...args),
|
||||
}));
|
||||
|
||||
describe('TaskTab response shape handling', () => {
|
||||
it('백엔드가 {tasks: [...]} 객체로 wrapping해서 응답해도 .map 깨지지 않음', async () => {
|
||||
mockGetAgentTasks.mockResolvedValueOnce({
|
||||
tasks: [
|
||||
{ id: 't1', task_type: 'compose', status: 'succeeded', created_at: '2026-05-18T08:00:00Z' },
|
||||
{ id: 't2', task_type: 'fetch_news', status: 'failed', created_at: '2026-05-18T08:05:00Z' },
|
||||
],
|
||||
});
|
||||
render(<TaskTab agentId="music" refreshTrigger={0} />);
|
||||
await waitFor(() => expect(screen.getByText('compose')).toBeInTheDocument());
|
||||
expect(screen.getByText('fetch_news')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('백엔드가 bare array를 반환해도 동작 (backward compat)', async () => {
|
||||
mockGetAgentTasks.mockResolvedValueOnce([
|
||||
{ id: 't9', task_type: 'compose', status: 'succeeded', created_at: '2026-05-18T08:00:00Z' },
|
||||
]);
|
||||
render(<TaskTab agentId="music" refreshTrigger={0} />);
|
||||
await waitFor(() => expect(screen.getByText('compose')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('응답이 falsy/empty이면 No tasks yet 표시', async () => {
|
||||
mockGetAgentTasks.mockResolvedValueOnce({ tasks: [] });
|
||||
render(<TaskTab agentId="music" refreshTrigger={0} />);
|
||||
await waitFor(() => expect(screen.getByText('No tasks yet')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('task 클릭 → result_data가 객체일 때도 stringify되어 안전하게 렌더', async () => {
|
||||
mockGetAgentTasks.mockResolvedValueOnce({
|
||||
tasks: [{
|
||||
id: 't_compose',
|
||||
task_type: 'compose',
|
||||
status: 'succeeded',
|
||||
created_at: '2026-05-18T08:00:00Z',
|
||||
result_data: { music_task_id: 'abc-123', tracks: [] },
|
||||
}],
|
||||
});
|
||||
render(<TaskTab agentId="music" refreshTrigger={0} />);
|
||||
const row = await screen.findByText('compose');
|
||||
fireEvent.click(row.closest('.ao-task-item'));
|
||||
const pre = await screen.findByText(/music_task_id/);
|
||||
expect(pre.textContent).toContain('"music_task_id": "abc-123"');
|
||||
expect(pre.textContent).toContain('"tracks": []');
|
||||
});
|
||||
|
||||
it('task 클릭 → result_data가 JSON 문자열일 때 parse 후 pretty 렌더', async () => {
|
||||
mockGetAgentTasks.mockResolvedValueOnce({
|
||||
tasks: [{
|
||||
id: 't_str',
|
||||
task_type: 'compose',
|
||||
status: 'succeeded',
|
||||
created_at: '2026-05-18T08:00:00Z',
|
||||
result_data: '{"foo":"bar"}',
|
||||
}],
|
||||
});
|
||||
render(<TaskTab agentId="music" refreshTrigger={0} />);
|
||||
const row = await screen.findByText('compose');
|
||||
fireEvent.click(row.closest('.ao-task-item'));
|
||||
const pre = await screen.findByText(/foo/);
|
||||
expect(pre.textContent).toContain('"foo": "bar"');
|
||||
});
|
||||
|
||||
it('task 클릭 → result_data가 plain string이면 그대로 표시 (parse 실패 fallback)', async () => {
|
||||
mockGetAgentTasks.mockResolvedValueOnce({
|
||||
tasks: [{
|
||||
id: 't_plain',
|
||||
task_type: 'fetch_news',
|
||||
status: 'succeeded',
|
||||
created_at: '2026-05-18T08:00:00Z',
|
||||
result_data: 'Just a log line',
|
||||
}],
|
||||
});
|
||||
render(<TaskTab agentId="music" refreshTrigger={0} />);
|
||||
const row = await screen.findByText('fetch_news');
|
||||
fireEvent.click(row.closest('.ao-task-item'));
|
||||
expect(await screen.findByText('Just a log line')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,19 @@
|
||||
// src/pages/agent-office/components/TopBar.jsx
|
||||
export default function TopBar({ connected }) {
|
||||
export default function TopBar({ connected, reconnectAttempt = 0 }) {
|
||||
let statusText;
|
||||
if (connected) {
|
||||
statusText = 'Connected';
|
||||
} else if (reconnectAttempt === 0) {
|
||||
statusText = 'Connecting…';
|
||||
} else {
|
||||
statusText = `Disconnected · 재연결 시도 #${reconnectAttempt}`;
|
||||
}
|
||||
return (
|
||||
<div className="ao-topbar">
|
||||
<div className="ao-topbar-left">
|
||||
<span className="ao-topbar-title">Agent Office</span>
|
||||
<span className={`ao-topbar-status ${connected ? 'connected' : 'disconnected'}`}>
|
||||
● {connected ? 'Connected' : 'Disconnected'}
|
||||
● {statusText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,6 @@ export const STATE_COLORS = {
|
||||
working: { color: '#22c55e', pulse: true },
|
||||
error: { color: '#ef4444', pulse: false },
|
||||
waiting_approval: { color: '#f59e0b', pulse: true },
|
||||
break: { color: '#94a3b8', pulse: false },
|
||||
};
|
||||
|
||||
export const DEFAULT_STATE_COLOR = STATE_COLORS.idle;
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
// src/pages/agent-office/hooks/useAgentManager.js
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
const WS_RECONNECT_DELAY = 3000;
|
||||
// Exponential backoff with cap. 1s → 2s → 4s → 8s → 16s → 30s (cap)
|
||||
const WS_BACKOFF_BASE_MS = 1000;
|
||||
const WS_BACKOFF_CAP_MS = 30000;
|
||||
const WS_BACKOFF_MAX_EXP = 5;
|
||||
|
||||
export function useAgentManager() {
|
||||
const [agents, setAgents] = useState({}); // { agentId: { state, detail, task_id } }
|
||||
const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}]
|
||||
const [notifications, setNotifications] = useState({}); // { agentId: count }
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [reconnectAttempt, setReconnectAttempt] = useState(0);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용
|
||||
|
||||
const wsRef = useRef(null);
|
||||
const reconnectRef = useRef(null);
|
||||
const connectRef = useRef(null);
|
||||
const attemptRef = useRef(0);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
attemptRef.current = 0;
|
||||
setReconnectAttempt(0);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
@@ -69,10 +78,17 @@ export function useAgentManager() {
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
reconnectRef.current = setTimeout(() => connectRef.current?.(), WS_RECONNECT_DELAY);
|
||||
const exp = Math.min(attemptRef.current, WS_BACKOFF_MAX_EXP);
|
||||
const delay = Math.min(WS_BACKOFF_BASE_MS * 2 ** exp, WS_BACKOFF_CAP_MS);
|
||||
attemptRef.current += 1;
|
||||
setReconnectAttempt(attemptRef.current);
|
||||
reconnectRef.current = setTimeout(() => connectRef.current?.(), delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
// onerror fires before onclose; swallow so the browser doesn't print an
|
||||
// unhandled-error pair for every retry. onclose still runs and schedules
|
||||
// the next attempt.
|
||||
ws.onerror = () => {};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -108,6 +124,7 @@ export function useAgentManager() {
|
||||
pendingTasks,
|
||||
notifications,
|
||||
connected,
|
||||
reconnectAttempt,
|
||||
refreshTrigger,
|
||||
sendCommand,
|
||||
sendApproval,
|
||||
|
||||
Reference in New Issue
Block a user