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
40 changes: 40 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
)
from .models import (
Finding,
FindingStatusUpdate,
FixRequest,
FixResponse,
Location,
Expand Down Expand Up @@ -828,6 +829,45 @@ async def get_findings(job_id: str):
}


@app.patch("/findings/{finding_id}/status")
async def update_finding_status(finding_id: str, payload: FindingStatusUpdate):
if payload.status not in ("open", "accepted", "ignored"):
raise HTTPException(
status_code=400,
detail="Invalid status value. Must be 'open', 'accepted', or 'ignored'.",
)

db = await get_db()
try:
cur = await db.execute("SELECT id FROM findings WHERE id = ?", (finding_id,))
if not await cur.fetchone():
raise HTTPException(
status_code=404, detail=f"Finding '{finding_id}' not found."
)
try:
await db.execute(
"UPDATE findings SET status = ? WHERE id = ?",
(payload.status, finding_id),
)
await db.commit()
except Exception as e:
if "no such column: status" in str(e).lower():
await db.execute(
"ALTER TABLE findings ADD COLUMN status TEXT DEFAULT 'open'"
)
await db.execute(
"UPDATE findings SET status = ? WHERE id = ?",
(payload.status, finding_id),
)
await db.commit()
else:
raise e
finally:
await db.close()

return {"id": finding_id, "status": payload.status}


@app.get("/jobs/{job_id}/verify")
async def get_verify(job_id: str):
db = await get_db()
Expand Down
6 changes: 6 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ class Reachability(BaseModel):
evidence: Optional[str] = None


class FindingStatusUpdate(BaseModel):
status: str = Field(
..., description="The new status: 'open', 'accepted', or 'ignored'"
)


class Finding(BaseModel):
id: str
category: str
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/app/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ export async function getJobFindings(jobId: string): Promise<BackendFinding[]> {
return (await res.json()) as BackendFinding[];
}

export async function updateFindingStatus(findingId: string, status: "open" | "accepted" | "ignored") {
const res = await fetch(`${API_BASE}/findings/${findingId}/status`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});

if (!res.ok) throw new Error(await res.text());
return res.json();
}

export async function fix(jobId: string, findingIds: string[]) {
const res = await fetch(`${API_BASE}/fix`, {
method: "POST",
Expand Down
85 changes: 69 additions & 16 deletions frontend/src/app/pages/findings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "../components/ui/sheet";
import {
Tabs,
Expand All @@ -41,7 +42,7 @@ import { CodeBlock } from "../components/code-block";
import { FilterChips } from "../components/filter-chips";
import type { Finding } from "../data/sample-data";
import { loadLastScan } from "../lib/scan-store";
import { getJobFindings } from "../lib/api";
import { getJobFindings, updateFindingStatus } from "../lib/api";
import { mapBackendFindingToUi } from "../lib/mappers";
import { cn } from "../components/ui/utils";

Expand Down Expand Up @@ -140,6 +141,24 @@ export function Findings() {

const [selectedFindings, setSelectedFindings] = useState<Set<string>>(new Set());
const [detailFinding, setDetailFinding] = useState<Finding | null>(null);
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);

const handleStatusUpdate = async (findingId: string, newStatus: "open" | "accepted" | "ignored") => {
setIsUpdatingStatus(true);
try {
await updateFindingStatus(findingId, newStatus);
setFindings((prev) =>
prev.map((f) => f.id === findingId ? { ...f, status: newStatus } : f)
);
if (detailFinding && detailFinding.id === findingId) {
setDetailFinding({ ...detailFinding, status: newStatus });
}
} catch (err) {
console.error("Failed to update status", err);
} finally {
setIsUpdatingStatus(false);
}
};

const [searchParams, setSearchParams] = useSearchParams();

Expand Down Expand Up @@ -329,7 +348,7 @@ export function Findings() {
</TableRow>
</TableHeader>
<TableBody>
{filteredFindings.map((finding) => (
{filteredFindings.slice(0, 150).map((finding) => (
<TableRow
key={finding.id}
className="cursor-pointer hover:bg-muted/50"
Expand Down Expand Up @@ -392,7 +411,7 @@ export function Findings() {
</Card>

<div className="md:hidden space-y-3">
{filteredFindings.map((finding) => (
{filteredFindings.slice(0, 150).map((finding) => (
<Card
key={finding.id}
className="hover:bg-muted/50 transition-colors cursor-pointer"
Expand Down Expand Up @@ -429,13 +448,12 @@ export function Findings() {
<>
<SheetHeader className="pb-4 border-b border-border/50">
<SheetTitle className="text-xl font-semibold tracking-tight">{detailFinding.title}</SheetTitle>
{/* 🚨 CRITICAL ACCESSIBILITY FIX: Added SheetDescription to prevent Radix layout crash */}
<div id="dialog-description" className="text-sm text-muted-foreground mt-1">
<SheetDescription className="text-sm text-muted-foreground mt-1">
Finding ID: <span className="font-mono">{detailFinding.id}</span>
</div>
</SheetDescription>
</SheetHeader>

<div className="flex-1 overflow-y-auto mt-6 space-y-8" aria-describedby="dialog-description">
<div className="flex-1 overflow-y-auto mt-6 space-y-8">
<div className="flex flex-wrap items-center gap-3">
<SeverityChip severity={detailFinding.severity} />
<ToolBadge tool={detailFinding.tool} />
Expand Down Expand Up @@ -541,16 +559,51 @@ export function Findings() {
</Tabs>
</div>

{/* Status Buttons - Pushed cleanly to the bottom */}
{/* Status Buttons - Dynamic and clickable */}
<div className="flex gap-3 pt-6 mt-6 border-t border-border/50 shrink-0">
<Button variant="outline" className="flex-1 bg-muted/5 border-border/60 text-muted-foreground" disabled>
<CheckCircle2 className="h-4 w-4 mr-2 opacity-50" />
Accept Risk
</Button>
<Button variant="outline" className="flex-1 bg-muted/5 border-border/60 text-muted-foreground" disabled>
<XCircle className="h-4 w-4 mr-2 opacity-50" />
Ignore Finding
</Button>
{detailFinding.status === 'accepted' ? (
<Button
variant="outline"
className="flex-1 border-status-success text-status-success bg-status-success/10 hover:bg-status-success/20 hover:text-status-success"
onClick={() => handleStatusUpdate(detailFinding.id, 'open')}
disabled={isUpdatingStatus}
>
{isUpdatingStatus ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <CheckCircle2 className="h-4 w-4 mr-2" />}
Accepted (Click to Re-open)
</Button>
) : (
<Button
variant="outline"
className="flex-1 hover:bg-status-success/10 hover:text-status-success hover:border-status-success/50 transition-colors"
onClick={() => handleStatusUpdate(detailFinding.id, 'accepted')}
disabled={isUpdatingStatus}
>
{isUpdatingStatus ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <CheckCircle2 className="h-4 w-4 mr-2" />}
Accept Risk
</Button>
)}

{detailFinding.status === 'ignored' ? (
<Button
variant="outline"
className="flex-1 border-muted-foreground text-muted-foreground bg-muted hover:bg-muted/80"
onClick={() => handleStatusUpdate(detailFinding.id, 'open')}
disabled={isUpdatingStatus}
>
{isUpdatingStatus ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <XCircle className="h-4 w-4 mr-2" />}
Ignored (Click to Re-open)
</Button>
) : (
<Button
variant="outline"
className="flex-1 hover:bg-muted/50 transition-colors"
onClick={() => handleStatusUpdate(detailFinding.id, 'ignored')}
disabled={isUpdatingStatus}
>
{isUpdatingStatus ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <XCircle className="h-4 w-4 mr-2" />}
Ignore Finding
</Button>
)}
</div>
</>
)}
Expand Down
Loading