From 2c16bc0b56ae01e13319edb04d599f933ff78a99 Mon Sep 17 00:00:00 2001 From: gitrusprus Date: Tue, 30 Jun 2026 14:40:59 +0300 Subject: [PATCH] Add CA admin UI and extend admin API for the unified platform. Deliver parsers, events, analytics, and PI management in Vue; fix Telegram session mount and map navigation to events by eventId. Co-authored-by: Cursor --- README.md | 28 +- centers/analytics/api/app/routers/admin.py | 140 +++++- centers/analytics/api/app/schemas.py | 36 ++ .../analytics/api/app/services/analytics.py | 92 ++++ .../api/app/services/events_query.py | 42 ++ .../analytics/api/app/services/filtering.py | 61 +++ centers/analytics/frontend/package.json | 5 +- centers/analytics/frontend/src/App.vue | 345 +------------- centers/analytics/frontend/src/api/admin.ts | 120 +++++ centers/analytics/frontend/src/api/client.ts | 33 ++ centers/analytics/frontend/src/api/objects.ts | 28 +- .../frontend/src/components/MapView.vue | 19 +- .../frontend/src/layouts/AppLayout.vue | 115 +++++ centers/analytics/frontend/src/main.ts | 3 +- .../analytics/frontend/src/router/index.ts | 27 ++ centers/analytics/frontend/src/style.css | 275 +++++++++++ centers/analytics/frontend/src/types/admin.ts | 97 ++++ centers/analytics/frontend/src/types/event.ts | 17 + .../analytics/frontend/src/types/object.ts | 1 + .../frontend/src/views/AnalyticsView.vue | 214 +++++++++ .../frontend/src/views/ConsumersView.vue | 438 ++++++++++++++++++ .../frontend/src/views/EventsView.vue | 261 +++++++++++ .../frontend/src/views/MapViewPage.vue | 383 +++++++++++++++ .../frontend/src/views/ParsersView.vue | 161 +++++++ centers/analytics/frontend/vite.config.ts | 1 + docker-compose.yml | 4 +- 26 files changed, 2550 insertions(+), 396 deletions(-) create mode 100644 centers/analytics/api/app/services/analytics.py create mode 100644 centers/analytics/api/app/services/events_query.py create mode 100644 centers/analytics/frontend/src/api/admin.ts create mode 100644 centers/analytics/frontend/src/api/client.ts create mode 100644 centers/analytics/frontend/src/layouts/AppLayout.vue create mode 100644 centers/analytics/frontend/src/router/index.ts create mode 100644 centers/analytics/frontend/src/types/admin.ts create mode 100644 centers/analytics/frontend/src/types/event.ts create mode 100644 centers/analytics/frontend/src/views/AnalyticsView.vue create mode 100644 centers/analytics/frontend/src/views/ConsumersView.vue create mode 100644 centers/analytics/frontend/src/views/EventsView.vue create mode 100644 centers/analytics/frontend/src/views/MapViewPage.vue create mode 100644 centers/analytics/frontend/src/views/ParsersView.vue diff --git a/README.md b/README.md index 468e9a6..bda1b24 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,19 @@ ls -la data/telegram.session docker compose up --build ``` -4. Откройте карту: [http://localhost:8080](http://localhost:8080) +4. Откройте UI: [http://localhost:8080](http://localhost:8080) + +## Admin UI + +Веб-интерфейс ЦА доступен по тем же адресу. Навигация в шапке: + +| Раздел | Путь | Описание | +|--------|------|----------| +| **Карта** | `/` | Интерактивная карта с объектами, таймлайном и CRUD. Поддерживает `?eventId=` для перехода к событию | +| **Парсеры** | `/parsers` | Создание заданий Telegram-парсинга, таблица статусов с автообновлением (5 с), повтор failed-заданий | +| **События** | `/events` | Фильтрация, пагинация, просмотр деталей, ссылка «На карте» для событий с координатами | +| **Аналитика** | `/analytics` | KPI-карточки, график динамики ingest за 30 дней, топ населённых пунктов и регионов | +| **ПИ** | `/consumers` | CRUD подписчиков distribution API, ротация ключей, тест среза через `/api/v1/events` | ## Сохранение Telegram-сессии @@ -66,11 +78,11 @@ docker compose up --build - Оригинал: `SocialParser/data/telegram.session` - В репозитории MapMil: `data/telegram.session` — симлинк для локальной разработки -- В Docker `cp-workers`: файл монтируется напрямую как `../SocialParser/data/telegram.session:/data/telegram.session:ro` +- В Docker `cp-workers`: каталог `../SocialParser/data` монтируется как `/data` (read-write; Telethon обновляет SQLite-сессию) - Путь в контейнере: `/data/telegram.session` - Переменные из `.env`: `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `TELEGRAM_SESSION_PATH=/data/telegram.session` -> Симлинк не работает внутри Docker — compose монтирует исходный файл из SocialParser. +> Симлинк не работает внутри Docker — compose монтирует каталог `SocialParser/data` целиком. Файл сессии и `.env` добавлены в `.gitignore` и не коммитятся. @@ -90,8 +102,16 @@ docker compose up --build |-------|------|----------| | POST | `/admin/jobs` | Создать задание парсинга (ставится в Redis) | | GET | `/admin/jobs` | Список заданий | -| GET | `/admin/events` | Все события | +| POST | `/admin/jobs/{id}/retry` | Повторить failed-задание | +| GET | `/admin/events` | События с фильтрами (`{ items, total }`) | +| GET | `/admin/analytics/summary` | KPI-сводка | +| GET | `/admin/analytics/timeline?days=` | Динамика ingest по дням | +| GET | `/admin/analytics/top-localities?limit=` | Топ населённых пунктов | +| GET | `/admin/analytics/top-regions?limit=` | Топ регионов | +| GET | `/admin/consumers` | Список подписчиков ПИ | | POST | `/admin/consumers` | Создать подписчика ПИ | +| PATCH | `/admin/consumers/{id}` | Обновить подписчика | +| POST | `/admin/consumers/{id}/rotate-key` | Сменить API-ключ | Пример задания Telegram: diff --git a/centers/analytics/api/app/routers/admin.py b/centers/analytics/api/app/routers/admin.py index acb5217..1aeaa2d 100644 --- a/centers/analytics/api/app/routers/admin.py +++ b/centers/analytics/api/app/routers/admin.py @@ -1,16 +1,36 @@ +from datetime import datetime + from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from ..database import get_db -from ..models import Event, ParseJob +from ..models import Consumer, Event, ParseJob from ..schemas import ( + AnalyticsSummary, ConsumerCreate, ConsumerRead, + ConsumerUpdate, + EventListResponse, EventRead, ParseJobCreate, ParseJobRead, + TimelinePoint, + TopItem, +) +from ..services.analytics import ( + get_analytics_summary, + get_timeline, + get_top_localities, + get_top_regions, +) +from ..services.events_query import build_events_query +from ..services.filtering import ( + consumer_to_read, + create_consumer, + list_consumers, + rotate_consumer_key, + update_consumer, ) -from ..services.filtering import create_consumer from ..services.jobs import enqueue_job router = APIRouter(prefix="/admin", tags=["admin"]) @@ -45,19 +65,81 @@ def get_parse_job(job_id: int, db: Session = Depends(get_db)): return job -@router.get("/events", response_model=list[EventRead]) +@router.post("/jobs/{job_id}/retry", response_model=ParseJobRead) +def retry_parse_job(job_id: int, db: Session = Depends(get_db)): + job = db.query(ParseJob).filter(ParseJob.id == job_id).first() + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + job.status = "queued" + job.last_error = None + db.commit() + db.refresh(job) + + enqueue_job(job.id, job.source_type, job.source_config) + return job + + +@router.get("/events", response_model=EventListResponse) def list_events( - limit: int = Query(default=100, ge=1, le=1000), + limit: int = Query(default=50, ge=1, le=1000), offset: int = Query(default=0, ge=0), + source_type: str | None = None, + region: str | None = None, + topic: str | None = None, + locality: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + search: str | None = None, db: Session = Depends(get_db), ): - return ( - db.query(Event) - .order_by(Event.ingested_at.desc()) - .offset(offset) - .limit(limit) - .all() + query = build_events_query( + db, + source_type=source_type, + region=region, + topic=topic, + locality=locality, + date_from=date_from, + date_to=date_to, + search=search, ) + total = query.count() + items = query.offset(offset).limit(limit).all() + return EventListResponse(items=items, total=total) + + +@router.get("/analytics/summary", response_model=AnalyticsSummary) +def analytics_summary(db: Session = Depends(get_db)): + return get_analytics_summary(db) + + +@router.get("/analytics/timeline", response_model=list[TimelinePoint]) +def analytics_timeline( + days: int = Query(default=30, ge=1, le=365), + db: Session = Depends(get_db), +): + return get_timeline(db, days=days) + + +@router.get("/analytics/top-localities", response_model=list[TopItem]) +def analytics_top_localities( + limit: int = Query(default=10, ge=1, le=100), + db: Session = Depends(get_db), +): + return get_top_localities(db, limit=limit) + + +@router.get("/analytics/top-regions", response_model=list[TopItem]) +def analytics_top_regions( + limit: int = Query(default=10, ge=1, le=100), + db: Session = Depends(get_db), +): + return get_top_regions(db, limit=limit) + + +@router.get("/consumers", response_model=list[ConsumerRead]) +def list_pi_consumers(db: Session = Depends(get_db)): + return [consumer_to_read(c) for c in list_consumers(db)] @router.post("/consumers", response_model=ConsumerRead, status_code=201) @@ -69,10 +151,36 @@ def create_pi_consumer(payload: ConsumerCreate, db: Session = Depends(get_db)): topics=payload.topics, date_from=payload.date_from, ) - return ConsumerRead( - id=consumer.id, - name=consumer.name, - is_active=consumer.is_active, - created_at=consumer.created_at, - api_key=api_key, + return consumer_to_read(consumer, api_key=api_key) + + +@router.patch("/consumers/{consumer_id}", response_model=ConsumerRead) +def patch_pi_consumer( + consumer_id: int, + payload: ConsumerUpdate, + db: Session = Depends(get_db), +): + consumer = db.query(Consumer).filter(Consumer.id == consumer_id).first() + if not consumer: + raise HTTPException(status_code=404, detail="Consumer not found") + + consumer = update_consumer( + db, + consumer, + name=payload.name, + is_active=payload.is_active, + regions=payload.regions, + topics=payload.topics, + date_from=payload.date_from, ) + return consumer_to_read(consumer) + + +@router.post("/consumers/{consumer_id}/rotate-key", response_model=ConsumerRead) +def rotate_pi_consumer_key(consumer_id: int, db: Session = Depends(get_db)): + consumer = db.query(Consumer).filter(Consumer.id == consumer_id).first() + if not consumer: + raise HTTPException(status_code=404, detail="Consumer not found") + + consumer, api_key = rotate_consumer_key(db, consumer) + return consumer_to_read(consumer, api_key=api_key) diff --git a/centers/analytics/api/app/schemas.py b/centers/analytics/api/app/schemas.py index 7aeaca6..da9d5ed 100644 --- a/centers/analytics/api/app/schemas.py +++ b/centers/analytics/api/app/schemas.py @@ -122,6 +122,14 @@ class ConsumerCreate(BaseModel): date_from: datetime | None = None +class ConsumerUpdate(BaseModel): + name: str | None = None + is_active: bool | None = None + regions: list[str] | None = None + topics: list[str] | None = None + date_from: datetime | None = None + + class ConsumerRead(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -130,3 +138,31 @@ class ConsumerRead(BaseModel): is_active: bool created_at: datetime api_key: str | None = None + regions: list[str] | None = None + topics: list[str] | None = None + date_from: datetime | None = None + + +class EventListResponse(BaseModel): + items: list[EventRead] + total: int + + +class AnalyticsSummary(BaseModel): + total_events: int + events_with_coords: int + events_last_24h: int + total_consumers: int + active_consumers: int + total_jobs: int + pending_jobs: int + + +class TimelinePoint(BaseModel): + date: str + count: int + + +class TopItem(BaseModel): + name: str + count: int diff --git a/centers/analytics/api/app/services/analytics.py b/centers/analytics/api/app/services/analytics.py new file mode 100644 index 0000000..7edd4e0 --- /dev/null +++ b/centers/analytics/api/app/services/analytics.py @@ -0,0 +1,92 @@ +from datetime import datetime, timedelta, timezone + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ..models import Consumer, Event, ParseJob +from ..schemas import AnalyticsSummary, TimelinePoint, TopItem + + +def get_analytics_summary(db: Session) -> AnalyticsSummary: + now = datetime.now(timezone.utc) + day_ago = now - timedelta(days=1) + + total_events = db.query(func.count(Event.id)).scalar() or 0 + events_with_coords = ( + db.query(func.count(Event.id)) + .filter(Event.latitude.isnot(None), Event.longitude.isnot(None)) + .scalar() + or 0 + ) + events_last_24h = ( + db.query(func.count(Event.id)) + .filter(Event.ingested_at >= day_ago) + .scalar() + or 0 + ) + total_consumers = db.query(func.count(Consumer.id)).scalar() or 0 + active_consumers = ( + db.query(func.count(Consumer.id)) + .filter(Consumer.is_active.is_(True)) + .scalar() + or 0 + ) + total_jobs = db.query(func.count(ParseJob.id)).scalar() or 0 + pending_jobs = ( + db.query(func.count(ParseJob.id)) + .filter(ParseJob.status.in_(["queued", "running", "pending"])) + .scalar() + or 0 + ) + + return AnalyticsSummary( + total_events=total_events, + events_with_coords=events_with_coords, + events_last_24h=events_last_24h, + total_consumers=total_consumers, + active_consumers=active_consumers, + total_jobs=total_jobs, + pending_jobs=pending_jobs, + ) + + +def get_timeline(db: Session, days: int = 30) -> list[TimelinePoint]: + since = datetime.now(timezone.utc) - timedelta(days=days) + day_col = func.date_trunc("day", Event.ingested_at).label("day") + + rows = ( + db.query(day_col, func.count(Event.id).label("count")) + .filter(Event.ingested_at >= since) + .group_by(day_col) + .order_by(day_col) + .all() + ) + + return [ + TimelinePoint(date=row.day.date().isoformat(), count=row.count) + for row in rows + ] + + +def get_top_localities(db: Session, limit: int = 10) -> list[TopItem]: + rows = ( + db.query(Event.locality, func.count(Event.id).label("count")) + .filter(Event.locality != "", Event.locality.isnot(None)) + .group_by(Event.locality) + .order_by(func.count(Event.id).desc()) + .limit(limit) + .all() + ) + return [TopItem(name=row.locality, count=row.count) for row in rows] + + +def get_top_regions(db: Session, limit: int = 10) -> list[TopItem]: + rows = ( + db.query(Event.region, func.count(Event.id).label("count")) + .filter(Event.region.isnot(None), Event.region != "") + .group_by(Event.region) + .order_by(func.count(Event.id).desc()) + .limit(limit) + .all() + ) + return [TopItem(name=row.region, count=row.count) for row in rows] diff --git a/centers/analytics/api/app/services/events_query.py b/centers/analytics/api/app/services/events_query.py new file mode 100644 index 0000000..8d1bcf3 --- /dev/null +++ b/centers/analytics/api/app/services/events_query.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from sqlalchemy.orm import Query, Session + +from ..models import Event + + +def build_events_query( + db: Session, + *, + source_type: str | None = None, + region: str | None = None, + topic: str | None = None, + locality: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + search: str | None = None, +) -> Query: + query = db.query(Event) + + if source_type: + query = query.filter(Event.source_type == source_type) + if region: + query = query.filter(Event.region == region) + if topic: + query = query.filter(Event.topic == topic) + if locality: + query = query.filter(Event.locality.ilike(f"%{locality}%")) + if date_from: + query = query.filter(Event.event_date >= date_from) + if date_to: + query = query.filter(Event.event_date <= date_to) + if search: + pattern = f"%{search}%" + query = query.filter( + Event.title.ilike(pattern) + | Event.description.ilike(pattern) + | Event.raw_text.ilike(pattern) + | Event.locality.ilike(pattern) + ) + + return query.order_by(Event.ingested_at.desc()) diff --git a/centers/analytics/api/app/services/filtering.py b/centers/analytics/api/app/services/filtering.py index be3e236..0eb96db 100644 --- a/centers/analytics/api/app/services/filtering.py +++ b/centers/analytics/api/app/services/filtering.py @@ -5,6 +5,7 @@ from datetime import datetime from sqlalchemy.orm import Session from ..models import Consumer, ConsumerFilter, Event, MapObject +from ..schemas import ConsumerRead def hash_api_key(api_key: str) -> str: @@ -15,6 +16,66 @@ def generate_api_key() -> str: return secrets.token_urlsafe(32) +def consumer_to_read(consumer: Consumer, api_key: str | None = None) -> ConsumerRead: + consumer_filter = consumer.filter + return ConsumerRead( + id=consumer.id, + name=consumer.name, + is_active=consumer.is_active, + created_at=consumer.created_at, + api_key=api_key, + regions=consumer_filter.regions if consumer_filter else None, + topics=consumer_filter.topics if consumer_filter else None, + date_from=consumer_filter.date_from if consumer_filter else None, + ) + + +def list_consumers(db: Session) -> list[Consumer]: + return db.query(Consumer).order_by(Consumer.id).all() + + +def update_consumer( + db: Session, + consumer: Consumer, + *, + name: str | None = None, + is_active: bool | None = None, + regions: list[str] | None = None, + topics: list[str] | None = None, + date_from: datetime | None = None, +) -> Consumer: + if name is not None: + consumer.name = name + if is_active is not None: + consumer.is_active = is_active + + if any(v is not None for v in (regions, topics, date_from)): + consumer_filter = consumer.filter + if consumer_filter is None: + consumer_filter = ConsumerFilter(consumer_id=consumer.id) + db.add(consumer_filter) + consumer.filter = consumer_filter + + if regions is not None: + consumer_filter.regions = regions + if topics is not None: + consumer_filter.topics = topics + if date_from is not None: + consumer_filter.date_from = date_from + + db.commit() + db.refresh(consumer) + return consumer + + +def rotate_consumer_key(db: Session, consumer: Consumer) -> tuple[Consumer, str]: + api_key = generate_api_key() + consumer.api_key_hash = hash_api_key(api_key) + db.commit() + db.refresh(consumer) + return consumer, api_key + + def create_consumer( db: Session, name: str, diff --git a/centers/analytics/frontend/package.json b/centers/analytics/frontend/package.json index a0c2ffd..e64125d 100644 --- a/centers/analytics/frontend/package.json +++ b/centers/analytics/frontend/package.json @@ -9,8 +9,11 @@ "preview": "vite preview" }, "dependencies": { + "chart.js": "^4.4.7", "leaflet": "^1.9.4", - "vue": "^3.5.13" + "vue": "^3.5.13", + "vue-chartjs": "^5.3.2", + "vue-router": "^4.5.0" }, "devDependencies": { "@types/leaflet": "^1.9.15", diff --git a/centers/analytics/frontend/src/App.vue b/centers/analytics/frontend/src/App.vue index 42b19e2..c048298 100644 --- a/centers/analytics/frontend/src/App.vue +++ b/centers/analytics/frontend/src/App.vue @@ -1,349 +1,6 @@ - - diff --git a/centers/analytics/frontend/src/api/admin.ts b/centers/analytics/frontend/src/api/admin.ts new file mode 100644 index 0000000..30a403a --- /dev/null +++ b/centers/analytics/frontend/src/api/admin.ts @@ -0,0 +1,120 @@ +import { ADMIN_BASE, request } from "./client"; +import type { + AnalyticsSummary, + Consumer, + ConsumerCreate, + ConsumerUpdate, + EventFilters, + EventListResponse, + EventRecord, + ParseJob, + ParseJobCreate, + TimelinePoint, + TopItem, +} from "../types/admin"; + +function buildQuery(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== "") { + search.set(key, String(value)); + } + } + const qs = search.toString(); + return qs ? `?${qs}` : ""; +} + +export function fetchJobs(): Promise { + return request("/jobs", undefined, ADMIN_BASE); +} + +export function createJob(payload: ParseJobCreate): Promise { + return request( + "/jobs", + { method: "POST", body: JSON.stringify(payload) }, + ADMIN_BASE, + ); +} + +export function retryJob(jobId: number): Promise { + return request( + `/jobs/${jobId}/retry`, + { method: "POST" }, + ADMIN_BASE, + ); +} + +export function fetchEvents(filters: EventFilters = {}): Promise { + return request( + `/events${buildQuery(filters as Record)}`, + undefined, + ADMIN_BASE, + ); +} + +export function fetchConsumers(): Promise { + return request("/consumers", undefined, ADMIN_BASE); +} + +export function createConsumer(payload: ConsumerCreate): Promise { + return request( + "/consumers", + { method: "POST", body: JSON.stringify(payload) }, + ADMIN_BASE, + ); +} + +export function updateConsumer(id: number, payload: ConsumerUpdate): Promise { + return request( + `/consumers/${id}`, + { method: "PATCH", body: JSON.stringify(payload) }, + ADMIN_BASE, + ); +} + +export function rotateConsumerKey(id: number): Promise { + return request( + `/consumers/${id}/rotate-key`, + { method: "POST" }, + ADMIN_BASE, + ); +} + +export function fetchAnalyticsSummary(): Promise { + return request("/analytics/summary", undefined, ADMIN_BASE); +} + +export function fetchAnalyticsTimeline(days = 30): Promise { + return request( + `/analytics/timeline${buildQuery({ days })}`, + undefined, + ADMIN_BASE, + ); +} + +export function fetchTopLocalities(limit = 10): Promise { + return request( + `/analytics/top-localities${buildQuery({ limit })}`, + undefined, + ADMIN_BASE, + ); +} + +export function fetchTopRegions(limit = 10): Promise { + return request( + `/analytics/top-regions${buildQuery({ limit })}`, + undefined, + ADMIN_BASE, + ); +} + +export async function testDistribution(apiKey: string, limit = 5): Promise { + const response = await fetch(`/api/v1/events?limit=${limit}`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Ошибка запроса: ${response.status}`); + } + return response.json() as Promise; +} diff --git a/centers/analytics/frontend/src/api/client.ts b/centers/analytics/frontend/src/api/client.ts new file mode 100644 index 0000000..8eb058f --- /dev/null +++ b/centers/analytics/frontend/src/api/client.ts @@ -0,0 +1,33 @@ +const API_BASE = "/api"; +const ADMIN_BASE = "/admin"; + +export async function request( + url: string, + options?: RequestInit, + base: string = API_BASE, +): Promise { + const headers = new Headers(options?.headers); + const isFormData = options?.body instanceof FormData; + + if (!isFormData && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(`${base}${url}`, { + ...options, + headers, + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Ошибка запроса: ${response.status}`); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; +} + +export { API_BASE, ADMIN_BASE }; diff --git a/centers/analytics/frontend/src/api/objects.ts b/centers/analytics/frontend/src/api/objects.ts index e03ff13..5b81ced 100644 --- a/centers/analytics/frontend/src/api/objects.ts +++ b/centers/analytics/frontend/src/api/objects.ts @@ -1,32 +1,6 @@ +import { request } from "./client"; import type { MapObject, MapObjectCreate, MapObjectUpdate, ObjectMedia } from "../types/object"; -const API_BASE = "/api"; - -async function request(url: string, options?: RequestInit): Promise { - const headers = new Headers(options?.headers); - const isFormData = options?.body instanceof FormData; - - if (!isFormData && !headers.has("Content-Type")) { - headers.set("Content-Type", "application/json"); - } - - const response = await fetch(`${API_BASE}${url}`, { - ...options, - headers, - }); - - if (!response.ok) { - const message = await response.text(); - throw new Error(message || `Ошибка запроса: ${response.status}`); - } - - if (response.status === 204) { - return undefined as T; - } - - return response.json() as Promise; -} - export function fetchObjects(): Promise { return request("/objects"); } diff --git a/centers/analytics/frontend/src/components/MapView.vue b/centers/analytics/frontend/src/components/MapView.vue index ebab741..2134d10 100644 --- a/centers/analytics/frontend/src/components/MapView.vue +++ b/centers/analytics/frontend/src/components/MapView.vue @@ -96,6 +96,18 @@ function bindMarker(marker: L.Marker, obj: MapObject) { } } +function panToObject(obj: MapObject) { + if (!map) return; + const zoom = Math.max(map.getZoom(), 12); + map.flyTo([obj.latitude, obj.longitude], zoom, { duration: 0.6 }); +} + +function panToSelected() { + if (props.selectedId === null) return; + const obj = props.objects.find((item) => item.id === props.selectedId); + if (obj) panToObject(obj); +} + function syncMarkers() { if (!map || !markersLayer) return; @@ -175,6 +187,8 @@ onMounted(() => { markersLayer = L.layerGroup().addTo(map); mapContainer.value.addEventListener("contextmenu", handleMapContextMenu); syncMarkers(); + panToSelected(); + setTimeout(() => map?.invalidateSize(), 0); }); onUnmounted(() => { @@ -187,7 +201,10 @@ onUnmounted(() => { watch( () => [props.objects, props.selectedId] as const, - () => syncMarkers(), + () => { + syncMarkers(); + panToSelected(); + }, { deep: true }, ); diff --git a/centers/analytics/frontend/src/layouts/AppLayout.vue b/centers/analytics/frontend/src/layouts/AppLayout.vue new file mode 100644 index 0000000..3370139 --- /dev/null +++ b/centers/analytics/frontend/src/layouts/AppLayout.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/centers/analytics/frontend/src/main.ts b/centers/analytics/frontend/src/main.ts index de275e7..01b3dd4 100644 --- a/centers/analytics/frontend/src/main.ts +++ b/centers/analytics/frontend/src/main.ts @@ -1,5 +1,6 @@ import { createApp } from "vue"; import App from "./App.vue"; +import router from "./router"; import "./style.css"; -createApp(App).mount("#app"); +createApp(App).use(router).mount("#app"); diff --git a/centers/analytics/frontend/src/router/index.ts b/centers/analytics/frontend/src/router/index.ts new file mode 100644 index 0000000..9f856ed --- /dev/null +++ b/centers/analytics/frontend/src/router/index.ts @@ -0,0 +1,27 @@ +import { createRouter, createWebHistory } from "vue-router"; + +import AppLayout from "../layouts/AppLayout.vue"; +import AnalyticsView from "../views/AnalyticsView.vue"; +import ConsumersView from "../views/ConsumersView.vue"; +import EventsView from "../views/EventsView.vue"; +import MapViewPage from "../views/MapViewPage.vue"; +import ParsersView from "../views/ParsersView.vue"; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/", + component: AppLayout, + children: [ + { path: "", name: "map", component: MapViewPage }, + { path: "parsers", name: "parsers", component: ParsersView }, + { path: "events", name: "events", component: EventsView }, + { path: "analytics", name: "analytics", component: AnalyticsView }, + { path: "consumers", name: "consumers", component: ConsumersView }, + ], + }, + ], +}); + +export default router; diff --git a/centers/analytics/frontend/src/style.css b/centers/analytics/frontend/src/style.css index 66dfe3f..84f044c 100644 --- a/centers/analytics/frontend/src/style.css +++ b/centers/analytics/frontend/src/style.css @@ -20,3 +20,278 @@ select, textarea { font: inherit; } + +/* Admin UI */ + +.page { + padding: 1.25rem; + max-width: 1400px; +} + +.page-heading { + margin: 0 0 1rem; + font-size: 1.25rem; + font-weight: 600; +} + +.card { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} + +.card h3 { + margin: 0 0 0.75rem; + font-size: 1rem; + font-weight: 600; +} + +.muted { + color: #999; + font-weight: 400; +} + +.admin-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8rem; + color: #555; +} + +.admin-form input, +.admin-form select, +.admin-form textarea { + padding: 0.4rem 0.6rem; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 0.875rem; +} + +.form-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.form-row label { + flex: 1 1 180px; +} + +.form-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.form-error { + color: #c62828; + font-size: 0.875rem; + margin-top: 0.5rem; +} + +.btn { + padding: 0.4rem 0.85rem; + border: 1px solid #ccc; + border-radius: 6px; + background: #fff; + cursor: pointer; + font-size: 0.875rem; +} + +.btn:hover { + background: #f5f5f5; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: #1565c0; + color: #fff; + border-color: #1565c0; +} + +.btn-primary:hover { + background: #0d47a1; +} + +.btn-sm { + padding: 0.25rem 0.55rem; + font-size: 0.8rem; +} + +.link-btn { + background: none; + border: none; + color: #1565c0; + cursor: pointer; + padding: 0; + text-align: left; + font-size: inherit; +} + +.link-btn:hover { + text-decoration: underline; +} + +.table-wrap { + overflow-x: auto; +} + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.admin-table th, +.admin-table td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid #eee; +} + +.admin-table th { + font-weight: 600; + color: #555; + background: #fafafa; +} + +.admin-table .empty { + text-align: center; + color: #999; + padding: 1.5rem; +} + +.admin-table .mono { + font-family: ui-monospace, monospace; + font-size: 0.8rem; +} + +.admin-table .error-cell { + color: #c62828; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.table-header { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: #666; +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-top: 0.75rem; + font-size: 0.875rem; +} + +.badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.badge-success { + background: #e8f5e9; + color: #2e7d32; +} + +.badge-error { + background: #ffebee; + color: #c62828; +} + +.badge-running { + background: #e3f2fd; + color: #1565c0; +} + +.badge-pending { + background: #fff3e0; + color: #e65100; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: #fff; + border-radius: 10px; + width: 90%; + max-width: 560px; + max-height: 85vh; + overflow: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid #eee; +} + +.modal-header h3 { + margin: 0; + font-size: 1rem; +} + +.modal-body { + padding: 1rem 1.25rem; +} + +.modal-footer { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + border-top: 1px solid #eee; +} + +.detail-list { + display: grid; + grid-template-columns: 140px 1fr; + gap: 0.5rem 1rem; + font-size: 0.875rem; +} + +.detail-list dt { + color: #666; + font-weight: 500; +} + +.detail-list dd { + margin: 0; +} + +.detail-list .raw-text { + white-space: pre-wrap; + max-height: 200px; + overflow: auto; + background: #f9f9f9; + padding: 0.5rem; + border-radius: 4px; +} + diff --git a/centers/analytics/frontend/src/types/admin.ts b/centers/analytics/frontend/src/types/admin.ts new file mode 100644 index 0000000..d9e836e --- /dev/null +++ b/centers/analytics/frontend/src/types/admin.ts @@ -0,0 +1,97 @@ +export interface ParseJob { + id: number; + source_type: string; + source_config: Record; + schedule: string | null; + status: string; + last_run_at: string | null; + last_error: string | null; + created_at: string; +} + +export interface ParseJobCreate { + source_type: string; + source_config: Record; + schedule?: string | null; +} + +export interface EventRecord { + id: number; + source_type: string; + source_url: string; + raw_text: string; + title: string; + description: string; + locality: string; + latitude: number | null; + longitude: number | null; + event_date: string | null; + ingested_at: string; + region: string | null; + topic: string | null; + tags: string[] | null; + metadata: Record | null; +} + +export interface EventListResponse { + items: EventRecord[]; + total: number; +} + +export interface EventFilters { + source_type?: string; + region?: string; + topic?: string; + locality?: string; + date_from?: string; + date_to?: string; + search?: string; + limit?: number; + offset?: number; +} + +export interface Consumer { + id: number; + name: string; + is_active: boolean; + created_at: string; + api_key?: string | null; + regions: string[] | null; + topics: string[] | null; + date_from: string | null; +} + +export interface ConsumerCreate { + name: string; + regions?: string[] | null; + topics?: string[] | null; + date_from?: string | null; +} + +export interface ConsumerUpdate { + name?: string; + is_active?: boolean; + regions?: string[] | null; + topics?: string[] | null; + date_from?: string | null; +} + +export interface AnalyticsSummary { + total_events: number; + events_with_coords: number; + events_last_24h: number; + total_consumers: number; + active_consumers: number; + total_jobs: number; + pending_jobs: number; +} + +export interface TimelinePoint { + date: string; + count: number; +} + +export interface TopItem { + name: string; + count: number; +} diff --git a/centers/analytics/frontend/src/types/event.ts b/centers/analytics/frontend/src/types/event.ts new file mode 100644 index 0000000..94b45a5 --- /dev/null +++ b/centers/analytics/frontend/src/types/event.ts @@ -0,0 +1,17 @@ +export interface EventRead { + id: number; + source_type: string; + source_url: string; + raw_text: string; + title: string; + description: string; + locality: string; + latitude: number | null; + longitude: number | null; + event_date: string | null; + ingested_at: string; + region: string | null; + topic: string | null; + tags: string[] | null; + metadata: Record | null; +} diff --git a/centers/analytics/frontend/src/types/object.ts b/centers/analytics/frontend/src/types/object.ts index 4a13eea..6e73035 100644 --- a/centers/analytics/frontend/src/types/object.ts +++ b/centers/analytics/frontend/src/types/object.ts @@ -8,6 +8,7 @@ export interface MapObject { latitude: number; longitude: number; created_at: string; + event_id?: number | null; } export interface MapObjectCreate { diff --git a/centers/analytics/frontend/src/views/AnalyticsView.vue b/centers/analytics/frontend/src/views/AnalyticsView.vue new file mode 100644 index 0000000..9e799b6 --- /dev/null +++ b/centers/analytics/frontend/src/views/AnalyticsView.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/centers/analytics/frontend/src/views/ConsumersView.vue b/centers/analytics/frontend/src/views/ConsumersView.vue new file mode 100644 index 0000000..c3b6435 --- /dev/null +++ b/centers/analytics/frontend/src/views/ConsumersView.vue @@ -0,0 +1,438 @@ + + + + + diff --git a/centers/analytics/frontend/src/views/EventsView.vue b/centers/analytics/frontend/src/views/EventsView.vue new file mode 100644 index 0000000..dd30295 --- /dev/null +++ b/centers/analytics/frontend/src/views/EventsView.vue @@ -0,0 +1,261 @@ + + + diff --git a/centers/analytics/frontend/src/views/MapViewPage.vue b/centers/analytics/frontend/src/views/MapViewPage.vue new file mode 100644 index 0000000..d274332 --- /dev/null +++ b/centers/analytics/frontend/src/views/MapViewPage.vue @@ -0,0 +1,383 @@ + + + + + diff --git a/centers/analytics/frontend/src/views/ParsersView.vue b/centers/analytics/frontend/src/views/ParsersView.vue new file mode 100644 index 0000000..75a3bff --- /dev/null +++ b/centers/analytics/frontend/src/views/ParsersView.vue @@ -0,0 +1,161 @@ + + + diff --git a/centers/analytics/frontend/vite.config.ts b/centers/analytics/frontend/vite.config.ts index d401d30..e8afb50 100644 --- a/centers/analytics/frontend/vite.config.ts +++ b/centers/analytics/frontend/vite.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ server: { proxy: { "/api": "http://localhost:8000", + "/admin": "http://localhost:8000", }, }, }); diff --git a/docker-compose.yml b/docker-compose.yml index 520fba6..fced6c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,8 +52,8 @@ services: REDIS_URL: redis://redis:6379/0 INTERNAL_TOKEN: dev-internal-token volumes: - # Mount original session from SocialParser (preserved, read-only) - - ../SocialParser/data/telegram.session:/data/telegram.session:ro + # Mount SocialParser data dir (session + SQLite journal); same file as on host + - ../SocialParser/data:/data depends_on: - redis - ca-api