diff --git a/README.md b/README.md index df285c8..3b63c80 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,13 @@ uvicorn app.main:app --reload Админка: `http://localhost:8000/admin`. +В админке доступны: + +- `Dashboard`: общая статистика, последний добавленный сотрудник, прогресс текущего/последнего парсинга и ручной запуск. +- `Directory`: настраиваемая таблица сотрудников с фильтрами, сортировкой, пагинацией и выбором колонок. +- `Employees`: простая legacy-таблица сотрудников. +- `Runs`: история запусков, ошибки и progress bar. + ## Docker Compose ```bash @@ -57,7 +64,7 @@ docker compose up --build ## Парсинг -Weekly worker запускается по `CRAWL_CRON`. Ручной запуск доступен в админке на странице `Runs` или через REST: +Weekly worker запускается по `CRAWL_CRON`. Ручной запуск доступен в админке на `Dashboard` и странице `Runs` или через REST: ```bash curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..." @@ -67,9 +74,12 @@ curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=. - найденные сотрудники получают статус `active` и обновленный `last_seen_at`; - новые сотрудники добавляются в `employees`; +- количество новых сотрудников за запуск сохраняется в `crawl_runs.new_count`; - активные сотрудники, исчезнувшие из текущего списка источника, получают статус `dismissed` и `dismissed_at`; - каждый успешный разбор сохраняет запись в `employee_snapshots`. +Во время выполнения парсинга `found_count`, `parsed_count` и `error_count` обновляются в базе. Админка опрашивает `/api/crawl-runs/latest` и показывает прогресс как `parsed_count + error_count / found_count`. + ## MCP Endpoint: `POST /mcp`, авторизация `Authorization: Bearer `. @@ -100,4 +110,4 @@ docker compose exec postgres pg_dump -U miem miem_workers > backup.sql docker compose down ``` -Версия сервиса: `0.1.0`. Админка всегда показывает версии backend и frontend в footer. +Версия сервиса: `0.2.0`. Админка всегда показывает версии backend и frontend в footer. diff --git a/app/admin.py b/app/admin.py index b31acb1..de5b251 100644 --- a/app/admin.py +++ b/app/admin.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request, Response +from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import desc, func, or_, select @@ -8,7 +8,8 @@ from app.config import Settings, get_settings from app.db import SessionLocal, get_db from app.models import CrawlError, CrawlRun, Employee from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin -from app.services.crawler import run_crawl +from app.services.admin_data import list_employees_page, run_payload, stats_payload +from app.services.crawl_control import get_running_run, run_crawl_if_idle from app.version import BACKEND_VERSION, FRONTEND_VERSION router = APIRouter(prefix="/admin") @@ -18,14 +19,11 @@ templates = Jinja2Templates(directory="app/templates") @router.get("", response_class=HTMLResponse) def dashboard(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): require_admin(request, settings) - counts = { - "active": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "active")) or 0, - "dismissed": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "dismissed")) or 0, - "runs": db.scalar(select(func.count()).select_from(CrawlRun)) or 0, - "errors": db.scalar(select(func.count()).select_from(CrawlError)) or 0, - } + counts = stats_payload(db) + counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0 + counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0 runs = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(10)).all() - return _render(request, "dashboard.html", {"counts": counts, "runs": runs}) + return _render(request, "dashboard.html", {"counts": counts, "runs": runs, "latest_run": run_payload(runs[0]) if runs else None}) @router.get("/login", response_class=HTMLResponse) @@ -35,7 +33,6 @@ def login_form(request: Request): @router.post("/login") def login( - response: Response, request: Request, username: str = Form(...), password: str = Form(...), @@ -74,6 +71,57 @@ def employees( return _render(request, "employees.html", {"employees": items, "status": status or "", "q": q or ""}) +@router.get("/directory", response_class=HTMLResponse) +def directory( + request: Request, + status: str | None = None, + q: str | None = None, + started_from: str | None = None, + started_to: str | None = None, + has_email: str | None = None, + sort: str = "full_name", + direction: str = "asc", + limit: int = 50, + offset: int = 0, + db: Session = Depends(get_db), + settings: Settings = Depends(get_settings), +): + require_admin(request, settings) + parsed_started_from = _parse_date(started_from) + parsed_started_to = _parse_date(started_to) + parsed_has_email = None if has_email in (None, "") else has_email == "true" + page = list_employees_page( + db, + status=status, + q=q, + started_from=parsed_started_from, + started_to=parsed_started_to, + has_email=parsed_has_email, + sort=sort, + direction=direction, + limit=limit, + offset=offset, + ) + return _render( + request, + "directory.html", + { + "page": page, + "filters": { + "status": status or "", + "q": q or "", + "started_from": started_from or "", + "started_to": started_to or "", + "has_email": has_email or "", + "sort": sort, + "direction": direction, + "limit": limit, + "offset": offset, + }, + }, + ) + + @router.get("/employees/{employee_id}", response_class=HTMLResponse) def employee_detail( employee_id: int, @@ -101,18 +149,40 @@ def runs(request: Request, db: Session = Depends(get_db), settings: Settings = D def trigger_run( request: Request, background_tasks: BackgroundTasks, + db: Session = Depends(get_db), settings: Settings = Depends(get_settings), ): require_admin(request, settings) + if get_running_run(db): + return RedirectResponse("/admin/runs", status_code=303) def _crawl() -> None: with SessionLocal() as db: - run_crawl(db, settings) + run_crawl_if_idle(db, settings) background_tasks.add_task(_crawl) return RedirectResponse("/admin/runs", status_code=303) +@router.post("/crawl-now") +def crawl_now( + request: Request, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + settings: Settings = Depends(get_settings), +): + require_admin(request, settings) + if get_running_run(db): + return RedirectResponse("/admin", status_code=303) + + def _crawl() -> None: + with SessionLocal() as db: + run_crawl_if_idle(db, settings) + + background_tasks.add_task(_crawl) + return RedirectResponse("/admin", status_code=303) + + def _render(request: Request, template: str, context: dict, status_code: int = 200) -> HTMLResponse: payload = { "request": request, @@ -121,3 +191,14 @@ def _render(request: Request, template: str, context: dict, status_code: int = 2 **context, } return templates.TemplateResponse(template, payload, status_code=status_code) + + +def _parse_date(value: str | None): + if not value: + return None + try: + from datetime import date + + return date.fromisoformat(value) + except ValueError: + return None diff --git a/app/api.py b/app/api.py index bc41735..12f3a78 100644 --- a/app/api.py +++ b/app/api.py @@ -1,12 +1,15 @@ +from datetime import date + from fastapi import APIRouter, BackgroundTasks, Depends, Request -from sqlalchemy import desc, or_, select +from sqlalchemy import desc, select from sqlalchemy.orm import Session from app.config import Settings, get_settings from app.db import SessionLocal, get_db from app.models import CrawlRun, Employee from app.security import require_admin -from app.services.crawler import run_crawl +from app.services.admin_data import employee_display_payload, list_employees_page, run_payload, stats_payload +from app.services.crawl_control import get_running_run, run_crawl_if_idle from app.version import BACKEND_VERSION, FRONTEND_VERSION router = APIRouter(prefix="/api") @@ -22,20 +25,29 @@ def list_employees( request: Request, status: str | None = None, q: str | None = None, + started_from: date | None = None, + started_to: date | None = None, + has_email: bool | None = None, + sort: str = "full_name", + direction: str = "asc", limit: int = 50, offset: int = 0, db: Session = Depends(get_db), settings: Settings = Depends(get_settings), ) -> dict: require_admin(request, settings) - stmt = select(Employee) - if status: - stmt = stmt.where(Employee.status == status) - if q: - pattern = f"%{q}%" - stmt = stmt.where(or_(Employee.full_name.ilike(pattern), Employee.canonical_url.ilike(pattern))) - employees = db.scalars(stmt.order_by(Employee.full_name).limit(limit).offset(offset)).all() - return {"items": [_employee_summary(item) for item in employees], "limit": limit, "offset": offset} + return list_employees_page( + db, + status=status, + q=q, + started_from=started_from, + started_to=started_to, + has_email=has_email, + sort=sort, + direction=direction, + limit=limit, + offset=offset, + ) @router.get("/employees/{employee_id}") @@ -61,34 +73,53 @@ def list_crawl_runs( ) -> dict: require_admin(request, settings) runs = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(limit)).all() - return {"items": [_run_summary(run) for run in runs]} + return {"items": [run_payload(run) for run in runs]} + + +@router.get("/crawl-runs/latest") +def latest_crawl_run( + request: Request, + db: Session = Depends(get_db), + settings: Settings = Depends(get_settings), +) -> dict: + require_admin(request, settings) + running = get_running_run(db) + latest = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1)) + return {"running": run_payload(running), "latest": run_payload(latest)} @router.post("/crawl-runs") def trigger_crawl( request: Request, background_tasks: BackgroundTasks, + db: Session = Depends(get_db), settings: Settings = Depends(get_settings), ) -> dict: require_admin(request, settings) + running = get_running_run(db) + if running: + return {"status": "already_running", "run": run_payload(running)} def _crawl() -> None: with SessionLocal() as db: - run_crawl(db, settings) + run_crawl_if_idle(db, settings) background_tasks.add_task(_crawl) return {"status": "scheduled"} +@router.get("/stats") +def stats( + request: Request, + db: Session = Depends(get_db), + settings: Settings = Depends(get_settings), +) -> dict: + require_admin(request, settings) + return stats_payload(db) + + def _employee_summary(employee: Employee) -> dict: - return { - "id": employee.id, - "full_name": employee.full_name, - "status": employee.status, - "canonical_url": employee.canonical_url, - "last_seen_at": employee.last_seen_at.isoformat() if employee.last_seen_at else None, - "dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None, - } + return employee_display_payload(employee) def _employee_detail(employee: Employee) -> dict: @@ -99,15 +130,4 @@ def _employee_detail(employee: Employee) -> dict: def _run_summary(run: CrawlRun) -> dict: - return { - "id": run.id, - "source_url": run.source_url, - "status": run.status, - "started_at": run.started_at.isoformat() if run.started_at else None, - "finished_at": run.finished_at.isoformat() if run.finished_at else None, - "found_count": run.found_count, - "parsed_count": run.parsed_count, - "error_count": run.error_count, - "dismissed_count": run.dismissed_count, - "message": run.message, - } + return run_payload(run) or {} diff --git a/app/models.py b/app/models.py index 805aa3b..dd84c56 100644 --- a/app/models.py +++ b/app/models.py @@ -69,6 +69,7 @@ class CrawlRun(Base): finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) found_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) parsed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + new_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) error_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) dismissed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) message: Mapped[str | None] = mapped_column(Text) diff --git a/app/services/admin_data.py b/app/services/admin_data.py new file mode 100644 index 0000000..133c4c9 --- /dev/null +++ b/app/services/admin_data.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from datetime import date, datetime, time +from math import ceil +from typing import Any + +from sqlalchemy import Select, Text, and_, desc, func, or_, select +from sqlalchemy.orm import Session + +from app.models import CrawlRun, Employee + +EMPLOYEE_SORTS = { + "full_name": Employee.full_name, + "status": Employee.status, + "first_seen_at": Employee.first_seen_at, + "last_seen_at": Employee.last_seen_at, + "dismissed_at": Employee.dismissed_at, + "hse_start_year": Employee.current_data["hse_start_year"].as_integer(), +} + + +def employee_display_payload(employee: Employee) -> dict[str, Any]: + data = employee.current_data or {} + contacts = data.get("contacts") or {} + sections = data.get("sections") or [] + emails = contacts.get("emails") or [] + phones = contacts.get("phones") or [] + return { + "id": employee.id, + "full_name": employee.full_name, + "status": employee.status, + "canonical_url": employee.canonical_url, + "positions": data.get("positions") or [], + "positions_text": "; ".join(data.get("positions") or []), + "hse_start_year": data.get("hse_start_year"), + "emails": emails, + "email_text": ", ".join(emails), + "phones": phones, + "phone_text": ", ".join(phones), + "address": contacts.get("address"), + "publications_count": _count_section_items(sections, "publications"), + "courses_count": _count_section_items(sections, "courses_by_year"), + "first_seen_at": employee.first_seen_at.isoformat() if employee.first_seen_at else None, + "last_seen_at": employee.last_seen_at.isoformat() if employee.last_seen_at else None, + "dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None, + } + + +def build_employee_query( + *, + status: str | None = None, + q: str | None = None, + started_from: date | None = None, + started_to: date | None = None, + has_email: bool | None = None, +) -> Select[tuple[Employee]]: + stmt = select(Employee) + filters = [] + if status: + filters.append(Employee.status == status) + if q: + pattern = f"%{q}%" + filters.append(or_(Employee.full_name.ilike(pattern), Employee.canonical_url.ilike(pattern))) + if started_from: + filters.append(Employee.first_seen_at >= datetime.combine(started_from, time.min)) + if started_to: + filters.append(Employee.first_seen_at <= datetime.combine(started_to, time.max)) + if has_email is True: + filters.append(Employee.current_data.cast(Text).ilike("%@%")) + elif has_email is False: + filters.append(or_(Employee.current_data.is_(None), ~Employee.current_data.cast(Text).ilike("%@%"))) + if filters: + stmt = stmt.where(and_(*filters)) + return stmt + + +def list_employees_page( + db: Session, + *, + status: str | None = None, + q: str | None = None, + started_from: date | None = None, + started_to: date | None = None, + has_email: bool | None = None, + sort: str = "full_name", + direction: str = "asc", + limit: int = 50, + offset: int = 0, +) -> dict[str, Any]: + limit = max(1, min(limit, 200)) + offset = max(0, offset) + base_stmt = build_employee_query( + status=status, + q=q, + started_from=started_from, + started_to=started_to, + has_email=has_email, + ) + total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0 + sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name) + order = desc(sort_column) if direction == "desc" else sort_column + employees = db.scalars(base_stmt.order_by(order).limit(limit).offset(offset)).all() + return { + "items": [employee_display_payload(employee) for employee in employees], + "total": total, + "limit": limit, + "offset": offset, + "pages": ceil(total / limit) if total else 0, + "page": (offset // limit) + 1, + } + + +def stats_payload(db: Session) -> dict[str, Any]: + latest_run = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1)) + running_run = db.scalar(select(CrawlRun).where(CrawlRun.status == "running").order_by(desc(CrawlRun.started_at)).limit(1)) + latest_added = db.scalar(select(Employee).order_by(desc(Employee.first_seen_at)).limit(1)) + return { + "total": db.scalar(select(func.count()).select_from(Employee)) or 0, + "active": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "active")) or 0, + "dismissed": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "dismissed")) or 0, + "new_in_last_run": latest_run.new_count if latest_run else 0, + "latest_added": employee_display_payload(latest_added) if latest_added else None, + "latest_run": run_payload(latest_run) if latest_run else None, + "current_running_run": run_payload(running_run) if running_run else None, + } + + +def run_payload(run: CrawlRun | None) -> dict[str, Any] | None: + if not run: + return None + processed = run.parsed_count + run.error_count + percent = round((processed / run.found_count) * 100, 1) if run.found_count else 0 + return { + "id": run.id, + "source_url": run.source_url, + "status": run.status, + "started_at": run.started_at.isoformat() if run.started_at else None, + "finished_at": run.finished_at.isoformat() if run.finished_at else None, + "found_count": run.found_count, + "parsed_count": run.parsed_count, + "new_count": run.new_count, + "error_count": run.error_count, + "dismissed_count": run.dismissed_count, + "processed_count": processed, + "progress_percent": percent, + "message": run.message, + } + + +def _count_section_items(sections: list[dict[str, Any]], section_type: str) -> int: + total = 0 + for section in sections: + if section.get("type") != section_type: + continue + if section_type == "publications": + total += len(section.get("publications") or section.get("items") or []) + elif section_type == "courses_by_year": + total += len(section.get("courses") or []) + return total diff --git a/app/services/crawl_control.py b/app/services/crawl_control.py new file mode 100644 index 0000000..e478dbc --- /dev/null +++ b/app/services/crawl_control.py @@ -0,0 +1,17 @@ +from sqlalchemy import desc, select +from sqlalchemy.orm import Session + +from app.config import Settings +from app.models import CrawlRun +from app.services.crawler import run_crawl + + +def get_running_run(db: Session) -> CrawlRun | None: + return db.scalar(select(CrawlRun).where(CrawlRun.status == "running").order_by(desc(CrawlRun.started_at)).limit(1)) + + +def run_crawl_if_idle(db: Session, settings: Settings) -> tuple[CrawlRun, bool]: + running = get_running_run(db) + if running: + return running, False + return run_crawl(db, settings), True diff --git a/app/services/crawler.py b/app/services/crawler.py index a0861e2..377a03e 100644 --- a/app/services/crawler.py +++ b/app/services/crawler.py @@ -106,6 +106,7 @@ def _upsert_employee(db: Session, run: CrawlRun, parsed: dict) -> Employee: first_seen_at=now, ) db.add(employee) + run.new_count += 1 employee.full_name = parsed.get("full_name") employee.status = "active" diff --git a/app/static/admin.css b/app/static/admin.css index 3e48108..455693c 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -151,3 +151,262 @@ border-radius: 8px; white-space: pre-wrap; } + +.stats-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; + margin-top: 16px; +} + +.stats-strip__item { + padding: 14px 16px; + background: #ffffff; + border: 1px solid #d9dee7; + border-radius: 8px; +} + +.stats-strip__label { + display: block; + color: #6b7280; + font-size: 12px; + text-transform: uppercase; +} + +.stats-strip__value { + display: block; + margin-top: 6px; + color: #1f2937; + font-weight: 700; +} + +.progress-panel { + display: grid; + gap: 12px; +} + +.progress-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.progress-panel__body { + display: grid; + gap: 10px; +} + +.progress-panel__meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + color: #4b5563; + font-size: 14px; +} + +.progress-panel__percent { + color: #0f766e; + font-weight: 700; +} + +.progress-panel__empty { + margin: 0; + color: #6b7280; +} + +.progress-bar { + height: 12px; + overflow: hidden; + background: #e5e7eb; + border-radius: 999px; +} + +.progress-bar__fill { + height: 100%; + width: 0; + background: #0f766e; + transition: width 0.25s ease; +} + +.directory { + display: grid; + gap: 18px; +} + +.directory__header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; +} + +.directory__title { + margin: 0; + font-size: 24px; +} + +.directory__summary { + margin: 6px 0 0; + color: #6b7280; +} + +.directory__filters { + display: grid; + grid-template-columns: minmax(220px, 1.7fr) repeat(6, minmax(120px, 1fr)); + gap: 10px; + padding: 16px; + background: #ffffff; + border: 1px solid #d9dee7; + border-radius: 8px; +} + +.directory__input { + min-width: 0; + padding: 10px 12px; + border: 1px solid #cbd5e1; + border-radius: 6px; +} + +.directory__table-wrap { + overflow-x: auto; + background: #ffffff; + border: 1px solid #d9dee7; + border-radius: 8px; +} + +.directory__pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; +} + +.directory__page { + color: #4b5563; + font-weight: 700; +} + +.directory-table { + width: 100%; + min-width: 1120px; + border-collapse: collapse; +} + +.directory-table__head { + padding: 12px 10px; + color: #374151; + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; + font-size: 13px; + text-align: left; + white-space: nowrap; +} + +.directory-table__cell { + max-width: 280px; + padding: 12px 10px; + border-bottom: 1px solid #e5e7eb; + vertical-align: top; +} + +.directory-table__row { + cursor: pointer; +} + +.directory-table__row:hover { + background: #f0fdfa; +} + +.directory-table__empty { + padding: 28px; + color: #6b7280; + text-align: center; +} + +.directory-table__cell--hidden, +.directory-table__head--hidden { + display: none; +} + +.columns-modal { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 20px; +} + +.columns-modal[hidden] { + display: none; +} + +.columns-modal__backdrop { + position: absolute; + inset: 0; + background: rgba(17, 24, 39, 0.54); +} + +.columns-modal__panel { + position: relative; + width: min(620px, 100%); + max-height: min(720px, calc(100vh - 40px)); + overflow: auto; + padding: 20px; + background: #ffffff; + border-radius: 8px; + box-shadow: 0 24px 80px rgba(15, 23, 42, 0.22); +} + +.columns-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.columns-modal__title { + margin: 0; + font-size: 18px; +} + +.columns-modal__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin-top: 18px; +} + +.columns-modal__option { + display: flex; + align-items: center; + gap: 8px; + padding: 10px; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 6px; +} + +.columns-modal__checkbox { + width: 16px; + height: 16px; +} + +@media (max-width: 920px) { + .directory__filters { + grid-template-columns: 1fr 1fr; + } + + .progress-panel__header, + .directory__header { + align-items: stretch; + flex-direction: column; + } +} + +@media (max-width: 620px) { + .directory__filters { + grid-template-columns: 1fr; + } +} diff --git a/app/static/admin.js b/app/static/admin.js new file mode 100644 index 0000000..9e2165d --- /dev/null +++ b/app/static/admin.js @@ -0,0 +1,111 @@ +(function () { + const columnDefaults = [ + "full_name", + "status", + "positions", + "hse_start_year", + "email", + "last_seen_at", + "dismissed_at", + "profile", + ]; + const storageKey = "miem.directory.columns"; + + function readColumns() { + try { + const stored = JSON.parse(localStorage.getItem(storageKey) || "[]"); + return Array.isArray(stored) && stored.length ? stored : columnDefaults; + } catch (_error) { + return columnDefaults; + } + } + + function writeColumns(columns) { + localStorage.setItem(storageKey, JSON.stringify(columns)); + } + + function applyColumns(columns) { + document.querySelectorAll("[data-column]").forEach((node) => { + const visible = columns.includes(node.dataset.column); + node.classList.toggle("directory-table__cell--hidden", !visible && node.classList.contains("directory-table__cell")); + node.classList.toggle("directory-table__head--hidden", !visible && node.classList.contains("directory-table__head")); + }); + document.querySelectorAll("[data-column-toggle]").forEach((checkbox) => { + checkbox.checked = columns.includes(checkbox.value); + }); + } + + function setupColumns() { + if (!document.querySelector("[data-directory-table]")) return; + let columns = readColumns(); + const modal = document.querySelector("[data-columns-modal]"); + applyColumns(columns); + + document.querySelectorAll("[data-columns-open]").forEach((button) => { + button.addEventListener("click", () => { + modal.hidden = false; + }); + }); + document.querySelectorAll("[data-columns-close]").forEach((button) => { + button.addEventListener("click", () => { + modal.hidden = true; + }); + }); + document.querySelectorAll("[data-column-toggle]").forEach((checkbox) => { + checkbox.addEventListener("change", () => { + columns = Array.from(document.querySelectorAll("[data-column-toggle]:checked")).map((item) => item.value); + if (!columns.length) columns = ["full_name"]; + writeColumns(columns); + applyColumns(columns); + }); + }); + document.querySelectorAll("[data-row-href]").forEach((row) => { + row.addEventListener("click", (event) => { + if (event.target.closest("a, button, input, select, label")) return; + window.location.href = row.dataset.rowHref; + }); + }); + } + + function setupProgress() { + const panel = document.querySelector("[data-progress-panel]"); + if (!panel) return; + + const update = (run) => { + if (!run) return; + const status = document.querySelector("[data-progress-status]"); + const processed = document.querySelector("[data-progress-processed]"); + const found = document.querySelector("[data-progress-found]"); + const errors = document.querySelector("[data-progress-errors]"); + const fill = document.querySelector("[data-progress-fill]"); + const percent = document.querySelector("[data-progress-percent]"); + if (status) status.textContent = run.status; + if (processed) processed.textContent = run.processed_count; + if (found) found.textContent = run.found_count; + if (errors) errors.textContent = run.error_count; + if (fill) fill.style.width = `${run.progress_percent}%`; + if (percent) percent.textContent = run.progress_percent; + }; + + const poll = async () => { + try { + const response = await fetch("/api/crawl-runs/latest", { credentials: "same-origin" }); + if (!response.ok) return false; + const data = await response.json(); + const run = data.running || data.latest; + update(run); + return Boolean(data.running); + } catch (_error) { + return false; + } + }; + + const interval = window.setInterval(async () => { + const keepGoing = await poll(); + if (!keepGoing) window.clearInterval(interval); + }, 4000); + } + + setupColumns(); + setupProgress(); +})(); diff --git a/app/templates/base.html b/app/templates/base.html index f058686..977dd0e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -11,6 +11,7 @@

MIEM Employees