Отображение истории
Build and push Docker image / docker (push) Successful in 1m8s
Details
Build and push Docker image / docker (push) Successful in 1m8s
Details
This commit is contained in:
parent
a2163a5472
commit
134a47dfc5
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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`, без авторизации.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
78
web.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue