feat: LLM-анализ — индикатор прогресса, кнопка рескана, статистика на дашборде
- Добавлен статус {"status": "analyzing"} в finding.report на время LLM-анализа
- Кнопка рескана (Retry) под LLM-отчётом в ручном режиме
- LLM-статистика на дашборде: analysed / pending
- Защита от двойного анализа через per-finding asyncio.Lock
- _llm_spinner.html — фрагмент спиннера для состояния analysing
- Удалён мёртвый код: constants, i18n, CSS, queries
- Фиксы: _env_int, индексы БД, UnicodeDecodeError, time.mktime и др.
- Шаблоны: shared includes (_status_badge, _pagination)
- AGENTS.md: workflow (lint, test, commit, rebuild)
This commit is contained in:
@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import config
|
||||
from ..constants import (
|
||||
DEFAULT_OFFSET,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
@@ -55,32 +54,3 @@ async def list_findings(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{finding_id}/analyze")
|
||||
async def analyze_finding_endpoint(
|
||||
finding_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Manually trigger LLM analysis for a single finding."""
|
||||
if not config.llm_enabled:
|
||||
return {"detail": "LLM analysis is disabled"}
|
||||
|
||||
finding = await session.scalar(
|
||||
select(Finding).where(Finding.id == finding_id)
|
||||
)
|
||||
if not finding:
|
||||
return {"detail": "Not found"}
|
||||
|
||||
from ..core.llm import analyze_finding
|
||||
|
||||
report = await analyze_finding(finding.data)
|
||||
if report is None:
|
||||
return {"detail": "LLM analysis failed"}
|
||||
|
||||
finding.report = report
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"id": finding.id,
|
||||
**finding.data,
|
||||
"report": report,
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ async def scan_stats(session: AsyncSession = Depends(get_session)):
|
||||
"total_findings": dashboard["total_findings"],
|
||||
"top_rules": dashboard["top_rules"],
|
||||
"latest_scan_at": dashboard["latest_flagged"][0].started_at.isoformat()
|
||||
if dashboard["latest_flagged"]
|
||||
if dashboard["latest_flagged"] and dashboard["latest_flagged"][0].started_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Prometheus-compatible metrics endpoint."""
|
||||
|
||||
import time
|
||||
import calendar
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from sqlalchemy import func, select
|
||||
@@ -69,7 +69,7 @@ async def metrics(session: AsyncSession = Depends(get_session)):
|
||||
lines.append(f'guarddog_scans_by_ecosystem{{ecosystem="{eco}"}} {count}')
|
||||
|
||||
if latest:
|
||||
ts = time.mktime(latest.timetuple())
|
||||
ts = calendar.timegm(latest.timetuple())
|
||||
lines += [
|
||||
"",
|
||||
"# HELP guarddog_last_scan_timestamp_seconds Unix timestamp of most recent scan.",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Web UI routes — Jinja2 + htmx pages."""
|
||||
|
||||
import asyncio
|
||||
from urllib.parse import unquote
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
@@ -8,6 +9,7 @@ from jinja2 import Environment, PackageLoader, select_autoescape
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import config
|
||||
from ..constants import (
|
||||
APP_PACKAGE,
|
||||
DEFAULT_SORT_BY_PACKAGES,
|
||||
@@ -26,11 +28,15 @@ from ..i18n import t as _t
|
||||
|
||||
router = APIRouter(tags=["web"])
|
||||
|
||||
_llm_locks: dict[int, asyncio.Lock] = {}
|
||||
_llm_lock = asyncio.Lock()
|
||||
|
||||
_jinja_env = Environment(
|
||||
loader=PackageLoader(APP_PACKAGE, "web/templates"),
|
||||
autoescape=select_autoescape(),
|
||||
)
|
||||
_jinja_env.globals["t"] = _t
|
||||
_jinja_env.globals["config"] = config
|
||||
|
||||
|
||||
def _render(name: str, **context) -> HTMLResponse:
|
||||
@@ -109,7 +115,7 @@ async def scan_detail(
|
||||
.options(selectinload(Scan.findings))
|
||||
)
|
||||
if not scan:
|
||||
return HTMLResponse("<h1>Not found</h1>", status_code=404)
|
||||
return HTMLResponse(f"<h1>{_t('not_found', request.state.lang)}</h1>", status_code=404)
|
||||
|
||||
return _render("scan_detail.html", scan=scan, request=request)
|
||||
|
||||
@@ -186,7 +192,7 @@ async def package_detail(
|
||||
)
|
||||
|
||||
if not scans:
|
||||
return HTMLResponse("<h1>Not found</h1>", status_code=404)
|
||||
return HTMLResponse(f"<h1>{_t('not_found', request.state.lang)}</h1>", status_code=404)
|
||||
|
||||
all_findings = []
|
||||
for s in scans:
|
||||
@@ -205,31 +211,72 @@ async def package_detail(
|
||||
@router.post("/api/v1/findings/{finding_id}/analyze", response_class=HTMLResponse)
|
||||
async def analyze_finding_htmx(
|
||||
finding_id: int,
|
||||
request: Request,
|
||||
retry: bool = False,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""HTMX fragment: trigger LLM analysis and return styled result HTML."""
|
||||
from ..config import config
|
||||
from ..core.llm import analyze_finding
|
||||
|
||||
lang = request.state.lang
|
||||
|
||||
if not config.llm_enabled:
|
||||
msg = _t("llm_disabled", lang)
|
||||
return HTMLResponse(
|
||||
'<div class="llm-actions"><small class="flagged">LLM analysis is disabled</small></div>'
|
||||
f'<div class="llm-actions"><small class="flagged">{msg}</small></div>'
|
||||
)
|
||||
|
||||
finding = await session.scalar(select(Finding).where(Finding.id == finding_id))
|
||||
if not finding:
|
||||
msg = _t("llm_not_found", lang)
|
||||
return HTMLResponse(
|
||||
'<div class="llm-actions"><small class="flagged">Finding not found</small></div>',
|
||||
f'<div class="llm-actions"><small class="flagged">{msg}</small></div>',
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
report = await analyze_finding(finding.data)
|
||||
if not retry and finding.report and finding.report.get("verdict"):
|
||||
return _render(
|
||||
"_llm_report_fragment.html",
|
||||
report=finding.report,
|
||||
finding_id=finding_id,
|
||||
request=request,
|
||||
)
|
||||
|
||||
if not retry and finding.report and finding.report.get("status") == "analyzing":
|
||||
return _render("_llm_spinner.html", request=request)
|
||||
|
||||
async with _llm_lock:
|
||||
if finding_id not in _llm_locks:
|
||||
_llm_locks[finding_id] = asyncio.Lock()
|
||||
|
||||
lock = _llm_locks[finding_id]
|
||||
if lock.locked():
|
||||
return _render("_llm_spinner.html", request=request)
|
||||
|
||||
async with lock:
|
||||
try:
|
||||
finding.report = {"status": "analyzing"}
|
||||
await session.commit()
|
||||
report = await analyze_finding(finding.data)
|
||||
finally:
|
||||
async with _llm_lock:
|
||||
_llm_locks.pop(finding_id, None)
|
||||
|
||||
if report is None:
|
||||
finding.report = None
|
||||
await session.commit()
|
||||
msg = _t("llm_failed", lang)
|
||||
return HTMLResponse(
|
||||
'<div class="llm-actions"><small class="flagged">LLM analysis failed</small></div>'
|
||||
f'<div class="llm-actions"><small class="flagged">{msg}</small></div>'
|
||||
)
|
||||
|
||||
finding.report = report
|
||||
await session.commit()
|
||||
|
||||
return _render("_llm_report_fragment.html", report=report)
|
||||
return _render(
|
||||
"_llm_report_fragment.html",
|
||||
report=report,
|
||||
finding_id=finding_id,
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -86,10 +86,10 @@ async def nexus_webhook(
|
||||
|
||||
try:
|
||||
data = json.loads(payload.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
log.warning("Webhook received invalid JSON")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
log.warning("Webhook received invalid body")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON"
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid request body"
|
||||
)
|
||||
|
||||
action = data.get("action", "").upper()
|
||||
@@ -112,6 +112,11 @@ async def nexus_webhook(
|
||||
action, initiator, source_ip)
|
||||
|
||||
repository = data.get("repositoryName", "")
|
||||
if not repository:
|
||||
log.warning("Webhook rejected: missing repositoryName")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Missing repository name"
|
||||
)
|
||||
asset = data.get("asset")
|
||||
component = data.get("component")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user