Отображение истории
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` (с подсказкой известных пользователей); - фильтр по Telegram `user_id` (с подсказкой известных пользователей);
- фильтр по статусу и режиму (`t2i`/`i2i`); - фильтр по статусу и режиму (`t2i`/`i2i`);
- пагинация; - пагинация;
- превью результата в списке (кэшируются в `storage/.thumbnails`);
- карточка запроса с полным промптом, ошибкой, seed и изображениями. - карточка запроса с полным промптом, ошибкой, seed и изображениями.
Доступ только с локальной сети: Compose публикует порт на `WEB_BIND_IP`. Для доступа из LAN: Доступ только с локальной сети: 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] `web.py`: FastAPI + Jinja2, отдельный контейнер `web`.
- [x] Список запросов с фильтрами `user_id`, `status`, `mode` и пагинацией. - [x] Список запросов с фильтрами `user_id`, `status`, `mode` и пагинацией.
- [x] Превью результата в списке, генерация и кэш в `storage/.thumbnails`.
- [x] Карточка запроса: промпт, seed, ошибка, `comfy_prompt_id`, вход/результат. - [x] Карточка запроса: промпт, seed, ошибка, `comfy_prompt_id`, вход/результат.
- [x] Отдача изображений только из `STORAGE_DIR` (защита от path traversal). - [x] Отдача изображений только из `STORAGE_DIR` (защита от path traversal).
- [x] Доступ только из локальной сети: публикация на `WEB_BIND_IP`, без авторизации. - [x] Доступ только из локальной сети: публикация на `WEB_BIND_IP`, без авторизации.

View File

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

View File

@ -4,6 +4,7 @@ import sqlite3
from pathlib import Path from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from PIL import Image
from config import Settings from config import Settings
from web import create_app from web import create_app
@ -70,7 +71,8 @@ def test_detail_and_media(tmp_path: Path) -> None:
settings = make_settings(tmp_path) settings = make_settings(tmp_path)
output = settings.storage_dir / "1" / "1_output.png" output = settings.storage_dir / "1" / "1_output.png"
output.parent.mkdir(parents=True) 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) app = create_app(settings)
with TestClient(app) as client: 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.status_code == 200
assert "detail prompt" in detail.text 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") media = client.get(f"/media/{request_id}/output")
assert media.status_code == 200 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") missing = client.get(f"/media/{request_id}/input")
assert missing.status_code == 404 assert missing.status_code == 404

78
web.py
View File

@ -1,16 +1,21 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging
import math import math
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
from uuid import uuid4
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from PIL import Image, ImageOps
from config import Settings from config import Settings
from db import Database from db import Database
@ -23,6 +28,37 @@ STATIC_DIR = BASE_DIR / "web" / "static"
STATUSES = ("queued", "running", "done", "failed") STATUSES = ("queued", "running", "done", "failed")
MODES = ("t2i", "i2i") 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: def fmt_dt(value: str | None) -> str:
if not value: if not value:
@ -163,15 +199,45 @@ def create_app(settings: Settings | None = None) -> FastAPI:
raise HTTPException(status_code=404, detail="Файл не найден") raise HTTPException(status_code=404, detail="Файл не найден")
storage_root = settings.storage_dir.resolve() storage_root = settings.storage_dir.resolve()
path = Path(raw_path).resolve() path = _safe_storage_path(raw_path, storage_root)
try: if path is None or not path.is_file():
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="Файл не найден") raise HTTPException(status_code=404, detail="Файл не найден")
return FileResponse(path) 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 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; } .empty { text-align: center; color: var(--muted); padding: 28px; }
.details { white-space: nowrap; } .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 { .badge {
display: inline-block; display: inline-block;
padding: 2px 8px; padding: 2px 8px;

View File

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