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:
Marker689
2026-05-10 09:54:04 +03:00
parent c99a7bf67c
commit 6984844161
26 changed files with 261 additions and 266 deletions

View File

@@ -31,6 +31,7 @@ NEXUS_API_TIMEOUT_SECONDS=30
# LLM analysis (optional — set LLM_ENABLED=1 to activate) # LLM analysis (optional — set LLM_ENABLED=1 to activate)
LLM_ENABLED=0 LLM_ENABLED=0
LLM_AUTO_ANALYZE=0
LLM_API_BASE=https://api.openai.com/v1 LLM_API_BASE=https://api.openai.com/v1
LLM_API_KEY= LLM_API_KEY=
LLM_MODEL=gpt-4o-mini LLM_MODEL=gpt-4o-mini

View File

@@ -197,3 +197,15 @@ curl -X POST http://localhost:8080/webhooks/nexus \
- **No Nexus Pro required:** the system works with Nexus OSS. Webhooks can be triggered manually or via community plugins. - **No Nexus Pro required:** the system works with Nexus OSS. Webhooks can be triggered manually or via community plugins.
- **GuardDog deadlocks:** GuardDog is CPU-intensive. Use `MAX_CONCURRENT_SCANS` to avoid resource exhaustion. - **GuardDog deadlocks:** GuardDog is CPU-intensive. Use `MAX_CONCURRENT_SCANS` to avoid resource exhaustion.
- **LLM may be slow:** increase `LLM_TIMEOUT_SECONDS` for large models. Set `LLM_MAX_CONCURRENT_ANALYSES` to limit parallel requests. - **LLM may be slow:** increase `LLM_TIMEOUT_SECONDS` for large models. Set `LLM_MAX_CONCURRENT_ANALYSES` to limit parallel requests.
---
## Workflow
**After every change** — follow these steps in order:
1. **Document** — update `AGENTS.md` if the change introduces a new concept, env var, endpoint, or workflow.
2. **Lint**`ruff check guarddog_nexus && ruff format guarddog_nexus`
3. **Test**`python3 -m pytest -v` (must pass 100%)
4. **Commit** — use the existing commit prefix convention (`feat:`, `fix:`, `refactor:`, `docs:`, `ui:`).
5. **Rebuild**`docker compose up -d --build` to deploy changes.

View File

@@ -12,6 +12,7 @@ services:
HOST: "0.0.0.0" HOST: "0.0.0.0"
PORT: "8080" PORT: "8080"
LLM_ENABLED: "${LLM_ENABLED:-0}" LLM_ENABLED: "${LLM_ENABLED:-0}"
LLM_AUTO_ANALYZE: "${LLM_AUTO_ANALYZE:-0}"
LLM_API_BASE: "${LLM_API_BASE:-https://api.openai.com/v1}" LLM_API_BASE: "${LLM_API_BASE:-https://api.openai.com/v1}"
LLM_API_KEY: "${LLM_API_KEY:-}" LLM_API_KEY: "${LLM_API_KEY:-}"
LLM_MODEL: "${LLM_MODEL:-gpt-4o-mini}" LLM_MODEL: "${LLM_MODEL:-gpt-4o-mini}"

View File

@@ -14,52 +14,59 @@ from guarddog_nexus.constants import (
) )
def _env_int(name: str, default: int) -> int:
val = os.getenv(name)
if val is None:
return default
try:
return int(val)
except ValueError:
return default
@dataclass @dataclass
class Config: class Config:
# Nexus connection # Nexus connection
nexus_url: str = os.getenv("NEXUS_URL", "http://localhost:8081") nexus_url: str = os.getenv("NEXUS_URL", "http://localhost:8081")
nexus_username: str = os.getenv("NEXUS_USERNAME", "admin") nexus_username: str = os.getenv("NEXUS_USERNAME", "admin")
nexus_password: str = os.getenv("NEXUS_PASSWORD", "admin123") nexus_password: str = os.getenv("NEXUS_PASSWORD", "admin123")
nexus_download_timeout: int = int( nexus_download_timeout: int = _env_int(
os.getenv("NEXUS_DOWNLOAD_TIMEOUT_SECONDS", str(HTTP_TIMEOUT_DOWNLOAD)) "NEXUS_DOWNLOAD_TIMEOUT_SECONDS", HTTP_TIMEOUT_DOWNLOAD
)
nexus_api_timeout: int = int(
os.getenv("NEXUS_API_TIMEOUT_SECONDS", str(HTTP_TIMEOUT_API))
) )
nexus_api_timeout: int = _env_int("NEXUS_API_TIMEOUT_SECONDS", HTTP_TIMEOUT_API)
# Database # Database
database_path: str = os.getenv("DATABASE_PATH", "data/guarddog.db") database_path: str = os.getenv("DATABASE_PATH", "data/guarddog.db")
# Server # Server
host: str = os.getenv("HOST", "0.0.0.0") host: str = os.getenv("HOST", "0.0.0.0")
port: int = int(os.getenv("PORT", "8080")) port: int = _env_int("PORT", 8080)
# Logging # Logging
log_level: str = os.getenv("LOG_LEVEL", "INFO") log_level: str = os.getenv("LOG_LEVEL", "INFO")
log_syslog_host: str = os.getenv("LOG_SYSLOG_HOST", "") log_syslog_host: str = os.getenv("LOG_SYSLOG_HOST", "")
log_syslog_port: int = int(os.getenv("LOG_SYSLOG_PORT", "514")) log_syslog_port: int = _env_int("LOG_SYSLOG_PORT", 514)
log_syslog_facility: str = os.getenv("LOG_SYSLOG_FACILITY", "") log_syslog_facility: str = os.getenv("LOG_SYSLOG_FACILITY", "")
# Webhooks # Webhooks
webhook_secret: str = os.getenv("WEBHOOK_SECRET", "") webhook_secret: str = os.getenv("WEBHOOK_SECRET", "")
# Scanner # Scanner
scan_timeout_seconds: int = int(os.getenv("SCAN_TIMEOUT_SECONDS", "300")) scan_timeout_seconds: int = _env_int("SCAN_TIMEOUT_SECONDS", 300)
temp_dir: str = os.getenv("TEMP_DIR", "/tmp/guarddog-nexus") temp_dir: str = os.getenv("TEMP_DIR", "/tmp/guarddog-nexus")
guarddog_binary: str = os.getenv("GUARDDOG_BINARY", GUARDDOG_BINARY_FALLBACK) guarddog_binary: str = os.getenv("GUARDDOG_BINARY", GUARDDOG_BINARY_FALLBACK)
max_concurrent_scans: int = int( max_concurrent_scans: int = _env_int(
os.getenv("MAX_CONCURRENT_SCANS", str(DEFAULT_MAX_CONCURRENT_SCANS)) "MAX_CONCURRENT_SCANS", DEFAULT_MAX_CONCURRENT_SCANS
) )
# LLM analysis # LLM analysis
llm_enabled: bool = os.getenv("LLM_ENABLED", "").lower() in ("1", "true", "yes") llm_enabled: bool = os.getenv("LLM_ENABLED", "").lower() in ("1", "true", "yes")
llm_auto_analyze: bool = os.getenv("LLM_AUTO_ANALYZE", "").lower() in ("1", "true", "yes")
llm_api_base: str = os.getenv("LLM_API_BASE", LLM_DEFAULT_API_BASE) llm_api_base: str = os.getenv("LLM_API_BASE", LLM_DEFAULT_API_BASE)
llm_api_key: str = os.getenv("LLM_API_KEY", "") llm_api_key: str = os.getenv("LLM_API_KEY", "")
llm_model: str = os.getenv("LLM_MODEL", LLM_DEFAULT_MODEL) llm_model: str = os.getenv("LLM_MODEL", LLM_DEFAULT_MODEL)
llm_timeout: int = int(os.getenv("LLM_TIMEOUT_SECONDS", str(LLM_DEFAULT_TIMEOUT))) llm_timeout: int = _env_int("LLM_TIMEOUT_SECONDS", LLM_DEFAULT_TIMEOUT)
llm_max_concurrent: int = int( llm_max_concurrent: int = _env_int("LLM_MAX_CONCURRENT_ANALYSES", 2)
os.getenv("LLM_MAX_CONCURRENT_ANALYSES", "2")
)
config = Config() config = Config()

View File

@@ -13,9 +13,8 @@ used across the codebase live here to avoid duplication and drift.
# harvester uses it to decide whether to download and scan. # harvester uses it to decide whether to download and scan.
PACKAGE_EXTENSIONS = (".tar.gz", ".tgz", ".whl", ".zip") PACKAGE_EXTENSIONS = (".tar.gz", ".tgz", ".whl", ".zip")
# Prefix used in PyPI-style asset paths ("/packages/name/ver/file") # Prefix used in PyPI/NPM asset paths ("/packages/name/ver/file")
PYPI_PATH_PREFIX = "packages" PKG_PATH_PREFIX = "packages"
NPM_PATH_PREFIX = "packages"
# Metadata file patterns that should never be scanned # Metadata file patterns that should never be scanned
METADATA_PATTERNS = ( METADATA_PATTERNS = (
@@ -39,7 +38,6 @@ DEFAULT_ECOSYSTEM = "pypi"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SEVERITY_WARNING = "WARNING" SEVERITY_WARNING = "WARNING"
SEVERITY_ERROR = "ERROR"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sorting # Sorting
@@ -81,20 +79,9 @@ WEB_PER_PAGE = 50
DASHBOARD_LATEST_FLAGGED_LIMIT = 8 DASHBOARD_LATEST_FLAGGED_LIMIT = 8
DASHBOARD_LATEST_SCANS_LIMIT = 10 DASHBOARD_LATEST_SCANS_LIMIT = 10
DASHBOARD_MOST_FLAGGED_LIMIT = 8
TOP_RULES_LIMIT = 10 TOP_RULES_LIMIT = 10
RECENT_FLAGGED_DAYS = 7 RECENT_FLAGGED_DAYS = 7
HEATMAP_DAYS = 14
# ---------------------------------------------------------------------------
# Database fields
# ---------------------------------------------------------------------------
MAX_PACKAGE_NAME_LENGTH = 255
MAX_PACKAGE_VERSION_LENGTH = 255
MAX_ECOSYSTEM_LENGTH = 50
SHA256_HEX_LENGTH = 64
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Scanner # Scanner
@@ -114,8 +101,7 @@ SCAN_ERROR_DOWNLOAD_FAILED = "Download failed"
ERROR_MESSAGE_MAX_LENGTH = 1000 ERROR_MESSAGE_MAX_LENGTH = 1000
SHA256_CHUNK_SIZE = 8192 SHA256_CHUNK_SIZE = 8192
# Finding data dict keys # Finding severity default
FINDING_KEYS = ("rule", "severity", "message", "location", "code")
DEFAULT_FINDING_SEVERITY = SEVERITY_WARNING DEFAULT_FINDING_SEVERITY = SEVERITY_WARNING
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -141,8 +127,6 @@ WEBHOOK_STATUS_IGNORED = "ignored"
# API # API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
API_PREFIX_V1 = "/api/v1"
HEALTH_PATH = "/health"
STATIC_MOUNT_PATH = "/static" STATIC_MOUNT_PATH = "/static"
CSV_MEDIA_TYPE = "text/csv" CSV_MEDIA_TYPE = "text/csv"

View File

@@ -63,16 +63,20 @@ async def harvest(
return None return None
async with lock: async with lock:
# Re-check DB in case another task already created and finished a scan try:
active = await session.scalar( # Re-check DB in case another task already created and finished a scan
select(Scan.id).where( active = await session.scalar(
Scan.nexus_asset_url == download_url, select(Scan.id).where(
Scan.status.in_([ScanStatus.PENDING.value, ScanStatus.SCANNING.value]), Scan.nexus_asset_url == download_url,
Scan.status.in_([ScanStatus.PENDING.value, ScanStatus.SCANNING.value]),
)
) )
) if active:
if active: log.info("Already scanning this URL, skipping")
log.info("Already scanning this URL, skipping") return None
return None finally:
async with _url_lock:
_url_locks.pop(download_url, None)
scan = Scan( scan = Scan(
package_name=package_name, package_name=package_name,
@@ -88,10 +92,9 @@ async def harvest(
await session.commit() await session.commit()
await session.refresh(scan) await session.refresh(scan)
os.makedirs(config.temp_dir, exist_ok=True)
tmpdir = tempfile.mkdtemp(dir=config.temp_dir)
try: try:
os.makedirs(config.temp_dir, exist_ok=True)
tmpdir = tempfile.mkdtemp(dir=config.temp_dir)
scan.status = ScanStatus.SCANNING.value scan.status = ScanStatus.SCANNING.value
await session.commit() await session.commit()
@@ -103,7 +106,7 @@ async def harvest(
await session.commit() await session.commit()
return scan return scan
scan.sha256 = compute_sha256(downloaded) scan.sha256 = await compute_sha256(downloaded)
await session.commit() await session.commit()
existing = await session.scalar( existing = await session.scalar(
@@ -148,8 +151,12 @@ async def harvest(
# Auto-trigger LLM analysis for flagged packages # Auto-trigger LLM analysis for flagged packages
llm_reports = [] llm_reports = []
if scan.flagged and config.llm_enabled: if scan.flagged and config.llm_enabled and config.llm_auto_analyze:
llm_reports = await _run_llm_analysis(created_findings, session) try:
llm_reports = await _run_llm_analysis(created_findings, session)
except Exception as e:
log.error("LLM analysis failed for %s==%s: %s", package_name, package_version, e)
llm_reports = []
if scan.flagged: if scan.flagged:
extra = { extra = {
@@ -199,11 +206,18 @@ async def _run_llm_analysis(findings: list[Finding], session: AsyncSession) -> l
"""Run LLM analysis on findings and persist reports to the database.""" """Run LLM analysis on findings and persist reports to the database."""
from .llm import analyze_finding from .llm import analyze_finding
# Mark all as analyzing so the UI shows a spinner
for finding in findings:
finding.report = {"status": "analyzing"}
await session.commit()
reports = [] reports = []
for finding in findings: for finding in findings:
report = await analyze_finding(finding.data) report = await analyze_finding(finding.data)
if report: if report:
finding.report = report finding.report = report
reports.append(report) reports.append(report)
else:
finding.report = None
await session.commit() await session.commit()
return reports return reports

View File

@@ -1,5 +1,6 @@
"""Sonatype Nexus REST API client using httpx async.""" """Sonatype Nexus REST API client using httpx async."""
import asyncio
import hashlib import hashlib
import os import os
@@ -7,8 +8,7 @@ import httpx
from ..config import config from ..config import config
from ..constants import ( from ..constants import (
NPM_PATH_PREFIX, PKG_PATH_PREFIX,
PYPI_PATH_PREFIX,
SHA256_CHUNK_SIZE, SHA256_CHUNK_SIZE,
) )
from ..logging_setup import log from ..logging_setup import log
@@ -20,7 +20,7 @@ def extract_pypi_info(asset_path: str) -> tuple[str, str] | None:
Path format: packages/requests/2.31.0/requests-2.31.0.tar.gz Path format: packages/requests/2.31.0/requests-2.31.0.tar.gz
""" """
parts = asset_path.strip("/").split("/") parts = asset_path.strip("/").split("/")
if len(parts) >= 3 and parts[0] == PYPI_PATH_PREFIX: if len(parts) >= 3 and parts[0] == PKG_PATH_PREFIX:
return parts[1], parts[2] return parts[1], parts[2]
return None return None
@@ -35,8 +35,8 @@ def extract_go_info(asset_path: str) -> tuple[str, str] | None:
idx = cleaned.find("/@v/") idx = cleaned.find("/@v/")
if idx == -1: if idx == -1:
return None return None
if cleaned.startswith(PYPI_PATH_PREFIX + "/"): if cleaned.startswith(PKG_PATH_PREFIX + "/"):
module = cleaned[len(PYPI_PATH_PREFIX) + 1 : idx] module = cleaned[len(PKG_PATH_PREFIX) + 1 : idx]
else: else:
module = cleaned[:idx] module = cleaned[:idx]
if not module: if not module:
@@ -56,7 +56,7 @@ def extract_npm_info(asset_path: str) -> tuple[str, str] | None:
Path format: packages/react/-/react-18.2.0.tgz Path format: packages/react/-/react-18.2.0.tgz
""" """
parts = asset_path.strip("/").split("/") parts = asset_path.strip("/").split("/")
if len(parts) < 4 or parts[0] != NPM_PATH_PREFIX: if len(parts) < 4 or parts[0] != PKG_PATH_PREFIX:
return None return None
name = parts[1] name = parts[1]
# Last segment: <name>-<version>.tgz # Last segment: <name>-<version>.tgz
@@ -100,14 +100,19 @@ async def download_asset(download_url: str, dest_dir: str) -> str | None:
try: try:
response = await client.get(download_url) response = await client.get(download_url)
response.raise_for_status() response.raise_for_status()
with open(dest_path, "wb") as f: content = response.content
f.write(response.content) await asyncio.to_thread(_write_file, dest_path, content)
return dest_path return dest_path
except Exception as e: except Exception as e:
log.warning("Failed to download %s: %s", download_url, e) log.warning("Failed to download %s: %s", download_url, e)
return None return None
def _write_file(path: str, content: bytes) -> None:
with open(path, "wb") as f:
f.write(content)
async def nexus_get(path: str) -> httpx.Response: async def nexus_get(path: str) -> httpx.Response:
"""Make an authenticated GET request to Nexus REST API.""" """Make an authenticated GET request to Nexus REST API."""
auth = httpx.BasicAuth(config.nexus_username, config.nexus_password) auth = httpx.BasicAuth(config.nexus_username, config.nexus_password)
@@ -117,7 +122,11 @@ async def nexus_get(path: str) -> httpx.Response:
return await client.get(f"{config.nexus_url.rstrip('/')}{path}") return await client.get(f"{config.nexus_url.rstrip('/')}{path}")
def compute_sha256(filepath: str) -> str: async def compute_sha256(filepath: str) -> str:
return await asyncio.to_thread(_compute_sha256_sync, filepath)
def _compute_sha256_sync(filepath: str) -> str:
h = hashlib.sha256() h = hashlib.sha256()
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(SHA256_CHUNK_SIZE), b""): for chunk in iter(lambda: f.read(SHA256_CHUNK_SIZE), b""):

View File

@@ -43,6 +43,9 @@ async def scan_package(filepath: str, ecosystem: str = DEFAULT_ECOSYSTEM) -> dic
log.error("GuardDog exited %d: %s", proc.returncode, stderr.decode()) log.error("GuardDog exited %d: %s", proc.returncode, stderr.decode())
return {"findings": [], "errors": [stderr.decode().strip()]} return {"findings": [], "errors": [stderr.decode().strip()]}
if proc.returncode == 1 and stderr:
log.warning("GuardDog stderr (exit 1): %s", stderr.decode().strip())
try: try:
data = json.loads(stdout.decode()) data = json.loads(stdout.decode())
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -96,6 +99,17 @@ def _normalize_output(data: dict) -> dict:
) )
elif isinstance(value, dict) and not value: elif isinstance(value, dict) and not value:
continue continue
elif isinstance(value, dict):
# Non-empty dict — treat as a single finding
findings.append(
{
"rule": rule_name,
"severity": value.get("severity", DEFAULT_FINDING_SEVERITY),
"message": value.get("message", ""),
"location": value.get("location", ""),
"code": value.get("code", ""),
}
)
errors = data.get("errors", {}) errors = data.get("errors", {})
if isinstance(errors, dict): if isinstance(errors, dict):

View File

@@ -68,8 +68,25 @@ async def init_db():
async with _engine.begin() as conn: async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
await _migrate() await _migrate()
await _ensure_indexes()
async def get_session() -> AsyncSession: async def get_session() -> AsyncSession:
async with _async_session() as session: async with _async_session() as session:
yield session yield session
async def _ensure_indexes():
"""Create indexes that are not covered by ORM model definitions."""
indexes = [
"CREATE INDEX IF NOT EXISTS idx_scans_status ON scans(status)",
"CREATE INDEX IF NOT EXISTS idx_scans_sha256 ON scans(sha256)",
"CREATE INDEX IF NOT EXISTS idx_scans_package_name ON scans(package_name)",
"CREATE INDEX IF NOT EXISTS idx_scans_package_version ON scans(package_version)",
"CREATE INDEX IF NOT EXISTS idx_scans_flagged ON scans(flagged)",
"CREATE INDEX IF NOT EXISTS idx_scans_nexus_asset_url ON scans(nexus_asset_url)",
"CREATE INDEX IF NOT EXISTS idx_findings_scan_id ON findings(scan_id)",
]
async with _engine.begin() as conn:
for sql in indexes:
await conn.execute(text(sql))

View File

@@ -5,16 +5,13 @@ Eliminates ~90% duplicated SQL between api/*.py and web/routes.py.
import datetime import datetime
from sqlalchemy import Integer, cast, func, select, text from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from guarddog_nexus.constants import ( from guarddog_nexus.constants import (
DASHBOARD_LATEST_FLAGGED_LIMIT, DASHBOARD_LATEST_FLAGGED_LIMIT,
DASHBOARD_LATEST_SCANS_LIMIT, DASHBOARD_LATEST_SCANS_LIMIT,
DASHBOARD_MOST_FLAGGED_LIMIT,
HEATMAP_DAYS,
JSON_PATH_RULE, JSON_PATH_RULE,
JSON_PATH_SEVERITY,
PACKAGE_SORT_FIELDS, PACKAGE_SORT_FIELDS,
RECENT_FLAGGED_DAYS, RECENT_FLAGGED_DAYS,
SCAN_SORT_FIELDS, SCAN_SORT_FIELDS,
@@ -143,15 +140,13 @@ async def get_dashboard_stats(session: AsyncSession) -> dict:
) )
total_findings = await session.scalar(select(func.count(Finding.id))) total_findings = await session.scalar(select(func.count(Finding.id)))
warnings_count = await session.scalar( llm_analyzed = await session.scalar(
select(func.count(Finding.id)).where( select(func.count(Finding.id)).where(
func.json_extract(Finding.data, JSON_PATH_SEVERITY) == "WARNING" func.json_extract(Finding.report, "$.verdict").isnot(None)
) )
) )
errors_count = await session.scalar( llm_pending = await session.scalar(
select(func.count(Finding.id)).where( select(func.count(Finding.id)).where(Finding.report.is_(None))
func.json_extract(Finding.data, JSON_PATH_SEVERITY) == "ERROR"
)
) )
latest_flagged = ( latest_flagged = (
@@ -191,48 +186,15 @@ async def get_dashboard_stats(session: AsyncSession) -> dict:
) )
).all() ).all()
most_flagged = (
await session.execute(
select(
Scan.package_name,
Scan.package_version,
func.sum(Scan.total_findings).label("total"),
func.max(Scan.started_at).label("last_scan"),
)
.where(Scan.flagged == True)
.group_by(Scan.package_name, Scan.package_version)
.order_by(func.sum(Scan.total_findings).desc())
.limit(DASHBOARD_MOST_FLAGGED_LIMIT)
)
).all()
max_findings = max((r.total for r in most_flagged), default=1)
days_raw = (
await session.execute(
select(
func.date(Scan.started_at).label("day"),
func.count(Scan.id).label("cnt"),
func.sum(cast(Scan.flagged, Integer)).label("flagged_cnt"),
)
.where(Scan.started_at >= func.datetime("now", f"-{HEATMAP_DAYS} days"))
.group_by("day")
.order_by("day")
)
).all()
return { return {
"total_scans": total_scans or 0, "total_scans": total_scans or 0,
"flagged_scans": flagged_scans or 0, "flagged_scans": flagged_scans or 0,
"recent_flagged": recent_flagged or 0, "recent_flagged": recent_flagged or 0,
"total_findings": total_findings or 0, "total_findings": total_findings or 0,
"warnings_count": warnings_count or 0, "llm_analyzed": llm_analyzed or 0,
"errors_count": errors_count or 0, "llm_pending": llm_pending or 0,
"latest_flagged": latest_flagged, "latest_flagged": latest_flagged,
"latest_scans": latest_scans, "latest_scans": latest_scans,
"top_rules": [{"rule": r.rule, "count": r.cnt} for r in top_rules], "top_rules": [{"rule": r.rule, "count": r.cnt} for r in top_rules],
"most_flagged": most_flagged,
"max_findings": max_findings,
"days": [(d.day, d.cnt, d.flagged_cnt) for d in days_raw],
"now": datetime.datetime.now(datetime.timezone.utc), "now": datetime.datetime.now(datetime.timezone.utc),
} }

View File

@@ -17,14 +17,12 @@ _STRINGS = {
"heading_packages": {"en": "Packages", "ru": "Пакеты"}, "heading_packages": {"en": "Packages", "ru": "Пакеты"},
"heading_latest_flagged": {"en": "Latest Flagged", "ru": "Последние обнаружения"}, "heading_latest_flagged": {"en": "Latest Flagged", "ru": "Последние обнаружения"},
"heading_latest_scans": {"en": "Latest Scans", "ru": "Последние сканирования"}, "heading_latest_scans": {"en": "Latest Scans", "ru": "Последние сканирования"},
"heading_findings": {"en": "Findings", "ru": "Находки"},
"heading_findings_count": {"en": "Findings ({})", "ru": "Находки ({})"}, "heading_findings_count": {"en": "Findings ({})", "ru": "Находки ({})"},
"heading_scans_count": {"en": "Scans ({})", "ru": "Сканирований ({})"}, "heading_scans_count": {"en": "Scans ({})", "ru": "Сканирований ({})"},
"col_id": {"en": "ID", "ru": "ID"}, "col_id": {"en": "ID", "ru": "ID"},
"col_package": {"en": "Package", "ru": "Пакет"}, "col_package": {"en": "Package", "ru": "Пакет"},
"col_version": {"en": "Version", "ru": "Версия"}, "col_version": {"en": "Version", "ru": "Версия"},
"col_repo": {"en": "Repo", "ru": "Репозиторий"}, "col_repo": {"en": "Repo", "ru": "Репозиторий"},
"col_repository": {"en": "Repository", "ru": "Репозиторий"},
"col_status": {"en": "Status", "ru": "Статус"}, "col_status": {"en": "Status", "ru": "Статус"},
"col_findings": {"en": "Findings", "ru": "Находки"}, "col_findings": {"en": "Findings", "ru": "Находки"},
"col_time": {"en": "Time", "ru": "Время"}, "col_time": {"en": "Time", "ru": "Время"},
@@ -82,6 +80,11 @@ _STRINGS = {
"ru": "⚠ Анализ сгенерирован AI — может содержать неточности. " "ru": "⚠ Анализ сгенерирован AI — может содержать неточности. "
"Всегда проверяйте находки перед принятием мер.", "Всегда проверяйте находки перед принятием мер.",
}, },
"llm_analyzing": {"en": "Analyzing...", "ru": "Анализирую..."},
"llm_retry": {"en": "Retry", "ru": "Повторить"},
"llm_analyzed": {"en": "LLM analyzed", "ru": "LLM проанализ."},
"llm_pending": {"en": "Pending", "ru": "Ожидают"},
"not_found": {"en": "Not found", "ru": "Не найдено"},
"breadcrumb_home": {"en": "Home", "ru": "Главная"}, "breadcrumb_home": {"en": "Home", "ru": "Главная"},
"breadcrumb_dashboard": {"en": "Dashboard", "ru": "Панель"}, "breadcrumb_dashboard": {"en": "Dashboard", "ru": "Панель"},
"breadcrumb_scans": {"en": "Scans", "ru": "Сканирования"}, "breadcrumb_scans": {"en": "Scans", "ru": "Сканирования"},

View File

@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..config import config
from ..constants import ( from ..constants import (
DEFAULT_OFFSET, DEFAULT_OFFSET,
DEFAULT_PAGE_SIZE, 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,
}

View File

@@ -127,7 +127,7 @@ async def scan_stats(session: AsyncSession = Depends(get_session)):
"total_findings": dashboard["total_findings"], "total_findings": dashboard["total_findings"],
"top_rules": dashboard["top_rules"], "top_rules": dashboard["top_rules"],
"latest_scan_at": dashboard["latest_flagged"][0].started_at.isoformat() "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, else None,
} }

View File

@@ -1,6 +1,6 @@
"""Prometheus-compatible metrics endpoint.""" """Prometheus-compatible metrics endpoint."""
import time import calendar
from fastapi import APIRouter, Depends, Response from fastapi import APIRouter, Depends, Response
from sqlalchemy import func, select 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}') lines.append(f'guarddog_scans_by_ecosystem{{ecosystem="{eco}"}} {count}')
if latest: if latest:
ts = time.mktime(latest.timetuple()) ts = calendar.timegm(latest.timetuple())
lines += [ lines += [
"", "",
"# HELP guarddog_last_scan_timestamp_seconds Unix timestamp of most recent scan.", "# HELP guarddog_last_scan_timestamp_seconds Unix timestamp of most recent scan.",

View File

@@ -1,5 +1,6 @@
"""Web UI routes — Jinja2 + htmx pages.""" """Web UI routes — Jinja2 + htmx pages."""
import asyncio
from urllib.parse import unquote from urllib.parse import unquote
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
@@ -8,6 +9,7 @@ from jinja2 import Environment, PackageLoader, select_autoescape
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..config import config
from ..constants import ( from ..constants import (
APP_PACKAGE, APP_PACKAGE,
DEFAULT_SORT_BY_PACKAGES, DEFAULT_SORT_BY_PACKAGES,
@@ -26,11 +28,15 @@ from ..i18n import t as _t
router = APIRouter(tags=["web"]) router = APIRouter(tags=["web"])
_llm_locks: dict[int, asyncio.Lock] = {}
_llm_lock = asyncio.Lock()
_jinja_env = Environment( _jinja_env = Environment(
loader=PackageLoader(APP_PACKAGE, "web/templates"), loader=PackageLoader(APP_PACKAGE, "web/templates"),
autoescape=select_autoescape(), autoescape=select_autoescape(),
) )
_jinja_env.globals["t"] = _t _jinja_env.globals["t"] = _t
_jinja_env.globals["config"] = config
def _render(name: str, **context) -> HTMLResponse: def _render(name: str, **context) -> HTMLResponse:
@@ -109,7 +115,7 @@ async def scan_detail(
.options(selectinload(Scan.findings)) .options(selectinload(Scan.findings))
) )
if not scan: 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) return _render("scan_detail.html", scan=scan, request=request)
@@ -186,7 +192,7 @@ async def package_detail(
) )
if not scans: 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 = [] all_findings = []
for s in scans: for s in scans:
@@ -205,31 +211,72 @@ async def package_detail(
@router.post("/api/v1/findings/{finding_id}/analyze", response_class=HTMLResponse) @router.post("/api/v1/findings/{finding_id}/analyze", response_class=HTMLResponse)
async def analyze_finding_htmx( async def analyze_finding_htmx(
finding_id: int, finding_id: int,
request: Request,
retry: bool = False,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""HTMX fragment: trigger LLM analysis and return styled result HTML.""" """HTMX fragment: trigger LLM analysis and return styled result HTML."""
from ..config import config from ..config import config
from ..core.llm import analyze_finding from ..core.llm import analyze_finding
lang = request.state.lang
if not config.llm_enabled: if not config.llm_enabled:
msg = _t("llm_disabled", lang)
return HTMLResponse( 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)) finding = await session.scalar(select(Finding).where(Finding.id == finding_id))
if not finding: if not finding:
msg = _t("llm_not_found", lang)
return HTMLResponse( 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, 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: if report is None:
finding.report = None
await session.commit()
msg = _t("llm_failed", lang)
return HTMLResponse( 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 finding.report = report
await session.commit() 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,
)

View File

@@ -86,10 +86,10 @@ async def nexus_webhook(
try: try:
data = json.loads(payload.decode("utf-8")) data = json.loads(payload.decode("utf-8"))
except json.JSONDecodeError: except (json.JSONDecodeError, UnicodeDecodeError):
log.warning("Webhook received invalid JSON") log.warning("Webhook received invalid body")
raise HTTPException( 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() action = data.get("action", "").upper()
@@ -112,6 +112,11 @@ async def nexus_webhook(
action, initiator, source_ip) action, initiator, source_ip)
repository = data.get("repositoryName", "") 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") asset = data.get("asset")
component = data.get("component") component = data.get("component")

View File

@@ -15,25 +15,8 @@
.severity-ERROR { color: var(--pico-color-red-400); } .severity-ERROR { color: var(--pico-color-red-400); }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Dashboard mini-bar */ /* Dashboard blocks */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
.stat-minibar {
display: flex;
gap: 1.5rem;
padding: 0.6rem 0;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--pico-color-gray-500);
font-size: 0.9rem;
opacity: 0.9;
}
/* Dashboard block grid (2 cols → 1 on mobile) */
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.dash-block { .dash-block {
padding: 1rem; padding: 1rem;
@@ -57,51 +40,6 @@ table.compact { font-size: 0.82rem; }
table.compact th, table.compact th,
table.compact td { padding: 0.35rem 0.5rem; } table.compact td { padding: 0.35rem 0.5rem; }
/* ------------------------------------------------------------------ */
/* Heatmap */
/* ------------------------------------------------------------------ */
.heatmap {
display: flex;
align-items: flex-end;
gap: 2px;
height: 40px;
margin: 0.4rem 0 0 0;
}
.heatmap-day {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
position: relative;
}
.heatmap-day .bar {
border-radius: 2px 2px 0 0;
opacity: 0.8;
transition: height 0.3s ease, opacity 0.2s;
}
.heatmap-day:hover .bar { opacity: 1; }
.heatmap-day .tooltip {
display: none;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--pico-color-gray-700);
color: var(--pico-color-white);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
white-space: nowrap;
z-index: 10;
margin-bottom: 4px;
}
.heatmap-day:hover .tooltip { display: block; }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Scan info block (detail page) */ /* Scan info block (detail page) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -167,17 +105,6 @@ table.compact td { padding: 0.35rem 0.5rem; }
margin-bottom: 0; margin-bottom: 0;
} }
.finding-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.finding-header-row h2 {
margin-bottom: 0;
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* LLM report — verdict-based colour scheme */ /* LLM report — verdict-based colour scheme */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -237,6 +164,15 @@ table.compact td { padding: 0.35rem 0.5rem; }
.llm-actions { margin-top: 0.5rem; } .llm-actions { margin-top: 0.5rem; }
.llm-actions button { font-size: 0.8rem; } .llm-actions button { font-size: 0.8rem; }
.llm-retry {
margin-left: auto;
font-size: 0.7rem;
opacity: 0.5;
cursor: pointer;
border-bottom: 1px dashed;
}
.llm-retry:hover { opacity: 0.8; }
.llm-disclaimer { .llm-disclaimer {
margin-top: 0.6rem; margin-top: 0.6rem;
font-size: 0.72rem; font-size: 0.72rem;
@@ -269,18 +205,6 @@ table.compact td { padding: 0.35rem 0.5rem; }
.copy-btn:hover { background: var(--pico-color-gray-600); } .copy-btn:hover { background: var(--pico-color-gray-600); }
.copy-btn.copied { color: var(--pico-color-green-400); border-color: var(--pico-color-green-400); } .copy-btn.copied { color: var(--pico-color-green-400); border-color: var(--pico-color-green-400); }
.toggle-all-btn {
font-size: 0.8rem;
cursor: pointer;
background: none;
border: 1px solid var(--pico-color-gray-500);
padding: 0.2rem 0.6rem;
border-radius: 3px;
color: var(--pico-color-gray-300);
}
.toggle-all-btn:hover { background: var(--pico-color-gray-600); }
.htmx-indicator { display: inline; } .htmx-indicator { display: inline; }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -336,9 +260,7 @@ th.sortable.active .sort-icon { opacity: 1; }
/* Responsive */ /* Responsive */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@media (max-width: 768px) { @media (max-width: 768px) {
.dashboard-grid { grid-template-columns: 1fr; }
.scan-info-grid { grid-template-columns: 1fr 1fr; } .scan-info-grid { grid-template-columns: 1fr 1fr; }
.stat-minibar { flex-wrap: wrap; gap: 0.75rem; }
.filter-bar { flex-direction: column; align-items: stretch; } .filter-bar { flex-direction: column; align-items: stretch; }
nav ul { flex-wrap: wrap; } nav ul { flex-wrap: wrap; }
table, table.compact { font-size: 0.78rem; } table, table.compact { font-size: 0.78rem; }
@@ -347,14 +269,13 @@ th.sortable.active .sort-icon { opacity: 1; }
@media (max-width: 480px) { @media (max-width: 480px) {
.scan-info-grid { grid-template-columns: 1fr; } .scan-info-grid { grid-template-columns: 1fr; }
.stat-minibar { font-size: 0.8rem; }
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Print */ /* Print */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@media print { @media print {
nav, .filter-bar, .copy-btn, .toggle-all-btn, nav.sticky, nav, .filter-bar, .copy-btn, nav.sticky,
.llm-actions, .breadcrumbs { display: none !important; } .llm-actions, .breadcrumbs { display: none !important; }
body { background: white; color: black; } body { background: white; color: black; }
.llm-report { border: 1px solid #ccc; background: none; } .llm-report { border: 1px solid #ccc; background: none; }

View File

@@ -4,8 +4,15 @@
{% if report.severity_rating %} {% if report.severity_rating %}
<span class="llm-severity">{{ report.severity_rating }}</span> <span class="llm-severity">{{ report.severity_rating }}</span>
{% endif %} {% endif %}
{% if config.llm_enabled and not config.llm_auto_analyze %}
<span class="llm-retry"
hx-post="/api/v1/findings/{{ finding_id }}/analyze?retry=1"
hx-target="closest .llm-report"
hx-swap="outerHTML"
hx-indicator="closest .llm-report">{{ t('llm_retry', request.state.lang) }}</span>
{% endif %}
</div> </div>
<p class="llm-summary">{{ report.summary }}</p> <p class="llm-summary">{{ report.summary }}</p>
<p class="llm-analysis">{{ report.analysis }}</p> <p class="llm-analysis">{{ report.analysis }}</p>
<p class="llm-disclaimer">⚠ AI-generated analysis — may contain inaccuracies. Always verify findings before taking action.</p> <p class="llm-disclaimer">{{ t('llm_disclaimer', request.state.lang) }}</p>
</div> </div>

View File

@@ -0,0 +1,3 @@
<div class="llm-actions">
<small><span class="spinner"></span> {{ t('llm_analyzing', request.state.lang) }}</small>
</div>

View File

@@ -38,14 +38,5 @@
</tbody> </tbody>
</table> </table>
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %} {% include "_pagination.html" %}
{% if total_pages > 1 %}
<nav>
<ul>
<li>{% if page > 1 %}<a href="?page={{ page - 1 }}&flagged={{ flagged_filter }}&search={{ search }}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">{{ t('btn_prev', request.state.lang) }}</a>{% else %}<span>{{ t('btn_prev', request.state.lang) }}</span>{% endif %}</li>
<li><small>{{ t('page_label', request.state.lang) }} {{ page }} {{ t('page_of', request.state.lang) }} {{ total_pages }}</small></li>
<li>{% if page < total_pages %}<a href="?page={{ page + 1 }}&flagged={{ flagged_filter }}&search={{ search }}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">{{ t('btn_next', request.state.lang) }}</a>{% else %}<span>{{ t('btn_next', request.state.lang) }}</span>{% endif %}</li>
</ul>
</nav>
{% endif %}
<small style="opacity: 0.5;">{{ t('total_packages', request.state.lang, total) }}</small> <small style="opacity: 0.5;">{{ t('total_packages', request.state.lang, total) }}</small>

View File

@@ -0,0 +1,10 @@
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
{% if total_pages > 1 %}
<nav>
<ul>
<li>{% if page > 1 %}<a href="?page={{ page - 1 }}{% if flagged_filter %}&flagged={{ flagged_filter }}{% endif %}{% if search %}&search={{ search }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">{{ t('btn_prev', request.state.lang) }}</a>{% else %}<span>{{ t('btn_prev', request.state.lang) }}</span>{% endif %}</li>
<li><small>{{ t('page_label', request.state.lang) }} {{ page }} {{ t('page_of', request.state.lang) }} {{ total_pages }}</small></li>
<li>{% if page < total_pages %}<a href="?page={{ page + 1 }}{% if flagged_filter %}&flagged={{ flagged_filter }}{% endif %}{% if search %}&search={{ search }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">{{ t('btn_next', request.state.lang) }}</a>{% else %}<span>{{ t('btn_next', request.state.lang) }}</span>{% endif %}</li>
</ul>
</nav>
{% endif %}

View File

@@ -28,7 +28,7 @@
<td>{{ s.package_version }}</td> <td>{{ s.package_version }}</td>
<td>{{ s.repository }}</td> <td>{{ s.repository }}</td>
<td> <td>
{% if s.status == 'scanning' %}<span class="status-scanning"><span class="spinner"></span>scanning</span>{% else %}<span class="status-{{ s.status }}">{{ s.status }}</span>{% endif %} {% with status=s.status %}{% include "_status_badge.html" %}{% endwith %}
</td> </td>
<td>{% if s.flagged %}<span class="flagged">{{ s.total_findings }}</span>{% else %}<span class="clean">0</span>{% endif %}</td> <td>{% if s.flagged %}<span class="flagged">{{ s.total_findings }}</span>{% else %}<span class="clean">0</span>{% endif %}</td>
<td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }}</td> <td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }}</td>
@@ -42,14 +42,5 @@
</tbody> </tbody>
</table> </table>
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %} {% include "_pagination.html" %}
{% if total_pages > 1 %}
<nav>
<ul>
<li>{% if page > 1 %}<a href="?page={{ page - 1 }}&flagged={{ flagged_filter }}&search={{ search }}&status={{ status_filter }}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">{{ t('btn_prev', request.state.lang) }}</a>{% else %}<span>{{ t('btn_prev', request.state.lang) }}</span>{% endif %}</li>
<li><small>{{ t('page_label', request.state.lang) }} {{ page }} {{ t('page_of', request.state.lang) }} {{ total_pages }}</small></li>
<li>{% if page < total_pages %}<a href="?page={{ page + 1 }}&flagged={{ flagged_filter }}&search={{ search }}&status={{ status_filter }}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">{{ t('btn_next', request.state.lang) }}</a>{% else %}<span>{{ t('btn_next', request.state.lang) }}</span>{% endif %}</li>
</ul>
</nav>
{% endif %}
<small style="opacity: 0.5;">{{ t('total_scans', request.state.lang, total) }}</small> <small style="opacity: 0.5;">{{ t('total_scans', request.state.lang, total) }}</small>

View File

@@ -0,0 +1 @@
{% if status == 'scanning' %}<span class="status-scanning"><span class="spinner"></span>scanning</span>{% else %}<span class="status-{{ status }}">{{ status }}</span>{% endif %}

View File

@@ -1,3 +1,10 @@
{% if total_findings %}
<div style="display:flex; gap:1.5rem; padding:0.3rem 0; margin-bottom:1rem; border-bottom:1px solid var(--pico-color-gray-500); font-size:0.82rem; opacity:0.8;">
<span>{{ t('col_findings', request.state.lang) }}: <strong>{{ total_findings }}</strong></span>
<span>{{ t('llm_analyzed', request.state.lang) }}: <strong>{{ llm_analyzed }}</strong></span>
<span>{{ t('llm_pending', request.state.lang) }}: <strong>{{ llm_pending }}</strong></span>
</div>
{% endif %}
{% if latest_flagged %} {% if latest_flagged %}
<article class="dash-block dash-block-warn"> <article class="dash-block dash-block-warn">
<h3>{{ t('heading_latest_flagged', request.state.lang) }}</h3> <h3>{{ t('heading_latest_flagged', request.state.lang) }}</h3>
@@ -30,7 +37,7 @@
<td>{{ s.package_version }}</td> <td>{{ s.package_version }}</td>
<td><small>{{ s.repository }}</small></td> <td><small>{{ s.repository }}</small></td>
<td> <td>
{% if s.status == 'scanning' %}<span class="status-scanning"><span class="spinner"></span>scanning</span>{% else %}<span class="status-{{ s.status }}">{{ s.status }}</span>{% endif %} {% with status=s.status %}{% include "_status_badge.html" %}{% endwith %}
</td> </td>
<td>{% if s.flagged %}<span class="flagged">⚠ {{ s.total_findings }}</span>{% elif s.status == 'completed' %}<span class="clean"></span>{% else %}<span>-</span>{% endif %}</td> <td>{% if s.flagged %}<span class="flagged">⚠ {{ s.total_findings }}</span>{% elif s.status == 'completed' %}<span class="clean"></span>{% else %}<span>-</span>{% endif %}</td>
<td>{{ s.started_at.strftime('%m-%d %H:%M') if s.started_at }}</td> <td>{{ s.started_at.strftime('%m-%d %H:%M') if s.started_at }}</td>

View File

@@ -24,7 +24,7 @@
<td><a href="/scans/{{ s.id }}">#{{ s.id }}</a></td> <td><a href="/scans/{{ s.id }}">#{{ s.id }}</a></td>
<td>{{ s.repository }}</td> <td>{{ s.repository }}</td>
<td> <td>
{% if s.status == 'scanning' %}<span class="status-scanning"><span class="spinner"></span>scanning</span>{% else %}<span class="status-{{ s.status }}">{{ s.status }}</span>{% endif %} {% with status=s.status %}{% include "_status_badge.html" %}{% endwith %}
</td> </td>
<td>{% if s.flagged %}<span class="flagged">{{ s.total_findings }}</span>{% else %}<span class="clean">0</span>{% endif %}</td> <td>{% if s.flagged %}<span class="flagged">{{ s.total_findings }}</span>{% else %}<span class="clean">0</span>{% endif %}</td>
<td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }}</td> <td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }}</td>
@@ -54,19 +54,28 @@
<pre><code id="code-{{ f.id }}">{{ f.data.code }}</code></pre> <pre><code id="code-{{ f.id }}">{{ f.data.code }}</code></pre>
{% endif %} {% endif %}
{% if f.report %} {% if f.report and f.report.status == "analyzing" %}
{% include "_llm_spinner.html" %}
{% elif f.report and f.report.verdict %}
<div class="llm-report llm-{{ f.report.verdict }}"> <div class="llm-report llm-{{ f.report.verdict }}">
<div class="llm-header"> <div class="llm-header">
<span class="llm-badge llm-badge-{{ f.report.verdict }}">{{ f.report.verdict }}</span> <span class="llm-badge llm-badge-{{ f.report.verdict }}">{{ f.report.verdict }}</span>
{% if f.report.severity_rating %} {% if f.report.severity_rating %}
<span class="llm-severity">{{ f.report.severity_rating }}</span> <span class="llm-severity">{{ f.report.severity_rating }}</span>
{% endif %} {% endif %}
{% if config.llm_enabled and not config.llm_auto_analyze %}
<span class="llm-retry"
hx-post="/api/v1/findings/{{ f.id }}/analyze?retry=1"
hx-target="closest .llm-report"
hx-swap="outerHTML"
hx-indicator="closest .llm-report">{{ t('llm_retry', request.state.lang) }}</span>
{% endif %}
</div> </div>
<p class="llm-summary">{{ f.report.summary }}</p> <p class="llm-summary">{{ f.report.summary }}</p>
<p class="llm-analysis">{{ f.report.analysis }}</p> <p class="llm-analysis">{{ f.report.analysis }}</p>
<p class="llm-disclaimer">{{ t('llm_disclaimer', request.state.lang) }}</p> <p class="llm-disclaimer">{{ t('llm_disclaimer', request.state.lang) }}</p>
</div> </div>
{% else %} {% elif config.llm_enabled and not config.llm_auto_analyze %}
<div class="llm-actions" id="llm-{{ f.id }}"> <div class="llm-actions" id="llm-{{ f.id }}">
<button class="outline" <button class="outline"
hx-post="/api/v1/findings/{{ f.id }}/analyze" hx-post="/api/v1/findings/{{ f.id }}/analyze"

View File

@@ -19,7 +19,7 @@
<div><strong>{{ t('scan_info_ecosystem', request.state.lang) }}</strong><br>{{ scan.ecosystem }}</div> <div><strong>{{ t('scan_info_ecosystem', request.state.lang) }}</strong><br>{{ scan.ecosystem }}</div>
<div><strong>{{ t('scan_info_repository', request.state.lang) }}</strong><br>{{ scan.repository }}</div> <div><strong>{{ t('scan_info_repository', request.state.lang) }}</strong><br>{{ scan.repository }}</div>
<div><strong>{{ t('scan_info_status', request.state.lang) }}</strong><br> <div><strong>{{ t('scan_info_status', request.state.lang) }}</strong><br>
{% if scan.status == 'scanning' %}<span class="status-scanning"><span class="spinner"></span>scanning</span>{% else %}<span class="status-{{ scan.status }}">{{ scan.status }}</span>{% endif %} {% with status=scan.status %}{% include "_status_badge.html" %}{% endwith %}
</div> </div>
<div><strong>{{ t('scan_info_sha256', request.state.lang) }}</strong><br><code class="sha256">{{ scan.sha256 or '-' }}</code></div> <div><strong>{{ t('scan_info_sha256', request.state.lang) }}</strong><br><code class="sha256">{{ scan.sha256 or '-' }}</code></div>
<div><strong>{{ t('scan_info_started', request.state.lang) }}</strong><br>{{ scan.started_at.strftime('%Y-%m-%d %H:%M') if scan.started_at }}</div> <div><strong>{{ t('scan_info_started', request.state.lang) }}</strong><br>{{ scan.started_at.strftime('%Y-%m-%d %H:%M') if scan.started_at }}</div>
@@ -50,19 +50,28 @@
<pre><code id="code-{{ f.id }}">{{ f.data.code }}</code></pre> <pre><code id="code-{{ f.id }}">{{ f.data.code }}</code></pre>
{% endif %} {% endif %}
{% if f.report %} {% if f.report and f.report.status == "analyzing" %}
{% include "_llm_spinner.html" %}
{% elif f.report and f.report.verdict %}
<div class="llm-report llm-{{ f.report.verdict }}"> <div class="llm-report llm-{{ f.report.verdict }}">
<div class="llm-header"> <div class="llm-header">
<span class="llm-badge llm-badge-{{ f.report.verdict }}">{{ f.report.verdict }}</span> <span class="llm-badge llm-badge-{{ f.report.verdict }}">{{ f.report.verdict }}</span>
{% if f.report.severity_rating %} {% if f.report.severity_rating %}
<span class="llm-severity">{{ f.report.severity_rating }}</span> <span class="llm-severity">{{ f.report.severity_rating }}</span>
{% endif %} {% endif %}
{% if config.llm_enabled and not config.llm_auto_analyze %}
<span class="llm-retry"
hx-post="/api/v1/findings/{{ f.id }}/analyze?retry=1"
hx-target="closest .llm-report"
hx-swap="outerHTML"
hx-indicator="closest .llm-report">{{ t('llm_retry', request.state.lang) }}</span>
{% endif %}
</div> </div>
<p class="llm-summary">{{ f.report.summary }}</p> <p class="llm-summary">{{ f.report.summary }}</p>
<p class="llm-analysis">{{ f.report.analysis }}</p> <p class="llm-analysis">{{ f.report.analysis }}</p>
<p class="llm-disclaimer">{{ t('llm_disclaimer', request.state.lang) }}</p> <p class="llm-disclaimer">{{ t('llm_disclaimer', request.state.lang) }}</p>
</div> </div>
{% else %} {% elif config.llm_enabled and not config.llm_auto_analyze %}
<div class="llm-actions" id="llm-{{ f.id }}"> <div class="llm-actions" id="llm-{{ f.id }}">
<button class="outline" <button class="outline"
hx-post="/api/v1/findings/{{ f.id }}/analyze" hx-post="/api/v1/findings/{{ f.id }}/analyze"