Make monitoring TG API-only
This commit is contained in:
@@ -45,7 +45,3 @@ LLM_MIN_TEXT_LENGTH=20
|
||||
# processes per tick. With 5/20s ≈ 900 messages/hour at ~3-6s per call.
|
||||
LLM_CLASSIFY_INTERVAL_SECONDS=20
|
||||
LLM_CLASSIFY_BATCH_SIZE=5
|
||||
|
||||
# Optional local fallback for admin-only UI/API operations. In production the
|
||||
# portal forwards X-User-Is-Admin=1, so no local password is required.
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
15
README.md
15
README.md
@@ -1,12 +1,16 @@
|
||||
# monitoring-tg
|
||||
|
||||
Сервис мониторинга Telegram-каналов для портала. Он сохраняет сообщения в
|
||||
Postgres, раскладывает каналы по вертикалям/подразделам и выполняет AI-анализ
|
||||
Backend-сервис мониторинга Telegram-каналов для Portal. Он сохраняет сообщения
|
||||
в Postgres, раскладывает каналы по вертикалям/подразделам и выполняет AI-анализ
|
||||
через OpenAI-compatible endpoint, общий с другими сервисами портала.
|
||||
|
||||
Пользовательский UI живёт в `portal/frontend/src/app/features/monitoring-tg`.
|
||||
Этот сервис не отдаёт отдельные HTML-страницы и работает как API/worker за
|
||||
портальным прокси `/api/monitoring-tg`.
|
||||
|
||||
## Доступ
|
||||
|
||||
- Админские операции остаются за админом портала: portal прокидывает
|
||||
- Админские операции остаются за ролью `admin` в Portal: portal прокидывает
|
||||
`X-User-Is-Admin=1`.
|
||||
- Отдел видит только свои подразделы, каналы, сообщения и промпты через
|
||||
`X-User-Department-Id`.
|
||||
@@ -38,8 +42,8 @@ LLM_API_KEY=
|
||||
LLM_MODEL=qwen2.5-14b
|
||||
```
|
||||
|
||||
Для локальной админской отладки можно задать `ADMIN_PASSWORD`, но в проде доступ
|
||||
должен идти через портал.
|
||||
Локального админ-пароля нет: админские API доступны только через роль `admin`
|
||||
в Portal.
|
||||
|
||||
## Запуск в k8s
|
||||
|
||||
@@ -60,7 +64,6 @@ src/parser_bot/
|
||||
├── db/ SQLAlchemy модели + сессии
|
||||
├── scheduler/ APScheduler-воркер периодического опроса
|
||||
├── telegram/ Telethon-клиент
|
||||
├── web/static/ страницы UI без бандлера
|
||||
├── config.py pydantic-settings
|
||||
└── main.py FastAPI lifespan + uvicorn
|
||||
alembic/ миграции
|
||||
|
||||
@@ -11,7 +11,6 @@ stringData:
|
||||
TG_SESSION_STRING: ""
|
||||
POSTGRES_PASSWORD: "parser"
|
||||
LLM_API_KEY: ""
|
||||
ADMIN_PASSWORD: "CHANGE_ME"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
|
||||
@@ -33,9 +33,6 @@ build-backend = "setuptools.build_meta"
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"parser_bot.web" = ["static/*", "static/**/*"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
@@ -1,80 +1,11 @@
|
||||
"""Portal-aware access helpers for monitoring-tg."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Request, Response
|
||||
|
||||
from parser_bot.config import settings
|
||||
|
||||
ADMIN_COOKIE = "parser_admin"
|
||||
_ADMIN_TOKEN_MESSAGE = b"parser-tg-bot-admin-v1"
|
||||
|
||||
|
||||
def admin_password_enabled() -> bool:
|
||||
return bool(settings.admin_password)
|
||||
|
||||
|
||||
def verify_admin_password(password: str | None) -> bool:
|
||||
if not settings.admin_password:
|
||||
return True
|
||||
if password is None:
|
||||
return False
|
||||
return secrets.compare_digest(password, settings.admin_password)
|
||||
|
||||
|
||||
def admin_token() -> str:
|
||||
return hmac.new(
|
||||
settings.admin_password.encode("utf-8"),
|
||||
_ADMIN_TOKEN_MESSAGE,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def verify_admin_token(token: str | None) -> bool:
|
||||
if not settings.admin_password:
|
||||
return True
|
||||
if token is None:
|
||||
return False
|
||||
return secrets.compare_digest(token, admin_token())
|
||||
|
||||
|
||||
def set_admin_cookie(response: Response) -> None:
|
||||
response.set_cookie(
|
||||
ADMIN_COOKIE,
|
||||
admin_token(),
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False,
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
)
|
||||
|
||||
|
||||
def clear_admin_cookie(response: Response) -> None:
|
||||
response.delete_cookie(ADMIN_COOKIE)
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
||||
def is_admin_request(request: Request) -> bool:
|
||||
if request.headers.get("x-user-is-admin") == "1":
|
||||
return True
|
||||
if not settings.admin_password:
|
||||
return False
|
||||
return verify_admin_token(request.cookies.get(ADMIN_COOKIE)) or verify_admin_password(
|
||||
request.headers.get("x-admin-password")
|
||||
)
|
||||
|
||||
|
||||
def require_admin_network(request: Request) -> None:
|
||||
"""Compatibility dependency for the local admin login page.
|
||||
|
||||
IP allowlists were removed: portal's X-User-Is-Admin header is the
|
||||
production boundary, and ADMIN_PASSWORD is only a local fallback.
|
||||
"""
|
||||
if is_admin_request(request) or admin_password_enabled():
|
||||
return
|
||||
raise HTTPException(status_code=404)
|
||||
return request.headers.get("x-user-is-admin") == "1"
|
||||
|
||||
|
||||
def portal_department_id(request: Request) -> str | None:
|
||||
|
||||
@@ -9,7 +9,6 @@ from fastapi import (
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -17,20 +16,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from parser_bot import llm as llm_client
|
||||
from parser_bot import prompt_store
|
||||
from parser_bot.access import (
|
||||
admin_password_enabled,
|
||||
can_manage_department,
|
||||
clear_admin_cookie,
|
||||
is_admin_request,
|
||||
portal_department_id,
|
||||
require_admin,
|
||||
require_admin_network,
|
||||
require_department_manager,
|
||||
set_admin_cookie,
|
||||
verify_admin_password,
|
||||
)
|
||||
|
||||
from parser_bot.api.schemas import (
|
||||
AdminLogin,
|
||||
AuthCode,
|
||||
AuthCodeResult,
|
||||
AuthPassword,
|
||||
@@ -177,27 +170,9 @@ async def access_me(request: Request) -> dict[str, Any]:
|
||||
"is_admin": admin,
|
||||
"can_manage_department": can_manage,
|
||||
"department_id": department_id,
|
||||
"admin_password_enabled": admin_password_enabled(),
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/access/admin-login",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_admin_network)],
|
||||
)
|
||||
async def admin_login(payload: AdminLogin, response: Response) -> None:
|
||||
if not verify_admin_password(payload.password):
|
||||
raise HTTPException(status_code=401, detail="invalid admin password")
|
||||
if admin_password_enabled():
|
||||
set_admin_cookie(response)
|
||||
|
||||
|
||||
@router.post("/access/admin-logout", status_code=204)
|
||||
async def admin_logout(response: Response) -> None:
|
||||
clear_admin_cookie(response)
|
||||
|
||||
|
||||
# --- Auth (admin-only) --------------------------------------------------
|
||||
# Telegram session controls are an admin surface.
|
||||
|
||||
@@ -487,7 +462,10 @@ async def add_channel(
|
||||
section = await _get_section(session, payload.vertical, payload.section, department_id)
|
||||
|
||||
if not await tg.is_authorized():
|
||||
raise HTTPException(status_code=401, detail="not authorized: log in at /auth.html")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="not authorized: open Monitoring TG in Portal and authorize Telegram",
|
||||
)
|
||||
try:
|
||||
resolved = await tg.resolve_channel(payload.identifier)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -217,7 +217,3 @@ class AuthPassword(BaseModel):
|
||||
|
||||
class AuthCodeResult(BaseModel):
|
||||
needs_password: bool
|
||||
|
||||
|
||||
class AdminLogin(BaseModel):
|
||||
password: str = Field(..., min_length=1)
|
||||
|
||||
@@ -40,10 +40,6 @@ class Settings(BaseSettings):
|
||||
llm_classify_interval_seconds: int = Field(20, alias="LLM_CLASSIFY_INTERVAL_SECONDS")
|
||||
llm_classify_batch_size: int = Field(5, alias="LLM_CLASSIFY_BATCH_SIZE")
|
||||
|
||||
# Optional local fallback for admin-only UI/API operations. In production
|
||||
# portal sets X-User-Is-Admin=1 and no local password is required.
|
||||
admin_password: str = Field("", alias="ADMIN_PASSWORD")
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return (
|
||||
|
||||
@@ -3,14 +3,13 @@ from pathlib import Path
|
||||
|
||||
import structlog
|
||||
import uvicorn
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.types import Scope
|
||||
|
||||
from parser_bot.access import require_admin, require_admin_network
|
||||
from parser_bot.access import require_admin
|
||||
from parser_bot.api.routes import router
|
||||
from parser_bot.config import settings
|
||||
from parser_bot.scheduler.poller import build_scheduler
|
||||
@@ -25,22 +24,6 @@ structlog.configure(
|
||||
)
|
||||
log = structlog.get_logger()
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "web" / "static"
|
||||
NOCACHE = {"Cache-Control": "no-cache, must-revalidate"}
|
||||
|
||||
|
||||
class NoCacheStaticFiles(StaticFiles):
|
||||
"""StaticFiles with Cache-Control: no-cache.
|
||||
|
||||
The browser still gets to validate via ETag/Last-Modified (304 is fine),
|
||||
but it will not silently serve a stale JS bundle after a deploy.
|
||||
"""
|
||||
|
||||
async def get_response(self, path: str, scope: Scope):
|
||||
response = await super().get_response(path, scope)
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -52,7 +35,7 @@ async def lifespan(app: FastAPI):
|
||||
"startup", poll_interval=settings.poll_interval_seconds, authorized=authorized
|
||||
)
|
||||
if not authorized:
|
||||
log.warning("not_authorized", action="open /auth.html to log in")
|
||||
log.warning("not_authorized", action="open monitoring-tg in portal")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -61,22 +44,6 @@ async def lifespan(app: FastAPI):
|
||||
log.info("shutdown")
|
||||
|
||||
|
||||
def _serve_section_template(vertical_dir: str, page: str) -> FileResponse:
|
||||
"""Resolve a section-scoped URL to a single shared template.
|
||||
|
||||
Sections are dynamic (created via UI), so `/real-estate/dubai/channels.html`
|
||||
can't be a real file. We serve `web/static/<vertical_dir>/section/<page>`
|
||||
for any section slug — the section name is read from the URL by JS.
|
||||
"""
|
||||
target_name = page if page else "index.html"
|
||||
if "/" in target_name or target_name.startswith(".."):
|
||||
raise HTTPException(404)
|
||||
target = STATIC_DIR / vertical_dir / "section" / target_name
|
||||
if not target.is_file():
|
||||
raise HTTPException(404)
|
||||
return FileResponse(target, headers=NOCACHE)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
public_base = settings.public_base_path.rstrip("/")
|
||||
# Disable the default /docs, /redoc and /openapi.json — we serve our own
|
||||
@@ -95,26 +62,8 @@ def create_app() -> FastAPI:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "index.html", headers=NOCACHE)
|
||||
|
||||
# Admin-only: Telegram login page. Registered BEFORE the static catch-all
|
||||
# so the static mount can't accidentally serve it to non-admin visitors.
|
||||
@app.get(
|
||||
"/admin.html",
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(require_admin_network)],
|
||||
)
|
||||
async def admin_page() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "admin.html", headers=NOCACHE)
|
||||
|
||||
@app.get(
|
||||
"/auth.html",
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def auth_page() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "auth.html", headers=NOCACHE)
|
||||
async def index() -> dict[str, str]:
|
||||
return {"service": "monitoring-tg", "ui": "portal"}
|
||||
|
||||
# Admin-only: OpenAPI surface. Custom routes so we can wrap them in
|
||||
# `require_admin`; the auto-generated ones from FastAPI bypass it.
|
||||
@@ -139,7 +88,7 @@ def create_app() -> FastAPI:
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def docs() -> FileResponse:
|
||||
async def docs():
|
||||
return get_swagger_ui_html(
|
||||
openapi_url=f"{public_base}/openapi.json" if public_base else "/openapi.json",
|
||||
title=app.title + " — docs",
|
||||
@@ -150,42 +99,16 @@ def create_app() -> FastAPI:
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def redoc() -> FileResponse:
|
||||
async def redoc():
|
||||
return get_redoc_html(
|
||||
openapi_url=f"{public_base}/openapi.json" if public_base else "/openapi.json",
|
||||
title=app.title + " — redoc",
|
||||
)
|
||||
|
||||
# IMPORTANT: register /static and /media mounts BEFORE the dynamic
|
||||
# vertical/section routes. Starlette matches routes in registration order,
|
||||
# and a generic /{v}/{s}/{page} pattern would otherwise eat /static/*.
|
||||
app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
|
||||
media_dir = Path(settings.media_dir)
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
# /media is fine to cache — file names are content-stable.
|
||||
app.mount("/media", StaticFiles(directory=media_dir), name="media")
|
||||
|
||||
# Section-templated dynamic routes, explicit per vertical so /static/*,
|
||||
# /api/*, /media/* (and any future top-level path) can't be captured.
|
||||
@app.get("/real-estate/{section}/", include_in_schema=False)
|
||||
async def re_section_root(section: str) -> FileResponse:
|
||||
return _serve_section_template("real-estate", "index.html")
|
||||
|
||||
@app.get("/real-estate/{section}/{page}", include_in_schema=False)
|
||||
async def re_section_page(section: str, page: str) -> FileResponse:
|
||||
return _serve_section_template("real-estate", page)
|
||||
|
||||
@app.get("/hr/{section}/", include_in_schema=False)
|
||||
async def hr_section_root(section: str) -> FileResponse:
|
||||
return _serve_section_template("hr", "index.html")
|
||||
|
||||
@app.get("/hr/{section}/{page}", include_in_schema=False)
|
||||
async def hr_section_page(section: str, page: str) -> FileResponse:
|
||||
return _serve_section_template("hr", page)
|
||||
|
||||
# Catch-all for top-level static pages (chooser, css, etc.). auth.html is
|
||||
# already handled above, so the static catch-all can't bypass the guard.
|
||||
app.mount("/", NoCacheStaticFiles(directory=STATIC_DIR, html=True), name="pages")
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -180,7 +180,9 @@ async def stop_client() -> None:
|
||||
async def require_authorized() -> TelegramClient:
|
||||
client = await start_client()
|
||||
if not await client.is_user_authorized():
|
||||
raise RuntimeError("not authorized: complete login at /auth.html")
|
||||
raise RuntimeError(
|
||||
"not authorized: open Monitoring TG in Portal and authorize Telegram"
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Админ — parser-tg-bot</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>parser-tg-bot</h1>
|
||||
<nav>
|
||||
<a href="/api/monitoring-tg/">Разделы</a>
|
||||
<a class="admin-login-link active" href="/api/monitoring-tg/admin.html">Админ</a>
|
||||
<a class="admin-link" href="/api/monitoring-tg/auth.html">Авторизация</a>
|
||||
<a class="admin-link" href="/api/monitoring-tg/docs" target="_blank">API</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Админ-доступ</h2>
|
||||
|
||||
<div class="card" style="max-width:520px">
|
||||
<div id="admin-status" class="muted" style="margin-bottom:12px">Проверка...</div>
|
||||
<form id="admin-form" class="row">
|
||||
<input type="password" id="admin-password" autocomplete="current-password"
|
||||
placeholder="Админ пароль" required style="flex:1; min-width:220px" />
|
||||
<button type="submit">Войти</button>
|
||||
</form>
|
||||
<div class="row" style="margin-top:12px">
|
||||
<button id="admin-logout" class="secondary" type="button">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,85 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Авторизация — parser-tg-bot</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>parser-tg-bot</h1>
|
||||
<nav>
|
||||
<a href="/api/monitoring-tg/">Разделы</a>
|
||||
<a href="/api/monitoring-tg/real-estate/">🏠 Недвижимость</a>
|
||||
<a href="/api/monitoring-tg/hr/">👥 HR</a>
|
||||
<a class="admin-login-link" href="/api/monitoring-tg/admin.html">Админ</a>
|
||||
<a class="admin-link active" href="/api/monitoring-tg/auth.html">Авторизация</a>
|
||||
<a class="admin-link" href="/api/monitoring-tg/docs" target="_blank">API</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Авторизация Telegram</h2>
|
||||
|
||||
<div class="card" style="max-width:520px">
|
||||
<div id="status-block">
|
||||
<div class="empty">Проверка статуса...</div>
|
||||
</div>
|
||||
|
||||
<div id="step-idle" hidden>
|
||||
<p>
|
||||
Не авторизовано. Номер из конфигурации: <span class="mono" id="phone"></span>.
|
||||
Нажми кнопку ниже — Telegram пришлёт одноразовый код на этот номер.
|
||||
</p>
|
||||
<button id="btn-send">Отправить код</button>
|
||||
</div>
|
||||
|
||||
<div id="step-code" hidden>
|
||||
<p>Код отправлен на <span class="mono" id="phone-2"></span>. Введи его:</p>
|
||||
<form id="form-code" class="row">
|
||||
<input type="text" id="code" inputmode="numeric" autocomplete="one-time-code"
|
||||
placeholder="12345" required style="flex:1; min-width:160px" />
|
||||
<button type="submit">Подтвердить</button>
|
||||
</form>
|
||||
<button id="btn-resend" class="secondary" style="margin-top:8px">
|
||||
Запросить код повторно
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="step-password" hidden>
|
||||
<p>На аккаунте включён 2FA. Введи облачный пароль Telegram:</p>
|
||||
<form id="form-password" class="row">
|
||||
<input type="password" id="password" autocomplete="current-password"
|
||||
required style="flex:1; min-width:200px" />
|
||||
<button type="submit">Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="step-done" hidden>
|
||||
<p>
|
||||
Авторизовано как <span class="mono" id="username"></span>.
|
||||
Парсер начнёт опрашивать каналы согласно расписанию.
|
||||
</p>
|
||||
<div class="row">
|
||||
<a id="return-link" href="/api/monitoring-tg/"><button>Перейти к разделам</button></a>
|
||||
<button id="btn-logout" class="danger">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width:520px; margin-top:16px">
|
||||
<h3 style="margin-top:0">Прод-вариант (без UI)</h3>
|
||||
<p class="muted">
|
||||
Для деплоя в k8s удобнее заранее получить опаковую строку сессии и положить её
|
||||
в Secret — тогда поды поднимаются без интерактива:
|
||||
</p>
|
||||
<pre>python -m parser_bot.auth</pre>
|
||||
<p class="muted">
|
||||
Скрипт напечатает <span class="mono">TG_SESSION_STRING=...</span> — вставить
|
||||
в <span class="mono">.env</span> или Secret и забыть про авторизацию.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/auth.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,241 +0,0 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #161a22;
|
||||
--panel-2: #1d222c;
|
||||
--border: #262c38;
|
||||
--text: #e6e8ec;
|
||||
--muted: #8a93a3;
|
||||
--accent: #4f8cff;
|
||||
--accent-hover: #6aa0ff;
|
||||
--danger: #ff6464;
|
||||
--ok: #2ecc71;
|
||||
--warn: #f1c40f;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); }
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 14px 24px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
header h1 {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
nav { display: flex; gap: 6px; }
|
||||
nav a {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
nav a.active, nav a:hover {
|
||||
color: var(--text);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
main { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||
h2 { font-size: 18px; margin: 0 0 16px; }
|
||||
h3 { font-size: 14px; margin: 24px 0 12px; color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.stat .value { font-size: 24px; font-weight: 600; margin-top: 4px; }
|
||||
|
||||
input, select, textarea, button {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
input:focus, select:focus { border-color: var(--accent); }
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
button:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
button.secondary { background: var(--panel-2); color: var(--text); }
|
||||
button.secondary:hover { background: var(--border); }
|
||||
button.danger { background: transparent; color: var(--danger); border-color: var(--border); }
|
||||
button.danger:hover { background: rgba(255, 100, 100, 0.1); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
tr:hover td { background: var(--panel-2); }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
background: var(--panel-2);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.badge.ok { color: var(--ok); border-color: rgba(46, 204, 113, 0.4); }
|
||||
.badge.off { color: var(--muted); }
|
||||
.badge.warn { color: var(--warn); border-color: rgba(241, 196, 15, 0.4); }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
|
||||
.message {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.message:last-child { border-bottom: none; }
|
||||
.message-meta { display: flex; gap: 12px; color: var(--muted); font-size: 12px; margin-bottom: 6px; }
|
||||
.message-text { white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.message-tags {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.message-tags .badge.re { color: #2ecc71; border-color: rgba(46, 204, 113, 0.4); }
|
||||
.message-tags .badge.phone { color: #4f8cff; border-color: rgba(79, 140, 255, 0.4); }
|
||||
.message-tags .badge.name { color: #f1c40f; border-color: rgba(241, 196, 15, 0.4); }
|
||||
.message-tags .badge.tg { color: #4f8cff; border-color: rgba(79, 140, 255, 0.4); }
|
||||
.message-tags .badge.tg-link { color: #fff; background: rgba(79, 140, 255, 0.2); border-color: rgba(79, 140, 255, 0.6); }
|
||||
.message-tags .badge.tg-link:hover { background: rgba(79, 140, 255, 0.35); }
|
||||
|
||||
.lead-card {
|
||||
margin-top: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(46, 204, 113, 0.05);
|
||||
}
|
||||
.lead-card.lead-strong { border-color: rgba(46, 204, 113, 0.6); background: rgba(46, 204, 113, 0.1); }
|
||||
.lead-card.lead-medium { border-color: rgba(241, 196, 15, 0.5); background: rgba(241, 196, 15, 0.06); }
|
||||
.lead-card.lead-weak { border-color: rgba(138, 147, 163, 0.4); background: rgba(138, 147, 163, 0.05); }
|
||||
.lead-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.lead-facts { color: var(--text); font-weight: 500; }
|
||||
.lead-summary { margin-top: 4px; color: var(--muted); font-size: 13px; }
|
||||
.lead-confidence {
|
||||
margin-left: auto; padding: 2px 8px; border-radius: 999px;
|
||||
background: var(--panel-2); border: 1px solid var(--border);
|
||||
font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.badge.lead { color: #2ecc71; border-color: rgba(46, 204, 113, 0.5); font-weight: 600; }
|
||||
|
||||
.message-media {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.media-thumb {
|
||||
max-width: 240px; max-height: 240px;
|
||||
border-radius: 6px; cursor: zoom-in;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.media-video { max-width: 360px; max-height: 240px; border-radius: 6px; background: black; }
|
||||
.media-doc {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; background: var(--panel-2);
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
.media-doc:hover { border-color: var(--accent); }
|
||||
.media-skipped {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 6px 10px; background: var(--panel-2);
|
||||
border-radius: 6px; font-size: 12px;
|
||||
}
|
||||
|
||||
#lightbox {
|
||||
position: fixed; inset: 0; z-index: 2000;
|
||||
background: rgba(0,0,0,0.85);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
#lightbox img { max-width: 95vw; max-height: 95vh; border-radius: 4px; }
|
||||
|
||||
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.toolbar input[type="search"], .toolbar select { min-width: 200px; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.4);
|
||||
animation: slideIn 0.18s ease-out;
|
||||
z-index: 1000;
|
||||
max-width: 360px;
|
||||
}
|
||||
.toast.error { border-color: var(--danger); }
|
||||
.toast.success { border-color: var(--ok); }
|
||||
@keyframes slideIn { from { transform: translateY(8px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
|
||||
.empty { padding: 32px; text-align: center; color: var(--muted); }
|
||||
|
||||
.sections-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.section-tile { padding: 16px; }
|
||||
.section-tile-link { display: block; color: var(--text); }
|
||||
.section-tile-link:hover { color: var(--text); }
|
||||
.section-tile-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.section-emoji { font-size: 28px; }
|
||||
.section-title { font-size: 16px; font-weight: 600; }
|
||||
.section-stats { display: flex; flex-wrap: wrap; gap: 12px; color: var(--muted); font-size: 13px; }
|
||||
.section-stats b { color: var(--text); }
|
||||
.section-desc { margin-top: 8px; font-size: 13px; }
|
||||
.section-code { margin-top: 8px; color: var(--warn); font-size: 12px; }
|
||||
.section-slug { margin-top: 8px; font-size: 11px; }
|
||||
.pagination { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
|
||||
|
||||
dialog {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
min-width: 400px;
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
}
|
||||
dialog::backdrop { background: rgba(0,0,0,0.6); }
|
||||
pre { background: var(--bg); padding: 12px; border-radius: 6px; overflow: auto; font-size: 12px; max-height: 60vh; }
|
||||
@@ -1,89 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>👥 HR — подразделы</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="page-title">parser-tg-bot · 👥 HR / Кадры</h1>
|
||||
<nav id="nav-section"></nav>
|
||||
</header>
|
||||
<main>
|
||||
<div class="row">
|
||||
<h2>Подразделы HR</h2>
|
||||
<div class="spacer"></div>
|
||||
<button id="open-create">+ Новый подраздел</button>
|
||||
</div>
|
||||
<p class="muted">
|
||||
Каждый подраздел — это собственный набор каналов, своя статистика и свой
|
||||
LLM-промпт (с фоллбэком на промпт вертикали). Например: IT, продажи,
|
||||
маркетинг, рабочие специальности.
|
||||
</p>
|
||||
|
||||
<div id="sections-grid"></div>
|
||||
</main>
|
||||
|
||||
<dialog id="create-dialog">
|
||||
<h3 style="margin-top:0">Новый подраздел</h3>
|
||||
<form id="create-form">
|
||||
<label class="row" style="gap:8px; margin-bottom:8px">
|
||||
<span style="min-width:120px" class="muted">Название</span>
|
||||
<input type="text" id="new-title" required placeholder="IT" style="flex:1" />
|
||||
</label>
|
||||
<div class="row" style="gap:8px; margin-bottom:8px; font-size:12px">
|
||||
<span style="min-width:120px" class="muted">URL-адрес</span>
|
||||
<span class="muted mono">/hr/<span id="new-slug-preview">(введите название)</span>/</span>
|
||||
<div class="spacer"></div>
|
||||
<a href="#" id="new-slug-manual" class="muted">изменить вручную</a>
|
||||
</div>
|
||||
<label class="row slug-row" style="gap:8px; margin-bottom:8px" hidden>
|
||||
<span style="min-width:120px" class="muted">Slug</span>
|
||||
<input type="text" id="new-slug" pattern="[a-z0-9][a-z0-9_-]*[a-z0-9]?"
|
||||
placeholder="it" style="flex:1" />
|
||||
</label>
|
||||
<label class="row" style="gap:8px; margin-bottom:8px">
|
||||
<span style="min-width:120px" class="muted">Иконка</span>
|
||||
<input type="text" id="new-emoji" maxlength="4" placeholder="💻" style="width:80px" />
|
||||
</label>
|
||||
<label class="row" style="gap:8px; margin-bottom:8px; align-items:flex-start">
|
||||
<span style="min-width:120px" class="muted">Описание</span>
|
||||
<textarea id="new-description" rows="3" style="flex:1"></textarea>
|
||||
</label>
|
||||
<div class="row" style="justify-content:flex-end; gap:8px; margin-top:12px">
|
||||
<button type="button" id="create-cancel" class="secondary">Отмена</button>
|
||||
<button type="submit">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="edit-dialog">
|
||||
<h3 style="margin-top:0">Редактировать подраздел</h3>
|
||||
<form id="edit-form">
|
||||
<input type="hidden" id="edit-slug" />
|
||||
<label class="row" style="gap:8px; margin-bottom:8px">
|
||||
<span style="min-width:120px" class="muted">Название</span>
|
||||
<input type="text" id="edit-title" required style="flex:1" />
|
||||
</label>
|
||||
<label class="row" style="gap:8px; margin-bottom:8px">
|
||||
<span style="min-width:120px" class="muted">Иконка</span>
|
||||
<input type="text" id="edit-emoji" maxlength="4" style="width:80px" />
|
||||
</label>
|
||||
<label class="row" style="gap:8px; margin-bottom:8px; align-items:flex-start">
|
||||
<span style="min-width:120px" class="muted">Описание</span>
|
||||
<textarea id="edit-description" rows="3" style="flex:1"></textarea>
|
||||
</label>
|
||||
<div class="row" style="justify-content:flex-end; gap:8px; margin-top:12px">
|
||||
<button type="button" id="edit-cancel" class="secondary">Отмена</button>
|
||||
<button type="submit">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/sections-list.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,48 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>👥 HR · Каналы — parser-tg-bot</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="page-title">parser-tg-bot</h1>
|
||||
<nav id="nav-section"></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2 id="page-heading">Каналы подраздела</h2>
|
||||
|
||||
<div class="card" style="margin-bottom:24px">
|
||||
<form id="add-form" class="row">
|
||||
<input type="text" id="identifier" placeholder="@channel или https://t.me/..." required style="flex:1; min-width:280px" />
|
||||
<button type="submit">Добавить канал</button>
|
||||
</form>
|
||||
<div class="muted" style="margin-top:8px; font-size:12px">
|
||||
Канал будет привязан к текущему подразделу.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Канал</th>
|
||||
<th>Telegram ID</th>
|
||||
<th>Сообщ.</th>
|
||||
<th>Последний опрос</th>
|
||||
<th>Статус</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/channels.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,43 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>👥 HR · Дашборд — parser-tg-bot</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="page-title">parser-tg-bot</h1>
|
||||
<nav id="nav-section"></nav>
|
||||
</header>
|
||||
<main>
|
||||
<div class="row">
|
||||
<h2 id="page-heading">Дашборд</h2>
|
||||
<div class="spacer"></div>
|
||||
<button id="poll-all">Опросить все каналы подраздела</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" id="stats"></div>
|
||||
|
||||
<h3>Каналы подраздела</h3>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Канал</th>
|
||||
<th>Сообщений</th>
|
||||
<th>Последнее сообщение</th>
|
||||
<th>Последний опрос</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="channels-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,78 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>👥 HR · Сообщения — parser-tg-bot</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="page-title">parser-tg-bot</h1>
|
||||
<nav id="nav-section"></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2 id="page-heading">Сообщения подраздела</h2>
|
||||
|
||||
<div class="toolbar card">
|
||||
<select id="channel-filter">
|
||||
<option value="">Все каналы подраздела</option>
|
||||
</select>
|
||||
<input type="search" id="search" placeholder="Поиск по тексту..." />
|
||||
<select id="hr-kind">
|
||||
<option value="">Любой тип лида</option>
|
||||
<option value="any">👥 HR (любой)</option>
|
||||
<option value="vacancy">📢 Вакансия (наниматель)</option>
|
||||
<option value="resume">📄 Резюме (соискатель)</option>
|
||||
<option value="contact">📇 Лид-контакт</option>
|
||||
</select>
|
||||
<label class="row" style="gap:6px">
|
||||
<input type="checkbox" id="leads-only" />
|
||||
<span class="muted">🎯 Только лиды (ИИ)</span>
|
||||
</label>
|
||||
<select id="min-confidence" title="Минимальная уверенность ИИ">
|
||||
<option value="0.3">0.3+</option>
|
||||
<option value="0.5" selected>0.5+</option>
|
||||
<option value="0.7">0.7+</option>
|
||||
<option value="0.9">0.9+</option>
|
||||
</select>
|
||||
<label class="row" style="gap:6px">
|
||||
<input type="checkbox" id="has-phone" />
|
||||
<span class="muted">📞 С телефоном</span>
|
||||
</label>
|
||||
<select id="limit">
|
||||
<option value="25">25</option>
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
<div class="spacer"></div>
|
||||
<label class="row" style="gap:6px">
|
||||
<input type="checkbox" id="autorefresh" />
|
||||
<span class="muted">Автообновление</span>
|
||||
</label>
|
||||
<button id="refresh" class="secondary">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="card" id="list"></div>
|
||||
|
||||
<div class="pagination">
|
||||
<button id="prev" class="secondary">← Назад</button>
|
||||
<span class="muted" id="page-info" style="align-self:center"></span>
|
||||
<button id="next" class="secondary">Вперёд →</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<dialog id="raw-dialog">
|
||||
<h3 style="margin-top:0">Сообщение</h3>
|
||||
<pre id="raw-content"></pre>
|
||||
<div class="row" style="justify-content:flex-end; margin-top:12px">
|
||||
<button class="secondary" id="raw-close">Закрыть</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/messages.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,65 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>👥 HR · Настройки — parser-tg-bot</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="page-title">parser-tg-bot</h1>
|
||||
<nav id="nav-section"></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2 id="page-heading">Настройки подраздела</h2>
|
||||
|
||||
<div class="card" style="margin-bottom:24px">
|
||||
<h3 style="margin-top:0">Текущая конфигурация</h3>
|
||||
<table>
|
||||
<tbody id="config-tbody">
|
||||
<tr><td colspan="2" class="empty">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="muted" style="font-size:12px; margin-top:12px">
|
||||
Параметры задаются через переменные окружения и k8s-манифесты.
|
||||
Для изменения обновите ConfigMap/Secret и перезапустите deployment.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:24px">
|
||||
<h3 style="margin-top:0">Действия</h3>
|
||||
<div class="row">
|
||||
<button id="poll-all">Опросить все каналы подраздела сейчас</button>
|
||||
<a href="/api/monitoring-tg/docs" target="_blank" class="badge">OpenAPI / Swagger</a>
|
||||
<a href="/api/monitoring-tg/healthz" target="_blank" class="badge">Health check</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:24px">
|
||||
<h3 style="margin-top:0">🤖 Промпт ИИ</h3>
|
||||
<div class="row" style="margin-bottom:8px">
|
||||
<span class="badge" id="prompt-status">—</span>
|
||||
<span class="muted" id="prompt-length"></span>
|
||||
<div class="spacer"></div>
|
||||
<select id="prompt-level" title="Уровень редактирования промпта">
|
||||
<option value="section" selected>Промпт подраздела</option>
|
||||
<option value="vertical">Промпт вертикали</option>
|
||||
</select>
|
||||
<button id="prompt-reset" class="secondary">Сбросить уровень</button>
|
||||
<button id="prompt-save">Сохранить</button>
|
||||
</div>
|
||||
<textarea id="prompt-editor" rows="22"
|
||||
style="width:100%; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:12px"></textarea>
|
||||
<div class="muted" style="font-size:12px; margin-top:8px">
|
||||
Каскад: <strong>section → vertical → default</strong>. Если промпта на
|
||||
уровне подраздела нет, используется промпт вертикали; если и его нет —
|
||||
встроенный по умолчанию. Сохранение применится в течение ~5 сек.
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,76 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>parser-tg-bot — выбор раздела</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
<style>
|
||||
.chooser {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
max-width: 880px;
|
||||
margin: 32px auto 0;
|
||||
}
|
||||
.chooser .tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 28px 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
transition: transform 0.08s, border-color 0.1s;
|
||||
}
|
||||
.chooser .tile:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.chooser .tile .emoji { font-size: 40px; }
|
||||
.chooser .tile .title { font-size: 18px; font-weight: 600; }
|
||||
.chooser .tile .hint { color: var(--muted); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>parser-tg-bot</h1>
|
||||
<nav>
|
||||
<a href="/api/monitoring-tg/" class="active">Разделы</a>
|
||||
<a class="admin-login-link" href="/api/monitoring-tg/admin.html">Админ</a>
|
||||
<a class="admin-link" href="/api/monitoring-tg/auth.html">Авторизация</a>
|
||||
<a class="admin-link" href="/api/monitoring-tg/docs" target="_blank">API</a>
|
||||
</nav>
|
||||
</header>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/access.js"></script>
|
||||
<main>
|
||||
<h2>Выберите вертикаль</h2>
|
||||
<p class="muted">
|
||||
У каждой вертикали — свои подразделы (например, «Дубай», «Москва»
|
||||
внутри Недвижимости, или «IT», «Продажи» внутри HR). Канал привязан
|
||||
к одному подразделу одной вертикали.
|
||||
</p>
|
||||
|
||||
<div class="chooser">
|
||||
<a class="tile" href="/api/monitoring-tg/real-estate/">
|
||||
<div class="emoji">🏠</div>
|
||||
<div class="title">Недвижимость</div>
|
||||
<div class="hint">
|
||||
Объявления о покупке, продаже и аренде квартир, домов, апартаментов,
|
||||
земли, коммерции. RU / EN / арабский — любой язык.
|
||||
</div>
|
||||
</a>
|
||||
<a class="tile" href="/api/monitoring-tg/hr/">
|
||||
<div class="emoji">👥</div>
|
||||
<div class="title">HR / Кадры</div>
|
||||
<div class="hint">
|
||||
Вакансии (наниматели), резюме (соискатели) и короткие лиды-контакты
|
||||
с указанием профессии и контактов.
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,54 +0,0 @@
|
||||
// Ask the backend which portal rights are available. `isAdmin()` is kept as
|
||||
// a legacy name for "can manage this department" because the UI already uses
|
||||
// it to show edit buttons.
|
||||
|
||||
let _statusPromise = null;
|
||||
|
||||
export function adminStatus() {
|
||||
if (!_statusPromise) {
|
||||
_statusPromise = fetch("/api/monitoring-tg/api/v1/access/me")
|
||||
.then(r => r.ok ? r.json() : {
|
||||
is_admin: false,
|
||||
can_manage_department: false,
|
||||
admin_password_enabled: false,
|
||||
})
|
||||
.catch(() => ({
|
||||
is_admin: false,
|
||||
can_manage_department: false,
|
||||
admin_password_enabled: false,
|
||||
}));
|
||||
}
|
||||
return _statusPromise;
|
||||
}
|
||||
|
||||
export function isAdmin() {
|
||||
return adminStatus().then(d => !!d.can_manage_department);
|
||||
}
|
||||
|
||||
export function isPortalAdmin() {
|
||||
return adminStatus().then(d => !!d.is_admin);
|
||||
}
|
||||
|
||||
adminStatus().then(status => {
|
||||
const admin = !!status.is_admin;
|
||||
const manager = !!status.can_manage_department;
|
||||
const canOpenAdminLogin = !!status.admin_password_enabled;
|
||||
// Remove any `.admin-link` from the DOM. Works for both server-rendered
|
||||
// navs (auth.html, chooser pages) and JS-built navs (nav.js fires before
|
||||
// its own write, but DOMContentLoaded ordering means the elements appear
|
||||
// after — handle via a MutationObserver for late insertions).
|
||||
const hide = () => {
|
||||
if (!admin) {
|
||||
document.querySelectorAll(".admin-link").forEach(el => el.remove());
|
||||
}
|
||||
if (!manager) {
|
||||
document.querySelectorAll(".admin-only").forEach(el => el.remove());
|
||||
}
|
||||
if (admin || !canOpenAdminLogin) {
|
||||
document.querySelectorAll(".admin-login-link").forEach(el => el.remove());
|
||||
}
|
||||
};
|
||||
hide();
|
||||
const mo = new MutationObserver(hide);
|
||||
mo.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { api, toast } from "/api/monitoring-tg/static/js/api.js";
|
||||
import "/api/monitoring-tg/static/js/access.js";
|
||||
|
||||
const form = document.getElementById("admin-form");
|
||||
const password = document.getElementById("admin-password");
|
||||
const statusEl = document.getElementById("admin-status");
|
||||
const logoutBtn = document.getElementById("admin-logout");
|
||||
|
||||
function returnUrl() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
return params.get("return") || "/";
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const status = await api.accessMe();
|
||||
if (status.is_admin) {
|
||||
statusEl.textContent = "Админ-доступ активен.";
|
||||
form.hidden = true;
|
||||
logoutBtn.hidden = false;
|
||||
} else if (!status.admin_password_enabled) {
|
||||
statusEl.textContent = "Админ пароль не задан. Доступ управляется порталом.";
|
||||
form.hidden = true;
|
||||
logoutBtn.hidden = true;
|
||||
} else {
|
||||
statusEl.textContent = "Введите админ пароль, чтобы открыть админские функции.";
|
||||
form.hidden = false;
|
||||
logoutBtn.hidden = true;
|
||||
setTimeout(() => password.focus(), 30);
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.adminLogin(password.value);
|
||||
password.value = "";
|
||||
toast("Админ-доступ открыт", "success");
|
||||
location.href = returnUrl();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
await api.adminLogout();
|
||||
location.reload();
|
||||
});
|
||||
|
||||
refresh().catch(err => toast(err.message, "error"));
|
||||
@@ -1,156 +0,0 @@
|
||||
import { getVertical, getSection } from "/api/monitoring-tg/static/js/vertical.js";
|
||||
|
||||
const BASE = "/api/monitoring-tg/api/v1";
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const fetchOptions = options;
|
||||
const res = await fetch(BASE + path, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
try { detail = (await res.json()).detail || detail; } catch {}
|
||||
throw new Error(`${res.status}: ${detail}`);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Build a query string scoped to the current (vertical, section). The
|
||||
// section is intentionally optional — pages at /<vertical>/ (chooser)
|
||||
// pass null so they see all sections, while pages inside a section
|
||||
// always carry their section slug.
|
||||
function qs(extra = {}, { vertical, section } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("vertical", vertical ?? getVertical());
|
||||
const s = section === undefined ? getSection() : section;
|
||||
if (s) params.set("section", s);
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
if (v == null || v === false) continue;
|
||||
params.set(k, String(v));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
accessMe: () => request("/access/me"),
|
||||
adminLogin: (password) =>
|
||||
request("/access/admin-login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
adminLogout: () => request("/access/admin-logout", { method: "POST" }),
|
||||
|
||||
// Auth — section-agnostic.
|
||||
authStatus: () => request("/auth/status"),
|
||||
authSendCode: () => request("/auth/send-code", { method: "POST" }),
|
||||
authSubmitCode: (code) =>
|
||||
request("/auth/submit-code", { method: "POST", body: JSON.stringify({ code }) }),
|
||||
authSubmitPassword: (password) =>
|
||||
request("/auth/submit-password", { method: "POST", body: JSON.stringify({ password }) }),
|
||||
authLogout: () => request("/auth/logout", { method: "POST" }),
|
||||
|
||||
// Sections (sub-sections within a vertical).
|
||||
listSections: (vertical) => request(`/sections?${qs({}, { vertical, section: null })}`),
|
||||
createSection: ({ vertical, slug, title, emoji, description }) =>
|
||||
request("/sections", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
vertical: vertical ?? getVertical(),
|
||||
slug, title, emoji, description,
|
||||
}),
|
||||
}),
|
||||
updateSection: (vertical, slug, patch) =>
|
||||
request(`/sections/${encodeURIComponent(vertical)}/${encodeURIComponent(slug)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
deleteSection: (vertical, slug) =>
|
||||
request(`/sections/${encodeURIComponent(vertical)}/${encodeURIComponent(slug)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
// Scoped reads: implicit (vertical, section) from URL.
|
||||
globalStats: (scope) => request(`/stats?${qs({}, scope)}`),
|
||||
|
||||
listChannels: (scope) => request(`/channels?${qs({}, scope)}`),
|
||||
getChannel: (id, scope) => request(`/channels/${id}?${qs({}, scope)}`),
|
||||
channelStats: (id, scope) => request(`/channels/${id}/stats?${qs({}, scope)}`),
|
||||
addChannel: (identifier, scope = {}) => {
|
||||
const vertical = scope.vertical ?? getVertical();
|
||||
const section = scope.section === undefined ? getSection() : scope.section;
|
||||
if (!section) {
|
||||
throw new Error("addChannel requires a section context");
|
||||
}
|
||||
return request("/channels", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identifier, vertical, section }),
|
||||
});
|
||||
},
|
||||
updateChannel: (id, patch, scope) =>
|
||||
request(`/channels/${id}?${qs({}, scope)}`, {
|
||||
method: "PATCH", body: JSON.stringify(patch),
|
||||
}),
|
||||
deleteChannel: (id, scope) =>
|
||||
request(`/channels/${id}?${qs({}, scope)}`, { method: "DELETE" }),
|
||||
pollChannel: (id, scope) =>
|
||||
request(`/channels/${id}/poll?${qs({}, scope)}`, { method: "POST" }),
|
||||
backfillMedia: (id, batch = 50, scope) =>
|
||||
request(`/channels/${id}/backfill-media?${qs({ batch }, scope)}`, { method: "POST" }),
|
||||
reanalyze: (id, batch = 500, scope) =>
|
||||
request(`/channels/${id}/reanalyze?${qs({ batch }, scope)}`, { method: "POST" }),
|
||||
|
||||
pollAll: (scope) => request(`/poll?${qs({}, scope)}`, { method: "POST" }),
|
||||
|
||||
listMessages: ({ channelId, q, realEstate, hrKind, hasPhone, leadsOnly,
|
||||
minConfidence, limit = 50, offset = 0,
|
||||
vertical, section } = {}) => {
|
||||
const extra = { limit, offset };
|
||||
if (channelId) extra.channel_id = channelId;
|
||||
if (q) extra.q = q;
|
||||
if (realEstate) extra.real_estate = realEstate;
|
||||
if (hrKind) extra.hr_kind = hrKind;
|
||||
if (hasPhone) extra.has_phone = "true";
|
||||
if (leadsOnly) {
|
||||
extra.leads_only = "true";
|
||||
if (minConfidence != null) extra.min_confidence = minConfidence;
|
||||
}
|
||||
return request(`/messages?${qs(extra, { vertical, section })}`);
|
||||
},
|
||||
getMessage: (id, scope) => request(`/messages/${id}?${qs({}, scope)}`),
|
||||
|
||||
llmStatus: () => request("/llm/status"),
|
||||
llmQueue: (scope) => request(`/llm/queue?${qs({}, scope)}`),
|
||||
llmPromptGet: (scope) => request(`/llm/prompt?${qs({}, scope)}`),
|
||||
llmPromptSave: (prompt, scope) =>
|
||||
request(`/llm/prompt?${qs({}, scope)}`, {
|
||||
method: "PUT", body: JSON.stringify({ prompt }),
|
||||
}),
|
||||
llmPromptReset: (scope) =>
|
||||
request(`/llm/prompt?${qs({}, scope)}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
export function toast(message, type = "info") {
|
||||
const el = document.createElement("div");
|
||||
el.className = `toast ${type}`;
|
||||
el.textContent = message;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), 3500);
|
||||
}
|
||||
|
||||
export function fmtDate(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export function fmtRelative(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
const diff = (Date.now() - d.getTime()) / 1000;
|
||||
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { api, toast } from "/api/monitoring-tg/static/js/api.js";
|
||||
|
||||
const returnTo = (() => {
|
||||
const raw = new URLSearchParams(location.search).get("return");
|
||||
// Only allow same-origin relative paths to avoid open-redirect via ?return=
|
||||
if (raw && raw.startsWith("/") && !raw.startsWith("//")) return raw;
|
||||
return null;
|
||||
})();
|
||||
const returnLink = document.getElementById("return-link");
|
||||
if (returnLink && returnTo) {
|
||||
returnLink.href = returnTo;
|
||||
returnLink.querySelector("button").textContent = "← Вернуться";
|
||||
}
|
||||
|
||||
const steps = ["idle", "code", "password", "done"];
|
||||
function show(step) {
|
||||
steps.forEach(s => {
|
||||
document.getElementById(`step-${s}`).hidden = s !== step;
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(html) {
|
||||
document.getElementById("status-block").innerHTML = html;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const status = await api.authStatus();
|
||||
document.getElementById("phone").textContent = status.phone || "—";
|
||||
document.getElementById("phone-2").textContent = status.phone || "—";
|
||||
|
||||
if (status.authorized) {
|
||||
setStatus(`<div class="badge ok">Авторизовано</div>`);
|
||||
document.getElementById("username").textContent = status.username || "(unnamed)";
|
||||
show("done");
|
||||
} else {
|
||||
setStatus(`<div class="badge warn">Не авторизовано</div>`);
|
||||
show("idle");
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("btn-send").addEventListener("click", async (e) => {
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
await api.authSendCode();
|
||||
toast("Код отправлен в Telegram", "success");
|
||||
show("code");
|
||||
document.getElementById("code").focus();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
} finally {
|
||||
e.target.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-resend").addEventListener("click", async (e) => {
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
await api.authSendCode();
|
||||
toast("Новый код отправлен", "success");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
} finally {
|
||||
e.target.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("form-code").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const code = document.getElementById("code").value.trim();
|
||||
const btn = e.target.querySelector("button");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await api.authSubmitCode(code);
|
||||
if (res.needs_password) {
|
||||
toast("Введи 2FA-пароль", "success");
|
||||
show("password");
|
||||
document.getElementById("password").focus();
|
||||
} else {
|
||||
toast("Готово", "success");
|
||||
await refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("form-password").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById("password").value;
|
||||
const btn = e.target.querySelector("button");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api.authSubmitPassword(password);
|
||||
toast("Авторизовано", "success");
|
||||
document.getElementById("password").value = "";
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", async (e) => {
|
||||
if (!confirm("Выйти из Telegram-сессии?")) return;
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
await api.authLogout();
|
||||
toast("Сессия завершена", "success");
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
} finally {
|
||||
e.target.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
refresh().catch(err => toast(err.message, "error"));
|
||||
@@ -1,132 +0,0 @@
|
||||
import { api, toast, fmtRelative } from "/api/monitoring-tg/static/js/api.js";
|
||||
import { isAdmin } from "/api/monitoring-tg/static/js/access.js";
|
||||
import { getVertical, getSection, sectionBase, VERTICAL_META } from "/api/monitoring-tg/static/js/vertical.js";
|
||||
|
||||
const V = getVertical();
|
||||
const section = getSection();
|
||||
const sBase = sectionBase();
|
||||
const meta = VERTICAL_META[V];
|
||||
|
||||
function escape(s) {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const admin = await isAdmin();
|
||||
const channels = await api.listChannels();
|
||||
const tbody = document.getElementById("tbody");
|
||||
if (!channels.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="empty">Каналов пока нет</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const stats = await Promise.all(channels.map(c => api.channelStats(c.id).catch(() => null)));
|
||||
tbody.innerHTML = channels.map((c, i) => {
|
||||
const s = stats[i] || {};
|
||||
return `
|
||||
<tr data-id="${c.id}">
|
||||
<td class="muted mono">${c.id}</td>
|
||||
<td>
|
||||
<div>${escape(c.title || "—")}</div>
|
||||
<div class="muted mono" style="font-size:12px">${escape(c.identifier)}</div>
|
||||
</td>
|
||||
<td class="mono muted">${c.tg_id ?? "—"}</td>
|
||||
<td>${(s.message_count ?? 0).toLocaleString()}</td>
|
||||
<td>${fmtRelative(c.last_polled_at)}</td>
|
||||
<td>
|
||||
<label class="row" style="gap:6px">
|
||||
<input type="checkbox" data-action="toggle" ${c.is_active ? "checked" : ""} ${admin ? "" : "disabled"} />
|
||||
<span class="badge ${c.is_active ? "ok" : "off"}">${c.is_active ? "on" : "off"}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row" style="gap:6px">
|
||||
<a href="${sBase}/messages.html?channel_id=${c.id}" class="badge">сообщения</a>
|
||||
${admin ? `
|
||||
<button class="secondary" data-action="poll">Опросить</button>
|
||||
<button class="secondary" data-action="backfill-media">Подкачать медиа</button>
|
||||
<button class="secondary" data-action="reanalyze">Переанализировать</button>
|
||||
<button class="danger" data-action="delete">Удалить</button>
|
||||
` : ""}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
document.getElementById("add-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const input = document.getElementById("identifier");
|
||||
const id = input.value.trim();
|
||||
if (!id) return;
|
||||
const btn = e.target.querySelector("button");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api.addChannel(id);
|
||||
const where = section ? `${meta.short} / ${section}` : meta.short;
|
||||
toast(`Канал добавлен в "${where}"`, "success");
|
||||
input.value = "";
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("tbody").addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr");
|
||||
const id = Number(tr.dataset.id);
|
||||
const action = btn.dataset.action;
|
||||
try {
|
||||
if (action === "delete") {
|
||||
if (!confirm("Удалить канал и все его сообщения?")) return;
|
||||
await api.deleteChannel(id);
|
||||
toast("Удалено", "success");
|
||||
await load();
|
||||
} else if (action === "poll") {
|
||||
btn.disabled = true;
|
||||
const res = await api.pollChannel(id);
|
||||
toast(`Добавлено ${res.inserted} сообщений`, "success");
|
||||
await load();
|
||||
} else if (action === "backfill-media") {
|
||||
btn.disabled = true;
|
||||
let totalUpdated = 0;
|
||||
let pending = Infinity;
|
||||
while (pending > 0) {
|
||||
btn.textContent = `Качаю... (готово: ${totalUpdated})`;
|
||||
const res = await api.backfillMedia(id, 50);
|
||||
totalUpdated += res.updated;
|
||||
pending = res.pending;
|
||||
if (res.updated === 0) break;
|
||||
}
|
||||
btn.textContent = "Подкачать медиа";
|
||||
toast(`Подкачано ${totalUpdated}, осталось ${pending}`, "success");
|
||||
} else if (action === "reanalyze") {
|
||||
btn.disabled = true;
|
||||
let total = 0;
|
||||
let pending = Infinity;
|
||||
while (pending > 0) {
|
||||
btn.textContent = `Анализирую... (${total})`;
|
||||
const res = await api.reanalyze(id, 500);
|
||||
total += res.updated;
|
||||
pending = res.pending;
|
||||
if (res.updated === 0) break;
|
||||
}
|
||||
btn.textContent = "Переанализировать";
|
||||
toast(`Проанализировано ${total} сообщений, осталось ${pending}`, "success");
|
||||
} else if (action === "toggle") {
|
||||
const isActive = btn.checked;
|
||||
await api.updateChannel(id, { is_active: isActive });
|
||||
toast(isActive ? "Канал включён" : "Канал выключен", "success");
|
||||
await load();
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
await load();
|
||||
}
|
||||
});
|
||||
|
||||
load().catch(err => toast(err.message, "error"));
|
||||
@@ -1,87 +0,0 @@
|
||||
import { api, toast, fmtRelative } from "/api/monitoring-tg/static/js/api.js";
|
||||
import { isAdmin } from "/api/monitoring-tg/static/js/access.js";
|
||||
import { getVertical, getSection, sectionBase, VERTICAL_META } from "/api/monitoring-tg/static/js/vertical.js";
|
||||
|
||||
const V = getVertical();
|
||||
const section = getSection();
|
||||
const sBase = sectionBase();
|
||||
const meta = VERTICAL_META[V];
|
||||
|
||||
function escape(s) {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const [stats, llm, queue] = await Promise.all([
|
||||
api.globalStats(),
|
||||
api.llmStatus().catch(() => ({ enabled: false, ready: false, model: "—" })),
|
||||
api.llmQueue().catch(() => ({ pending: null })),
|
||||
]);
|
||||
const grid = document.getElementById("stats");
|
||||
const llmBadge = llm.enabled
|
||||
? (llm.ready ? `<span class="badge ok">ready</span>` : `<span class="badge warn">загружается</span>`)
|
||||
: `<span class="badge off">off</span>`;
|
||||
const queueValue = queue.pending == null ? "—" : queue.pending.toLocaleString();
|
||||
grid.innerHTML = `
|
||||
<div class="card stat"><div class="label">Каналы</div><div class="value">${stats.channels_active} / ${stats.channels_total}</div></div>
|
||||
<div class="card stat"><div class="label">Сообщений всего</div><div class="value">${stats.messages_total.toLocaleString()}</div></div>
|
||||
<div class="card stat"><div class="label">Сообщений за 24ч</div><div class="value">${stats.messages_last_24h.toLocaleString()}</div></div>
|
||||
<div class="card stat"><div class="label">🎯 Лидов всего</div><div class="value">${(stats.leads_total ?? 0).toLocaleString()}</div></div>
|
||||
<div class="card stat"><div class="label">🎯 Лидов за 24ч</div><div class="value"><a href="${sBase}/messages.html?leads_only=true">${(stats.leads_last_24h ?? 0).toLocaleString()}</a></div></div>
|
||||
<div class="card stat"><div class="label">⏳ В очереди ИИ</div><div class="value">${queueValue}</div></div>
|
||||
<div class="card stat"><div class="label">Период опроса</div><div class="value">${stats.poll_interval_seconds}s</div></div>
|
||||
<div class="card stat"><div class="label">Последний опрос</div><div class="value">${fmtRelative(stats.last_poll_at)}</div></div>
|
||||
<div class="card stat"><div class="label">Локальный ИИ</div><div class="value" style="font-size:14px">${llmBadge}<div class="muted mono" style="font-size:11px;margin-top:4px">${escape(llm.model || "")}</div></div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
const channels = await api.listChannels();
|
||||
const tbody = document.getElementById("channels-tbody");
|
||||
if (!channels.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="empty">Каналов в этом подразделе пока нет — добавьте их на странице <a href="${sBase}/channels.html">Каналы</a></td></tr>`;
|
||||
return;
|
||||
}
|
||||
const stats = await Promise.all(channels.map(c => api.channelStats(c.id).catch(() => null)));
|
||||
tbody.innerHTML = channels.map((c, i) => {
|
||||
const s = stats[i] || {};
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div><a href="${sBase}/messages.html?channel_id=${c.id}">${escape(c.title || c.identifier)}</a></div>
|
||||
<div class="muted mono" style="font-size:12px">${escape(c.identifier)}</div>
|
||||
</td>
|
||||
<td>${(s.message_count ?? 0).toLocaleString()}</td>
|
||||
<td>${fmtRelative(s.last_message_at)}</td>
|
||||
<td>${fmtRelative(c.last_polled_at)}</td>
|
||||
<td>${c.is_active ? '<span class="badge ok">on</span>' : '<span class="badge off">off</span>'}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
document.getElementById("poll-all").addEventListener("click", async (e) => {
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
const res = await api.pollAll();
|
||||
const scope = section ? `${meta.short} / ${section}` : meta.short;
|
||||
toast(`В очереди ${res.queued ?? 0} каналов (${scope}) — опрос идёт в фоне`, "success");
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
} finally {
|
||||
e.target.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
document.getElementById("poll-all").hidden = !(await isAdmin());
|
||||
await Promise.all([loadStats(), loadChannels()]);
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
loadAll();
|
||||
setInterval(loadAll, 15000);
|
||||
@@ -1,433 +0,0 @@
|
||||
import { api, toast, fmtDate } from "/api/monitoring-tg/static/js/api.js";
|
||||
import { getVertical, getSection, VERTICAL_META } from "/api/monitoring-tg/static/js/vertical.js";
|
||||
|
||||
const V = getVertical();
|
||||
const section = getSection();
|
||||
const meta = VERTICAL_META[V];
|
||||
|
||||
const state = {
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
channelId: null,
|
||||
q: "",
|
||||
realEstate: "",
|
||||
hrKind: "",
|
||||
hasPhone: false,
|
||||
leadsOnly: false,
|
||||
minConfidence: 0.5,
|
||||
channels: [],
|
||||
autorefresh: false,
|
||||
timer: null,
|
||||
};
|
||||
|
||||
function escape(s) {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
|
||||
function highlight(text, q) {
|
||||
if (!q || !text) return escape(text);
|
||||
const escaped = escape(text);
|
||||
const re = new RegExp(escape(q).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
||||
return escaped.replace(re, m => `<mark style="background:#f1c40f33;color:inherit">${m}</mark>`);
|
||||
}
|
||||
|
||||
function channelTitle(id) {
|
||||
const c = state.channels.find(c => c.id === id);
|
||||
return c ? (c.title || c.identifier) : `#${id}`;
|
||||
}
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (bytes == null) return "";
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
const REAL_ESTATE_LABELS = { sale: "продажа", rent: "аренда", purchase: "покупка" };
|
||||
const HR_KIND_LABELS = { vacancy: "вакансия", resume: "резюме", contact: "контакт" };
|
||||
|
||||
function senderContacts(m) {
|
||||
const contacts = [];
|
||||
if (m && m.post_url) {
|
||||
contacts.push(`<a class="badge tg-link" href="${escape(m.post_url)}" target="_blank">📬 Открыть в Telegram</a>`);
|
||||
}
|
||||
if (m && m.sender_username) {
|
||||
const u = m.sender_username.startsWith("@") ? m.sender_username : "@" + m.sender_username;
|
||||
contacts.push(`<a class="badge tg" href="https://t.me/${escape(m.sender_username.replace(/^@/, ""))}" target="_blank">✉️ ${escape(u)}</a>`);
|
||||
} else if (m && m.sender_name) {
|
||||
contacts.push(`<span class="badge name">✍️ ${escape(m.sender_name)}</span>`);
|
||||
}
|
||||
const handles = (m && m.extracted && m.extracted.tg_handles) || [];
|
||||
for (const h of handles) {
|
||||
const bare = h.replace(/^@/, "");
|
||||
contacts.push(`<a class="badge tg" href="https://t.me/${escape(bare)}" target="_blank">✉️ ${escape(h)}</a>`);
|
||||
}
|
||||
return contacts;
|
||||
}
|
||||
|
||||
function renderReLead(lead, m) {
|
||||
if (!lead || !lead.is_listing) return "";
|
||||
const tone =
|
||||
lead.confidence >= 0.7 ? "lead-strong" :
|
||||
lead.confidence >= 0.4 ? "lead-medium" : "lead-weak";
|
||||
const bits = [];
|
||||
if (lead.kind) bits.push(REAL_ESTATE_LABELS[lead.kind] || lead.kind);
|
||||
if (lead.property_type) bits.push(lead.property_type);
|
||||
if (lead.rooms) bits.push(lead.rooms);
|
||||
if (lead.area_m2) bits.push(`${lead.area_m2} м²`);
|
||||
const priceBit = lead.price_text
|
||||
|| (lead.price_value != null
|
||||
? `${lead.price_value.toLocaleString()}${lead.currency ? " " + lead.currency : ""}`
|
||||
: null);
|
||||
if (priceBit) bits.push(priceBit);
|
||||
else if (lead.currency) bits.push(lead.currency);
|
||||
if (lead.location) bits.push(lead.location);
|
||||
const facts = bits.length
|
||||
? `<div class="lead-facts">${escape(bits.join(" · "))}</div>` : "";
|
||||
const summary = lead.summary
|
||||
? `<div class="lead-summary">${escape(lead.summary)}</div>` : "";
|
||||
const contacts = [];
|
||||
if (lead.contact_phone) {
|
||||
contacts.push(`<a class="badge phone" href="tel:${escape(lead.contact_phone)}">📞 ${escape(lead.contact_phone)}</a>`);
|
||||
}
|
||||
if (lead.contact_name) {
|
||||
contacts.push(`<span class="badge name">👤 ${escape(lead.contact_name)}</span>`);
|
||||
}
|
||||
contacts.push(...senderContacts(m));
|
||||
return `
|
||||
<div class="lead-card ${tone}">
|
||||
<div class="lead-head">
|
||||
<span class="badge lead">🎯 ЛИД · 🏠</span>
|
||||
${facts}
|
||||
<span class="lead-confidence">${(lead.confidence * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
${summary}
|
||||
${contacts.length ? `<div class="message-tags">${contacts.join(" ")}</div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderHrLead(lead, m) {
|
||||
if (!lead || !lead.is_lead) return "";
|
||||
const tone =
|
||||
lead.confidence >= 0.7 ? "lead-strong" :
|
||||
lead.confidence >= 0.4 ? "lead-medium" : "lead-weak";
|
||||
const bits = [];
|
||||
if (lead.kind) bits.push(HR_KIND_LABELS[lead.kind] || lead.kind);
|
||||
if (lead.title) bits.push(lead.title);
|
||||
if (lead.company) bits.push(lead.company);
|
||||
if (lead.candidate_name) bits.push(lead.candidate_name);
|
||||
if (lead.experience_years != null) bits.push(`${lead.experience_years}+ лет опыта`);
|
||||
if (lead.employment_type) bits.push(lead.employment_type);
|
||||
if (lead.remote === true) bits.push("удалёнка");
|
||||
else if (lead.remote === false) bits.push("офис");
|
||||
if (lead.location) bits.push(lead.location);
|
||||
const salaryBit = lead.salary_text
|
||||
|| (lead.salary_value != null
|
||||
? `${lead.salary_value.toLocaleString()}${lead.currency ? " " + lead.currency : ""}`
|
||||
: null);
|
||||
if (salaryBit) bits.push(salaryBit);
|
||||
else if (lead.currency) bits.push(lead.currency);
|
||||
const facts = bits.length
|
||||
? `<div class="lead-facts">${escape(bits.join(" · "))}</div>` : "";
|
||||
const summary = lead.summary
|
||||
? `<div class="lead-summary">${escape(lead.summary)}</div>` : "";
|
||||
const skills = (lead.skills || []).slice(0, 12);
|
||||
const skillsBlock = skills.length
|
||||
? `<div class="message-tags">${skills.map(s => `<span class="badge">${escape(s)}</span>`).join(" ")}</div>`
|
||||
: "";
|
||||
const contacts = [];
|
||||
if (lead.contact_phone) {
|
||||
contacts.push(`<a class="badge phone" href="tel:${escape(lead.contact_phone)}">📞 ${escape(lead.contact_phone)}</a>`);
|
||||
}
|
||||
if (lead.contact_name) {
|
||||
contacts.push(`<span class="badge name">👤 ${escape(lead.contact_name)}</span>`);
|
||||
}
|
||||
contacts.push(...senderContacts(m));
|
||||
return `
|
||||
<div class="lead-card ${tone}">
|
||||
<div class="lead-head">
|
||||
<span class="badge lead">🎯 ЛИД · 👥</span>
|
||||
${facts}
|
||||
<span class="lead-confidence">${(lead.confidence * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
${summary}
|
||||
${skillsBlock}
|
||||
${contacts.length ? `<div class="message-tags">${contacts.join(" ")}</div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderExtracted(ex) {
|
||||
if (!ex) return "";
|
||||
const parts = [];
|
||||
const re = ex.real_estate;
|
||||
const showRegexRE =
|
||||
V === "real_estate" && re && !(ex.lead && ex.lead.is_listing);
|
||||
if (showRegexRE) {
|
||||
const bits = [];
|
||||
if (re.kind) bits.push(REAL_ESTATE_LABELS[re.kind] || re.kind);
|
||||
if (re.property_type) bits.push(re.property_type);
|
||||
if (re.rooms) bits.push(re.rooms);
|
||||
if (re.area_m2) bits.push(`${re.area_m2} м²`);
|
||||
if (re.price) bits.push(re.price);
|
||||
if (bits.length) parts.push(`<span class="badge re">🏠 regex: ${escape(bits.join(" · "))}</span>`);
|
||||
}
|
||||
// Phones/names from regex are still useful even when there's a lead — show
|
||||
// only those that aren't already inside the lead card.
|
||||
const inLead = new Set();
|
||||
const activeLead = V === "hr" ? ex.hr_lead : ex.lead;
|
||||
if (activeLead) {
|
||||
if (activeLead.contact_phone) inLead.add(activeLead.contact_phone);
|
||||
if (activeLead.contact_name) inLead.add(activeLead.contact_name);
|
||||
}
|
||||
for (const p of ex.phones || []) {
|
||||
if (inLead.has(p)) continue;
|
||||
parts.push(`<a class="badge phone" href="tel:${escape(p)}">📞 ${escape(p)}</a>`);
|
||||
}
|
||||
for (const n of (ex.names || []).slice(0, 3)) {
|
||||
if (inLead.has(n)) continue;
|
||||
parts.push(`<span class="badge name">👤 ${escape(n)}</span>`);
|
||||
}
|
||||
if ((ex.names || []).length > 3) {
|
||||
parts.push(`<span class="badge name muted">+${ex.names.length - 3}</span>`);
|
||||
}
|
||||
const leadShown = (V === "hr" && ex.hr_lead && ex.hr_lead.is_lead) ||
|
||||
(V === "real_estate" && ex.lead && ex.lead.is_listing);
|
||||
if (!leadShown) {
|
||||
for (const h of (ex.tg_handles || [])) {
|
||||
const bare = h.replace(/^@/, "");
|
||||
parts.push(`<a class="badge tg" href="https://t.me/${escape(bare)}" target="_blank">✉️ ${escape(h)}</a>`);
|
||||
}
|
||||
}
|
||||
const tags = parts.length ? `<div class="message-tags">${parts.join(" ")}</div>` : "";
|
||||
return tags;
|
||||
}
|
||||
|
||||
function renderMedia(files) {
|
||||
if (!files || !files.length) return "";
|
||||
return `<div class="message-media">${files.map(f => {
|
||||
if (f.skipped) {
|
||||
const why = f.skipped === "too_large" ? "слишком большой" : f.skipped;
|
||||
return `<div class="media-item media-skipped"><span class="badge warn">${escape(f.kind)}</span>
|
||||
<span class="muted">${why}${f.size ? `, ${fmtSize(f.size)}` : ""}</span></div>`;
|
||||
}
|
||||
if (!f.url) return "";
|
||||
if (f.kind === "photo" || f.kind === "sticker") {
|
||||
return `<a href="${escape(f.url)}" target="_blank" data-action="lightbox" data-url="${escape(f.url)}">
|
||||
<img class="media-thumb" src="${escape(f.url)}" loading="lazy" alt="" />
|
||||
</a>`;
|
||||
}
|
||||
if (f.kind === "video") {
|
||||
return `<video class="media-video" src="${escape(f.url)}" controls preload="metadata"></video>`;
|
||||
}
|
||||
if (f.kind === "audio") {
|
||||
return `<audio src="${escape(f.url)}" controls preload="none" style="width:100%"></audio>`;
|
||||
}
|
||||
return `<a class="media-doc" href="${escape(f.url)}" target="_blank" download>
|
||||
<span class="badge">${escape(f.kind)}</span>
|
||||
<span>${escape(f.mime || "файл")}</span>
|
||||
<span class="muted">${fmtSize(f.size)}</span>
|
||||
</a>`;
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
|
||||
function readUrl() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.has("channel_id")) state.channelId = Number(params.get("channel_id"));
|
||||
if (params.has("q")) state.q = params.get("q");
|
||||
if (params.has("real_estate")) state.realEstate = params.get("real_estate");
|
||||
if (params.has("hr_kind")) state.hrKind = params.get("hr_kind");
|
||||
if (params.get("has_phone") === "true") state.hasPhone = true;
|
||||
if (params.get("leads_only") === "true") state.leadsOnly = true;
|
||||
if (params.has("min_confidence")) state.minConfidence = Number(params.get("min_confidence"));
|
||||
}
|
||||
|
||||
function syncControls() {
|
||||
document.getElementById("channel-filter").value = state.channelId ?? "";
|
||||
document.getElementById("search").value = state.q;
|
||||
const reSel = document.getElementById("real-estate");
|
||||
if (reSel) reSel.value = state.realEstate;
|
||||
const hrSel = document.getElementById("hr-kind");
|
||||
if (hrSel) hrSel.value = state.hrKind;
|
||||
document.getElementById("has-phone").checked = state.hasPhone;
|
||||
document.getElementById("leads-only").checked = state.leadsOnly;
|
||||
document.getElementById("min-confidence").value = String(state.minConfidence);
|
||||
document.getElementById("limit").value = state.limit;
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
state.channels = await api.listChannels();
|
||||
const sel = document.getElementById("channel-filter");
|
||||
sel.innerHTML = `<option value="">Все каналы (${meta.short})</option>` + state.channels.map(c =>
|
||||
`<option value="${c.id}">${escape(c.title || c.identifier)}</option>`
|
||||
).join("");
|
||||
syncControls();
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
const list = document.getElementById("list");
|
||||
list.innerHTML = `<div class="empty">Загрузка...</div>`;
|
||||
try {
|
||||
const msgs = await api.listMessages({
|
||||
channelId: state.channelId,
|
||||
q: state.q || undefined,
|
||||
realEstate: state.realEstate || undefined,
|
||||
hrKind: state.hrKind || undefined,
|
||||
hasPhone: state.hasPhone || undefined,
|
||||
leadsOnly: state.leadsOnly || undefined,
|
||||
minConfidence: state.leadsOnly ? state.minConfidence : undefined,
|
||||
limit: state.limit,
|
||||
offset: state.offset,
|
||||
});
|
||||
if (!msgs.length) {
|
||||
list.innerHTML = `<div class="empty">Сообщений нет</div>`;
|
||||
} else {
|
||||
list.innerHTML = msgs.map(m => `
|
||||
<div class="message" data-id="${m.id}">
|
||||
<div class="message-meta">
|
||||
<a href="?channel_id=${m.channel_id}">${escape(channelTitle(m.channel_id))}</a>
|
||||
<span>·</span>
|
||||
<span>${fmtDate(m.date)}</span>
|
||||
<span>·</span>
|
||||
<span class="mono">#${m.tg_message_id}</span>
|
||||
${m.group_size > 1 ? `<span class="badge">альбом · ${m.group_size}</span>` : (m.has_media ? '<span class="badge">media</span>' : '')}
|
||||
${m.views != null ? `<span>👁 ${m.views}</span>` : ''}
|
||||
${m.forwards ? `<span>↗ ${m.forwards}</span>` : ''}
|
||||
<div class="spacer"></div>
|
||||
<a href="#" data-action="raw">json</a>
|
||||
</div>
|
||||
<div class="message-text">${m.text ? highlight(m.text, state.q) : '<span class="muted">(без текста)</span>'}</div>
|
||||
${V === "hr"
|
||||
? renderHrLead(m.extracted && m.extracted.hr_lead, m)
|
||||
: renderReLead(m.extracted && m.extracted.lead, m)}
|
||||
${renderExtracted(m.extracted)}
|
||||
${renderMedia(m.media_files)}
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
document.getElementById("page-info").textContent =
|
||||
`${state.offset + 1}–${state.offset + msgs.length}`;
|
||||
document.getElementById("prev").disabled = state.offset === 0;
|
||||
document.getElementById("next").disabled = msgs.length < state.limit;
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
list.innerHTML = `<div class="empty">Ошибка: ${escape(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("channel-filter").addEventListener("change", (e) => {
|
||||
state.channelId = e.target.value ? Number(e.target.value) : null;
|
||||
state.offset = 0;
|
||||
loadMessages();
|
||||
});
|
||||
|
||||
let searchTimer;
|
||||
document.getElementById("search").addEventListener("input", (e) => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
state.q = e.target.value.trim();
|
||||
state.offset = 0;
|
||||
loadMessages();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
document.getElementById("limit").addEventListener("change", (e) => {
|
||||
state.limit = Number(e.target.value);
|
||||
state.offset = 0;
|
||||
loadMessages();
|
||||
});
|
||||
|
||||
const reSelEl = document.getElementById("real-estate");
|
||||
if (reSelEl) {
|
||||
reSelEl.addEventListener("change", (e) => {
|
||||
state.realEstate = e.target.value;
|
||||
state.offset = 0;
|
||||
loadMessages();
|
||||
});
|
||||
}
|
||||
|
||||
const hrSelEl = document.getElementById("hr-kind");
|
||||
if (hrSelEl) {
|
||||
hrSelEl.addEventListener("change", (e) => {
|
||||
state.hrKind = e.target.value;
|
||||
state.offset = 0;
|
||||
loadMessages();
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("has-phone").addEventListener("change", (e) => {
|
||||
state.hasPhone = e.target.checked;
|
||||
state.offset = 0;
|
||||
loadMessages();
|
||||
});
|
||||
|
||||
document.getElementById("leads-only").addEventListener("change", (e) => {
|
||||
state.leadsOnly = e.target.checked;
|
||||
state.offset = 0;
|
||||
loadMessages();
|
||||
});
|
||||
|
||||
document.getElementById("min-confidence").addEventListener("change", (e) => {
|
||||
state.minConfidence = Number(e.target.value);
|
||||
if (state.leadsOnly) {
|
||||
state.offset = 0;
|
||||
loadMessages();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("refresh").addEventListener("click", loadMessages);
|
||||
|
||||
document.getElementById("prev").addEventListener("click", () => {
|
||||
state.offset = Math.max(0, state.offset - state.limit);
|
||||
loadMessages();
|
||||
});
|
||||
document.getElementById("next").addEventListener("click", () => {
|
||||
state.offset += state.limit;
|
||||
loadMessages();
|
||||
});
|
||||
|
||||
document.getElementById("autorefresh").addEventListener("change", (e) => {
|
||||
state.autorefresh = e.target.checked;
|
||||
if (state.timer) { clearInterval(state.timer); state.timer = null; }
|
||||
if (state.autorefresh) state.timer = setInterval(loadMessages, 10000);
|
||||
});
|
||||
|
||||
document.getElementById("list").addEventListener("click", async (e) => {
|
||||
const lightbox = e.target.closest("[data-action='lightbox']");
|
||||
if (lightbox) {
|
||||
e.preventDefault();
|
||||
openLightbox(lightbox.dataset.url);
|
||||
return;
|
||||
}
|
||||
const a = e.target.closest("[data-action='raw']");
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
const id = Number(a.closest(".message").dataset.id);
|
||||
try {
|
||||
const msg = await api.getMessage(id);
|
||||
document.getElementById("raw-content").textContent = JSON.stringify(msg, null, 2);
|
||||
document.getElementById("raw-dialog").showModal();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
function openLightbox(url) {
|
||||
let lb = document.getElementById("lightbox");
|
||||
if (!lb) {
|
||||
lb = document.createElement("div");
|
||||
lb.id = "lightbox";
|
||||
lb.addEventListener("click", () => lb.remove());
|
||||
document.body.appendChild(lb);
|
||||
}
|
||||
lb.innerHTML = `<img src="${escape(url)}" alt="" />`;
|
||||
}
|
||||
document.getElementById("raw-close").addEventListener("click", () => {
|
||||
document.getElementById("raw-dialog").close();
|
||||
});
|
||||
|
||||
readUrl();
|
||||
(async () => {
|
||||
await loadChannels();
|
||||
await loadMessages();
|
||||
})();
|
||||
@@ -1,25 +0,0 @@
|
||||
import { api } from "/api/monitoring-tg/static/js/api.js";
|
||||
import { isPortalAdmin } from "/api/monitoring-tg/static/js/access.js";
|
||||
import { appBase } from "/api/monitoring-tg/static/js/vertical.js";
|
||||
|
||||
// "Telegram not authorized" banner. Only useful for admins — non-admin
|
||||
// visitors can't open /auth.html anyway, so showing the banner would be
|
||||
// noise (and the /auth/status call itself 404s for non-admins).
|
||||
(async () => {
|
||||
if (!(await isPortalAdmin())) return;
|
||||
try {
|
||||
const status = await api.authStatus();
|
||||
if (status.authorized) return;
|
||||
const banner = document.createElement("div");
|
||||
banner.className = "card";
|
||||
banner.style.cssText =
|
||||
"border-color: rgba(241, 196, 15, 0.5); background: rgba(241, 196, 15, 0.08); margin-bottom: 16px;";
|
||||
banner.innerHTML = `
|
||||
<strong>Telegram не авторизован.</strong>
|
||||
Парсер не сможет ходить за сообщениями, пока вы не залогинитесь.
|
||||
<a href="${appBase()}/auth.html?return=${encodeURIComponent(location.pathname)}">Открыть страницу авторизации →</a>
|
||||
`;
|
||||
const main = document.querySelector("main");
|
||||
if (main) main.insertBefore(banner, main.firstChild);
|
||||
} catch {}
|
||||
})();
|
||||
@@ -1,71 +0,0 @@
|
||||
import { api } from "/api/monitoring-tg/static/js/api.js";
|
||||
// Import for side-effect: access.js hides .admin-link elements for non-admins.
|
||||
import "/api/monitoring-tg/static/js/access.js";
|
||||
import {
|
||||
VERTICAL_META,
|
||||
appBase,
|
||||
getVertical,
|
||||
getSection,
|
||||
verticalBase,
|
||||
sectionBase,
|
||||
} from "/api/monitoring-tg/static/js/vertical.js";
|
||||
|
||||
const V = getVertical();
|
||||
const section = getSection();
|
||||
const meta = VERTICAL_META[V];
|
||||
|
||||
const titleEl = document.getElementById("page-title");
|
||||
if (titleEl) {
|
||||
titleEl.textContent = section
|
||||
? `parser-tg-bot · ${meta.emoji} ${meta.short} · ${section}`
|
||||
: `parser-tg-bot · ${meta.emoji} ${meta.short}`;
|
||||
}
|
||||
|
||||
const navEl = document.getElementById("nav-section");
|
||||
if (navEl) {
|
||||
const here = location.pathname;
|
||||
const active = (href) => here === href ? "active" : "";
|
||||
const links = [];
|
||||
|
||||
// Up-link: chooser if we are inside a section, vertical-list otherwise.
|
||||
if (section) {
|
||||
links.push(`<a href="${verticalBase()}/">← ${meta.short} (подразделы)</a>`);
|
||||
} else {
|
||||
links.push(`<a href="${appBase()}/">← Разделы</a>`);
|
||||
}
|
||||
|
||||
if (section) {
|
||||
const sBase = sectionBase();
|
||||
links.push(
|
||||
`<a href="${sBase}/" class="${active(sBase + '/')}">Дашборд</a>`,
|
||||
`<a href="${sBase}/channels.html" class="${active(sBase + '/channels.html')}">Каналы</a>`,
|
||||
`<a href="${sBase}/messages.html" class="${active(sBase + '/messages.html')}">Сообщения</a>`,
|
||||
`<a href="${sBase}/settings.html" class="admin-only ${active(sBase + '/settings.html')}">Настройки</a>`,
|
||||
);
|
||||
}
|
||||
|
||||
links.push(
|
||||
`<a class="admin-login-link" href="${appBase()}/admin.html?return=${encodeURIComponent(location.pathname)}">Админ</a>`,
|
||||
`<a class="admin-link" href="${appBase()}/auth.html">Авторизация</a>`,
|
||||
`<a class="admin-link" href="${appBase()}/docs" target="_blank">API</a>`,
|
||||
);
|
||||
navEl.innerHTML = links.join("");
|
||||
}
|
||||
|
||||
// Best-effort: resolve section's display title from the API and update the
|
||||
// page heading. Falls back to the raw slug if the network call fails.
|
||||
const headingEl = document.getElementById("page-heading");
|
||||
if (headingEl && section) {
|
||||
api.listSections(V)
|
||||
.then(sections => {
|
||||
const s = sections.find(x => x.slug === section);
|
||||
if (s) {
|
||||
const baseText = headingEl.dataset.base || headingEl.textContent;
|
||||
headingEl.dataset.base = baseText;
|
||||
headingEl.textContent = `${baseText} · ${s.emoji ? s.emoji + " " : ""}${s.title}`;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export { section, V, meta };
|
||||
@@ -1,189 +0,0 @@
|
||||
import { api, toast } from "/api/monitoring-tg/static/js/api.js";
|
||||
import { isAdmin } from "/api/monitoring-tg/static/js/access.js";
|
||||
import { getVertical, verticalBase, VERTICAL_META } from "/api/monitoring-tg/static/js/vertical.js";
|
||||
import { slugify } from "/api/monitoring-tg/static/js/slugify.js";
|
||||
|
||||
const V = getVertical();
|
||||
const base = verticalBase(V);
|
||||
const meta = VERTICAL_META[V];
|
||||
let sectionsBySlug = new Map();
|
||||
|
||||
function escape(s) {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const grid = document.getElementById("sections-grid");
|
||||
grid.innerHTML = `<div class="empty">Загрузка...</div>`;
|
||||
try {
|
||||
const admin = await isAdmin();
|
||||
const sections = await api.listSections(V);
|
||||
sectionsBySlug = new Map(sections.map(s => [s.slug, s]));
|
||||
if (!sections.length) {
|
||||
grid.innerHTML = `<div class="empty">Подразделов пока нет — нажми «+ Новый подраздел»</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = `<div class="sections-grid">${sections.map(s => `
|
||||
<div class="card section-tile" data-slug="${escape(s.slug)}">
|
||||
<a href="${base}/${encodeURIComponent(s.slug)}/" class="section-tile-link">
|
||||
<div class="section-tile-head">
|
||||
<span class="section-emoji">${escape(s.emoji || meta.emoji)}</span>
|
||||
<span class="section-title">${escape(s.title)}</span>
|
||||
</div>
|
||||
<div class="section-stats">
|
||||
<span title="Каналов (активных/всего)"><b>${s.channels_active}</b> / ${s.channels_total} каналов</span>
|
||||
<span title="Сообщений всего">${s.messages_total.toLocaleString()} сообщ.</span>
|
||||
<span title="🎯 Лидов">${s.leads_total.toLocaleString()} лидов</span>
|
||||
</div>
|
||||
${s.description ? `<div class="section-desc muted">${escape(s.description)}</div>` : ""}
|
||||
<div class="section-slug muted mono">${escape(V)} / ${escape(s.slug)}</div>
|
||||
</a>
|
||||
${admin ? `
|
||||
<div class="row admin-only" style="justify-content:flex-end; gap:8px; margin-top:8px">
|
||||
<button class="secondary" data-action="edit">Переименовать</button>
|
||||
<button class="danger" data-action="delete">Удалить</button>
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
grid.innerHTML = `<div class="empty">Ошибка: ${escape(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create-section dialog with auto-slug -------------------------------
|
||||
|
||||
const titleInput = document.getElementById("new-title");
|
||||
const slugInput = document.getElementById("new-slug");
|
||||
const slugPreview = document.getElementById("new-slug-preview");
|
||||
const slugManualToggle = document.getElementById("new-slug-manual");
|
||||
|
||||
// Track whether the user has taken manual control of the slug. As soon as
|
||||
// they touch the slug field directly, stop auto-syncing it.
|
||||
let slugIsAuto = true;
|
||||
|
||||
function syncSlugFromTitle() {
|
||||
if (!slugIsAuto) return;
|
||||
const proposed = slugify(titleInput.value);
|
||||
slugInput.value = proposed;
|
||||
if (slugPreview) {
|
||||
slugPreview.textContent = proposed || "(введите название)";
|
||||
}
|
||||
}
|
||||
|
||||
if (titleInput) {
|
||||
titleInput.addEventListener("input", syncSlugFromTitle);
|
||||
}
|
||||
if (slugInput) {
|
||||
slugInput.addEventListener("input", () => { slugIsAuto = false; });
|
||||
}
|
||||
if (slugManualToggle) {
|
||||
slugManualToggle.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const hidden = slugInput.closest(".slug-row");
|
||||
if (hidden) hidden.hidden = !hidden.hidden;
|
||||
slugInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
document.getElementById("create-form").reset();
|
||||
slugIsAuto = true;
|
||||
if (slugPreview) slugPreview.textContent = "(введите название)";
|
||||
if (slugInput) slugInput.value = "";
|
||||
const hidden = slugInput?.closest(".slug-row");
|
||||
if (hidden) hidden.hidden = true;
|
||||
}
|
||||
|
||||
document.getElementById("open-create").addEventListener("click", () => {
|
||||
resetForm();
|
||||
document.getElementById("create-dialog").showModal();
|
||||
setTimeout(() => titleInput?.focus(), 50);
|
||||
});
|
||||
|
||||
document.getElementById("create-cancel").addEventListener("click", () => {
|
||||
document.getElementById("create-dialog").close();
|
||||
});
|
||||
|
||||
document.getElementById("edit-cancel").addEventListener("click", () => {
|
||||
document.getElementById("edit-dialog").close();
|
||||
});
|
||||
|
||||
document.getElementById("create-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const title = titleInput.value.trim();
|
||||
if (!title) return;
|
||||
// Re-sync once more in case `input` didn't fire before submit (autofill).
|
||||
if (slugIsAuto) syncSlugFromTitle();
|
||||
const slug = slugInput.value.trim() || slugify(title);
|
||||
if (!slug) {
|
||||
toast("Не удалось сформировать slug — введите его вручную", "error");
|
||||
return;
|
||||
}
|
||||
const emoji = document.getElementById("new-emoji").value.trim() || null;
|
||||
const description = document.getElementById("new-description").value.trim() || null;
|
||||
try {
|
||||
await api.createSection({ vertical: V, slug, title, emoji, description });
|
||||
toast(`Подраздел "${title}" создан`, "success");
|
||||
document.getElementById("create-dialog").close();
|
||||
resetForm();
|
||||
await render();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("sections-grid").addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const tile = btn.closest(".section-tile");
|
||||
const slug = tile.dataset.slug;
|
||||
const action = btn.dataset.action;
|
||||
if (action === "edit") {
|
||||
const section = sectionsBySlug.get(slug);
|
||||
if (!section) return;
|
||||
document.getElementById("edit-slug").value = slug;
|
||||
document.getElementById("edit-title").value = section.title || "";
|
||||
document.getElementById("edit-emoji").value = section.emoji || "";
|
||||
document.getElementById("edit-description").value = section.description || "";
|
||||
document.getElementById("edit-dialog").showModal();
|
||||
setTimeout(() => document.getElementById("edit-title").focus(), 50);
|
||||
return;
|
||||
}
|
||||
if (action !== "delete") return;
|
||||
if (!confirm(`Удалить подраздел "${slug}"? Удалить можно только пустой подраздел (без каналов).`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.deleteSection(V, slug);
|
||||
toast(`Подраздел "${slug}" удалён`, "success");
|
||||
await render();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("edit-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const slug = document.getElementById("edit-slug").value;
|
||||
const title = document.getElementById("edit-title").value.trim();
|
||||
const emoji = document.getElementById("edit-emoji").value.trim() || null;
|
||||
const description = document.getElementById("edit-description").value.trim() || null;
|
||||
if (!title) return;
|
||||
try {
|
||||
await api.updateSection(V, slug, {
|
||||
title,
|
||||
emoji,
|
||||
description,
|
||||
});
|
||||
toast(`Подраздел "${title}" сохранён`, "success");
|
||||
document.getElementById("edit-dialog").close();
|
||||
await render();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
@@ -1,133 +0,0 @@
|
||||
import { api, toast, fmtDate } from "/api/monitoring-tg/static/js/api.js";
|
||||
import { getVertical, getSection, VERTICAL_META } from "/api/monitoring-tg/static/js/vertical.js";
|
||||
|
||||
const V = getVertical();
|
||||
const section = getSection();
|
||||
const meta = VERTICAL_META[V];
|
||||
const PROMPT_LIMIT = 30000;
|
||||
|
||||
// `level` decides which override layer the editor edits/saves/resets.
|
||||
// "section" → store key llm_system_prompt:<vertical>:<section_slug>
|
||||
// "vertical" → store key llm_system_prompt:<vertical>
|
||||
// Effective resolution always goes section → vertical → default.
|
||||
let level = section ? "section" : "vertical";
|
||||
|
||||
const levelEl = document.getElementById("prompt-level");
|
||||
if (levelEl) {
|
||||
if (!section) {
|
||||
levelEl.value = "vertical";
|
||||
levelEl.disabled = true;
|
||||
} else {
|
||||
levelEl.value = "section";
|
||||
levelEl.addEventListener("change", async (e) => {
|
||||
level = e.target.value;
|
||||
await loadPrompt();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function levelScope() {
|
||||
return level === "section"
|
||||
? { vertical: V, section }
|
||||
: { vertical: V, section: null };
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
const res = await fetch("/api/monitoring-tg/api/v1/settings");
|
||||
if (res.status === 404 || res.status === 403) {
|
||||
document.getElementById("config-tbody").innerHTML =
|
||||
`<tr><td colspan="2" class="empty">Конфигурация сервиса доступна только админу портала</td></tr>`;
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
const cfg = await res.json();
|
||||
const stats = await api.globalStats();
|
||||
|
||||
const scopeLabel = section ? `${meta.short} / ${section}` : meta.short;
|
||||
const rows = [
|
||||
["Раздел", `${meta.emoji} ${scopeLabel}`],
|
||||
["Период опроса", `${cfg.poll_interval_seconds}s`],
|
||||
["Лимит истории за опрос", cfg.poll_history_limit],
|
||||
["Telethon session", cfg.tg_session_path],
|
||||
["Postgres host", `${cfg.postgres_host}:${cfg.postgres_port}/${cfg.postgres_db}`],
|
||||
["API host", `${cfg.api_host}:${cfg.api_port}`],
|
||||
[`Каналов в ${scopeLabel}`, `${stats.channels_active} активных / ${stats.channels_total}`],
|
||||
[`Сообщений в ${scopeLabel}`, stats.messages_total.toLocaleString()],
|
||||
["Последний опрос (scope)", fmtDate(stats.last_poll_at)],
|
||||
];
|
||||
document.getElementById("config-tbody").innerHTML = rows.map(([k, v]) =>
|
||||
`<tr><td class="muted">${k}</td><td class="mono">${v ?? "—"}</td></tr>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
document.getElementById("poll-all").addEventListener("click", async (e) => {
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
const res = await api.pollAll();
|
||||
toast(`В очереди ${res.queued ?? 0} каналов — опрос идёт в фоне`, "success");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
} finally {
|
||||
e.target.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadPrompt() {
|
||||
const data = await api.llmPromptGet(levelScope());
|
||||
const editor = document.getElementById("prompt-editor");
|
||||
editor.value = data.prompt || "";
|
||||
const status = document.getElementById("prompt-status");
|
||||
const lengthEl = document.getElementById("prompt-length");
|
||||
editor.maxLength = PROMPT_LIMIT;
|
||||
|
||||
const map = {
|
||||
section: ["override · подраздел", "ok"],
|
||||
vertical: ["override · вертикаль", "ok"],
|
||||
default: ["встроенный по умолчанию", "off"],
|
||||
};
|
||||
const [label, cls] = map[data.source] || ["—", "off"];
|
||||
status.textContent = label;
|
||||
status.className = `badge ${cls}`;
|
||||
const used = (data.prompt || "").length;
|
||||
lengthEl.textContent =
|
||||
`${used.toLocaleString()} / ${PROMPT_LIMIT.toLocaleString()} символов`;
|
||||
}
|
||||
|
||||
document.getElementById("prompt-editor").addEventListener("input", (e) => {
|
||||
const lengthEl = document.getElementById("prompt-length");
|
||||
lengthEl.textContent =
|
||||
`${e.target.value.length.toLocaleString()} / ${PROMPT_LIMIT.toLocaleString()} символов`;
|
||||
});
|
||||
|
||||
document.getElementById("prompt-save").addEventListener("click", async (e) => {
|
||||
const text = document.getElementById("prompt-editor").value;
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
await api.llmPromptSave(text, levelScope());
|
||||
const where = level === "section" ? `${meta.short} / ${section}` : meta.short;
|
||||
toast(`Промпт ${where} сохранён, применится в течение 5 секунд`, "success");
|
||||
await loadPrompt();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
} finally {
|
||||
e.target.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("prompt-reset").addEventListener("click", async (e) => {
|
||||
const where = level === "section" ? `подраздела "${section}"` : `вертикали "${meta.short}"`;
|
||||
if (!confirm(`Сбросить пользовательский промпт ${where} и вернуться к фоллбэку?`)) return;
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
await api.llmPromptReset(levelScope());
|
||||
toast(`Промпт ${where} сброшен`, "success");
|
||||
await loadPrompt();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
} finally {
|
||||
e.target.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
loadConfig().catch(err => toast(err.message, "error"));
|
||||
loadPrompt().catch(err => toast(err.message, "error"));
|
||||
@@ -1,22 +0,0 @@
|
||||
// URL-safe slug from arbitrary text. Cyrillic → Latin so titles like
|
||||
// "Дубай Marina" become "dubai-marina" without forcing the user to type
|
||||
// a slug by hand.
|
||||
|
||||
const RU_TO_LAT = {
|
||||
а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "yo", ж: "zh",
|
||||
з: "z", и: "i", й: "y", к: "k", л: "l", м: "m", н: "n", о: "o",
|
||||
п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "ts",
|
||||
ч: "ch", ш: "sh", щ: "sch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu",
|
||||
я: "ya",
|
||||
};
|
||||
|
||||
export function slugify(text) {
|
||||
return (text || "")
|
||||
.toLowerCase()
|
||||
.split("")
|
||||
.map(c => RU_TO_LAT[c] ?? c)
|
||||
.join("")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
const APP_BASE = "/api/monitoring-tg";
|
||||
|
||||
// Detect the current scope from the URL path.
|
||||
//
|
||||
// / → vertical=null, section=null
|
||||
// /real-estate/ → vertical=real_estate, section=null (section chooser)
|
||||
// /real-estate/dubai/ → vertical=real_estate, section=dubai
|
||||
// /real-estate/dubai/channels.html → same
|
||||
// /hr/ → vertical=hr, section=null
|
||||
// /hr/it/settings.html → vertical=hr, section=it
|
||||
//
|
||||
// Section slug comes from URL path[2] and is opaque (created via UI). The
|
||||
// frontend treats it as a string and passes it to the API; the backend
|
||||
// resolves slug→Section row at query time.
|
||||
|
||||
function _segments() {
|
||||
const segments = location.pathname.split("/").filter(Boolean);
|
||||
const base = APP_BASE.split("/").filter(Boolean);
|
||||
if (base.every((part, idx) => segments[idx] === part)) {
|
||||
return segments.slice(base.length);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function getVerticalSlug() {
|
||||
const seg = (_segments()[0] || "").toLowerCase();
|
||||
if (seg === "hr") return "hr";
|
||||
if (seg === "real-estate") return "real-estate";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getVertical() {
|
||||
const slug = getVerticalSlug();
|
||||
if (slug === "hr") return "hr";
|
||||
if (slug === "real-estate") return "real_estate";
|
||||
return "real_estate"; // harmless default for section-less pages
|
||||
}
|
||||
|
||||
export function getSection() {
|
||||
const segs = _segments();
|
||||
// Only treat segment[1] as a section slug when segment[0] is a known vertical.
|
||||
if (!getVerticalSlug()) return null;
|
||||
const candidate = segs[1];
|
||||
if (!candidate || candidate.endsWith(".html")) return null;
|
||||
return candidate.toLowerCase();
|
||||
}
|
||||
|
||||
export const VERTICAL_META = {
|
||||
real_estate: {
|
||||
slug: "real-estate",
|
||||
title: "Недвижимость",
|
||||
short: "Недвижимость",
|
||||
emoji: "🏠",
|
||||
leadLabel: "Объявление",
|
||||
},
|
||||
hr: {
|
||||
slug: "hr",
|
||||
title: "HR / Кадры",
|
||||
short: "HR",
|
||||
emoji: "👥",
|
||||
leadLabel: "HR-лид",
|
||||
},
|
||||
};
|
||||
|
||||
export function appBase() {
|
||||
return APP_BASE;
|
||||
}
|
||||
|
||||
export function verticalBase(vertical = getVertical()) {
|
||||
return `${APP_BASE}/${VERTICAL_META[vertical].slug}`;
|
||||
}
|
||||
|
||||
export function sectionBase(vertical = getVertical(), section = getSection()) {
|
||||
const v = verticalBase(vertical);
|
||||
return section ? `${v}/${section}` : v;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>🏠 Недвижимость — подразделы</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="page-title">parser-tg-bot · 🏠 Недвижимость</h1>
|
||||
<nav id="nav-section"></nav>
|
||||
</header>
|
||||
<main>
|
||||
<div class="row">
|
||||
<h2>Подразделы недвижимости</h2>
|
||||
<div class="spacer"></div>
|
||||
<button id="open-create">+ Новый подраздел</button>
|
||||
</div>
|
||||
<p class="muted">
|
||||
Каждый подраздел — это собственный набор каналов, своя статистика и свой
|
||||
LLM-промпт (с фоллбэком на промпт вертикали). Например: Дубай, Москва,
|
||||
Сочи, коммерческая недвижимость.
|
||||
</p>
|
||||
|
||||
<div id="sections-grid"></div>
|
||||
</main>
|
||||
|
||||
<dialog id="create-dialog">
|
||||
<h3 style="margin-top:0">Новый подраздел</h3>
|
||||
<form id="create-form">
|
||||
<label class="row" style="gap:8px; margin-bottom:8px">
|
||||
<span style="min-width:120px" class="muted">Название</span>
|
||||
<input type="text" id="new-title" required placeholder="Дубай" style="flex:1" />
|
||||
</label>
|
||||
<div class="row" style="gap:8px; margin-bottom:8px; font-size:12px">
|
||||
<span style="min-width:120px" class="muted">URL-адрес</span>
|
||||
<span class="muted mono">/real-estate/<span id="new-slug-preview">(введите название)</span>/</span>
|
||||
<div class="spacer"></div>
|
||||
<a href="#" id="new-slug-manual" class="muted">изменить вручную</a>
|
||||
</div>
|
||||
<label class="row slug-row" style="gap:8px; margin-bottom:8px" hidden>
|
||||
<span style="min-width:120px" class="muted">Slug</span>
|
||||
<input type="text" id="new-slug" pattern="[a-z0-9][a-z0-9_-]*[a-z0-9]?"
|
||||
placeholder="dubai" style="flex:1" />
|
||||
</label>
|
||||
<label class="row" style="gap:8px; margin-bottom:8px">
|
||||
<span style="min-width:120px" class="muted">Иконка</span>
|
||||
<input type="text" id="new-emoji" maxlength="4" placeholder="🌴" style="width:80px" />
|
||||
</label>
|
||||
<label class="row" style="gap:8px; margin-bottom:8px; align-items:flex-start">
|
||||
<span style="min-width:120px" class="muted">Описание</span>
|
||||
<textarea id="new-description" rows="3" style="flex:1"></textarea>
|
||||
</label>
|
||||
<div class="row" style="justify-content:flex-end; gap:8px; margin-top:12px">
|
||||
<button type="button" id="create-cancel" class="secondary">Отмена</button>
|
||||
<button type="submit">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="edit-dialog">
|
||||
<h3 style="margin-top:0">Редактировать подраздел</h3>
|
||||
<form id="edit-form">
|
||||
<input type="hidden" id="edit-slug" />
|
||||
<label class="row" style="gap:8px; margin-bottom:8px">
|
||||
<span style="min-width:120px" class="muted">Название</span>
|
||||
<input type="text" id="edit-title" required style="flex:1" />
|
||||
</label>
|
||||
<label class="row" style="gap:8px; margin-bottom:8px">
|
||||
<span style="min-width:120px" class="muted">Иконка</span>
|
||||
<input type="text" id="edit-emoji" maxlength="4" style="width:80px" />
|
||||
</label>
|
||||
<label class="row" style="gap:8px; margin-bottom:8px; align-items:flex-start">
|
||||
<span style="min-width:120px" class="muted">Описание</span>
|
||||
<textarea id="edit-description" rows="3" style="flex:1"></textarea>
|
||||
</label>
|
||||
<div class="row" style="justify-content:flex-end; gap:8px; margin-top:12px">
|
||||
<button type="button" id="edit-cancel" class="secondary">Отмена</button>
|
||||
<button type="submit">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/sections-list.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,48 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>🏠 Недвижимость · Каналы — parser-tg-bot</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="page-title">parser-tg-bot</h1>
|
||||
<nav id="nav-section"></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2 id="page-heading">Каналы подраздела</h2>
|
||||
|
||||
<div class="card" style="margin-bottom:24px">
|
||||
<form id="add-form" class="row">
|
||||
<input type="text" id="identifier" placeholder="@channel или https://t.me/..." required style="flex:1; min-width:280px" />
|
||||
<button type="submit">Добавить канал</button>
|
||||
</form>
|
||||
<div class="muted" style="margin-top:8px; font-size:12px">
|
||||
Канал будет привязан к текущему подразделу.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Канал</th>
|
||||
<th>Telegram ID</th>
|
||||
<th>Сообщ.</th>
|
||||
<th>Последний опрос</th>
|
||||
<th>Статус</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/channels.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,43 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>🏠 Недвижимость · Дашборд — parser-tg-bot</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="page-title">parser-tg-bot</h1>
|
||||
<nav id="nav-section"></nav>
|
||||
</header>
|
||||
<main>
|
||||
<div class="row">
|
||||
<h2 id="page-heading">Дашборд</h2>
|
||||
<div class="spacer"></div>
|
||||
<button id="poll-all">Опросить все каналы подраздела</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" id="stats"></div>
|
||||
|
||||
<h3>Каналы подраздела</h3>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Канал</th>
|
||||
<th>Сообщений</th>
|
||||
<th>Последнее сообщение</th>
|
||||
<th>Последний опрос</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="channels-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,78 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>🏠 Недвижимость · Сообщения — parser-tg-bot</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="page-title">parser-tg-bot</h1>
|
||||
<nav id="nav-section"></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2 id="page-heading">Сообщения подраздела</h2>
|
||||
|
||||
<div class="toolbar card">
|
||||
<select id="channel-filter">
|
||||
<option value="">Все каналы подраздела</option>
|
||||
</select>
|
||||
<input type="search" id="search" placeholder="Поиск по тексту..." />
|
||||
<select id="real-estate">
|
||||
<option value="">Любая тема</option>
|
||||
<option value="any">🏠 Недвижимость (любая)</option>
|
||||
<option value="sale">🏠 Продажа</option>
|
||||
<option value="rent">🏠 Аренда</option>
|
||||
<option value="purchase">🏠 Покупка</option>
|
||||
</select>
|
||||
<label class="row" style="gap:6px">
|
||||
<input type="checkbox" id="leads-only" />
|
||||
<span class="muted">🎯 Только лиды (ИИ)</span>
|
||||
</label>
|
||||
<select id="min-confidence" title="Минимальная уверенность ИИ">
|
||||
<option value="0.3">0.3+</option>
|
||||
<option value="0.5" selected>0.5+</option>
|
||||
<option value="0.7">0.7+</option>
|
||||
<option value="0.9">0.9+</option>
|
||||
</select>
|
||||
<label class="row" style="gap:6px">
|
||||
<input type="checkbox" id="has-phone" />
|
||||
<span class="muted">📞 С телефоном</span>
|
||||
</label>
|
||||
<select id="limit">
|
||||
<option value="25">25</option>
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
<div class="spacer"></div>
|
||||
<label class="row" style="gap:6px">
|
||||
<input type="checkbox" id="autorefresh" />
|
||||
<span class="muted">Автообновление</span>
|
||||
</label>
|
||||
<button id="refresh" class="secondary">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="card" id="list"></div>
|
||||
|
||||
<div class="pagination">
|
||||
<button id="prev" class="secondary">← Назад</button>
|
||||
<span class="muted" id="page-info" style="align-self:center"></span>
|
||||
<button id="next" class="secondary">Вперёд →</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<dialog id="raw-dialog">
|
||||
<h3 style="margin-top:0">Сообщение</h3>
|
||||
<pre id="raw-content"></pre>
|
||||
<div class="row" style="justify-content:flex-end; margin-top:12px">
|
||||
<button class="secondary" id="raw-close">Закрыть</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/messages.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,65 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>🏠 Недвижимость · Настройки — parser-tg-bot</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="page-title">parser-tg-bot</h1>
|
||||
<nav id="nav-section"></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2 id="page-heading">Настройки подраздела</h2>
|
||||
|
||||
<div class="card" style="margin-bottom:24px">
|
||||
<h3 style="margin-top:0">Текущая конфигурация</h3>
|
||||
<table>
|
||||
<tbody id="config-tbody">
|
||||
<tr><td colspan="2" class="empty">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="muted" style="font-size:12px; margin-top:12px">
|
||||
Параметры задаются через переменные окружения и k8s-манифесты.
|
||||
Для изменения обновите ConfigMap/Secret и перезапустите deployment.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:24px">
|
||||
<h3 style="margin-top:0">Действия</h3>
|
||||
<div class="row">
|
||||
<button id="poll-all">Опросить все каналы подраздела сейчас</button>
|
||||
<a href="/api/monitoring-tg/docs" target="_blank" class="badge">OpenAPI / Swagger</a>
|
||||
<a href="/api/monitoring-tg/healthz" target="_blank" class="badge">Health check</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:24px">
|
||||
<h3 style="margin-top:0">🤖 Промпт ИИ</h3>
|
||||
<div class="row" style="margin-bottom:8px">
|
||||
<span class="badge" id="prompt-status">—</span>
|
||||
<span class="muted" id="prompt-length"></span>
|
||||
<div class="spacer"></div>
|
||||
<select id="prompt-level" title="Уровень редактирования промпта">
|
||||
<option value="section" selected>Промпт подраздела</option>
|
||||
<option value="vertical">Промпт вертикали</option>
|
||||
</select>
|
||||
<button id="prompt-reset" class="secondary">Сбросить уровень</button>
|
||||
<button id="prompt-save">Сохранить</button>
|
||||
</div>
|
||||
<textarea id="prompt-editor" rows="22"
|
||||
style="width:100%; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:12px"></textarea>
|
||||
<div class="muted" style="font-size:12px; margin-top:8px">
|
||||
Каскад: <strong>section → vertical → default</strong>. Если промпта на
|
||||
уровне подраздела нет, используется промпт вертикали; если и его нет —
|
||||
встроенный по умолчанию. Сохранение применится в течение ~5 сек.
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
|
||||
<script type="module" src="/api/monitoring-tg/static/js/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user