diff --git a/Dockerfile b/Dockerfile index b092997..bfb902a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,8 @@ FROM alpine:3.20 ARG KIND_VERSION=0.24.0 +# Helm для страницы «Аддоны» (установка ingress-nginx, prometheus-stack и т.д.) +ARG HELM_VERSION=v3.16.3 # Пусто — взять актуальный stable.txt; иначе явная версия, например v1.32.0 ARG KUBECTL_VERSION= # Платформа целевого образа (BuildKit подставляет amd64/arm64; иначе — uname внутри слоя) @@ -28,6 +30,10 @@ RUN apk add --no-cache python3 py3-pip docker-cli curl bash ca-certificates \ && if [ -n "${KUBECTL_VERSION}" ]; then KVER="${KUBECTL_VERSION}"; else KVER=$(curl -Ls https://dl.k8s.io/release/stable.txt); fi \ && curl -sSLo /usr/local/bin/kubectl "https://dl.k8s.io/release/${KVER}/bin/linux/${KARCH}/kubectl" \ && chmod +x /usr/local/bin/kubectl \ + && curl -sSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-${KARCH}.tar.gz" | tar xz -C /tmp \ + && mv "/tmp/linux-${KARCH}/helm" /usr/local/bin/helm \ + && chmod +x /usr/local/bin/helm \ + && rm -rf "/tmp/linux-${KARCH}" \ && pip3 install --no-cache-dir --break-system-packages -r /opt/kind-k8s/requirements.txt COPY app/ /opt/kind-k8s/app/ diff --git a/README.md b/README.md index 64f5415..cb5d836 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий. - **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке). - **Таблица кластеров**: признаки регистрации в kind и наличия kubeconfig, **старт** и **стоп** узлов (фоновые задания с тем же журналом, что и при создании), скачивание kubeconfig, просмотр узлов/подов в модальном окне, удаление. +- **Аддоны** (`/cluster-addons`): выбор кластера и установка/удаление через **Helm** в контейнере — **ingress-nginx** (версия чарта, NodePort 30080 для kind), **kube-prometheus-stack** (логин/пароль Grafana), **metrics-server**, **Istio + Kiali** (логин/пароль Kiali). Нужна **пересборка образа** после обновления Dockerfile (бинарник `helm`). Таймаут операций: **`KIND_K8S_HELM_TIMEOUT_SEC`** (по умолчанию 900 с). - **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания). - **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы). - При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **docker** при поддержке CLI — **`pull --progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды. diff --git a/app/api/v1/endpoints/cluster_addons.py b/app/api/v1/endpoints/cluster_addons.py new file mode 100644 index 0000000..fa814e6 --- /dev/null +++ b/app/api/v1/endpoints/cluster_addons.py @@ -0,0 +1,267 @@ +"""Helm-аддоны для выбранного кластера: ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali. + +Операции синхронные (долгие таймауты — см. ``KIND_K8S_HELM_TIMEOUT_SEC``). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter, Body, HTTPException + +from core.cluster_lifecycle import validate_cluster_name +from core.helm_addons import ( + HelmAddonError, + addon_status, + require_helm_binary, + install_ingress_nginx, + install_istio_and_kiali, + install_kube_prometheus_stack, + install_metrics_server, + uninstall_ingress_nginx, + uninstall_istio_and_kiali, + uninstall_kube_prometheus_stack, + uninstall_metrics_server, +) +from core.kind_guard import kind_cluster_lock +from models.schemas import ( + ClusterAddonsStatusResponse, + HelmAddonApplyResponse, + IngressNginxInstallRequest, + IstioKialiInstallRequest, + KubePrometheusStackInstallRequest, + MetricsServerInstallRequest, +) + +logger = logging.getLogger("kind_k8s.api.cluster_addons") + +router = APIRouter(tags=["cluster-addons"]) + +_LOG_MAX = 24000 + + +def _trim_log(s: str) -> str: + if len(s) <= _LOG_MAX: + return s + return s[: _LOG_MAX - 20] + "\n… (обрезано)" + + +def _map_helm_error(e: HelmAddonError) -> HTTPException: + msg = str(e) + if e.exit_code == 127: + return HTTPException(status_code=503, detail=msg) + return HTTPException(status_code=500, detail=msg) + + +@router.get( + "/clusters/{name}/addons/status", + response_model=ClusterAddonsStatusResponse, + summary="Статус Helm-аддонов в кластере", +) +async def get_cluster_addons_status(name: str) -> ClusterAddonsStatusResponse: + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + try: + require_helm_binary() + except HelmAddonError as e: + raise _map_helm_error(e) from e + try: + st = await asyncio.to_thread(addon_status, n) + except HelmAddonError as e: + raise _map_helm_error(e) from e + mesh = bool(st.get("istio_base") and st.get("istiod") and st.get("kiali")) + return ClusterAddonsStatusResponse( + cluster_name=n, + ingress_nginx=bool(st.get("ingress_nginx")), + kube_prometheus_stack=bool(st.get("kube_prometheus_stack")), + metrics_server=bool(st.get("metrics_server")), + istio_base=bool(st.get("istio_base")), + istiod=bool(st.get("istiod")), + kiali_server=bool(st.get("kiali")), + istio_mesh_ready=mesh, + ) + + +@router.post( + "/clusters/{name}/addons/ingress-nginx", + response_model=HelmAddonApplyResponse, + summary="Установить ingress-nginx (Helm)", +) +async def post_install_ingress_nginx( + name: str, + body: IngressNginxInstallRequest | None = Body(default=None), +) -> HelmAddonApplyResponse: + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + ver: str | None = None + if body is not None and body.chart_version and body.chart_version.strip(): + ver = body.chart_version.strip() + async with kind_cluster_lock: + try: + _, log = await asyncio.to_thread(install_ingress_nginx, n, ver) + except HelmAddonError as e: + raise _map_helm_error(e) from e + logger.info("Установлен ingress-nginx для кластера «%s»", n) + return HelmAddonApplyResponse(ok=True, message="ingress-nginx установлен.", log=_trim_log(log)) + + +@router.delete( + "/clusters/{name}/addons/ingress-nginx", + response_model=HelmAddonApplyResponse, + summary="Удалить ingress-nginx", +) +async def delete_ingress_nginx(name: str) -> HelmAddonApplyResponse: + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + async with kind_cluster_lock: + try: + _, log = await asyncio.to_thread(uninstall_ingress_nginx, n) + except HelmAddonError as e: + raise _map_helm_error(e) from e + return HelmAddonApplyResponse(ok=True, message="ingress-nginx удалён.", log=_trim_log(log)) + + +@router.post( + "/clusters/{name}/addons/kube-prometheus-stack", + response_model=HelmAddonApplyResponse, + summary="Установить kube-prometheus-stack (Prometheus Operator + Grafana)", +) +async def post_install_kube_prometheus_stack( + name: str, + body: KubePrometheusStackInstallRequest, +) -> HelmAddonApplyResponse: + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + prom_ver: str | None = None + if body.chart_version and body.chart_version.strip(): + prom_ver = body.chart_version.strip() + async with kind_cluster_lock: + try: + _, log = await asyncio.to_thread( + install_kube_prometheus_stack, + n, + grafana_admin_user=body.grafana_admin_user.strip(), + grafana_admin_password=body.grafana_admin_password, + chart_version=prom_ver, + ) + except HelmAddonError as e: + raise _map_helm_error(e) from e + logger.info("Установлен kube-prometheus-stack для кластера «%s»", n) + return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack установлен.", log=_trim_log(log)) + + +@router.delete( + "/clusters/{name}/addons/kube-prometheus-stack", + response_model=HelmAddonApplyResponse, + summary="Удалить kube-prometheus-stack", +) +async def delete_kube_prometheus_stack(name: str) -> HelmAddonApplyResponse: + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + async with kind_cluster_lock: + try: + _, log = await asyncio.to_thread(uninstall_kube_prometheus_stack, n) + except HelmAddonError as e: + raise _map_helm_error(e) from e + return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack удалён.", log=_trim_log(log)) + + +@router.post( + "/clusters/{name}/addons/metrics-server", + response_model=HelmAddonApplyResponse, + summary="Установить metrics-server", +) +async def post_install_metrics_server( + name: str, + body: MetricsServerInstallRequest | None = Body(default=None), +) -> HelmAddonApplyResponse: + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + ms_ver: str | None = None + if body is not None and body.chart_version and body.chart_version.strip(): + ms_ver = body.chart_version.strip() + async with kind_cluster_lock: + try: + _, log = await asyncio.to_thread(install_metrics_server, n, ms_ver) + except HelmAddonError as e: + raise _map_helm_error(e) from e + logger.info("Установлен metrics-server для кластера «%s»", n) + return HelmAddonApplyResponse(ok=True, message="metrics-server установлен.", log=_trim_log(log)) + + +@router.delete( + "/clusters/{name}/addons/metrics-server", + response_model=HelmAddonApplyResponse, + summary="Удалить metrics-server", +) +async def delete_metrics_server(name: str) -> HelmAddonApplyResponse: + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + async with kind_cluster_lock: + try: + _, log = await asyncio.to_thread(uninstall_metrics_server, n) + except HelmAddonError as e: + raise _map_helm_error(e) from e + return HelmAddonApplyResponse(ok=True, message="metrics-server удалён.", log=_trim_log(log)) + + +@router.post( + "/clusters/{name}/addons/istio-kiali", + response_model=HelmAddonApplyResponse, + summary="Установить Istio (base+istiod) и Kiali с логином", +) +async def post_install_istio_kiali( + name: str, + body: IstioKialiInstallRequest, +) -> HelmAddonApplyResponse: + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + istio_ver: str | None = None + if body.istio_chart_version and body.istio_chart_version.strip(): + istio_ver = body.istio_chart_version.strip() + kiali_ver: str | None = None + if body.kiali_chart_version and body.kiali_chart_version.strip(): + kiali_ver = body.kiali_chart_version.strip() + async with kind_cluster_lock: + try: + _, log = await asyncio.to_thread( + install_istio_and_kiali, + n, + kiali_username=body.kiali_username.strip(), + kiali_password=body.kiali_password, + istio_chart_version=istio_ver, + kiali_chart_version=kiali_ver, + ) + except HelmAddonError as e: + raise _map_helm_error(e) from e + logger.info("Установлены Istio+Kiali для кластера «%s»", n) + return HelmAddonApplyResponse(ok=True, message="Istio и Kiali установлены.", log=_trim_log(log)) + + +@router.delete( + "/clusters/{name}/addons/istio-kiali", + response_model=HelmAddonApplyResponse, + summary="Удалить Kiali, istiod и istio-base", +) +async def delete_istio_kiali(name: str) -> HelmAddonApplyResponse: + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + async with kind_cluster_lock: + try: + _, log = await asyncio.to_thread(uninstall_istio_and_kiali, n) + except HelmAddonError as e: + raise _map_helm_error(e) from e + return HelmAddonApplyResponse(ok=True, message="Istio/Kiali удалены.", log=_trim_log(log)) diff --git a/app/api/v1/endpoints/helm_charts.py b/app/api/v1/endpoints/helm_charts.py new file mode 100644 index 0000000..08046d9 --- /dev/null +++ b/app/api/v1/endpoints/helm_charts.py @@ -0,0 +1,44 @@ +"""Справочник версий Helm-чартов для страницы аддонов (``helm search repo … --versions``). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter, HTTPException + +from core.helm_addons import HelmAddonError, helm_addon_chart_versions_all, require_helm_binary +from models.schemas import HelmAddonChartVersionsResponse + +logger = logging.getLogger("kind_k8s.api.helm_charts") + +router = APIRouter(tags=["helm-charts"]) + + +@router.get( + "/helm/chart-versions", + response_model=HelmAddonChartVersionsResponse, + summary="Версии чартов аддонов (для выпадающих списков)", +) +async def get_helm_addon_chart_versions() -> HelmAddonChartVersionsResponse: + """ + Выполняет ``helm repo update`` и для каждого аддона — ``helm search repo --versions``. + + Результат кэшируется на ``KIND_K8S_HELM_VERSIONS_CACHE_SEC`` (по чарту). + Пустой список — если helm недоступен или репозиторий не ответил. + """ + try: + require_helm_binary() + except HelmAddonError as e: + logger.warning("helm недоступен для списка версий: %s", e) + raise HTTPException(status_code=503, detail=str(e)) from e + try: + data = await asyncio.to_thread(helm_addon_chart_versions_all) + except HelmAddonError as e: + logger.exception("Ошибка получения версий чартов") + raise HTTPException(status_code=500, detail=str(e)) from e + return HelmAddonChartVersionsResponse(**data) diff --git a/app/api/v1/endpoints/journal.py b/app/api/v1/endpoints/journal.py new file mode 100644 index 0000000..a01cf23 --- /dev/null +++ b/app/api/v1/endpoints/journal.py @@ -0,0 +1,84 @@ +"""Чтение журнала завершённых заданий из ``clusters/<имя>/journal/jobs_history.json``. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter, HTTPException, Query + +from core.cluster_lifecycle import validate_cluster_name +from core.job_journal import collect_recent_journal_entries_page_sync, read_cluster_journal_sync +from models.schemas import ClusterJournalResponse, JournalEntryModel, JournalRecentResponse + +logger = logging.getLogger("kind_k8s.api.journal") + +router = APIRouter(tags=["journal"]) + + +def _journal_total_pages(total: int, limit: int) -> int: + if total <= 0 or limit <= 0: + return 0 + return (total + limit - 1) // limit + + +@router.get( + "/journal/recent", + response_model=JournalRecentResponse, + summary="Страница журнала по всем кластерам (пагинация)", +) +async def get_journal_recent( + limit: int = Query(default=30, ge=1, le=100, description="Записей на страницу"), + offset: int = Query(default=0, ge=0, description="Смещение (0 = первая страница)"), +) -> JournalRecentResponse: + """Сборка из ``clusters/*/journal/jobs_history.json``, новые первыми; ``total`` и ``total_pages`` для UI.""" + rows, total = await asyncio.to_thread( + collect_recent_journal_entries_page_sync, + limit=limit, + offset=offset, + ) + entries = [JournalEntryModel.model_validate(r) for r in rows] + total_pages = _journal_total_pages(total, limit) + if total <= 0: + page = 1 + else: + page = min((offset // limit) + 1, total_pages) + page = max(1, page) + return JournalRecentResponse( + limit=limit, + offset=offset, + total=total, + page=page, + total_pages=total_pages, + entries=entries, + ) + + +@router.get( + "/clusters/{name}/journal", + response_model=ClusterJournalResponse, + summary="Журнал заданий кластера (JSON с диска)", +) +async def get_cluster_journal(name: str) -> ClusterJournalResponse: + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + raw = await asyncio.to_thread(read_cluster_journal_sync, n) + if raw is None: + return ClusterJournalResponse(cluster_name=n, file_version=None, entries=[]) + fv = raw.get("file_version") if isinstance(raw.get("file_version"), int) else None + ent = raw.get("entries") + if not isinstance(ent, list): + return ClusterJournalResponse(cluster_name=n, file_version=fv, entries=[]) + entries: list[JournalEntryModel] = [] + for item in ent: + if isinstance(item, dict): + try: + entries.append(JournalEntryModel.model_validate(item)) + except Exception: + logger.debug("Пропуск некорректной записи журнала кластера «%s»", n) + return ClusterJournalResponse(cluster_name=n, file_version=fv, entries=entries) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 77475dc..62c7805 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -8,10 +8,13 @@ from __future__ import annotations from fastapi import APIRouter -from api.v1.endpoints import clusters, docs_readme, health, versions +from api.v1.endpoints import cluster_addons, clusters, docs_readme, health, helm_charts, journal, versions api_router = APIRouter() api_router.include_router(health.router, prefix="") api_router.include_router(versions.router, prefix="") api_router.include_router(docs_readme.router, prefix="") api_router.include_router(clusters.router, prefix="") +api_router.include_router(cluster_addons.router, prefix="") +api_router.include_router(helm_charts.router, prefix="") +api_router.include_router(journal.router, prefix="") diff --git a/app/core/helm_addons.py b/app/core/helm_addons.py new file mode 100644 index 0000000..66037e8 --- /dev/null +++ b/app/core/helm_addons.py @@ -0,0 +1,512 @@ +"""Установка и удаление типовых Helm-чартов в выбранном кластере (kubeconfig с диска). + +Используются бинарники ``helm`` и ``kubectl`` из PATH; ``KUBECONFIG`` — путь из +:func:`kubeconfig_patch.kubeconfig_path_for_container_kubectl` (как для kubectl в приложении). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import subprocess +import tempfile +import threading +import time +from pathlib import Path +from typing import Any + +import yaml + +from core.cluster_lifecycle import validate_cluster_name +from kind_k8s_paths import clusters_dir +from kubeconfig_patch import kubeconfig_path_for_container_kubectl + +logger = logging.getLogger("kind_k8s.helm_addons") + +# Официальные репозитории чартов (имя → URL). +HELM_REPOS: dict[str, str] = { + "ingress-nginx": "https://kubernetes.github.io/ingress-nginx", + "prometheus-community": "https://prometheus-community.github.io/helm-charts", + "metrics-server": "https://kubernetes-sigs.github.io/metrics-server/", + "istio": "https://istio-release.storage.googleapis.com/charts", + "kiali": "https://kiali.org/helm-charts", +} + +# Ссылки для ``helm search repo --versions`` (ключи совпадают с полями API версий чартов). +HELM_ADDON_CHART_REFS: dict[str, str] = { + "ingress_nginx": "ingress-nginx/ingress-nginx", + "kube_prometheus_stack": "prometheus-community/kube-prometheus-stack", + "metrics_server": "metrics-server/metrics-server", + # Для base и istiod обычно берут одну версию чарта Istio. + "istio": "istio/base", + "kiali_server": "kiali/kiali-server", +} + +_versions_cache: dict[str, tuple[float, list[str]]] = {} +_versions_lock = threading.Lock() + + +class HelmAddonError(Exception): + """Ошибка helm/kubectl (сообщение для API).""" + + def __init__(self, message: str, *, exit_code: int = 1) -> None: + super().__init__(message) + self.exit_code = exit_code + + +def _helm_timeout_sec() -> int: + raw = (os.environ.get("KIND_K8S_HELM_TIMEOUT_SEC") or "900").strip() + try: + return max(60, min(int(raw), 7200)) + except ValueError: + return 900 + + +def _helm_versions_cache_sec() -> int: + """TTL кэша списков версий (``helm search repo`` после ``repo update``).""" + raw = (os.environ.get("KIND_K8S_HELM_VERSIONS_CACHE_SEC") or "600").strip() + try: + return max(30, min(int(raw), 86400)) + except ValueError: + return 600 + + +def _helm_versions_max_list() -> int: + raw = (os.environ.get("KIND_K8S_HELM_VERSIONS_MAX") or "80").strip() + try: + return max(10, min(int(raw), 200)) + except ValueError: + return 80 + + +def search_repo_chart_versions(chart_ref: str) -> list[str]: + """ + Уникальные версии чарта (новые первыми), через ``helm search repo … --versions -o json``. + + Перед вызовом рекомендуется :func:`helm_repo_ensure`; здесь кэш по ``chart_ref``. + """ + ref = chart_ref.strip() + if not ref: + return [] + now = time.monotonic() + ttl = _helm_versions_cache_sec() + with _versions_lock: + hit = _versions_cache.get(ref) + if hit is not None: + ts, vers = hit + if now - ts < ttl: + logger.debug("Версии чарта %s из кэша (%s шт.)", ref, len(vers)) + return list(vers) + + require_helm_binary() + rc, out, err = _run_cmd( + ["helm", "search", "repo", ref, "--versions", "-o", "json"], + timeout=180, + ) + if rc != 0: + logger.warning("helm search repo %s: %s", ref, err or out or rc) + with _versions_lock: + _versions_cache[ref] = (now, []) + return [] + + versions: list[str] = [] + seen: set[str] = set() + try: + rows = json.loads(out or "[]") + except json.JSONDecodeError: + rows = [] + cap = _helm_versions_max_list() + if isinstance(rows, list): + for row in rows: + if not isinstance(row, dict): + continue + v = str(row.get("version", "")).strip() + if not v or v in seen: + continue + seen.add(v) + versions.append(v) + if len(versions) >= cap: + break + + with _versions_lock: + _versions_cache[ref] = (now, versions) + logger.info("Получены версии для %s: %s записей", ref, len(versions)) + return versions + + +def helm_addon_chart_versions_all() -> dict[str, list[str]]: + """ + Версии для всех аддонов UI (после обновления репозиториев — один раз). + + Ключи: ``ingress_nginx``, ``kube_prometheus_stack``, ``metrics_server``, ``istio``, ``kiali_server``. + """ + helm_repo_ensure() + out: dict[str, list[str]] = {} + for key, cref in HELM_ADDON_CHART_REFS.items(): + out[key] = search_repo_chart_versions(cref) + return out + + +def require_helm_binary() -> None: + if not shutil.which("helm"): + raise HelmAddonError("В образе не найден helm в PATH. Пересоберите образ с установленным Helm.", exit_code=127) + + +def require_kubectl_binary() -> None: + if not shutil.which("kubectl"): + raise HelmAddonError("Не найден kubectl в PATH.", exit_code=127) + + +def _kubeconfig_runtime_path(cluster_name: str) -> Path: + if not validate_cluster_name(cluster_name): + raise HelmAddonError("Некорректное имя кластера") + src = clusters_dir() / cluster_name / "kubeconfig" + if not src.is_file(): + raise HelmAddonError(f"Нет kubeconfig: {src}") + return kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src) + + +def _run_cmd( + cmd: list[str], + *, + timeout: int | None = None, + env: dict[str, str] | None = None, +) -> tuple[int, str, str]: + """Выполнить команду; возвращает (код, stdout, stderr).""" + t = timeout if timeout is not None else _helm_timeout_sec() + logger.info("Выполнение: %s (timeout=%s)", " ".join(cmd[:12]) + (" …" if len(cmd) > 12 else ""), t) + try: + p = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=t, + env={**os.environ, **(env or {})}, + ) + except subprocess.TimeoutExpired: + logger.warning("Таймаут команды: %s", cmd[0]) + raise HelmAddonError(f"Превышено время ожидания ({t} с) для команды «{cmd[0]}»", exit_code=124) from None + out = (p.stdout or "").strip() + err = (p.stderr or "").strip() + return p.returncode, out, err + + +def helm_repo_ensure() -> tuple[int, str]: + """Добавить недостающие репозитории и выполнить ``helm repo update``.""" + require_helm_binary() + lines: list[str] = [] + for name, url in HELM_REPOS.items(): + rc, o, e = _run_cmd(["helm", "repo", "add", name, url], timeout=120) + if rc != 0 and "already exists" not in (e + o).lower(): + raise HelmAddonError(f"helm repo add {name}: {e or o or rc}", exit_code=rc) + if o: + lines.append(o) + if e and "already exists" not in e.lower(): + lines.append(e) + rc2, o2, e2 = _run_cmd(["helm", "repo", "update"], timeout=300) + if rc2 != 0: + raise HelmAddonError(f"helm repo update: {e2 or o2 or rc2}", exit_code=rc2) + lines.append(o2 or e2) + return 0, "\n".join(x for x in lines if x).strip() + + +def helm_list_all(cluster_name: str) -> list[dict[str, Any]]: + """Список релизов ``helm list -A -o json``.""" + kcfg = str(_kubeconfig_runtime_path(cluster_name)) + rc, out, err = _run_cmd( + ["helm", "list", "-A", "-o", "json", "--kubeconfig", kcfg], + timeout=120, + ) + if rc != 0: + logger.warning("helm list: %s", err or out) + return [] + if not out.strip(): + return [] + try: + data = json.loads(out) + return data if isinstance(data, list) else [] + except json.JSONDecodeError: + return [] + + +def addon_status(cluster_name: str) -> dict[str, bool]: + """Какие аддоны установлены (по имени релиза и namespace).""" + lst = helm_list_all(cluster_name) + names_ns = {(str(x.get("name", "")), str(x.get("namespace", ""))) for x in lst if isinstance(x, dict)} + return { + "ingress_nginx": ("ingress-nginx", "ingress-nginx") in names_ns, + "kube_prometheus_stack": ("kube-prometheus-stack", "monitoring") in names_ns, + "metrics_server": ("metrics-server", "kube-system") in names_ns, + "istio_base": ("istio-base", "istio-system") in names_ns, + "istiod": ("istiod", "istio-system") in names_ns, + "kiali": ("kiali-server", "istio-system") in names_ns, + } + + +def _kubectl_apply_manifest(cluster_name: str, manifest: dict[str, Any]) -> tuple[int, str]: + require_kubectl_binary() + kcfg = str(_kubeconfig_runtime_path(cluster_name)) + yml = yaml.safe_dump(manifest, allow_unicode=True, default_flow_style=False) + p = subprocess.run( + ["kubectl", "apply", "-f", "-", "--kubeconfig", kcfg, "--request-timeout=120s"], + input=yml, + capture_output=True, + text=True, + timeout=180, + ) + msg = ((p.stdout or "").strip() + "\n" + (p.stderr or "").strip()).strip() + return p.returncode, msg + + +def _ensure_namespace(cluster_name: str, namespace: str) -> None: + manifest = { + "apiVersion": "v1", + "kind": "Namespace", + "metadata": {"name": namespace}, + } + rc, msg = _kubectl_apply_manifest(cluster_name, manifest) + if rc != 0: + raise HelmAddonError(f"Создание namespace {namespace}: {msg}", exit_code=rc) + + +def _helm_upgrade_install( + cluster_name: str, + release: str, + chart: str, + namespace: str, + *, + chart_version: str | None = None, + extra_args: list[str] | None = None, + values_file: Path | None = None, +) -> tuple[int, str, str]: + kcfg = str(_kubeconfig_runtime_path(cluster_name)) + cmd = [ + "helm", + "upgrade", + "--install", + release, + chart, + "-n", + namespace, + "--create-namespace", + "--kubeconfig", + kcfg, + "--wait", + "--timeout", + f"{_helm_timeout_sec()}s", + ] + ver = (chart_version or "").strip() + if ver: + cmd.extend(["--version", ver]) + if values_file is not None: + cmd.extend(["-f", str(values_file)]) + if extra_args: + cmd.extend(extra_args) + return _run_cmd(cmd, timeout=_helm_timeout_sec() + 60) + + +def _helm_uninstall(cluster_name: str, release: str, namespace: str) -> tuple[int, str, str]: + kcfg = str(_kubeconfig_runtime_path(cluster_name)) + return _run_cmd( + ["helm", "uninstall", release, "-n", namespace, "--kubeconfig", kcfg, "--wait", "--timeout", "600s"], + timeout=660, + ) + + +# --- Установка / удаление по типам --- + + +def install_ingress_nginx(cluster_name: str, chart_version: str | None) -> tuple[bool, str]: + """ingress-nginx с NodePort для kind.""" + helm_repo_ensure() + extra: list[str] = [ + "--set", + "controller.service.type=NodePort", + "--set", + "controller.service.nodePorts.http=30080", + ] + rc, out, err = _helm_upgrade_install( + cluster_name, + "ingress-nginx", + "ingress-nginx/ingress-nginx", + "ingress-nginx", + chart_version=chart_version, + extra_args=extra, + ) + text = "\n".join(filter(None, [out, err])).strip() + if rc != 0: + raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc) + return True, text or "ingress-nginx установлен." + + +def uninstall_ingress_nginx(cluster_name: str) -> tuple[bool, str]: + rc, out, err = _helm_uninstall(cluster_name, "ingress-nginx", "ingress-nginx") + text = "\n".join(filter(None, [out, err])).strip() + if rc != 0 and "not found" not in text.lower(): + raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc) + return True, text or "ingress-nginx удалён (или отсутствовал)." + + +def install_kube_prometheus_stack( + cluster_name: str, + *, + grafana_admin_user: str, + grafana_admin_password: str, + chart_version: str | None = None, +) -> tuple[bool, str]: + helm_repo_ensure() + values = { + "grafana": { + "adminUser": grafana_admin_user, + "adminPassword": grafana_admin_password, + } + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp: + yaml.safe_dump(values, tmp, allow_unicode=True) + tmp_path = Path(tmp.name) + try: + rc, out, err = _helm_upgrade_install( + cluster_name, + "kube-prometheus-stack", + "prometheus-community/kube-prometheus-stack", + "monitoring", + chart_version=chart_version, + values_file=tmp_path, + ) + finally: + tmp_path.unlink(missing_ok=True) + text = "\n".join(filter(None, [out, err])).strip() + if rc != 0: + raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc) + return True, text or "kube-prometheus-stack установлен." + + +def uninstall_kube_prometheus_stack(cluster_name: str) -> tuple[bool, str]: + rc, out, err = _helm_uninstall(cluster_name, "kube-prometheus-stack", "monitoring") + text = "\n".join(filter(None, [out, err])).strip() + if rc != 0 and "not found" not in text.lower(): + raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc) + return True, text or "kube-prometheus-stack удалён (или отсутствовал)." + + +def install_metrics_server(cluster_name: str, chart_version: str | None = None) -> tuple[bool, str]: + helm_repo_ensure() + values = { + "args": [ + "--kubelet-insecure-tls", + "--kubelet-preferred-address-types=InternalIP,Hostname,ExternalIP", + ] + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp: + yaml.safe_dump(values, tmp, allow_unicode=True) + tmp_path = Path(tmp.name) + try: + rc, out, err = _helm_upgrade_install( + cluster_name, + "metrics-server", + "metrics-server/metrics-server", + "kube-system", + chart_version=chart_version, + values_file=tmp_path, + ) + finally: + tmp_path.unlink(missing_ok=True) + text = "\n".join(filter(None, [out, err])).strip() + if rc != 0: + raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc) + return True, text or "metrics-server установлен." + + +def uninstall_metrics_server(cluster_name: str) -> tuple[bool, str]: + rc, out, err = _helm_uninstall(cluster_name, "metrics-server", "kube-system") + text = "\n".join(filter(None, [out, err])).strip() + if rc != 0 and "not found" not in text.lower(): + raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc) + return True, text or "metrics-server удалён (или отсутствовал)." + + +def install_istio_and_kiali( + cluster_name: str, + *, + kiali_username: str, + kiali_password: str, + istio_chart_version: str | None = None, + kiali_chart_version: str | None = None, +) -> tuple[bool, str]: + """Istio base + istiod + секрет входа Kiali + chart kiali-server (strategy=login).""" + helm_repo_ensure() + _ensure_namespace(cluster_name, "istio-system") + + log_parts: list[str] = [] + istio_ver = (istio_chart_version or "").strip() or None + kiali_ver = (kiali_chart_version or "").strip() or None + + rc, out, err = _helm_upgrade_install( + cluster_name, + "istio-base", + "istio/base", + "istio-system", + chart_version=istio_ver, + ) + log_parts.append(out or err) + if rc != 0: + raise HelmAddonError((err or out or f"istio-base exit {rc}")[:8000], exit_code=rc) + + rc2, o2, e2 = _helm_upgrade_install( + cluster_name, + "istiod", + "istio/istiod", + "istio-system", + chart_version=istio_ver, + ) + log_parts.append(o2 or e2) + if rc2 != 0: + raise HelmAddonError((e2 or o2 or f"istiod exit {rc2}")[:8000], exit_code=rc2) + + # Секрет для стратегии login (ключи username / passphrase — ожидание Kiali). + secret = { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": "kiali", "namespace": "istio-system"}, + "type": "Opaque", + "stringData": { + "username": kiali_username, + "passphrase": kiali_password, + }, + } + rcs, msgs = _kubectl_apply_manifest(cluster_name, secret) + if rcs != 0: + raise HelmAddonError(f"Secret kiali: {msgs}", exit_code=rcs) + log_parts.append(msgs) + + rc3, o3, e3 = _helm_upgrade_install( + cluster_name, + "kiali-server", + "kiali/kiali-server", + "istio-system", + chart_version=kiali_ver, + extra_args=["--set", "auth.strategy=login"], + ) + log_parts.append(o3 or e3) + if rc3 != 0: + raise HelmAddonError((e3 or o3 or f"kiali-server exit {rc3}")[:8000], exit_code=rc3) + + return True, "\n\n".join(x for x in log_parts if x).strip() or "Istio и Kiali установлены." + + +def uninstall_istio_and_kiali(cluster_name: str) -> tuple[bool, str]: + """Обратный порядок: Kiali → istiod → istio-base.""" + parts: list[str] = [] + for rel in ("kiali-server", "istiod", "istio-base"): + rc, out, err = _helm_uninstall(cluster_name, rel, "istio-system") + line = "\n".join(filter(None, [out, err])).strip() + if line: + parts.append(line) + if rc != 0 and "not found" not in line.lower() and "release: not found" not in line.lower(): + raise HelmAddonError(line or f"helm uninstall {rel} код {rc}", exit_code=rc) + return True, "\n".join(parts).strip() or "Istio/Kiali удалены (или отсутствовали)." diff --git a/app/core/job_journal.py b/app/core/job_journal.py new file mode 100644 index 0000000..a7ce8a0 --- /dev/null +++ b/app/core/job_journal.py @@ -0,0 +1,205 @@ +"""История завершённых заданий (create/start/stop и т.д.) в ``clusters/<имя>/journal/jobs_history.json``. + +Дополняет глобальный ``kind_k8s_jobs.json``: записи дублируются в каталог кластера для архива на томе. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from core.cluster_lifecycle import validate_cluster_name +from kind_k8s_paths import clusters_dir + +logger = logging.getLogger("kind_k8s.job_journal") + +JOURNAL_SUBDIR = "journal" +JOBS_HISTORY_FILENAME = "jobs_history.json" +JOURNAL_FILE_VERSION = 1 + +# Блокировки по имени кластера (один процесс uvicorn). +_locks_guard = threading.Lock() +_cluster_locks: dict[str, threading.Lock] = {} + + +def _cluster_lock(name: str) -> threading.Lock: + with _locks_guard: + if name not in _cluster_locks: + _cluster_locks[name] = threading.Lock() + return _cluster_locks[name] + + +def _max_entries() -> int: + raw = (os.environ.get("KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES") or "500").strip() + try: + return max(20, min(int(raw), 5000)) + except ValueError: + return 500 + + +def _max_log_lines() -> int: + raw = (os.environ.get("KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES") or "2000").strip() + try: + return max(50, min(int(raw), 20000)) + except ValueError: + return 2000 + + +def journal_file_path(cluster_name: str) -> Path: + """Путь к JSON с историей заданий кластера.""" + return clusters_dir() / cluster_name.strip() / JOURNAL_SUBDIR / JOBS_HISTORY_FILENAME + + +def append_job_from_record_sync( + *, + job_id: str, + kind: str, + cluster_name: str | None, + status: str, + message: str | None, + created_at_utc: str, + log_lines: list[str], + result: dict[str, Any] | None, +) -> None: + """ + Добавить запись о завершённом задании в журнал каталога кластера. + + Вызывается из потока после ``job_store`` (to_thread); без паролей в result. + """ + if not cluster_name or not str(cluster_name).strip(): + return + name = str(cluster_name).strip() + if not validate_cluster_name(name): + logger.debug("Журнал кластера: пропуск, некорректное имя «%s»", name) + return + + cdir = clusters_dir() / name + if not cdir.is_dir(): + logger.debug("Журнал кластера: каталог %s отсутствует", cdir) + return + + jdir = cdir / JOURNAL_SUBDIR + finished = datetime.now(timezone.utc).isoformat() + cap_lines = _max_log_lines() + lines = list(log_lines)[-cap_lines:] if log_lines else [] + + entry: dict[str, Any] = { + "version": JOURNAL_FILE_VERSION, + "job_id": job_id, + "kind": kind, + "cluster_name": name, + "status": status, + "message": message, + "created_at_utc": created_at_utc, + "finished_at_utc": finished, + "log_lines": lines, + "result": result, + } + + path = jdir / JOBS_HISTORY_FILENAME + lock = _cluster_lock(name) + with lock: + jdir.mkdir(parents=True, exist_ok=True) + entries: list[dict[str, Any]] = [] + if path.is_file(): + try: + raw = path.read_text(encoding="utf-8") + data = json.loads(raw) + if isinstance(data, dict) and isinstance(data.get("entries"), list): + entries = [e for e in data["entries"] if isinstance(e, dict)] + except (OSError, json.JSONDecodeError) as e: + logger.warning("Журнал %s: не прочитан, создаём заново: %s", path, e) + entries = [] + + entries.insert(0, entry) + max_e = _max_entries() + if len(entries) > max_e: + entries = entries[:max_e] + + payload = {"file_version": JOURNAL_FILE_VERSION, "entries": entries} + tmp = path.with_suffix(path.suffix + ".tmp") + try: + tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8") + tmp.replace(path) + logger.info("Журнал кластера «%s»: запись %s (%s)", name, job_id, kind) + except OSError as e: + logger.warning("Журнал %s: запись не удалась: %s", path, e) + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + + +def read_cluster_journal_sync(cluster_name: str) -> dict[str, Any] | None: + """Прочитать файл журнала кластера или ``None``.""" + n = cluster_name.strip() + if not validate_cluster_name(n): + return None + path = journal_file_path(n) + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +def _collect_all_journal_rows_sorted_sync() -> list[tuple[str, dict[str, Any]]]: + """Все записи из журналов кластеров, отсортированные по времени (новые первыми).""" + root = clusters_dir() + if not root.is_dir(): + return [] + flat: list[tuple[str, dict[str, Any]]] = [] + for sub in sorted(root.iterdir()): + if not sub.is_dir(): + continue + name = sub.name + if not validate_cluster_name(name): + continue + path = sub / JOURNAL_SUBDIR / JOBS_HISTORY_FILENAME + if not path.is_file(): + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + entries = data.get("entries") if isinstance(data, dict) else None + if not isinstance(entries, list): + continue + for e in entries: + if isinstance(e, dict): + flat.append((name, e)) + + def sort_key(item: tuple[str, dict[str, Any]]) -> str: + return str(item[1].get("finished_at_utc") or item[1].get("created_at_utc") or "") + + flat.sort(key=sort_key, reverse=True) + return flat + + +def collect_recent_journal_entries_page_sync(*, limit: int, offset: int) -> tuple[list[dict[str, Any]], int]: + """ + Страница записей из всех ``clusters/*/journal/jobs_history.json``, новые первыми. + + В каждую запись добавляется поле ``source_cluster`` (имя каталога). + Возвращает ``(страница записей, общее число записей)``. + """ + lim = max(1, min(limit, 100)) + off = max(0, offset) + flat = _collect_all_journal_rows_sorted_sync() + total = len(flat) + chunk = flat[off : off + lim] + out: list[dict[str, Any]] = [] + for cluster_dir_name, e in chunk: + row = dict(e) + row["source_cluster"] = cluster_dir_name + out.append(row) + return out, total diff --git a/app/core/job_store.py b/app/core/job_store.py index 74f0813..ab0a88a 100644 --- a/app/core/job_store.py +++ b/app/core/job_store.py @@ -386,34 +386,87 @@ class JobStore: async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None: logs = take_logs_finalize_sync(job_id) + snapshot: dict[str, Any] | None = None async with self._lock: if job_id in self._jobs: self._jobs[job_id].status = "success" self._jobs[job_id].result = result self._jobs[job_id].message = message self._jobs[job_id].log_lines = logs + r = self._jobs[job_id] + snapshot = { + "job_id": r.job_id, + "kind": r.kind, + "cluster_name": r.cluster_name, + "status": r.status, + "message": r.message, + "created_at_utc": r.created_at_utc, + "log_lines": list(r.log_lines), + "result": dict(r.result) if r.result else None, + } set_progress_sync(job_id, "Готово", 100) await self._persist_async() + if snapshot and snapshot.get("cluster_name"): + from core.job_journal import append_job_from_record_sync + + await asyncio.to_thread(append_job_from_record_sync, **snapshot) async def set_failed(self, job_id: str, message: str) -> None: logs = take_logs_finalize_sync(job_id) + # Чтобы в kind_k8s_jobs.json и в UI всегда был хотя бы текст ошибки (ранний сбой без stdout). + if not logs: + logs = [f"[ошибка] {message}"] + snapshot: dict[str, Any] | None = None async with self._lock: if job_id in self._jobs: self._jobs[job_id].status = "failed" self._jobs[job_id].message = message self._jobs[job_id].log_lines = logs + r = self._jobs[job_id] + snapshot = { + "job_id": r.job_id, + "kind": r.kind, + "cluster_name": r.cluster_name, + "status": r.status, + "message": r.message, + "created_at_utc": r.created_at_utc, + "log_lines": list(r.log_lines), + "result": dict(r.result) if r.result else None, + } logger.warning("Задание %s завершилось ошибкой: %s", job_id, message) await self._persist_async() + if snapshot and snapshot.get("cluster_name"): + from core.job_journal import append_job_from_record_sync + + await asyncio.to_thread(append_job_from_record_sync, **snapshot) async def set_cancelled(self, job_id: str, message: str = "Операция отменена") -> None: logs = take_logs_finalize_sync(job_id) + if not logs: + logs = [f"[отмена] {message}"] + snapshot: dict[str, Any] | None = None async with self._lock: if job_id in self._jobs: self._jobs[job_id].status = "cancelled" self._jobs[job_id].message = message self._jobs[job_id].log_lines = logs + r = self._jobs[job_id] + snapshot = { + "job_id": r.job_id, + "kind": r.kind, + "cluster_name": r.cluster_name, + "status": r.status, + "message": r.message, + "created_at_utc": r.created_at_utc, + "log_lines": list(r.log_lines), + "result": dict(r.result) if r.result else None, + } logger.info("Задание %s отменено: %s", job_id, message) await self._persist_async() + if snapshot and snapshot.get("cluster_name"): + from core.job_journal import append_job_from_record_sync + + await asyncio.to_thread(append_job_from_record_sync, **snapshot) async def get(self, job_id: str) -> JobRecord | None: async with self._lock: diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index b675211..8d8fbed 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -22,11 +22,13 @@ | `GET /` | HTML-панель: единая карточка «панель + среда», статистика, **ссылка с имени кластера на** `GET /cluster/<имя>` (сводка ресурсов, kubectl, действия), **старт/стоп** кластера с тем же журналом (фоновые **POST …/start** и **…/stop**), модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | | `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). | | `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение — **`PUT /api/v1/clusters/{name}/config`**. | +| `GET /cluster-addons` | HTML **Аддоны**: выбор кластера, **`GET /api/v1/helm/chart-versions`** для выпадающих списков версий чартов (или «Последняя»), установка/удаление Helm-релизов. | +| `GET /journal` | HTML **Журнал**: **`GET /api/v1/journal/recent`** с пагинацией (**30** записей на страницу), навигация по страницам внизу таблицы. | | `GET /documentation` | HTML-оболочка; **`documentation.js`**: без `path` — **`GET /api/v1/docs/readme`**, с `?path=app/docs/…` — **`GET /api/v1/docs/file`**; разбор Markdown из **`/static/js/vendor/`** (marked, DOMPurify). Каждая секция по **H2** — **одна карточка** (заголовок h2 и содержимое до следующего h2 вместе). Заголовок вкладки браузера: **«Документация — …»** + текст **первого H1** документа + имя приложения (`KIND_K8S_APP_TITLE` на `body`). В шапке на этой странице активна только **Документация**; **Панель** как обычная пилюля (на дашборде активна **Панель**). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. | | `GET /ui` | Редирект **307** на `/` (удобный ярлык). | | `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `` (по умолчанию `/api/v1`). | -Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/cluster_detail.html` (страница кластера), `app/templates/cluster_edit.html` (редактирование конфигурации), `app/templates/documentation.html` (README). +Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/cluster_detail.html` (страница кластера), `app/templates/cluster_edit.html` (редактирование конфигурации), `app/templates/cluster_addons.html` (Helm-аддоны), `app/templates/journal.html` (журнал заданий), `app/templates/documentation.html` (README). **kubectl на хосте не обязателен:** бинарник есть в образе; узлы и поды доступны через API и веб-UI. Внутри контейнера веб-приложения `kubectl` использует временный kubeconfig с `server` через **`host.docker.internal:<порт>`** (см. `kubeconfig_patch.py`, `extra_hosts` в compose). Скачивание для хоста — **`GET …/kubeconfig`** (файл **`kubeconfig.host`** при наличии). Для консоли: **`make docker kubectl CLUSTER=<имя>`** — **`/work/clusters/<имя>/kubeconfig`**; при сбое попробуйте kubectl **с хоста** с **`clusters/<имя>/kubeconfig.host`**. Перезапуск веб-сервиса: **`make docker restart`**. Подробности — **README.md**. @@ -54,6 +56,18 @@ | GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) | | POST | `/api/v1/clusters/{name}/pods/restart` | Удалить под (`kubectl delete pod`) для мягкого рестарта | | DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` | +| GET | `/api/v1/clusters/{name}/addons/status` | Статус Helm-релизов (ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali); нужен **helm** в образе | +| GET | `/api/v1/helm/chart-versions` | Версии чартов для UI (после `helm repo update`); кэш **`KIND_K8S_HELM_VERSIONS_CACHE_SEC`**; поля: `ingress_nginx`, `kube_prometheus_stack`, `metrics_server`, `istio`, `kiali_server` | +| POST | `/api/v1/clusters/{name}/addons/ingress-nginx` | Установить **ingress-nginx** (NodePort 30080); тело: `{ "chart_version": "опционально" }` (пусто = последняя) | +| DELETE | `/api/v1/clusters/{name}/addons/ingress-nginx` | Удалить ingress-nginx | +| POST | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Установить **kube-prometheus-stack**; тело: `grafana_admin_user`, `grafana_admin_password` (≥8), опционально **`chart_version`** | +| DELETE | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Удалить стек | +| POST | `/api/v1/clusters/{name}/addons/metrics-server` | Установить **metrics-server** (kind: `--kubelet-insecure-tls`); тело опционально: `{ "chart_version": "…" }` или `{}` | +| DELETE | `/api/v1/clusters/{name}/addons/metrics-server` | Удалить metrics-server | +| POST | `/api/v1/clusters/{name}/addons/istio-kiali` | Установить **istio-base**, **istiod**, секрет и **kiali-server**; тело: `kiali_username`, `kiali_password` (≥8), опционально **`istio_chart_version`**, **`kiali_chart_version`** | +| DELETE | `/api/v1/clusters/{name}/addons/istio-kiali` | Удалить kiali-server, istiod, istio-base | +| GET | `/api/v1/journal/recent` | Пагинация журнала по всем кластерам: query **`limit`** (по умолчанию **30**, макс. 100), **`offset`** (по умолчанию **0**); в ответе **`total`**, **`total_pages`**, **`page`** | +| GET | `/api/v1/clusters/{name}/journal` | Полный файл журнала кластера (массив `entries` или пусто, если файла нет) | | GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) | | GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) | | DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) | @@ -64,6 +78,7 @@ - Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется. - В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`). - Снимок заданий сохраняется в JSON в каталоге **`clusters/`** (файл **`kind_k8s_jobs.json`** на томе с хоста) — после перезапуска контейнера список восстанавливается. Записи в статусе **queued**/**running** при старте помечаются как **failed** (процесс уже не выполняется). Путь переопределяется переменной **`KIND_K8S_JOBS_JSON`**. +- При **завершении** задания (успех / ошибка / отмена), если указано **`cluster_name`** и существует каталог **`clusters/<имя>/`**, в **`clusters/<имя>/journal/jobs_history.json`** дозаписывается запись с хвостом лога и **`result`** (без секретов). Лимиты: **`KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES`**, **`KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES`**. Чтение: **`GET /api/v1/journal/recent`**, **`GET /api/v1/clusters/{name}/journal`**, страница **`GET /journal`**. - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI). - В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания. - В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**). @@ -271,6 +286,96 @@ Accept: text/markdown --- +## GET /api/v1/helm/chart-versions + +Список версий чартов для выпадающих списков на **`/cluster-addons`**. Выполняется **`helm repo update`** и для каждого чарта — **`helm search repo --versions -o json`**. Результаты кэшируются на **`KIND_K8S_HELM_VERSIONS_CACHE_SEC`** (по умолчанию **600** с) отдельно для каждого рефа чарта. Длина списка ограничена **`KIND_K8S_HELM_VERSIONS_MAX`** (по умолчанию **80**). + +**Пример ответа 200:** + +```json +{ + "ingress_nginx": ["4.14.0", "4.13.3", "4.12.1"], + "kube_prometheus_stack": ["69.0.0", "68.4.3"], + "metrics_server": ["3.12.2", "3.12.1"], + "istio": ["1.24.3", "1.24.2"], + "kiali_server": ["2.1.0", "2.0.0"] +} +``` + +При недоступном **helm** — **503** с текстом ошибки. + +--- + +## GET /api/v1/journal/recent + +Объединение записей из всех **`clusters/*/journal/jobs_history.json`**, сортировка по **`finished_at_utc`** (новые первыми). В каждой записи добавляется **`source_cluster`** — имя каталога кластера. + +**Query:** + +| Параметр | Описание | +|----------|----------| +| `limit` | Записей на страницу, **1–100**, по умолчанию **30** (как в веб-интерфейсе **`/journal`**) | +| `offset` | Смещение от начала списка, **≥ 0**, по умолчанию **0** (первая страница: `offset=0`, вторая при `limit=30`: `offset=30`) | + +**Поля ответа:** **`limit`**, **`offset`**, **`total`** (всего записей), **`page`** (текущая страница с 1), **`total_pages`**, **`entries`** (фрагмент текущей страницы). + +**Пример ответа 200 (первая страница, до 30 записей):** + +```json +{ + "limit": 30, + "offset": 0, + "total": 75, + "page": 1, + "total_pages": 3, + "entries": [ + { + "job_id": "a1b2c3", + "kind": "create_cluster", + "cluster_name": "dev", + "source_cluster": "dev", + "status": "success", + "message": null, + "created_at_utc": "2026-04-04T11:58:00+00:00", + "finished_at_utc": "2026-04-04T12:05:00+00:00", + "log_lines": ["[1] …", "[2] …"], + "result": { "cluster_name": "dev" } + } + ] +} +``` + +--- + +## GET /api/v1/clusters/{name}/journal + +Содержимое файла **`clusters/<имя>/journal/jobs_history.json`**. Если файла нет — **`entries`: []**. + +**Пример ответа 200:** + +```json +{ + "cluster_name": "dev", + "file_version": 1, + "entries": [ + { + "version": 1, + "job_id": "a1b2c3", + "kind": "stop_containers", + "cluster_name": "dev", + "status": "success", + "message": null, + "created_at_utc": "2026-04-04T10:00:00+00:00", + "finished_at_utc": "2026-04-04T10:00:15+00:00", + "log_lines": [], + "result": null + } + ] +} +``` + +--- + ## GET /api/v1/jobs Список последних фоновых заданий, от новых к старым. Поле **`progress_log`** в каждом элементе **пустое** — используйте **GET /api/v1/jobs/{job_id}** для журнала. diff --git a/app/main.py b/app/main.py index 2d5edea..38469d3 100644 --- a/app/main.py +++ b/app/main.py @@ -120,6 +120,36 @@ async def cluster_edit_page(request: Request, cluster_name: str) -> HTMLResponse ) +@app.get("/cluster-addons", response_class=HTMLResponse, summary="Helm-аддоны для кластера") +async def cluster_addons_page(request: Request) -> HTMLResponse: + """Выбор кластера и установка/удаление ingress-nginx, prometheus-stack, metrics-server, Istio+Kiali.""" + if not _templates_dir.is_dir(): + return HTMLResponse( + content="

Шаблоны не найдены. Ожидается каталог app/templates/

", + status_code=500, + ) + return templates.TemplateResponse( + request, + "cluster_addons.html", + {"app_title": settings.app_title, "nav_active": "cluster_addons"}, + ) + + +@app.get("/journal", response_class=HTMLResponse, summary="Журнал завершённых заданий по кластерам") +async def journal_page(request: Request) -> HTMLResponse: + """Агрегат записей из ``clusters/<имя>/journal/jobs_history.json`` (создание/старт/стоп и др.).""" + if not _templates_dir.is_dir(): + return HTMLResponse( + content="

Шаблоны не найдены. Ожидается каталог app/templates/

", + status_code=500, + ) + return templates.TemplateResponse( + request, + "journal.html", + {"app_title": settings.app_title, "nav_active": "journal"}, + ) + + @app.get("/cluster-create", response_class=HTMLResponse, summary="Создание кластера и задания") async def cluster_create_page(request: Request) -> HTMLResponse: """Форма создания кластера, прогресс и список последних заданий.""" diff --git a/app/models/schemas.py b/app/models/schemas.py index c0078af..9baa793 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -283,3 +283,128 @@ class ClusterConfigUpdateResponse(BaseModel): registered_in_kind: bool kind_note: str summary: ClusterSummary = Field(description="Актуальная сводка для строки таблицы / панели") + + +# --- Helm-аддоны (страница «Дополнения кластера») --- + + +class ClusterAddonsStatusResponse(BaseModel): + """Какие релизы Helm обнаружены для кластера (по helm list -A).""" + + cluster_name: str + ingress_nginx: bool = Field(description="Релиз ingress-nginx в ns ingress-nginx") + kube_prometheus_stack: bool = Field(description="Релиз kube-prometheus-stack в ns monitoring") + metrics_server: bool = Field(description="Релиз metrics-server в ns kube-system") + istio_base: bool + istiod: bool + kiali_server: bool + istio_mesh_ready: bool = Field( + description="Установлены istio-base, istiod и kiali-server в istio-system", + ) + + +class HelmAddonApplyResponse(BaseModel): + """Результат синхронной установки/удаления чарта.""" + + ok: bool = True + message: str + log: str = Field(default="", description="Сводный вывод helm/kubectl") + + +class IngressNginxInstallRequest(BaseModel): + """Тело POST …/addons/ingress-nginx — установка ingress-nginx.""" + + chart_version: str | None = Field( + default=None, + max_length=64, + description="Версия чарта; пусто — последняя из репозитория", + ) + + +class KubePrometheusStackInstallRequest(BaseModel): + """Тело POST …/addons/kube-prometheus-stack.""" + + grafana_admin_user: str = Field(default="admin", min_length=1, max_length=256) + grafana_admin_password: str = Field(..., min_length=8, max_length=256) + chart_version: str | None = Field( + default=None, + max_length=64, + description="Версия чарта kube-prometheus-stack; пусто — последняя", + ) + + +class MetricsServerInstallRequest(BaseModel): + """Тело POST …/addons/metrics-server (все поля необязательны).""" + + chart_version: str | None = Field( + default=None, + max_length=64, + description="Версия чарта metrics-server; пусто — последняя", + ) + + +class IstioKialiInstallRequest(BaseModel): + """Тело POST …/addons/istio-kiali — Istio + Kiali со входом по логину.""" + + kiali_username: str = Field(default="kiali-admin", min_length=1, max_length=256) + kiali_password: str = Field(..., min_length=8, max_length=256) + istio_chart_version: str | None = Field( + default=None, + max_length=64, + description="Версия чартов istio/base и istio/istiod; пусто — последняя", + ) + kiali_chart_version: str | None = Field( + default=None, + max_length=64, + description="Версия чарта kiali/kiali-server; пусто — последняя", + ) + + +class HelmAddonChartVersionsResponse(BaseModel): + """Список версий чартов для выпадающих списков на странице аддонов (после helm repo update).""" + + ingress_nginx: list[str] = Field(default_factory=list) + kube_prometheus_stack: list[str] = Field(default_factory=list) + metrics_server: list[str] = Field(default_factory=list) + istio: list[str] = Field(default_factory=list, description="Версии istio/base (для base+istiod)") + kiali_server: list[str] = Field(default_factory=list) + + +class JournalEntryModel(BaseModel): + """Одна запись из ``clusters/<имя>/journal/jobs_history.json``.""" + + model_config = {"extra": "allow"} + + version: int | None = Field(default=None, description="Версия схемы записи в файле") + job_id: str = "" + kind: str = "" + cluster_name: str = "" + status: str = "" + message: str | None = None + created_at_utc: str = "" + finished_at_utc: str | None = None + log_lines: list[str] = Field(default_factory=list) + result: dict[str, Any] | None = None + source_cluster: str | None = Field( + default=None, + description="Имя каталога кластера в ответе GET /journal/recent", + ) + + +class ClusterJournalResponse(BaseModel): + """Содержимое журнала одного кластера.""" + + cluster_name: str + file_version: int | None = None + entries: list[JournalEntryModel] = Field(default_factory=list) + + +class JournalRecentResponse(BaseModel): + """Страница агрегированного журнала (все ``clusters/*/journal/jobs_history.json``).""" + + limit: int = Field(description="Размер страницы (записей за запрос)") + offset: int = Field(description="Смещение от начала отсортированного списка (0 = первая страница)") + total: int = Field(ge=0, description="Всего записей во всех журналах") + page: int = Field(ge=1, description="Номер текущей страницы с 1") + total_pages: int = Field(ge=0, description="Число страниц при данном limit; 0 если записей нет") + entries: list[JournalEntryModel] = Field(default_factory=list) diff --git a/app/static/js/cluster_addons.js b/app/static/js/cluster_addons.js new file mode 100644 index 0000000..a3ad90f --- /dev/null +++ b/app/static/js/cluster_addons.js @@ -0,0 +1,305 @@ +/** + * Страница Helm-аддонов: GET /api/v1/clusters, статус и POST/DELETE …/addons/*. + * + * Автор: Сергей Антропов + * Сайт: https://devops.org.ru + */ +(function () { + "use strict"; + + const body = document.body; + if ((body.getAttribute("data-dashboard-mode") || "") !== "addons") return; + + const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); + + /** + * @param {string} path + * @param {RequestInit} [opts] + */ + async function api(path, opts) { + const url = path.startsWith("http") ? path : API + path; + const r = await fetch(url, opts); + const text = await r.text(); + var data; + try { + data = text ? JSON.parse(text) : null; + } catch (e) { + data = { raw: text }; + } + if (!r.ok) { + const msg = + typeof data.detail === "string" + ? data.detail + : Array.isArray(data.detail) + ? data.detail.map(function (x) { return x.msg || JSON.stringify(x); }).join("; ") + : text || r.statusText; + const err = new Error(msg); + err.status = r.status; + throw err; + } + return data; + } + + const sel = document.getElementById("addons-cluster-select"); + const statusBar = document.getElementById("addons-status-bar"); + const logPre = document.getElementById("addons-log-pre"); + const overlay = document.getElementById("addons-busy-overlay"); + const busyLabel = document.getElementById("addons-busy-label"); + + function getClusterName() { + if (!sel || !sel.value) return ""; + return String(sel.value).trim(); + } + + function setBusy(show, label) { + if (!overlay) return; + overlay.classList.toggle("hidden", !show); + overlay.setAttribute("aria-busy", show ? "true" : "false"); + overlay.setAttribute("aria-hidden", show ? "false" : "true"); + document.body.classList.toggle("cluster-addons-busy", !!show); + if (busyLabel && label) busyLabel.textContent = label; + } + + /** Скрыть полноэкранный спиннер первой загрузки страницы (как на панели и странице кластера). */ + function hideAddonsPageLoadingOverlay() { + const el = document.getElementById("addons-page-loading-overlay"); + if (!el) return; + el.classList.add("hidden"); + el.setAttribute("aria-busy", "false"); + el.setAttribute("aria-hidden", "true"); + document.body.classList.remove("cluster-addons-page-loading"); + } + + function appendLog(line) { + if (!logPre) return; + logPre.textContent = (logPre.textContent ? logPre.textContent + "\n" : "") + line; + logPre.scrollTop = logPre.scrollHeight; + } + + function formatStatus(st) { + if (!st) return ""; + const parts = [ + "ingress-nginx: " + (st.ingress_nginx ? "да" : "нет"), + "prometheus-stack: " + (st.kube_prometheus_stack ? "да" : "нет"), + "metrics-server: " + (st.metrics_server ? "да" : "нет"), + "Istio+Kiali: " + (st.istio_mesh_ready ? "да" : "нет"), + ]; + return parts.join(" · "); + } + + /** + * Заполнить выпадающий список версиями чарта; первая опция — «последняя». + * @param {HTMLSelectElement | null} selectEl + * @param {string[] | undefined} versions + */ + function fillVersionSelect(selectEl, versions) { + if (!selectEl) return; + const cur = selectEl.value; + selectEl.innerHTML = ""; + const o0 = document.createElement("option"); + o0.value = ""; + o0.textContent = "Последняя (из репозитория)"; + selectEl.appendChild(o0); + (versions || []).forEach(function (v) { + if (!v) return; + const o = document.createElement("option"); + o.value = v; + o.textContent = v; + selectEl.appendChild(o); + }); + if (cur) { + for (let i = 0; i < selectEl.options.length; i++) { + if (selectEl.options[i].value === cur) { + selectEl.value = cur; + break; + } + } + } + } + + async function loadChartVersions() { + try { + const d = await api("/helm/chart-versions"); + fillVersionSelect(document.getElementById("addons-ing-version-select"), d.ingress_nginx); + fillVersionSelect(document.getElementById("addons-prom-version-select"), d.kube_prometheus_stack); + fillVersionSelect(document.getElementById("addons-ms-version-select"), d.metrics_server); + fillVersionSelect(document.getElementById("addons-istio-version-select"), d.istio); + fillVersionSelect(document.getElementById("addons-kiali-version-select"), d.kiali_server); + } catch (e) { + appendLog("Список версий чартов недоступен: " + e.message); + } + } + + async function loadClusters() { + if (!sel) return; + try { + const rows = await api("/clusters"); + sel.innerHTML = ""; + const withKube = rows.filter(function (c) { return c.has_local_kubeconfig; }); + if (!withKube.length) { + const o = document.createElement("option"); + o.value = ""; + o.textContent = "Нет кластеров с kubeconfig на диске"; + sel.appendChild(o); + return; + } + const o0 = document.createElement("option"); + o0.value = ""; + o0.textContent = "— выберите кластер —"; + sel.appendChild(o0); + withKube.forEach(function (c) { + const o = document.createElement("option"); + o.value = c.name; + o.textContent = c.name + (c.kind_nodes_running ? "" : " (узлы остановлены)"); + sel.appendChild(o); + }); + } catch (e) { + sel.innerHTML = ""; + const o = document.createElement("option"); + o.value = ""; + o.textContent = "Ошибка загрузки списка"; + sel.appendChild(o); + appendLog("Ошибка GET /clusters: " + e.message); + } + } + + async function refreshStatus() { + const n = getClusterName(); + if (!statusBar) return; + if (!n) { + statusBar.textContent = "Выберите кластер для просмотра статуса аддонов."; + return; + } + try { + const st = await api("/clusters/" + encodeURIComponent(n) + "/addons/status"); + statusBar.textContent = formatStatus(st); + } catch (e) { + statusBar.textContent = "Статус недоступен: " + e.message; + } + } + + async function runInstall(endpoint, payload) { + const n = getClusterName(); + if (!n) { + alert("Выберите кластер."); + return; + } + setBusy(true, "Установка (Helm), подождите…"); + if (logPre) logPre.textContent = ""; + appendLog("POST …/addons/" + endpoint + " для «" + n + "»"); + try { + const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: payload !== undefined ? JSON.stringify(payload) : "{}", + }); + appendLog(r.message || "OK"); + if (r.log) appendLog(r.log); + await refreshStatus(); + } catch (e) { + appendLog("Ошибка: " + e.message); + } finally { + setBusy(false); + } + } + + async function runDelete(endpoint) { + const n = getClusterName(); + if (!n) { + alert("Выберите кластер."); + return; + } + if (!window.confirm("Удалить аддон «" + endpoint + "» в кластере «" + n + "»?")) return; + setBusy(true, "Удаление релиза, подождите…"); + if (logPre) logPre.textContent = ""; + appendLog("DELETE …/addons/" + endpoint + " для «" + n + "»"); + try { + const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, { + method: "DELETE", + headers: { Accept: "application/json" }, + }); + appendLog(r.message || "OK"); + if (r.log) appendLog(r.log); + await refreshStatus(); + } catch (e) { + appendLog("Ошибка: " + e.message); + } finally { + setBusy(false); + } + } + + document.querySelectorAll(".btn-addon-install").forEach(function (btn) { + btn.addEventListener("click", function () { + const ep = btn.getAttribute("data-endpoint") || ""; + if (ep === "ingress-nginx") { + const sel = document.getElementById("addons-ing-version-select"); + const v = sel && sel.value.trim(); + runInstall(ep, v ? { chart_version: v } : {}); + return; + } + if (ep === "kube-prometheus-stack") { + const u = document.getElementById("addons-grafana-user"); + const p = document.getElementById("addons-grafana-pass"); + const pv = document.getElementById("addons-prom-version-select"); + const user = u ? u.value.trim() : "admin"; + const pw = p ? p.value : ""; + if (pw.length < 8) { + alert("Задайте пароль Grafana не короче 8 символов."); + return; + } + const payload = { grafana_admin_user: user, grafana_admin_password: pw }; + if (pv && pv.value.trim()) payload.chart_version = pv.value.trim(); + runInstall(ep, payload); + return; + } + if (ep === "metrics-server") { + const ms = document.getElementById("addons-ms-version-select"); + const payload = {}; + if (ms && ms.value.trim()) payload.chart_version = ms.value.trim(); + runInstall(ep, Object.keys(payload).length ? payload : {}); + return; + } + if (ep === "istio-kiali") { + const u = document.getElementById("addons-kiali-user"); + const p = document.getElementById("addons-kiali-pass"); + const isel = document.getElementById("addons-istio-version-select"); + const ksel = document.getElementById("addons-kiali-version-select"); + const user = u ? u.value.trim() : "kiali-admin"; + const pw = p ? p.value : ""; + if (pw.length < 8) { + alert("Задайте пароль Kiali не короче 8 символов."); + return; + } + const payload = { kiali_username: user, kiali_password: pw }; + if (isel && isel.value.trim()) payload.istio_chart_version = isel.value.trim(); + if (ksel && ksel.value.trim()) payload.kiali_chart_version = ksel.value.trim(); + runInstall(ep, payload); + } + }); + }); + + document.querySelectorAll(".btn-addon-remove").forEach(function (btn) { + btn.addEventListener("click", function () { + const ep = btn.getAttribute("data-endpoint") || ""; + if (ep) runDelete(ep); + }); + }); + + const refreshBtn = document.getElementById("addons-refresh-status"); + if (refreshBtn) refreshBtn.addEventListener("click", function () { refreshStatus(); }); + + if (sel) { + sel.addEventListener("change", function () { refreshStatus(); }); + } + + loadClusters() + .then(function () { + return refreshStatus(); + }) + .then(function () { + return loadChartVersions(); + }) + .finally(function () { + hideAddonsPageLoadingOverlay(); + }); +})(); diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index d134540..9295161 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -189,6 +189,50 @@ if (el) el.classList.add("hidden"); } + function isJobLogModalOpen() { + const el = document.getElementById("job-log-modal-overlay"); + return !!(el && !el.classList.contains("hidden")); + } + + function closeJobLogModal() { + const el = document.getElementById("job-log-modal-overlay"); + if (el) el.classList.add("hidden"); + } + + /** + * Открыть модалку с текстовым журналом завершённого задания (GET /jobs/{id}). + * @param {string} jobId + */ + function openJobLogModal(jobId) { + const overlay = document.getElementById("job-log-modal-overlay"); + const sub = document.getElementById("job-log-modal-sub"); + const pre = document.getElementById("job-log-modal-body"); + const titleEl = document.getElementById("job-log-modal-title"); + if (!overlay || !pre) return; + pre.textContent = "Загрузка журнала…"; + if (sub) sub.textContent = "Задание: " + jobId; + if (titleEl) titleEl.textContent = "Журнал задания"; + overlay.classList.remove("hidden"); + api("/jobs/" + encodeURIComponent(jobId)) + .then(function (job) { + if (titleEl) { + titleEl.textContent = + "Журнал задания · " + (job.status || "") + (job.kind ? " · " + job.kind : ""); + } + if (sub) { + var parts = ["Задание: " + jobId]; + if (job.cluster_name) parts.push("Кластер: " + job.cluster_name); + if (job.message) parts.push(job.message); + sub.textContent = parts.join(" · "); + } + var lines = job.progress_log || []; + pre.textContent = lines.length ? lines.join("\n") : job.message || "(журнал пуст)"; + }) + .catch(function (e) { + pre.textContent = "Не удалось загрузить журнал: " + (e && e.message ? e.message : String(e)); + }); + } + /** * Загрузить ``GET /clusters/{name}/provision-log`` и показать JSON в модалке. * @param {string} clusterName @@ -1760,6 +1804,27 @@ } } + /** + * Один раз: кнопка «Журнал» в таблице последних заданий открывает модалку (не сбрасывается при автообновлении списка). + */ + function bindJobsTableJobLogOpen() { + const tbl = document.getElementById("tbl-jobs"); + if (!tbl || tbl.getAttribute("data-jobs-job-log-bound") === "1") return; + tbl.setAttribute("data-jobs-job-log-bound", "1"); + tbl.addEventListener( + "click", + function (ev) { + const btn = ev.target && ev.target.closest ? ev.target.closest("button.jobs-open-log-btn") : null; + if (!btn || !tbl.contains(btn)) return; + const jobId = btn.getAttribute("data-job-id"); + if (!jobId) return; + ev.preventDefault(); + openJobLogModal(jobId); + }, + false, + ); + } + async function loadJobs() { const tbody = document.querySelector("#tbl-jobs tbody"); const msg = document.getElementById("jobs-msg"); @@ -1782,6 +1847,14 @@ j.progress_stage + (j.progress_percent != null ? " (" + j.progress_percent + "%)" : ""); } + var msgCol = + "
" + escapeHtml(cellMsg) + "
"; + if (j.status === "failed" || j.status === "cancelled") { + msgCol += + ""; + } const iso = j.created_at_utc || ""; const timeLabel = formatJobTimeUtc(iso); tr.innerHTML = @@ -1801,7 +1874,7 @@ st + "" + "" + - escapeHtml(cellMsg) + + msgCol + ""; frag.appendChild(tr); }); @@ -2324,6 +2397,10 @@ }); } + if (document.getElementById("tbl-jobs")) { + bindJobsTableJobLogOpen(); + } + const mClose = document.getElementById("modal-close"); if (mClose) mClose.addEventListener("click", closeModal); @@ -2340,6 +2417,10 @@ closeConfirmModal(confirmModalIsAlert ? true : false); return; } + if (isJobLogModalOpen()) { + closeJobLogModal(); + return; + } if (isProvisionLogModalOpen()) { closeProvisionLogModal(); return; @@ -2378,6 +2459,17 @@ }); } + const jobLogOv = document.getElementById("job-log-modal-overlay"); + const jobLogClose = document.getElementById("job-log-modal-close"); + if (jobLogClose) { + jobLogClose.addEventListener("click", closeJobLogModal); + } + if (jobLogOv) { + jobLogOv.addEventListener("click", function (ev) { + if (ev.target === jobLogOv) closeJobLogModal(); + }); + } + window.addEventListener( "scroll", function () { @@ -2419,6 +2511,7 @@ } bindActionTooltipHosts(document.getElementById("modal-overlay")); bindActionTooltipHosts(document.getElementById("provision-log-modal-overlay")); + bindActionTooltipHosts(document.getElementById("job-log-modal-overlay")); } if (document.readyState === "loading") { diff --git a/app/static/js/journal.js b/app/static/js/journal.js new file mode 100644 index 0000000..8bfde71 --- /dev/null +++ b/app/static/js/journal.js @@ -0,0 +1,347 @@ +/** + * Страница «Журнал»: GET /api/v1/journal/recent с пагинацией (30 записей на страницу). + * + * Автор: Сергей Антропов + * Сайт: https://devops.org.ru + */ +(function () { + "use strict"; + + const body = document.body; + if ((body.getAttribute("data-dashboard-mode") || "") !== "journal") return; + + const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); + /** Размер страницы (синхронно с default limit в API). */ + var PAGE_SIZE = 30; + + /** Текущая страница с 1 (состояние для пагинации). */ + var currentPage = 1; + + /** + * @param {string} path + */ + async function apiGet(path) { + const url = path.startsWith("http") ? path : API + path; + const r = await fetch(url, { headers: { Accept: "application/json" } }); + const text = await r.text(); + var data; + try { + data = text ? JSON.parse(text) : null; + } catch (e) { + data = { raw: text }; + } + if (!r.ok) { + const msg = + typeof data.detail === "string" + ? data.detail + : Array.isArray(data.detail) + ? data.detail.map(function (x) { return x.msg || JSON.stringify(x); }).join("; ") + : text || r.statusText; + const err = new Error(msg); + err.status = r.status; + throw err; + } + return data; + } + + const tbody = document.getElementById("journal-tbody"); + const statusEl = document.getElementById("journal-status"); + const emptyEl = document.getElementById("journal-empty"); + const tableWrap = document.getElementById("journal-table-wrap"); + const refreshBtn = document.getElementById("journal-refresh-btn"); + const pagNav = document.getElementById("journal-pagination"); + + /** Скрыть полноэкранный спиннер первой загрузки (повторные вызовы безопасны). */ + function hideJournalPageLoadingOverlay() { + const el = document.getElementById("journal-page-loading-overlay"); + if (!el) return; + el.classList.add("hidden"); + el.setAttribute("aria-busy", "false"); + el.setAttribute("aria-hidden", "true"); + document.body.classList.remove("journal-page-loading"); + } + + function esc(s) { + const d = document.createElement("div"); + d.textContent = s == null ? "" : String(s); + return d.innerHTML; + } + + function formatUtc(iso) { + if (!iso) return "—"; + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " Z"); + } catch (e) { + return iso; + } + } + + function statusClass(st) { + const s = (st || "").toLowerCase(); + if (s === "success") return "journal-status-pill journal-status-pill--ok"; + if (s === "failed") return "journal-status-pill journal-status-pill--err"; + if (s === "cancelled") return "journal-status-pill journal-status-pill--warn"; + return "journal-status-pill"; + } + + /** + * Список номеров страниц и многоточий для компактной навигации. + * @param {number} current + * @param {number} total + * @returns {(number|string)[]} + */ + function visiblePageItems(current, total) { + const delta = 2; + const range = []; + let i; + for (i = 1; i <= total; i++) { + if (i === 1 || i === total || (i >= current - delta && i <= current + delta)) { + range.push(i); + } + } + const out = []; + let l = 0; + for (i = 0; i < range.length; i++) { + const p = range[i]; + if (l) { + if (p - l === 2) out.push(l + 1); + else if (p - l > 2) out.push("…"); + } + out.push(p); + l = p; + } + return out; + } + + /** + * @param {number} itemCount число строк на текущей странице (для подписи «записи N–M») + */ + function renderPagination(total, limit, offset, page, totalPages, itemCount, onSelectPage) { + if (!pagNav) return; + pagNav.innerHTML = ""; + if (total <= 0 || totalPages <= 0) { + pagNav.classList.add("hidden"); + return; + } + pagNav.classList.remove("hidden"); + + const wrap = document.createElement("div"); + wrap.className = "journal-pagination-inner"; + + function mkBtn(label, disabled, ariaLabel, onClick) { + const b = document.createElement("button"); + b.type = "button"; + b.className = "journal-pag-btn"; + b.textContent = label; + b.setAttribute("aria-label", ariaLabel); + b.disabled = !!disabled; + if (!disabled && onClick) b.addEventListener("click", onClick); + return b; + } + + const firstOff = 0; + const prevOff = Math.max(0, offset - limit); + const nextOff = Math.min(Math.max(0, total - 1), offset + limit); + const lastOff = totalPages > 0 ? (totalPages - 1) * limit : 0; + + wrap.appendChild( + mkBtn("« Первая", page <= 1, "Первая страница", function () { + onSelectPage(1); + }), + ); + wrap.appendChild( + mkBtn("‹ Назад", page <= 1, "Предыдущая страница", function () { + onSelectPage(page - 1); + }), + ); + + const pagesEl = document.createElement("div"); + pagesEl.className = "journal-pag-pages"; + pagesEl.setAttribute("role", "group"); + pagesEl.setAttribute("aria-label", "Номера страниц"); + + visiblePageItems(page, totalPages).forEach(function (item) { + if (item === "…") { + const span = document.createElement("span"); + span.className = "journal-pag-ellipsis"; + span.setAttribute("aria-hidden", "true"); + span.textContent = "…"; + pagesEl.appendChild(span); + return; + } + const pNum = item; + const b = document.createElement("button"); + b.type = "button"; + b.className = "journal-pag-num" + (pNum === page ? " journal-pag-num--active" : ""); + b.textContent = String(pNum); + b.setAttribute("aria-label", "Страница " + pNum); + if (pNum === page) b.setAttribute("aria-current", "page"); + else b.removeAttribute("aria-current"); + if (pNum !== page) { + b.addEventListener("click", function () { + onSelectPage(pNum); + }); + } + pagesEl.appendChild(b); + }); + + wrap.appendChild(pagesEl); + + wrap.appendChild( + mkBtn("Вперёд ›", page >= totalPages, "Следующая страница", function () { + onSelectPage(page + 1); + }), + ); + wrap.appendChild( + mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () { + onSelectPage(totalPages); + }), + ); + + pagNav.appendChild(wrap); + + const from = total === 0 || itemCount === 0 ? 0 : offset + 1; + const to = total === 0 || itemCount === 0 ? 0 : offset + itemCount; + const hint = document.createElement("p"); + hint.className = "muted journal-pagination-summary"; + hint.textContent = + from && to + ? "Записи " + from + "–" + to + " из " + total + " · страница " + page + " из " + totalPages + : "Всего записей: " + total + (totalPages ? " · страниц: " + totalPages : ""); + pagNav.appendChild(hint); + } + + function goToPage(p) { + if (p < 1) p = 1; + currentPage = p; + loadJournal(false); + } + + /** + * @param {boolean} resetPage сбросить на 1 (после «Обновить») + */ + async function loadJournal(resetPage) { + if (!tbody || !statusEl) { + hideJournalPageLoadingOverlay(); + return; + } + if (resetPage) currentPage = 1; + const offset = (currentPage - 1) * PAGE_SIZE; + statusEl.textContent = "Загрузка…"; + tbody.innerHTML = ""; + if (emptyEl) emptyEl.classList.add("hidden"); + if (tableWrap) tableWrap.classList.remove("hidden"); + if (pagNav) pagNav.classList.add("hidden"); + + try { + const data = await apiGet( + "/journal/recent?limit=" + + encodeURIComponent(String(PAGE_SIZE)) + + "&offset=" + + encodeURIComponent(String(offset)), + ); + const entries = data.entries || []; + const total = typeof data.total === "number" ? data.total : entries.length; + const limit = typeof data.limit === "number" ? data.limit : PAGE_SIZE; + const respOffset = typeof data.offset === "number" ? data.offset : offset; + const totalPages = typeof data.total_pages === "number" ? data.total_pages : Math.ceil(total / limit) || 0; + let page = typeof data.page === "number" ? data.page : currentPage; + + if (total > 0 && currentPage > totalPages) { + currentPage = totalPages; + await loadJournal(false); + return; + } + + if (total > 0 && entries.length === 0 && respOffset >= total && totalPages > 0) { + currentPage = totalPages; + await loadJournal(false); + return; + } + + page = currentPage; + + if (!entries.length) { + if (emptyEl) emptyEl.classList.remove("hidden"); + if (tableWrap) tableWrap.classList.add("hidden"); + statusEl.textContent = + total === 0 + ? "Нет записей в журналах." + : "Страница пуста (всего записей: " + total + ")."; + renderPagination(total, limit, respOffset, page, totalPages, 0, goToPage); + return; + } + + const fromN = respOffset + 1; + const toN = respOffset + entries.length; + statusEl.textContent = + "Записи " + fromN + "–" + toN + " из " + total + " · страница " + page + " из " + (totalPages || 1) + "."; + + entries.forEach(function (e) { + const tr = document.createElement("tr"); + tr.className = "journal-data-row"; + const cluster = e.source_cluster || e.cluster_name || "—"; + const finished = formatUtc(e.finished_at_utc || e.created_at_utc); + const msg = (e.message || "").trim(); + const lines = Array.isArray(e.log_lines) ? e.log_lines : []; + const logText = lines.join("\n"); + const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11); + + tr.innerHTML = + "" + esc(finished) + "" + + "" + esc(cluster) + "" + + "" + esc(e.kind || "") + "" + + "" + esc(e.status || "") + "" + + "" + esc(msg || "—") + "" + + "" + + "" + + ""; + tbody.appendChild(tr); + + if (logText) { + const trDetails = document.createElement("tr"); + trDetails.className = "journal-details-row hidden"; + trDetails.innerHTML = + "" + + "" + + ""; + tbody.appendChild(trDetails); + const pre = trDetails.querySelector("pre"); + if (pre) pre.textContent = logText; + const btn = tr.querySelector(".btn-journal-toggle"); + if (btn && trDetails && pre) { + btn.addEventListener("click", function () { + const open = trDetails.classList.contains("hidden"); + trDetails.classList.toggle("hidden", !open); + pre.hidden = !open; + btn.setAttribute("aria-expanded", open ? "true" : "false"); + btn.textContent = open ? "Скрыть лог" : "Показать лог"; + }); + } + } else { + const btn = tr.querySelector(".btn-journal-toggle"); + if (btn) { + btn.disabled = true; + btn.textContent = "Нет строк"; + } + } + }); + + renderPagination(total, limit, respOffset, page, totalPages, entries.length, goToPage); + } catch (err) { + statusEl.textContent = "Ошибка: " + err.message; + if (emptyEl) emptyEl.classList.remove("hidden"); + if (tableWrap) tableWrap.classList.add("hidden"); + if (pagNav) pagNav.classList.add("hidden"); + } finally { + hideJournalPageLoadingOverlay(); + } + } + + if (refreshBtn) refreshBtn.addEventListener("click", function () { loadJournal(true); }); + + loadJournal(false); +})(); diff --git a/app/static/style.css b/app/static/style.css index 7eb26f7..055d41b 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -964,7 +964,16 @@ html[data-theme="light"] .cluster-node-res-card__bar-track { word-break: break-word; } -/* Заголовок блока заданий + кнопка очистки */ +/* Заголовок блока заданий + кнопка «Журнал» у failed/cancelled */ +.jobs-msg-cell .jobs-msg-primary { + margin-bottom: 0.35rem; +} + +.jobs-open-log-btn { + margin-top: 0.25rem; + font-size: 0.875rem; +} + .jobs-card-toolbar { margin-bottom: 0.65rem; } @@ -1572,6 +1581,25 @@ html[data-theme="light"] .icon-btn--danger { .provision-log-modal-overlay { z-index: 500; } +.job-log-modal-overlay { + z-index: 500; +} +.modal-box--job-log .job-log-modal-body { + max-height: min(70vh, 32rem); + overflow: auto; + margin: 0.35rem 0 0; + padding: 0.65rem 0.75rem; + font-size: 0.78rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.2); +} +html[data-theme="light"] .modal-box--job-log .job-log-modal-body { + background: rgba(0, 0, 0, 0.04); +} .modal-box--provision-log .provision-log-body { max-height: min(70vh, 32rem); overflow: auto; @@ -2272,6 +2300,12 @@ body.dashboard-home-loading { body.cluster-page-loading { overflow: hidden; } + +/* Первая загрузка страниц «Аддоны» и «Журнал» */ +body.cluster-addons-page-loading, +body.journal-page-loading { + overflow: hidden; +} .page-loading-overlay { position: fixed; inset: 0; @@ -2714,3 +2748,270 @@ html[data-theme="light"] .cluster-edit-yaml-preview-head { align-items: center; justify-content: center; } + +/* Страница «Аддоны»: Helm-чарты по выбранному кластеру */ +.cluster-addons-toolbar-card .cluster-addons-cluster-row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.65rem 1rem; + margin-bottom: 0.35rem; +} +.cluster-addons-cluster-label { + display: block; + width: 100%; + font-size: 0.8125rem; + font-weight: 600; + margin-bottom: 0.2rem; +} +.cluster-addons-cluster-select { + min-width: min(100%, 18rem); + flex: 1 1 14rem; + max-width: 28rem; +} +.cluster-addons-hint { + margin: 0.35rem 0 0; + font-size: 0.82rem; +} +.cluster-addons-status-bar { + margin-top: 0.65rem; + font-size: 0.85rem; + line-height: 1.45; +} +.cluster-addons-grid { + display: grid; + gap: 1rem; + margin: 1rem 0; +} +@media (min-width: 52rem) { + .cluster-addons-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +.cluster-addon-card__title { + margin: 0 0 0.35rem; + font-size: 1.05rem; +} +.cluster-addon-card__desc { + margin: 0 0 0.75rem; + font-size: 0.85rem; + line-height: 1.45; +} +.cluster-addon-card__actions { + margin-top: 0.85rem; + gap: 0.5rem; + align-items: center; +} +.cluster-addon-card--wide { + grid-column: 1 / -1; +} +.cluster-addon-istio-grid { + margin-top: 0.25rem; +} +.cluster-addons-log-card { + margin-bottom: 1.5rem; +} +.cluster-addons-log-pre { + margin: 0.5rem 0 0; + max-height: min(50vh, 22rem); + overflow: auto; + padding: 0.65rem 0.75rem; + font-size: 0.78rem; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.12); +} +html[data-theme="light"] .cluster-addons-log-pre { + background: rgba(15, 23, 42, 0.04); +} +.cluster-addons-busy-overlay { + z-index: 9995; +} +body.cluster-addons-busy { + overflow: hidden; +} + +/* Версии чартов на странице аддонов */ +.cluster-addons-version-select { + width: 100%; + max-width: 22rem; + padding: 0.45rem 0.6rem; + font-size: 0.9rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); +} + +/* Страница «Журнал» */ +.journal-toolbar-card .journal-toolbar-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; + margin-top: 0.35rem; +} +.journal-status { + margin: 0.65rem 0 0; +} +.journal-empty { + margin: 0.5rem 0; +} +.journal-table-wrap { + overflow-x: auto; + margin-top: 0.5rem; +} +.journal-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +.journal-table th, +.journal-table td { + padding: 0.45rem 0.55rem; + text-align: left; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.journal-table th { + font-weight: 600; + color: var(--muted); +} +.journal-code { + font-size: 0.8rem; +} +.journal-cell-time { + white-space: nowrap; +} +.journal-cell-msg { + max-width: 14rem; + word-break: break-word; +} +.journal-cell-actions { + white-space: nowrap; +} +.journal-status-pill { + display: inline-block; + padding: 0.12rem 0.45rem; + border-radius: 999px; + font-size: 0.78rem; + background: rgba(148, 163, 184, 0.2); +} +.journal-status-pill--ok { + background: rgba(34, 197, 94, 0.2); + color: var(--text); +} +.journal-status-pill--err { + background: rgba(239, 68, 68, 0.2); +} +.journal-status-pill--warn { + background: rgba(234, 179, 8, 0.22); +} +.journal-details-cell { + padding: 0.5rem 0.55rem 0.85rem; + background: rgba(0, 0, 0, 0.08); +} +html[data-theme="light"] .journal-details-cell { + background: rgba(15, 23, 42, 0.04); +} +.journal-log-pre { + margin: 0; + max-height: min(40vh, 18rem); + overflow: auto; + padding: 0.5rem 0.65rem; + font-size: 0.76rem; + line-height: 1.35; + white-space: pre-wrap; + word-break: break-word; + border-radius: 8px; + border: 1px solid var(--border); +} + +.journal-page-size-hint { + font-size: 0.88rem; +} + +/* Пагинация журнала */ +.journal-pagination { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} +.journal-pagination-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.35rem 0.5rem; +} +.journal-pag-btn { + padding: 0.4rem 0.65rem; + font-size: 0.82rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} +.journal-pag-btn:hover:not(:disabled) { + background: rgba(99, 102, 241, 0.12); + border-color: rgba(99, 102, 241, 0.45); +} +.journal-pag-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.journal-pag-pages { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + margin: 0 0.15rem; +} +.journal-pag-num { + min-width: 2.35rem; + padding: 0.4rem 0.5rem; + font-size: 0.85rem; + font-weight: 500; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--text); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} +.journal-pag-num:hover:not(:disabled) { + background: rgba(99, 102, 241, 0.1); +} +.journal-pag-num--active { + background: linear-gradient(135deg, rgba(99, 102, 241, 0.35), rgba(129, 140, 248, 0.25)); + border-color: rgba(99, 102, 241, 0.5); + color: var(--text); + cursor: default; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2); +} +.journal-pag-ellipsis { + padding: 0 0.2rem; + color: var(--muted); + font-size: 0.9rem; + user-select: none; +} +.journal-pagination-summary { + width: 100%; + text-align: center; + margin: 0.85rem 0 0; + font-size: 0.84rem; +} +@media (max-width: 640px) { + .journal-pagination-inner { + flex-direction: column; + } + .journal-pag-pages { + order: -1; + margin: 0.25rem 0; + } +} diff --git a/app/templates/base.html b/app/templates/base.html index f43564b..9fc8a27 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -57,6 +57,8 @@ {# Прокрутка только у пилюль: overflow-x на nav обрезал бы выпадающее меню API (position: absolute). #} +{# Журнал фонового задания (ошибка / отмена) — страница «Последние задания». #} + +