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:
@@ -16,12 +16,12 @@ services:
|
||||
- ./alembic.ini:/app/alembic.ini
|
||||
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh
|
||||
|
||||
sync:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ./evo:/var/www/evo
|
||||
- ./vk:/var/www/vk
|
||||
- ./run:/var/www/run
|
||||
- ./logs:/var/www/logs
|
||||
# sync:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# volumes:
|
||||
# - ./evo:/var/www/evo
|
||||
# - ./vk:/var/www/vk
|
||||
# - ./run:/var/www/run
|
||||
# - ./logs:/var/www/logs
|
||||
|
||||
95
docs/plans/connections-dashboard.md
Normal file
95
docs/plans/connections-dashboard.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Connections Dashboard with Background Health Checks
|
||||
|
||||
## Context
|
||||
|
||||
Users currently access Evotor connection via a dedicated `/evotor` page linked from the navbar. As more integrations are planned, we need a unified **Connections** page where users can see all their connections at a glance, with real-time status indicators powered by background health checks.
|
||||
|
||||
## Plan
|
||||
|
||||
### 1. Model Changes — `web/models.py`
|
||||
|
||||
Add to `EvotorConnection`:
|
||||
- `is_online` (Boolean, default=False, server_default="0")
|
||||
- `last_checked_at` (DateTime, nullable)
|
||||
|
||||
### 2. Alembic Migration
|
||||
|
||||
Generate migration for the two new columns on `evotor_connections` table.
|
||||
|
||||
### 3. Config Addition — `web/config.py`
|
||||
|
||||
Add `HEALTH_CHECK_INTERVAL_SECONDS: int = 600` (10 minutes default).
|
||||
|
||||
### 4. Background Health Checker — `web/health_checker.py` (new)
|
||||
|
||||
- `check_evotor_connection(access_token) -> bool` — async, calls `GET https://api.evotor.ru/stores` with Bearer token, returns True if 200
|
||||
- `run_health_checks()` — queries all `EvotorConnection` rows using its own `SessionLocal()`, checks each, updates `is_online` and `last_checked_at`
|
||||
- `health_check_loop(interval)` — infinite loop with `asyncio.sleep`, calls `run_health_checks()`
|
||||
|
||||
### 5. Wire Background Task — `web/main.py`
|
||||
|
||||
Add FastAPI lifespan context manager:
|
||||
- On startup: `asyncio.create_task(health_check_loop(...))`
|
||||
- On shutdown: cancel the task
|
||||
- Register new connections router
|
||||
|
||||
### 6. Connections Route — `web/routes/connections.py` (new)
|
||||
|
||||
`GET /connections` — requires auth, builds a list of connection descriptors:
|
||||
```python
|
||||
connections = [{
|
||||
"name": "Эвотор",
|
||||
"icon": "bi-shop",
|
||||
"connected": bool,
|
||||
"is_online": bool,
|
||||
"last_checked_at": datetime | None,
|
||||
"details": store_name,
|
||||
"connect_url": "/evotor",
|
||||
"disconnect_url": "/evotor/disconnect",
|
||||
}]
|
||||
```
|
||||
Future connections just append another dict — template stays generic.
|
||||
|
||||
### 7. Connections Template — `web/templates/connections.html` (new)
|
||||
|
||||
Card per connection showing:
|
||||
- Icon + name + optional details (store name)
|
||||
- Status: green `bi-circle-fill` (online), red `bi-circle-fill` (offline), grey `bi-circle` (not connected)
|
||||
- Action button: "Подключить" (link to connect_url) or "Отключить" (POST form to disconnect_url)
|
||||
- Card footer with last check timestamp
|
||||
|
||||
### 8. Navbar Update — `web/templates/base.html`
|
||||
|
||||
Replace "Эвотор" link with "Подключения" → `/connections`.
|
||||
|
||||
### 9. Evotor Callback Update — `web/routes/evotor.py`
|
||||
|
||||
On successful OAuth callback, set `is_online=True` and `last_checked_at=func.now()`.
|
||||
|
||||
### 10. Evotor Template Back Link — `web/templates/evotor.html`
|
||||
|
||||
Change "Вернуться в личный кабинет" → "Вернуться к подключениям" linking to `/connections`.
|
||||
|
||||
## Files Summary
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `web/models.py` | Modify — add 2 fields |
|
||||
| `web/config.py` | Modify — add interval setting |
|
||||
| `web/main.py` | Modify — lifespan + router |
|
||||
| `web/routes/evotor.py` | Modify — set online on callback |
|
||||
| `web/routes/connections.py` | Create |
|
||||
| `web/health_checker.py` | Create |
|
||||
| `web/templates/connections.html` | Create |
|
||||
| `web/templates/base.html` | Modify — navbar |
|
||||
| `web/templates/evotor.html` | Modify — back link |
|
||||
| Alembic migration | Create |
|
||||
|
||||
## Verification
|
||||
|
||||
1. Run `alembic upgrade head` to apply migration
|
||||
2. Start the app, verify background task logs appear
|
||||
3. Visit `/connections` — should show Evotor as disconnected (grey)
|
||||
4. Connect Evotor via `/evotor` — should redirect back, connections page shows green status
|
||||
5. Disconnect — status returns to grey
|
||||
6. Wait for health check interval or trigger manually — verify `is_online` and `last_checked_at` update
|
||||
189
docs/plans/vk-connection.md
Normal file
189
docs/plans/vk-connection.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# VK OAuth Connection Feature
|
||||
|
||||
## Context
|
||||
|
||||
EvoSync syncs product catalogs from Evotor to VK. Users already connect their Evotor account via OAuth. Now we need the same for VK — users authorize via VK OAuth, we store the access token, and show connection status on the connections dashboard.
|
||||
|
||||
## VK OAuth Flow (Web)
|
||||
|
||||
- **Authorize URL**: `https://oauth.vk.com/authorize`
|
||||
- **Token URL**: `https://oauth.vk.com/access_token`
|
||||
- **Verify endpoint**: `GET https://api.vk.com/method/users.get?access_token={token}&v=5.131`
|
||||
- Error code 5 = token invalid/expired
|
||||
- **Scopes**: `market groups offline` (offline = permanent token, no expiry)
|
||||
- **Token response fields**: `access_token`, `user_id`, `expires_in` (0 if offline scope used)
|
||||
|
||||
With `offline` scope, tokens don't expire — no refresh logic needed. If a user revokes access on VK's side, the health checker will detect it.
|
||||
|
||||
## Plan
|
||||
|
||||
### 1. New Model — `VkConnection` in `web/models.py`
|
||||
|
||||
```python
|
||||
class VkConnection(Base):
|
||||
__tablename__ = "vk_connections"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
|
||||
access_token = Column(Text, nullable=False)
|
||||
vk_user_id = Column(String(50), nullable=True) # VK user ID from token response
|
||||
first_name = Column(String(255), nullable=True) # VK profile first name
|
||||
last_name = Column(String(255), nullable=True) # VK profile last name
|
||||
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)
|
||||
|
||||
user = relationship("User", back_populates="vk_connection")
|
||||
```
|
||||
|
||||
Add to `User` model:
|
||||
```python
|
||||
vk_connection = relationship("VkConnection", back_populates="user", uselist=False)
|
||||
```
|
||||
|
||||
### 2. Alembic Migration
|
||||
|
||||
Generate migration for the new `vk_connections` table and the relationship.
|
||||
|
||||
### 3. Config — `web/config.py`
|
||||
|
||||
Add:
|
||||
```python
|
||||
VK_CLIENT_ID: str = ""
|
||||
VK_CLIENT_SECRET: str = ""
|
||||
VK_SCOPES: str = "market groups offline"
|
||||
VK_API_VERSION: str = "5.131"
|
||||
```
|
||||
|
||||
### 4. VK Route — `web/routes/vk.py` (new)
|
||||
|
||||
Follow the same pattern as `web/routes/evotor.py`:
|
||||
|
||||
**Constants:**
|
||||
```python
|
||||
VK_AUTHORIZE_URL = "https://oauth.vk.com/authorize"
|
||||
VK_TOKEN_URL = "https://oauth.vk.com/access_token"
|
||||
VK_API_URL = "https://api.vk.com/method"
|
||||
```
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
- `GET /vk` — Connection page. Shows connected state (VK profile name, user_id) or disconnected state with explanation and connect button.
|
||||
|
||||
- `GET /vk/connect` — Generate state token, save in session, redirect to:
|
||||
```
|
||||
https://oauth.vk.com/authorize?client_id={id}&response_type=code
|
||||
&redirect_uri={BASE_URL}/vk/callback&scope={scopes}&state={state}
|
||||
&display=page&v=5.131
|
||||
```
|
||||
|
||||
- `GET /vk/callback` — OAuth callback:
|
||||
1. Validate state from session
|
||||
2. Exchange code for token via GET to `https://oauth.vk.com/access_token` with params: `client_id`, `client_secret`, `code`, `redirect_uri` (NOTE: VK uses GET, not POST, and params in query string, not body)
|
||||
3. Response contains: `access_token`, `user_id`, `expires_in`
|
||||
4. Fetch user profile via `users.get` to get first_name, last_name
|
||||
5. Save/update `VkConnection` record with `is_online=True`, `last_checked_at=now()`
|
||||
6. Redirect to `/connections`
|
||||
|
||||
- `POST /vk/disconnect` — Delete VkConnection record, redirect to `/vk`
|
||||
|
||||
### 5. VK Template — `web/templates/vk.html` (new)
|
||||
|
||||
Same structure as `evotor.html`:
|
||||
|
||||
**Connected state:**
|
||||
- Status badge: "Подключено" (green)
|
||||
- VK profile: first_name + last_name
|
||||
- VK user ID (monospace)
|
||||
- Connected timestamp
|
||||
- Buttons: "Переподключить", "Отключить аккаунт ВКонтакте"
|
||||
|
||||
**Disconnected state:**
|
||||
- Explanation text: "Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать каталог товаров из Эвотор в вашу группу ВКонтакте."
|
||||
- Bullet points: redirect to VK for auth, auto-setup after confirmation, can disconnect anytime
|
||||
- Button: "Подключить ВКонтакте"
|
||||
|
||||
**Error display:** same pattern as evotor.html (invalid_state, token_exchange, no_token)
|
||||
|
||||
**Back link:** "Вернуться к подключениям" → `/connections`
|
||||
|
||||
### 6. Register Route — `web/main.py`
|
||||
|
||||
```python
|
||||
from web.routes import vk
|
||||
app.include_router(vk.router)
|
||||
```
|
||||
|
||||
### 7. Add to Connections Dashboard — `web/routes/connections.py`
|
||||
|
||||
Add VK entry to the connections list:
|
||||
```python
|
||||
vk_conn = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
||||
|
||||
connections.append({
|
||||
"name": "ВКонтакте",
|
||||
"icon": "bi-chat-dots", # or another suitable Bootstrap icon
|
||||
"connected": vk_conn is not None,
|
||||
"is_online": vk_conn.is_online if vk_conn else False,
|
||||
"last_checked_at": vk_conn.last_checked_at if vk_conn else None,
|
||||
"details": f"{vk_conn.first_name} {vk_conn.last_name}" if vk_conn and vk_conn.first_name else None,
|
||||
"connect_url": "/vk",
|
||||
"disconnect_url": "/vk/disconnect",
|
||||
})
|
||||
```
|
||||
|
||||
### 8. Background Health Check — `web/health_checker.py`
|
||||
|
||||
Add VK check alongside existing Evotor check:
|
||||
|
||||
```python
|
||||
async def check_vk_connection(access_token: str) -> bool:
|
||||
"""Call users.get to verify VK token is valid."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
"https://api.vk.com/method/users.get",
|
||||
params={"access_token": access_token, "v": "5.131"},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
data = resp.json()
|
||||
# Error code 5 = invalid token
|
||||
if "error" in data:
|
||||
return False
|
||||
return True
|
||||
```
|
||||
|
||||
In `run_health_checks()`, add a loop over `VkConnection` rows with the same pattern as Evotor checks.
|
||||
|
||||
## Files Summary
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `web/models.py` | Modify — add `VkConnection` model + User relationship |
|
||||
| `web/config.py` | Modify — add `VK_*` settings |
|
||||
| `web/main.py` | Modify — register vk router |
|
||||
| `web/routes/vk.py` | Create — OAuth flow (connect/callback/disconnect/page) |
|
||||
| `web/routes/connections.py` | Modify — add VK to connections list |
|
||||
| `web/health_checker.py` | Modify — add VK health check |
|
||||
| `web/templates/vk.html` | Create — VK connection page |
|
||||
| Alembic migration | Create — `vk_connections` table |
|
||||
|
||||
## Env Config Needed
|
||||
|
||||
```
|
||||
VK_CLIENT_ID=your_vk_app_id
|
||||
VK_CLIENT_SECRET=your_vk_app_secret
|
||||
VK_SCOPES=market groups offline
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. Run `alembic upgrade head`
|
||||
2. Visit `/connections` — should show VK as disconnected (grey)
|
||||
3. Click VK → "Подключить ВКонтакте" → redirects to VK auth
|
||||
4. After VK auth → callback saves token → redirects to `/connections` → VK shows green
|
||||
5. Visit `/vk` — shows connected state with VK profile info
|
||||
6. Disconnect → VK returns to grey on connections page
|
||||
7. Wait for health check cycle — verify `is_online` and `last_checked_at` update
|
||||
@@ -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