Веб-история запросов

Отдельный сервис на FastAPI: список запросов с фильтрами, карточка, превью и отдача файлов из storage. Показываю @username.
This commit is contained in:
MpakobecEx 2026-10-10 15:32:00 +05:00
parent d6df4a3118
commit 20a4676034
16 changed files with 1020 additions and 4 deletions

View File

@ -29,4 +29,8 @@ DEFAULT_NEGATIVE=low quality, blurry, bad anatomy, extra digits
# Доступ: пусто — всем; иначе список id через запятую # Доступ: пусто — всем; иначе список id через запятую
ALLOWED_USER_IDS= ALLOWED_USER_IDS=
# Веб-история: только локальная сеть, без авторизации
WEB_BIND_IP=192.168.31.158
WEB_PAGE_SIZE=50
LOG_LEVEL=INFO LOG_LEVEL=INFO

View File

@ -7,6 +7,7 @@ Telegram-бот для генерации и редактирования изо
- **Text-to-image**: текст → изображение (Z-Image Turbo, 1024×1024). - **Text-to-image**: текст → изображение (Z-Image Turbo, 1024×1024).
- **Image-to-image**: фото + подпись → редактирование (Krea2 edit). - **Image-to-image**: фото + подпись → редактирование (Krea2 edit).
- **Reply-to-image**: ответ текстом на готовую картинку бота → редактирование этой картинки. - **Reply-to-image**: ответ текстом на готовую картинку бота → редактирование этой картинки.
- **Веб-история**: просмотр запросов с фильтрами по `user_id`, статусу и режиму, просмотр изображений.
- Глобальная очередь с одной активной задачей на пользователя. - Глобальная очередь с одной активной задачей на пользователя.
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI. - Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
- SQLite: запросы, статусы, seed, пути к файлам. - SQLite: запросы, статусы, seed, пути к файлам.
@ -77,6 +78,32 @@ docker compose up -d --build
Workflow: `.gitea/workflows/docker-build.yml`. Runner использует label `ubuntu-latest` и смонтированный Docker socket. Для входа в registry нужны секреты `REGISTRY_USER` и `REGISTRY_TOKEN`. Workflow: `.gitea/workflows/docker-build.yml`. Runner использует label `ubuntu-latest` и смонтированный Docker socket. Для входа в registry нужны секреты `REGISTRY_USER` и `REGISTRY_TOKEN`.
## Веб-история
После `docker compose up -d` доступна страница:
```text
http://192.168.31.158:8099
```
Возможности:
- фильтр по Telegram `user_id` (с подсказкой известных пользователей);
- в списке показывается `@username`, если он известен, иначе Telegram ID;
- фильтр по статусу и режиму (`t2i`/`i2i`);
- пагинация;
- превью результата в списке (кэшируются в `storage/.thumbnails`);
- карточка запроса с полным промптом, ошибкой, seed и изображениями.
Доступ только с локальной сети: Compose публикует порт на `WEB_BIND_IP`. Для доступа из LAN:
```dotenv
WEB_BIND_IP=192.168.31.158
WEB_PAGE_SIZE=50
```
Авторизации нет. Не публикуй порт `8099` наружу и не делай проброс на роутере.
После успешной сборки обновление контейнера: После успешной сборки обновление контейнера:
```bash ```bash

1
bot.py
View File

@ -290,6 +290,7 @@ class MpakoBot:
try: try:
request, position = await self._queue.enqueue( request, position = await self._queue.enqueue(
user_id=user.id, user_id=user.id,
username=user.username,
mode=mode, mode=mode,
prompt=prompt, prompt=prompt,
input_file_path=input_path, input_file_path=input_path,

View File

@ -6,7 +6,7 @@
## Текущий статус ## Текущий статус
- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11) и README (12). - Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12) и веб-история (13).
- Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, переключение T2I → I2I. - Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, переключение T2I → I2I.
- 15 тестов проходят: БД, workflows, очередь, вспомогательные функции ComfyUI. - 15 тестов проходят: БД, workflows, очередь, вспомогательные функции ComfyUI.
- Не проверено: сборка Docker (демон не запущен), очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, реальная отправка в Telegram (нет токена). - Не проверено: сборка Docker (демон не запущен), очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, реальная отправка в Telegram (нет токена).
@ -293,6 +293,20 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
**DoD:** новый разработчик поднимает проект по README без устных пояснений. **DoD:** новый разработчик поднимает проект по README без устных пояснений.
## 13. Веб-история
- [x] `web.py`: FastAPI + Jinja2, отдельный контейнер `web`.
- [x] Список запросов с фильтрами `user_id`, `status`, `mode` и пагинацией.
- [x] Отображение `@username` с fallback на Telegram ID; миграция старых БД.
- [x] Превью результата в списке, генерация и кэш в `storage/.thumbnails`.
- [x] Карточка запроса: промпт, seed, ошибка, `comfy_prompt_id`, вход/результат.
- [x] Отдача изображений только из `STORAGE_DIR` (защита от path traversal).
- [x] Доступ только из локальной сети: публикация на `WEB_BIND_IP`, без авторизации.
- [x] Тесты: фильтры БД, страницы, media.
- [x] Compose-сервис на порту `8099` → `8080` контейнера.
**DoD:** история открывается по `http://IP:8099`, фильтр по пользователю работает, изображения доступны.
## Порядок работ ## Порядок работ
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow. 1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.

View File

@ -71,6 +71,8 @@ class Settings:
default_negative: str = "low quality, blurry, bad anatomy, extra digits" default_negative: str = "low quality, blurry, bad anatomy, extra digits"
max_prompt_length: int = 1000 max_prompt_length: int = 1000
max_photo_bytes: int = 15 * 1024 * 1024 max_photo_bytes: int = 15 * 1024 * 1024
web_page_size: int = 50
web_bind_ip: str = "127.0.0.1"
@property @property
def comfy_ws_url(self) -> str: def comfy_ws_url(self) -> str:
@ -117,6 +119,9 @@ class Settings:
or cls.default_negative, or cls.default_negative,
max_prompt_length=max(1, _int("MAX_PROMPT_LENGTH", 1000)), max_prompt_length=max(1, _int("MAX_PROMPT_LENGTH", 1000)),
max_photo_bytes=max(1, _int("MAX_PHOTO_BYTES", 15 * 1024 * 1024)), max_photo_bytes=max(1, _int("MAX_PHOTO_BYTES", 15 * 1024 * 1024)),
web_page_size=max(10, min(200, _int("WEB_PAGE_SIZE", 50))),
web_bind_ip=os.getenv("WEB_BIND_IP", "127.0.0.1").strip()
or "127.0.0.1",
) )
return settings return settings

143
db.py
View File

@ -29,6 +29,7 @@ class UserAlreadyQueued(RuntimeError):
class Request: class Request:
id: int id: int
user_id: int user_id: int
username: str | None
timestamp: str timestamp: str
prompt_text: str prompt_text: str
input_file_path: str | None input_file_path: str | None
@ -40,11 +41,16 @@ class Request:
error_text: str | None error_text: str | None
finished_at: str | None finished_at: str | None
@property
def display_user(self) -> str:
return f"@{self.username}" if self.username else str(self.user_id)
@classmethod @classmethod
def from_row(cls, row: aiosqlite.Row) -> Request: def from_row(cls, row: aiosqlite.Row) -> Request:
return cls( return cls(
id=row["id"], id=row["id"],
user_id=row["user_id"], user_id=row["user_id"],
username=row["username"],
timestamp=row["timestamp"], timestamp=row["timestamp"],
prompt_text=row["prompt_text"], prompt_text=row["prompt_text"],
input_file_path=row["input_file_path"], input_file_path=row["input_file_path"],
@ -58,10 +64,23 @@ class Request:
) )
@dataclass(frozen=True)
class UserStat:
user_id: int
username: str | None
total: int
last_seen: str
@property
def display_user(self) -> str:
return f"@{self.username}" if self.username else str(self.user_id)
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS requests ( CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
username TEXT,
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
prompt_text TEXT NOT NULL, prompt_text TEXT NOT NULL,
input_file_path TEXT, input_file_path TEXT,
@ -104,8 +123,16 @@ class Database:
await self._conn.execute("PRAGMA busy_timeout=5000") await self._conn.execute("PRAGMA busy_timeout=5000")
await self._conn.execute("PRAGMA foreign_keys=ON") await self._conn.execute("PRAGMA foreign_keys=ON")
await self._conn.executescript(SCHEMA) await self._conn.executescript(SCHEMA)
await self._migrate()
await self._conn.commit() await self._conn.commit()
async def _migrate(self) -> None:
conn = self._connection()
async with conn.execute("PRAGMA table_info(requests)") as cursor:
columns = {row["name"] async for row in cursor}
if "username" not in columns:
await conn.execute("ALTER TABLE requests ADD COLUMN username TEXT")
async def close(self) -> None: async def close(self) -> None:
if self._conn is not None: if self._conn is not None:
await self._conn.close() await self._conn.close()
@ -120,6 +147,7 @@ class Database:
self, self,
*, *,
user_id: int, user_id: int,
username: str | None = None,
mode: str, mode: str,
prompt_text: str, prompt_text: str,
input_file_path: str | None, input_file_path: str | None,
@ -131,10 +159,19 @@ class Database:
cursor = await conn.execute( cursor = await conn.execute(
""" """
INSERT INTO requests INSERT INTO requests
(user_id, timestamp, prompt_text, input_file_path, status, mode, seed) (user_id, username, timestamp, prompt_text,
VALUES (?, ?, ?, ?, 'queued', ?, ?) input_file_path, status, mode, seed)
VALUES (?, ?, ?, ?, ?, 'queued', ?, ?)
""", """,
(user_id, utc_now(), prompt_text, input_file_path, mode, seed), (
user_id,
username,
utc_now(),
prompt_text,
input_file_path,
mode,
seed,
),
) )
request_id = cursor.lastrowid request_id = cursor.lastrowid
await conn.commit() await conn.commit()
@ -175,6 +212,106 @@ class Database:
rows = await cursor.fetchall() rows = await cursor.fetchall()
return [Request.from_row(row) for row in rows] return [Request.from_row(row) for row in rows]
@staticmethod
def _filters(
user_id: int | None,
status: str | None,
mode: str | None,
) -> tuple[str, list[Any]]:
clauses: list[str] = []
params: list[Any] = []
if user_id is not None:
clauses.append("user_id = ?")
params.append(user_id)
if status:
clauses.append("status = ?")
params.append(status)
if mode:
clauses.append("mode = ?")
params.append(mode)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
return where, params
async def list_requests(
self,
*,
user_id: int | None = None,
status: str | None = None,
mode: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Request]:
conn = self._connection()
where, params = self._filters(user_id, status, mode)
query = (
f"SELECT * FROM requests{where} ORDER BY id DESC LIMIT ? OFFSET ?"
)
async with conn.execute(query, (*params, limit, offset)) as cursor:
rows = await cursor.fetchall()
return [Request.from_row(row) for row in rows]
async def count_requests(
self,
*,
user_id: int | None = None,
status: str | None = None,
mode: str | None = None,
) -> int:
conn = self._connection()
where, params = self._filters(user_id, status, mode)
async with conn.execute(
f"SELECT COUNT(*) AS total FROM requests{where}", params
) as cursor:
row = await cursor.fetchone()
return int(row["total"]) if row is not None else 0
async def stats(self) -> dict[str, Any]:
conn = self._connection()
by_status: dict[str, int] = {}
async with conn.execute(
"SELECT status, COUNT(*) AS total FROM requests GROUP BY status"
) as cursor:
async for row in cursor:
by_status[str(row["status"])] = int(row["total"])
async with conn.execute("SELECT COUNT(DISTINCT user_id) AS total FROM requests") as cursor:
row = await cursor.fetchone()
return {
"total": sum(by_status.values()),
"users": int(row["total"]) if row is not None else 0,
"by_status": by_status,
}
async def list_users(self, limit: int = 100) -> list[UserStat]:
conn = self._connection()
async with conn.execute(
"""
SELECT
user_id,
COUNT(*) AS total,
MAX(timestamp) AS last_seen,
(
SELECT username FROM requests AS r2
WHERE r2.user_id = requests.user_id AND r2.username IS NOT NULL
ORDER BY r2.id DESC LIMIT 1
) AS username
FROM requests
GROUP BY user_id
ORDER BY last_seen DESC
LIMIT ?
""",
(limit,),
) as cursor:
rows = await cursor.fetchall()
return [
UserStat(
user_id=int(row["user_id"]),
username=row["username"],
total=int(row["total"]),
last_seen=str(row["last_seen"]),
)
for row in rows
]
async def count_ahead(self, request_id: int) -> int: async def count_ahead(self, request_id: int) -> int:
conn = self._connection() conn = self._connection()
async with conn.execute( async with conn.execute(

View File

@ -9,3 +9,15 @@ services:
- ./storage:/app/storage - ./storage:/app/storage
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped
web:
image: mpakoimageaibot:latest
build: .
command: ["uvicorn", "web:app", "--host", "0.0.0.0", "--port", "8080"]
env_file: .env
ports:
- "${WEB_BIND_IP:-127.0.0.1}:8099:8080"
volumes:
- ./storage:/app/storage
- ./data:/app/data
restart: unless-stopped

View File

@ -100,6 +100,7 @@ class QueueManager:
mode: str, mode: str,
prompt: str, prompt: str,
input_file_path: str | None = None, input_file_path: str | None = None,
username: str | None = None,
) -> tuple[Request, int]: ) -> tuple[Request, int]:
async with self._lock: async with self._lock:
if self._stopping: if self._stopping:
@ -113,6 +114,7 @@ class QueueManager:
seed = secrets.randbelow(2**32) seed = secrets.randbelow(2**32)
request = await self._db.create_request( request = await self._db.create_request(
user_id=user_id, user_id=user_id,
username=username,
mode=mode, mode=mode,
prompt_text=prompt, prompt_text=prompt,
input_file_path=input_file_path, input_file_path=input_file_path,

View File

@ -3,3 +3,7 @@ httpx[socks]>=0.27,<1
aiosqlite>=0.20,<1 aiosqlite>=0.20,<1
websockets>=13,<16 websockets>=13,<16
python-dotenv>=1.0,<2 python-dotenv>=1.0,<2
fastapi>=0.115,<1
uvicorn>=0.30,<1
jinja2>=3.1,<4
pillow>=10,<13

View File

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import sqlite3
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@ -108,3 +110,97 @@ async def test_recover_marks_running_failed_and_returns_queued(db: Database) ->
assert failed is not None assert failed is not None
assert failed.status == "failed" assert failed.status == "failed"
assert failed.finished_at is not None assert failed.finished_at is not None
async def test_history_filters_and_stats(db: Database) -> None:
first = await db.create_request(
user_id=1,
username="alice",
mode="t2i",
prompt_text="apple",
input_file_path=None,
seed=1,
)
await db.set_done(first.id, "/tmp/1.png")
second = await db.create_request(
user_id=1,
username="alice",
mode="i2i",
prompt_text="banana",
input_file_path="/tmp/in.png",
seed=2,
)
await db.set_failed(second.id, "error")
third = await db.create_request(
user_id=2,
mode="t2i",
prompt_text="cherry",
input_file_path=None,
seed=3,
)
by_user = await db.list_requests(user_id=1)
assert {item.id for item in by_user} == {first.id, second.id}
failed = await db.list_requests(status="failed")
assert [item.id for item in failed] == [second.id]
queued_t2i = await db.list_requests(mode="t2i", status="queued")
assert [item.id for item in queued_t2i] == [third.id]
assert await db.count_requests(user_id=1) == 2
assert await db.count_requests(status="failed") == 1
stats = await db.stats()
assert stats["total"] == 3
assert stats["users"] == 2
assert stats["by_status"]["done"] == 1
assert stats["by_status"]["failed"] == 1
assert stats["by_status"]["queued"] == 1
users = await db.list_users()
assert {user.user_id for user in users} == {1, 2}
alice = next(user for user in users if user.user_id == 1)
assert alice.username == "alice"
assert alice.display_user == "@alice"
async def test_migrates_legacy_schema_without_username(tmp_path) -> None:
path = tmp_path / "legacy.db"
connection = sqlite3.connect(path)
connection.execute(
"""
CREATE TABLE requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
timestamp TEXT NOT NULL,
prompt_text TEXT NOT NULL,
input_file_path TEXT,
output_file_path TEXT,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'done', 'failed')),
comfy_prompt_id TEXT,
mode TEXT NOT NULL CHECK (mode IN ('t2i', 'i2i')),
seed INTEGER,
error_text TEXT,
finished_at TEXT
)
"""
)
connection.commit()
connection.close()
database = Database(path)
await database.connect()
try:
request = await database.create_request(
user_id=1,
username="alice",
mode="t2i",
prompt_text="apple",
input_file_path=None,
seed=1,
)
assert request.username == "alice"
finally:
await database.close()

109
tests/test_web.py Normal file
View File

@ -0,0 +1,109 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from fastapi.testclient import TestClient
from PIL import Image
from config import Settings
from web import create_app
def make_settings(tmp_path: Path, **overrides) -> Settings:
values = {
"bot_token": "",
"db_path": tmp_path / "bot.db",
"storage_dir": tmp_path / "storage",
}
values.update(overrides)
return Settings(**values)
def insert_request(
path: Path,
*,
user_id: int,
prompt: str,
status: str = "done",
mode: str = "t2i",
output: str | None = None,
username: str | None = None,
) -> int:
connection = sqlite3.connect(path)
try:
cursor = connection.execute(
"""
INSERT INTO requests
(user_id, username, timestamp, prompt_text, status, mode, seed,
output_file_path)
VALUES (?, ?, '2026-10-18T12:00:00+00:00', ?, ?, ?, 1, ?)
""",
(user_id, username, prompt, status, mode, output),
)
connection.commit()
return int(cursor.lastrowid)
finally:
connection.close()
def test_history_page_and_user_filter(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
app = create_app(settings)
with TestClient(app) as client:
insert_request(
settings.db_path, user_id=1, username="alice", prompt="apple prompt"
)
insert_request(settings.db_path, user_id=2, prompt="banana prompt")
response = client.get("/")
assert response.status_code == 200
assert "apple prompt" in response.text
assert "banana prompt" in response.text
assert "@alice" in response.text
assert 'title="ID: 2">2</a>' in response.text
response = client.get("/?user_id=1")
assert response.status_code == 200
assert "apple prompt" in response.text
assert "banana prompt" not in response.text
response = client.get("/?user_id=abc")
assert response.status_code == 200
assert "user_id должен состоять только из цифр" in response.text
def test_detail_and_media(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
output = settings.storage_dir / "1" / "1_output.png"
output.parent.mkdir(parents=True)
with Image.new("RGB", (320, 200), (180, 40, 40)) as image:
image.save(output)
app = create_app(settings)
with TestClient(app) as client:
request_id = insert_request(
settings.db_path,
user_id=1,
prompt="detail prompt",
output=str(output),
)
detail = client.get(f"/request/{request_id}")
assert detail.status_code == 200
assert "detail prompt" in detail.text
index = client.get("/")
assert f"/thumb/{request_id}" in index.text
media = client.get(f"/media/{request_id}/output")
assert media.status_code == 200
assert media.content.startswith(b"\x89PNG")
thumb = client.get(f"/thumb/{request_id}")
assert thumb.status_code == 200
assert thumb.headers["content-type"] == "image/jpeg"
assert thumb.content.startswith(b"\xff\xd8")
missing = client.get(f"/media/{request_id}/input")
assert missing.status_code == 404

244
web.py Normal file
View File

@ -0,0 +1,244 @@
from __future__ import annotations
import asyncio
import logging
import math
import os
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from uuid import uuid4
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from PIL import Image, ImageOps
from config import Settings
from db import Database
from db import Request as HistoryRequest
BASE_DIR = Path(__file__).resolve().parent
TEMPLATES_DIR = BASE_DIR / "web" / "templates"
STATIC_DIR = BASE_DIR / "web" / "static"
STATUSES = ("queued", "running", "done", "failed")
MODES = ("t2i", "i2i")
logger = logging.getLogger(__name__)
def _safe_storage_path(raw_path: str, storage_root: Path) -> Path | None:
path = Path(raw_path).resolve()
try:
path.relative_to(storage_root)
except ValueError:
return None
return path
def _make_thumbnail(source: Path, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
temp_path = destination.with_name(f"{destination.name}.{uuid4().hex}.tmp")
try:
with Image.open(source) as image:
image = ImageOps.exif_transpose(image)
image.thumbnail((320, 320))
if image.mode in {"RGBA", "LA"} or "transparency" in image.info:
rgba = image.convert("RGBA")
background = Image.new("RGB", rgba.size, (15, 17, 21))
background.paste(rgba, mask=rgba.getchannel("A"))
image = background
else:
image = image.convert("RGB")
image.save(temp_path, format="JPEG", quality=85, optimize=True)
os.replace(temp_path, destination)
finally:
temp_path.unlink(missing_ok=True)
def fmt_dt(value: str | None) -> str:
if not value:
return "—"
try:
parsed = datetime.fromisoformat(value)
return parsed.astimezone().strftime("%d.%m.%Y %H:%M")
except ValueError:
return value
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or Settings.from_env(require_token=False)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates.env.filters["fmt_dt"] = fmt_dt
@asynccontextmanager
async def lifespan(app: FastAPI):
db = Database(settings.db_path)
await db.connect()
app.state.db = db
yield
await db.close()
app = FastAPI(
title="MpakoImageAIbot history",
docs_url=None,
redoc_url=None,
lifespan=lifespan,
)
app.state.settings = settings
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/healthz")
async def healthz() -> dict[str, bool]:
return {"ok": True}
@app.get("/", response_class=HTMLResponse)
async def index(
request: Request,
user_id: str = "",
status: str = "",
mode: str = "",
page: int = 1,
):
db: Database = request.app.state.db
settings: Settings = request.app.state.settings
error = ""
uid: int | None = None
raw_user_id = user_id.strip()
if raw_user_id:
if raw_user_id.isdigit():
uid = int(raw_user_id)
else:
error = "user_id должен состоять только из цифр."
status = status if status in STATUSES else ""
mode = mode if mode in MODES else ""
total = await db.count_requests(user_id=uid, status=status or None, mode=mode or None)
page_size = settings.web_page_size
pages = max(1, math.ceil(total / page_size))
page = max(1, min(page, pages))
offset = (page - 1) * page_size
rows = await db.list_requests(
user_id=uid,
status=status or None,
mode=mode or None,
limit=page_size,
offset=offset,
)
users = await db.list_users()
stats = await db.stats()
base_filters = {
key: value
for key, value in (
("user_id", raw_user_id),
("status", status),
("mode", mode),
)
if value
}
def page_url(number: int) -> str:
params = dict(base_filters)
if number > 1:
params["page"] = str(number)
query = urlencode(params)
return f"/?{query}" if query else "/"
context: dict[str, Any] = {
"rows": rows,
"users": users,
"stats": stats,
"statuses": STATUSES,
"modes": MODES,
"user_id": raw_user_id,
"status": status,
"mode": mode,
"error": error,
"page": page,
"pages": pages,
"total": total,
"prev_url": page_url(page - 1) if page > 1 else None,
"next_url": page_url(page + 1) if page < pages else None,
}
return templates.TemplateResponse(
request=request, name="index.html", context=context
)
@app.get("/request/{request_id}", response_class=HTMLResponse)
async def detail(request: Request, request_id: int):
db: Database = request.app.state.db
item = await db.get_request(request_id)
if item is None:
raise HTTPException(status_code=404, detail="Запрос не найден")
return templates.TemplateResponse(
request=request,
name="detail.html",
context={"item": item},
)
@app.get("/media/{request_id}/{kind}")
async def media(request: Request, request_id: int, kind: str):
if kind not in {"input", "output"}:
raise HTTPException(status_code=404, detail="Не найдено")
db: Database = request.app.state.db
settings: Settings = request.app.state.settings
item: HistoryRequest | None = await db.get_request(request_id)
if item is None:
raise HTTPException(status_code=404, detail="Запрос не найден")
raw_path = item.input_file_path if kind == "input" else item.output_file_path
if not raw_path:
raise HTTPException(status_code=404, detail="Файл не найден")
storage_root = settings.storage_dir.resolve()
path = _safe_storage_path(raw_path, storage_root)
if path is None or not path.is_file():
raise HTTPException(status_code=404, detail="Файл не найден")
return FileResponse(path)
@app.get("/thumb/{request_id}")
async def thumbnail(request: Request, request_id: int):
db: Database = request.app.state.db
settings: Settings = request.app.state.settings
item: HistoryRequest | None = await db.get_request(request_id)
if item is None or not item.output_file_path:
raise HTTPException(status_code=404, detail="Файл не найден")
storage_root = settings.storage_dir.resolve()
source = _safe_storage_path(item.output_file_path, storage_root)
if source is None or not source.is_file():
raise HTTPException(status_code=404, detail="Файл не найден")
cache_dir = storage_root / ".thumbnails"
cache_path = cache_dir / f"{request_id}.jpg"
if cache_path.is_file() and cache_path.stat().st_mtime >= source.stat().st_mtime:
return FileResponse(
cache_path,
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=3600"},
)
try:
await asyncio.to_thread(_make_thumbnail, source, cache_path)
except Exception:
logger.exception("Не удалось создать превью для запроса %s", request_id)
return FileResponse(source)
return FileResponse(
cache_path,
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=3600"},
)
return app
app = create_app()

197
web/static/style.css Normal file
View File

@ -0,0 +1,197 @@
:root {
color-scheme: dark;
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1e222b;
--border: #2a2f3a;
--text: #e8eaf0;
--muted: #8b93a7;
--accent: #7aa2ff;
--green: #2fbf71;
--yellow: #d9a441;
--red: #e05d5d;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.topbar {
display: flex;
align-items: baseline;
gap: 14px;
padding: 16px 28px;
border-bottom: 1px solid var(--border);
background: #12151b;
}
.brand { font-weight: 700; font-size: 18px; color: var(--text); }
.muted { color: var(--muted); font-size: 13px; }
.page { max-width: 1280px; margin: 0 auto; padding: 24px 28px 48px; }
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.card-value { font-size: 26px; font-weight: 700; }
.card-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.filters {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: end;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 18px;
}
.filters label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
input, select, button {
font: inherit;
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 7px;
padding: 8px 10px;
}
input { min-width: 220px; }
button { cursor: pointer; background: #2f3b57; border-color: #3b4b70; }
button:hover { background: #394865; }
.filter-actions { display: flex; gap: 12px; align-items: center; }
.reset { color: var(--muted); font-size: 13px; }
.alert {
margin-bottom: 16px;
padding: 10px 14px;
border: 1px solid #6a3d3d;
background: #2a1c1c;
border-radius: 8px;
color: #ffb3b3;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 10px;
}
table { width: 100%; border-collapse: collapse; min-width: 900px; }
th, td { padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; background: #141821; }
tbody tr:hover { background: #1a1e27; }
tbody tr:last-child td { border-bottom: 0; }
.mono { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px; }
.nowrap { white-space: nowrap; }
.prompt { max-width: 420px; color: #cfd5e3; }
.empty { text-align: center; color: var(--muted); padding: 28px; }
.details { white-space: nowrap; }
.thumb-cell { width: 78px; }
.thumb {
width: 64px;
height: 64px;
object-fit: cover;
display: block;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel-2);
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: .03em;
background: #2a2f3a;
}
.badge.done { background: #173a28; color: #7ee2a8; }
.badge.failed { background: #3a1d1d; color: #ff9b9b; }
.badge.running { background: #3a3219; color: #ffd479; }
.badge.queued { background: #202a3a; color: #9db8e8; }
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-top: 16px;
color: var(--muted);
font-size: 13px;
}
.back { display: inline-block; margin-bottom: 14px; color: var(--muted); }
h1 { margin: 0 0 20px; font-size: 26px; }
.meta {
display: grid;
grid-template-columns: 180px 1fr;
gap: 10px 18px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
}
.meta dt { color: var(--muted); }
.meta dd { margin: 0; min-width: 0; }
.prompt-block { white-space: pre-wrap; overflow-wrap: anywhere; }
.error-text { color: #ff9b9b; white-space: pre-wrap; }
.images {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-top: 20px;
}
.images figure {
margin: 0;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.images img { display: block; width: 100%; height: auto; }
.images figcaption { padding: 8px 12px; color: var(--muted); font-size: 13px; }
@media (max-width: 640px) {
.page { padding: 18px 14px 36px; }
.topbar { padding: 14px; }
.meta { grid-template-columns: 1fr; }
input { min-width: 0; width: 100%; }
}

18
web/templates/base.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}История запросов{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="/">MpakoImageAIbot</a>
<span class="muted">История запросов</span>
</header>
<main class="page">
{% block content %}{% endblock %}
</main>
</body>
</html>

48
web/templates/detail.html Normal file
View File

@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Запрос #{{ item.id }} — MpakoImageAIbot{% endblock %}
{% block content %}
<a class="back" href="/">← К истории</a>
<h1>Запрос #{{ item.id }}</h1>
<dl class="meta">
<dt>Пользователь</dt>
<dd><a href="/?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></dd>
<dt>Статус</dt>
<dd><span class="badge {{ item.status }}">{{ item.status }}</span></dd>
<dt>Режим</dt>
<dd class="mono">{{ item.mode }}</dd>
<dt>Создан</dt>
<dd>{{ item.timestamp|fmt_dt }}</dd>
<dt>Завершён</dt>
<dd>{{ item.finished_at|fmt_dt }}</dd>
<dt>Seed</dt>
<dd class="mono">{{ item.seed if item.seed is not none else '—' }}</dd>
<dt>Comfy prompt_id</dt>
<dd class="mono">{{ item.comfy_prompt_id or '—' }}</dd>
<dt>Промпт</dt>
<dd class="prompt-block">{{ item.prompt_text }}</dd>
{% if item.error_text %}
<dt>Ошибка</dt>
<dd class="error-text">{{ item.error_text }}</dd>
{% endif %}
</dl>
<div class="images">
{% if item.input_file_path %}
<figure>
<a href="/media/{{ item.id }}/input" target="_blank" rel="noreferrer">
<img src="/media/{{ item.id }}/input" alt="Входное изображение">
</a>
<figcaption>Вход</figcaption>
</figure>
{% endif %}
{% if item.output_file_path %}
<figure>
<a href="/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
<img src="/media/{{ item.id }}/output" alt="Результат">
</a>
<figcaption>Результат</figcaption>
</figure>
{% endif %}
</div>
{% endblock %}

98
web/templates/index.html Normal file
View File

@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block title %}История запросов — MpakoImageAIbot{% endblock %}
{% block content %}
<section class="cards">
<div class="card"><span class="card-value">{{ stats.total }}</span><span class="card-label">Всего запросов</span></div>
<div class="card"><span class="card-value">{{ stats.users }}</span><span class="card-label">Пользователей</span></div>
<div class="card"><span class="card-value">{{ stats.by_status.get('done', 0) }}</span><span class="card-label">Готово</span></div>
<div class="card"><span class="card-value">{{ stats.by_status.get('failed', 0) }}</span><span class="card-label">Ошибок</span></div>
</section>
<form class="filters" method="get" action="/">
<label>
Telegram user_id
<input name="user_id" value="{{ user_id }}" list="known-users" inputmode="numeric" placeholder="например 7467236616">
</label>
<datalist id="known-users">
{% for user in users %}
<option value="{{ user.user_id }}">{{ user.display_user }} · {{ user.total }} запросов</option>
{% endfor %}
</datalist>
<label>
Статус
<select name="status">
<option value="">Все</option>
{% for item in statuses %}
<option value="{{ item }}" {% if status == item %}selected{% endif %}>{{ item }}</option>
{% endfor %}
</select>
</label>
<label>
Режим
<select name="mode">
<option value="">Все</option>
{% for item in modes %}
<option value="{{ item }}" {% if mode == item %}selected{% endif %}>{{ item }}</option>
{% endfor %}
</select>
</label>
<div class="filter-actions">
<button type="submit">Фильтровать</button>
<a class="reset" href="/">Сбросить</a>
</div>
</form>
{% if error %}<div class="alert">{{ error }}</div>{% endif %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Превью</th>
<th>Дата</th>
<th>Пользователь</th>
<th>Режим</th>
<th>Статус</th>
<th>Промпт</th>
<th>Seed</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in rows %}
<tr>
<td class="mono">#{{ item.id }}</td>
<td class="thumb-cell">
{% if item.output_file_path %}
<a href="/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
<img class="thumb" src="/thumb/{{ item.id }}" alt="Результат #{{ item.id }}" loading="lazy">
</a>
{% else %}
<span class="muted">—</span>
{% endif %}
</td>
<td class="nowrap">{{ item.timestamp|fmt_dt }}</td>
<td><a href="/?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></td>
<td class="mono">{{ item.mode }}</td>
<td><span class="badge {{ item.status }}">{{ item.status }}</span></td>
<td class="prompt" title="{{ item.prompt_text }}">{{ item.prompt_text|truncate(90, True) }}</td>
<td class="mono">{{ item.seed if item.seed is not none else '—' }}</td>
<td><a class="details" href="/request/{{ item.id }}">Детали</a></td>
</tr>
{% else %}
<tr><td colspan="9" class="empty">Ничего не найдено</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<nav class="pagination">
{% if prev_url %}<a href="{{ prev_url }}">← Назад</a>{% else %}<span></span>{% endif %}
<span>Страница {{ page }} из {{ pages }} · {{ total }} запросов</span>
{% if next_url %}<a href="{{ next_url }}">Вперёд →</a>{% else %}<span></span>{% endif %}
</nav>
{% endblock %}