diff --git a/.env.example b/.env.example index 0f4e9e9..8bd62c5 100644 --- a/.env.example +++ b/.env.example @@ -29,4 +29,8 @@ DEFAULT_NEGATIVE=low quality, blurry, bad anatomy, extra digits # Доступ: пусто — всем; иначе список id через запятую ALLOWED_USER_IDS= +# Веб-история: только локальная сеть, без авторизации +WEB_BIND_IP=192.168.31.158 +WEB_PAGE_SIZE=50 + LOG_LEVEL=INFO diff --git a/README.md b/README.md index cd65699..d962568 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Telegram-бот для генерации и редактирования изо - **Text-to-image**: текст → изображение (Z-Image Turbo, 1024×1024). - **Image-to-image**: фото + подпись → редактирование (Krea2 edit). - **Reply-to-image**: ответ текстом на готовую картинку бота → редактирование этой картинки. +- **Веб-история**: просмотр запросов с фильтрами по `user_id`, статусу и режиму, просмотр изображений. - Глобальная очередь с одной активной задачей на пользователя. - Позиция в очереди и прогресс генерации через WebSocket ComfyUI. - 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`. +## Веб-история + +После `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 diff --git a/bot.py b/bot.py index d16a41f..1105d4b 100644 --- a/bot.py +++ b/bot.py @@ -290,6 +290,7 @@ class MpakoBot: try: request, position = await self._queue.enqueue( user_id=user.id, + username=user.username, mode=mode, prompt=prompt, input_file_path=input_path, diff --git a/checklist.md b/checklist.md index 9db6525..fda4503 100644 --- a/checklist.md +++ b/checklist.md @@ -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. - 15 тестов проходят: БД, workflows, очередь, вспомогательные функции ComfyUI. - Не проверено: сборка Docker (демон не запущен), очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, реальная отправка в Telegram (нет токена). @@ -293,6 +293,20 @@ ON requests(user_id) WHERE status IN ('queued', 'running'); **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. diff --git a/config.py b/config.py index a40e200..8b2f93f 100644 --- a/config.py +++ b/config.py @@ -71,6 +71,8 @@ class Settings: default_negative: str = "low quality, blurry, bad anatomy, extra digits" max_prompt_length: int = 1000 max_photo_bytes: int = 15 * 1024 * 1024 + web_page_size: int = 50 + web_bind_ip: str = "127.0.0.1" @property def comfy_ws_url(self) -> str: @@ -117,6 +119,9 @@ class Settings: or cls.default_negative, max_prompt_length=max(1, _int("MAX_PROMPT_LENGTH", 1000)), 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 diff --git a/db.py b/db.py index abaa71b..4402d16 100644 --- a/db.py +++ b/db.py @@ -29,6 +29,7 @@ class UserAlreadyQueued(RuntimeError): class Request: id: int user_id: int + username: str | None timestamp: str prompt_text: str input_file_path: str | None @@ -40,11 +41,16 @@ class Request: error_text: 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 def from_row(cls, row: aiosqlite.Row) -> Request: return cls( id=row["id"], user_id=row["user_id"], + username=row["username"], timestamp=row["timestamp"], prompt_text=row["prompt_text"], 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 = """ CREATE TABLE IF NOT EXISTS requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, + username TEXT, timestamp TEXT NOT NULL, prompt_text TEXT NOT NULL, input_file_path TEXT, @@ -104,8 +123,16 @@ class Database: await self._conn.execute("PRAGMA busy_timeout=5000") await self._conn.execute("PRAGMA foreign_keys=ON") await self._conn.executescript(SCHEMA) + await self._migrate() 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: if self._conn is not None: await self._conn.close() @@ -120,6 +147,7 @@ class Database: self, *, user_id: int, + username: str | None = None, mode: str, prompt_text: str, input_file_path: str | None, @@ -131,10 +159,19 @@ class Database: cursor = await conn.execute( """ INSERT INTO requests - (user_id, timestamp, prompt_text, input_file_path, status, mode, seed) - VALUES (?, ?, ?, ?, 'queued', ?, ?) + (user_id, username, timestamp, prompt_text, + 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 await conn.commit() @@ -175,6 +212,106 @@ class Database: rows = await cursor.fetchall() 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: conn = self._connection() async with conn.execute( diff --git a/docker-compose.yml b/docker-compose.yml index d0b3627..d920a4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,3 +9,15 @@ services: - ./storage:/app/storage - ./data:/app/data 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 diff --git a/queue_manager.py b/queue_manager.py index a09daf1..52f9d2e 100644 --- a/queue_manager.py +++ b/queue_manager.py @@ -100,6 +100,7 @@ class QueueManager: mode: str, prompt: str, input_file_path: str | None = None, + username: str | None = None, ) -> tuple[Request, int]: async with self._lock: if self._stopping: @@ -113,6 +114,7 @@ class QueueManager: seed = secrets.randbelow(2**32) request = await self._db.create_request( user_id=user_id, + username=username, mode=mode, prompt_text=prompt, input_file_path=input_file_path, diff --git a/requirements.txt b/requirements.txt index e133446..76f3e41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,7 @@ httpx[socks]>=0.27,<1 aiosqlite>=0.20,<1 websockets>=13,<16 python-dotenv>=1.0,<2 +fastapi>=0.115,<1 +uvicorn>=0.30,<1 +jinja2>=3.1,<4 +pillow>=10,<13 diff --git a/tests/test_db.py b/tests/test_db.py index 4532287..fd15241 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,5 +1,7 @@ from __future__ import annotations +import sqlite3 + import pytest 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.status == "failed" 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() diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..ed5df56 --- /dev/null +++ b/tests/test_web.py @@ -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' 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 diff --git a/web.py b/web.py new file mode 100644 index 0000000..fa272ee --- /dev/null +++ b/web.py @@ -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() diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..c792191 --- /dev/null +++ b/web/static/style.css @@ -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%; } +} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..9e6fc00 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,18 @@ + + +
+ + +| ID | +Превью | +Дата | +Пользователь | +Режим | +Статус | +Промпт | +Seed | ++ |
|---|---|---|---|---|---|---|---|---|
| #{{ item.id }} | +
+ {% if item.output_file_path %}
+
+ |
+ {{ item.timestamp|fmt_dt }} | +{{ item.display_user }} | +{{ item.mode }} | +{{ item.status }} | +{{ item.prompt_text|truncate(90, True) }} | +{{ item.seed if item.seed is not none else '—' }} | +Детали | +
| Ничего не найдено | ||||||||