Веб-история запросов
Отдельный сервис на FastAPI: список запросов с фильтрами, карточка, превью и отдача файлов из storage. Показываю @username.
This commit is contained in:
parent
d6df4a3118
commit
20a4676034
|
|
@ -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
|
||||||
|
|
|
||||||
27
README.md
27
README.md
|
|
@ -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
1
bot.py
|
|
@ -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,
|
||||||
|
|
|
||||||
16
checklist.md
16
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.
|
- Вживую проверены: 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.
|
||||||
|
|
|
||||||
|
|
@ -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
143
db.py
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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%; }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
Loading…
Reference in New Issue