diff --git a/Dockerfile b/Dockerfile index b60a1c1..b092997 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,8 @@ RUN apk add --no-cache python3 py3-pip docker-cli curl bash ca-certificates \ && pip3 install --no-cache-dir --break-system-packages -r /opt/kind-k8s/requirements.txt COPY app/ /opt/kind-k8s/app/ +# README для страницы «Документация» в веб-UI (см. app/core/readme_doc.py) +COPY README.md /opt/kind-k8s/README.md COPY scripts/run_uvicorn.sh /opt/kind-k8s/run_uvicorn.sh RUN chmod +x /opt/kind-k8s/run_uvicorn.sh diff --git a/Makefile b/Makefile index de0d712..c32c026 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,8 @@ # Все операции с Compose только с явным выбором среды: # make docker up | make docker down | make docker logs | … # make podman up | make podman down | … -# Без префикса docker/podman цели up/down/logs/ps/compose-build/check-docker завершатся с подсказкой. +# make docker rebuild / make podman rebuild — образ без кэша и пересоздание контейнера +# Без префикса docker/podman цели up/down/logs/ps/compose-build/rebuild/check-docker завершатся с подсказкой. # # Автор: Сергей Антропов — https://devops.org.ru @@ -14,7 +15,7 @@ else ifneq (,$(filter docker,$(MAKECMDGOALS))) COMPOSE := docker compose endif -.PHONY: help docker podman _require_runtime up down logs ps setup clusters-dir check-docker compose-build +.PHONY: help docker podman _require_runtime up down logs ps setup clusters-dir check-docker compose-build rebuild KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py @@ -29,6 +30,7 @@ help: ## Справка по целям @echo " make docker logs / make podman logs (follow -f)" @echo " make docker ps / make podman ps (статус сервисов)" @echo " make docker compose-build / make podman compose-build" + @echo " make docker rebuild / make podman rebuild (build --no-cache + up --force-recreate)" @echo " make docker check-docker / make podman check-docker" @echo "Без установки Compose: make setup, make clusters-dir (python3 для setup)." @grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?##"} {printf " \033[36m%-28s\033[0m %s\n", $$1, $$2}' @@ -39,12 +41,12 @@ docker: ## Маркер среды: задайте вторую цель (нап podman: ## Маркер среды: задайте вторую цель (например: make podman up) @: -# Общая проверка: цели up/down/logs/ps/compose-build/check-docker — только make docker … / make podman … +# Общая проверка: цели up/down/logs/ps/compose-build/rebuild/check-docker — только make docker … / make podman … _require_runtime: @if [ -z "$(COMPOSE)" ]; then \ echo >&2 "Укажите среду в той же команде, что и цель:"; \ echo >&2 " make docker up | make podman up"; \ - echo >&2 " make docker down | make docker logs | make docker ps | make docker compose-build | make docker check-docker"; \ + echo >&2 " make docker down | make docker logs | make docker ps | make docker compose-build | make docker rebuild | make docker check-docker"; \ echo >&2 " (или то же с префиксом podman)"; \ exit 1; \ fi @@ -77,3 +79,6 @@ check-docker: _require_runtime ## (с docker/podman) Проверить CLI и c compose-build: _require_runtime clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS) + +rebuild: _require_runtime clusters-dir ## (с docker/podman) Пересобрать образ без кэша и пересоздать контейнер kind-k8s-web + cd "$(KIND_K8S_DIR)" && $(COMPOSE) build --no-cache $(COMPOSE_BUILD_FLAGS) && $(COMPOSE) up -d --force-recreate kind-k8s-web diff --git a/README.md b/README.md index 004b793..61f3713 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ **В образ не обязательно ставить на хост:** kind, Python приложения — они внутри контейнера. -Смонтированы **сокет** Docker/Podman и каталог **`./clusters`** → в контейнере **`/work/clusters`**. Каталог **`./app`** монтируется в **`/opt/kind-k8s/app`** для разработки без пересборки образа. +Смонтированы **сокет** Docker/Podman и каталог **`./clusters`** → в контейнере **`/work/clusters`**. Каталог **`./app`** монтируется в **`/opt/kind-k8s/app`** для разработки без пересборки образа. Файл **`./README.md`** монтируется в **`/opt/kind-k8s/README.md`** (страница **«Документация»** и **`GET /api/v1/docs/readme`** без пересборки образа). После создания кластера kubeconfig по умолчанию **патчится** на `https://127.0.0.1:<порт>` для доступа с хоста (`KIND_K8S_PATCH_KUBECONFIG`, см. `app/kubeconfig_patch.py`). @@ -67,7 +67,7 @@ make docker up # или: make podman up ### Разработка UI и API без пересборки образа -В **`docker-compose.yml`** каталог **`./app`** смонтирован в контейнер как **`/opt/kind-k8s/app`**. +В **`docker-compose.yml`** смонтированы **`./app`** → **`/opt/kind-k8s/app`** и **`./README.md`** → **`/opt/kind-k8s/README.md`** (только чтение). По умолчанию (**`KIND_K8S_UVICORN_RELOAD=1`**) uvicorn запускается с **`--reload`** (см. **`scripts/run_uvicorn.sh`**) и перезапускает процесс при изменении `*.py`, `*.html`, `*.css`, `*.js` в `app/`. Пересобирать образ нужно после изменений **Dockerfile**, **`requirements.txt`** или **`scripts/run_uvicorn.sh`**. @@ -99,12 +99,13 @@ docker compose run --rm --entrypoint python3 kind-k8s-web \ | `make docker logs` / `make podman logs` | Логи `kind-k8s-web` (stream, `-f`) | | `make docker ps` / `make podman ps` | Статус контейнеров текущего compose-проекта | | `make docker compose-build` / `make podman compose-build` | Собрать образ `kind-k8s-tools:local` | +| `make docker rebuild` / `make podman rebuild` | Пересборка образа **без кэша** (`build --no-cache`) и пересоздание контейнера (`up -d --force-recreate`) | | `make docker check-docker` / `make podman check-docker` | Проверить выбранный CLI и `compose version` | | `make setup` | Интерактивно создать `.env` (список переменных в `scripts/setup_env_interactive.py`) | | `make clusters-dir` | Создать каталог `clusters/` | -| `make docker …` / `make podman …` | Префикс **обязателен** для целей `up`, `down`, `logs`, `ps`, `compose-build`, `check-docker` | +| `make docker …` / `make podman …` | Префикс **обязателен** для целей `up`, `down`, `logs`, `ps`, `compose-build`, `rebuild`, `check-docker` | -Цели `up`, `down`, `logs`, `ps`, `compose-build` и `check-docker` **без** `docker`/`podman` в той же команде завершатся с подсказкой. +Цели `up`, `down`, `logs`, `ps`, `compose-build`, `rebuild` и `check-docker` **без** `docker`/`podman` в той же команде завершатся с подсказкой. ## Переменные окружения @@ -129,8 +130,10 @@ docker compose run --rm --entrypoint python3 kind-k8s-web \ | **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько тегов отдавать в API/UI | | **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Лимит страниц API Hub | | **`KIND_K8S_DEBUG`** | контейнер | `1`/`true`/`yes`/`да` — уровень DEBUG в логах | +| **`KIND_K8S_JOB_LOG_MAX_LINES`** | приложение | Размер буфера строк журнала фонового задания (`kind create`) для поля `progress_log` в API/UI; по умолчанию **500** (задаётся в коде, при необходимости передайте в compose) | +| **`KIND_K8S_README_PATH`** | контейнер / приложение | Абсолютный путь к **README.md** для страницы **`/documentation`**; если пусто — используется `README.md` рядом с каталогом `app/` (в образе: `/opt/kind-k8s/README.md`) | | **`KIND_K8S_WORKDIR`** | локальный запуск | Корень данных на машине разработчика без compose | -| **`COMPOSE_BUILD_FLAGS`** | Makefile | Например `make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64` | +| **`COMPOSE_BUILD_FLAGS`** | Makefile | Например `make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64` (то же для **`make docker rebuild`**) | ## Podman (пример rootless) @@ -143,18 +146,18 @@ make podman up | Путь | Назначение | |------|------------| -| `Makefile` | Запуск веб-UI; префикс `docker` или `podman` обязателен для compose-целей | +| `Makefile` | Запуск веб-UI; префикс `docker` или `podman` обязателен; цели `up`, `rebuild`, `compose-build` и др. | | `scripts/setup_env_interactive.py` | Интерактивное создание `.env` (все ключи и дефолты внутри скрипта) | | `scripts/run_uvicorn.sh` | Точка входа контейнера: uvicorn с опциональным `--reload` | | `Dockerfile` | Образ: kind, kubectl, docker-cli, FastAPI | | `requirements.txt` | pip-зависимости веб-приложения | -| `docker-compose.yml` | Сервис `kind-k8s-web`, тома `./clusters`, `./app`, сокет | -| `app/main.py` | FastAPI: дашборд `/`, редирект `/ui`, монтирование `/static` | -| `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `clusters`) | +| `docker-compose.yml` | Сервис `kind-k8s-web`, тома `./clusters`, `./app`, `./README.md`, сокет | +| `app/main.py` | FastAPI: дашборд `/`, `/documentation`, редирект `/ui`, монтирование `/static` | +| `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `docs_readme`, `clusters`) | | `app/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути | | `app/models/schemas.py` | Pydantic-схемы запросов/ответов API | -| `app/templates/` | Jinja2: `base.html`, `dashboard.html` | -| `app/static/` | `style.css`, `js/dashboard.js` | +| `app/templates/` | Jinja2: `base.html`, `dashboard.html`, `documentation.html` | +| `app/static/` | `style.css`, `js/dashboard.js`, `js/documentation.js`, `js/vendor/` (marked, DOMPurify для README в UI) | | `app/docs/` | `api_routes.md`, `README.md` | | `app/create_cluster.py`, `delete_cluster.py`, `cluster_status.py` | CLI и переиспользование из API / `compose run` | @@ -178,4 +181,4 @@ make podman up - На **Windows** без WSL удобнее WSL2 + Docker Desktop. - Для проверки с хоста нужен отдельный **kubectl** (в образе kubectl только внутри контейнера). - История заданий создания в UI/API хранится в памяти (до **200** записей); после перезапуска контейнера очищается. -- При **`exec format error`** у kind пересоберите образ: `make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64` (или `make podman …`, или `linux/amd64`). +- При **`exec format error`** у kind пересоберите образ: `make docker rebuild COMPOSE_BUILD_FLAGS=--platform linux/arm64` (или `make podman …`, или `compose-build` без `--no-cache`, или `linux/amd64`). diff --git a/app/api/v1/endpoints/clusters.py b/app/api/v1/endpoints/clusters.py index 51e9551..b80428b 100644 --- a/app/api/v1/endpoints/clusters.py +++ b/app/api/v1/endpoints/clusters.py @@ -11,7 +11,7 @@ import logging from typing import Any from fastapi import APIRouter, BackgroundTasks, HTTPException, Query -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse from core.cluster_lifecycle import ( KindClusterError, @@ -22,9 +22,18 @@ from core.cluster_lifecycle import ( kubectl_pods_all_namespaces, list_registered_kind_clusters, read_meta_json, + start_kind_cluster_containers, + stop_kind_cluster_containers, validate_cluster_name, ) -from core.job_store import JobRecord, end_job_tracking, get_progress_sync, job_store, request_cancel_sync +from core.job_store import ( + JobRecord, + end_job_tracking, + get_logs_snapshot_sync, + get_progress_sync, + job_store, + request_cancel_sync, +) from core.kind_guard import kind_cluster_lock from kind_k8s_paths import clusters_dir from models.schemas import ( @@ -47,6 +56,13 @@ def _record_to_job_view(rec: JobRecord) -> JobView: stage, pct = (None, None) if prog is not None: stage, pct = prog[0], prog[1] + if rec.status in ("queued", "running"): + log_tail = get_logs_snapshot_sync(rec.job_id) + else: + log_tail = list(rec.log_lines or []) + max_log = 400 + if len(log_tail) > max_log: + log_tail = log_tail[-max_log:] return JobView( job_id=rec.job_id, kind=rec.kind, @@ -57,6 +73,7 @@ def _record_to_job_view(rec: JobRecord) -> JobView: result=rec.result, progress_stage=stage, progress_percent=pct, + progress_log=log_tail, ) @@ -235,6 +252,49 @@ async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None: end_job_tracking(job_id) +async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag: str, workers: int) -> None: + """Фоновое создание кластера по уже сохранённому ``kind-config.yaml`` (без kind в списке).""" + try: + async with kind_cluster_lock: + await job_store.set_running(job_id) + try: + result = await asyncio.to_thread( + create_cluster_non_interactive, + name=name.strip(), + kubernetes_version_tag=kubernetes_version_tag.strip(), + workers=workers, + job_id=job_id, + use_existing_config=True, + ) + except KindClusterError as e: + msg = str(e) + if "отменено" in msg.lower(): + await job_store.set_cancelled(job_id, msg) + else: + await job_store.set_failed(job_id, msg) + logger.warning("start_cluster job %s: %s", job_id, e) + return + except Exception as e: + await job_store.set_failed(job_id, f"{type(e).__name__}: {e}") + logger.exception("start_cluster job %s: непредвиденная ошибка", job_id) + return + + payload: dict[str, Any] = { + "cluster_name": result.cluster_name, + "kubernetes_version_tag": result.ver_tag, + "node_image": result.node_image, + "workers": result.workers, + "kubeconfig_path": str(result.kubeconfig_path), + "kubeconfig_patched_for_host": result.kubeconfig_patched_for_host, + "nodes_ready": result.nodes_ready, + "nodes_ready_message": result.nodes_ready_message, + } + await job_store.set_success(job_id, result=payload, message="Кластер поднят по сохранённому конфигу") + logger.info("start_cluster job %s: успех, кластер %s", job_id, result.cluster_name) + finally: + end_job_tracking(job_id) + + @router.post( "/clusters", response_model=ClusterCreateAccepted, @@ -279,6 +339,104 @@ async def delete_cluster(name: str) -> dict[str, object]: return {"name": name, "kind_delete_ok": kind_ok, "summary": summary} +@router.post( + "/clusters/{name}/stop", + summary="Остановить узлы кластера (docker stop)", + responses={400: {"description": "Некорректное имя"}}, +) +async def stop_cluster_nodes(name: str) -> dict[str, object]: + """ + Остановить контейнеры узлов kind; запись кластера в kind сохраняется. + + После этого API «Старт» запустит те же контейнеры без ``kind create``. + """ + if not validate_cluster_name(name): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + + async with kind_cluster_lock: + + def _do() -> tuple[bool, str]: + return stop_kind_cluster_containers(name=name) + + try: + ok, summary = await asyncio.to_thread(_do) + except KindClusterError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + logger.info("Остановка узлов %s: ok=%s", name, ok) + return {"name": name, "containers_stopped_ok": ok, "summary": summary} + + +@router.post( + "/clusters/{name}/start", + summary="Запустить кластер (контейнеры или kind create по конфигу)", + responses={400: {"description": "Нет kind и нет kind-config.yaml"}}, +) +async def start_cluster_nodes( + name: str, + background_tasks: BackgroundTasks, +) -> JSONResponse: + """ + Если кластер есть в ``kind get clusters`` — ``docker start`` всех узлов. + + Если в kind нет, но есть ``clusters/<имя>/kind-config.yaml`` — фоновое ``kind create`` + (как при создании, с журналом в GET /jobs/{job_id}). + """ + if not validate_cluster_name(name): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + + n = name.strip() + + async with kind_cluster_lock: + in_kind = n in await asyncio.to_thread(list_registered_kind_clusters) + if in_kind: + + def _start() -> tuple[bool, str]: + return start_kind_cluster_containers(name=n) + + try: + ok, summary = await asyncio.to_thread(_start) + except KindClusterError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + logger.info("Запуск контейнеров кластера %s: ok=%s", n, ok) + return JSONResponse( + status_code=200, + content={ + "name": n, + "mode": "containers", + "containers_started_ok": ok, + "summary": summary, + }, + ) + + cfg = clusters_dir() / n / "kind-config.yaml" + if not cfg.is_file(): + raise HTTPException( + status_code=400, + detail="Кластер не в kind и нет файла clusters/<имя>/kind-config.yaml — создайте кластер или восстановите конфиг.", + ) + + meta = read_meta_json(n) or {} + ver_raw = str(meta.get("kubernetes_version_tag") or "v1.29.4").strip() or "v1.29.4" + w_raw = meta.get("worker_nodes") + try: + w = int(w_raw) if w_raw is not None else 0 + except (TypeError, ValueError): + w = 0 + + rec = await job_store.create_job("start_cluster", cluster_name=n) + background_tasks.add_task(_run_start_cluster_job, rec.job_id, n, ver_raw, w) + logger.info("Фоновый старт кластера %s по конфигу, job_id=%s", n, rec.job_id) + return JSONResponse( + status_code=202, + content={ + "job_id": rec.job_id, + "status": "queued", + "message": "Подъём кластера по kind-config.yaml; опросите GET /api/v1/jobs/{job_id}", + }, + ) + + @router.post( "/jobs/{job_id}/cancel", summary="Запросить отмену создания кластера", @@ -286,8 +444,10 @@ async def delete_cluster(name: str) -> dict[str, object]: ) async def cancel_create_job(job_id: str) -> dict[str, object]: """ - Установить флаг отмены. Этап ``kind create cluster`` нельзя прервать до его завершения; - после него отмена удалит кластер и данные (если успели создать). + Установить флаг отмены для задания ``create_cluster`` или ``start_cluster``. + + Этап ``kind create cluster`` нельзя прервать до его завершения; после него отмена удалит + кластер и данные (если успели создать). """ rec = await job_store.get(job_id) if not rec: diff --git a/app/api/v1/endpoints/docs_readme.py b/app/api/v1/endpoints/docs_readme.py new file mode 100644 index 0000000..64e05a6 --- /dev/null +++ b/app/api/v1/endpoints/docs_readme.py @@ -0,0 +1,53 @@ +"""Отдача сырого README.md для клиентского рендера Markdown (marked в static). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter, HTTPException +from fastapi.responses import PlainTextResponse + +from core.readme_doc import read_readme_text + +logger = logging.getLogger("kind_k8s.api.docs_readme") + +router = APIRouter(tags=["documentation"]) + + +@router.get( + "/docs/readme", + response_class=PlainTextResponse, + summary="README.md как текст (Markdown)", + responses={404: {"description": "Файл не найден"}}, +) +async def get_readme_markdown() -> PlainTextResponse: + """ + Тело ответа — содержимое README в кодировке UTF-8. + + Разбор Markdown выполняется в браузере скриптами из ``/static/js/vendor/`` (marked + DOMPurify). + """ + try: + + def _read() -> str: + return read_readme_text() + + text = await asyncio.to_thread(_read) + except FileNotFoundError: + logger.info("GET /docs/readme: файл README не найден") + raise HTTPException( + status_code=404, + detail=( + "README.md не найден. Укажите KIND_K8S_README_PATH, смонтируйте в compose " + "./README.md:/opt/kind-k8s/README.md:ro или пересоберите образ (COPY README.md)." + ), + ) from None + logger.debug("GET /docs/readme: отдано %s символов", len(text)) + return PlainTextResponse( + content=text, + media_type="text/markdown; charset=utf-8", + ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index f3f0078..77475dc 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -8,9 +8,10 @@ from __future__ import annotations from fastapi import APIRouter -from api.v1.endpoints import clusters, health, versions +from api.v1.endpoints import clusters, docs_readme, health, 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="") diff --git a/app/core/cluster_lifecycle.py b/app/core/cluster_lifecycle.py index 873cdb1..8af0919 100644 --- a/app/core/cluster_lifecycle.py +++ b/app/core/cluster_lifecycle.py @@ -14,6 +14,7 @@ import os import re import shutil import subprocess +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -106,6 +107,42 @@ def _run_checked(cmd: list[str], *, cwd: Path | None = None) -> None: raise KindClusterError(f"Команда завершилась с кодом {p.returncode}: {err}", exit_code=p.returncode) +def _run_checked_stream( + cmd: list[str], + *, + cwd: Path | None = None, + on_line: Callable[[str], None] | None = None, +) -> None: + """ + Выполнить команду с построчным выводом в колбэк (stdout+stderr объединены). + + Нужен для ``kind create cluster``: pull образов и подъём нод видны в UI по опросу job. + """ + logger.info("Выполнение (поток): %s", " ".join(cmd)) + p = subprocess.Popen( + cmd, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + if p.stdout is None: + raise KindClusterError("Не удалось открыть stdout процесса", exit_code=1) + try: + for raw in p.stdout: + line = raw.rstrip("\n\r") + if on_line and line: + on_line(line) + if line: + logger.debug("stream: %s", line[:800]) + rc = p.wait() + finally: + p.stdout.close() + if rc != 0: + raise KindClusterError(f"Команда завершилась с кодом {rc} (см. журнал задания выше)", exit_code=rc) + + def _run_capture_checked(cmd: list[str]) -> str: p = subprocess.run(cmd, capture_output=True, text=True) if p.returncode != 0: @@ -177,6 +214,7 @@ def create_cluster_non_interactive( kubernetes_version_tag: str, workers: int, job_id: str | None = None, + use_existing_config: bool = False, ) -> CreateClusterResult: """ Создать кластер kind без диалогов. @@ -184,12 +222,20 @@ def create_cluster_non_interactive( ``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``. ``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``). + + ``use_existing_config=True`` — не перезаписывать ``kind-config.yaml``, поднять кластер по уже + сохранённому файлу (каталог ``clusters/<имя>/`` должен существовать). """ from core import job_store as _job_store def _progress(stage: str, pct: int) -> None: if job_id: _job_store.set_progress_sync(job_id, stage, pct) + _job_store.append_log_sync(job_id, f"[{pct}%] {stage}") + + def _log(line: str) -> None: + if job_id: + _job_store.append_log_sync(job_id, line) def _cancelled() -> bool: return bool(job_id and _job_store.is_cancelled_sync(job_id)) @@ -204,7 +250,7 @@ def create_cluster_non_interactive( if name in existing: raise KindClusterError(f"Кластер «{name}» уже существует в kind.") - if workers < 0 or workers > 20: + if not use_existing_config and (workers < 0 or workers > 20): raise KindClusterError("Количество worker-нод должно быть от 0 до 20.") ver_tag = normalize_tag_v_prefix(kubernetes_version_tag) @@ -218,17 +264,39 @@ def create_cluster_non_interactive( kube_path = out_dir / "kubeconfig" meta_path = out_dir / "meta.json" - yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers) - cfg_path.write_text(yaml_text, encoding="utf-8") + prev_meta_for_workers: dict[str, object] = {} + if use_existing_config: + if not cfg_path.is_file(): + raise KindClusterError(f"Нет сохранённого kind-config.yaml: {cfg_path}") + prev = read_meta_json(name) or {} + prev_meta_for_workers = prev + if prev.get("node_image"): + node_image = str(prev["node_image"]) + if prev.get("kubernetes_version_tag"): + ver_tag = str(prev["kubernetes_version_tag"]) + _progress("Используется существующий kind-config.yaml", 10) + else: + yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers) + cfg_path.write_text(yaml_text, encoding="utf-8") + _progress("Подготовка каталога и kind-config", 12) - _progress("Подготовка каталога и kind-config", 12) if _cancelled(): _rollback_after_cancel(cluster_name=name, out_dir=out_dir) raise KindClusterError("Создание отменено пользователем") - logger.info("Создание кластера «%s», образ %s, workers=%s", name, node_image, workers) + logger.info( + "Создание кластера «%s», образ %s, workers=%s, existing_cfg=%s", + name, + node_image, + workers, + use_existing_config, + ) _progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28) - _run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)]) + _log("--- kind create cluster ---") + _run_checked_stream( + ["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)], + on_line=_log, + ) if _cancelled(): _rollback_after_cancel(cluster_name=name, out_dir=out_dir) @@ -262,12 +330,22 @@ def create_cluster_non_interactive( logger.info("Ноды готовы: %s", msg) else: logger.warning("Ожидание нод не завершилось успешно: %s", msg) + _log(f"kubectl wait nodes: {msg}"[:4000]) + + worker_nodes_meta = workers + if use_existing_config: + prev_w = prev_meta_for_workers.get("worker_nodes") + if prev_w is not None: + try: + worker_nodes_meta = int(prev_w) + except (TypeError, ValueError): + worker_nodes_meta = workers meta = { "cluster_name": name, "kubernetes_version_tag": ver_tag, "node_image": node_image, - "worker_nodes": workers, + "worker_nodes": worker_nodes_meta, "created_at_utc": datetime.now(timezone.utc).isoformat(), "kind_config_path": str(cfg_path.relative_to(root)), "kubeconfig_path": str(kube_path.relative_to(root)), @@ -275,6 +353,7 @@ def create_cluster_non_interactive( "created_via_container": _in_container(), "nodes_ready_after_create": nodes_ready, "nodes_ready_message": nodes_msg, + "provisioned_from_existing_config": use_existing_config, } meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") @@ -284,7 +363,7 @@ def create_cluster_non_interactive( cluster_name=name, ver_tag=ver_tag, node_image=node_image, - workers=workers, + workers=worker_nodes_meta, kubeconfig_path=kube_path, meta_path=meta_path, kubeconfig_patched_for_host=patched, @@ -340,6 +419,84 @@ def delete_kind_cluster_and_data(*, name: str, log_to_stdout: bool = False) -> t return kind_ok, "; ".join(parts) +def _sort_kind_node_containers(names: list[str]) -> list[str]: + """Сначала control-plane, затем остальные — удобнее для ``docker start``.""" + + def sort_key(n: str) -> tuple[int, str]: + if n.endswith("-control-plane"): + return (0, n) + return (1, n) + + return sorted(names, key=sort_key) + + +def list_kind_cluster_container_names(*, cluster_name: str) -> list[str]: + """Имена контейнеров узлов kind (все с префиксом ``<имя>-``).""" + cli = _container_cli_bin() + if not shutil.which(cli): + raise KindClusterError(f"CLI контейнеров «{cli}» не найден в PATH.", exit_code=127) + p = subprocess.run( + [cli, "ps", "-a", "--format", "{{.Names}}"], + capture_output=True, + text=True, + ) + if p.returncode != 0: + err = (p.stderr or p.stdout or "").strip() + raise KindClusterError(f"{cli} ps: {err}", exit_code=p.returncode) + prefix = f"{cluster_name}-" + raw = [n.strip() for n in (p.stdout or "").splitlines() if n.strip()] + matched = [n for n in raw if n.startswith(prefix)] + return _sort_kind_node_containers(matched) + + +def stop_kind_cluster_containers(*, name: str) -> tuple[bool, str]: + """ + Остановить контейнеры узлов (``docker stop`` / ``podman stop``). + + Запись kind о кластере сохраняется; позже можно вызвать ``start_kind_cluster_containers``. + """ + names = list_kind_cluster_container_names(cluster_name=name) + if not names: + return True, "Нет контейнеров с префиксом «%s-» (уже остановлены или удалены)" % name + cli = _container_cli_bin() + ok_all = True + parts: list[str] = [] + for ctr in names: + p = subprocess.run([cli, "stop", ctr], capture_output=True, text=True) + if p.returncode != 0: + ok_all = False + err = (p.stderr or p.stdout or "").strip() or str(p.returncode) + parts.append(f"{ctr}: ошибка ({err})") + logger.warning("%s stop %s: %s", cli, ctr, err) + else: + parts.append(f"{ctr}: OK") + return ok_all, "; ".join(parts) + + +def start_kind_cluster_containers(*, name: str) -> tuple[bool, str]: + """Запустить контейнеры узлов kind (после ``stop`` или рестарта движка).""" + names = list_kind_cluster_container_names(cluster_name=name) + if not names: + return False, ( + "Не найдены контейнеры «%s-*». Если кластера нет в kind — используйте «Старт» " + "из UI (создание по сохранённому kind-config.yaml) или создайте кластер заново." + % name + ) + cli = _container_cli_bin() + ok_all = True + parts: list[str] = [] + for ctr in names: + p = subprocess.run([cli, "start", ctr], capture_output=True, text=True) + if p.returncode != 0: + ok_all = False + err = (p.stderr or p.stdout or "").strip() or str(p.returncode) + parts.append(f"{ctr}: ошибка ({err})") + logger.warning("%s start %s: %s", cli, ctr, err) + else: + parts.append(f"{ctr}: OK") + return ok_all, "; ".join(parts) + + def read_meta_json(cluster_name: str) -> dict[str, object] | None: """Прочитать ``clusters/<имя>/meta.json`` если есть.""" p = clusters_dir() / cluster_name / "meta.json" diff --git a/app/core/job_store.py b/app/core/job_store.py index d951107..9443ff5 100644 --- a/app/core/job_store.py +++ b/app/core/job_store.py @@ -12,9 +12,11 @@ from __future__ import annotations import asyncio import logging +import os import threading import uuid -from dataclasses import dataclass +from collections import deque +from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any, Literal @@ -29,6 +31,46 @@ JobStatus = Literal["queued", "running", "success", "failed", "cancelled"] _thread_lock = threading.Lock() _cancel_events: dict[str, threading.Event] = {} _progress: dict[str, tuple[str, int]] = {} +# Хвост логов для активных заданий (kind create и т.д.); после завершения копируется в JobRecord.log_lines +_job_log_deques: dict[str, deque[str]] = {} + + +def _max_job_log_lines() -> int: + raw = (os.environ.get("KIND_K8S_JOB_LOG_MAX_LINES") or "500").strip() + try: + return max(50, min(int(raw), 5000)) + except ValueError: + return 500 + + +def append_log_sync(job_id: str, line: str) -> None: + """Добавить строку в журнал задания (вызывается из worker-thread во время долгих команд).""" + text = (line or "").rstrip() + if not text: + return + cap = _max_job_log_lines() + with _thread_lock: + if job_id not in _job_log_deques: + _job_log_deques[job_id] = deque(maxlen=cap) + _job_log_deques[job_id].append(text) + + +def get_logs_snapshot_sync(job_id: str) -> list[str]: + """Снимок текущего журнала (для API во время running/queued).""" + with _thread_lock: + d = _job_log_deques.get(job_id) + return list(d) if d else [] + + +def take_logs_finalize_sync(job_id: str) -> list[str]: + """ + Забрать журнал в список и удалить deque (после успеха/ошибки/отмены). + + Вызывать перед или внутри обновления JobRecord. + """ + with _thread_lock: + d = _job_log_deques.pop(job_id, None) + return list(d) if d else [] def begin_job_tracking(job_id: str) -> None: @@ -43,6 +85,7 @@ def end_job_tracking(job_id: str) -> None: with _thread_lock: _cancel_events.pop(job_id, None) _progress.pop(job_id, None) + _job_log_deques.pop(job_id, None) def set_progress_sync(job_id: str, stage: str, percent: int) -> None: @@ -88,6 +131,8 @@ class JobRecord: created_at_utc: str message: str | None = None result: dict[str, Any] | None = None + # Журнал после завершения (stdout/stderr kind create и этапы); пока задание активно — см. deque + log_lines: list[str] = field(default_factory=list) class JobStore: @@ -127,25 +172,31 @@ class JobStore: set_progress_sync(job_id, "Запуск создания кластера…", 5) 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) 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 set_progress_sync(job_id, "Готово", 100) async def set_failed(self, job_id: str, message: str) -> None: + logs = take_logs_finalize_sync(job_id) 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 logger.warning("Задание %s завершилось ошибкой: %s", job_id, message) async def set_cancelled(self, job_id: str, message: str = "Создание отменено пользователем") -> None: + logs = take_logs_finalize_sync(job_id) 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 logger.info("Задание %s отменено: %s", job_id, message) async def get(self, job_id: str) -> JobRecord | None: diff --git a/app/core/readme_doc.py b/app/core/readme_doc.py new file mode 100644 index 0000000..e31a665 --- /dev/null +++ b/app/core/readme_doc.py @@ -0,0 +1,88 @@ +"""Чтение README.md для API ``GET /api/v1/docs/readme`` и страницы «Документация». + +Разметка Markdown преобразуется в браузере: ``/static/js/vendor/marked.min.js`` и +``purify.min.js`` (файлы входят в репозиторий, без CDN). + +Путь к файлу: ``KIND_K8S_README_PATH`` или ``README.md`` в корне рядом с ``app/``; +в Docker-образе — ``/opt/kind-k8s/README.md``. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +logger = logging.getLogger("kind_k8s.readme_doc") + +# app/core/readme_doc.py: parents[2] = корень репозитория (рядом с app/) или /opt/kind-k8s в образе +_LIB_FILE = Path(__file__).resolve() + + +def _candidates_without_env() -> list[Path]: + """ + Возможные пути к README без KIND_K8S_README_PATH. + + Порядок: родитель каталога app/ (типично репозиторий), затем фиксированный путь образа. + В compose рекомендуется монтировать ./README.md → /opt/kind-k8s/README.md (см. docker-compose.yml). + """ + out: list[Path] = [] + seen: set[Path] = set() + try: + repo_readme = (_LIB_FILE.parents[2] / "README.md").resolve() + if repo_readme not in seen: + seen.add(repo_readme) + out.append(repo_readme) + except (IndexError, OSError): + pass + fixed = Path("/opt/kind-k8s/README.md") + try: + fixed_r = fixed.resolve() + if fixed_r not in seen: + seen.add(fixed_r) + out.append(fixed_r) + except OSError: + out.append(fixed) + return out + + +def get_readme_path() -> Path | None: + """Первый существующий путь к README или ``None``.""" + raw = (os.environ.get("KIND_K8S_README_PATH") or "").strip() + if raw: + p = Path(raw).expanduser().resolve() + return p if p.is_file() else None + for p in _candidates_without_env(): + if p.is_file(): + return p + return None + + +def read_readme_text() -> str: + """Прочитать README как UTF-8; ``FileNotFoundError`` если файла нет.""" + raw = (os.environ.get("KIND_K8S_README_PATH") or "").strip() + if raw: + p = Path(raw).expanduser().resolve() + if not p.is_file(): + logger.warning("KIND_K8S_README_PATH: файл не найден: %s", p) + raise FileNotFoundError(str(p)) + text = p.read_text(encoding="utf-8") + logger.debug("README из KIND_K8S_README_PATH, %s символов", len(text)) + return text + + for p in _candidates_without_env(): + if p.is_file(): + text = p.read_text(encoding="utf-8") + logger.info("README прочитан: %s (%s символов)", p, len(text)) + return text + + logger.warning( + "README.md не найден. Проверены пути: %s. " + "В Docker Compose добавьте монтирование ./README.md:/opt/kind-k8s/README.md " + "или пересоберите образ (COPY README.md в Dockerfile).", + [str(x) for x in _candidates_without_env()], + ) + raise FileNotFoundError("README.md") diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index 1288d6b..e8f12d5 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -10,19 +10,21 @@ | Swagger UI (OpenAPI) | `http://127.0.0.1:<порт>/docs` (порт на хосте по умолчанию **8080**, см. `KIND_K8S_WEB_PORT`; 6000 на хосте блокируется Chrome) | | ReDoc | `http://127.0.0.1:<порт>/redoc` | | Health (JSON) | `http://127.0.0.1:<порт>/api/v1/health` | +| Документация проекта | `http://127.0.0.1:<порт>/documentation` — **README.md**: текст с `GET /api/v1/docs/readme`, рендер **Markdown** в браузере (**marked** + **DOMPurify** из `app/static/js/vendor/`, без CDN) | | Этот файл | `app/docs/api_routes.md` в репозитории | -С **веб-панели** (`GET /`) пункты меню **Swagger**, **ReDoc** и **Health** вызывают `window.open` с именами окон `kind_swagger`, `kind_redoc`, `kind_health` (отдельное окно, повторный клик переиспользует то же окно). +С **веб-панели** (`GET /`) пункты меню **Swagger**, **ReDoc** и **Health** вызывают `window.open` с именами окон `kind_swagger`, `kind_redoc`, `kind_health` (отдельное окно, повторный клик переиспользует то же окно). Пункт **Документация** открывает `GET /documentation` в той же вкладке. ## Веб-интерфейс и статика (не JSON) | Маршрут | Описание | |---------|----------| -| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, отмена), таблицы (автообновление ~3,5 с), модалка узлов/подов; в шапке — меню-пилюли и отдельные окна для Swagger / ReDoc / Health. | +| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, **журнал** `kind create`, отмена), таблица кластеров с **иконками** действий и **всплывающими подсказками**, модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | +| `GET /documentation` | HTML-оболочка; контент — запрос к **`GET /api/v1/docs/readme`** и разбор Markdown скриптами из **`/static/js/vendor/`** (marked, DOMPurify). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. | | `GET /ui` | Редирект **307** на `/` (удобный ярлык). | | `GET /static/…` | CSS (`style.css`), скрипт панели (`js/dashboard.js`); базовый URL API задаётся атрибутом `data-api-base` на `
` (по умолчанию `/api/v1`). | -Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели). +Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/documentation.html` (README). --- @@ -31,10 +33,13 @@ | Метод | Путь | Кратко | |-------|------|--------| | GET | `/api/v1/health` | Среда: kind, kubectl, движок контейнеров | +| GET | `/api/v1/docs/readme` | Текст **README.md** (`text/markdown`; для страницы `/documentation`) | | GET | `/api/v1/versions` | Теги `kindest/node` (Docker Hub) или пусто при `KIND_K8S_SKIP_VERSION_LIST` | | GET | `/api/v1/stats` | Сводка для дашборда | | GET | `/api/v1/clusters` | Список кластеров | | POST | `/api/v1/clusters` | Создание в фоне (**202** + `job_id`) | +| POST | `/api/v1/clusters/{name}/start` | Запуск: **200** — `docker start` узлов (кластер в kind); **202** + `job_id` — фоновый `kind create` по сохранённому `kind-config.yaml` | +| POST | `/api/v1/clusters/{name}/stop` | Остановка узлов (`docker`/`podman` **stop**), запись в kind сохраняется | | GET | `/api/v1/clusters/{name}` | Детали + `kubectl get nodes` при наличии kubeconfig | | GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig | | GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) | @@ -49,6 +54,8 @@ - В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`). - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI). - В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания. +- Поле **`progress_log`** — массив последних строк журнала (вывод `kind create`: pull образов, подъём нод и т.д.); размер ограничен (см. `KIND_K8S_JOB_LOG_MAX_LINES` в коде `job_store`, по умолчанию до **500** строк в буфере, в JSON отдаётся хвост). +- Тип задания **`kind`**: `create_cluster` или `start_cluster` (повторный подъём по `clusters/<имя>/kind-config.yaml`). - Статус **`cancelled`** — пользователь запросил отмену (`POST .../cancel`); этап `kind create cluster` до завершения не прерывается. --- @@ -86,6 +93,16 @@ --- +## GET /api/v1/docs/readme + +Сырое содержимое **README.md** проекта в кодировке UTF-8, заголовок **`Content-Type: text/markdown; charset=utf-8`**. + +Используется страницей **`GET /documentation`**: скрипт `documentation.js` загружает текст и превращает его в HTML через **marked** и **DOMPurify** (файлы лежат в репозитории: `app/static/js/vendor/`, без внешних CDN). + +**Ошибка 404:** файл не найден. В Compose смонтируйте `./README.md:/opt/kind-k8s/README.md:ro`, задайте `KIND_K8S_README_PATH` или пересоберите образ (`COPY README.md`). См. `app/core/readme_doc.py`. + +--- + ## GET /api/v1/versions Список стабильных тегов `kindest/node` с Docker Hub (для выпадающего списка в UI). @@ -293,6 +310,56 @@ --- +## POST /api/v1/clusters/{name}/start + +Запуск кластера двумя сценариями: + +1. Кластер **есть** в `kind get clusters` (узлы когда-либо создавались) — выполняется **`docker start`** / **`podman start`** для всех контейнеров с именами вида `<имя>-control-plane`, `<имя>-worker`, … Ответ **200**. +2. В **kind** кластера **нет**, но в `clusters/<имя>/kind-config.yaml` файл **есть** — ставится фоновое задание **`start_cluster`** (как при создании: `kind create` по сохранённому конфигу, журнал в `GET /jobs/{job_id}`). Ответ **202** + `job_id`. + +**Пример ответа 200 (контейнеры запущены):** + +```json +{ + "name": "dev", + "mode": "containers", + "containers_started_ok": true, + "summary": "dev-control-plane: OK; dev-worker: OK; dev-worker2: OK" +} +``` + +**Пример ответа 202 (подъём по конфигу):** + +```json +{ + "job_id": "cafebabe...", + "status": "queued", + "message": "Подъём кластера по kind-config.yaml; опросите GET /api/v1/jobs/{job_id}" +} +``` + +**Ошибка 400:** некорректное имя или нет ни кластера в kind, ни `kind-config.yaml` в `clusters/<имя>/`. + +--- + +## POST /api/v1/clusters/{name}/stop + +Остановка **всех** контейнеров узлов кластера (`docker stop` / `podman stop` по префиксу имени). Запись кластера в kind **не удаляется**; позже можно снова вызвать **POST …/start** (режим `containers`). + +**Пример ответа 200:** + +```json +{ + "name": "dev", + "containers_stopped_ok": true, + "summary": "dev-control-plane: OK; dev-worker: OK" +} +``` + +**Ошибка 400:** некорректное имя кластера. + +--- + ## GET /api/v1/jobs/{job_id} Статус фонового задания создания. @@ -307,7 +374,15 @@ "cluster_name": "dev", "created_at_utc": "2026-04-04T12:00:00+00:00", "message": null, - "result": null + "result": null, + "progress_stage": "kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", + "progress_percent": 28, + "progress_log": [ + "[12%] Подготовка каталога и kind-config", + "--- kind create cluster ---", + "Creating cluster \"dev\" ...", + " • Ensuring node image (kindest/node:v1.29.4) 🖼 ..." + ] } ``` @@ -321,6 +396,7 @@ "cluster_name": "dev", "created_at_utc": "2026-04-04T12:00:00+00:00", "message": "Кластер создан", + "progress_log": ["[95%] Финализация", "kubectl wait nodes: ..."], "result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4", diff --git a/app/main.py b/app/main.py index 8515f21..fc043b5 100644 --- a/app/main.py +++ b/app/main.py @@ -79,3 +79,18 @@ async def dashboard(request: Request) -> HTMLResponse: async def ui_redirect() -> RedirectResponse: """Удобный алиас на корень UI.""" return RedirectResponse(url="/", status_code=307) + + +@app.get("/documentation", response_class=HTMLResponse, summary="Документация (README)") +async def documentation_page(request: Request) -> HTMLResponse: + """Оболочка страницы: Markdown подгружается с ``GET /api/v1/docs/readme``, рендер в браузере (marked + DOMPurify из ``/static/js/vendor/``).""" + if not _templates_dir.is_dir(): + return HTMLResponse( + content="Шаблоны не найдены.
", + status_code=500, + ) + return templates.TemplateResponse( + request, + "documentation.html", + {"app_title": settings.app_title}, + ) diff --git a/app/models/schemas.py b/app/models/schemas.py index df3e06f..441de48 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -43,6 +43,10 @@ class JobView(BaseModel): result: dict[str, Any] | None = None progress_stage: str | None = Field(default=None, description="Текущий этап создания (пока задание активно)") progress_percent: int | None = Field(default=None, description="Прогресс 0–100 для индикатора в UI") + progress_log: list[str] = Field( + default_factory=list, + description="Хвост лога (kind create, этапы); обновляется при опросе GET /jobs/{id}", + ) class ClusterSummary(BaseModel): diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 9dac2db..18acc5e 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -1,6 +1,7 @@ /** * Панель управления кластерами kind (REST /api/v1). - * Автообновление списков и health; прогресс и отмена создания кластера. + * Полная перезагрузка страницы (location.reload) не используется: только fetch и точечная + * замена содержимого блоков (статистика, таблицы, плашка среды) — SPA-поведение. * * Автор: Сергей Антропов * Сайт: https://devops.org.ru @@ -23,6 +24,8 @@ var createInProgress = false; /** @type {string | null} */ var currentPollJobId = null; + /** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */ + var currentModalClusterName = null; function formatApiError(data, fallback) { if (!data) return fallback; @@ -66,6 +69,137 @@ return d.innerHTML; } + /** + * Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig). + * @param {string} clusterName + */ + function downloadKubeconfig(clusterName) { + const url = API + "/clusters/" + encodeURIComponent(clusterName) + "/kubeconfig"; + const a = document.createElement("a"); + a.href = url; + a.download = "kubeconfig-" + clusterName + ".yaml"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + + /** SVG-иконки действий (stroke, currentColor). */ + var ICONS = { + state: + '', + play: + '', + stop: + '', + download: + '', + trash: + '', + }; + + /** @type {ReturnType" +
nameEsc +
@@ -223,40 +344,78 @@
"" +
escapeHtml(String(wn)) +
" " +
- " ";
- const td = tr.querySelector(".actions");
- const b1 = document.createElement("button");
- b1.type = "button";
- b1.className = "btn-small";
- b1.textContent = "Состояние";
- b1.addEventListener("click", function () {
- openWorkloadsModal(c.name);
- });
- td.appendChild(b1);
- if (c.has_local_kubeconfig) {
- const a = document.createElement("a");
- a.href = dlHref;
- a.className = "btn-secondary btn-small";
- a.download = "kubeconfig-" + c.name + ".yaml";
- a.textContent = "kubeconfig";
- a.title = "Скачать kubeconfig";
- td.appendChild(a);
+ " ";
+ const td = tr.querySelector(".actions-toolbar");
+ td.appendChild(
+ iconActionButton(
+ ICONS.state,
+ "Состояние: узлы и поды (kubectl get nodes / pods)",
+ "",
+ function () {
+ openWorkloadsModal(c.name);
+ },
+ false,
+ ),
+ );
+ td.appendChild(
+ iconActionButton(
+ ICONS.play,
+ "Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
+ "",
+ function () {
+ startCluster(c.name);
+ },
+ false,
+ ),
+ );
+ if (c.registered_in_kind) {
+ td.appendChild(
+ iconActionButton(
+ ICONS.stop,
+ "Стоп: остановить узлы (docker/podman stop), запись кластера в kind сохраняется",
+ "icon-btn--secondary",
+ function () {
+ stopCluster(c.name);
+ },
+ false,
+ ),
+ );
}
- const b2 = document.createElement("button");
- b2.type = "button";
- b2.className = "btn-small btn-danger";
- b2.textContent = "Удалить";
- b2.addEventListener("click", function () {
- deleteCluster(c.name);
- });
- td.appendChild(b2);
- tbody.appendChild(tr);
+ td.appendChild(
+ iconActionButton(
+ ICONS.download,
+ c.has_local_kubeconfig
+ ? "Скачать kubeconfig для kubectl на хосте"
+ : "Kubeconfig ещё нет — появится после создания или подъёма кластера",
+ "icon-btn--secondary",
+ c.has_local_kubeconfig
+ ? function () {
+ downloadKubeconfig(c.name);
+ }
+ : null,
+ !c.has_local_kubeconfig,
+ ),
+ );
+ td.appendChild(
+ iconActionButton(
+ ICONS.trash,
+ "Удалить кластер (kind delete) и каталог clusters/<имя>/",
+ "icon-btn--danger",
+ function () {
+ deleteCluster(c.name);
+ },
+ false,
+ ),
+ );
+ frag.appendChild(tr);
});
- if (!rows.length && msg) msg.textContent = "Кластеров пока нет.";
+ tbody.replaceChildren(frag);
+ bindActionTooltipHosts(document.getElementById("tbl-clusters"));
+ if (msg) {
+ msg.textContent = rows.length ? "" : "Кластеров пока нет.";
+ }
} catch (e) {
if (msg) msg.textContent = "Ошибка списка: " + e.message;
- } finally {
- setBusy("clusters", false);
}
}
@@ -264,17 +423,19 @@
const tbody = document.querySelector("#tbl-jobs tbody");
const msg = document.getElementById("jobs-msg");
if (!tbody) return;
- setBusy("jobs", true);
- tbody.innerHTML = "";
- if (msg) msg.textContent = "";
try {
const rows = await api("/jobs?limit=30");
+ const frag = document.createDocumentFragment();
rows.forEach(function (j) {
const tr = document.createElement("tr");
const st = escapeHtml(j.status || "");
- var cellMsg = (j.message || "").slice(0, 160);
+ var kindTag = j.kind === "start_cluster" ? "[старт] " : "";
+ var cellMsg = kindTag + (j.message || "").slice(0, 140);
if ((j.status === "running" || j.status === "queued") && j.progress_stage) {
- cellMsg = j.progress_stage + (j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
+ cellMsg =
+ kindTag +
+ j.progress_stage +
+ (j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
}
tr.innerHTML =
" ";
- tbody.appendChild(tr);
+ frag.appendChild(tr);
});
- if (!rows.length && msg) {
- msg.textContent = "Заданий ещё не было (или контейнер перезапускали).";
+ tbody.replaceChildren(frag);
+ if (msg) {
+ msg.textContent = rows.length
+ ? ""
+ : "Заданий ещё не было (или контейнер перезапускали).";
}
} catch (e) {
if (msg) msg.textContent = "Задания: " + e.message;
- } finally {
- setBusy("jobs", false);
}
}
@@ -311,11 +473,15 @@
const nodes = document.getElementById("modal-nodes");
const pods = document.getElementById("modal-pods");
const spin = document.getElementById("modal-spinner");
+ const modalDlWrap = document.getElementById("modal-dl-wrap");
if (!overlay) return;
+ hideActionTooltip();
+ currentModalClusterName = name;
document.getElementById("modal-title").textContent = "Кластер «" + name + "»";
sub.textContent = "";
nodes.textContent = "";
pods.textContent = "";
+ if (modalDlWrap) modalDlWrap.classList.add("hidden");
if (spin) spin.classList.remove("hidden");
overlay.classList.remove("hidden");
document.body.classList.add("modal-open");
@@ -328,6 +494,7 @@
sub.textContent = "kubectl: узлы rc=" + w.nodes_rc + ", поды rc=" + w.pods_rc;
nodes.textContent = w.nodes_output || "(пусто)";
pods.textContent = w.pods_output || "(пусто)";
+ if (modalDlWrap) modalDlWrap.classList.remove("hidden");
} catch (e) {
sub.textContent = "Ошибка: " + e.message;
} finally {
@@ -335,14 +502,131 @@
}
}
+ /** Колбэк ожидающего Promise от openConfirmModal (одно окно за раз). */
+ var confirmModalResolver = null;
+
+ function isConfirmModalOpen() {
+ const el = document.getElementById("confirm-modal-overlay");
+ return !!(el && !el.classList.contains("hidden"));
+ }
+
+ /**
+ * Закрыть модалку подтверждения и вернуть результат в Promise.
+ * @param {boolean} confirmed
+ */
+ function closeConfirmModal(confirmed) {
+ const ov = document.getElementById("confirm-modal-overlay");
+ if (ov) ov.classList.add("hidden");
+ document.body.classList.remove("modal-open");
+ if (confirmModalResolver) {
+ var fn = confirmModalResolver;
+ confirmModalResolver = null;
+ fn(!!confirmed);
+ }
+ }
+
+ /**
+ * Показать модальное подтверждение (вместо window.confirm).
+ * @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean }} opts
+ * @returns {Promise}
+ */
+ function openConfirmModal(opts) {
+ return new Promise(function (resolve) {
+ const ov = document.getElementById("confirm-modal-overlay");
+ const titleEl = document.getElementById("confirm-modal-title");
+ const msgEl = document.getElementById("confirm-modal-message");
+ const okBtn = document.getElementById("confirm-modal-ok");
+ if (!ov || !titleEl || !msgEl || !okBtn) {
+ resolve(false);
+ return;
+ }
+ if (confirmModalResolver) {
+ closeConfirmModal(false);
+ }
+ confirmModalResolver = resolve;
+ titleEl.textContent = opts.title || "Подтвердите действие";
+ msgEl.textContent = opts.message || "";
+ okBtn.textContent = opts.confirmLabel || "Подтвердить";
+ okBtn.className = opts.danger ? "btn-danger" : "";
+ ov.classList.remove("hidden");
+ document.body.classList.add("modal-open");
+ okBtn.focus();
+ });
+ }
+
function closeModal() {
+ hideActionTooltip();
const overlay = document.getElementById("modal-overlay");
+ const modalDlWrap = document.getElementById("modal-dl-wrap");
+ if (modalDlWrap) modalDlWrap.classList.add("hidden");
+ currentModalClusterName = null;
if (overlay) overlay.classList.add("hidden");
document.body.classList.remove("modal-open");
}
+ async function stopCluster(name) {
+ const ok = await openConfirmModal({
+ title: "Остановить узлы кластера?",
+ message:
+ "Кластер «" +
+ name +
+ "»: контейнеры будут остановлены (docker/podman stop). Запись в kind сохранится — позже можно снова нажать «Старт».",
+ confirmLabel: "Остановить",
+ danger: false,
+ });
+ if (!ok) return;
+ try {
+ const res = await api("/clusters/" + encodeURIComponent(name) + "/stop", { method: "POST" });
+ showToast(String(res.summary || "Узлы остановлены"), false);
+ await loadClusters();
+ await loadStats();
+ await loadHealth();
+ } catch (e) {
+ showToast(e.message, true);
+ }
+ }
+
+ /**
+ * Старт: либо docker start узлов (кластер уже в kind), либо фоновый kind create по kind-config.yaml.
+ */
+ async function startCluster(name) {
+ const url = API + "/clusters/" + encodeURIComponent(name) + "/start";
+ const r = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
+ const text = await r.text();
+ var data = {};
+ try {
+ data = text ? JSON.parse(text) : {};
+ } catch (e) {
+ data = {};
+ }
+ if (r.status === 202 && data.job_id) {
+ setProgressHint(name);
+ pollJob(data.job_id);
+ return;
+ }
+ if (!r.ok) {
+ showToast(formatApiError(data, text || r.statusText), true);
+ return;
+ }
+ showToast(String(data.summary || "Готово"), false);
+ await loadClusters();
+ await loadStats();
+ await loadHealth();
+ }
+
async function deleteCluster(name) {
- if (!confirm("Удалить кластер «" + name + "» и папку clusters/" + name + "?")) return;
+ const ok = await openConfirmModal({
+ title: "Удалить кластер?",
+ message:
+ "Кластер «" +
+ name +
+ "»: будут выполнены kind delete и удаление каталога clusters/" +
+ name +
+ "/ на томе данных. Действие необратимо.",
+ confirmLabel: "Удалить",
+ danger: true,
+ });
+ if (!ok) return;
const msg = document.getElementById("list-msg");
if (msg) msg.textContent = "Удаление…";
try {
@@ -376,6 +660,29 @@
if (!wrap) return;
wrap.classList.toggle("hidden", !show);
wrap.setAttribute("aria-hidden", show ? "false" : "true");
+ if (!show) {
+ const logEl = document.getElementById("job-log-panel");
+ if (logEl) logEl.textContent = "";
+ }
+ }
+
+ /**
+ * Обновить текст подсказки над прогрессом (создание с нуля или старт по конфигу).
+ * @param {string | null} clusterName
+ */
+ function setProgressHint(clusterName) {
+ const hint = document.getElementById("create-progress-hint");
+ if (!hint) return;
+ if (clusterName) {
+ hint.innerHTML =
+ "Кластер " +
+ escapeHtml(clusterName) +
+ ": подъём по конфигу или длительный kind create — ниже журнал (pull образов и ноды).";
+ } else {
+ hint.innerHTML =
+ "Создание кластера: шаг kind create может занять несколько минут при первом pull образов. " +
+ "Ниже — журнал в реальном времени.";
+ }
}
function updateCreateProgressFromJob(j) {
@@ -417,6 +724,8 @@
function pollJob(jobId) {
const details = document.getElementById("job-details");
const msg = document.getElementById("create-msg");
+ const logEl = document.getElementById("job-log-panel");
+ if (logEl) logEl.textContent = "";
if (details) {
details.classList.remove("hidden");
details.open = false;
@@ -434,20 +743,29 @@
const preEl = document.getElementById("job-json");
if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
updateCreateProgressFromJob(j);
+ if (logEl && j.progress_log && j.progress_log.length) {
+ logEl.textContent = j.progress_log.join("\n");
+ logEl.scrollTop = logEl.scrollHeight;
+ }
if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
stopPollJob();
+ setProgressHint(null);
if (msg) {
- if (j.status === "success") msg.textContent = "Кластер создан.";
- else if (j.status === "cancelled") msg.textContent = j.message || "Создание отменено.";
+ if (j.status === "success") {
+ msg.textContent =
+ j.kind === "start_cluster" ? "Кластер поднят по сохранённому конфигу." : "Кластер создан.";
+ } else if (j.status === "cancelled") msg.textContent = j.message || "Операция отменена.";
else msg.textContent = "Ошибка: " + (j.message || "");
}
- if (j.status === "success") showToast("Кластер создан", false);
- else if (j.status === "cancelled") showToast(j.message || "Отменено", false);
- else showToast(j.message || "Ошибка создания", true);
+ if (j.status === "success") {
+ showToast(j.kind === "start_cluster" ? "Кластер запущен" : "Кластер создан", false);
+ } else if (j.status === "cancelled") showToast(j.message || "Отменено", false);
+ else showToast(j.message || "Ошибка", true);
await loadClusters();
await loadStats();
await loadJobs();
+ await loadHealth();
}
} catch (e) {
if (msg) msg.textContent = "Ошибка опроса задания: " + e.message;
@@ -474,6 +792,7 @@
const details = document.getElementById("job-details");
if (msg) msg.textContent = "";
if (details) details.classList.add("hidden");
+ setProgressHint(null);
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
@@ -505,6 +824,13 @@
const mClose = document.getElementById("modal-close");
if (mClose) mClose.addEventListener("click", closeModal);
+ const modalBtnKube = document.getElementById("modal-btn-kubeconfig");
+ if (modalBtnKube) {
+ modalBtnKube.addEventListener("click", function () {
+ if (currentModalClusterName) downloadKubeconfig(currentModalClusterName);
+ });
+ }
+
const overlay = document.getElementById("modal-overlay");
if (overlay) {
overlay.addEventListener("click", function (ev) {
@@ -513,9 +839,43 @@
}
document.addEventListener("keydown", function (ev) {
- if (ev.key === "Escape") closeModal();
+ if (ev.key !== "Escape") return;
+ if (isConfirmModalOpen()) {
+ closeConfirmModal(false);
+ return;
+ }
+ hideActionTooltip();
+ closeModal();
});
+ const confirmOv = document.getElementById("confirm-modal-overlay");
+ const confirmCancel = document.getElementById("confirm-modal-cancel");
+ const confirmOk = document.getElementById("confirm-modal-ok");
+ if (confirmCancel) {
+ confirmCancel.addEventListener("click", function () {
+ closeConfirmModal(false);
+ });
+ }
+ if (confirmOk) {
+ confirmOk.addEventListener("click", function () {
+ closeConfirmModal(true);
+ });
+ }
+ if (confirmOv) {
+ confirmOv.addEventListener("click", function (ev) {
+ if (ev.target === confirmOv) closeConfirmModal(false);
+ });
+ }
+
+ window.addEventListener(
+ "scroll",
+ function () {
+ hideActionTooltip();
+ },
+ true,
+ );
+ window.addEventListener("resize", hideActionTooltip);
+
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
loadHealth();
@@ -523,6 +883,7 @@
loadVersions();
loadClusters();
loadJobs();
+ bindActionTooltipHosts(document.getElementById("modal-overlay"));
}
if (document.readyState === "loading") {
diff --git a/app/static/js/documentation.js b/app/static/js/documentation.js
new file mode 100644
index 0000000..4912933
--- /dev/null
+++ b/app/static/js/documentation.js
@@ -0,0 +1,75 @@
+/**
+ * Страница /documentation: загрузка README через API и рендер Markdown.
+ * Зависимости из репозитория: /static/js/vendor/marked.min.js, purify.min.js
+ *
+ * Автор: Сергей Антропов
+ * Сайт: https://devops.org.ru
+ */
+(function () {
+ "use strict";
+
+ const body = document.body;
+ const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
+
+ function showErr(el, msg) {
+ if (!el) return;
+ el.textContent = msg;
+ el.classList.remove("hidden");
+ }
+
+ async function run() {
+ const article = document.getElementById("readme-doc-article");
+ const errEl = document.getElementById("readme-error");
+ const loadEl = document.getElementById("readme-loading");
+ if (!article || !loadEl) return;
+
+ if (typeof marked === "undefined" || typeof DOMPurify === "undefined") {
+ showErr(errEl, "Не загружены скрипты marked или DOMPurify из /static/js/vendor/.");
+ loadEl.classList.add("hidden");
+ return;
+ }
+
+ try {
+ const r = await fetch(API + "/docs/readme", {
+ headers: { Accept: "text/markdown, text/plain, */*" },
+ });
+ const text = await r.text();
+ if (!r.ok) {
+ var detail = text;
+ try {
+ var j = JSON.parse(text);
+ if (j.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
+ } catch (e) {
+ /* сырой текст */
+ }
+ showErr(errEl, "Не удалось загрузить README: " + (detail || r.statusText));
+ loadEl.classList.add("hidden");
+ return;
+ }
+
+ /* marked v15: переносы строк в параграфах (breaks). */
+ try {
+ if (marked.defaults && typeof marked.defaults === "object") {
+ marked.defaults.breaks = true;
+ marked.defaults.gfm = true;
+ }
+ } catch (e) {
+ /* игнорируем, если defaults защищены от записи */
+ }
+ var rawHtml =
+ typeof marked.parse === "function" ? marked.parse(text, { async: false }) : marked(text);
+ article.innerHTML = DOMPurify.sanitize(rawHtml);
+ article.removeAttribute("hidden");
+ loadEl.classList.add("hidden");
+ } catch (e) {
+ showErr(errEl, "Ошибка: " + (e.message || String(e)));
+ loadEl.classList.add("hidden");
+ }
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", run);
+ } else {
+ run();
+ }
+})();
diff --git a/app/static/js/vendor/ATTRIBUTION.txt b/app/static/js/vendor/ATTRIBUTION.txt
new file mode 100644
index 0000000..9f8a333
--- /dev/null
+++ b/app/static/js/vendor/ATTRIBUTION.txt
@@ -0,0 +1,8 @@
+Вендорные скрипты для страницы /documentation (Markdown в браузере):
+
+- marked v15.0.7 — https://github.com/markedjs/marked (лицензия MIT)
+- DOMPurify v3.2.4 — https://github.com/cure53/DOMPurify (Apache-2.0 / MPL-2.0)
+
+Файлы: marked.min.js, purify.min.js (без CDN, в репозитории).
+
+Автор сводки: Сергей Антропов — https://devops.org.ru
diff --git a/app/static/js/vendor/marked.min.js b/app/static/js/vendor/marked.min.js
new file mode 100644
index 0000000..4052d1b
--- /dev/null
+++ b/app/static/js/vendor/marked.min.js
@@ -0,0 +1,6 @@
+/**
+ * marked v15.0.7 - a markdown parser
+ * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed)
+ * https://github.com/markedjs/marked
+ */
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s={exec:()=>null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,c=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),h=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),p=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,u=/(?!\s*\])(?:\\.|[^\[\]\\])+/,g=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",u).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),k=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),d="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",f=/|$))/,x=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",f).replace("tag",d).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),b=r(p).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex(),w={blockquote:r(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",b).getRegex(),code:/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,def:g,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:l,html:x,lheading:c,list:k,newline:/^(?:[ \t]*(?:\n|$))+/,paragraph:b,table:s,text:/^[^\n]+/},m=r("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3}\t)[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex(),y={...w,lheading:h,table:m,paragraph:r(p).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",m).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex()},$={...w,html:r("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)| \\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",f).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:s,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:r(p).replace("hr",l).replace("heading"," *#{1,6} *[^\n]").replace("lheading",c).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},R=/^( {2,}|\\)\n(?!\s*$)/,S=/[\p{P}\p{S}]/u,T=/[\s\p{P}\p{S}]/u,z=/[^\s\p{P}\p{S}]/u,A=r(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,T).getRegex(),_=/(?!~)[\p{P}\p{S}]/u,P=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,I=r(P,"u").replace(/punct/g,S).getRegex(),L=r(P,"u").replace(/punct/g,_).getRegex(),B="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",C=r(B,"gu").replace(/notPunctSpace/g,z).replace(/punctSpace/g,T).replace(/punct/g,S).getRegex(),q=r(B,"gu").replace(/notPunctSpace/g,/(?:[^\s\p{P}\p{S}]|~)/u).replace(/punctSpace/g,/(?!~)[\s\p{P}\p{S}]/u).replace(/punct/g,_).getRegex(),E=r("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,z).replace(/punctSpace/g,T).replace(/punct/g,S).getRegex(),Z=r(/\\(punct)/,"gu").replace(/punct/g,S).getRegex(),v=r(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),D=r(f).replace("(?:--\x3e|$)","--\x3e").getRegex(),M=r("^comment|^[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",D).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),O=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Q=r(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",O).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),j=r(/^!?\[(label)\]\[(ref)\]/).replace("label",O).replace("ref",u).getRegex(),N=r(/^!?\[(ref)\](?:\[\])?/).replace("ref",u).getRegex(),G={_backpedal:s,anyPunctuation:Z,autolink:v,blockSkip:/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,br:R,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:s,emStrongLDelim:I,emStrongRDelimAst:C,emStrongRDelimUnd:E,escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,link:Q,nolink:N,punctuation:A,reflink:j,reflinkSearch:r("reflink|nolink(?!\\()","g").replace("reflink",j).replace("nolink",N).getRegex(),tag:M,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},V=e=>K[e];function W(e,t){if(t){if(i.escapeTest.test(e))return e.replace(i.escapeReplace,V)}else if(i.escapeTestNoEncode.test(e))return e.replace(i.escapeReplaceNoEncode,V);return e}function Y(e){try{e=encodeURI(e).replace(i.percentDecode,"%")}catch{return null}return e}function ee(e,t){const n=e.replace(i.findPipe,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(i.splitPipe);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:te(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t,n){const s=e.match(n.other.indentCodeCompensation);if(null===s)return t;const r=s[1];return t.split("\n").map((e=>{const t=e.match(n.other.beginningSpace);if(null===t)return e;const[s]=t;return s.length>=r.length?e.slice(r.length):e})).join("\n")}(e,t[3]||"",this.rules);return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(this.rules.other.endingHash.test(e)){const t=te(e,"#");this.options.pedantic?e=t.trim():t&&!this.rules.other.endingSpaceChar.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:te(t[0],"\n")}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=te(t[0],"\n").split("\n"),n="",s="";const r=[];for(;e.length>0;){let t=!1;const i=[];let l;for(l=0;l1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=this.rules.other.listItemRegex(n);let l=!1;for(;e;){let n=!1,s="",o="";if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;s=t[0],e=e.substring(s.length);let a=t[2].split("\n",1)[0].replace(this.rules.other.listReplaceTabs,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=!a.trim(),p=0;if(this.options.pedantic?(p=2,o=a.trimStart()):h?p=t[1].length+1:(p=t[2].search(this.rules.other.nonSpaceChar),p=p>4?1:p,o=a.slice(p),p+=t[1].length),h&&this.rules.other.blankLine.test(c)&&(s+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=this.rules.other.nextBulletRegex(p),n=this.rules.other.hrRegex(p),r=this.rules.other.fencesBeginRegex(p),i=this.rules.other.headingBeginRegex(p),l=this.rules.other.htmlBeginRegex(p);for(;e;){const u=e.split("\n",1)[0];let g;if(c=u,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),g=c):g=c.replace(this.rules.other.tabCharGlobal," "),r.test(c))break;if(i.test(c))break;if(l.test(c))break;if(t.test(c))break;if(n.test(c))break;if(g.search(this.rules.other.nonSpaceChar)>=p||!c.trim())o+="\n"+g.slice(p);else{if(h)break;if(a.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4)break;if(r.test(a))break;if(i.test(a))break;if(n.test(a))break;o+="\n"+c}h||c.trim()||(h=!0),s+=u+"\n",e=e.substring(u.length+1),a=g.slice(p)}}r.loose||(l?r.loose=!0:this.rules.other.doubleBlankLine.test(s)&&(l=!0));let u,g=null;this.options.gfm&&(g=this.rules.other.listIsTask.exec(o),g&&(u="[ ] "!==g[0],o=o.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:s,task:!!g,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=s}const o=r.items.at(-1);if(!o)return;o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>this.rules.other.anyLine.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e({text:e,tokens:this.lexer.inline(e),header:!1,align:i.align[t]}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(e)){if(!this.rules.other.endAngleBracket.test(e))return;const t=te(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=this.rules.other.pedanticHrefTitle.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(n=this.options.pedantic&&!this.rules.other.endAngleBracket.test(e)?n.slice(1):n.slice(1,-1)),ne(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return ne(n,e,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(this.rules.other.newLineCharGlobal," ");const n=this.rules.other.nonSpaceChar.test(e),s=this.rules.other.startingSpaceChar.test(e)&&this.rules.other.endingSpaceChar.test(e);return n&&s&&(e=e.substring(1,e.length-1)),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=t[1],n="mailto:"+e):(e=t[1],n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=t[0],n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=t[0],n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const e=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:e}}}}class re{tokens;options;state;tokenizer;inlineQueue;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||e.defaults,this.options.tokenizer=this.options.tokenizer||new se,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={other:i,block:U.normal,inline:J.normal};this.options.pedantic?(n.block=U.pedantic,n.inline=J.pedantic):this.options.gfm&&(n.block=U.gfm,this.options.breaks?n.inline=J.breaks:n.inline=J.gfm),this.tokenizer.rules=n}static get rules(){return{block:U,inline:J}}static lex(e,t){return new re(t).lex(e)}static lexInline(e,t){return new re(t).inlineTokens(e)}lex(e){e=e.replace(i.carriageReturn,"\n"),this.blockTokens(e,this.tokens);for(let e=0;e!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);const n=t.at(-1);1===s.raw.length&&void 0!==n?n.raw+="\n":t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.at(-1).src=n.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.raw,this.inlineQueue.at(-1).src=n.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let r=e;if(this.options.extensions?.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(s=this.tokenizer.paragraph(r))){const i=t.at(-1);n&&"paragraph"===i?.type?(i.raw+="\n"+s.raw,i.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):t.push(s),n=r.length!==e.length,e=e.substring(s.raw.length)}else if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=n.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(s=this.tokenizer.rules.inline.reflinkSearch.exec(n));)e.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(s=this.tokenizer.rules.inline.blockSkip.exec(n));)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(s=this.tokenizer.rules.inline.anyPunctuation.exec(n));)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r=!1,i="";for(;e;){let s;if(r||(i=""),r=!1,this.options.extensions?.inline?.some((n=>!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.escape(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.tag(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.link(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===s.type&&"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s);continue}if(s=this.tokenizer.emStrong(e,n,i)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.codespan(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.br(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.del(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.autolink(e)){e=e.substring(s.raw.length),t.push(s);continue}if(!this.state.inLink&&(s=this.tokenizer.url(e))){e=e.substring(s.raw.length),t.push(s);continue}let l=e;if(this.options.extensions?.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(l=e.substring(0,t+1))}if(s=this.tokenizer.inlineText(l)){e=e.substring(s.raw.length),"_"!==s.raw.slice(-1)&&(i=s.raw.slice(-1)),r=!0;const n=t.at(-1);"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return t}}class ie{options;parser;constructor(t){this.options=t||e.defaults}space(e){return""}code({text:e,lang:t,escaped:n}){const s=(t||"").match(i.notSpaceStart)?.[0],r=e.replace(i.endingNewline,"")+"\n";return s?''+(n?r:W(r,!0))+"
\n":""+(n?r:W(r,!0))+"
\n"}blockquote({tokens:e}){return`\n${this.parser.parse(e)}
\n`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} \n`}hr(e){return"
\n"}list(e){const t=e.ordered,n=e.start;let s="";for(let t=0;t\n"+s+""+r+">\n"}listitem(e){let t="";if(e.task){const n=this.checkbox({checked:!!e.checked});e.loose?"paragraph"===e.tokens[0]?.type?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&"text"===e.tokens[0].tokens[0].type&&(e.tokens[0].tokens[0].text=n+" "+W(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`${t} \n`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`${this.parser.parseInline(e)}
\n`}table(e){let t="",n="";for(let t=0;t${s}`),"\n\n"+t+"\n"+s+"
\n"}tablerow({text:e}){return`\n${e} \n`}tablecell(e){const t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`${n}>\n`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${W(e,!0)}`}br(e){return"
"}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const s=this.parser.parseInline(n),r=Y(e);if(null===r)return s;let i='"+s+"",i}image({href:e,title:t,text:n}){const s=Y(e);if(null===s)return W(n);let r=`
",r}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:W(e.text)}}class le{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}}class oe{options;renderer;textRenderer;constructor(t){this.options=t||e.defaults,this.options.renderer=this.options.renderer||new ie,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new le}static parse(e,t){return new oe(t).parse(e)}static parseInline(e,t){return new oe(t).parseInline(e)}parse(e,t=!0){let n="";for(let s=0;s{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new ie(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new se(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new ae;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;const s=n,r=e.hooks[s],i=t[s];ae.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return re.lex(e,t??this.defaults)}parser(e,t){return oe.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{const s={...n},r={...this.defaults,...s},i=this.onError(!!r.silent,!!r.async);if(!0===this.defaults.async&&!1===s.async)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(null==t)return i(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof t)return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);const l=r.hooks?r.hooks.provideLexer():e?re.lex:re.lexInline,o=r.hooks?r.hooks.provideParser():e?oe.parse:oe.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(t):t).then((e=>l(e,r))).then((e=>r.hooks?r.hooks.processAllTokens(e):e)).then((e=>r.walkTokens?Promise.all(this.walkTokens(e,r.walkTokens)).then((()=>e)):e)).then((e=>o(e,r))).then((e=>r.hooks?r.hooks.postprocess(e):e)).catch(i);try{r.hooks&&(t=r.hooks.preprocess(t));let e=l(t,r);r.hooks&&(e=r.hooks.processAllTokens(e)),r.walkTokens&&this.walkTokens(e,r.walkTokens);let n=o(e,r);return r.hooks&&(n=r.hooks.postprocess(n)),n}catch(e){return i(e)}}}onError(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="An error occurred:
"+W(n.message+"",!0)+"
";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const he=new ce;function pe(e,t){return he.parse(e,t)}pe.options=pe.setOptions=function(e){return he.setOptions(e),pe.defaults=he.defaults,n(pe.defaults),pe},pe.getDefaults=t,pe.defaults=e.defaults,pe.use=function(...e){return he.use(...e),pe.defaults=he.defaults,n(pe.defaults),pe},pe.walkTokens=function(e,t){return he.walkTokens(e,t)},pe.parseInline=he.parseInline,pe.Parser=oe,pe.parser=oe.parse,pe.Renderer=ie,pe.TextRenderer=le,pe.Lexer=re,pe.lexer=re.lex,pe.Tokenizer=se,pe.Hooks=ae,pe.parse=pe;const ue=pe.options,ge=pe.setOptions,ke=pe.use,de=pe.walkTokens,fe=pe.parseInline,xe=pe,be=oe.parse,we=re.lex;e.Hooks=ae,e.Lexer=re,e.Marked=ce,e.Parser=oe,e.Renderer=ie,e.TextRenderer=le,e.Tokenizer=se,e.getDefaults=t,e.lexer=we,e.marked=pe,e.options=ue,e.parse=xe,e.parseInline=fe,e.parser=be,e.setOptions=ge,e.use=ke,e.walkTokens=de}));
diff --git a/app/static/js/vendor/purify.min.js b/app/static/js/vendor/purify.min.js
new file mode 100644
index 0000000..b472a86
--- /dev/null
+++ b/app/static/js/vendor/purify.min.js
@@ -0,0 +1,3 @@
+/*! @license DOMPurify 3.2.4 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.4/LICENSE */
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=R(Array.prototype.forEach),m=R(Array.prototype.lastIndexOf),p=R(Array.prototype.pop),f=R(Array.prototype.push),d=R(Array.prototype.splice),h=R(String.prototype.toLowerCase),g=R(String.prototype.toString),T=R(String.prototype.match),y=R(String.prototype.replace),E=R(String.prototype.indexOf),A=R(String.prototype.trim),_=R(Object.prototype.hasOwnProperty),S=R(RegExp.prototype.test),b=(N=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:h;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function O(e){for(let t=0;t/gm),G=a(/\$\{[\w\W]*/gm),Y=a(/^data-[\-\w.\u00B7-\uFFFF]+$/),j=a(/^aria-[\-\w]+$/),X=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),q=a(/^(?:\w+script|data):/i),$=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),K=a(/^html$/i),V=a(/^[a-z][.\w]*(-[.\w]+)+$/i);var Z=Object.freeze({__proto__:null,ARIA_ATTR:j,ATTR_WHITESPACE:$,CUSTOM_ELEMENT:V,DATA_ATTR:Y,DOCTYPE_NAME:K,ERB_EXPR:W,IS_ALLOWED_URI:X,IS_SCRIPT_OR_DATA:q,MUSTACHE_EXPR:B,TMPLIT_EXPR:G});const J=1,Q=3,ee=7,te=8,ne=9,oe=function(){return"undefined"==typeof window?null:window};var re=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:oe();const o=e=>t(e);if(o.version="3.2.4",o.removed=[],!n||!n.document||n.document.nodeType!==ne||!n.Element)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:N,Node:R,Element:O,NodeFilter:B,NamedNodeMap:W=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:G,DOMParser:Y,trustedTypes:j}=n,q=O.prototype,$=v(q,"cloneNode"),V=v(q,"remove"),re=v(q,"nextSibling"),ie=v(q,"childNodes"),ae=v(q,"parentNode");if("function"==typeof N){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let le,ce="";const{implementation:se,createNodeIterator:ue,createDocumentFragment:me,getElementsByTagName:pe}=r,{importNode:fe}=a;let de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof e&&"function"==typeof ae&&se&&void 0!==se.createHTMLDocument;const{MUSTACHE_EXPR:he,ERB_EXPR:ge,TMPLIT_EXPR:Te,DATA_ATTR:ye,ARIA_ATTR:Ee,IS_SCRIPT_OR_DATA:Ae,ATTR_WHITESPACE:_e,CUSTOM_ELEMENT:Se}=Z;let{IS_ALLOWED_URI:be}=Z,Ne=null;const Re=w({},[...L,...C,...x,...k,...U]);let we=null;const Oe=w({},[...z,...P,...H,...F]);let De=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ve=null,Le=null,Ce=!0,xe=!0,Me=!1,ke=!0,Ie=!1,Ue=!0,ze=!1,Pe=!1,He=!1,Fe=!1,Be=!1,We=!1,Ge=!0,Ye=!1,je=!0,Xe=!1,qe={},$e=null;const Ke=w({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Ve=null;const Ze=w({},["audio","video","img","source","image","track"]);let Je=null;const Qe=w({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),et="http://www.w3.org/1998/Math/MathML",tt="http://www.w3.org/2000/svg",nt="http://www.w3.org/1999/xhtml";let ot=nt,rt=!1,it=null;const at=w({},[et,tt,nt],g);let lt=w({},["mi","mo","mn","ms","mtext"]),ct=w({},["annotation-xml"]);const st=w({},["title","style","font","a","script"]);let ut=null;const mt=["application/xhtml+xml","text/html"];let pt=null,ft=null;const dt=r.createElement("form"),ht=function(e){return e instanceof RegExp||e instanceof Function},gt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ft||ft!==e){if(e&&"object"==typeof e||(e={}),e=D(e),ut=-1===mt.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,pt="application/xhtml+xml"===ut?g:h,Ne=_(e,"ALLOWED_TAGS")?w({},e.ALLOWED_TAGS,pt):Re,we=_(e,"ALLOWED_ATTR")?w({},e.ALLOWED_ATTR,pt):Oe,it=_(e,"ALLOWED_NAMESPACES")?w({},e.ALLOWED_NAMESPACES,g):at,Je=_(e,"ADD_URI_SAFE_ATTR")?w(D(Qe),e.ADD_URI_SAFE_ATTR,pt):Qe,Ve=_(e,"ADD_DATA_URI_TAGS")?w(D(Ze),e.ADD_DATA_URI_TAGS,pt):Ze,$e=_(e,"FORBID_CONTENTS")?w({},e.FORBID_CONTENTS,pt):Ke,ve=_(e,"FORBID_TAGS")?w({},e.FORBID_TAGS,pt):{},Le=_(e,"FORBID_ATTR")?w({},e.FORBID_ATTR,pt):{},qe=!!_(e,"USE_PROFILES")&&e.USE_PROFILES,Ce=!1!==e.ALLOW_ARIA_ATTR,xe=!1!==e.ALLOW_DATA_ATTR,Me=e.ALLOW_UNKNOWN_PROTOCOLS||!1,ke=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,Ie=e.SAFE_FOR_TEMPLATES||!1,Ue=!1!==e.SAFE_FOR_XML,ze=e.WHOLE_DOCUMENT||!1,Fe=e.RETURN_DOM||!1,Be=e.RETURN_DOM_FRAGMENT||!1,We=e.RETURN_TRUSTED_TYPE||!1,He=e.FORCE_BODY||!1,Ge=!1!==e.SANITIZE_DOM,Ye=e.SANITIZE_NAMED_PROPS||!1,je=!1!==e.KEEP_CONTENT,Xe=e.IN_PLACE||!1,be=e.ALLOWED_URI_REGEXP||X,ot=e.NAMESPACE||nt,lt=e.MATHML_TEXT_INTEGRATION_POINTS||lt,ct=e.HTML_INTEGRATION_POINTS||ct,De=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(De.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(De.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(De.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),Ie&&(xe=!1),Be&&(Fe=!0),qe&&(Ne=w({},U),we=[],!0===qe.html&&(w(Ne,L),w(we,z)),!0===qe.svg&&(w(Ne,C),w(we,P),w(we,F)),!0===qe.svgFilters&&(w(Ne,x),w(we,P),w(we,F)),!0===qe.mathMl&&(w(Ne,k),w(we,H),w(we,F))),e.ADD_TAGS&&(Ne===Re&&(Ne=D(Ne)),w(Ne,e.ADD_TAGS,pt)),e.ADD_ATTR&&(we===Oe&&(we=D(we)),w(we,e.ADD_ATTR,pt)),e.ADD_URI_SAFE_ATTR&&w(Je,e.ADD_URI_SAFE_ATTR,pt),e.FORBID_CONTENTS&&($e===Ke&&($e=D($e)),w($e,e.FORBID_CONTENTS,pt)),je&&(Ne["#text"]=!0),ze&&w(Ne,["html","head","body"]),Ne.table&&(w(Ne,["tbody"]),delete ve.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');le=e.TRUSTED_TYPES_POLICY,ce=le.createHTML("")}else void 0===le&&(le=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(j,c)),null!==le&&"string"==typeof ce&&(ce=le.createHTML(""));i&&i(e),ft=e}},Tt=w({},[...C,...x,...M]),yt=w({},[...k,...I]),Et=function(e){f(o.removed,{element:e});try{ae(e).removeChild(e)}catch(t){V(e)}},At=function(e,t){try{f(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){f(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e)if(Fe||Be)try{Et(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},_t=function(e){let t=null,n=null;if(He)e=" "+e;else{const t=T(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===ut&&ot===nt&&(e=''+e+"");const o=le?le.createHTML(e):e;if(ot===nt)try{t=(new Y).parseFromString(o,ut)}catch(e){}if(!t||!t.documentElement){t=se.createDocument(ot,"template",null);try{t.documentElement.innerHTML=rt?ce:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),ot===nt?pe.call(t,ze?"html":"body")[0]:ze?t.documentElement:i},St=function(e){return ue.call(e.ownerDocument||e,e,B.SHOW_ELEMENT|B.SHOW_COMMENT|B.SHOW_TEXT|B.SHOW_PROCESSING_INSTRUCTION|B.SHOW_CDATA_SECTION,null)},bt=function(e){return e instanceof G&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof W)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Nt=function(e){return"function"==typeof R&&e instanceof R};function Rt(e,t,n){u(e,(e=>{e.call(o,t,n,ft)}))}const wt=function(e){let t=null;if(Rt(de.beforeSanitizeElements,e,null),bt(e))return Et(e),!0;const n=pt(e.nodeName);if(Rt(de.uponSanitizeElement,e,{tagName:n,allowedTags:Ne}),e.hasChildNodes()&&!Nt(e.firstElementChild)&&S(/<[/\w]/g,e.innerHTML)&&S(/<[/\w]/g,e.textContent))return Et(e),!0;if(e.nodeType===ee)return Et(e),!0;if(Ue&&e.nodeType===te&&S(/<[/\w]/g,e.data))return Et(e),!0;if(!Ne[n]||ve[n]){if(!ve[n]&&Dt(n)){if(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n))return!1;if(De.tagNameCheck instanceof Function&&De.tagNameCheck(n))return!1}if(je&&!$e[n]){const t=ae(e)||e.parentNode,n=ie(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o){const r=$(n[o],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,re(e))}}}return Et(e),!0}return e instanceof O&&!function(e){let t=ae(e);t&&t.tagName||(t={namespaceURI:ot,tagName:"template"});const n=h(e.tagName),o=h(t.tagName);return!!it[e.namespaceURI]&&(e.namespaceURI===tt?t.namespaceURI===nt?"svg"===n:t.namespaceURI===et?"svg"===n&&("annotation-xml"===o||lt[o]):Boolean(Tt[n]):e.namespaceURI===et?t.namespaceURI===nt?"math"===n:t.namespaceURI===tt?"math"===n&&ct[o]:Boolean(yt[n]):e.namespaceURI===nt?!(t.namespaceURI===tt&&!ct[o])&&!(t.namespaceURI===et&&!lt[o])&&!yt[n]&&(st[n]||!Tt[n]):!("application/xhtml+xml"!==ut||!it[e.namespaceURI]))}(e)?(Et(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!S(/<\/no(script|embed|frames)/i,e.innerHTML)?(Ie&&e.nodeType===Q&&(t=e.textContent,u([he,ge,Te],(e=>{t=y(t,e," ")})),e.textContent!==t&&(f(o.removed,{element:e.cloneNode()}),e.textContent=t)),Rt(de.afterSanitizeElements,e,null),!1):(Et(e),!0)},Ot=function(e,t,n){if(Ge&&("id"===t||"name"===t)&&(n in r||n in dt))return!1;if(xe&&!Le[t]&&S(ye,t));else if(Ce&&S(Ee,t));else if(!we[t]||Le[t]){if(!(Dt(e)&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,e)||De.tagNameCheck instanceof Function&&De.tagNameCheck(e))&&(De.attributeNameCheck instanceof RegExp&&S(De.attributeNameCheck,t)||De.attributeNameCheck instanceof Function&&De.attributeNameCheck(t))||"is"===t&&De.allowCustomizedBuiltInElements&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n)||De.tagNameCheck instanceof Function&&De.tagNameCheck(n))))return!1}else if(Je[t]);else if(S(be,y(n,_e,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==E(n,"data:")||!Ve[e]){if(Me&&!S(Ae,y(n,_e,"")));else if(n)return!1}else;return!0},Dt=function(e){return"annotation-xml"!==e&&T(e,Se)},vt=function(e){Rt(de.beforeSanitizeAttributes,e,null);const{attributes:t}=e;if(!t||bt(e))return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:we,forceKeepAttr:void 0};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=pt(a);let m="value"===a?c:A(c);if(n.attrName=s,n.attrValue=m,n.keepAttr=!0,n.forceKeepAttr=void 0,Rt(de.uponSanitizeAttribute,e,n),m=n.attrValue,!Ye||"id"!==s&&"name"!==s||(At(a,e),m="user-content-"+m),Ue&&S(/((--!?|])>)|<\/(style|title)/i,m)){At(a,e);continue}if(n.forceKeepAttr)continue;if(At(a,e),!n.keepAttr)continue;if(!ke&&S(/\/>/i,m)){At(a,e);continue}Ie&&u([he,ge,Te],(e=>{m=y(m,e," ")}));const f=pt(e.nodeName);if(Ot(f,s,m)){if(le&&"object"==typeof j&&"function"==typeof j.getAttributeType)if(l);else switch(j.getAttributeType(f,s)){case"TrustedHTML":m=le.createHTML(m);break;case"TrustedScriptURL":m=le.createScriptURL(m)}try{l?e.setAttributeNS(l,a,m):e.setAttribute(a,m),bt(e)?Et(e):p(o.removed)}catch(e){}}}Rt(de.afterSanitizeAttributes,e,null)},Lt=function e(t){let n=null;const o=St(t);for(Rt(de.beforeSanitizeShadowDOM,t,null);n=o.nextNode();)Rt(de.uponSanitizeShadowNode,n,null),wt(n),vt(n),n.content instanceof s&&e(n.content);Rt(de.afterSanitizeShadowDOM,t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(rt=!e,rt&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Nt(e)){if("function"!=typeof e.toString)throw b("toString is not a function");if("string"!=typeof(e=e.toString()))throw b("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Pe||gt(t),o.removed=[],"string"==typeof e&&(Xe=!1),Xe){if(e.nodeName){const t=pt(e.nodeName);if(!Ne[t]||ve[t])throw b("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof R)n=_t("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===J&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!Fe&&!Ie&&!ze&&-1===e.indexOf("<"))return le&&We?le.createHTML(e):e;if(n=_t(e),!n)return Fe?null:We?ce:""}n&&He&&Et(n.firstChild);const c=St(Xe?e:n);for(;i=c.nextNode();)wt(i),vt(i),i.content instanceof s&&Lt(i.content);if(Xe)return e;if(Fe){if(Be)for(l=me.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(we.shadowroot||we.shadowrootmode)&&(l=fe.call(a,l,!0)),l}let m=ze?n.outerHTML:n.innerHTML;return ze&&Ne["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&S(K,n.ownerDocument.doctype.name)&&(m="\n"+m),Ie&&u([he,ge,Te],(e=>{m=y(m,e," ")})),le&&We?le.createHTML(m):m},o.setConfig=function(){gt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Pe=!0},o.clearConfig=function(){ft=null,Pe=!1},o.isValidAttribute=function(e,t,n){ft||gt({});const o=pt(e),r=pt(t);return Ot(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&f(de[e],t)},o.removeHook=function(e,t){if(void 0!==t){const n=m(de[e],t);return-1===n?void 0:d(de[e],n,1)[0]}return p(de[e])},o.removeHooks=function(e){de[e]=[]},o.removeAllHooks=function(){de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return re}));
+//# sourceMappingURL=purify.min.js.map
diff --git a/app/static/style.css b/app/static/style.css
index 5de5adf..bc53d8c 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -196,6 +196,84 @@ body.modal-open {
font-weight: 600;
}
+/* Верхний ряд: панель управления слева, статистика справа */
+.top-dashboard-row {
+ display: grid;
+ gap: 1rem;
+ margin-bottom: 1rem;
+ align-items: stretch;
+}
+
+@media (min-width: 960px) {
+ .top-dashboard-row {
+ grid-template-columns: 1fr minmax(17rem, 24rem);
+ }
+}
+
+.top-dashboard-row .hero-panel {
+ margin-bottom: 0;
+}
+
+.stats-side-card {
+ display: flex;
+ flex-direction: column;
+}
+
+.stats-side-card h2 {
+ margin-bottom: 0.5rem;
+}
+
+.stats-dl--compact {
+ flex: 1;
+ margin: 0;
+ font-size: 0.88rem;
+}
+
+.create-cluster-card {
+ margin-bottom: 1rem;
+}
+
+/* Форма создания: две колонки, кнопка на всю ширину */
+.create-form-grid {
+ display: grid;
+ gap: 0.75rem 1.5rem;
+ margin-top: 0.25rem;
+}
+
+@media (min-width: 640px) {
+ .create-form-grid {
+ grid-template-columns: 1fr 1fr;
+ align-items: start;
+ }
+}
+
+.create-form-col > label:first-of-type {
+ margin-top: 0;
+}
+
+.create-form-span {
+ grid-column: 1 / -1;
+}
+
+.create-ver-hint {
+ margin: 0.25rem 0 0;
+ font-size: 0.85rem;
+}
+
+.create-form-grid .create-actions {
+ margin-top: 0.25rem;
+}
+
+.create-form-grid .create-actions button {
+ margin-top: 0;
+}
+
+.create-form-col input,
+.create-form-col select {
+ max-width: none;
+ width: 100%;
+}
+
/* Одна карточка: заголовок + описание + строка состояния среды */
.hero-panel {
margin-bottom: 1rem;
@@ -262,6 +340,29 @@ body.modal-open {
margin-top: 0;
}
+/* Журнал kind create в панели прогресса (потоковые строки из API) */
+.job-log-panel-wrap {
+ margin: 0 0 0.65rem;
+}
+.job-log-label {
+ display: block;
+ font-size: 0.8rem;
+ margin-bottom: 0.25rem;
+}
+.job-log-panel {
+ margin: 0;
+ max-height: min(40vh, 280px);
+ overflow: auto;
+ padding: 0.5rem 0.65rem;
+ font-size: 0.78rem;
+ line-height: 1.35;
+ background: rgba(0, 0, 0, 0.2);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
.git-hint {
font-size: 0.85rem;
margin: 0 0 0.65rem;
@@ -336,13 +437,6 @@ body.modal-open {
}
/* Состояние загрузки таблиц */
-/* Пока идёт запрос к API, секция слегка приглушается */
-[data-busy].is-loading {
- opacity: 0.55;
- pointer-events: none;
- transition: opacity 0.2s ease;
-}
-
.job-details {
margin-top: 0.75rem;
border: 1px solid var(--border);
@@ -365,6 +459,16 @@ body.modal-open {
margin-bottom: 0.5rem;
align-items: flex-start;
}
+.modal-dl-wrap {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem 0.75rem;
+ margin: 0 0 0.65rem;
+}
+.modal-dl-hint {
+ font-size: 0.8rem;
+}
.modal-title-text {
margin: 0;
font-size: 1.05rem;
@@ -599,12 +703,113 @@ button.btn-small {
font-size: 0.8rem;
}
-td.actions {
+/* Колонка «Действия»: ширина по содержимому (иконки в ряд) */
+#tbl-clusters th.col-actions,
+#tbl-clusters td.actions {
+ width: 1%;
+ padding: 0.3rem 0.25rem;
+ text-align: center;
+ vertical-align: middle;
+}
+#tbl-clusters td.actions {
white-space: nowrap;
}
-td.actions button {
- margin-top: 0;
- margin-right: 0.25rem;
+.actions-toolbar {
+ display: inline-flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 0.12rem;
+}
+
+/* Обёртка иконки; текст подсказки — в #action-tooltip (JS, fixed), иначе режется overflow таблицы */
+.icon-tooltip-host {
+ position: relative;
+ display: inline-flex;
+ vertical-align: middle;
+}
+
+/* Плавающая подсказка над/под иконкой (полный текст, переносы строк) */
+.action-tooltip-floating {
+ position: fixed;
+ z-index: 400;
+ left: 0;
+ top: 0;
+ max-width: min(22rem, calc(100vw - 20px));
+ max-height: min(70vh, 22rem);
+ overflow-y: auto;
+ padding: 0.55rem 0.7rem;
+ box-sizing: border-box;
+ font-size: 0.8rem;
+ font-weight: 500;
+ line-height: 1.45;
+ text-align: left;
+ white-space: normal;
+ overflow-wrap: break-word;
+ word-break: break-word;
+ hyphens: auto;
+ color: var(--fg);
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
+ pointer-events: none;
+}
+.action-tooltip-floating.hidden {
+ display: none;
+}
+.icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.95rem;
+ height: 1.95rem;
+ padding: 0;
+ margin: 0;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.15);
+ color: var(--fg);
+ cursor: pointer;
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
+}
+.icon-btn:hover:not(:disabled) {
+ background: rgba(59, 130, 246, 0.2);
+ border-color: var(--accent);
+ color: var(--accent);
+}
+.icon-btn:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+.icon-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+.icon-btn svg {
+ width: 1.05rem;
+ height: 1.05rem;
+ flex-shrink: 0;
+}
+.icon-btn--secondary {
+ background: transparent;
+}
+.icon-btn--danger {
+ color: #f87171;
+ border-color: rgba(185, 28, 28, 0.5);
+ background: rgba(185, 28, 28, 0.08);
+}
+.icon-btn--danger:hover:not(:disabled) {
+ color: #fca5a5;
+ border-color: #ef4444;
+ background: rgba(239, 68, 68, 0.14);
+}
+@media (prefers-color-scheme: light) {
+ .icon-btn {
+ background: rgba(0, 0, 0, 0.04);
+ }
+ .icon-btn--danger {
+ color: #b91c1c;
+ }
}
/* Модальное окно «Состояние кластера» */
@@ -622,6 +827,28 @@ td.actions button {
.modal-overlay.hidden {
display: none;
}
+/* Поверх модалки «Состояние» (z-index 100), если когда-либо понадобится стек */
+.confirm-modal-overlay {
+ z-index: 150;
+}
+.modal-box--confirm {
+ max-width: 24rem;
+}
+.confirm-modal-title {
+ margin: 0 0 0.5rem;
+ font-size: 1.05rem;
+}
+.confirm-modal-message {
+ margin: 0 0 0.25rem;
+ white-space: pre-wrap;
+}
+.confirm-modal-actions {
+ margin-top: 1rem;
+ justify-content: flex-end;
+}
+.confirm-modal-actions button {
+ margin-top: 0;
+}
.modal-box {
background: var(--card);
border: 1px solid var(--border);
@@ -659,3 +886,127 @@ a.btn-danger {
button.btn-danger:hover {
filter: brightness(1.12);
}
+
+/* Страница «Документация»: README.md → HTML (Markdown) */
+.readme-doc-card {
+ max-width: 52rem;
+ margin: 0 auto 2rem;
+}
+.readme-doc-toolbar {
+ align-items: center;
+ margin-bottom: 0.35rem;
+}
+.readme-doc-toolbar .page-title {
+ margin: 0;
+}
+.readme-doc-lead {
+ margin: 0 0 1rem;
+ font-size: 0.88rem;
+}
+.readme-doc-loading {
+ margin: 0 0 0.75rem;
+ font-size: 0.9rem;
+}
+.readme-doc.markdown-body {
+ font-size: 0.95rem;
+ line-height: 1.55;
+}
+.readme-doc.markdown-body h1,
+.readme-doc.markdown-body h2,
+.readme-doc.markdown-body h3,
+.readme-doc.markdown-body h4 {
+ margin: 1.25rem 0 0.5rem;
+ line-height: 1.25;
+ font-weight: 650;
+}
+.readme-doc.markdown-body h1 {
+ font-size: 1.45rem;
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 0.35rem;
+}
+.readme-doc.markdown-body h2 {
+ font-size: 1.2rem;
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 0.25rem;
+}
+.readme-doc.markdown-body h3 {
+ font-size: 1.05rem;
+}
+.readme-doc.markdown-body p {
+ margin: 0.5rem 0;
+}
+.readme-doc.markdown-body ul,
+.readme-doc.markdown-body ol {
+ margin: 0.5rem 0;
+ padding-left: 1.35rem;
+}
+.readme-doc.markdown-body li {
+ margin: 0.2rem 0;
+}
+.readme-doc.markdown-body a {
+ color: var(--accent);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+.readme-doc.markdown-body a:hover {
+ filter: brightness(1.15);
+}
+.readme-doc.markdown-body code {
+ font-size: 0.88em;
+ padding: 0.12em 0.35em;
+ border-radius: 4px;
+ background: rgba(0, 0, 0, 0.22);
+ border: 1px solid var(--border);
+}
+.readme-doc.markdown-body pre {
+ margin: 0.65rem 0;
+ padding: 0.65rem 0.85rem;
+ overflow-x: auto;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: rgba(0, 0, 0, 0.25);
+ font-size: 0.82rem;
+ line-height: 1.4;
+}
+.readme-doc.markdown-body pre code {
+ padding: 0;
+ border: none;
+ background: transparent;
+ font-size: inherit;
+}
+.readme-doc.markdown-body blockquote {
+ margin: 0.65rem 0;
+ padding: 0.35rem 0.75rem;
+ border-left: 4px solid var(--accent);
+ background: rgba(59, 130, 246, 0.08);
+ color: var(--muted);
+}
+.readme-doc.markdown-body table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0.75rem 0;
+ font-size: 0.88rem;
+}
+.readme-doc.markdown-body th,
+.readme-doc.markdown-body td {
+ border: 1px solid var(--border);
+ padding: 0.4rem 0.55rem;
+ text-align: left;
+}
+.readme-doc.markdown-body th {
+ background: rgba(0, 0, 0, 0.15);
+ font-weight: 600;
+}
+.readme-doc.markdown-body hr {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 1.25rem 0;
+}
+@media (prefers-color-scheme: light) {
+ .readme-doc.markdown-body code {
+ background: rgba(0, 0, 0, 0.06);
+ }
+ .readme-doc.markdown-body pre {
+ background: rgba(0, 0, 0, 0.04);
+ }
+}
diff --git a/app/templates/base.html b/app/templates/base.html
index 2598713..d318138 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -21,10 +21,10 @@