Добавить статистику и очистку аудита; доработать UI расшифровки.

- Страница /admin/stats: MinIO, pending/consumed, загрузки, audit-метрики
- Кнопка «Очистить» в аудите с подтверждением
- Красивые callout’ы и ошибки пароля; пустой пароль не тратит попытку
- Убрать автоопределение языка; «Расшифруй» в RU UI
This commit is contained in:
2026-07-18 23:50:44 +03:00
parent 8c8dbd2348
commit a881570938
17 changed files with 1007 additions and 258 deletions
+8
View File
@@ -15,6 +15,7 @@ KNOWN_AUDIT_EVENT_TYPES = [
"admin.logout",
"admin.settings_update",
"admin.purge",
"admin.audit_clear",
]
@@ -51,6 +52,13 @@ async def purge_old_audit(db: AsyncSession, retention_days: int) -> int:
return result.rowcount or 0
async def purge_all_audit(db: AsyncSession) -> int:
"""Delete all audit events. Returns number of deleted rows."""
result = await db.execute(delete(AuditEvent))
await db.commit()
return int(result.rowcount or 0)
def _audit_filters(event_type: str | None, wrap_id: str | None):
filters = []
if event_type:
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditEvent, Wrap, WrapStatus
from app.services.storage import storage
def format_bytes(n: int) -> str:
n = int(n or 0)
if n < 1024:
return f"{n} B"
kb = n / 1024
if kb < 1024:
return f"{kb:.1f} KB" if kb < 10 else f"{round(kb)} KB"
mb = kb / 1024
if mb < 1024:
return f"{mb:.2f} MB" if mb < 10 else f"{mb:.1f} MB"
gb = mb / 1024
return f"{gb:.2f} GB"
async def collect_stats(db: AsyncSession) -> dict[str, Any]:
# Wrap aggregates by status
wrap_rows = (
await db.execute(
select(
Wrap.status,
func.count(Wrap.id),
func.coalesce(func.sum(Wrap.size_bytes), 0),
func.coalesce(func.sum(Wrap.item_count), 0),
).group_by(Wrap.status)
)
).all()
by_status: dict[str, dict[str, int]] = {
"pending": {"count": 0, "size_bytes": 0, "items": 0},
"consumed": {"count": 0, "size_bytes": 0, "items": 0},
"expired": {"count": 0, "size_bytes": 0, "items": 0},
}
for status, count, size_bytes, items in wrap_rows:
key = status.value if hasattr(status, "value") else str(status)
by_status[key] = {
"count": int(count or 0),
"size_bytes": int(size_bytes or 0),
"items": int(items or 0),
}
totals = await db.execute(
select(
func.count(Wrap.id),
func.coalesce(func.sum(Wrap.size_bytes), 0),
func.coalesce(func.sum(Wrap.item_count), 0),
func.coalesce(
func.sum(case((Wrap.has_password.is_(True), 1), else_=0)),
0,
),
)
)
total_wraps, total_size, total_items, with_password = totals.one()
# Last 24h / 7d creates
now = datetime.now(timezone.utc)
day_ago = now - timedelta(days=1)
week_ago = now - timedelta(days=7)
created_24h = int(
(
await db.execute(
select(func.count()).select_from(Wrap).where(Wrap.created_at >= day_ago)
)
).scalar_one()
or 0
)
created_7d = int(
(
await db.execute(
select(func.count()).select_from(Wrap).where(Wrap.created_at >= week_ago)
)
).scalar_one()
or 0
)
consumed_24h = int(
(
await db.execute(
select(func.count())
.select_from(Wrap)
.where(
Wrap.status == WrapStatus.consumed,
Wrap.consumed_at.is_not(None),
Wrap.consumed_at >= day_ago,
)
)
).scalar_one()
or 0
)
# Audit counters (all-time successes / failures)
audit_rows = (
await db.execute(
select(
AuditEvent.event_type,
AuditEvent.success,
func.count(AuditEvent.id),
).group_by(AuditEvent.event_type, AuditEvent.success)
)
).all()
audit: dict[str, dict[str, int]] = {}
for event_type, success, count in audit_rows:
bucket = audit.setdefault(event_type, {"ok": 0, "fail": 0})
if success:
bucket["ok"] += int(count or 0)
else:
bucket["fail"] += int(count or 0)
# MinIO live objects under wraps/
try:
minio = await storage.prefix_stats("wraps/")
minio_error = None
except Exception as exc: # noqa: BLE001 — surface soft error in UI
minio = {"objects": 0, "bytes": 0}
minio_error = str(exc)[:200]
pending = by_status["pending"]
consumed = by_status["consumed"]
expired = by_status["expired"]
return {
"wraps_total": int(total_wraps or 0),
"wraps_pending": pending["count"],
"wraps_consumed": consumed["count"],
"wraps_expired": expired["count"],
"wraps_with_password": int(with_password or 0),
"items_total": int(total_items or 0),
"items_pending": pending["items"],
"size_total_bytes": int(total_size or 0),
"size_pending_bytes": pending["size_bytes"],
"size_consumed_bytes": consumed["size_bytes"],
"size_expired_bytes": expired["size_bytes"],
"size_total_human": format_bytes(int(total_size or 0)),
"size_pending_human": format_bytes(pending["size_bytes"]),
"size_consumed_human": format_bytes(consumed["size_bytes"]),
"created_24h": created_24h,
"created_7d": created_7d,
"consumed_24h": consumed_24h,
"audit_creates_ok": audit.get("wrap.create", {}).get("ok", 0),
"audit_creates_fail": audit.get("wrap.create", {}).get("fail", 0),
"audit_unwraps_ok": audit.get("wrap.unwrap", {}).get("ok", 0),
"audit_unwraps_fail": audit.get("wrap.unwrap", {}).get("fail", 0),
"minio_objects": int(minio.get("objects") or 0),
"minio_bytes": int(minio.get("bytes") or 0),
"minio_human": format_bytes(int(minio.get("bytes") or 0)),
"minio_error": minio_error,
"generated_at": now,
}
+23
View File
@@ -85,5 +85,28 @@ class ObjectStorage:
token = resp.get("NextContinuationToken")
return deleted
async def prefix_stats(self, prefix: str = "wraps/") -> dict[str, int]:
"""Count objects and total bytes under prefix."""
objects = 0
total_bytes = 0
async with self.client() as client:
token: str | None = None
while True:
kwargs: dict[str, Any] = {
"Bucket": self.settings.s3_bucket,
"Prefix": prefix,
"MaxKeys": 1000,
}
if token:
kwargs["ContinuationToken"] = token
resp = await client.list_objects_v2(**kwargs)
for obj in resp.get("Contents") or []:
objects += 1
total_bytes += int(obj.get("Size") or 0)
if not resp.get("IsTruncated"):
break
token = resp.get("NextContinuationToken")
return {"objects": objects, "bytes": total_bytes}
storage = ObjectStorage()