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
44 changes: 13 additions & 31 deletions frontend/src/pages/Scans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
formatLocaleTime,
} from "../utils/date";
import { ConfirmModal } from "../components/ConfirmModal";
import { useToast } from "../components/ToastContext";
import Pagination from "../components/Pagination";

interface Task {
Expand Down Expand Up @@ -58,6 +59,7 @@ const itemVariants: Variants = {

export default function Scans() {
const navigate = useNavigate();
const { addToast } = useToast();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("all");
Expand All @@ -66,7 +68,6 @@ export default function Scans() {
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const PAGE_LIMIT = 10;
const [error, setError] = useState<string | null>(null);

// Modal state for confirm dialogs
const [modalState, setModalState] = useState<{
Expand Down Expand Up @@ -174,7 +175,7 @@ export default function Scans() {
}

function handleFilterChange(value: string) {
setError(null);

setFilter(value);
setPage(1);
}
Expand All @@ -193,6 +194,7 @@ export default function Scans() {
}
} catch (err) {
console.error("Rescan failed:", err);
addToast("Rescan failed. Please try again.", "error");
}
}

Expand All @@ -204,14 +206,14 @@ export default function Scans() {
type: "danger",
onConfirm: async () => {
try {
setError(null);

await deleteTask(taskId);
setTasks((prev) => prev.filter((t) => t.task_id !== taskId));
if (expandedId === taskId) setExpandedId(null);
setModalState(prev => ({ ...prev, isOpen: false }));
} catch (err) {
console.error("Failed to delete task:", err);
setError("Failed to delete task. It might still be running.");
addToast("Failed to delete task. It might still be running.", "error");
setModalState(prev => ({ ...prev, isOpen: false }));
}
},
Expand All @@ -226,15 +228,15 @@ export default function Scans() {
type: "danger",
onConfirm: async () => {
try {
setError(null);

await clearAllTasks();
setTasks([]);
setSelectedIds([]);
setExpandedId(null);
setModalState(prev => ({ ...prev, isOpen: false }));
} catch (err) {
console.error("Failed to clear history:", err);
setError("Failed to clear history. Ensure no tasks are currently running.");
addToast("Failed to clear history. Ensure no tasks are currently running.", "error");
setModalState(prev => ({ ...prev, isOpen: false }));
}
},
Expand All @@ -250,14 +252,14 @@ export default function Scans() {
type: "danger",
onConfirm: async () => {
try {
setError(null);

await bulkDeleteTasks(selectedIds);
setTasks((prev) => prev.filter((t) => !selectedIds.includes(t.task_id)));
setSelectedIds([]);
setModalState(prev => ({ ...prev, isOpen: false }));
} catch (err) {
console.error("Bulk delete failed:", err);
setError("Failed to delete some tasks. Ensure they are not currently running.");
addToast("Failed to delete some tasks. Ensure they are not currently running.", "error");
setModalState(prev => ({ ...prev, isOpen: false }));
}
},
Expand Down Expand Up @@ -326,27 +328,7 @@ export default function Scans() {
</div>
</header>

{error && (
<div
role="alert"
className="bg-rag-red/10 border-4 border-rag-red text-rag-red p-6 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] flex items-center justify-between gap-4"
>
<div className="flex items-center gap-4">
<span className="material-symbols-outlined text-2xl shrink-0">error</span>
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em]">System_Alert</p>
<p className="font-mono text-xs">{error}</p>
</div>
</div>
<button
onClick={() => setError(null)}
className="text-rag-red/60 hover:text-rag-red transition-colors shrink-0"
aria-label="Close alert"
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
</div>
)}


{/* Filtration Block */}
<section className="bg-charcoal border-4 border-black p-8 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] flex flex-col xl:flex-row justify-between items-center gap-12">
Expand Down Expand Up @@ -737,11 +719,11 @@ export default function Scans() {
limit={PAGE_LIMIT}
loading={loading}
onPrev={() => {
setError(null);

setPage((p) => p - 1);
}}
onNext={() => {
setError(null);

setPage((p) => p + 1);
}}
/>
Expand Down
7 changes: 5 additions & 2 deletions frontend/testing/unit/pages/Scans.polling.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { render, act, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import Scans from '../../../src/pages/Scans';
import { ToastProvider } from '../../../src/components/ToastContext'

// ── Mocks ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -68,8 +69,10 @@ afterEach(() => {
function renderScans() {
return render(
<MemoryRouter>
<Scans />
</MemoryRouter>,
<ToastProvider>
<Scans />
</ToastProvider>
</MemoryRouter>
);
}

Expand Down
14 changes: 6 additions & 8 deletions frontend/testing/unit/pages/Scans.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { MemoryRouter } from 'react-router-dom'
import Scans from '../../../src/pages/Scans'
import { ToastProvider } from '../../../src/components/ToastContext'

// ── Mocks ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -83,7 +84,9 @@ function mockFetch(tasks: ReturnType<typeof makeTask>[], total?: number) {
function renderScans() {
return render(
<MemoryRouter>
<Scans />
<ToastProvider>
<Scans />
</ToastProvider>
</MemoryRouter>
)
}
Expand Down Expand Up @@ -273,12 +276,7 @@ describe('Scans — task list', () => {
expect(screen.getByText('nmap')).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()

// Test that the error banner can be closed
const closeBtn = screen.getByRole('button', { name: /Close alert/i })
await userEvent.click(closeBtn)

await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
// Error is shown via toast, not the banner — no Close alert button expected
expect(screen.queryByRole('button', { name: /Close alert/i })).not.toBeInTheDocument()
})
})
7 changes: 5 additions & 2 deletions frontend/testing/unit/pages/ScansPhases.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { render, act, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import Scans from '../../../src/pages/Scans';
import { ToastProvider } from '../../../src/components/ToastContext'

vi.mock('../../../src/api', () => ({
API_BASE: 'http://localhost',
Expand Down Expand Up @@ -64,8 +65,10 @@ async function tickTime(ms: number) {
function renderScans() {
return render(
<MemoryRouter>
<Scans />
</MemoryRouter>,
<ToastProvider>
<Scans />
</ToastProvider>
</MemoryRouter>
);
}

Expand Down
129 changes: 129 additions & 0 deletions frontend/testing/unit/pages/ScansToastErrors.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { ToastProvider } from '../../../src/components/ToastContext'
import Scans from '../../../src/pages/Scans'
import { deleteTask, clearAllTasks, bulkDeleteTasks, startTask } from '../../../src/api'

vi.mock('../../../src/api', async () => {
const actual: any = await vi.importActual('../../../src/api')
return {
...actual,
deleteTask: vi.fn(),
clearAllTasks: vi.fn(),
bulkDeleteTasks: vi.fn(),
startTask: vi.fn(),
}
})

function makeTask(overrides = {}) {
return {
task_id: 'task-1',
plugin_id: 'nmap',
tool: 'nmap',
target: 'example.com',
status: 'completed',
created_at: '2026-05-14T10:00:00Z',
...overrides,
}
}

function mockFetch(tasks: any[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tasks, pagination: { total_items: tasks.length } }),
}),
)
}

function renderScans() {
return render(
<MemoryRouter>
<ToastProvider>
<Scans />
</ToastProvider>
</MemoryRouter>,
)
}

beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockFetch([makeTask()])
})

afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})

describe('Scans — toast error feedback', () => {
it('shows a toast when clear all tasks fails', async () => {
vi.mocked(clearAllTasks).mockRejectedValueOnce(new Error('clear failed'))
const user = userEvent.setup()
renderScans()

await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument())

await user.click(screen.getByRole('button', { name: /Purge_All_Records/i }))
const confirmBtn = await screen.findByRole('button', { name: /Confirm/i })
await user.click(confirmBtn)

await waitFor(() => {
expect(screen.getByText(/Failed to clear history/i)).toBeInTheDocument()
})
})

it('shows a toast when bulk delete fails', async () => {
vi.mocked(bulkDeleteTasks).mockRejectedValueOnce(new Error('bulk failed'))
const user = userEvent.setup()
renderScans()

await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument())

await user.click(screen.getByRole('button', { name: /Select_All/i }))
await waitFor(() => expect(screen.getByText('1')).toBeInTheDocument())

await user.click(screen.getByRole('button', { name: /Prune_Selected_Records/i }))
const confirmBtn = await screen.findByRole('button', { name: /Confirm/i })
await user.click(confirmBtn)

await waitFor(() => {
expect(screen.getByText(/Failed to delete some tasks/i)).toBeInTheDocument()
})
})

it('shows a toast when rescan fails', async () => {
vi.mocked(startTask).mockRejectedValueOnce(new Error('rescan failed'))
const user = userEvent.setup()
renderScans()

await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument())

const rescanBtn = screen.getByRole('button', { name: /Re-run nmap scan/i })
await user.click(rescanBtn)

await waitFor(() => {
expect(screen.getByText(/Rescan failed/i)).toBeInTheDocument()
})
})

it('does not show the old error banner after a failure', async () => {
vi.mocked(clearAllTasks).mockRejectedValueOnce(new Error('clear failed'))
const user = userEvent.setup()
renderScans()

await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument())

await user.click(screen.getByRole('button', { name: /Purge_All_Records/i }))
const confirmBtn = await screen.findByRole('button', { name: /Confirm/i })
await user.click(confirmBtn)

await waitFor(() => {
expect(screen.getByText(/Failed to clear history/i)).toBeInTheDocument()
})

expect(screen.queryByRole('button', { name: /Close alert/i })).not.toBeInTheDocument()
})
})
Loading