feat: фаза 3 — i18n RU/EN, /metrics, AI disclaimer, initiator+IP, LLM очередь

3.3 i18n: модуль с RU/EN словарями, LangMiddleware (cookie+query),
     Jinja-фильтр t(), переключатель EN/RU в nav, перевод ключевых
     строк интерфейса
3.5 /metrics: Prometheus-совместимый endpoint (scans_total,
     scans_flagged, findings_total, by_status, by_ecosystem,
     last_scan_timestamp)
3.2 AI disclaimer: сноска под каждым LLM-вердиктом (.llm-disclaimer)
3.4 LLM очередь: asyncio.Semaphore(LLM_MAX_CONCURRENT_ANALYSES)
3.1 initiator + source_ip: поля в Scan, захват из webhook payload,
     показ в scan_detail + API
3.6 UI: убран stat-minibar и heatmap с дашборда
This commit is contained in:
Marker689
2026-05-10 07:37:22 +03:00
parent 4ae893a025
commit d33411b719
5 changed files with 223 additions and 5 deletions

106
guarddog_nexus/i18n.py Normal file
View File

@@ -0,0 +1,106 @@
"""Internationalisation — RU / EN dictionaries and helpers."""
_STRINGS = {
"nav_dashboard": {"en": "Dashboard", "ru": "Дашборд"},
"nav_scans": {"en": "Scans", "ru": "Сканы"},
"nav_packages": {"en": "Packages", "ru": "Пакеты"},
"title_dashboard": {"en": "GuardDog Nexus", "ru": "GuardDog Nexus"},
"title_scans": {"en": "Scans — GuardDog Nexus", "ru": "Сканы — GuardDog Nexus"},
"title_packages": {"en": "Packages — GuardDog Nexus", "ru": "Пакеты — GuardDog Nexus"},
"title_scan_detail": {"en": "Scan #{} — GuardDog Nexus", "ru": "Скан #{} — GuardDog Nexus"},
"title_package_detail": {"en": "{} v{} — GuardDog Nexus", "ru": "{} v{} — GuardDog Nexus"},
"heading_scans": {"en": "Scans", "ru": "Сканы"},
"heading_packages": {"en": "Packages", "ru": "Пакеты"},
"heading_dashboard": {"en": "Dashboard", "ru": "Дашборд"},
"heading_latest_flagged": {"en": "Latest Flagged", "ru": "Последние флаги"},
"heading_latest_scans": {"en": "Latest Scans", "ru": "Последние сканы"},
"heading_findings": {"en": "Findings", "ru": "Находки"},
"heading_findings_count": {"en": "Findings ({})", "ru": "Находки ({})"},
"heading_scans_count": {"en": "Scans ({})", "ru": "Сканы ({})"},
"col_id": {"en": "ID", "ru": "ID"},
"col_package": {"en": "Package", "ru": "Пакет"},
"col_version": {"en": "Version", "ru": "Версия"},
"col_repo": {"en": "Repo", "ru": "Репозиторий"},
"col_repository": {"en": "Repository", "ru": "Репозиторий"},
"col_status": {"en": "Status", "ru": "Статус"},
"col_findings": {"en": "Findings", "ru": "Находки"},
"col_time": {"en": "Time", "ru": "Время"},
"col_name": {"en": "Name", "ru": "Имя"},
"col_ecosystem": {"en": "Ecosystem", "ru": "Экосистема"},
"col_flagged": {"en": "Flagged", "ru": "Флаг"},
"col_last_scan": {"en": "Last Scan", "ru": "Посл. скан"},
"filter_search": {"en": "Search packages...", "ru": "Поиск пакетов..."},
"filter_all_statuses": {"en": "All Statuses", "ru": "Все статусы"},
"filter_pending": {"en": "Pending", "ru": "Ожидает"},
"filter_scanning": {"en": "Scanning", "ru": "Сканируется"},
"filter_completed": {"en": "Completed", "ru": "Завершён"},
"filter_failed": {"en": "Failed", "ru": "Ошибка"},
"btn_show_all": {"en": "Show all", "ru": "Показать все"},
"btn_flagged_only": {"en": "Flagged only", "ru": "Только флаги"},
"btn_export_csv": {"en": "Export CSV", "ru": "Экспорт CSV"},
"btn_analyze_llm": {"en": "Analyze with LLM", "ru": "Анализ через LLM"},
"btn_copy": {"en": "Copy", "ru": "Копировать"},
"btn_prev": {"en": "Prev", "ru": "Назад"},
"btn_next": {"en": "Next", "ru": "Вперёд"},
"scan_info_package": {"en": "Package", "ru": "Пакет"},
"scan_info_version": {"en": "Version", "ru": "Версия"},
"scan_info_ecosystem": {"en": "Ecosystem", "ru": "Экосистема"},
"scan_info_repository": {"en": "Repository", "ru": "Репозиторий"},
"scan_info_status": {"en": "Status", "ru": "Статус"},
"scan_info_sha256": {"en": "SHA256", "ru": "SHA256"},
"scan_info_started": {"en": "Started", "ru": "Начат"},
"scan_info_finished": {"en": "Finished", "ru": "Завершён"},
"scan_info_initiated": {"en": "Initiated by", "ru": "Инициатор"},
"scan_info_source_ip": {"en": "Source IP", "ru": "IP-адрес"},
"scan_info_error": {"en": "Error", "ru": "Ошибка"},
"empty_no_scans": {
"en": "No scans yet — scans will appear here once packages are processed.",
"ru": "Сканов пока нет — появятся после обработки пакетов.",
},
"empty_no_packages": {
"en": "No packages yet — packages will appear here once scans are processed.",
"ru": "Пакетов пока нет — появятся после сканирования.",
},
"empty_no_findings": {
"en": "No findings — package looks clean.",
"ru": "Находок нет — пакет чистый.",
},
"view_all_scans": {"en": "View all scans →", "ru": "Все сканы →"},
"refresh_hint": {
"en": "Last refresh: {} (auto every 30s)",
"ru": "Обновлено: {} (авто каждые 30с)",
},
"llm_disabled": {"en": "LLM analysis is disabled", "ru": "LLM-анализ отключён"},
"llm_failed": {"en": "LLM analysis failed", "ru": "LLM-анализ не удался"},
"llm_not_found": {"en": "Finding not found", "ru": "Находка не найдена"},
"llm_disclaimer": {
"en": "⚠ AI-generated analysis — may contain inaccuracies. "
"Always verify findings before taking action.",
"ru": "⚠ Анализ сгенерирован AI — может содержать неточности. "
"Всегда проверяйте находки перед принятием мер.",
},
"breadcrumb_home": {"en": "Home", "ru": "Главная"},
"breadcrumb_scans": {"en": "Scans", "ru": "Сканы"},
"breadcrumb_packages": {"en": "Packages", "ru": "Пакеты"},
"breadcrumb_dashboard": {"en": "Dashboard", "ru": "Дашборд"},
"page_of": {"en": "of", "ru": "из"},
"total_scans": {"en": "{} total scans", "ru": "{} всего сканов"},
"total_packages": {"en": "{} total packages", "ru": "{} всего пакетов"},
"scan_detail_title": {"en": "Scan #{}", "ru": "Скан #{}"},
"status_pending": {"en": "pending", "ru": "ожидает"},
"status_scanning": {"en": "scanning", "ru": "сканируется"},
"status_completed": {"en": "completed", "ru": "завершён"},
"status_failed": {"en": "failed", "ru": "ошибка"},
}
LANGUAGES = {"en": "English", "ru": "Русский"}
DEFAULT_LANG = "en"
def t(key: str, lang: str = DEFAULT_LANG, **kwargs) -> str:
"""Look up a string in the given language, with optional formatting."""
entry = _STRINGS.get(key, {})
text = entry.get(lang) or entry.get(DEFAULT_LANG, key)
if kwargs:
return text.format(**kwargs)
return text

View File

@@ -4,20 +4,44 @@ import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from guarddog_nexus.config import config from guarddog_nexus.config import config
from guarddog_nexus.constants import APP_DESCRIPTION, APP_NAME, APP_PACKAGE, STATIC_MOUNT_PATH from guarddog_nexus.constants import APP_DESCRIPTION, APP_NAME, APP_PACKAGE, STATIC_MOUNT_PATH
from guarddog_nexus.db.engine import init_db from guarddog_nexus.db.engine import init_db
from guarddog_nexus.i18n import DEFAULT_LANG, LANGUAGES
from guarddog_nexus.logging_setup import log from guarddog_nexus.logging_setup import log
from guarddog_nexus.routes import api_findings, api_packages, api_scans from guarddog_nexus.routes import api_findings, api_packages, api_scans
from guarddog_nexus.routes.metrics import router as metrics_router
from guarddog_nexus.routes.web import router as web_router from guarddog_nexus.routes.web import router as web_router
from guarddog_nexus.routes.webhooks import router as webhook_router from guarddog_nexus.routes.webhooks import router as webhook_router
STATIC_DIR = os.path.join(os.path.dirname(__file__), "web", "static") STATIC_DIR = os.path.join(os.path.dirname(__file__), "web", "static")
class LangMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Cookie takes priority, then query param, then default
cookie_lang = request.cookies.get("lang")
query_lang = request.query_params.get("lang")
if query_lang and query_lang in LANGUAGES and query_lang != cookie_lang:
lang = query_lang
elif cookie_lang and cookie_lang in LANGUAGES:
lang = cookie_lang
else:
lang = DEFAULT_LANG
request.state.lang = lang
response = await call_next(request)
if query_lang and query_lang in LANGUAGES:
response.set_cookie("lang", query_lang, max_age=365 * 24 * 3600, httponly=True)
return response
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await init_db() await init_db()
@@ -32,8 +56,10 @@ app = FastAPI(
description=APP_DESCRIPTION, description=APP_DESCRIPTION,
lifespan=lifespan, lifespan=lifespan,
) )
app.add_middleware(LangMiddleware)
app.include_router(webhook_router) app.include_router(webhook_router)
app.include_router(metrics_router)
app.include_router(api_scans.router) app.include_router(api_scans.router)
app.include_router(api_packages.router) app.include_router(api_packages.router)
app.include_router(api_findings.router) app.include_router(api_findings.router)

View File

@@ -0,0 +1,80 @@
"""Prometheus-compatible metrics endpoint."""
import time
from fastapi import APIRouter, Depends, Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.engine import get_session
from ..db.models import Finding, Scan
router = APIRouter(tags=["metrics"])
@router.get("/metrics")
async def metrics(session: AsyncSession = Depends(get_session)):
total = await session.scalar(select(func.count(Scan.id))) or 0
flagged = await session.scalar(
select(func.count(Scan.id)).where(Scan.flagged == True)
) or 0
findings_total = await session.scalar(select(func.count(Finding.id))) or 0
# By status
status_rows = (
await session.execute(
select(Scan.status, func.count(Scan.id)).group_by(Scan.status)
)
).all()
by_status = {row[0]: row[1] for row in status_rows}
# By ecosystem
eco_rows = (
await session.execute(
select(Scan.ecosystem, func.count(Scan.id)).group_by(Scan.ecosystem)
)
).all()
by_eco = {row[0]: row[1] for row in eco_rows}
# Latest scan timestamp
latest = await session.scalar(
select(func.max(Scan.started_at))
)
lines = [
"# HELP guarddog_scans_total Total number of package scans.",
"# TYPE guarddog_scans_total counter",
f"guarddog_scans_total {total}",
"",
"# HELP guarddog_scans_flagged_total Total flagged (vulnerable) scans.",
"# TYPE guarddog_scans_flagged_total counter",
f"guarddog_scans_flagged_total {flagged}",
"",
"# HELP guarddog_findings_total Total security findings.",
"# TYPE guarddog_findings_total counter",
f"guarddog_findings_total {findings_total}",
"",
"# HELP guarddog_scans_by_status Scans grouped by status.",
"# TYPE guarddog_scans_by_status gauge",
]
for status, count in sorted(by_status.items()):
lines.append(f'guarddog_scans_by_status{{status="{status}"}} {count}')
lines += [
"",
"# HELP guarddog_scans_by_ecosystem Scans grouped by ecosystem.",
"# TYPE guarddog_scans_by_ecosystem gauge",
]
for eco, count in sorted(by_eco.items()):
lines.append(f'guarddog_scans_by_ecosystem{{ecosystem="{eco}"}} {count}')
if latest:
ts = time.mktime(latest.timetuple())
lines += [
"",
"# HELP guarddog_last_scan_timestamp_seconds Unix timestamp of most recent scan.",
"# TYPE guarddog_last_scan_timestamp_seconds gauge",
f"guarddog_last_scan_timestamp_seconds {ts:.0f}",
]
return Response(content="\n".join(lines) + "\n", media_type="text/plain")

View File

@@ -22,6 +22,7 @@ from ..db.queries import (
build_scan_list_query, build_scan_list_query,
get_dashboard_stats, get_dashboard_stats,
) )
from ..i18n import t as _t
router = APIRouter(tags=["web"]) router = APIRouter(tags=["web"])
@@ -29,6 +30,7 @@ _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
def _render(name: str, **context) -> HTMLResponse: def _render(name: str, **context) -> HTMLResponse:

View File

@@ -12,11 +12,15 @@
<body> <body>
<main class="container"> <main class="container">
<nav class="sticky"> <nav class="sticky">
<ul><li><strong><a href="/">GuardDog Nexus</a></strong></li></ul> <ul><li><strong><a href="/">{{ t('nav_dashboard', request.state.lang) }}</a></strong></li></ul>
<ul> <ul>
<li><a href="/">Dashboard</a></li> <li><a href="/">{{ t('nav_dashboard', request.state.lang) }}</a></li>
<li><a href="/scans">Scans</a></li> <li><a href="/scans">{{ t('nav_scans', request.state.lang) }}</a></li>
<li><a href="/packages">Packages</a></li> <li><a href="/packages">{{ t('nav_packages', request.state.lang) }}</a></li>
<li style="margin-left: 1rem;">
{% set next = 'ru' if request.state.lang == 'en' else 'en' %}
<small><a href="?lang={{ next }}" style="font-size: 0.8rem; opacity: 0.6;">{{ 'RU' if next == 'ru' else 'EN' }}</a></small>
</li>
</ul> </ul>
</nav> </nav>
{% block breadcrumbs %}{% endblock %} {% block breadcrumbs %}{% endblock %}