infinity serch
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,7 @@
|
|||||||
# ── Secrets ────────────────────────────────────────────────────────────────
|
# ── Secrets ────────────────────────────────────────────────────────────────
|
||||||
.env
|
.env
|
||||||
|
.env.internal
|
||||||
|
.env.external
|
||||||
# .env.example is intentionally committed as a setup template
|
# .env.example is intentionally committed as a setup template
|
||||||
|
|
||||||
# ── Local data volumes (postgres / redis / minio) ─────────────────────────
|
# ── Local data volumes (postgres / redis / minio) ─────────────────────────
|
||||||
|
|||||||
@@ -83,5 +83,6 @@ class VkSource(Base):
|
|||||||
group_id: Mapped[str] = mapped_column(String(50), unique=True) # числовой ID без минуса
|
group_id: Mapped[str] = mapped_column(String(50), unique=True) # числовой ID без минуса
|
||||||
group_name: Mapped[str] = mapped_column(String(200)) # станет названием категории
|
group_name: Mapped[str] = mapped_column(String(200)) # станет названием категории
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
strict_filter: Mapped[bool] = mapped_column(Boolean, default=True) # True = только посты с тегами
|
||||||
last_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
last_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from route.admin_articles import router as articles_router
|
|||||||
from route.admin_categories import router as categories_router
|
from route.admin_categories import router as categories_router
|
||||||
from route.admin_media import router as media_router
|
from route.admin_media import router as media_router
|
||||||
from route.admin_vk import router as vk_router
|
from route.admin_vk import router as vk_router
|
||||||
|
from route.admin_system import router as system_router
|
||||||
import scheduler
|
import scheduler
|
||||||
|
|
||||||
DOCS_USER = os.getenv("DOCS_USERNAME", "admin")
|
DOCS_USER = os.getenv("DOCS_USERNAME", "admin")
|
||||||
@@ -102,6 +103,7 @@ app.include_router(articles_router, prefix="/admin/articles", tags=["admin-art
|
|||||||
app.include_router(categories_router,prefix="/admin/categories",tags=["admin-categories"])
|
app.include_router(categories_router,prefix="/admin/categories",tags=["admin-categories"])
|
||||||
app.include_router(media_router, prefix="/admin/media", tags=["admin-media"])
|
app.include_router(media_router, prefix="/admin/media", tags=["admin-media"])
|
||||||
app.include_router(vk_router, prefix="/admin/vk", tags=["admin-vk"])
|
app.include_router(vk_router, prefix="/admin/vk", tags=["admin-vk"])
|
||||||
|
app.include_router(system_router, prefix="/admin/system", tags=["admin-system"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add strict_filter to vk_sources
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: 25d6ed4a524a
|
||||||
|
Create Date: 2026-05-19 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = 'a1b2c3d4e5f6'
|
||||||
|
down_revision: Union[str, None] = '25d6ed4a524a'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('vk_sources',
|
||||||
|
sa.Column('strict_filter', sa.Boolean(), nullable=False, server_default=sa.true())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('vk_sources', 'strict_filter')
|
||||||
115
api/route/admin_system.py
Normal file
115
api/route/admin_system.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bd.database import get_db, get_redis, DATABASE_URL, REDIS_URL
|
||||||
|
from route.deps import require_admin
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
||||||
|
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
|
||||||
|
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
|
||||||
|
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media")
|
||||||
|
MINIO_PUBLIC_URL = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_db_url(url: str) -> dict:
|
||||||
|
"""Вытащить host/port/db/user из DATABASE_URL без пароля."""
|
||||||
|
try:
|
||||||
|
# postgresql+asyncpg://user:pass@host:port/db
|
||||||
|
p = urlparse(url.replace("+asyncpg", ""))
|
||||||
|
return {
|
||||||
|
"host": p.hostname or "?",
|
||||||
|
"port": p.port or 5432,
|
||||||
|
"database": (p.path or "/").lstrip("/") or "?",
|
||||||
|
"user": p.username or "?",
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {"host": "?", "port": 5432, "database": "?", "user": "?"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_redis_url(url: str) -> dict:
|
||||||
|
"""Вытащить host/port из REDIS_URL без пароля."""
|
||||||
|
try:
|
||||||
|
p = urlparse(url)
|
||||||
|
return {
|
||||||
|
"host": p.hostname or "?",
|
||||||
|
"port": p.port or 6379,
|
||||||
|
"db": (p.path or "/0").lstrip("/") or "0",
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {"host": "?", "port": 6379, "db": "0"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_postgres(db: AsyncSession) -> dict:
|
||||||
|
info = _parse_db_url(DATABASE_URL)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
result = await db.execute(text("SELECT version()"))
|
||||||
|
version = result.scalar() or ""
|
||||||
|
latency_ms = round((time.monotonic() - t0) * 1000, 1)
|
||||||
|
# Извлекаем короткую версию: "PostgreSQL 15.3 ..."
|
||||||
|
short_ver = version.split(",")[0] if version else "?"
|
||||||
|
return {"ok": True, "latency_ms": latency_ms, "version": short_ver, **info}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e), "latency_ms": None, **info}
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_redis() -> dict:
|
||||||
|
info = _parse_redis_url(REDIS_URL)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
redis = await get_redis()
|
||||||
|
pong = await redis.ping()
|
||||||
|
latency_ms = round((time.monotonic() - t0) * 1000, 1)
|
||||||
|
server_info = await redis.info("server")
|
||||||
|
version = server_info.get("redis_version", "?")
|
||||||
|
return {"ok": bool(pong), "latency_ms": latency_ms, "version": f"Redis {version}", **info}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e), "latency_ms": None, **info}
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_minio() -> dict:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
from minio import Minio
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
client = Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False)
|
||||||
|
exists = await loop.run_in_executor(None, client.bucket_exists, MINIO_BUCKET)
|
||||||
|
latency_ms = round((time.monotonic() - t0) * 1000, 1)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"endpoint": MINIO_ENDPOINT,
|
||||||
|
"bucket": MINIO_BUCKET,
|
||||||
|
"public_url": MINIO_PUBLIC_URL,
|
||||||
|
"bucket_exists": exists,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": str(e),
|
||||||
|
"latency_ms": None,
|
||||||
|
"endpoint": MINIO_ENDPOINT,
|
||||||
|
"bucket": MINIO_BUCKET,
|
||||||
|
"public_url": MINIO_PUBLIC_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def system_status(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||||
|
pg, redis, minio = await asyncio.gather(
|
||||||
|
_check_postgres(db),
|
||||||
|
_check_redis(),
|
||||||
|
_check_minio(),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"postgres": pg,
|
||||||
|
"redis": redis,
|
||||||
|
"minio": minio,
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ class SourceIn(BaseModel):
|
|||||||
group_id: str
|
group_id: str
|
||||||
group_name: str
|
group_name: str
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
strict_filter: bool = True
|
||||||
|
|
||||||
|
|
||||||
def _source_dict(s: VkSource) -> dict:
|
def _source_dict(s: VkSource) -> dict:
|
||||||
@@ -23,6 +24,7 @@ def _source_dict(s: VkSource) -> dict:
|
|||||||
"group_id": s.group_id,
|
"group_id": s.group_id,
|
||||||
"group_name": s.group_name,
|
"group_name": s.group_name,
|
||||||
"enabled": s.enabled,
|
"enabled": s.enabled,
|
||||||
|
"strict_filter": s.strict_filter,
|
||||||
"last_run": s.last_run.isoformat() if s.last_run else None,
|
"last_run": s.last_run.isoformat() if s.last_run else None,
|
||||||
"created_at": s.created_at.isoformat(),
|
"created_at": s.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -56,7 +58,7 @@ async def add_source(data: SourceIn, db: AsyncSession = Depends(get_db), _: str
|
|||||||
exists = (await db.execute(select(VkSource).where(VkSource.group_id == group_id))).scalar_one_or_none()
|
exists = (await db.execute(select(VkSource).where(VkSource.group_id == group_id))).scalar_one_or_none()
|
||||||
if exists:
|
if exists:
|
||||||
raise HTTPException(status_code=409, detail="Источник с таким group_id уже есть")
|
raise HTTPException(status_code=409, detail="Источник с таким group_id уже есть")
|
||||||
source = VkSource(group_id=group_id, group_name=data.group_name, enabled=data.enabled)
|
source = VkSource(group_id=group_id, group_name=data.group_name, enabled=data.enabled, strict_filter=data.strict_filter)
|
||||||
db.add(source)
|
db.add(source)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(source)
|
await db.refresh(source)
|
||||||
@@ -71,6 +73,7 @@ async def update_source(source_id: str, data: SourceIn, db: AsyncSession = Depen
|
|||||||
source.group_id = _clean_group_id(data.group_id)
|
source.group_id = _clean_group_id(data.group_id)
|
||||||
source.group_name = data.group_name
|
source.group_name = data.group_name
|
||||||
source.enabled = data.enabled
|
source.enabled = data.enabled
|
||||||
|
source.strict_filter = data.strict_filter
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(source)
|
await db.refresh(source)
|
||||||
return _source_dict(source)
|
return _source_dict(source)
|
||||||
@@ -94,20 +97,25 @@ async def vk_manual_import(_: str = Depends(require_admin)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/import/history")
|
@router.post("/import/history")
|
||||||
async def vk_history_import_all(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
|
async def vk_history_import_all(
|
||||||
background_tasks.add_task(run_history_import)
|
background_tasks: BackgroundTasks,
|
||||||
return {"ok": True, "message": "Исторический импорт всех групп запущен в фоне (до 5 мин)"}
|
since_days: int = 365,
|
||||||
|
_: str = Depends(require_admin),
|
||||||
|
):
|
||||||
|
background_tasks.add_task(run_history_import, since_days=since_days)
|
||||||
|
return {"ok": True, "message": f"Исторический импорт всех групп запущен в фоне (глубина {since_days} дн.)"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import/history/{source_id}")
|
@router.post("/import/history/{source_id}")
|
||||||
async def vk_history_import_one(
|
async def vk_history_import_one(
|
||||||
source_id: str,
|
source_id: str,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
|
since_days: int = 365,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(require_admin),
|
_: str = Depends(require_admin),
|
||||||
):
|
):
|
||||||
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
|
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(status_code=404, detail="Источник не найден")
|
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||||
background_tasks.add_task(run_history_import, group_id=source.group_id)
|
background_tasks.add_task(run_history_import, group_id=source.group_id, since_days=since_days)
|
||||||
return {"ok": True, "message": f"Исторический импорт группы «{source.group_name}» запущен в фоне"}
|
return {"ok": True, "message": f"Исторический импорт группы «{source.group_name}» запущен в фоне"}
|
||||||
|
|||||||
@@ -359,6 +359,12 @@ async def _slug_exists(slug: str, db: AsyncSession) -> bool:
|
|||||||
return r.scalar_one_or_none() is not None
|
return r.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def _vk_post_exists(owner_id: int, post_id: int, db: AsyncSession) -> bool:
|
||||||
|
source_url = f"https://vk.com/wall{owner_id}_{post_id}"
|
||||||
|
r = await db.execute(select(Article.id).where(Article.source_url == source_url))
|
||||||
|
return r.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_category(name: str, db: AsyncSession) -> Category:
|
async def _get_or_create_category(name: str, db: AsyncSession) -> Category:
|
||||||
s = slugify(name)
|
s = slugify(name)
|
||||||
cat = (await db.execute(select(Category).where(Category.slug == s))).scalar_one_or_none()
|
cat = (await db.execute(select(Category).where(Category.slug == s))).scalar_one_or_none()
|
||||||
@@ -381,21 +387,39 @@ async def _get_or_create_tag(name: str, db: AsyncSession) -> Tag:
|
|||||||
|
|
||||||
# ── Post processing ───────────────────────────────────────────────────────────
|
# ── Post processing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _process_post(post: dict, category_name: str, db: AsyncSession) -> bool:
|
async def _process_post(post: dict, category_name: str, db: AsyncSession, strict_filter: bool = True) -> bool:
|
||||||
owner_id = post["owner_id"]
|
owner_id = post["owner_id"]
|
||||||
post_id = post["id"]
|
post_id = post["id"]
|
||||||
vk_slug = _vk_slug(owner_id, post_id)
|
vk_slug = _vk_slug(owner_id, post_id)
|
||||||
|
|
||||||
if await _slug_exists(vk_slug, db):
|
if await _vk_post_exists(owner_id, post_id, db):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
text = post.get("text", "")
|
text = post.get("text", "")
|
||||||
attachments = post.get("attachments", [])
|
attachments = post.get("attachments", [])
|
||||||
ts = post["date"]
|
ts = post["date"]
|
||||||
|
|
||||||
# Фильтр: импортируем только посты с разрешёнными тегами
|
# Обработка репостов (copy_history)
|
||||||
|
copy_history = post.get("copy_history", [])
|
||||||
|
if copy_history:
|
||||||
|
original = copy_history[0]
|
||||||
|
orig_gid = str(abs(original.get("owner_id", 0)))
|
||||||
|
in_db = (await db.execute(
|
||||||
|
select(VkSource.id).where(VkSource.group_id == orig_gid)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if in_db:
|
||||||
|
# Оригинал придёт сам из своей группы — пропускаем
|
||||||
|
return False
|
||||||
|
# Берём содержимое оригинала; комментарий репостера добавляем в начало
|
||||||
|
orig_text = original.get("text", "")
|
||||||
|
orig_atts = original.get("attachments", [])
|
||||||
|
text = (text + "\n" + orig_text).strip() if text else orig_text
|
||||||
|
attachments = orig_atts if orig_atts else attachments
|
||||||
|
|
||||||
tag_names = _detect_tags(text)
|
tag_names = _detect_tags(text)
|
||||||
if not tag_names:
|
# strict_filter=True: пропускаем пост если нет тегов
|
||||||
|
# strict_filter=False: импортируем всё, теги назначаем если найдены
|
||||||
|
if strict_filter and not tag_names:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Заранее генерируем UUID и slug — используются в путях MinIO
|
# Заранее генерируем UUID и slug — используются в путях MinIO
|
||||||
@@ -648,7 +672,7 @@ async def run_import() -> dict:
|
|||||||
batch_new = 0
|
batch_new = 0
|
||||||
for post in posts:
|
for post in posts:
|
||||||
try:
|
try:
|
||||||
ok = await _process_post(post, source.group_name, db)
|
ok = await _process_post(post, source.group_name, db, source.strict_filter)
|
||||||
if ok:
|
if ok:
|
||||||
imported += 1; grp_new += 1; batch_new += 1
|
imported += 1; grp_new += 1; batch_new += 1
|
||||||
else:
|
else:
|
||||||
@@ -721,7 +745,7 @@ async def run_history_import(group_id: str | None = None, since_days: int = 365)
|
|||||||
hit_cutoff = True
|
hit_cutoff = True
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
ok = await _process_post(post, source.group_name, db)
|
ok = await _process_post(post, source.group_name, db, source.strict_filter)
|
||||||
if ok:
|
if ok:
|
||||||
imported += 1; grp_new += 1; batch_new += 1
|
imported += 1; grp_new += 1; batch_new += 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
61
docker-compose.external.yml
Normal file
61
docker-compose.external.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
# ── FastAPI backend ────────────────────────────────────────────────────────
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
MINIO_ENDPOINT: ${MINIO_ENDPOINT}
|
||||||
|
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
|
||||||
|
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
|
||||||
|
MINIO_BUCKET: ${MINIO_BUCKET:-news-media}
|
||||||
|
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL}
|
||||||
|
DOCS_USERNAME: ${DOCS_USERNAME:-admin}
|
||||||
|
DOCS_PASSWORD: ${DOCS_PASSWORD:-admin}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme}
|
||||||
|
VK_ACCESS_TOKEN: ${VK_ACCESS_TOKEN:-}
|
||||||
|
VK_IMPORT_STATUS: ${VK_IMPORT_STATUS:-published}
|
||||||
|
VK_POSTS_PER_RUN: ${VK_POSTS_PER_RUN:-100}
|
||||||
|
VK_IMPORT_HOUR: ${VK_IMPORT_HOUR:-6}
|
||||||
|
VK_IMPORT_MINUTE: ${VK_IMPORT_MINUTE:-0}
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\" || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- news_net
|
||||||
|
|
||||||
|
# ── React SPA + TipTap editor + nginx ─────────────────────────────────────
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: web-react/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost/ || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- news_net
|
||||||
|
|
||||||
|
|
||||||
|
networks:
|
||||||
|
news_net:
|
||||||
|
driver: bridge
|
||||||
@@ -1,18 +1,82 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
|
# ── PostgreSQL ────────────────────────────────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: news
|
||||||
|
POSTGRES_USER: news_admin
|
||||||
|
POSTGRES_PASSWORD: ${NEWS_DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U news_admin -d news"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- news_net
|
||||||
|
|
||||||
|
# ── Redis ─────────────────────────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- news_net
|
||||||
|
|
||||||
|
# ── MinIO ─────────────────────────────────────────────────────────────────
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mc ready local || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- news_net
|
||||||
|
|
||||||
# ── FastAPI backend ────────────────────────────────────────────────────────
|
# ── FastAPI backend ────────────────────────────────────────────────────────
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: postgresql+asyncpg://news_admin:${NEWS_DB_PASSWORD}@db:5432/news
|
||||||
REDIS_URL: ${REDIS_URL}
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
MINIO_ENDPOINT: ${MINIO_ENDPOINT:-192.168.1.21:9000}
|
MINIO_ENDPOINT: minio:9000
|
||||||
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
|
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
|
||||||
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
|
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
|
||||||
MINIO_BUCKET: ${MINIO_BUCKET:-news-media}
|
MINIO_BUCKET: ${MINIO_BUCKET:-news-media}
|
||||||
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
|
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
|
||||||
DOCS_USERNAME: ${DOCS_USERNAME:-admin}
|
DOCS_USERNAME: ${DOCS_USERNAME:-admin}
|
||||||
@@ -47,7 +111,7 @@ services:
|
|||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost/ || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -56,7 +120,11 @@ services:
|
|||||||
- news_net
|
- news_net
|
||||||
|
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
redis_data:
|
||||||
|
minio_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
news_net:
|
news_net:
|
||||||
external: true
|
driver: bridge
|
||||||
name: news_net
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import ArticleEditor from './pages/admin/ArticleEditor'
|
|||||||
import Categories from './pages/admin/Categories'
|
import Categories from './pages/admin/Categories'
|
||||||
import MediaLibrary from './pages/admin/MediaLibrary'
|
import MediaLibrary from './pages/admin/MediaLibrary'
|
||||||
import VkSources from './pages/admin/VkSources'
|
import VkSources from './pages/admin/VkSources'
|
||||||
|
import SystemStatus from './pages/admin/SystemStatus'
|
||||||
|
|
||||||
function PrivateRoute({ children }) {
|
function PrivateRoute({ children }) {
|
||||||
const { isAuth } = useAuth()
|
const { isAuth } = useAuth()
|
||||||
@@ -36,6 +37,7 @@ export default function App() {
|
|||||||
<Route path="/admin/categories" element={<PrivateRoute><Categories /></PrivateRoute>} />
|
<Route path="/admin/categories" element={<PrivateRoute><Categories /></PrivateRoute>} />
|
||||||
<Route path="/admin/media" element={<PrivateRoute><MediaLibrary /></PrivateRoute>} />
|
<Route path="/admin/media" element={<PrivateRoute><MediaLibrary /></PrivateRoute>} />
|
||||||
<Route path="/admin/vk" element={<PrivateRoute><VkSources /></PrivateRoute>} />
|
<Route path="/admin/vk" element={<PrivateRoute><VkSources /></PrivateRoute>} />
|
||||||
|
<Route path="/admin/system" element={<PrivateRoute><SystemStatus /></PrivateRoute>} />
|
||||||
|
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -97,11 +97,11 @@ export const adminVkStatus = token => req('GET', 'admin/vk/status', token)
|
|||||||
|
|
||||||
export const adminVkListSources = token => req('GET', 'admin/vk/sources', token)
|
export const adminVkListSources = token => req('GET', 'admin/vk/sources', token)
|
||||||
|
|
||||||
export const adminVkAddSource = (token, group_id, group_name, enabled = true) =>
|
export const adminVkAddSource = (token, group_id, group_name, enabled = true, strict_filter = true) =>
|
||||||
req('POST', 'admin/vk/sources', token, { group_id, group_name, enabled })
|
req('POST', 'admin/vk/sources', token, { group_id, group_name, enabled, strict_filter })
|
||||||
|
|
||||||
export const adminVkUpdateSource = (token, id, group_id, group_name, enabled) =>
|
export const adminVkUpdateSource = (token, id, group_id, group_name, enabled, strict_filter = true) =>
|
||||||
req('PATCH', `admin/vk/sources/${id}`, token, { group_id, group_name, enabled })
|
req('PATCH', `admin/vk/sources/${id}`, token, { group_id, group_name, enabled, strict_filter })
|
||||||
|
|
||||||
export const adminVkDeleteSource = (token, id) =>
|
export const adminVkDeleteSource = (token, id) =>
|
||||||
req('DELETE', `admin/vk/sources/${id}`, token)
|
req('DELETE', `admin/vk/sources/${id}`, token)
|
||||||
@@ -109,5 +109,8 @@ export const adminVkDeleteSource = (token, id) =>
|
|||||||
export const adminVkImport = token =>
|
export const adminVkImport = token =>
|
||||||
req('POST', 'admin/vk/import', token)
|
req('POST', 'admin/vk/import', token)
|
||||||
|
|
||||||
export const adminVkImportHistory = (token, sourceId) =>
|
export const adminVkImportHistory = (token, sourceId, sinceDays = 365) =>
|
||||||
req('POST', `admin/vk/import/history${sourceId ? `/${sourceId}` : ''}`, token)
|
req('POST', `admin/vk/import/history${sourceId ? `/${sourceId}` : ''}?since_days=${sinceDays}`, token)
|
||||||
|
|
||||||
|
export const adminSystemStatus = token =>
|
||||||
|
req('GET', 'admin/system/status', token)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const NAV = [
|
|||||||
{ to: '/admin/categories', label: 'Категории', icon: '🏷️' },
|
{ to: '/admin/categories', label: 'Категории', icon: '🏷️' },
|
||||||
{ to: '/admin/media', label: 'Медиа', icon: '🖼️' },
|
{ to: '/admin/media', label: 'Медиа', icon: '🖼️' },
|
||||||
{ to: '/admin/vk', label: 'ВКонтакте', icon: '📥' },
|
{ to: '/admin/vk', label: 'ВКонтакте', icon: '📥' },
|
||||||
|
{ to: '/admin/system', label: 'Сервисы', icon: '🖥️' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function AdminLayout({ children, title }) {
|
export default function AdminLayout({ children, title }) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const DATE_PRESETS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function FilterBar({ categories, tags, filters, onChange }) {
|
export default function FilterBar({ categories, tags, filters, onChange }) {
|
||||||
const set = key => val => onChange({ ...filters, [key]: val, page: 1 })
|
const set = key => val => onChange({ ...filters, [key]: val })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="filter-bar">
|
<div className="filter-bar">
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ export default function NewsFeed() {
|
|||||||
const [categories, setCategories] = useState([])
|
const [categories, setCategories] = useState([])
|
||||||
const [tags, setTags] = useState([])
|
const [tags, setTags] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [filters, setFilters] = useState({ category: null, tag: null, datePreset: '', page: 1 })
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
|
const [filters, setFilters] = useState({ category: null, tag: null, datePreset: '' })
|
||||||
|
|
||||||
|
const offsetRef = useRef(0)
|
||||||
|
const busyRef = useRef(false)
|
||||||
|
const sentinelRef = useRef(null)
|
||||||
|
|
||||||
// Поиск с debounce
|
// Поиск с debounce
|
||||||
const [searchInput, setSearchInput] = useState('')
|
const [searchInput, setSearchInput] = useState('')
|
||||||
@@ -35,19 +41,14 @@ export default function NewsFeed() {
|
|||||||
const val = e.target.value
|
const val = e.target.value
|
||||||
setSearchInput(val)
|
setSearchInput(val)
|
||||||
clearTimeout(debounceRef.current)
|
clearTimeout(debounceRef.current)
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => setSearchQuery(val.trim()), 400)
|
||||||
setSearchQuery(val.trim())
|
|
||||||
setFilters(f => ({ ...f, page: 1 }))
|
|
||||||
}, 400)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearSearch = () => {
|
const clearSearch = () => {
|
||||||
setSearchInput('')
|
setSearchInput('')
|
||||||
setSearchQuery('')
|
setSearchQuery('')
|
||||||
setFilters(f => ({ ...f, page: 1 }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модальное окно
|
|
||||||
const [modalArticle, setModalArticle] = useState(null)
|
const [modalArticle, setModalArticle] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,30 +58,71 @@ export default function NewsFeed() {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadNews = useCallback(async () => {
|
const buildParams = useCallback((offset) => {
|
||||||
setLoading(true)
|
const params = { limit: PAGE_SIZE, offset }
|
||||||
try {
|
|
||||||
const params = {
|
|
||||||
limit: PAGE_SIZE,
|
|
||||||
offset: (filters.page - 1) * PAGE_SIZE,
|
|
||||||
}
|
|
||||||
if (filters.category) params.category = filters.category
|
if (filters.category) params.category = filters.category
|
||||||
if (filters.tag) params.tag = filters.tag
|
if (filters.tag) params.tag = filters.tag
|
||||||
const df = presetToDateFrom(filters.datePreset)
|
const df = presetToDateFrom(filters.datePreset)
|
||||||
if (df) params.date_from = df
|
if (df) params.date_from = df
|
||||||
if (searchQuery) params.q = searchQuery
|
if (searchQuery) params.q = searchQuery
|
||||||
|
return params
|
||||||
const data = await getNews(params)
|
|
||||||
setArticles(data.items ?? [])
|
|
||||||
setTotal(data.total ?? 0)
|
|
||||||
} catch {
|
|
||||||
setArticles([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [filters, searchQuery])
|
}, [filters, searchQuery])
|
||||||
|
|
||||||
useEffect(() => { loadNews() }, [loadNews])
|
// Сброс и первая загрузка при смене фильтров/поиска
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
offsetRef.current = 0
|
||||||
|
busyRef.current = true
|
||||||
|
setLoading(true)
|
||||||
|
setArticles([])
|
||||||
|
setHasMore(false)
|
||||||
|
|
||||||
|
getNews(buildParams(0)).then(data => {
|
||||||
|
if (cancelled) return
|
||||||
|
setArticles(data.items ?? [])
|
||||||
|
setTotal(data.total ?? 0)
|
||||||
|
setHasMore(data.has_more ?? false)
|
||||||
|
offsetRef.current = PAGE_SIZE
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) setArticles([])
|
||||||
|
}).finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false)
|
||||||
|
busyRef.current = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [buildParams])
|
||||||
|
|
||||||
|
// Подгрузка следующего батча
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (busyRef.current || !hasMore) return
|
||||||
|
busyRef.current = true
|
||||||
|
setLoadingMore(true)
|
||||||
|
try {
|
||||||
|
const data = await getNews(buildParams(offsetRef.current))
|
||||||
|
setArticles(prev => [...prev, ...(data.items ?? [])])
|
||||||
|
setHasMore(data.has_more ?? false)
|
||||||
|
offsetRef.current += PAGE_SIZE
|
||||||
|
} catch {}
|
||||||
|
finally {
|
||||||
|
setLoadingMore(false)
|
||||||
|
busyRef.current = false
|
||||||
|
}
|
||||||
|
}, [hasMore, buildParams])
|
||||||
|
|
||||||
|
// IntersectionObserver на sentinel-div внизу списка
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current
|
||||||
|
if (!el) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
entries => { if (entries[0].isIntersecting) loadMore() },
|
||||||
|
{ rootMargin: '300px' }
|
||||||
|
)
|
||||||
|
observer.observe(el)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [loadMore])
|
||||||
|
|
||||||
const openArticle = async (slug) => {
|
const openArticle = async (slug) => {
|
||||||
setModalArticle({ _loading: true })
|
setModalArticle({ _loading: true })
|
||||||
@@ -92,8 +134,6 @@ export default function NewsFeed() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="public-header">
|
<header className="public-header">
|
||||||
@@ -104,7 +144,6 @@ export default function NewsFeed() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Строка поиска */}
|
|
||||||
<div className="search-bar-wrap">
|
<div className="search-bar-wrap">
|
||||||
<div className="search-bar">
|
<div className="search-bar">
|
||||||
<svg className="search-bar__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg className="search-bar__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
@@ -129,10 +168,9 @@ export default function NewsFeed() {
|
|||||||
categories={categories}
|
categories={categories}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={setFilters}
|
onChange={f => setFilters(f)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Счётчик результатов при поиске */}
|
|
||||||
{searchQuery && !loading && (
|
{searchQuery && !loading && (
|
||||||
<p className="search-results-hint">
|
<p className="search-results-hint">
|
||||||
{total > 0
|
{total > 0
|
||||||
@@ -142,48 +180,47 @@ export default function NewsFeed() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading
|
{loading ? (
|
||||||
? <Loader />
|
<Loader />
|
||||||
: articles.length === 0
|
) : articles.length === 0 ? (
|
||||||
? <p style={{ textAlign: 'center', color: 'var(--c-text-2)', marginTop: '3rem' }}>
|
<p style={{ textAlign: 'center', color: 'var(--c-text-2)', marginTop: '3rem' }}>
|
||||||
Новостей не найдено
|
Новостей не найдено
|
||||||
</p>
|
</p>
|
||||||
: <div className="news-list">
|
) : (
|
||||||
|
<div className="news-list">
|
||||||
{articles.map(a => (
|
{articles.map(a => (
|
||||||
<NewsCard key={a.id} article={a} onClick={() => openArticle(a.slug)} />
|
<NewsCard key={a.id} article={a} onClick={() => openArticle(a.slug)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
|
|
||||||
{!loading && totalPages > 1 && (
|
{/* Sentinel для IntersectionObserver */}
|
||||||
<div className="pagination">
|
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-sm"
|
{loadingMore && (
|
||||||
disabled={filters.page <= 1}
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '1.5rem 0' }}>
|
||||||
onClick={() => setFilters(f => ({ ...f, page: f.page - 1 }))}
|
<Loader />
|
||||||
>← Назад</button>
|
|
||||||
<span className="pagination__info">{filters.page} / {totalPages}</span>
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
disabled={filters.page >= totalPages}
|
|
||||||
onClick={() => setFilters(f => ({ ...f, page: f.page + 1 }))}
|
|
||||||
>Вперёд →</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!loading && !hasMore && articles.length > 0 && (
|
||||||
|
<p style={{ textAlign: 'center', color: 'var(--c-text-2)', fontSize: '0.85rem', padding: '1.5rem 0' }}>
|
||||||
|
Все новости загружены · {total}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Модальное окно статьи */}
|
|
||||||
{modalArticle && (
|
{modalArticle && (
|
||||||
modalArticle._loading
|
modalArticle._loading ? (
|
||||||
? (
|
|
||||||
<div className="article-modal-overlay" onClick={() => setModalArticle(null)}>
|
<div className="article-modal-overlay" onClick={() => setModalArticle(null)}>
|
||||||
<div className="article-modal" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}
|
<div className="article-modal" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}
|
||||||
onClick={e => e.stopPropagation()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
<Loader />
|
<Loader />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<ArticleModal article={modalArticle} onClose={() => setModalArticle(null)} />
|
||||||
)
|
)
|
||||||
: <ArticleModal article={modalArticle} onClose={() => setModalArticle(null)} />
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
144
web-react/src/pages/admin/SystemStatus.jsx
Normal file
144
web-react/src/pages/admin/SystemStatus.jsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
import { adminSystemStatus } from '../../api/index'
|
||||||
|
import AdminLayout from '../../components/AdminLayout'
|
||||||
|
import Loader from '../../components/Loader'
|
||||||
|
|
||||||
|
function StatusBadge({ ok }) {
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '2px 10px', borderRadius: 99, fontSize: '0.8rem', fontWeight: 600,
|
||||||
|
background: ok ? 'var(--c-success-bg, #d1fae5)' : 'var(--c-danger-bg, #fee2e2)',
|
||||||
|
color: ok ? 'var(--c-success, #065f46)' : 'var(--c-danger, #991b1b)',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '0.65rem' }}>{ok ? '●' : '●'}</span>
|
||||||
|
{ok ? 'OK' : 'Ошибка'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceCard({ title, icon, data }) {
|
||||||
|
if (!data) return null
|
||||||
|
const rows = Object.entries(data).filter(([k]) => !['ok', 'error'].includes(k))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--c-surface)',
|
||||||
|
borderRadius: 'var(--r-card)',
|
||||||
|
boxShadow: 'var(--shadow-card)',
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderLeft: `4px solid ${data.ok ? 'var(--c-success, #10b981)' : 'var(--c-danger, #ef4444)'}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<span style={{ fontSize: '1.4rem' }}>{icon}</span>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: '1rem' }}>{title}</span>
|
||||||
|
</div>
|
||||||
|
<StatusBadge ok={data.ok} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.error && (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--c-danger-bg, #fee2e2)',
|
||||||
|
color: 'var(--c-danger, #991b1b)',
|
||||||
|
borderRadius: 6, padding: '0.5rem 0.75rem',
|
||||||
|
fontSize: '0.8rem', fontFamily: 'monospace',
|
||||||
|
marginBottom: '0.75rem', wordBreak: 'break-all',
|
||||||
|
}}>
|
||||||
|
{data.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||||
|
<tbody>
|
||||||
|
{rows.map(([key, value]) => (
|
||||||
|
<tr key={key} style={{ borderBottom: '1px solid var(--c-border, #e5e7eb)' }}>
|
||||||
|
<td style={{ padding: '0.4rem 0', color: 'var(--c-text-2)', width: '40%', fontWeight: 500 }}>
|
||||||
|
{LABELS[key] ?? key}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.4rem 0', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||||
|
{value === null ? <span style={{ color: 'var(--c-text-2)' }}>—</span>
|
||||||
|
: value === true ? '✅ да'
|
||||||
|
: value === false ? '❌ нет'
|
||||||
|
: String(value)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
host: 'Хост',
|
||||||
|
port: 'Порт',
|
||||||
|
database: 'База данных',
|
||||||
|
user: 'Пользователь',
|
||||||
|
version: 'Версия',
|
||||||
|
latency_ms: 'Задержка (мс)',
|
||||||
|
db: 'Номер БД',
|
||||||
|
endpoint: 'Эндпоинт',
|
||||||
|
bucket: 'Бакет',
|
||||||
|
public_url: 'Публичный URL',
|
||||||
|
bucket_exists: 'Бакет существует',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SystemStatus() {
|
||||||
|
const { token } = useAuth()
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [lastUpdated, setLastUpdated] = useState(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await adminSystemStatus(token)
|
||||||
|
setData(result)
|
||||||
|
setLastUpdated(new Date())
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const allOk = data && Object.values(data).every(s => s.ok)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="Состояние сервисов">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
{data && (
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 14px', borderRadius: 99, fontWeight: 700, fontSize: '0.85rem',
|
||||||
|
background: allOk ? 'var(--c-success-bg, #d1fae5)' : 'var(--c-danger-bg, #fee2e2)',
|
||||||
|
color: allOk ? 'var(--c-success, #065f46)' : 'var(--c-danger, #991b1b)',
|
||||||
|
}}>
|
||||||
|
{allOk ? '✅ Все сервисы работают' : '⚠️ Есть проблемы'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{lastUpdated && (
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--c-text-2)' }}>
|
||||||
|
Обновлено: {lastUpdated.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={load} disabled={loading}>
|
||||||
|
{loading ? 'Проверяю...' : '🔄 Обновить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !data ? <Loader /> : (
|
||||||
|
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))' }}>
|
||||||
|
<ServiceCard title="PostgreSQL" icon="🐘" data={data?.postgres} />
|
||||||
|
<ServiceCard title="Redis" icon="⚡" data={data?.redis} />
|
||||||
|
<ServiceCard title="MinIO" icon="🗄️" data={data?.minio} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,8 +17,9 @@ export default function VkSources() {
|
|||||||
const [confirm, setConfirm] = useState(null)
|
const [confirm, setConfirm] = useState(null)
|
||||||
const [alert, setAlert] = useState(null)
|
const [alert, setAlert] = useState(null)
|
||||||
const [importing, setImporting] = useState('')
|
const [importing, setImporting] = useState('')
|
||||||
|
const [sinceDays, setSinceDays] = useState(365)
|
||||||
|
|
||||||
const [form, setForm] = useState({ group_id: '', group_name: '', enabled: true })
|
const [form, setForm] = useState({ group_id: '', group_name: '', enabled: true, strict_filter: true })
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -40,8 +41,8 @@ export default function VkSources() {
|
|||||||
const doAdd = async e => {
|
const doAdd = async e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
try {
|
try {
|
||||||
await adminVkAddSource(token, form.group_id.trim(), form.group_name.trim(), form.enabled)
|
await adminVkAddSource(token, form.group_id.trim(), form.group_name.trim(), form.enabled, form.strict_filter)
|
||||||
setForm({ group_id: '', group_name: '', enabled: true })
|
setForm({ group_id: '', group_name: '', enabled: true, strict_filter: true })
|
||||||
flash('success', 'Источник добавлен')
|
flash('success', 'Источник добавлен')
|
||||||
load()
|
load()
|
||||||
} catch(err) { flash('error', String(err.message)) }
|
} catch(err) { flash('error', String(err.message)) }
|
||||||
@@ -50,7 +51,7 @@ export default function VkSources() {
|
|||||||
const doUpdate = async () => {
|
const doUpdate = async () => {
|
||||||
if (!editing) return
|
if (!editing) return
|
||||||
try {
|
try {
|
||||||
await adminVkUpdateSource(token, editing.id, editing.group_id, editing.group_name, editing.enabled)
|
await adminVkUpdateSource(token, editing.id, editing.group_id, editing.group_name, editing.enabled, editing.strict_filter)
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
flash('success', 'Сохранено')
|
flash('success', 'Сохранено')
|
||||||
load()
|
load()
|
||||||
@@ -79,7 +80,7 @@ export default function VkSources() {
|
|||||||
const doHistory = async (sourceId = null) => {
|
const doHistory = async (sourceId = null) => {
|
||||||
setImporting('history')
|
setImporting('history')
|
||||||
try {
|
try {
|
||||||
const r = await adminVkImportHistory(token, sourceId)
|
const r = await adminVkImportHistory(token, sourceId, sinceDays)
|
||||||
flash('success', r.message ?? `История загружена: +${r.imported ?? 0} постов`)
|
flash('success', r.message ?? `История загружена: +${r.imported ?? 0} постов`)
|
||||||
} catch { flash('error', 'Ошибка загрузки истории') }
|
} catch { flash('error', 'Ошибка загрузки истории') }
|
||||||
finally { setImporting('') }
|
finally { setImporting('') }
|
||||||
@@ -103,7 +104,17 @@ export default function VkSources() {
|
|||||||
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
|
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
|
||||||
{status?.configured ? `ВК API настроен (токен: ${status.token_preview ?? '***'})` : 'ВК API не настроен (нет токена)'}
|
{status?.configured ? `ВК API настроен (токен: ${status.token_preview ?? '***'})` : 'ВК API не настроен (нет токена)'}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.85rem' }}>
|
||||||
|
<span style={{ color: 'var(--c-text-2)' }}>Глубина:</span>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={3650} value={sinceDays}
|
||||||
|
onChange={e => setSinceDays(Number(e.target.value))}
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: 70, padding: '3px 6px', fontSize: '0.85rem' }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: 'var(--c-text-2)' }}>дн.</span>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={doImport}
|
onClick={doImport}
|
||||||
@@ -136,6 +147,11 @@ export default function VkSources() {
|
|||||||
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} />
|
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} />
|
||||||
Активен
|
Активен
|
||||||
</label>
|
</label>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.875rem', paddingBottom: 4 }}>
|
||||||
|
<input type="checkbox" checked={form.strict_filter}
|
||||||
|
onChange={e => setForm(f => ({ ...f, strict_filter: e.target.checked }))} />
|
||||||
|
Строгий фильтр
|
||||||
|
</label>
|
||||||
<button className="btn btn-primary" type="submit" style={{ paddingBottom: 9, paddingTop: 9 }}>Добавить</button>
|
<button className="btn btn-primary" type="submit" style={{ paddingBottom: 9, paddingTop: 9 }}>Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -148,6 +164,7 @@ export default function VkSources() {
|
|||||||
<th>ID группы</th>
|
<th>ID группы</th>
|
||||||
<th>Название</th>
|
<th>Название</th>
|
||||||
<th>Активен</th>
|
<th>Активен</th>
|
||||||
|
<th title="Строгий фильтр: только посты с тегами. Выкл — все посты паблика">Фильтр тегов</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -178,7 +195,7 @@ export default function VkSources() {
|
|||||||
className="toggle-btn"
|
className="toggle-btn"
|
||||||
data-on={s.enabled}
|
data-on={s.enabled}
|
||||||
title={s.enabled ? 'Отключить' : 'Включить'}
|
title={s.enabled ? 'Отключить' : 'Включить'}
|
||||||
onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, !s.enabled)
|
onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, !s.enabled, s.strict_filter)
|
||||||
.then(load).catch(() => flash('error', 'Ошибка'))}
|
.then(load).catch(() => flash('error', 'Ошибка'))}
|
||||||
>
|
>
|
||||||
{s.enabled ? '✅' : '❌'}
|
{s.enabled ? '✅' : '❌'}
|
||||||
@@ -186,6 +203,23 @@ export default function VkSources() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{editing?.id === s.id
|
||||||
|
? <input type="checkbox" checked={editing.strict_filter}
|
||||||
|
onChange={e => setEditing(ed => ({ ...ed, strict_filter: e.target.checked }))} />
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
className="toggle-btn"
|
||||||
|
data-on={s.strict_filter}
|
||||||
|
title={s.strict_filter ? 'Строгий фильтр: только посты с тегами. Нажмите чтобы отключить' : 'Фильтр выключен: импортируются все посты. Нажмите чтобы включить'}
|
||||||
|
onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, s.enabled, !s.strict_filter)
|
||||||
|
.then(load).catch(() => flash('error', 'Ошибка'))}
|
||||||
|
>
|
||||||
|
{s.strict_filter ? '🔒' : '🔓'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="td-actions">
|
<div className="td-actions">
|
||||||
{editing?.id === s.id
|
{editing?.id === s.id
|
||||||
@@ -208,7 +242,7 @@ export default function VkSources() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{sources.length === 0 && (
|
{sources.length === 0 && (
|
||||||
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--c-text-2)', padding: '2rem' }}>
|
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--c-text-2)', padding: '2rem' }}>
|
||||||
Источников нет
|
Источников нет
|
||||||
</td></tr>
|
</td></tr>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ export default function App() {
|
|||||||
const articleLoadedRef = useRef(false)
|
const articleLoadedRef = useRef(false)
|
||||||
const doSaveRef = useRef(null) // always points to latest doSave
|
const doSaveRef = useRef(null) // always points to latest doSave
|
||||||
|
|
||||||
|
// Loaded article data — fetched independently of editor lifecycle
|
||||||
|
const [articleData, setArticleData] = useState(null)
|
||||||
|
|
||||||
/* ── Editor ── */
|
/* ── Editor ── */
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -84,7 +87,14 @@ export default function App() {
|
|||||||
Color,
|
Color,
|
||||||
Highlight.configure({ multicolor: true }),
|
Highlight.configure({ multicolor: true }),
|
||||||
TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
|
TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
|
||||||
Image.configure({ allowBase64: false }),
|
Image.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
style: { default: null },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).configure({ allowBase64: false }),
|
||||||
Video,
|
Video,
|
||||||
Link.configure({ openOnClick: false, HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' } }),
|
Link.configure({ openOnClick: false, HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' } }),
|
||||||
Table.configure({ resizable: true }),
|
Table.configure({ resizable: true }),
|
||||||
@@ -105,45 +115,51 @@ export default function App() {
|
|||||||
// Keep editorRef current every render so doSave never uses a stale instance
|
// Keep editorRef current every render so doSave never uses a stale instance
|
||||||
editorRef.current = editor
|
editorRef.current = editor
|
||||||
|
|
||||||
/* ── Load categories once on mount ── */
|
/* ── Load categories + article data on mount (independent of editor) ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getCategories(TOKEN).then(setCategories).catch(() => {})
|
api.getCategories(TOKEN).then(setCategories).catch(() => {})
|
||||||
|
if (!IS_NEW) {
|
||||||
|
api.getArticle(ARTICLE_ID, TOKEN).then(setArticleData).catch(() => {})
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/* ── Load article once the editor is ready ── */
|
/* ── Apply article data once BOTH editor AND data are ready ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || IS_NEW || articleLoadedRef.current) return
|
if (!editor || !articleData || articleLoadedRef.current) return
|
||||||
articleLoadedRef.current = true
|
articleLoadedRef.current = true
|
||||||
|
|
||||||
api.getArticle(ARTICLE_ID, TOKEN)
|
|
||||||
.then(data => {
|
|
||||||
setMeta({
|
setMeta({
|
||||||
id: data.id,
|
id: articleData.id,
|
||||||
title: data.title,
|
title: articleData.title,
|
||||||
slug: data.slug,
|
slug: articleData.slug,
|
||||||
excerpt: data.excerpt || '',
|
excerpt: articleData.excerpt || '',
|
||||||
cover_url: data.cover_url || null,
|
cover_url: articleData.cover_url || null,
|
||||||
source_url: data.source_url || null,
|
source_url: articleData.source_url || null,
|
||||||
font_family: data.font_family || 'Merriweather',
|
font_family: articleData.font_family || 'Merriweather',
|
||||||
category_id: data.category?.id || null,
|
category_id: articleData.category?.id || null,
|
||||||
tag_names: (data.tags || []).map(t => t.name),
|
tag_names: (articleData.tags || []).map(t => t.name),
|
||||||
status: data.status,
|
status: articleData.status,
|
||||||
})
|
})
|
||||||
const html = data.content?.startsWith('<')
|
|
||||||
? data.content
|
const html = articleData.content?.startsWith('<')
|
||||||
: marked.parse(data.content || '')
|
? articleData.content
|
||||||
|
: marked.parse(articleData.content || '')
|
||||||
const isFullDoc = /^\s*<!DOCTYPE|^\s*<html/i.test(html)
|
const isFullDoc = /^\s*<!DOCTYPE|^\s*<html/i.test(html)
|
||||||
|
|
||||||
|
// Use setTimeout to ensure React has committed meta state before setContent
|
||||||
|
setTimeout(() => {
|
||||||
|
const ed = editorRef.current
|
||||||
|
if (!ed) return
|
||||||
if (isFullDoc) {
|
if (isFullDoc) {
|
||||||
setRawHtml(html)
|
setRawHtml(html)
|
||||||
editor.commands.setContent('', false)
|
ed.commands.setContent('', false)
|
||||||
} else {
|
} else {
|
||||||
setRawHtml(null)
|
setRawHtml(null)
|
||||||
editor.commands.setContent(html, false)
|
ed.commands.setContent(html, false)
|
||||||
}
|
}
|
||||||
setSaveStatus('saved')
|
setSaveStatus('saved')
|
||||||
})
|
}, 0)
|
||||||
.catch(() => {})
|
}, [editor, articleData])
|
||||||
}, [editor])
|
|
||||||
|
|
||||||
/* ── Save ─────────────────────────────────────────────────────────────────
|
/* ── Save ─────────────────────────────────────────────────────────────────
|
||||||
Uses editorRef + metaRef so this callback is stable (no deps that change).
|
Uses editorRef + metaRef so this callback is stable (no deps that change).
|
||||||
|
|||||||
Reference in New Issue
Block a user