Compare commits
7 Commits
25ffd9e678
...
v0.0.9
| Author | SHA1 | Date | |
|---|---|---|---|
| 54fd1a26d7 | |||
| 456cc58b25 | |||
| dccc369d0b | |||
| 77ee26014d | |||
| 413789ba22 | |||
| c4721fcf99 | |||
| 09215da22a |
@@ -98,7 +98,7 @@ SMTP_TIMEOUT_S=30
|
||||
# --- Версия инстанса (релиз v0.0.1) ---
|
||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||
# установке/обновлении — руками менять не нужно.
|
||||
VIDCONF_VERSION=0.0.7
|
||||
VIDCONF_VERSION=0.0.9
|
||||
|
||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||
|
||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -3,6 +3,47 @@
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
||||
|
||||
## [0.0.9] — 2026-07-28
|
||||
|
||||
Метрики CPU/RAM/диска хоста и контейнеров в Grafana; дефолтные подвкладки
|
||||
конференций и пользователей в админке.
|
||||
|
||||
### Добавлено
|
||||
- Мониторинг железа: `node-exporter` (CPU/RAM/диск/сеть/load average хоста)
|
||||
и `cAdvisor` (метрики по каждому контейнеру) — профиль compose
|
||||
`monitoring`, без публикации портов наружу. Новый дашборд Grafana «Хост и
|
||||
контейнеры». Три новых алерта Prometheus: `HostMemoryLow`, `HostDiskLow`,
|
||||
`HostCpuHigh` (пороги — под сервер `1gb`: 8 ГБ RAM, 4 CPU, 50 ГБ диска).
|
||||
|
||||
### Изменено
|
||||
- Админка, вкладка «Конференции»: вкладка «Все» перенесена в конец списка
|
||||
фильтров (после «Завершённые»), по умолчанию открываются «Запланированные».
|
||||
- Админка, вкладка «Пользователи»: по умолчанию открываются «Активные»
|
||||
(было «Все»).
|
||||
|
||||
## [0.0.8] — 2026-07-27
|
||||
|
||||
Админка: несколько mail-доменов для регистрации, фильтр и поиск по команде в
|
||||
списке пользователей, исправлен баг сохранения настроек.
|
||||
|
||||
### Исправлено
|
||||
- Сохранение любой настройки инстанса блокировалось ошибкой «уровень AI
|
||||
недоступен», даже если AI-модуль (транскрибация/суммаризация) выключен и
|
||||
пользователь уровень не трогал — фронт отправлял в `PUT /admin/settings`
|
||||
весь набор полей целиком, включая уже сохранённый `ai_level`. Теперь
|
||||
доступность уровня проверяется только при его фактической смене; фронт
|
||||
отправляет в запросе лишь реально изменённые поля.
|
||||
|
||||
### Добавлено
|
||||
- «Эталон mail-домена» в настройках инстанса поддерживает несколько
|
||||
доменов вместо одного — регистрация проходит с email с любого домена из
|
||||
списка. Старое значение из БД (один домен) читается без миграции и
|
||||
переписывается в новый формат при первом же сохранении.
|
||||
- В списке пользователей админки — вкладки «Активные»/«Заблокированные»/
|
||||
«Все» перед полем поиска.
|
||||
- Поиск по пользователям учитывает и название команды — пользователи без
|
||||
команды по-прежнему остаются в общей выдаче.
|
||||
|
||||
## [0.0.7] — 2026-07-27
|
||||
|
||||
Настройки устройств: выбор аудиовывода и компактная шторка на мобильном.
|
||||
@@ -183,6 +224,8 @@
|
||||
|
||||
Первоначальная версия VidConf.
|
||||
|
||||
[0.0.9]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.9
|
||||
[0.0.8]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.8
|
||||
[0.0.7]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.7
|
||||
[0.0.6]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.6
|
||||
[0.0.5]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.5
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import anyio
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
@@ -174,12 +174,16 @@ async def send_conference_invitations(
|
||||
async def list_users(
|
||||
admin: Annotated[User, Depends(require_admin)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
status: Annotated[Literal["active", "blocked"] | None, Query()] = None,
|
||||
q: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> AdminUserListOut:
|
||||
"""Список всех пользователей инстанса с текстовым поиском по email/имени."""
|
||||
rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset)
|
||||
"""Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без
|
||||
параметра — все) и текстовый поиск по email/имени/названию команды."""
|
||||
rows, total = await AdminUserRepository(session).list_paginated(
|
||||
status=status, q=q, limit=limit, offset=offset
|
||||
)
|
||||
media_root = _media_root()
|
||||
items = [
|
||||
_to_admin_user_out(user, team_name=team_name, media_root=media_root)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import ColumnElement, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.conference import Conference
|
||||
@@ -56,25 +56,37 @@ class AdminConferenceRepository:
|
||||
|
||||
|
||||
class AdminUserRepository:
|
||||
"""Постраничный список пользователей с текстовым поиском по email/имени."""
|
||||
"""Постраничный список пользователей с фильтром по статусу и текстовым поиском."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def list_paginated(
|
||||
self, *, q: str | None, limit: int, offset: int
|
||||
self, *, status: str | None, q: str | None, limit: int, offset: int
|
||||
) -> tuple[list[tuple[User, str | None]], int]:
|
||||
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
|
||||
|
||||
`LEFT JOIN` на `teams` — имя команды нужно карточке профиля/таблице
|
||||
админки, у пользователя без команды — `None`.
|
||||
`status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` —
|
||||
без фильтра (все пользователи). `q` ищет по email/имени пользователя
|
||||
И по названию команды (`LEFT JOIN` на `teams` — нужен и для этого
|
||||
поиска, и для колонки «Команда» в таблице админки; `LEFT`, а не
|
||||
`INNER`, — пользователи без команды не должны пропадать из общей
|
||||
выдачи, только не совпадать с поиском по названию команды).
|
||||
"""
|
||||
filters = []
|
||||
filters: list[ColumnElement[bool]] = []
|
||||
if status == "active":
|
||||
filters.append(User.is_blocked.is_(False))
|
||||
elif status == "blocked":
|
||||
filters.append(User.is_blocked.is_(True))
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
filters.append(or_(User.email.ilike(like), User.name_user.ilike(like)))
|
||||
filters.append(
|
||||
or_(User.email.ilike(like), User.name_user.ilike(like), Team.name.ilike(like))
|
||||
)
|
||||
|
||||
count_stmt = select(func.count()).select_from(User)
|
||||
count_stmt = (
|
||||
select(func.count()).select_from(User).outerjoin(Team, User.team_id == Team.id)
|
||||
)
|
||||
items_stmt = (
|
||||
select(User, Team.name)
|
||||
.outerjoin(Team, User.team_id == Team.id)
|
||||
|
||||
@@ -381,6 +381,67 @@ async def test_list_users_returns_all(client: httpx.AsyncClient, db_session: Asy
|
||||
assert all("email_verified" in item for item in items)
|
||||
|
||||
|
||||
async def test_list_users_filters_by_status(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""`status=active`/`blocked` фильтрует по `is_blocked`; без параметра — все."""
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
active_user = await _make_user(db_session)
|
||||
blocked_user = await _make_user(db_session)
|
||||
blocked_user.is_blocked = True
|
||||
await db_session.commit()
|
||||
|
||||
active_response = await client.get(
|
||||
"/api/v1/admin/users", params={"status": "active"}, headers=_auth_headers(admin)
|
||||
)
|
||||
assert active_response.status_code == 200, active_response.text
|
||||
active_ids = [item["id"] for item in active_response.json()["items"]]
|
||||
assert str(active_user.id) in active_ids
|
||||
assert str(blocked_user.id) not in active_ids
|
||||
|
||||
blocked_response = await client.get(
|
||||
"/api/v1/admin/users", params={"status": "blocked"}, headers=_auth_headers(admin)
|
||||
)
|
||||
assert blocked_response.status_code == 200, blocked_response.text
|
||||
blocked_ids = [item["id"] for item in blocked_response.json()["items"]]
|
||||
assert str(blocked_user.id) in blocked_ids
|
||||
assert str(active_user.id) not in blocked_ids
|
||||
|
||||
all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
|
||||
all_ids = [item["id"] for item in all_response.json()["items"]]
|
||||
assert str(active_user.id) in all_ids
|
||||
assert str(blocked_user.id) in all_ids
|
||||
|
||||
|
||||
async def test_list_users_search_matches_team_name(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""`q` находит пользователей по (части) названия их команды; пользователи
|
||||
без команды при этом не пропадают из общей (без поиска) выдачи."""
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
team = Team(name=f"Rocket-{suffix} Squad")
|
||||
db_session.add(team)
|
||||
await db_session.flush()
|
||||
teamed_user = await _make_user(db_session)
|
||||
teamed_user.team_id = team.id
|
||||
teamless_user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/users", params={"q": f"Rocket-{suffix}"}, headers=_auth_headers(admin)
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
ids = [item["id"] for item in response.json()["items"]]
|
||||
assert str(teamed_user.id) in ids
|
||||
assert str(teamless_user.id) not in ids
|
||||
assert str(admin.id) not in ids
|
||||
|
||||
all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
|
||||
all_ids = [item["id"] for item in all_response.json()["items"]]
|
||||
assert str(teamless_user.id) in all_ids
|
||||
|
||||
|
||||
async def test_patch_user_role_and_block(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
|
||||
@@ -82,7 +82,7 @@ services:
|
||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.7}
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.9}
|
||||
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
||||
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
||||
volumes:
|
||||
@@ -704,6 +704,73 @@ services:
|
||||
profiles: ["monitoring"]
|
||||
logging: *default-logging
|
||||
|
||||
node-exporter:
|
||||
# Метрики железа хоста (CPU, память, диск, сеть, load average) — то,
|
||||
# чего нет ни в одном из приложенческих экспортеров выше. Без
|
||||
# `network_mode: host` (не нужен: читаем /proc,/sys,/ хоста через
|
||||
# bind-mount, а Prometheus достаёт их по имени сервиса во внутренней
|
||||
# сети compose — так безопаснее, не расширяет сетевой доступ контейнера).
|
||||
image: prom/node-exporter:v1.8.2
|
||||
restart: unless-stopped
|
||||
pid: host
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
command:
|
||||
- '--path.procfs=/host/proc'
|
||||
- '--path.sysfs=/host/sys'
|
||||
- '--path.rootfs=/rootfs'
|
||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||
# Не публикуем порт наружу вообще (не 127.0.0.1:9100, а совсем без
|
||||
# ports) — Prometheus ходит к нему по внутренней сети compose
|
||||
# (`node-exporter:9100`), публикация на хост для этого не нужна.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9100/metrics >/dev/null || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
profiles: ["monitoring"]
|
||||
logging: *default-logging
|
||||
|
||||
cadvisor:
|
||||
# Метрики по контейнерам (CPU/память каждого) — отвечает на вопрос
|
||||
# «какой из контейнеров ест ресурсы», в отличие от node-exporter
|
||||
# (только хост целиком). Дороже node-exporter по CPU/RAM (собственные
|
||||
# ~60-120 МБ RSS + периодический обход cgroups) — на сервере 8 ГБ/4 CPU
|
||||
# с ~13 контейнерами это приемлемо, но не бесплатно; `--docker_only`
|
||||
# и `--disable_metrics` ниже отключают часть коллекторов сверх дефолта
|
||||
# cAdvisor (который уже отключает cpu_topology/hugetlb/memory_numa/
|
||||
# process/sched/tcp/udp и т.п.) — здесь дополнительно per-core CPU,
|
||||
# perf_event и дисковые метрики (диск и так покрыт node-exporter'ом,
|
||||
# дублировать через cAdvisor незачем). Список допустимых значений —
|
||||
# `cadvisor -help` (в версии образа меняется, не все имена стабильны
|
||||
# между релизами — при апгрейде образа сверяться с `-help`).
|
||||
image: gcr.io/cadvisor/cadvisor:v0.49.2
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
# Только чтение — cAdvisor не должен иметь возможность управлять
|
||||
# контейнерами, ему нужен только список и статистика.
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
command:
|
||||
- '--docker_only=true'
|
||||
- '--disable_metrics=percpu,perf_event,diskIO,disk,app'
|
||||
# Без ports вообще — тот же принцип, что и у node-exporter: Prometheus
|
||||
# достаёт метрики по имени сервиса (`cadvisor:8080`) внутри сети compose.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 15s
|
||||
profiles: ["monitoring"]
|
||||
logging: *default-logging
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:13.1.0
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -60,3 +60,58 @@ groups:
|
||||
# суммаризация встанет (задачи будут копиться в очереди summarize,
|
||||
# см. также алерт QueueGrowing). Проверить
|
||||
# `docker compose ps llm` / `llm-gpu` и `docker compose logs llm`.
|
||||
|
||||
# Железо хоста (job `node` — node-exporter). Пороги подобраны под
|
||||
# конкретный сервер 1gb: 8 ГБ RAM, 4 CPU, 50 ГБ диска — если сервер
|
||||
# сменится, пересчитать.
|
||||
- name: vidconf-host
|
||||
rules:
|
||||
# MemAvailable — уже честная оценка Linux с учётом того, что легко
|
||||
# освобождаемый page cache/buffers не в счёт (в отличие от naive
|
||||
# used = total - free). 10% от 8 ГБ ≈ 800 МБ — `for: 10m`, чтобы не
|
||||
# дёргать на кратковременный всплеск (например, разовый всплеск
|
||||
# transcribe/summarize), но успеть среагировать до OOM killer.
|
||||
- alert: HostMemoryLow
|
||||
expr: (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 < 10
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Мало свободной памяти на хосте"
|
||||
description: >-
|
||||
Свободно {{ $value | printf "%.1f" }}% RAM дольше 10 минут
|
||||
(порог 10% ≈ 800 МБ из 8 ГБ). Смотреть, какой контейнер ест
|
||||
память — дашборд «Хост и контейнеры», топ по памяти (cAdvisor).
|
||||
|
||||
# 50 ГБ диска — 10% ≈ 5 ГБ. `for: 15m` (не мгновенно): диск не растёт
|
||||
# так же резко, как память, ложные срабатывания на всплеск не грозят,
|
||||
# но и разовая проверка на границе некритична — 15 минут отсекает шум.
|
||||
- alert: HostDiskLow
|
||||
expr: (node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}) * 100 < 10
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Мало места на диске хоста"
|
||||
description: >-
|
||||
Свободно {{ $value | printf "%.1f" }}% диска дольше 15 минут
|
||||
(порог 10% ≈ 5 ГБ из 50 ГБ). Частые причины на этом проекте —
|
||||
записи транскрибации (`recordings`), логи docker, образы/слои
|
||||
после пересборки — проверить `docker system df`.
|
||||
|
||||
# 4 CPU. Порог 90% и `for: 15m` — сознательно строже по времени, чем
|
||||
# у памяти/диска: кратковременные пики от пайплайна пост-обработки
|
||||
# (транскрибация/суммаризация) — это ожидаемая, не аварийная нагрузка,
|
||||
# алерт должен ловить именно устойчивую перегрузку, а не обычный всплеск.
|
||||
- alert: HostCpuHigh
|
||||
expr: 100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Устойчиво высокая загрузка CPU хоста"
|
||||
description: >-
|
||||
Загрузка CPU {{ $value | printf "%.1f" }}% дольше 15 минут
|
||||
подряд (порог 90% из 4 ядер). Смотреть топ контейнеров по CPU
|
||||
(дашборд «Хост и контейнеры», cAdvisor) и латентность API —
|
||||
возможно, не хватает уровня AI/ресурсов под нагрузку.
|
||||
|
||||
245
deploy/monitoring/grafana/dashboards/host.json
Normal file
245
deploy/monitoring/grafana/dashboards/host.json
Normal file
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"title": "Хост и контейнеры",
|
||||
"description": "CPU/RAM/диск/сеть хоста (node-exporter) и разбивка по контейнерам (cAdvisor) — профиль compose monitoring. Пороги на панелях согласованы с deploy/monitoring/alerts.yml (группа vidconf-host).",
|
||||
"uid": "vidconf-host",
|
||||
"editable": false,
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"time": { "from": "now-6h", "to": "now" },
|
||||
"refresh": "30s",
|
||||
"tags": ["vidconf", "host"],
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Загрузка CPU",
|
||||
"description": "100 - idle, усреднено по всем ядрам. Порог алерта HostCpuHigh — 90% дольше 15 минут, см. alerts.yml.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "percent", "max": 100, "min": 0, "custom": { "drawStyle": "line", "fillOpacity": 10 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"legendFormat": "CPU busy",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Load average",
|
||||
"description": "node_load1/5/15 — на 4-ядерном сервере устойчивое значение выше ~4 означает очередь на CPU.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 5 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load1", "legendFormat": "1 мин", "refId": "A" },
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load5", "legendFormat": "5 мин", "refId": "B" },
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load15", "legendFormat": "15 мин", "refId": "C" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Использование RAM",
|
||||
"description": "(1 - MemAvailable/MemTotal) — MemAvailable уже учитывает легко освобождаемый page cache/buffers. Порог алерта HostMemoryLow — доступно <10% дольше 10 минут, см. alerts.yml.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "percent", "max": 100, "min": 0, "custom": { "drawStyle": "line", "fillOpacity": 10 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
|
||||
"legendFormat": "RAM used",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Своп (использовано)",
|
||||
"description": "SwapTotal - SwapFree. На этом сервере swap не заведён под мониторинг отдельным алертом — рост от нуля сам по себе сигнал, что памяти уже не хватает (см. HostMemoryLow).",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "decbytes", "custom": { "drawStyle": "line", "fillOpacity": 10 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes",
|
||||
"legendFormat": "swap used",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Свободно на диске (/)",
|
||||
"description": "node_filesystem_avail_bytes для корня. Порог алерта HostDiskLow — <10% (≈5 ГБ из 50 ГБ) дольше 15 минут.",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 16 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "decbytes",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 5368709120 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "RAM сейчас, %",
|
||||
"description": "Текущий снимок панели «Использование RAM». Порог совпадает с HostMemoryLow (used > 90%).",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 16 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 90 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Диск сейчас, %",
|
||||
"description": "Текущий снимок использования корневого раздела. Порог совпадает с HostDiskLow (used > 90%).",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 16 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 90 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "(1 - node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"} / node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}) * 100",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "CPU сейчас, %",
|
||||
"description": "Текущий снимок загрузки CPU. Порог совпадает с HostCpuHigh (>90%).",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 16 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 90 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"title": "Сетевой трафик",
|
||||
"description": "rx/tx по физическим интерфейсам хоста (исключены lo/veth/docker/br — это внутренние интерфейсы контейнеров, а не реальный сетевой трафик сервера).",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "Bps", "custom": { "drawStyle": "line", "fillOpacity": 5 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "rate(node_network_receive_bytes_total{device!~\"lo|veth.*|docker.*|br-.*\"}[5m])",
|
||||
"legendFormat": "rx {{device}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|docker.*|br-.*\"}[5m])",
|
||||
"legendFormat": "tx {{device}}",
|
||||
"refId": "B"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"title": "Топ контейнеров по CPU",
|
||||
"description": "Источник — cAdvisor (job cadvisor). Отвечает на вопрос «кто из контейнеров грузит CPU» при срабатывании HostCpuHigh.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "percentunit", "custom": { "drawStyle": "line", "fillOpacity": 10, "stacking": { "mode": "normal" } } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "topk(5, sum by (name) (rate(container_cpu_usage_seconds_total{name!=\"\"}[5m])))",
|
||||
"legendFormat": "{{name}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Топ контейнеров по памяти",
|
||||
"description": "container_memory_working_set_bytes — это то, что реально учитывает cgroup OOM killer (в отличие от RSS), источник — cAdvisor.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 28 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "decbytes", "custom": { "drawStyle": "line", "fillOpacity": 10, "stacking": { "mode": "normal" } } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "topk(5, container_memory_working_set_bytes{name!=\"\"})",
|
||||
"legendFormat": "{{name}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
# Конфигурация Prometheus (devops) — сбор метрик backend,
|
||||
# PostgreSQL, Redis и локального LLM-сервера. Поднимается compose-профилем
|
||||
# `monitoring` (deploy/docker-compose.yml, сервис `prometheus`).
|
||||
# PostgreSQL, Redis, железа хоста, контейнеров и локального LLM-сервера.
|
||||
# Поднимается compose-профилем `monitoring` (deploy/docker-compose.yml,
|
||||
# сервис `prometheus`).
|
||||
#
|
||||
# Имена метрик backend (`vidconf_http_request_duration_seconds`,
|
||||
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`) — КОНТРАКТ с
|
||||
@@ -33,6 +34,22 @@ scrape_configs:
|
||||
static_configs:
|
||||
- targets: ["redis-exporter:9121"]
|
||||
|
||||
# Хост целиком: CPU, память, диск, сеть, load average (профиль monitoring
|
||||
# — сервис node-exporter). Единственный источник, который покажет
|
||||
# нехватку памяти/CPU на сервере, если она не проявится как рост
|
||||
# латентности API (см. дашборд host.json).
|
||||
- job_name: node
|
||||
static_configs:
|
||||
- targets: ["node-exporter:9100"]
|
||||
|
||||
# Метрики по каждому контейнеру (CPU/память отдельно у backend, worker,
|
||||
# postgres и т.д. — профиль monitoring, сервис cadvisor). Отвечает на
|
||||
# вопрос «какой из контейнеров ест ресурсы», в отличие от job `node`
|
||||
# (только хост целиком).
|
||||
- job_name: cadvisor
|
||||
static_configs:
|
||||
- targets: ["cadvisor:8080"]
|
||||
|
||||
# Локальный LLM-сервер (llama.cpp, LLAMA_ARG_ENDPOINT_METRICS=1). Адрес
|
||||
# `llm:8080` разрешается ОДНИМ из двух compose-сервисов в зависимости от
|
||||
# выбранного при установке пресета — `llm` (CPU, профиль `llm`, уровни
|
||||
|
||||
@@ -10,11 +10,25 @@
|
||||
| `prometheus` | `prom/prometheus:v3.13.1` | `9090` | сбор и хранение метрик, оценка правил алертинга |
|
||||
| `postgres-exporter` | `quay.io/prometheuscommunity/postgres-exporter:v0.20.1` | — (внутренний) | метрики PostgreSQL |
|
||||
| `redis-exporter` | `oliver006/redis_exporter:v1.87.0-alpine` | — (внутренний) | метрики Redis |
|
||||
| `grafana` | `grafana/grafana:13.1.0` | `3001` (внутри контейнера `3000`) | дашборд «Пайплайны пост-обработки» |
|
||||
| `node-exporter` | `prom/node-exporter:v1.8.2` | — (внутренний) | метрики хоста: CPU, память, диск, сеть, load average |
|
||||
| `cadvisor` | `gcr.io/cadvisor/cadvisor:v0.49.2` | — (внутренний) | метрики по каждому контейнеру (CPU/память) |
|
||||
| `grafana` | `grafana/grafana:13.1.0` | `3001` (внутри контейнера `3000`) | дашборды «Пайплайны пост-обработки» и «Хост и контейнеры» |
|
||||
|
||||
`node-exporter`/`cadvisor` не публикуют порт на хост вообще (не только
|
||||
127.0.0.1) — Prometheus ходит к ним по имени сервиса во внутренней сети
|
||||
compose, публикация на хост для этого не нужна. `cadvisor` смонтирован
|
||||
к `/var/run/docker.sock` только на чтение (`:ro`) и запущен с
|
||||
`--docker_only` + урезанным набором коллекторов (`--disable_metrics`) —
|
||||
он заметно дороже `node-exporter` по CPU/RAM, урезание снижает накладные
|
||||
расходы. Если на конкретном сервере это всё равно избыточно —
|
||||
`cadvisor` можно убрать из `deploy/docker-compose.yml`, оставив только
|
||||
`node-exporter` (метрики хоста при этом не пострадают, пропадёт только
|
||||
разбивка по контейнерам).
|
||||
|
||||
Файлы: `deploy/monitoring/prometheus.yml`, `deploy/monitoring/alerts.yml`,
|
||||
`deploy/monitoring/grafana/provisioning/` (datasource + провайдер
|
||||
дашбордов), `deploy/monitoring/grafana/dashboards/pipelines.json`.
|
||||
дашбордов), `deploy/monitoring/grafana/dashboards/pipelines.json`,
|
||||
`deploy/monitoring/grafana/dashboards/host.json`.
|
||||
|
||||
## 2. Запуск
|
||||
|
||||
@@ -37,9 +51,9 @@ ssh -L 9090:127.0.0.1:9090 -L 3001:127.0.0.1:3001 <user>@<host>
|
||||
```
|
||||
и открывайте `http://localhost:9090` / `http://localhost:3001` у себя.
|
||||
|
||||
Дашборд «Пайплайны пост-обработки» (папка VidConf в Grafana) появляется
|
||||
сразу — источник данных и дашборд провижинятся из файлов, без ручной
|
||||
настройки.
|
||||
Дашборды «Пайплайны пост-обработки» и «Хост и контейнеры» (папка VidConf
|
||||
в Grafana) появляются сразу — источник данных и дашборды провижинятся из
|
||||
файлов, без ручной настройки.
|
||||
|
||||
## 3. Метрики backend
|
||||
|
||||
@@ -78,6 +92,13 @@ Job `llm` в `prometheus.yml` скрейпит `llm:8080/metrics`
|
||||
| `PipelineFailed` | рост числа сеансов в статусе `failed` за 15 минут | critical |
|
||||
| `QueueGrowing` | глубина очереди растёт 15 минут подряд и превышает 10 задач | warning |
|
||||
| `LlmDown` | `up{job="llm"} == 0` дольше 2 минут | critical (закомментирован по умолчанию) |
|
||||
| `HostMemoryLow` | свободно <10% RAM (≈800 МБ из 8 ГБ) дольше 10 минут | warning |
|
||||
| `HostDiskLow` | свободно <10% диска (≈5 ГБ из 50 ГБ) дольше 15 минут | warning |
|
||||
| `HostCpuHigh` | загрузка CPU >90% дольше 15 минут подряд | warning |
|
||||
|
||||
Пороги трёх алертов по железу подобраны под конкретный сервер `1gb`
|
||||
(8 ГБ RAM, 4 CPU, 50 ГБ диска) — при смене сервера пересчитать
|
||||
(`deploy/monitoring/alerts.yml`, группа `vidconf-host`).
|
||||
|
||||
`LlmDown` актуален только на инсталляциях с профилем `llm`/`llm-gpu`
|
||||
(пресеты 3–5) — по умолчанию (профили `media,monitoring`, без AI) правило
|
||||
|
||||
@@ -137,6 +137,8 @@ export type AdminUserDetailOut = AdminUserOut
|
||||
|
||||
/** Параметры выборки списка пользователей. */
|
||||
export interface AdminUserQuery {
|
||||
/** Фильтр по блокировке — без параметра отдаются все пользователи. */
|
||||
status?: 'active' | 'blocked'
|
||||
q?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
@@ -221,7 +223,7 @@ export async function sendConferenceInvitations(id: string, emails?: string[]):
|
||||
|
||||
/** Список пользователей для админки — с поиском и пагинацией. */
|
||||
export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> {
|
||||
const qs = toQueryString({ q: query.q, limit: query.limit, offset: query.offset })
|
||||
const qs = toQueryString({ status: query.status, q: query.q, limit: query.limit, offset: query.offset })
|
||||
return apiRequest<PagedResult<AdminUserOut>>(`/admin/users${qs}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ import { formatRecurrenceSummary } from '@/lib/recurrenceFormat'
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const STATUS_FILTERS: { value: ConferenceStatus | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: 'Все' },
|
||||
{ value: 'scheduled', label: 'Запланированные' },
|
||||
{ value: 'active', label: 'Активные' },
|
||||
{ value: 'ended', label: 'Завершённые' },
|
||||
{ value: 'all', label: 'Все' },
|
||||
]
|
||||
|
||||
function StatusBadge({ conference }: { conference: AdminConferenceOut }) {
|
||||
@@ -287,7 +287,7 @@ function InviteModal({ conference, onClose }: { conference: AdminConferenceOut;
|
||||
* блок «table-card», адаптированный под сущность «конференция»).
|
||||
*/
|
||||
export function AdminConferencesTab() {
|
||||
const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('scheduled')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
@@ -13,6 +13,12 @@ const PAGE_SIZE = 10
|
||||
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
|
||||
const TEAMS_LIMIT = 200
|
||||
|
||||
const STATUS_FILTERS: { value: 'active' | 'blocked' | 'all'; label: string }[] = [
|
||||
{ value: 'active', label: 'Активные' },
|
||||
{ value: 'blocked', label: 'Заблокированные' },
|
||||
{ value: 'all', label: 'Все' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Вкладка «Пользователи» админки — список с поиском, пагинацией, сменой
|
||||
* роли, блокировкой и командой (design/mockups/admin.html,
|
||||
@@ -28,6 +34,7 @@ const TEAMS_LIMIT = 200
|
||||
*/
|
||||
export function AdminUsersTab() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const [statusFilter, setStatusFilter] = useState<'active' | 'blocked' | 'all'>('active')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [offset, setOffset] = useState(0)
|
||||
@@ -45,8 +52,14 @@ export function AdminUsersTab() {
|
||||
}, [searchInput])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'users', search, offset],
|
||||
queryFn: () => listAdminUsers({ q: search || undefined, limit: PAGE_SIZE, offset }),
|
||||
queryKey: ['admin', 'users', statusFilter, search, offset],
|
||||
queryFn: () =>
|
||||
listAdminUsers({
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
q: search || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
}),
|
||||
})
|
||||
|
||||
const { data: teamsData } = useQuery({
|
||||
@@ -89,13 +102,29 @@ export function AdminUsersTab() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-tabs-inline">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
className={`chip${statusFilter === f.value ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setStatusFilter(f.value)
|
||||
setOffset(0)
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="toolbar-row">
|
||||
<div className="toolbar-left">
|
||||
<div className="search-wrap">
|
||||
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по имени или email…"
|
||||
placeholder="Поиск по имени, email или команде…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user