Add Telegram listener, scheduler, and extended parsers admin UI.

Introduce background scheduling and migrations for analytics ingest, expand parser management and map toolbar UX, and ignore local data/session files from version control.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-02 20:47:56 +03:00
co-authored by Cursor
parent 1d6d54ab9f
commit 1492576fd9
23 changed files with 1124 additions and 267 deletions
+5
View File
@@ -6,6 +6,8 @@ from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine, get_db
from .routers import admin, internal, map, objects, v1
from .seed import seed_objects, seed_test_consumer
from .services.migrations import migrate_schema
from .services.scheduler import start_scheduler
from .storage import ensure_upload_dir
@@ -13,6 +15,8 @@ from .storage import ensure_upload_dir
async def lifespan(_: FastAPI):
ensure_upload_dir()
Base.metadata.create_all(bind=engine)
migrate_schema(engine)
scheduler_stop = start_scheduler()
db = next(get_db())
try:
seed_objects(db)
@@ -20,6 +24,7 @@ async def lifespan(_: FastAPI):
finally:
db.close()
yield
scheduler_stop.set()
app = FastAPI(title="CA API (Analytics Center)", lifespan=lifespan)
+2
View File
@@ -100,6 +100,8 @@ class ParseJob(Base):
source_type: Mapped[str] = mapped_column(String(50), nullable=False)
source_config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
schedule: Mapped[str | None] = mapped_column(String(100), nullable=True)
interval_seconds: Mapped[int] = mapped_column(Integer, default=3600, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
status: Mapped[str] = mapped_column(String(50), default="pending", index=True)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -14,6 +14,7 @@ from ..schemas import (
EventRead,
ParseJobCreate,
ParseJobRead,
ParseJobUpdate,
TimelinePoint,
TopItem,
)
@@ -42,6 +43,8 @@ def create_parse_job(payload: ParseJobCreate, db: Session = Depends(get_db)):
source_type=payload.source_type,
source_config=payload.source_config,
schedule=payload.schedule,
interval_seconds=payload.interval_seconds,
is_active=payload.is_active,
status="queued",
)
db.add(job)
@@ -70,6 +73,8 @@ 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")
if job.status in ("queued", "running"):
raise HTTPException(status_code=409, detail="Job is already running or queued")
job.status = "queued"
job.last_error = None
@@ -80,6 +85,40 @@ def retry_parse_job(job_id: int, db: Session = Depends(get_db)):
return job
@router.patch("/jobs/{job_id}", response_model=ParseJobRead)
def update_parse_job(
job_id: int,
payload: ParseJobUpdate,
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")
if payload.source_config is not None:
job.source_config = payload.source_config
if payload.interval_seconds is not None:
job.interval_seconds = payload.interval_seconds
if payload.is_active is not None:
job.is_active = payload.is_active
db.commit()
db.refresh(job)
return job
@router.delete("/jobs/{job_id}", status_code=204)
def delete_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")
if job.status == "running":
raise HTTPException(status_code=409, detail="Cannot delete a running job")
db.delete(job)
db.commit()
@router.get("/events", response_model=EventListResponse)
def list_events(
limit: int = Query(default=50, ge=1, le=1000),
+33 -3
View File
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import verify_internal_token
from ..models import ParseJob
from ..schemas import IngestRequest, IngestResponse
from ..schemas import IngestRequest, IngestResponse, ListenerSubscription
from ..services.ingest import ingest_events
router = APIRouter(prefix="/internal", tags=["internal"])
@@ -18,10 +18,16 @@ def internal_ingest(
_: None = Depends(verify_internal_token),
db: Session = Depends(get_db),
):
ingested, updated, map_synced = ingest_events(db, payload.events, payload.job_id)
ingested, updated, skipped, map_synced = ingest_events(
db,
payload.events,
payload.job_id,
touch_job=not payload.listener,
)
return IngestResponse(
ingested=ingested,
updated=updated,
skipped=skipped,
map_objects_synced=map_synced,
)
@@ -39,7 +45,31 @@ def update_job_status(
raise HTTPException(status_code=404, detail="Job not found")
job.status = status
job.last_run_at = datetime.now(timezone.utc)
if status in ("completed", "failed"):
job.last_run_at = datetime.now(timezone.utc)
job.last_error = error
db.commit()
return {"id": job.id, "status": job.status}
@router.get("/listener/subscriptions", response_model=list[ListenerSubscription])
def listener_subscriptions(
_: None = Depends(verify_internal_token),
db: Session = Depends(get_db),
):
jobs = (
db.query(ParseJob)
.filter(
ParseJob.is_active.is_(True),
ParseJob.source_type == "telegram",
)
.order_by(ParseJob.id.asc())
.all()
)
result: list[ListenerSubscription] = []
for job in jobs:
channel = job.source_config.get("channel") if job.source_config else None
if not channel:
continue
result.append(ListenerSubscription(job_id=job.id, channel=str(channel)))
return result
+17
View File
@@ -111,11 +111,18 @@ class IngestEventItem(BaseModel):
class IngestRequest(BaseModel):
job_id: int | None = None
events: list[IngestEventItem] = Field(default_factory=list)
listener: bool = False
class ListenerSubscription(BaseModel):
job_id: int
channel: str
class IngestResponse(BaseModel):
ingested: int
updated: int
skipped: int = 0
map_objects_synced: int
@@ -123,6 +130,14 @@ class ParseJobCreate(BaseModel):
source_type: str = "telegram"
source_config: dict[str, Any] = Field(default_factory=dict)
schedule: str | None = None
interval_seconds: int = Field(default=3600, ge=60, le=604800)
is_active: bool = True
class ParseJobUpdate(BaseModel):
source_config: dict[str, Any] | None = None
interval_seconds: int | None = Field(default=None, ge=60, le=604800)
is_active: bool | None = None
class ParseJobRead(BaseModel):
@@ -132,6 +147,8 @@ class ParseJobRead(BaseModel):
source_type: str
source_config: dict[str, Any]
schedule: str | None
interval_seconds: int
is_active: bool
status: str
last_run_at: datetime | None
last_error: str | None
+26 -35
View File
@@ -9,9 +9,12 @@ def ingest_events(
db: Session,
items: list[IngestEventItem],
job_id: int | None = None,
) -> tuple[int, int, int]:
*,
touch_job: bool = True,
) -> tuple[int, int, int, int]:
ingested = 0
updated = 0
skipped = 0
map_synced = 0
for item in items:
@@ -22,44 +25,32 @@ def ingest_events(
)
if existing:
existing.source_type = item.source_type
existing.raw_text = item.raw_text
existing.title = item.title
existing.description = item.description
existing.locality = item.locality
existing.latitude = item.latitude
existing.longitude = item.longitude
existing.event_date = item.event_date
existing.region = item.region
existing.topic = item.topic
existing.tags = item.tags
existing.metadata_ = item.metadata
event = existing
updated += 1
else:
event = Event(
source_type=item.source_type,
source_url=item.source_url,
raw_text=item.raw_text,
title=item.title,
description=item.description,
locality=item.locality,
latitude=item.latitude,
longitude=item.longitude,
event_date=item.event_date,
region=item.region,
topic=item.topic,
tags=item.tags,
metadata_=item.metadata,
)
db.add(event)
ingested += 1
skipped += 1
continue
event = Event(
source_type=item.source_type,
source_url=item.source_url,
raw_text=item.raw_text,
title=item.title,
description=item.description,
locality=item.locality,
latitude=item.latitude,
longitude=item.longitude,
event_date=item.event_date,
region=item.region,
topic=item.topic,
tags=item.tags,
metadata_=item.metadata,
)
db.add(event)
ingested += 1
db.flush()
if sync_event_to_map_object(db, event):
map_synced += 1
if job_id is not None:
if touch_job and job_id is not None:
job = db.query(ParseJob).filter(ParseJob.id == job_id).first()
if job:
from datetime import datetime, timezone
@@ -69,4 +60,4 @@ def ingest_events(
job.last_error = None
db.commit()
return ingested, updated, map_synced
return ingested, updated, skipped, map_synced
@@ -0,0 +1,28 @@
from sqlalchemy import inspect, text
from sqlalchemy.engine import Engine
def migrate_schema(engine: Engine) -> None:
"""Apply lightweight schema updates for existing deployments."""
inspector = inspect(engine)
if "parse_jobs" not in inspector.get_table_names():
return
columns = {col["name"] for col in inspector.get_columns("parse_jobs")}
statements: list[str] = []
if "interval_seconds" not in columns:
statements.append(
"ALTER TABLE parse_jobs ADD COLUMN interval_seconds INTEGER NOT NULL DEFAULT 3600"
)
if "is_active" not in columns:
statements.append(
"ALTER TABLE parse_jobs ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE"
)
if not statements:
return
with engine.begin() as conn:
for stmt in statements:
conn.execute(text(stmt))
@@ -0,0 +1,58 @@
import logging
import threading
import time
from datetime import datetime, timezone
from ..database import SessionLocal
from ..models import ParseJob
from .jobs import enqueue_job
logger = logging.getLogger(__name__)
TICK_SECONDS = 30
RECURRING_STATUSES = ("completed", "failed")
def run_scheduler_tick() -> None:
db = SessionLocal()
try:
now = datetime.now(timezone.utc)
jobs = (
db.query(ParseJob)
.filter(
ParseJob.is_active.is_(True),
ParseJob.interval_seconds > 0,
ParseJob.status.in_(RECURRING_STATUSES),
)
.all()
)
for job in jobs:
if job.last_run_at is None:
continue
elapsed = (now - job.last_run_at).total_seconds()
if elapsed < job.interval_seconds:
continue
job.status = "queued"
job.last_error = None
db.commit()
enqueue_job(job.id, job.source_type, job.source_config)
logger.info("Re-queued recurring job %s (interval %ss)", job.id, job.interval_seconds)
except Exception:
logger.exception("Scheduler tick failed")
db.rollback()
finally:
db.close()
def _scheduler_loop(stop_event: threading.Event) -> None:
while not stop_event.wait(TICK_SECONDS):
run_scheduler_tick()
def start_scheduler() -> threading.Event:
stop_event = threading.Event()
thread = threading.Thread(target=_scheduler_loop, args=(stop_event,), daemon=True)
thread.start()
logger.info("Parse job scheduler started (tick every %ss)", TICK_SECONDS)
return stop_event
@@ -9,6 +9,7 @@ import type {
EventRecord,
ParseJob,
ParseJobCreate,
ParseJobUpdate,
TimelinePoint,
TopItem,
} from "../types/admin";
@@ -44,6 +45,22 @@ export function retryJob(jobId: number): Promise<ParseJob> {
);
}
export function updateJob(jobId: number, payload: ParseJobUpdate): Promise<ParseJob> {
return request<ParseJob>(
`/jobs/${jobId}`,
{ method: "PATCH", body: JSON.stringify(payload) },
ADMIN_BASE,
);
}
export function deleteJob(jobId: number): Promise<void> {
return request<void>(
`/jobs/${jobId}`,
{ method: "DELETE" },
ADMIN_BASE,
);
}
export function fetchEvents(filters: EventFilters = {}): Promise<EventListResponse> {
return request<EventListResponse>(
`/events${buildQuery(filters as Record<string, string | number | undefined>)}`,
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from "vue";
import { ref, watch } from "vue";
import { CITY_PRESETS } from "../../config/cities";
import type { LeafletMapApi } from "../../composables/useLeafletMap";
import { parseCoordsInput } from "../../composables/usePlaceSearch";
@@ -16,13 +16,13 @@ function formatCoords(lat: number, lng: number): string {
return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch {
// ignore
}
}
watch(
() => props.centerCoords,
(coords) => {
coordsInput.value = formatCoords(coords.lat, coords.lng);
},
{ immediate: true, deep: true },
);
function centerOnInput() {
const parsed = parseCoordsInput(coordsInput.value);
@@ -36,37 +36,37 @@ function centerOnCity() {
props.mapApi.flyTo(city.latitude, city.longitude, city.zoom ?? 12);
}
function copyCenter() {
void copyText(formatCoords(props.centerCoords.lat, props.centerCoords.lng));
}
function copyInput() {
void copyText(coordsInput.value);
function clearCoords() {
coordsInput.value = "";
}
</script>
<template>
<div class="coords-tools">
<label class="coords-label">Центрировать на:</label>
<input
v-model="coordsInput"
type="text"
class="coord-input"
placeholder="48.65, 37.67"
@keydown.enter="centerOnInput"
/>
<button type="button" class="icon-btn" title="Перейти" @click="centerOnInput"></button>
<button type="button" class="icon-btn" title="Копировать" @click="copyInput"></button>
<div class="coords-tools toolbar-group">
<div class="coord-field">
<input
v-model="coordsInput"
type="text"
class="coord-input"
placeholder="lat, lng"
title="Координаты центра"
@keydown.enter="centerOnInput"
/>
<button
v-if="coordsInput"
type="button"
class="coord-clear"
title="Очистить"
@click="clearCoords"
>×</button>
</div>
<button type="button" class="icon-btn" title="Перейти к координатам" @click="centerOnInput"></button>
<select v-model="selectedCity" class="city-select" @change="centerOnCity">
<option value="" disabled>🏘 Город</option>
<select v-model="selectedCity" class="city-select" title="Город" @change="centerOnCity">
<option value="" disabled>🏘</option>
<option v-for="city in CITY_PRESETS" :key="city.name" :value="city.name">
{{ city.name }}
</option>
</select>
<span class="current-center-label">Текущий центр:</span>
<span class="current-coords">{{ formatCoords(centerCoords.lat, centerCoords.lng) }}</span>
<button type="button" class="icon-btn" title="Копировать центр" @click="copyCenter"></button>
</div>
</template>
@@ -21,6 +21,8 @@ const props = defineProps<{
topic: string;
sourceType: string;
loading?: boolean;
statusText?: string;
statusError?: boolean;
}>();
const emit = defineEmits<{
@@ -33,8 +35,10 @@ const emit = defineEmits<{
}>();
const dateInput = ref<HTMLInputElement | null>(null);
const rangeBtnRef = ref<HTMLButtonElement | null>(null);
const rangeOpen = ref(false);
const filtersOpen = ref(false);
const dropdownPos = ref({ top: 0, left: 0 });
let picker: flatpickr.Instance | null = null;
const availableDates = computed(() => props.filters?.available_dates ?? []);
@@ -74,6 +78,31 @@ function selectRange(preset: DateRangePreset) {
emit("apply");
}
function toggleRangeOpen(event: MouseEvent) {
event.stopPropagation();
if (!rangeOpen.value && rangeBtnRef.value) {
const rect = rangeBtnRef.value.getBoundingClientRect();
dropdownPos.value = { top: rect.bottom + 4, left: rect.left };
}
rangeOpen.value = !rangeOpen.value;
if (rangeOpen.value) {
filtersOpen.value = false;
}
}
function toggleFiltersOpen(event: MouseEvent) {
event.stopPropagation();
filtersOpen.value = !filtersOpen.value;
if (filtersOpen.value) {
rangeOpen.value = false;
}
}
function closeDropdowns() {
rangeOpen.value = false;
filtersOpen.value = false;
}
function resetFilters() {
emit("update:region", "");
emit("update:topic", "");
@@ -82,6 +111,8 @@ function resetFilters() {
}
onMounted(() => {
document.addEventListener("click", closeDropdowns);
if (!dateInput.value) return;
picker = flatpickr(dateInput.value, {
locale: Russian,
@@ -101,6 +132,7 @@ onMounted(() => {
});
onUnmounted(() => {
document.removeEventListener("click", closeDropdowns);
picker?.destroy();
});
@@ -115,7 +147,8 @@ watch(
<template>
<div class="map-toolbar">
<div class="date-navigator">
<div class="map-toolbar-inner">
<div class="toolbar-group date-navigator">
<button type="button" class="nav-btn" title="Первая дата" @click="navigateDate('first')"></button>
<button type="button" class="nav-btn" title="Предыдущий" @click="navigateDate('prev')"></button>
<div class="date-selector">
@@ -126,34 +159,46 @@ watch(
<div class="filter-btn-container">
<button
ref="rangeBtnRef"
type="button"
class="filter-btn"
title="Период"
:class="{ active: rangePreset !== 'all' }"
@click="rangeOpen = !rangeOpen"
@click="toggleRangeOpen"
></button>
<div v-if="rangeOpen" class="dropdown-content">
<button
v-for="opt in RANGE_OPTIONS"
:key="opt.value"
type="button"
class="range-option"
:class="{ active: rangePreset === opt.value }"
@click="selectRange(opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
<div class="filter-row">
<button type="button" class="filter-btn mobile-toggle" @click="filtersOpen = !filtersOpen">📁</button>
<Teleport to="body">
<div
v-if="rangeOpen"
class="dropdown-content range-dropdown dropdown-fixed"
:style="{ top: `${dropdownPos.top}px`, left: `${dropdownPos.left}px` }"
@click.stop
>
<button
v-for="opt in RANGE_OPTIONS"
:key="opt.value"
type="button"
class="range-option"
:class="{ active: rangePreset === opt.value }"
@click="selectRange(opt.value)"
>
{{ opt.label }}
</button>
</div>
</Teleport>
<div class="filter-controls" :class="{ open: filtersOpen }">
<div class="toolbar-sep" />
<div class="toolbar-group filter-row">
<button type="button" class="filter-btn mobile-toggle" title="Фильтры" @click="toggleFiltersOpen">📁</button>
<div class="filter-controls" :class="{ open: filtersOpen }" @click.stop>
<select
:value="region"
class="filter-select"
title="Регион"
@change="emit('update:region', ($event.target as HTMLSelectElement).value); emit('apply')"
>
<option value="">Все регионы</option>
@@ -163,6 +208,7 @@ watch(
<select
:value="topic"
class="filter-select"
title="Тема"
@change="emit('update:topic', ($event.target as HTMLSelectElement).value); emit('apply')"
>
<option value="">Все темы</option>
@@ -172,6 +218,7 @@ watch(
<select
:value="sourceType"
class="filter-select"
title="Источник"
@change="emit('update:sourceType', ($event.target as HTMLSelectElement).value); emit('apply')"
>
<option value="">Все источники</option>
@@ -180,8 +227,22 @@ watch(
<button type="button" class="btn btn-sm" @click="resetFilters">Сбросить</button>
</div>
</div>
<span v-if="loading" class="toolbar-status">Загрузка...</span>
<div class="toolbar-sep" />
<slot name="coords" />
<div class="toolbar-sep" />
<slot name="search" />
<span
v-if="statusText"
class="toolbar-status"
:class="{ 'toolbar-status-error': statusError }"
>{{ statusText }}</span>
<span v-else-if="loading" class="toolbar-status">Загрузка...</span>
</div>
</div>
</template>
@@ -30,12 +30,13 @@ function selectPlace(place: PlaceSearchResult) {
</script>
<template>
<div class="place-search">
<div class="place-search toolbar-group">
<input
v-model="query"
type="text"
class="search-input"
placeholder="Поиск населённого пункта"
placeholder="Поиск НП"
title="Поиск населённого пункта"
@keydown.enter="runSearch"
/>
<button type="button" class="icon-btn" title="Найти" :disabled="searching" @click="runSearch">
@@ -1,33 +1,62 @@
.map-toolbar {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #fff;
border-bottom: 1px solid #e0e0e0;
position: relative;
z-index: 50;
flex-shrink: 0;
background: #d4d4d4;
border-bottom: 1px solid #b0b0b0;
overflow: visible;
}
.map-toolbar-inner {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
min-height: 38px;
overflow-x: auto;
overflow-y: visible;
}
.toolbar-group {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.3rem;
flex-shrink: 0;
}
.toolbar-sep {
width: 1px;
height: 24px;
background: #aaa;
flex-shrink: 0;
margin: 0 0.15rem;
}
.date-navigator {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 0.35rem;
gap: 0.25rem;
}
.nav-btn,
.filter-btn {
width: 30px;
height: 30px;
border: 1px solid #ccc;
border-radius: 4px;
background: #f0f0f0;
width: 28px;
height: 28px;
min-width: 28px;
border: 1px solid #aaa;
border-radius: 3px;
background: #ececec;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
font-size: 0.8rem;
padding: 0;
flex-shrink: 0;
}
.nav-btn:hover,
@@ -36,22 +65,37 @@
}
.date-selector {
width: 90px;
width: 82px;
flex-shrink: 0;
}
.date-picker-input {
width: 100%;
height: 30px;
padding: 0 6px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.85rem;
height: 28px;
padding: 0 4px;
border: 1px solid #aaa;
border-radius: 3px;
font-size: 0.8rem;
text-align: center;
box-sizing: border-box;
background: #fff;
}
.filter-btn-container {
position: relative;
z-index: 60;
}
.dropdown-content {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 1100;
background: #fff;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
min-width: 140px;
}
.filter-btn.active {
@@ -59,17 +103,13 @@
background: #e6f2ff;
}
.dropdown-content {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
background: #fff;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-top: 4px;
min-width: 140px;
.range-dropdown {
white-space: nowrap;
}
.dropdown-fixed {
position: fixed;
z-index: 10000;
}
.range-option {
@@ -94,88 +134,127 @@
.filter-row {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 0.5rem;
gap: 0.3rem;
}
.filter-controls {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 0.5rem;
gap: 0.3rem;
}
.filter-select {
height: 30px;
padding: 0 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.85rem;
height: 28px;
padding: 0 6px;
border: 1px solid #aaa;
border-radius: 3px;
font-size: 0.78rem;
max-width: 130px;
background: #fff;
}
.filter-select option {
max-width: 200px;
}
.toolbar-status {
margin-left: auto;
font-size: 0.8rem;
color: #888;
padding-left: 0.5rem;
font-size: 0.75rem;
color: #555;
white-space: nowrap;
flex-shrink: 0;
}
.toolbar-status-error {
color: #c62828;
}
.coords-tools {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.75rem;
background: #fafafa;
border-bottom: 1px solid #eee;
font-size: 0.85rem;
gap: 0.25rem;
font-size: 0.8rem;
}
.coords-label,
.current-center-label {
color: #555;
.coord-field {
position: relative;
display: flex;
align-items: center;
}
.coord-input,
.search-input {
height: 28px;
padding: 0 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.85rem;
width: 150px;
padding: 0 22px 0 8px;
border: 1px solid #aaa;
border-radius: 3px;
font-size: 0.78rem;
background: #fff;
box-sizing: border-box;
}
.coord-input {
width: 200px;
font-family: ui-monospace, monospace;
}
.search-input {
width: 140px;
}
.coord-clear {
position: absolute;
right: 4px;
width: 18px;
height: 18px;
border: none;
background: transparent;
color: #888;
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
}
.coord-clear:hover {
color: #333;
}
.city-select {
height: 28px;
padding: 0 6px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.85rem;
}
.current-coords {
font-family: ui-monospace, monospace;
background: #f5f5f5;
padding: 2px 6px;
max-width: 110px;
padding: 0 4px;
border: 1px solid #aaa;
border-radius: 3px;
font-size: 0.78rem;
background: #fff;
}
.icon-btn {
width: 28px;
height: 28px;
border: 1px solid #ccc;
border-radius: 4px;
background: #f0f0f0;
min-width: 28px;
border: 1px solid #aaa;
border-radius: 3px;
background: #ececec;
cursor: pointer;
font-size: 0.85rem;
flex-shrink: 0;
}
.icon-btn:hover {
background: #e0e0e0;
}
.place-search {
display: flex;
align-items: center;
gap: 0.35rem;
gap: 0.25rem;
position: relative;
}
@@ -194,6 +273,7 @@
max-height: 200px;
overflow-y: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 260px;
}
.result-btn {
@@ -222,16 +302,6 @@
background: #fff;
}
.map-tools-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background: #fafafa;
border-bottom: 1px solid #eee;
}
.map-content {
position: relative;
display: flex;
@@ -240,35 +310,67 @@
overflow: hidden;
}
.map-status {
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
color: #666;
background: #fff;
border-bottom: 1px solid #eee;
.map-toolbar .btn-sm {
height: 28px;
padding: 0 8px;
font-size: 0.75rem;
}
.map-status.error {
color: #c62828;
}
@media (max-width: 768px) {
@media (max-width: 1100px) {
.filter-controls {
display: none;
width: 100%;
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
flex-direction: column;
align-items: stretch;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
min-width: 200px;
}
.filter-row {
position: relative;
}
.filter-controls.open {
display: flex;
}
.filter-select {
max-width: none;
width: 100%;
}
.mobile-toggle {
display: flex;
}
}
@media (min-width: 769px) {
@media (min-width: 1101px) {
.mobile-toggle {
display: none;
}
}
@media (max-width: 768px) {
.coord-input {
width: 150px;
}
.search-input {
width: 110px;
}
.city-select {
max-width: 80px;
}
.toolbar-sep {
display: none;
}
}
@@ -3,6 +3,8 @@ export interface ParseJob {
source_type: string;
source_config: Record<string, unknown>;
schedule: string | null;
interval_seconds: number;
is_active: boolean;
status: string;
last_run_at: string | null;
last_error: string | null;
@@ -13,6 +15,14 @@ export interface ParseJobCreate {
source_type: string;
source_config: Record<string, unknown>;
schedule?: string | null;
interval_seconds?: number;
is_active?: boolean;
}
export interface ParseJobUpdate {
source_config?: Record<string, unknown>;
interval_seconds?: number;
is_active?: boolean;
}
export interface EventRecord {
@@ -309,16 +309,17 @@ onUnmounted(() => {
v-model:source-type="sourceType"
:filters="mapFilters"
:loading="loading"
:status-text="error || `${objects.length} объектов`"
:status-error="Boolean(error)"
@apply="loadObjects"
/>
<div class="map-tools-row">
<CoordsTools :map-api="mapApi" :center-coords="centerCoords" />
<PlaceSearch :map-api="mapApi" />
</div>
<div v-if="error" class="map-status error">{{ error }}</div>
<div v-else class="map-status">{{ objects.length }} объектов на карте</div>
>
<template #coords>
<CoordsTools :map-api="mapApi" :center-coords="centerCoords" />
</template>
<template #search>
<PlaceSearch :map-api="mapApi" />
</template>
</MapToolbar>
<div class="map-content">
<MapView
@@ -1,21 +1,55 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { createJob, fetchJobs, retryJob } from "../api/admin";
import { createJob, deleteJob, fetchJobs, retryJob, updateJob } from "../api/admin";
import type { ParseJob } from "../types/admin";
const INTERVAL_OPTIONS = [
{ value: 900, label: "15 мин" },
{ value: 1800, label: "30 мин" },
{ value: 3600, label: "1 ч" },
{ value: 10800, label: "3 ч" },
{ value: 21600, label: "6 ч" },
{ value: 86400, label: "24 ч" },
];
const jobs = ref<ParseJob[]>([]);
const loading = ref(true);
const error = ref("");
const submitting = ref(false);
const editingJob = ref<ParseJob | null>(null);
const editForm = ref({
channel: "",
limit: 50,
interval_seconds: 3600,
is_active: true,
});
const form = ref({
source_type: "telegram",
channel: "",
limit: 50,
interval_seconds: 3600,
});
let pollTimer: ReturnType<typeof setInterval> | null = null;
function channelFromConfig(config: Record<string, unknown>): string {
return typeof config.channel === "string" ? config.channel : "";
}
function limitFromConfig(config: Record<string, unknown>): number {
const limit = config.limit;
return typeof limit === "number" ? limit : 50;
}
function formatInterval(seconds: number): string {
const opt = INTERVAL_OPTIONS.find((item) => item.value === seconds);
if (opt) return opt.label;
if (seconds < 3600) return `${Math.round(seconds / 60)} мин`;
if (seconds < 86400) return `${Math.round(seconds / 3600)} ч`;
return `${Math.round(seconds / 86400)} д`;
}
async function loadJobs() {
try {
jobs.value = await fetchJobs();
@@ -42,6 +76,8 @@ async function handleSubmit() {
channel: form.value.channel.trim(),
limit: form.value.limit,
},
interval_seconds: form.value.interval_seconds,
is_active: true,
});
form.value.channel = "";
await loadJobs();
@@ -52,6 +88,61 @@ async function handleSubmit() {
}
}
function openEdit(job: ParseJob) {
editingJob.value = job;
editForm.value = {
channel: channelFromConfig(job.source_config),
limit: limitFromConfig(job.source_config),
interval_seconds: job.interval_seconds,
is_active: job.is_active,
};
}
function closeEdit() {
editingJob.value = null;
}
async function handleSaveEdit() {
if (!editingJob.value) return;
if (!editForm.value.channel.trim()) {
error.value = "Укажите канал Telegram";
return;
}
submitting.value = true;
error.value = "";
try {
await updateJob(editingJob.value.id, {
source_config: {
channel: editForm.value.channel.trim(),
limit: editForm.value.limit,
},
interval_seconds: editForm.value.interval_seconds,
is_active: editForm.value.is_active,
});
closeEdit();
await loadJobs();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось сохранить задание";
} finally {
submitting.value = false;
}
}
async function handleDelete(job: ParseJob) {
if (!window.confirm(`Удалить парсер #${job.id} (${channelFromConfig(job.source_config)})?`)) {
return;
}
error.value = "";
try {
await deleteJob(job.id);
if (editingJob.value?.id === job.id) closeEdit();
await loadJobs();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось удалить задание";
}
}
async function handleRetry(jobId: number) {
try {
await retryJob(jobId);
@@ -88,7 +179,12 @@ onUnmounted(() => {
<h2 class="page-heading">Парсеры</h2>
<section class="card">
<h3>Новое задание</h3>
<h3>Новый парсер</h3>
<p class="hint">
Активные парсеры подхватываются real-time listener (новые посты сразу в БД).
Дополнительно batch-прогон по интервалу забирает последние N постов.
Дубликаты по <code>source_url</code> не записываются.
</p>
<form class="admin-form" @submit.prevent="handleSubmit">
<div class="form-row">
<label>
@@ -105,10 +201,18 @@ onUnmounted(() => {
Лимит
<input v-model.number="form.limit" type="number" min="1" max="1000" />
</label>
<label>
Интервал
<select v-model.number="form.interval_seconds">
<option v-for="opt in INTERVAL_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? "Создание..." : "Создать задание" }}
{{ submitting ? "Создание..." : "Создать парсер" }}
</button>
</div>
</form>
@@ -116,14 +220,16 @@ onUnmounted(() => {
</section>
<section class="card">
<h3>Задания <span v-if="loading" class="muted">(загрузка...)</span></h3>
<h3>Парсеры <span v-if="loading" class="muted">(загрузка...)</span></h3>
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Источник</th>
<th>Конфиг</th>
<th>Канал</th>
<th>Лимит</th>
<th>Интервал</th>
<th>Активен</th>
<th>Статус</th>
<th>Последний запуск</th>
<th>Ошибка</th>
@@ -132,30 +238,122 @@ onUnmounted(() => {
</thead>
<tbody>
<tr v-if="jobs.length === 0 && !loading">
<td colspan="7" class="empty">Нет заданий</td>
<td colspan="9" class="empty">Нет парсеров</td>
</tr>
<tr v-for="job in jobs" :key="job.id">
<td>{{ job.id }}</td>
<td>{{ job.source_type }}</td>
<td class="mono">{{ JSON.stringify(job.source_config) }}</td>
<td>{{ channelFromConfig(job.source_config) }}</td>
<td>{{ limitFromConfig(job.source_config) }}</td>
<td>{{ formatInterval(job.interval_seconds) }}</td>
<td>{{ job.is_active ? "да" : "нет" }}</td>
<td>
<span class="badge" :class="statusClass(job.status)">{{ job.status }}</span>
</td>
<td>{{ formatDate(job.last_run_at) }}</td>
<td class="error-cell">{{ job.last_error ?? "—" }}</td>
<td>
<td class="actions-cell">
<button class="btn btn-sm" type="button" @click="openEdit(job)">Изменить</button>
<button
v-if="job.status === 'failed' || job.status === 'error'"
class="btn btn-sm"
type="button"
@click="handleRetry(job.id)"
>
Повторить
</button>
<button
class="btn btn-sm btn-danger"
type="button"
:disabled="job.status === 'running'"
@click="handleDelete(job)"
>
Удалить
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<div v-if="editingJob" class="modal-backdrop" @click.self="closeEdit">
<div class="modal card">
<h3>Редактировать парсер #{{ editingJob.id }}</h3>
<form class="admin-form" @submit.prevent="handleSaveEdit">
<div class="form-row">
<label>
Канал
<input v-model="editForm.channel" type="text" required />
</label>
<label>
Лимит
<input v-model.number="editForm.limit" type="number" min="1" max="1000" />
</label>
<label>
Интервал
<select v-model.number="editForm.interval_seconds">
<option v-for="opt in INTERVAL_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</label>
<label class="checkbox-row">
<input v-model="editForm.is_active" type="checkbox" />
<span>Активен (периодический запуск)</span>
</label>
</div>
<div class="form-actions">
<button type="button" class="btn" @click="closeEdit">Отмена</button>
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? "Сохранение..." : "Сохранить" }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
.hint {
margin: 0 0 1rem;
font-size: 0.875rem;
color: #666;
line-height: 1.5;
}
.actions-cell {
white-space: nowrap;
}
.actions-cell .btn {
margin-right: 0.25rem;
}
.btn-danger {
color: #b91c1c;
border-color: #fecaca;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
}
.modal {
width: min(520px, 92vw);
margin: 0;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.5rem;
}
</style>