Make monitoring TG API-only

This commit is contained in:
Grendgi
2026-06-04 15:42:50 +03:00
parent b78d1eac02
commit 5cda374fb1
37 changed files with 26 additions and 2836 deletions

View File

@@ -45,7 +45,3 @@ LLM_MIN_TEXT_LENGTH=20
# processes per tick. With 5/20s ≈ 900 messages/hour at ~3-6s per call. # processes per tick. With 5/20s ≈ 900 messages/hour at ~3-6s per call.
LLM_CLASSIFY_INTERVAL_SECONDS=20 LLM_CLASSIFY_INTERVAL_SECONDS=20
LLM_CLASSIFY_BATCH_SIZE=5 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=

View File

@@ -1,12 +1,16 @@
# monitoring-tg # monitoring-tg
Сервис мониторинга Telegram-каналов для портала. Он сохраняет сообщения в Backend-сервис мониторинга Telegram-каналов для Portal. Он сохраняет сообщения
Postgres, раскладывает каналы по вертикалям/подразделам и выполняет AI-анализ в Postgres, раскладывает каналы по вертикалям/подразделам и выполняет AI-анализ
через OpenAI-compatible endpoint, общий с другими сервисами портала. через 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-Is-Admin=1`.
- Отдел видит только свои подразделы, каналы, сообщения и промпты через - Отдел видит только свои подразделы, каналы, сообщения и промпты через
`X-User-Department-Id`. `X-User-Department-Id`.
@@ -38,8 +42,8 @@ LLM_API_KEY=
LLM_MODEL=qwen2.5-14b LLM_MODEL=qwen2.5-14b
``` ```
Для локальной админской отладки можно задать `ADMIN_PASSWORD`, но в проде доступ Локального админ-пароля нет: админские API доступны только через роль `admin`
должен идти через портал. в Portal.
## Запуск в k8s ## Запуск в k8s
@@ -60,7 +64,6 @@ src/parser_bot/
├── db/ SQLAlchemy модели + сессии ├── db/ SQLAlchemy модели + сессии
├── scheduler/ APScheduler-воркер периодического опроса ├── scheduler/ APScheduler-воркер периодического опроса
├── telegram/ Telethon-клиент ├── telegram/ Telethon-клиент
├── web/static/ страницы UI без бандлера
├── config.py pydantic-settings ├── config.py pydantic-settings
└── main.py FastAPI lifespan + uvicorn └── main.py FastAPI lifespan + uvicorn
alembic/ миграции alembic/ миграции

View File

@@ -11,7 +11,6 @@ stringData:
TG_SESSION_STRING: "" TG_SESSION_STRING: ""
POSTGRES_PASSWORD: "parser" POSTGRES_PASSWORD: "parser"
LLM_API_KEY: "" LLM_API_KEY: ""
ADMIN_PASSWORD: "CHANGE_ME"
--- ---
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret

View File

@@ -33,9 +33,6 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.setuptools.package-data]
"parser_bot.web" = ["static/*", "static/**/*"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py311" target-version = "py311"

View File

@@ -1,80 +1,11 @@
"""Portal-aware access helpers for monitoring-tg.""" """Portal-aware access helpers for monitoring-tg."""
from __future__ import annotations from __future__ import annotations
import hashlib from fastapi import HTTPException, Request
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)
def is_admin_request(request: Request) -> bool: def is_admin_request(request: Request) -> bool:
if request.headers.get("x-user-is-admin") == "1": return 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)
def portal_department_id(request: Request) -> str | None: def portal_department_id(request: Request) -> str | None:

View File

@@ -9,7 +9,6 @@ from fastapi import (
HTTPException, HTTPException,
Query, Query,
Request, Request,
Response,
) )
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession 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 llm as llm_client
from parser_bot import prompt_store from parser_bot import prompt_store
from parser_bot.access import ( from parser_bot.access import (
admin_password_enabled,
can_manage_department, can_manage_department,
clear_admin_cookie,
is_admin_request, is_admin_request,
portal_department_id, portal_department_id,
require_admin, require_admin,
require_admin_network,
require_department_manager, require_department_manager,
set_admin_cookie,
verify_admin_password,
) )
from parser_bot.api.schemas import ( from parser_bot.api.schemas import (
AdminLogin,
AuthCode, AuthCode,
AuthCodeResult, AuthCodeResult,
AuthPassword, AuthPassword,
@@ -177,27 +170,9 @@ async def access_me(request: Request) -> dict[str, Any]:
"is_admin": admin, "is_admin": admin,
"can_manage_department": can_manage, "can_manage_department": can_manage,
"department_id": department_id, "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) -------------------------------------------------- # --- Auth (admin-only) --------------------------------------------------
# Telegram session controls are an admin surface. # 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) section = await _get_section(session, payload.vertical, payload.section, department_id)
if not await tg.is_authorized(): 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: try:
resolved = await tg.resolve_channel(payload.identifier) resolved = await tg.resolve_channel(payload.identifier)
except Exception as exc: except Exception as exc:

View File

@@ -217,7 +217,3 @@ class AuthPassword(BaseModel):
class AuthCodeResult(BaseModel): class AuthCodeResult(BaseModel):
needs_password: bool needs_password: bool
class AdminLogin(BaseModel):
password: str = Field(..., min_length=1)

View File

@@ -40,10 +40,6 @@ class Settings(BaseSettings):
llm_classify_interval_seconds: int = Field(20, alias="LLM_CLASSIFY_INTERVAL_SECONDS") llm_classify_interval_seconds: int = Field(20, alias="LLM_CLASSIFY_INTERVAL_SECONDS")
llm_classify_batch_size: int = Field(5, alias="LLM_CLASSIFY_BATCH_SIZE") 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 @property
def database_url(self) -> str: def database_url(self) -> str:
return ( return (

View File

@@ -3,14 +3,13 @@ from pathlib import Path
import structlog import structlog
import uvicorn 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.docs import get_redoc_html, get_swagger_ui_html
from fastapi.openapi.utils import get_openapi from fastapi.openapi.utils import get_openapi
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles 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.api.routes import router
from parser_bot.config import settings from parser_bot.config import settings
from parser_bot.scheduler.poller import build_scheduler from parser_bot.scheduler.poller import build_scheduler
@@ -25,22 +24,6 @@ structlog.configure(
) )
log = structlog.get_logger() 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -52,7 +35,7 @@ async def lifespan(app: FastAPI):
"startup", poll_interval=settings.poll_interval_seconds, authorized=authorized "startup", poll_interval=settings.poll_interval_seconds, authorized=authorized
) )
if not 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: try:
yield yield
finally: finally:
@@ -61,22 +44,6 @@ async def lifespan(app: FastAPI):
log.info("shutdown") 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: def create_app() -> FastAPI:
public_base = settings.public_base_path.rstrip("/") public_base = settings.public_base_path.rstrip("/")
# Disable the default /docs, /redoc and /openapi.json — we serve our own # Disable the default /docs, /redoc and /openapi.json — we serve our own
@@ -95,26 +62,8 @@ def create_app() -> FastAPI:
return {"status": "ok"} return {"status": "ok"}
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
async def index() -> FileResponse: async def index() -> dict[str, str]:
return FileResponse(STATIC_DIR / "index.html", headers=NOCACHE) return {"service": "monitoring-tg", "ui": "portal"}
# 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)
# Admin-only: OpenAPI surface. Custom routes so we can wrap them in # Admin-only: OpenAPI surface. Custom routes so we can wrap them in
# `require_admin`; the auto-generated ones from FastAPI bypass it. # `require_admin`; the auto-generated ones from FastAPI bypass it.
@@ -139,7 +88,7 @@ def create_app() -> FastAPI:
include_in_schema=False, include_in_schema=False,
dependencies=[Depends(require_admin)], dependencies=[Depends(require_admin)],
) )
async def docs() -> FileResponse: async def docs():
return get_swagger_ui_html( return get_swagger_ui_html(
openapi_url=f"{public_base}/openapi.json" if public_base else "/openapi.json", openapi_url=f"{public_base}/openapi.json" if public_base else "/openapi.json",
title=app.title + " — docs", title=app.title + " — docs",
@@ -150,42 +99,16 @@ def create_app() -> FastAPI:
include_in_schema=False, include_in_schema=False,
dependencies=[Depends(require_admin)], dependencies=[Depends(require_admin)],
) )
async def redoc() -> FileResponse: async def redoc():
return get_redoc_html( return get_redoc_html(
openapi_url=f"{public_base}/openapi.json" if public_base else "/openapi.json", openapi_url=f"{public_base}/openapi.json" if public_base else "/openapi.json",
title=app.title + " — redoc", 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 = Path(settings.media_dir)
media_dir.mkdir(parents=True, exist_ok=True) media_dir.mkdir(parents=True, exist_ok=True)
# /media is fine to cache — file names are content-stable. # /media is fine to cache — file names are content-stable.
app.mount("/media", StaticFiles(directory=media_dir), name="media") 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 return app

View File

@@ -180,7 +180,9 @@ async def stop_client() -> None:
async def require_authorized() -> TelegramClient: async def require_authorized() -> TelegramClient:
client = await start_client() client = await start_client()
if not await client.is_user_authorized(): 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 return client

View File

@@ -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>

View File

@@ -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>

View File

@@ -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; }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 });
});

View File

@@ -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"));

View File

@@ -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`;
}

View File

@@ -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"));

View File

@@ -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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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"));

View File

@@ -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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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);

View File

@@ -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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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();
})();

View File

@@ -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 {}
})();

View File

@@ -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 };

View File

@@ -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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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();

View File

@@ -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"));

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>