feat: add web request history
Build and push Docker image / docker (push) Successful in 56s Details

This commit is contained in:
MpakobecEx 2026-10-18 18:57:30 +05:00
parent 28f7da349e
commit eafbee5df9
14 changed files with 861 additions and 1 deletions

View File

@ -29,4 +29,9 @@ DEFAULT_NEGATIVE=low quality, blurry, bad anatomy, extra digits
# Доступ: пусто — всем; иначе список id через запятую # Доступ: пусто — всем; иначе список id через запятую
ALLOWED_USER_IDS= ALLOWED_USER_IDS=
# Веб-история
WEB_PAGE_SIZE=50
WEB_USERNAME=admin
WEB_PASSWORD=
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,30 @@ 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` (с подсказкой известных пользователей);
- фильтр по статусу и режиму (`t2i`/`i2i`);
- пагинация;
- карточка запроса с полным промптом, ошибкой, seed и изображениями.
Доступ защищается HTTP Basic, если задан `WEB_PASSWORD`:
```dotenv
WEB_USERNAME=admin
WEB_PASSWORD=change-me
```
Если `WEB_PASSWORD` пустой — страница открыта без пароля. Не оставляй её публично доступной.
После успешной сборки обновление контейнера: После успешной сборки обновление контейнера:
```bash ```bash

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,18 @@ 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] Карточка запроса: промпт, seed, ошибка, `comfy_prompt_id`, вход/результат.
- [x] Отдача изображений только из `STORAGE_DIR` (защита от path traversal).
- [x] Опциональная HTTP Basic-авторизация (`WEB_USERNAME`/`WEB_PASSWORD`).
- [x] Тесты: фильтры БД, страницы, media, basic auth.
- [x] Compose-сервис на порту `8099` → `8080` контейнера.
**DoD:** история открывается по `http://IP:8099`, фильтр по пользователю работает, изображения доступны.
## Порядок работ ## Порядок работ
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow. 1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.

View File

@ -71,6 +71,9 @@ 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_username: str = "admin"
web_password: str = ""
@property @property
def comfy_ws_url(self) -> str: def comfy_ws_url(self) -> str:
@ -117,6 +120,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_username=os.getenv("WEB_USERNAME", "admin").strip() or "admin",
web_password=os.getenv("WEB_PASSWORD", "").strip(),
) )
return settings return settings

98
db.py
View File

@ -58,6 +58,13 @@ class Request:
) )
@dataclass(frozen=True)
class UserStat:
user_id: int
total: int
last_seen: str
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS requests ( CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -175,6 +182,97 @@ 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
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"]),
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:
- "8099:8080"
volumes:
- ./storage:/app/storage
- ./data:/app/data
restart: unless-stopped

View File

@ -3,3 +3,6 @@ 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

View File

@ -108,3 +108,51 @@ 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,
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,
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}

102
tests/test_web.py Normal file
View File

@ -0,0 +1,102 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from fastapi.testclient import TestClient
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,
) -> int:
connection = sqlite3.connect(path)
try:
cursor = connection.execute(
"""
INSERT INTO requests
(user_id, timestamp, prompt_text, status, mode, seed, output_file_path)
VALUES (?, '2026-10-18T12:00:00+00:00', ?, ?, ?, 1, ?)
""",
(user_id, 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, 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
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)
output.write_bytes(b"fake-png")
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
media = client.get(f"/media/{request_id}/output")
assert media.status_code == 200
assert media.content == b"fake-png"
missing = client.get(f"/media/{request_id}/input")
assert missing.status_code == 404
def test_basic_auth(tmp_path: Path) -> None:
settings = make_settings(tmp_path, web_username="admin", web_password="secret")
app = create_app(settings)
with TestClient(app) as client:
assert client.get("/").status_code == 401
assert client.get("/", auth=("admin", "secret")).status_code == 200
assert client.get("/", auth=("admin", "wrong")).status_code == 401

209
web.py Normal file
View File

@ -0,0 +1,209 @@
from __future__ import annotations
import base64
import math
import secrets
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
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")
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 make_auth(settings: Settings):
async def require_auth(request: Request) -> None:
if not settings.web_password:
return
header = request.headers.get("Authorization", "")
if not header.startswith("Basic "):
raise HTTPException(
status_code=401,
detail="Требуется авторизация",
headers={"WWW-Authenticate": 'Basic realm="MpakoImageAIbot"'},
)
try:
decoded = base64.b64decode(header[6:]).decode("utf-8")
username, _, password = decoded.partition(":")
except (ValueError, UnicodeDecodeError):
username = password = ""
valid_user = secrets.compare_digest(username, settings.web_username)
valid_password = secrets.compare_digest(password, settings.web_password)
if not (valid_user and valid_password):
raise HTTPException(
status_code=401,
detail="Требуется авторизация",
headers={"WWW-Authenticate": 'Basic realm="MpakoImageAIbot"'},
)
return require_auth
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,
dependencies=[Depends(make_auth(settings))],
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 = Path(raw_path).resolve()
try:
path.relative_to(storage_root)
except ValueError:
raise HTTPException(status_code=404, detail="Файл не найден") from None
if not path.is_file():
raise HTTPException(status_code=404, detail="Файл не найден")
return FileResponse(path)
return app
app = create_app()

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

@ -0,0 +1,186 @@
: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; }
.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 }}">{{ item.user_id }}</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 %}

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

@ -0,0 +1,88 @@
{% 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.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>Seed</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in rows %}
<tr>
<td class="mono">#{{ item.id }}</td>
<td class="nowrap">{{ item.timestamp|fmt_dt }}</td>
<td><a href="/?user_id={{ item.user_id }}">{{ item.user_id }}</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="8" 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 %}