UI: автообновление, прогресс, отмена; порт 8080; меню-пилюли и отдельные окна
- Порт хоста по умолчанию 8080 (Chrome ERR_UNSAFE_PORT на 6000); compose, setup, config, README.
- Дашборд: одна hero-карточка, прогресс создания, POST /jobs/{id}/cancel, JobView progress_*.
- job_store: отмена и прогресс (thread-safe); cluster_lifecycle этапы и откат.
- Навигация: стили nav-pill; Swagger/ReDoc/Health через window.open.
- main.py: TemplateResponse(request, …) для Starlette.
- Документация: README, app/docs (api_routes, README); Makefile ps; .gitignore clusters.
This commit is contained in:
@@ -24,7 +24,7 @@ from core.cluster_lifecycle import (
|
||||
read_meta_json,
|
||||
validate_cluster_name,
|
||||
)
|
||||
from core.job_store import job_store
|
||||
from core.job_store import JobRecord, end_job_tracking, 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 (
|
||||
@@ -41,6 +41,25 @@ logger = logging.getLogger("kind_k8s.api.clusters")
|
||||
router = APIRouter(tags=["clusters"])
|
||||
|
||||
|
||||
def _record_to_job_view(rec: JobRecord) -> JobView:
|
||||
"""JobRecord → JobView с полями прогресса из потокобезопасного снимка."""
|
||||
prog = get_progress_sync(rec.job_id)
|
||||
stage, pct = (None, None)
|
||||
if prog is not None:
|
||||
stage, pct = prog[0], prog[1]
|
||||
return JobView(
|
||||
job_id=rec.job_id,
|
||||
kind=rec.kind,
|
||||
status=rec.status,
|
||||
cluster_name=rec.cluster_name,
|
||||
created_at_utc=rec.created_at_utc,
|
||||
message=rec.message,
|
||||
result=rec.result,
|
||||
progress_stage=stage,
|
||||
progress_percent=pct,
|
||||
)
|
||||
|
||||
|
||||
def _stats_sync() -> StatsResponse:
|
||||
"""Собрать статистику (синхронно; вызывать из thread при необходимости)."""
|
||||
kind_names = list_registered_kind_clusters()
|
||||
@@ -86,18 +105,7 @@ async def get_stats() -> StatsResponse:
|
||||
async def list_jobs(limit: int = Query(30, ge=1, le=200, description="Сколько последних заданий")) -> list[JobView]:
|
||||
"""История создания кластеров (в памяти процесса; после перезапуска контейнера пусто)."""
|
||||
items = job_store.snapshot_recent_sorted(limit=limit)
|
||||
return [
|
||||
JobView(
|
||||
job_id=r.job_id,
|
||||
kind=r.kind,
|
||||
status=r.status,
|
||||
cluster_name=r.cluster_name,
|
||||
created_at_utc=r.created_at_utc,
|
||||
message=r.message,
|
||||
result=r.result,
|
||||
)
|
||||
for r in items
|
||||
]
|
||||
return [_record_to_job_view(r) for r in items]
|
||||
|
||||
|
||||
@router.get("/clusters", response_model=list[ClusterSummary], summary="Список кластеров")
|
||||
@@ -187,36 +195,44 @@ async def get_cluster(name: str) -> dict[str, object]:
|
||||
|
||||
|
||||
async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
|
||||
async with kind_cluster_lock:
|
||||
await job_store.set_running(job_id)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
create_cluster_non_interactive,
|
||||
name=body.name.strip(),
|
||||
kubernetes_version_tag=body.kubernetes_version.strip(),
|
||||
workers=body.workers,
|
||||
)
|
||||
except KindClusterError as e:
|
||||
await job_store.set_failed(job_id, str(e))
|
||||
logger.warning("create 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("create job %s: непредвиденная ошибка", job_id)
|
||||
return
|
||||
try:
|
||||
async with kind_cluster_lock:
|
||||
await job_store.set_running(job_id)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
create_cluster_non_interactive,
|
||||
name=body.name.strip(),
|
||||
kubernetes_version_tag=body.kubernetes_version.strip(),
|
||||
workers=body.workers,
|
||||
job_id=job_id,
|
||||
)
|
||||
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("create 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("create 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("create job %s: успех, кластер %s", job_id, result.cluster_name)
|
||||
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("create job %s: успех, кластер %s", job_id, result.cluster_name)
|
||||
finally:
|
||||
end_job_tracking(job_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -263,18 +279,35 @@ async def delete_cluster(name: str) -> dict[str, object]:
|
||||
return {"name": name, "kind_delete_ok": kind_ok, "summary": summary}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/jobs/{job_id}/cancel",
|
||||
summary="Запросить отмену создания кластера",
|
||||
responses={400: {"description": "Задание уже завершено"}, 404: {"description": "Нет задания"}},
|
||||
)
|
||||
async def cancel_create_job(job_id: str) -> dict[str, object]:
|
||||
"""
|
||||
Установить флаг отмены. Этап ``kind create cluster`` нельзя прервать до его завершения;
|
||||
после него отмена удалит кластер и данные (если успели создать).
|
||||
"""
|
||||
rec = await job_store.get(job_id)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail="Задание не найдено")
|
||||
if rec.status not in ("queued", "running"):
|
||||
raise HTTPException(status_code=400, detail="Задание уже завершено; отмена невозможна")
|
||||
if not request_cancel_sync(job_id):
|
||||
raise HTTPException(status_code=404, detail="Задание не найдено")
|
||||
logger.info("Принят запрос отмены задания %s", job_id)
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"cancel_requested": True,
|
||||
"message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=JobView, summary="Статус одного задания")
|
||||
async def get_job(job_id: str) -> JobView:
|
||||
"""Узнать состояние фонового создания кластера."""
|
||||
rec = await job_store.get(job_id)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail="Задание не найдено")
|
||||
return JobView(
|
||||
job_id=rec.job_id,
|
||||
kind=rec.kind,
|
||||
status=rec.status,
|
||||
cluster_name=rec.cluster_name,
|
||||
created_at_utc=rec.created_at_utc,
|
||||
message=rec.message,
|
||||
result=rec.result,
|
||||
)
|
||||
return _record_to_job_view(rec)
|
||||
|
||||
@@ -24,6 +24,22 @@ from kubeconfig_patch import patch_kubeconfig_server_for_host, should_patch_afte
|
||||
|
||||
logger = logging.getLogger("kind_k8s.cluster_lifecycle")
|
||||
|
||||
|
||||
def _rollback_after_cancel(*, cluster_name: str, out_dir: Path) -> None:
|
||||
"""Удалить кластер kind и каталог данных после запроса отмены (best-effort)."""
|
||||
logger.info("Откат после отмены: kind delete «%s»", cluster_name)
|
||||
subprocess.run(
|
||||
["kind", "delete", "cluster", "--name", cluster_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if out_dir.is_dir():
|
||||
try:
|
||||
shutil.rmtree(out_dir)
|
||||
logger.info("Удалён каталог %s", out_dir)
|
||||
except OSError as e:
|
||||
logger.warning("Не удалось удалить %s: %s", out_dir, e)
|
||||
|
||||
# Имя кластера: поддомен DNS (RFC 1123)
|
||||
_NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
|
||||
|
||||
@@ -160,12 +176,24 @@ def create_cluster_non_interactive(
|
||||
name: str,
|
||||
kubernetes_version_tag: str,
|
||||
workers: int,
|
||||
job_id: str | None = None,
|
||||
) -> CreateClusterResult:
|
||||
"""
|
||||
Создать кластер kind без диалогов.
|
||||
|
||||
``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``.
|
||||
|
||||
``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``).
|
||||
"""
|
||||
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)
|
||||
|
||||
def _cancelled() -> bool:
|
||||
return bool(job_id and _job_store.is_cancelled_sync(job_id))
|
||||
|
||||
if not shutil.which("kind"):
|
||||
raise KindClusterError("Не найден бинарник kind в PATH.", exit_code=127)
|
||||
|
||||
@@ -193,19 +221,40 @@ def create_cluster_non_interactive(
|
||||
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)
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Создание отменено пользователем")
|
||||
|
||||
logger.info("Создание кластера «%s», образ %s, workers=%s", name, node_image, workers)
|
||||
_progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28)
|
||||
_run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)])
|
||||
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Создание отменено пользователем")
|
||||
|
||||
_progress("Сохранение kubeconfig", 58)
|
||||
kube = _run_capture_checked(["kind", "get", "kubeconfig", "--name", name])
|
||||
kube_path.write_text(kube, encoding="utf-8")
|
||||
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Создание отменено пользователем")
|
||||
|
||||
patched = False
|
||||
if should_patch_after_create():
|
||||
_progress("Патч kubeconfig для доступа к API с хоста", 72)
|
||||
patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path)
|
||||
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Создание отменено пользователем")
|
||||
|
||||
nodes_ready: bool | None = None
|
||||
nodes_msg: str | None = None
|
||||
if _wait_nodes_enabled():
|
||||
_progress("Ожидание готовности нод (kubectl wait …)", 82)
|
||||
ok, msg = wait_nodes_ready(kubeconfig_path=kube_path)
|
||||
nodes_ready = ok
|
||||
nodes_msg = msg
|
||||
@@ -229,6 +278,8 @@ def create_cluster_non_interactive(
|
||||
}
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
_progress("Финализация", 95)
|
||||
|
||||
return CreateClusterResult(
|
||||
cluster_name=name,
|
||||
ver_tag=ver_tag,
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
kind_k8s_web_host: str = Field(default="0.0.0.0", validation_alias="KIND_K8S_WEB_HOST")
|
||||
kind_k8s_web_port: int = Field(default=6000, validation_alias="KIND_K8S_WEB_PORT")
|
||||
# Согласовано с дефолтом compose на хосте (8080); в контейнере процесс слушает 6000 через run_uvicorn.sh.
|
||||
kind_k8s_web_port: int = Field(default=8080, validation_alias="KIND_K8S_WEB_PORT")
|
||||
|
||||
# Заголовок в OpenAPI / HTML; пустая строка из compose не должна ломать FastAPI.
|
||||
app_title: str = Field(default=_DEFAULT_TITLE, validation_alias="KIND_K8S_APP_TITLE")
|
||||
|
||||
+66
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
При перезапуске контейнера история заданий обнуляется — это ожидаемо для dev-среды.
|
||||
|
||||
Потокобезопасные флаги отмены и прогресс (для worker-thread) — через ``threading.Lock``.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
@@ -10,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -20,7 +23,58 @@ logger = logging.getLogger("kind_k8s.job_store")
|
||||
# Лимит записей в памяти (dev-инструмент; старые задания вытесняются)
|
||||
_MAX_JOBS = 200
|
||||
|
||||
JobStatus = Literal["queued", "running", "success", "failed"]
|
||||
JobStatus = Literal["queued", "running", "success", "failed", "cancelled"]
|
||||
|
||||
# --- Синхронное сопровождение задания (worker-thread и HTTP отмена) ---
|
||||
_thread_lock = threading.Lock()
|
||||
_cancel_events: dict[str, threading.Event] = {}
|
||||
_progress: dict[str, tuple[str, int]] = {}
|
||||
|
||||
|
||||
def begin_job_tracking(job_id: str) -> None:
|
||||
"""Зарегистрировать отмену/прогресс для нового job_id (вызывать при создании задания)."""
|
||||
with _thread_lock:
|
||||
_cancel_events[job_id] = threading.Event()
|
||||
_progress[job_id] = ("В очереди", 0)
|
||||
|
||||
|
||||
def end_job_tracking(job_id: str) -> None:
|
||||
"""Очистить служебные структуры после завершения задания."""
|
||||
with _thread_lock:
|
||||
_cancel_events.pop(job_id, None)
|
||||
_progress.pop(job_id, None)
|
||||
|
||||
|
||||
def set_progress_sync(job_id: str, stage: str, percent: int) -> None:
|
||||
"""Обновить текст этапа и процент (0–100) из worker-thread."""
|
||||
pct = max(0, min(100, int(percent)))
|
||||
with _thread_lock:
|
||||
if job_id in _progress:
|
||||
_progress[job_id] = (stage, pct)
|
||||
|
||||
|
||||
def get_progress_sync(job_id: str) -> tuple[str, int] | None:
|
||||
"""Снимок прогресса для ответа API."""
|
||||
with _thread_lock:
|
||||
return _progress.get(job_id)
|
||||
|
||||
|
||||
def request_cancel_sync(job_id: str) -> bool:
|
||||
"""Запросить отмену. Вернуть False, если job_id не отслеживается."""
|
||||
with _thread_lock:
|
||||
ev = _cancel_events.get(job_id)
|
||||
if ev is None:
|
||||
return False
|
||||
ev.set()
|
||||
logger.info("Запрошена отмена задания %s", job_id)
|
||||
return True
|
||||
|
||||
|
||||
def is_cancelled_sync(job_id: str) -> bool:
|
||||
"""Проверка из worker-thread между этапами создания кластера."""
|
||||
with _thread_lock:
|
||||
ev = _cancel_events.get(job_id)
|
||||
return bool(ev and ev.is_set())
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -58,8 +112,10 @@ class JobStore:
|
||||
self._jobs[jid] = rec
|
||||
while len(self._jobs) > _MAX_JOBS:
|
||||
oldest_id = min(self._jobs, key=lambda k: self._jobs[k].created_at_utc)
|
||||
end_job_tracking(oldest_id)
|
||||
del self._jobs[oldest_id]
|
||||
logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id)
|
||||
begin_job_tracking(jid)
|
||||
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
|
||||
return rec
|
||||
|
||||
@@ -68,6 +124,7 @@ class JobStore:
|
||||
if job_id in self._jobs:
|
||||
self._jobs[job_id].status = "running"
|
||||
self._jobs[job_id].message = None
|
||||
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:
|
||||
async with self._lock:
|
||||
@@ -75,6 +132,7 @@ class JobStore:
|
||||
self._jobs[job_id].status = "success"
|
||||
self._jobs[job_id].result = result
|
||||
self._jobs[job_id].message = message
|
||||
set_progress_sync(job_id, "Готово", 100)
|
||||
|
||||
async def set_failed(self, job_id: str, message: str) -> None:
|
||||
async with self._lock:
|
||||
@@ -83,6 +141,13 @@ class JobStore:
|
||||
self._jobs[job_id].message = message
|
||||
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
|
||||
|
||||
async def set_cancelled(self, job_id: str, message: str = "Создание отменено пользователем") -> None:
|
||||
async with self._lock:
|
||||
if job_id in self._jobs:
|
||||
self._jobs[job_id].status = "cancelled"
|
||||
self._jobs[job_id].message = message
|
||||
logger.info("Задание %s отменено: %s", job_id, message)
|
||||
|
||||
async def get(self, job_id: str) -> JobRecord | None:
|
||||
async with self._lock:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
+1
-1
@@ -6,6 +6,6 @@
|
||||
|------|------------|
|
||||
| [api_routes.md](api_routes.md) | Полное описание REST API `/api/v1/*` с примерами JSON (ориентир для фронтенда и клиентов). |
|
||||
|
||||
После запуска сервиса доступны интерактивно: **Swagger** — `/docs`, **ReDoc** — `/redoc` (тот же порт, что и веб-UI).
|
||||
После запуска: **Swagger** — `/docs`, **ReDoc** — `/redoc`, **Health** — `/api/v1/health` (тот же порт, что и UI). С дашборда эти ссылки открываются в **отдельном окне** браузера.
|
||||
|
||||
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
|
||||
|
||||
+31
-4
@@ -7,15 +7,18 @@
|
||||
|
||||
| Способ | URL / путь |
|
||||
|--------|------------|
|
||||
| Swagger UI (OpenAPI) | `http://127.0.0.1:<порт>/docs` (порт по умолчанию **6000**, см. `KIND_K8S_WEB_PORT`) |
|
||||
| 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` |
|
||||
| Этот файл | `app/docs/api_routes.md` в репозитории |
|
||||
|
||||
С **веб-панели** (`GET /`) пункты меню **Swagger**, **ReDoc** и **Health** вызывают `window.open` с именами окон `kind_swagger`, `kind_redoc`, `kind_health` (отдельное окно, повторный клик переиспользует то же окно).
|
||||
|
||||
## Веб-интерфейс и статика (не JSON)
|
||||
|
||||
| Маршрут | Описание |
|
||||
|---------|----------|
|
||||
| `GET /` | HTML-панель: статус среды (kind, kubectl, Docker/Podman), статистика, форма создания кластера, таблицы кластеров и заданий, модальное окно «узлы / поды», ссылки на API. |
|
||||
| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, отмена), таблицы (автообновление ~3,5 с), модалка узлов/подов; в шапке — меню-пилюли и отдельные окна для Swagger / ReDoc / Health. |
|
||||
| `GET /ui` | Редирект **307** на `/` (удобный ярлык). |
|
||||
| `GET /static/…` | CSS (`style.css`), скрипт панели (`js/dashboard.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). |
|
||||
|
||||
@@ -37,13 +40,16 @@
|
||||
| GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) |
|
||||
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
|
||||
| GET | `/api/v1/jobs` | Последние задания создания |
|
||||
| GET | `/api/v1/jobs/{job_id}` | Статус одного задания |
|
||||
| GET | `/api/v1/jobs/{job_id}` | Статус одного задания (включая `progress_stage`, `progress_percent`) |
|
||||
| POST | `/api/v1/jobs/{job_id}/cancel` | Запросить отмену создания (между этапами; `kind create` до конца не прерывается) |
|
||||
|
||||
### Фоновые задания (jobs)
|
||||
|
||||
- Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется.
|
||||
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
|
||||
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
|
||||
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания.
|
||||
- Статус **`cancelled`** — пользователь запросил отмену (`POST .../cancel`); этап `kind create cluster` до завершения не прерывается.
|
||||
|
||||
---
|
||||
|
||||
@@ -170,13 +176,34 @@
|
||||
"cluster_name": "dev",
|
||||
"created_at_utc": "2026-04-04T12:00:00+00:00",
|
||||
"message": "Кластер создан",
|
||||
"result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4" }
|
||||
"result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4" },
|
||||
"progress_stage": null,
|
||||
"progress_percent": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/jobs/{job_id}/cancel
|
||||
|
||||
Запрос отмены создания кластера. Пока задание в статусе `queued` или `running`, между этапами выполняется проверка флага; после уже запущенного `kind create cluster` нужно дождаться окончания этого шага.
|
||||
|
||||
**Пример ответа 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"job_id": "a1b2…",
|
||||
"cancel_requested": true,
|
||||
"message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага"
|
||||
}
|
||||
```
|
||||
|
||||
**Ошибка 400:** задание уже завершено.
|
||||
**Ошибка 404:** неизвестный `job_id`.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/clusters/{name}/kubeconfig
|
||||
|
||||
Скачать файл `kubeconfig` (ответ — тело файла, `Content-Disposition` с именем `kubeconfig-{name}.yaml`).
|
||||
|
||||
+6
-5
@@ -1,4 +1,6 @@
|
||||
"""Веб-интерфейс и REST API для управления локальными кластерами kind (порт по умолчанию 6000).
|
||||
"""Веб-интерфейс и REST API для управления локальными кластерами kind.
|
||||
|
||||
В контейнере uvicorn слушает порт 6000; на хост публикация по умолчанию 8080 (``KIND_K8S_WEB_PORT``), т.к. 6000 на хосте блокируется Chrome (ERR_UNSAFE_PORT).
|
||||
|
||||
Запуск в контейнере: ``python3 -m uvicorn main:app --host 0.0.0.0 --port 6000`` из каталога ``/opt/kind-k8s/app``
|
||||
или через ``make docker up`` / ``make podman up``.
|
||||
@@ -65,12 +67,11 @@ async def dashboard(request: Request) -> HTMLResponse:
|
||||
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||||
status_code=500,
|
||||
)
|
||||
# Starlette ≥0.37: первым аргументом обязателен Request (иначе dict уйдёт в get_template → unhashable type).
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"app_title": settings.app_title,
|
||||
},
|
||||
{"app_title": settings.app_title},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -36,11 +36,13 @@ class JobView(BaseModel):
|
||||
|
||||
job_id: str
|
||||
kind: str
|
||||
status: Literal["queued", "running", "success", "failed"]
|
||||
status: Literal["queued", "running", "success", "failed", "cancelled"]
|
||||
cluster_name: str | None
|
||||
created_at_utc: str
|
||||
message: str | None = None
|
||||
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")
|
||||
|
||||
|
||||
class ClusterSummary(BaseModel):
|
||||
|
||||
+99
-50
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Панель управления кластерами kind (REST /api/v1).
|
||||
* Автообновление списков и health; прогресс и отмена создания кластера.
|
||||
*
|
||||
* Автор: Сергей Антропов
|
||||
* Сайт: https://devops.org.ru
|
||||
@@ -10,11 +11,18 @@
|
||||
const body = document.body;
|
||||
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
|
||||
|
||||
/** Интервал опроса списков и среды (мс) */
|
||||
var AUTO_REFRESH_MS = 3500;
|
||||
/** Интервал опроса задания создания (мс) */
|
||||
var JOB_POLL_MS = 1500;
|
||||
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
let autoTimer = null;
|
||||
var autoTimer = null;
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
let pollTimer = null;
|
||||
let createInProgress = false;
|
||||
var pollTimer = null;
|
||||
var createInProgress = false;
|
||||
/** @type {string | null} */
|
||||
var currentPollJobId = null;
|
||||
|
||||
function formatApiError(data, fallback) {
|
||||
if (!data) return fallback;
|
||||
@@ -37,10 +45,10 @@
|
||||
const url = path.startsWith("http") ? path : API + path;
|
||||
const r = await fetch(url, opts);
|
||||
const text = await r.text();
|
||||
let data;
|
||||
var data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
} catch (e) {
|
||||
data = { raw: text };
|
||||
}
|
||||
if (!r.ok) {
|
||||
@@ -81,6 +89,16 @@
|
||||
el.classList.toggle("is-loading", busy);
|
||||
}
|
||||
|
||||
function setStatusBannerClass(ok, degraded) {
|
||||
const el = document.getElementById("status-banner");
|
||||
if (!el) return;
|
||||
var cls = "hero-panel-status muted ";
|
||||
if (ok) cls += "ok";
|
||||
else if (degraded) cls += "degraded";
|
||||
else cls += "err";
|
||||
el.className = cls;
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
const el = document.getElementById("status-banner");
|
||||
if (!el) return;
|
||||
@@ -88,8 +106,8 @@
|
||||
const h = await api("/health");
|
||||
const ok =
|
||||
h.status === "ok" && h.container_engine_ok && h.kind_in_path && h.kubectl_in_path;
|
||||
el.className = "status-banner " + (ok ? "ok" : "degraded");
|
||||
let lines = "<strong>Среда:</strong> ";
|
||||
setStatusBannerClass(ok, !ok);
|
||||
var lines = "<strong>Среда:</strong> ";
|
||||
lines += escapeHtml(String(h.container_cli || "?"));
|
||||
lines += " → " + (h.container_engine_ok ? "API OK" : "API недоступен");
|
||||
lines += " · kind: " + (h.kind_in_path ? "да" : "нет");
|
||||
@@ -102,7 +120,7 @@
|
||||
}
|
||||
el.innerHTML = lines;
|
||||
} catch (e) {
|
||||
el.className = "status-banner err";
|
||||
setStatusBannerClass(false, false);
|
||||
el.textContent = "Не удалось запросить health: " + e.message;
|
||||
}
|
||||
}
|
||||
@@ -161,7 +179,7 @@
|
||||
sel.onchange = function () {
|
||||
if (sel.value) verInput.value = sel.value.replace(/^v/, "");
|
||||
};
|
||||
} catch {
|
||||
} catch (e) {
|
||||
sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>";
|
||||
}
|
||||
}
|
||||
@@ -170,6 +188,7 @@
|
||||
if (status === "success") return "badge badge-ok";
|
||||
if (status === "failed") return "badge badge-err";
|
||||
if (status === "running") return "badge badge-run";
|
||||
if (status === "cancelled") return "badge badge-cancelled";
|
||||
return "badge";
|
||||
}
|
||||
|
||||
@@ -253,6 +272,10 @@
|
||||
rows.forEach(function (j) {
|
||||
const tr = document.createElement("tr");
|
||||
const st = escapeHtml(j.status || "");
|
||||
var cellMsg = (j.message || "").slice(0, 160);
|
||||
if ((j.status === "running" || j.status === "queued") && j.progress_stage) {
|
||||
cellMsg = j.progress_stage + (j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
|
||||
}
|
||||
tr.innerHTML =
|
||||
"<td><time datetime=\"" +
|
||||
escapeHtml(j.created_at_utc || "") +
|
||||
@@ -268,7 +291,7 @@
|
||||
st +
|
||||
"</span></td>" +
|
||||
"<td class=\"jobs-msg-cell\">" +
|
||||
escapeHtml((j.message || "").slice(0, 160)) +
|
||||
escapeHtml(cellMsg) +
|
||||
"</td>";
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
@@ -348,38 +371,80 @@
|
||||
});
|
||||
}
|
||||
|
||||
function showCreateProgress(show) {
|
||||
const wrap = document.getElementById("create-progress-wrap");
|
||||
if (!wrap) return;
|
||||
wrap.classList.toggle("hidden", !show);
|
||||
wrap.setAttribute("aria-hidden", show ? "false" : "true");
|
||||
}
|
||||
|
||||
function updateCreateProgressFromJob(j) {
|
||||
const bar = document.getElementById("create-progress-bar");
|
||||
const track = document.getElementById("create-progress-track");
|
||||
const label = document.getElementById("create-progress-label");
|
||||
var pct = j.progress_percent != null ? Number(j.progress_percent) : 0;
|
||||
if (isNaN(pct)) pct = 0;
|
||||
pct = Math.max(0, Math.min(100, pct));
|
||||
var stage = j.progress_stage || (j.status === "queued" ? "В очереди…" : "…");
|
||||
if (bar) bar.style.width = pct + "%";
|
||||
if (track) {
|
||||
track.setAttribute("aria-valuenow", String(pct));
|
||||
}
|
||||
if (label) label.textContent = stage;
|
||||
}
|
||||
|
||||
function stopPollJob() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
currentPollJobId = null;
|
||||
createInProgress = false;
|
||||
setCreateFormDisabled(false);
|
||||
showCreateProgress(false);
|
||||
}
|
||||
|
||||
async function requestCancelJob() {
|
||||
if (!currentPollJobId) return;
|
||||
try {
|
||||
await api("/jobs/" + encodeURIComponent(currentPollJobId) + "/cancel", { method: "POST" });
|
||||
showToast("Запрос отмены отправлен (между этапами)", false);
|
||||
} catch (e) {
|
||||
showToast(e.message || "Не удалось отменить", true);
|
||||
}
|
||||
}
|
||||
|
||||
function pollJob(jobId) {
|
||||
const pre = document.getElementById("job-status");
|
||||
const details = document.getElementById("job-details");
|
||||
const msg = document.getElementById("create-msg");
|
||||
if (pre) pre.classList.add("hidden");
|
||||
if (details) {
|
||||
details.classList.remove("hidden");
|
||||
details.open = true;
|
||||
details.open = false;
|
||||
}
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
createInProgress = true;
|
||||
currentPollJobId = jobId;
|
||||
setCreateFormDisabled(true);
|
||||
showCreateProgress(true);
|
||||
updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" });
|
||||
|
||||
const tick = async function () {
|
||||
try {
|
||||
const j = await api("/jobs/" + jobId);
|
||||
const preEl = document.getElementById("job-json");
|
||||
if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
|
||||
if (j.status === "success" || j.status === "failed") {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
createInProgress = false;
|
||||
setCreateFormDisabled(false);
|
||||
updateCreateProgressFromJob(j);
|
||||
|
||||
if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
|
||||
stopPollJob();
|
||||
if (msg) {
|
||||
msg.textContent =
|
||||
j.status === "success" ? "Кластер создан." : "Ошибка: " + (j.message || "");
|
||||
}
|
||||
if (j.status === "success") {
|
||||
showToast("Кластер создан", false);
|
||||
} else {
|
||||
showToast(j.message || "Ошибка создания", true);
|
||||
if (j.status === "success") msg.textContent = "Кластер создан.";
|
||||
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);
|
||||
await loadClusters();
|
||||
await loadStats();
|
||||
await loadJobs();
|
||||
@@ -389,10 +454,10 @@
|
||||
}
|
||||
};
|
||||
tick();
|
||||
pollTimer = setInterval(tick, 2000);
|
||||
pollTimer = setInterval(tick, JOB_POLL_MS);
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
function refreshLists() {
|
||||
loadHealth();
|
||||
loadStats();
|
||||
loadClusters();
|
||||
@@ -430,17 +495,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
const bStats = document.getElementById("btn-refresh-stats");
|
||||
if (bStats) bStats.addEventListener("click", function () { loadHealth(); loadStats(); });
|
||||
|
||||
const bList = document.getElementById("btn-refresh-list");
|
||||
if (bList) bList.addEventListener("click", loadClusters);
|
||||
|
||||
const bJobs = document.getElementById("btn-refresh-jobs");
|
||||
if (bJobs) bJobs.addEventListener("click", loadJobs);
|
||||
|
||||
const bAll = document.getElementById("btn-refresh-all");
|
||||
if (bAll) bAll.addEventListener("click", refreshAll);
|
||||
const btnCancel = document.getElementById("btn-cancel-create");
|
||||
if (btnCancel) {
|
||||
btnCancel.addEventListener("click", function () {
|
||||
requestCancelJob();
|
||||
});
|
||||
}
|
||||
|
||||
const mClose = document.getElementById("modal-close");
|
||||
if (mClose) mClose.addEventListener("click", closeModal);
|
||||
@@ -456,18 +516,7 @@
|
||||
if (ev.key === "Escape") closeModal();
|
||||
});
|
||||
|
||||
const auto = document.getElementById("auto-refresh");
|
||||
if (auto) {
|
||||
auto.addEventListener("change", function (ev) {
|
||||
if (autoTimer) {
|
||||
clearInterval(autoTimer);
|
||||
autoTimer = null;
|
||||
}
|
||||
if (ev.target.checked) {
|
||||
autoTimer = setInterval(refreshAll, 15000);
|
||||
}
|
||||
});
|
||||
}
|
||||
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
|
||||
|
||||
loadHealth();
|
||||
loadStats();
|
||||
|
||||
+160
-3
@@ -88,14 +88,80 @@ body.modal-open {
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 1rem;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.nav-link {
|
||||
|
||||
/* Пункты меню: компактные «пилюли» */
|
||||
.nav-link.nav-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.42rem 0.95rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: var(--fg);
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.nav-link.nav-pill:hover {
|
||||
text-decoration: none;
|
||||
border-color: var(--accent);
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
color: var(--accent);
|
||||
box-shadow: 0 2px 12px rgba(59, 130, 246, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-link.nav-pill:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Текущее приложение — акцентная пилюля */
|
||||
.nav-link.nav-pill--home {
|
||||
background: linear-gradient(145deg, rgba(59, 130, 246, 0.28), rgba(59, 130, 246, 0.1));
|
||||
border-color: rgba(59, 130, 246, 0.55);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.nav-link.nav-pill--home {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link.nav-pill--home:hover {
|
||||
color: var(--accent);
|
||||
background: linear-gradient(145deg, rgba(59, 130, 246, 0.35), rgba(59, 130, 246, 0.15));
|
||||
}
|
||||
|
||||
/* Внешние окна: иконка «новое окно» */
|
||||
.nav-link.nav-pill--ext::after {
|
||||
content: "↗";
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.72em;
|
||||
opacity: 0.72;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Обычная ссылка без пилюли (если понадобится) */
|
||||
.nav-link:not(.nav-pill) {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.nav-link:hover {
|
||||
.nav-link:not(.nav-pill):hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -114,6 +180,93 @@ body.modal-open {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
text-align: center;
|
||||
}
|
||||
.footer-line {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.footer-copyright {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
.footer-copyright a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Одна карточка: заголовок + описание + строка состояния среды */
|
||||
.hero-panel {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.hero-panel-main {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.hero-panel .page-title {
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.hero-panel .page-lead {
|
||||
margin: 0;
|
||||
max-width: 48rem;
|
||||
}
|
||||
.hero-panel-status {
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.hero-panel-status.ok {
|
||||
border-color: #15803d;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.hero-panel-status.degraded {
|
||||
border-color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
.hero-panel-status.err {
|
||||
border-color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
/* Прогресс создания кластера */
|
||||
.create-progress-wrap {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.create-progress-hint {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.progress-track {
|
||||
height: 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--accent), #60a5fa);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
.progress-label {
|
||||
margin: 0.45rem 0 0.65rem;
|
||||
font-size: 0.88rem;
|
||||
min-height: 1.35rem;
|
||||
}
|
||||
#create-progress-wrap .btn-secondary {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.git-hint {
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
|
||||
.page-intro {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -173,6 +326,10 @@ body.modal-open {
|
||||
border-color: #b45309;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.badge-cancelled {
|
||||
border-color: #6b7280;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.cluster-name {
|
||||
font-weight: 600;
|
||||
|
||||
+25
-6
@@ -24,10 +24,10 @@
|
||||
<span class="nav-tag muted">kind · локальные кластеры</span>
|
||||
</div>
|
||||
<nav class="nav-links" aria-label="Разделы">
|
||||
<a href="/" class="nav-link">Панель</a>
|
||||
<a href="/docs" class="nav-link" target="_blank" rel="noopener">Swagger</a>
|
||||
<a href="/redoc" class="nav-link" target="_blank" rel="noopener">ReDoc</a>
|
||||
<a href="/api/v1/health" class="nav-link" target="_blank" rel="noopener">Health</a>
|
||||
<a href="/" class="nav-link nav-pill nav-pill--home">Панель</a>
|
||||
<a href="/docs" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_swagger">Swagger</a>
|
||||
<a href="/redoc" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_redoc">ReDoc</a>
|
||||
<a href="/api/v1/health" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_health">Health</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -38,12 +38,31 @@
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="footer muted app-footer">
|
||||
<footer class="footer app-footer">
|
||||
{% block footer %}
|
||||
<span>Данные: том <code>clusters/</code> на хосте · <code>app/docs/api_routes.md</code></span>
|
||||
<div class="footer-inner">
|
||||
<p class="muted footer-line">Данные: том <code>clusters/</code> на хосте</p>
|
||||
<p class="footer-copyright">
|
||||
© kind-k8s-develop ·
|
||||
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</footer>
|
||||
|
||||
{# Отдельное окно браузера для документации и health (не вкладка). #}
|
||||
<script>
|
||||
(function () {
|
||||
var opts = "noopener,noreferrer,width=1240,height=840,scrollbars=yes,resizable=yes";
|
||||
document.querySelectorAll(".nav-link[data-open-window]").forEach(function (a) {
|
||||
a.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
var name = a.getAttribute("data-open-window") || "kind_ext";
|
||||
window.open(a.getAttribute("href"), name, opts);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,39 +4,41 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block footer %}
|
||||
<span>Документация API: <code>app/docs/api_routes.md</code> · том данных <code>clusters/</code></span>
|
||||
<div class="footer-inner">
|
||||
<p class="footer-line muted">
|
||||
Документация API: <code>app/docs/api_routes.md</code> · том данных <code>clusters/</code> (не в Git)
|
||||
</p>
|
||||
<p class="footer-copyright">
|
||||
© kind-k8s-develop ·
|
||||
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-intro">
|
||||
<h1 class="page-title">Панель управления</h1>
|
||||
<p class="muted page-lead">
|
||||
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="status-banner" class="status-banner muted" role="status" aria-live="polite">
|
||||
Проверка среды…
|
||||
</div>
|
||||
|
||||
<div class="toolbar row spread">
|
||||
<div class="row" style="margin-top:0;align-items:center;gap:0.75rem">
|
||||
<button type="button" id="btn-refresh-all" class="btn-secondary">Обновить всё</button>
|
||||
<label class="row" style="margin-top:0;align-items:center;gap:0.35rem">
|
||||
<input type="checkbox" id="auto-refresh" />
|
||||
авто каждые 15 с
|
||||
</label>
|
||||
<section class="card hero-panel" id="hero-panel" aria-labelledby="hero-title">
|
||||
<div class="hero-panel-main">
|
||||
<h1 class="page-title" id="hero-title">Панель управления</h1>
|
||||
<p class="muted page-lead">
|
||||
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
|
||||
Данные обновляются автоматически.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="status-banner"
|
||||
class="hero-panel-status muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
Проверка среды…
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="card">
|
||||
<h2>Статистика</h2>
|
||||
<dl id="stats-dl" class="stats-dl" aria-label="Сводка по кластерам и заданиям"></dl>
|
||||
<p id="stats-err" class="msg hidden" role="alert"></p>
|
||||
<div class="row">
|
||||
<button type="button" id="btn-refresh-stats">Обновить статистику</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -74,21 +76,43 @@
|
||||
<label for="fld-workers">Worker-ноды (0–20)</label>
|
||||
<input id="fld-workers" name="workers" type="number" min="0" max="20" value="2" />
|
||||
|
||||
<button type="submit">Создать кластер</button>
|
||||
<div class="row create-actions">
|
||||
<button type="submit">Создать кластер</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="create-progress-wrap" class="create-progress-wrap hidden" aria-hidden="true">
|
||||
<p class="create-progress-hint muted" id="create-progress-hint">
|
||||
Идёт создание — шаг <code>kind create</code> может занять несколько минут при первом pull образов.
|
||||
</p>
|
||||
<div
|
||||
class="progress-track"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="0"
|
||||
aria-labelledby="create-progress-label"
|
||||
id="create-progress-track"
|
||||
>
|
||||
<div class="progress-bar" id="create-progress-bar" style="width:0%"></div>
|
||||
</div>
|
||||
<p class="progress-label" id="create-progress-label">—</p>
|
||||
<button type="button" id="btn-cancel-create" class="btn-secondary">Отменить создание</button>
|
||||
</div>
|
||||
|
||||
<p id="create-msg" class="msg" aria-live="polite"></p>
|
||||
<details id="job-details" class="job-details hidden">
|
||||
<summary>Ход задания (JSON)</summary>
|
||||
<summary>Технические детали (JSON)</summary>
|
||||
<pre id="job-json" class="mono job-json-pre" tabindex="0"></pre>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card clusters-card">
|
||||
<div class="row spread">
|
||||
<h2>Кластеры</h2>
|
||||
<button type="button" class="btn-secondary" id="btn-refresh-list">Обновить список</button>
|
||||
</div>
|
||||
<h2>Кластеры</h2>
|
||||
<p class="muted git-hint">
|
||||
Каталог <code>clusters/</code> в Git не коммитится — только локальные kubeconfig и meta.
|
||||
</p>
|
||||
<div class="table-wrap" data-busy="clusters">
|
||||
<table id="tbl-clusters" aria-label="Список кластеров">
|
||||
<thead>
|
||||
@@ -108,10 +132,7 @@
|
||||
</section>
|
||||
|
||||
<section class="card jobs-card">
|
||||
<div class="row spread">
|
||||
<h2>Последние задания</h2>
|
||||
<button type="button" class="btn-secondary" id="btn-refresh-jobs">Обновить</button>
|
||||
</div>
|
||||
<h2>Последние задания</h2>
|
||||
<div class="table-wrap" data-busy="jobs">
|
||||
<table id="tbl-jobs" aria-label="Последние задания создания">
|
||||
<thead>
|
||||
|
||||
Reference in New Issue
Block a user