diff --git a/app/api/admin_routes.py b/app/api/admin_routes.py index 2eae8fe..a585d37 100644 --- a/app/api/admin_routes.py +++ b/app/api/admin_routes.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, Request, Depends, HTTPException, Query, status from pydantic import ValidationError -from sqlalchemy import delete, func, select +from sqlalchemy import delete, func, select, text from sqlalchemy.orm import Session from app.auth.apikeys import generate_token, token_hash_of @@ -53,6 +53,7 @@ from app.i18n_server import locale_from_request, localize_settings from app.settings_registry import EDITABLE, is_secret, validate as validate_setting from app.settings_store import default_of, get_setting_row, set_setting +from app.worker.celery_app import celery_app router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) @@ -843,3 +844,46 @@ created_at=row.created_at, last_used_at=row.last_used_at, revoked_at=row.revoked_at, ) + + +# --- обзор (дашборд) --- + +def dashboard_stats(db: Session) -> dict: + """Агрегаты справочников и потока (одно место на SPA-обзор и тул stats_get).""" + ev = dict(db.execute(select(Event.status, func.count(Event.id)).group_by(Event.status)).all()) + dv = dict( + db.execute(select(Delivery.status, func.count(Delivery.id)).group_by(Delivery.status)).all() + ) + return { + "events": ev, + "deliveries": dv, + "pending_deliveries": dv.get("pending", 0), + "sources": db.scalar(select(func.count(Source.id)).where(Source.deleted_at.is_(None))), + "types": db.scalar( + select(func.count(NotificationType.id)).where(NotificationType.deleted_at.is_(None)) + ), + "targets": db.scalar( + select(func.count(ChannelTarget.id)).where(ChannelTarget.deleted_at.is_(None)) + ), + "rules": db.scalar(select(func.count(RoutingRule.id)).where(RoutingRule.deleted_at.is_(None))), + } + + +@router.get("/stats") +def stats( + user: AuthenticatedUser = Depends(require_admin), + db: Session = Depends(get_db), +) -> dict: + """Сводка для «Обзора» админки: агрегаты, живость БД/воркера, SSO. + + Недоступный воркер не валит эндпоинт (ping с коротким таймаутом) — + на карточке будет «unavailable», как в system_status тула docs/07. + """ + out = dashboard_stats(db) + out["database"] = "ok" # дошли — БД отвечает + try: + out["worker"] = celery_app.send_task("synapse.ping").get(timeout=3) + except Exception: # noqa: BLE001 — недоступный воркер не ломает обзор + out["worker"] = "unavailable" + out["sso_configured"] = get_settings().gauth_configured() + return out diff --git a/app/mcp/tools.py b/app/mcp/tools.py index f6875b9..212af48 100644 --- a/app/mcp/tools.py +++ b/app/mcp/tools.py @@ -19,7 +19,7 @@ from fastapi import HTTPException from mcp.server.fastmcp.exceptions import ToolError from pydantic import BaseModel, ValidationError -from sqlalchemy import func, select, text +from sqlalchemy import select, text from app.api import admin_routes from app.api.admin_schemas import ( @@ -35,15 +35,7 @@ from app.config import get_settings from app.database import SessionLocal from app.mcp.context import current_mcp_user -from app.models import ( - ChannelTarget, - Delivery, - Event, - NotificationType, - PushSubscription, - RoutingRule, - Source, -) +from app.models import PushSubscription, Source from app.worker.celery_app import celery_app LIMIT_CAP = 500 @@ -108,37 +100,7 @@ def stats_get() -> dict: """Сводка: события и доставки по статусам, число живых sources/types/targets/rules, число deliveries в ожидании.""" - def fn(db): - ev = dict( - db.execute(select(Event.status, func.count(Event.id)).group_by(Event.status)).all() - ) - dv = dict( - db.execute( - select(Delivery.status, func.count(Delivery.id)).group_by(Delivery.status) - ).all() - ) - return { - "events": ev, - "deliveries": dv, - "pending_deliveries": dv.get("pending", 0), - "sources": db.scalar( - select(func.count(Source.id)).where(Source.deleted_at.is_(None)) - ), - "types": db.scalar( - select(func.count(NotificationType.id)).where( - NotificationType.deleted_at.is_(None) - ) - ), - "targets": db.scalar( - select(func.count(ChannelTarget.id)).where( - ChannelTarget.deleted_at.is_(None) - ) - ), - "rules": db.scalar( - select(func.count(RoutingRule.id)).where(RoutingRule.deleted_at.is_(None)) - ), - } - return _admin_run(fn) + return _admin_run(admin_routes.dashboard_stats) # --- источники и ключи (выдача ключей клиентам, docs/07) --- diff --git a/frontend/src/api.js b/frontend/src/api.js index 6d70d90..c2b3fd2 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -128,6 +128,11 @@ return apiFetch("/api/v1/admin/me"); } +/** Сводка для «Обзора»: агрегаты справочников/потока, БД, воркер, SSO (только admin). */ +export function adminStats() { + return apiFetch("/api/v1/admin/stats"); +} + // --- Личный кабинет (app/api/me_routes.py): любой аутентифицированный. --- /** Профиль: без ролевого гейта, role в ответе (system_role) — с этого гейт роутера. */ diff --git a/frontend/src/i18n/messages/en.js b/frontend/src/i18n/messages/en.js index ef31fc7..5b6b1ce 100644 --- a/frontend/src/i18n/messages/en.js +++ b/frontend/src/i18n/messages/en.js @@ -386,4 +386,15 @@ "mcpKeys.cancel": "Cancel", 'push.unsupported': 'The browser does not support web-push', 'push.swNotReady': 'Service worker is not ready yet (reload the page)', + 'dashboard.metricSourcesMeta': 'active', + 'dashboard.systemOkTitle': 'All systems normal', + 'dashboard.systemOkText': 'Database and worker are responding', + 'dashboard.ssoOk': 'SSO configured', + 'dashboard.ssoMissing': 'SSO not configured (OAuth client is empty)', + 'dashboard.systemWarnTitle': 'Problems detected', + 'dashboard.dbDown': 'Database is not responding', + 'dashboard.workerDown': 'worker unavailable — events will pile up in the queue', + 'dashboard.flowEvents': 'Events', + 'dashboard.flowDeliveries': 'Deliveries', + 'dashboard.flowEmpty': 'Nothing yet — no events so far', }; diff --git a/frontend/src/i18n/messages/ru.js b/frontend/src/i18n/messages/ru.js index 3a737b9..13dcf10 100644 --- a/frontend/src/i18n/messages/ru.js +++ b/frontend/src/i18n/messages/ru.js @@ -387,4 +387,15 @@ "mcpKeys.cancel": "Отмена", 'push.unsupported': 'Браузер не поддерживает web-push', 'push.swNotReady': 'Service worker ещё не готов (обновите страницу)', + 'dashboard.metricSourcesMeta': 'активные', + 'dashboard.systemOkTitle': 'Сервис здоров', + 'dashboard.systemOkText': 'База данных и воркер отвечают', + 'dashboard.ssoOk': 'SSO настроен', + 'dashboard.ssoMissing': 'SSO не настроен (OAuth-клиент пуст)', + 'dashboard.systemWarnTitle': 'Есть проблемы', + 'dashboard.dbDown': 'База данных не отвечает', + 'dashboard.workerDown': 'воркер недоступен — события будут копиться в очереди', + 'dashboard.flowEvents': 'События', + 'dashboard.flowDeliveries': 'Доставки', + 'dashboard.flowEmpty': 'Пока пусто — событий ещё не было', }; diff --git a/frontend/src/i18n/messages/uk.js b/frontend/src/i18n/messages/uk.js index e94fceb..f51236c 100644 --- a/frontend/src/i18n/messages/uk.js +++ b/frontend/src/i18n/messages/uk.js @@ -386,4 +386,15 @@ "mcpKeys.cancel": "Скасувати", 'push.unsupported': 'Браузер не підтримує web-push', 'push.swNotReady': 'Service worker ще не готовий (оновіть сторінку)', + 'dashboard.metricSourcesMeta': 'активні', + 'dashboard.systemOkTitle': 'Сервіс справний', + 'dashboard.systemOkText': 'База даних і воркер відповідають', + 'dashboard.ssoOk': 'SSO налаштовано', + 'dashboard.ssoMissing': 'SSO не налаштовано (OAuth-клієнт порожній)', + 'dashboard.systemWarnTitle': 'Є проблеми', + 'dashboard.dbDown': 'База даних не відповідає', + 'dashboard.workerDown': 'воркер недоступний — події накопичуватимуться в черзі', + 'dashboard.flowEvents': 'Події', + 'dashboard.flowDeliveries': 'Доставки', + 'dashboard.flowEmpty': 'Поки порожньо — подій ще не було', }; diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 6ba84fb..08eaeeb 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -1,19 +1,60 @@