Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import re
import secrets
import time
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
Expand All @@ -14,6 +15,7 @@
from src.config import AppConfig, load_config
from src.web.assembly import (
build_log_buffer,
build_timing_buffer,
configure_app,
register_builtin_endpoints,
register_routes,
Expand Down Expand Up @@ -77,6 +79,31 @@ def _resolve_action_label(path: str) -> str:
return ""


class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
t0 = time.monotonic()
response = None
try:
response = await call_next(request)
finally:
ms = int((time.monotonic() - t0) * 1000)
path = request.url.path
if not is_public_path(path):
status = response.status_code if response is not None else 500
buf = getattr(request.app.state, "timing_buffer", None)
if buf is not None:
buf.add({
"time": time.strftime("%H:%M:%S"),
"method": request.method,
"path": path,
"status": status,
"ms": ms,
})
if ms > 500:
logger.warning("SLOW %s %s %dms [%d]", request.method, path, ms, status)
return response


class ActionLogMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.method in _LOG_METHODS:
Expand Down Expand Up @@ -137,6 +164,7 @@ async def lifespan(app: FastAPI):
container = await build_container_with_templates(
app.state.config,
log_buffer=app.state.log_buffer,
timing_buffer=app.state.timing_buffer,
templates=app.state.templates,
)
configure_app(app, container)
Expand All @@ -157,6 +185,7 @@ def create_app(config: AppConfig | None = None) -> FastAPI:
app = FastAPI(title="TG Post Search", lifespan=lifespan)
app.state.config = config
app.state.log_buffer = build_log_buffer()
app.state.timing_buffer = build_timing_buffer()
app.state.templates = configure_template_globals(
Jinja2Templates(directory=str(TEMPLATES_DIR)),
config,
Expand All @@ -167,6 +196,7 @@ def create_app(config: AppConfig | None = None) -> FastAPI:
app.add_middleware(BasicAuthMiddleware, password=config.web.password)
app.add_middleware(OriginCSRFMiddleware)
app.add_middleware(ActionLogMiddleware)
app.add_middleware(TimingMiddleware)

@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
Expand Down
7 changes: 7 additions & 0 deletions src/web/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def configure_app(app: FastAPI, container: AppContainer | None) -> None:
app.state.photo_task_service = container.photo_task_service
app.state.photo_auto_upload_service = container.photo_auto_upload_service
app.state.session_secret = container.session_secret
app.state.timing_buffer = container.timing_buffer
elif not hasattr(app.state, "templates"):
app.state.templates = configure_template_globals(
Jinja2Templates(directory=str(TEMPLATES_DIR)),
Expand Down Expand Up @@ -146,6 +147,12 @@ def register_routes(app: FastAPI) -> None:
app.include_router(pipelines_router, prefix="/pipelines")


def build_timing_buffer():
from src.web.timing import TimingBuffer

return TimingBuffer(maxlen=200)


def build_log_buffer() -> logging.Handler:
from src.web.log_handler import LogBuffer

Expand Down
3 changes: 3 additions & 0 deletions src/web/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from src.web.log_handler import LogBuffer
from src.web.paths import TEMPLATES_DIR
from src.web.template_globals import configure_template_globals
from src.web.timing import TimingBuffer

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -68,6 +69,7 @@ async def build_container_with_templates(
config: AppConfig,
*,
log_buffer: LogBuffer,
timing_buffer: TimingBuffer | None = None,
templates: Jinja2Templates | None,
) -> AppContainer:
db = Database(
Expand Down Expand Up @@ -194,6 +196,7 @@ async def build_container_with_templates(
scheduler=scheduler,
templates=_templates,
log_buffer=log_buffer,
timing_buffer=timing_buffer,
session_secret=session_secret,
bg_tasks=set(),
agent_manager=agent_manager,
Expand Down
2 changes: 2 additions & 0 deletions src/web/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from src.telegram.collector import Collector
from src.telegram.notifier import Notifier
from src.web.log_handler import LogBuffer
from src.web.timing import TimingBuffer


@dataclass(slots=True)
Expand Down Expand Up @@ -67,6 +68,7 @@ class AppContainer:
scheduler: SchedulerManager
templates: Jinja2Templates
log_buffer: LogBuffer | None
timing_buffer: TimingBuffer | None
session_secret: str
bg_tasks: set[asyncio.Task]
agent_manager: AgentManager | None = None
Expand Down
6 changes: 6 additions & 0 deletions src/web/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from src.web.container import AppContainer
from src.web.log_handler import LogBuffer
from src.web.paths import TEMPLATES_DIR
from src.web.timing import TimingBuffer

T = TypeVar("T")
_MISSING = object()
Expand Down Expand Up @@ -133,6 +134,7 @@ def get_container(request: Request) -> AppContainer:
scheduler=_require_app_state_attr(request, "scheduler"),
templates=templates,
log_buffer=getattr(request.app.state, "log_buffer", None),
timing_buffer=getattr(request.app.state, "timing_buffer", None),
session_secret=_require_app_state_attr(request, "session_secret"),
bg_tasks=getattr(request.app.state, "bg_tasks", set()),
agent_manager=getattr(request.app.state, "agent_manager", None),
Expand Down Expand Up @@ -246,6 +248,10 @@ def get_log_buffer(request: Request) -> LogBuffer | None:
return get_container(request).log_buffer


def get_timing_buffer(request: Request) -> TimingBuffer | None:
return get_container(request).timing_buffer


def is_shutting_down(request: Request) -> bool:
return get_container(request).shutting_down

Expand Down
18 changes: 18 additions & 0 deletions src/web/routes/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,21 @@ async def debug_logs_partial(request: Request):
return deps.get_templates(request).TemplateResponse(
request, "_debug_logs.html", {"records": records}
)


@router.get("/timing", response_class=HTMLResponse)
async def debug_timing(request: Request):
buf = deps.get_timing_buffer(request)
records = sorted(buf.get_records(), key=lambda r: r["ms"], reverse=True) if buf else []
return deps.get_templates(request).TemplateResponse(
request, "debug_timing.html", {"records": records}
)


@router.get("/timing/rows", response_class=HTMLResponse)
async def debug_timing_rows(request: Request):
buf = deps.get_timing_buffer(request)
records = sorted(buf.get_records(), key=lambda r: r["ms"], reverse=True) if buf else []
return deps.get_templates(request).TemplateResponse(
request, "_timing_rows.html", {"records": records}
)
11 changes: 11 additions & 0 deletions src/web/templates/_timing_rows.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% for r in records %}
<tr{% if r.ms > 1000 %} class="table-danger"{% elif r.ms > 500 %} class="table-warning"{% endif %}>
<td><code>{{ r.time }}</code></td>
<td><strong>{{ r.ms }} ms</strong></td>
<td>{{ r.method }}</td>
<td><code>{{ r.path }}</code></td>
<td>{{ r.status }}</td>
</tr>
{% else %}
<tr><td colspan="5">Записей нет — выполните несколько запросов к страницам.</td></tr>
{% endfor %}
5 changes: 4 additions & 1 deletion src/web/templates/debug.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
{% extends "base.html" %}
{% block title %}Дебаг — TG Agent{% endblock %}
{% block content %}
<h1>Консоль сервера</h1>
<div class="d-flex align-items-center gap-3 mb-3">
<h1 class="mb-0">Консоль сервера</h1>
<a href="/debug/timing" class="btn btn-sm btn-outline-secondary">Тайминг запросов →</a>
</div>
<p><small class="text-muted">Последние 500 записей.</small></p>
<div class="table-responsive" style="font-size:0.85rem">
<table class="table table-sm table-striped">
Expand Down
28 changes: 28 additions & 0 deletions src/web/templates/debug_timing.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Тайминг запросов — TG Agent{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-3 mb-3">
<h1 class="mb-0">Тайминг запросов</h1>
<a href="/debug" class="btn btn-sm btn-outline-secondary">← Логи</a>
</div>
<p class="text-muted">Последние 200 запросов, отсортированных по убыванию длительности.
<span class="badge bg-danger">красный</span> &gt;1000 ms,
<span class="badge bg-warning text-dark">жёлтый</span> 500–999 ms.
</p>
<div class="table-responsive" style="font-size:0.85rem">
<table class="table table-sm table-striped table-hover">
<thead>
<tr>
<th>Время</th>
<th>Длит., ms</th>
<th>Метод</th>
<th>Путь</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% include "_timing_rows.html" %}
</tbody>
</table>
</div>
{% endblock %}
23 changes: 23 additions & 0 deletions src/web/timing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from __future__ import annotations

from collections import deque
from typing import TypedDict


class TimingRecord(TypedDict):
time: str # "HH:MM:SS"
method: str # GET / POST
path: str # /channels
status: int # 200
ms: int # длительность в миллисекундах


class TimingBuffer:
def __init__(self, maxlen: int = 200):
self._records: deque[TimingRecord] = deque(maxlen=maxlen)

def add(self, record: TimingRecord) -> None:
self._records.append(record)

def get_records(self) -> list[TimingRecord]:
return list(self._records)
Loading