Отображение истории
Build and push Docker image / docker (push) Successful in 1m8s Details

This commit is contained in:
MpakobecEx 2026-10-18 19:06:08 +05:00
parent a2163a5472
commit 134a47dfc5
7 changed files with 109 additions and 9 deletions

View File

@ -91,6 +91,7 @@ http://192.168.31.158:8099
- фильтр по Telegram `user_id` (с подсказкой известных пользователей);
- фильтр по статусу и режиму (`t2i`/`i2i`);
- пагинация;
- превью результата в списке (кэшируются в `storage/.thumbnails`);
- карточка запроса с полным промптом, ошибкой, seed и изображениями.
Доступ только с локальной сети: Compose публикует порт на `WEB_BIND_IP`. Для доступа из LAN:

View File

@ -297,6 +297,7 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
- [x] `web.py`: FastAPI + Jinja2, отдельный контейнер `web`.
- [x] Список запросов с фильтрами `user_id`, `status`, `mode` и пагинацией.
- [x] Превью результата в списке, генерация и кэш в `storage/.thumbnails`.
- [x] Карточка запроса: промпт, seed, ошибка, `comfy_prompt_id`, вход/результат.
- [x] Отдача изображений только из `STORAGE_DIR` (защита от path traversal).
- [x] Доступ только из локальной сети: публикация на `WEB_BIND_IP`, без авторизации.

View File

@ -6,3 +6,4 @@ python-dotenv>=1.0,<2
fastapi>=0.115,<1
uvicorn>=0.30,<1
jinja2>=3.1,<4
pillow>=10,<13

View File

@ -4,6 +4,7 @@ import sqlite3
from pathlib import Path
from fastapi.testclient import TestClient
from PIL import Image
from config import Settings
from web import create_app
@ -70,7 +71,8 @@ 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")
with Image.new("RGB", (320, 200), (180, 40, 40)) as image:
image.save(output)
app = create_app(settings)
with TestClient(app) as client:
@ -85,9 +87,17 @@ def test_detail_and_media(tmp_path: Path) -> None:
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 == b"fake-png"
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

78
web.py
View File

@ -1,16 +1,21 @@
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
@ -23,6 +28,37 @@ 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:
@ -163,15 +199,45 @@ def create_app(settings: Settings | None = None) -> FastAPI:
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():
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

View File

@ -120,6 +120,17 @@ tbody tr:last-child td { border-bottom: 0; }
.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;

View File

@ -52,6 +52,7 @@
<thead>
<tr>
<th>ID</th>
<th>Превью</th>
<th>Дата</th>
<th>Пользователь</th>
<th>Режим</th>
@ -65,6 +66,15 @@
{% 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 }}">{{ item.user_id }}</a></td>
<td class="mono">{{ item.mode }}</td>
@ -74,7 +84,7 @@
<td><a class="details" href="/request/{{ item.id }}">Детали</a></td>
</tr>
{% else %}
<tr><td colspan="8" class="empty">Ничего не найдено</td></tr>
<tr><td colspan="9" class="empty">Ничего не найдено</td></tr>
{% endfor %}
</tbody>
</table>