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

@@ -1,5 +1,6 @@
"""Sonatype Nexus REST API client using httpx async."""
import asyncio
import hashlib
import os
@@ -7,8 +8,7 @@ import httpx
from ..config import config
from ..constants import (
NPM_PATH_PREFIX,
PYPI_PATH_PREFIX,
PKG_PATH_PREFIX,
SHA256_CHUNK_SIZE,
)
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
"""
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 None
@@ -35,8 +35,8 @@ def extract_go_info(asset_path: str) -> tuple[str, str] | None:
idx = cleaned.find("/@v/")
if idx == -1:
return None
if cleaned.startswith(PYPI_PATH_PREFIX + "/"):
module = cleaned[len(PYPI_PATH_PREFIX) + 1 : idx]
if cleaned.startswith(PKG_PATH_PREFIX + "/"):
module = cleaned[len(PKG_PATH_PREFIX) + 1 : idx]
else:
module = cleaned[:idx]
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
"""
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
name = parts[1]
# Last segment: <name>-<version>.tgz
@@ -100,14 +100,19 @@ async def download_asset(download_url: str, dest_dir: str) -> str | None:
try:
response = await client.get(download_url)
response.raise_for_status()
with open(dest_path, "wb") as f:
f.write(response.content)
content = response.content
await asyncio.to_thread(_write_file, dest_path, content)
return dest_path
except Exception as e:
log.warning("Failed to download %s: %s", download_url, e)
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:
"""Make an authenticated GET request to Nexus REST API."""
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}")
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()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(SHA256_CHUNK_SIZE), b""):