Веб просмотр истории
Build and push Docker image / docker (push) Successful in 10s
Details
Build and push Docker image / docker (push) Successful in 10s
Details
This commit is contained in:
parent
28f7da349e
commit
a2163a5472
|
|
@ -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
|
||||
|
|
|
|||
25
README.md
25
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,30 @@ 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` (с подсказкой известных пользователей);
|
||||
- фильтр по статусу и режиму (`t2i`/`i2i`);
|
||||
- пагинация;
|
||||
- карточка запроса с полным промптом, ошибкой, seed и изображениями.
|
||||
|
||||
Доступ только с локальной сети: Compose публикует порт на `WEB_BIND_IP`. Для доступа из LAN:
|
||||
|
||||
```dotenv
|
||||
WEB_BIND_IP=192.168.31.158
|
||||
WEB_PAGE_SIZE=50
|
||||
```
|
||||
|
||||
Авторизации нет. Не публикуй порт `8099` наружу и не делай проброс на роутере.
|
||||
|
||||
После успешной сборки обновление контейнера:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
14
checklist.md
14
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,18 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
|
|||
|
||||
**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] Доступ только из локальной сети: публикация на `WEB_BIND_IP`, без авторизации.
|
||||
- [x] Тесты: фильтры БД, страницы, media.
|
||||
- [x] Compose-сервис на порту `8099` → `8080` контейнера.
|
||||
|
||||
**DoD:** история открывается по `http://IP:8099`, фильтр по пользователю работает, изображения доступны.
|
||||
|
||||
## Порядок работ
|
||||
|
||||
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
98
db.py
98
db.py
|
|
@ -58,6 +58,13 @@ class Request:
|
|||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserStat:
|
||||
user_id: int
|
||||
total: int
|
||||
last_seen: str
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -175,6 +182,97 @@ 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
|
||||
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:
|
||||
conn = self._connection()
|
||||
async with conn.execute(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,3 +3,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -108,3 +108,51 @@ 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,
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
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
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import 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 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 = 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()
|
||||
|
|
@ -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%; }
|
||||
}
|
||||
|
|
@ -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 }}">{{ 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 %}
|
||||
|
|
@ -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 %}
|
||||
Loading…
Reference in New Issue