Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -780,12 +780,12 @@ describe('Signal product UI', () => {
});

test('gives strategy names and dates the flexible column instead of a removed drag-handle track', () => {
expect(balancedStyles).toMatch(/\.strategy-row\s*\{[^}]*grid-template-columns:\s*38px minmax\(180px,\s*1fr\) 64px 90px 68px;/s);
expect(balancedStyles).toMatch(/\.variant-balanced\[data-design="signal-studio"\] \.strategy-row\s*\{[^}]*grid-template-columns:\s*30px minmax\(180px,\s*1fr\) 72px 96px 68px;/s);
expect(balancedStyles).toMatch(/\.strategy-row\s*\{[^}]*grid-template-columns:\s*38px minmax\(180px,\s*1fr\) 64px 90px 104px;/s);
expect(balancedStyles).toMatch(/\.variant-balanced\[data-design="signal-studio"\] \.strategy-row\s*\{[^}]*grid-template-columns:\s*30px minmax\(180px,\s*1fr\) 72px 96px 104px;/s);
expect(balancedStyles).not.toMatch(/grid-template-columns:\s*(?:16px 38px|13px 30px)/);
});

/* The track above is a fixed two-button width. A row that renders only one
/* The track above is a fixed three-button width. A row that renders only one
button must push it to the right edge, or the fixed track would leave the
button floating in the middle of its own column. */
test('right-aligns row actions inside the fixed action track', () => {
Expand Down
84 changes: 84 additions & 0 deletions src/DeletionFlows.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, test, vi } from 'vitest';
import type { BotOperationsClient, BotOperationsView } from './api/botOperations';
import type { StrategyAuthoringClient, StrategyLibraryClient } from './api/strategies';
import { BotsView } from './views/BotsView';
import { StrategyHome } from './views/StrategyViews';

const strategyLibrary: StrategyLibraryClient = {
list: vi.fn().mockResolvedValue({
items: [{
id: 'strategy-id', kind: 'draft', mode: 'BASIC', name: '삭제할 전략', description: null,
status: 'DRAFT', validationStatus: 'VALID', backtestStatus: null, editable: true,
updatedAt: '2026-08-09T00:00:00Z', version: null, blockCount: 1, symbols: ['AAPL'],
}],
nextCursor: null,
hasMore: false,
}),
};

const stoppedBot: BotOperationsView = {
botId: 'bot-id',
name: '정지된 봇',
state: 'stopped',
lifecycleChangedAt: '2026-08-09T00:00:00Z',
executionBlockedAt: null,
executionBlockReasonCode: null,
lastEventSequence: 0,
instruments: [],
};

describe('destructive resource flows', () => {
test('confirms and removes an owned strategy from the library', async () => {
const user = userEvent.setup();
const deleteStrategy = vi.fn().mockResolvedValue(undefined);
const authoringClient = { deleteStrategy } as unknown as StrategyAuthoringClient;

render(<MemoryRouter><StrategyHome openEditor={() => {}} client={strategyLibrary} authoringClient={authoringClient} /></MemoryRouter>);

await user.click(await screen.findByRole('button', { name: '삭제할 전략 삭제' }));
expect(screen.getByRole('dialog', { name: '전략 삭제 확인' })).toHaveTextContent('출시된 봇과 기록은 유지됩니다');
await user.click(screen.getByRole('button', { name: '전략 삭제' }));

await waitFor(() => expect(deleteStrategy).toHaveBeenCalledWith('strategy-id'));
expect(screen.queryByTestId('strategy-row-삭제할 전략')).not.toBeInTheDocument();
});

test('offers deletion only for a stopped bot and removes it after confirmation', async () => {
const user = userEvent.setup();
const deleteBot = vi.fn().mockResolvedValue(undefined);
const operationsClient: BotOperationsClient = {
listOperations: vi.fn().mockResolvedValue([stoppedBot]),
listJudgments: vi.fn().mockResolvedValue({ entries: [], nextAfterSequence: 0, hasMore: false }),
runBot: vi.fn(),
stopBot: vi.fn(),
deleteBot,
};

render(<MemoryRouter><BotsView operationsClient={operationsClient} tradingClient={null} marketDataClient={null} /></MemoryRouter>);

await user.click(await screen.findByRole('button', { name: '정지된 봇 삭제' }));
expect(screen.getByRole('dialog', { name: '봇 삭제 확인' })).toHaveTextContent('운용 및 거래 기록은 유지됩니다');
await user.click(screen.getByRole('button', { name: '봇 삭제' }));

await waitFor(() => expect(deleteBot).toHaveBeenCalledWith('bot-id'));
expect(screen.queryByRole('button', { name: '정지된 봇 상세 보기' })).not.toBeInTheDocument();
});

test('does not expose deletion while a bot is still running', async () => {
const operationsClient: BotOperationsClient = {
listOperations: vi.fn().mockResolvedValue([{ ...stoppedBot, name: '실행 중 봇', state: 'running' }]),
listJudgments: vi.fn().mockResolvedValue({ entries: [], nextAfterSequence: 0, hasMore: false }),
runBot: vi.fn(),
stopBot: vi.fn(),
deleteBot: vi.fn(),
};

render(<MemoryRouter><BotsView operationsClient={operationsClient} tradingClient={null} marketDataClient={null} /></MemoryRouter>);

expect(await screen.findByRole('button', { name: '실행 중 봇 상세 보기' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '실행 중 봇 삭제' })).not.toBeInTheDocument();
});
});
12 changes: 12 additions & 0 deletions src/api/botOperations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ describe('bot operations API client', () => {
);
});

it('soft-deletes a stopped bot without parsing an empty response', async () => {
const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
const client = createBotOperationsClient({ baseUrl: 'https://api.example.com', fetchImpl });

await client.deleteBot!('30000000-0000-4000-8000-000000000001');

expect(fetchImpl).toHaveBeenCalledWith(
'https://api.example.com/api/v1/bots/30000000-0000-4000-8000-000000000001',
expect.objectContaining({ method: 'DELETE', credentials: 'include' }),
);
});

it('loads preflight and renews the server-owned continuation deadline', async () => {
const fetchImpl = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
Expand Down
16 changes: 13 additions & 3 deletions src/api/botOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface BotOperationsClient {
): Promise<BotJudgmentLogPage>;
runBot(botId: string, signal?: AbortSignal): Promise<void>;
stopBot(botId: string, reasonCode: string, signal?: AbortSignal): Promise<void>;
deleteBot?(botId: string, signal?: AbortSignal): Promise<void>;
getPreflight?(botId: string, signal?: AbortSignal): Promise<BotExecutionPreflight>;
getContinuation?(botId: string, signal?: AbortSignal): Promise<BotContinuation>;
renewContinuation?(botId: string, signal?: AbortSignal): Promise<BotContinuation>;
Expand Down Expand Up @@ -97,11 +98,11 @@ export function createBotOperationsClient({
}: ClientOptions = {}): BotOperationsClient {
const root = baseUrl.replace(/\/$/, '');

const request = async (
const requestResponse = async (
path: string,
signal?: AbortSignal,
init: RequestInit = {},
): Promise<unknown> => {
): Promise<Response> => {
const token = getAccessToken?.();
const response = await fetchImpl(`${root}${path}`, {
...init,
Expand All @@ -117,8 +118,11 @@ export function createBotOperationsClient({
if (!response.ok) {
throw new BotOperationsApiError(response.status);
}
return response.json();
return response;
};
const request = async (path: string, signal?: AbortSignal, init: RequestInit = {}): Promise<unknown> => (
(await requestResponse(path, signal, init)).json()
);

return {
async listOperations(signal) {
Expand Down Expand Up @@ -154,6 +158,12 @@ export function createBotOperationsClient({
});
},

async deleteBot(botId, signal) {
await requestResponse(`/api/v1/bots/${encodeURIComponent(botId)}`, signal, {
method: 'DELETE',
});
},

async getPreflight(botId, signal) {
return readPreflight(await request(
`/api/v1/bots/${encodeURIComponent(botId)}/preflight`, signal,
Expand Down
12 changes: 12 additions & 0 deletions src/api/strategies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ describe('strategy authoring API client', () => {
);
});

it('soft-deletes an owned strategy through the versioned command', async () => {
const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
const client = createStrategyAuthoringClient({ baseUrl: 'https://api.example.com/', fetchImpl });

await client.deleteStrategy!(document.strategyId);

expect(fetchImpl).toHaveBeenCalledWith(
`https://api.example.com/api/v1/strategies/${document.strategyId}`,
expect.objectContaining({ method: 'DELETE', credentials: 'include' }),
);
});

it('loads, leases, heartbeats, saves, and releases an owned document', async () => {
const fetchImpl = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(document), { status: 200 }))
Expand Down
6 changes: 6 additions & 0 deletions src/api/strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export interface ReleaseStrategyInput {
export interface StrategyAuthoringClient {
createBasic(name: string, description?: string, signal?: AbortSignal): Promise<{ id: string; mode: 'BASIC' }>;
copyStrategy(strategyId: string, signal?: AbortSignal): Promise<{ id: string }>;
deleteStrategy?(strategyId: string, signal?: AbortSignal): Promise<void>;
getDocument(strategyId: string, signal?: AbortSignal): Promise<StrategyDocument>;
acquireLease(strategyId: string, signal?: AbortSignal): Promise<StrategyEditLease>;
heartbeatLease(strategyId: string, leaseToken: string, signal?: AbortSignal): Promise<{ expiresAt: string }>;
Expand Down Expand Up @@ -265,6 +266,11 @@ export function createStrategyAuthoringClient({
const result = object(await response.json(), 'Invalid strategy copy response');
return { id: string(result.id, 'id') };
},
async deleteStrategy(strategyId, signal) {
await request(`/api/v1/strategies/${encodeURIComponent(strategyId)}`, 'Strategy deletion', {
method: 'DELETE', signal,
});
},
async getDocument(strategyId, signal) {
const response = await request(documentPath(strategyId), 'Strategy document request', { signal });
return readDocument(await response.json());
Expand Down
53 changes: 50 additions & 3 deletions src/styles/balanced.css
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@
.strategy-row {
min-height: 88px;
display: grid;
grid-template-columns: 38px minmax(180px, 1fr) 64px 90px 68px;
grid-template-columns: 38px minmax(180px, 1fr) 64px 90px 104px;
align-items: center;
gap: 13px;
padding: 12px 16px;
Expand Down Expand Up @@ -467,6 +467,7 @@
}
.strategy-row-actions button:hover,
.strategy-create-dialog header button:hover { color: var(--accent); background: var(--accent-soft); }
.strategy-row-actions button.is-danger:hover { color: var(--negative); background: var(--negative-soft); }
.strategy-row-actions button:disabled,
.strategy-import-list > button:disabled { color: var(--text-faint); cursor: not-allowed; opacity: .58; }
.strategy-row-actions button:disabled:hover,
Expand All @@ -487,6 +488,52 @@
background: rgba(11, 18, 32, .38);
backdrop-filter: blur(5px);
}
.resource-delete-backdrop {
position: fixed;
z-index: 220;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
background: rgba(5, 8, 12, .54);
backdrop-filter: blur(5px);
}
.resource-delete-dialog {
width: min(410px, 100%);
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
gap: 12px;
padding: 18px;
border: 1px solid var(--line-strong);
border-radius: 15px;
color: var(--text);
background: var(--surface);
box-shadow: var(--shadow-overlay);
}
.resource-delete-dialog > span {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 11px;
color: var(--negative);
background: var(--negative-soft);
}
.resource-delete-dialog > div { display: grid; gap: 7px; }
.resource-delete-dialog strong { font-size: 13px; }
.resource-delete-dialog p { margin: 0; color: var(--text-soft); font-size: 11px; line-height: 1.65; }
.resource-delete-dialog p.resource-delete-error { color: var(--negative); }
.resource-delete-dialog footer {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 7px;
padding-top: 5px;
}
.resource-delete-confirm,
.resource-delete-trigger { border-color: color-mix(in srgb, var(--negative) 45%, var(--line)) !important; color: var(--negative) !important; }
.resource-delete-confirm:hover:not(:disabled),
.resource-delete-trigger:hover:not(:disabled) { background: var(--negative-soft) !important; }
.strategy-create-dialog {
width: min(460px, 100%);
overflow: hidden;
Expand Down Expand Up @@ -2473,10 +2520,10 @@
}
.variant-balanced[data-design="signal-studio"] .strategy-row {
min-height: 76px;
/* The action column is a fixed two-button width even when a row shows one
/* The action column is a fixed three-button width even when a row shows one
button, so the mode label and status stay on the same vertical line down
the whole list instead of drifting with the row's action count. */
grid-template-columns: 30px minmax(180px,1fr) 72px 96px 68px;
grid-template-columns: 30px minmax(180px,1fr) 72px 96px 104px;
border-top-color: var(--line);
}
.variant-balanced[data-design="signal-studio"] .strategy-row:hover { background: color-mix(in srgb,var(--accent-soft) 25%,var(--surface)); }
Expand Down
52 changes: 51 additions & 1 deletion src/views/BotsView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import { Bot, Boxes, CircleDollarSign, Coins, GitBranch, GripVertical, LockKeyhole, Play, Save, Search, ShieldCheck, Timer, X } from 'lucide-react';
import { Bot, Boxes, CircleDollarSign, Coins, GitBranch, GripVertical, LockKeyhole, Play, Save, Search, ShieldCheck, Timer, Trash2, X } from 'lucide-react';
import { Button, DataTable, EmptyState, ErrorState, LoadingState, PageHeading, Status, TabPanel, Tabs } from '../components/common';
import { ErrorPage, SignInRequiredPage } from '../components/StatePages';
import type { DataTableColumn } from '../components/common';
Expand Down Expand Up @@ -1270,6 +1270,9 @@ export function BotsView({
const [preflight, setPreflight] = useState<BotExecutionPreflight | null | undefined>(undefined);
const [continuation, setContinuation] = useState<BotContinuation | null | undefined>(undefined);
const [botControlError, setBotControlError] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletePending, setDeletePending] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const cursorByBot = useRef<Record<string, number>>({});
const activeBots = useMemo(
() => prototypeMode ? staticBotList : operations === null ? [] : mergeBotOperations(operations),
Expand Down Expand Up @@ -1455,6 +1458,30 @@ export function BotsView({
}
};

const deleteStoppedBot = async () => {
if (!operationsClient?.deleteBot || !selected?.id || selectedOperations?.state !== 'stopped' || deletePending) return;
const deletedId = selected.id;
setDeletePending(true);
setDeleteError(null);
try {
await operationsClient.deleteBot(deletedId);
const remaining = (confirmedOperationsRef.current ?? operations ?? [])
.filter((bot) => bot.botId !== deletedId);
confirmedOperationsRef.current = remaining;
setOperations(remaining);
setDeleteDialogOpen(false);
setCommandMessage('봇을 목록에서 삭제했습니다.');
} catch (error) {
setDeleteError(error instanceof BotOperationsApiError && error.status === 409
? '정산이 끝나고 봇이 중지됨 상태가 된 뒤 삭제할 수 있습니다.'
: error instanceof BotOperationsApiError && error.status === 404
? '이 봇은 이미 삭제되었거나 더 이상 접근할 수 없습니다.'
: '봇을 삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.');
} finally {
setDeletePending(false);
}
};

useEffect(() => {
if (!operationsClient || !selected?.id) return undefined;
const botId = selected.id;
Expand Down Expand Up @@ -1918,6 +1945,13 @@ export function BotsView({
aria-label={`${selected.name} 영구 중단`}
onClick={() => void issueBotCommand('stop')}
>영구 중단</Button>}
{selectedOperations?.state === 'stopped' && operationsClient?.deleteBot && <Button
className="resource-delete-trigger"
icon={Trash2}
disabled={deletePending}
aria-label={`${selected.name} 삭제`}
onClick={() => { setDeleteError(null); setDeleteDialogOpen(true); }}
>삭제</Button>}
</div>
</header>
{commandMessage && <p className="bots-decision-note" role="status">{commandMessage}</p>}
Expand Down Expand Up @@ -2220,5 +2254,21 @@ export function BotsView({
setLayoutOpen(false);
}}
/>}
{deleteDialogOpen && selected && <div className="resource-delete-backdrop" onMouseDown={() => { if (!deletePending) setDeleteDialogOpen(false); }}>
<section role="dialog" aria-modal="true" aria-label="봇 삭제 확인" className="resource-delete-dialog" onMouseDown={(event) => event.stopPropagation()}>
<span aria-hidden="true"><Trash2 size={19} /></span>
<div>
<strong>‘{selected.name}’ 봇을 삭제할까요?</strong>
<p>봇 목록에서 제거되며 되돌릴 수 없습니다. 감사에 필요한 운용 및 거래 기록은 유지됩니다.</p>
{deleteError && <p className="resource-delete-error" role="alert">{deleteError}</p>}
</div>
<footer>
<Button disabled={deletePending} onClick={() => setDeleteDialogOpen(false)}>취소</Button>
<Button className="resource-delete-confirm" disabled={deletePending} icon={Trash2} onClick={() => { void deleteStoppedBot(); }}>
{deletePending ? '삭제 중…' : '봇 삭제'}
</Button>
</footer>
</section>
</div>}
</div></Localized>;
}
Loading
Loading