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

- Страница /admin/stats: MinIO, pending/consumed, загрузки, audit-метрики
- Кнопка «Очистить» в аудите с подтверждением
- Красивые callout’ы и ошибки пароля; пустой пароль не тратит попытку
- Убрать автоопределение языка; «Расшифруй» в RU UI
This commit is contained in:
Sergey Antropoff
2026-07-18 23:50:44 +03:00
parent 91719740b9
commit 8361b8e3f2
17 changed files with 1007 additions and 258 deletions
+41 -3
View File
@@ -20,11 +20,13 @@ from app.services.audit import (
count_audit,
list_audit,
list_audit_event_types,
purge_all_audit,
purge_old_audit,
write_audit,
)
from app.services.rate_limit import rate_limiter
from app.services.settings_service import PASSWORD_MODE_HELP, get_or_create_settings
from app.services.stats import collect_stats
from app.services.wraps import cleanup_expired_meta, expire_due_wraps, purge_all_wraps, request_meta
router = APIRouter(prefix="/admin", include_in_schema=False)
@@ -35,14 +37,14 @@ templates = Jinja2Templates(directory="app/templates")
@router.get("")
async def admin_root(request: Request):
if get_admin_user(request):
return RedirectResponse("/admin/settings", status_code=302)
return RedirectResponse("/admin/stats", status_code=302)
return RedirectResponse("/admin/login", status_code=302)
@router.get("/login")
async def login_page(request: Request):
if get_admin_user(request):
return RedirectResponse("/admin/settings", status_code=302)
return RedirectResponse("/admin/stats", status_code=302)
return templates.TemplateResponse(
request,
"admin_login.html",
@@ -93,7 +95,7 @@ async def login_submit(request: Request, db: AsyncSession = Depends(get_db)):
"cleaned_meta": cleaned,
},
)
response = RedirectResponse("/admin/settings", status_code=302)
response = RedirectResponse("/admin/stats", status_code=302)
set_admin_cookie(response, username)
return response
@@ -122,6 +124,24 @@ async def logout(request: Request, db: AsyncSession = Depends(get_db)):
return response
@router.get("/stats")
async def stats_page(
request: Request,
db: AsyncSession = Depends(get_db),
_admin: AdminWebAuth = ...,
):
stats = await collect_stats(db)
return templates.TemplateResponse(
request,
"admin_stats.html",
{
"title": "Stats",
"active": "stats",
"stats": stats,
},
)
@router.get("/settings")
async def settings_page(
request: Request,
@@ -355,6 +375,24 @@ async def audit_page(
)
@router.post("/audit/clear")
async def audit_clear(
request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminWebAuth = ...,
):
meta = request_meta(request)
deleted = await purge_all_audit(db)
await write_audit(
db,
event_type="admin.audit_clear",
success=True,
**meta,
details={"admin": admin, "deleted": deleted},
)
return RedirectResponse(f"/admin/audit?cleared={deleted}", status_code=302)
@api_router.get("/settings", summary="Get admin settings")
async def admin_settings_api(
db: AsyncSession = Depends(get_db),
+25 -1
View File
@@ -253,7 +253,31 @@ async def unwrap(
if wrap.has_password and wrap.password_hash:
max_attempts = max(1, int(settings.password_max_attempts or 3))
if not body.password or not verify_password(wrap.password_hash, body.password):
# Empty password: ask to enter it, do not burn an attempt.
if not (body.password or "").strip():
remaining = max(0, max_attempts - int(wrap.password_fail_count or 0))
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={
"reason": "password_required",
"attempts_remaining": remaining,
"attempts_max": max_attempts,
},
)
raise HTTPException(
status_code=403,
detail={
"code": "password_required",
"attempts_remaining": remaining,
"attempts_max": max_attempts,
},
)
if not verify_password(wrap.password_hash, body.password):
wrap.password_fail_count = int(wrap.password_fail_count or 0) + 1
remaining = max(0, max_attempts - wrap.password_fail_count)
now_fail = datetime.now(timezone.utc)