Skip to content
Open
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
63 changes: 63 additions & 0 deletions frontend/src/components/ScanHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import { getTasks } from "../api";

interface ScanMeta {
task_id: string;
tool_name: string;
target: string;
status: string;
created_at: string;
duration_seconds?: number;
}

interface Props {
onSelect: (taskId: string) => void;
activeTaskId?: string;
}

export function ScanHistory({ onSelect, activeTaskId }: Props) {
const [history, setHistory] = useState<ScanMeta[]>([]);

useEffect(() => {
const params = new URLSearchParams({ per_page: "20", page: "1" });
getTasks(params)
.then((data: any) => setHistory(data.tasks || []))
.catch(console.error);
}, []);

if (history.length === 0) {
return (
<div className="text-silver/40 text-xs font-mono uppercase tracking-[0.15em] p-4">
No past scans found.
</div>
);
}

return (
<div className="flex flex-col gap-1 p-2">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-silver/40 px-2 mb-1">
Load Past Scan
</h3>
{history.map((scan) => (
<button
key={scan.task_id}
onClick={() => onSelect(scan.task_id)}
className={`text-left rounded-md px-3 py-2 text-sm transition-colors ${
activeTaskId === scan.task_id
? "bg-silver-bright/10 text-silver-bright border-l-2 border-rag-red"
: "text-silver/70 hover:bg-silver-bright/5 hover:text-silver-bright"
}`}
>
<div className="font-medium truncate">{scan.target || scan.tool_name}</div>
<div className="text-xs text-muted-foreground flex gap-2 mt-0.5">
<span>{new Date(scan.created_at).toLocaleDateString()}</span>
<span>·</span>
<span className={scan.status === "completed" ? "text-green-500" : scan.status === "failed" ? "text-red-500" : ""}>
{scan.status}
</span>
</div>
</button>
))}
</div>
);
}
24 changes: 21 additions & 3 deletions frontend/src/pages/Findings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'
import { motion } from 'framer-motion'
import { getFindings } from '../api'
import { getFindings, getTaskResult } from '../api'
import { ScanHistory } from '../components/ScanHistory'
import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date'
type RiskFactor = {
factor: string
Expand Down Expand Up @@ -118,6 +119,7 @@ export default function Findings() {
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(null)
const [reviewState, setReviewState] = useState<ReviewState>({})
const [copiedFindingId, setCopiedFindingId] = useState<string | null>(null)
const [activeTaskId, setActiveTaskId] = useState<string | undefined>()

useEffect(() => {
setLoading(true)
Expand All @@ -130,6 +132,17 @@ export default function Findings() {
.finally(() => setLoading(false))
}, [])

useEffect(() => {
if (!activeTaskId) return
getTaskResult(activeTaskId)
.then((data: any) => {
const nextFindings = data.findings || []
setFindings(nextFindings)
setSelectedFindingId(nextFindings[0]?.id ?? null) // reset to first finding of new scan
})
.catch(console.error)
}, [activeTaskId])

useEffect(() => {
try {
const saved = localStorage.getItem('secuscan-finding-review-state')
Expand Down Expand Up @@ -397,7 +410,11 @@ export default function Findings() {

return (
<div className="min-h-screen bg-charcoal-dark text-silver px-4 py-6 md:px-8 md:py-10">
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-8">
<div className="mx-auto flex w-full max-w-[1600px] gap-4">
<aside className="w-56 shrink-0 border-r border-silver/10 pr-2">
<ScanHistory onSelect={setActiveTaskId} activeTaskId={activeTaskId} />
</aside>
<div className="flex flex-col gap-8 flex-1">
<header className="border-b-4 border-silver-bright/10 pb-8">
<div className="mb-4 inline-block bg-rag-red px-4 py-1 text-xs font-black uppercase tracking-widest text-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
Triage Workspace v5.1
Expand Down Expand Up @@ -788,7 +805,8 @@ export default function Findings() {
</div>
</motion.aside>
</div>
</div>
</div>
</div>
)
}
}
2 changes: 2 additions & 0 deletions frontend/testing/unit/AppRoutes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { MemoryRouter, useLocation } from 'react-router-dom'
import { AppRoutes } from '../../src/App'

vi.mock('../../src/api', () => ({
getTasks: vi.fn(() => Promise.resolve({ tasks: [] })),
getTaskResult: vi.fn(() => Promise.resolve({ findings: [] })),
getHealth: vi.fn().mockResolvedValue({ status: 'operational' }),
getDashboardSummary: vi.fn().mockResolvedValue({
total_findings: 0,
Expand Down
92 changes: 90 additions & 2 deletions frontend/testing/unit/pages/Findings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { render, screen, waitFor, within, fireEvent } from '@testing-library/rea
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import Findings from '../../../src/pages/Findings'
import { getFindings } from '../../../src/api'
import { getFindings, getTasks, getTaskResult } from '../../../src/api'
import * as dateUtils from '../../../src/utils/date'

vi.mock('../../../src/api', () => ({
getTasks: vi.fn(() => Promise.resolve({ tasks: [] })),
getTaskResult: vi.fn(() => Promise.resolve({ findings: [] })),
getFindings: vi.fn(),
API_BASE: 'http://127.0.0.1:8000',
}))
Expand Down Expand Up @@ -97,7 +99,7 @@ describe('Findings — loading state', () => {
// ── Severity filter ───────────────────────────────────────────────────────────

describe('Findings — severity filtering', () => {
beforeEach(() => {
beforeEach(() => {
vi.mocked(getFindings).mockResolvedValue({ findings: allFindings })
})

Expand Down Expand Up @@ -578,3 +580,89 @@ describe('Findings — risk score display', () => {
expect(screen.queryByText('Risk Score')).not.toBeInTheDocument()
})
})
// ── ScanHistory — selecting a scan loads its findings ─────────────────────────

describe('Findings — ScanHistory scan selection', () => {
const pastTasks = [
{
task_id: 'task-abc-1',
tool_name: 'sqlmap',
target: 'old.example.com',
status: 'completed',
created_at: '2026-05-10T09:00:00Z',
},
{
task_id: 'task-abc-2',
tool_name: 'zap',
target: 'other.example.com',
status: 'completed',
created_at: '2026-05-11T12:00:00Z',
},
]

const taskFindings = [
{
id: 'task-finding-1',
severity: 'high',
category: 'xss',
title: 'XSS From Past Scan',
target: 'old.example.com',
description: 'Found in historical scan.',
remediation: 'Sanitize input.',
discovered_at: '2026-05-10T09:00:00Z',
plugin_id: 'sqlmap',
},
]

beforeEach(() => {
vi.mocked(getFindings).mockResolvedValue({ findings: allFindings })
vi.mocked(getTasks).mockResolvedValue({ tasks: pastTasks })
vi.mocked(getTaskResult).mockResolvedValue({ findings: taskFindings })
})

it('renders past scans in the sidebar', async () => {
renderFindings()
await waitForLoad()

await waitFor(() => {
expect(screen.getByText('old.example.com')).toBeInTheDocument()
expect(screen.getByText('other.example.com')).toBeInTheDocument()
})
})

it('loads findings for the selected scan when a sidebar entry is clicked', async () => {
const user = userEvent.setup()
renderFindings()
await waitForLoad()

await waitFor(() => {
expect(screen.getByText('old.example.com')).toBeInTheDocument()
})

await user.click(screen.getByText('old.example.com'))

await waitFor(() => {
expect(screen.getAllByText('XSS From Past Scan').length).toBeGreaterThanOrEqual(1)
})

// Original findings should be replaced
expect(screen.queryByText('SQL Injection in Login')).not.toBeInTheDocument()
})

it('highlights the active scan in the sidebar after selection', async () => {
const user = userEvent.setup()
renderFindings()
await waitForLoad()

await waitFor(() => {
expect(screen.getByText('old.example.com')).toBeInTheDocument()
})

const scanButton = screen.getByText('old.example.com').closest('button')!
await user.click(scanButton)

await waitFor(() => {
expect(scanButton.className).toContain('border-rag-red')
})
})
})
Loading