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:
@@ -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
48
web/health_checker.py
Normal 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)
|
||||
32
web/main.py
32
web/main.py
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
@@ -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
42
web/routes/connections.py
Normal 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,
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 существует, мы отправили письмо со ссылкой для сброса пароля.",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
55
web/templates/connections.html
Normal file
55
web/templates/connections.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user