feat: add connections dashboard with background health checks

- Add /connections page showing all integrations with online/offline status
- Add background health checker that polls Evotor API every 10 minutes
- Add is_online and last_checked_at fields to evotor_connections table
- Replace Evotor navbar link with unified Connections link
- Redirect connect/disconnect flows to /connections
- Add Alembic migration for new columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-03-06 15:26:49 +03:00
parent 9edb77efba
commit 379f781e1e
15 changed files with 510 additions and 19 deletions

View File

@@ -11,6 +11,8 @@ class Settings(BaseSettings):
EVOTOR_CLIENT_SECRET: str = ""
EVOTOR_SCOPES: str = "store:read product:read"
HEALTH_CHECK_INTERVAL_SECONDS: int = 600
# Docker compose vars (ignored in app, kept for env compatibility)
DB_ROOT_PASSWORD: str = ""
DB_NAME: str = ""

48
web/health_checker.py Normal file
View File

@@ -0,0 +1,48 @@
import asyncio
import logging
from datetime import datetime
import httpx
from web.database import SessionLocal
from web.models import EvotorConnection
logger = logging.getLogger("uvicorn.error")
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
async def check_evotor_connection(access_token: str) -> bool:
try:
async with httpx.AsyncClient() as client:
response = await client.get(
EVOTOR_STORES_URL,
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
return response.status_code == 200
except Exception:
return False
async def run_health_checks() -> None:
db = SessionLocal()
try:
connections = db.query(EvotorConnection).all()
for conn in connections:
is_online = await check_evotor_connection(conn.access_token)
conn.is_online = is_online
conn.last_checked_at = datetime.utcnow()
db.commit()
logger.info("Health checks completed for %d connection(s)", len(connections))
except Exception:
logger.exception("Error during health checks")
db.rollback()
finally:
db.close()
async def health_check_loop(interval: int) -> None:
while True:
await run_health_checks()
await asyncio.sleep(interval)

View File

@@ -1,11 +1,31 @@
from fastapi import FastAPI
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from web.auth import get_current_user
from web.config import settings
from web.health_checker import health_check_loop
from web.models import User
from web.routes import auth, profile, reset, evotor
from web.routes import connections
app = FastAPI(title="EvoSync — Личный кабинет")
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(health_check_loop(settings.HEALTH_CHECK_INTERVAL_SECONDS))
yield
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="EvoSync — Личный кабинет", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
app.mount("/static", StaticFiles(directory="web/static"), name="static")
@@ -14,3 +34,11 @@ app.include_router(auth.router)
app.include_router(profile.router)
app.include_router(reset.router)
app.include_router(evotor.router)
app.include_router(connections.router)
@app.get("/")
def home(request: Request, user: User | None = Depends(get_current_user)):
if user:
return RedirectResponse("/profile", 302)
return RedirectResponse("/login", 302)

View File

@@ -0,0 +1,26 @@
"""add is_online and last_checked_at to evotor_connections
Revision ID: a1b2c3d4e5f6
Revises: 2c15000e752b
Create Date: 2026-03-06 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'a1b2c3d4e5f6'
down_revision = '2c15000e752b'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('evotor_connections',
sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'))
op.add_column('evotor_connections',
sa.Column('last_checked_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column('evotor_connections', 'last_checked_at')
op.drop_column('evotor_connections', 'is_online')

View File

@@ -32,6 +32,8 @@ class EvotorConnection(Base):
access_token = Column(Text, nullable=False)
store_id = Column(String(255), nullable=True)
store_name = Column(String(255), nullable=True)
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
last_checked_at = Column(DateTime, nullable=True)
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

42
web/routes/connections.py Normal file
View File

@@ -0,0 +1,42 @@
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from web.auth import get_current_user
from web.database import get_db
from web.models import User, EvotorConnection
router = APIRouter()
templates = Jinja2Templates(directory="web/templates")
@router.get("/connections")
def connections_page(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
connections = [
{
"name": "Эвотор",
"icon": "bi-shop",
"connected": evotor is not None,
"is_online": evotor.is_online if evotor else False,
"last_checked_at": evotor.last_checked_at if evotor else None,
"details": evotor.store_name if evotor else None,
"connect_url": "/evotor/connect",
"disconnect_url": "/evotor/disconnect",
}
]
return templates.TemplateResponse("connections.html", {
"request": request,
"user": user,
"connections": connections,
})

View File

@@ -118,22 +118,27 @@ async def evotor_callback(
pass # Store info is optional; token is still saved
# Save or update connection
from datetime import datetime
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if connection:
connection.access_token = access_token
connection.store_id = store_id
connection.store_name = store_name
connection.is_online = True
connection.last_checked_at = datetime.utcnow()
else:
connection = EvotorConnection(
user_id=user.id,
access_token=access_token,
store_id=store_id,
store_name=store_name,
is_online=True,
last_checked_at=datetime.utcnow(),
)
db.add(connection)
db.commit()
return RedirectResponse("/evotor", 303)
return RedirectResponse("/connections", 303)
@router.post("/disconnect")
@@ -150,4 +155,4 @@ async def evotor_disconnect(
db.delete(connection)
db.commit()
return RedirectResponse("/evotor", 303)
return RedirectResponse("/connections", 303)

View File

@@ -47,7 +47,7 @@ async def forgot_submit(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Сброс пароля",
"message": "Если аккаунт с таким email существует, ссылка для сброса пароля выведена в консоль сервера.",
"message": "Если аккаунт с таким email существует, мы отправили письмо со ссылкой для сброса пароля.",
})

View File

@@ -19,7 +19,7 @@
<ul class="navbar-nav ms-auto">
{% if user %}
<li class="nav-item">
<a href="/evotor" class="nav-link">Эвотор</a>
<a href="/connections" class="nav-link">Подключения</a>
</li>
<li class="nav-item">
<a href="/profile" class="nav-link"><i class="bi bi-person-circle me-1"></i>Личный кабинет</a>

View File

@@ -8,8 +8,7 @@
<div class="card-body p-5">
<i class="bi bi-envelope-check display-4 text-primary mb-3"></i>
<h1 class="h4 mb-3">Подтвердите ваш email</h1>
<p class="text-muted">Ссылка для подтверждения email выведена в консоль сервера.</p>
<p class="text-muted">Скопируйте её и откройте в браузере.</p>
<p class="text-muted">Проверьте почту и нажмите на ссылку для подтверждения.</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}Подключения — EvoSync{% endblock %}
{% block content %}
<h1 class="h4 mb-4">Подключения</h1>
<div class="row g-3">
{% for conn in connections %}
<div class="col-sm-6 col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<i class="bi {{ conn.icon }} fs-2 me-3 text-secondary"></i>
<div>
<h5 class="mb-0">{{ conn.name }}</h5>
{% if conn.details %}
<small class="text-muted">{{ conn.details }}</small>
{% endif %}
</div>
<div class="ms-auto">
{% if not conn.connected %}
<i class="bi bi-circle text-secondary fs-5" title="Не подключено"></i>
{% elif conn.is_online %}
<i class="bi bi-circle-fill text-success fs-5" title="Онлайн"></i>
{% else %}
<i class="bi bi-circle-fill text-danger fs-5" title="Офлайн"></i>
{% endif %}
</div>
</div>
<div class="d-grid gap-2">
{% if conn.connected %}
<a href="{{ conn.connect_url }}" class="btn btn-outline-primary btn-sm">Переподключить</a>
<form method="post" action="{{ conn.disconnect_url }}">
<button type="submit" class="btn btn-outline-danger btn-sm w-100">Отключить</button>
</form>
{% else %}
<a href="{{ conn.connect_url }}" class="btn btn-primary btn-sm">Подключить</a>
{% endif %}
</div>
</div>
{% if conn.connected %}
<div class="card-footer text-muted small">
{% if conn.last_checked_at %}
Проверено: {{ conn.last_checked_at.strftime("%d.%m.%Y %H:%M") }}
{% else %}
Статус ещё не проверялся
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -76,8 +76,8 @@
</div>
<div class="mt-3 text-center">
<a href="/profile" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Вернуться в личный кабинет
<a href="/connections" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
</a>
</div>