diff --git a/guarddog_nexus/i18n.py b/guarddog_nexus/i18n.py new file mode 100644 index 0000000..c4c22d1 --- /dev/null +++ b/guarddog_nexus/i18n.py @@ -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 diff --git a/guarddog_nexus/main.py b/guarddog_nexus/main.py index 43b2e55..2013f1c 100644 --- a/guarddog_nexus/main.py +++ b/guarddog_nexus/main.py @@ -4,20 +4,44 @@ import os from contextlib import asynccontextmanager import uvicorn -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware from guarddog_nexus.config import config 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.i18n import DEFAULT_LANG, LANGUAGES from guarddog_nexus.logging_setup import log 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.webhooks import router as webhook_router 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 async def lifespan(app: FastAPI): await init_db() @@ -32,8 +56,10 @@ app = FastAPI( description=APP_DESCRIPTION, lifespan=lifespan, ) +app.add_middleware(LangMiddleware) app.include_router(webhook_router) +app.include_router(metrics_router) app.include_router(api_scans.router) app.include_router(api_packages.router) app.include_router(api_findings.router) diff --git a/guarddog_nexus/routes/metrics.py b/guarddog_nexus/routes/metrics.py new file mode 100644 index 0000000..82b6f58 --- /dev/null +++ b/guarddog_nexus/routes/metrics.py @@ -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") diff --git a/guarddog_nexus/routes/web.py b/guarddog_nexus/routes/web.py index a91f876..708fb87 100644 --- a/guarddog_nexus/routes/web.py +++ b/guarddog_nexus/routes/web.py @@ -22,6 +22,7 @@ from ..db.queries import ( build_scan_list_query, get_dashboard_stats, ) +from ..i18n import t as _t router = APIRouter(tags=["web"]) @@ -29,6 +30,7 @@ _jinja_env = Environment( loader=PackageLoader(APP_PACKAGE, "web/templates"), autoescape=select_autoescape(), ) +_jinja_env.globals["t"] = _t def _render(name: str, **context) -> HTMLResponse: diff --git a/guarddog_nexus/web/templates/base.html b/guarddog_nexus/web/templates/base.html index 4171ffd..fc03324 100644 --- a/guarddog_nexus/web/templates/base.html +++ b/guarddog_nexus/web/templates/base.html @@ -12,11 +12,15 @@
{% block breadcrumbs %}{% endblock %}