From 359afb3a59588ca6757bdafcde8746dda55f2c59 Mon Sep 17 00:00:00 2001 From: tomasz-syn-grzegorza Date: Sun, 5 Jul 2026 12:02:04 +0000 Subject: [PATCH] Initial import: bare-metal stacks, Server UI, GPU fan, tutorials. Infrastructure configs for GMKtec K11 (Docker, vLLM, LocalAI, ComfyUI, control-plane, gpu-fan agent, Server UI with CLI/file explorer/GPU fan curve). Co-authored-by: Cursor --- .gitignore | 19 + README.md | 152 ++ .../ADR-001-host-agent-control-plane.md | 102 + coding-agent/BACKLOG.md | 94 + coding-agent/COMFYUI-DEPLOYMENT.md | 119 + coding-agent/COMFYUI-HANDOFF.md | 82 + coding-agent/CONTROL-PLANE-ENV.md | 149 ++ coding-agent/CONVENTIONS.md | 91 + coding-agent/DOCKER-UI-DEPLOYMENT.md | 90 + coding-agent/DOCKER-UI-HANDOFF.md | 55 + coding-agent/DOCKGE-REMOVAL.md | 36 + coding-agent/GITEA-PREP.md | 113 + coding-agent/HANDOFF.md | 104 + coding-agent/NETWORK-STATIC-IP-BLOG.md | 144 ++ coding-agent/NETWORK-STATIC-IP-DEPLOYMENT.md | 94 + coding-agent/NETWORK-STATIC-IP-HANDOFF.md | 109 + coding-agent/PORTAINER-REMOVAL.md | 40 + coding-agent/PORTAINER-SETUP-TOKEN.md | 225 ++ coding-agent/README.md | 28 + coding-agent/RERANKER-DEPLOYMENT.md | 102 + coding-agent/RERANKER-HANDOFF.md | 62 + .../SERVER-UI-ARCHITECTURE-RESEARCH.md | 451 ++++ coding-agent/SERVER-UI-CLI-BUGFIX.md | 65 + coding-agent/SERVER-UI-CLI.md | 101 + coding-agent/SERVER-UI-FILE-EXPLORER.md | 84 + coding-agent/SERVER-UI-INSTALL-OPTIONS.md | 142 ++ coding-agent/SERVER-UI-PORT-CONFIG.md | 173 ++ coding-agent/STABILITYMATRIX-RESEARCH.md | 69 + coding-agent/STATE.md | 147 ++ coding-agent/SWARMUI-REMOVAL.md | 31 + manual-tutorial/00-prerequisites.md | 114 + .../01-system-update-and-docker.md | 474 ++++ manual-tutorial/02-nvidia-driver.md | 367 ++++ .../03-nvidia-container-toolkit.md | 362 +++ manual-tutorial/03b-system-tools.md | 283 +++ manual-tutorial/04-vllm-stack.md | 773 +++++++ manual-tutorial/04a-api-key.md | 140 ++ manual-tutorial/05-localai-stack.md | 315 +++ manual-tutorial/06-gpu-fan-control.md | 356 +++ manual-tutorial/07-comfyui-stack.md | 286 +++ manual-tutorial/08-server-ui-install.md | 230 ++ manual-tutorial/09-file-explorer.md | 117 + manual-tutorial/10-server-ui-cli.md | 122 ++ scripts/configure-static-dual-nic.sh | 421 ++++ scripts/restore-dhcp-network.sh | 189 ++ scripts/setup-data-disk.sh | 106 + stacks/comfyui/.env.example | 14 + stacks/comfyui/.gitignore | 1 + stacks/comfyui/README.md | 134 ++ stacks/comfyui/compose.yaml | 1 + stacks/comfyui/docker-compose.yml | 33 + stacks/comfyui/scripts/ensure-dirs.sh | 19 + stacks/comfyui/scripts/pull.sh | 16 + stacks/comfyui/scripts/start.sh | 68 + stacks/control-plane/.env.example | 34 + stacks/control-plane/.gitignore | 1 + stacks/control-plane/env_loader.py | 127 ++ stacks/gpu-fan/.env.example | 2 + stacks/gpu-fan/.gitignore | 4 + stacks/gpu-fan/README.md | 154 ++ stacks/gpu-fan/app.py | 195 ++ .../coding-agent/DOCKER-VS-HOST-REPORT.md | 228 ++ stacks/gpu-fan/config/curve.json | 8 + stacks/gpu-fan/curve.default.json | 8 + stacks/gpu-fan/docs/00-START-TUTAJ.md | 56 + .../docs/01-INSTALACJA-KROK-PO-KROKU.md | 127 ++ .../docs/02-OTWIERANIE-UI-W-PRZEGLADARCE.md | 100 + stacks/gpu-fan/docs/03-KRZYWa-I-TRYBY.md | 112 + stacks/gpu-fan/docs/04-CZESTE-BLEDY.md | 163 ++ stacks/gpu-fan/docs/05-DLACZEGO-NIE-DOCKER.md | 78 + stacks/gpu-fan/fan_controller.py | 358 +++ stacks/gpu-fan/fan_daemon.py | 185 ++ stacks/gpu-fan/gpu-fan.service | 18 + stacks/gpu-fan/requirements.txt | 3 + stacks/gpu-fan/scripts/enable-lan.sh | 51 + stacks/gpu-fan/scripts/install.sh | 80 + stacks/gpu-fan/scripts/self-test.sh | 85 + stacks/gpu-fan/scripts/start.sh | 83 + stacks/gpu-fan/scripts/status.sh | 74 + stacks/gpu-fan/static/index.html | 755 +++++++ stacks/llamacpp/README.md | 65 + stacks/localai/.env.example | 16 + stacks/localai/.gitignore | 2 + stacks/localai/README.md | 152 ++ stacks/localai/coding-agent/BACKLOG.md | 28 + stacks/localai/coding-agent/CONVENTIONS.md | 41 + .../coding-agent/EMBEDDING-STATUS-REPORT.md | 147 ++ stacks/localai/coding-agent/HANDOFF.md | 46 + stacks/localai/coding-agent/KV-CACHE.md | 84 + stacks/localai/coding-agent/README.md | 25 + stacks/localai/coding-agent/STATE.md | 62 + stacks/localai/compose.yaml | 1 + stacks/localai/docker-compose.yml | 31 + .../bge-m3-FP16-embedding.yaml.example | 16 + ...ge-reranker-v2-m3-FP16-rerank.yaml.example | 27 + .../gemma-4-12b-q4-kv-q8.yaml.example | 32 + stacks/localai/scripts/apply-kv-profile.sh | 95 + stacks/localai/scripts/clone-upstream.sh | 28 + stacks/localai/scripts/download-reranker.sh | 66 + stacks/localai/scripts/ensure-dirs.sh | 16 + stacks/localai/scripts/pull.sh | 31 + stacks/localai/scripts/start.sh | 55 + stacks/npmplus/.env.example | 21 + stacks/npmplus/.gitignore | 1 + stacks/npmplus/README.md | 89 + stacks/npmplus/compose.yaml | 1 + stacks/npmplus/docker-compose.yml | 27 + stacks/npmplus/scripts/configure-firewall.sh | 45 + .../scripts/configure-localai-proxy.sh | 111 + stacks/npmplus/scripts/enable-http-proxy.sh | 81 + stacks/npmplus/scripts/ensure-dirs.sh | 11 + .../npmplus/scripts/regenerate-admin-cert.sh | 80 + stacks/npmplus/scripts/start.sh | 68 + stacks/server-ui/.env.example | 2 + stacks/server-ui/.gitignore | 1 + stacks/server-ui/Dockerfile | 29 + stacks/server-ui/README.md | 111 + stacks/server-ui/app.py | 453 ++++ stacks/server-ui/cli_pty.py | 174 ++ stacks/server-ui/compose_runner.py | 433 ++++ stacks/server-ui/docker-compose.yml | 28 + stacks/server-ui/file_explorer.py | 248 +++ stacks/server-ui/gpu_fan_proxy.py | 75 + stacks/server-ui/gpu_info.py | 69 + stacks/server-ui/requirements.txt | 4 + .../server-ui/scripts/deploy-gpu-fan-fix.sh | 29 + .../scripts/install-control-plane.sh | 163 ++ stacks/server-ui/scripts/install-docker.sh | 107 + stacks/server-ui/scripts/install.sh | 89 + .../scripts/print-api-key-instructions.sh | 57 + stacks/server-ui/scripts/restart-stack.sh | 17 + .../scripts/setup-control-plane-env.sh | 172 ++ stacks/server-ui/scripts/show-api-key.sh | 24 + stacks/server-ui/scripts/start.sh | 29 + stacks/server-ui/server-ui.service | 20 + stacks/server-ui/stacks.yaml | 44 + stacks/server-ui/static/index.html | 1942 +++++++++++++++++ .../static/vendor/xterm/addon-fit.min.js | 8 + .../server-ui/static/vendor/xterm/xterm.css | 218 ++ .../static/vendor/xterm/xterm.min.js | 8 + stacks/vllm/.env.example | 23 + stacks/vllm/README.md | 182 ++ stacks/vllm/compose.yaml | 1 + stacks/vllm/docker-compose.yml | 26 + stacks/vllm/models.catalog.yaml | 32 + stacks/vllm/profiles/_template.env | 18 + stacks/vllm/profiles/qwen3.6-27b-awq-128k.env | 19 + stacks/vllm/scripts/catalog-lib.sh | 63 + stacks/vllm/scripts/download-model.sh | 114 + stacks/vllm/scripts/list-models.sh | 46 + stacks/vllm/scripts/start.sh | 88 + stacks/vllm/scripts/switch-model.sh | 73 + stacks/vllm/scripts/vllm-entrypoint.sh | 26 + 153 files changed, 18169 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 coding-agent/ADR-001-host-agent-control-plane.md create mode 100644 coding-agent/BACKLOG.md create mode 100644 coding-agent/COMFYUI-DEPLOYMENT.md create mode 100644 coding-agent/COMFYUI-HANDOFF.md create mode 100644 coding-agent/CONTROL-PLANE-ENV.md create mode 100644 coding-agent/CONVENTIONS.md create mode 100644 coding-agent/DOCKER-UI-DEPLOYMENT.md create mode 100644 coding-agent/DOCKER-UI-HANDOFF.md create mode 100644 coding-agent/DOCKGE-REMOVAL.md create mode 100644 coding-agent/GITEA-PREP.md create mode 100644 coding-agent/HANDOFF.md create mode 100644 coding-agent/NETWORK-STATIC-IP-BLOG.md create mode 100644 coding-agent/NETWORK-STATIC-IP-DEPLOYMENT.md create mode 100644 coding-agent/NETWORK-STATIC-IP-HANDOFF.md create mode 100644 coding-agent/PORTAINER-REMOVAL.md create mode 100644 coding-agent/PORTAINER-SETUP-TOKEN.md create mode 100644 coding-agent/README.md create mode 100644 coding-agent/RERANKER-DEPLOYMENT.md create mode 100644 coding-agent/RERANKER-HANDOFF.md create mode 100644 coding-agent/SERVER-UI-ARCHITECTURE-RESEARCH.md create mode 100644 coding-agent/SERVER-UI-CLI-BUGFIX.md create mode 100644 coding-agent/SERVER-UI-CLI.md create mode 100644 coding-agent/SERVER-UI-FILE-EXPLORER.md create mode 100644 coding-agent/SERVER-UI-INSTALL-OPTIONS.md create mode 100644 coding-agent/SERVER-UI-PORT-CONFIG.md create mode 100644 coding-agent/STABILITYMATRIX-RESEARCH.md create mode 100644 coding-agent/STATE.md create mode 100644 coding-agent/SWARMUI-REMOVAL.md create mode 100644 manual-tutorial/00-prerequisites.md create mode 100644 manual-tutorial/01-system-update-and-docker.md create mode 100644 manual-tutorial/02-nvidia-driver.md create mode 100644 manual-tutorial/03-nvidia-container-toolkit.md create mode 100644 manual-tutorial/03b-system-tools.md create mode 100644 manual-tutorial/04-vllm-stack.md create mode 100644 manual-tutorial/04a-api-key.md create mode 100644 manual-tutorial/05-localai-stack.md create mode 100644 manual-tutorial/06-gpu-fan-control.md create mode 100644 manual-tutorial/07-comfyui-stack.md create mode 100644 manual-tutorial/08-server-ui-install.md create mode 100644 manual-tutorial/09-file-explorer.md create mode 100644 manual-tutorial/10-server-ui-cli.md create mode 100755 scripts/configure-static-dual-nic.sh create mode 100755 scripts/restore-dhcp-network.sh create mode 100755 scripts/setup-data-disk.sh create mode 100644 stacks/comfyui/.env.example create mode 100644 stacks/comfyui/.gitignore create mode 100644 stacks/comfyui/README.md create mode 120000 stacks/comfyui/compose.yaml create mode 100644 stacks/comfyui/docker-compose.yml create mode 100755 stacks/comfyui/scripts/ensure-dirs.sh create mode 100755 stacks/comfyui/scripts/pull.sh create mode 100755 stacks/comfyui/scripts/start.sh create mode 100644 stacks/control-plane/.env.example create mode 100644 stacks/control-plane/.gitignore create mode 100644 stacks/control-plane/env_loader.py create mode 100644 stacks/gpu-fan/.env.example create mode 100644 stacks/gpu-fan/.gitignore create mode 100644 stacks/gpu-fan/README.md create mode 100644 stacks/gpu-fan/app.py create mode 100644 stacks/gpu-fan/coding-agent/DOCKER-VS-HOST-REPORT.md create mode 100644 stacks/gpu-fan/config/curve.json create mode 100644 stacks/gpu-fan/curve.default.json create mode 100644 stacks/gpu-fan/docs/00-START-TUTAJ.md create mode 100644 stacks/gpu-fan/docs/01-INSTALACJA-KROK-PO-KROKU.md create mode 100644 stacks/gpu-fan/docs/02-OTWIERANIE-UI-W-PRZEGLADARCE.md create mode 100644 stacks/gpu-fan/docs/03-KRZYWa-I-TRYBY.md create mode 100644 stacks/gpu-fan/docs/04-CZESTE-BLEDY.md create mode 100644 stacks/gpu-fan/docs/05-DLACZEGO-NIE-DOCKER.md create mode 100644 stacks/gpu-fan/fan_controller.py create mode 100644 stacks/gpu-fan/fan_daemon.py create mode 100644 stacks/gpu-fan/gpu-fan.service create mode 100644 stacks/gpu-fan/requirements.txt create mode 100755 stacks/gpu-fan/scripts/enable-lan.sh create mode 100755 stacks/gpu-fan/scripts/install.sh create mode 100755 stacks/gpu-fan/scripts/self-test.sh create mode 100755 stacks/gpu-fan/scripts/start.sh create mode 100755 stacks/gpu-fan/scripts/status.sh create mode 100644 stacks/gpu-fan/static/index.html create mode 100644 stacks/llamacpp/README.md create mode 100644 stacks/localai/.env.example create mode 100644 stacks/localai/.gitignore create mode 100644 stacks/localai/README.md create mode 100644 stacks/localai/coding-agent/BACKLOG.md create mode 100644 stacks/localai/coding-agent/CONVENTIONS.md create mode 100644 stacks/localai/coding-agent/EMBEDDING-STATUS-REPORT.md create mode 100644 stacks/localai/coding-agent/HANDOFF.md create mode 100644 stacks/localai/coding-agent/KV-CACHE.md create mode 100644 stacks/localai/coding-agent/README.md create mode 100644 stacks/localai/coding-agent/STATE.md create mode 120000 stacks/localai/compose.yaml create mode 100644 stacks/localai/docker-compose.yml create mode 100644 stacks/localai/profiles/bge-m3-FP16-embedding.yaml.example create mode 100644 stacks/localai/profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example create mode 100644 stacks/localai/profiles/gemma-4-12b-q4-kv-q8.yaml.example create mode 100755 stacks/localai/scripts/apply-kv-profile.sh create mode 100755 stacks/localai/scripts/clone-upstream.sh create mode 100755 stacks/localai/scripts/download-reranker.sh create mode 100755 stacks/localai/scripts/ensure-dirs.sh create mode 100755 stacks/localai/scripts/pull.sh create mode 100755 stacks/localai/scripts/start.sh create mode 100644 stacks/npmplus/.env.example create mode 100644 stacks/npmplus/.gitignore create mode 100644 stacks/npmplus/README.md create mode 120000 stacks/npmplus/compose.yaml create mode 100644 stacks/npmplus/docker-compose.yml create mode 100755 stacks/npmplus/scripts/configure-firewall.sh create mode 100755 stacks/npmplus/scripts/configure-localai-proxy.sh create mode 100755 stacks/npmplus/scripts/enable-http-proxy.sh create mode 100755 stacks/npmplus/scripts/ensure-dirs.sh create mode 100755 stacks/npmplus/scripts/regenerate-admin-cert.sh create mode 100755 stacks/npmplus/scripts/start.sh create mode 100644 stacks/server-ui/.env.example create mode 100644 stacks/server-ui/.gitignore create mode 100644 stacks/server-ui/Dockerfile create mode 100644 stacks/server-ui/README.md create mode 100644 stacks/server-ui/app.py create mode 100644 stacks/server-ui/cli_pty.py create mode 100644 stacks/server-ui/compose_runner.py create mode 100644 stacks/server-ui/docker-compose.yml create mode 100644 stacks/server-ui/file_explorer.py create mode 100644 stacks/server-ui/gpu_fan_proxy.py create mode 100644 stacks/server-ui/gpu_info.py create mode 100644 stacks/server-ui/requirements.txt create mode 100755 stacks/server-ui/scripts/deploy-gpu-fan-fix.sh create mode 100755 stacks/server-ui/scripts/install-control-plane.sh create mode 100755 stacks/server-ui/scripts/install-docker.sh create mode 100755 stacks/server-ui/scripts/install.sh create mode 100755 stacks/server-ui/scripts/print-api-key-instructions.sh create mode 100755 stacks/server-ui/scripts/restart-stack.sh create mode 100755 stacks/server-ui/scripts/setup-control-plane-env.sh create mode 100755 stacks/server-ui/scripts/show-api-key.sh create mode 100755 stacks/server-ui/scripts/start.sh create mode 100644 stacks/server-ui/server-ui.service create mode 100644 stacks/server-ui/stacks.yaml create mode 100644 stacks/server-ui/static/index.html create mode 100644 stacks/server-ui/static/vendor/xterm/addon-fit.min.js create mode 100644 stacks/server-ui/static/vendor/xterm/xterm.css create mode 100644 stacks/server-ui/static/vendor/xterm/xterm.min.js create mode 100644 stacks/vllm/.env.example create mode 100644 stacks/vllm/README.md create mode 120000 stacks/vllm/compose.yaml create mode 100644 stacks/vllm/docker-compose.yml create mode 100644 stacks/vllm/models.catalog.yaml create mode 100644 stacks/vllm/profiles/_template.env create mode 100644 stacks/vllm/profiles/qwen3.6-27b-awq-128k.env create mode 100755 stacks/vllm/scripts/catalog-lib.sh create mode 100755 stacks/vllm/scripts/download-model.sh create mode 100755 stacks/vllm/scripts/list-models.sh create mode 100755 stacks/vllm/scripts/start.sh create mode 100755 stacks/vllm/scripts/switch-model.sh create mode 100755 stacks/vllm/scripts/vllm-entrypoint.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..049b7dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Secrets +.env +**/.env + +# Python +.venv/ +**/.venv/ +__pycache__/ +*.pyc +*.pyo + +# Local clones / runtime +upstream/ +*.pem + +# OS / editor +.DS_Store +*.swp +.cursor/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f948eca --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# ubuntu-bare-metal + +Repozytorium konfiguracji czystego Ubuntu na fizycznym serwerze (bare metal) pod hostowanie **vLLM**, **LocalAI**, **ComfyUI** i **Docker**. + +Tutorial jest prowadzony krok po kroku — każdy rozdział to samodzielny etap z komendami, weryfikacją i sekcją troubleshooting. + +## Sprzęt docelowy + +| Parametr | Wartość | +|----------|---------| +| Urządzenie | GMKtec K11 | +| CPU | AMD Phoenix (Ryzen) | +| GPU (LLM / ComfyUI) | NVIDIA GeForce RTX 3090 Ti (24 GB VRAM) | +| iGPU | AMD HawkPoint1 (nieużywane do workloadów AI) | +| RAM | 29 GiB | +| Dysk systemowy | 128 GB NVMe (`/`) — tylko Ubuntu | +| Dysk danych | 1 TB (`/data`) — Docker, modele, aplikacje | +| System | Ubuntu 26.04 LTS (`resolute`) — instalacja minimized | + +## Jak korzystać z tutoriala + +1. Czytaj rozdziały w kolejności numeracji (`00`, `01`, `02`, …). +2. Wykonuj komendy na serwerze w podanej kolejności. +3. Po każdym rozdziale przejdź sekcję **Weryfikacja** — nie przechodź dalej, dopóki wszystkie punkty nie są zielone. +4. Zgłaszaj poprawki w rozmowie z Cursorem — tutorial będzie aktualizowany. + +Instrukcje: **po polsku**. Komendy i nazwy pakietów: **po angielsku** (jak w systemie). + +## Roadmapa rozdziałów + +| # | Rozdział | Status | +|---|----------|--------| +| 00 | [Wymagania i konwencje](manual-tutorial/00-prerequisites.md) | Gotowy | +| 01 | [Aktualizacja systemu i Docker CE](manual-tutorial/01-system-update-and-docker.md) | Ukończony | +| 02 | [Sterowniki NVIDIA](manual-tutorial/02-nvidia-driver.md) | Ukończony | +| 03 | [NVIDIA Container Toolkit](manual-tutorial/03-nvidia-container-toolkit.md) | Ukończony | +| 03b | [Narzędzia bazowe — Ubuntu minimized](manual-tutorial/03b-system-tools.md) | **Wykonaj przed 04** | +| 04 | [Dysk 1 TB + vLLM stack](manual-tutorial/04-vllm-stack.md) | Ukończony / w toku | +| 05 | [LocalAI stack (UI + GGUF)](manual-tutorial/05-localai-stack.md) | Ukończony / w toku | +| 06 | [Sterowanie wentylatorami GPU](manual-tutorial/06-gpu-fan-control.md) | Gotowy | +| 07 | [ComfyUI stack (Docker)](manual-tutorial/07-comfyui-stack.md) | Gotowy (stack w repo) | +| 08 | [Server UI — panel sterowania](manual-tutorial/08-server-ui-install.md) | Gotowy | +| 09 | Firewall i hardening | Planowany | + +```mermaid +flowchart LR + step01["01: apt + Docker CE"] + step02["02: NVIDIA driver"] + step03["03: NVIDIA Container Toolkit"] + step03b["03b: system tools"] + step04["04: disk + vLLM"] + step05["05: LocalAI stack"] + step06["06: ComfyUI stack"] + step07["07: firewall i hardening"] + + step01 --> step02 --> step03 --> step03b --> step04 + step04 --> step05 + step03 --> step06 + step05 --> step06 + step06 --> step07 +``` + +Kolejność jest krytyczna: **Docker → sterownik GPU → nvidia-container-toolkit → workloady GPU w kontenerach**. + +## Architektura dysków + +| Dysk | Mount | Zawartość | +|------|-------|-----------| +| 128 GB NVMe | `/` | Ubuntu, `/etc`, `/home`, repo konfiguracyjne | +| 1 TB | `/data` | `/data/docker` (Docker), `/data/apps/*` (modele, LocalAI, ComfyUI) | + +Szczegóły konfiguracji dysku 1 TB: rozdział [04 — vLLM stack](manual-tutorial/04-vllm-stack.md) (część A). + +## Uwagi + +- Do workloadów AI używamy wyłącznie **NVIDIA RTX 3090 Ti** (`CUDA_VISIBLE_DEVICES=0`), nie iGPU AMD. +- vLLM: brak domyślnego modelu — katalog w [`stacks/vllm/models.catalog.yaml`](stacks/vllm/models.catalog.yaml). Szczegóły: [`stacks/vllm/README.md`](stacks/vllm/README.md). +- LocalAI: UI + API na porcie **8070**, start bez modelu — [`stacks/localai/README.md`](stacks/localai/README.md). +- GPU Fan Control: web UI na porcie **8090** (SSH tunnel) — [`stacks/gpu-fan/README.md`](stacks/gpu-fan/README.md). +- Server UI: własny panel stacków na porcie **8091** — [`stacks/server-ui/README.md`](stacks/server-ui/README.md). +- Architektura panelu (gpu-fan + Docker UI): [`coding-agent/SERVER-UI-ARCHITECTURE-RESEARCH.md`](coding-agent/SERVER-UI-ARCHITECTURE-RESEARCH.md) + +## Struktura repozytorium + +``` +ubuntu-bare-metal/ +├── README.md +├── scripts/ +│ └── setup-data-disk.sh # migracja dysku 1 TB (rozdział 04A) +├── stacks/ +│ ├── vllm/ +│ │ ├── README.md +│ │ ├── models.catalog.yaml # GGUF + vLLM AWQ, bez auto-download +│ │ ├── docker-compose.yml +│ │ ├── .env.example +│ │ ├── profiles/ +│ │ │ ├── _template.env +│ │ │ └── qwen3.6-27b-awq-128k.env +│ │ └── scripts/ +│ │ ├── list-models.sh +│ │ ├── download-model.sh +│ │ ├── switch-model.sh +│ │ └── start.sh +│ ├── llamacpp/ +│ │ └── README.md # placeholder pod GGUF (port 8001) +│ ├── localai/ +│ │ ├── README.md +│ │ ├── docker-compose.yml +│ │ ├── .env.example +│ │ └── scripts/ +│ │ ├── clone-upstream.sh +│ │ ├── pull.sh +│ │ └── start.sh +│ ├── comfyui/ +│ │ ├── README.md +│ │ ├── docker-compose.yml +│ │ ├── .env.example +│ │ └── scripts/ +│ │ ├── ensure-dirs.sh +│ │ ├── pull.sh +│ │ └── start.sh +│ ├── dockge/ # DEPRECATED → server-ui +│ │ └── README.md +│ ├── server-ui/ +│ │ ├── README.md +│ │ ├── app.py +│ │ ├── stacks.yaml +│ │ ├── server-ui.service +│ │ ├── static/index.html +│ │ └── scripts/ +│ │ ├── install.sh +│ │ └── start.sh +│ └── gpu-fan/ +│ ├── README.md +│ ├── app.py +│ ├── fan_controller.py +│ ├── curve.default.json +│ └── scripts/ +│ ├── install.sh +│ └── start.sh +└── manual-tutorial/ + ├── 00-prerequisites.md + ├── 01-system-update-and-docker.md + ├── 02-nvidia-driver.md + ├── 03-nvidia-container-toolkit.md + ├── 03b-system-tools.md + ├── 04-vllm-stack.md + ├── 05-localai-stack.md + ├── 06-gpu-fan-control.md + ├── 07-comfyui-stack.md + └── ... (kolejne rozdziały) +``` diff --git a/coding-agent/ADR-001-host-agent-control-plane.md b/coding-agent/ADR-001-host-agent-control-plane.md new file mode 100644 index 0000000..6b45a5e --- /dev/null +++ b/coding-agent/ADR-001-host-agent-control-plane.md @@ -0,0 +1,102 @@ +# ADR-001: Host Agent + Control Plane UI + +**Status:** Zaakceptowany (research 2026-07-04) +**Kontekst:** Uniwersalny panel serwera (Server UI) + gpu-fan na gmktec-k11 +**Pełna analiza:** [`SERVER-UI-ARCHITECTURE-RESEARCH.md`](SERVER-UI-ARCHITECTURE-RESEARCH.md) + +--- + +## Kontekst i problem + +Na hoście działają: +- **gpu-fan** (systemd, root, NVML) — port 8090, własny Web UI +- **server-ui** (systemd) — port 8091, zarządzanie Docker compose +- **Workloady AI** (Docker) — ComfyUI, LocalAI, vLLM + +Użytkownik chce: +1. Jeden panel zamiast wielu portów (8090, 8091, …) +2. UI serwerowe w Dockerze +3. Operacje wymagające root (wentylatory GPU) nadal na hoście + +Pytanie: jak to pogodzić bez pakowania NVML do kontenera? + +--- + +## Decyzja + +Przyjmujemy architekturę **dwuwarstwową**: + +1. **Host Agents** — procesy systemd jako root, API tylko na `127.0.0.1`, bez publicznego UI: + - `gpu-fan-daemon` (:18090) — NVML fan loop + REST API + - (przyszłość) `host-agent` — agregacja systemd, disk health + +2. **Control Plane UI** — Server UI (docelowo kontener Docker): + - Jeden dashboard, jeden `API_KEY` dla użytkownika + - Mount `/var/run/docker.sock` — orchestracja stacków + - HTTP proxy `/api/gpu-fan/*` → host agent + +**Strategia implementacji:** C z planu research (split gpu-fan → integracja UI → dockerize server-ui). + +--- + +## Uzasadnienie + +| Alternatywa | Dlaczego odrzucona | +|-------------|-------------------| +| gpu-fan w Dockerze | NVML write na GeForce wymaga root na hoście; kruche, nieutrzymywane | +| Status quo (dwa panele) | Rozproszony UX, dwa klucze | +| Tylko Cockpit | Brak whitelist AI stacks, polityki GPU, custom NVML | +| Monolit root z UI na 0.0.0.0 | Obecny gpu-fan — do refaktoru ze względów bezpieczeństwa | + +Wzorzec zgodny z **Portainer** (UI + docker.sock) i **Proxmox** (privileged daemons na hoście). + +--- + +## Konsekwencje + +### Pozytywne +- Jeden port publiczny dla panelu (:8091, potem opcjonalnie NPMPlus :443) +- Daemon fan niezależny od UI — bezpieczeństwo termiczne przy padzie panelu +- Możliwość rozszerzenia o `host-agent` bez kolejnych portów LAN + +### Negatywne / koszt +- Refactor gpu-fan (rozdzielenie UI i API) +- Dockerize server-ui (Dockerfile, mounty, testy `host.docker.internal`) +- Dwa miejsca deploy: systemd (agenty) + compose (UI) + +### Neutralne +- ComfyUI/LocalAI UI pozostają osobnymi aplikacjami (linki z dashboardu) +- npmplus bez zmian (`network_mode: host`) + +--- + +## Szczegóły techniczne (skrót) + +``` +Browser → server-ui:8091 (Docker) + ├─ docker.sock → compose stacks + └─ proxy → 127.0.0.1:18090 (gpu-fan-daemon, systemd root) +``` + +Env agenta: +- `GPU_FAN_API_HOST=127.0.0.1` +- `GPU_FAN_API_PORT=18090` + +Env server-ui: +- `GPU_FAN_AGENT_URL=http://host.docker.internal:18090` (w Dockerze) + +--- + +## Kiedy przejrzeć tę decyzję + +- Multi-node cluster (więcej niż jeden host) — wtedy centralny panel + agenty na każdym nodzie +- Migracja na Cockpit/Kubernetes — osobna decyzja ADR +- NVIDIA udostępni oficjalny fan API w kontenerach bez root — mało prawdopodobne na GeForce + +--- + +## Powiązane dokumenty + +- [`SERVER-UI-ARCHITECTURE-RESEARCH.md`](SERVER-UI-ARCHITECTURE-RESEARCH.md) +- [`../stacks/gpu-fan/coding-agent/DOCKER-VS-HOST-REPORT.md`](../stacks/gpu-fan/coding-agent/DOCKER-VS-HOST-REPORT.md) +- [`DOCKER-UI-DEPLOYMENT.md`](DOCKER-UI-DEPLOYMENT.md) diff --git a/coding-agent/BACKLOG.md b/coding-agent/BACKLOG.md new file mode 100644 index 0000000..7838841 --- /dev/null +++ b/coding-agent/BACKLOG.md @@ -0,0 +1,94 @@ +# Backlog + +Priorytety dla kolejnego agenta. Aktualizuj po ukończeniu zadań. + +## P0 — naprawy / dokończenie bieżącego + +- [ ] **Przekazać `LOCALAI_API_KEY` do kontenera** + Dodać do `stacks/localai/docker-compose.yml` w sekcji `environment:`: + ```yaml + - LOCALAI_API_KEY=${LOCALAI_API_KEY:-} + ``` + Dodać komentarz / placeholder do `stacks/localai/.env.example` (bez prawdziwego klucza). + Restart: `docker compose --profile localai restart localai` + +- [ ] **Zweryfikować API auth po restarcie** + ```bash + curl -s http://localhost:8070/v1/models \ + -H "Authorization: Bearer " + ``` + Bez klucza powinno być 401. + +- [ ] **Zsynchronizować port 8070** + Albo zaktualizować `05-localai-stack.md`, `stacks/localai/README.md`, root README (port 8070), albo zapytać użytkownika o powrót do 8080. + +- [ ] **Dokończyć test modelu LocalAI** + Po pobraniu modelu: chat w UI, `curl /v1/chat/completions`, `nvidia-smi` (VRAM). + +## P1 — produkcja / dostęp zewnętrzny + +- [x] ~~SwarmUI~~ — **usunięte 2026-07-04** ([`SWARMUI-REMOVAL.md`](SWARMUI-REMOVAL.md)) + +- [ ] Reverse proxy LocalAI — **częściowo** (NPMPlus działa dla `llm.rtx1.mobile.agency-ai.dev`) +- [ ] `LOCALAI_BASE_URL=https://llm.rtx1.mobile.agency-ai.dev` w dokumentacji klienta + +- [ ] Bind LocalAI tylko na localhost po proxy: + ```yaml + ports: + - "127.0.0.1:${LOCALAI_PORT:-8080}:8080" + ``` + +- [ ] Rozdział **08 — firewall i hardening** (ufw, ograniczenie portów 8070/8090/**8091**) + +- [ ] Opcjonalnie: `LOCALAI_AUTH=true` + rejestracja invite (multi-user) zamiast samego legacy key + +## P2 — integracja modeli i GPU + +- [x] **BGE-Reranker-v2-m3** — GGUF + YAML + `download-reranker.sh` (2026-07-01). Raport: [`RERANKER-DEPLOYMENT.md`](RERANKER-DEPLOYMENT.md) + +- [ ] Połączyć `stacks/vllm/models.catalog.yaml` z LocalAI + GGUF z `/data/apps/gguf/` → symlink lub kopia do `/data/apps/localai/models/` + YAML + +- [ ] Skrypt pobierania modelu pod LocalAI (obok vLLM `download-model.sh`) + +- [ ] vLLM opcjonalnie: `./scripts/download-model.sh qwen3.6-27b-awq-vllm` + `switch-model.sh` + Tylko gdy użytkownik chce AWQ zamiast GGUF w LocalAI + +- [x] Dokumentacja: jeden aktywny duży model na GPU — LocalAI ↔ ComfyUI, procedura w Server UI — [`DOCKER-UI-DEPLOYMENT.md`](DOCKER-UI-DEPLOYMENT.md), [`stacks/comfyui/README.md`](../stacks/comfyui/README.md) + +- [x] ~~Dockge~~ — **usunięty 2026-07-04** ([`DOCKGE-REMOVAL.md`](DOCKGE-REMOVAL.md)) +- [x] **Server UI** — `stacks/server-ui/`, port **8091** (2026-07-04) +- [ ] **Produkcja server-ui** — `sudo ./scripts/install.sh` na hoście (systemd) +- [ ] **Architektura Server UI + gpu-fan** — research: [`SERVER-UI-ARCHITECTURE-RESEARCH.md`](SERVER-UI-ARCHITECTURE-RESEARCH.md), ADR: [`ADR-001-host-agent-control-plane.md`](ADR-001-host-agent-control-plane.md) + - Faza 1: gpu-fan API-only `127.0.0.1:18090` + - Faza 2: UI gpu-fan w server-ui (proxy) + - Faza 3: server-ui w Dockerze (`docker.sock` + mount repo) + +## P3 — roadmapa tutoriala + +- [x] Rozdział **07 — ComfyUI stack** (Docker, `/data/apps/comfyui/`) — stack + tutorial 2026-07-04. Research SM: [`STABILITYMATRIX-RESEARCH.md`](STABILITYMATRIX-RESEARCH.md) +- [ ] **Uruchomić ComfyUI na hoście** — `cd stacks/comfyui && ./scripts/start.sh` (użytkownik) + +- [ ] Pełny Docker llama.cpp — **niski priorytet** jeśli LocalAI wystarczy dla GGUF + +- [ ] Open WebUI dla vLLM — opcjonalnie, nie planowane na teraz + +## Czego nie robić bez prośby użytkownika + +- `git commit` / `git push` +- Pobieranie modeli LLM przy `docker compose pull` +- Build LocalAI lub vLLM ze źródeł GitHub +- Edycja plików w `.cursor/plans/` +- Zapisywanie sekretów w `coding-agent/` lub w commitach + +## Ukończone w tej sesji (referencja) + +- [x] Tutorial 00–03b, 04 (dysk + vLLM), 05 (LocalAI) +- [x] `stacks/vllm/` — katalog modeli, skrypty, elastyczny compose +- [x] `stacks/localai/` — stack GPU cuda-13, skrypty, README +- [x] `stacks/llamacpp/README.md` — placeholder +- [x] `stacks/server-ui/` — własny Docker UI, port 8091 (2026-07-04) +- [x] `scripts/setup-data-disk.sh` — katalogi aplikacji na `/data` +- [x] Root README — roadmapa 05/06/07 +- [x] `stacks/comfyui/` — ComfyUI Docker, port 8188 (2026-07-04); research SM → [`STABILITYMATRIX-RESEARCH.md`](STABILITYMATRIX-RESEARCH.md) +- [x] `manual-tutorial/07-comfyui-stack.md` — tutorial ComfyUI diff --git a/coding-agent/COMFYUI-DEPLOYMENT.md b/coding-agent/COMFYUI-DEPLOYMENT.md new file mode 100644 index 0000000..433d5ff --- /dev/null +++ b/coding-agent/COMFYUI-DEPLOYMENT.md @@ -0,0 +1,119 @@ +# Wdrożenie ComfyUI stack (Docker) + +Data: 2026-07-04 +Host: `gmktec-k11` — RTX 3090 Ti 24 GB, `/data` zamontowany + +## Kontekst + +Po researchu [Stability Matrix](STABILITYMATRIX-RESEARCH.md) — **nie instalujemy SM** (wymaga GUI). Zamiast tego ComfyUI w Dockerze, wzorowany na `stacks/localai/`. + +SwarmUI usunięte 2026-07-04 — [`SWARMUI-REMOVAL.md`](SWARMUI-REMOVAL.md). + +## Podsumowanie stacku + +| Element | Wartość | +|---------|---------| +| Stack | [`stacks/comfyui/`](../stacks/comfyui/) | +| Kontener | `comfyui` | +| Obraz | `yanwk/comfyui-boot:cu126-slim` | +| Port | **8188** | +| Profil compose | `comfyui` | +| Dane | `/data/apps/comfyui/` | + +## Architektura + +```mermaid +flowchart TB + subgraph host [gmktec-k11 headless] + localai[localai :8070] + comfyui[comfyui :8188] + dockge[server-ui :8091] + end + data["/data/apps/comfyui/"] + comfyui --> data + note["GPU: localai LUB comfyui — nie oba z dużym modelem"] +``` + +## Pliki w repo + +| Plik | Rola | +|------|------| +| `stacks/comfyui/docker-compose.yml` | GPU, port 8188, bind mounty | +| `stacks/comfyui/.env.example` | `DATA_ROOT`, `COMFYUI_PORT`, obraz | +| `stacks/comfyui/scripts/start.sh` | ensure-dirs, pull, up, ostrzeżenie o LocalAI | +| `stacks/comfyui/scripts/ensure-dirs.sh` | katalogi na `/data` | +| `stacks/comfyui/scripts/pull.sh` | tylko `docker compose pull` | +| `stacks/comfyui/README.md` | dokumentacja stacku | + +## Katalogi na hoście + +``` +/data/apps/comfyui/ +├── storage/ # kopia ComfyUI (pierwszy start obrazu yanwk) +├── models/ +├── cache/hf-hub/ +├── cache/torch-hub/ +├── input/ +├── output/ +├── custom_nodes/ +└── workflows/ +``` + +Istniejące katalogi z `setup-data-disk.sh` (models, input, output, custom_nodes) są rozszerzone przez `ensure-dirs.sh`. + +## Procedura wdrożenia + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/comfyui +cp .env.example .env + +# opcjonalnie: zatrzymaj LocalAI przed dużym modelem SD +cd ../localai && docker compose --profile localai stop localai +cd ../comfyui + +./scripts/start.sh +``` + +Weryfikacja: + +```bash +docker compose --profile comfyui ps +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8188/ +nvidia-smi +``` + +Oczekiwany HTTP: **200** (po `start_period` healthcheck, ~3 min pierwszy start). + +## Polityka GPU + +| Scenariusz | Akcja | +|------------|-------| +| Chat LLM (LocalAI) | `comfyui` stopped lub bez modelu w VRAM | +| Generowanie obrazów (ComfyUI) | `localai` stopped | +| Portainer | Stop/Start kontenerów według potrzeb | + +## Modele + +- Brak auto-download w `compose pull` +- Użytkownik: ComfyUI-Manager w UI lub ręcznie do `/data/apps/comfyui/models/` +- Szacunki VRAM: SDXL ~8–12 GB; Flux ~12–20 GB (mieści się na 24 GB przy zatrzymanym LocalAI) + +## Dostęp sieciowy + +| Sieć | URL | +|------|-----| +| localhost | `http://127.0.0.1:8188` | +| LAN (po statycznym IP) | `http://192.168.100.90:8188` (enp3s0) | +| Reverse proxy | opcjonalnie później przez NPMPlus (jak LocalAI) | + +## Szacowany rozmiar + +- Obraz Docker: ~5–15 GB (zależnie od wariantu yanwk) +- Modele SD/Flux: dziesiątki GB — użytkownik pobiera sam + +## Powiązane dokumenty + +- [`COMFYUI-HANDOFF.md`](COMFYUI-HANDOFF.md) +- [`STABILITYMATRIX-RESEARCH.md`](STABILITYMATRIX-RESEARCH.md) +- [`manual-tutorial/07-comfyui-stack.md`](../manual-tutorial/07-comfyui-stack.md) +- [`DOCKER-UI-DEPLOYMENT.md`](DOCKER-UI-DEPLOYMENT.md) — polityka GPU w Portainer diff --git a/coding-agent/COMFYUI-HANDOFF.md b/coding-agent/COMFYUI-HANDOFF.md new file mode 100644 index 0000000..00f4637 --- /dev/null +++ b/coding-agent/COMFYUI-HANDOFF.md @@ -0,0 +1,82 @@ +# Handoff: ComfyUI stack + +Data: 2026-07-04 +Dla kolejnego agenta / sesji Cursor + +## Co zrobiono + +1. Research Stability Matrix → decyzja **nie instalować** ([`STABILITYMATRIX-RESEARCH.md`](STABILITYMATRIX-RESEARCH.md)) +2. Utworzono pełny stack [`stacks/comfyui/`](../stacks/comfyui/) — wzorzec `localai` +3. Dokumentacja wdrożenia i tutorial rozdział 07 +4. Zaktualizowano BACKLOG, Portainer README, politykę GPU LocalAI ↔ ComfyUI + +## Co użytkownik musi zrobić na hoście + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/comfyui +cp .env.example .env +./scripts/start.sh +``` + +Pierwszy start: kopia ComfyUI do `/data/apps/comfyui/storage/` — kilka minut, duży pull obrazu. + +Przed generowaniem obrazów z dużym modelem: + +```bash +cd ../localai && docker compose --profile localai stop localai +``` + +## Weryfikacja po wdrożeniu + +```bash +docker ps --filter name=comfyui +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8188/ +nvidia-smi +``` + +UI: `http://192.168.100.90:8188` (LAN, enp3s0) lub localhost. + +## Otwarte / następne kroki + +| Priorytet | Zadanie | +|-----------|---------| +| P1 | Uruchomić stack na hoście (`./scripts/start.sh`) — **nie wykonane w repo-only** | +| P2 | Pobrać pierwszy checkpoint SD/SDXL do `models/` | +| P2 | NPMPlus reverse proxy dla ComfyUI (opcjonalnie, jak LocalAI) | +| P3 | Bind `127.0.0.1:8188` po proxy (bezpieczeństwo) | + +## Polityka GPU (przypomnienie) + +RTX 3090 Ti 24 GB — **jeden** duży workload GPU: + +- LocalAI (LLM) **lub** ComfyUI (SD/Flux) z modelem w VRAM +- W Portainer: Stop/Start `localai` / `comfyui` + +## Pliki kluczowe + +``` +stacks/comfyui/ +├── docker-compose.yml # profile comfyui, port 8188, gpus: all +├── .env.example +└── scripts/start.sh # ostrzeżenie gdy localai running + +coding-agent/ +├── STABILITYMATRIX-RESEARCH.md +├── COMFYUI-DEPLOYMENT.md +└── COMFYUI-HANDOFF.md # ten plik + +manual-tutorial/07-comfyui-stack.md +``` + +## Czego nie robić bez prośby + +- `git commit` / `git push` +- Pobieranie modeli SD przy `docker compose pull` +- Instalacja Stability Matrix AppImage +- Edycja `.cursor/plans/` + +## Powiązane + +- [`BACKLOG.md`](BACKLOG.md) +- [`STATE.md`](STATE.md) — zaktualizuj po uruchomieniu na hoście +- [`SWARMUI-REMOVAL.md`](SWARMUI-REMOVAL.md) diff --git a/coding-agent/CONTROL-PLANE-ENV.md b/coding-agent/CONTROL-PLANE-ENV.md new file mode 100644 index 0000000..dd7f263 --- /dev/null +++ b/coding-agent/CONTROL-PLANE-ENV.md @@ -0,0 +1,149 @@ +# Control plane — jeden plik `.env` + +**Data:** 2026-07-05 +**Status:** wdrożone + +gpu-fan (agent NVML) i Server UI (panel stacków) używają **jednego** pliku credentials. + +## Kanoniczne ścieżki + +| Środowisko | Plik | +|------------|------| +| Produkcja (systemd) | `/opt/control-plane/.env` | +| Dev w repo | `stacks/control-plane/.env` | +| Szablon | `stacks/control-plane/.env.example` | + +Oba serwisy systemd: + +```ini +EnvironmentFile=-/opt/control-plane/.env +``` + +- `server-ui.service` +- `gpu-fan.service` + +## Zmienne + +| Zmienna | Opis | +|---------|------| +| `API_KEY` | **Wspólny** klucz: auth panelu Server UI (`X-API-Key`) + auth agenta gpu-fan (proxy `/api/gpu-fan/*`) | +| `SERVER_UI_HOST`, `SERVER_UI_PORT` | Bind Server UI (domyślnie `0.0.0.0:8091`) | +| `REPO_ROOT` | Ścieżka do `ubuntu-bare-metal` (wymagane dla Docker Server UI) | +| `DOCKER_GID` | GID grupy docker (Docker install) | +| `GPU_FAN_AGENT_URL` | URL agenta z perspektywy Server UI (`http://127.0.0.1:18090` native, `http://host.docker.internal:18090` Docker) | +| `GPU_FAN_API_HOST`, `GPU_FAN_API_PORT` | Bind agenta gpu-fan (domyślnie `127.0.0.1:18090`) | +| `CURVE_PATH`, `POLL_INTERVAL`, `GPU_INDEX` | Konfiguracja NVML / krzywej wentylatorów | + +**Usunięte:** `GPU_FAN_AGENT_KEY` — duplikat `API_KEY`. + +## Ładowanie w Pythonie + +[`stacks/control-plane/env_loader.py`](../stacks/control-plane/env_loader.py) — `load_control_plane_env(stack_dir)`: + +**Produkcja** (`stack_dir` pod `/opt/`): tylko `/opt/control-plane/.env` + `os.environ` (systemd). + +**Dev** (repo `stacks/*`): tylko `stacks/control-plane/.env` + `os.environ`. + +**Nie ładowane:** `stacks/server-ui/.env`, `/opt/server-ui/.env` (legacy — `API_KEY` usuwany przez `setup-control-plane-env.sh`). + +Kolejność priorytetu: pliki env → **`os.environ` wygrywa** (systemd `EnvironmentFile`). + +`api_key_source(stack_dir, values)` — log przy starcie Server UI (bez ujawniania sekretu). + +Produkcja: `env_loader.py` kopiowany do `/opt/control-plane/env_loader.py` przez `setup-control-plane-env.sh`. + +## Instalacja / migracja + +```bash +sudo bash stacks/server-ui/scripts/setup-control-plane-env.sh +sudo systemctl daemon-reload +sudo systemctl restart gpu-fan server-ui +``` + +Skrypt: + +- tworzy `/opt/control-plane/.env` z example jeśli brak +- **migruje** z `/opt/server-ui/.env` i `/opt/gpu-fan/.env` (backup `.env.bak.`) +- generuje `API_KEY` jeśli brak lub `change-me` +- **sync** `stacks/control-plane/.env` z produkcją (ten sam `API_KEY`) +- **usuwa** `API_KEY` / `GPU_FAN_AGENT_KEY` z legacy `stacks/server-ui/.env` +- wypisuje gotowy URL z kluczem (`print-api-key-instructions.sh`) + +Instrukcja dla użytkownika po instalacji: + +```bash +bash stacks/server-ui/scripts/show-api-key.sh +``` + +Pełna instalacja: + +```bash +sudo bash stacks/server-ui/scripts/install-control-plane.sh +``` + +## Weryfikacja + +```bash +bash stacks/server-ui/scripts/show-api-key.sh + +API_KEY=$(sudo grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2) + +# Test klucza w panelu +curl -s -X POST http://127.0.0.1:8091/api/auth/verify \ + -H "Content-Type: application/json" \ + -d "{\"api_key\":\"${API_KEY}\"}" + +# Agent bezpośrednio +curl -s http://127.0.0.1:18090/api/status -H "X-API-Key: ${API_KEY}" + +# Proxy przez Server UI +curl -s http://127.0.0.1:8091/api/gpu-fan/health -H "X-API-Key: ${API_KEY}" +``` + +Oczekiwane: JSON z danymi / `"ok": true`. + +## Docker Server UI + +[`stacks/server-ui/docker-compose.yml`](../stacks/server-ui/docker-compose.yml): + +```yaml +env_file: + - ../control-plane/.env +``` + +## Auth flow + +```mermaid +sequenceDiagram + participant Browser + participant ServerUI + participant GpuFanAgent + Browser->>ServerUI: X-API-Key: API_KEY + ServerUI->>GpuFanAgent: X-API-Key: API_KEY (ten sam) + GpuFanAgent-->>ServerUI: status JSON + ServerUI-->>Browser: proxy response +``` + +## Poza scope + +Sekrety workloadów Docker (`LOCALAI_API_KEY`, `HF_TOKEN`, `INITIAL_ADMIN_PASSWORD`, …) pozostają w `stacks//.env` — edycja portów przez Server UI bez zmian. + +## Pliki zmienione (implementacja) + +- `stacks/control-plane/` — `.env.example`, `env_loader.py` +- `stacks/server-ui/scripts/setup-control-plane-env.sh` +- `server-ui.service`, `gpu-fan.service` +- `server-ui/app.py`, `gpu_fan_proxy.py`, `gpu-fan/fan_daemon.py`, `gpu-fan/app.py` +- Instalatory: `install-control-plane.sh`, `install.sh`, `install-docker.sh`, `gpu-fan/install.sh` +- Skrypty dev: `start.sh`, `deploy-gpu-fan-fix.sh`, `restart-stack.sh`, `enable-lan.sh`, `status.sh` + +## Troubleshooting — Invalid API key + +1. Klucz tylko z `/opt/control-plane/.env` (`sudo grep ^API_KEY= …`) +2. UI: **Zapisz** → **Sprawdź klucz** +3. Sync: `sudo bash stacks/server-ui/scripts/setup-control-plane-env.sh` +4. Tutorial: [`manual-tutorial/04a-api-key.md`](../manual-tutorial/04a-api-key.md) + +## Legacy + +Stare pliki `/opt/server-ui/.env` i `/opt/gpu-fan/.env` nie są już czytane przez systemd. Po migracji można je usunąć (backup w `.env.bak.*`). diff --git a/coding-agent/CONVENTIONS.md b/coding-agent/CONVENTIONS.md new file mode 100644 index 0000000..badcb94 --- /dev/null +++ b/coding-agent/CONVENTIONS.md @@ -0,0 +1,91 @@ +# Konwencje projektu + +Zasady spójne dla wszystkich stacków w `ubuntu-bare-metal`. + +## Stack Docker + +Każdy stack w `stacks//`: + +``` +stacks// +├── README.md +├── docker-compose.yml +├── .env.example # szablon bez sekretów +├── .env # lokalny, w .gitignore +├── .gitignore +└── scripts/ + ├── start.sh # walidacja + pull + up + └── pull.sh # opcjonalnie — tylko obraz +``` + +- **Profile compose:** `profiles: []` — start jawny: `docker compose --profile up -d` +- **GPU:** `gpus: all` + `CUDA_VISIBLE_DEVICES=0` (tylko RTX 3090 Ti) +- **Dane:** bind mounty pod `${DATA_ROOT}/apps//`, nie named volumes (backup na `/data`) +- **Restart:** `restart: unless-stopped` na produkcji + +## Zmienne środowiskowe + +| Zmienna | Typowy zakres | +|---------|----------------| +| `DATA_ROOT` | `/data` | +| `CUDA_VISIBLE_DEVICES` | `0` | +| `HF_TOKEN` | vLLM — tylko gated models | +| `LOCALAI_API_KEY` | LocalAI — legacy API auth | +| `LOCALAI_PORT` | domyślnie 8080 (użytkownik może zmienić, np. 8070) | +| `COMFYUI_PORT` | domyślnie 8188 | +| `VLLM_PORT` | domyślnie 8000 | + +W **Server UI** porty edytowalnych stacków są zapisywane w `stacks//.env`. Metadane (`port_env`, `port_default`, `port_editable`) w `stacks/server-ui/stacks.yaml`. + +Sekrety **tylko** w `.env` na serwerze. W `.env.example` — puste lub komentarz. + +### Control plane (gpu-fan + Server UI) + +Jeden plik credentials — **nie** osobne `/opt/server-ui/.env` i `/opt/gpu-fan/.env`: + +| Środowisko | Plik | +|------------|------| +| Produkcja | `/opt/control-plane/.env` | +| Dev | `stacks/control-plane/.env` | + +Wspólny `API_KEY` dla panelu i proxy gpu-fan. Szczegóły: [`CONTROL-PLANE-ENV.md`](CONTROL-PLANE-ENV.md). + +## Tutorial (`manual-tutorial/`) + +- Numeracja: `00`, `01`, … `03b`, `04`, `05`, … +- Język: polski; komendy i nazwy pakietów po angielsku +- Każdy rozdział: cel, wymagania, komendy, weryfikacja, troubleshooting +- Użytkownik potwierdza ukończenie („krok X gotowy”) przed kolejnym rozdziałem + +## Modele LLM + +- **Brak auto-download** przy `docker compose pull` +- Katalog centralny vLLM: `stacks/vllm/models.catalog.yaml` +- GGUF → LocalAI lub llama.cpp; AWQ/HF → vLLM +- Kilka modeli na dysku, **jeden aktywny w VRAM** + +## Git i bezpieczeństwo + +- Nie commituj: `.env`, `upstream/`, kluczy API, tokenów HF +- `coding-agent/` — bez wartości sekretów +- Commity tylko na wyraźną prośbę użytkownika +- Nie `git push --force` na main/master + +## Ścieżki bezwzględne na serwerze + +Domyślny root repo: + +``` +/home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal +``` + +W tutorialach używaj tej pełnej ścieżki w przykładach `cd`. + +## GMKtec K11 — numeracja NVMe + +| Urządzenie | Rozmiar | Rola | +|------------|---------|------| +| `nvme1n1` | ~128 GB | System `/` | +| `nvme0n1` | ~1 TB | Dane `/data` | + +Zawsze weryfikuj po `SIZE` i `MOUNTPOINT`, nie po numerze urządzenia. diff --git a/coding-agent/DOCKER-UI-DEPLOYMENT.md b/coding-agent/DOCKER-UI-DEPLOYMENT.md new file mode 100644 index 0000000..03821a8 --- /dev/null +++ b/coding-agent/DOCKER-UI-DEPLOYMENT.md @@ -0,0 +1,90 @@ +# Wdrożenie UI zarządzania Dockerem (Server UI) + +Data: 2026-07-05 (aktualizacja: instalator control plane) +Host: `gmktec-k11` (`192.168.100.90` — enp3s0) + +## Kontekst + +- Portainer usunięty 2026-07-04 — [`PORTAINER-REMOVAL.md`](PORTAINER-REMOVAL.md) +- Dockge usunięty 2026-07-04 — [`DOCKGE-REMOVAL.md`](DOCKGE-REMOVAL.md) +- **Własny panel:** [`stacks/server-ui/`](../stacks/server-ui/) — port **8091** +- **gpu-fan:** host agent :18090 (tylko systemd, bez Docker) + +--- + +## Podsumowanie + +| Element | Wartość | +|---------|---------| +| Stack | [`stacks/server-ui/`](../stacks/server-ui/) | +| Runtime native | systemd (`server-ui.service`) → `/opt/server-ui` | +| Runtime Docker | `docker compose --profile server-ui` (opcjonalnie) | +| HTTP | **http://192.168.100.90:8091** | +| Auth mutacji | `X-API-Key` — `/opt/control-plane/.env` (native) lub `stacks/control-plane/.env` (docker) | +| Stacki | `localai`, `vllm`, `comfyui`, `npmplus` | + +**Server UI nie pojawia się w `docker ps` przy instalacji native** — to usługa systemd, nie kontener. + +--- + +## Instalacja (zalecana) + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/server-ui +sudo ./scripts/install-control-plane.sh +``` + +Menu: gpu-fan native (Y/n) + Server UI native (1) / Docker (2) / skip (3). + +```bash +# Bez pytań: gpu-fan + server-ui native +sudo ./scripts/install-control-plane.sh -y + +# Tylko Server UI Docker +sudo ./scripts/install-control-plane.sh --gpu-fan=no --server-ui=docker +``` + +### Tylko Server UI + +| Tryb | Komenda | +|------|---------| +| Native | `sudo ./scripts/install.sh` | +| Docker | `sudo ./scripts/install-docker.sh` | + +Klucze: +```bash +grep ^API_KEY= /opt/control-plane/.env +``` + +Tutorial: [`manual-tutorial/08-server-ui-install.md`](../manual-tutorial/08-server-ui-install.md) + +--- + +## Pierwsze użycie + +1. Otwórz **http://192.168.100.90:8091/?api_key=KLUCZ** +2. Karty stacków: Start/Stop, logi, edycja portów, linki (8070, 8188, …) +3. Zakładka **GPU Fan** — wymaga działającego `gpu-fan.service` + +## Polityka GPU + +RTX 3090 Ti 24 GB — jeden duży workload GPU naraz. API zwraca **409** przy konflikcie. + +## Weryfikacja + +```bash +# Native +systemctl status server-ui gpu-fan +curl -s http://127.0.0.1:8091/api/health + +# Docker +docker compose --profile server-ui ps +curl -s http://127.0.0.1:8091/api/stacks +``` + +## Powiązane + +- [`SERVER-UI-INSTALL-OPTIONS.md`](SERVER-UI-INSTALL-OPTIONS.md) +- [`SERVER-UI-PORT-CONFIG.md`](SERVER-UI-PORT-CONFIG.md) +- [`DOCKER-UI-HANDOFF.md`](DOCKER-UI-HANDOFF.md) +- [`stacks/server-ui/README.md`](../stacks/server-ui/README.md) diff --git a/coding-agent/DOCKER-UI-HANDOFF.md b/coding-agent/DOCKER-UI-HANDOFF.md new file mode 100644 index 0000000..729f44d --- /dev/null +++ b/coding-agent/DOCKER-UI-HANDOFF.md @@ -0,0 +1,55 @@ +# Handoff — Server UI (Docker stack manager) + +## Stan: kod gotowy (2026-07-04) + +- [x] `stacks/server-ui/` — app, API, dashboard, systemd unit +- [x] Dockge usunięty z Dockera +- [ ] **Produkcja:** `sudo ./scripts/install.sh` (wymaga hasła sudo na hoście) +- [x] Dev mode zweryfikowany: `:8091` HTTP 200, `/api/gpu`, `/api/stacks` + +Raport: [`DOCKER-UI-DEPLOYMENT.md`](DOCKER-UI-DEPLOYMENT.md) + +--- + +## URL + +``` +http://192.168.100.90:8091 +``` + +API Key po `sudo ./scripts/install.sh`: + +```bash +grep ^API_KEY= /opt/control-plane/.env +``` + +Dev mode (z repo): + +```bash +cp stacks/control-plane/.env.example stacks/control-plane/.env +cd stacks/server-ui && ./scripts/start.sh +``` + +--- + +## Zarządzanie GPU + +Stop/Start stacków `localai` / `comfyui` / `vllm` w UI — jeden aktywny GPU workload naraz. + +--- + +## Czego nie robić + +- Nie przywracać Portainera/Dockge bez uzgodnienia +- Nie commitować `.env` z API_KEY +- Nie `git commit` bez prośby użytkownika + +--- + +## Diagnostyka + +```bash +systemctl status server-ui +journalctl -u server-ui -f +curl -s http://127.0.0.1:8091/api/stacks +``` diff --git a/coding-agent/DOCKGE-REMOVAL.md b/coding-agent/DOCKGE-REMOVAL.md new file mode 100644 index 0000000..9abd7f4 --- /dev/null +++ b/coding-agent/DOCKGE-REMOVAL.md @@ -0,0 +1,36 @@ +# Usunięcie Dockge + +Data: 2026-07-04 +Host: `gmktec-k11` + +## Powód + +Dockge wdrożony tymczasowo po usunięciu Portainera. Zastąpiony **własnym Server UI** ([`stacks/server-ui/`](../stacks/server-ui/)) — skupiony panel pod znane stacki, bez zewnętrznej zależności. + +## Co usunięto + +| Element | Status | +|---------|--------| +| Kontener `dockge` | usunięty | +| Obraz `louislam/dockge:1` | usunięty | +| Stack Docker | `docker compose --profile dockge down` | + +## Co pozostało (opcjonalnie do ręcznego cleanup) + +``` +/data/apps/dockge/data/ # dane Dockge (nieużywane) +/data/apps/stacks/ # symlinki do repo (nieużywane przez Server UI) +stacks/dockge/ # deprecated w repo +``` + +## Zamiennik + +**Server UI** — port **8091**, systemd na hoście: + +- [`stacks/server-ui/README.md`](../stacks/server-ui/README.md) +- [`DOCKER-UI-DEPLOYMENT.md`](DOCKER-UI-DEPLOYMENT.md) + +## Powiązane + +- [`PORTAINER-REMOVAL.md`](PORTAINER-REMOVAL.md) +- [`DOCKER-UI-HANDOFF.md`](DOCKER-UI-HANDOFF.md) diff --git a/coding-agent/GITEA-PREP.md b/coding-agent/GITEA-PREP.md new file mode 100644 index 0000000..2ff8953 --- /dev/null +++ b/coding-agent/GITEA-PREP.md @@ -0,0 +1,113 @@ +# Przygotowanie repozytorium pod Gitea + +Raport z sesji przygotowującej pierwszy commit `ubuntu-bare-metal`. + +## Analiza stanu wyjściowego + +| Aspekt | Wynik | +|--------|--------| +| Git | Katalog **nie był** repozytorium — wykonano `git init` | +| Gitea remote | **Nie skonfigurowany** — URL do podania przez użytkownika | +| Pliki `.env` | 7 na dysku (sekrety) — **wykluczone** przez root `.gitignore` | +| `.venv` | `stacks/server-ui/.venv`, `stacks/gpu-fan/.venv` (~54 MB każdy) — **wykluczone** | +| Bloker bezpieczeństwa | Prawdziwy `API_KEY` w `manual-tutorial/04a-api-key.md` — **zredagowany** na placeholder | + +## Zmiany przygotowawcze (ta sesja) + +1. **Root [`.gitignore`](../.gitignore)** — `.env`, `.venv`, `__pycache__`, `upstream/`, `.cursor/` +2. **Redakcja** [`manual-tutorial/04a-api-key.md`](../manual-tutorial/04a-api-key.md) — przykład `API_KEY` to placeholder `xxxxxxxx…`, nie produkcyjny klucz +3. **`git init`** + branch `main` +4. **Pierwszy commit** — pełny import repo (bez push) + +## Zakres pierwszego commita + +``` +ubuntu-bare-metal/ +├── README.md +├── .gitignore +├── scripts/ +├── manual-tutorial/ # 13 rozdziałów (00–10, 04a) +├── coding-agent/ # handoff, ADR, deployment notes +└── stacks/ + ├── vllm/ + ├── localai/ + ├── comfyui/ + ├── gpu-fan/ + ├── server-ui/ # panel :8091, CLI PTY, Pliki, GPU Fan + ├── control-plane/ # env_loader, wspólny API_KEY + ├── npmplus/ + └── llamacpp/ +``` + +### Nie trafia do gita + +- `**/.env` (produkcja: `/opt/control-plane/.env` poza repo) +- `**/.venv/`, `**/__pycache__/` +- `stacks/localai/upstream/` (jeśli sklonowany lokalnie) + +## Ostatnie zmiany Server UI (GPU Fan chart) + +W [`stacks/server-ui/static/index.html`](../stacks/server-ui/static/index.html): + +- Siatka i podziałka temperatury (°C) / prędkości (%) +- Linijki kreskowe (zamiast kolorowych pasków gradientowych) +- Odstęp etykiet Y od lewej linijki (`yLabelX`) +- Odstęp etykiet X od dolnej linijki (`xLabelY`) +- Responsywne SVG: `aspect-ratio: 600/360`, `height: auto` (bez rozciągania na mobile) +- Wyśrodkowanie wykresu w karcie: `margin: 0 auto` + +Powiązane docs: [`manual-tutorial/06-gpu-fan-control.md`](../manual-tutorial/06-gpu-fan-control.md), [`SERVER-UI-CLI.md`](SERVER-UI-CLI.md), [`SERVER-UI-CLI-BUGFIX.md`](SERVER-UI-CLI-BUGFIX.md). + +## Push na Gitea (gdy masz URL) + +```bash +cd ~/cursor/ubuntu-bare-metal + +# jednorazowo — podstaw URL z Gitea (SSH lub HTTPS) +git remote add origin + +# jeśli repo na Gitea ma już README / initial commit: +# git pull origin main --allow-unrelated-histories +# (rozwiąż ewentualne konflikty, potem push) + +git push -u origin main +``` + +Przykładowe URL: + +- HTTPS: `https://gitea.example.com/user/ubuntu-bare-metal.git` +- SSH: `git@gitea.example.com:user/ubuntu-bare-metal.git` + +## Checklist bezpieczeństwa przed każdym commitem + +```bash +cd ~/cursor/ubuntu-bare-metal + +# 1. Brak sekretów w staged plikach +git diff --cached | grep -iE 'API_KEY=|HF_TOKEN=|password=|secret=' && echo 'STOP: sekret w diff!' || echo 'OK' + +# 2. .env nie jest śledzony +git status --short | grep '\.env' && echo 'STOP: .env w staging!' || echo 'OK' + +# 3. Dry-run — co trafi do commita +git add -n . + +# 4. Szukaj przypadkowych kluczy w docs +grep -rE '[a-f0-9]{32}' manual-tutorial/ coding-agent/ --include='*.md' | grep -v xxxxxxxx +``` + +## Zalecenia po redakcji klucza w tutorialu + +Klucz produkcyjny był kiedyś w `04a-api-key.md`. Rozważ **rotację** `API_KEY` w `/opt/control-plane/.env` i restart `server-ui` / `gpu-fan`, jeśli repo będzie publiczne lub współdzielone. + +## Weryfikacja po clone na innym hoście + +```bash +git clone ubuntu-bare-metal +cd ubuntu-bare-metal/stacks/control-plane +cp .env.example .env # uzupełnij API_KEY lokalnie +``` + +--- + +*Wygenerowano przy pierwszym imporcie git — lipiec 2026.* diff --git a/coding-agent/HANDOFF.md b/coding-agent/HANDOFF.md new file mode 100644 index 0000000..e601566 --- /dev/null +++ b/coding-agent/HANDOFF.md @@ -0,0 +1,104 @@ +# Handoff — sesja ubuntu-bare-metal + +Dokument dla agenta kontynuującego pracę. Ostatni znany stan po sesji konfiguracji serwera LLM na GMKtec K11. + +## Kontekst projektu + +| Element | Wartość | +|---------|---------| +| Repo | `/home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal` | +| Urządzenie | GMKtec K11 | +| OS | Ubuntu 26.04 LTS minimized (`resolute`) | +| GPU | NVIDIA GeForce RTX 3090 Ti, 24 GB VRAM (`CUDA_VISIBLE_DEVICES=0`) | +| RAM | ~29 GiB | +| Dysk systemowy | `nvme1n1` 128 GB → `/` | +| Dysk danych | `nvme0n1` 1 TB → `/data` | +| Docker data-root | `/data/docker` | +| Użytkownik | `tomasz-syn-grzegorza` (sudo) | + +**Workflow:** tutorial krok po kroku w [`manual-tutorial/`](../manual-tutorial/). Użytkownik czyta markdown i wykonuje komendy ręcznie. Agent nie może wpisać hasła `sudo` interaktywnie — skrypty wymagające sudo użytkownik uruchamia w swoim terminalu SSH. + +## Ukończone (infrastruktura + repo) + +| Rozdział / obszar | Status | +|-------------------|--------| +| 01 — Docker CE ~29.x | Ukończony przez użytkownika | +| 02 — NVIDIA driver 595 open | Ukończony | +| 03 — NVIDIA Container Toolkit 1.19 | Ukończony | +| 03b — narzędzia minimized | Dokumentacja gotowa | +| 04A — dysk 1 TB, fstab, migracja Docker | Ukończony (`scripts/setup-data-disk.sh`) | +| 04B — stack vLLM | Repo gotowe; obraz Docker pobrany; **kontener nie uruchomiony** (brak modelu) | +| Katalog modeli vLLM | `stacks/vllm/models.catalog.yaml` + skrypty list/download/switch | +| Placeholder llama.cpp | `stacks/llamacpp/README.md` | +| 05 — stack LocalAI | Repo gotowe; **kontener uruchomiony przez użytkownika** | +| LocalAI API key | Użytkownik dodał `LOCALAI_API_KEY` do `stacks/localai/.env` — **wartość nie dokumentowana tutaj** | + +## Stan runtime (faktyczny na serwerze) + +| Serwis | Stan | +|--------|------| +| vLLM | Obraz `vllm/vllm-openai:latest` na dysku; kontener **nie działa** (`VLLM_MODEL` puste w `.env`) | +| LocalAI | Kontener `localai` **running**; obraz `localai/localai:v4.4.3-gpu-nvidia-cuda-13` | +| Model LocalAI | Użytkownik **w trakcie pobierania** modelu (prawdopodobnie przez UI / galerię) | +| API key | Ustawiony w `.env` — **wymaga** przekazania do kontenera w `docker-compose.yml` + restart (patrz BACKLOG P0) | + +## Porty + +| Serwis | Port w dokumentacji | Port faktyczny (`.env` użytkownika) | +|--------|---------------------|-------------------------------------| +| vLLM API | 8000 | 8000 (nieaktywny) | +| LocalAI UI + API | 8080 | **8070** | +| llama.cpp (plan) | 8001 | nie wdrożony | + +Wewnątrz kontenera LocalAI zawsze nasłuchuje na **8080**; mapowanie hosta ustawia `LOCALAI_PORT` w `.env`. + +## Decyzje techniczne (nie zmieniać bez uzgodnienia) + +1. **GGUF** z lmstudio-community **nie działa** w standardowym `vllm/vllm-openai` — użycie przez LocalAI (llama.cpp backend) lub przyszły host llama.cpp. +2. **vLLM interim** dla Q4-odpowiednika: AWQ `Qwen/Qwen3.6-27B-Instruct-AWQ`, profil `qwen3.6-27b-awq-128k`, kontekst 128K (`MAX_MODEL_LEN=131072`), KV `fp8`. +3. **LocalAI:** oficjalny obraz Docker (nie build ze źródeł); bez `command: phi-2` (brak auto-pobierania modelu przy starcie). +4. **Jeden duży model w VRAM** naraz na 24 GB — nie uruchamiać vLLM + LocalAI z dużymi modelami równolegle. +5. **Modele docelowe (katalog):** Qwen3.6-27B Q4_K_M GGUF, Gemma 4 12B Q4_0 GGUF (lmstudio-community). +6. **Brak domyślnego modelu** przy instalacji stacków — pobieranie on-demand. + +## Co zrobiono w repo (implementacja agenta) + +### vLLM (`stacks/vllm/`) + +- `models.catalog.yaml` — GGUF (llamacpp) + AWQ (vllm) +- Elastyczny `docker-compose.yml` + `vllm-entrypoint.sh` (`QUANTIZATION`, `VLLM_EXTRA_ARGS`) +- Skrypty: `list-models.sh`, `download-model.sh`, `switch-model.sh`, `start.sh` +- Profile: `qwen3.6-27b-awq-128k.env`, `_template.env` +- Tutorial część B w `manual-tutorial/04-vllm-stack.md` + +### LocalAI (`stacks/localai/`) + +- `docker-compose.yml`, `.env.example`, skrypty `pull.sh`, `start.sh`, `clone-upstream.sh` +- Bind mounty na `/data/apps/localai/{models,backends,configuration,images,data}` +- Tutorial `manual-tutorial/05-localai-stack.md` +- Roadmapa root README: 05 LocalAI, 06 ComfyUI, 07 firewall + +### Inne + +- `scripts/setup-data-disk.sh` — rozszerzony o katalogi `gguf/` i `localai/` + +## Ostatnia rozmowa (advisory — nie wdrożone w repo) + +Użytkownik pytał o: + +1. **Wystawienie endpointu przez domenę** — reverse proxy (Caddy/nginx) + DNS + HTTPS; LocalAI za proxy na `127.0.0.1:PORT`. +2. **API token** — opcja A: `LOCALAI_API_KEY` (legacy, pełny admin); opcja B: `LOCALAI_AUTH=true` + `LOCALAI_BASE_URL` (konta użytkowników). + +Brak w repo: `Caddyfile`, rozdział proxy/TLS, wpis `LOCALAI_API_KEY` w `docker-compose.yml`. + +## Następny agent — zacznij od + +1. Przeczytaj [BACKLOG.md](BACKLOG.md) — sekcja **P0**. +2. Napraw przekazanie `LOCALAI_API_KEY` do kontenera i zweryfikuj auth po restarcie. +3. Uzgodnij z użytkownikiem port **8070** vs **8080** w dokumentacji. +4. Po zakończeniu pobierania modelu — test chat w UI i `curl /v1/chat/completions`. + +## Transkrypt sesji + +Pełna historia rozmowy (Cursor): +`/home/tomasz-syn-grzegorza/.cursor/projects/home-tomasz-syn-grzegorza/agent-transcripts/f1d1befe-e1e0-44e8-8a40-1f175e6df09c/f1d1befe-e1e0-44e8-8a40-1f175e6df09c.jsonl` diff --git a/coding-agent/NETWORK-STATIC-IP-BLOG.md b/coding-agent/NETWORK-STATIC-IP-BLOG.md new file mode 100644 index 0000000..340ce32 --- /dev/null +++ b/coding-agent/NETWORK-STATIC-IP-BLOG.md @@ -0,0 +1,144 @@ +# Dwa adresy IP na serwerze domowym — poradnik krok po kroku + +*Dla hosta GMKtec K11 (`gmktec-k11`), Ubuntu 26.04, dwie karty Ethernet.* + +--- + +## Po co dwa IP? + +Serwer ma **dwie fizyczne karty sieciowe**. Docelowo (gdy oba kable podłączone): + +| Karta | Adres | Do czego | +|-------|-------|----------| +| **eno1** | `192.168.100.80` | Internet — brama domyślna `192.168.100.1` | +| **enp3s0** | `192.168.100.90` | LAN — Portainer, LocalAI | + +Skrypt **sam wykrywa**, które porty mają kabel, i pokazuje menu z sensownymi opcjami. Nie musisz zgadywać, który port „działa”. + +--- + +## Zanim zaczniesz + +### 1. Podłącz kabel(e) + +- **Oba porty** — wybierzesz konfigurację dual (`.80` na eno1 + brama, `.90` na enp3s0). +- **Jeden port** — skrypt zaproponuje wariant pojedynczy (np. tylko enp3s0 z `.90` i bramą). + +Sprawdź ręcznie: + +```bash +ip -br link show eno1 enp3s0 +``` + +Kolumna stanu: `UP` + link = kabel w porcie. + +### 2. Zarezerwuj adresy na routerze + +Wyklucz z DHCP: `192.168.100.80` i `192.168.100.90`. + +### 3. Plan B + +Druga sesja SSH lub konsola fizyczna — po zmianie IP stara sesja może się rozłączyć. + +--- + +## Instalacja — skrypt ze skanem i menu + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/scripts +``` + +### Podgląd (bez zmian) + +```bash +sudo ./configure-static-dual-nic.sh --dry-run +``` + +Zobaczysz skan interfejsów, domyślną opcję menu i YAML Netplan. + +### Właściwe uruchomienie + +```bash +sudo ./configure-static-dual-nic.sh +``` + +Przykład interakcji: + +``` +=== Skan interfejsów === + Interfejs Stan Link IPv4 + eno1 UP NIE — + enp3s0 UP TAK 192.168.100.2/24 + +=== Wybierz konfigurację === + 1) Pojedynczy enp3s0: .90+brama (zalecane — usługi Docker) + 2) Pojedynczy enp3s0: .80+brama + 3) Pojedynczy enp3s0: .80+.90+brama (oba IP na jednej karcie) + 0) Anuluj + +Wybór [1-3, domyślnie 1]: +``` + +Gdy **oba** porty mają link: + +``` + 1) Dual: eno1=.80+brama, enp3s0=.90 (zalecane) + 2) Dual odwrócony: enp3s0=.80+brama, eno1=.90 +``` + +Skrypt dalej: backup Netplan → zapis → `netplan apply` → weryfikacja IP i ping do routera. + +--- + +## Sprawdzenie, czy działa + +```bash +ip -br addr show eno1 enp3s0 +ip route show default +ping -c 2 192.168.100.1 +``` + +Usługi (gdy `.90` na enp3s0 lub w profilu „both” na enp3s0): + +- Server UI: `http://192.168.100.90:8091` +- LocalAI: `http://192.168.100.90:8070` + +--- + +## Co zrobić, gdy coś poszło nie tak + +```bash +sudo ./restore-dhcp-network.sh +ip -br addr +``` + +SSH może wrócić pod adresem DHCP (np. `192.168.100.2`). + +Po rollbacku uruchom ponownie `configure-static-dual-nic.sh` i wybierz inną opcję z menu (np. pojedynczy enp3s0, jeśli eno1 nie współpracuje). + +--- + +## FAQ + +**Miałem kabel w eno1 i nie było IP — co się stało?** +Stara wersja skryptu wymuszała bramę na eno1 bez uwzględnienia stanu drugiej karty. Wersja v2 używa poprawnego Netplan (`optional`, `ConfigureWithoutCarrier`) i menu zależnego od kabli. + +**Czy muszę restartować serwer?** +Nie — wystarczy `netplan apply` (robi to skrypt). + +**Który adres przy port forward na routerze?** +Zwykle **`192.168.100.90`** — sprawdź `ip a`, gdzie faktycznie siedzi `.90`. + +--- + +## Pliki + +| Plik | Opis | +|------|------| +| [`scripts/configure-static-dual-nic.sh`](../scripts/configure-static-dual-nic.sh) | Skan + menu + statyczne IP | +| [`scripts/restore-dhcp-network.sh`](../scripts/restore-dhcp-network.sh) | Powrót do DHCP | +| [`NETWORK-STATIC-IP-HANDOFF.md`](NETWORK-STATIC-IP-HANDOFF.md) | Notatki dla agenta | + +--- + +*Ostatnia aktualizacja: 2026-07-04 (skrypt v2 — skan + menu).* diff --git a/coding-agent/NETWORK-STATIC-IP-DEPLOYMENT.md b/coding-agent/NETWORK-STATIC-IP-DEPLOYMENT.md new file mode 100644 index 0000000..9ba1496 --- /dev/null +++ b/coding-agent/NETWORK-STATIC-IP-DEPLOYMENT.md @@ -0,0 +1,94 @@ +# Raport wdrożenia — statyczne IP dual NIC + +> **Szablon** — uzupełnij po uruchomieniu `configure-static-dual-nic.sh` na hoście. + +## Metadane + +| Pole | Wartość | +|------|---------| +| Host | `gmktec-k11` | +| Data wdrożenia | _do uzupełnienia_ | +| Wykonał | _do uzupełnienia_ | +| Skrypt | [`scripts/configure-static-dual-nic.sh`](../scripts/configure-static-dual-nic.sh) | +| **Wybrany profil** | _np. dual-eno1, single-enp3s0-90_ | +| **Opis profilu** | _np. eno1=.80+brama, enp3s0=.90_ | + +--- + +## Skan przed apply (z outputu skryptu) + +| Interfejs | Link | IPv4 przed | +|-----------|------|------------| +| eno1 | _TAK/NIE_ | _…_ | +| enp3s0 | _TAK/NIE_ | _…_ | + +--- + +## Konfiguracja po apply + +| Interfejs | IP | Brama | +|-----------|-----|-------| +| eno1 | _…_ | _tak/nie_ | +| enp3s0 | _…_ | _tak/nie_ | + +--- + +## Pre-flight + +- [ ] Co najmniej jeden interfejs z linkiem +- [ ] `.80` i `.90` zarezerwowane na routerze +- [ ] Backup Netplan utworzony + +Ścieżka backupu: `_/etc/netplan/00-installer-config.yaml.bak._________` + +--- + +## Wyniki weryfikacji (wklej output) + +### `ip -br addr show eno1 enp3s0` + +``` +_do uzupełnienia_ +``` + +### `ip route show default` + +``` +_do uzupełnienia_ +``` + +### Ping + +| Test | Wynik | +|------|-------| +| `ping -I 192.168.100.1` | _OK / FAIL_ | +| `ping 8.8.8.8` | _OK / FAIL_ | + +### Usługi + +| URL | Kod | +|-----|-----| +| `http://192.168.100.90:8091` (Server UI) | _…_ | +| `http://192.168.100.90:8070` (LocalAI) | _…_ | + +--- + +## Problemy i rozwiązania + +_do uzupełnienia_ + +--- + +## Rollback + +- [ ] Nie użyto +- [ ] `restore-dhcp-network.sh` — data: _ + +--- + +## Status + +- [ ] Wdrożenie zakończone pomyślnie +- [ ] Wymaga follow-up + +**Następne kroki:** [`NETWORK-STATIC-IP-HANDOFF.md`](NETWORK-STATIC-IP-HANDOFF.md) — sync IP w dokumentacji. diff --git a/coding-agent/NETWORK-STATIC-IP-HANDOFF.md b/coding-agent/NETWORK-STATIC-IP-HANDOFF.md new file mode 100644 index 0000000..2708627 --- /dev/null +++ b/coding-agent/NETWORK-STATIC-IP-HANDOFF.md @@ -0,0 +1,109 @@ +# Handoff — statyczne IP (dual NIC) dla agenta kodującego + +## Stan: skrypt v2 (skan + menu), wdrożenie przez użytkownika + +- [x] [`scripts/configure-static-dual-nic.sh`](../scripts/configure-static-dual-nic.sh) — Netplan **1.2** (`ignore-carrier`, DNS na interfejsie z bramą; bez `networkd:`) +- [x] [`scripts/restore-dhcp-network.sh`](../scripts/restore-dhcp-network.sh) — pomija zepsute backupy; `--force-dhcp` +- [ ] **Użytkownik uruchamia** skrypt na hoście +- [ ] Po apply: uzupełnić [`NETWORK-STATIC-IP-DEPLOYMENT.md`](NETWORK-STATIC-IP-DEPLOYMENT.md) (wybrany profil) +- [ ] Zsynchronizować `192.168.100.5` → `192.168.100.90` w pozostałych handoffach + +Artykuł: [`NETWORK-STATIC-IP-BLOG.md`](NETWORK-STATIC-IP-BLOG.md). + +--- + +## Jak działa skrypt (v2) + +1. **Skan** `eno1` / `enp3s0` — stan, link (TAK/NIE), obecny IPv4 +2. **Menu CLI** — tylko opcje pasujące do wykrytych kabli +3. **Netplan** — globalne `nameservers`, `optional` / `networkd.ConfigureWithoutCarrier`, brama z `metric: 100` +4. **Weryfikacja** — oczekiwane IP, `networkctl reconfigure`, `ping -I 192.168.100.1` + +### Profile (menu / `--profile`) + +| Profil | eno1 | enp3s0 | Brama | +|--------|------|--------|-------| +| `dual-eno1` (dual, zalecane) | `.80` | `.90` | eno1 | +| `dual-enp3s0` | `.90` | `.80` | enp3s0 | +| `single-eno1-80` | `.80` | — | eno1 | +| `single-eno1-both` | `.80` + `.90` | — | eno1 | +| `single-enp3s0-90` | — | `.90` | enp3s0 | +| `single-enp3s0-80` | — | `.80` | enp3s0 | +| `single-enp3s0-both` | — | `.80` + `.90` | enp3s0 | + +Menu pokazuje się tylko gdy **co najmniej jeden** interfejs ma link. Żaden link → błąd i `exit 1`. + +--- + +## Uruchomienie + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/scripts + +# Interaktywnie (skan + menu): +sudo ./configure-static-dual-nic.sh + +# Podgląd YAML (domyślna opcja 1 z menu po skanie): +sudo ./configure-static-dual-nic.sh --dry-run + +# Bez menu (agent / automatyzacja): +sudo ./configure-static-dual-nic.sh --non-interactive --profile dual-eno1 +``` + +Rollback (pomija zepsute backupy ze statycznej konfiguracji): + +```bash +sudo ./restore-dhcp-network.sh +# lub wymuś czysty DHCP: +sudo ./restore-dhcp-network.sh --force-dhcp +``` + +--- + +## Pre-flight + +1. Zarezerwuj `.80` i `.90` na routerze (DHCP) +2. Druga sesja SSH lub konsola fizyczna +3. Skrypt tworzy backup: `/etc/netplan/00-installer-config.yaml.bak.` + +**Nie zakładaj** sztywnie kabla w `eno1` — skrypt wykrywa link i proponuje właściwy profil. + +--- + +## Weryfikacja po apply + +```bash +ip -br addr show eno1 enp3s0 +ip route show default +ping -c 2 -I eno1 192.168.100.1 # lub -I enp3s0, zależnie od profilu +curl -s -o /dev/null -w '%{http_code}\n' http://192.168.100.90:8091 +``` + +--- + +## Troubleshooting + +| Problem | Działanie | +|---------|-----------| +| Brak IPv4 na eno1 mimo kabla (stary bug) | v2: `ConfigureWithoutCarrier` + menu; spróbuj `dual-eno1` lub `single-enp3s0-90` jeśli tylko enp3s0 | +| Brak internetu | `ip route`; brama musi być na interfejsie **z kablem** | +| SSH urwane | `ip a` z konsoli; rollback `restore-dhcp-network.sh` | +| Konflikt IP | Zwolnij `.80`/`.90` na routerze | + +--- + +## Follow-up po wdrożeniu + +Pliki z `192.168.100.5` → **`.90`** (gdy usługi na enp3s0): + +- `coding-agent/DOCKER-UI-*.md`, `SWARMUI-*.md`, `PORTAINER-SETUP-TOKEN.md`, `BACKLOG.md` +- `stacks/npmplus/README.md`, `manual-tutorial/06-gpu-fan-control.md` + +Gdy wybrano `single-enp3s0-both` lub `single-enp3s0-90` — usługi pod `.90` na enp3s0. + +--- + +## Backlog + +- [ ] Wypełnić `NETWORK-STATIC-IP-DEPLOYMENT.md` z wybranym profilem +- [ ] Zaktualizować IP w handoffach diff --git a/coding-agent/PORTAINER-REMOVAL.md b/coding-agent/PORTAINER-REMOVAL.md new file mode 100644 index 0000000..4012f33 --- /dev/null +++ b/coding-agent/PORTAINER-REMOVAL.md @@ -0,0 +1,40 @@ +# Usunięcie Portainer CE + +Data: 2026-07-04 +Host: `gmktec-k11` + +## Powód + +Portainer 2.43+ wymaga setup tokena i dokończenia konfiguracji w ~5 minut. Na headless serwerze (SSH-only) powodowało to komunikat timeout i blokadę UI. + +## Co usunięto + +| Element | Status | +|---------|--------| +| Kontener `portainer` | usunięty | +| Obraz `portainer/portainer-ce:latest` | usunięty | +| Stack w Dockerze | `docker compose --profile portainer down` | + +## Co pozostało na dysku (nie ruszane) + +``` +/data/apps/portainer/ +├── data/ # portainer.db, konfiguracja UI +└── admin-password # plik hasła (jeśli istniał) +``` + +Katalog `stacks/portainer/` w repo — usunięty; zastąpiony przez [`stacks/server-ui/`](../stacks/server-ui/). + +## Zamiennik + +**Server UI** — port **8091**, [`DOCKER-UI-DEPLOYMENT.md`](DOCKER-UI-DEPLOYMENT.md) + +- Własny panel FastAPI (whitelist stacków) +- Polityka GPU wbudowana w API +- Start/Stop: nagłówek `X-API-Key` + +## Powiązane + +- [`PORTAINER-SETUP-TOKEN.md`](PORTAINER-SETUP-TOKEN.md) — archiwum procedur Portainer +- [`stacks/server-ui/README.md`](../stacks/server-ui/README.md) +- [`DOCKGE-REMOVAL.md`](DOCKGE-REMOVAL.md) diff --git a/coding-agent/PORTAINER-SETUP-TOKEN.md b/coding-agent/PORTAINER-SETUP-TOKEN.md new file mode 100644 index 0000000..ad5f11a --- /dev/null +++ b/coding-agent/PORTAINER-SETUP-TOKEN.md @@ -0,0 +1,225 @@ +# Portainer — setup token (archiwum) + +> **Portainer usunięty 2026-07-04.** Aktualny Docker UI: [Server UI](../stacks/server-ui/README.md) na porcie **8091**. + +Od Portainer **2.43** (w tym `portainer-ce:latest`) przy **pierwszej** konfiguracji panelu potrzebny jest **setup token** — jednorazowy kod z logów kontenera. Chroni to świeżą instalację przed przejęciem przez kogoś innego w sieci LAN. + +**To nie jest** token API z menu użytkownika (ten tworzysz później, po zalogowaniu). + +--- + +## Czy mnie to dotyczy? + +| Sytuacja | Co robić | +|----------|----------| +| Logujesz się już **loginem i hasłem** | Setup token **nie** jest potrzebny | +| W przeglądarce widzisz pole **Setup token** | Potrzebujesz kodu z logów (poniżej) | +| Plik `/data/apps/portainer/data/portainer.db` **istnieje** | Instancja została już zainicjowana — zwykle logujesz się hasłem, nie tokenem | + +Sprawdzenie na serwerze (SSH): + +```bash +ls -la /data/apps/portainer/data/portainer.db +``` + +Jeśli plik jest — konto admina mogło zostać utworzone. Jeśli nie pamiętasz hasła, patrz sekcja [Reset od zera](#reset-od-zera-ostrożnie). + +--- + +## Jak znaleźć setup token (krok po kroku) + +### 1. Upewnij się, że kontener działa + +```bash +docker ps --filter name=portainer +``` + +Powinien być **Up**. + +### 2. Odczytaj token z logów + +Na maszynie z Dockerem (ten sam host co Portainer): + +```bash +docker logs portainer 2>&1 | grep setup_token +``` + +Szukaj linii w stylu: + +```text +setup_token=AbCdEf123456... +``` + +**Kopiujesz tylko część po znaku `=`** (bez `setup_token=`). + +Jeśli `grep` nic nie zwraca: + +```bash +docker logs portainer 2>&1 | less +``` + +W `less` wciśnij `/` i wpisz `setup_token`, Enter. + +### 3. Wpisz token w przeglądarce + +1. Otwórz **http://192.168.100.90:9000** +2. Wklej **Setup token** +3. Ustaw **hasło administratora** (minimum **12 znaków**) +4. Zatwierdź i wybierz środowisko **Local** (Docker na tym komputerze) + +### 4. Ważne ograniczenia czasowe + +- Token jest **jednorazowy** — po udanym setupie nie musisz go zapisywać. +- Masz około **5 minut** od startu kontenera na dokończenie konfiguracji. Po tym czasie token może wygasnąć. +- Jeśli minęło za dużo czasu: zrestartuj kontener i **od razu** odczytaj nowy token: + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/portainer +docker compose --profile portainer restart portainer +sleep 3 +docker logs portainer 2>&1 | grep setup_token +``` + +--- + +## Gdy token „nie działa” + +| Problem | Rozwiązanie | +|---------|-------------| +| „Invalid setup token” | Ponownie `docker logs portainer \| grep setup_token` — literówka, stary token | +| Minęło > 5 min | Restart kontenera (jak wyżej), nowy token z logów | +| Panel mówi, że admin już istnieje | **Nie** szukaj tokena — loguj się hasłem ustawionym wcześniej | +| Brak linii `setup_token` w logach | Możliwe, że setup już zakończony (`portainer.db` istnieje) lub starsza wersja obrazu | + +--- + +## Jak pominąć setup token (tylko zaufana sieć LAN) + +Jeśli Portainer działa **wyłącznie** w domowej sieci (`192.168.100.x`) i **nie** jest wystawiony na internet, możesz wyłączyć wymóg tokena. + +W [`stacks/portainer/docker-compose.yml`](../stacks/portainer/docker-compose.yml) dodaj pod serwisem `portainer`: + +```yaml +command: --no-setup-token +``` + +Potem: + +```bash +cd stacks/portainer +docker compose --profile portainer up -d +``` + +**Uwagi:** + +- Działa sensownie przy **nowej** instalacji (brak `portainer.db`). Przy istniejącej bazie ta flaga **nie zmienia** hasła ani konta. +- **Nie używaj** `--no-setup-token`, jeśli port 9000 jest dostępny z internetu. +- To świadome osłabienie zabezpieczenia przy pierwszym starcie — akceptowalne na izolowanym LAN. + +--- + +## Własny token (zamiast losowego z logów) + +Przydatne przy skryptach — **nie commituj** tej wartości do git. + +```yaml +command: --setup-token TwojTajnyKodNaSetup +``` + +Ten sam ciąg wpisujesz w polu **Setup token** w przeglądarce. + +--- + +## Hasło admina z pliku (bez tokena) + +Rekomendowane przez Portainer przy instalacjach, gdzie nie masz wygodnego dostępu do logów. + +### 1. Plik z hasłem (poza repozytorium) + +```bash +echo -n 'TwojeSilneHasloMin12Znakow' > /data/apps/portainer/admin-password +chmod 600 /data/apps/portainer/admin-password +``` + +Plik musi zawierać **samą treść hasła** — bez pustej linii na końcu (`echo -n`). + +### 2. Zmiany w `docker-compose.yml` + +```yaml +command: --admin-password-file /run/portainer/admin-password +volumes: + # ... istniejące wolumeny ... + - ${DATA_ROOT:-/data}/apps/portainer/admin-password:/run/portainer/admin-password:ro +``` + +### 3. Pierwszy start + +```bash +cd stacks/portainer +docker compose --profile portainer up -d +``` + +Portainer tworzy konto **admin** z tym hasłem. **Setup token nie jest potrzebny.** + +**Uwaga:** przy **istniejącym** `portainer.db` ta flaga **nie nadpisuje** hasła. + +--- + +## Setup token vs Access token (API) + +| | Setup token | Access token | +|---|-------------|--------------| +| **Kiedy** | Pierwsza konfiguracja panelu | Po zalogowaniu do UI | +| **Gdzie go wziąć** | `docker logs portainer` | UI → ikona użytkownika → **My account** → **Access tokens** | +| **W pliku `.env` w repo** | Nie | Nie | +| **Do czego** | Jednorazowe utworzenie admina | Wywołania API Portainera (skrypty) | + +W [`stacks/portainer/.env`](../stacks/portainer/.env) **nie ma** żadnego tokena — to normalne. + +--- + +## Reset od zera (ostrożnie) + +Gdy utknąłeś: nie znasz hasła, token wygasł, a setup się nie udał. + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/portainer +docker compose --profile portainer down +mv /data/apps/portainer/data /data/apps/portainer/data.bak.$(date +%Y%m%d) +./scripts/start.sh +docker logs portainer 2>&1 | grep setup_token +``` + +**Stracisz:** zapisane ustawienia Portainer w UI (endpointy, preferencje). Kontenery Docker (`localai`, `npmplus`, …) **nie są** usuwane — tylko konfiguracja samego Portainera. + +Przy reinstalacji możesz od razu dodać `command: --no-setup-token` lub `--admin-password-file` (sekcje wyżej). + +--- + +## Rekomendacja dla RTX1 (tylko LAN, headless) + +| Scenariusz | Najprostsza droga | +|------------|-------------------| +| **Domyślnie w repo** | `--admin-password-file` + plik na `/data` — **bez tokena, bez limitu 5 min** | +| Masz SSH, setup token | `docker logs portainer \| grep setup_token` → przeglądarka (w ciągu 5 min) | +| Chcesz bez tokena na LAN | `--no-setup-token` przy **czystej** instalacji (nadal ~5 min na hasło w UI) | + +Stack w repo: [`stacks/portainer/docker-compose.yml`](../stacks/portainer/docker-compose.yml) używa **`--admin-password-file`**. + +--- + +## Stan na hoście (2026-07-04) + +- Naprawiono timeout pierwszej konfiguracji przez `--admin-password-file` w compose. +- Hasło: `/data/apps/portainer/admin-password` (utworzone przy wdrożeniu, **nie** w git). +- Logowanie: użytkownik **admin**, hasło z pliku. +- `portainer.db` istnieje — konto admin utworzone przy starcie z pliku hasła. + +--- + +## Powiązane dokumenty + +- [`DOCKER-UI-DEPLOYMENT.md`](DOCKER-UI-DEPLOYMENT.md) +- [`DOCKER-UI-HANDOFF.md`](DOCKER-UI-HANDOFF.md) +- [`stacks/portainer/README.md`](../stacks/portainer/README.md) +- Oficjalna dokumentacja: [How do I find, skip, or customize my setup token?](https://docs.portainer.io/) (Portainer 2.43+) diff --git a/coding-agent/README.md b/coding-agent/README.md new file mode 100644 index 0000000..05428d9 --- /dev/null +++ b/coding-agent/README.md @@ -0,0 +1,28 @@ +# coding-agent — notatki dla agenta kodującego + +Katalog handoff dla kolejnych sesji Cursor pracujących nad [`ubuntu-bare-metal`](../). + +## Kolejność czytania + +1. **[HANDOFF.md](HANDOFF.md)** — podsumowanie sesji, decyzje, stan runtime +2. **[STATE.md](STATE.md)** — co działa teraz, ścieżki na dysku, kluczowe pliki repo +3. **[CONTROL-PLANE-ENV.md](CONTROL-PLANE-ENV.md)** — jeden `.env` dla gpu-fan + Server UI +4. **[BACKLOG.md](BACKLOG.md)** — priorytetyzowane zadania (P0–P3) +5. **[CONVENTIONS.md](CONVENTIONS.md)** — wzorce stacków, sekrety, konwencje +6. **[GITEA-PREP.md](GITEA-PREP.md)** — git/Gitea: pierwszy commit, push, checklist sekretów + +## Gitea + +Repozytorium zainicjowane lokalnie (`main`). Remote i push — według [GITEA-PREP.md](GITEA-PREP.md) (URL od użytkownika). + +## Zasady + +- Instrukcje dla użytkownika: **po polsku**. Komendy: **po angielsku**. +- **Nie commituj** `.env`, tokenów API ani haseł — tylko `.env.example` bez sekretów. +- W notatkach w tym katalogu **nie zapisuj** wartości `LOCALAI_API_KEY`, `HF_TOKEN` itd. +- Commity i push **tylko na prośbę** użytkownika. +- Nie edytuj plików planu w `.cursor/plans/`. + +## Ostatnia aktualizacja + +Sesja: pierwszy import git pod Gitea; Server UI — GPU Fan chart (siatka, linijki, responsywność, wyśrodkowanie). Szczegóły: [GITEA-PREP.md](GITEA-PREP.md). diff --git a/coding-agent/RERANKER-DEPLOYMENT.md b/coding-agent/RERANKER-DEPLOYMENT.md new file mode 100644 index 0000000..95ca5a1 --- /dev/null +++ b/coding-agent/RERANKER-DEPLOYMENT.md @@ -0,0 +1,102 @@ +# Wdrożenie BGE-Reranker-v2-m3 w LocalAI + +Data: 2026-07-01 +Host: `gmktec-k11` (RTX 3090 Ti, LocalAI `v4.4.3-gpu-nvidia-cuda-13`, port **8070**) + +## Podsumowanie + +| Element | Wartość | +|---------|---------| +| Model GGUF | `bge-reranker-v2-m3-FP16.gguf` (~1.1 GB) | +| Źródło | [gpustack/bge-reranker-v2-m3-GGUF](https://huggingface.co/gpustack/bge-reranker-v2-m3-GGUF/resolve/main/bge-reranker-v2-m3-FP16.gguf) | +| Backend | **`llama-cpp`** → automatycznie `cuda13-llama-cpp` na obrazie cuda-13 | +| API id | `bge-reranker-v2-m3-FP16.gguf` | +| Endpoint | `POST /v1/rerank` (format Jina/Cohere-compatible) | +| Status | **OK** — HTTP 200, dokument o pandzie na indeksie 2 ma najwyższy `relevance_score` | + +## Problem z importem z galerii (naprawiony) + +Wcześniejsza próba importu przez UI LocalAI utworzyła błędny YAML: + +```yaml +backend: rerankers # źle dla GGUF +parameters: + model: gpustack/bge-reranker-v2-m3-GGUF/resolve/main/bge-reranker-v2-m3-FP16.gguf # URL zamiast pliku lokalnego +``` + +Backend `rerankers` służy modelom HuggingFace (transformers), **nie** plikom GGUF. Dla GGUF wymagany jest `llama-cpp` z: + +- `reranking: true` +- `known_usecases: [rerank]` +- `parameters.model` = lokalna nazwa pliku `.gguf` + +Dodatkowy backend **nie był potrzebny** — `cuda13-llama-cpp` był już zainstalowany (ten sam co Gemma chat i BGE-M3 embed). + +## Co zrobiono + +1. Pobrano GGUF do `/data/apps/localai/models/bge-reranker-v2-m3-FP16.gguf` +2. Zastosowano poprawny YAML (szablon w repo) +3. Dodano skrypt `stacks/localai/scripts/download-reranker.sh` +4. Restart kontenera `localai` +5. Smoke test `/v1/rerank` — **200 OK** + +## Pliki w repo + +| Plik | Rola | +|------|------| +| [`stacks/localai/profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example`](../stacks/localai/profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example) | Szablon YAML | +| [`stacks/localai/scripts/download-reranker.sh`](../stacks/localai/scripts/download-reranker.sh) | Pobieranie GGUF + aplikacja profilu | + +## Pliki na hoście (runtime) + +``` +/data/apps/localai/models/ +├── bge-reranker-v2-m3-FP16.gguf # ~1.1 GB +└── bge-reranker-v2-m3-FP16.gguf.yaml # backend llama-cpp, reranking: true +``` + +## Weryfikacja + +```bash +API_KEY=$(grep '^LOCALAI_API_KEY=' stacks/localai/.env | cut -d= -f2) + +curl -s http://127.0.0.1:8070/v1/models \ + -H "Authorization: Bearer $API_KEY" +# powinien zawierać bge-reranker-v2-m3-FP16.gguf + +curl -s http://127.0.0.1:8070/v1/rerank \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "bge-reranker-v2-m3-FP16.gguf", + "query": "What is a panda?", + "top_n": 3, + "documents": [ + "hi", + "it is a bear", + "The giant panda (Ailuropoda melanoleuca) is a bear species endemic to China." + ] + }' +``` + +Oczekiwany wynik: `results[0].index == 2` (dokument o pandzie), `relevance_score` najwyższy dla tego indeksu. + +## VRAM i SINGLE_ACTIVE_BACKEND + +Compose ma `SINGLE_ACTIVE_BACKEND=true` — przy przełączaniu chat → embed → rerank model jest przeładowywany (jak przy embed). Reranker (~1.1 GB) + Gemma (~6.5 GB) **nie mieszczą się naraz** w sensie aktywnego backendu; kolejne wywołania API przełączają model. + +Dla pipeline RAG (embed → rerank → chat) klient musi liczyć się z latencją przeładowania (~kilka sekund między typami modeli). + +## Integracja z klientem (ai-lawyer / dev) + +| Zmienna | Wartość | +|---------|---------| +| `RERANK_MODEL` | `bge-reranker-v2-m3-FP16.gguf` | +| `RERANK_URL` | `https://llm.rtx1.mobile.agency-ai.dev/v1/rerank` (lub LAN `http://192.168.100.5:8070/v1/rerank`) | +| Auth | `Authorization: Bearer ` | + +Payload zgodny z OpenAI/Jina rerank API — pole `documents` to tablica stringów, `query` to zapytanie, `top_n` opcjonalne. + +## Uwaga: uprawnienia plików + +Pliki YAML utworzone przez kontener/UI mogą być owned by `root`. Skrypt `download-reranker.sh` przy błędzie `cp` zapisuje YAML przez `docker exec localai` (bez sudo na hoście). diff --git a/coding-agent/RERANKER-HANDOFF.md b/coding-agent/RERANKER-HANDOFF.md new file mode 100644 index 0000000..7e0f809 --- /dev/null +++ b/coding-agent/RERANKER-HANDOFF.md @@ -0,0 +1,62 @@ +# Handoff — BGE-Reranker w LocalAI + +Dla agenta kodującego. Kontekst wdrożenia: [`RERANKER-DEPLOYMENT.md`](RERANKER-DEPLOYMENT.md). + +## Stan: ukończone na hoście + +- [x] GGUF pobrany +- [x] YAML `llama-cpp` + `reranking: true` +- [x] Model widoczny w `/v1/models` +- [x] `/v1/rerank` zwraca HTTP 200 + +## Zadania opcjonalne (backlog) + +- [ ] Dodać `RERANK_MODEL` do dokumentacji klienta / `.env.example` dev hosta +- [ ] Smoke test rerank przez publiczną domenę NPMPlus (z zewnątrz LAN — hairpin NAT na RTX1) +- [ ] Rozważyć Q4_K_M zamiast FP16 jeśli VRAM/latencja przełączania modeli jest problemem +- [ ] Dodać rozdział w `manual-tutorial/05-localai-stack.md` o modelach embed + rerank + +## Powtórzenie instalacji (nowy host) + +```bash +cd stacks/localai +cp .env.example .env # ustaw LOCALAI_API_KEY, LOCALAI_PORT=8070 +./scripts/download-reranker.sh +docker compose --profile localai restart localai +# czekaj na readyz, potem curl /v1/rerank (patrz RERANKER-DEPLOYMENT.md) +``` + +## Czego nie robić + +- Nie używać `backend: rerankers` dla GGUF +- Nie instalować osobnego backendu — `cuda13-llama-cpp` wystarczy +- Nie commitować `LOCALAI_API_KEY` +- Nie zmieniać `SINGLE_ACTIVE_BACKEND` bez testu VRAM (chat + embed + rerank sekwencyjnie) + +## Kluczowe pliki do edycji w przyszłości + +| Plik | Kiedy | +|------|-------| +| `stacks/localai/profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example` | zmiana quant / parametrów rerankera | +| `stacks/localai/scripts/download-reranker.sh` | nowy URL lub wariant Q4 | +| `stacks/localai/coding-agent/STATE.md` | po każdej zmianie runtime | + +## API — minimalny przykład (Python) + +```python +import requests + +resp = requests.post( + "http://127.0.0.1:8070/v1/rerank", + headers={"Authorization": f"Bearer {api_key}"}, + json={ + "model": "bge-reranker-v2-m3-FP16.gguf", + "query": user_query, + "documents": chunk_texts, + "top_n": 5, + }, + timeout=120, +) +resp.raise_for_status() +ranked = resp.json()["results"] # sorted by relevance_score desc +``` diff --git a/coding-agent/SERVER-UI-ARCHITECTURE-RESEARCH.md b/coding-agent/SERVER-UI-ARCHITECTURE-RESEARCH.md new file mode 100644 index 0000000..179dbbe --- /dev/null +++ b/coding-agent/SERVER-UI-ARCHITECTURE-RESEARCH.md @@ -0,0 +1,451 @@ +# Server UI — architektura uniwersalnego panelu (research DevOps) + +**Data:** 2026-07-04 +**Host:** gmktec-k11, Ubuntu 26.04, RTX 3090 Ti +**Kontekst:** Czy UI gpu-fan można przenieść do Server UI w Dockerze, skoro daemon NVML musi zostać na hoście (root)? Jak zbudować panel w stylu appliance (Proxmox / TrueNAS) na Ubuntu? + +**Powiązane:** +- [`stacks/server-ui/`](../stacks/server-ui/) +- [`stacks/gpu-fan/coding-agent/DOCKER-VS-HOST-REPORT.md`](../stacks/gpu-fan/coding-agent/DOCKER-VS-HOST-REPORT.md) +- [`DOCKER-UI-DEPLOYMENT.md`](DOCKER-UI-DEPLOYMENT.md) +- [`ADR-001-host-agent-control-plane.md`](ADR-001-host-agent-control-plane.md) + +--- + +## 1. Executive summary + +**Werdykt:** Przeniesienie **UI** gpu-fan do Server UI przy **daemonie NVML na hoście** to **właściwy, branżowo spójny kierunek** (wzorzec *control plane UI + host agent*). **Nie** pakować sterowania wentylatorami do kontenera Docker. + +**Rekomendowana strategia:** **C** — rozdziel gpu-fan na agent localhost (`127.0.0.1:18090`) + zintegrowany panel w Server UI + dockerizacja Server UI z `docker.sock` i proxy do agenta. + +**Stan dziś:** Oba panele (gpu-fan :8090, server-ui :8091) działają jako **systemd na hoście**, nie w Dockerze. Cel „wszystkie UI serwerowe w Dockerze” wymaga najpierw dockerizacji server-ui, potem integracji gpu-fan jako host agent. + +--- + +## 2. Pytanie biznesowe + +> gpu-fan musi działać na hoście (root, NVML). Czy UI gpu-fan może działać w kontenerze Docker razem z server-ui, żeby wszystkie UI serwerowe zarządzały rootem z jednego miejsca? + +**Odpowiedź skrócona:** + +| Warstwa | Gdzie | Dlaczego | +|---------|-------|----------| +| Pętla NVML + zapis wentylatorów | **Host, systemd, root** | Jedyny wspierany i bezpieczny model ([gpu-fan report](../stacks/gpu-fan/coding-agent/DOCKER-VS-HOST-REPORT.md)) | +| API agenta (status, krzywa, tryb) | **Host, localhost only** | Most dla UI bez publicznego NVML | +| Web UI (dashboard, wykres, przyciski) | **Server UI (docelowo Docker)** | Jedna przeglądarka, jeden port, jeden API key | +| Start/stop stacków Docker | **Server UI + docker.sock** | Wzorzec Portainer/Dockge | + +„UI w Dockerze zarządza rootem” = **proxy HTTP do host agenta**, nie uruchomienie NVML write w kontenerze. + +--- + +## 3. Stan obecny (as-is) + +```mermaid +flowchart LR + browser1["Przeglądarka"] + browser2["Przeglądarka"] + gpuFan["gpu-fan.service root :8090"] + serverUI["server-ui systemd :8091"] + dockerStacks["Docker stacks"] + gpu["GPU NVML"] + + browser1 --> gpuFan + browser2 --> serverUI + gpuFan --> gpu + serverUI -->|"docker compose CLI"| dockerStacks + serverUI -.->|"link :8090"| gpuFan +``` + +| Komponent | Runtime | Port | Uprawnienia | Pliki kluczowe | +|-----------|---------|------|-------------|----------------| +| gpu-fan | systemd, `User=root` | 8090 | NVML write + FastAPI + static UI w **jednym** procesie | `stacks/gpu-fan/app.py`, `fan_controller.py` | +| server-ui | systemd | 8091 | `docker compose` subprocess, `nvidia-smi` read-only | `stacks/server-ui/app.py`, `compose_runner.py` | +| comfyui / localai / vllm | Docker | 8188 / 8070 / 8000 | GPU compute | `stacks/*/compose.yaml` | +| npmplus | Docker, `network_mode: host` | 80, 443, 81 | reverse proxy | `stacks/npmplus/compose.yaml` | + +### Problemy architektoniczne + +1. **Dwa panele** — porty 8090 i 8091, osobne API key, rozproszony UX. +2. **Coupling UI+daemon w gpu-fan** — `app.py` uruchamia wątek NVML i uvicorn w jednym procesie; static HTML woła `/api/*` na tym samym origin. +3. **Server-ui nie jest w Dockerze** — mimo nazwy „Docker UI” to FastAPI na hoście (`/opt/server-ui`, `server-ui.service`). +4. **Brak jednego entry pointu** — NPMPlus domyślnie nie proxyuje 8090/8091; użytkownik musi znać wiele URL. +5. **„Zarządzanie rootem”** — server-ui nie może bezpośrednio pisać do NVML; potrzebuje host agent API. + +### Wymuszenie root w gpu-fan + +```python +# stacks/gpu-fan/app.py (linie 161–164) +if os.geteuid() != 0 and not DRY_RUN: + log.error("GPU fan control requires root (NVML write access). Run with sudo.") + sys.exit(1) +``` + +--- + +## 4. Analiza coupling gpu-fan (UI + daemon) + +### Obecna struktura procesu + +``` +gpu-fan.service (root) +└── python app.py + ├── FanController.run_loop() # wątek daemon — co 2s NVML + └── uvicorn :8090 + ├── GET / → static/index.html (~750 linii JS) + ├── GET /api/status + ├── GET/PUT /api/curve + ├── POST /api/mode + └── POST /api/reload +``` + +Frontend (`static/index.html`) używa **względnych** ścieżek (`fetch('/api/status')`) — działa tylko gdy UI i API są na tym samym hoście:porcie. + +### Co można rozdzielić bez ryzyka + +| Element | Zostaje na hoście | Może do Server UI | +|---------|-------------------|-------------------| +| `fan_controller.py` pętla NVML | **Tak** | Nie | +| FastAPI endpoints `/api/*` | **Tak** (localhost) | Proxy z server-ui | +| `static/index.html` | Nie | **Tak** | +| Graceful shutdown → auto fan | **Tak** (SIGTERM w systemd) | Nie | + +### Docelowy podział (agent) + +``` +gpu-fan-daemon.service (root) +└── python fan_daemon.py # lub app.py --api-only + ├── FanController.run_loop() + └── uvicorn 127.0.0.1:18090 + └── /api/* only (bez static/) + +server-ui (Docker lub systemd) +└── static + proxy /api/gpu-fan/* → http://host.docker.internal:18090/api/* +``` + +**Krytyczne:** Pętla NVML musi działać **niezależnie** od UI — pad panelu nie może zatrzymać chłodzenia. + +--- + +## 5. Porównanie branżowe + +### Proxmox VE (Debian appliance) + +| Aspekt | Implementacja | Lekcja | +|--------|---------------|--------| +| Web UI | `pveproxy` na hoście | UI oddzielone od workloadów, ale **nie** w „zwykłym” kontenerze aplikacyjnym | +| Operacje VM/storage | `pvedaemon`, QEMU, LVM na hoście | Privileged zawsze na host kernel | +| Model | Single-node hypervisor | Jeden panel, wiele usług backendowych na hoście | + +Proxmox **nie** pakuje hypervisora do Docker — control plane jest częścią OS. + +### TrueNAS + +| Wariant | UI | Storage / hardware | +|---------|-----|------------------| +| CORE | nginx + middleware na hoście | ZFS w kernelu | +| SCALE | UI w middleware (K8s) | Dane nadal przy host storage stack | + +Lekcja: nawet przy „nowoczesnym” SCALE ciężkie operacje zostają przy hoście. + +### Cockpit (Ubuntu) + +- `cockpit.socket` + moduły na hoście +- Dostęp do `systemd`, sieci, storage przez D-Bus / polkit +- Oficjalny wzorzec Canonical dla headless Ubuntu + +Alternatywa: zamiast własnego server-ui — Cockpit + plugin. Minus: brak whitelist stacków AI, polityki GPU, custom NVML fan. + +### Portainer / Dockge (homelab Docker) + +| Element | Wzorzec | +|---------|---------| +| UI | Kontener | +| Docker API | Mount `/var/run/docker.sock` | +| Host hardware (fan, disk, systemd) | **Poza scope** — nie sterują NVML | + +To jest **najbliższy analog** do waszej wizji dla warstwy Docker; gpu-fan wymaga **dodatkowego** host agent (jak brakujący moduł Portainera). + +### Macierz porównawcza + +| Produkt | UI w kontenerze? | Host privileged ops | Pasuje do gmktec-k11? | +|---------|------------------|----------------------|------------------------| +| Proxmox | Nie (host) | Tak | Wzorzec appliance, za ciężki | +| TrueNAS | Częściowo | Tak | Storage-first, nie AI | +| Cockpit | Nie | Tak | Generyczny, słaba integracja AI stacks | +| Portainer | Tak | Tylko docker.sock | Brak gpu-fan | +| **Server UI (propozycja)** | **Tak (docelowo)** | **Via host agent** | **Dopasowany** | + +--- + +## 6. Macierz strategii (A–E) + +### A — Status quo (dwa panele, host systemd) + +- **Plus:** Działa dziś, zero refactoru +- **Minus:** 8090 + 8091, dwa klucze, rozproszony UX +- **Verdict:** Utrzymanie krótkoterminowe + +### B — Split gpu-fan + UI w server-ui, oba systemd na hoście + +- **Plus:** Jeden port 8091, mniejszy refactor niż Docker +- **Minus:** Server-ui nadal nie w kontenerze +- **Verdict:** **Dobry krok pośredni** (Faza 1–2 bez Fazy 3) + +### C — Split gpu-fan daemon + server-ui w Dockerze (REKOMENDOWANE) + +- **Plus:** Portainer pattern; jeden panel; agent tylko localhost; :8090 znika z LAN +- **Minus:** Dockerfile, mounty, `host.docker.internal` na Linux +- **Verdict:** **Strategia docelowa** + +### D — Cockpit zamiast server-ui + +- **Plus:** Dojrzały ekosystem Ubuntu +- **Minus:** Custom plugin NVML, brak polityki GPU / whitelist compose +- **Verdict:** Tylko przy rezygnacji z własnego UI + +### E — gpu-fan NVML w privileged kontenerze + +- **Plus:** Literalnie „wszystko w Dockerze” +- **Minus:** Kruche, `Insufficient Permissions` na GeForce, ryzyko manual fan po crash +- **Verdict:** **Odrzuć** — patrz [`gpu-fan/docs/05-DLACZEGO-NIE-DOCKER.md`](../stacks/gpu-fan/docs/05-DLACZEGO-NIE-DOCKER.md) + +**Wybór:** **C**, z opcjonalnym **B** jako pierwsze PR (integracja UI przed dockerizacją). + +--- + +## 7. Architektura docelowa (to-be) + +```mermaid +flowchart TB + browser["Przeglądarka"] + npm["NPMPlus :443 opcjonalnie"] + subgraph dockerPlane [Docker control plane] + serverUI["server-ui container :8091"] + end + subgraph hostAgents [Host systemd root] + gpuAgent["gpu-fan-daemon 127.0.0.1:18090"] + futureAgent["host-agent przyszłość"] + end + subgraph workloads [Docker workloads] + comfyui["comfyui"] + localai["localai"] + npmplus["npmplus host network"] + end + gpu["RTX 3090 Ti"] + + browser --> npm + npm --> serverUI + browser --> serverUI + serverUI -->|"docker.sock"| workloads + serverUI -->|"proxy /api/gpu-fan"| gpuAgent + serverUI -.->|"future /api/host"| futureAgent + gpuAgent --> gpu +``` + +### Warstwy odpowiedzialności + +| Warstwa | Odpowiedzialność | Technologia | +|---------|------------------|-------------| +| **Presentation** | Jeden dashboard, auth, nawigacja | server-ui | +| **Orchestration** | start/stop compose, logi, polityka GPU | server-ui + `docker.sock` | +| **Host agents** | NVML fan; przyszłe: systemd, `/data` health | systemd, API localhost | +| **Workloads** | AI inference / image gen | istniejące stacki compose | + +### Co NIE wchodzi do Server UI (scope) + +- Pełne osadzenie ComfyUI / LocalAI SPA w iframe — opcjonalne; dziś wystarczą linki `:8188`, `:8070` +- Zastąpienie NPMPlus — osobny stack reverse proxy +- Pełny hypervisor (VM, ZFS) — poza zakresem ubuntu-bare-metal + +--- + +## 8. Plan implementacji (fazy) + +### Faza 1 — Rozdziel gpu-fan (host) + +**Cel:** Agent API bez publicznego UI. + +| Zadanie | Pliki | +|---------|-------| +| Wydziel tryb API-only lub `fan_daemon.py` | `stacks/gpu-fan/app.py` lub nowy moduł | +| Env: `GPU_FAN_API_HOST=127.0.0.1`, `GPU_FAN_API_PORT=18090` | `.env.example`, `/opt/control-plane/.env` | +| Usuń serwowanie `static/` z produkcji | `app.py` | +| Zaktualizuj `gpu-fan.service` | `gpu-fan.service` | +| Deprecate publiczny :8090 w `enable-lan.sh` | `scripts/enable-lan.sh` | +| Dokumentacja | `docs/`, `README.md` | + +**Weryfikacja:** +```bash +curl -s http://127.0.0.1:18090/api/status -H "X-API-Key: $KEY" +ss -tlnp | grep 18090 # 127.0.0.1 only +curl -s -o /dev/null -w '%{http_code}\n' http://192.168.100.90:8090/ # connection refused +``` + +### Faza 2 — Integracja UI w server-ui + +| Zadanie | Pliki | +|---------|-------| +| Przenieś UI gpu-fan do zakładki / podstrony | `server-ui/static/gpu-fan.html` lub sekcja w `index.html` | +| Zmień `fetch` na `/api/gpu-fan/...` | JS frontend | +| Proxy backend | `server-ui/app.py` — `httpx` lub `requests` do agenta | +| Jednolity `API_KEY` dla użytkownika | `.env` server-ui; agent akceptuje ten sam secret z localhost | +| Usuń link `:8090` | `server-ui/static/index.html` | + +**Weryfikacja:** Panel gpu-fan w `:8091`, brak potrzeby otwierania :8090. + +### Faza 3 — Dockerize server-ui + +| Zadanie | Pliki | +|---------|-------| +| `Dockerfile` | `stacks/server-ui/Dockerfile` | +| `compose.yaml` | mount `docker.sock`, repo, env | +| `extra_hosts: host.docker.internal:host-gateway` | compose | +| Opcja: systemd wrapper `docker compose up -d` | `server-ui.service` lub nowy unit | +| Whitelist w `stacks.yaml` — bez zmian | — | + +**Weryfikacja:** +```bash +docker compose --profile server-ui up -d +curl -s http://127.0.0.1:8091/api/health +curl -s http://127.0.0.1:8091/api/stacks +``` + +### Faza 4 — Jeden punkt wejścia (opcjonalnie) + +- NPMPlus proxy host `panel.` → `127.0.0.1:8091` +- UFW: zamknij 18090, opcjonalnie 8091 poza LAN +- Patrz BACKLOG P1 — rozdział 08 firewall + +### Faza 5 — `host-agent` (przyszłość) + +Jeden daemon root agregujący: +- gpu-fan (NVML) +- `systemctl` status wybranych unitów (`gpu-fan`, `server-ui`) +- health mountu `/data` +- wersje driver / CUDA + +Server-ui woła `/api/host/*` zamiast wielu portów agentów. + +--- + +## 9. API contract — gpu-fan agent (do proxy) + +Agent nasłuchuje na `127.0.0.1:18090`. Server-ui mapuje `/api/gpu-fan/{path}` → `/api/{path}`. + +| Endpoint agenta | Metoda | Opis | Auth | +|-----------------|--------|------|------| +| `/api/status` | GET | Temperatura, fan speeds, tryb, krzywa | `X-API-Key` | +| `/api/curve` | GET | Punkty krzywej | `X-API-Key` | +| `/api/curve` | PUT | Zapis krzywej + tryb curve | `X-API-Key` | +| `/api/mode` | POST | `{"mode":"auto\|curve\|manual","speed":100}` | `X-API-Key` | +| `/api/reload` | POST | Przeładuj `curve.json` | `X-API-Key` | + +**Bezpieczeństwo agenta:** +- Bind tylko `127.0.0.1` (nie `0.0.0.0`) +- Akceptuj requesty tylko z loopback lub z zaufanego `GPU_FAN_TRUSTED_PROXY` (server-ui IP w docker bridge — opcjonalnie) +- Ten sam `API_KEY` co server-ui lub osobny `AGENT_KEY` w env server-ui (nie w repo) + +**Przykład proxy (szkic Python):** +```python +# server-ui/app.py (szkic — nie zaimplementowano) +GPU_FAN_AGENT = os.environ.get("GPU_FAN_AGENT_URL", "http://127.0.0.1:18090") + +@app.api_route("/api/gpu-fan/{path:path}", methods=["GET", "PUT", "POST"]) +async def proxy_gpu_fan(path: str, request: Request): + require_mutation_auth(request) # lub require_auth dla GET też + url = f"{GPU_FAN_AGENT}/api/{path}" + # forward method, body, X-API-Key header +``` + +--- + +## 10. Szkic `compose.yaml` — server-ui (bez implementacji) + +```yaml +# stacks/server-ui/compose.yaml — PROPOZYCJA, nie wdrożono +name: server-ui + +services: + server-ui: + build: . + container_name: server-ui + profiles: [server-ui] + restart: unless-stopped + ports: + - "${SERVER_UI_PORT:-8091}:8091" + environment: + - SERVER_UI_HOST=0.0.0.0 + - SERVER_UI_PORT=8091 + - API_KEY=${API_KEY} + - REPO_ROOT=/repo + - GPU_FAN_AGENT_URL=http://host.docker.internal:18090 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${REPO_ROOT:-/home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal}:/repo:ro + extra_hosts: + - "host.docker.internal:host-gateway" +``` + +**Uwagi:** +- `docker compose` w kontenerze wymaga CLI w obrazie lub wywołania Docker API bezpośrednio (obecnie `compose_runner.py` używa subprocess — działa z mounted sock + `docker` CLI w image). +- Ścieżki `compose_dir` w `stacks.yaml` muszą istnieć w `REPO_ROOT` wewnątrz kontenera (`/repo/stacks/...`). + +--- + +## 11. Ryzyka i mitigacje + +| Ryzyko | Prawdopodobieństwo | Mitigacja | +|--------|-------------------|-----------| +| Pad server-ui = brak edycji krzywej | Średnie | Daemon gpu-fan **niezależny** — ostatnia krzywa dalej działa | +| Server-ui w Docker nie widzi ścieżek compose | Wysokie przy złym mount | Mount całego repo; test `REPO_ROOT` w healthcheck | +| `host.docker.internal` niedostępny | Niskie na Docker 20.10+ | `extra_hosts: host-gateway` | +| Dwa API key — UX | Średnie | Jeden klucz w UI; proxy dokleja secret do agenta | +| Regresja graceful shutdown fan | Niskie przy zachowaniu systemd | Nie ruszać `fan_controller.shutdown()`; test po `systemctl stop` | +| npmplus + server-ui port conflict | Brak | 8091 vs 80/443/81 | + +--- + +## 12. Anti-patterns (czego unikać) + +1. **NVML fan control w kontenerze Docker** — odrzucone w gpu-fan research. +2. **Jeden monolityczny proces root z UI na 0.0.0.0** — obecny gpu-fan; do refaktoru. +3. **Server-ui bez docker.sock a z pretensją do zarządzania kontenerami** — wymaga sock lub Docker API. +4. **Osadzanie ComfyUI w server-ui zamiast linków** — duży scope, CORS, osobne sesje. +5. **Budowa „Proxmox w Dockerze”** — over-engineering dla single-node AI homelab. +6. **Publiczny port agenta 18090** — tylko localhost. + +--- + +## 13. Autentykacja — model docelowy + +| Warstwa | Mechanizm | +|---------|-----------| +| Użytkownik → server-ui | `X-API-Key` (mutacje + opcjonalnie odczyt gpu-fan) | +| server-ui → gpu-fan agent | Ten sam key lub `AGENT_KEY` w env server-ui; request z kontenera przez `host.docker.internal` | +| Użytkownik → ComfyUI/LocalAI | Osobne klucze aplikacji (poza server-ui) | +| NPMPlus → server-ui | TLS + opcjonalnie basic auth na proxy host | + +Docelowo: **jeden panel, jeden klucz** do operacji serwerowych (start/stop stacków + gpu-fan). + +--- + +## 14. Checklist dla kolejnego agenta + +### Research (ten dokument) +- [x] Raport SERVER-UI-ARCHITECTURE-RESEARCH.md +- [x] ADR-001-host-agent-control-plane.md +- [x] Link w BACKLOG.md + +### Implementacja (przyszłe PR) +- [ ] Faza 1: gpu-fan API-only na localhost:18090 +- [ ] Faza 2: proxy + UI w server-ui +- [ ] Faza 3: Dockerfile + compose server-ui +- [ ] Faza 4: NPMPlus proxy panel +- [ ] Faza 5: host-agent (opcjonalnie) + +--- + +## 15. Podsumowanie jednym akapitem + +Przeniesienie **samego UI** gpu-fan do Server UI w Dockerze, przy **daemonie NVML na hoście**, to właściwy plan zgodny z Portainer (UI w kontenerze) i Proxmox (privileged na hoście). Najpierw rozdziel proces gpu-fan na agent localhost i panel, potem dockerizuj server-ui z `docker.sock` i proxy do agenta; w dłuższej perspektywie rozważ jeden `host-agent` zamiast wielu portów. **Nie** przenoś NVML do kontenera. diff --git a/coding-agent/SERVER-UI-CLI-BUGFIX.md b/coding-agent/SERVER-UI-CLI-BUGFIX.md new file mode 100644 index 0000000..e50285c --- /dev/null +++ b/coding-agent/SERVER-UI-CLI-BUGFIX.md @@ -0,0 +1,65 @@ +# Server UI CLI — bugfix (pusty terminal) + +**Data:** 2026-07-05 +**Status:** naprawione + +## Symptom + +- Zakładka **CLI** pusta (`cli-terminal-wrap` bez zawartości) +- Przycisk **Połącz ponownie** bez efektu +- Inne zakładki (Stacki, Pliki, GPU Fan) działają z tym samym API Key +- **Sprawdź klucz** zwraca „Klucz poprawny” + +## Root cause + +Błąd JavaScript w [`stacks/server-ui/static/index.html`](../stacks/server-ui/static/index.html) — `connectCli()`: + +```javascript +cliFitAddon = new FitAddon(); // TypeError: FitAddon is not a constructor +``` + +Bundel UMD [`addon-fit.min.js`](../stacks/server-ui/static/vendor/xterm/addon-fit.min.js) eksportuje **obiekt modułu** `{ FitAddon: class }`, nie samą klasę na `window.FitAddon`. + +Wywołanie rzuca wyjątek **przed** `cliTerm.open()` → pusty kontener, WebSocket nigdy nie startuje. + +Auth WebSocket (`?api_key=`) nie był przyczyną — HTTP API używa tego samego klucza i działało. + +## Fix + +1. **`createFitAddon()`** — obsługa obu wariantów UMD: + - `new FitAddon()` (gdy global to klasa) + - `new FitAddon.FitAddon()` (aktualny bundel) +2. **`try/catch`** w `connectCli()` — błąd widoczny w `#cli-status` +3. **Sprawdzenie** `typeof Terminal === 'function'` +4. **Fallback** bez FitAddon: `cols: 80`, `rows: 24`, `#cli-terminal { min-height: 360px }` +5. **Reconnect** po udanym `verifyApiKey()` na zakładce CLI +6. **Czytelne komunikaty** `cliWs.onclose` (kod 1008, zły klucz, CLI disabled) + +## Weryfikacja + +```bash +# Deploy +rsync -a --exclude '.venv' --exclude '__pycache__' stacks/server-ui/ /opt/server-ui/ +sudo systemctl restart server-ui + +# WebSocket (wymaga websockets / wscat) +API_KEY=$(sudo grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2) +# python -c z websockets → echo hello-cli-test +``` + +**UI:** + +1. `http://:8091/#cli` + API Key → **Zapisz** → **Sprawdź klucz** +2. Prompt bash widoczny, `echo test` działa +3. **Połącz ponownie** → nowa sesja OK +4. Zły klucz → komunikat w statusie (nie pusty ekran) + +## Pliki zmienione + +| Plik | Zmiana | +|------|--------| +| `stacks/server-ui/static/index.html` | createFitAddon, try/catch, WS messages, CSS | +| `manual-tutorial/10-server-ui-cli.md` | troubleshooting pustego CLI | +| `coding-agent/SERVER-UI-CLI.md` | link do tego raportu | + +Tutorial użytkownika: [`manual-tutorial/10-server-ui-cli.md`](../manual-tutorial/10-server-ui-cli.md) diff --git a/coding-agent/SERVER-UI-CLI.md b/coding-agent/SERVER-UI-CLI.md new file mode 100644 index 0000000..48016a1 --- /dev/null +++ b/coding-agent/SERVER-UI-CLI.md @@ -0,0 +1,101 @@ +# Server UI — CLI (terminal PTY) + +**Data:** 2026-07-05 +**Status:** wdrożone (bugfix FitAddon: 2026-07-05 — patrz [SERVER-UI-CLI-BUGFIX.md](SERVER-UI-CLI-BUGFIX.md)) + +Zakładka **CLI** w panelu Server UI (`:8091`) — interaktywny shell bash w przeglądarce (PTY + xterm.js). + +## UI + +- Zakładka **CLI** (pierwsza, przed Pliki) +- Hash URL: `http://:8091/#cli` +- Wymaga **API Key** w toolbarze (WebSocket nie obsługuje nagłówka `X-API-Key`) + +## Architektura + +```mermaid +sequenceDiagram + participant Browser + participant xterm as xterm_js + participant WS as WS_api_cli_ws + participant PTY as cli_pty_py + participant Shell as bash + + Browser->>xterm: klawiatura + xterm->>WS: dane terminala + WS->>PTY: zapis master_fd + PTY->>Shell: stdin + Shell->>PTY: stdout/stderr + PTY->>WS: odczyt master_fd + WS->>xterm: output +``` + +## WebSocket + +| Parametr | Wartość | +|----------|---------| +| Ścieżka | `/api/cli/ws` | +| Auth | `?api_key=` (query string) | +| Protokół I/O | surowe bajty terminala (text/binary frames) | +| Resize | JSON: `{"type":"resize","cols":120,"rows":40}` | + +### Przykład (wscat) + +```bash +API_KEY=$(sudo grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2) + +# wymaga: npm i -g wscat +wscat -c "ws://127.0.0.1:8091/api/cli/ws?api_key=${API_KEY}" +``` + +Bez klucza na bind LAN → połączenie zamknięte z kodem `1008`. + +## Konfiguracja + +W [`/opt/control-plane/.env`](/opt/control-plane/.env) (opcjonalnie): + +| Zmienna | Domyślnie | Opis | +|---------|-----------|------| +| `CLI_ENABLED` | `1` | `0` = WebSocket odrzucany | +| `CLI_SHELL` | `/bin/bash` | Shell PTY (np. `/bin/bash -l`) | +| `CLI_DEFAULT_CWD` | `$HOME` usługi | Katalog startowy | +| `CLI_MAX_SESSIONS` | `5` | Limit równoległych sesji WS | + +## Uprawnienia POSIX + +Server UI działa jako **`tomasz-syn-grzegorza`** (`server-ui.service`): + +- `docker ps`, edycja repo, `/data/apps/` — OK +- `sudo` — interaktywnie (hasło użytkownika w terminalu) +- root-only pliki — odmowa jak w SSH + +## Kod + +| Plik | Rola | +|------|------| +| [`stacks/server-ui/cli_pty.py`](../stacks/server-ui/cli_pty.py) | PTY + asyncio bridge | +| [`stacks/server-ui/app.py`](../stacks/server-ui/app.py) | WebSocket `/api/cli/ws`, mount `/static` | +| [`stacks/server-ui/static/index.html`](../stacks/server-ui/static/index.html) | Zakładka CLI | +| [`stacks/server-ui/static/vendor/xterm/`](../stacks/server-ui/static/vendor/xterm/) | xterm.js + FitAddon (vendored; UMD: `new FitAddon.FitAddon()`) | + +## Bezpieczeństwo + +- Pełny shell przez LAN = **wysokie ryzyko** — wymagany `API_KEY` przy `SERVER_UI_HOST=0.0.0.0` +- `CLI_ENABLED=0` jako kill switch +- `CLI_MAX_SESSIONS` ogranicza liczbę równoległych bashów +- Brak whitelisty komend (właściciel serwera) +- Sesja kończy się przy zamknięciu zakładki / rozłączeniu WS (nowy bash przy reconnect) + +## Deploy + +```bash +sudo rsync -a --exclude '.venv' --exclude '__pycache__' \ + stacks/server-ui/ /opt/server-ui/ +sudo systemctl restart server-ui +``` + +Tutorial użytkownika: [`manual-tutorial/10-server-ui-cli.md`](../manual-tutorial/10-server-ui-cli.md) + +## Znane problemy / bugfix + +Pusty terminal przy działającym API Key: [`SERVER-UI-CLI-BUGFIX.md`](SERVER-UI-CLI-BUGFIX.md) diff --git a/coding-agent/SERVER-UI-FILE-EXPLORER.md b/coding-agent/SERVER-UI-FILE-EXPLORER.md new file mode 100644 index 0000000..6c35110 --- /dev/null +++ b/coding-agent/SERVER-UI-FILE-EXPLORER.md @@ -0,0 +1,84 @@ +# Server UI — File Explorer + +**Data:** 2026-07-05 +**Status:** wdrożone + +Zakładka **Pliki** w panelu Server UI (`:8091`) — przeglądanie systemu plików, edycja tekstu, pełny CRUD. + +## UI + +- Zakładka **Pliki** (druga, po CLI) +- Hash URL: `http://:8091/#files` +- Wymaga `X-API-Key` (ten sam co reszta panelu przy bind LAN) + +## API + +Wszystkie endpointy: nagłówek `X-API-Key` (gdy `API_KEY` ustawiony i bind LAN). + +| Metoda | Ścieżka | Opis | +|--------|---------|------| +| GET | `/api/files?path=/` | Lista katalogu | +| GET | `/api/files/read?path=...` | Odczyt pliku (UTF-8 lub base64 dla binarnych) | +| PUT | `/api/files/write` | `{"path","content"}` — zapis tekstu | +| POST | `/api/files/mkdir` | `{"path"}` — nowy folder | +| POST | `/api/files/rename` | `{"old_path","new_path"}` | +| DELETE | `/api/files?path=...` | Usuń plik lub pusty katalog | + +### Przykłady curl + +```bash +API_KEY=$(sudo grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2) + +curl -s "http://127.0.0.1:8091/api/files?path=/tmp" \ + -H "X-API-Key: ${API_KEY}" + +curl -s "http://127.0.0.1:8091/api/files/read?path=/tmp/test.txt" \ + -H "X-API-Key: ${API_KEY}" + +curl -s -X PUT "http://127.0.0.1:8091/api/files/write" \ + -H "X-API-Key: ${API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{"path":"/tmp/test.txt","content":"hello"}' +``` + +## Konfiguracja + +W [`/opt/control-plane/.env`](/opt/control-plane/.env) (opcjonalnie): + +| Zmienna | Domyślnie | Opis | +|---------|-----------|------| +| `FILE_EXPLORER_ROOT` | `/` | Korzeń przeglądania | +| `FILE_EXPLORER_MAX_BYTES` | `2097152` | Limit odczytu/zapisu (2 MiB) | + +## Uprawnienia POSIX + +Server UI działa jako użytkownik **`tomasz-syn-grzegorza`** (`server-ui.service`). Explorer **nie omija** uprawnień systemowych: + +- `/home/...`, repo, `/data/apps/...` — zwykle OK +- `/opt/control-plane/.env` (root 600) — odmowa w UI +- pliki root-only — komunikat „Brak uprawnień” + +## Kod + +| Plik | Rola | +|------|------| +| [`stacks/server-ui/file_explorer.py`](../stacks/server-ui/file_explorer.py) | Logika FS | +| [`stacks/server-ui/app.py`](../stacks/server-ui/app.py) | Endpointy `/api/files*` | +| [`stacks/server-ui/static/index.html`](../stacks/server-ui/static/index.html) | Zakładka Pliki | + +## Bezpieczeństwo + +- Path traversal: `Path.resolve()` + sprawdzenie względem `FILE_EXPLORER_ROOT` +- Pliki binarne: podgląd base64, zapis zablokowany (415) +- Usuwanie: potwierdzenie w UI (`confirm()`) +- Katalog niepusty: 409 przy DELETE + +## Deploy + +```bash +sudo bash stacks/server-ui/scripts/install.sh +# lub tylko restart po rsync: +sudo systemctl restart server-ui +``` + +Tutorial użytkownika: [`manual-tutorial/09-file-explorer.md`](../manual-tutorial/09-file-explorer.md) diff --git a/coding-agent/SERVER-UI-INSTALL-OPTIONS.md b/coding-agent/SERVER-UI-INSTALL-OPTIONS.md new file mode 100644 index 0000000..0fb63bb --- /dev/null +++ b/coding-agent/SERVER-UI-INSTALL-OPTIONS.md @@ -0,0 +1,142 @@ +# Server UI — opcje instalacji (Control Plane) + +**Data:** 2026-07-05 +**Host:** gmktec-k11 +**Kontekst:** Użytkownik nie widzi Server UI w `docker ps` — wyjaśnienie runtime + instalator z wyborem native/Docker. + +**Powiązane:** +- [`stacks/server-ui/`](../stacks/server-ui/) +- [`manual-tutorial/08-server-ui-install.md`](../manual-tutorial/08-server-ui-install.md) +- [`ADR-001-host-agent-control-plane.md`](ADR-001-host-agent-control-plane.md) + +--- + +## 1. Executive summary + +- **Server UI** domyślnie działa jako **systemd** (`server-ui.service`, port 8091) — **nie** jako kontener Docker. +- **gpu-fan** działa wyłącznie jako **host agent** (systemd, port 18090 localhost) — Docker **nie jest obsługiwany** (NVML). +- Nowy skrypt **`install-control-plane.sh`** instaluje oba komponenty z menu: gpu-fan native + Server UI native **lub** Docker. +- Tutorial dla użytkownika: [`08-server-ui-install.md`](../manual-tutorial/08-server-ui-install.md). + +--- + +## 2. Architektura + +```mermaid +flowchart TB + subgraph dockerStacks [Docker workloads] + comfyui[comfyui :8188] + localai[localai :8070] + npmplus[npmplus :81] + end + subgraph hostNative [Host systemd] + gpufan[gpu-fan agent :18090] + end + subgraph serverUINative [Server UI native] + suiNative[server-ui :8091] + end + subgraph serverUIDocker [Server UI docker optional] + suiDocker[container server-ui :8091] + end + browser[Browser] --> suiNative + browser -.-> suiDocker + suiNative --> dockerStacks + suiDocker --> dockerStacks + suiNative --> gpufan + suiDocker --> gpufan + gpufan --> gpu[RTX 3090 Ti] +``` + +--- + +## 3. Instalator + +**Ścieżka:** `stacks/server-ui/scripts/install-control-plane.sh` + +| Flaga | Wartości | Domyślnie (-y) | +|-------|----------|----------------| +| `--gpu-fan` | yes, no, skip | yes | +| `--server-ui` | native, docker, skip | native | + +**Przykłady:** + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/server-ui +sudo ./scripts/install-control-plane.sh # interaktywne menu +sudo ./scripts/install-control-plane.sh -y # gpu-fan + server-ui native +sudo ./scripts/install-control-plane.sh --gpu-fan=yes --server-ui=docker +``` + +**Skrypty pomocnicze:** + +| Skrypt | Rola | +|--------|------| +| `install-control-plane.sh` | Menu: gpu-fan + server-ui | +| `install.sh` | Tylko Server UI native | +| `install-docker.sh` | Tylko Server UI Docker | + +Przy przełączaniu native ↔ Docker instalator wyłącza drugi tryb (systemd disable lub `compose down`). + +--- + +## 4. Klucze API + +Jeden plik dla gpu-fan + Server UI: + +| Środowisko | Plik | Zmienna | +|------------|------|---------| +| Produkcja (systemd) | `/opt/control-plane/.env` | `API_KEY` | +| Dev / Docker | `stacks/control-plane/.env` | `API_KEY` | + +`API_KEY` służy do auth panelu (`X-API-Key`) **i** proxy `/api/gpu-fan/*` do agenta. + +```bash +grep ^API_KEY= /opt/control-plane/.env +``` + +Szczegóły: [`CONTROL-PLANE-ENV.md`](CONTROL-PLANE-ENV.md) + +--- + +## 5. Server UI w Dockerze + +| Plik | Opis | +|------|------| +| `Dockerfile` | Python 3.12 + docker CLI + compose plugin | +| `docker-compose.yml` | profil `server-ui`, mount `docker.sock` + repo `/repo` | +| `install-docker.sh` | build, up, generacja `.env` | + +**GPU fan z kontenera:** `GPU_FAN_AGENT_URL=http://host.docker.internal:18090` + `extra_hosts: host-gateway`. + +**Wymagania:** `REPO_ROOT` w `.env` wskazuje na katalog z `stacks/` (mount read-only do `/repo`). + +--- + +## 6. Weryfikacja + +```bash +# Native +systemctl is-active server-ui gpu-fan +curl -s http://127.0.0.1:8091/api/health + +# Docker +docker compose --profile server-ui ps +curl -s http://127.0.0.1:8091/api/stacks +``` + +--- + +## 7. Checklist wdrożenia + +- [x] `Dockerfile`, `docker-compose.yml`, `install-docker.sh` +- [x] `install-control-plane.sh` +- [x] `manual-tutorial/08-server-ui-install.md` +- [x] README server-ui + główny README (rozdział 08) +- [ ] Na produkcji: `sudo ./scripts/install-control-plane.sh` lub `install.sh` / `install-docker.sh` +- [ ] Test GPU Fan proxy po instalacji Docker + +--- + +## 8. Dlaczego gpu-fan bez Docker + +Patrz [`stacks/gpu-fan/coding-agent/DOCKER-VS-HOST-REPORT.md`](../stacks/gpu-fan/coding-agent/DOCKER-VS-HOST-REPORT.md) i ADR-001. Instalator wyświetla komunikat i oferuje tylko native. diff --git a/coding-agent/SERVER-UI-PORT-CONFIG.md b/coding-agent/SERVER-UI-PORT-CONFIG.md new file mode 100644 index 0000000..da702b3 --- /dev/null +++ b/coding-agent/SERVER-UI-PORT-CONFIG.md @@ -0,0 +1,173 @@ +# Server UI — edycja portów stacków + +**Data:** 2026-07-05 +**Host:** gmktec-k11 +**Kontekst:** Linki „Otwórz :PORT” w Server UI brały port z `stacks.yaml`, podczas gdy Docker mapował port z osobnego `stacks//.env`. Wdrożono jeden source of truth w `.env` stacku + edycję w UI. + +**Powiązane:** +- [`stacks/server-ui/`](../stacks/server-ui/) +- [`coding-agent/SERVER-UI-ARCHITECTURE-RESEARCH.md`](SERVER-UI-ARCHITECTURE-RESEARCH.md) +- [`coding-agent/CONVENTIONS.md`](CONVENTIONS.md) + +--- + +## 1. Executive summary + +Server UI odczytuje i zapisuje port hosta w pliku `stacks//.env` (np. `LOCALAI_PORT=8070`). Karty stacków mają pole numeryczne i przycisk **Zapisz port**; po zapisie uruchamiany jest `docker compose up -d --force-recreate` gdy stack działa. Nowa usługa wymaga tylko wpisu w `stacks.yaml` z polami `port_env`, `port_default`, `port_editable`. + +--- + +## 2. As-is / to-be + +| Aspekt | Przed | Po | +|--------|-------|-----| +| Źródło portu w linku UI | `stacks.yaml` → `ui_port` (statyczny) | `stacks//.env` → `port_env` | +| Edycja portu | ręcznie w `.env` + restart compose | UI + `PATCH /api/stacks/{id}/port` | +| Nowa usługa | hardcode w JS | metadane w `stacks.yaml` | +| NPMPlus | link :81 | read-only (host network) | + +```mermaid +flowchart LR + yaml[stacks.yaml port_env metadata] + env[stack .env] + api[Server UI API] + ui[Stack card port input] + compose[docker compose force-recreate] + yaml --> api + env --> api + api --> ui + api -->|PATCH| env + env --> compose +``` + +--- + +## 3. Konfiguracja stacków (`stacks.yaml`) + +| Stack | `port_env` | `port_default` | `port_editable` | +|-------|------------|----------------|-----------------| +| localai | `LOCALAI_PORT` | 8080 | true | +| comfyui | `COMFYUI_PORT` | 8188 | true | +| vllm | `VLLM_PORT` | 8000 | true | +| npmplus | — | 81 | false | + +**Nowa usługa** — dodaj wpis: + +```yaml + - id: myservice + name: My Service + compose_dir: myservice + profile: myservice + container: myservice + ui_port: 9000 # fallback gdy brak w .env + port_env: MYSERVICE_PORT + port_default: 9000 + port_editable: true + gpu: false +``` + +W `docker-compose.yml` stacku: `"${MYSERVICE_PORT:-9000}:9000"`. + +--- + +## 4. API + +### `GET /api/stacks` + +Każdy stack zawiera dodatkowo: + +```json +{ + "ui_port": 8070, + "port_env": "LOCALAI_PORT", + "port_editable": true, + "port_default": 8080, + "published_port": 8070, + "port_pending_restart": false +} +``` + +`port_pending_restart`: kontener działa, ale opublikowany port ≠ wartość w `.env`. + +### `PATCH /api/stacks/{stack_id}/port` + +**Auth:** `X-API-Key` (jak start/stop) + +**Body:** +```json +{ "port": 8070, "recreate": true } +``` + +**Odpowiedź:** +```json +{ + "ok": true, + "stack_id": "localai", + "port": 8070, + "port_env": "LOCALAI_PORT", + "requires_restart": false, + "recreated": true, + "running": true +} +``` + +**Błędy:** +- `400` — stack bez edycji portu (npmplus) +- `409` — port zajęty / poza zakresem 1024–65535 +- `500` — błąd docker compose + +**Zarezerwowane porty:** 80, 443, 8090 (gpu-fan), 18090 (gpu-fan agent), `SERVER_UI_PORT` (8091). + +--- + +## 5. Pliki zmienione + +| Plik | Zmiana | +|------|--------| +| `stacks/server-ui/stacks.yaml` | `port_env`, `port_default`, `port_editable` | +| `stacks/server-ui/compose_runner.py` | read/write `.env`, walidacja, recreate | +| `stacks/server-ui/app.py` | `PATCH /api/stacks/{id}/port` | +| `stacks/server-ui/static/index.html` | input portu w kartach stacków | + +--- + +## 6. Weryfikacja + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/server-ui + +# Lista stacków z portami +curl -s http://127.0.0.1:8091/api/stacks | python3 -m json.tool + +# Zmiana portu (wymaga API_KEY z .env) +curl -s -X PATCH http://127.0.0.1:8091/api/stacks/localai/port \ + -H "X-API-Key: $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"port":8070,"recreate":true}' + +grep LOCALAI_PORT ../localai/.env +``` + +**UI:** zakładka Stacki → karta LocalAI → pole portu → **Zapisz port** → link „Otwórz” się aktualizuje. + +**Prod:** `sudo scripts/install.sh && sudo systemctl restart server-ui` + +--- + +## 7. Uwagi operacyjne + +- Po zmianie `LOCALAI_PORT` zaktualizuj **upstream w NPMPlus** ręcznie (proxy host → nowy port). +- `docker compose restart` **nie** zmienia mapowania portów — wymagany recreate (`recreate: true` w API). +- NPMPlus (`network_mode: host`) — port 81 tylko do odczytu w UI. + +--- + +## 8. Checklist wdrożenia + +- [x] Metadane portów w `stacks.yaml` +- [x] Backend: odczyt/zapis `.env`, walidacja, recreate +- [x] API `PATCH /port` +- [x] UI: input + Zapisz w kartach +- [x] NPMPlus read-only +- [ ] `install.sh` + restart server-ui na produkcji +- [ ] Test zmiany portu LocalAI na hoście diff --git a/coding-agent/STABILITYMATRIX-RESEARCH.md b/coding-agent/STABILITYMATRIX-RESEARCH.md new file mode 100644 index 0000000..7dd6c75 --- /dev/null +++ b/coding-agent/STABILITYMATRIX-RESEARCH.md @@ -0,0 +1,69 @@ +# Research: Stability Matrix na gmktec-k11 + +Data: 2026-07-04 +Host: `gmktec-k11` — Ubuntu 26.04 minimized, RTX 3090 Ti 24 GB, **tylko SSH (headless)** + +## Czym jest Stability Matrix + +[LykosAI/StabilityMatrix](https://github.com/LykosAI/StabilityMatrix) to menedżer pakietów + **GUI desktop** (Avalonia/.NET) do instalacji ekosystemu Stable Diffusion: + +- Pakiety: ComfyUI, Forge/A1111, Fooocus, InvokeAI, Kohya, StableSwarmUI, … +- Funkcje: Inference UI, Model Browser (CivitAI/HF), wspólny katalog modeli +- Linux: oficjalnie **AppImage** (`StabilityMatrix-linux-x64.zip`, latest v2.16.1) +- Licencja: AGPL (kod), EULA (binaria) + +## Czy działa na Ubuntu? + +**Tak — oficjalnie wspierane**, ale jako aplikacja graficzna, nie serwis headless. + +| Wymaganie | gmktec-k11 | Ocena | +|-----------|------------|-------| +| OS Linux x64 | Ubuntu 26.04 | OK | +| GPU NVIDIA | RTX 3090 Ti, `nvidia-smi` | OK | +| FUSE (AppImage) | Ubuntu minimized — brak domyślnie | Dołożyć `libfuse2t64` | +| GUI (X11/Wayland) | Serwer SSH, minimized | **Brak** — SM wymaga pulpitu | +| Miejsce na dysku | `/data` ~1 TB | OK | +| Konflikt GPU | LocalAI na 8070 | Jeden duży workload GPU naraz | + +### Znane problemy (GitHub Issues) + +- Ubuntu 24.04+: `libfuse2t64` wymagane do AppImage +- Instalacja pakietów: czasem brakuje `build-essential`, `python3-dev`, … ([#1454](https://github.com/LykosAI/StabilityMatrix/issues/1454)) +- Headless: możliwe z VNC/X11, ale SM nie jest do tego zaprojektowany +- PATH w subprocessach: sporadyczne błędy `nvidia-smi` ([#1577](https://github.com/LykosAI/StabilityMatrix/issues/1577)) + +## Werdykt + +| Pytanie | Odpowiedź | +|---------|-----------| +| Czy można zainstalować na Ubuntu? | **Tak** (AppImage + zależności) | +| Czy ma sens na **tym** hoście? | **Nie** — tylko SSH, bez GUI | +| Zgodność z repo `ubuntu-bare-metal`? | **Słaba** — tutorial 03b zakłada ComfyUI **w Dockerze** | + +## Decyzja + +**Stability Matrix nie instalujemy.** + +Zamiast SM wdrażamy stack **ComfyUI w Dockerze** (`stacks/comfyui/`) — odpowiednik funkcji (generowanie obrazów / ComfyUI), bez GUI-menedżera, zgodny z architekturą repo. + +## Gdyby kiedyś (z GUI) + +Tylko z VNC/RDP + lightweight desktop lub fizycznym monitorem: + +```bash +sudo apt install libfuse2t64 +cd /data/apps +wget https://github.com/LykosAI/StabilityMatrix/releases/download/v2.16.1/StabilityMatrix-linux-x64.zip +unzip StabilityMatrix-linux-x64.zip +chmod +x StabilityMatrix.AppImage +./StabilityMatrix.AppImage # wymaga DISPLAY +``` + +Data Directory: `/data/apps/stability-matrix/` — osobna ścieżka, niezależna od Docker stacks. + +## Powiązane + +- [`stacks/comfyui/README.md`](../stacks/comfyui/README.md) +- [`COMFYUI-DEPLOYMENT.md`](COMFYUI-DEPLOYMENT.md) +- [`COMFYUI-HANDOFF.md`](COMFYUI-HANDOFF.md) +- [`SWARMUI-REMOVAL.md`](SWARMUI-REMOVAL.md) — poprzedni UI obrazów usunięty diff --git a/coding-agent/STATE.md b/coding-agent/STATE.md new file mode 100644 index 0000000..9b5bd26 --- /dev/null +++ b/coding-agent/STATE.md @@ -0,0 +1,147 @@ +# Stan systemu i repo + +Snapshot na koniec sesji. Weryfikuj na serwerze przed działaniem (`docker ps`, `mountpoint /data`). + +## Co działa + +| Komponent | Status | Uwagi | +|-----------|--------|-------| +| Docker CE | OK | Root: `/data/docker` | +| NVIDIA driver | OK | ~595.x, `nvidia-smi` na hoście | +| GPU w kontenerze | OK | `--gpus all` testowany wcześniej | +| Mount `/data` | OK | ~1 TB, ext4, fstab UUID | +| vLLM image | OK | `vllm/vllm-openai:latest` pulled | +| vLLM container | STOP | `VLLM_MODEL` puste | +| LocalAI image | OK | `v4.4.3-gpu-nvidia-cuda-13` | +| LocalAI container | RUN | profil `localai`, port host **8070** | +| Server UI | stack w repo | `stacks/server-ui/` :8091 — native (systemd) lub Docker; zakładki: **CLI**, Pliki, Stacki, GPU Fan | +| gpu-fan agent | host systemd | `/opt/gpu-fan` :18090 localhost — **nie w `docker ps`** | +| ComfyUI | stack w repo | `stacks/comfyui/` — **nie uruchomiony na hoście** (rozdział 07) | +| Firewall / TLS | — | nie wdrożony (rozdział 07) | + +## Ścieżki na `/data` + +``` +/data/ +├── docker/ # obrazy i warstwy Docker +└── apps/ + ├── vllm/huggingface/ # cache HF (AWQ) — bez pobranego modelu + ├── gguf/ + │ ├── qwen3.6-27b/ # pod GGUF z katalogu vLLM + │ └── gemma-4-12b/ + ├── localai/ + │ ├── models/ # modele LocalAI (pobieranie w toku) + │ ├── backends/ + │ ├── configuration/ # api_keys.json, runtime_settings.json + │ ├── images/ + │ └── data/ # auth DB, agents (przyszłe LOCALAI_AUTH) + └── comfyui/ # rozdział 07 + ├── storage/ + ├── models/ + ├── cache/hf-hub/ + ├── cache/torch-hub/ + ├── input/ + ├── output/ + ├── custom_nodes/ + └── workflows/ +``` + +## Pliki `.env` na serwerze (nie w git) + +| Plik | Kluczowe ustawienia | +|------|---------------------| +| `/opt/control-plane/.env` | `API_KEY`, `SERVER_UI_*`, `GPU_FAN_*` — **jedyny** klucz panelu; instrukcja: [`04a-api-key.md`](../manual-tutorial/04a-api-key.md) | +| `stacks/vllm/.env` | `VLLM_MODEL=` (puste), `MAX_MODEL_LEN=131072`, port 8000 | +| `stacks/localai/.env` | `LOCALAI_PORT=8070`, `LOCALAI_API_KEY` ustawiony (sekret), obraz cuda-13 | + +## Porty stacków (Server UI) + +| Stack | Zmienna `.env` | Domyślny | Edycja w UI | +|-------|----------------|----------|-------------| +| LocalAI | `LOCALAI_PORT` | 8080 | tak | +| ComfyUI | `COMFYUI_PORT` | 8188 | tak | +| vLLM | `VLLM_PORT` | 8000 | tak | +| NPMPlus | — (host network) | 81 | nie (read-only) | + +Szczegóły: [`SERVER-UI-PORT-CONFIG.md`](SERVER-UI-PORT-CONFIG.md) + +## Control plane (instalacja) + +| Komponent | Runtime | Instalator | +|-----------|---------|------------| +| gpu-fan | systemd native only | `install-control-plane.sh` lub `gpu-fan/scripts/install.sh` | +| Server UI | systemd **lub** Docker | `install-control-plane.sh`, `install.sh`, `install-docker.sh` | + +Szczegóły: [`SERVER-UI-INSTALL-OPTIONS.md`](SERVER-UI-INSTALL-OPTIONS.md), [`CONTROL-PLANE-ENV.md`](CONTROL-PLANE-ENV.md), [`SERVER-UI-FILE-EXPLORER.md`](SERVER-UI-FILE-EXPLORER.md), tutorial [`08-server-ui-install.md`](../manual-tutorial/08-server-ui-install.md) + +## Struktura repo (kluczowe pliki) + +``` +ubuntu-bare-metal/ +├── README.md +├── scripts/setup-data-disk.sh +├── coding-agent/ # ten katalog +├── manual-tutorial/ +│ ├── 00-prerequisites.md +│ ├── 01-system-update-and-docker.md +│ ├── 02-nvidia-driver.md +│ ├── 03-nvidia-container-toolkit.md +│ ├── 03b-system-tools.md +│ ├── 04-vllm-stack.md # część A dysk + część B vLLM +│ └── 05-localai-stack.md +│ ├── 06-gpu-fan-control.md +│ └── 07-comfyui-stack.md +└── stacks/ + ├── vllm/ + │ ├── models.catalog.yaml + │ ├── docker-compose.yml + │ ├── profiles/ + │ └── scripts/ + ├── localai/ + │ ├── docker-compose.yml + │ └── scripts/ + ├── comfyui/ + │ ├── docker-compose.yml + │ └── scripts/ + └── llamacpp/ + └── README.md +``` + +## Szybka weryfikacja (komendy) + +```bash +# dysk i docker +mountpoint /data && df -h /data +docker info | grep "Docker Root Dir" + +# vLLM +cd ~/cursor/ubuntu-bare-metal/stacks/vllm +docker compose --profile vllm ps +grep VLLM_MODEL .env + +# LocalAI (port z .env użytkownika: 8070) +cd ~/cursor/ubuntu-bare-metal/stacks/localai +docker compose --profile localai ps +curl -s http://localhost:8070/readyz + +# GPU +nvidia-smi +``` + +## Znane rozjazdy (docs vs runtime) + +| Temat | Repo / docs | Faktycznie na serwerze | +|-------|-------------|------------------------| +| LocalAI port | 8080 w `.env.example`, tutorial 05 | **8070** w `.env` użytkownika | +| LOCALAI_API_KEY | brak w `docker-compose.yml` | ustawiony w `.env`, **może nie działać** w kontenerze | +| vLLM model | katalog + skrypty gotowe | model nie pobrany, stack nie startowany | + +## Git / Gitea + +- Repo zainicjowane lokalnie w `ubuntu-bare-metal/` (branch `main`) +- Root `.gitignore` — wyklucza `.env`, `.venv`, `upstream/` +- Push na Gitea: czeka na URL remote — [GITEA-PREP.md](GITEA-PREP.md) + +## Server UI — wykres GPU Fan (ostatnie zmiany UI) + +W `stacks/server-ui/static/index.html`: siatka osi, linijki kreskowe, odstępy etykiet, `aspect-ratio` SVG, wyśrodkowanie w karcie. Tutorial: [06-gpu-fan-control.md](../manual-tutorial/06-gpu-fan-control.md). diff --git a/coding-agent/SWARMUI-REMOVAL.md b/coding-agent/SWARMUI-REMOVAL.md new file mode 100644 index 0000000..6340135 --- /dev/null +++ b/coding-agent/SWARMUI-REMOVAL.md @@ -0,0 +1,31 @@ +# SwarmUI — usunięte z hosta (2026-07-04) + +Użytkownik zrezygnował ze SwarmUI na serwerze `gmktec-k11`. + +## Co usunięto + +| Element | Status | +|---------|--------| +| Kontener `swarmui` | usunięty | +| Obraz `swarmui:local` | usunięty | +| Sieć `swarmui_default` | usunięta | +| Dane `/data/apps/swarmui/` (~3.3 GB) | usunięte | +| Stack `stacks/swarmui/` (compose, upstream, skrypty) | usunięty z repo | +| Dokumentacja `SWARMUI-*.md` | usunięta | + +## Co zostało + +- **LocalAI** — główny workload LLM/embed/rerank (`8070`) +- **Server UI** — zarządzanie stackami compose (`8091`). +- **NPMPlus** — reverse proxy (`80/443`) +- Katalog `/data/apps/comfyui/` — pusty placeholder (nie był używany przez SwarmUI stack) + +## Portainer + +W UI Portainer stack/kontener `swarmui` może jeszcze widnieć jako „orphan” do czasu odświeżenia — kontener nie istnieje (`docker ps -a`). + +## Nie odtwarzać bez prośby + +- Nie klonować ponownie `stacks/swarmui/` +- Nie tworzyć `/data/apps/swarmui/` +- Port **7801** jest wolny diff --git a/manual-tutorial/00-prerequisites.md b/manual-tutorial/00-prerequisites.md new file mode 100644 index 0000000..25831de --- /dev/null +++ b/manual-tutorial/00-prerequisites.md @@ -0,0 +1,114 @@ +# 00 — Wymagania i konwencje + +> Przeczytaj ten rozdział przed rozpoczęciem pracy. Nie wymaga wykonywania komend na serwerze. + +## Cel + +Ustalenie wspólnego kontekstu: jaki sprzęt konfigurujemy, jakie są wymagania wstępne i jak czytać kolejne rozdziały tutoriala. + +## Sprzęt i system + +| Parametr | Wartość | +|----------|---------| +| Hostname | `gmktec-k11` | +| System | Ubuntu 26.04 LTS (`resolute`) | +| Kernel | 7.0.x (aktualizowany przez `apt upgrade`) | +| GPU do AI | NVIDIA GeForce RTX 3090 Ti | +| Użytkownik | `tomasz-syn-grzegorza` (grupa `sudo`) | + +## Wymagania wstępne + +- Dostęp SSH lub fizyczna konsola do serwera +- Użytkownik z uprawnieniami `sudo` +- Stabilne połączenie internetowe +- Czysta instalacja Ubuntu minimized (bez wcześniejszej konfiguracji serwera) + +**Ubuntu minimized** — nie ma edytorów (`nano`/`vim`), `rsync`, `parted`, `jq`. Doinstalowujemy je w rozdziale [03b — Narzędzia bazowe](03b-system-tools.md) przed konfiguracją dysku i vLLM. + +## Konwencje w tutorialu + +### Język + +- **Opisy i wyjaśnienia** — po polsku +- **Komendy, nazwy pakietów, ścieżki, zmienne** — po angielsku (jak w systemie Linux) + +### Format komend + +```bash +# komentarz — wyjaśnienie co robi komenda +sudo apt update +``` + +- Komendy z prefiksem `sudo` wymagają uprawnień administratora +- `$USER` oznacza aktualnie zalogowanego użytkownika — nie zamieniaj ręcznie +- Bloki oznaczone **Opcjonalnie** możesz pominąć przy pierwszym przejściu + +### Oznaczenia w tekście + +| Oznaczenie | Znaczenie | +|------------|-----------| +| **Weryfikacja** | Sprawdź wynik przed przejściem dalej | +| **Troubleshooting** | Rozwiązanie typowych problemów | +| **Uwaga** | Ważna informacja — przeczytaj przed wykonaniem | +| **Następny krok** | Link do kolejnego rozdziału | + +### Kolejność rozdziałów + +Nie pomijaj rozdziałów i nie zmieniaj kolejności. Każdy etap buduje na poprzednim: + +1. Aktualizacja systemu + Docker CE +2. Sterowniki NVIDIA +3. NVIDIA Container Toolkit (GPU w kontenerach) +4. vLLM +5. ComfyUI +6. Firewall i hardening + +## Czego NIE instalujemy w kroku 01 + +W pierwszym rozdziale **świadomie pomijamy**: + +- Sterowniki NVIDIA (`nvidia-driver-*`) +- NVIDIA Container Toolkit (`nvidia-ctk`) +- vLLM, ComfyUI i jakiekolwiek compose stacki +- Konfigurację firewalla + +Te elementy pojawią się w kolejnych rozdziałach we właściwej kolejności. + +## Przydatne komendy diagnostyczne (bez zmian w systemie) + +Możesz je uruchomić teraz, żeby potwierdzić punkt startowy: + +```bash +# wersja systemu +lsb_release -a + +# kernel i architektura +uname -a + +# miejsce na dysku +df -h / + +# pamięć RAM +free -h + +# uprawnienia sudo +groups + +# czy Docker jest już zainstalowany (powinno być puste) +which docker +docker --version 2>/dev/null || echo "Docker not installed" + +# GPU wykryte przez PCI (sterownik może jeszcze nie być zainstalowany) +lspci | grep -i nvidia +``` + +Oczekiwany stan przed rozdziałem 01: + +- Ubuntu 26.04 LTS +- Użytkownik w grupie `sudo` +- Docker **nie** zainstalowany +- `nvidia-smi` **niedostępne** (to normalne — sterownik w rozdziale 02) + +## Następny krok + +→ [01 — Aktualizacja systemu i instalacja Docker CE](01-system-update-and-docker.md) diff --git a/manual-tutorial/01-system-update-and-docker.md b/manual-tutorial/01-system-update-and-docker.md new file mode 100644 index 0000000..061bd98 --- /dev/null +++ b/manual-tutorial/01-system-update-and-docker.md @@ -0,0 +1,474 @@ +# 01 — Aktualizacja systemu i instalacja Docker CE + +> **Cel rozdziału:** zaktualizować świeży Ubuntu 26.04 LTS i zainstalować Docker Engine z oficjalnego repozytorium Docker Inc. (nie `docker.io` z Ubuntu). + +**Szacowany czas:** 10–20 minut (zależy od liczby pakietów do aktualizacji) + +**Wymagania:** rozdział [00 — Wymagania i konwencje](00-prerequisites.md) + +--- + +## Spis treści + +1. [Weryfikacja punktu startowego](#1-weryfikacja-punktu-startowego) +2. [Aktualizacja systemu](#2-aktualizacja-systemu) +3. [Instalacja Docker CE](#3-instalacja-docker-ce) +4. [Konfiguracja post-install](#4-konfiguracja-post-install) +5. [Weryfikacja](#5-weryfikacja) +6. [Troubleshooting](#6-troubleshooting) +7. [Czego nie robimy w tym kroku](#7-czego-nie-robimy-w-tym-kroku) +8. [Następny krok](#8-następny-krok) + +--- + +## 1. Weryfikacja punktu startowego + +Przed jakimikolwiek zmianami potwierdź stan systemu. Te komendy **nic nie modyfikują**. + +```bash +# wersja Ubuntu — oczekiwane: 26.04, codename: resolute +lsb_release -a + +# kernel +uname -r + +# wolne miejsce na dysku głównym (minimum ~5 GB na upgrade + Docker) +df -h / + +# uprawnienia sudo +groups | grep -q sudo && echo "sudo: OK" || echo "sudo: BRAK — wymagane!" + +# Docker nie powinien być jeszcze zainstalowany +docker --version 2>/dev/null || echo "Docker: not installed (expected)" + +# GPU widoczne w PCI (sterownik jeszcze niepotrzebny) +lspci | grep -i "nvidia" +``` + +**Oczekiwany wynik:** + +- Ubuntu 26.04 LTS (`resolute`) +- Użytkownik w grupie `sudo` +- Docker niezainstalowany +- NVIDIA RTX 3090 Ti widoczna w `lspci` + +**Uwaga:** `nvidia-smi` nie działa na tym etapie — to normalne. Sterowniki instalujemy w rozdziale 02. + +--- + +## 2. Aktualizacja systemu + +### 2.1 Aktualizacja list pakietów i upgrade + +```bash +sudo apt update +sudo apt upgrade -y +``` + +Pierwszy pełny upgrade na świeżej instalacji może pobrać setki pakietów — poczekaj na zakończenie. + +### 2.2 Instalacja pakietów bazowych + +Te pakiety są potrzebne do dodania repozytorium Docker i dalszej konfiguracji serwera: + +```bash +sudo apt install -y \ + ca-certificates \ + curl \ + gnupg \ + lsb-release \ + apt-transport-https \ + software-properties-common +``` + +| Pakiet | Po co | +|--------|-------| +| `ca-certificates` | Weryfikacja certyfikatów HTTPS (repo Docker) | +| `curl` | Pobieranie kluczy GPG i plików z internetu | +| `gnupg` | Weryfikacja podpisów pakietów | +| `lsb-release` | Odczyt wersji Ubuntu (codename `resolute`) | +| `apt-transport-https` | Obsługa repozytoriów HTTPS przez apt | +| `software-properties-common` | Narzędzia do zarządzania repozytoriami | + +### 2.3 Sprawdzenie, czy wymagany jest restart + +```bash +# jeśli plik istnieje — kernel lub libc wymagają restartu +test -f /var/run/reboot-required && cat /var/run/reboot-required || echo "Restart not required" +``` + +Jeśli restart jest wymagany: + +```bash +sudo reboot +``` + +Po restarcie zaloguj się ponownie i wróć do tego rozdziału od sekcji 3. + +**Uwaga:** Na tym systemie `unattended-upgrades` jest domyślnie aktywny — to dobrze dla bezpieczeństwa. Pierwszy pełny upgrade wykonujemy jednak ręcznie, żeby mieć kontrolę nad procesem. + +--- + +## 3. Instalacja Docker CE + +Instalujemy Docker z **oficjalnego repozytorium Docker Inc.**, nie z pakietu `docker.io` dostępnego w repozytoriach Ubuntu. Oficjalne repo daje: + +- najnowsze wersje Engine, +- plugin `docker compose` (v2), +- plugin `docker-buildx`, +- bezpośrednią ścieżkę aktualizacji (`apt upgrade`). + +Dokumentacja: [Install Docker Engine on Ubuntu](https://docs.docker.com/engine/install/ubuntu/) + +### 3.1 Usunięcie konfliktowych pakietów + +Jeśli wcześniej nic nie instalowałeś, ten krok nic nie usunie — ale warto go wykonać dla czystości: + +```bash +for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do + sudo apt remove -y $pkg 2>/dev/null +done +``` + +### 3.2 Dodanie oficjalnego klucza GPG Docker + +```bash +sudo install -m 0755 -d /etc/apt/keyrings + +sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + -o /etc/apt/keyrings/docker.asc + +sudo chmod a+r /etc/apt/keyrings/docker.asc +``` + +### 3.3 Dodanie repozytorium Docker (format DEB822) + +```bash +sudo tee /etc/apt/sources.list.d/docker.sources </dev/null | grep -E 'Server Version|Storage Driver|Cgroup Driver|Logging Driver' +``` + +Oczekiwane m.in.: +- `Storage Driver: overlay2` +- `Logging Driver: json-file` + +### 5.5 Test bez sudo (po re-logowaniu / newgrp) + +```bash +docker ps +``` + +Oczekiwane: pusta lista kontenerów, **bez** błędu uprawnień. + +### Checklist + +- [ ] `docker --version` działa +- [ ] `docker compose version` działa +- [ ] `docker run --rm hello-world` zakończone sukcesem +- [ ] `systemctl status docker` → active (running) +- [ ] `docker ps` działa bez `sudo` +- [ ] `/etc/docker/daemon.json` istnieje z rotacją logów + +--- + +## 6. Troubleshooting + +### 6.1 Permission denied na `/var/run/docker.sock` + +``` +permission denied while trying to connect to the Docker daemon socket +``` + +**Przyczyna:** Użytkownik nie jest w grupie `docker` lub nie zalogował się ponownie po `usermod`. + +**Rozwiązanie:** + +```bash +# sprawdź grupy +groups + +# jeśli brak "docker": +sudo usermod -aG docker $USER +newgrp docker # lub wyloguj się i zaloguj ponownie +``` + +### 6.2 `docker-ce has no installation candidate` + +**Przyczyna:** Błędny codename w `/etc/apt/sources.list.d/docker.sources`. + +**Rozwiązanie:** + +```bash +grep Suites /etc/apt/sources.list.d/docker.sources +# musi być: Suites: resolute + +# jeśli inny — usuń i dodaj repo ponownie (sekcja 3.3) +sudo rm /etc/apt/sources.list.d/docker.sources +# ... powtórz kroki 3.2 i 3.3 +``` + +### 6.3 Konflikt z `docker.io` + +**Objaw:** apt instaluje `docker.io` zamiast `docker-ce`, lub oba się gryzą. + +**Rozwiązanie:** + +```bash +sudo apt remove -y docker.io docker-doc docker-compose docker-compose-v2 +sudo apt autoremove -y +# powtórz sekcję 3.4 +``` + +### 6.4 Błąd pobierania klucza GPG + +``` +curl: (6) Could not resolve host: download.docker.com +``` + +**Przyczyna:** Brak internetu lub problem DNS. + +**Rozwiązanie:** + +```bash +# test połączenia +ping -c 3 download.docker.com +ping -c 3 8.8.8.8 + +# sprawdź DNS +cat /etc/resolv.conf +``` + +### 6.5 Daemon nie startuje po `daemon.json` + +**Objaw:** `systemctl status docker` → failed. + +**Przyczyna:** Błędny JSON w `/etc/docker/daemon.json`. + +**Rozwiązanie:** + +```bash +# walidacja JSON +python3 -m json.tool /etc/docker/daemon.json + +# jeśli błąd składni — przywróć minimalną konfigurację: +sudo tee /etc/docker/daemon.json <<'EOF' +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "50m", + "max-file": "3" + } +} +EOF + +sudo systemctl restart docker +``` + +### 6.6 `hello-world` — image pull failed + +```bash +# test dostępu do Docker Hub +docker pull hello-world + +# sprawdź logi daemona +sudo journalctl -u docker --no-pager -n 50 +``` + +--- + +## 7. Czego nie robimy w tym kroku + +Świadomie **pomijamy** — pojawią się w kolejnych rozdziałach: + +| Element | Rozdział | +|---------|----------| +| Sterowniki NVIDIA (`nvidia-driver-*`) | 02 | +| `nvidia-smi`, CUDA toolkit | 02 | +| NVIDIA Container Toolkit (`nvidia-ctk`) | 03 | +| GPU w kontenerach (`--gpus all`) | 03 | +| vLLM | 04 | +| ComfyUI | 05 | +| UFW / firewall / fail2ban | 06 | +| Katalog `/data` na modele | później | + +Docker zainstalowany w tym rozdziale **nie ma dostępu do GPU** — to zamierzone. Najpierw fundament, potem warstwy. + +--- + +## 8. Następny krok + +Po przejściu checklisty z sekcji 5: + +1. Zgłoś w rozmowie z Cursorem, że krok 01 jest gotowy (lub opisz problemy z Troubleshooting). +2. Przejdź do rozdziału [**02 — Sterowniki NVIDIA**](02-nvidia-driver.md). + +--- + +## Podsumowanie wykonanych zmian + +Po ukończeniu tego rozdziału na serwerze powinno być: + +- Zaktualizowany system Ubuntu 26.04 LTS +- Zainstalowany Docker CE z oficjalnego repo +- Pluginy `docker compose` i `docker-buildx` +- Użytkownik w grupie `docker` +- Skonfigurowana rotacja logów w `/etc/docker/daemon.json` +- Działający test `hello-world` diff --git a/manual-tutorial/02-nvidia-driver.md b/manual-tutorial/02-nvidia-driver.md new file mode 100644 index 0000000..c4c3089 --- /dev/null +++ b/manual-tutorial/02-nvidia-driver.md @@ -0,0 +1,367 @@ +# 02 — Sterowniki NVIDIA + +> **Cel rozdziału:** zainstalować sterownik NVIDIA dla RTX 3090 Ti na headless serwerze Ubuntu 26.04, tak aby `nvidia-smi` działało na hoście przed konfiguracją GPU w Dockerze. + +**Szacowany czas:** 15–30 minut (zależy od pobierania pakietów i ewentualnego DKMS) + +**Wymagania:** ukończony rozdział [01 — Aktualizacja systemu i Docker CE](01-system-update-and-docker.md) + +--- + +## Spis treści + +1. [Weryfikacja punktu startowego](#1-weryfikacja-punktu-startowego) +2. [Wybór sterownika](#2-wybór-sterownika) +3. [Instalacja sterownika](#3-instalacja-sterownika) +4. [Restart i pierwsze uruchomienie](#4-restart-i-pierwsze-uruchomienie) +5. [Konfiguracja serwerowa (opcjonalna)](#5-konfiguracja-serwerowa-opcjonalna) +6. [Weryfikacja](#6-weryfikacja) +7. [Troubleshooting](#7-troubleshooting) +8. [Czego nie robimy w tym kroku](#8-czego-nie-robimy-w-tym-kroku) +9. [Następny krok](#9-następny-krok) + +--- + +## 1. Weryfikacja punktu startowego + +```bash +# Docker z rozdziału 01 — musi działać +docker --version +docker ps + +# GPU widoczne w PCI +lspci | grep -i "nvidia" + +# nvidia-smi jeszcze nie działa — to normalne +nvidia-smi 2>/dev/null || echo "nvidia-smi: not available (expected)" + +# lista dostępnych sterowników +sudo ubuntu-drivers devices +``` + +**Oczekiwany wynik:** + +- RTX 3090 Ti (`GA102`) widoczna w `lspci` +- `nvidia-smi` niedostępne +- `ubuntu-drivers` pokazuje m.in. `nvidia-driver-595-open` jako **recommended** + +Sprawdź też Secure Boot (wpływa na DKMS): + +```bash +mokutil --sb-state 2>/dev/null || echo "mokutil not available" +``` + +Na tym serwerze Secure Boot jest wyłączony — nie będzie promptu MOK przy instalacji. + +--- + +## 2. Wybór sterownika + +Dla **headless serwera AI** (vLLM + ComfyUI, bez monitora) rekomendujemy wariant **server + open kernel modules**: + +| Wariant | Pakiet | Kiedy użyć | +|---------|--------|------------| +| **Rekomendowany** | `nvidia-driver-595-server-open` | Serwer compute, bez GUI, nowoczesne moduły open | +| Alternatywa | `nvidia-driver-595-open` | Jeśli server-open sprawia problemy | +| Nie używamy | `nouveau` | Sterownik open-source — za wolny do AI | + +**Dlaczego server-open, a nie desktop `595-open`?** + +- Wariant **server** nie ciągnie zbędnych zależności od display managera +- Wariant **open** to aktualna rekomendacja Ubuntu 26.04 dla nowoczesnych GPU +- Sterownik pochodzi z repozytorium Ubuntu (`restricted`) — aktualizuje się przez `apt upgrade` i przeżywa upgrade kernela (DKMS / prebuilt modules) + +**Uwaga:** Na hoście **nie instalujemy** pełnego CUDA Toolkit. Kontenery vLLM/ComfyUI dostarczą własne biblioteki CUDA — w rozdziale 03 dodamy tylko NVIDIA Container Toolkit. + +**Dual GPU:** W tym systemie jest też iGPU AMD (HawkPoint). Do workloadów AI używamy wyłącznie NVIDIA: + +```bash +# zapisz na później — używane w compose stackach +export CUDA_VISIBLE_DEVICES=0 +``` + +--- + +## 3. Instalacja sterownika + +### 3.1 Pakiety pomocnicze + +```bash +sudo apt update +sudo apt install -y ubuntu-drivers-common +``` + +### 3.2 Instalacja sterownika server-open (rekomendowana) + +```bash +sudo ubuntu-drivers install --gpgpu nvidia:595-server-open +``` + +Flaga `--gpgpu` filtruje sterowniki pod obciążenia compute (bez GUI). + +Jeśli powyższa komenda zgłosi brak pakietu, użyj bezpośredniej instalacji apt: + +```bash +sudo apt install -y nvidia-driver-595-server-open +``` + +### 3.3 Narzędzia monitorowania (`nvidia-smi`) + +Na serwerach headless pakiet `nvidia-utils` czasem nie jest dołączany automatycznie: + +```bash +sudo apt install -y nvidia-utils-595-server +``` + +### 3.4 Alternatywa — sterownik desktop recommended + +Jeśli wolisz iść ścieżką „recommended” z `ubuntu-drivers devices`: + +```bash +sudo ubuntu-drivers install nvidia:595-open +sudo apt install -y nvidia-utils-595 +``` + +Ta ścieżka też zadziała — ale dla serwera AI preferujemy wariant z sekcji 3.2. + +### 3.5 Sprawdzenie zainstalowanych pakietów + +```bash +dpkg -l | grep -i nvidia | grep -v linux-firmware +``` + +Oczekiwane m.in.: `nvidia-driver-595-server-open`, `nvidia-utils-595-server`. + +--- + +## 4. Restart i pierwsze uruchomienie + +Sterownik NVIDIA ładuje się do jądra dopiero po restarcie: + +```bash +sudo reboot +``` + +Po restarcie zaloguj się ponownie przez SSH i wróć do sekcji 6 (Weryfikacja). + +--- + +## 5. Konfiguracja serwerowa (opcjonalna) + +Te kroki możesz wykonać po pierwszym udanym `nvidia-smi`. + +### 5.1 Persistence mode + +Zmniejsza opóźnienie przy pierwszym uruchomieniu workloadu GPU (kontenery startują szybciej): + +```bash +# włącz persistence mode +sudo nvidia-smi -pm 1 + +# sprawdź +nvidia-smi | grep -i persistence +``` + +Aby włączać przy każdym bootcie, utwórz systemd service (opcjonalnie): + +```bash +sudo tee /etc/systemd/system/nvidia-persistenced.service <<'EOF' +[Unit] +Description=NVIDIA Persistence Daemon +After=nvidia-persistenced.socket + +[Service] +Type=forking +ExecStart=/usr/bin/nvidia-persistenced --user nvidia-persistenced +Restart=on-failure + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl enable --now nvidia-persistenced 2>/dev/null || true +``` + +**Uwaga:** Na Ubuntu 26.04 daemon może już być zarządzany przez pakiet sterownika — jeśli `nvidia-persistenced` działa, nie twórz duplikatu. + +### 5.2 Limit mocy GPU (opcjonalnie) + +RTX 3090 Ti domyślnie pobiera dużo energii. Na serwerze domowym możesz ograniczyć TDP: + +```bash +# sprawdź aktualny limit (450W max dla 3090 Ti) +nvidia-smi -q -d POWER | grep -E "Power Limit|Power Draw" + +# przykład: limit 350W (wartość w mW) — dostosuj do swojego PSU +# sudo nvidia-smi -pl 350 +``` + +Pomiń ten krok, jeśli zależy Ci na maksymalnej wydajności inference. + +--- + +## 6. Weryfikacja + +Wykonaj wszystkie punkty po restarcie. + +### 6.1 `nvidia-smi` + +```bash +nvidia-smi +``` + +Oczekiwane: +- GPU: **NVIDIA GeForce RTX 3090 Ti** +- Driver Version: **595.x** +- CUDA Version: wyświetlona (np. 12.x) — to wersja wspierana przez sterownik, nie osobna instalacja CUDA +- Brak błędów `NVIDIA-SMI has failed` + +### 6.2 Wersja modułu jądra + +```bash +cat /proc/driver/nvidia/version +``` + +### 6.3 Pełna lista GPU + +```bash +nvidia-smi -L +``` + +Oczekiwane: jedna karta NVIDIA (GPU 0). iGPU AMD nie pojawia się w `nvidia-smi` — to prawidłowe. + +### 6.4 Test obciążenia (krótki) + +```bash +nvidia-smi dmon -s pucvmet -d 1 -c 3 +``` + +Powinieneś zobaczyć odczyty temperatury, mocy i wykorzystania GPU. + +### 6.5 Docker nadal działa + +```bash +docker run --rm hello-world +``` + +Sterownik NVIDIA nie powinien zakłócić Dockera z rozdziału 01. + +### Checklist + +- [ ] `nvidia-smi` działa bez błędów +- [ ] Widoczna RTX 3090 Ti (24 GB VRAM) +- [ ] Driver Version 595.x +- [ ] `cat /proc/driver/nvidia/version` zwraca wersję +- [ ] Docker nadal działa (`hello-world` OK) +- [ ] (Opcjonalnie) persistence mode włączony + +--- + +## 7. Troubleshooting + +### 7.1 `NVIDIA-SMI has failed` po restarcie + +**Przyczyna:** Moduł jądra nie załadowany lub konflikt z `nouveau`. + +**Diagnostyka:** + +```bash +lsmod | grep -E 'nvidia|nouveau' +dmesg | grep -i nvidia | tail -20 +``` + +**Rozwiązanie:** + +```bash +# nouveau powinien być wyłączony — jeśli załadowany: +cat /etc/modprobe.d/blacklist-nvidia-nouveau.conf 2>/dev/null + +# przeinstaluj sterownik +sudo apt install --reinstall nvidia-driver-595-server-open +sudo reboot +``` + +### 7.2 DKMS — błąd kompilacji modułu + +**Objaw:** Instalacja kończy się błędem DKMS. + +**Rozwiązanie:** + +```bash +# upewnij się, że masz nagłówki kernela +sudo apt install -y linux-headers-$(uname -r) build-essential dkms + +# ponów instalację +sudo apt install --reinstall nvidia-driver-595-server-open +``` + +### 7.3 Secure Boot — moduł niepodpisany + +**Objaw:** Po restarcie brak `nvidia` w `lsmod`, Secure Boot enabled. + +Na tym serwerze Secure Boot jest wyłączony. Jeśli włączysz go później: + +```bash +mokutil --sb-state +# wymagana rejestracja klucza MOK po instalacji sterownika +sudo reboot # → menu MOK enrollment +``` + +### 7.4 `ubuntu-drivers install` — GUI nie działa + +Na Ubuntu 26.04 zakładka „Additional Drivers” w GUI może być pusta — to znany problem. **Używaj wyłącznie CLI** (ten rozdział). + +### 7.5 Po aktualizacji kernela — `nvidia-smi` przestaje działać + +Po `apt upgrade` z nowym kernelem wymagany restart: + +```bash +test -f /var/run/reboot-required && echo "Reboot required" || echo "OK" +sudo reboot +``` + +Ubuntu 26.04 na tym sprzęcie używa prebuilt modules (`linux-modules-nvidia-*`) — zwykle nie wymaga ręcznej rekompilacji DKMS. + +### 7.6 Zła karta GPU używana przez aplikację + +Jeśli w przyszłości pojawi się druga karta NVIDIA, wymuszaj: + +```bash +export CUDA_VISIBLE_DEVICES=0 +``` + +Dla RTX 3090 Ti jako jedynej karty NVIDIA w systemie domyślnie jest `GPU 0`. + +--- + +## 8. Czego nie robimy w tym kroku + +| Element | Rozdział | +|---------|----------| +| NVIDIA Container Toolkit (`nvidia-ctk`) | 03 | +| GPU w kontenerach Docker (`--gpus all`) | 03 | +| CUDA Toolkit na hoście | niepotrzebne — CUDA w kontenerach | +| vLLM | 04 | +| ComfyUI | 05 | +| Firewall | 06 | + +--- + +## 9. Następny krok + +Po przejściu checklisty z sekcji 6: + +1. Zgłoś w rozmowie z Cursorem, że krok 02 jest gotowy (lub opisz problemy). +2. Przejdź do rozdziału [**03 — NVIDIA Container Toolkit**](03-nvidia-container-toolkit.md). + +--- + +## Podsumowanie wykonanych zmian + +Po ukończeniu tego rozdziału na serwerze powinno być: + +- Zainstalowany sterownik `nvidia-driver-595-server-open` (lub `595-open`) +- Działające `nvidia-smi` z RTX 3090 Ti +- Narzędzia `nvidia-utils-595-server` +- Docker z rozdziału 01 nadal sprawny +- (Opcjonalnie) włączony persistence mode diff --git a/manual-tutorial/03-nvidia-container-toolkit.md b/manual-tutorial/03-nvidia-container-toolkit.md new file mode 100644 index 0000000..18c5265 --- /dev/null +++ b/manual-tutorial/03-nvidia-container-toolkit.md @@ -0,0 +1,362 @@ +# 03 — NVIDIA Container Toolkit + +> **Cel rozdziału:** skonfigurować Docker tak, aby kontenery miały dostęp do GPU NVIDIA (RTX 3090 Ti) — fundament pod vLLM i ComfyUI. + +**Szacowany czas:** 10–15 minut + +**Wymagania:** ukończone rozdziały [01](01-system-update-and-docker.md) i [02](02-nvidia-driver.md) + +--- + +## Spis treści + +1. [Weryfikacja punktu startowego](#1-weryfikacja-punktu-startowego) +2. [Co robi NVIDIA Container Toolkit](#2-co-robi-nvidia-container-toolkit) +3. [Instalacja](#3-instalacja) +4. [Konfiguracja runtime Docker](#4-konfiguracja-runtime-docker) +5. [Test GPU w kontenerze](#5-test-gpu-w-kontenerze) +6. [Składnia GPU dla Compose (podgląd)](#6-składnia-gpu-dla-compose-podgląd) +7. [Weryfikacja](#7-weryfikacja) +8. [Troubleshooting](#8-troubleshooting) +9. [Czego nie robimy w tym kroku](#9-czego-nie-robimy-w-tym-kroku) +10. [Następny krok](#10-następny-krok) + +--- + +## 1. Weryfikacja punktu startowego + +```bash +# sterownik NVIDIA z rozdziału 02 +nvidia-smi + +# Docker z rozdziału 01 +docker --version +docker ps + +# toolkit jeszcze niezainstalowany +which nvidia-ctk 2>/dev/null || echo "nvidia-ctk: not installed (expected)" +``` + +**Oczekiwany wynik:** + +- `nvidia-smi` pokazuje RTX 3090 Ti, driver 595.x +- Docker działa (`docker ps` bez błędów) +- `nvidia-ctk` niedostępne + +--- + +## 2. Co robi NVIDIA Container Toolkit + +Bez tego pakietu Docker **nie widzi GPU** — kontener uruchomi się, ale bez akceleracji CUDA. + +Toolkit dostarcza: + +| Komponent | Rola | +|-----------|------| +| `nvidia-container-toolkit` | Integracja GPU z container runtime | +| `nvidia-ctk` | CLI do konfiguracji (Docker, containerd) | +| `libnvidia-container` | Biblioteka montująca sterownik i urządzenia GPU do kontenera | + +**Jak to działa:** + +```mermaid +flowchart LR + host["Host: nvidia-driver + nvidia-smi"] + docker["Docker Engine"] + toolkit["NVIDIA Container Toolkit"] + container["Kontener vLLM / ComfyUI"] + + host --> toolkit + docker --> toolkit + toolkit --> container +``` + +Kontener **nie potrzebuje** własnego sterownika NVIDIA — dziedziczy go z hosta przez toolkit. Obraz musi mieć tylko biblioteki CUDA kompatybilne z wersją sterownika. + +**Uwaga:** Nie instalujemy przestarzałego `nvidia-docker2` — został zastąpiony przez Container Toolkit. + +--- + +## 3. Instalacja + +### 3.1 Dodanie repozytorium NVIDIA + +```bash +curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \ + | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg + +curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \ + | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \ + | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list +``` + +### 3.2 Instalacja pakietu + +```bash +sudo apt update +sudo apt install -y nvidia-container-toolkit +``` + +### 3.3 Sprawdzenie wersji + +```bash +nvidia-ctk --version +dpkg -l | grep nvidia-container +``` + +--- + +## 4. Konfiguracja runtime Docker + +Toolkit musi zarejestrować runtime NVIDIA w Dockerze: + +```bash +sudo nvidia-ctk runtime configure --runtime=docker +``` + +Ta komenda modyfikuje `/etc/docker/daemon.json` — dodaje konfigurację runtime `nvidia`, **zachowując** istniejące ustawienia (np. rotację logów z rozdziału 01). + +Sprawdź wynik: + +```bash +cat /etc/docker/daemon.json +``` + +Oczekiwana struktura (przykład — Twoja może wyglądać nieco inaczej): + +```json +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "50m", + "max-file": "3" + }, + "runtimes": { + "nvidia": { + "args": [], + "path": "nvidia-container-runtime" + } + } +} +``` + +Zrestartuj Docker: + +```bash +sudo systemctl restart docker +``` + +Sprawdź, że daemon wstał: + +```bash +sudo systemctl status docker --no-pager +``` + +--- + +## 5. Test GPU w kontenerze + +### 5.1 Podstawowy test — `nvidia-smi` w kontenerze + +```bash +docker run --rm --gpus all nvidia/cuda:12.6.0-base-ubuntu24.04 nvidia-smi +``` + +**Oczekiwany wynik:** + +- Ten sam GPU: NVIDIA GeForce RTX 3090 Ti +- Driver Version: 595.x (z hosta) +- Brak błędów `could not select device driver` + +Pierwsze uruchomienie pobierze obraz CUDA (~100–200 MB) — to normalne. + +### 5.2 Test z jednym GPU (explicit) + +Na tym serwerze jest jedna karta NVIDIA, ale warto od razu testować jawne przypisanie: + +```bash +docker run --rm --gpus '"device=0"' nvidia/cuda:12.6.0-base-ubuntu24.04 nvidia-smi -L +``` + +Oczekiwane: `GPU 0: NVIDIA GeForce RTX 3090 Ti` + +### 5.3 Test bez GPU (kontrolny) + +```bash +docker run --rm nvidia/cuda:12.6.0-base-ubuntu24.04 nvidia-smi +``` + +Oczekiwane: błąd — kontener bez `--gpus` nie widzi GPU. To potwierdza, że GPU nie jest przypadkowo dostępne dla każdego kontenera. + +--- + +## 6. Składnia GPU dla Compose (podgląd) + +W rozdziałach 04 (vLLM) i 05 (ComfyUI) użyjemy `docker compose` z GPU. Podgląd składni: + +```yaml +services: + vllm: + image: vllm/vllm-openai:latest + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] +``` + +Alternatywnie (prostsza składnia, działa w Compose v2): + +```yaml +services: + comfyui: + image: yanwk/comfyui-boot:latest + gpus: all +``` + +Na razie **nie twórz** tych plików — to tylko podgląd na przyszłość. + +--- + +## 7. Weryfikacja + +### Checklist + +- [ ] `nvidia-ctk --version` zwraca wersję +- [ ] `/etc/docker/daemon.json` zawiera runtime `nvidia` +- [ ] `docker run --rm --gpus all nvidia/cuda:12.6.0-base-ubuntu24.04 nvidia-smi` działa +- [ ] W kontenerze widać RTX 3090 Ti (24 GB) +- [ ] `docker run` **bez** `--gpus` nie widzi GPU (kontrolny test) +- [ ] Docker z rozdziału 01 nadal działa (`hello-world` OK) + +### Szybki test końcowy + +```bash +docker run --rm hello-world +docker run --rm --gpus all nvidia/cuda:12.6.0-base-ubuntu24.04 nvidia-smi +``` + +--- + +## 8. Troubleshooting + +### 8.1 `could not select device driver "" with capabilities: [[gpu]]` + +**Przyczyna:** Runtime NVIDIA nie skonfigurowany lub Docker nie zrestartowany. + +**Rozwiązanie:** + +```bash +sudo nvidia-ctk runtime configure --runtime=docker +sudo systemctl restart docker +docker info | grep -i nvidia +``` + +### 8.2 `nvidia-smi` działa na hoście, ale nie w kontenerze + +**Diagnostyka:** + +```bash +# na hoście +nvidia-smi + +# sprawdź runtime w docker info +docker info 2>/dev/null | grep -A5 -i runtime + +# logi toolkit +sudo journalctl -u docker --no-pager -n 30 +``` + +**Rozwiązanie:** + +```bash +sudo apt install --reinstall nvidia-container-toolkit +sudo nvidia-ctk runtime configure --runtime=docker +sudo systemctl restart docker +``` + +### 8.3 Konflikt w `daemon.json` + +**Objaw:** `systemctl restart docker` → failed po edycji `daemon.json`. + +**Rozwiązanie:** + +```bash +# walidacja JSON +python3 -m json.tool /etc/docker/daemon.json + +# jeśli błąd składni — przywróć minimalną konfigurację i skonfiguruj ponownie +sudo tee /etc/docker/daemon.json <<'EOF' +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "50m", + "max-file": "3" + } +} +EOF + +sudo nvidia-ctk runtime configure --runtime=docker +sudo systemctl restart docker +``` + +### 8.4 `apt update` — konflikt Signed-By + +**Objaw:** Błąd repozytorium przy `apt update` po dodaniu NVIDIA repo. + +**Rozwiązanie:** + +```bash +# usuń i dodaj repo ponownie (sekcja 3.1) +sudo rm /etc/apt/sources.list.d/nvidia-container-toolkit.list +# ... powtórz kroki 3.1 i 3.2 +``` + +### 8.5 Obraz CUDA — `pull` failed / timeout + +```bash +# test połączenia z registry +docker pull nvidia/cuda:12.6.0-base-ubuntu24.04 + +# alternatywny lekki obraz testowy +docker run --rm --gpus all nvidia/cuda:12.2.0-base-ubuntu22.04 nvidia-smi +``` + +### 8.6 `CUDA Version` w kontenerze vs na hoście + +Na hoście `nvidia-smi` może pokazywać CUDA 13.2, a obraz testowy CUDA 12.6 — to **normalne**. Kontener używa sterownika hosta; wersja CUDA w obrazie to maksymalna wersja bibliotek w kontenerze, nie sterownik. + +--- + +## 9. Czego nie robimy w tym kroku + +| Element | Rozdział | +|---------|----------| +| vLLM | 04 | +| ComfyUI | 05 | +| Pobieranie modeli LLM | 04 | +| CUDA Toolkit na hoście | niepotrzebne | +| Firewall | 06 | +| Katalog `/data` na modele | później | + +--- + +## 10. Następny krok + +Po przejściu checklisty z sekcji 7: + +1. Zgłoś w rozmowie z Cursorem, że krok 03 jest gotowy (lub opisz problemy). +2. Przejdź do rozdziału [**03b — Narzędzia bazowe**](03b-system-tools.md), a następnie [**04 — Dysk + vLLM**](04-vllm-stack.md). + +--- + +## Podsumowanie wykonanych zmian + +Po ukończeniu tego rozdziału na serwerze powinno być: + +- Zainstalowany `nvidia-container-toolkit` +- Skonfigurowany runtime NVIDIA w Dockerze +- Działający `docker run --gpus all` z widoczną RTX 3090 Ti +- Gotowa infrastruktura pod vLLM i ComfyUI w kontenerach diff --git a/manual-tutorial/03b-system-tools.md b/manual-tutorial/03b-system-tools.md new file mode 100644 index 0000000..414a2c9 --- /dev/null +++ b/manual-tutorial/03b-system-tools.md @@ -0,0 +1,283 @@ +# 03b — Narzędzia bazowe (Ubuntu minimized) + +> **Cel rozdziału:** doinstalować minimalny zestaw narzędzi systemowych na Ubuntu minimized — tylko to, co potrzebne na **hoście**. vLLM, ComfyUI i CUDA działają w Dockerze, nie na systemie. + +**Szacowany czas:** 5 minut + +**Wymagania:** ukończony rozdział [01 — Docker CE](01-system-update-and-docker.md) + +**Kiedy wykonać:** przed rozdziałem [04 — dysk + vLLM](04-vllm-stack.md). Można zrobić teraz (kroki 01–03 już ukończone). + +--- + +## Spis treści + +1. [Filozofia: host vs Docker](#1-filozofia-host-vs-docker) +2. [Co już masz po krokach 01–03](#2-co-już-masz-po-krokach-0103) +3. [Instalacja pakietów bazowych](#3-instalacja-pakietów-bazowych) +4. [Python na hoście — co i czego nie](#4-python-na-hoście--co-i-czego-nie) +5. [Git i repozytorium konfiguracyjne](#5-git-i-repozytorium-konfiguracyjne) +6. [Weryfikacja](#6-weryfikacja) +7. [Opcjonalne narzędzia](#7-opcjonalne-narzędzia) +8. [Czego świadomie NIE instalujemy na hoście](#8-czego-świadomie-nie-instalujemy-na-hoście) +9. [Następny krok](#9-następny-krok) + +--- + +## 1. Filozofia: host vs Docker + +Ubuntu **minimized** to świadomie odchudzona instalacja — bez edytorów, bez wielu narzędzi deweloperskich, bez GUI. Na serwerze AI trzymamy się zasady: + +| Warstwa | Co tam żyje | Przykłady | +|---------|-------------|-----------| +| **Host (128 GB `/`)** | System, Docker, sterowniki, konfiguracja | `git`, `python3`, `nano`, `rsync`, `parted` | +| **Docker (`/data/docker`)** | Obrazy i kontenery | vLLM, ComfyUI, CUDA runtime | +| **Dane (`/data/apps`)** | Modele, checkpointy, cache | Hugging Face, ComfyUI models | + +```mermaid +flowchart TB + host["Host Ubuntu minimized"] + docker["Docker Engine"] + vllm["Kontener vLLM — Python + PyTorch + CUDA"] + comfy["Kontener ComfyUI — Python + torch"] + + host --> docker + docker --> vllm + docker --> comfy +``` + +**Nie instalujemy PyTorch, CUDA Toolkit ani vLLM na hoście** — wszystko to jest w kontenerach. Host dostaje tylko narzędzia administracyjne. + +--- + +## 2. Co już masz po krokach 01–03 + +Sprawdź aktualny stan: + +```bash +for cmd in git python3 curl wget docker nvidia-smi; do + printf "%-12s " "$cmd" + command -v $cmd 2>/dev/null || echo "BRAK" +done +python3 --version +git --version +``` + +Na typowym stanie po rozdziałach 01–03: + +| Narzędzie | Status | Skąd | +|-----------|--------|------| +| `git` | zazwyczaj jest | zależność systemowa / automatyczna | +| `python3` | jest (minimalny) | preinstalowany na Ubuntu | +| `curl`, `wget` | są | rozdział 01 | +| `docker` | jest | rozdział 01 | +| `nvidia-smi` | jest | rozdział 02 | +| `nano`, `vim` | **brak** | minimized | +| `rsync` | **brak** | potrzebny do migracji Docker → `/data` | +| `parted` | **brak** | potrzebny do partycjonowania dysku 1 TB | +| `jq` | **brak** | wygodne testy API vLLM | +| `pip3` | **brak** | celowo — patrz sekcja 4 | + +--- + +## 3. Instalacja pakietów bazowych + +Jedna komenda — zestaw dla administracji serwerem i kolejnych rozdziałów tutoriala: + +```bash +sudo apt update + +sudo apt install -y \ + git \ + python3 \ + python3-venv \ + nano \ + vim \ + jq \ + rsync \ + parted \ + e2fsprogs \ + util-linux \ + htop \ + tmux \ + tree \ + unzip \ + zip \ + pciutils \ + usbutils \ + net-tools \ + smartmontools +``` + +### Po co który pakiet + +| Pakiet | Po co na tym serwerze | +|--------|----------------------| +| `git` | Wersjonowanie `ubuntu-bare-metal`, klonowanie configów | +| `python3` | `python3 -m json.tool`, skrypty admin (stdlib) | +| `python3-venv` | Izolowane środowiska Python — jeśli kiedyś własny skrypt | +| `nano` / `vim` | Edycja `.env`, `fstab`, `daemon.json` | +| `jq` | Parsowanie JSON z API vLLM (`curl ... \| jq`) | +| `rsync` | Migracja `/var/lib/docker` → `/data/docker` (rozdział 04) | +| `parted` | Partycjonowanie dysku 1 TB (rozdział 04) | +| `e2fsprogs` | `mkfs.ext4`, `fsck` — formatowanie `/data` | +| `util-linux` | `lsblk`, `blkid`, `mount` — diagnostyka dysków | +| `htop` | Monitorowanie CPU/RAM | +| `tmux` | Sesje SSH — proces nie ginie po rozłączeniu | +| `tree` | Podgląd struktury `/data` | +| `pciutils` | `lspci` — diagnostyka GPU | +| `smartmontools` | `smartctl` — zdrowie dysków (opcjonalnie, ale przydatne) | + +--- + +## 4. Python na hoście — co i czego nie + +### Co mamy + +Ubuntu 26.04 minimized dostarcza **Python 3.14** (minimalny) — wystarczy do: + +```bash +# formatowanie JSON z API +curl -s http://localhost:8000/v1/models | python3 -m json.tool + +# walidacja daemon.json +python3 -m json.tool /etc/docker/daemon.json +``` + +### Czego NIE robimy na hoście + +```bash +# NIE instaluj tego na hoście: +# pip install torch vllm transformers +# apt install nvidia-cuda-toolkit +``` + +Ubuntu 26.04 blokuje też `pip install` do systemowego Pythona (PEP 668 — „externally managed environment”). To **dobre** — chroni system przed bałaganem. + +Jeśli kiedyś potrzebujesz własnego skryptu Python na hoście: + +```bash +python3 -m venv ~/venv +source ~/venv/bin/activate +pip install requests # tylko w venv, nie globalnie +deactivate +``` + +Dla vLLM i ComfyUI — **nie potrzebujesz** venv na hoście. + +--- + +## 5. Git i repozytorium konfiguracyjne + +### 5.1 Inicjalizacja repo (jeśli jeszcze nie zrobione) + +Repozytorium konfiguracyjne trzymamy na dysku **systemowym** (lekki tekst), nie na `/data`: + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal + +git init +git add README.md manual-tutorial/ stacks/ +git status +``` + +**Nie commituj** plików `.env` z tokenami — tylko `.env.example`. + +Opcjonalnie `.gitignore`: + +```bash +cat > .gitignore <<'EOF' +.env +*.log +__pycache__/ +.venv/ +EOF +``` + +### 5.2 Podstawowa konfiguracja git (opcjonalnie) + +```bash +git config --global user.name "Twoje Imię" +git config --global user.email "twoj@email.com" +``` + +Bez tego `git commit` zapyta o autora przy pierwszym commicie. + +--- + +## 6. Weryfikacja + +```bash +# pakiety kluczowe +for cmd in git python3 nano vim jq rsync parted lsblk blkid htop tmux; do + printf "%-12s " "$cmd" + command -v $cmd 2>/dev/null || echo "BRAK" +done + +# wersje +python3 --version +git --version +jq --version + +# test jq +echo '{"status":"ok"}' | jq . + +# test rsync +rsync --version | head -1 + +# test parted +parted --version | head -1 +``` + +### Checklist + +- [ ] `nano` lub `vim` działa +- [ ] `jq` formatuje JSON +- [ ] `rsync` dostępny (rozdział 04 — migracja Docker) +- [ ] `parted` i `lsblk` dostępne (rozdział 04 — dysk 1 TB) +- [ ] `git status` w katalogu `ubuntu-bare-metal` działa + +--- + +## 7. Opcjonalne narzędzia + +Instaluj tylko jeśli potrzebujesz: + +```bash +# kompilacja czegoś ze źródeł (zwykle niepotrzebne na tym serwerze) +# sudo apt install -y build-essential + +# monitorowanie GPU w czasie rzeczywistym (wygodniejsze niż watch nvidia-smi) +# sudo apt install -y nvtop + +# sieć — ss jest nowocześniejszy niż netstat +ss -tlnp + +# synchronizacja czasu (zwykle już działa) +timedatectl status +``` + +--- + +## 8. Czego świadomie NIE instalujemy na hoście + +| Pakiet / narzędzie | Dlaczego nie | +|--------------------|--------------| +| `nvidia-cuda-toolkit` | CUDA jest w kontenerach Docker | +| `python3-pip` (globalnie) | PEP 668; używaj `venv` lub `jq` | +| `torch`, `vllm`, `transformers` (pip) | Działają w kontenerze vLLM | +| `nodejs`, `npm` | ComfyUI w Dockerze | +| `docker.io` (Ubuntu) | Mamy `docker-ce` z rozdziału 01 | +| GUI / desktop | Serwer headless | +| `snap` pakiety | Niepotrzebna złożoność na serwerze | + +--- + +## 9. Następny krok + +→ [04 — Dysk danych 1 TB + vLLM stack](04-vllm-stack.md) + +Kolejność: +1. Ten rozdział (03b) — narzędzia bazowe +2. Rozdział 04 część A — dysk 1 TB + migracja Docker +3. Rozdział 04 część B — uruchomienie vLLM diff --git a/manual-tutorial/04-vllm-stack.md b/manual-tutorial/04-vllm-stack.md new file mode 100644 index 0000000..1b98a7d --- /dev/null +++ b/manual-tutorial/04-vllm-stack.md @@ -0,0 +1,773 @@ +# 04 — Dysk danych 1 TB + vLLM stack + +> **Cel rozdziału:** dodać dysk 1 TB jako `/data` (aplikacje, modele, Docker), a na dysku systemowym 128 GB zostawić wyłącznie Ubuntu. Następnie uruchomić vLLM z mountami na `/data`. + +**Szacowany czas:** +- Dysk 1 TB + migracja Docker: 20–40 minut +- Pierwsze uruchomienie vLLM: 20–60 minut (pobieranie obrazu i modelu) + +**Wymagania:** ukończone rozdziały [01](01-system-update-and-docker.md)–[03](03-nvidia-container-toolkit.md) oraz [03b — Narzędzia bazowe](03b-system-tools.md) + +--- + +## Spis treści + +**Część A — dysk danych** + +1. [Architektura dysków](#1-architektura-dysków) +2. [Wykrycie i partycjonowanie dysku 1 TB](#2-wykrycie-i-partycjonowanie-dysku-1-tb) +3. [Montowanie `/data` i wpis w fstab](#3-montowanie-data-i-wpis-w-fstab) +4. [Struktura katalogów na `/data`](#4-struktura-katalogów-na-data) +5. [Przeniesienie Docker data-root na `/data`](#5-przeniesienie-docker-data-root-na-data) + +**Część B — vLLM** + +6. [Jak działa vLLM (bez UI)](#6-jak-działa-vllm-bez-ui) +7. [Mapowanie ustawień z LM Studio](#7-mapowanie-ustawień-z-lm-studio) +8. [Struktura plików stacku](#8-struktura-plików-stacku) +9. [Przygotowanie stacku (bez modelu)](#9-przygotowanie-stacku-bez-modelu) +10. [Wybór modelu i start](#10-wybór-modelu-i-start) +11. [Test API](#11-test-api) +12. [Tuning po starcie (jeśli OOM)](#12-tuning-po-starcie-jeśli-oom) +13. [Zmiana modelu](#13-zmiana-modelu) +14. [Zarządzanie stackiem](#14-zarządzanie-stackiem) +15. [Weryfikacja](#15-weryfikacja) +16. [Troubleshooting](#16-troubleshooting) +17. [Czego nie robimy w tym kroku](#17-czego-nie-robimy-w-tym-kroku) +18. [Następny krok](#18-następny-krok) + +--- + +# Część A — Dysk danych 1 TB + +## 1. Architektura dysków + +| Dysk | Rozmiar | Mount | Przeznaczenie | +|------|---------|-------|---------------| +| NVMe systemowy | 128 GB (`nvme1n1`) | `/` | Ubuntu, konfiguracja, repo tutoriala | +| Dysk danych | 1 TB (`nvme0n1`) | `/data` | Docker, modele LLM, ComfyUI, cache | + +```mermaid +flowchart TB + subgraph systemDisk ["128GB NVMe — /"] + os["Ubuntu 26.04"] + etc["/etc /boot"] + home["/home"] + repo["cursor/ubuntu-bare-metal"] + end + + subgraph dataDisk ["1TB — /data"] + docker["/data/docker — Docker images/volumes"] + vllm["/data/apps/vllm/huggingface"] + comfyui["/data/apps/comfyui/*"] + end + + docker --> vllm + docker --> comfyui +``` + +**Zasada:** Na dysku systemowym nie trzymamy modeli ani obrazów Docker. Wszystko ciężkie idzie na `/data`. + +**Uwaga:** Fizycznie zamontuj dysk 1 TB w GMKtec K11 przed wykonaniem sekcji 2. Po podłączeniu zrób reboot lub rescann PCI/NVMe. + +--- + +## 2. Wykrycie i partycjonowanie dysku 1 TB + +### 2.1 Wykrycie nowego dysku + +```bash +lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL +``` + +Oczekiwany układ **po** podłączeniu dysku 1 TB na GMKtec K11: + +``` +nvme1n1 119G # dysk SYSTEMOWY — NIE DOTYKAĆ +├─nvme1n1p1 1G /boot/efi +└─nvme1n1p2 118G / +nvme0n1 931G # dysk DANYCH 1 TB — ten partycjonujemy +``` + +**Uwaga:** Na tym modelu GMKtec K11 numeracja bywa odwrotna niż intuicyjnie — `nvme0n1` to 1 TB, `nvme1n1` to 128 GB systemu. Zawsze weryfikuj po `SIZE` i `MOUNTPOINT`, nie po numerze. + +**KRYTYCZNE:** Partycjonuj wyłącznie dysk ~1 TB **bez** mountpointu `/`. Nigdy `nvme1n1`. + +Zapisz nazwę urządzenia: + +```bash +# GMKtec K11 — dysk DANYCH 1 TB (sprawdź lsblk!) +export DATA_DISK=/dev/nvme0n1 +export DATA_PART=/dev/nvme0n1p1 + +echo "DATA_DISK=$DATA_DISK" +echo "DATA_PART=$DATA_PART" +lsblk $DATA_DISK + +# weryfikacja: DATA_DISK nie może mieć mountpoint / +lsblk -n -o MOUNTPOINT $DATA_DISK | grep -q '^/$' && echo "BŁĄD: to dysk systemowy!" && exit 1 +``` + +### Szybka instalacja (skrypt) + +Jeśli partycja `nvme0n1p1` już istnieje (lub po ręcznym parted), uruchom w **swoim terminalu SSH** (wymaga hasła sudo): + +```bash +sudo bash /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/scripts/setup-data-disk.sh +``` + +Skrypt: formatuje `nvme0n1p1`, montuje `/data`, fstab, katalogi, migracja Docker → `/data/docker`. + +--- + +### 2.2 Partycjonowanie ręczne (jeśli bez skryptu) + +Partycja: `/dev/nvme0n1p1` (na GMKtec K11). + +```bash +# OBOWIĄZKOWO ustaw obie zmienne przed każdą komendą! +export DATA_DISK=/dev/nvme0n1 +export DATA_PART=/dev/nvme0n1p1 + +# sprawdź jeszcze raz przed zapisem! +lsblk $DATA_DISK +echo "Partycja: $DATA_PART" + +sudo parted -s $DATA_DISK mklabel gpt +sudo parted -s $DATA_DISK mkpart primary ext4 0% 100% +sudo partprobe $DATA_DISK +sleep 2 +lsblk $DATA_DISK +``` + +### 2.3 Formatowanie ext4 + +```bash +sudo mkfs.ext4 -L data1tb $DATA_PART +``` + +--- + +## 3. Montowanie `/data` i wpis w fstab + +### 3.1 Pobranie UUID (stabilniejsze niż /dev/sdX) + +```bash +sudo blkid $DATA_PART +``` + +Zapisz UUID, np. `UUID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`. + +### 3.2 Montowanie tymczasowe i test + +```bash +sudo mkdir -p /data +sudo mount $DATA_PART /data +df -h /data +``` + +Oczekiwane: ~1 TB dostępne na `/data`. + +### 3.3 Wpis w `/etc/fstab` (montowanie przy bootcie) + +```bash +# zamień YOUR-UUID na rzeczywisty UUID z blkid +echo 'UUID=YOUR-UUID /data ext4 defaults,noatime 0 2' | sudo tee -a /etc/fstab +``` + +**Lepiej edytować ręcznie** — sprawdź składnię: + +```bash +sudo nano /etc/fstab +``` + +Dodaj linię (przykład): + +``` +UUID=a1b2c3d4-e5f6-7890-abcd-ef1234567890 /data ext4 defaults,noatime 0 2 +``` + +Test fstab (montuje wszystko bez rebootu): + +```bash +sudo umount /data +sudo mount -a +df -h /data +``` + +Jeśli `mount -a` nie zwraca błędu — fstab jest poprawny. + +--- + +## 4. Struktura katalogów na `/data` + +```bash +# Docker data-root (własność root) +sudo mkdir -p /data/docker + +# Katalogi aplikacji (własność użytkownika — łatwiejszy dostęp) +sudo mkdir -p /data/apps/vllm/huggingface +sudo mkdir -p /data/apps/gguf/{qwen3.6-27b,gemma-4-12b} +sudo mkdir -p /data/apps/localai/{models,backends,configuration,images,data} +sudo mkdir -p /data/apps/comfyui/{models,input,output,custom_nodes} + +sudo chown -R $USER:$USER /data/apps +``` + +Docelowa struktura: + +``` +/data/ +├── docker/ # Docker data-root (obrazy, warstwy, volumes) +└── apps/ + ├── vllm/ + │ └── huggingface/ # cache modeli Hugging Face (AWQ) + ├── gguf/ # przyszłe GGUF (llama.cpp) + │ ├── qwen3.6-27b/ + │ └── gemma-4-12b/ + └── comfyui/ # przygotowane pod rozdział 05 + ├── models/ + ├── input/ + ├── output/ + └── custom_nodes/ +``` + +Sprawdź: + +```bash +tree -L 3 /data 2>/dev/null || find /data -maxdepth 3 -type d +df -h / /data +``` + +--- + +## 5. Przeniesienie Docker data-root na `/data` + +Domyślnie Docker trzyma dane w `/var/lib/docker` na dysku systemowym. Przenosimy na `/data/docker`. + +### 5.1 Zatrzymanie Docker + +```bash +sudo systemctl stop docker +sudo systemctl stop docker.socket 2>/dev/null || true +``` + +Upewnij się, że nie ma uruchomionych kontenerów: + +```bash +docker ps # powinno być puste lub błąd „cannot connect” — OK +``` + +### 5.2 Kopia istniejących danych Docker + +Jeśli już pobierałeś obrazy (np. `hello-world`, `nvidia/cuda`): + +```bash +sudo rsync -aHAX --progress /var/lib/docker/ /data/docker/ +``` + +Jeśli `/var/lib/docker` jest pusty lub mało znaczący: + +```bash +sudo mkdir -p /data/docker +``` + +### 5.3 Aktualizacja `/etc/docker/daemon.json` + +```bash +sudo python3 -c " +import json +from pathlib import Path +p = Path('/etc/docker/daemon.json') +cfg = json.loads(p.read_text()) if p.exists() else {} +cfg['data-root'] = '/data/docker' +p.write_text(json.dumps(cfg, indent=2) + '\n') +print(p.read_text()) +" +``` + +Oczekiwany fragment: + +```json +{ + "data-root": "/data/docker", + "log-driver": "json-file", + "log-opts": { + "max-size": "50m", + "max-file": "3" + }, + "runtimes": { + "nvidia": { + "args": [], + "path": "nvidia-container-runtime" + } + } +} +``` + +### 5.4 Uruchomienie Docker i weryfikacja + +```bash +sudo systemctl start docker +docker info | grep "Docker Root Dir" +``` + +Oczekiwane: `Docker Root Dir: /data/docker` + +```bash +docker run --rm hello-world +docker run --rm --gpus all nvidia/cuda:12.6.0-base-ubuntu24.04 nvidia-smi +``` + +### 5.5 Opcjonalnie: zwolnienie miejsca na dysku systemowym + +**Dopiero po potwierdzeniu**, że Docker działa z nowego data-root: + +```bash +# UWAGA: nieodwracalne — usuwa stare dane Dockera z dysku systemowego +sudo rm -rf /var/lib/docker +``` + +Sprawdź, że dysk systemowy ma więcej wolnego miejsca: + +```bash +df -h / +df -h /data +``` + +### Checklist — część A (dysk) + +- [ ] Dysk 1 TB widoczny w `lsblk` +- [ ] Partycja sformatowana ext4 +- [ ] `/data` zamontowany, ~1 TB wolne +- [ ] Wpis w `/etc/fstab` (UUID) +- [ ] `mount -a` bez błędów +- [ ] Katalogi `/data/docker` i `/data/apps/*` utworzone +- [ ] `Docker Root Dir: /data/docker` +- [ ] `hello-world` i test GPU w Dockerze działają + +**Nie przechodź do vLLM, dopóki checklista części A nie jest ukończona.** + +--- + +# Część B — vLLM stack + +## 6. Jak działa vLLM (bez UI) + +vLLM to **serwer API** — nie ma panelu administracyjnego jak LM Studio. + +| LM Studio | vLLM na serwerze | +|-----------|------------------| +| Panel UI, suwaki | Plik `.env` + profile + skrypty | +| GGUF Q4 (lmstudio-community) | **Nie w vLLM** — przyszły stack [`stacks/llamacpp/`](../stacks/llamacpp/); interim: AWQ z HF | +| Hugging Face AWQ | Kontener Docker `vllm/vllm-openai` | +| Lokalny chat | HTTP API OpenAI (`/v1/chat/completions`) | + +Konfiguracja = zmienne w `.env` przekazywane jako flagi `vllm serve`. Test: `curl` + `jq` lub klient OpenAI. + +**Model nie jest pobierany przy instalacji stacku** — katalog `models.catalog.yaml` + `download-model.sh` na żądanie. `VLLM_MODEL` ustawiasz przez profil dopiero gdy jesteś gotowy. + +### GGUF z lmstudio-community — nie działa w standardowym vLLM + +Linki typu `lmstudio-community/Qwen3.6-27B-GGUF` to pliki **`.gguf`**. Obraz `vllm/vllm-openai` ich nie obsługuje (wymagałby eksperymentalnego `vllm-gguf-plugin` lub osobnego hosta). + +| Co chcesz | Co robisz | +|-----------|-----------| +| Q4 jak w LM Studio (GGUF) | `download-model.sh ` → `/data/apps/gguf/` → później `stacks/llamacpp/` | +| Q4-odpowiednik **teraz** na vLLM | `download-model.sh qwen3.6-27b-awq-vllm` + profil AWQ | + +--- + +## 7. Mapowanie ustawień z LM Studio + +Jeśli testowałeś Qwen3.6-27B na Windows (LM Studio / Ollama): + +| LM Studio / Ollama | vLLM (`.env` / flagi) | +|--------------------|----------------------| +| Model GGUF Q4 (lmstudio) | Katalog `runtime: llamacpp` — **nie** ten stack vLLM | +| Odpowiednik Q4 na vLLM (interim) | `VLLM_MODEL=Qwen/Qwen3.6-27B-Instruct-AWQ`, `QUANTIZATION=awq` | +| K Cache Q4_0 | `KV_CACHE_DTYPE=fp8` | +| V Cache Q4_0 | j.w. — vLLM **nie ma** `Q4_0` (format GGUF/llama.cpp) | +| Context 128K | `MAX_MODEL_LEN=131072` | +| 1 wątek | `MAX_NUM_SEQS=1` | +| GPU layers max | `GPU_MEMORY_UTILIZATION=0.95` | + +Alternatywa KV cache (więcej miejsca, bliżej Q4): `KV_CACHE_DTYPE=turboquant_k8v4` — testuj po udanym starcie z `fp8`. + +> Później porównamy z kopią ustawień LM Studio na Windows i doprecyzujemy parametry. + +--- + +## 8. Struktura plików stacku + +``` +stacks/vllm/ +├── README.md +├── models.catalog.yaml # GGUF + vLLM AWQ, bez auto-pobierania +├── docker-compose.yml +├── .env.example +├── profiles/ +│ ├── _template.env +│ └── qwen3.6-27b-awq-128k.env +└── scripts/ + ├── list-models.sh + ├── download-model.sh + ├── switch-model.sh + ├── start.sh + └── vllm-entrypoint.sh +``` + +Dane na dysku 1 TB: + +``` +/data/apps/vllm/huggingface/ ← cache AWQ (Hugging Face) +/data/apps/gguf/ ← przyszłe GGUF (tworzone przez skrypty) +/data/docker/ ← obrazy Docker (vLLM ~10 GB) +``` + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/vllm +``` + +### Kluczowe elementy `docker-compose.yml` + +| Element | Po co | +|---------|-------| +| `profiles: [vllm]` | Serwis nie startuje przypadkowo bez `--profile vllm` | +| `ipc: host` | Shared memory — wymagane przez PyTorch | +| `vllm-entrypoint.sh` | Buduje flagi z `.env` (`QUANTIZATION`, `VLLM_EXTRA_ARGS`) | +| `QUANTIZATION` | Opcjonalne — puste = model pełnej precyzji | +| `VLLM_EXTRA_ARGS` | Flagi per profil (Qwen: `--reasoning-parser qwen3`) | + +Domyślne flagi w profilu Qwen (przekazywane przez `VLLM_EXTRA_ARGS`): + +| Flaga | Po co | +|-------|-------| +| `--language-model-only` | Bez vision encoder — więcej VRAM na KV cache | +| `--enforce-eager` | Mniej overhead CUDA graphs na 24 GB | +| `--max-num-seqs 1` | Jedna sekwencja naraz (jak 1 wątek w LM Studio) | +| `--kv-cache-dtype fp8` | Kompresja KV cache (`KV_CACHE_DTYPE`) | +| `--max-model-len 131072` | Okno kontekstu 128K (`MAX_MODEL_LEN`) | + +--- + +## 9. Przygotowanie stacku (bez modelu) + +### 9.1 Utwórz `.env` z szablonu + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/vllm +cp .env.example .env +cat .env +``` + +Oczekiwane — `VLLM_MODEL` **pusty**: + +```env +DATA_ROOT=/data +VLLM_MODEL= +SERVED_MODEL_NAME=qwen3.6-27b +MAX_MODEL_LEN=131072 +MAX_NUM_SEQS=1 +GPU_MEMORY_UTILIZATION=0.95 +KV_CACHE_DTYPE=fp8 +QUANTIZATION=awq +VLLM_EXTRA_ARGS=--language-model-only --enforce-eager --reasoning-parser qwen3 +``` + +Na tym etapie **nic nie pobierasz** — stack jest gotowy do konfiguracji. + +### 9.2 Katalog modeli + +```bash +./scripts/list-models.sh +``` + +Pokazuje wpisy z `models.catalog.yaml` i czy pliki są już na dysku (`ON DISK`). + +### 9.3 Sprawdź katalogi cache + +```bash +mkdir -p /data/apps/vllm/huggingface +df -h /data +``` + +--- + +## 10. Wybór modelu i start + +### 10.1 Pobierz model (on demand) + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/vllm + +# vLLM interim — AWQ (~15 GB) do cache HF +./scripts/download-model.sh qwen3.6-27b-awq-vllm +``` + +GGUF (na później, pod llama.cpp): + +```bash +# ./scripts/download-model.sh qwen3.6-27b-q4-gguf +# ./scripts/download-model.sh gemma-4-12b-q4-gguf +``` + +### 10.2 Preset Qwen3.6-27B AWQ @ 128K + +```bash +./scripts/switch-model.sh qwen3.6-27b-awq-128k +``` + +Alternatywa — ręcznie: + +```bash +cp profiles/qwen3.6-27b-awq-128k.env .env +``` + +### 10.3 Uruchomienie + +```bash +./scripts/start.sh +# lub profil w jednej komendzie: +# ./scripts/start.sh qwen3.6-27b-awq-128k +``` + +Skrypt sprawdza: `VLLM_MODEL` ustawiony, brak `.gguf`, `/data` zamontowany, Docker działa. + +Alternatywa ręczna: + +```bash +docker compose --profile vllm pull +docker compose --profile vllm up -d +``` + +### 10.4 Pierwszy start — czego się spodziewać + +1. Pobranie obrazu `vllm/vllm-openai` (~8–12 GB) → `/data/docker` +2. Pobranie modelu AWQ (~15 GB) → `/data/apps/vllm/huggingface` +3. Ładowanie wag do VRAM — **10–30+ minut** + +```bash +docker compose --profile vllm logs -f vllm +``` + +Szukaj: + +``` +INFO: Uvicorn running on http://0.0.0.0:8000 +``` + +### 10.5 Monitorowanie + +```bash +# terminal 1 +watch -n 1 nvidia-smi + +# terminal 2 +watch -n 5 'df -h /data; du -sh /data/apps/vllm/huggingface 2>/dev/null' +``` + +--- + +## 11. Test API + +Użyj `SERVED_MODEL_NAME` z `.env` (domyślnie `qwen3.6-27b-awq` w presecie). + +### 11.1 Lista modeli + +```bash +curl -s http://localhost:8000/v1/models | jq . +``` + +### 11.2 Health check + +```bash +curl -s http://localhost:8000/health +``` + +### 11.3 Chat completion + +```bash +curl -s http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6-27b-awq", + "messages": [{"role": "user", "content": "Say hello in one word."}], + "max_tokens": 32 + }' | jq . +``` + +Pole `"model"` musi odpowiadać `SERVED_MODEL_NAME` z `.env`. + +--- + +## 12. Tuning po starcie (jeśli OOM) + +Kolejność — zmieniaj w `.env` i restartuj: + +```bash +docker compose --profile vllm down +# edytuj .env +docker compose --profile vllm up -d +``` + +| Krok | Zmiana | Kiedy | +|------|--------|-------| +| 1 | `MAX_MODEL_LEN=98304` lub `65536` | OOM przy ładowaniu lub długim prompcie | +| 2 | `GPU_MEMORY_UTILIZATION=0.90` | Nadal OOM | +| 3 | `KV_CACHE_DTYPE=turboquant_k8v4` | Potrzeba więcej miejsca na KV (bliżej Q4_0) | + +--- + +## 13. Zmiana modelu (A / B na dysku, jeden w VRAM) + +Kilka modeli może leżeć na `/data` — **aktywny jest tylko jeden** w VRAM. Przełączenie = profil + restart kontenera. + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/vllm + +# sprawdź co jest na dysku +./scripts/list-models.sh + +# pobierz drugi model (jeśli potrzeba) +# ./scripts/download-model.sh + +# przełącz profil vLLM i zrestartuj +./scripts/switch-model.sh qwen3.6-27b-awq-128k +``` + +Nowy profil: skopiuj `profiles/_template.env`, dostosuj `VLLM_MODEL`, `QUANTIZATION`, `VLLM_EXTRA_ARGS`. + +`start.sh` **odrzuca** `.gguf` w `VLLM_MODEL` — GGUF wymaga przyszłego `stacks/llamacpp/`. + +Sprawdź miejsce przed większym modelem: + +```bash +df -h /data +du -sh /data/apps/vllm/huggingface +``` + +--- + +## 14. Zarządzanie stackiem + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/vllm + +docker compose --profile vllm ps +docker compose --profile vllm logs -f vllm +docker compose --profile vllm restart vllm +docker compose --profile vllm down +``` + +--- + +## 15. Weryfikacja + +### Checklist — cały rozdział 04 + +**Dysk:** +- [ ] `/data` ~1 TB zamontowany (fstab) +- [ ] `Docker Root Dir: /data/docker` + +**vLLM:** +- [ ] `.env` z ustawionym `VLLM_MODEL` +- [ ] `docker compose --profile vllm ps` — `vllm` running +- [ ] `nvidia-smi` — proces vLLM, VRAM w użyciu +- [ ] `curl http://localhost:8000/v1/models` — JSON z modelem +- [ ] `curl .../v1/chat/completions` — odpowiedź tekstowa +- [ ] Model na `/data/apps/vllm/huggingface` + +### Szybki test końcowy + +```bash +df -h / /data +docker info | grep "Docker Root Dir" +curl -s http://localhost:8000/v1/models | jq . +du -sh /data/apps/vllm/huggingface /data/docker +``` + +--- + +## 16. Troubleshooting + +### 16.1 `/data` nie montuje się po reboot + +```bash +sudo mount -a +cat /etc/fstab +``` + +### 16.2 `VLLM_MODEL is empty` lub `.gguf` rejected + +```bash +./scripts/list-models.sh +./scripts/download-model.sh qwen3.6-27b-awq-vllm +./scripts/switch-model.sh qwen3.6-27b-awq-128k +``` + +Jeśli wpisałeś ścieżkę `.gguf` — użyj AWQ (vLLM) lub poczekaj na stack llama.cpp. + +### 16.3 OOM na GPU (CUDA out of memory) + +Zobacz sekcję 12 (tuning). Typowy zestaw na start po OOM: + +```env +MAX_MODEL_LEN=65536 +GPU_MEMORY_UTILIZATION=0.90 +KV_CACHE_DTYPE=fp8 +``` + +### 16.4 Błąd kwantyzacji AWQ + +Upewnij się, że model ma suffix `-AWQ` i `QUANTIZATION=awq` w `.env`. + +### 16.5 `connection refused` na :8000 + +Model jeszcze się ładuje — `docker compose --profile vllm logs -f vllm`. + +### 16.6 Wolny pierwszy prefill przy długim kontekście + +Normalne przy 128K — pierwsze żądanie z długim promptem trwa dłużej. + +### 16.7 Brak miejsca na `/data` + +```bash +df -h /data +docker system df +``` + +--- + +## 17. Czego nie robimy w tym kroku + +| Element | Gdzie | +|---------|-------| +| ComfyUI | Rozdział 06 | +| LocalAI (UI + GGUF) | Rozdział 05 | +| Open WebUI (panel do vLLM) | później, opcjonalnie | +| Pełny Docker llama.cpp / GGUF | placeholder: [`stacks/llamacpp/`](../stacks/llamacpp/) — LocalAI może obsłużyć GGUF | +| Context 262K | start 128K; tuning później | +| Firewall | Rozdział 07 | + +--- + +## 18. Następny krok + +Po przejściu checklisty z sekcji 15: + +1. Zgłoś w rozmowie z Cursorem, że krok 04 jest gotowy. +2. Przejdź do rozdziału **05 — LocalAI stack** ([`05-localai-stack.md`](05-localai-stack.md)). + +--- + +## Podsumowanie wykonanych zmian + +Po ukończeniu tego rozdziału: + +- Dysk 1 TB: `/data` (Docker + modele) +- Stack vLLM gotowy bez domyślnego modelu +- Katalog modeli (`models.catalog.yaml`) + skrypty list/download/switch +- Po wyborze: Qwen3.6-27B AWQ (interim Q4), kontekst 128K, KV cache fp8 +- GGUF lmstudio — ścieżki na `/data/apps/gguf/`, host llama.cpp planowany +- API OpenAI na porcie 8000 diff --git a/manual-tutorial/04a-api-key.md b/manual-tutorial/04a-api-key.md new file mode 100644 index 0000000..23a561f --- /dev/null +++ b/manual-tutorial/04a-api-key.md @@ -0,0 +1,140 @@ +# 04a — Klucz API Server UI (krok po kroku) + +> **Cel:** wpisać poprawny klucz API w panelu `:8091`, żeby działały Start/Stop, CLI, Pliki i GPU Fan. + +**Szacowany czas:** 3 minuty + +--- + +## 1. Co to jest klucz API? + +To **hasło do panelu** — bez niego możesz tylko **oglądać** status stacków. Z kluczem możesz: + +- Start / Stop / Restart stacków +- Terminal **CLI** +- Zakładka **Pliki** +- Sterowanie **GPU Fan** + +--- + +## 2. Gdzie jest JEDYNY plik z kluczem? + +Na serwerze produkcyjnym (systemd): + +``` +/opt/control-plane/.env +``` + +**Nie używaj** innych plików — stare ścieżki mylą: + +| Plik | Status | +|------|--------| +| `/opt/control-plane/.env` | **TAK — używaj tego** | +| `stacks/control-plane/.env` | tylko dev (po sync ma ten sam klucz) | +| `stacks/server-ui/.env` | **NIE** — przestarzałe, ignoruj | +| `GPU_FAN_AGENT_KEY` | **NIE** — usunięte, używaj `API_KEY` | + +--- + +## 3. Wyświetl klucz na serwerze + +Zaloguj się na serwer (SSH) i wpisz: + +```bash +sudo grep ^API_KEY= /opt/control-plane/.env +``` + +Przykład wyniku: + +``` +API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +Skopiuj **tylko** część po `=` (bez słowa `API_KEY=`). + +Alternatywa — gotowa instrukcja z linkiem: + +```bash +bash ~/cursor/ubuntu-bare-metal/stacks/server-ui/scripts/show-api-key.sh +``` + +--- + +## 4. Wpisz klucz w panelu (3 kroki) + +### Krok A — otwórz panel + +W przeglądarce na swoim komputerze: + +``` +http://:8091/ +``` + +Przykład: `http://192.168.100.90:8091/` + +### Krok B — wklej klucz + +1. U góry strony znajdź pole **API Key** +2. Wklej skopiowany klucz +3. Kliknij **Zapisz** + +### Krok C — sprawdź + +1. Kliknij **Sprawdź klucz** +2. Powinno pojawić się: **Klucz poprawny** (zielony tekst) +3. Dopiero teraz używaj Start/Stop, CLI itd. + +--- + +## 5. Szybsza metoda — gotowy link + +Zamiast kroków B–C możesz otworzyć od razu: + +``` +http://:8091/?api_key=TWÓJ_KLUCZ_Z_KROKU_3 +``` + +Klucz zapisze się w przeglądarce automatycznie. Nadal kliknij **Sprawdź klucz** dla pewności. + +--- + +## 6. Jak sprawdzić że wszystko działa + +1. Zakładka **Stacki** → wybierz stack → **Start** +2. Brak czerwonego komunikatu `Invalid or missing API key` +3. Status stacku zmienia się na „running” + +--- + +## 7. Błąd „Invalid or missing API key” — checklist + +| # | Sprawdź | Co zrobić | +|---|---------|-----------| +| 1 | Kliknąłeś **Zapisz**? | Wklej klucz → **Zapisz** → **Sprawdź klucz** | +| 2 | Stary klucz w przeglądarce? | F12 → Application → Local Storage → usuń `server-ui-api-key`, odśwież stronę | +| 3 | Klucz z właściwego pliku? | Tylko `sudo grep ^API_KEY= /opt/control-plane/.env` | +| 4 | Panel po restarcie? | `sudo systemctl restart server-ui` | +| 5 | Właściwy proces na porcie? | `ss -tlnp \| grep 8091` — powinno być `/opt/server-ui` | + +--- + +## 8. Po instalacji panelu + +Instalator wypisuje gotowy link z kluczem. Jeśli go nie zapisałeś: + +```bash +bash ~/cursor/ubuntu-bare-metal/stacks/server-ui/scripts/show-api-key.sh +``` + +--- + +## Podsumowanie + +| Pytanie | Odpowiedź | +|---------|-----------| +| Gdzie klucz? | `/opt/control-plane/.env` → `API_KEY` | +| Jak wyświetlić? | `sudo grep ^API_KEY= /opt/control-plane/.env` | +| Jak wpisać w UI? | Pole API Key → Zapisz → Sprawdź klucz | +| Gotowy link? | `http://IP:8091/?api_key=KLUCZ` | + +Powrót: [08 — Server UI](08-server-ui-install.md) · [README](README.md) diff --git a/manual-tutorial/05-localai-stack.md b/manual-tutorial/05-localai-stack.md new file mode 100644 index 0000000..2eeeb12 --- /dev/null +++ b/manual-tutorial/05-localai-stack.md @@ -0,0 +1,315 @@ +# 05 — LocalAI stack + +> **Cel rozdziału:** uruchomić [LocalAI](https://github.com/mudler/LocalAI) w Dockerze z GPU (CUDA 13), wbudowanym UI na porcie **8070** i pustym katalogiem modeli. Modele dodasz później (GGUF, galeria). + +**Szacowany czas:** +- Pobranie obrazu Docker: 10–30 minut +- Start bez modelu: 1–2 minuty + +**Wymagania:** ukończone rozdziały [01](01-system-update-and-docker.md)–[04](04-vllm-stack.md) (część A — dysk `/data` zamontowany) + +--- + +## Spis treści + +1. [LocalAI vs vLLM](#1-localai-vs-vllm) +2. [Porty i architektura](#2-porty-i-architektura) +3. [Struktura plików stacku](#3-struktura-plików-stacku) +4. [Przygotowanie `.env`](#4-przygotowanie-env) +5. [Clone upstream (opcjonalnie)](#5-clone-upstream-opcjonalnie) +6. [Instalacja obrazu (bez modelu)](#6-instalacja-obrazu-bez-modelu) +7. [Start stacku](#7-start-stacku) +8. [Weryfikacja UI i API](#8-weryfikacja-ui-i-api) +9. [Zarządzanie stackiem](#9-zarządzanie-stackiem) +10. [Troubleshooting](#10-troubleshooting) +11. [Następny krok](#11-następny-krok) + +--- + +## 1. LocalAI vs vLLM + +| | vLLM (`stacks/vllm/`) | LocalAI (`stacks/localai/`) | +|--|----------------------|----------------------------| +| UI | Brak (tylko API) | **Wbudowany chat w przeglądarce** | +| Port | 8000 | **8070** (host) → 8080 (kontener) | +| Modele skwantyzowane | AWQ / HF (nie GGUF) | **GGUF, AWQ**, wiele backendów | +| Ten krok | Obraz pobrany, bez modelu OK | Start **bez modelu** — pusty `/models` | + +Oba stacki mogą współistnieć na dysku, ale **nie ładuj dużych modeli na GPU równocześnie** (24 GB VRAM). + +--- + +## 2. Porty i architektura + +```mermaid +flowchart LR + browser["Przeglądarka :8070"] + curl["curl / OpenAI SDK"] + localai["Kontener localai"] + gpu["RTX 3090 Ti"] + disk["/data/apps/localai/models"] + + browser --> localai + curl --> localai + localai --> gpu + localai --> disk +``` + +| Element | Wartość | +|---------|---------| +| Obraz | `localai/localai:v4.4.3-gpu-nvidia-cuda-13` | +| UI + API (LAN / tunel) | `http://127.0.0.1:8070` lub publicznie przez NPMPlus (rozdział 07) | +| Modele | `/data/apps/localai/models` | +| Docker data | `/data/docker` | + +--- + +## 3. Struktura plików stacku + +``` +stacks/localai/ +├── README.md +├── docker-compose.yml +├── .env.example +├── .gitignore +├── upstream/ # opcjonalny shallow clone (gitignored) +└── scripts/ + ├── clone-upstream.sh + ├── pull.sh + └── start.sh +``` + +Katalogi na dysku 1 TB (tworzone przez skrypty): + +``` +/data/apps/localai/ +├── models/ +├── backends/ +├── configuration/ +├── images/ +└── data/ +``` + +--- + +## 4. Przygotowanie `.env` + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/localai +cp .env.example .env +cat .env +``` + +Oczekiwane: + +```env +DATA_ROOT=/data +LOCALAI_PORT=8070 +LOCALAI_API_KEY=your-secret-key +LOCALAI_IMAGE=localai/localai:v4.4.3-gpu-nvidia-cuda-13 +CUDA_VISIBLE_DEVICES=0 +DEBUG=false +``` + +--- + +## 5. Clone upstream (opcjonalnie) + +Shallow clone repozytorium GitHub — **tylko referencja** (przykładowe YAML modeli). Runtime idzie z oficjalnego obrazu Docker, nie z buildu lokalnego. + +```bash +./scripts/clone-upstream.sh +``` + +Powstaje `stacks/localai/upstream/` (ignorowane przez git). + +--- + +## 6. Instalacja obrazu (bez modelu) + +Pobiera wyłącznie obraz Docker — **nie startuje kontenera**, **nie pobiera modeli LLM**. + +```bash +./scripts/pull.sh +``` + +Alternatywa ręczna: + +```bash +docker compose --profile localai pull +``` + +Weryfikacja: + +```bash +docker images | grep localai +docker compose --profile localai ps +``` + +Oczekiwane: obraz widoczny, kontener **nie** działa. + +--- + +## 7. Start stacku + +```bash +./scripts/start.sh +``` + +Skrypt sprawdza: `/data` zamontowany, Docker działa, tworzy katalogi w `/data/apps/localai/`. + +Logi: + +```bash +docker compose --profile localai logs -f localai +``` + +--- + +## 8. Weryfikacja UI i API + +### 8.1 Health check + +```bash +curl -s http://localhost:8070/readyz +``` + +Oczekiwane: odpowiedź HTTP 200 (tekst potwierdzający gotowość). + +### 8.2 UI w przeglądarce + +Otwórz na swoim PC (z sieci LAN): + +``` +http://127.0.0.1:8070 +``` + +Adres IP serwera: + +```bash +hostname -I | awk '{print $1}' +``` + +UI powinno się załadować — lista modeli będzie **pusta** (to OK na tym etapie). + +### 8.3 API (opcjonalnie) + +```bash +curl -s http://localhost:8070/v1/models -H "Authorization: Bearer $LOCALAI_API_KEY" | jq . +``` + +Pusta lista modeli — normalne bez pobranego modelu. + +--- + +## 9. Zarządzanie stackiem + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/localai + +docker compose --profile localai ps +docker compose --profile localai logs -f localai +docker compose --profile localai restart localai +docker compose --profile localai down +``` + +Przed testem modelu w LocalAI — zatrzymaj vLLM jeśli działa: + +```bash +cd ../vllm +docker compose --profile vllm down +``` + +--- + +## 10. Troubleshooting + +### 10.1 `/data` is not mounted + +Wróć do [04-vllm-stack.md — część A](04-vllm-stack.md) i dokończ setup dysku. + +### 10.2 GPU niewidoczne w kontenerze + +```bash +docker run --rm --gpus all nvidia/cuda:12.6.0-base-ubuntu24.04 nvidia-smi +``` + +Upewnij się, że używasz obrazu `-cuda-13`, nie `-cuda-12`. + +### 10.3 Healthcheck failing / restarting + +```bash +docker compose --profile localai logs --tail 100 localai +``` + +Pierwszy start może trwać 1–2 minuty. Healthcheck ma `start_period: 2m`. + +### 10.4 UI niedostępne z innego komputera + +LocalAI nasłuchuje na `127.0.0.1:8070`. Dostęp z LAN/internetu — przez NPMPlus (rozdział 07) lub SSH tunnel: + +```bash +# na swoim PC: +ssh -L 8070:127.0.0.1:8070 tomasz-syn-grzegorza@ +# potem: http://localhost:8070 +``` + +### 10.5 Brak modeli w UI + +Zamierzone na tym kroku. Modele dodasz w kolejnym etapie (Model Gallery, GGUF, CLI). + +--- + +## 11. KV cache (po dodaniu modelu chat) + +Domyślnie KV cache w llama.cpp jest w **f16** — zajmuje dużo VRAM przy długim kontekście. Na RTX 3090 Ti (24 GB) rekomendujemy **q8_0** dla K i V. + +Ustawienia są w YAML modelu na `/data/apps/localai/models/.yaml`, sekcja `parameters:`: + +| Pole | Wartość startowa | +|------|------------------| +| `cache_type_k` | `q8_0` | +| `cache_type_v` | `q8_0` | +| `flash_attention` | `true` | +| `context_size` | `8192` | + +Zastosowanie z repo: + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/localai +./scripts/apply-kv-profile.sh gemma-4-12b-it-qat-q4_0 +docker compose --profile localai restart localai +``` + +Skrypt tworzy backup YAML. Pliki w `/data/apps/localai/models/` są często **root-owned** — skrypt używa wtedy `docker exec localai` (volume `/models`). + +Szczegóły: [`stacks/localai/coding-agent/KV-CACHE.md`](../stacks/localai/coding-agent/KV-CACHE.md) + +Po załadowaniu modelu sprawdź VRAM: + +```bash +nvidia-smi +docker compose --profile localai logs localai 2>&1 | tail -50 +``` + +--- + +## 12. Następny krok + +Po przejściu weryfikacji: + +1. Zgłoś w rozmowie z Cursorem, że krok 05 jest gotowy. +2. Pobierz model (np. GGUF Qwen z katalogu vLLM) — osobny krok. +3. Później: rozdział **06 — sterowanie wentylatorami GPU** lub **07 — ComfyUI stack**. + +--- + +## Checklist + +- [ ] `.env` utworzony z `.env.example` +- [ ] `docker compose --profile localai pull` — obraz pobrany +- [ ] `./scripts/start.sh` — kontener `localai` running +- [ ] `curl http://localhost:8070/readyz` — OK +- [ ] UI otwiera się w przeglądarce na `:8070` (lub przez tunel SSH) +- [ ] Katalogi istnieją pod `/data/apps/localai/` +- [ ] (Po modelu chat) KV cache q8_0 w YAML + restart diff --git a/manual-tutorial/06-gpu-fan-control.md b/manual-tutorial/06-gpu-fan-control.md new file mode 100644 index 0000000..869118c --- /dev/null +++ b/manual-tutorial/06-gpu-fan-control.md @@ -0,0 +1,356 @@ +# 06 — Sterowanie wentylatorami GPU + +> **Cel rozdziału:** skonfigurować sterowanie wentylatorami RTX 3090 Ti na headless serwerze — krzywa temp → prędkość, web UI w sieci lokalnej, usługa systemd. + +**Szacowany czas:** 15–20 minut + +**Wymagania:** ukończony rozdział [02 — Sterowniki NVIDIA](02-nvidia-driver.md) (`nvidia-smi` działa) + +--- + +## Spis treści + +1. [Dlaczego własny stack](#1-dlaczego-własny-stack) +2. [Weryfikacja punktu startowego](#2-weryfikacja-punktu-startowego) +3. [Instalacja](#3-instalacja) +4. [Web UI w sieci lokalnej](#4-web-ui-w-sieci-lokalnej) +5. [Krzywa max cooling](#5-krzywa-max-cooling) +6. [Zarządzanie usługą](#6-zarządzanie-usługą) +7. [Weryfikacja pod obciążeniem](#7-weryfikacja-pod-obciążeniem) +8. [Troubleshooting](#8-troubleshooting) +9. [Następny krok](#9-następny-krok) + +--- + +## 1. Dlaczego własny stack + +Na headless Ubuntu **nie działają** narzędzia oparte na `nvidia-settings` + CoolBits (wymagają display servera). + +Od sterownika NVIDIA **520+** sterowanie wentylatorami GeForce jest dostępne przez **NVML** — działa bez monitora i bez X11. + +Stack [`stacks/gpu-fan/`](../stacks/gpu-fan/): + +- daemon NVML (Python + `pynvml`) +- web UI do edycji krzywej +- preset agresywny pod długie obciążenie LocalAI / vLLM + +`nvidia-smi` pokazuje tylko odczyty — **nie ustawia** prędkości wentylatorów. + +--- + +## 2. Weryfikacja punktu startowego + +```bash +nvidia-smi --query-gpu=name,driver_version,temperature.gpu,fan.speed --format=csv,noheader + +# Oczekiwane: +# NVIDIA GeForce RTX 3090 Ti, 595.x, , % +``` + +```bash +python3 -c "import pynvml; pynvml.nvmlInit(); h=pynvml.nvmlDeviceGetHandleByIndex(0); print(pynvml.nvmlDeviceGetName(h)); pynvml.nvmlShutdown()" +``` + +Jeśli `ModuleNotFoundError: pynvml` — `install.sh` doinstaluje zależności. Możesz też: + +```bash +sudo apt install -y python3-pynvml +``` + +--- + +## 3. Instalacja + +Z katalogu repozytorium na serwerze: + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan + +cp .env.example .env +# opcjonalnie: edytuj GPU_FAN_PORT, API_KEY + +sudo scripts/install.sh +sudo scripts/enable-lan.sh +sudo systemctl status gpu-fan +``` + +**Co robi `install.sh`:** + +| Krok | Efekt | +|------|-------| +| `apt install python3-venv` | Środowisko Python | +| Kopiuje pliki do `/opt/gpu-fan` | Kod aplikacji | +| Tworzy `/etc/gpu-fan/curve.json` | Domyślna krzywa max cooling | +| `systemctl enable gpu-fan` | Start przy bootcie | + +Logi: + +```bash +journalctl -u gpu-fan -f +``` + +--- + +## 4. Web UI w sieci lokalnej + +Domyślnie UI nasłuchuje na **0.0.0.0:8090** — dostępne z każdego urządzenia w LAN. + +```bash +# IP serwera +hostname -I | awk '{print $1}' + +# API key +grep ^API_KEY= /opt/control-plane/.env +``` + +W przeglądarce na laptopie / telefonie w tej samej sieci: + +``` +http://192.168.100.5:8090/?api_key=TWÓJ_KLUCZ_Z_.env +``` + +Klucz można też podać w promptcie UI przy pierwszym wejściu (bez `?api_key=` w URL). + +### Tylko localhost (opcjonalnie) + +W `/opt/control-plane/.env`: + +```bash +GPU_FAN_HOST=127.0.0.1 +API_KEY= +``` + +```bash +sudo systemctl restart gpu-fan +``` + +Dostęp przez SSH tunnel: + +```bash +ssh -L 8090:127.0.0.1:8090 tomasz-syn-grzegorza@gmktec-k11 +``` + +Otwórz: **http://localhost:8090** + +### Funkcje UI + +- Wykres krzywej — przeciąganie punktów; podziałka temperatury (°C) i prędkości (%), siatka pomocnicza i linijki z kreskami na zewnętrznych krawędziach osi +- Tabela temp / speed +- Status live: temperatura, wentylatory, moc, wykorzystanie GPU +- **Zapisz krzywą** — zapis do `/etc/gpu-fan/curve.json` +- **Tryb auto** — oddaje sterowanie driverowi NVIDIA +- **Manual 100%** — awaryjne pełne obroty + +### API key + +Przy dostępie z LAN **API_KEY jest wymagany** (generowany przy `install.sh` lub ustaw ręcznie w `.env`): + +```bash +sudo nano /opt/control-plane/.env +``` + +```bash +sudo systemctl restart gpu-fan +``` + +--- + +## 5. Krzywa max cooling + +Domyślny plik `/etc/gpu-fan/curve.json`: + +```json +{ + "30": 50, + "40": 65, + "50": 80, + "55": 90, + "60": 100, + "70": 100 +} +``` + +Edycja ręczna: + +```bash +sudo nano /etc/gpu-fan/curve.json +sudo systemctl reload gpu-fan +``` + +### Ograniczenia API NVIDIA + +| Wartość | Znaczenie | +|---------|-----------| +| 0% | Oddaj sterowanie driverowi (auto) | +| 1–29% | **Niedozwolone** przez API | +| 30–100% | Dozwolone w trybie manual | + +RTX 3090 Ti w trybie **auto** może mieć **0 RPM** przy niskiej temperaturze — to normalne zachowanie karty. + +--- + +## 6. Zarządzanie usługą + +```bash +sudo systemctl start gpu-fan # start +sudo systemctl stop gpu-fan # stop + przywrócenie auto +sudo systemctl restart gpu-fan # restart +sudo systemctl reload gpu-fan # przeładuj curve.json (SIGHUP) +``` + +Test API z serwera: + +```bash +curl -s http://127.0.0.1:8090/api/status | python3 -m json.tool +``` + +```bash +curl -s -X POST http://127.0.0.1:8090/api/mode \ + -H 'Content-Type: application/json' \ + -d '{"mode":"manual","speed":100}' +``` + +```bash +curl -s -X POST http://127.0.0.1:8090/api/mode \ + -H 'Content-Type: application/json' \ + -d '{"mode":"auto"}' +``` + +--- + +## 7. Weryfikacja pod obciążeniem + +### 7.1 Idle + +```bash +watch -n2 'curl -s http://127.0.0.1:8090/api/status | python3 -m json.tool' +``` + +W trybie `curve` przy ~40°C oczekuj target_speed zgodnego z krzywą (np. ~50–65%). + +### 7.2 Obciążenie GPU + +Uruchom inference (LocalAI lub vLLM), np.: + +```bash +# LocalAI (jeśli działa) +curl -s http://localhost:8070/v1/models + +# lub krótki stress (opcjonalnie, wymaga cuda w kontenerze) +# docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi dmon -s puc -d 1 -c 30 +``` + +Obserwuj w UI lub przez API: + +- `temperature_c` rośnie pod loadem +- `target_speed_pct` podąża za krzywą +- `fan_speeds_pct` dążą do `target_speed_pct` + +### 7.3 Graceful shutdown + +```bash +sudo systemctl stop gpu-fan +nvidia-smi --query-gpu=fan.speed --format=csv,noheader +``` + +Po stopie usługi wentylatory powinny wrócić do polityki drivera (auto). + +### Checklist + +- [ ] `systemctl status gpu-fan` — active (running) +- [ ] Web UI dostępne z LAN (`http://:8090`) +- [ ] Zapis krzywej w UI działa +- [ ] Pod loadem temp rośnie, wentylatory przyspieszają +- [ ] Po `stop` — tryb auto drivera + +--- + +## 8. Troubleshooting + +### 8.1 `Insufficient Permissions` + +Sterowanie wentylatorami wymaga **root**. Uruchamiaj przez systemd lub `sudo scripts/start.sh`. + +### 8.2 `Failed to initialize NVML: Driver/library version mismatch` + +Po aktualizacji kernela lub sterownika: + +```bash +sudo reboot +``` + +### 8.3 Wentylatory nie schodzą poniżej 30% + +To ograniczenie API NVIDIA w trybie manual — nie błąd aplikacji. Dla ciszy przy idle użyj **Tryb auto** w UI. + +### 8.4 Port 8090 zajęty / `address already in use` + +**Nie zmieniaj portu na 8091, 8092…** — to tylko maskuje problem. Przy kolejnym podwójnym starcie następny port też będzie zajęty. + +Diagnostyka: + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan +scripts/status.sh +sudo ss -tlnp | grep -E ':809[0-9]' +jobs -l # zawieszone Ctrl+Z w tej sesji SSH +``` + +Czyszczenie: + +```bash +sudo scripts/status.sh --cleanup +sudo systemctl start gpu-fan +``` + +Typowe przyczyny: + +| Objaw | Przyczyna | Rozwiązanie | +|-------|-----------|-------------| +| Błąd po `systemctl restart` + `start.sh` | Dwie instancje | Używaj **albo** systemd **albo** `start.sh` | +| `[n]+ Stopped start.sh` w terminalu | Ctrl+Z zamiast Ctrl+C | `kill %n` lub `sudo scripts/status.sh --cleanup` | +| Port zajęty po reboot | `gpu-fan.service` włączone (`enable`) | Normalne — nie uruchamiaj drugiej kopii | +| Zmiana portu w repo `.env` bez efektu | Produkcja czyta `/opt/control-plane/.env` | Edytuj `/opt/control-plane/.env` lub uruchom `setup-control-plane-env.sh` | + +Po zmianach w kodzie UI (np. monitoring GPU): + +```bash +sudo scripts/install.sh && sudo systemctl restart gpu-fan +``` + +Foreground debug (tylko gdy systemd zatrzymany): + +```bash +sudo systemctl stop gpu-fan +sudo scripts/start.sh +# Zakończ: Ctrl+C (nie Ctrl+Z!) +``` + +### 8.5 Krzywa odrzucona (400) + +- 3–7 punktów +- temperatury rosnąco, unikalne +- prędkość: 0 lub 30–100 + +--- + +## 9. Następny krok + +Po przejściu checklisty: + +1. Zostaw `gpu-fan` włączony przy workloadach AI (LocalAI, ComfyUI, vLLM). +2. Rozdział **07 — ComfyUI stack** — generowanie obrazów w Dockerze. +3. Rozważ `API_KEY` przed ewentualnym wystawieniem portu w rozdziale firewall (08). + +Powrót do roadmapy: [README.md](../README.md) + +--- + +## Podsumowanie + +Po ukończeniu rozdziału: + +- Działa `gpu-fan.service` na hoście (NVML, root) +- Krzywa w `/etc/gpu-fan/curve.json` +- Web UI na **0.0.0.0:8090** (dostęp z sieci lokalnej, z API key) +- Stop usługi przywraca auto drivera NVIDIA diff --git a/manual-tutorial/07-comfyui-stack.md b/manual-tutorial/07-comfyui-stack.md new file mode 100644 index 0000000..f58ff28 --- /dev/null +++ b/manual-tutorial/07-comfyui-stack.md @@ -0,0 +1,286 @@ +# 07 — ComfyUI stack + +> **Cel rozdziału:** uruchomić [ComfyUI](https://github.com/comfyanonymous/ComfyUI) w Dockerze z GPU, web UI na porcie **8188** i pustym katalogiem modeli. Modele dodasz później (checkpoint, LoRA, ComfyUI-Manager). + +**Szacowany czas:** +- Pobranie obrazu Docker: 10–30 minut (pierwszy raz) +- Pierwszy start (kopia ComfyUI do `/data`): 3–10 minut + +**Wymagania:** ukończone rozdziały [01](01-system-update-and-docker.md)–[05](05-localai-stack.md), mount `/data` z [04](04-vllm-stack.md) + +**Kontekst:** Zamiast [Stability Matrix](https://github.com/LykosAI/StabilityMatrix) (GUI desktop) używamy ComfyUI w kontenerze — zgodnie z [03b](03b-system-tools.md) i researchiem w [`coding-agent/STABILITYMATRIX-RESEARCH.md`](../coding-agent/STABILITYMATRIX-RESEARCH.md). + +--- + +## Spis treści + +1. [ComfyUI vs Stability Matrix](#1-comfyui-vs-stability-matrix) +2. [Porty i architektura](#2-porty-i-architektura) +3. [Polityka GPU (LocalAI ↔ ComfyUI)](#3-polityka-gpu-localai--comfyui) +4. [Struktura plików stacku](#4-struktura-plików-stacku) +5. [Przygotowanie `.env`](#5-przygotowanie-env) +6. [Instalacja obrazu](#6-instalacja-obrazu) +7. [Start stacku](#7-start-stacku) +8. [Weryfikacja UI](#8-weryfikacja-ui) +9. [Zarządzanie stackiem](#9-zarządzanie-stackiem) +10. [Modele (później)](#10-modele-później) +11. [Troubleshooting](#11-troubleshooting) +12. [Następny krok](#12-następny-krok) + +--- + +## 1. ComfyUI vs Stability Matrix + +| | Stability Matrix | ComfyUI (`stacks/comfyui/`) | +|--|------------------|----------------------------| +| Typ | GUI desktop (AppImage) | **Docker, headless** | +| Wymaga pulpitu | Tak (X11/Wayland) | Nie — tylko przeglądarka | +| ComfyUI | Jeden z pakietów SM | **Bezpośrednio** w kontenerze | +| Zgodność z repo | Słaba na SSH-only | **Tak** | + +SwarmUI (poprzedni UI obrazów) zostało usunięte — patrz [`SWARMUI-REMOVAL.md`](../coding-agent/SWARMUI-REMOVAL.md). + +--- + +## 2. Porty i architektura + +```mermaid +flowchart LR + browser["Przeglądarka :8188"] + comfyui["Kontener comfyui"] + gpu["RTX 3090 Ti"] + disk["/data/apps/comfyui/models"] + + browser --> comfyui + comfyui --> gpu + comfyui --> disk +``` + +| Element | Wartość | +|---------|---------| +| Obraz | `yanwk/comfyui-boot:cu126-slim` | +| Web UI | `http://127.0.0.1:8188` lub `http://192.168.100.90:8188` (LAN) | +| Modele | `/data/apps/comfyui/models` | +| Docker data | `/data/docker` | + +--- + +## 3. Polityka GPU (LocalAI ↔ ComfyUI) + +RTX 3090 Ti 24 GB — **nie uruchamiaj** dużego modelu LLM (LocalAI) i dużego checkpointu SD/Flux **równocześnie**. + +Przed startem ComfyUI z generowaniem obrazów: + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/localai +docker compose --profile localai stop localai +``` + +W Server UI (port 8091): Stop/Start stack `localai`, Start stack `comfyui`. + +Skrypt `start.sh` ostrzega, gdy `localai` jest uruchomiony. + +--- + +## 4. Struktura plików stacku + +``` +stacks/comfyui/ +├── README.md +├── docker-compose.yml +├── .env.example +├── .gitignore +└── scripts/ + ├── ensure-dirs.sh + ├── pull.sh + └── start.sh +``` + +Katalogi na dysku 1 TB: + +``` +/data/apps/comfyui/ +├── storage/ # kopia ComfyUI (pierwszy start) +├── models/ +├── cache/hf-hub/ +├── cache/torch-hub/ +├── input/ +├── output/ +├── custom_nodes/ +└── workflows/ +``` + +--- + +## 5. Przygotowanie `.env` + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/comfyui +cp .env.example .env +cat .env +``` + +Domyślne wartości: + +| Zmienna | Wartość | +|---------|---------| +| `DATA_ROOT` | `/data` | +| `COMFYUI_PORT` | `8188` | +| `COMFYUI_IMAGE` | `yanwk/comfyui-boot:cu126-slim` | +| `CUDA_VISIBLE_DEVICES` | `0` | + +--- + +## 6. Instalacja obrazu + +```bash +./scripts/pull.sh +``` + +Oczekiwany wynik: pobranie warstw `yanwk/comfyui-boot:cu126-slim` (kilka GB). + +--- + +## 7. Start stacku + +```bash +./scripts/start.sh +``` + +Skrypt: + +1. Sprawdza mount `/data` +2. Tworzy katalogi (`ensure-dirs.sh`) +3. Ostrzega, jeśli `localai` działa +4. `docker compose --profile comfyui up -d` + +Pierwszy start kopiuje ComfyUI do `/data/apps/comfyui/storage/` — obserwuj logi: + +```bash +docker compose --profile comfyui logs -f comfyui +``` + +--- + +## 8. Weryfikacja UI + +```bash +docker compose --profile comfyui ps +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8188/ +``` + +Oczekiwane: kontener `healthy` lub `running`, HTTP **200** (może zająć ~3 min przy pierwszym starcie). + +W przeglądarce (z sieci lokalnej): + +``` +http://192.168.100.90:8188 +``` + +Tunel SSH z laptopa: + +```bash +ssh -L 8188:127.0.0.1:8188 tomasz-syn-grzegorza@gmktec-k11 +# potem http://localhost:8188 +``` + +GPU: + +```bash +nvidia-smi +``` + +--- + +## 9. Zarządzanie stackiem + +```bash +docker compose --profile comfyui ps +docker compose --profile comfyui logs -f comfyui +docker compose --profile comfyui restart comfyui +docker compose --profile comfyui down +``` + +Powrót do LocalAI: + +```bash +cd ../comfyui && docker compose --profile comfyui stop comfyui +cd ../localai && docker compose --profile localai start localai +``` + +--- + +## 10. Modele (później) + +Compose **nie pobiera** modeli automatycznie. + +Opcje: + +1. **ComfyUI-Manager** w UI — instalacja węzłów i modeli +2. Ręcznie — pliki `.safetensors` / `.ckpt` do `/data/apps/comfyui/models/checkpoints/` + +Szacunki VRAM (przy zatrzymanym LocalAI): + +| Model | VRAM (orientacyjnie) | +|-------|----------------------| +| SD 1.5 | ~4–6 GB | +| SDXL | ~8–12 GB | +| Flux | ~12–20 GB | + +--- + +## 11. Troubleshooting + +### HTTP nie odpowiada / 000 + +Pierwszy start trwa dłużej — sprawdź logi: + +```bash +docker compose --profile comfyui logs --tail 80 comfyui +``` + +### `CUDA out of memory` + +Zatrzymaj LocalAI i inne workloady GPU: + +```bash +docker ps --format '{{.Names}}' +nvidia-smi +``` + +### Brak `/data` + +```bash +mountpoint /data +# jeśli nie — rozdział 04, część A +``` + +### Kontener restartuje się w pętli + +Sprawdź, czy NVIDIA Container Toolkit działa (rozdział 03): + +```bash +docker run --rm --gpus all nvidia/cuda:12.6.0-base-ubuntu22.04 nvidia-smi +``` + +--- + +## 12. Następny krok + +Po przejściu weryfikacji: + +1. Zgłoś w rozmowie z Cursorem, że krok 07 jest gotowy. +2. Pobierz pierwszy checkpoint (SD 1.5 lub SDXL) — osobny krok. +3. Później: rozdział **08 — firewall i hardening**. + +--- + +## Checklist + +- [ ] `.env` utworzony z `.env.example` +- [ ] `docker compose --profile comfyui pull` — obraz pobrany +- [ ] LocalAI zatrzymany przed pierwszym testem generowania (opcjonalnie przy samym starcie UI) +- [ ] `./scripts/start.sh` — kontener `comfyui` running +- [ ] `curl http://127.0.0.1:8188/` — HTTP 200 +- [ ] UI otwiera się w przeglądarce na `:8188` +- [ ] Katalogi istnieją pod `/data/apps/comfyui/` diff --git a/manual-tutorial/08-server-ui-install.md b/manual-tutorial/08-server-ui-install.md new file mode 100644 index 0000000..212d10a --- /dev/null +++ b/manual-tutorial/08-server-ui-install.md @@ -0,0 +1,230 @@ +# 08 — Server UI i panel sterowania (Control Plane) + +> **Cel rozdziału:** zainstalować panel Server UI do zarządzania stackami Docker (LocalAI, ComfyUI, vLLM, NPMPlus) oraz zakładkę GPU Fan — i wiedzieć, gdzie są klucze API. + +**Szacowany czas:** 10–15 minut + +**Wymagania:** rozdział [01 — Docker](01-system-update-and-docker.md); opcjonalnie [06 — gpu-fan](06-gpu-fan-control.md) dla zakładki wentylatorów + +--- + +## Spis treści + +1. [Co to jest Server UI](#1-co-to-jest-server-ui) +2. [Dlaczego nie ma go w docker ps](#2-dlaczego-nie-ma-go-w-docker-ps) +3. [Instalacja jednym skryptem](#3-instalacja-jednym-skryptem) +4. [Gdzie są klucze API](#4-gdzie-są-klucze-api) +5. [Jak uruchomić i otworzyć panel](#5-jak-uruchomić-i-otworzyć-panel) +6. [Tryb Docker (opcjonalnie)](#6-tryb-docker-opcjonalnie) +7. [Troubleshooting](#7-troubleshooting) +8. [Następny krok](#8-następny-krok) + +--- + +## 1. Co to jest Server UI + +**Server UI** to jeden panel w przeglądarce (port **8091**), w którym możesz: + +- włączać i wyłączać stacki Docker (Start / Stop / Restart), +- przeglądać logi kontenerów, +- otwierać linki do LocalAI, ComfyUI, vLLM, NPMPlus, +- zmieniać porty usług (zapis do `.env` stacku), +- sterować wentylatorami GPU (zakładka **GPU Fan**). + +Zastępuje Portainer i Dockge. + +--- + +## 2. Dlaczego nie ma go w `docker ps` + +Komenda `docker ps` pokazuje **tylko kontenery Docker**. + +| Co widzisz w `docker ps` | Co to jest | +|--------------------------|------------| +| `comfyui`, `npmplus`, `localai` | Stacki AI / proxy — **Docker** | +| **Brak** `server-ui` | Panel działa jako **usługa systemd** na hoście (domyślnie) | +| **Brak** `gpu-fan` | Agent wentylatorów — też **systemd** na hoście | + +To **nie jest błąd**. Panel nie musi być kontenerem. + +Sprawdzenie: + +```bash +systemctl status server-ui gpu-fan +ss -tln | grep -E '8091|18090' +``` + +Jeśli wybrałeś instalację Server UI w **Dockerze** (opcja 2 w instalatorze), wtedy zobaczysz kontener: + +```bash +docker compose --profile server-ui ps +``` + +--- + +## 3. Instalacja jednym skryptem + +Na serwerze: + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/server-ui +sudo ./scripts/install-control-plane.sh +``` + +### Menu instalatora + +**1) gpu-fan** — agent sterowania wentylatorami GPU + +- Działa **tylko na hoście** (root + NVML). +- **Nie instalujemy go w Dockerze** — karta wymaga bezpośredniego dostępu do sterownika NVIDIA. +- Zalecane: **Y** (tak). + +**2) Server UI** — panel w przeglądarce + +| Opcja | Opis | +|-------|------| +| **1 — Native** | systemd, `/opt/server-ui` (zalecane) | +| **2 — Docker** | kontener z dostępem do `docker.sock` | +| **3 — Pomiń** | tylko gpu-fan | + +### Instalacja bez pytań (domyślnie: gpu-fan + server-ui native) + +```bash +sudo ./scripts/install-control-plane.sh -y +``` + +### Tylko Server UI (bez gpu-fan) + +```bash +sudo ./scripts/install-control-plane.sh --gpu-fan=no --server-ui=native +``` + +--- + +## 4. Klucz API — wpisz w panelu + +**Pełna instrukcja krok po kroku:** [04a — Klucz API](04a-api-key.md) + +Skrót po instalacji: + +1. Na serwerze: `sudo grep ^API_KEY= /opt/control-plane/.env` — skopiuj wartość po `=` +2. W przeglądarce: `http://:8091/` → pole **API Key** → **Zapisz** → **Sprawdź klucz** +3. Lub gotowy link: `bash stacks/server-ui/scripts/show-api-key.sh` (wypisze URL z kluczem) + +Jeden plik, jeden klucz — `/opt/control-plane/.env` (Server UI + gpu-fan). + +**Nie używaj** `stacks/server-ui/.env` ani `GPU_FAN_AGENT_KEY` — to przestarzałe. + +--- + +## 5. Jak uruchomić i otworzyć panel + +### Native (systemd) + +```bash +sudo systemctl status server-ui +sudo systemctl start server-ui # jeśli zatrzymany +sudo systemctl restart server-ui # po aktualizacji kodu +``` + +Adres (przykład): + +``` +http://192.168.100.90:8091/ +``` + +### gpu-fan (jeśli zainstalowany) + +```bash +sudo systemctl status gpu-fan +journalctl -u gpu-fan -f +``` + +### Weryfikacja API + +```bash +curl -s http://127.0.0.1:8091/api/health +curl -s http://127.0.0.1:8091/api/stacks +``` + +### Aktualizacja kodu po zmianach w repo + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/server-ui +sudo ./scripts/install.sh && sudo systemctl restart server-ui +``` + +--- + +## 6. Tryb Docker (opcjonalnie) + +Jeśli w instalatorze wybrałeś **2 — Docker**: + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/server-ui +docker compose --profile server-ui ps +docker compose --profile server-ui logs -f +docker compose --profile server-ui down # stop +sudo ./scripts/install-docker.sh # ponowna instalacja +``` + +gpu-fan **nadal** działa na hoście (systemd) — kontener Server UI łączy się z agentem przez `host.docker.internal:18090`. + +Przełączenie z Docker na native: + +```bash +docker compose --profile server-ui down +sudo ./scripts/install.sh +``` + +--- + +## 7. Troubleshooting + +### Port 8091 zajęty + +```bash +ss -tlnp | grep 8091 +systemctl status server-ui +``` + +Zobacz też: [06 — gpu-fan, sekcja portów](06-gpu-fan-control.md#84-port-8090-zajęty--address-already-in-use) (podobna sytuacja — nie zwiększaj portu bez diagnozy). + +### Zakładka GPU Fan nie działa + +1. Czy gpu-fan działa: `systemctl status gpu-fan` +2. Czy agent odpowiada: + ```bash + API_KEY=$(grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2) + curl -s http://127.0.0.1:18090/api/status -H "X-API-Key: $API_KEY" + ``` +3. Ten sam klucz w panelu Server UI (pole API Key lub `?api_key=`) + +### Start stacku zwraca 401 / Invalid API key + +Zobacz [04a — Klucz API, sekcja 7](04a-api-key.md#7-błąd-invalid-or-missing-api-key--checklist). + +### Start stacku zwraca 409 + +Tylko **jeden** duży workload GPU naraz (LocalAI **lub** ComfyUI **lub** vLLM). Zatrzymaj działający stack GPU przed startem drugiego. + +--- + +## 8. Następny krok + +- Zarządzaj stackami z panelu :8091 +- Edytuj porty usług w kartach stacków (pole **Zapisz port**) +- Przy zmianie portu LocalAI zaktualizuj upstream w NPMPlus ręcznie + +Powrót do roadmapy: [README.md](../README.md) + +--- + +## Podsumowanie + +| Pytanie | Odpowiedź | +|---------|-----------| +| Gdzie panel? | `http://:8091/` | +| Dlaczego nie w `docker ps`? | Domyślnie systemd, nie kontener | +| Klucz (Server UI + gpu-fan) | [04a — Klucz API](04a-api-key.md) · `/opt/control-plane/.env` | +| Instalacja | `sudo ./scripts/install-control-plane.sh` | diff --git a/manual-tutorial/09-file-explorer.md b/manual-tutorial/09-file-explorer.md new file mode 100644 index 0000000..e7fdd08 --- /dev/null +++ b/manual-tutorial/09-file-explorer.md @@ -0,0 +1,117 @@ +# 09 — Przeglądarka plików w Server UI + +> **Cel rozdziału:** przeglądać i edytować pliki na serwerze z przeglądarki (zakładka **Pliki** w panelu :8091). + +**Szacowany czas:** 10 minut + +**Wymagania:** rozdział [08 — Server UI](08-server-ui-install.md) (działający panel + klucz API) + +--- + +## 1. Otwórz zakładkę Pliki + +1. Wejdź na panel: `http://:8091/` +2. Wpisz **API Key** (toolbar u góry) i kliknij **Zapisz** +3. Kliknij zakładkę **Pliki** (druga od lewej) + +Bezpośredni link: + +``` +http://:8091/#files?api_key=TWÓJ_KLUCZ +``` + +--- + +## 2. Klucz API + +Zobacz [04a — Klucz API](04a-api-key.md): pole **API Key** → **Zapisz** → **Sprawdź klucz**. + +--- + +## 3. Nawigacja + +| Element | Działanie | +|---------|-----------| +| Lista po lewej | Klik na **folder** → wejście; klik na **plik** → otwarcie w edytorze | +| Breadcrumb u góry | Skróty do katalogów nadrzędnych | +| **↑ Wyżej** | Katalog rodzica | +| **Odśwież** | Ponowne wczytanie listy | + +Przykładowe ścieżki: + +| Co chcesz zrobić | Ścieżka | +|------------------|---------| +| Repo projektu | `/home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal` | +| `.env` stacku LocalAI | `.../stacks/localai/.env` | +| Dane aplikacji | `/data/apps/` | +| Logi systemowe | `/var/log/` (jeśli masz uprawnienia) | + +--- + +## 4. Edycja pliku + +1. Kliknij plik tekstowy na liście +2. Zawartość pojawi się po prawej +3. Edytuj w polu tekstowym +4. Kliknij **Zapisz** + +Pliki **binarne** (obrazy, modele) — tylko podgląd base64, bez zapisu. + +--- + +## 5. Tworzenie i usuwanie + +| Przycisk | Działanie | +|----------|-----------| +| **+ Folder** | Nowy podfolder w bieżącym katalogu | +| **+ Plik** | Nowy pusty plik | +| **Zmień nazwę** | Zaznacz element na liście, potem przycisk | +| **Usuń** | Zaznacz element — pojawi się potwierdzenie | + +**Uwaga:** Nie można usunąć niepustego folderu — najpierw opróżnij go. + +--- + +## 6. Brak uprawnień — co to znaczy? + +Panel działa jako Twój użytkownik Linux (`tomasz-syn-grzegorza`), **nie jako root**. + +| Objaw | Przyczyna | Rozwiązanie | +|-------|-----------|-------------| +| „Brak uprawnień” przy `/opt/control-plane/.env` | Plik root-only (600) | `sudo nano /opt/control-plane/.env` w SSH | +| Nie widać cudzego katalogu | Brak prawa odczytu | `sudo` w terminalu lub zmiana właściciela | +| Zapis odrzucony | Brak prawa zapisu | `chmod` / `chown` w SSH | + +To normalne — explorer nie omija zabezpieczeń systemu. + +--- + +## 7. Weryfikacja + +1. Wejdź w `/tmp` +2. Kliknij **+ Plik**, nazwa: `server-ui-test.txt` +3. Wpisz tekst, **Zapisz** +4. Odśwież — plik na liście +5. **Usuń** plik + +--- + +## 8. Następny krok + +- Terminal w przeglądarce: [10 — CLI](10-server-ui-cli.md) +- Edytuj `.env` stacków w `stacks//.env` +- Zarządzaj stackami w zakładce **Stacki** +- Steruj wentylatorami w **GPU Fan** + +Powrót: [README.md](../README.md) + +--- + +## Podsumowanie + +| Pytanie | Odpowiedź | +|---------|-----------| +| Gdzie panel? | `http://:8091/#files` | +| Klucz API? | `sudo grep ^API_KEY= /opt/control-plane/.env` | +| Limit rozmiaru pliku? | 2 MiB (domyślnie) | +| Root w przeglądarce? | Nie — tylko uprawnienia użytkownika panelu | diff --git a/manual-tutorial/10-server-ui-cli.md b/manual-tutorial/10-server-ui-cli.md new file mode 100644 index 0000000..f0a8c58 --- /dev/null +++ b/manual-tutorial/10-server-ui-cli.md @@ -0,0 +1,122 @@ +# 10 — Terminal CLI w Server UI + +> **Cel rozdziału:** uruchomić shell bash w przeglądarce (zakładka **CLI** w panelu :8091) — jak krótkie SSH, ale w UI. + +**Szacowany czas:** 5 minut + +**Wymagania:** rozdział [08 — Server UI](08-server-ui-install.md) (działający panel + klucz API) + +--- + +## 1. Otwórz zakładkę CLI + +1. Wejdź na panel: `http://:8091/` +2. Wpisz **API Key** (toolbar u góry) i kliknij **Zapisz** +3. Kliknij zakładkę **CLI** (pierwsza od lewej) + +Bezpośredni link: + +``` +http://:8091/#cli?api_key=TWÓJ_KLUCZ +``` + +Status u góry terminala: +- **Połączono** (zielony) — gotowe +- **Wpisz i zapisz API Key** — brak klucza +- **Rozłączono** — kliknij **Połącz ponownie** + +--- + +## 2. Klucz API + +Zobacz [04a — Klucz API](04a-api-key.md): pole **API Key** → **Zapisz** → **Sprawdź klucz**. + +--- + +## 3. Podstawowe komendy + +W terminalu wpisz jak w SSH: + +```bash +whoami +pwd +ls +docker ps +``` + +Powinieneś zobaczyć użytkownika `tomasz-syn-grzegorza` i dostęp do Dockera. + +--- + +## 4. Programy interaktywne + +| Program | Jak wyjść | +|---------|-----------| +| `htop` | klawisz `q` | +| `less plik` | `q` | +| `vim plik` | `:q` lub `:wq` | +| `nano plik` | `Ctrl+O`, `Ctrl+X` | + +Zmiana rozmiaru okna przeglądarki dostosowuje terminal automatycznie. + +--- + +## 5. sudo + +Możesz użyć `sudo` — terminal poprosi o hasło **Twojego użytkownika Linux** (nie API Key): + +```bash +sudo ls /root +``` + +--- + +## 6. Ograniczenia + +| Co | Wyjaśnienie | +|----|-------------| +| Nie jesteś rootem | Panel działa jako Twój użytkownik — jak zwykłe SSH | +| Nowa sesja po reconnect | Zamknięcie zakładki / **Połącz ponownie** = nowy bash | +| Limit sesji | Domyślnie max 5 równoległych terminali (wszystkie karty) | +| Wyłączenie CLI | Admin może ustawić `CLI_ENABLED=0` w `/opt/control-plane/.env` | + +--- + +## 7a. Pusty terminal (inne zakładki działają) + +1. U góry: **Zapisz** → **Sprawdź klucz** (musi być „Klucz poprawny”) +2. Kliknij **Połącz ponownie** +3. Status powinien zmienić się na **Połączono** — pojawi się prompt bash +4. Jeśli nadal pusto: twardy refresh strony (Ctrl+F5), potem kroki 1–2 +5. Zły klucz: status „Zły klucz API…” — zobacz [04a — Klucz API](04a-api-key.md) + +--- + +## 7. Weryfikacja + +1. Zakładka **CLI** → status **Połączono** +2. `echo test-cli-ok` → widzisz `test-cli-ok` +3. `docker ps` → lista kontenerów +4. Zmień rozmiar okna — prompt nie „łamie się” dziwnie +5. Przełącz na **Stacki** i wróć do **CLI** — nowa sesja (to normalne) + +--- + +## 8. Następny krok + +- Przeglądaj pliki w zakładce [Pliki](09-file-explorer.md) +- Zarządzaj stackami w **Stacki** +- Steruj wentylatorami w **GPU Fan** + +Powrót: [README.md](../README.md) + +--- + +## Podsumowanie + +| Pytanie | Odpowiedź | +|---------|-----------| +| Gdzie terminal? | `http://:8091/#cli` | +| Klucz API? | [04a — Klucz API](04a-api-key.md) | +| Kto jesteś w shellu? | `tomasz-syn-grzegorza` (nie root) | +| vim/htop? | Tak — pełny PTY | diff --git a/scripts/configure-static-dual-nic.sh b/scripts/configure-static-dual-nic.sh new file mode 100755 index 0000000..8c84941 --- /dev/null +++ b/scripts/configure-static-dual-nic.sh @@ -0,0 +1,421 @@ +#!/usr/bin/env bash +# Statyczne IP na dwóch kartach Ethernet — GMKtec K11 (gmktec-k11) +# +# Skanuje link na eno1 / enp3s0, pokazuje menu CLI i generuje Netplan. +# Rollback: scripts/restore-dhcp-network.sh +set -euo pipefail + +NETPLAN_FILE="/etc/netplan/00-installer-config.yaml" +NIC_A="eno1" +NIC_B="enp3s0" +IP80="192.168.100.80/24" +IP90="192.168.100.90/24" +GATEWAY="192.168.100.1" +DNS="192.168.100.1" + +DRY_RUN=false +NON_INTERACTIVE=false +PROFILE="" + +# Po wyborze profilu (ustawiane przez apply_profile) +GW_NIC="" +ENO1_IPS="" +ENP3S0_IPS="" +PROFILE_NAME="" + +usage() { + cat <<'EOF' +Użycie: sudo ./configure-static-dual-nic.sh [opcje] + +Opcje: + --dry-run Skan + menu (lub profil) + YAML, bez zapisu i apply + --non-interactive Pomiń menu (wymaga --profile) + --profile Profil konfiguracji (patrz niżej) + -h, --help Ta pomoc + +Profile (--non-interactive): + dual-eno1 eno1=.80+brama, enp3s0=.90 + dual-enp3s0 enp3s0=.80+brama, eno1=.90 + single-eno1-80 tylko eno1: .80+brama + single-eno1-both tylko eno1: .80+.90+brama + single-enp3s0-90 tylko enp3s0: .90+brama + single-enp3s0-80 tylko enp3s0: .80+brama + single-enp3s0-both tylko enp3s0: .80+.90+brama + +Interaktywnie skrypt skanuje kable i pokazuje tylko pasujące opcje. + +Rollback: sudo ./restore-dhcp-network.sh +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + --non-interactive) NON_INTERACTIVE=true; shift ;; + --profile) + if [[ $# -lt 2 ]]; then + echo "BŁĄD: --profile wymaga nazwy." >&2 + exit 1 + fi + PROFILE="$2" + shift 2 + ;; + -h|--help) usage; exit 0 ;; + *) echo "Nieznana opcja: $1" >&2; usage >&2; exit 1 ;; + esac +done + +if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + echo "BŁĄD: Uruchom skrypt jako root (sudo)." >&2 + exit 1 +fi + +if ! command -v netplan >/dev/null 2>&1; then + echo "BŁĄD: netplan nie jest zainstalowany." >&2 + exit 1 +fi + +for nic in "$NIC_A" "$NIC_B"; do + if ! ip link show "$nic" &>/dev/null; then + echo "BŁĄD: Interfejs $nic nie istnieje." >&2 + ip -br link || true + exit 1 + fi +done + +has_link() { + local nic="$1" + if command -v ethtool >/dev/null 2>&1; then + ethtool "$nic" 2>/dev/null | grep -q "Link detected: yes" + return $? + fi + ip link show "$nic" | grep -q "LOWER_UP" +} + +current_ipv4() { + local nic="$1" + ip -4 -br addr show "$nic" 2>/dev/null | awk '{print $3}' | head -1 +} + +# Zwraca: LINK_ENO1 LINK_ENP3S0 (yes/no) +scan_nics() { + LINK_ENO1="no" + LINK_ENP3S0="no" + has_link "$NIC_A" && LINK_ENO1="yes" + has_link "$NIC_B" && LINK_ENP3S0="yes" + + echo "=== Skan interfejsów ===" + printf " %-8s %-6s %-8s %s\n" "Interfejs" "Stan" "Link" "IPv4" + for nic in "$NIC_A" "$NIC_B"; do + local state link_flag ipv4 + state=$(ip -br link show "$nic" | awk '{print $2}') + if has_link "$nic"; then link_flag="TAK"; else link_flag="NIE"; fi + ipv4=$(current_ipv4 "$nic") + ipv4="${ipv4:-—}" + printf " %-8s %-6s %-8s %s\n" "$nic" "$state" "$link_flag" "$ipv4" + done + echo "" +} + +ips_has() { + local list="$1" + local which="$2" + [[ " $list " == *" $which "* ]] +} + +apply_profile() { + local key="$1" + PROFILE_NAME="$key" + case "$key" in + dual-eno1) + GW_NIC="$NIC_A"; ENO1_IPS="80"; ENP3S0_IPS="90" ;; + dual-enp3s0) + GW_NIC="$NIC_B"; ENO1_IPS="90"; ENP3S0_IPS="80" ;; + single-eno1-80) + GW_NIC="$NIC_A"; ENO1_IPS="80"; ENP3S0_IPS="" ;; + single-eno1-both) + GW_NIC="$NIC_A"; ENO1_IPS="80 90"; ENP3S0_IPS="" ;; + single-enp3s0-90) + GW_NIC="$NIC_B"; ENO1_IPS=""; ENP3S0_IPS="90" ;; + single-enp3s0-80) + GW_NIC="$NIC_B"; ENO1_IPS=""; ENP3S0_IPS="80" ;; + single-enp3s0-both) + GW_NIC="$NIC_B"; ENO1_IPS=""; ENP3S0_IPS="80 90" ;; + *) + echo "BŁĄD: Nieznany profil: $key" >&2 + return 1 + ;; + esac +} + +profile_description() { + local key="$1" + case "$key" in + dual-eno1) echo "eno1=.80+brama, enp3s0=.90" ;; + dual-enp3s0) echo "enp3s0=.80+brama, eno1=.90" ;; + single-eno1-80) echo "eno1=.80+brama (enp3s0 bez adresu)" ;; + single-eno1-both) echo "eno1=.80+.90+brama (oba IP na eno1)" ;; + single-enp3s0-90) echo "enp3s0=.90+brama (usługi LAN)" ;; + single-enp3s0-80) echo "enp3s0=.80+brama" ;; + single-enp3s0-both) echo "enp3s0=.80+.90+brama (oba IP na enp3s0)" ;; + *) echo "$key" ;; + esac +} + +# MENU_KEYS[], MENU_LABELS[] — numeracja od 1 +declare -a MENU_KEYS=() +declare -a MENU_LABELS=() + +build_menu() { + MENU_KEYS=() + MENU_LABELS=() + + if [[ "$LINK_ENO1" == "yes" && "$LINK_ENP3S0" == "yes" ]]; then + MENU_KEYS+=(dual-eno1 dual-enp3s0) + MENU_LABELS+=( + "Dual: eno1=.80+brama, enp3s0=.90 (zalecane)" + "Dual odwrócony: enp3s0=.80+brama, eno1=.90" + ) + elif [[ "$LINK_ENO1" == "yes" ]]; then + MENU_KEYS+=(single-eno1-80 single-eno1-both) + MENU_LABELS+=( + "Pojedynczy eno1: .80+brama" + "Pojedynczy eno1: .80+.90+brama (awaryjnie, oba IP na jednej karcie)" + ) + elif [[ "$LINK_ENP3S0" == "yes" ]]; then + MENU_KEYS+=(single-enp3s0-90 single-enp3s0-80 single-enp3s0-both) + MENU_LABELS+=( + "Pojedynczy enp3s0: .90+brama (zalecane — usługi Docker)" + "Pojedynczy enp3s0: .80+brama" + "Pojedynczy enp3s0: .80+.90+brama (oba IP na jednej karcie)" + ) + else + echo "BŁĄD: Żaden interfejs nie ma aktywnego linku (kabel sieciowy)." >&2 + echo " Podłącz kabel do eno1 lub enp3s0 i uruchom skrypt ponownie." >&2 + exit 1 + fi +} + +show_menu() { + echo "=== Wybierz konfigurację ===" + local i + for i in "${!MENU_KEYS[@]}"; do + printf " %d) %s\n" "$((i + 1))" "${MENU_LABELS[$i]}" + done + printf " 0) Anuluj\n" + echo "" +} + +pick_profile_interactive() { + build_menu + show_menu + + local choice + while true; do + read -r -p "Wybór [1-${#MENU_KEYS[@]}, domyślnie 1]: " choice + choice="${choice:-1}" + if [[ "$choice" == "0" ]]; then + echo "Anulowano." + exit 0 + fi + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#MENU_KEYS[@]} )); then + apply_profile "${MENU_KEYS[$((choice - 1))]}" + return 0 + fi + echo "Nieprawidłowy wybór." + done +} + +pick_profile() { + if [[ -n "$PROFILE" ]]; then + apply_profile "$PROFILE" + elif [[ "$NON_INTERACTIVE" == true ]]; then + echo "BŁĄD: --non-interactive wymaga --profile ." >&2 + exit 1 + else + pick_profile_interactive + fi +} + +build_nic_block() { + local nic="$1" + local ips="$2" + local is_gw="$3" + local has_cable="$4" + + local optional="true" + [[ "$is_gw" == "yes" ]] && optional="false" + + echo " ${nic}:" + echo " dhcp4: false" + echo " dhcp6: false" + echo " optional: ${optional}" + + if [[ -n "$ips" ]]; then + echo " ignore-carrier: true" + echo " addresses:" + ips_has "$ips" "80" && echo " - ${IP80}" + ips_has "$ips" "90" && echo " - ${IP90}" + fi + + if [[ "$is_gw" == "yes" ]]; then + echo " routes:" + echo " - to: default" + echo " via: ${GATEWAY}" + echo " metric: 100" + echo " nameservers:" + echo " addresses:" + echo " - ${DNS}" + fi +} + +write_netplan_file() { + local content="$1" + local tmp + tmp=$(mktemp) || { echo "BŁĄD: mktemp nie powiódł się." >&2; return 1; } + chmod 600 "$tmp" + printf '%s\n' "$content" > "$tmp" + mv -f "$tmp" "$NETPLAN_FILE" + chmod 600 "$NETPLAN_FILE" +} + +render_netplan() { + local eno1_gw="no" enp3_gw="no" + [[ "$GW_NIC" == "$NIC_A" ]] && eno1_gw="yes" + [[ "$GW_NIC" == "$NIC_B" ]] && enp3_gw="yes" + + { + echo "network:" + echo " version: 2" + echo " renderer: networkd" + echo " ethernets:" + build_nic_block "$NIC_A" "$ENO1_IPS" "$eno1_gw" "$LINK_ENO1" + build_nic_block "$NIC_B" "$ENP3S0_IPS" "$enp3_gw" "$LINK_ENP3S0" + } +} + +expected_ips_for_nic() { + local nic="$1" + local ips="" + if [[ "$nic" == "$NIC_A" ]]; then ips="$ENO1_IPS"; else ips="$ENP3S0_IPS"; fi + if ips_has "$ips" "80"; then echo "192.168.100.80"; fi + if ips_has "$ips" "90"; then echo "192.168.100.90"; fi +} + +verify_config() { + local ok=true + echo "=== Weryfikacja ===" + + for nic in "$NIC_A" "$NIC_B"; do + local ip + while read -r ip; do + [[ -z "$ip" ]] && continue + if ip -4 addr show dev "$nic" | grep -q "inet ${ip}/"; then + echo " ${nic}: ${ip} OK" + else + echo " ${nic}: ${ip} BRAK — próbuję networkctl reconfigure ..." + networkctl reconfigure "$nic" 2>/dev/null || true + sleep 2 + if ip -4 addr show dev "$nic" | grep -q "inet ${ip}/"; then + echo " ${nic}: ${ip} OK (po reconfigure)" + else + echo " ${nic}: ${ip} NADAL BRAK" + ok=false + fi + fi + done < <(expected_ips_for_nic "$nic") + done + + ip -br addr show "$NIC_A" "$NIC_B" 2>/dev/null || ip -br addr show "$NIC_A" "$NIC_B" + echo "--- trasy ---" + ip route show default || echo " (brak trasy domyślnej)" + + if ping -c 1 -W 3 -I "$GW_NIC" "$GATEWAY" &>/dev/null; then + echo " ping -I ${GW_NIC} ${GATEWAY}: OK" + else + echo " ping -I ${GW_NIC} ${GATEWAY}: NIEUDANY" + ok=false + fi + + if [[ "$ok" != true ]]; then + echo "" + echo "WERYFIKACJA NIEUDANA — sprawdź kable, rezerwację IP na routerze." + echo "Rollback: sudo $(dirname "$0")/restore-dhcp-network.sh" + return 1 + fi + return 0 +} + +wait_for_ips() { + local attempt + for attempt in $(seq 1 15); do + local missing=false + for nic in "$NIC_A" "$NIC_B"; do + local ip + while read -r ip; do + [[ -z "$ip" ]] && continue + ip -4 addr show dev "$nic" | grep -q "inet ${ip}/" || missing=true + done < <(expected_ips_for_nic "$nic") + done + [[ "$missing" == false ]] && return 0 + networkctl reconfigure "$NIC_A" 2>/dev/null || true + networkctl reconfigure "$NIC_B" 2>/dev/null || true + sleep 1 + done + return 1 +} + +# --- main --- +scan_nics +pick_profile + +STATIC_YAML=$(render_netplan) + +echo "=== Wybrany profil: ${PROFILE_NAME} ===" +echo " $(profile_description "$PROFILE_NAME")" +echo " Brama domyślna: ${GW_NIC}" +echo "" + +if [[ "$DRY_RUN" == true ]]; then + echo "=== DRY-RUN: docelowy Netplan ($NETPLAN_FILE) ===" + echo "$STATIC_YAML" + echo "" + echo "Dry-run: backup, zapis i netplan apply zostały pominięte." + exit 0 +fi + +echo "=== Backup Netplan ===" +if [[ -f "$NETPLAN_FILE" ]]; then + backup="${NETPLAN_FILE}.bak.$(date +%Y%m%d%H%M%S)" + cp -a "$NETPLAN_FILE" "$backup" + echo " Zapisano: $backup" +else + echo " Brak istniejącego $NETPLAN_FILE — backup pominięty." +fi + +echo "=== Zapis statycznej konfiguracji ===" +write_netplan_file "$STATIC_YAML" +echo " Zapisano: $NETPLAN_FILE" + +echo "=== netplan generate ===" +if ! netplan generate 2>&1; then + echo "" + echo "BŁĄD: netplan generate nie powiódł się. Przywróć backup lub uruchom restore-dhcp-network.sh" + exit 1 +fi + +echo "=== netplan apply ===" +netplan apply + +wait_for_ips || true +verify_config || exit 1 + +echo "" +echo "=== GOTOWE ===" +echo " Profil: ${PROFILE_NAME} — $(profile_description "$PROFILE_NAME")" +echo " Brama: ${GW_NIC} -> ${GATEWAY}" +[[ -n "$ENO1_IPS" ]] && echo " eno1: ${ENO1_IPS} -> 192.168.100.{${ENO1_IPS// /,}}" +[[ -n "$ENP3S0_IPS" ]] && echo " enp3s0: ${ENP3S0_IPS} -> 192.168.100.{${ENP3S0_IPS// /,}}" +echo "" +echo "SSH: sprawdź ip -br addr (adresy .80 / .90 na aktywnych kartach)" +echo "Rollback: sudo $(dirname "$0")/restore-dhcp-network.sh" diff --git a/scripts/restore-dhcp-network.sh b/scripts/restore-dhcp-network.sh new file mode 100755 index 0000000..66aee4a --- /dev/null +++ b/scripts/restore-dhcp-network.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# Przywrócenie automatycznego IP (DHCP) — GMKtec K11 (gmktec-k11) +# +# Kolejność: +# 1. Przywróć najnowszy *poprawny* backup /etc/netplan/00-installer-config.yaml.bak.* +# 2. Jeśli brak poprawnego backupu — szablon DHCP na eno1 i enp3s0 +# +# Wymaga: sudo +set -euo pipefail + +NETPLAN_FILE="/etc/netplan/00-installer-config.yaml" +NIC_PRIMARY="eno1" +NIC_LAN="enp3s0" + +DRY_RUN=false +FORCE_DHCP=false +BACKUP_FILE="" + +usage() { + cat <<'EOF' +Użycie: sudo ./restore-dhcp-network.sh [opcje] + +Opcje: + --dry-run Pokaż plan bez zapisu ani netplan apply + --force-dhcp Pomiń backupy — od razu szablon DHCP + --backup <ścieżka> Przywróć wskazany plik backupu (musi przejść netplan generate) + -h, --help Ta pomoc + +Po rollbacku sprawdź: ip -br addr +Sesja SSH może być pod starym adresem DHCP (np. 192.168.100.2). + +Ponowna konfiguracja statyczna (skan + menu): + sudo ./configure-static-dual-nic.sh +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + --force-dhcp) FORCE_DHCP=true; shift ;; + --backup) + if [[ $# -lt 2 ]]; then + echo "BŁĄD: --backup wymaga ścieżki." >&2 + exit 1 + fi + BACKUP_FILE="$2" + shift 2 + ;; + -h|--help) usage; exit 0 ;; + *) echo "Nieznana opcja: $1" >&2; usage >&2; exit 1 ;; + esac +done + +if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + echo "BŁĄD: Uruchom skrypt jako root (sudo)." >&2 + exit 1 +fi + +if ! command -v netplan >/dev/null 2>&1; then + echo "BŁĄD: netplan nie jest zainstalowany." >&2 + exit 1 +fi + +DHCP_YAML=$(cat <&2; return 1; } + chmod 600 "$tmp" + printf '%s\n' "$content" > "$tmp" + mv -f "$tmp" "$NETPLAN_FILE" + chmod 600 "$NETPLAN_FILE" +} + +netplan_file_valid() { + local file="$1" + local tmpdir + [[ -f "$file" ]] || return 1 + tmpdir=$(mktemp -d) || return 1 + mkdir -p "$tmpdir/etc/netplan" + cp "$file" "$tmpdir/etc/netplan/00-installer-config.yaml" + if netplan generate --root-dir "$tmpdir" &>/dev/null; then + rm -rf "$tmpdir" + return 0 + fi + rm -rf "$tmpdir" + return 1 +} + +find_valid_backup() { + local f + if [[ -n "$BACKUP_FILE" ]]; then + if netplan_file_valid "$BACKUP_FILE"; then + echo "$BACKUP_FILE" + return 0 + fi + echo "BŁĄD: Backup nie przechodzi netplan generate: $BACKUP_FILE" >&2 + return 1 + fi + + # Najpierw najnowsze poprawne; pomijaj zepsute backupy ze skryptu statycznego + while IFS= read -r f; do + [[ -z "$f" ]] && continue + if netplan_file_valid "$f"; then + echo "$f" + return 0 + fi + echo " Pomijam niepoprawny backup: $f" >&2 + done < <(ls -1t "${NETPLAN_FILE}.bak."* 2>/dev/null || true) + + return 1 +} + +echo "=== Przywracanie DHCP ===" + +source_file="" +action="dhcp-fallback" + +if [[ "$FORCE_DHCP" != true ]]; then + if backup_path=$(find_valid_backup); then + echo " Źródło: backup $backup_path" + source_file="$backup_path" + action="restore-backup" + else + echo " Brak poprawnego backupu — używam szablonu DHCP." + fi +else + echo " --force-dhcp: szablon DHCP (bez backupów)." +fi + +if [[ "$DRY_RUN" == true ]]; then + echo "=== DRY-RUN ===" + if [[ "$action" == "restore-backup" ]]; then + echo "Przywrócono by został plik z: $source_file" + echo "--- zawartość backupu ---" + cat "$source_file" + else + echo "Zapisano by został $NETPLAN_FILE:" + echo "$DHCP_YAML" + fi + echo "" + echo "Dry-run: netplan apply pominięty." + exit 0 +fi + +if [[ "$action" == "restore-backup" ]]; then + cp -a "$source_file" "$NETPLAN_FILE" + echo " Przywrócono: $NETPLAN_FILE <- $source_file" +else + write_netplan_file "$DHCP_YAML" + echo " Zapisano szablon DHCP: $NETPLAN_FILE" +fi + +echo "=== netplan generate ===" +if ! netplan generate 2>&1; then + echo "" + echo "BŁĄD: netplan generate nie powiódł się." + if [[ "$action" == "restore-backup" ]]; then + echo "Próbuję szablon DHCP..." + write_netplan_file "$DHCP_YAML" + netplan generate + else + exit 1 + fi +fi + +echo "=== netplan apply ===" +netplan apply + +sleep 2 + +echo "=== Weryfikacja ===" +ip -br addr show "$NIC_PRIMARY" "$NIC_LAN" 2>/dev/null || ip -br addr + +echo "" +echo "=== GOTOWE (DHCP) ===" +echo "Sprawdź aktualne adresy: ip -br addr" +echo "Połącz się ponownie SSH pod adresem DHCP (np. ip addr show enp3s0)." +echo "Aby ponownie ustawić statyczne IP: sudo $(dirname "$0")/configure-static-dual-nic.sh" diff --git a/scripts/setup-data-disk.sh b/scripts/setup-data-disk.sh new file mode 100755 index 0000000..a3ae18c --- /dev/null +++ b/scripts/setup-data-disk.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Setup 1TB data disk on GMKtec K11 +# nvme0n1 = 1TB data | nvme1n1 = 128GB system +set -euo pipefail + +DATA_DISK=/dev/nvme0n1 +DATA_PART=/dev/nvme0n1p1 +MOUNT_POINT=/data + +echo "=== Sprawdzam dyski ===" +lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT,MODEL + +# Safety: refuse if DATA_DISK has mountpoints (system disk) +if lsblk -n -o MOUNTPOINT "$DATA_DISK" | grep -q '/'; then + echo "BŁĄD: $DATA_DISK ma partycje zamontowane — przerwanie!" + exit 1 +fi + +# Create partition if missing +if ! lsblk -n "$DATA_PART" &>/dev/null; then + echo "=== Partycjonowanie $DATA_DISK ===" + parted -s "$DATA_DISK" mklabel gpt + parted -s "$DATA_DISK" mkpart primary ext4 0% 100% + partprobe "$DATA_DISK" + sleep 2 +fi + +# Format if no filesystem +if ! blkid -o value -s TYPE "$DATA_PART" 2>/dev/null | grep -q ext4; then + echo "=== Formatowanie $DATA_PART (ext4) ===" + mkfs.ext4 -F -L data1tb "$DATA_PART" +fi + +UUID=$(blkid -o value -s UUID "$DATA_PART") +echo "UUID=$UUID" + +# Mount point +mkdir -p "$MOUNT_POINT" +if ! mountpoint -q "$MOUNT_POINT"; then + echo "=== Montowanie $DATA_PART -> $MOUNT_POINT ===" + mount "$DATA_PART" "$MOUNT_POINT" +fi + +# fstab +FSTAB_LINE="UUID=${UUID} ${MOUNT_POINT} ext4 defaults,noatime 0 2" +if ! grep -q "$MOUNT_POINT" /etc/fstab; then + echo "=== Dodaję wpis do /etc/fstab ===" + cp /etc/fstab /etc/fstab.bak.$(date +%Y%m%d%H%M%S) + echo "$FSTAB_LINE" >> /etc/fstab +else + echo "=== fstab już zawiera $MOUNT_POINT ===" +fi + +mount -a +df -h "$MOUNT_POINT" + +# Directory structure +echo "=== Tworzę strukturę katalogów ===" +mkdir -p "$MOUNT_POINT/docker" +mkdir -p "$MOUNT_POINT/apps/vllm/huggingface" +mkdir -p "$MOUNT_POINT/apps/gguf/"{qwen3.6-27b,gemma-4-12b} +mkdir -p "$MOUNT_POINT/apps/localai/"{models,backends,configuration,images,data} +mkdir -p "$MOUNT_POINT/apps/comfyui/storage" +mkdir -p "$MOUNT_POINT/apps/comfyui/cache/"{hf-hub,torch-hub} +mkdir -p "$MOUNT_POINT/apps/comfyui/"{models,input,output,custom_nodes,workflows} +chown -R tomasz-syn-grzegorza:tomasz-syn-grzegorza "$MOUNT_POINT/apps" + +# Docker data-root migration +echo "=== Migracja Docker data-root ===" +systemctl stop docker docker.socket 2>/dev/null || true + +if [ -d /var/lib/docker ] && [ "$(ls -A /var/lib/docker 2>/dev/null)" ]; then + echo "Kopiuję /var/lib/docker -> $MOUNT_POINT/docker ..." + rsync -aHAX /var/lib/docker/ "$MOUNT_POINT/docker/" +fi + +mkdir -p "$MOUNT_POINT/docker" + +python3 <<'PYEOF' +import json +from pathlib import Path +p = Path("/etc/docker/daemon.json") +cfg = json.loads(p.read_text()) if p.exists() else {} +cfg["data-root"] = "/data/docker" +p.write_text(json.dumps(cfg, indent=2) + "\n") +print(p.read_text()) +PYEOF + +systemctl start docker +sleep 2 + +echo "=== Weryfikacja ===" +docker info | grep "Docker Root Dir" +df -h / "$MOUNT_POINT" +lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT,MODEL + +echo "=== Test Docker ===" +docker run --rm hello-world +docker run --rm --gpus all nvidia/cuda:12.6.0-base-ubuntu24.04 nvidia-smi + +echo "" +echo "=== GOTOWE ===" +echo "Dysk danych: $MOUNT_POINT ($UUID)" +echo "Docker Root: /data/docker" +echo "Modele vLLM: /data/apps/vllm/huggingface" +echo "LocalAI: /data/apps/localai/models" diff --git a/stacks/comfyui/.env.example b/stacks/comfyui/.env.example new file mode 100644 index 0000000..2fba3f0 --- /dev/null +++ b/stacks/comfyui/.env.example @@ -0,0 +1,14 @@ +# Data disk mount point +DATA_ROOT=/data + +# ComfyUI web UI (default ComfyUI port) +COMFYUI_PORT=8188 + +# yanwk/comfyui-boot — CUDA 12.6 slim (GPU in container) +COMFYUI_IMAGE=yanwk/comfyui-boot:cu126-slim + +# Use only the discrete NVIDIA GPU +CUDA_VISIBLE_DEVICES=0 + +# Extra CLI args passed to ComfyUI (e.g. --fast) +CLI_ARGS= diff --git a/stacks/comfyui/.gitignore b/stacks/comfyui/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/stacks/comfyui/.gitignore @@ -0,0 +1 @@ +.env diff --git a/stacks/comfyui/README.md b/stacks/comfyui/README.md new file mode 100644 index 0000000..d8c3e82 --- /dev/null +++ b/stacks/comfyui/README.md @@ -0,0 +1,134 @@ +# ComfyUI stack + +[ComfyUI](https://github.com/comfyanonymous/ComfyUI) — grafowy interfejs do generowania obrazów (Stable Diffusion, Flux, …). Stack oparty na obrazie [`yanwk/comfyui-boot`](https://github.com/YanWenKun/ComfyUI-Docker). + +Zamiast [Stability Matrix](https://github.com/LykosAI/StabilityMatrix) (GUI desktop) używamy ComfyUI w Dockerze — zgodnie z filozofią headless serwera i tutoriala [03b](../../manual-tutorial/03b-system-tools.md). + +## Porty + +| Serwis | Port | URL | +|--------|------|-----| +| ComfyUI web UI | **8188** | `http://HOST:8188` | +| LocalAI (osobny stack) | 8070 | LLM / chat — **nie równolegle z dużym modelem SD** | + +## Jak to działa + +```mermaid +flowchart LR + browser["Przeglądarka :8188"] + comfyui["Kontener comfyui"] + gpu["RTX 3090 Ti"] + models["/data/apps/comfyui/models"] + + browser --> comfyui + comfyui --> gpu + comfyui --> models +``` + +| Element | Opis | +|---------|------| +| Obraz | `yanwk/comfyui-boot:cu126-slim` | +| Konfiguracja | `.env` + `docker-compose.yml` | +| Modele | `/data/apps/comfyui/models` (puste na start — pobierz ręcznie lub przez ComfyUI-Manager) | +| Pierwszy start | Kopiuje ComfyUI do `/data/apps/comfyui/storage/` (~kilka minut) | + +## Struktura + +``` +stacks/comfyui/ +├── README.md +├── docker-compose.yml +├── .env.example +├── .gitignore +└── scripts/ + ├── ensure-dirs.sh + ├── pull.sh + └── start.sh +``` + +Na dysku `/data`: + +``` +/data/apps/comfyui/ +├── storage/ # kopia ComfyUI z obrazu (pierwszy start) +├── models/ # checkpoints, LoRA, VAE, … +├── cache/ +│ ├── hf-hub/ +│ └── torch-hub/ +├── input/ +├── output/ +├── custom_nodes/ +└── workflows/ +``` + +## Workflow (bez modelu) + +```bash +cd stacks/comfyui +cp .env.example .env + +./scripts/pull.sh +./scripts/start.sh +``` + +Weryfikacja: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8188/ +# UI: http://:8188 +``` + +## Zmienne `.env` + +| Zmienna | Opis | Domyślnie | +|---------|------|-----------| +| `DATA_ROOT` | Mount dysku danych | `/data` | +| `COMFYUI_PORT` | Port na hoście | `8188` | +| `COMFYUI_IMAGE` | Obraz Docker | `yanwk/comfyui-boot:cu126-slim` | +| `CUDA_VISIBLE_DEVICES` | GPU | `0` | +| `CLI_ARGS` | Dodatkowe flagi ComfyUI | puste | + +## Polityka GPU (LocalAI ↔ ComfyUI) + +RTX 3090 Ti 24 GB — **jeden** duży workload GPU naraz. + +Przed startem ComfyUI z dużym modelem (SDXL, Flux): + +```bash +cd ../localai +docker compose --profile localai stop localai +``` + +Skrypt `start.sh` ostrzega, gdy `localai` jest uruchomiony. + +W Server UI (port 8091) → Stop/Start stack `localai` lub `comfyui` według potrzeb. + +## Modele (później) + +- **ComfyUI-Manager** w UI (custom node w obrazie yanwk) — pobieranie modeli i węzłów +- Ręcznie: pliki do `/data/apps/comfyui/models/checkpoints/` (lub odpowiednie podkatalogi) + +Szacunki VRAM (przy zatrzymanym LocalAI): + +| Model | VRAM (orientacyjnie) | +|-------|----------------------| +| SD 1.5 | ~4–6 GB | +| SDXL | ~8–12 GB | +| Flux | ~12–20 GB | + +## Zarządzanie + +```bash +docker compose --profile comfyui ps +docker compose --profile comfyui logs -f comfyui +docker compose --profile comfyui restart comfyui +docker compose --profile comfyui down +``` + +## Dokumentacja + +- Tutorial: [manual-tutorial/07-comfyui-stack.md](../../manual-tutorial/07-comfyui-stack.md) +- Research Stability Matrix: [`coding-agent/STABILITYMATRIX-RESEARCH.md`](../../coding-agent/STABILITYMATRIX-RESEARCH.md) +- Wdrożenie: [`coding-agent/COMFYUI-DEPLOYMENT.md`](../../coding-agent/COMFYUI-DEPLOYMENT.md) + +Upstream: [github.com/comfyanonymous/ComfyUI](https://github.com/comfyanonymous/ComfyUI) diff --git a/stacks/comfyui/compose.yaml b/stacks/comfyui/compose.yaml new file mode 120000 index 0000000..412c257 --- /dev/null +++ b/stacks/comfyui/compose.yaml @@ -0,0 +1 @@ +docker-compose.yml \ No newline at end of file diff --git a/stacks/comfyui/docker-compose.yml b/stacks/comfyui/docker-compose.yml new file mode 100644 index 0000000..136a808 --- /dev/null +++ b/stacks/comfyui/docker-compose.yml @@ -0,0 +1,33 @@ +name: comfyui + +services: + comfyui: + image: ${COMFYUI_IMAGE:-yanwk/comfyui-boot:cu126-slim} + container_name: comfyui + profiles: + - comfyui + restart: unless-stopped + init: true + ports: + - "${COMFYUI_PORT:-8188}:8188" + environment: + - CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0} + - NVIDIA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0} + - NVIDIA_DRIVER_CAPABILITIES=compute,utility + - CLI_ARGS=${CLI_ARGS:-} + volumes: + - ${DATA_ROOT:-/data}/apps/comfyui/storage:/root + - ${DATA_ROOT:-/data}/apps/comfyui/models:/root/ComfyUI/models + - ${DATA_ROOT:-/data}/apps/comfyui/cache/hf-hub:/root/.cache/huggingface/hub + - ${DATA_ROOT:-/data}/apps/comfyui/cache/torch-hub:/root/.cache/torch/hub + - ${DATA_ROOT:-/data}/apps/comfyui/input:/root/ComfyUI/input + - ${DATA_ROOT:-/data}/apps/comfyui/output:/root/ComfyUI/output + - ${DATA_ROOT:-/data}/apps/comfyui/custom_nodes:/root/ComfyUI/custom_nodes + - ${DATA_ROOT:-/data}/apps/comfyui/workflows:/root/ComfyUI/user/default/workflows + gpus: all + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8188/"] + interval: 1m + timeout: 30s + retries: 5 + start_period: 3m diff --git a/stacks/comfyui/scripts/ensure-dirs.sh b/stacks/comfyui/scripts/ensure-dirs.sh new file mode 100755 index 0000000..422d91e --- /dev/null +++ b/stacks/comfyui/scripts/ensure-dirs.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Create ComfyUI data directories on the data disk. + +ensure_comfyui_dirs() { + local data_root="${1:-/data}" + mkdir -p \ + "${data_root}/apps/comfyui/storage" \ + "${data_root}/apps/comfyui/models" \ + "${data_root}/apps/comfyui/cache/hf-hub" \ + "${data_root}/apps/comfyui/cache/torch-hub" \ + "${data_root}/apps/comfyui/input" \ + "${data_root}/apps/comfyui/output" \ + "${data_root}/apps/comfyui/custom_nodes" \ + "${data_root}/apps/comfyui/workflows" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + ensure_comfyui_dirs "${1:-/data}" +fi diff --git a/stacks/comfyui/scripts/pull.sh b/stacks/comfyui/scripts/pull.sh new file mode 100755 index 0000000..25749f9 --- /dev/null +++ b/stacks/comfyui/scripts/pull.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${STACK_DIR}" + +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +docker compose --profile comfyui pull diff --git a/stacks/comfyui/scripts/start.sh b/stacks/comfyui/scripts/start.sh new file mode 100755 index 0000000..e3a34f8 --- /dev/null +++ b/stacks/comfyui/scripts/start.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/ensure-dirs.sh" + +cd "${STACK_DIR}" + +if [[ ! -f .env ]]; then + echo "ERROR: .env not found. Run: cp .env.example .env" + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +source .env +set +a + +DATA_ROOT="${DATA_ROOT:-/data}" + +if ! mountpoint -q "${DATA_ROOT}" 2>/dev/null; then + echo "ERROR: ${DATA_ROOT} is not mounted" + exit 1 +fi + +ensure_comfyui_dirs "${DATA_ROOT}" + +if ! docker info &>/dev/null; then + echo "ERROR: Docker is not running" + exit 1 +fi + +if docker ps --format '{{.Names}}' | grep -qx localai; then + echo "UWAGA: Kontener localai jest uruchomiony." + echo " Na RTX 3090 Ti 24 GB uruchom tylko jeden duży workload GPU." + echo " Zatrzymaj LocalAI przed generowaniem obrazów:" + echo " cd ../localai && docker compose --profile localai stop localai" + echo "" + read -r -p "Kontynuować mimo to? [y/N]: " confirm + if [[ "${confirm,,}" != "y" ]]; then + exit 1 + fi +fi + +echo "=== ComfyUI stack ===" +echo "Image: ${COMFYUI_IMAGE:-yanwk/comfyui-boot:cu126-slim}" +echo "Port: ${COMFYUI_PORT:-8188}" +echo "Models: ${DATA_ROOT}/apps/comfyui/models" +echo "GPU: CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}" +echo "" +echo "Pierwszy start kopiuje ComfyUI do ${DATA_ROOT}/apps/comfyui/storage/ (~kilka minut)." +echo "" + +docker compose --profile comfyui pull +docker compose --profile comfyui up -d + +echo "" +echo "Started. Follow logs:" +echo " docker compose --profile comfyui logs -f comfyui" +echo "" +echo "Web UI (po starcie):" +echo " http://localhost:${COMFYUI_PORT:-8188}" +echo "" +echo "Health:" +echo " curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:${COMFYUI_PORT:-8188}/" diff --git a/stacks/control-plane/.env.example b/stacks/control-plane/.env.example new file mode 100644 index 0000000..e178b49 --- /dev/null +++ b/stacks/control-plane/.env.example @@ -0,0 +1,34 @@ +# Unified control plane credentials (gpu-fan + Server UI) +# Production: /opt/control-plane/.env +# Dev: cp stacks/control-plane/.env.example stacks/control-plane/.env + +# --- Shared auth (Server UI panel + gpu-fan agent proxy) --- +API_KEY=change-me-generate-with-openssl-rand-hex-16 + +# --- Server UI --- +SERVER_UI_HOST=0.0.0.0 +SERVER_UI_PORT=8091 +REPO_ROOT=/home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal +DOCKER_GID=999 + +# GPU fan agent URL (Server UI proxy target) +GPU_FAN_AGENT_URL=http://127.0.0.1:18090 + +# --- gpu-fan agent (NVML daemon) --- +GPU_FAN_API_HOST=127.0.0.1 +GPU_FAN_API_PORT=18090 + +# Fan curve config (created by install.sh under /etc/gpu-fan/) +CURVE_PATH=/etc/gpu-fan/curve.json +POLL_INTERVAL=2.0 +GPU_INDEX=0 + +# --- File explorer (Server UI) --- +FILE_EXPLORER_ROOT=/ +FILE_EXPLORER_MAX_BYTES=2097152 + +# --- CLI terminal (Server UI) --- +CLI_ENABLED=1 +CLI_SHELL=/bin/bash +CLI_DEFAULT_CWD=/home/tomasz-syn-grzegorza +CLI_MAX_SESSIONS=5 diff --git a/stacks/control-plane/.gitignore b/stacks/control-plane/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/stacks/control-plane/.gitignore @@ -0,0 +1 @@ +.env diff --git a/stacks/control-plane/env_loader.py b/stacks/control-plane/env_loader.py new file mode 100644 index 0000000..4cd34cb --- /dev/null +++ b/stacks/control-plane/env_loader.py @@ -0,0 +1,127 @@ +"""Load unified control-plane environment from file + os.environ.""" + +from __future__ import annotations + +import os +from pathlib import Path + +_ENV_OVERRIDE_KEYS = ( + "API_KEY", + "SERVER_UI_HOST", + "SERVER_UI_PORT", + "REPO_ROOT", + "DOCKER_GID", + "GPU_FAN_AGENT_URL", + "GPU_FAN_API_HOST", + "GPU_FAN_API_PORT", + "GPU_FAN_HOST", + "GPU_FAN_PORT", + "CURVE_PATH", + "POLL_INTERVAL", + "GPU_INDEX", + "DRY_RUN", + "FILE_EXPLORER_ROOT", + "FILE_EXPLORER_MAX_BYTES", + "CLI_ENABLED", + "CLI_SHELL", + "CLI_DEFAULT_CWD", + "CLI_MAX_SESSIONS", +) + + +def _parse_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.is_file(): + return values + try: + text = path.read_text(encoding="utf-8") + except OSError: + # e.g. /opt/control-plane/.env is root-only; systemd still injects via os.environ + return values + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + values[key.strip()] = val.strip() + return values + + +def _merge_into(target: dict[str, str], source: dict[str, str]) -> None: + for key, val in source.items(): + if val: + target[key] = val + + +def _is_production_stack_dir(stack_dir: Path) -> bool: + try: + return stack_dir.resolve().parts[1:2] == ("opt",) + except IndexError: + return False + + +def control_plane_env_paths(stack_dir: Path) -> list[Path]: + """Candidate .env files in load order (earlier = lower priority among files).""" + paths: list[Path] = [] + custom = os.environ.get("CONTROL_PLANE_ENV", "").strip() + if custom: + paths.append(Path(custom)) + + if _is_production_stack_dir(stack_dir): + # Production (/opt/server-ui, /opt/gpu-fan): single canonical file only. + prod = Path("/opt/control-plane/.env") + if prod not in paths: + paths.append(prod) + docker_repo = Path("/repo/stacks/control-plane/.env") + if docker_repo.is_file() and docker_repo not in paths: + paths.append(docker_repo) + return paths + + # Dev (repo stacks/*): stacks/control-plane/.env only — no legacy per-service .env. + repo_control_plane = stack_dir.parent / "control-plane" / ".env" + if repo_control_plane.is_file() and repo_control_plane not in paths: + paths.append(repo_control_plane) + docker_repo = Path("/repo/stacks/control-plane/.env") + if docker_repo.is_file() and docker_repo not in paths: + paths.append(docker_repo) + return paths + + +def api_key_source(stack_dir: Path, values: dict[str, str]) -> str: + """Human-readable hint for logs (no secret values).""" + if os.environ.get("API_KEY"): + return "systemd/os.environ" + for path in control_plane_env_paths(stack_dir): + parsed = _parse_env_file(path) + if parsed.get("API_KEY"): + return str(path) + if values.get("API_KEY"): + return "merged env" + return "not configured" + + +def load_control_plane_env(stack_dir: Path) -> dict[str, str]: + """Merge env files then apply os.environ (systemd EnvironmentFile wins).""" + values: dict[str, str] = {} + for path in control_plane_env_paths(stack_dir): + _merge_into(values, _parse_env_file(path)) + + for key in _ENV_OVERRIDE_KEYS: + if key in os.environ: + values[key] = os.environ[key] + + values.setdefault("GPU_FAN_AGENT_URL", "http://127.0.0.1:18090") + return values + + +def ensure_control_plane_import_path() -> None: + """Add /opt/control-plane to sys.path for production imports.""" + import sys + + opt = "/opt/control-plane" + if opt not in sys.path: + sys.path.insert(0, opt) + repo = Path(__file__).resolve().parent + repo_str = str(repo) + if repo_str not in sys.path: + sys.path.insert(0, repo_str) diff --git a/stacks/gpu-fan/.env.example b/stacks/gpu-fan/.env.example new file mode 100644 index 0000000..107c517 --- /dev/null +++ b/stacks/gpu-fan/.env.example @@ -0,0 +1,2 @@ +# DEPRECATED — use stacks/control-plane/.env.example +# Copy: cp ../control-plane/.env.example ../control-plane/.env diff --git a/stacks/gpu-fan/.gitignore b/stacks/gpu-fan/.gitignore new file mode 100644 index 0000000..69cac2e --- /dev/null +++ b/stacks/gpu-fan/.gitignore @@ -0,0 +1,4 @@ +.env +.venv/ +__pycache__/ +*.pyc diff --git a/stacks/gpu-fan/README.md b/stacks/gpu-fan/README.md new file mode 100644 index 0000000..00b8d42 --- /dev/null +++ b/stacks/gpu-fan/README.md @@ -0,0 +1,154 @@ +# GPU Fan Control stack + +Sterowanie wentylatorami **RTX 3090 Ti** na headless Ubuntu przez NVML — bez `nvidia-settings` i bez GUI. + +**Panel webowy** jest w **Server UI** (`http://:8091` → zakładka **GPU Fan**). Ten stack uruchamia tylko **agenta API** na localhost. + +## Porty + +| Serwis | Port | Dostęp | +|--------|------|--------| +| GPU Fan agent API | **18090** | `127.0.0.1` tylko (systemd, root) | +| GPU Fan UI | — | Server UI **:8091** (zakładka GPU Fan) | + +Port **8090** nie jest już używany w produkcji. + +## Jak to działa + +```mermaid +flowchart LR + browser["Przeglądarka :8091"] + serverUI["server-ui"] + agent["fan_daemon.py :18090"] + nvml["NVML / pynvml"] + gpu["RTX 3090 Ti"] + + browser --> serverUI + serverUI -->|"proxy /api/gpu-fan"| agent + agent --> nvml + nvml --> gpu +``` + +| Element | Opis | +|---------|------| +| Agent | `fan_daemon.py` — pętla NVML + API REST | +| Dev UI | `app.py` — monolit z UI (tylko dev / DRY_RUN) | +| Logika | `fan_controller.py` — interpolacja krzywej | +| Konfiguracja | `/etc/gpu-fan/curve.json` | +| systemd | `gpu-fan.service` (root, auto-restart) | + +## Wymagania + +- Sterownik NVIDIA ≥ 520 (testowane: **595-server-open**) +- Root / sudo (zapis NVML wymaga uprawnień root) +- Python 3 + venv (instalowane przez `install.sh`) +- **Server UI** na :8091 (proxy do agenta) + +## Szybki start + +```bash +cd stacks/gpu-fan +cp ../control-plane/.env.example ../control-plane/.env + +sudo scripts/install.sh +sudo systemctl start gpu-fan + +cd ../server-ui +sudo scripts/install.sh +``` + +Panel: `http://:8091` → zakładka **GPU Fan**. + +Po zmianach w kodzie: + +```bash +sudo scripts/install.sh && sudo systemctl restart gpu-fan +cd ../server-ui && sudo scripts/install.sh && sudo systemctl restart server-ui +``` + +Konfiguracja: produkcja `/opt/control-plane/.env`, dev `stacks/control-plane/.env`. + +### Klucz API + +Jeden wspólny `API_KEY` w `/opt/control-plane/.env` — auth panelu Server UI i agenta gpu-fan (proxy `/api/gpu-fan/*`). + +W panelu Server UI wpisz ten sam klucz w polu **API Key** (lub `?api_key=...` w URL). + +## Preset max cooling + +Domyślna krzywa w [`curve.default.json`](curve.default.json): + +| Temp | Speed | +|------|-------| +| 30°C | 50% | +| 40°C | 65% | +| 50°C | 80% | +| 55°C | 90% | +| 60°C | 100% | +| 70°C+ | 100% | + +## Tryby + +| Tryb | Opis | +|------|------| +| `curve` | Krzywa z JSON — interpolacja liniowa | +| `manual` | Stała prędkość (np. 100% awaryjnie) | +| `auto` | Oddaje sterowanie driverowi NVIDIA | + +## API agenta (localhost :18090) + +| Endpoint | Metoda | Opis | +|----------|--------|------| +| `/api/status` | GET | Temperatura, wentylatory, moc, tryb | +| `/api/curve` | GET/PUT | Odczyt / zapis krzywej | +| `/api/mode` | POST | `{"mode":"auto\|curve\|manual","speed":100}` | +| `/api/reload` | POST | Przeładuj `curve.json` (jak `SIGHUP`) | + +Z LAN używaj proxy Server UI: `/api/gpu-fan/status`, `/api/gpu-fan/curve`, itd. + +## Ograniczenia NVIDIA + +- `nvidia-smi` **nie steruje** wentylatorami — tylko odczyt +- API akceptuje **0%** (= auto) lub **30–100%** +- Po `systemctl stop gpu-fan` wentylatory wracają do trybu auto drivera + +## Struktura + +``` +stacks/gpu-fan/ +├── README.md +├── fan_daemon.py # produkcja — agent API +├── app.py # dev — UI + API (opcjonalnie) +├── fan_controller.py +├── curve.default.json +├── requirements.txt +├── gpu-fan.service +├── .env.example +├── static/index.html # referencja UI (wbudowane w server-ui) +└── scripts/ + ├── install.sh + ├── enable-lan.sh # konfiguruje agent + wskazówka :8091 + └── status.sh +``` + +## Troubleshooting + +**`Insufficient Permissions`** — uruchom jako root (`sudo systemctl start gpu-fan`). + +**Panel GPU Fan pusty / 502** — sprawdź agent: `curl -s http://127.0.0.1:18090/api/status -H "X-API-Key: ..."` + +**Port 18090 zajęty** — `scripts/status.sh` / `sudo scripts/status.sh --cleanup` + +**Przeładuj krzywą bez restartu:** + +```bash +sudo systemctl reload gpu-fan +``` + +## Dokumentacja + +| Dokument | Opis | +|----------|------| +| [docs/00-START-TUTAJ.md](docs/00-START-TUTAJ.md) | Mapa — zacznij tutaj | +| [docs/02-OTWIERANIE-UI-W-PRZEGLADARCE.md](docs/02-OTWIERANIE-UI-W-PRZEGLADARCE.md) | Panel w Server UI :8091 | +| [docs/05-DLACZEGO-NIE-DOCKER.md](docs/05-DLACZEGO-NIE-DOCKER.md) | Dlaczego host, nie Docker | diff --git a/stacks/gpu-fan/app.py b/stacks/gpu-fan/app.py new file mode 100644 index 0000000..a2992ee --- /dev/null +++ b/stacks/gpu-fan/app.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""GPU fan control web UI + NVML daemon (single process).""" + +from __future__ import annotations + +import logging +import os +import signal +import sys +import threading +from pathlib import Path +from typing import Any + +import uvicorn +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +from fan_controller import ( + FanControlError, + FanController, + MAX_CURVE_POINTS, + MIN_CURVE_POINTS, + MIN_FAN_SPEED, + MAX_FAN_SPEED, + curve_to_dict, + parse_curve, +) + +STACK_DIR = Path(__file__).resolve().parent + +# Unified control-plane env (see stacks/control-plane/env_loader.py) +for _cp in ("/opt/control-plane", "/repo/stacks/control-plane", str(STACK_DIR.parent / "control-plane")): + if _cp not in sys.path and Path(_cp).exists(): + sys.path.insert(0, _cp) +from env_loader import load_control_plane_env # noqa: E402 + +STATIC_DIR = STACK_DIR / "static" + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +log = logging.getLogger(__name__) + + +def _is_localhost_bind(host: str) -> bool: + return host.strip().lower() in ("127.0.0.1", "localhost", "::1") + + +def _resolve_host_port(env: dict[str, str]) -> tuple[str, int]: + host = env.get("GPU_FAN_API_HOST") or env.get("GPU_FAN_HOST", "0.0.0.0") + port_raw = env.get("GPU_FAN_API_PORT") or env.get("GPU_FAN_PORT", "8090") + return host, int(port_raw) + + +ENV = load_control_plane_env(STACK_DIR) +HOST, PORT = _resolve_host_port(ENV) +API_KEY = ENV.get("API_KEY", "") +CURVE_PATH = Path(ENV.get("CURVE_PATH", "/etc/gpu-fan/curve.json")) +POLL_INTERVAL = float(ENV.get("POLL_INTERVAL", "2.0")) +GPU_INDEX = int(ENV.get("GPU_INDEX", "0")) +DRY_RUN = ENV.get("DRY_RUN", "").lower() in ("1", "true", "yes") + +controller = FanController( + curve_path=CURVE_PATH, + gpu_index=GPU_INDEX, + poll_interval=POLL_INTERVAL, +) +controller.dry_run = DRY_RUN +app = FastAPI(title="GPU Fan Control", version="1.0.0") + + +class CurvePoint(BaseModel): + temp: int = Field(ge=0, le=120) + speed: int = Field(ge=0, le=100) + + +class CurveUpdate(BaseModel): + points: list[CurvePoint] = Field(min_length=MIN_CURVE_POINTS, max_length=MAX_CURVE_POINTS) + + +class ModeUpdate(BaseModel): + mode: str + speed: int | None = Field(default=None, ge=MIN_FAN_SPEED, le=MAX_FAN_SPEED) + + +def require_auth(request: Request) -> None: + if not API_KEY: + return + key = request.headers.get("X-API-Key", "") + if key != API_KEY: + raise HTTPException(status_code=401, detail="Invalid or missing API key") + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +@app.get("/api/status") +def api_status(_: None = Depends(require_auth)) -> dict[str, Any]: + return controller.get_metrics() + + +@app.get("/api/curve") +def api_get_curve(_: None = Depends(require_auth)) -> dict[str, Any]: + points = controller.get_curve() + return {"points": [{"temp": t, "speed": s} for t, s in points]} + + +@app.put("/api/curve") +def api_put_curve(body: CurveUpdate, _: None = Depends(require_auth)) -> dict[str, Any]: + points = [(p.temp, p.speed) for p in body.points] + try: + parse_curve(curve_to_dict(points)) + controller.save_curve_file(points) + controller.set_mode("curve") + except FanControlError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, "curve": curve_to_dict(controller.get_curve())} + + +@app.post("/api/mode") +def api_set_mode(body: ModeUpdate, _: None = Depends(require_auth)) -> dict[str, Any]: + try: + controller.set_mode(body.mode, body.speed) + if body.mode != "auto": + controller.update_once() + except FanControlError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, "mode": controller.mode, "manual_speed": controller.manual_speed} + + +@app.post("/api/reload") +def api_reload(_: None = Depends(require_auth)) -> dict[str, Any]: + try: + controller.reload_curve() + except FanControlError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, "curve": curve_to_dict(controller.get_curve())} + + +def run_daemon_thread() -> None: + controller.run_loop() + + +def main() -> None: + if os.geteuid() != 0 and not DRY_RUN: + log.error("GPU fan control requires root (NVML write access). Run with sudo.") + sys.exit(1) + + if not _is_localhost_bind(HOST) and not API_KEY: + log.error( + "API_KEY is required when GPU_FAN_HOST=%s (LAN/public bind). " + "Set API_KEY in .env", + HOST, + ) + sys.exit(1) + + controller.init_nvml() + + def shutdown_handler(signum: int, _frame: object) -> None: + log.info("Received signal %s", signum) + if signum == signal.SIGHUP: + try: + controller.reload_curve() + except FanControlError as exc: + log.error("Curve reload failed: %s", exc) + return + controller.shutdown() + sys.exit(0) + + signal.signal(signal.SIGTERM, shutdown_handler) + signal.signal(signal.SIGINT, shutdown_handler) + signal.signal(signal.SIGHUP, shutdown_handler) + + thread = threading.Thread(target=run_daemon_thread, daemon=True) + thread.start() + + if _is_localhost_bind(HOST): + log.info("Web UI at http://127.0.0.1:%d", PORT) + else: + log.info("Web UI listening on 0.0.0.0:%d (LAN — API key required)", PORT) + uvicorn.run(app, host=HOST, port=PORT, log_level="info") + + +if __name__ == "__main__": + if os.environ.get("GPU_FAN_LEGACY_UI", "").lower() in ("1", "true", "yes"): + main() + else: + from fan_daemon import main as daemon_main + + daemon_main() diff --git a/stacks/gpu-fan/coding-agent/DOCKER-VS-HOST-REPORT.md b/stacks/gpu-fan/coding-agent/DOCKER-VS-HOST-REPORT.md new file mode 100644 index 0000000..251bf4f --- /dev/null +++ b/stacks/gpu-fan/coding-agent/DOCKER-VS-HOST-REPORT.md @@ -0,0 +1,228 @@ +# GPU Fan — Docker vs host (raport techniczny) + +**Data:** 2026-07-04 +**Stack:** `stacks/gpu-fan/` +**Host docelowy:** gmktec-k11, RTX 3090 Ti, Ubuntu headless + +--- + +## 1. Executive summary + +**GPU Fan musi działać na hoście jako usługa systemd (root), nie w Dockerze.** + +Aplikacja steruje wentylatorami karty NVIDIA przez zapis do NVML (`nvmlDeviceSetFanSpeed_v2`, `nvmlDeviceSetFanControlPolicy`). Na kartach GeForce wymaga to uprawnień root i bezpośredniego dostępu do sterownika hosta. Repo nie zawiera Dockerfile ani compose dla tego stacku — jedyny wspierany model to `sudo scripts/install.sh` → `/opt/gpu-fan` + `gpu-fan.service`. + +Docker jest teoretycznie możliwy (privileged container, host network, mount `/dev/nvidia*`), ale kruchy, nieutrzymywany i niezgodny z architekturą ubuntu-bare-metal (gpu-fan jako daemon sprzętowy obok workloadów AI w kontenerach). + +--- + +## 2. Co robi aplikacja + +| Komponent | Plik | Rola | +|-----------|------|------| +| Pętla sterowania | `fan_controller.py` | Odczyt temp/mocy, interpolacja krzywej, zapis prędkości wentylatorów | +| Web UI + API | `app.py` | FastAPI na porcie **8090**, wątek daemon NVML | +| UI statyczne | `static/index.html` | Wykres krzywej, status live, edycja trybu | +| Krzywa | `/etc/gpu-fan/curve.json` | Mapowanie temp °C → speed % | + +### Tryby pracy + +| Tryb | Zachowanie | +|------|------------| +| `curve` | Prędkość z krzywej JSON (interpolacja liniowa, 3–7 punktów) | +| `manual` | Stała prędkość 30–100% | +| `auto` | Przywraca politykę drivera NVIDIA (`NVML_FAN_POLICY_TEMPERATURE_CONTINOUS_SW`) | + +### API (port 8090) + +| Endpoint | Metoda | Uwagi | +|----------|--------|-------| +| `/` | GET | Web UI | +| `/api/status` | GET | Metryki GPU + tryb | +| `/api/curve` | GET/PUT | Odczyt/zapis krzywej | +| `/api/mode` | POST | Zmiana trybu | +| `/api/reload` | POST | Przeładowanie `curve.json` (jak SIGHUP) | + +Nagłówek `X-API-Key` wymagany gdy `GPU_FAN_HOST` ≠ localhost (domyślnie LAN bind `0.0.0.0`). + +### Shutdown + +Przy `SIGTERM` / `SIGINT` kontroler wywołuje `_restore_auto_policy()` przed `nvmlShutdown()` — wentylatory nie zostają w trybie manual po zatrzymaniu usługi. + +--- + +## 3. Zależności sprzętowe i software + +| Zależność | Wymagana | Uwagi | +|-----------|----------|-------| +| NVIDIA driver ≥ 520 | Tak | Testowane: 595-server-open | +| `nvidia-ml-py` (pynvml) | Tak | Jedyny interfejs sterowania w kodzie | +| `nvidia-smi` | Nie w kodzie | Tylko weryfikacja w dokumentacji; **nie ustawia** wentylatorów | +| Root (euid 0) | Tak | `app.py` kończy się błędem bez root (chyba że `DRY_RUN=true`) | +| `nvidia-persistenced` | Zalecane | `gpu-fan.service` After=/Wants= | +| IPMI | Nie | Brak referencji w kodzie | +| D-Bus | Nie | Brak referencji | +| X11 / nvidia-settings | Nie | Headless — celowo unikane | +| Python 3 + venv | Tak | FastAPI, uvicorn | + +### Ścieżki produkcyjne + +| Ścieżka | Zawartość | +|---------|-----------| +| `/opt/gpu-fan/` | Kod aplikacji (rsync z repo przez `install.sh`) | +| `/opt/control-plane/.env` | `API_KEY`, `GPU_FAN_API_*`, `CURVE_PATH`, … | +| `/etc/gpu-fan/curve.json` | Krzywa temp → speed | +| `/etc/systemd/system/gpu-fan.service` | Unit systemd | + +**Uwaga:** `stacks/control-plane/.env` w repo ≠ `/opt/control-plane/.env` — `setup-control-plane-env.sh` migruje i synchronizuje. + +--- + +## 4. Obecny model wdrożenia + +``` +repo stacks/gpu-fan/ + │ + │ sudo scripts/install.sh + ▼ +/opt/gpu-fan/ ← kod + .venv + .env +/etc/gpu-fan/curve.json +/etc/systemd/system/gpu-fan.service + │ + │ systemctl enable --now gpu-fan + ▼ +Proces root: python app.py + ├── wątek: fan_controller.run_loop() (co POLL_INTERVAL s) + └── uvicorn: 0.0.0.0:8090 +``` + +Skrypty pomocnicze: + +| Skrypt | Cel | +|--------|-----| +| `scripts/install.sh` | Instalacja produkcyjna | +| `scripts/enable-lan.sh` | `GPU_FAN_HOST=0.0.0.0`, API_KEY, restart | +| `scripts/start.sh` | Foreground debug (wymaga stop systemd) | +| `scripts/status.sh` | Diagnostyka portu/procesu | +| `scripts/self-test.sh` | Test krzywej, NVML read, API dry-run | + +--- + +## 5. Analiza Docker — dlaczego nie + +### Brak artefaktów w repo + +- Brak `Dockerfile`, `compose.yaml`, profilu w `server-ui/stacks.yaml` +- Inne stacki GPU (ComfyUI, LocalAI, vLLM) używają Docker; gpu-fan jest wyjątkiem celowym + +### Blokery techniczne + +| Bloker | Szczegóły | +|--------|-----------| +| NVML write na GeForce | `nvmlDeviceSetFanSpeed_v2` wymaga root; kontenery GPU (`NVIDIA_DRIVER_CAPABILITIES=compute,utility`) nie gwarantują zapisu fan policy | +| Coupling do host driver | Wersja NVML w kontenerze musi pasować do kernel drivera hosta | +| Lifecycle | `docker kill` / crash kontenera może pominąć `_restore_auto_policy()` → wentylatory w manual | +| `nvidia-persistenced` | Daemon na hoście; kontener nie zarządza persystencją GPU | +| Privileged + host network | Minimalny „Docker” wyglądałby jak host install z dodatkową warstwą — bez korzyści | + +### Hipotetyczny kontener (nie implementować) + +Gdyby ktoś eksperymentował: + +```yaml +# NIE WDRAŻAĆ — tylko dokumentacja ryzyka +privileged: true +network_mode: host +user: root +pid: host # opcjonalnie, nadal ryzykowne +volumes: + - /etc/gpu-fan:/etc/gpu-fan +devices: + - /dev/nvidia0 + - /dev/nvidiactl + - /dev/nvidia-uvm +``` + +Nawet wtedy sukces nie jest gwarantowany na RTX 3090 Ti; repo nie będzie tego utrzymywać. + +--- + +## 6. Współistnienie z Docker AI stacks + +``` +┌─────────────────────────────────────────────────┐ +│ Host (gmktec-k11) │ +│ │ +│ gpu-fan.service (root, :8090) ──NVML──► GPU │ +│ │ +│ ┌─────────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ comfyui │ │ localai │ │ vllm │ │ +│ │ :8188 │ │ :8070 │ │ :8000 │ │ +│ └─────────────┘ └──────────┘ └──────────┘ │ +│ Docker containers (GPU compute) │ +└─────────────────────────────────────────────────┘ +``` + +- ComfyUI/LocalAI obciążają GPU → temperatura rośnie → gpu-fan reaguje krzywą +- Zatrzymaj gpu-fan **nie** jest wymagane przed startem kontenerów AI +- Polityka GPU w Server UI (jeden duży workload) dotyczy LLM/SD, nie gpu-fan +- Firewall (NPMPlus): port 8090 nie powinien być publiczny; LAN + API key + +Źródło: `manual-tutorial/06-gpu-fan-control.md` — „gpu-fan.service na hoście (NVML, root)”. + +--- + +## 7. Checklist operacyjny (coding-agent) + +### Instalacja / upgrade + +- [ ] `nvidia-smi` działa +- [ ] `sudo scripts/install.sh` z katalogu `stacks/gpu-fan` +- [ ] `sudo scripts/enable-lan.sh` jeśli dostęp z LAN +- [ ] `sudo systemctl enable --now gpu-fan` +- [ ] `curl -s http://127.0.0.1:18090/api/status -H "X-API-Key: $(grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2)"` → JSON z `temperature_c` + +### Po zmianie kodu + +```bash +sudo scripts/install.sh && sudo systemctl restart gpu-fan +``` + +### Diagnostyka + +```bash +systemctl status gpu-fan +journalctl -u gpu-fan -f +scripts/status.sh +sudo scripts/status.sh --cleanup # tylko gdy port zajęty przez osierocony proces +``` + +### Czego nie robić + +- Nie uruchamiać `start.sh` i systemd jednocześnie (port 8090) +- Nie pakować gpu-fan do Docker bez nowego ADR i testów na sprzęcie +- Nie edytować tylko `stacks/control-plane/.env` — produkcja czyta `/opt/control-plane/.env` + +--- + +## 8. Rekomendacja + +| Decyzja | Uzasadnienie | +|---------|--------------| +| **Zostaw na hoście (systemd)** | Wspierane, przetestowane, bezpieczny shutdown, zgodne z tutorial 06 | +| **Nie dodawaj Docker** | Brak wartości, wysokie ryzyko, duplikacja root access | +| **Dokumentacja użytkownika** | `docs/` — kroki instalacji i troubleshooting | +| **Ten raport** | `coding-agent/DOCKER-VS-HOST-REPORT.md` — odniesienie dla agentów | + +--- + +## 9. Pliki źródłowe (indeks) + +| Plik | Kluczowe fragmenty | +|------|-------------------| +| `app.py:161-164` | Wymóg root | +| `fan_controller.py:250-279` | NVML fan policy + speed write | +| `fan_controller.py:335-343` | Shutdown → auto policy | +| `gpu-fan.service` | User=root, After=nvidia-persistenced | +| `scripts/install.sh` | rsync → /opt/gpu-fan | +| `requirements.txt` | fastapi, uvicorn, nvidia-ml-py | diff --git a/stacks/gpu-fan/config/curve.json b/stacks/gpu-fan/config/curve.json new file mode 100644 index 0000000..08cb1b0 --- /dev/null +++ b/stacks/gpu-fan/config/curve.json @@ -0,0 +1,8 @@ +{ + "30": 50, + "40": 65, + "50": 80, + "55": 90, + "60": 100, + "70": 100 +} diff --git a/stacks/gpu-fan/curve.default.json b/stacks/gpu-fan/curve.default.json new file mode 100644 index 0000000..08cb1b0 --- /dev/null +++ b/stacks/gpu-fan/curve.default.json @@ -0,0 +1,8 @@ +{ + "30": 50, + "40": 65, + "50": 80, + "55": 90, + "60": 100, + "70": 100 +} diff --git a/stacks/gpu-fan/docs/00-START-TUTAJ.md b/stacks/gpu-fan/docs/00-START-TUTAJ.md new file mode 100644 index 0000000..d71c031 --- /dev/null +++ b/stacks/gpu-fan/docs/00-START-TUTAJ.md @@ -0,0 +1,56 @@ +# GPU Fan — START TUTAJ + +**Cel:** Wiedzieć od czego zacząć i czy ta aplikacja idzie do Dockera. +**Czas:** 2 minuty czytania +**Wymagania:** Brak (to tylko mapa dokumentacji) + +--- + +## Jednozdaniowa odpowiedź + +**GPU Fan NIE działa w Dockerze — instalujesz go na hoście (systemd), raz, i zapominasz.** + +Steruje wentylatorami karty graficznej. Reszta (ComfyUI, LocalAI) może być w Dockerze — to osobne programy. + +--- + +## Mapa dokumentacji + +| Plik | Kiedy czytać | +|------|----------------| +| [01-INSTALACJA-KROK-PO-KROKU.md](01-INSTALACJA-KROK-PO-KROKU.md) | Pierwsza instalacja na serwerze | +| [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md) | Chcesz otworzyć panel w przeglądarce | +| [03-KRZYWa-I-TRYBY.md](03-KRZYWa-I-TRYBY.md) | Chcesz zmienić chłodzenie / tryb wentylatorów | +| [04-CZESTE-BLEDY.md](04-CZESTE-BLEDY.md) | Coś nie działa | +| [05-DLACZEGO-NIE-DOCKER.md](05-DLACZEGO-NIE-DOCKER.md) | „A czemu nie w kontenerze?” | + +Dla agentów AI / deweloperów: [../coding-agent/DOCKER-VS-HOST-REPORT.md](../coding-agent/DOCKER-VS-HOST-REPORT.md) + +--- + +## Szybka ścieżka (jeśli się spieszysz) + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan +nvidia-smi # musi pokazać kartę — jeśli nie, najpierw napraw driver +sudo scripts/install.sh +sudo scripts/enable-lan.sh +sudo systemctl start gpu-fan +sudo systemctl status gpu-fan +``` + +Potem: [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md) + +--- + +## Co zobaczysz gdy działa + +- `systemctl status gpu-fan` → **active (running)** +- W przeglądarce: panel z temperaturą GPU i wykresem krzywej (port **8090**) +- Wentylatory reagują na temperaturę (w trybie **curve**) + +--- + +## Co zrobić gdy nie działa + +→ [04-CZESTE-BLEDY.md](04-CZESTE-BLEDY.md) diff --git a/stacks/gpu-fan/docs/01-INSTALACJA-KROK-PO-KROKU.md b/stacks/gpu-fan/docs/01-INSTALACJA-KROK-PO-KROKU.md new file mode 100644 index 0000000..e122935 --- /dev/null +++ b/stacks/gpu-fan/docs/01-INSTALACJA-KROK-PO-KROKU.md @@ -0,0 +1,127 @@ +# Instalacja GPU Fan — krok po kroku + +**Cel:** Zainstalować sterowanie wentylatorami GPU na serwerze. +**Czas:** ~10 minut +**Wymagania:** `sudo`, działający `nvidia-smi`, internet (pip) + +--- + +## Krok 0 — Sprawdź kartę graficzną + +Na serwerze wpisz: + +```bash +nvidia-smi +``` + +**Co zobaczysz gdy OK:** tabela z nazwą karty (np. RTX 3090 Ti), driverem, temperaturą. + +**Gdy nie działa:** najpierw napraw sterownik NVIDIA. Bez tego GPU Fan nie ma sensu. + +--- + +## Krok 1 — Wejdź do katalogu stacku + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan +``` + +(Ścieżka może być inna — ważne żebyś był w folderze z plikiem `app.py`.) + +--- + +## Krok 2 — Zainstaluj na hoście (NIE Docker) + +```bash +sudo scripts/install.sh +``` + +Skrypt: +- kopiuje pliki do `/opt/gpu-fan` +- tworzy `/etc/gpu-fan/curve.json` (domyślna krzywa chłodzenia) +- tworzy `/opt/control-plane/.env` z losowym `API_KEY` (przy pierwszej instalacji) +- instaluje usługę systemd `gpu-fan` + +**Co zobaczysz gdy OK:** komunikat „Installed to /opt/gpu-fan” i instrukcja startu. + +--- + +## Krok 3 — Włącz dostęp z sieci lokalnej (LAN) + +```bash +sudo scripts/enable-lan.sh +``` + +Ustawia nasłuch na `0.0.0.0:8090` i upewnia się że jest `API_KEY`. + +--- + +## Krok 4 — Uruchom usługę + +```bash +sudo systemctl start gpu-fan +sudo systemctl enable gpu-fan +``` + +`enable` = start automatyczny po restarcie serwera. + +--- + +## Krok 5 — Sprawdź czy działa + +```bash +sudo systemctl status gpu-fan +``` + +**Co zobaczysz gdy OK:** `Active: active (running)` na zielono. + +Logi na żywo: + +```bash +journalctl -u gpu-fan -f +``` + +(Wyjdź: Ctrl+C) + +Test API (skopiuj całość): + +```bash +API_KEY=$(grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2) +curl -s http://127.0.0.1:8090/api/status -H "X-API-Key: $API_KEY" | head -c 200 +``` + +**Co zobaczysz gdy OK:** JSON z `"temperature_c"` i `"mode"`. + +--- + +## Krok 6 — Otwórz panel w przeglądarce + +→ [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md) + +--- + +## Po aktualizacji kodu w repo + +Jeśli zmieniłeś pliki w `stacks/gpu-fan/`: + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan +sudo scripts/install.sh +sudo systemctl restart gpu-fan +``` + +--- + +## Czego NIE robić + +| Nie rób tego | Dlaczego | +|--------------|----------| +| `sudo scripts/start.sh` + `systemctl start gpu-fan` naraz | Dwa programy na porcie 8090 — błąd | +| Instalacja w Dockerze | Nie wspierane — patrz [05-DLACZEGO-NIE-DOCKER.md](05-DLACZEGO-NIE-DOCKER.md) | +| Edycja tylko `stacks/control-plane/.env` | Produkcja czyta `/opt/control-plane/.env` | + +--- + +## Co zrobić gdy nie działa + +→ [04-CZESTE-BLEDY.md](04-CZESTE-BLEDY.md) diff --git a/stacks/gpu-fan/docs/02-OTWIERANIE-UI-W-PRZEGLADARCE.md b/stacks/gpu-fan/docs/02-OTWIERANIE-UI-W-PRZEGLADARCE.md new file mode 100644 index 0000000..bcbf19b --- /dev/null +++ b/stacks/gpu-fan/docs/02-OTWIERANIE-UI-W-PRZEGLADARCE.md @@ -0,0 +1,100 @@ +# Otwieranie panelu GPU Fan w przeglądarce + +**Cel:** Wejść do panelu sterowania wentylatorami GPU z komputera w sieci LAN. +**Czas:** ~3 minuty +**Wymagania:** Działające usługi `gpu-fan` (agent) i `server-ui` (panel) + +> UI gpu-fan **nie** jest już na porcie 8090. Użyj **Server UI** na porcie **8091**, zakładka **GPU Fan**. + +--- + +## Krok 1 — Sprawdź IP serwera + +Na serwerze: + +```bash +hostname -I | awk '{print $1}' +``` + +Przykład wyniku: `192.168.100.90` — to Twój adres w LAN. + +--- + +## Krok 2 — Pobierz klucz API + +Na serwerze: + +```bash +grep ^API_KEY= /opt/control-plane/.env +# lub ten sam klucz z agenta: +grep ^API_KEY= /opt/control-plane/.env +``` + +Przykład: `API_KEY=a1b2c3d4e5f6...` — skopiuj część **po** znaku `=`. + +--- + +## Krok 3 — Otwórz URL w przeglądarce + +Z innego komputera w tej samej sieci Wi‑Fi/LAN: + +``` +http://192.168.100.90:8091/?api_key=WKLEJ_TUTAJ_KLUCZ#gpu-fan +``` + +Zamień: +- `192.168.100.90` → IP z kroku 1 +- `WKLEJ_TUTAJ_KLUCZ` → wartość z kroku 2 + +Kliknij zakładkę **GPU Fan** (lub użyj `#gpu-fan` w URL). + +**Co zobaczysz gdy OK:** wykres krzywej wentylatorów, temperatura GPU, monitoring mocy/VRAM. + +Klucz zapisze się w przeglądarce (localStorage `server-ui-api-key`) — przy kolejnych wizytach wystarczy `http://IP:8091/#gpu-fan`. + +--- + +## Dostęp tylko z samego serwera + +``` +http://127.0.0.1:8091/#gpu-fan +``` + +(API key może być wymagany gdy `SERVER_UI_HOST=0.0.0.0`.) + +--- + +## Dostęp przez SSH (opcjonalnie) + +Na **swoim** laptopie: + +```bash +ssh -L 8091:127.0.0.1:8091 TWOJ_USER@192.168.100.90 +``` + +W przeglądarce na laptopie: `http://localhost:8091/#gpu-fan` + +--- + +## Co zrobić gdy nie działa + +| Objaw | Co zrobić | +|-------|-----------| +| Strona się nie ładuje | `sudo systemctl status server-ui` — czy **running**? | +| Zakładka GPU Fan pusta / błąd 502 | `sudo systemctl status gpu-fan` — agent na :18090 | +| `401` / brak danych | Zły klucz API — sprawdź `/opt/control-plane/.env` | +| Agent nie odpowiada | `curl -s http://127.0.0.1:18090/api/status -H "X-API-Key: KLUCZ"` | + +Test proxy: + +```bash +curl -s http://127.0.0.1:8091/api/gpu-fan/health -H "X-API-Key: $(grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2)" +``` + +--- + +## Bezpieczeństwo (krótko) + +- Panel **nie** wystawiaj na internet bez firewall / VPN. +- W LAN używaj `API_KEY` w Server UI. +- Agent gpu-fan (:18090) nasłuchuje tylko na localhost. diff --git a/stacks/gpu-fan/docs/03-KRZYWa-I-TRYBY.md b/stacks/gpu-fan/docs/03-KRZYWa-I-TRYBY.md new file mode 100644 index 0000000..7997b52 --- /dev/null +++ b/stacks/gpu-fan/docs/03-KRZYWa-I-TRYBY.md @@ -0,0 +1,112 @@ +# Krzywa temperatury i tryby wentylatorów + +**Cel:** Zrozumieć jak GPU Fan reguluje wentylatory i kiedy zmienić tryb. +**Czas:** ~5 minut +**Wymagania:** Działający panel (patrz [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md)) + +--- + +## Trzy tryby — który wybrać + +| Tryb | Kiedy używać | Co robi | +|------|--------------|---------| +| **curve** | Normalna praca (domyślny) | Im wyższa temp GPU, tym szybsze wentylatory — według krzywej | +| **manual** | Awaryjnie: „na full” lub stała prędkość | Ustawiasz np. 100% ręcznie | +| **auto** | Chcesz oddać sterowanie driverowi NVIDIA | Jak bez GPU Fan — driver sam decyduje | + +**Rekomendacja:** zostaw **curve** przy obciążeniu AI (ComfyUI, LocalAI). Użyj **manual 100%** tylko gdy karta się przegrzewa i chcesz na chwilę max chłodzenia. + +--- + +## Jak działa krzywa (curve) + +Plik: `/etc/gpu-fan/curve.json` + +Przykład (uproszczony): + +```json +{ + "30": 50, + "40": 65, + "50": 80, + "55": 90, + "60": 100, + "70": 100 +} +``` + +Znaczenie: przy **50°C** wentylatory ~**80%**, przy **60°C** i wyżej → **100%**. + +Między punktami program **interpoluje** (płynna zmiana). + +### Zasady (ważne) + +- **3 do 7** punktów +- Temperatury **rosnąco**, każda **unikalna** +- Prędkość: **0** (= auto w API) albo **30–100%** (wartości 1–29 są niedozwolone) + +--- + +## Domyślna krzywa „max cooling” + +Po instalacji masz agresywne chłodzenie (bezpieczne dla RTX 3090 Ti pod obciążeniem): + +| Temp GPU | Wentylatory | +|----------|-------------| +| 30°C | 50% | +| 40°C | 65% | +| 50°C | 80% | +| 55°C | 90% | +| 60°C+ | 100% | + +Źródło: `curve.default.json` w repo → kopiowane do `/etc/gpu-fan/curve.json`. + +--- + +## Edycja w panelu web + +1. Otwórz UI (port 8090) +2. Zmień punkty na wykresie / w tabeli +3. Kliknij **Zapisz** — zapisuje do `/etc/gpu-fan/curve.json` i włącza tryb **curve** + +--- + +## Edycja z terminala + +```bash +sudo nano /etc/gpu-fan/curve.json +sudo systemctl reload gpu-fan +``` + +`reload` przeładowuje plik bez pełnego restartu. + +--- + +## Co zobaczysz w panelu + +- **Temperatura** — aktualna temp GPU +- **Fan speeds** — odczyt z karty (%) +- **Target** — co program próbuje ustawić (w trybie curve/manual) +- **Mode** — curve / manual / auto + +--- + +## Po zatrzymaniu usługi + +```bash +sudo systemctl stop gpu-fan +``` + +Wentylatory wracają do trybu **auto** drivera NVIDIA — to zamierzone (bezpieczeństwo). + +--- + +## Co zrobić gdy wentylatory „dziwnie” się zachowują + +| Sytuacja | Wyjaśnienie | +|----------|-------------| +| 0% przy niskiej temp w trybie **auto** | Normalne — karta wyłącza wentylatory przy idle | +| Głośno od razu w **curve** | Domyślna krzywa jest agresywna — obniż prędkości w JSON | +| Brak reakcji | Sprawdź tryb — czy na pewno **curve**, nie **auto** | + +Więcej: [04-CZESTE-BLEDY.md](04-CZESTE-BLEDY.md) diff --git a/stacks/gpu-fan/docs/04-CZESTE-BLEDY.md b/stacks/gpu-fan/docs/04-CZESTE-BLEDY.md new file mode 100644 index 0000000..bebf72b --- /dev/null +++ b/stacks/gpu-fan/docs/04-CZESTE-BLEDY.md @@ -0,0 +1,163 @@ +# Częste błędy GPU Fan + +**Cel:** Naprawić typowe problemy bez zgadywania. +**Czas:** zależy od problemu (2–15 min) +**Wymagania:** Dostęp SSH do serwera, `sudo` + +--- + +## Szybka diagnostyka (zrób to najpierw) + +```bash +sudo systemctl status gpu-fan +nvidia-smi +scripts/status.sh +``` + +Skopiuj wynik jeśli dalej nie działa. + +--- + +## Błąd: `Insufficient Permissions` w logach + +**Przyczyna:** Program nie działa jako root. + +**Naprawa:** + +```bash +sudo systemctl restart gpu-fan +sudo systemctl status gpu-fan +``` + +Nie uruchamiaj `python app.py` bez sudo (chyba że `DRY_RUN=true` tylko do testów). + +--- + +## Błąd: `address already in use` / port 8090 zajęty + +**Przyczyna:** Dwie kopie programu naraz (najczęściej systemd + `scripts/start.sh`). + +**Naprawa:** + +```bash +sudo systemctl stop gpu-fan +sudo scripts/status.sh --cleanup +sudo systemctl start gpu-fan +``` + +**Nie zmieniaj portu na 8091** — to maskuje problem, nie go rozwiązuje. + +--- + +## Błąd: Strona w przeglądarce się nie ładuje + +| Sprawdź | Komenda | +|---------|---------| +| Usługa działa? | `sudo systemctl status gpu-fan` | +| Port nasłuchuje? | `ss -tlnp \| grep 8090` | +| LAN włączony? | `grep GPU_FAN_HOST /opt/control-plane/.env` → powinno być `0.0.0.0` | +| Firewall? | Upewnij się że LAN może dojść do 8090 | + +Włącz LAN: + +```bash +sudo scripts/enable-lan.sh +``` + +--- + +## Błąd: `401` / Invalid API key + +**Przyczyna:** Zły klucz w URL lub brak nagłówka. + +**Naprawa:** + +```bash +grep ^API_KEY= /opt/control-plane/.env +``` + +Użyj w przeglądarce: + +``` +http://IP_SERWERA:8090/?api_key=KLUCZ_Z_PLIKU +``` + +Patrz: [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md) + +--- + +## Błąd: Zmieniam `.env` w repo i nic się nie dzieje + +**Przyczyna:** Produkcja czyta **`/opt/control-plane/.env`**, nie `stacks/control-plane/.env`. + +**Naprawa:** + +```bash +sudo nano /opt/control-plane/.env +sudo systemctl restart gpu-fan +``` + +Albo pełna reinstalacja kodu (nie nadpisuje istniejącego `.env`): + +```bash +sudo scripts/install.sh +``` + +--- + +## Błąd: `nvidia-smi` nie działa + +**Przyczyna:** Brak / zły sterownik NVIDIA. + +GPU Fan **nie naprawi** drivera. Najpierw napraw GPU w systemie, potem wróć do instalacji. + +--- + +## Błąd: Po Ctrl+Z w terminalu port zajęty + +**Przyczyna:** Proces zawieszony w tle nadal trzyma port. + +**Naprawa:** + +```bash +jobs -l # zobacz zawieszone +kill %1 # numer z jobs +# albo: +sudo scripts/status.sh --cleanup +``` + +--- + +## Błąd: Krzywa się nie zapisuje + +Sprawdź format JSON — 3–7 punktów, temp unikalne, speed 0 lub 30–100. + +```bash +sudo cat /etc/gpu-fan/curve.json +journalctl -u gpu-fan -n 30 +``` + +--- + +## Błąd: Chcę uruchomić w Dockerze + +**Odpowiedź:** Nie rób tego. Patrz [05-DLACZEGO-NIE-DOCKER.md](05-DLACZEGO-NIE-DOCKER.md). + +--- + +## Pełny reset (ostateczność) + +```bash +sudo systemctl stop gpu-fan +sudo scripts/status.sh --cleanup +cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan +sudo scripts/install.sh +sudo scripts/enable-lan.sh +sudo systemctl start gpu-fan +``` + +Logi: + +```bash +journalctl -u gpu-fan -f +``` diff --git a/stacks/gpu-fan/docs/05-DLACZEGO-NIE-DOCKER.md b/stacks/gpu-fan/docs/05-DLACZEGO-NIE-DOCKER.md new file mode 100644 index 0000000..d75606b --- /dev/null +++ b/stacks/gpu-fan/docs/05-DLACZEGO-NIE-DOCKER.md @@ -0,0 +1,78 @@ +# Dlaczego GPU Fan NIE jest w Dockerze + +**Cel:** Zrozumieć dlaczego instalujemy na hoście, a nie jak ComfyUI. +**Czas:** 3 minuty +**Wymagania:** Brak + +--- + +## Krótka odpowiedź + +GPU Fan **musi** siedzieć na hoście obok sterownika NVIDIA. W Dockerze **nie ma** gotowego obrazu w tym repo i **nie planujemy** go dodawać. + +ComfyUI / LocalAI = programy w kontenerach (obliczenia AI). +GPU Fan = „termometr + regulator wentylatorów” na poziomie sprzętu. + +--- + +## Analogia + +| Co | Gdzie | Dlaczego | +|----|-------|----------| +| Aplikacja w przeglądarce | Docker OK | Nie dotyka wentylatorów bezpośrednio | +| Sterownik wentylatorów karty | Host (root) | Tylko system z uprawnieniami root może pisać do NVML | + +To jak termostat w kotłowni — nie wkładasz go do pudełka z aplikacją webową w innym pokoju. + +--- + +## Co by się stało w Dockerze (gdyby ktoś próbował) + +1. Kontener musiałby być **privileged** i **root** +2. I tak często dostaniesz **Insufficient Permissions** na kartach GeForce +3. Po awaryjnym `docker kill` wentylatory mogłyby zostać w trybie ręcznym +4. Zero korzyści — i tak potrzebujesz tego samego sterownika na hoście + +Dlatego w repo jest tylko: + +```bash +sudo scripts/install.sh +sudo systemctl start gpu-fan +``` + +--- + +## Jak to wygląda na serwerze (poprawnie) + +``` +HOST (Ubuntu) +├── gpu-fan.service ← steruje wentylatorami (port 8090) +├── sterownik NVIDIA +└── Docker + ├── comfyui :8188 + ├── localai :8070 + └── vllm :8000 +``` + +Wszystko może działać **równocześnie**. GPU Fan nie koliduje z kontenerami AI. + +--- + +## „Ale inne stacki są w Dockerze!” + +Tak — bo tam Docker **ma sens** (izolacja aplikacji, GPU do inference). + +GPU Fan to **daemon sprzętowy** — jak `nvidia-persistenced`, nie jak aplikacja webowa. + +--- + +## Gdzie jest pełna analiza techniczna + +Dla agentów / deweloperów: +[../coding-agent/DOCKER-VS-HOST-REPORT.md](../coding-agent/DOCKER-VS-HOST-REPORT.md) + +--- + +## Co robić zamiast Dockera + +→ [01-INSTALACJA-KROK-PO-KROKU.md](01-INSTALACJA-KROK-PO-KROKU.md) diff --git a/stacks/gpu-fan/fan_controller.py b/stacks/gpu-fan/fan_controller.py new file mode 100644 index 0000000..876b398 --- /dev/null +++ b/stacks/gpu-fan/fan_controller.py @@ -0,0 +1,358 @@ +"""NVML fan control with JSON curve, modes, and graceful shutdown.""" + +from __future__ import annotations + +import json +import logging +import signal +import threading +import time +from pathlib import Path +from typing import Any + +import pynvml + +log = logging.getLogger(__name__) + +MIN_CURVE_POINTS = 3 +MAX_CURVE_POINTS = 7 +MIN_FAN_SPEED = 30 +MAX_FAN_SPEED = 100 + + +class FanControlError(Exception): + pass + + +def _clamp_speed(speed: int) -> int: + if speed == 0: + return 0 + return max(MIN_FAN_SPEED, min(MAX_FAN_SPEED, speed)) + + +def parse_curve(raw: dict[str, Any]) -> list[tuple[int, int]]: + if not raw: + raise FanControlError("Curve is empty") + + points: list[tuple[int, int]] = [] + for temp_str, speed in raw.items(): + try: + temp = int(temp_str) + speed_int = int(speed) + except (TypeError, ValueError) as exc: + raise FanControlError(f"Invalid curve point {temp_str!r}: {speed!r}") from exc + + if not 0 <= temp <= 120: + raise FanControlError(f"Temperature {temp} out of range 0-120") + if speed_int != 0 and not MIN_FAN_SPEED <= speed_int <= MAX_FAN_SPEED: + raise FanControlError( + f"Fan speed {speed_int}% invalid (use 0 or {MIN_FAN_SPEED}-{MAX_FAN_SPEED})" + ) + points.append((temp, speed_int)) + + points.sort(key=lambda p: p[0]) + temps = [p[0] for p in points] + if len(set(temps)) != len(temps): + raise FanControlError("Temperatures must be unique") + if len(points) < MIN_CURVE_POINTS or len(points) > MAX_CURVE_POINTS: + raise FanControlError( + f"Curve must have {MIN_CURVE_POINTS}-{MAX_CURVE_POINTS} points" + ) + return points + + +def curve_to_dict(points: list[tuple[int, int]]) -> dict[str, int]: + return {str(temp): speed for temp, speed in points} + + +def interpolate_speed(temp: int, curve: list[tuple[int, int]]) -> int: + if temp <= curve[0][0]: + return curve[0][1] + if temp >= curve[-1][0]: + return curve[-1][1] + + for i in range(len(curve) - 1): + t1, s1 = curve[i] + t2, s2 = curve[i + 1] + if t1 <= temp <= t2: + if t2 == t1: + return s2 + ratio = (temp - t1) / (t2 - t1) + return int(s1 + ratio * (s2 - s1)) + return curve[-1][1] + + +class FanController: + def __init__( + self, + curve_path: Path, + gpu_index: int = 0, + poll_interval: float = 2.0, + ) -> None: + self.curve_path = curve_path + self.gpu_index = gpu_index + self.poll_interval = poll_interval + self._lock = threading.Lock() + self._running = False + self._mode = "curve" + self._manual_speed = 100 + self._curve: list[tuple[int, int]] = [] + self._handle: Any = None + self._fan_count = 0 + self._gpu_name = "" + self._last_metrics: dict[str, Any] = {} + self._auto_active = False + self.dry_run = False + + @property + def mode(self) -> str: + with self._lock: + return self._mode + + @property + def manual_speed(self) -> int: + with self._lock: + return self._manual_speed + + def load_curve_file(self) -> list[tuple[int, int]]: + if not self.curve_path.exists(): + raise FanControlError(f"Curve file not found: {self.curve_path}") + raw = json.loads(self.curve_path.read_text(encoding="utf-8")) + return parse_curve(raw) + + def save_curve_file(self, points: list[tuple[int, int]]) -> None: + validated = parse_curve(curve_to_dict(points)) + self.curve_path.parent.mkdir(parents=True, exist_ok=True) + self.curve_path.write_text( + json.dumps(curve_to_dict(validated), indent=2) + "\n", + encoding="utf-8", + ) + with self._lock: + self._curve = validated + log.info("Saved curve: %s", validated) + + def reload_curve(self) -> None: + curve = self.load_curve_file() + with self._lock: + self._curve = curve + log.info("Reloaded curve: %s", curve) + + def set_mode(self, mode: str, manual_speed: int | None = None) -> None: + if mode not in ("auto", "curve", "manual"): + raise FanControlError(f"Unknown mode: {mode}") + with self._lock: + self._mode = mode + if manual_speed is not None: + self._manual_speed = _clamp_speed(manual_speed) + log.info("Mode set to %s (manual_speed=%s)", mode, manual_speed) + + def get_curve(self) -> list[tuple[int, int]]: + with self._lock: + return list(self._curve) + + def get_metrics(self) -> dict[str, Any]: + with self._lock: + return dict(self._last_metrics) + + def init_nvml(self) -> None: + pynvml.nvmlInit() + count = pynvml.nvmlDeviceGetCount() + if self.gpu_index >= count: + raise FanControlError( + f"GPU index {self.gpu_index} out of range (found {count} GPU(s))" + ) + + handle = pynvml.nvmlDeviceGetHandleByIndex(self.gpu_index) + name = pynvml.nvmlDeviceGetName(handle) + fan_count = pynvml.nvmlDeviceGetNumFans(handle) + + self._handle = handle + self._fan_count = fan_count + self._gpu_name = name if isinstance(name, str) else name.decode() + self.reload_curve() + log.info("GPU %d: %s (%d fan(s))", self.gpu_index, self._gpu_name, fan_count) + + def _read_metrics(self) -> dict[str, Any]: + handle = self._handle + assert handle is not None + + temp = pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU) + util = pynvml.nvmlDeviceGetUtilizationRates(handle) + power_mw = pynvml.nvmlDeviceGetPowerUsage(handle) + + mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle) + memory_used_mb = mem_info.used // (1024**2) + memory_total_mb = mem_info.total // (1024**2) + + power_limit_w: float | None = None + clock_graphics_mhz: int | None = None + clock_memory_mhz: int | None = None + try: + power_limit_w = round( + pynvml.nvmlDeviceGetEnforcedPowerLimit(handle) / 1000, 1 + ) + except pynvml.NVMLError: + pass + try: + clock_graphics_mhz = pynvml.nvmlDeviceGetClockInfo( + handle, pynvml.NVML_CLOCK_GRAPHICS + ) + except pynvml.NVMLError: + pass + try: + clock_memory_mhz = pynvml.nvmlDeviceGetClockInfo( + handle, pynvml.NVML_CLOCK_MEM + ) + except pynvml.NVMLError: + pass + + fan_speeds: list[int] = [] + for fan_idx in range(self._fan_count): + try: + fan_speeds.append(pynvml.nvmlDeviceGetFanSpeed_v2(handle, fan_idx)) + except pynvml.NVMLError: + fan_speeds.append(-1) + + with self._lock: + mode = self._mode + manual_speed = self._manual_speed + curve = list(self._curve) + + if mode == "curve": + target = interpolate_speed(temp, curve) + elif mode == "manual": + target = manual_speed + else: + target = None + + metrics = { + "gpu_index": self.gpu_index, + "gpu_name": self._gpu_name, + "temperature_c": temp, + "fan_speeds_pct": fan_speeds, + "target_speed_pct": target, + "power_w": round(power_mw / 1000, 1), + "power_limit_w": power_limit_w, + "utilization_pct": util.gpu, + "memory_utilization_pct": util.memory, + "memory_used_mb": memory_used_mb, + "memory_total_mb": memory_total_mb, + "clock_graphics_mhz": clock_graphics_mhz, + "clock_memory_mhz": clock_memory_mhz, + "mode": mode, + "manual_speed_pct": manual_speed, + "curve": curve_to_dict(curve), + } + with self._lock: + self._last_metrics = metrics + return metrics + + def _set_manual_policy(self) -> None: + handle = self._handle + assert handle is not None + for fan_idx in range(self._fan_count): + pynvml.nvmlDeviceSetFanControlPolicy( + handle, fan_idx, pynvml.NVML_FAN_POLICY_MANUAL + ) + + def _restore_auto_policy(self) -> None: + if self._handle is None: + return + log.info("Restoring automatic fan control...") + for fan_idx in range(self._fan_count): + try: + pynvml.nvmlDeviceSetFanControlPolicy( + self._handle, + fan_idx, + pynvml.NVML_FAN_POLICY_TEMPERATURE_CONTINOUS_SW, + ) + log.info("Fan %d: restored to auto", fan_idx) + except pynvml.NVMLError as exc: + log.error("Fan %d: could not restore auto: %s", fan_idx, exc) + + def _apply_fan_speed(self, speed: int) -> None: + handle = self._handle + assert handle is not None + speed = _clamp_speed(speed) + self._set_manual_policy() + for fan_idx in range(self._fan_count): + pynvml.nvmlDeviceSetFanSpeed_v2(handle, fan_idx, speed) + + def update_once(self) -> dict[str, Any]: + metrics = self._read_metrics() + mode = metrics["mode"] + + if mode == "auto": + if not self._auto_active and not self.dry_run: + self._restore_auto_policy() + self._auto_active = True + elif self.dry_run: + self._auto_active = True + return metrics + + self._auto_active = False + target = metrics["target_speed_pct"] + if target is None: + return metrics + + if self.dry_run: + log.info( + "[dry-run] GPU %d: %d°C -> %d%% (fans: %s)", + self.gpu_index, + metrics["temperature_c"], + target, + metrics["fan_speeds_pct"], + ) + return metrics + + try: + self._apply_fan_speed(target) + log.info( + "GPU %d: %d°C -> %d%% (fans: %s)", + self.gpu_index, + metrics["temperature_c"], + target, + metrics["fan_speeds_pct"], + ) + except pynvml.NVMLError as exc: + log.error("Failed to set fan speed: %s", exc) + metrics["error"] = str(exc) + return metrics + + def run_loop(self) -> None: + self._running = True + log.info("Fan control loop started (interval=%ss)", self.poll_interval) + while self._running: + try: + self.update_once() + except Exception: + log.exception("Fan control loop error") + time.sleep(self.poll_interval) + + def stop(self) -> None: + self._running = False + + def shutdown(self) -> None: + self.stop() + if not self.dry_run: + self._restore_auto_policy() + try: + pynvml.nvmlShutdown() + except pynvml.NVMLError: + pass + log.info("Fan controller shut down") + + def install_signal_handlers(self) -> None: + def handler(signum: int, _frame: Any) -> None: + log.info("Received signal %s", signum) + if signum == signal.SIGHUP: + try: + self.reload_curve() + except FanControlError as exc: + log.error("Curve reload failed: %s", exc) + return + self.shutdown() + + signal.signal(signal.SIGTERM, handler) + signal.signal(signal.SIGINT, handler) + signal.signal(signal.SIGHUP, handler) diff --git a/stacks/gpu-fan/fan_daemon.py b/stacks/gpu-fan/fan_daemon.py new file mode 100644 index 0000000..9a824b6 --- /dev/null +++ b/stacks/gpu-fan/fan_daemon.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""GPU fan control NVML daemon + API only (no web UI).""" + +from __future__ import annotations + +import logging +import os +import signal +import sys +import threading +from pathlib import Path +from typing import Any + +import uvicorn +from fastapi import Depends, FastAPI, HTTPException, Request +from pydantic import BaseModel, Field + +from fan_controller import ( + FanControlError, + FanController, + MAX_CURVE_POINTS, + MIN_CURVE_POINTS, + MIN_FAN_SPEED, + MAX_FAN_SPEED, + curve_to_dict, + parse_curve, +) + +STACK_DIR = Path(__file__).resolve().parent + +# Unified control-plane env (see stacks/control-plane/env_loader.py) +for _cp in ("/opt/control-plane", "/repo/stacks/control-plane", str(STACK_DIR.parent / "control-plane")): + if _cp not in sys.path and Path(_cp).exists(): + sys.path.insert(0, _cp) +from env_loader import load_control_plane_env # noqa: E402 + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +log = logging.getLogger(__name__) + + +def _resolve_host_port(env: dict[str, str]) -> tuple[str, int]: + """Agent API bind — prefer GPU_FAN_API_*; never fall back to legacy LAN :8090.""" + if env.get("GPU_FAN_API_HOST") or env.get("GPU_FAN_API_PORT"): + host = env.get("GPU_FAN_API_HOST", "127.0.0.1") + port_raw = env.get("GPU_FAN_API_PORT", "18090") + else: + host = "127.0.0.1" + port_raw = "18090" + return host, int(port_raw) + + +def _is_localhost_bind(host: str) -> bool: + return host.strip().lower() in ("127.0.0.1", "localhost", "::1") + + +ENV = load_control_plane_env(STACK_DIR) +HOST, PORT = _resolve_host_port(ENV) +API_KEY = ENV.get("API_KEY", "") +CURVE_PATH = Path(ENV.get("CURVE_PATH", "/etc/gpu-fan/curve.json")) +POLL_INTERVAL = float(ENV.get("POLL_INTERVAL", "2.0")) +GPU_INDEX = int(ENV.get("GPU_INDEX", "0")) +DRY_RUN = ENV.get("DRY_RUN", "").lower() in ("1", "true", "yes") + +controller = FanController( + curve_path=CURVE_PATH, + gpu_index=GPU_INDEX, + poll_interval=POLL_INTERVAL, +) +controller.dry_run = DRY_RUN +app = FastAPI(title="GPU Fan Agent", version="1.0.0") + + +class CurvePoint(BaseModel): + temp: int = Field(ge=0, le=120) + speed: int = Field(ge=0, le=100) + + +class CurveUpdate(BaseModel): + points: list[CurvePoint] = Field(min_length=MIN_CURVE_POINTS, max_length=MAX_CURVE_POINTS) + + +class ModeUpdate(BaseModel): + mode: str + speed: int | None = Field(default=None, ge=MIN_FAN_SPEED, le=MAX_FAN_SPEED) + + +def require_auth(request: Request) -> None: + if not API_KEY: + return + key = request.headers.get("X-API-Key", "") + if key != API_KEY: + raise HTTPException(status_code=401, detail="Invalid or missing API key") + + +@app.get("/api/status") +def api_status(_: None = Depends(require_auth)) -> dict[str, Any]: + return controller.get_metrics() + + +@app.get("/api/curve") +def api_get_curve(_: None = Depends(require_auth)) -> dict[str, Any]: + points = controller.get_curve() + return {"points": [{"temp": t, "speed": s} for t, s in points]} + + +@app.put("/api/curve") +def api_put_curve(body: CurveUpdate, _: None = Depends(require_auth)) -> dict[str, Any]: + points = [(p.temp, p.speed) for p in body.points] + try: + parse_curve(curve_to_dict(points)) + controller.save_curve_file(points) + controller.set_mode("curve") + except FanControlError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, "curve": curve_to_dict(controller.get_curve())} + + +@app.post("/api/mode") +def api_set_mode(body: ModeUpdate, _: None = Depends(require_auth)) -> dict[str, Any]: + try: + controller.set_mode(body.mode, body.speed) + if body.mode != "auto": + controller.update_once() + except FanControlError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, "mode": controller.mode, "manual_speed": controller.manual_speed} + + +@app.post("/api/reload") +def api_reload(_: None = Depends(require_auth)) -> dict[str, Any]: + try: + controller.reload_curve() + except FanControlError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, "curve": curve_to_dict(controller.get_curve())} + + +def run_daemon_thread() -> None: + controller.run_loop() + + +def main() -> None: + if os.geteuid() != 0 and not DRY_RUN: + log.error("GPU fan control requires root (NVML write access). Run with sudo.") + sys.exit(1) + + if not _is_localhost_bind(HOST) and not API_KEY: + log.error( + "API_KEY is required when GPU_FAN_API_HOST=%s (LAN/public bind). " + "Set API_KEY in .env", + HOST, + ) + sys.exit(1) + + controller.init_nvml() + + def shutdown_handler(signum: int, _frame: object) -> None: + log.info("Received signal %s", signum) + if signum == signal.SIGHUP: + try: + controller.reload_curve() + except FanControlError as exc: + log.error("Curve reload failed: %s", exc) + return + controller.shutdown() + sys.exit(0) + + signal.signal(signal.SIGTERM, shutdown_handler) + signal.signal(signal.SIGINT, shutdown_handler) + signal.signal(signal.SIGHUP, shutdown_handler) + + thread = threading.Thread(target=run_daemon_thread, daemon=True) + thread.start() + + log.info("GPU fan agent API at http://%s:%d", HOST, PORT) + log.info("Web UI: use Server UI at :8091 (GPU Fan tab)") + uvicorn.run(app, host=HOST, port=PORT, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/stacks/gpu-fan/gpu-fan.service b/stacks/gpu-fan/gpu-fan.service new file mode 100644 index 0000000..2bd9016 --- /dev/null +++ b/stacks/gpu-fan/gpu-fan.service @@ -0,0 +1,18 @@ +[Unit] +Description=GPU Fan Control (NVML agent API) +After=nvidia-persistenced.service +Wants=nvidia-persistenced.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/gpu-fan +EnvironmentFile=-/opt/control-plane/.env +ExecStart=/opt/gpu-fan/.venv/bin/python /opt/gpu-fan/fan_daemon.py +Restart=on-failure +RestartSec=5 +KillSignal=SIGTERM +TimeoutStopSec=15 + +[Install] +WantedBy=multi-user.target diff --git a/stacks/gpu-fan/requirements.txt b/stacks/gpu-fan/requirements.txt new file mode 100644 index 0000000..f534831 --- /dev/null +++ b/stacks/gpu-fan/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +nvidia-ml-py>=12.560.0 diff --git a/stacks/gpu-fan/scripts/enable-lan.sh b/stacks/gpu-fan/scripts/enable-lan.sh new file mode 100755 index 0000000..497b251 --- /dev/null +++ b/stacks/gpu-fan/scripts/enable-lan.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Configure gpu-fan agent for localhost + ensure API key. UI is in Server UI :8091. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +SERVER_UI_DIR="$(cd "${STACK_DIR}/../server-ui" && pwd)" +CONTROL_PLANE_ENV="/opt/control-plane/.env" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run as root: sudo ${SCRIPT_DIR}/enable-lan.sh" + exit 1 +fi + +"${SCRIPT_DIR}/install.sh" + +set_env_var() { + local file="$1" key="$2" val="$3" + if grep -q "^${key}=" "${file}"; then + sed -i "s|^${key}=.*|${key}=${val}|" "${file}" + else + echo "${key}=${val}" >> "${file}" + fi +} + +for pair in "GPU_FAN_API_HOST=127.0.0.1" "GPU_FAN_API_PORT=18090"; do + set_env_var "${CONTROL_PLANE_ENV}" "${pair%%=*}" "${pair#*=}" +done + +if ! grep -q '^API_KEY=.\{8,\}' "${CONTROL_PLANE_ENV}"; then + KEY="$(openssl rand -hex 16)" + set_env_var "${CONTROL_PLANE_ENV}" "API_KEY" "${KEY}" + echo "Generated new API_KEY in ${CONTROL_PLANE_ENV}" +else + echo "API_KEY already set in ${CONTROL_PLANE_ENV}" +fi + +systemctl restart gpu-fan +sleep 2 + +LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" + +echo "" +echo "gpu-fan agent: $(systemctl is-active gpu-fan)" +echo "Agent API: http://127.0.0.1:18090 (localhost only)" +echo "" +echo "GPU Fan panel (LAN):" +echo " http://${LAN_IP:-}:8091 → zakładka GPU Fan" +echo "" +echo "API key (Server UI + gpu-fan):" +echo " grep ^API_KEY= ${CONTROL_PLANE_ENV}" diff --git a/stacks/gpu-fan/scripts/install.sh b/stacks/gpu-fan/scripts/install.sh new file mode 100755 index 0000000..58a7486 --- /dev/null +++ b/stacks/gpu-fan/scripts/install.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +SERVER_UI_DIR="$(cd "${STACK_DIR}/../server-ui" && pwd)" +INSTALL_DIR="/opt/gpu-fan" +CONFIG_DIR="/etc/gpu-fan" +SERVICE_NAME="gpu-fan.service" +AGENT_PORT=18090 +CONTROL_PLANE_ENV="/opt/control-plane/.env" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run as root: sudo ${SCRIPT_DIR}/install.sh" + exit 1 +fi + +echo "=== GPU Fan Control — install ===" + +bash "${SERVER_UI_DIR}/scripts/setup-control-plane-env.sh" + +apt-get update -qq +apt-get install -y python3-venv python3-pip + +mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}" + +rsync -a --delete \ + --exclude '.venv' \ + --exclude '.env' \ + --exclude '__pycache__' \ + "${STACK_DIR}/" "${INSTALL_DIR}/" + +if [[ ! -f "${CONFIG_DIR}/curve.json" ]]; then + cp "${INSTALL_DIR}/curve.default.json" "${CONFIG_DIR}/curve.json" + echo "Installed default curve: ${CONFIG_DIR}/curve.json" +fi + +python3 -m venv "${INSTALL_DIR}/.venv" +"${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip -q +"${INSTALL_DIR}/.venv/bin/pip" install -r "${INSTALL_DIR}/requirements.txt" -q + +install -m 644 "${INSTALL_DIR}/gpu-fan.service" "/etc/systemd/system/${SERVICE_NAME}" +systemctl daemon-reload +systemctl enable "${SERVICE_NAME}" +systemctl restart "${SERVICE_NAME}" +sleep 2 + +API_KEY_VAL="$(grep '^API_KEY=' "${CONTROL_PLANE_ENV}" | cut -d= -f2- || true)" +LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" + +echo "" +echo "Installed to ${INSTALL_DIR}" +echo "Service: $(systemctl is-active "${SERVICE_NAME}" 2>/dev/null || echo unknown)" +echo "Env: ${CONTROL_PLANE_ENV}" +echo "Curve config: ${CONFIG_DIR}/curve.json" +echo "Agent API: 127.0.0.1:${AGENT_PORT} (localhost only)" +echo "" +echo "GPU Fan UI (Server UI, LAN):" +echo " http://${LAN_IP:-}:8091/#gpu-fan" +echo "" +echo "API key:" +echo " grep ^API_KEY= ${CONTROL_PLANE_ENV}" +echo "" + +if ss -tlnp 2>/dev/null | grep -q ":${AGENT_PORT}"; then + echo "Port ${AGENT_PORT}: listening" + if [[ -n "${API_KEY_VAL}" ]]; then + STATUS="$(curl -sf "http://127.0.0.1:${AGENT_PORT}/api/status" -H "X-API-Key: ${API_KEY_VAL}" 2>/dev/null | head -c 120 || true)" + echo "Agent status: ${STATUS}..." + fi +else + echo "WARN: port ${AGENT_PORT} not listening — check: journalctl -u ${SERVICE_NAME} -n 20" +fi + +echo "" +echo "Logs:" +echo " journalctl -u ${SERVICE_NAME} -f" +echo "" +echo "Pełna instalacja (gpu-fan + Server UI):" +echo " sudo ${SERVER_UI_DIR}/scripts/install-control-plane.sh" diff --git a/stacks/gpu-fan/scripts/self-test.sh b/stacks/gpu-fan/scripts/self-test.sh new file mode 100755 index 0000000..a3195ab --- /dev/null +++ b/stacks/gpu-fan/scripts/self-test.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Self-test: curve logic, NVML read, API (dry-run). Full fan write needs sudo. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${STACK_DIR}" + +mkdir -p config +cp -n curve.default.json config/curve.json 2>/dev/null || true + +export CURVE_PATH="${STACK_DIR}/config/curve.json" +export DRY_RUN=true +export GPU_FAN_PORT=18090 + +echo "=== 1. Curve logic ===" +.venv/bin/python -c " +from fan_controller import parse_curve, interpolate_speed +pts = parse_curve({'30':50,'40':65,'50':80,'55':90,'60':100,'70':100}) +assert interpolate_speed(45, pts) in (72, 73) +assert interpolate_speed(70, pts) == 100 +print('OK') +" + +echo "=== 2. NVML read ===" +.venv/bin/python -c " +import pynvml +pynvml.nvmlInit() +h = pynvml.nvmlDeviceGetHandleByIndex(0) +t = pynvml.nvmlDeviceGetTemperature(h, pynvml.NVML_TEMPERATURE_GPU) +print(f'GPU temp: {t}C') +pynvml.nvmlShutdown() +print('OK') +" + +echo "=== 3. API dry-run (background) ===" +.venv/bin/python app.py & +APP_PID=$! +trap 'kill $APP_PID 2>/dev/null || true' EXIT +sleep 2 + +STATUS=$(curl -sf "http://127.0.0.1:${GPU_FAN_PORT}/api/status") +echo "$STATUS" | .venv/bin/python -m json.tool | head -20 + +TARGET=$(echo "$STATUS" | .venv/bin/python -c "import sys,json; print(json.load(sys.stdin)['target_speed_pct'])") +TEMP=$(echo "$STATUS" | .venv/bin/python -c "import sys,json; print(json.load(sys.stdin)['temperature_c'])") +echo "Temp=${TEMP}C target=${TARGET}%" + +curl -sf -X PUT "http://127.0.0.1:${GPU_FAN_PORT}/api/curve" \ + -H 'Content-Type: application/json' \ + -d '{"points":[{"temp":30,"speed":50},{"temp":40,"speed":65},{"temp":50,"speed":80},{"temp":55,"speed":90},{"temp":60,"speed":100},{"temp":70,"speed":100}]}' \ + | .venv/bin/python -m json.tool >/dev/null +echo "Curve PUT: OK" + +curl -sf -X POST "http://127.0.0.1:${GPU_FAN_PORT}/api/mode" \ + -H 'Content-Type: application/json' \ + -d '{"mode":"manual","speed":100}' >/dev/null +echo "Mode manual 100%: OK" + +kill $APP_PID 2>/dev/null || true +trap - EXIT + +echo "" +if sudo -n true 2>/dev/null; then + echo "=== 4. Fan write (root) ===" + sudo -n env CURVE_PATH="$CURVE_PATH" .venv/bin/python -c " +from pathlib import Path +from fan_controller import FanController +c = FanController(Path('$CURVE_PATH'), 0, 2.0) +c.init_nvml() +c.set_mode('manual', 50) +m = c.update_once() +print(f\"Applied: {m['temperature_c']}C -> {m['target_speed_pct']}%\") +c.set_mode('auto') +c.update_once() +c.shutdown() +print('OK') +" +else + echo "=== 4. Fan write (root) — SKIP (sudo needs password) ===" + echo " Run: sudo scripts/install.sh && sudo systemctl start gpu-fan" +fi + +echo "" +echo "All automated checks passed." diff --git a/stacks/gpu-fan/scripts/start.sh b/stacks/gpu-fan/scripts/start.sh new file mode 100755 index 0000000..92db72d --- /dev/null +++ b/stacks/gpu-fan/scripts/start.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +CONTROL_PLANE_ENV="${STACK_DIR}/../control-plane/.env" +EXAMPLE="${STACK_DIR}/../control-plane/.env.example" + +cd "${STACK_DIR}" + +if [[ ! -f "${CONTROL_PLANE_ENV}" ]]; then + cp "${EXAMPLE}" "${CONTROL_PLANE_ENV}" + echo "Created ${CONTROL_PLANE_ENV} from example" +fi + +set -a +# shellcheck disable=SC1091 +source "${CONTROL_PLANE_ENV}" +set +a + +export CONTROL_PLANE_ENV="${CONTROL_PLANE_ENV}" + +CURVE_PATH="${CURVE_PATH:-/etc/gpu-fan/curve.json}" +export CURVE_PATH + +if [[ ! -f "${CURVE_PATH}" ]]; then + if [[ "${EUID}" -eq 0 ]]; then + mkdir -p "$(dirname "${CURVE_PATH}")" + cp curve.default.json "${CURVE_PATH}" + else + mkdir -p "${STACK_DIR}/config" + CURVE_PATH="${STACK_DIR}/config/curve.json" + export CURVE_PATH + [[ -f "${CURVE_PATH}" ]] || cp curve.default.json "${CURVE_PATH}" + echo "Using local curve: ${CURVE_PATH}" + fi +fi + +if [[ ! -d .venv ]]; then + python3 -m venv .venv + .venv/bin/pip install -q -r requirements.txt +fi + +if [[ "${EUID}" -ne 0 ]]; then + echo "ERROR: Fan control requires root. Use:" + echo " sudo -E ${SCRIPT_DIR}/start.sh" + echo "Or install system-wide:" + echo " sudo ${SCRIPT_DIR}/install.sh && sudo systemctl start gpu-fan" + exit 1 +fi + +if systemctl is-active --quiet gpu-fan 2>/dev/null; then + echo "ERROR: gpu-fan.service is already running (systemd)." + echo " Use: sudo systemctl stop gpu-fan" + echo " Or: sudo journalctl -u gpu-fan -f" + echo "" + echo "Do not run scripts/start.sh alongside systemd — one instance only." + exit 1 +fi + +PORT="${GPU_FAN_API_PORT:-18090}" +HOST="${GPU_FAN_API_HOST:-127.0.0.1}" + +if ss -tln "sport = :${PORT}" 2>/dev/null | grep -q LISTEN; then + echo "ERROR: Port ${PORT} is already in use." + echo "" + ss -tlnp "sport = :${PORT}" 2>/dev/null || ss -tln "sport = :${PORT}" + echo "" + echo "Common causes:" + echo " - Suspended foreground start (Ctrl+Z) — run: jobs -l && kill %" + echo " - Orphan process — run: sudo ${SCRIPT_DIR}/status.sh" + echo " - systemd still stopping — wait a few seconds" + exit 1 +fi + +echo "=== GPU Fan Control (foreground) ===" +echo "Agent API: http://${HOST}:${PORT}" +echo "Curve: ${CURVE_PATH}" +echo "" +echo "Stop with Ctrl+C (not Ctrl+Z — suspended process keeps the port)." +echo "" + +exec .venv/bin/python app.py diff --git a/stacks/gpu-fan/scripts/status.sh b/stacks/gpu-fan/scripts/status.sh new file mode 100755 index 0000000..ba57ee9 --- /dev/null +++ b/stacks/gpu-fan/scripts/status.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Diagnose gpu-fan port conflicts and running instances. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +OPT_ENV="/opt/control-plane/.env" +REPO_ENV="${STACK_DIR}/../control-plane/.env" + +echo "=== gpu-fan status ===" +echo "" + +if systemctl list-unit-files gpu-fan.service &>/dev/null; then + echo "systemd:" + systemctl is-active gpu-fan 2>/dev/null && systemctl status gpu-fan --no-pager -l 2>/dev/null | head -8 || echo " inactive" +else + echo "systemd: gpu-fan.service not installed" +fi +echo "" + +echo "Ports 8090–8099:" +if ss -tlnp 2>/dev/null | grep -E ':809[0-9]' ; then + : +elif ss -tln 2>/dev/null | grep -E ':809[0-9]' ; then + echo " (run as root for process names: sudo ss -tlnp)" +else + echo " (none listening)" +fi +echo "" + +echo "gpu-fan processes:" +pgrep -af '/opt/gpu-fan/.venv/bin/python|stacks/gpu-fan/.venv/bin/python|gpu-fan/app.py' 2>/dev/null \ + || echo " (none)" +echo "" + +echo "Shell suspended jobs (Ctrl+Z):" +if jobs -l 2>/dev/null | grep -q .; then + jobs -l + echo " Kill with: kill %" +else + echo " (none in this shell)" +fi +echo "" + +echo "GPU_FAN_API_PORT config:" +[[ -f "${OPT_ENV}" ]] && echo " /opt/control-plane/.env: $(grep '^GPU_FAN_API_PORT=' "${OPT_ENV}" || echo '(not set)')" +[[ -f "${REPO_ENV}" ]] && echo " repo control-plane: $(grep '^GPU_FAN_API_PORT=' "${REPO_ENV}" || echo '(not set)')" +echo " Note: production uses /opt/control-plane/.env (shared with Server UI)" +echo "" + +if [[ "${1:-}" == "--cleanup" ]]; then + if [[ "${EUID}" -ne 0 ]]; then + echo "ERROR: --cleanup requires root: sudo ${SCRIPT_DIR}/status.sh --cleanup" + exit 1 + fi + echo "=== cleanup ===" + systemctl stop gpu-fan 2>/dev/null || true + pkill -f '/opt/gpu-fan/.venv/bin/python /opt/gpu-fan/app.py' 2>/dev/null || true + pkill -f 'stacks/gpu-fan/.venv/bin/python app.py' 2>/dev/null || true + sleep 0.5 + if ss -tln 2>/dev/null | grep -qE ':809[0-9]'; then + echo "WARNING: ports still in use:" + ss -tlnp 2>/dev/null | grep -E ':809[0-9]' || ss -tln | grep -E ':809[0-9]' + echo "Check suspended jobs in other shells: jobs -l" + else + echo "Ports 809x are free." + fi + echo "" + echo "Start production instance:" + echo " sudo systemctl start gpu-fan" + echo "Or foreground debug (systemd stopped):" + echo " sudo ${SCRIPT_DIR}/start.sh" +fi diff --git a/stacks/gpu-fan/static/index.html b/stacks/gpu-fan/static/index.html new file mode 100644 index 0000000..be08e00 --- /dev/null +++ b/stacks/gpu-fan/static/index.html @@ -0,0 +1,755 @@ + + + + + + GPU Fan Control — RTX 3090 Ti + + + +
+
+

GPU Fan Control

+
Ładowanie…
+
+ +
+ +
+
+
+

Krzywa wentylatorów

+ + + + + + + + + + Temperatura (°C) + Prędkość (%) + + + + + + +

Przeciągnij punkty na wykresie. Prędkość: 30–100% (API NVIDIA). Min. 3, max. 7 punktów.

+ + + + + + +
#Temp (°C)Speed (%)
+ +
+ + + + + +
+
+ +
+

Status GPU

+
+
+ Wentylatory + +
+
+ Cel (krzywa) + +
+
+
+
+ +
+

Monitoring GPU

+
+
+
+ Obciążenie GPU + +
+
+
+
+
+ Pobór mocy + +
+
+
+
+
+
+ Pamięć VRAM + +
+
+
+
+
+ +
+
+ Wykorzystanie GPU (%) + Moc (W) + Temperatura (°C) +
+ +
+ +
+
+
+
Temperatura
+
+
+
+
Wentylatory
+
+
+
+
Taktowanie GPU
+
+
+
+
Taktowanie pamięci
+
+
+
+
Wykorzystanie pamięci
+
+
+
+
Tryb wentylatora
+
+
+
+
+ +
+ + + + diff --git a/stacks/llamacpp/README.md b/stacks/llamacpp/README.md new file mode 100644 index 0000000..3e78828 --- /dev/null +++ b/stacks/llamacpp/README.md @@ -0,0 +1,65 @@ +# llama.cpp stack (planned) + +Placeholder for a future Docker stack serving **GGUF** models from `models.catalog.yaml` (lmstudio-community Q4). + +vLLM on port **8000** stays separate; this stack is intended for port **8001**. + +## Why a separate host + +| Format | Runtime | Notes | +|--------|---------|-------| +| AWQ / safetensors (HF) | vLLM (`stacks/vllm/`) | Interim Q4 equivalent | +| GGUF Q4_K_M / Q4_0 | **llama.cpp** (this stack) | Native K/V cache `q4_0` like LM Studio | + +Standard `vllm/vllm-openai` does **not** load `.gguf` files. Links in the catalog point here. + +## Disk layout (created by vLLM scripts) + +``` +/data/apps/gguf/ +├── qwen3.6-27b/Qwen3.6-27B-Q4_K_M.gguf +└── gemma-4-12b/ + ├── gemma-4-12B-it-QAT-Q4_0.gguf + └── mmproj-gemma-4-12B-it-QAT-BF16.gguf +``` + +Download on demand from vLLM stack: + +```bash +cd stacks/vllm +./scripts/download-model.sh qwen3.6-27b-q4-gguf +./scripts/download-model.sh gemma-4-12b-q4-gguf +``` + +## Planned configuration + +- **Binary:** `llama-server` (llama.cpp) +- **Router mode:** `--models-dir /data/apps/gguf` — multiple models on disk, one loaded per request via API `"model"` field +- **K/V cache:** `--cache-type-k q4_0 --cache-type-v q4_0` (LM Studio parity) +- **Port:** `8001` (vLLM remains on `8000`) +- **GPU:** RTX 3090 Ti only (`CUDA_VISIBLE_DEVICES=0`) + +Example (not wired yet): + +```bash +llama-server \ + --host 0.0.0.0 \ + --port 8001 \ + --models-dir /data/apps/gguf \ + --ctx-size 131072 \ + --cache-type-k q4_0 \ + --cache-type-v q4_0 \ + --n-gpu-layers 999 +``` + +## Model switching + +Unlike vLLM (one model per container restart), llama.cpp router mode can keep several GGUF files on disk and select by name in the OpenAI-compatible API without restarting the whole service. + +See: [Model management in llama.cpp](https://huggingface.co/blog/ggml-org/model-management-in-llamacpp) + +## Status + +- Catalog entries: `stacks/vllm/models.catalog.yaml` (`runtime: llamacpp`) +- Docker compose: **not implemented** — this README only +- When ready: add `docker-compose.yml`, profile, and `scripts/start.sh` mirroring the vLLM stack pattern diff --git a/stacks/localai/.env.example b/stacks/localai/.env.example new file mode 100644 index 0000000..b42cf86 --- /dev/null +++ b/stacks/localai/.env.example @@ -0,0 +1,16 @@ +# Data disk mount point +DATA_ROOT=/data + +# LocalAI web UI + OpenAI-compatible API (localhost bind when behind NPMPlus) +LOCALAI_PORT=8070 + +# Bearer token for /v1/* API +LOCALAI_API_KEY= + +# Pinned GPU image for CUDA 13 (RTX 3090 Ti) +LOCALAI_IMAGE=localai/localai:v4.4.3-gpu-nvidia-cuda-13 + +# Use only the discrete NVIDIA GPU +CUDA_VISIBLE_DEVICES=0 + +DEBUG=false diff --git a/stacks/localai/.gitignore b/stacks/localai/.gitignore new file mode 100644 index 0000000..6e5445c --- /dev/null +++ b/stacks/localai/.gitignore @@ -0,0 +1,2 @@ +.env +upstream/ diff --git a/stacks/localai/README.md b/stacks/localai/README.md new file mode 100644 index 0000000..51e9373 --- /dev/null +++ b/stacks/localai/README.md @@ -0,0 +1,152 @@ +# LocalAI stack + +[LocalAI](https://github.com/mudler/LocalAI) — silnik inference z **wbudowanym UI** (chat) i API kompatybilnym z OpenAI. Obsługuje modele skwantyzowane (GGUF, AWQ, …) przez backendy (llama.cpp, vLLM, …). + +## Porty + +| Serwis | Port | URL | +|--------|------|-----| +| LocalAI UI + API | **8080** | `http://HOST:8080` | +| vLLM (osobny stack) | 8000 | tylko API, bez UI | + +Jeden port — UI i API na tym samym endpoincie. + +## Jak to działa + +```mermaid +flowchart LR + browser["Przeglądarka"] + api["curl / OpenAI SDK"] + localai["LocalAI :8080"] + gpu["RTX 3090 Ti"] + models["/data/apps/localai/models"] + + browser --> localai + api --> localai + localai --> gpu + localai --> models +``` + +| Element | Opis | +|---------|------| +| Obraz | `localai/localai:v4.4.3-gpu-nvidia-cuda-13` | +| Konfiguracja | `.env` + `docker-compose.yml` | +| Modele | `/data/apps/localai/models` (puste na start) | +| Upstream repo | opcjonalnie `upstream/` przez `clone-upstream.sh` | + +## Struktura + +``` +stacks/localai/ +├── README.md +├── docker-compose.yml +├── .env.example +├── .gitignore +├── coding-agent/ # notatki dla agenta (KV cache, STATE) +├── profiles/ # szablony YAML (KV q8_0) +├── upstream/ # shallow clone (gitignored) +└── scripts/ + ├── clone-upstream.sh + ├── pull.sh + ├── start.sh + └── apply-kv-profile.sh +``` + +Na dysku `/data`: + +``` +/data/apps/localai/ +├── models/ # GGUF, YAML model configs +├── backends/ # custom backends +├── configuration/ # api_keys.json, runtime settings +├── images/ # generated images +└── data/ # agents, skills, persistent app data +``` + +## Workflow (bez modelu) + +```bash +cd stacks/localai +cp .env.example .env + +# opcjonalnie — referencja YAML z GitHub +./scripts/clone-upstream.sh + +# tylko obraz Docker +./scripts/pull.sh + +# uruchom (pusty katalog models/) +./scripts/start.sh +``` + +Weryfikacja: + +```bash +curl -s http://localhost:8080/readyz +# UI: http://:8080 +``` + +## Zmienne `.env` + +| Zmienna | Opis | Domyślnie | +|---------|------|-----------| +| `DATA_ROOT` | Mount dysku danych | `/data` | +| `LOCALAI_PORT` | Port na hoście | `8080` | +| `LOCALAI_IMAGE` | Obraz Docker (CUDA 13) | `localai/localai:v4.4.3-gpu-nvidia-cuda-13` | +| `CUDA_VISIBLE_DEVICES` | GPU | `0` | +| `DEBUG` | Verbose logs | `false` | + +## VRAM (24 GB) + +Compose ustawia `SINGLE_ACTIVE_BACKEND=true` i `PARALLEL_REQUESTS=false` — jeden aktywny backend/model naraz. + +**Nie uruchamiaj** dużego modelu w vLLM i LocalAI równocześnie na tej samej karcie: + +```bash +cd ../vllm && docker compose --profile vllm down +``` + +## Modele (później) + +- UI → Model Gallery w przeglądarce +- CLI w kontenerze: `docker exec -it localai local-ai models install ...` +- Ręcznie: GGUF + YAML w `/data/apps/localai/models/` + +GGUF z [`stacks/vllm/models.catalog.yaml`](../vllm/models.catalog.yaml) można skopiować lub podlinkować do `models/`. + +## KV cache (skwantyzowany q8_0) + +Domyślnie llama.cpp trzyma KV cache w **f16** — dużo VRAM przy długim kontekście. Ustawienia są **per model** w YAML na `/data`, nie w compose. + +| Pole | Rekomendacja | +|------|--------------| +| `cache_type_k` | `q8_0` | +| `cache_type_v` | `q8_0` | +| `flash_attention` | `true` (wymagane przy q8_0 V) | +| `context_size` | `8192` (start; zwiększ po teście VRAM) | + +Szablon: [`profiles/gemma-4-12b-q4-kv-q8.yaml.example`](profiles/gemma-4-12b-q4-kv-q8.yaml.example) + +Zastosowanie na istniejącym modelu: + +```bash +./scripts/apply-kv-profile.sh gemma-4-12b-it-qat-q4_0 +docker compose --profile localai restart localai +``` + +Szczegóły: [`coding-agent/KV-CACHE.md`](coding-agent/KV-CACHE.md) + +## Zarządzanie + +```bash +docker compose --profile localai ps +docker compose --profile localai logs -f localai +docker compose --profile localai restart localai +docker compose --profile localai down +``` + +## Dokumentacja + +Tutorial: [manual-tutorial/05-localai-stack.md](../../manual-tutorial/05-localai-stack.md) + +Upstream: [github.com/mudler/LocalAI](https://github.com/mudler/LocalAI) diff --git a/stacks/localai/coding-agent/BACKLOG.md b/stacks/localai/coding-agent/BACKLOG.md new file mode 100644 index 0000000..cb15df4 --- /dev/null +++ b/stacks/localai/coding-agent/BACKLOG.md @@ -0,0 +1,28 @@ +# BACKLOG — LocalAI stack + +## P0 — KV cache (bieżąca sesja) + +- [x] Audyt konfiguracji KV (compose, YAML, backendy) +- [x] Szablon `profiles/gemma-4-12b-q4-kv-q8.yaml.example` +- [x] Skrypt `scripts/apply-kv-profile.sh` +- [x] Aktualizacja YAML Gemma na `/data` (via `docker exec` — plik root-owned) +- [x] Restart `localai` — health OK +- [ ] **Test VRAM po załadowaniu Gemma GGUF** — uzupełnić STATE.md + +## P1 — po pierwszym modelu chat + +- [ ] Dostroić `context_size` (8192 → 16384 jeśli VRAM pozwala) +- [ ] Porównanie jakości odpowiedzi f16 vs q8_0 KV (krótki prompt) +- [ ] Profil KV dla Qwen3.6-27B GGUF (gdy dodany do LocalAI) +- [ ] Przekazać `LOCALAI_API_KEY` do `docker-compose.yml` (zsynchronizować z root BACKLOG) + +## P2 — opcjonalnie + +- [ ] Backend `turboquant` + `turbo3`/`turbo4` (~3–4× kompresja KV) +- [ ] Skrypt sync GGUF z `stacks/vllm/models.catalog.yaml` +- [ ] Reverse proxy + firewall (root tutorial 07) + +## P3 — dokumentacja + +- [ ] Zsynchronizować port 8070 vs 8080 w całym repo (root BACKLOG) +- [ ] Przykład `curl /v1/chat/completions` z auth w tutorialu diff --git a/stacks/localai/coding-agent/CONVENTIONS.md b/stacks/localai/coding-agent/CONVENTIONS.md new file mode 100644 index 0000000..8cef14c --- /dev/null +++ b/stacks/localai/coding-agent/CONVENTIONS.md @@ -0,0 +1,41 @@ +# Konwencje — stack LocalAI + +Skrót reguł specyficznych dla tego stacku. Pełne konwencje repo: [`../../coding-agent/CONVENTIONS.md`](../../coding-agent/CONVENTIONS.md). + +## Ścieżki + +| Warstwa | Ścieżka | +|---------|---------| +| Repo stack | `ubuntu-bare-metal/stacks/localai/` | +| `.env` (sekrety) | `stacks/localai/.env` — **gitignore** | +| Modele runtime | `/data/apps/localai/models/` | +| YAML modeli | `/data/apps/localai/models/*.yaml` — **poza git** | +| Szablony KV | `stacks/localai/profiles/*.yaml.example` | +| Notatki agenta | `stacks/localai/coding-agent/` | + +## KV cache + +- Ustawienia **tylko** w sekcji `parameters:` pliku YAML modelu. +- Skwantyzowany `cache_type_v` wymaga `flash_attention: true`. +- Dozwolone na `llama-cpp`: `f16`, `f32`, `q8_0`, `q4_0`, `q4_1`, `q5_0`, `q5_1`. +- Startowa rekomendacja: `cache_type_k: q8_0`, `cache_type_v: q8_0`, `context_size: 8192`. +- Modele embedding (np. bge-m3) — **nie** dodawać KV cache. + +## Docker + +```bash +cd stacks/localai +docker compose --profile localai up -d +docker compose --profile localai restart localai +``` + +Port wewnątrz kontenera zawsze **8080**; host mapuje `LOCALAI_PORT` (użytkownik: **8070**). + +## VRAM + +- Jeden duży model chat na GPU naraz. +- Przed loadem dużego modelu: `cd ../vllm && docker compose --profile vllm down`. + +## Sekrety + +- `LOCALAI_API_KEY` — tylko w `.env` na serwerze, nie w `coding-agent/`. diff --git a/stacks/localai/coding-agent/EMBEDDING-STATUS-REPORT.md b/stacks/localai/coding-agent/EMBEDDING-STATUS-REPORT.md new file mode 100644 index 0000000..2236a67 --- /dev/null +++ b/stacks/localai/coding-agent/EMBEDDING-STATUS-REPORT.md @@ -0,0 +1,147 @@ +# RTX1 — raport naprawy embeddingu (odpowiedź dla ai-lawyer-srvr) + +**Typ dokumentu:** raport po stronie **RTX1** (LocalAI na `gmktec-k11`) — odpowiedź na żądanie z hosta dev. +**Data:** 2026-06-30 +**Status:** **NAPRAWIONE** — `POST /v1/embeddings` zwraca HTTP 200, wektor **1024** wymiarów. + +**Adresat:** agent kodujący na hoście dev (`ai-lawyer-srvr`). + +--- + +## 1. Podsumowanie wykonawcze + +| Obszar | Stan | +|--------|------| +| Connectivity (`llm.rtx1.mobile.agency-ai.dev`) | OK (wcześniej potwierdzone przez dev) | +| Graph LLM (`gemma-4-12b-it-qat-q4_0`) | OK | +| Embedding (`bge-m3-FP16.gguf`) | **OK** po poprawce YAML | +| Zmiana `.env` dev | **Nie wymagana** | + +--- + +## 2. Root cause + +Model `bge-m3-FP16.gguf` został zaimportowany z galerii LocalAI z konfiguracją **chat**, bez flagi embedding: + +```yaml +# PRZED (błędne) +known_usecases: + - chat +# brak: embeddings: true +``` + +Skutek: worker llama-cpp padał przy `POST /v1/embeddings` z błędem: + +``` +rpc error: code = Unavailable desc = error reading from server: EOF +``` + +**Nie było to:** zły URL, API key, brak pliku GGUF (1.1 GB OK), ani brak VRAM w stanie idle. + +--- + +## 3. Zastosowana poprawka + +Zaktualizowano `/data/apps/localai/models/bge-m3-FP16.gguf.yaml`: + +```yaml +name: bge-m3-FP16.gguf +backend: llama-cpp +embeddings: true +description: BGE-M3 embedding model (1024 dims) +known_usecases: + - embedding +parameters: + model: bge-m3-FP16.gguf +context_size: 8192 +``` + +Szablon w repo (na przyszłe reimporty): [`../profiles/bge-m3-FP16-embedding.yaml.example`](../profiles/bge-m3-FP16-embedding.yaml.example) + +Restart: `docker compose --profile localai restart localai` + +--- + +## 4. Wyniki testów akceptacyjnych (RTX1) + +| Test | Endpoint | HTTP | Wynik | +|------|----------|------|-------| +| Embeddings lokalnie | `POST http://127.0.0.1:8070/v1/embeddings` | **200** | wektor **1024** dims | +| Chat → embed (swap) | gemma chat, potem bge embed | **200** / **200** | `SINGLE_ACTIVE_BACKEND` OK | +| Embeddings przez domenę z RTX1 | `POST https://llm.rtx1.mobile.agency-ai.dev/v1/embeddings` | timeout (000) | hairpin NAT z wnętrza LAN — **nie blokuje dev** | + +Test lokalny (na RTX1): + +```bash +curl -s http://127.0.0.1:8070/v1/embeddings \ + -H "Authorization: Bearer $LOCALAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"input":"test prawny","model":"bge-m3-FP16.gguf"}' \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['data'][0]['embedding']))" +# Wynik: 1024 +``` + +--- + +## 5. Wartości dla `.env` dev — bez zmian + +```bash +EMBEDDING_URL=https://llm.rtx1.mobile.agency-ai.dev/v1 +GRAPH_LLM_URL=https://llm.rtx1.mobile.agency-ai.dev/v1 +EMBEDDING_MODEL=bge-m3-FP16.gguf +GRAPH_LLM_MODEL=gemma-4-12b-it-qat-q4_0 +EMBEDDING_DIMS=1024 +``` + +`EMBEDDING_API_KEY` / `GRAPH_LLM_API_KEY` — bez zmian (ten sam token co `LOCALAI_API_KEY` na RTX1). + +--- + +## 6. Checklist dla agenta dev (wykonaj teraz) + +1. [ ] `docker compose -f docker-compose.dev.yml exec api python scripts/discover_rtx1_models.py` + Oczekiwane: embed test **OK**, chat test **OK** +2. [ ] `docker compose -f docker-compose.dev.yml up -d api` (jeśli zmieniano `.env`) +3. [ ] Smoke hybrid chat — SSE z `"route": "hybrid"` i semantycznym retrieval +4. [ ] Jeśli ES był indeksowany bez wektorów: `python scripts/reindex_embeddings.py` + +Test z hosta dev: + +```bash +curl -s https://llm.rtx1.mobile.agency-ai.dev/v1/embeddings \ + -H "Authorization: Bearer $EMBEDDING_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"input":"test prawny","model":"bge-m3-FP16.gguf"}' \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print('dims:', len(d['data'][0]['embedding']))" +``` + +Oczekiwane: `dims: 1024` + +--- + +## 7. Architektura (bez zmian) + +- Publiczny URL: `https://llm.rtx1.mobile.agency-ai.dev/v1` +- LocalAI LAN: `http://192.168.100.5:8070` (bind `0.0.0.0`) +- `SINGLE_ACTIVE_BACKEND=true` — gemma i bge przełączają się sekwencyjnie (OK dla hybrid RAG: embed RTX1 → chat RTX2) + +--- + +## 8. Mapa dokumentacji + +| Plik | Opis | +|------|------| +| Ten dokument | Status naprawy embeddingu | +| [`STATE.md`](STATE.md) | Runtime LocalAI na RTX1 | +| [`../profiles/bge-m3-FP16-embedding.yaml.example`](../profiles/bge-m3-FP16-embedding.yaml.example) | Szablon YAML embedding | + +--- + +## 9. Odpowiedź na żądanie (tabela) + +| Punkt | Odpowiedź | +|-------|-----------| +| 1. Root cause | Błędny YAML — `chat` zamiast `embedding`, brak `embeddings: true` | +| 2. Poprawka | YAML + restart LocalAI | +| 3. curl embed | HTTP **200**, **1024** dims (lokalnie na RTX1) | +| 4. Zmiana `id` / dims | **Nie** — `bge-m3-FP16.gguf`, `1024` | diff --git a/stacks/localai/coding-agent/HANDOFF.md b/stacks/localai/coding-agent/HANDOFF.md new file mode 100644 index 0000000..ac65f0c --- /dev/null +++ b/stacks/localai/coding-agent/HANDOFF.md @@ -0,0 +1,46 @@ +# HANDOFF — LocalAI KV cache + +## Cel sesji + +Przeanalizować konfigurację KV cache w LocalAI i włączyć **skwantyzowany KV** (`q8_0`), aby zmieścić większe modele / dłuższy kontekst na RTX 3090 Ti (24 GB). + +## Wynik audytu + +1. W [`docker-compose.yml`](../docker-compose.yml) i [`.env`](../.env) **brak** ustawień KV — to prawidłowe; LocalAI konfiguruje KV w YAML modelu. +2. YAML `gemma-4-12b-it-qat-q4_0` na `/data` nie miał `cache_type_k`, `cache_type_v`, `flash_attention`, `context_size` → domyślnie **f16** KV (więcej VRAM). +3. Backend: tylko `cuda13-llama-cpp`. TurboQuant **nie** instalowany. +4. Plik GGUF Gemma **jeszcze nie pobrany** — YAML gotowy przed pierwszym loadem. + +## Decyzja + +| Opcja | Wybór | +|-------|-------| +| Standard `q8_0` + `flash_attention` na `llama-cpp` | **TAK** | +| Backend `turboquant` (turbo3/4) | **NIE** (odłożone) | + +Uzasadnienie: ~2× mniej pamięci KV vs f16, bez nowego backendu, minimalny wpływ na jakość. + +## Co zrobiono w repo + +- Katalog `coding-agent/` (ten handoff + STATE, BACKLOG, KV-CACHE, CONVENTIONS) +- [`profiles/gemma-4-12b-q4-kv-q8.yaml.example`](../profiles/gemma-4-12b-q4-kv-q8.yaml.example) +- [`scripts/apply-kv-profile.sh`](../scripts/apply-kv-profile.sh) +- Sekcja KV w README stacku i tutorialu 05 + +## Co zrobiono na serwerze + +- Zaktualizowano `/data/apps/localai/models/gemma-4-12b-it-qat-q4_0.yaml` (parametry KV) +- Restart kontenera `localai` + +## Następne kroki (dla agenta / użytkownika) + +1. Dokończyć pobieranie GGUF Gemma 4 12B (galeria UI lub URI z `._gallery_*.yaml`). +2. Po loadzie: `nvidia-smi` + krótki chat — zapisać VRAM w [STATE.md](STATE.md). +3. Jeśli zapas VRAM: podnieść `context_size` do 16384 w YAML. +4. Dla przyszłego Qwen3.6-27B GGUF: skopiować wzorzec KV z `profiles/`. +5. BACKLOG P0 root repo: przekazać `LOCALAI_API_KEY` do compose (osobne zadanie). + +## Dokumentacja zewnętrzna + +- [LocalAI model configuration — cache_type_k/v](https://localai.io/advanced/model-configuration/) +- [Text generation — llama-cpp backend](https://localai.io/features/text-generation/) diff --git a/stacks/localai/coding-agent/KV-CACHE.md b/stacks/localai/coding-agent/KV-CACHE.md new file mode 100644 index 0000000..b6ce9b1 --- /dev/null +++ b/stacks/localai/coding-agent/KV-CACHE.md @@ -0,0 +1,84 @@ +# KV cache w LocalAI (llama-cpp) + +## Problem + +Przy inference LLM pamięć KV rośnie z długością kontekstu. Domyślnie LocalAI/llama.cpp używa **f16** dla K i V — pełna precyzja, najwięcej VRAM. + +Na RTX 3090 Ti (24 GB) przy modelu Q4 + mmproj (Gemma 4 12B) kwantyzacja KV zwalnia miejsce na dłuższy `context_size` lub większy model. + +## Gdzie konfigurować + +| Miejsce | KV cache? | +|---------|-----------| +| `docker-compose.yml` | nie | +| `.env` stacku | nie | +| `models/.yaml` → `parameters:` | **tak** | + +## Pola YAML (llama-cpp) + +| Pole | Typ | Domyślnie | Opis | +|------|-----|-----------|------| +| `cache_type_k` | string | `f16` | Kwantyzacja cache kluczy (`-ctk` w llama.cpp) | +| `cache_type_v` | string | `f16` | Kwantyzacja cache wartości (`-ctv`) | +| `flash_attention` | bool/string | off | **Wymagane** przy skwantyzowanym `cache_type_v` | +| `context_size` | int | niski / auto | Maks. tokenów kontekstu (wpływa na rozmiar KV) | + +### Dozwolone typy (`cuda13-llama-cpp`) + +`f16`, `f32`, `q8_0`, `q4_0`, `q4_1`, `q5_0`, `q5_1` + +### Rekomendacja dla tego serwera (q8_0) + +```yaml +parameters: + cache_type_k: q8_0 + cache_type_v: q8_0 + flash_attention: true + context_size: 8192 +``` + +Po teście VRAM można podnieść `context_size` do `16384`. + +## Szacunek VRAM (Gemma 4 12B Q4_0 + mmproj) + +| Składnik | Orientacyjnie | +|----------|---------------| +| Wagi + mmproj | ~8–10 GB | +| KV @ f16, ctx 8k | ~2–4 GB | +| KV @ q8_0, ctx 8k | ~1–2 GB | + +## TurboQuant (odłożone) + +Backend `turboquant` + typy `turbo2`/`turbo3`/`turbo4` dają większą kompresję (~3–4×), ale wymagają: + +```bash +docker exec localai /local-ai backends install turboquant +``` + +oraz `backend: turboquant` w YAML. Nie wdrożone w bieżącej sesji. + +## Zastosowanie profilu + +```bash +cd stacks/localai +./scripts/apply-kv-profile.sh gemma-4-12b-it-qat-q4_0 +docker compose --profile localai restart localai +``` + +## Weryfikacja + +```bash +# modele +curl -s http://localhost:8070/v1/models -H "Authorization: Bearer $KEY" + +# VRAM +nvidia-smi + +# logi backendu +docker compose --profile localai logs localai 2>&1 | grep -iE 'cache|ctk|ctv|flash' | tail -20 +``` + +## Źródła + +- https://localai.io/advanced/model-configuration/ +- https://localai.io/features/text-generation/ diff --git a/stacks/localai/coding-agent/README.md b/stacks/localai/coding-agent/README.md new file mode 100644 index 0000000..1340180 --- /dev/null +++ b/stacks/localai/coding-agent/README.md @@ -0,0 +1,25 @@ +# coding-agent — notatki dla agenta (stack LocalAI) + +Katalog handoff dla sesji Cursor pracujących nad [`stacks/localai/`](../) na serwerze GMKtec K11. + +## Kolejność czytania + +1. **[HANDOFF.md](HANDOFF.md)** — decyzje (KV q8_0), audyt, następne kroki +2. **[STATE.md](STATE.md)** — stan runtime: kontener, modele, backendy, VRAM +3. **[KV-CACHE.md](KV-CACHE.md)** — referencja techniczna KV cache w YAML +4. **[BACKLOG.md](BACKLOG.md)** — priorytetyzowane zadania +5. **[CONVENTIONS.md](CONVENTIONS.md)** — ścieżki, sekrety, konwencje stacku + +Wspólne konwencje repo: [`../../coding-agent/CONVENTIONS.md`](../../coding-agent/CONVENTIONS.md) + +## Zasady + +- Instrukcje dla użytkownika: **po polsku**. Komendy: **po angielsku**. +- **Nie commituj** ani nie zapisuj tutaj wartości `LOCALAI_API_KEY`, tokenów HF itd. +- YAML modeli na `/data/apps/localai/models/` **nie są w git** — szablony trzymaj w [`../profiles/`](../profiles/). +- Commity i push **tylko na prośbę** użytkownika. +- Nie edytuj plików planu w `.cursor/plans/`. + +## Ostatnia aktualizacja + +Sesja: audyt KV cache → wdrożenie `cache_type_k/v: q8_0` + `flash_attention` dla modeli chat (llama-cpp). diff --git a/stacks/localai/coding-agent/STATE.md b/stacks/localai/coding-agent/STATE.md new file mode 100644 index 0000000..51c1af4 --- /dev/null +++ b/stacks/localai/coding-agent/STATE.md @@ -0,0 +1,62 @@ +# STATE — LocalAI runtime + +Ostatnia aktualizacja: po wdrożeniu BGE-Reranker-v2-m3 (2026-07-01). + +## Kontener + +| Element | Wartość | +|---------|---------| +| Nazwa | `localai` | +| Obraz | `localai/localai:v4.4.3-gpu-nvidia-cuda-13` | +| Status | running (healthy) | +| Port hosta | **8070** → 8080 w kontenerze (`0.0.0.0` — LAN) | +| GPU | `CUDA_VISIBLE_DEVICES=0` (RTX 3090 Ti) | +| API auth | `LOCALAI_API_KEY` w compose — 401 bez Bearer | + +## Publiczny endpoint (NPMPlus) + +| Element | Wartość | +|---------|---------| +| Domena | `https://llm.rtx1.mobile.agency-ai.dev/v1` | +| Upstream | `http://127.0.0.1:8070` (lub LAN `192.168.100.5:8070`) | + +## Backendy + +| Backend | Zainstalowany | +|---------|---------------| +| `cuda13-llama-cpp` (alias `llama-cpp`) | tak | +| `turboquant` | **nie** (odłożone) | + +## Modele (`/data/apps/localai/models/`) + +| Model | GGUF | YAML | Status API | +|-------|------|------|------------| +| `gemma-4-12b-it-qat-q4_0` | tak (~6.5 GB) | KV `q8_0`, `flash_attention`, `context_size: 8192` | chat **OK** | +| `bge-m3-FP16.gguf` | tak (1.1 GB) | `embeddings: true`, `known_usecases: [embedding]` | embed **OK**, 1024 dims | +| `bge-reranker-v2-m3-FP16.gguf` | tak (1.1 GB) | `reranking: true`, `known_usecases: [rerank]`, `backend: llama-cpp` | rerank **OK** | + +Szablony w repo: + +- Gemma KV: [`../profiles/gemma-4-12b-q4-kv-q8.yaml.example`](../profiles/gemma-4-12b-q4-kv-q8.yaml.example) +- BGE embed: [`../profiles/bge-m3-FP16-embedding.yaml.example`](../profiles/bge-m3-FP16-embedding.yaml.example) +- BGE rerank: [`../profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example`](../profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example) + +## Weryfikacja (2026-06-30) + +| Test | Wynik | +|------|-------| +| `GET /readyz` | 200 | +| `GET /v1/models` (auth) | 200 — 2 modele | +| `POST /v1/chat/completions` (gemma) | 200 | +| `POST /v1/embeddings` (bge-m3) | **200**, wektor **1024** | +| `POST /v1/rerank` (bge-reranker) | **200**, indeks 2 (panda) na top | +| Chat → embed (SINGLE_ACTIVE_BACKEND) | 200 / 200 | +| Embeddings z RTX1 przez publiczną domenę | timeout (hairpin NAT) — dev powinien testować z zewnątrz | + +## Ścieżki + +| Co | Gdzie | +|----|-------| +| Repo stack | `/home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/localai` | +| Modele runtime | `/data/apps/localai/models` | +| Raport dla dev | [`EMBEDDING-STATUS-REPORT.md`](EMBEDDING-STATUS-REPORT.md) | diff --git a/stacks/localai/compose.yaml b/stacks/localai/compose.yaml new file mode 120000 index 0000000..412c257 --- /dev/null +++ b/stacks/localai/compose.yaml @@ -0,0 +1 @@ +docker-compose.yml \ No newline at end of file diff --git a/stacks/localai/docker-compose.yml b/stacks/localai/docker-compose.yml new file mode 100644 index 0000000..4c017d5 --- /dev/null +++ b/stacks/localai/docker-compose.yml @@ -0,0 +1,31 @@ +services: + localai: + image: ${LOCALAI_IMAGE:-localai/localai:v4.4.3-gpu-nvidia-cuda-13} + container_name: localai + profiles: + - localai + restart: unless-stopped + init: true + ports: + - "${LOCALAI_PORT:-8080}:8080" + environment: + - LOCALAI_API_KEY=${LOCALAI_API_KEY:-} + - CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0} + - NVIDIA_DRIVER_CAPABILITIES=compute,utility + - MODELS_PATH=/models + - DEBUG=${DEBUG:-false} + - SINGLE_ACTIVE_BACKEND=true + - PARALLEL_REQUESTS=false + volumes: + - ${DATA_ROOT:-/data}/apps/localai/models:/models + - ${DATA_ROOT:-/data}/apps/localai/backends:/backends + - ${DATA_ROOT:-/data}/apps/localai/configuration:/configuration + - ${DATA_ROOT:-/data}/apps/localai/images:/tmp/generated/images + - ${DATA_ROOT:-/data}/apps/localai/data:/data + gpus: all + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/readyz"] + interval: 1m + timeout: 20m + retries: 5 + start_period: 2m diff --git a/stacks/localai/profiles/bge-m3-FP16-embedding.yaml.example b/stacks/localai/profiles/bge-m3-FP16-embedding.yaml.example new file mode 100644 index 0000000..7f9be90 --- /dev/null +++ b/stacks/localai/profiles/bge-m3-FP16-embedding.yaml.example @@ -0,0 +1,16 @@ +# BGE-M3 embedding model for LocalAI (llama-cpp backend). +# Copy to /data/apps/localai/models/bge-m3-FP16.gguf.yaml after gallery import. +# +# GGUF: bge-m3-FP16.gguf (~1.1 GB) in same directory as this YAML. +# API id: bge-m3-FP16.gguf +# Vector dimensions: 1024 + +name: bge-m3-FP16.gguf +backend: llama-cpp +embeddings: true +description: BGE-M3 embedding model (1024 dims) +known_usecases: + - embedding +parameters: + model: bge-m3-FP16.gguf +context_size: 8192 diff --git a/stacks/localai/profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example b/stacks/localai/profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example new file mode 100644 index 0000000..74f5159 --- /dev/null +++ b/stacks/localai/profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example @@ -0,0 +1,27 @@ +# BGE-Reranker-v2-m3 for LocalAI (llama-cpp / cuda13-llama-cpp backend). +# Copy to /data/apps/localai/models/bge-reranker-v2-m3-FP16.gguf.yaml after GGUF download. +# +# GGUF: bge-reranker-v2-m3-FP16.gguf (~1.1 GB) in same directory as this YAML. +# API id: bge-reranker-v2-m3-FP16.gguf +# Endpoint: POST /v1/rerank +# +# Do NOT use backend: rerankers for GGUF — that backend is for HuggingFace transformers. +# cuda13-llama-cpp is selected automatically on the cuda-13 image when backend: llama-cpp. + +name: bge-reranker-v2-m3-FP16.gguf +backend: llama-cpp +reranking: true +embeddings: false +description: BGE-Reranker-v2-m3 cross-encoder (FP16 GGUF) +known_usecases: + - rerank +parameters: + model: bge-reranker-v2-m3-FP16.gguf +context_size: 8192 +template: + use_tokenizer_template: true +function: + grammar: + disable: true +options: + - use_jinja:true diff --git a/stacks/localai/profiles/gemma-4-12b-q4-kv-q8.yaml.example b/stacks/localai/profiles/gemma-4-12b-q4-kv-q8.yaml.example new file mode 100644 index 0000000..d94f07b --- /dev/null +++ b/stacks/localai/profiles/gemma-4-12b-q4-kv-q8.yaml.example @@ -0,0 +1,32 @@ +# Szablon: Gemma 4 12B Q4_0 z kwantyzowanym KV cache (q8_0) +# +# Zastosowanie na serwerze: +# ./scripts/apply-kv-profile.sh gemma-4-12b-it-qat-q4_0 +# lub skopiuj sekcję parameters do /data/apps/localai/models/.yaml +# +# Dokumentacja: coding-agent/KV-CACHE.md + +name: gemma-4-12b-it-qat-q4_0 +backend: llama-cpp +mmproj: llama-cpp/mmproj/gemma-4-12B-it-qat-q4_0-gguf/mmproj-gemma-4-12b-it-qat-q4_0.gguf +known_usecases: + - chat +options: + - use_jinja:true +parameters: + model: llama-cpp/models/gemma-4-12B-it-qat-q4_0-gguf/gemma-4-12b-it-qat-q4_0.gguf + cache_type_k: q8_0 + cache_type_v: q8_0 + flash_attention: true + context_size: 8192 + temperature: 1 + top_p: 0.95 + top_k: 64 + repeat_penalty: 1 + min_p: 0 +template: + use_tokenizer_template: true +function: + automatic_tool_parsing_fallback: true + grammar: + disable: true diff --git a/stacks/localai/scripts/apply-kv-profile.sh b/stacks/localai/scripts/apply-kv-profile.sh new file mode 100755 index 0000000..62b8007 --- /dev/null +++ b/stacks/localai/scripts/apply-kv-profile.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Merge KV cache settings (q8_0) into a model YAML on /data. +# Usage: ./scripts/apply-kv-profile.sh +# Example: ./scripts/apply-kv-profile.sh gemma-4-12b-it-qat-q4_0 +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +if [[ ! -f "${STACK_DIR}/.env" ]]; then + echo "ERROR: ${STACK_DIR}/.env not found" + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +source "${STACK_DIR}/.env" +set +a + +DATA_ROOT="${DATA_ROOT:-/data}" +MODELS_DIR="${DATA_ROOT}/apps/localai/models" + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " + echo "Example: $0 gemma-4-12b-it-qat-q4_0" + exit 1 +fi + +MODEL_NAME="$1" +TARGET="${MODELS_DIR}/${MODEL_NAME}.yaml" + +if [[ ! -f "${TARGET}" ]]; then + echo "ERROR: ${TARGET} not found" + exit 1 +fi + +BACKUP="${TARGET}.bak.$(date +%Y%m%d%H%M%S)" +cp "${TARGET}" "${BACKUP}" +echo "Backup: ${BACKUP}" + +run_python_merge() { + python3 - "${1}" <<'PY' +import sys +from pathlib import Path + +try: + import yaml +except ImportError: + sys.exit("ERROR: python3-yaml required (sudo apt install python3-yaml)") + +path = Path(sys.argv[1]) +data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + +params = data.setdefault("parameters", {}) +kv = { + "cache_type_k": "q8_0", + "cache_type_v": "q8_0", + "flash_attention": True, +} +if "context_size" not in params: + kv["context_size"] = 8192 +params.update(kv) + +path.write_text( + yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False), + encoding="utf-8", +) +print(f"Updated KV settings in {path}") +for k, v in kv.items(): + print(f" {k}: {v}") +if "context_size" in params and "context_size" not in kv: + print(f" context_size: {params['context_size']} (unchanged)") +PY +} + +if run_python_merge "${TARGET}" 2>/dev/null; then + : +elif docker ps --format '{{.Names}}' 2>/dev/null | grep -qx localai; then + echo "Host write failed — applying via docker exec localai (models volume is root-owned)" + CONTAINER_PATH="/models/${MODEL_NAME}.yaml" + docker exec localai sh -c "grep -q cache_type_k '${CONTAINER_PATH}' || sed -i '/^ model:/a\\ + cache_type_k: q8_0\\ + cache_type_v: q8_0\\ + flash_attention: true\\ + context_size: 8192' '${CONTAINER_PATH}'" + grep -E 'cache_type_k|cache_type_v|flash_attention|context_size' "${TARGET}" || true +else + echo "ERROR: cannot write ${TARGET} (permission denied) and container localai not running" + echo "Run: sudo $0 ${MODEL_NAME}" + exit 1 +fi + +echo "" +echo "Restart LocalAI to apply:" +echo " cd ${STACK_DIR} && docker compose --profile localai restart localai" diff --git a/stacks/localai/scripts/clone-upstream.sh b/stacks/localai/scripts/clone-upstream.sh new file mode 100755 index 0000000..5cd715e --- /dev/null +++ b/stacks/localai/scripts/clone-upstream.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +UPSTREAM_DIR="${STACK_DIR}/upstream" +REPO_URL="https://github.com/mudler/LocalAI.git" +TAG="v4.4.3" + +cd "${STACK_DIR}" + +if [[ -d "${UPSTREAM_DIR}/.git" ]]; then + echo "Upstream already cloned: ${UPSTREAM_DIR}" + echo "Remove it first to re-clone: rm -rf upstream" + exit 0 +fi + +echo "=== Cloning LocalAI upstream (reference only) ===" +echo "Repo: ${REPO_URL}" +echo "Tag: ${TAG}" +echo "Dest: ${UPSTREAM_DIR}" +echo "" + +git clone --depth 1 --branch "${TAG}" "${REPO_URL}" "${UPSTREAM_DIR}" + +echo "" +echo "Done. Use upstream/ for example model YAML and compose reference." +echo "Runtime uses the official Docker image from .env — not a local build." diff --git a/stacks/localai/scripts/download-reranker.sh b/stacks/localai/scripts/download-reranker.sh new file mode 100755 index 0000000..eeacf8f --- /dev/null +++ b/stacks/localai/scripts/download-reranker.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Download BGE-Reranker-v2-m3 FP16 GGUF and apply YAML profile. +# Usage: ./scripts/download-reranker.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${STACK_DIR}" + +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +DATA_ROOT="${DATA_ROOT:-/data}" +MODELS_DIR="${DATA_ROOT}/apps/localai/models" +GGUF_NAME="bge-reranker-v2-m3-FP16.gguf" +GGUF_URL="https://huggingface.co/gpustack/bge-reranker-v2-m3-GGUF/resolve/main/${GGUF_NAME}" +YAML_SRC="${STACK_DIR}/profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example" +YAML_DST="${MODELS_DIR}/${GGUF_NAME}.yaml" + +"${SCRIPT_DIR}/ensure-dirs.sh" "${DATA_ROOT}" + +echo "=== BGE-Reranker-v2-m3 download ===" +echo "Target: ${MODELS_DIR}/${GGUF_NAME}" +echo "" + +if [[ -f "${MODELS_DIR}/${GGUF_NAME}" ]]; then + echo "GGUF already exists — skipping download" +else + if command -v wget &>/dev/null; then + wget -c -O "${MODELS_DIR}/${GGUF_NAME}.partial" "${GGUF_URL}" + mv "${MODELS_DIR}/${GGUF_NAME}.partial" "${MODELS_DIR}/${GGUF_NAME}" + else + curl -fL -C - -o "${MODELS_DIR}/${GGUF_NAME}.partial" "${GGUF_URL}" + mv "${MODELS_DIR}/${GGUF_NAME}.partial" "${MODELS_DIR}/${GGUF_NAME}" + fi +fi + +echo "" +echo "=== Applying YAML profile ===" +if cp "${YAML_SRC}" "${YAML_DST}" 2>/dev/null; then + chmod 644 "${YAML_DST}" "${MODELS_DIR}/${GGUF_NAME}" 2>/dev/null || true +elif docker ps --format '{{.Names}}' | grep -qx localai; then + echo "Host copy failed (permissions) — writing via docker exec localai" + docker exec -i localai sh -c "cat > /models/${GGUF_NAME}.yaml" < "${YAML_SRC}" +else + echo "ERROR: cannot write ${YAML_DST} (permission denied) and localai container not running" + exit 1 +fi + +ls -lh "${MODELS_DIR}/${GGUF_NAME}" "${YAML_DST}" + +echo "" +echo "=== Done ===" +echo "Restart LocalAI to load the model:" +echo " cd ${STACK_DIR} && docker compose --profile localai restart localai" +echo "" +echo "Smoke test:" +echo ' curl -s http://127.0.0.1:${LOCALAI_PORT:-8070}/v1/rerank \' +echo ' -H "Authorization: Bearer " \' +echo ' -H "Content-Type: application/json" \' +echo ' -d '"'"'{"model":"bge-reranker-v2-m3-FP16.gguf","query":"panda","documents":["hi","it is a bear","The giant panda is a bear species endemic to China."],"top_n":2}'"'" diff --git a/stacks/localai/scripts/ensure-dirs.sh b/stacks/localai/scripts/ensure-dirs.sh new file mode 100755 index 0000000..8e64e7c --- /dev/null +++ b/stacks/localai/scripts/ensure-dirs.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Create LocalAI data directories on the data disk. + +ensure_localai_dirs() { + local data_root="${1:-/data}" + mkdir -p \ + "${data_root}/apps/localai/models" \ + "${data_root}/apps/localai/backends" \ + "${data_root}/apps/localai/configuration" \ + "${data_root}/apps/localai/images" \ + "${data_root}/apps/localai/data" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + ensure_localai_dirs "${1:-/data}" +fi diff --git a/stacks/localai/scripts/pull.sh b/stacks/localai/scripts/pull.sh new file mode 100755 index 0000000..ae55164 --- /dev/null +++ b/stacks/localai/scripts/pull.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${STACK_DIR}" + +if [[ ! -f .env ]]; then + echo "ERROR: .env not found. Run: cp .env.example .env" + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +source .env +set +a + +if ! docker info &>/dev/null; then + echo "ERROR: Docker is not running" + exit 1 +fi + +echo "=== LocalAI — pull image only (no start) ===" +echo "Image: ${LOCALAI_IMAGE:-localai/localai:v4.4.3-gpu-nvidia-cuda-13}" +echo "" + +docker compose --profile localai pull + +echo "" +echo "Done. Start with: ./scripts/start.sh" diff --git a/stacks/localai/scripts/start.sh b/stacks/localai/scripts/start.sh new file mode 100755 index 0000000..5715076 --- /dev/null +++ b/stacks/localai/scripts/start.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/ensure-dirs.sh" + +cd "${STACK_DIR}" + +if [[ ! -f .env ]]; then + echo "ERROR: .env not found. Run: cp .env.example .env" + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +source .env +set +a + +DATA_ROOT="${DATA_ROOT:-/data}" + +if ! mountpoint -q "${DATA_ROOT}" 2>/dev/null; then + echo "ERROR: ${DATA_ROOT} is not mounted" + echo " Complete disk setup (tutorial 04 part A) first" + exit 1 +fi + +ensure_localai_dirs "${DATA_ROOT}" + +if ! docker info &>/dev/null; then + echo "ERROR: Docker is not running" + exit 1 +fi + +echo "=== LocalAI stack ===" +echo "Image: ${LOCALAI_IMAGE:-localai/localai:v4.4.3-gpu-nvidia-cuda-13}" +echo "Port: ${LOCALAI_PORT:-8080}" +echo "Models: ${DATA_ROOT}/apps/localai/models (empty OK)" +echo "GPU: CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}" +echo "" + +docker compose --profile localai pull +docker compose --profile localai up -d + +echo "" +echo "Started. Follow logs:" +echo " docker compose --profile localai logs -f localai" +echo "" +echo "Health check:" +echo " curl -s http://localhost:${LOCALAI_PORT:-8080}/readyz" +echo "" +echo "Web UI:" +echo " http://localhost:${LOCALAI_PORT:-8080}" diff --git a/stacks/npmplus/.env.example b/stacks/npmplus/.env.example new file mode 100644 index 0000000..a473d90 --- /dev/null +++ b/stacks/npmplus/.env.example @@ -0,0 +1,21 @@ +# Data disk mount point +DATA_ROOT=/data + +# NPMPlus image +NPMPLUS_IMAGE=docker.io/zoeyvid/npmplus:latest + +# Required for Let's Encrypt certificates +ACME_EMAIL=admin@example.com + +# Timezone (TZ database name) +TZ=Europe/Warsaw + +# Optional: set on first start instead of random password in logs +# INITIAL_ADMIN_EMAIL=admin@example.com +# INITIAL_ADMIN_PASSWORD=change-me-strong-password + +# Bind admin UI (port 81) to localhost only — use with SSH tunnel +# NPM_LISTEN_LOCALHOST=true + +# Disable IPv6 listeners if your network has no IPv6 +# DISABLE_IPV6=true diff --git a/stacks/npmplus/.gitignore b/stacks/npmplus/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/stacks/npmplus/.gitignore @@ -0,0 +1 @@ +.env diff --git a/stacks/npmplus/README.md b/stacks/npmplus/README.md new file mode 100644 index 0000000..b16eb57 --- /dev/null +++ b/stacks/npmplus/README.md @@ -0,0 +1,89 @@ +# NPMPlus — reverse proxy + Let's Encrypt + +[NPMPlus](https://github.com/ZoeyVid/NPMPlus) (fork nginx-proxy-manager) exposes backend services on **HTTPS** with automatic certificates. + +On this host it proxies **LocalAI** at `https://llm.rtx1.mobile.agency-ai.dev` → `http://127.0.0.1:8070`. + +## Ports (host network) + +| Port | Protocol | Service | +|------|----------|---------| +| 80 | tcp | HTTP (ACME challenge + redirects) | +| 443 | tcp, udp | HTTPS / HTTP3 | +| 81 | tcp | NPMPlus admin UI (HTTPS) | + +## Prerequisites + +1. `/data` mounted (tutorial 04) +2. DNS `A` record: `llm.rtx1.mobile.agency-ai.dev` → public IP +3. Router port forward: `80`, `443/tcp`, `443/udp` → `192.168.100.5` +4. `ACME_EMAIL` in `.env` (Let's Encrypt notifications) + +## Quick start + +```bash +cd stacks/npmplus +cp .env.example .env +# Edit ACME_EMAIL (and optionally INITIAL_ADMIN_*) +./scripts/start.sh +``` + +Admin UI: `https://:81` (default login `admin@example.org` unless `INITIAL_ADMIN_EMAIL` set). + +## Admin access (LAN) + +Use **`https://192.168.100.90:81`** (or your LAN IP), **not** `http://`. + +| Symptom | Cause | +|---------|-------| +| `http://IP:81` does not load UI | Port 81 serves HTTPS only; HTTP returns 308 redirect | +| Browser shows certificate error | Default cert has no SAN for IP; run cert regeneration (below) | +| `docker ps` shows empty PORTS | Normal with `network_mode: host` — check with `ss -tlnp \| grep ':81'` | + +Regenerate admin cert with SAN for your LAN IP: + +```bash +sudo ./scripts/regenerate-admin-cert.sh +``` + +After regeneration, open `https://:81`, click through the self-signed cert warning once, then log in. + +## Proxy host — LocalAI + +In NPMPlus UI → **Hosts** → **Proxy Hosts** → Add: + +| Field | Value | +|-------|-------| +| Domain | `llm.rtx1.mobile.agency-ai.dev` | +| Scheme | `http` | +| Forward hostname | `127.0.0.1` | +| Forward port | `8070` | +| SSL | Request new certificate, Force SSL, HTTP/2 | +| Websockets | Enabled | + +Clients send `Authorization: Bearer ` (same key as in `stacks/localai/.env`). + +Test: + +```bash +curl -s https://llm.rtx1.mobile.agency-ai.dev/v1/models \ + -H "Authorization: Bearer $LOCALAI_API_KEY" +``` + +## Firewall + +```bash +sudo ./scripts/configure-firewall.sh +``` + +Restricts direct access to LocalAI (8070) and gpu-fan (8090); allows 80/443 for NPMPlus. + +## Data + +Persistent files: `/data/apps/npmplus/` + +## Related + +- LocalAI stack: [`../localai/README.md`](../localai/README.md) +- Client handoff: [`../localai/coding-agent/APP-CLIENT-HANDOFF.md`](../localai/coding-agent/APP-CLIENT-HANDOFF.md) +- Tutorial: [`../../manual-tutorial/07-npmplus-reverse-proxy.md`](../../manual-tutorial/07-npmplus-reverse-proxy.md) diff --git a/stacks/npmplus/compose.yaml b/stacks/npmplus/compose.yaml new file mode 120000 index 0000000..412c257 --- /dev/null +++ b/stacks/npmplus/compose.yaml @@ -0,0 +1 @@ +docker-compose.yml \ No newline at end of file diff --git a/stacks/npmplus/docker-compose.yml b/stacks/npmplus/docker-compose.yml new file mode 100644 index 0000000..7f67b47 --- /dev/null +++ b/stacks/npmplus/docker-compose.yml @@ -0,0 +1,27 @@ +name: npmplus + +services: + npmplus: + image: ${NPMPLUS_IMAGE:-docker.io/zoeyvid/npmplus:latest} + container_name: npmplus + profiles: + - npmplus + restart: unless-stopped + network_mode: host + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + - SETGID + - DAC_OVERRIDE + security_opt: + - no-new-privileges:true + volumes: + - ${DATA_ROOT:-/data}/apps/npmplus:/data + environment: + - TZ=${TZ:-Europe/Warsaw} + - ACME_EMAIL=${ACME_EMAIL} + - INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-} + - INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:-} + - NPM_LISTEN_LOCALHOST=${NPM_LISTEN_LOCALHOST:-false} + - DISABLE_IPV6=${DISABLE_IPV6:-false} diff --git a/stacks/npmplus/scripts/configure-firewall.sh b/stacks/npmplus/scripts/configure-firewall.sh new file mode 100755 index 0000000..e29ba03 --- /dev/null +++ b/stacks/npmplus/scripts/configure-firewall.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# UFW rules for NPMPlus + LocalAI hardening. Run: sudo ./scripts/configure-firewall.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run as root: sudo ${SCRIPT_DIR}/configure-firewall.sh" + exit 1 +fi + +LAN_CIDR="${LAN_CIDR:-192.168.100.0/24}" + +echo "=== NPMPlus firewall (ufw) ===" +echo "LAN CIDR for admin port 81: ${LAN_CIDR}" +echo "" + +if ! command -v ufw &>/dev/null; then + echo "Installing ufw..." + apt-get update -qq && apt-get install -y ufw +fi + +ufw default deny incoming +ufw default allow outgoing + +ufw allow 22/tcp comment 'SSH' +ufw allow 80/tcp comment 'NPMPlus HTTP / ACME' +ufw allow 443/tcp comment 'NPMPlus HTTPS' +ufw allow 443/udp comment 'NPMPlus HTTP/3' +ufw allow from "${LAN_CIDR}" to any port 81 proto tcp comment 'NPMPlus admin LAN only' + +ufw deny 8070/tcp comment 'LocalAI localhost only' +ufw deny 8090/tcp comment 'gpu-fan not public' + +echo "" +echo "Rules to apply:" +ufw show added || true +echo "" + +ufw --force enable +ufw status verbose + +echo "" +echo "Done. Admin UI: https://:81 (from ${LAN_CIDR} only)" +echo "Public HTTPS: ports 80/443" diff --git a/stacks/npmplus/scripts/configure-localai-proxy.sh b/stacks/npmplus/scripts/configure-localai-proxy.sh new file mode 100755 index 0000000..03dffef --- /dev/null +++ b/stacks/npmplus/scripts/configure-localai-proxy.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Create NPMPlus proxy host for LocalAI + request Let's Encrypt cert. +# Usage: ./scripts/configure-localai-proxy.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${STACK_DIR}" + +if [[ ! -f .env ]]; then + echo "ERROR: .env not found" + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +source .env +set +a + +export INITIAL_ADMIN_EMAIL INITIAL_ADMIN_PASSWORD ACME_EMAIL \ + LOCALAI_PROXY_DOMAIN LOCALAI_FORWARD_PORT + +python3 <<'PY' +import json +import os +import ssl +import sys +import urllib.error +import urllib.request +import http.cookiejar + +domain = os.environ.get("LOCALAI_PROXY_DOMAIN", "llm.rtx1.mobile.agency-ai.dev") +forward_port = int(os.environ.get("LOCALAI_FORWARD_PORT", "8070")) +admin_email = os.environ.get("INITIAL_ADMIN_EMAIL", "") +admin_pass = os.environ.get("INITIAL_ADMIN_PASSWORD", "") +acme_email = os.environ.get("ACME_EMAIL", admin_email) + +if not admin_email or not admin_pass: + print("ERROR: Set INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD in .env", file=sys.stderr) + sys.exit(1) + +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + +cj = http.cookiejar.CookieJar() +opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(cj), + urllib.request.HTTPSHandler(context=ctx), +) + + +def api(method: str, path: str, payload=None): + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(f"https://127.0.0.1:81{path}", data=data, headers=headers, method=method) + with opener.open(req) as resp: + body = resp.read() + return json.loads(body) if body else {} + + +try: + api("POST", "/api/tokens", {"identity": admin_email, "secret": admin_pass}) +except urllib.error.HTTPError as e: + print(f"ERROR: NPMPlus login failed ({e.code})", file=sys.stderr) + print(e.read().decode()[:300], file=sys.stderr) + sys.exit(1) + +if not any(c.name == "__Host-Http-token" for c in cj): + print("ERROR: NPMPlus login did not return session cookie", file=sys.stderr) + sys.exit(1) + +hosts = api("GET", "/api/nginx/proxy-hosts") +for host in hosts: + if domain in host.get("domain_names", []): + print(f"Proxy host already exists (id={host['id']}) for {domain}") + sys.exit(0) + +payload = { + "domain_names": [domain], + "forward_scheme": "http", + "forward_host": "127.0.0.1", + "forward_port": forward_port, + "access_list_id": 0, + "certificate_id": "new", + "ssl_forced": True, + "http2_support": True, + "block_exploits": True, + "allow_websocket_upgrade": True, + "meta": { + "letsencrypt_email": acme_email, + "letsencrypt_agree": True, + "dns_challenge": False, + }, +} + +try: + result = api("POST", "/api/nginx/proxy-hosts", payload) +except urllib.error.HTTPError as e: + print(f"ERROR: create proxy host failed ({e.code})", file=sys.stderr) + print(e.read().decode()[:500], file=sys.stderr) + sys.exit(1) + +print(json.dumps(result, indent=2)) +print(f"\nProxy host created for https://{domain} -> 127.0.0.1:{forward_port}") +print("Certificate issuance may take 1-2 minutes (check NPMPlus logs).") +PY diff --git a/stacks/npmplus/scripts/enable-http-proxy.sh b/stacks/npmplus/scripts/enable-http-proxy.sh new file mode 100755 index 0000000..11ac207 --- /dev/null +++ b/stacks/npmplus/scripts/enable-http-proxy.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Enable HTTP proxy for LocalAI (no SSL) — use when LE cert is pending port-forward. +# After cert works, enable SSL in NPMPlus UI or re-run configure-localai-proxy.sh. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${STACK_DIR}" + +set -a +# shellcheck disable=SC1091 +source .env +set +a + +export INITIAL_ADMIN_EMAIL INITIAL_ADMIN_PASSWORD LOCALAI_PROXY_DOMAIN LOCALAI_FORWARD_PORT + +python3 <<'PY' +import json +import os +import ssl +import sys +import urllib.error +import urllib.request +import http.cookiejar + +domain = os.environ.get("LOCALAI_PROXY_DOMAIN", "llm.rtx1.mobile.agency-ai.dev") +forward_port = int(os.environ.get("LOCALAI_FORWARD_PORT", "8070")) + +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE +cj = http.cookiejar.CookieJar() +opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(cj), + urllib.request.HTTPSHandler(context=ctx), +) + + +def api(method, path, payload=None): + data = json.dumps(payload).encode() if payload is not None else None + headers = {"Content-Type": "application/json"} if payload is not None else {} + req = urllib.request.Request( + f"https://127.0.0.1:81{path}", data=data, headers=headers, method=method + ) + with opener.open(req) as resp: + body = resp.read() + return json.loads(body) if body else {} + + +api("POST", "/api/tokens", { + "identity": os.environ["INITIAL_ADMIN_EMAIL"], + "secret": os.environ["INITIAL_ADMIN_PASSWORD"], +}) + +hosts = api("GET", "/api/nginx/proxy-hosts") +host = next((h for h in hosts if domain in h.get("domain_names", [])), None) +if not host: + print(f"No proxy host for {domain}", file=sys.stderr) + sys.exit(1) + +host_id = host["id"] +payload = { + **{k: host[k] for k in host if k not in ("id", "created_on", "modified_on", "meta")}, + "ssl_forced": False, + "certificate_id": 0, + "http2_support": False, + "allow_websocket_upgrade": True, + "forward_scheme": "http", + "forward_host": "127.0.0.1", + "forward_port": forward_port, +} + +try: + result = api("PUT", f"/api/nginx/proxy-hosts/{host_id}", payload) +except urllib.error.HTTPError as e: + print(f"ERROR ({e.code}):", e.read().decode()[:500], file=sys.stderr) + sys.exit(1) + +print(f"Updated proxy host {host_id}: HTTP only until LE cert is ready") +print(json.dumps({"id": result.get("id"), "domain_names": result.get("domain_names")}, indent=2)) +PY diff --git a/stacks/npmplus/scripts/ensure-dirs.sh b/stacks/npmplus/scripts/ensure-dirs.sh new file mode 100755 index 0000000..b03339d --- /dev/null +++ b/stacks/npmplus/scripts/ensure-dirs.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Create NPMPlus data directory on the data disk. + +ensure_npmplus_dirs() { + local data_root="${1:-/data}" + mkdir -p "${data_root}/apps/npmplus" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + ensure_npmplus_dirs "${1:-/data}" +fi diff --git a/stacks/npmplus/scripts/regenerate-admin-cert.sh b/stacks/npmplus/scripts/regenerate-admin-cert.sh new file mode 100755 index 0000000..5ad5d20 --- /dev/null +++ b/stacks/npmplus/scripts/regenerate-admin-cert.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Regenerate NPMPlus admin UI TLS cert (port 81) with SAN for LAN IP access. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${STACK_DIR}" + +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +DATA_ROOT="${DATA_ROOT:-/data}" +TLS_DIR="${DATA_ROOT}/apps/npmplus/tls" +LAN_IP="${LAN_IP:-$(hostname -I 2>/dev/null | awk '{print $1}')}" +HOST_SHORT="${HOST_SHORT:-$(hostname -s 2>/dev/null || hostname)}" + +if [[ -z "${LAN_IP}" ]]; then + echo "ERROR: Could not detect LAN IP. Set LAN_IP in environment." + exit 1 +fi + +if [[ ! -d "${TLS_DIR}" ]]; then + echo "ERROR: ${TLS_DIR} not found. Start NPMPlus once: ./scripts/start.sh" + exit 1 +fi + +SAN="IP:${LAN_IP},DNS:${HOST_SHORT},DNS:localhost" +TS="$(date +%Y%m%d%H%M%S)" + +echo "=== NPMPlus admin cert regeneration ===" +echo "TLS dir: ${TLS_DIR}" +echo "SAN: ${SAN}" +echo "" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Re-run as root to write certs owned by container:" + echo " sudo ${SCRIPT_DIR}/regenerate-admin-cert.sh" + exit 1 +fi + +for f in dummycert.pem dummykey.pem; do + if [[ -f "${TLS_DIR}/${f}" ]]; then + cp -a "${TLS_DIR}/${f}" "${TLS_DIR}/${f}.bak.${TS}" + echo "Backed up ${f} -> ${f}.bak.${TS}" + fi +done + +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +openssl req -x509 -nodes -days 3650 -newkey rsa:4096 \ + -keyout "${TMP}/dummykey.pem" \ + -out "${TMP}/dummycert.pem" \ + -subj "/CN=${HOST_SHORT}" \ + -addext "subjectAltName=${SAN}" + +install -m 600 -o root -g root "${TMP}/dummykey.pem" "${TLS_DIR}/dummykey.pem" +install -m 644 -o root -g root "${TMP}/dummycert.pem" "${TLS_DIR}/dummycert.pem" + +echo "" +echo "Installed new cert. Verifying SAN:" +openssl x509 -in "${TLS_DIR}/dummycert.pem" -noout -text | grep -A1 'Subject Alternative Name' || true + +if docker ps --format '{{.Names}}' | grep -qx npmplus; then + echo "" + echo "Restarting npmplus..." + docker compose --profile npmplus restart npmplus +else + echo "" + echo "Container npmplus not running — start with: ./scripts/start.sh" +fi + +echo "" +echo "Admin UI: https://${LAN_IP}:81" +echo "Use HTTPS (not http). Accept the self-signed cert warning once in the browser." diff --git a/stacks/npmplus/scripts/start.sh b/stacks/npmplus/scripts/start.sh new file mode 100755 index 0000000..2257cad --- /dev/null +++ b/stacks/npmplus/scripts/start.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/ensure-dirs.sh" + +cd "${STACK_DIR}" + +if [[ ! -f .env ]]; then + echo "ERROR: .env not found. Run: cp .env.example .env" + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +source .env +set +a + +DATA_ROOT="${DATA_ROOT:-/data}" + +if [[ -z "${ACME_EMAIL:-}" || "${ACME_EMAIL}" == "admin@example.com" ]]; then + echo "ERROR: Set ACME_EMAIL in .env (required for Let's Encrypt)" + exit 1 +fi + +if ! mountpoint -q "${DATA_ROOT}" 2>/dev/null; then + echo "ERROR: ${DATA_ROOT} is not mounted" + exit 1 +fi + +ensure_npmplus_dirs "${DATA_ROOT}" + +if ! docker info &>/dev/null; then + echo "ERROR: Docker is not running" + exit 1 +fi + +if ss -tlnp 2>/dev/null | grep -qE ':80 |:443 '; then + echo "WARNING: Port 80 or 443 already in use — NPMPlus needs both for Let's Encrypt" + ss -tlnp 2>/dev/null | grep -E ':80 |:443 ' || true +fi + +echo "=== NPMPlus stack ===" +echo "Image: ${NPMPLUS_IMAGE:-docker.io/zoeyvid/npmplus:latest}" +echo "Data: ${DATA_ROOT}/apps/npmplus" +echo "Ports: 80, 443 (tcp+udp), 81 (admin HTTPS)" +echo "ACME: ${ACME_EMAIL}" +echo "" + +docker compose --profile npmplus pull +docker compose --profile npmplus up -d + +echo "" +echo "Started. Admin UI (HTTPS only — http:// redirects and may fail in browser):" +echo " https://$(hostname -I 2>/dev/null | awk '{print $1}'):81" +echo "" +echo "If the browser blocks the cert when opening by IP, run:" +echo " sudo ./scripts/regenerate-admin-cert.sh" +echo "" +echo "First login — check logs for random password if INITIAL_ADMIN_PASSWORD unset:" +echo " docker compose --profile npmplus logs npmplus | grep -i password" +echo "" +echo "Prerequisites before proxy host + SSL:" +echo " DNS A record → this host's public IP" +echo " Router port forward: 80/tcp, 443/tcp, 443/udp → this host" diff --git a/stacks/server-ui/.env.example b/stacks/server-ui/.env.example new file mode 100644 index 0000000..107c517 --- /dev/null +++ b/stacks/server-ui/.env.example @@ -0,0 +1,2 @@ +# DEPRECATED — use stacks/control-plane/.env.example +# Copy: cp ../control-plane/.env.example ../control-plane/.env diff --git a/stacks/server-ui/.gitignore b/stacks/server-ui/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/stacks/server-ui/.gitignore @@ -0,0 +1 @@ +.env diff --git a/stacks/server-ui/Dockerfile b/stacks/server-ui/Dockerfile new file mode 100644 index 0000000..7bd0d33 --- /dev/null +++ b/stacks/server-ui/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim-bookworm + +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends \ + docker.io \ + curl \ + ca-certificates \ + && mkdir -p /usr/local/lib/docker/cli-plugins \ + && curl -fsSL "https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-linux-$(uname -m)" \ + -o /usr/local/lib/docker/cli-plugins/docker-compose \ + && chmod +x /usr/local/lib/docker/cli-plugins/docker-compose \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY server-ui/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY control-plane/env_loader.py ./env_loader.py +COPY server-ui/app.py server-ui/compose_runner.py server-ui/gpu_info.py server-ui/gpu_fan_proxy.py server-ui/file_explorer.py server-ui/stacks.yaml ./ +COPY server-ui/static ./static + +ENV SERVER_UI_HOST=0.0.0.0 +ENV SERVER_UI_PORT=8091 +ENV REPO_ROOT=/repo + +EXPOSE 8091 + +CMD ["python", "app.py"] diff --git a/stacks/server-ui/README.md b/stacks/server-ui/README.md new file mode 100644 index 0000000..3d79609 --- /dev/null +++ b/stacks/server-ui/README.md @@ -0,0 +1,111 @@ +# Server UI — Docker stack manager + +Własny panel do zarządzania znanymi stackami compose na **gmktec-k11**. Zastępuje Dockge i Portainer. Zawiera **przeglądarkę plików**, panel **GPU Fan** (proxy do agenta na hoście) i zarządzanie stackami. + +## Dlaczego nie ma Server UI w `docker ps`? + +`docker ps` pokazuje tylko **kontenery Docker**. Domyślna instalacja Server UI to **systemd na hoście** (`server-ui.service` na porcie **8091**) — nie pojawi się na liście kontenerów. To normalne. + +| Usługa | Runtime | W `docker ps` | +|--------|---------|---------------| +| ComfyUI, LocalAI, vLLM, NPMPlus | Docker compose | tak | +| **Server UI** (native) | systemd → `/opt/server-ui` | **nie** | +| **gpu-fan** agent | systemd → `/opt/gpu-fan` | **nie** | + +Sprawdzenie: `systemctl status server-ui` lub `docker compose --profile server-ui ps` (tryb Docker). + +## Port + +| Serwis | Port | URL | +|--------|------|-----| +| Server UI | **8091** | `http://HOST:8091` | +| CLI (zakładka) | — | ten sam URL, zakładka **CLI** lub `#cli` | +| Pliki (zakładka) | — | ten sam URL, zakładka **Pliki** lub `#files` | +| GPU Fan (zakładka) | — | ten sam URL, zakładka **GPU Fan** | +| gpu-fan agent API | **18090** | tylko localhost (proxy z Server UI) | + +## Instalacja (zalecana — jeden skrypt) + +```bash +cd ~/cursor/ubuntu-bare-metal/stacks/server-ui +sudo ./scripts/install-control-plane.sh +``` + +Menu: +1. **gpu-fan** — tylko native (NVML na hoście; Docker nieobsługiwany) +2. **Server UI** — native (systemd) lub Docker (kontener) + +Flagi nieinteraktywne: +```bash +sudo ./scripts/install-control-plane.sh -y +sudo ./scripts/install-control-plane.sh --gpu-fan=yes --server-ui=docker +``` + +### Tylko Server UI + +| Tryb | Komenda | +|------|---------| +| Native (systemd) | `sudo ./scripts/install.sh` | +| Docker | `sudo ./scripts/install-docker.sh` | + +Klucze API po instalacji: +```bash +bash stacks/server-ui/scripts/show-api-key.sh +``` + +Tutorial klucza: [`../../manual-tutorial/04a-api-key.md`](../../manual-tutorial/04a-api-key.md) + +Dev: `stacks/control-plane/.env` (szablon: `stacks/control-plane/.env.example`) + +Tutorial: [`../../manual-tutorial/08-server-ui-install.md`](../../manual-tutorial/08-server-ui-install.md) · CLI: [`10-server-ui-cli.md`](../../manual-tutorial/10-server-ui-cli.md) · Pliki: [`09-file-explorer.md`](../../manual-tutorial/09-file-explorer.md) + +## Stacki (whitelist) + +| Stack | Profil | Port UI | GPU | +|-------|--------|---------|-----| +| LocalAI | `localai` | 8070 | tak | +| ComfyUI | `comfyui` | 8188 | tak | +| vLLM | `vllm` | 8000 | tak | +| NPMPlus | `npmplus` | 81 (HTTPS) | nie | + +Porty edytowalne w UI (zapis do `stacks//.env`). Szczegóły: [`../../coding-agent/SERVER-UI-PORT-CONFIG.md`](../../coding-agent/SERVER-UI-PORT-CONFIG.md) + +## Bezpieczeństwo + +- Odczyt statusu stacków/logów/GPU — bez klucza (LAN). +- **Start / Stop / Restart** stacków, **GPU Fan**, **Pliki** (CRUD) i **CLI** — nagłówek `X-API-Key` (CLI: klucz w query WebSocket `?api_key=`). +- Przy `SERVER_UI_HOST=0.0.0.0` klucz jest **wymagany** przy starcie usługi. + +## Tryb dev (z repo) + +```bash +cp ../control-plane/.env.example ../control-plane/.env +./scripts/start.sh +``` + +## Struktura + +``` +stacks/server-ui/ +├── app.py +├── Dockerfile +├── docker-compose.yml +├── compose_runner.py +├── stacks.yaml +├── server-ui.service +├── static/index.html +├── static/vendor/xterm/ # xterm.js dla zakładki CLI +├── cli_pty.py +└── scripts/ + ├── install-control-plane.sh # gpu-fan + server-ui (menu) + ├── install.sh # native only + ├── install-docker.sh # docker only + └── start.sh +``` + +## Dokumentacja + +- [`../../manual-tutorial/08-server-ui-install.md`](../../manual-tutorial/08-server-ui-install.md) +- [`../../coding-agent/SERVER-UI-INSTALL-OPTIONS.md`](../../coding-agent/SERVER-UI-INSTALL-OPTIONS.md) +- [`../../coding-agent/DOCKER-UI-DEPLOYMENT.md`](../../coding-agent/DOCKER-UI-DEPLOYMENT.md) +- [`../../coding-agent/ADR-001-host-agent-control-plane.md`](../../coding-agent/ADR-001-host-agent-control-plane.md) diff --git a/stacks/server-ui/app.py b/stacks/server-ui/app.py new file mode 100644 index 0000000..f68c311 --- /dev/null +++ b/stacks/server-ui/app.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python3 +"""GMKtec K11 — Docker stack management UI.""" + +from __future__ import annotations + +import logging +import os +import sys +from pathlib import Path +from typing import Any + +import uvicorn +from fastapi import Depends, FastAPI, HTTPException, Query, Request, WebSocket +from fastapi.responses import FileResponse, Response +from fastapi.staticfiles import StaticFiles + +from pydantic import BaseModel, Field + +from compose_runner import ( + ComposeError, + PortConfigError, + StackConfig, + find_running_gpu_stacks, + get_container_state, + get_logs, + load_stacks, + set_stack_port, + stack_restart, + stack_start, + stack_stop, +) +from cli_pty import CliSessionLimitError, run_pty_session, sessions_available +from file_explorer import FileExplorer, FileExplorerError +from gpu_info import GpuInfoError, query_gpu +from gpu_fan_proxy import AgentProxyError, check_agent_health, forward_request, resolve_agent_url + +STACK_DIR = Path(__file__).resolve().parent + +# Unified control-plane env (see stacks/control-plane/env_loader.py) +for _cp in ("/opt/control-plane", "/repo/stacks/control-plane", str(STACK_DIR.parent / "control-plane")): + if _cp not in sys.path and Path(_cp).exists(): + sys.path.insert(0, _cp) +from env_loader import api_key_source, load_control_plane_env # noqa: E402 +STATIC_DIR = STACK_DIR / "static" +CONFIG_PATH = STACK_DIR / "stacks.yaml" + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +log = logging.getLogger(__name__) + + +ENV = load_control_plane_env(STACK_DIR) +HOST = ENV.get("SERVER_UI_HOST", "0.0.0.0") +PORT = int(ENV.get("SERVER_UI_PORT", "8091")) +API_KEY = ENV.get("API_KEY", "") +GPU_FAN_AGENT_URL = resolve_agent_url( + ENV.get("GPU_FAN_AGENT_URL", "http://127.0.0.1:18090"), + API_KEY, +) +REPO_ROOT = Path( + ENV.get("REPO_ROOT", str(STACK_DIR.parent.parent)) +).resolve() +STACKS_BASE = REPO_ROOT / "stacks" + +STACKS: list[StackConfig] = load_stacks(CONFIG_PATH, STACKS_BASE) +STACK_BY_ID: dict[str, StackConfig] = {s.id: s for s in STACKS} + +FILE_EXPLORER_ROOT = ENV.get("FILE_EXPLORER_ROOT", "/") +FILE_EXPLORER_MAX_BYTES = int(ENV.get("FILE_EXPLORER_MAX_BYTES", "2097152")) +file_explorer = FileExplorer(root=FILE_EXPLORER_ROOT, max_bytes=FILE_EXPLORER_MAX_BYTES) + +CLI_ENABLED = ENV.get("CLI_ENABLED", "1").strip().lower() in ("1", "true", "yes") +CLI_SHELL = ENV.get("CLI_SHELL", "/bin/bash") +CLI_DEFAULT_CWD = ENV.get("CLI_DEFAULT_CWD", os.path.expanduser("~")) +CLI_MAX_SESSIONS = int(ENV.get("CLI_MAX_SESSIONS", "5")) + +app = FastAPI(title="Server UI", version="1.0.0") +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +class PortUpdate(BaseModel): + port: int = Field(ge=1024, le=65535) + recreate: bool = True + + +class FileWriteBody(BaseModel): + path: str + content: str = "" + + +class FilePathBody(BaseModel): + path: str + + +class FileRenameBody(BaseModel): + old_path: str + new_path: str + + +class AuthVerifyBody(BaseModel): + api_key: str = "" + + +def get_stack(stack_id: str) -> StackConfig: + stack = STACK_BY_ID.get(stack_id) + if not stack: + raise HTTPException(status_code=404, detail=f"Unknown stack: {stack_id}") + return stack + + +def require_mutation_auth(request: Request) -> None: + if not API_KEY: + return + key = request.headers.get("X-API-Key", "") + if key != API_KEY: + raise HTTPException(status_code=401, detail="Invalid or missing API key") + + +def _is_lan_bind() -> bool: + return HOST.strip().lower() not in ("127.0.0.1", "localhost", "::1") + + +def require_gpu_fan_read_auth(request: Request) -> None: + if not _is_lan_bind() or not API_KEY: + return + require_mutation_auth(request) + + +def require_file_auth(request: Request) -> None: + if not _is_lan_bind() or not API_KEY: + return + require_mutation_auth(request) + + +def _cli_ws_auth_ok(websocket: WebSocket) -> bool: + if not _is_lan_bind() or not API_KEY: + return True + return websocket.query_params.get("api_key", "") == API_KEY + + +def _file_exc_to_http(exc: FileExplorerError) -> HTTPException: + msg = str(exc) + if "za duż" in msg.lower() or "za duża" in msg.lower(): + return HTTPException(status_code=413, detail=msg) + if "poza dozwolonym" in msg.lower(): + return HTTPException(status_code=403, detail=msg) + if "Brak uprawnień" in msg: + return HTTPException(status_code=403, detail=msg) + if "nie jest pusty" in msg.lower() or "już istnieje" in msg.lower(): + return HTTPException(status_code=409, detail=msg) + if "Nie znaleziono" in msg: + return HTTPException(status_code=404, detail=msg) + if "nie jest katalogiem" in msg.lower() or "To jest katalog" in msg: + return HTTPException(status_code=400, detail=msg) + return HTTPException(status_code=400, detail=msg) + + +def check_gpu_policy(stack: StackConfig) -> None: + if not stack.gpu: + return + conflicts = find_running_gpu_stacks(STACKS, exclude_id=stack.id) + if conflicts: + names = ", ".join(conflicts) + raise HTTPException( + status_code=409, + detail=f"GPU conflict: stop running GPU stack(s) first: {names}", + ) + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +@app.get("/api/health") +def api_health() -> dict[str, bool]: + return {"ok": True} + + +@app.post("/api/auth/verify") +def api_auth_verify(body: AuthVerifyBody) -> dict[str, bool]: + if not API_KEY: + return {"ok": True} + if body.api_key == API_KEY: + return {"ok": True} + raise HTTPException(status_code=401, detail="Invalid or missing API key") + + +@app.get("/api/gpu") +def api_gpu() -> dict[str, Any]: + try: + return query_gpu() + except GpuInfoError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + +@app.get("/api/stacks") +def api_stacks() -> dict[str, Any]: + items = [get_container_state(s) for s in STACKS] + gpu_running = [i["id"] for i in items if i["gpu"] and i["running"]] + return { + "stacks": items, + "gpu_running": gpu_running, + "repo_root": str(REPO_ROOT), + } + + +@app.get("/api/stacks/{stack_id}/logs") +def api_logs(stack_id: str, tail: int = Query(default=100, ge=1, le=500)) -> dict[str, str]: + stack = get_stack(stack_id) + try: + text = get_logs(stack, tail=tail) + except ComposeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"stack_id": stack_id, "logs": text} + + +@app.post("/api/stacks/{stack_id}/start") +def api_start(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict[str, Any]: + stack = get_stack(stack_id) + check_gpu_policy(stack) + try: + output = stack_start(stack) + except ComposeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"ok": True, "action": "start", "stack_id": stack_id, "output": output} + + +@app.post("/api/stacks/{stack_id}/stop") +def api_stop(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict[str, Any]: + stack = get_stack(stack_id) + try: + output = stack_stop(stack) + except ComposeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"ok": True, "action": "stop", "stack_id": stack_id, "output": output} + + +@app.post("/api/stacks/{stack_id}/restart") +def api_restart(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict[str, Any]: + stack = get_stack(stack_id) + if stack.gpu: + conflicts = find_running_gpu_stacks(STACKS, exclude_id=stack.id) + if conflicts: + check_gpu_policy(stack) + try: + output = stack_restart(stack) + except ComposeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"ok": True, "action": "restart", "stack_id": stack_id, "output": output} + + +@app.patch("/api/stacks/{stack_id}/port") +def api_set_port( + stack_id: str, + body: PortUpdate, + _: None = Depends(require_mutation_auth), +) -> dict[str, Any]: + stack = get_stack(stack_id) + if not stack.port_editable or not stack.port_env: + raise HTTPException( + status_code=400, + detail=f"Port is not editable for stack: {stack_id}", + ) + try: + return set_stack_port( + stack, + body.port, + STACKS, + PORT, + recreate=body.recreate, + ) + except PortConfigError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ComposeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@app.get("/api/gpu-fan/health") +def api_gpu_fan_health(_: None = Depends(require_gpu_fan_read_auth)) -> dict[str, Any]: + return check_agent_health(GPU_FAN_AGENT_URL, API_KEY) + + +@app.api_route("/api/gpu-fan/{path:path}", methods=["GET", "PUT", "POST"]) +async def api_gpu_fan_proxy( + path: str, + request: Request, + _: None = Depends(require_gpu_fan_read_auth), +) -> Response: + if request.method in ("PUT", "POST"): + require_mutation_auth(request) + + body = await request.body() + try: + status, resp_headers, payload = forward_request( + GPU_FAN_AGENT_URL, + request.method, + path, + body=body if body else None, + agent_api_key=API_KEY, + content_type=request.headers.get("content-type", "application/json"), + ) + except AgentProxyError as exc: + raise HTTPException( + status_code=502, + detail=f"GPU fan agent unavailable at {GPU_FAN_AGENT_URL}: {exc}", + ) from exc + + media_type = resp_headers.get("Content-Type", "application/json") + return Response(content=payload, status_code=status, media_type=media_type) + + +@app.get("/api/files") +def api_files_list( + path: str = Query(default="/"), + _: None = Depends(require_file_auth), +) -> dict[str, Any]: + try: + return file_explorer.list_directory(path) + except FileExplorerError as exc: + raise _file_exc_to_http(exc) from exc + + +@app.get("/api/files/read") +def api_files_read( + path: str = Query(...), + _: None = Depends(require_file_auth), +) -> dict[str, Any]: + try: + return file_explorer.read_file(path) + except FileExplorerError as exc: + raise _file_exc_to_http(exc) from exc + + +@app.put("/api/files/write") +def api_files_write( + body: FileWriteBody, + _: None = Depends(require_file_auth), +) -> dict[str, Any]: + target = file_explorer.resolve_path(body.path) + if target.exists() and target.is_file(): + try: + info = file_explorer.read_file(body.path) + if info.get("binary"): + raise HTTPException( + status_code=415, + detail="Nie można zapisać pliku binarnego jako tekst", + ) + except FileExplorerError as exc: + raise _file_exc_to_http(exc) from exc + try: + return file_explorer.write_file(body.path, body.content) + except FileExplorerError as exc: + raise _file_exc_to_http(exc) from exc + + +@app.post("/api/files/mkdir") +def api_files_mkdir( + body: FilePathBody, + _: None = Depends(require_file_auth), +) -> dict[str, Any]: + try: + return file_explorer.mkdir(body.path) + except FileExplorerError as exc: + raise _file_exc_to_http(exc) from exc + + +@app.post("/api/files/rename") +def api_files_rename( + body: FileRenameBody, + _: None = Depends(require_file_auth), +) -> dict[str, Any]: + try: + return file_explorer.rename_path(body.old_path, body.new_path) + except FileExplorerError as exc: + raise _file_exc_to_http(exc) from exc + + +@app.delete("/api/files") +def api_files_delete( + path: str = Query(...), + _: None = Depends(require_file_auth), +) -> dict[str, Any]: + try: + return file_explorer.delete_path(path) + except FileExplorerError as exc: + raise _file_exc_to_http(exc) from exc + + +@app.websocket("/api/cli/ws") +async def api_cli_ws(websocket: WebSocket) -> None: + if not _cli_ws_auth_ok(websocket): + await websocket.close(code=1008, reason="Invalid or missing API key") + return + + if not CLI_ENABLED: + await websocket.close(code=1008, reason="CLI disabled") + return + + if not sessions_available(CLI_MAX_SESSIONS): + await websocket.close( + code=1008, + reason=f"Limit sesji CLI ({CLI_MAX_SESSIONS}) — spróbuj później", + ) + return + + await websocket.accept() + + try: + await run_pty_session( + websocket, + shell=CLI_SHELL, + cwd=CLI_DEFAULT_CWD, + env=os.environ.copy(), + max_sessions=CLI_MAX_SESSIONS, + ) + except CliSessionLimitError: + await websocket.close(code=1008, reason="Session limit") + + +def main() -> None: + if HOST.strip() not in ("127.0.0.1", "localhost", "::1") and not API_KEY: + log.error( + "API_KEY is required when SERVER_UI_HOST=%s (LAN bind). Set API_KEY in .env", + HOST, + ) + sys.exit(1) + + if not STACKS: + log.error("No stacks loaded from %s", CONFIG_PATH) + sys.exit(1) + + log.info("Repo root: %s", REPO_ROOT) + log.info("Stacks: %s", ", ".join(s.id for s in STACKS)) + log.info("GPU fan agent: %s", GPU_FAN_AGENT_URL) + if API_KEY: + src = api_key_source(STACK_DIR, ENV) + log.info("API key: configured (source: %s)", src) + else: + log.warning("API_KEY not set (proxy may fail agent auth on LAN)") + if CLI_ENABLED: + log.info("CLI: enabled (shell=%s, cwd=%s, max_sessions=%d)", CLI_SHELL, CLI_DEFAULT_CWD, CLI_MAX_SESSIONS) + else: + log.info("CLI: disabled (CLI_ENABLED=0)") + log.info("Web UI at http://%s:%d", HOST if HOST != "0.0.0.0" else "0.0.0.0", PORT) + uvicorn.run(app, host=HOST, port=PORT, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/stacks/server-ui/cli_pty.py b/stacks/server-ui/cli_pty.py new file mode 100644 index 0000000..c7149e5 --- /dev/null +++ b/stacks/server-ui/cli_pty.py @@ -0,0 +1,174 @@ +"""PTY shell sessions for browser CLI (WebSocket bridge).""" + +from __future__ import annotations + +import asyncio +import fcntl +import json +import logging +import os +import pty +import shlex +import signal +import struct +import termios +from typing import Any + +from starlette.websockets import WebSocket, WebSocketDisconnect + +log = logging.getLogger(__name__) + +TIOCSWINSZ = termios.TIOCSWINSZ + +_active_sessions = 0 +_session_lock = asyncio.Lock() + + +class CliSessionLimitError(Exception): + pass + + +def _set_winsize(fd: int, rows: int, cols: int) -> None: + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(fd, TIOCSWINSZ, winsize) + + +def _parse_shell(shell: str) -> list[str]: + parts = shlex.split(shell.strip()) + return parts if parts else ["/bin/bash"] + + +async def _read_pty_loop( + master_fd: int, + websocket: WebSocket, + running: asyncio.Event, +) -> None: + loop = asyncio.get_running_loop() + while running.is_set(): + try: + data = await loop.run_in_executor(None, os.read, master_fd, 4096) + except OSError: + break + if not data: + break + try: + await websocket.send_bytes(data) + except Exception: + break + + +async def _kill_process(process: asyncio.subprocess.Process) -> None: + if process.returncode is not None: + return + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + try: + await asyncio.wait_for(process.wait(), timeout=2.0) + except asyncio.TimeoutError: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + await process.wait() + + +def sessions_available(max_sessions: int) -> bool: + return _active_sessions < max_sessions + + +async def run_pty_session( + websocket: WebSocket, + *, + shell: str, + cwd: str, + env: dict[str, str], + max_sessions: int, +) -> None: + global _active_sessions + + async with _session_lock: + if _active_sessions >= max_sessions: + raise CliSessionLimitError( + f"Limit sesji CLI ({max_sessions}) — spróbuj później" + ) + _active_sessions += 1 + + master_fd: int | None = None + process: asyncio.subprocess.Process | None = None + read_task: asyncio.Task[None] | None = None + running = asyncio.Event() + running.set() + + try: + master_fd, slave_fd = pty.openpty() + shell_cmd = _parse_shell(shell) + proc_env = {**env, "TERM": "xterm-256color"} + + process = await asyncio.create_subprocess_exec( + *shell_cmd, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=cwd, + env=proc_env, + preexec_fn=os.setsid, + ) + os.close(slave_fd) + slave_fd = -1 + + read_task = asyncio.create_task(_read_pty_loop(master_fd, websocket, running)) + + while True: + message = await websocket.receive() + msg_type = message.get("type") + if msg_type == "websocket.disconnect": + break + + payload: bytes | None = None + if message.get("bytes"): + payload = message["bytes"] + elif message.get("text"): + text = message["text"] + if text.startswith("{"): + try: + obj: dict[str, Any] = json.loads(text) + if obj.get("type") == "resize": + rows = int(obj.get("rows", 24)) + cols = int(obj.get("cols", 80)) + if master_fd is not None: + _set_winsize(master_fd, rows, cols) + continue + except (json.JSONDecodeError, TypeError, ValueError): + pass + payload = text.encode("utf-8") + + if payload and master_fd is not None: + try: + os.write(master_fd, payload) + except OSError: + break + + if process.returncode is not None: + break + + except WebSocketDisconnect: + pass + finally: + running.clear() + if read_task is not None: + read_task.cancel() + try: + await read_task + except asyncio.CancelledError: + pass + if process is not None: + await _kill_process(process) + if master_fd is not None: + try: + os.close(master_fd) + except OSError: + pass + async with _session_lock: + _active_sessions = max(0, _active_sessions - 1) diff --git a/stacks/server-ui/compose_runner.py b/stacks/server-ui/compose_runner.py new file mode 100644 index 0000000..8bea5ff --- /dev/null +++ b/stacks/server-ui/compose_runner.py @@ -0,0 +1,433 @@ +"""Docker Compose helpers for whitelisted stacks.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +RESERVED_PORTS = frozenset({80, 443, 8090, 18090}) +PORT_MIN = 1024 +PORT_MAX = 65535 + + +class ComposeError(RuntimeError): + def __init__(self, message: str, returncode: int = 1, stderr: str = "") -> None: + super().__init__(message) + self.returncode = returncode + self.stderr = stderr + + +class PortConfigError(ValueError): + pass + + +@dataclass(frozen=True) +class StackConfig: + id: str + name: str + compose_dir: str + profile: str + container: str + ui_port: int | None + ui_scheme: str + gpu: bool + port_env: str | None = None + port_default: int | None = None + port_editable: bool = True + + @property + def path(self) -> Path: + return Path(self.compose_dir) + + @property + def env_path(self) -> Path: + return self.path / ".env" + + +def read_env_value(env_path: Path, key: str) -> str | None: + if not env_path.exists(): + return None + prefix = f"{key}=" + for line in env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if line.startswith(prefix): + val = line[len(prefix) :].strip() + if val.startswith('"') and val.endswith('"') and len(val) >= 2: + val = val[1:-1] + elif val.startswith("'") and val.endswith("'") and len(val) >= 2: + val = val[1:-1] + return val + return None + + +def write_env_value(env_path: Path, key: str, value: str) -> None: + env_path.parent.mkdir(parents=True, exist_ok=True) + prefix = f"{key}=" + new_line = f"{key}={value}" + lines: list[str] = [] + found = False + + if env_path.exists(): + for raw in env_path.read_text(encoding="utf-8").splitlines(): + stripped = raw.strip() + if stripped and not stripped.startswith("#") and stripped.startswith(prefix): + lines.append(new_line) + found = True + else: + lines.append(raw) + if not found: + if lines and lines[-1].strip(): + lines.append("") + lines.append(new_line) + + fd, tmp_path = tempfile.mkstemp( + dir=env_path.parent, + prefix=".env.", + suffix=".tmp", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines)) + if lines: + fh.write("\n") + os.replace(tmp_path, env_path) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def resolve_stack_port(stack: StackConfig) -> int | None: + if stack.port_env: + raw = read_env_value(stack.env_path, stack.port_env) + if raw and raw.isdigit(): + return int(raw) + if stack.ui_port is not None: + return stack.ui_port + return stack.port_default + + +def collect_used_ports( + stacks: list[StackConfig], + server_ui_port: int, + exclude_stack_id: str | None = None, +) -> set[int]: + used = set(RESERVED_PORTS) | {server_ui_port} + for s in stacks: + if s.id == exclude_stack_id: + continue + port = resolve_stack_port(s) + if port is not None: + used.add(port) + return used + + +def validate_port( + port: int, + stack: StackConfig, + stacks: list[StackConfig], + server_ui_port: int, +) -> None: + if not PORT_MIN <= port <= PORT_MAX: + raise PortConfigError(f"Port must be between {PORT_MIN} and {PORT_MAX}") + if not stack.port_editable or not stack.port_env: + raise PortConfigError(f"Port is not editable for stack: {stack.id}") + conflicts = collect_used_ports(stacks, server_ui_port, exclude_stack_id=stack.id) + if port in conflicts: + raise PortConfigError(f"Port {port} is already in use or reserved") + + +def get_published_host_port(stack: StackConfig) -> int | None: + proc = subprocess.run( + [ + "docker", + "port", + stack.container, + ], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + if proc.returncode != 0 or not proc.stdout.strip(): + return None + for line in proc.stdout.strip().splitlines(): + # 0.0.0.0:8070/tcp or [::]:8070/tcp + m = re.search(r":(\d+)/", line) + if m: + return int(m.group(1)) + return None + + +def load_stacks(config_path: Path, stacks_base: Path) -> list[StackConfig]: + import yaml # PyYAML optional — parse minimal yaml ourselves if missing + + text = config_path.read_text(encoding="utf-8") + try: + data = yaml.safe_load(text) + except Exception: + data = _parse_stacks_yaml_simple(text) + + items = data.get("stacks", []) if isinstance(data, dict) else [] + stacks: list[StackConfig] = [] + for item in items: + compose_rel = item["compose_dir"] + full = stacks_base / compose_rel + stacks.append( + StackConfig( + id=item["id"], + name=item["name"], + compose_dir=str(full), + profile=item["profile"], + container=item.get("container", item["id"]), + ui_port=item.get("ui_port"), + ui_scheme=item.get("ui_scheme") or "http", + gpu=bool(item.get("gpu", False)), + port_env=item.get("port_env"), + port_default=item.get("port_default"), + port_editable=bool(item.get("port_editable", item.get("port_env") is not None)), + ) + ) + return stacks + + +def _parse_stacks_yaml_simple(text: str) -> dict[str, Any]: + """Minimal parser fallback when PyYAML is not installed.""" + import re + + stacks: list[dict[str, Any]] = [] + current: dict[str, Any] | None = None + for line in text.splitlines(): + if re.match(r"^\s*-\s+id:\s*", line): + if current: + stacks.append(current) + current = {"id": line.split(":", 1)[1].strip()} + elif current is not None and ":" in line: + key, _, val = line.strip().partition(":") + key = key.strip() + val = val.strip() + if val == "null": + val = None + elif val == "true": + val = True + elif val == "false": + val = False + elif val.isdigit(): + val = int(val) + current[key] = val + if current: + stacks.append(current) + return {"stacks": stacks} + + +def _run_compose( + stack: StackConfig, + *args: str, + timeout: int = 120, +) -> subprocess.CompletedProcess[str]: + cmd = [ + "docker", + "compose", + "--project-directory", + stack.compose_dir, + "--profile", + stack.profile, + *args, + ] + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + + +def get_container_state(stack: StackConfig) -> dict[str, Any]: + proc = _run_compose( + stack, + "ps", + "--format", + "json", + timeout=30, + ) + running = False + status = "stopped" + health = None + + if proc.returncode == 0 and proc.stdout.strip(): + for line in proc.stdout.strip().splitlines(): + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + name = row.get("Name", "") + if stack.container in name or name == stack.container: + state = row.get("State", "").lower() + health = row.get("Health") + if state == "running": + running = True + status = "running" + elif state: + status = state + break + else: + ps = subprocess.run( + [ + "docker", + "ps", + "-a", + "--filter", + f"name=^{stack.container}$", + "--format", + "{{.State}}", + ], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + if ps.stdout.strip(): + status = ps.stdout.strip().lower() + running = status == "running" + + ui_port = resolve_stack_port(stack) + published = get_published_host_port(stack) if running else None + port_pending_restart = bool( + running and ui_port is not None and published is not None and published != ui_port + ) + + return { + "id": stack.id, + "name": stack.name, + "profile": stack.profile, + "container": stack.container, + "ui_port": ui_port, + "ui_scheme": stack.ui_scheme, + "gpu": stack.gpu, + "running": running, + "status": status, + "health": health, + "compose_dir": stack.compose_dir, + "port_env": stack.port_env, + "port_editable": stack.port_editable and bool(stack.port_env), + "port_default": stack.port_default, + "published_port": published, + "port_pending_restart": port_pending_restart, + } + + +def get_logs(stack: StackConfig, tail: int = 100) -> str: + proc = _run_compose( + stack, + "logs", + "--tail", + str(tail), + "--no-color", + timeout=60, + ) + if proc.returncode != 0: + raise ComposeError( + proc.stderr.strip() or "Failed to fetch logs", + proc.returncode, + proc.stderr, + ) + return proc.stdout + + +def stack_start(stack: StackConfig) -> str: + proc = _run_compose(stack, "up", "-d", timeout=300) + if proc.returncode != 0: + raise ComposeError( + proc.stderr.strip() or proc.stdout.strip() or "start failed", + proc.returncode, + proc.stderr, + ) + return (proc.stdout + proc.stderr).strip() + + +def stack_stop(stack: StackConfig) -> str: + proc = _run_compose(stack, "stop", timeout=120) + if proc.returncode != 0: + raise ComposeError( + proc.stderr.strip() or "stop failed", + proc.returncode, + proc.stderr, + ) + return (proc.stdout + proc.stderr).strip() + + +def stack_restart(stack: StackConfig) -> str: + proc = _run_compose(stack, "restart", timeout=180) + if proc.returncode != 0: + raise ComposeError( + proc.stderr.strip() or "restart failed", + proc.returncode, + proc.stderr, + ) + return (proc.stdout + proc.stderr).strip() + + +def stack_recreate(stack: StackConfig) -> str: + proc = _run_compose(stack, "up", "-d", "--force-recreate", timeout=300) + if proc.returncode != 0: + raise ComposeError( + proc.stderr.strip() or proc.stdout.strip() or "recreate failed", + proc.returncode, + proc.stderr, + ) + return (proc.stdout + proc.stderr).strip() + + +def set_stack_port( + stack: StackConfig, + port: int, + stacks: list[StackConfig], + server_ui_port: int, + *, + recreate: bool = False, +) -> dict[str, Any]: + validate_port(port, stack, stacks, server_ui_port) + assert stack.port_env is not None + write_env_value(stack.env_path, stack.port_env, str(port)) + + state = get_container_state(stack) + recreated = False + requires_restart = state["running"] + + if recreate and state["running"]: + stack_recreate(stack) + recreated = True + requires_restart = False + state = get_container_state(stack) + + return { + "ok": True, + "stack_id": stack.id, + "port": port, + "port_env": stack.port_env, + "requires_restart": requires_restart and not recreated, + "recreated": recreated, + "running": state["running"], + } + + +def find_running_gpu_stacks(stacks: list[StackConfig], exclude_id: str | None = None) -> list[str]: + running: list[str] = [] + for s in stacks: + if not s.gpu or s.id == exclude_id: + continue + state = get_container_state(s) + if state["running"]: + running.append(s.id) + return running diff --git a/stacks/server-ui/docker-compose.yml b/stacks/server-ui/docker-compose.yml new file mode 100644 index 0000000..52c1b53 --- /dev/null +++ b/stacks/server-ui/docker-compose.yml @@ -0,0 +1,28 @@ +name: server-ui + +services: + server-ui: + image: server-ui:local + build: + context: .. + dockerfile: server-ui/Dockerfile + container_name: server-ui + profiles: + - server-ui + restart: unless-stopped + ports: + - "${SERVER_UI_PORT:-8091}:8091" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${REPO_ROOT:?set REPO_ROOT in .env}:/repo:ro + env_file: + - ../control-plane/.env + environment: + REPO_ROOT: /repo + SERVER_UI_HOST: "0.0.0.0" + SERVER_UI_PORT: "8091" + GPU_FAN_AGENT_URL: ${GPU_FAN_AGENT_URL:-http://host.docker.internal:18090} + extra_hosts: + - "host.docker.internal:host-gateway" + group_add: + - ${DOCKER_GID:-999} diff --git a/stacks/server-ui/file_explorer.py b/stacks/server-ui/file_explorer.py new file mode 100644 index 0000000..d312238 --- /dev/null +++ b/stacks/server-ui/file_explorer.py @@ -0,0 +1,248 @@ +"""Server filesystem browser — list, read, write, CRUD.""" + +from __future__ import annotations + +import base64 +import os +import stat +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +class FileExplorerError(Exception): + """Raised for filesystem operations; map to HTTP in app.py.""" + + +def _posix_error(exc: OSError, action: str) -> FileExplorerError: + if exc.errno in (13, 1): + return FileExplorerError(f"Brak uprawnień: {action}") + if exc.errno == 2: + return FileExplorerError("Nie znaleziono pliku lub katalogu") + if exc.errno == 17: + return FileExplorerError("Plik lub katalog już istnieje") + if exc.errno == 39: + return FileExplorerError("Katalog nie jest pusty") + if exc.errno == 20: + return FileExplorerError("To nie jest katalog") + return FileExplorerError(f"{action}: {exc.strerror or exc}") + + +class FileExplorer: + def __init__(self, root: str = "/", max_bytes: int = 2_097_152) -> None: + self.root = Path(root).resolve() + self.max_bytes = max_bytes + + def resolve_path(self, raw: str) -> Path: + raw = (raw or "/").strip() + if not raw: + raw = "/" + if raw.startswith("/"): + target = Path(raw).resolve() + else: + target = (self.root / raw).resolve() + + if self.root == Path("/"): + return target + + root_s = str(self.root) + target_s = str(target) + if target != self.root and not target_s.startswith(root_s + os.sep): + raise FileExplorerError(f"Ścieżka poza dozwolonym katalogiem: {self.root}") + return target + + def _access_flags(self, path: Path) -> tuple[bool, bool]: + readable = os.access(path, os.R_OK) + writable = os.access(path, os.W_OK) + return readable, writable + + def list_directory(self, raw_path: str) -> dict[str, Any]: + path = self.resolve_path(raw_path) + if not path.exists(): + raise FileExplorerError("Nie znaleziono pliku lub katalogu") + if not path.is_dir(): + raise FileExplorerError("To nie jest katalog") + + readable, writable = self._access_flags(path) + if not readable: + raise FileExplorerError("Brak uprawnień: odczyt katalogu") + + entries: list[dict[str, Any]] = [] + try: + names = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) + except OSError as exc: + raise _posix_error(exc, "odczyt katalogu") from exc + + for child in names: + try: + st = child.stat() + except OSError: + continue + is_dir = stat.S_ISDIR(st.st_mode) + c_readable, c_writable = self._access_flags(child) + entries.append( + { + "name": child.name, + "path": str(child), + "is_dir": is_dir, + "size": st.st_size if not is_dir else None, + "mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(), + "readable": c_readable, + "writable": c_writable, + } + ) + + parent = str(path.parent) if path != self.root else None + return { + "path": str(path), + "parent": parent, + "readable": readable, + "writable": writable, + "entries": entries, + } + + def read_file(self, raw_path: str) -> dict[str, Any]: + path = self.resolve_path(raw_path) + if not path.exists(): + raise FileExplorerError("Nie znaleziono pliku lub katalogu") + if path.is_dir(): + raise FileExplorerError("To jest katalog, nie plik") + + readable, writable = self._access_flags(path) + if not readable: + raise FileExplorerError("Brak uprawnień: odczyt pliku") + + try: + size = path.stat().st_size + except OSError as exc: + raise _posix_error(exc, "odczyt pliku") from exc + + if size > self.max_bytes: + raise FileExplorerError( + f"Plik za duży ({size} B, limit {self.max_bytes} B)" + ) + + try: + data = path.read_bytes() + except OSError as exc: + raise _posix_error(exc, "odczyt pliku") from exc + + if b"\x00" in data: + return { + "path": str(path), + "binary": True, + "size": size, + "content_base64": base64.b64encode(data).decode("ascii"), + "readable": readable, + "writable": writable, + } + + try: + content = data.decode("utf-8") + except UnicodeDecodeError: + return { + "path": str(path), + "binary": True, + "size": size, + "content_base64": base64.b64encode(data).decode("ascii"), + "readable": readable, + "writable": writable, + } + + return { + "path": str(path), + "binary": False, + "size": size, + "content": content, + "readable": readable, + "writable": writable, + } + + def write_file(self, raw_path: str, content: str) -> dict[str, Any]: + path = self.resolve_path(raw_path) + encoded = content.encode("utf-8") + if len(encoded) > self.max_bytes: + raise FileExplorerError( + f"Zawartość za duża ({len(encoded)} B, limit {self.max_bytes} B)" + ) + + parent = path.parent + if not parent.exists(): + raise FileExplorerError("Katalog nadrzędny nie istnieje") + if not os.access(parent, os.W_OK): + raise FileExplorerError("Brak uprawnień: zapis pliku") + + if path.exists() and path.is_dir(): + raise FileExplorerError("To jest katalog, nie plik") + if path.exists() and not os.access(path, os.W_OK): + raise FileExplorerError("Brak uprawnień: zapis pliku") + + try: + path.write_text(content, encoding="utf-8") + except OSError as exc: + raise _posix_error(exc, "zapis pliku") from exc + + return {"ok": True, "path": str(path), "size": len(encoded)} + + def mkdir(self, raw_path: str) -> dict[str, Any]: + path = self.resolve_path(raw_path) + parent = path.parent + if not parent.exists(): + raise FileExplorerError("Katalog nadrzędny nie istnieje") + if not os.access(parent, os.W_OK): + raise FileExplorerError("Brak uprawnień: tworzenie katalogu") + if path.exists(): + raise FileExplorerError("Plik lub katalog już istnieje") + + try: + path.mkdir(parents=False, exist_ok=False) + except OSError as exc: + raise _posix_error(exc, "tworzenie katalogu") from exc + + return {"ok": True, "path": str(path)} + + def delete_path(self, raw_path: str) -> dict[str, Any]: + path = self.resolve_path(raw_path) + if path == self.root: + raise FileExplorerError("Nie można usunąć katalogu głównego") + if not path.exists(): + raise FileExplorerError("Nie znaleziono pliku lub katalogu") + if not os.access(path, os.W_OK): + raise FileExplorerError("Brak uprawnień: usuwanie") + + if path.is_dir(): + try: + if any(path.iterdir()): + raise FileExplorerError("Katalog nie jest pusty") + except OSError as exc: + raise _posix_error(exc, "odczyt katalogu") from exc + + try: + if path.is_dir(): + path.rmdir() + else: + path.unlink() + except OSError as exc: + raise _posix_error(exc, "usuwanie") from exc + + return {"ok": True, "path": str(path)} + + def rename_path(self, raw_from: str, raw_to: str) -> dict[str, Any]: + src = self.resolve_path(raw_from) + dst = self.resolve_path(raw_to) + if not src.exists(): + raise FileExplorerError("Nie znaleziono pliku lub katalogu") + if dst.exists(): + raise FileExplorerError("Docelowa ścieżka już istnieje") + if not os.access(src, os.W_OK): + raise FileExplorerError("Brak uprawnień: zmiana nazwy") + parent = dst.parent + if not parent.exists() or not os.access(parent, os.W_OK): + raise FileExplorerError("Brak uprawnień: zapis w katalogu docelowym") + + try: + src.rename(dst) + except OSError as exc: + raise _posix_error(exc, "zmiana nazwy") from exc + + return {"ok": True, "from": str(src), "to": str(dst)} diff --git a/stacks/server-ui/gpu_fan_proxy.py b/stacks/server-ui/gpu_fan_proxy.py new file mode 100644 index 0000000..cd102ff --- /dev/null +++ b/stacks/server-ui/gpu_fan_proxy.py @@ -0,0 +1,75 @@ +"""Proxy requests to the gpu-fan host agent.""" + +from __future__ import annotations + +import json +import logging +import urllib.error +import urllib.request +from typing import Any + +log = logging.getLogger(__name__) + + +class AgentProxyError(Exception): + """Raised when the gpu-fan agent request fails.""" + + +def check_agent_health(agent_url: str, agent_api_key: str) -> dict[str, Any]: + """Return health info for the gpu-fan agent.""" + try: + forward_request(agent_url, "GET", "status", agent_api_key=agent_api_key) + return {"ok": True, "agent_url": agent_url} + except AgentProxyError as exc: + return {"ok": False, "agent_url": agent_url, "error": str(exc)} + + +def resolve_agent_url(configured_url: str, agent_api_key: str) -> str: + """Pick first reachable agent URL (18090 preferred, 8090 legacy fallback).""" + candidates: list[str] = [] + if configured_url: + candidates.append(configured_url.rstrip("/")) + for url in ("http://127.0.0.1:18090", "http://127.0.0.1:8090"): + if url not in candidates: + candidates.append(url) + for url in candidates: + if check_agent_health(url, agent_api_key).get("ok"): + log.info("GPU fan agent reachable at %s", url) + return url + return candidates[0] if candidates else "http://127.0.0.1:18090" + + +def forward_request( + agent_url: str, + method: str, + path: str, + *, + body: bytes | None = None, + agent_api_key: str, + content_type: str = "application/json", +) -> tuple[int, dict[str, str], bytes]: + """Forward a request to the gpu-fan agent API.""" + path = path.lstrip("/") + url = f"{agent_url.rstrip('/')}/api/{path}" + headers = {"Accept": "application/json"} + if agent_api_key: + headers["X-API-Key"] = agent_api_key + if body is not None: + headers["Content-Type"] = content_type + + req = urllib.request.Request(url, data=body, headers=headers, method=method.upper()) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return resp.status, dict(resp.headers), resp.read() + except urllib.error.HTTPError as exc: + payload = exc.read() + detail = payload.decode("utf-8", errors="replace") + try: + parsed = json.loads(detail) + if isinstance(parsed, dict) and "detail" in parsed: + detail = str(parsed["detail"]) + except json.JSONDecodeError: + pass + raise AgentProxyError(detail or f"Agent HTTP {exc.code}") from exc + except urllib.error.URLError as exc: + raise AgentProxyError(f"Agent unreachable at {agent_url}: {exc.reason}") from exc diff --git a/stacks/server-ui/gpu_info.py b/stacks/server-ui/gpu_info.py new file mode 100644 index 0000000..c01d496 --- /dev/null +++ b/stacks/server-ui/gpu_info.py @@ -0,0 +1,69 @@ +"""Query GPU stats via nvidia-smi.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from typing import Any + + +class GpuInfoError(RuntimeError): + pass + + +def query_gpu() -> dict[str, Any]: + if not shutil.which("nvidia-smi"): + return { + "available": False, + "error": "nvidia-smi not found", + "gpus": [], + } + + try: + result = subprocess.run( + [ + "nvidia-smi", + "--query-gpu=index,name,memory.used,memory.total,temperature.gpu,utilization.gpu", + "--format=csv,noheader,nounits", + ], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise GpuInfoError("nvidia-smi timed out") from exc + + if result.returncode != 0: + return { + "available": False, + "error": (result.stderr or result.stdout or "nvidia-smi failed").strip(), + "gpus": [], + } + + gpus: list[dict[str, Any]] = [] + for line in result.stdout.strip().splitlines(): + parts = [p.strip() for p in line.split(",")] + if len(parts) < 6: + continue + idx, name, mem_used, mem_total, temp, util = parts[:6] + try: + used_mb = int(float(mem_used)) + total_mb = int(float(mem_total)) + except ValueError: + used_mb = 0 + total_mb = 0 + gpus.append( + { + "index": int(idx), + "name": name, + "memory_used_mb": used_mb, + "memory_total_mb": total_mb, + "memory_used_pct": round(100 * used_mb / total_mb, 1) if total_mb else 0, + "temperature_c": int(float(temp)) if temp not in ("N/A", "") else None, + "utilization_pct": int(float(util)) if util not in ("N/A", "") else None, + } + ) + + return {"available": True, "error": None, "gpus": gpus} diff --git a/stacks/server-ui/requirements.txt b/stacks/server-ui/requirements.txt new file mode 100644 index 0000000..6c9c178 --- /dev/null +++ b/stacks/server-ui/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +pydantic>=2.0.0 +PyYAML>=6.0 diff --git a/stacks/server-ui/scripts/deploy-gpu-fan-fix.sh b/stacks/server-ui/scripts/deploy-gpu-fan-fix.sh new file mode 100755 index 0000000..b3f403e --- /dev/null +++ b/stacks/server-ui/scripts/deploy-gpu-fan-fix.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# One-shot deploy: gpu-fan agent :18090 + server-ui proxy. Run: sudo scripts/deploy-gpu-fan-fix.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACKS_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run as root: sudo ${SCRIPT_DIR}/deploy-gpu-fan-fix.sh" + exit 1 +fi + +echo "=== GPU Fan integration deploy ===" + +pkill -f "stacks/server-ui/.venv/bin/python app.py" 2>/dev/null || true +pkill -f "SERVER_UI_PORT=18091" 2>/dev/null || true +pkill -f "SERVER_UI_PORT=8092" 2>/dev/null || true + +"${STACKS_DIR}/gpu-fan/scripts/install.sh" +"${SCRIPT_DIR}/install.sh" + +echo "" +echo "=== Verification ===" +UI_KEY="$(grep '^API_KEY=' /opt/control-plane/.env | cut -d= -f2-)" +curl -sf "http://127.0.0.1:8091/api/gpu-fan/health" -H "X-API-Key: ${UI_KEY}" | head -c 200 +echo "" +echo "" +echo "Panel: http://$(hostname -I | awk '{print $1}'):8091/#gpu-fan" +echo "API key: grep ^API_KEY= /opt/control-plane/.env" diff --git a/stacks/server-ui/scripts/install-control-plane.sh b/stacks/server-ui/scripts/install-control-plane.sh new file mode 100755 index 0000000..7f3002a --- /dev/null +++ b/stacks/server-ui/scripts/install-control-plane.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Unified installer: gpu-fan (native only) + Server UI (native or Docker). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +GPU_FAN_DIR="$(cd "${STACK_DIR}/../gpu-fan" && pwd)" + +GPU_FAN_CHOICE="" +SERVER_UI_CHOICE="" +NON_INTERACTIVE=false + +usage() { + cat <<'EOF' +Usage: install-control-plane.sh [options] + +Options: + --gpu-fan=yes|no|skip Install gpu-fan host agent (native only; no Docker) + --server-ui=native|docker|skip + -y, --non-interactive Use defaults: gpu-fan=yes, server-ui=native + -h, --help + +Recommended: run without flags for interactive menu. + +gpu-fan cannot run in Docker (NVML requires root on host). See ADR-001. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --gpu-fan=*) GPU_FAN_CHOICE="${1#*=}"; NON_INTERACTIVE=true; shift ;; + --server-ui=*) SERVER_UI_CHOICE="${1#*=}"; NON_INTERACTIVE=true; shift ;; + -y|--non-interactive) NON_INTERACTIVE=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1"; usage; exit 1 ;; + esac +done + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run as root: sudo ${SCRIPT_DIR}/install-control-plane.sh" + exit 1 +fi + +prompt_gpu_fan() { + if [[ -n "${GPU_FAN_CHOICE}" ]]; then + return + fi + echo "" + echo "=== 1) gpu-fan (agent NVML na hoście) ===" + echo " Sterowanie wentylatorami GPU wymaga root + NVML na hoście." + echo " Docker NIE jest obsługiwany (ADR-001)." + echo "" + read -r -p "Zainstalować gpu-fan native? [Y/n]: " ans + ans="${ans:-Y}" + case "${ans^^}" in + Y|YES|TAK) GPU_FAN_CHOICE="yes" ;; + *) GPU_FAN_CHOICE="no" ;; + esac +} + +prompt_server_ui() { + if [[ -n "${SERVER_UI_CHOICE}" ]]; then + return + fi + echo "" + echo "=== 2) Server UI (panel stacków Docker + GPU Fan) ===" + echo " [1] Native — systemd na hoście (zalecane)" + echo " [2] Docker — kontener + docker.sock" + echo " [3] Pomiń" + echo "" + read -r -p "Wybór [1]: " ans + ans="${ans:-1}" + case "${ans}" in + 1) SERVER_UI_CHOICE="native" ;; + 2) SERVER_UI_CHOICE="docker" ;; + 3) SERVER_UI_CHOICE="skip" ;; + *) echo "Nieprawidłowy wybór"; exit 1 ;; + esac +} + +if [[ "${NON_INTERACTIVE}" == true && -z "${GPU_FAN_CHOICE}" ]]; then + GPU_FAN_CHOICE="yes" +fi +if [[ "${NON_INTERACTIVE}" == true && -z "${SERVER_UI_CHOICE}" ]]; then + SERVER_UI_CHOICE="native" +fi + +echo "=== GMKtec Control Plane — instalacja ===" +bash "${SCRIPT_DIR}/setup-control-plane-env.sh" +prompt_gpu_fan +prompt_server_ui + +echo "" +echo "Plan:" +echo " gpu-fan: ${GPU_FAN_CHOICE}" +echo " server-ui: ${SERVER_UI_CHOICE}" +echo "" + +case "${GPU_FAN_CHOICE,,}" in + yes|y|true|1) + if [[ ! -x "${GPU_FAN_DIR}/scripts/install.sh" ]]; then + echo "ERROR: ${GPU_FAN_DIR}/scripts/install.sh not found" + exit 1 + fi + echo "--- Installing gpu-fan (native) ---" + bash "${GPU_FAN_DIR}/scripts/install.sh" + ;; + skip|no|n|false|0) + echo "Skipping gpu-fan." + ;; + *) + echo "ERROR: invalid --gpu-fan value: ${GPU_FAN_CHOICE}" + exit 1 + ;; +esac + +case "${SERVER_UI_CHOICE,,}" in + native|systemd|1) + echo "--- Installing Server UI (native / systemd) ---" + # Stop Docker deployment if running + if [[ -f "${STACK_DIR}/docker-compose.yml" ]]; then + (cd "${STACK_DIR}" && docker compose --profile server-ui down 2>/dev/null) || true + fi + bash "${SCRIPT_DIR}/install.sh" + ;; + docker|container|2) + echo "--- Installing Server UI (Docker) ---" + bash "${SCRIPT_DIR}/install-docker.sh" + ;; + skip|no|3) + echo "Skipping Server UI." + ;; + *) + echo "ERROR: invalid --server-ui value: ${SERVER_UI_CHOICE}" + exit 1 + ;; +esac + +LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" +UI_PORT=8091 + +echo "" +echo "==============================================" +echo " Instalacja zakończona" +echo "==============================================" +echo "" +echo "Server UI i gpu-fan NIE pojawią się w 'docker ps' gdy działają jako systemd." +echo "W docker ps widać tylko workloady (ComfyUI, LocalAI, NPMPlus, …)." +echo "" +echo "Sprawdzenie:" +echo " systemctl status gpu-fan server-ui # native" +echo " docker compose --profile server-ui ps # docker" +echo " ss -tln | grep -E '8091|18090'" +echo "" + +if [[ "${SERVER_UI_CHOICE,,}" != "skip" ]]; then + # shellcheck source=print-api-key-instructions.sh + source "${SCRIPT_DIR}/print-api-key-instructions.sh" + print_api_key_instructions "/opt/control-plane/.env" "${UI_PORT}" +fi + +echo "Tutorial: manual-tutorial/08-server-ui-install.md" +echo "Klucz API (szczegóły): manual-tutorial/04a-api-key.md" diff --git a/stacks/server-ui/scripts/install-docker.sh b/stacks/server-ui/scripts/install-docker.sh new file mode 100755 index 0000000..2abf799 --- /dev/null +++ b/stacks/server-ui/scripts/install-docker.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Install Server UI as Docker container (profile server-ui). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +CONTROL_PLANE_STACK="$(cd "${STACK_DIR}/../control-plane" && pwd)" +SERVICE_NAME="server-ui.service" +UI_PORT="${SERVER_UI_PORT:-8091}" +ENV_FILE="${CONTROL_PLANE_STACK}/.env" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run as root: sudo ${SCRIPT_DIR}/install-docker.sh" + exit 1 +fi + +set_env_var() { + local file="$1" key="$2" val="$3" + if grep -q "^${key}=" "${file}"; then + sed -i "s|^${key}=.*|${key}=${val}|" "${file}" + else + echo "${key}=${val}" >> "${file}" + fi +} + +detect_repo_root() { + if [[ -n "${REPO_ROOT:-}" ]]; then + echo "${REPO_ROOT}" + return + fi + local candidate + candidate="$(cd "${STACK_DIR}/../.." && pwd)" + if [[ -d "${candidate}/stacks/localai" ]]; then + echo "${candidate}" + return + fi + echo "${candidate}" +} + +echo "=== Server UI — install (Docker) ===" + +if ! command -v docker >/dev/null 2>&1; then + echo "ERROR: docker not found. Install Docker CE first." + exit 1 +fi + +bash "${SCRIPT_DIR}/setup-control-plane-env.sh" + +REPO_ROOT_VAL="$(detect_repo_root)" +DOCKER_GID="$(getent group docker | cut -d: -f3 || echo 999)" + +if [[ ! -f "${ENV_FILE}" ]]; then + cp "${CONTROL_PLANE_STACK}/.env.example" "${ENV_FILE}" +fi + +set_env_var "${ENV_FILE}" "REPO_ROOT" "${REPO_ROOT_VAL}" +set_env_var "${ENV_FILE}" "SERVER_UI_HOST" "0.0.0.0" +set_env_var "${ENV_FILE}" "SERVER_UI_PORT" "${UI_PORT}" +set_env_var "${ENV_FILE}" "GPU_FAN_AGENT_URL" "http://host.docker.internal:18090" +set_env_var "${ENV_FILE}" "DOCKER_GID" "${DOCKER_GID}" + +# Disable native systemd if present +if systemctl is-enabled "${SERVICE_NAME}" &>/dev/null; then + echo "Disabling native ${SERVICE_NAME}..." + systemctl disable --now "${SERVICE_NAME}" 2>/dev/null || true +fi + +cd "${STACK_DIR}" +export REPO_ROOT="${REPO_ROOT_VAL}" +export DOCKER_GID + +echo "Building server-ui image..." +docker compose --profile server-ui build + +echo "Starting server-ui container..." +docker compose --profile server-ui up -d + +sleep 2 + +LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" +API_KEY_VAL="$(grep '^API_KEY=' "${ENV_FILE}" | cut -d= -f2- || true)" + +echo "" +echo "Runtime: Docker (profile server-ui)" +echo "Repo mount: ${REPO_ROOT_VAL} → /repo" +echo "Env: ${ENV_FILE}" +echo "Container: $(docker compose --profile server-ui ps --format '{{.Name}} {{.Status}}' 2>/dev/null || docker ps --filter name=server-ui --format '{{.Names}} {{.Status}}')" +echo "" +echo "Web UI (LAN):" +echo " http://${LAN_IP:-}:${UI_PORT}/" +echo "" +echo "API key:" +echo " grep ^API_KEY= ${ENV_FILE}" +echo "" + +if [[ -n "${API_KEY_VAL}" ]]; then + HEALTH="$(curl -sf "http://127.0.0.1:${UI_PORT}/api/health" 2>/dev/null || echo '{"ok":false}')" + echo "Health: ${HEALTH}" + GF_HEALTH="$(curl -sf "http://127.0.0.1:${UI_PORT}/api/gpu-fan/health" -H "X-API-Key: ${API_KEY_VAL}" 2>/dev/null || echo '{"ok":false}')" + echo "GPU Fan proxy: ${GF_HEALTH}" +fi + +echo "" +echo "Logs:" +echo " docker compose --profile server-ui logs -f" +echo "Stop:" +echo " docker compose --profile server-ui down" diff --git a/stacks/server-ui/scripts/install.sh b/stacks/server-ui/scripts/install.sh new file mode 100755 index 0000000..d685a7b --- /dev/null +++ b/stacks/server-ui/scripts/install.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +INSTALL_DIR="/opt/server-ui" +SERVICE_NAME="server-ui.service" +UI_PORT=8091 +CONTROL_PLANE_ENV="/opt/control-plane/.env" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run as root: sudo ${SCRIPT_DIR}/install.sh" + exit 1 +fi + +free_ui_port() { + pkill -f "stacks/server-ui/.venv/bin/python app.py" 2>/dev/null || true + pkill -f "SERVER_UI_PORT=18091" 2>/dev/null || true + local pids + pids="$(ss -tlnp "sport = :${UI_PORT}" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | sort -u || true)" + for pid in ${pids}; do + local cmd + cmd="$(ps -p "${pid}" -o cmd= 2>/dev/null || true)" + if [[ "${cmd}" == *"stacks/server-ui"* ]]; then + echo "Stopping stale dev server-ui on :${UI_PORT} (pid ${pid})" + kill "${pid}" 2>/dev/null || true + fi + done + sleep 1 +} + +# Disable Docker deployment if switching to native +if [[ -f "${STACK_DIR}/docker-compose.yml" ]]; then + (cd "${STACK_DIR}" && docker compose --profile server-ui down 2>/dev/null) || true +fi + +echo "=== Server UI — install (native / systemd) ===" + +bash "${SCRIPT_DIR}/setup-control-plane-env.sh" + +apt-get update -qq +apt-get install -y python3-venv python3-pip + +mkdir -p "${INSTALL_DIR}" + +rsync -a --delete \ + --exclude '.venv' \ + --exclude '.env' \ + --exclude '__pycache__' \ + "${STACK_DIR}/" "${INSTALL_DIR}/" + +python3 -m venv "${INSTALL_DIR}/.venv" +"${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip -q +"${INSTALL_DIR}/.venv/bin/pip" install -r "${INSTALL_DIR}/requirements.txt" -q + +install -m 644 "${INSTALL_DIR}/server-ui.service" "/etc/systemd/system/${SERVICE_NAME}" +systemctl daemon-reload +systemctl enable "${SERVICE_NAME}" + +free_ui_port +systemctl restart "${SERVICE_NAME}" +sleep 2 + +LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" +API_KEY_VAL="$(grep '^API_KEY=' "${CONTROL_PLANE_ENV}" | cut -d= -f2- || true)" + +echo "" +echo "Installed to ${INSTALL_DIR}" +echo "Service: $(systemctl is-active "${SERVICE_NAME}" 2>/dev/null || echo unknown)" +echo "Env: ${CONTROL_PLANE_ENV}" + +# shellcheck source=print-api-key-instructions.sh +source "${SCRIPT_DIR}/print-api-key-instructions.sh" +print_api_key_instructions "${CONTROL_PLANE_ENV}" "${UI_PORT}" + +if [[ -n "${API_KEY_VAL}" ]]; then + HEALTH="$(curl -sf "http://127.0.0.1:${UI_PORT}/api/gpu-fan/health" -H "X-API-Key: ${API_KEY_VAL}" 2>/dev/null || echo '{"ok":false}')" + echo "GPU Fan proxy health: ${HEALTH}" + if ! echo "${HEALTH}" | grep -q '"ok":true'; then + echo "WARN: gpu-fan agent may be down — run: sudo ${STACK_DIR%/server-ui}/gpu-fan/scripts/install.sh" + fi +fi + +echo "" +echo "Logs:" +echo " journalctl -u ${SERVICE_NAME} -f" +echo "" +echo "Unified installer (gpu-fan + server-ui):" +echo " sudo ${SCRIPT_DIR}/install-control-plane.sh" diff --git a/stacks/server-ui/scripts/print-api-key-instructions.sh b/stacks/server-ui/scripts/print-api-key-instructions.sh new file mode 100755 index 0000000..fd4c0e8 --- /dev/null +++ b/stacks/server-ui/scripts/print-api-key-instructions.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Print API key + browser instructions (shared by install scripts and show-api-key.sh). +set -euo pipefail + +print_api_key_instructions() { + local env_file="${1:-/opt/control-plane/.env}" + local ui_port="${2:-8091}" + local api_key="" + local lan_ip="" + + if [[ -r "${env_file}" ]]; then + api_key="$(grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)" + elif [[ -f "${env_file}" ]]; then + api_key="$(sudo grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)" + fi + + lan_ip="$(hostname -I 2>/dev/null | awk '{print $1}')" + [[ -n "${lan_ip}" ]] || lan_ip="" + + echo "" + echo "══════════════════════════════════════════════" + echo " SERVER UI — KLUCZ API" + echo "══════════════════════════════════════════════" + echo "" + + if [[ -z "${api_key}" ]]; then + echo "Brak API_KEY w ${env_file}" + echo "Uruchom: sudo bash stacks/server-ui/scripts/setup-control-plane-env.sh" + echo "" + return 1 + fi + + echo "1. Twój klucz API (skopiuj):" + echo "" + echo " ${api_key}" + echo "" + echo "2. Otwórz panel (klucz zapisze się w przeglądarce):" + echo "" + echo " http://${lan_ip}:${ui_port}/?api_key=${api_key}" + echo "" + echo "3. Albo ręcznie:" + echo " a) Wejdź na http://${lan_ip}:${ui_port}/" + echo " b) Wklej klucz w pole „API Key”" + echo " c) Kliknij „Zapisz”" + echo " d) Kliknij „Sprawdź klucz” (powinno być: Klucz poprawny)" + echo " e) Dopiero potem Start/Stop, CLI, Pliki, GPU Fan" + echo "" + echo "Plik klucza (na przyszłość):" + echo " sudo grep ^API_KEY= ${env_file}" + echo "" + echo "══════════════════════════════════════════════" + echo "" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + print_api_key_instructions "${1:-/opt/control-plane/.env}" "${2:-8091}" +fi diff --git a/stacks/server-ui/scripts/restart-stack.sh b/stacks/server-ui/scripts/restart-stack.sh new file mode 100755 index 0000000..dc7acf0 --- /dev/null +++ b/stacks/server-ui/scripts/restart-stack.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Restart gpu-fan + server-ui after code sync. Requires: sudo scripts/restart-stack.sh +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ "${EUID}" -ne 0 ]]; then + echo "Run: sudo ${SCRIPT_DIR}/restart-stack.sh" + exit 1 +fi +systemctl restart gpu-fan +sleep 2 +systemctl restart server-ui +sleep 2 +echo "gpu-fan: $(systemctl is-active gpu-fan)" +echo "server-ui: $(systemctl is-active server-ui)" +ss -tlnp | grep -E '18090|8091' || true +UI_KEY="$(grep '^API_KEY=' /opt/control-plane/.env | cut -d= -f2-)" +curl -sf "http://127.0.0.1:8091/api/gpu-fan/health" -H "X-API-Key: ${UI_KEY}" || echo "health check failed" diff --git a/stacks/server-ui/scripts/setup-control-plane-env.sh b/stacks/server-ui/scripts/setup-control-plane-env.sh new file mode 100755 index 0000000..50cf39c --- /dev/null +++ b/stacks/server-ui/scripts/setup-control-plane-env.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# Create /opt/control-plane/.env and migrate from legacy per-service .env files. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${STACK_DIR}/../.." && pwd)" +CONTROL_PLANE_DIR="/opt/control-plane" +ENV_FILE="${CONTROL_PLANE_DIR}/.env" +EXAMPLE="${REPO_ROOT}/stacks/control-plane/.env.example" +LEGACY_SUI="/opt/server-ui/.env" +LEGACY_GF="/opt/gpu-fan/.env" +LEGACY_REPO_SUI="${REPO_ROOT}/stacks/server-ui/.env" +DEV_ENV="${REPO_ROOT}/stacks/control-plane/.env" +TIMESTAMP="$(date +%Y%m%d%H%M%S)" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run as root: sudo ${SCRIPT_DIR}/setup-control-plane-env.sh" + exit 1 +fi + +set_env_var() { + local file="$1" key="$2" val="$3" + if grep -q "^${key}=" "${file}"; then + sed -i "s|^${key}=.*|${key}=${val}|" "${file}" + else + echo "${key}=${val}" >> "${file}" + fi +} + +get_env_var() { + local file="$1" key="$2" + grep "^${key}=" "${file}" 2>/dev/null | cut -d= -f2- || true +} + +remove_deprecated_keys() { + local file="$1" + [[ -f "${file}" ]] || return 0 + cp -a "${file}" "${file}.bak.${TIMESTAMP}" + grep -v -E '^(API_KEY|GPU_FAN_AGENT_KEY)=' "${file}" > "${file}.tmp.${TIMESTAMP}" || true + { + echo "# API_KEY: use /opt/control-plane/.env (prod) or stacks/control-plane/.env (dev)" + echo "# Run: sudo bash stacks/server-ui/scripts/setup-control-plane-env.sh" + cat "${file}.tmp.${TIMESTAMP}" + } > "${file}" + rm -f "${file}.tmp.${TIMESTAMP}" + echo "Removed API_KEY / GPU_FAN_AGENT_KEY from ${file} (backup: ${file}.bak.${TIMESTAMP})" +} + +sync_dev_env_from_prod() { + local prod_key repo_root_val + prod_key="$(get_env_var "${ENV_FILE}" API_KEY)" + repo_root_val="$(get_env_var "${ENV_FILE}" REPO_ROOT)" + [[ -n "${repo_root_val}" ]] || repo_root_val="${REPO_ROOT}" + + if [[ ! -f "${DEV_ENV}" && -f "${EXAMPLE}" ]]; then + cp "${EXAMPLE}" "${DEV_ENV}" + echo "Created dev env: ${DEV_ENV}" + fi + + if [[ ! -f "${DEV_ENV}" ]]; then + echo "WARN: dev env missing: ${DEV_ENV}" + return 0 + fi + + if [[ -n "${prod_key}" ]]; then + set_env_var "${DEV_ENV}" "API_KEY" "${prod_key}" + fi + set_env_var "${DEV_ENV}" "REPO_ROOT" "${repo_root_val}" + local ui_port + ui_port="$(get_env_var "${ENV_FILE}" SERVER_UI_PORT)" + [[ -n "${ui_port}" ]] && set_env_var "${DEV_ENV}" "SERVER_UI_PORT" "${ui_port}" + echo "Synced dev env: ${DEV_ENV} (API_KEY matches ${ENV_FILE})" +} + +merge_env_file() { + local src="$1" + [[ -f "${src}" ]] || return 0 + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line%%#*}" + line="$(echo "${line}" | xargs)" + [[ -n "${line}" && "${line}" == *=* ]] || continue + local key="${line%%=*}" + local val="${line#*=}" + key="$(echo "${key}" | xargs)" + [[ -n "${val}" ]] || continue + # Skip deprecated duplicate key + [[ "${key}" == "GPU_FAN_AGENT_KEY" ]] && continue + set_env_var "${ENV_FILE}" "${key}" "${val}" + done < "${src}" +} + +echo "=== Control plane env setup ===" + +mkdir -p "${CONTROL_PLANE_DIR}" + +# Deploy env_loader for production Python imports +if [[ -f "${REPO_ROOT}/stacks/control-plane/env_loader.py" ]]; then + install -m 644 "${REPO_ROOT}/stacks/control-plane/env_loader.py" "${CONTROL_PLANE_DIR}/env_loader.py" +fi + +if [[ ! -f "${ENV_FILE}" ]]; then + if [[ -f "${EXAMPLE}" ]]; then + cp "${EXAMPLE}" "${ENV_FILE}" + echo "Created ${ENV_FILE} from example" + else + touch "${ENV_FILE}" + echo "Created empty ${ENV_FILE}" + fi +fi + +# Migrate legacy files (non-destructive: backup first) +SUI_KEY="" +GF_KEY="" +[[ -f "${LEGACY_SUI}" ]] && SUI_KEY="$(get_env_var "${LEGACY_SUI}" API_KEY)" +[[ -f "${LEGACY_GF}" ]] && GF_KEY="$(get_env_var "${LEGACY_GF}" API_KEY)" + +for legacy in "${LEGACY_SUI}" "${LEGACY_GF}"; do + if [[ -f "${legacy}" ]]; then + cp -a "${legacy}" "${legacy}.bak.${TIMESTAMP}" + echo "Backed up ${legacy} → ${legacy}.bak.${TIMESTAMP}" + merge_env_file "${legacy}" + fi +done + +if [[ -n "${SUI_KEY}" && -n "${GF_KEY}" && "${SUI_KEY}" != "${GF_KEY}" ]]; then + echo "WARN: API_KEY differed in legacy files — merged value: $(get_env_var "${ENV_FILE}" API_KEY)" + echo " After install, use one key everywhere: grep ^API_KEY= ${ENV_FILE}" +fi + +# Defaults +set_env_var "${ENV_FILE}" "GPU_FAN_AGENT_URL" "$(get_env_var "${ENV_FILE}" GPU_FAN_AGENT_URL || echo http://127.0.0.1:18090)" +[[ -n "$(get_env_var "${ENV_FILE}" GPU_FAN_AGENT_URL)" ]] || set_env_var "${ENV_FILE}" "GPU_FAN_AGENT_URL" "http://127.0.0.1:18090" +set_env_var "${ENV_FILE}" "GPU_FAN_API_HOST" "$(get_env_var "${ENV_FILE}" GPU_FAN_API_HOST || echo 127.0.0.1)" +[[ -n "$(get_env_var "${ENV_FILE}" GPU_FAN_API_HOST)" ]] || set_env_var "${ENV_FILE}" "GPU_FAN_API_HOST" "127.0.0.1" +set_env_var "${ENV_FILE}" "GPU_FAN_API_PORT" "$(get_env_var "${ENV_FILE}" GPU_FAN_API_PORT || echo 18090)" +[[ -n "$(get_env_var "${ENV_FILE}" GPU_FAN_API_PORT)" ]] || set_env_var "${ENV_FILE}" "GPU_FAN_API_PORT" "18090" +set_env_var "${ENV_FILE}" "CURVE_PATH" "$(get_env_var "${ENV_FILE}" CURVE_PATH || echo /etc/gpu-fan/curve.json)" +[[ -n "$(get_env_var "${ENV_FILE}" CURVE_PATH)" ]] || set_env_var "${ENV_FILE}" "CURVE_PATH" "/etc/gpu-fan/curve.json" + +# REPO_ROOT default +if [[ -z "$(get_env_var "${ENV_FILE}" REPO_ROOT)" ]]; then + set_env_var "${ENV_FILE}" "REPO_ROOT" "${REPO_ROOT}" +fi + +# Generate API_KEY if missing or placeholder +API_KEY_VAL="$(get_env_var "${ENV_FILE}" API_KEY)" +if [[ -z "${API_KEY_VAL}" || "${API_KEY_VAL}" == change-me* ]]; then + KEY="$(openssl rand -hex 16)" + set_env_var "${ENV_FILE}" "API_KEY" "${KEY}" + echo "Generated API_KEY in ${ENV_FILE}" +fi + +chmod 600 "${ENV_FILE}" + +# Always sync dev copy to production key +sync_dev_env_from_prod + +# Remove duplicate API keys from legacy per-service env files +for legacy in "${LEGACY_SUI}" "${LEGACY_GF}" "${LEGACY_REPO_SUI}"; do + if [[ -f "${legacy}" ]] && grep -qE '^(API_KEY|GPU_FAN_AGENT_KEY)=' "${legacy}" 2>/dev/null; then + remove_deprecated_keys "${legacy}" + fi +done + +echo "" +echo "Control plane env: ${ENV_FILE}" +echo "Dev copy: ${DEV_ENV}" +echo "" +# shellcheck source=print-api-key-instructions.sh +source "${SCRIPT_DIR}/print-api-key-instructions.sh" +print_api_key_instructions "${ENV_FILE}" "$(get_env_var "${ENV_FILE}" SERVER_UI_PORT || echo 8091)" diff --git a/stacks/server-ui/scripts/show-api-key.sh b/stacks/server-ui/scripts/show-api-key.sh new file mode 100755 index 0000000..e995c5d --- /dev/null +++ b/stacks/server-ui/scripts/show-api-key.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Show API key and step-by-step browser instructions for Server UI. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${STACK_DIR}/../.." && pwd)" +PROD_ENV="/opt/control-plane/.env" +DEV_ENV="${REPO_ROOT}/stacks/control-plane/.env" +UI_PORT=8091 + +if [[ -f "${DEV_ENV}" ]]; then + UI_PORT="$(grep '^SERVER_UI_PORT=' "${DEV_ENV}" 2>/dev/null | cut -d= -f2- || echo 8091)" +fi + +# Prefer dev copy when readable (same key after sync); else production. +ENV_FILE="${DEV_ENV}" +if [[ ! -r "${ENV_FILE}" ]]; then + ENV_FILE="${PROD_ENV}" +fi + +# shellcheck source=print-api-key-instructions.sh +source "${SCRIPT_DIR}/print-api-key-instructions.sh" +print_api_key_instructions "${ENV_FILE}" "${UI_PORT}" diff --git a/stacks/server-ui/scripts/start.sh b/stacks/server-ui/scripts/start.sh new file mode 100755 index 0000000..9645da9 --- /dev/null +++ b/stacks/server-ui/scripts/start.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +CONTROL_PLANE_ENV="${STACK_DIR}/../control-plane/.env" + +cd "${STACK_DIR}" + +if [[ ! -f "${CONTROL_PLANE_ENV}" ]]; then + echo "ERROR: ${CONTROL_PLANE_ENV} not found." + echo "Run: cp ${STACK_DIR}/../control-plane/.env.example ${CONTROL_PLANE_ENV}" + exit 1 +fi + +if [[ ! -d .venv ]]; then + python3 -m venv .venv + .venv/bin/pip install --upgrade pip -q + .venv/bin/pip install -r requirements.txt -q +fi + +set -a +# shellcheck disable=SC1091 +source "${CONTROL_PLANE_ENV}" +set +a + +export CONTROL_PLANE_ENV="${CONTROL_PLANE_ENV}" + +exec .venv/bin/python app.py diff --git a/stacks/server-ui/server-ui.service b/stacks/server-ui/server-ui.service new file mode 100644 index 0000000..6fb8db7 --- /dev/null +++ b/stacks/server-ui/server-ui.service @@ -0,0 +1,20 @@ +[Unit] +Description=GMKtec K11 Server UI (Docker stack manager) +After=docker.service network-online.target +Wants=docker.service + +[Service] +Type=simple +User=tomasz-syn-grzegorza +Group=tomasz-syn-grzegorza +SupplementaryGroups=docker +WorkingDirectory=/opt/server-ui +EnvironmentFile=-/opt/control-plane/.env +ExecStart=/opt/server-ui/.venv/bin/python /opt/server-ui/app.py +Restart=on-failure +RestartSec=5 +KillSignal=SIGTERM +TimeoutStopSec=15 + +[Install] +WantedBy=multi-user.target diff --git a/stacks/server-ui/stacks.yaml b/stacks/server-ui/stacks.yaml new file mode 100644 index 0000000..71bfac2 --- /dev/null +++ b/stacks/server-ui/stacks.yaml @@ -0,0 +1,44 @@ +stacks: + - id: localai + name: LocalAI + compose_dir: localai + profile: localai + container: localai + ui_port: 8070 + port_env: LOCALAI_PORT + port_default: 8080 + port_editable: true + gpu: true + + - id: comfyui + name: ComfyUI + compose_dir: comfyui + profile: comfyui + container: comfyui + ui_port: 8188 + port_env: COMFYUI_PORT + port_default: 8188 + port_editable: true + gpu: true + + - id: vllm + name: vLLM + compose_dir: vllm + profile: vllm + container: vllm + ui_port: 8000 + port_env: VLLM_PORT + port_default: 8000 + port_editable: true + gpu: true + + - id: npmplus + name: NPMPlus + compose_dir: npmplus + profile: npmplus + container: npmplus + ui_port: 81 + ui_scheme: https + port_default: 81 + port_editable: false + gpu: false diff --git a/stacks/server-ui/static/index.html b/stacks/server-ui/static/index.html new file mode 100644 index 0000000..1643e36 --- /dev/null +++ b/stacks/server-ui/static/index.html @@ -0,0 +1,1942 @@ + + + + + + GMKtec K11 — Server UI + + + + + + +
+
+

GMKtec K11 — Server UI

+
Docker compose stacks · RTX 3090 Ti
+
+ +
+ +
+
+ + + + + + + +
+ +
+
+
+
+

Terminal

+
Shell na serwerze (bash przez PTY) — wymaga API Key przy dostępie z LAN
+
+
+ Niepołączono + +
+
+
+
+
+
+
+ +
+
+
/
+ + +
+
+
+
Ładowanie…
+
+
+
+
Wybierz plik z listy
+ +
+ +
+ + +
+
+
+
+
+ + + + +
+
+ +
+ + +
+
+

GPU

+
Ładowanie…
+
+
+
+
+

Polityka GPU

+

Tylko jeden duży workload GPU naraz (LocalAI, ComfyUI, vLLM).

+

+
+
+ +
+ +
+

Logi:

+
Wybierz stack, aby zobaczyć logi.
+
+ + +
+ +
+
+
+

GPU Fan Control

+
Ładowanie…
+
+ +
+ +
Sprawdzanie połączenia z agentem…
+ +
+
+

Krzywa wentylatorów

+ + + + + + + + + + + Temperatura (°C) + Prędkość (%) + + + + + + +

Przeciągnij punkty na wykresie. Prędkość: 30–100% (API NVIDIA). Min. 3, max. 7 punktów.

+ + + + + + +
#Temp (°C)Speed (%)
+ +
+ + + + + +
+
+ +
+

Status GPU

+
+
+ Wentylatory + +
+
+ Cel (krzywa) + +
+
+
+
+ +
+

Monitoring GPU

+
+
+
+ Obciążenie GPU + +
+
+
+
+
+ Pobór mocy + +
+
+
+
+
+
+ Pamięć VRAM + +
+
+
+
+
+ +
+
+ Wykorzystanie GPU (%) + Moc (W) + Temperatura (°C) +
+ +
+ +
+
+
+
Temperatura
+
+
+
+
Wentylatory
+
+
+
+
Taktowanie GPU
+
+
+
+
Taktowanie pamięci
+
+
+
+
Wykorzystanie pamięci
+
+
+
+
Tryb wentylatora
+
+
+
+
+
+ +
+ + + + diff --git a/stacks/server-ui/static/vendor/xterm/addon-fit.min.js b/stacks/server-ui/static/vendor/xterm/addon-fit.min.js new file mode 100644 index 0000000..9f4e48c --- /dev/null +++ b/stacks/server-ui/static/vendor/xterm/addon-fit.min.js @@ -0,0 +1,8 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})())); +//# sourceMappingURL=addon-fit.js.map \ No newline at end of file diff --git a/stacks/server-ui/static/vendor/xterm/xterm.css b/stacks/server-ui/static/vendor/xterm/xterm.css new file mode 100644 index 0000000..e97b643 --- /dev/null +++ b/stacks/server-ui/static/vendor/xterm/xterm.css @@ -0,0 +1,218 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * @license MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +/** + * Default styles for xterm.js + */ + +.xterm { + cursor: text; + position: relative; + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; +} + +.xterm.focus, +.xterm:focus { + outline: none; +} + +.xterm .xterm-helpers { + position: absolute; + top: 0; + /** + * The z-index of the helpers must be higher than the canvases in order for + * IMEs to appear on top. + */ + z-index: 5; +} + +.xterm .xterm-helper-textarea { + padding: 0; + border: 0; + margin: 0; + /* Move textarea out of the screen to the far left, so that the cursor is not visible */ + position: absolute; + opacity: 0; + left: -9999em; + top: 0; + width: 0; + height: 0; + z-index: -5; + /** Prevent wrapping so the IME appears against the textarea at the correct position */ + white-space: nowrap; + overflow: hidden; + resize: none; +} + +.xterm .composition-view { + /* TODO: Composition position got messed up somewhere */ + background: #000; + color: #FFF; + display: none; + position: absolute; + white-space: nowrap; + z-index: 1; +} + +.xterm .composition-view.active { + display: block; +} + +.xterm .xterm-viewport { + /* On OS X this is required in order for the scroll bar to appear fully opaque */ + background-color: #000; + overflow-y: scroll; + cursor: default; + position: absolute; + right: 0; + left: 0; + top: 0; + bottom: 0; +} + +.xterm .xterm-screen { + position: relative; +} + +.xterm .xterm-screen canvas { + position: absolute; + left: 0; + top: 0; +} + +.xterm .xterm-scroll-area { + visibility: hidden; +} + +.xterm-char-measure-element { + display: inline-block; + visibility: hidden; + position: absolute; + top: 0; + left: -9999em; + line-height: normal; +} + +.xterm.enable-mouse-events { + /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ + cursor: default; +} + +.xterm.xterm-cursor-pointer, +.xterm .xterm-cursor-pointer { + cursor: pointer; +} + +.xterm.column-select.focus { + /* Column selection mode */ + cursor: crosshair; +} + +.xterm .xterm-accessibility:not(.debug), +.xterm .xterm-message { + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + z-index: 10; + color: transparent; + pointer-events: none; +} + +.xterm .xterm-accessibility-tree:not(.debug) *::selection { + color: transparent; +} + +.xterm .xterm-accessibility-tree { + user-select: text; + white-space: pre; +} + +.xterm .live-region { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.xterm-dim { + /* Dim should not apply to background, so the opacity of the foreground color is applied + * explicitly in the generated class and reset to 1 here */ + opacity: 1 !important; +} + +.xterm-underline-1 { text-decoration: underline; } +.xterm-underline-2 { text-decoration: double underline; } +.xterm-underline-3 { text-decoration: wavy underline; } +.xterm-underline-4 { text-decoration: dotted underline; } +.xterm-underline-5 { text-decoration: dashed underline; } + +.xterm-overline { + text-decoration: overline; +} + +.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } +.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } +.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } +.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } +.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } + +.xterm-strikethrough { + text-decoration: line-through; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration { + z-index: 6; + position: absolute; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { + z-index: 7; +} + +.xterm-decoration-overview-ruler { + z-index: 8; + position: absolute; + top: 0; + right: 0; + pointer-events: none; +} + +.xterm-decoration-top { + z-index: 2; + position: relative; +} diff --git a/stacks/server-ui/static/vendor/xterm/xterm.min.js b/stacks/server-ui/static/vendor/xterm/xterm.min.js new file mode 100644 index 0000000..0a51bfb --- /dev/null +++ b/stacks/server-ui/static/vendor/xterm/xterm.min.js @@ -0,0 +1,8 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/@xterm/xterm@5.5.0/lib/xterm.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(9924),a=i(844),h=i(4725),c=i(2585),l=i(3656);let d=t.AccessibilityManager=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,l.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.innerText=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=document.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let n=t=this._terminal.cols&&(++s,n=0),{row:s,column:n}},n=r(t),o=r(i);if(n&&o){if(n.row>o.row||n.row===o.row&&n.column>=o.column)throw new Error("invalid range");this._terminal.select(n.column,n.row,(o.row-n.row)*this._terminal.cols-n.column+o.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){this._accessibilityContainer.style.width=`${this._renderService.dimensions.css.canvas.width}px`,this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.rightClickHandler=t.moveTextAreaUnderMouseCursor=t.paste=t.handlePasteEvent=t.copyHandler=t.bracketTextForPaste=t.prepareTextForTerminal=void 0,t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7239:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(1505);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},3656:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.addDisposableDomListener=void 0,t.addDisposableDomListener=function(e,t,i,s){e.addEventListener(t,i,s);let r=!1;return{dispose:()=>{r||(r=!0,e.removeEventListener(t,i,s))}}}},3551:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const n=i(3656),o=i(8460),a=i(844),h=i(2585),c=i(4725);let l=t.Linkifier=class extends a.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this.register(new o.EventEmitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this.register(new o.EventEmitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this.register((0,a.getDisposeArrayDisposable)(this._linkCacheDisposables)),this.register((0,a.toDisposable)((()=>{this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()}))),this.register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0}))),this.register((0,n.addDisposableDomListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this.register((0,n.addDisposableDomListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,(t=>{if(this._isMouseOut)return;const r=t?.map((e=>({link:e})));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);t&&this._mouseDownLink===this._currentLink&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,a.disposeArray)(this._linkCacheDisposables))}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=l=s([r(1,c.IMouseService),r(2,c.IRenderService),r(3,h.IBufferService),r(4,c.ILinkProviderService)],l)},9042:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0,t.promptLabel="Terminal input",t.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},3730:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(511),o=i(2585);let a=t.OscLinkProvider=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=new n.CellData,a=i.getTrimmedLength();let c=-1,l=-1,d=!1;for(let t=0;tr?r.activate(e,t,n):h(0,t),hover:(e,t)=>r?.hover?.(e,t,n),leave:(e,t)=>r?.leave?.(e,t,n)})}d=!1,o.hasExtendedAttrs()&&o.extended.urlId?(l=t,c=o.extended.urlId):(l=-1,c=-1)}}t(s)}};function h(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch{}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},6193:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._renderCallback=e,this._coreBrowserService=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},3236:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Terminal=void 0;const s=i(3614),r=i(3656),n=i(3551),o=i(9042),a=i(3730),h=i(1680),c=i(3107),l=i(5744),d=i(2950),_=i(1296),u=i(428),f=i(4269),v=i(5114),p=i(8934),g=i(3230),m=i(9312),S=i(4725),C=i(6731),b=i(8055),w=i(8969),y=i(8460),E=i(844),k=i(6114),L=i(8437),D=i(2584),R=i(7399),x=i(5941),A=i(9074),B=i(2585),T=i(5435),M=i(4567),O=i(779);class P extends w.CoreTerminal{get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this.browser=k,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this.register(new E.MutableDisposable),this._onCursorMove=this.register(new y.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this.register(new y.EventEmitter),this.onKey=this._onKey.event,this._onRender=this.register(new y.EventEmitter),this.onRender=this._onRender.event,this._onSelectionChange=this.register(new y.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this.register(new y.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this.register(new y.EventEmitter),this.onBell=this._onBell.event,this._onFocus=this.register(new y.EventEmitter),this._onBlur=this.register(new y.EventEmitter),this._onA11yCharEmitter=this.register(new y.EventEmitter),this._onA11yTabEmitter=this.register(new y.EventEmitter),this._onWillOpen=this.register(new y.EventEmitter),this._setup(),this._decorationService=this._instantiationService.createInstance(A.DecorationService),this._instantiationService.setService(B.IDecorationService,this._decorationService),this._linkProviderService=this._instantiationService.createInstance(O.LinkProviderService),this._instantiationService.setService(S.ILinkProviderService,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(a.OscLinkProvider)),this.register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this.register(this._inputHandler.onRequestRefreshRows(((e,t)=>this.refresh(e,t)))),this.register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this.register(this._inputHandler.onRequestReset((()=>this.reset()))),this.register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this.register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this.register((0,y.forwardEvent)(this._inputHandler.onCursorMove,this._onCursorMove)),this.register((0,y.forwardEvent)(this._inputHandler.onTitleChange,this._onTitleChange)),this.register((0,y.forwardEvent)(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this.register((0,y.forwardEvent)(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this.register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this.register((0,E.toDisposable)((()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${D.C0.ESC}]${i};${(0,x.toRgbString)(s)}${D.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.channels.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.channels.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[I"),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this.register((0,r.addDisposableDomListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this.register((0,r.addDisposableDomListener)(this.textarea,"paste",e)),this.register((0,r.addDisposableDomListener)(this.element,"paste",e)),k.isFirefox?this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this.register((0,r.addDisposableDomListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),k.isLinux&&this.register((0,r.addDisposableDomListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this.register((0,r.addDisposableDomListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this.register((0,r.addDisposableDomListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this.register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this._viewportScrollArea=this._document.createElement("div"),this._viewportScrollArea.classList.add("xterm-scroll-area"),this._viewportElement.appendChild(this._viewportScrollArea),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this.register((0,r.addDisposableDomListener)(this.screenElement,"mousemove",(e=>this.updateCursorStyle(e)))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement),this.textarea=this._document.createElement("textarea"),this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",o.promptLabel),k.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._coreBrowserService=this.register(this._instantiationService.createInstance(v.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??"undefined"!=typeof window?window.document:null)),this._instantiationService.setService(S.ICoreBrowserService,this._coreBrowserService),this.register((0,r.addDisposableDomListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(u.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(S.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(C.ThemeService),this._instantiationService.setService(S.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(f.CharacterJoinerService),this._instantiationService.setService(S.ICharacterJoinerService,this._characterJoinerService),this._renderService=this.register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(S.IRenderService,this._renderService),this.register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(d.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(S.IMouseService,this._mouseService),this.linkifier=this.register(this._instantiationService.createInstance(n.Linkifier,this.screenElement)),this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this.viewport=this._instantiationService.createInstance(h.Viewport,this._viewportElement,this._viewportScrollArea),this.viewport.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent,1))),this.register(this._inputHandler.onRequestSyncScrollBar((()=>this.viewport.syncScrollArea()))),this.register(this.viewport),this.register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this.register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this.register(this.onBlur((()=>this._renderService.handleBlur()))),this.register(this.onFocus((()=>this._renderService.handleFocus()))),this.register(this._renderService.onDimensionsChange((()=>this.viewport.syncScrollArea()))),this._selectionService=this.register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,this.linkifier)),this._instantiationService.setService(S.ISelectionService,this._selectionService),this.register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this.register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this.register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this.register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this.register(this._onScroll.event((e=>{this.viewport.syncScrollArea(),this._selectionService.refresh()}))),this.register((0,r.addDisposableDomListener)(this._viewportElement,"scroll",(()=>this._selectionService.refresh()))),this.register(this._instantiationService.createInstance(c.BufferDecorationRenderer,this.screenElement)),this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)),this.register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRulerWidth&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRulerWidth",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(_.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(e._customWheelEventHandler&&!1===e._customWheelEventHandler(t))return!1;if(0===e.viewport.getLinesScrolled(t))return!1;r=t.deltaY<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},n={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this.register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",n.mousemove),s.mousemove=n.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",n.wheel,{passive:!1}),s.wheel=n.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(s.mouseup=n.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=n.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this.register((0,r.addDisposableDomListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this.register((0,r.addDisposableDomListener)(t,"wheel",(e=>{if(!s.wheel){if(this._customWheelEventHandler&&!1===this._customWheelEventHandler(e))return!1;if(!this.buffer.hasScrollback){const t=this.viewport.getLinesScrolled(e);if(0===t)return;const i=D.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");let s="";for(let e=0;e{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchStart(e),this.cancel(e)}),{passive:!0})),this.register((0,r.addDisposableDomListener)(t,"touchmove",(e=>{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchMove(e)?void 0:this.cancel(e)}),{passive:!1}))}refresh(e,t){this._renderService?.refreshRows(e,t)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t,i=0){1===i?(super.scrollLines(e,t,i),this.refresh(0,this.rows-1)):this.viewport?.scrollLines(e)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this._customWheelEventHandler=e}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,R.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==D.C0.ETX&&i.key!==D.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure(),this.viewport?.syncScrollArea(!0)}clear(){if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=Date.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=Date.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},1680:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(3656),o=i(4725),a=i(8460),h=i(844),c=i(2585);let l=t.Viewport=class extends h.Disposable{constructor(e,t,i,s,r,o,h,c){super(),this._viewportElement=e,this._scrollArea=t,this._bufferService=i,this._optionsService=s,this._charSizeService=r,this._renderService=o,this._coreBrowserService=h,this.scrollBarWidth=0,this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._wheelPartialScroll=0,this._refreshAnimationFrame=null,this._ignoreNextScrollEvent=!1,this._smoothScrollState={startTime:0,origin:-1,target:-1},this._onRequestScrollLines=this.register(new a.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this.scrollBarWidth=this._viewportElement.offsetWidth-this._scrollArea.offsetWidth||15,this.register((0,n.addDisposableDomListener)(this._viewportElement,"scroll",this._handleScroll.bind(this))),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._renderDimensions=this._renderService.dimensions,this.register(this._renderService.onDimensionsChange((e=>this._renderDimensions=e))),this._handleThemeChange(c.colors),this.register(c.onChangeColors((e=>this._handleThemeChange(e)))),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.syncScrollArea()))),setTimeout((()=>this.syncScrollArea()))}_handleThemeChange(e){this._viewportElement.style.backgroundColor=e.background.css}reset(){this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._coreBrowserService.window.requestAnimationFrame((()=>this.syncScrollArea()))}_refresh(e){if(e)return this._innerRefresh(),void(null!==this._refreshAnimationFrame&&this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame));null===this._refreshAnimationFrame&&(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._charSizeService.height>0){this._currentRowHeight=this._renderDimensions.device.cell.height/this._coreBrowserService.dpr,this._currentDeviceCellHeight=this._renderDimensions.device.cell.height,this._lastRecordedViewportHeight=this._viewportElement.offsetHeight;const e=Math.round(this._currentRowHeight*this._lastRecordedBufferLength)+(this._lastRecordedViewportHeight-this._renderDimensions.css.canvas.height);this._lastRecordedBufferHeight!==e&&(this._lastRecordedBufferHeight=e,this._scrollArea.style.height=this._lastRecordedBufferHeight+"px")}const e=this._bufferService.buffer.ydisp*this._currentRowHeight;this._viewportElement.scrollTop!==e&&(this._ignoreNextScrollEvent=!0,this._viewportElement.scrollTop=e),this._refreshAnimationFrame=null}syncScrollArea(e=!1){if(this._lastRecordedBufferLength!==this._bufferService.buffer.lines.length)return this._lastRecordedBufferLength=this._bufferService.buffer.lines.length,void this._refresh(e);this._lastRecordedViewportHeight===this._renderService.dimensions.css.canvas.height&&this._lastScrollTop===this._activeBuffer.ydisp*this._currentRowHeight&&this._renderDimensions.device.cell.height===this._currentDeviceCellHeight||this._refresh(e)}_handleScroll(e){if(this._lastScrollTop=this._viewportElement.scrollTop,!this._viewportElement.offsetParent)return;if(this._ignoreNextScrollEvent)return this._ignoreNextScrollEvent=!1,void this._onRequestScrollLines.fire({amount:0,suppressScrollEvent:!0});const t=Math.round(this._lastScrollTop/this._currentRowHeight)-this._bufferService.buffer.ydisp;this._onRequestScrollLines.fire({amount:t,suppressScrollEvent:!0})}_smoothScroll(){if(this._isDisposed||-1===this._smoothScrollState.origin||-1===this._smoothScrollState.target)return;const e=this._smoothScrollPercent();this._viewportElement.scrollTop=this._smoothScrollState.origin+Math.round(e*(this._smoothScrollState.target-this._smoothScrollState.origin)),e<1?this._coreBrowserService.window.requestAnimationFrame((()=>this._smoothScroll())):this._clearSmoothScrollState()}_smoothScrollPercent(){return this._optionsService.rawOptions.smoothScrollDuration&&this._smoothScrollState.startTime?Math.max(Math.min((Date.now()-this._smoothScrollState.startTime)/this._optionsService.rawOptions.smoothScrollDuration,1),0):1}_clearSmoothScrollState(){this._smoothScrollState.startTime=0,this._smoothScrollState.origin=-1,this._smoothScrollState.target=-1}_bubbleScroll(e,t){const i=this._viewportElement.scrollTop+this._lastRecordedViewportHeight;return!(t<0&&0!==this._viewportElement.scrollTop||t>0&&i0&&(i=e),s=""}}return{bufferElements:r,cursorElement:i}}getLinesScrolled(e){if(0===e.deltaY||e.shiftKey)return 0;let t=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(t/=this._currentRowHeight+0,this._wheelPartialScroll+=t,t=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(t*=this._bufferService.rows),t}_applyScrollModifier(e,t){const i=this._optionsService.rawOptions.fastScrollModifier;return"alt"===i&&t.altKey||"ctrl"===i&&t.ctrlKey||"shift"===i&&t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}handleTouchStart(e){this._lastTouchY=e.touches[0].pageY}handleTouchMove(e){const t=this._lastTouchY-e.touches[0].pageY;return this._lastTouchY=e.touches[0].pageY,0!==t&&(this._viewportElement.scrollTop+=t,this._bubbleScroll(e,t))}};t.Viewport=l=s([r(2,c.IBufferService),r(3,c.IOptionsService),r(4,o.ICharSizeService),r(5,o.IRenderService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],l)},3107:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(4725),o=i(844),a=i(2585);let h=t.BufferDecorationRenderer=class extends o.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this.register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this.register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this.register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this.register((0,o.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.display=this._altBufferIsActive?"none":"block",e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=h=s([r(1,a.IBufferService),r(2,n.ICoreBrowserService),r(3,a.IDecorationService),r(4,n.IRenderService)],h)},5871:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},5744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(5871),o=i(4725),a=i(844),h=i(2585),c={full:0,left:0,center:0,right:0},l={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let _=t.OverviewRulerRenderer=class extends a.Disposable{get _width(){return this._optionsService.options.overviewRulerWidth||0}constructor(e,t,i,s,r,o,h){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._coreBrowserService=h,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement);const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._registerDecorationListeners(),this._registerBufferChangeListeners(),this._registerDimensionChangeListeners(),this.register((0,a.toDisposable)((()=>{this._canvas?.remove()})))}_registerDecorationListeners(){this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this.register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0))))}_registerBufferChangeListeners(){this.register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this.register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})))}_registerDimensionChangeListeners(){this.register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this.register(this._optionsService.onSpecificOptionChange("overviewRulerWidth",(()=>this._queueRefresh(!0)))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh(!0)))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor(this._canvas.width/3),t=Math.ceil(this._canvas.width/3);l.full=this._canvas.width,l.left=e,l.center=t,l.right=e,this._refreshDrawHeightConstants(),d.full=0,d.left=0,d.center=l.left,d.right=l.left+l.center}_refreshDrawHeightConstants(){c.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);c.left=t,c.center=t,c.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1;const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-c[e.position||"full"]/2),l[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+c[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=_=s([r(2,h.IBufferService),r(3,h.IDecorationService),r(4,o.IRenderService),r(5,h.IOptionsService),r(6,o.ICoreBrowserService)],_)},2950:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(4725),o=i(2585),a=i(2584);let h=t.CompositionHelper=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,e.end):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=h=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],h)},9806:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoords=t.getCoordsRelativeToElement=void 0,t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,h,c){if(!o)return;const l=i(e,t,s);return l?(l[0]=Math.ceil((l[0]+(c?a/2:0))/a),l[1]=Math.ceil(l[1]/h),l[0]=Math.min(Math.max(l[0],1),r+(c?1:0)),l[1]=Math.min(Math.max(l[1],1),n),l):void 0}},9504:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=void 0;const s=i(2584);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),l=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,h="";for(;o!==i||a!==s;)o+=r?1:-1,r&&o>n.cols-1?(h+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(h+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return h+n.buffer.translateBufferLineToString(a,!1,e,o)}function h(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function c(e,t){e=Math.floor(e);let i="";for(let s=0;s0?s-n(s,o):t;const _=s,u=function(e,t,i,s,o,a){let h;return h=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&he?"D":"C",c(Math.abs(o-e),h(d,s));d=l>t?"D":"C";const _=Math.abs(l-t);return c(function(e,t){return t.cols-e}(l>t?e:o,i)+(_-1)*i.cols+1+((l>t?o:e)-1),h(d,s))}},1296:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(3787),o=i(2550),a=i(2223),h=i(6171),c=i(6052),l=i(4725),d=i(8055),_=i(8460),u=i(844),f=i(2585),v="xterm-dom-renderer-owner-",p="xterm-rows",g="xterm-fg-",m="xterm-bg-",S="xterm-focus",C="xterm-selection";let b=1,w=t.DomRenderer=class extends u.Disposable{constructor(e,t,i,s,r,a,l,d,f,g,m,S,w){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=l,this._charSizeService=f,this._optionsService=g,this._bufferService=m,this._coreBrowserService=S,this._themeService=w,this._terminalClass=b++,this._rowElements=[],this._selectionRenderModel=(0,c.createSelectionRenderModel)(),this.onRequestRedraw=this.register(new _.EventEmitter).event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add(p),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add(C),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this.register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=d.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(v+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this.register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this.register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this.register((0,u.toDisposable)((()=>{this._element.classList.remove(v+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(this._document,this._helperContainer),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${p} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${p} { color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${p} .xterm-dim { color: ${d.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${C} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${C} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${C} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${g}${i} { color: ${s.css}; }${this._terminalSelector} .${g}${i}.xterm-dim { color: ${d.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${m}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR} { color: ${d.color.opaque(e.background).css}; }${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${d.color.multiplyOpacity(d.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(S),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(S),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;this._selectionRenderModel.update(this._terminal,e,t,i);const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,n=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow;if(n>=this._bufferService.rows||o<0)return;const a=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,h=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,h));const c=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,c)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=this._document.createElement("div"),n=t*this.dimensions.css.cell.width;let o=this.dimensions.css.cell.width*(i-t);return n+o>this.dimensions.css.canvas.width&&(o=this.dimensions.css.canvas.width-n),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${n}px`,r.style.width=`${o}px`,r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._optionsService.rawOptions.cursorBlink,o=this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let h=e;h<=t;h++){const e=h+i.ydisp,t=this._rowElements[h],c=i.lines.get(e);if(!t||!c)break;t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${v}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,c=Math.min(a.x,r-1),l=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const u=o+a.ydisp,f=this._rowElements[o],v=a.lines.get(u);if(!f||!v)break;f.replaceChildren(...this._rowFactory.createRow(v,u,u===h,d,_,c,l,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=w=s([r(7,f.IInstantiationService),r(8,l.ICharSizeService),r(9,f.IOptionsService),r(10,f.IBufferService),r(11,l.ICoreBrowserService),r(12,l.IThemeService)],w)},3787:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(2223),o=i(643),a=i(511),h=i(2585),c=i(8055),l=i(4725),d=i(4269),_=i(6171),u=i(3734);let f=t.DomRendererRowFactory=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,l,_,f,p){const g=[],m=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let C,b=e.getNoBgTrimmedLength();i&&b0&&M===m[0][0]){O=!0;const t=m.shift();I=new d.JoinedCellData(this._workCell,e.translateToString(!0,t[0],t[1]),t[1]-t[0]),P=t[1]-1,b=I.getWidth()}const H=this._isCellInSelection(M,t),F=i&&M===a,W=T&&M>=f&&M<=p;let U=!1;this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{U=!0}));let N=I.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===N&&(I.isUnderline()||I.isOverline())&&(N=" "),A=b*l-_.get(N,I.isBold(),I.isItalic()),C){if(w&&(H&&x||!H&&!x&&I.bg===E)&&(H&&x&&S.selectionForeground||I.fg===k)&&I.extended.ext===L&&W===D&&A===R&&!F&&!O&&!U){I.isInvisible()?y+=o.WHITESPACE_CELL_CHAR:y+=N,w++;continue}w&&(C.textContent=y),C=this._document.createElement("span"),w=0,y=""}else C=this._document.createElement("span");if(E=I.bg,k=I.fg,L=I.extended.ext,D=W,R=A,x=H,O&&a>=M&&a<=P&&(a=M),!this._coreService.isCursorHidden&&F&&this._coreService.isCursorInitialized)if(B.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&B.push("xterm-cursor-blink"),B.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":B.push("xterm-cursor-outline");break;case"block":B.push("xterm-cursor-block");break;case"bar":B.push("xterm-cursor-bar");break;case"underline":B.push("xterm-cursor-underline")}if(I.isBold()&&B.push("xterm-bold"),I.isItalic()&&B.push("xterm-italic"),I.isDim()&&B.push("xterm-dim"),y=I.isInvisible()?o.WHITESPACE_CELL_CHAR:I.getChars()||o.WHITESPACE_CELL_CHAR,I.isUnderline()&&(B.push(`xterm-underline-${I.extended.underlineStyle}`)," "===y&&(y=" "),!I.isUnderlineColorDefault()))if(I.isUnderlineColorRGB())C.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(I.getUnderlineColor()).join(",")})`;else{let e=I.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&I.isBold()&&e<8&&(e+=8),C.style.textDecorationColor=S.ansi[e].css}I.isOverline()&&(B.push("xterm-overline")," "===y&&(y=" ")),I.isStrikethrough()&&B.push("xterm-strikethrough"),W&&(C.style.textDecoration="underline");let $=I.getFgColor(),j=I.getFgColorMode(),z=I.getBgColor(),K=I.getBgColorMode();const q=!!I.isInverse();if(q){const e=$;$=z,z=e;const t=j;j=K,K=t}let V,G,X,J=!1;switch(this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{"top"!==e.options.layer&&J||(e.backgroundColorRGB&&(K=50331648,z=e.backgroundColorRGB.rgba>>8&16777215,V=e.backgroundColorRGB),e.foregroundColorRGB&&(j=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,G=e.foregroundColorRGB),J="top"===e.options.layer)})),!J&&H&&(V=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,z=V.rgba>>8&16777215,K=50331648,J=!0,S.selectionForeground&&(j=50331648,$=S.selectionForeground.rgba>>8&16777215,G=S.selectionForeground)),J&&B.push("xterm-decoration-top"),K){case 16777216:case 33554432:X=S.ansi[z],B.push(`xterm-bg-${z}`);break;case 50331648:X=c.channels.toColor(z>>16,z>>8&255,255&z),this._addStyle(C,`background-color:#${v((z>>>0).toString(16),"0",6)}`);break;default:q?(X=S.foreground,B.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):X=S.background}switch(V||I.isDim()&&(V=c.color.multiplyOpacity(X,.5)),j){case 16777216:case 33554432:I.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(C,X,S.ansi[$],I,V,void 0)||B.push(`xterm-fg-${$}`);break;case 50331648:const e=c.channels.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(C,X,e,I,V,G)||this._addStyle(C,`color:#${v($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(C,X,S.foreground,I,V,G)||q&&B.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}B.length&&(C.className=B.join(" "),B.length=0),F||O||U?C.textContent=y:w++,A!==this.defaultSpacing&&(C.style.letterSpacing=`${A}px`),g.push(C),M=P}return C&&w&&(C.textContent=y),g}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=c.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function v(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e,t){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.classList.add("xterm-width-cache-measure-container"),this._container.setAttribute("aria-hidden","true"),this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const i=e.createElement("span");i.classList.add("xterm-char-measure-element");const s=e.createElement("span");s.classList.add("xterm-char-measure-element"),s.style.fontWeight="bold";const r=e.createElement("span");r.classList.add("xterm-char-measure-element"),r.style.fontStyle="italic";const n=e.createElement("span");n.classList.add("xterm-char-measure-element"),n.style.fontWeight="bold",n.style.fontStyle="italic",this._measureElements=[i,s,r,n],this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),this._container.appendChild(n),t.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),n>0&&this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},2223:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TEXT_BASELINE=t.DIM_OPACITY=t.INVERTED_DEFAULT_COLOR=void 0;const s=i(6114);t.INVERTED_DEFAULT_COLOR=257,t.DIM_OPACITY=.5,t.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},6171:(e,t)=>{function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.computeNextVariantOffset=t.createRenderDimensions=t.treatGlyphAsBackgroundColor=t.allowRescaling=t.isEmoji=t.isRestrictedPowerlineGlyph=t.isPowerlineGlyph=t.throwIfFalsy=void 0,t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,n){return 1===t&&r>Math.ceil(1.5*n)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},6052:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=void 0;class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,n=t[1]-r,o=i[1]-r,a=Math.max(n,0),h=Math.min(o,e.rows-1);a>=e.rows||h<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=n,this.viewportEndRow=o,this.viewportCappedStartRow=a,this.viewportCappedEndRow=h,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}t.createSelectionRenderModel=function(){return new i}},456:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},428:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(2585),o=i(8460),a=i(844);let h=t.CharSizeService=class extends a.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this.register(new o.EventEmitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this.register(new d(this._optionsService))}catch{this._measureStrategy=this.register(new l(e,t,this._optionsService))}this.register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h=s([r(2,n.IOptionsService)],h);class c extends a.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class l extends c{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends c{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},4269:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(3734),o=i(643),a=i(511),h=i(2585);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let l=t.CharacterJoinerService=class e{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new a.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0;const s=i(844),r=i(8460),n=i(3656);class o extends s.Disposable{constructor(e,t,i){super(),this._textarea=e,this._window=t,this.mainDocument=i,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=new a(this._window),this._onDprChange=this.register(new r.EventEmitter),this.onDprChange=this._onDprChange.event,this._onWindowChange=this.register(new r.EventEmitter),this.onWindowChange=this._onWindowChange.event,this.register(this.onWindowChange((e=>this._screenDprMonitor.setWindow(e)))),this.register((0,r.forwardEvent)(this._screenDprMonitor.onDprChange,this._onDprChange)),this._textarea.addEventListener("focus",(()=>this._isFocused=!0)),this._textarea.addEventListener("blur",(()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}t.CoreBrowserService=o;class a extends s.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this.register(new s.MutableDisposable),this._onDprChange=this.register(new r.EventEmitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this.register((0,s.toDisposable)((()=>this.clearListener())))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,n.addDisposableDomListener)(this._parentWindow,"resize",(()=>this._setDprAndFireIfDiffers()))}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},779:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(844);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this.register((0,s.toDisposable)((()=>this.linkProviders.length=0)))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},8934:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(4725),o=i(9806);let a=t.MouseService=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},3230:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(6193),o=i(4725),a=i(8460),h=i(844),c=i(7226),l=i(2585);let d=t.RenderService=class extends h.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,o,l,d){super(),this._rowCount=e,this._charSizeService=s,this._renderer=this.register(new h.MutableDisposable),this._pausedResizeTask=new c.DebouncedIdleTask,this._observerDisposable=this.register(new h.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this.register(new a.EventEmitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this.register(new a.EventEmitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this.register(new a.EventEmitter),this.onRender=this._onRender.event,this._onRefreshRequest=this.register(new a.EventEmitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new n.RenderDebouncer(((e,t)=>this._renderRows(e,t)),l),this.register(this._renderDebouncer),this.register(l.onDprChange((()=>this.handleDevicePixelRatioChange()))),this.register(o.onResize((()=>this._fullRefresh()))),this.register(o.buffers.onBufferActivate((()=>this._renderer.value?.clear()))),this.register(i.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this.register(r.onDecorationRegistered((()=>this._fullRefresh()))),this.register(r.onDecorationRemoved((()=>this._fullRefresh()))),this.register(i.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],(()=>{this.clear(),this.handleResize(o.cols,o.rows),this._fullRefresh()}))),this.register(i.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(o.buffer.y,o.buffer.y,!0)))),this.register(d.onChangeColors((()=>this._fullRefresh()))),this._registerIntersectionObserver(l.window,t),this.register(l.onWindowChange((e=>this._registerIntersectionObserver(e,t))))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});i.observe(t),this._observerDisposable.value=(0,h.toDisposable)((()=>i.disconnect()))}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){this._isPaused?this._needsFullRefresh=!0:(i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount))}_renderRows(e,t){this._renderer.value&&(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0)}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value?.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d=s([r(2,l.IOptionsService),r(3,o.ICharSizeService),r(4,l.IDecorationService),r(5,l.IBufferService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],d)},9312:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(9806),o=i(9504),a=i(456),h=i(4725),c=i(8460),l=i(844),d=i(6114),_=i(4841),u=i(511),f=i(2585),v=String.fromCharCode(160),p=new RegExp(v,"g");let g=t.SelectionService=class extends l.Disposable{constructor(e,t,i,s,r,n,o,h,d){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=h,this._coreBrowserService=d,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this.register(new c.EventEmitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this.register(new c.EventEmitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this.register(new c.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this.register(new c.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this.register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this.register((0,l.toDisposable)((()=>{this._removeMouseDownListeners()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(p," "))).join(d.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),d.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return d.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(d.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),h=a;const c=e[0]-a;let l=0,d=0,_=0,u=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(l++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+c-l+_,v=Math.min(this._bufferService.cols,h-a+l+d-_-u);if(t||""!==o.slice(a,h).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,v+=e}}}if(s&&f+v===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(v+=t.length)}}return{start:f,length:v}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=g=s([r(3,f.IBufferService),r(4,f.ICoreService),r(5,h.IMouseService),r(6,f.IOptionsService),r(7,h.IRenderService),r(8,h.ICoreBrowserService)],g)},4725:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(8343);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService")},6731:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=t.DEFAULT_ANSI_COLORS=void 0;const n=i(7239),o=i(8055),a=i(8460),h=i(844),c=i(2585),l=o.css.toColor("#ffffff"),d=o.css.toColor("#000000"),_=o.css.toColor("#ffffff"),u=o.css.toColor("#000000"),f={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117};t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[o.css.toColor("#2e3436"),o.css.toColor("#cc0000"),o.css.toColor("#4e9a06"),o.css.toColor("#c4a000"),o.css.toColor("#3465a4"),o.css.toColor("#75507b"),o.css.toColor("#06989a"),o.css.toColor("#d3d7cf"),o.css.toColor("#555753"),o.css.toColor("#ef2929"),o.css.toColor("#8ae234"),o.css.toColor("#fce94f"),o.css.toColor("#729fcf"),o.css.toColor("#ad7fa8"),o.css.toColor("#34e2e2"),o.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const s=t[i/36%6|0],r=t[i/6%6|0],n=t[i%6];e.push({css:o.channels.toCss(s,r,n),rgba:o.channels.toRgba(s,r,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:o.channels.toCss(i,i,i),rgba:o.channels.toRgba(i,i,i)})}return e})());let v=t.ThemeService=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this.register(new a.EventEmitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:l,background:d,cursor:_,cursorAccent:u,selectionForeground:void 0,selectionBackgroundTransparent:f,selectionBackgroundOpaque:o.color.blend(d,f),selectionInactiveBackgroundTransparent:f,selectionInactiveBackgroundOpaque:o.color.blend(d,f),ansi:t.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this.register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this.register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const i=this._colors;if(i.foreground=p(e.foreground,l),i.background=p(e.background,d),i.cursor=p(e.cursor,_),i.cursorAccent=p(e.cursorAccent,u),i.selectionBackgroundTransparent=p(e.selectionBackground,f),i.selectionBackgroundOpaque=o.color.blend(i.background,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundTransparent=p(e.selectionInactiveBackground,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundOpaque=o.color.blend(i.background,i.selectionInactiveBackgroundTransparent),i.selectionForeground=e.selectionForeground?p(e.selectionForeground,o.NULL_COLOR):void 0,i.selectionForeground===o.NULL_COLOR&&(i.selectionForeground=void 0),o.color.isOpaque(i.selectionBackgroundTransparent)){const e=.3;i.selectionBackgroundTransparent=o.color.opacity(i.selectionBackgroundTransparent,e)}if(o.color.isOpaque(i.selectionInactiveBackgroundTransparent)){const e=.3;i.selectionInactiveBackgroundTransparent=o.color.opacity(i.selectionInactiveBackgroundTransparent,e)}if(i.ansi=t.DEFAULT_ANSI_COLORS.slice(),i.ansi[0]=p(e.black,t.DEFAULT_ANSI_COLORS[0]),i.ansi[1]=p(e.red,t.DEFAULT_ANSI_COLORS[1]),i.ansi[2]=p(e.green,t.DEFAULT_ANSI_COLORS[2]),i.ansi[3]=p(e.yellow,t.DEFAULT_ANSI_COLORS[3]),i.ansi[4]=p(e.blue,t.DEFAULT_ANSI_COLORS[4]),i.ansi[5]=p(e.magenta,t.DEFAULT_ANSI_COLORS[5]),i.ansi[6]=p(e.cyan,t.DEFAULT_ANSI_COLORS[6]),i.ansi[7]=p(e.white,t.DEFAULT_ANSI_COLORS[7]),i.ansi[8]=p(e.brightBlack,t.DEFAULT_ANSI_COLORS[8]),i.ansi[9]=p(e.brightRed,t.DEFAULT_ANSI_COLORS[9]),i.ansi[10]=p(e.brightGreen,t.DEFAULT_ANSI_COLORS[10]),i.ansi[11]=p(e.brightYellow,t.DEFAULT_ANSI_COLORS[11]),i.ansi[12]=p(e.brightBlue,t.DEFAULT_ANSI_COLORS[12]),i.ansi[13]=p(e.brightMagenta,t.DEFAULT_ANSI_COLORS[13]),i.ansi[14]=p(e.brightCyan,t.DEFAULT_ANSI_COLORS[14]),i.ansi[15]=p(e.brightWhite,t.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const s=Math.min(i.ansi.length-16,e.extendedAnsi.length);for(let r=0;r{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this.register(new s.EventEmitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this.register(new s.EventEmitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this.register(new s.EventEmitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=void 0,t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},8055:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.contrastRatio=t.toPaddedHex=t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0;let i=0,s=0,r=0,n=0;var o,a,h,c,l;function d(e){const t=e.toString(16);return t.length<2?"0"+t:t}function _(e,t){return e>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(o||(t.channels=o={})),function(e){function t(e,t){return n=Math.round(255*t),[i,s,r]=l.toChannels(e.rgba),{css:o.toCss(i,s,r,n),rgba:o.toRgba(i,s,r,n)}}e.blend=function(e,t){if(n=(255&t.rgba)/255,1===n)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,h=t.rgba>>16&255,c=t.rgba>>8&255,l=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return i=l+Math.round((a-l)*n),s=d+Math.round((h-d)*n),r=_+Math.round((c-_)*n),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},e.isOpaque=function(e){return 255==(255&e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=l.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=l.toChannels(t),{css:o.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return n=255&e.rgba,t(e,n*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n=parseInt(e.slice(4,5).repeat(2),16),o.toColor(i,s,r,n);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const h=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(h)return i=parseInt(h[1]),s=parseInt(h[2]),r=parseInt(h[3]),n=Math.round(255*(void 0===h[5]?1:parseFloat(h[5]))),o.toColor(i,s,r,n);if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,n]=t.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,n),css:e}}}(h||(t.css=h={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(c||(t.rgb=c={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));for(;l0||a>0||h>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));return(o<<24|a<<16|h<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));for(;l>>0}e.blend=function(e,t){if(n=(255&t)/255,1===n)return t;const a=t>>24&255,h=t>>16&255,c=t>>8&255,l=e>>24&255,d=e>>16&255,_=e>>8&255;return i=l+Math.round((a-l)*n),s=d+Math.round((h-d)*n),r=_+Math.round((c-_)*n),o.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=c.relativeLuminance(e>>8),n=c.relativeLuminance(i>>8);if(_(r,n)>8));if(o_(r,c.relativeLuminance(t>>8))?n:t}return n}const o=a(e,i,s),h=_(r,c.relativeLuminance(o>>8));if(h_(r,c.relativeLuminance(n>>8))?o:n}return o}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(l||(t.rgba=l={})),t.toPaddedHex=d,t.contrastRatio=_},8969:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(844),r=i(2585),n=i(4348),o=i(7866),a=i(744),h=i(7302),c=i(6975),l=i(8460),d=i(1753),_=i(1480),u=i(7994),f=i(9282),v=i(5435),p=i(5981),g=i(2660);let m=!1;class S extends s.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this.register(new l.EventEmitter),this._onScroll.event((e=>{this._onScrollApi?.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this.register(new s.MutableDisposable),this._onBinary=this.register(new l.EventEmitter),this.onBinary=this._onBinary.event,this._onData=this.register(new l.EventEmitter),this.onData=this._onData.event,this._onLineFeed=this.register(new l.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this.register(new l.EventEmitter),this.onResize=this._onResize.event,this._onWriteParsed=this.register(new l.EventEmitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this.register(new l.EventEmitter),this._instantiationService=new n.InstantiationService,this.optionsService=this.register(new h.OptionsService(e)),this._instantiationService.setService(r.IOptionsService,this.optionsService),this._bufferService=this.register(this._instantiationService.createInstance(a.BufferService)),this._instantiationService.setService(r.IBufferService,this._bufferService),this._logService=this.register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(r.ILogService,this._logService),this.coreService=this.register(this._instantiationService.createInstance(c.CoreService)),this._instantiationService.setService(r.ICoreService,this.coreService),this.coreMouseService=this.register(this._instantiationService.createInstance(d.CoreMouseService)),this._instantiationService.setService(r.ICoreMouseService,this.coreMouseService),this.unicodeService=this.register(this._instantiationService.createInstance(_.UnicodeService)),this._instantiationService.setService(r.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(u.CharsetService),this._instantiationService.setService(r.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(g.OscLinkService),this._instantiationService.setService(r.IOscLinkService,this._oscLinkService),this._inputHandler=this.register(new v.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this.register((0,l.forwardEvent)(this._inputHandler.onLineFeed,this._onLineFeed)),this.register(this._inputHandler),this.register((0,l.forwardEvent)(this._bufferService.onResize,this._onResize)),this.register((0,l.forwardEvent)(this.coreService.onData,this._onData)),this.register((0,l.forwardEvent)(this.coreService.onBinary,this._onBinary)),this.register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom()))),this.register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this.register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this.register(this._bufferService.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this.register(this._inputHandler.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this.register(new p.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this.register((0,l.forwardEvent)(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=r.LogLevelEnum.WARN&&!m&&(this._logService.warn("writeSync is unreliable and will be removed soon."),m=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,a.MINIMUM_COLS),t=Math.max(t,a.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t,i){this._bufferService.scrollLines(e,t,i)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(f.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,f.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,s.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},8460:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.runAndSubscribe=t.forwardEvent=t.EventEmitter=void 0,t.EventEmitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=e=>(this._listeners.push(e),{dispose:()=>{if(!this._disposed)for(let t=0;tt.fire(e)))},t.runAndSubscribe=function(e,t){return t(void 0),e((e=>t(e)))}},5435:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0;const n=i(2584),o=i(7116),a=i(2015),h=i(844),c=i(482),l=i(8437),d=i(8460),_=i(643),u=i(511),f=i(3734),v=i(2585),p=i(1480),g=i(6242),m=i(6351),S=i(5941),C={"(":0,")":1,"*":2,"+":3,"-":1,".":2},b=131072;function w(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var y;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(y||(t.WindowsOptionsReportType=y={}));let E=0;class k extends h.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,h,_,f,v=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=h,this._coreMouseService=_,this._unicodeService=f,this._parser=v,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new c.StringToUtf32,this._utf8Decoder=new c.Utf8ToUtf32,this._workCell=new u.CellData,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this.register(new d.EventEmitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this.register(new d.EventEmitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this.register(new d.EventEmitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this.register(new d.EventEmitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this.register(new d.EventEmitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this.register(new d.EventEmitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this.register(new d.EventEmitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this.register(new d.EventEmitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this.register(new d.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this.register(new d.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this.register(new d.EventEmitter),this.onScroll=this._onScroll.event,this._onTitleChange=this.register(new d.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this.register(new d.EventEmitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this.register(this._parser),this._dirtyRowTracker=new L(this._bufferService),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new g.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new g.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new g.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new g.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new g.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new g.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new g.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new g.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new g.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new g.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new g.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new g.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new m.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=v.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>b&&(n=this._parseStack.position+b)}if(this._logService.logLevel<=v.LogLevelEnum.DEBUG&&this._logService.debug("parsing data"+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`),"string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthb)for(let t=n;t0&&2===f.getWidth(this._activeBuffer.x-1)&&f.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let v=this._parser.precedingJoinState;for(let g=t;ga)if(h){const e=f;let t=this._activeBuffer.x-m;for(this._activeBuffer.x=m,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),f=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),m>0&&f instanceof l.BufferLine&&f.copyCellsFrom(e,t,0,m,!1);t=0;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}else if(d&&(f.insertCells(this._activeBuffer.x,r-m,this._activeBuffer.getNullCell(u)),2===f.getWidth(a-1)&&f.setCellFromCodepoint(a-1,_.NULL_CELL_CODE,_.NULL_CELL_WIDTH,u)),f.setCellFromCodepoint(this._activeBuffer.x++,s,r,u),r>0)for(;--r;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=v,this._activeBuffer.x0&&0===f.getWidth(this._activeBuffer.x)&&!f.hasContent(this._activeBuffer.x)&&f.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!w(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new m.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new g.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let h=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,u=e.params[0];return f=u,v=t?2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(d.convertEol):0:1===u?_(i.applicationCursorKeys):3===u?d.windowOptions.setWinLines?80===h?2:132===h?1:0:0:6===u?_(i.origin):7===u?_(i.wraparound):8===u?3:9===u?_("X10"===s):12===u?_(d.cursorBlink):25===u?_(!o.isCursorHidden):45===u?_(i.reverseWraparound):66===u?_(i.applicationKeypad):67===u?4:1e3===u?_("VT200"===s):1002===u?_("DRAG"===s):1003===u?_("ANY"===s):1004===u?_(i.sendFocus):1005===u?4:1006===u?_("SGR"===r):1015===u?4:1016===u?_("SGR_PIXELS"===r):1048===u?1:47===u||1047===u||1049===u?_(c===l):2004===u?_(i.bracketedPasteMode):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${v}$y`),!0;var f,v}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=f.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=e.params[0]||1;switch(t){case 1:case 2:this._optionsService.options.cursorStyle="block";break;case 3:case 4:this._optionsService.options.cursorStyle="underline";break;case 5:case 6:this._optionsService.options.cursorStyle="bar"}const i=t%2==1;return this._optionsService.options.cursorBlink=i,!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!w(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(y.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(y.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(D(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,S.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.split(";");return!(t.length<2)&&(t[1]?this._createHyperlink(t[0],t[1]):!t[0]&&this._finishHyperlink())}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,S.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=k;let L=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(E=e,e=t,t=E),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function D(e){return 0<=e&&e<256}L=s([r(0,v.IBufferService)],L)},844:(e,t)=>{function i(e){for(const t of e)t.dispose();e.length=0}Object.defineProperty(t,"__esModule",{value:!0}),t.getDisposeArrayDisposable=t.disposeArray=t.toDisposable=t.MutableDisposable=t.Disposable=void 0,t.Disposable=class{constructor(){this._disposables=[],this._isDisposed=!1}dispose(){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.length=0}register(e){return this._disposables.push(e),e}unregister(e){const t=this._disposables.indexOf(e);-1!==t&&this._disposables.splice(t,1)}},t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}},t.toDisposable=function(e){return{dispose:e}},t.disposeArray=i,t.getDisposeArrayDisposable=function(e){return{dispose:()=>i(e)}}},1505:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},6114:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.getSafariVersion=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.isNode="undefined"!=typeof process&&"title"in process;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},6106:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;let i=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[]}clear(){this._array.length=0}insert(e){0!==this._array.length?(i=this._search(this._getKey(e)),this._array.splice(i,0,e)):this._array.push(e)}delete(e){if(0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(i=this._search(t),-1===i)return!1;if(this._getKey(this._array[i])!==t)return!1;do{if(this._array[i]===e)return this._array.splice(i,1),!0}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{yield this._array[i]}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{t(this._array[i])}while(++i=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},7226:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(6114);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=Date.now()+e;return{timeRemaining:()=>Math.max(0,t-Date.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},9282:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=void 0;const s=i(643);t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)}},3734:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return 50331648==(50331648&this.fg)}isBgRGB(){return 50331648==(50331648&this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return 0==(50331648&this.fg)}isBgDefault(){return 0==(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?50331648==(50331648&this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?0==(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},9092:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(6349),r=i(7226),n=i(3734),o=i(8437),a=i(4634),h=i(511),c=i(643),l=i(4863),d=i(7116);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=h.CellData.fromCharData([0,c.NULL_CELL_CHAR,c.NULL_CELL_WIDTH,c.NULL_CELL_CODE]),this._whitespaceCell=h.CellData.fromCharData([0,c.WHITESPACE_CELL_CHAR,c.WHITESPACE_CELL_WIDTH,c.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA));if(i.length>0){const s=(0,a.reflowLargerCreateNewLayout)(this.lines,i);(0,a.reflowLargerApplyNewLayout)(this.lines,s.layout),this._reflowLargerAdjustViewport(e,t,s.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);const l=this.ybase+this.y;if(l>=n&&l0&&(s.push({start:n+c.length+r,newLines:v}),r+=v.length),c.push(...v);let p=_.length-1,g=_[p];0===g&&(p--,g=_[p]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[p])break;if(c[p].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(p--,g=_[p]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;c--)if(a&&a.start>n+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(c--,a.newLines[e]);c++,e.push({index:n+1,amount:a.newLines.length}),h+=a.newLines.length,a=s[++o]}else this.lines.set(c,t[n--]);let c=0;for(let t=e.length-1;t>=0;t--)e[t].index+=c,this.lines.onInsertEmitter.fire(e[t]),c+=e[t].amount;const l=Math.max(0,i+r-this.lines.maxLength);l>0&&this.lines.onTrimEmitter.fire(l)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},8437:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(3734),r=i(511),n=i(643),o=i(482);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class h{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,o.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&s)+(0,o.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e,t,i,s){t=t??0,i=i??this.length,e&&(i=Math.min(i,this.getTrimmedLength())),s&&(s.length=0);let r="";for(;t>22||1}return s&&s.push(t),r}}t.BufferLine=h},4841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=void 0,t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},4634:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.getWrappedLineTrimmedLength=t.reflowSmallerGetNewLineLengths=t.reflowLargerApplyNewLayout=t.reflowLargerCreateNewLayout=t.reflowLargerGetLinesToRemove=void 0,t.reflowLargerGetLinesToRemove=function(e,t,s,r,n){const o=[];for(let a=0;a=a&&r0&&(e>d||0===l[e].getTrimmedLength());e--)v++;v>0&&(o.push(a+l.length-v),o.push(v)),a+=l.length-1}return o},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,h=0;for(;hc&&(o-=c,a++);const l=2===e[a].getWidth(o-1);l&&o--;const d=l?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},5295:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(8460),r=i(844),n=i(9092);class o extends r.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this.register(new s.EventEmitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this.register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new n.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new n.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},511:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(482),r=i(643),n=i(3734);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},643:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=256|t.DEFAULT_COLOR<<9,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},4863:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(8460),r=i(844);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.EventEmitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.disposeArray)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},7116:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},2584:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},7399:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=void 0;const s=i(2584),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:o.key=e.ctrlKey?"\b":s.C0.DEL,e.altKey&&(o.key=s.C0.ESC+o.key);break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"D",o.key===s.C0.ESC+"[1;3D"&&(o.key=s.C0.ESC+(i?"b":"[1;5D"))):o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"C",o.key===s.C0.ESC+"[1;3C"&&(o.key=s.C0.ESC+(i?"f":"[1;5C"))):o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"A",i||o.key!==s.C0.ESC+"[1;3A"||(o.key=s.C0.ESC+"[1;5A")):o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"B",i||o.key!==s.C0.ESC+"[1;3B"||(o.key=s.C0.ESC+"[1;5B")):o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=t?.[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o}},482:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=t.utf32ToString=t.stringFromCodePoint=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,h=0,c=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const h=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,l=h-o;for(;c=i)return 0;if(n=e[c++],128!=(192&n)){c--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===h?r<128?c--:t[a++]=r:3===h?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const l=i-4;let d=c;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(h=(31&s)<<6|63&r,h<128){d--;continue}t[a++]=h}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(h=(15&s)<<12|(63&r)<<6|63&n,h<2048||h>=55296&&h<=57343||65279===h)continue;t[a++]=h}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(h=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,h<65536||h>1114111)continue;t[a++]=h}}return a}}},225:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(1480),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],n=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let o;t.UnicodeV6=class{constructor(){if(this.version="6",!o){o=new Uint8Array(65536),o.fill(1),o[0]=0,o.fill(0,1,32),o.fill(0,127,160),o.fill(2,4352,4448),o[9001]=2,o[9002]=2,o.fill(2,11904,42192),o[12351]=1,o.fill(2,44032,55204),o.fill(2,63744,64256),o.fill(2,65040,65050),o.fill(2,65072,65136),o.fill(2,65280,65377),o.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},5981:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this.register(new s.EventEmitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||Date.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>Date.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,Date.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},5941:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.toRgbString=t.parseColor=void 0;const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`}},5770:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},6351:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(482),r=i(8742),n=i(5770),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},2015:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(844),r=i(8742),n=i(6242),o=i(6351);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(h,0,2,0),e.add(h,8,5,8),e.add(h,6,0,6),e.add(h,11,0,11),e.add(h,13,13,13),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this.register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this.register(new n.OscParser),this._dcsParser=this.register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingJoinState=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const c=this._escHandlers[this._collect<<8|r];let l=c?c.length-1:-1;for(;l>=0&&(s=c[l](),!0!==s);l--)if(s instanceof Promise)return this._preserveStack(4,c,l,n,i),s;l<0&&this._escHandlerFb(this._collect<<8|r),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(5770),r=i(482),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},8742:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},5741:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(3785),r=i(511);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},3785:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(511);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},8285:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(8771),r=i(8460),n=i(844);class o extends n.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this.register(new r.EventEmitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},7975:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},7090:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(8460),o=i(844),a=i(5295),h=i(2585);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let c=t.BufferService=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this.register(new n.EventEmitter),this.onResize=this._onResize.event,this._onScroll=this.register(new n.EventEmitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this.register(new a.BufferSet(e,this))}resize(e,t){this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t,i){const s=this.buffer;if(e<0){if(0===s.ydisp)return;this.isUserScrolling=!0}else e+s.ydisp>=s.ybase&&(this.isUserScrolling=!1);const r=s.ydisp;s.ydisp=Math.max(Math.min(s.ydisp+e,s.ybase),0),r!==s.ydisp&&(t||this._onScroll.fire(s.ydisp))}};t.BufferService=c=s([r(0,h.IOptionsService)],c)},7994:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},1753:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(2585),o=i(8460),a=i(844),h={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function c(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const l=String.fromCharCode,d={DEFAULT:e=>{const t=[c(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${l(t[0])}${l(t[1])}${l(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.x};${e.y}${t}`}};let _=t.CoreMouseService=class extends a.Disposable{constructor(e,t){super(),this._bufferService=e,this._coreService=t,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._onProtocolChange=this.register(new o.EventEmitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(h))this.addProtocol(e,h[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=_=s([r(0,n.IBufferService),r(1,n.ICoreService)],_)},6975:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(1439),o=i(8460),a=i(844),h=i(2585),c=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,origin:!1,reverseWraparound:!1,sendFocus:!1,wraparound:!0});let d=t.CoreService=class extends a.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this.register(new o.EventEmitter),this.onData=this._onData.event,this._onUserInput=this.register(new o.EventEmitter),this.onUserInput=this._onUserInput.event,this._onBinary=this.register(new o.EventEmitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this.register(new o.EventEmitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}reset(){this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d=s([r(0,h.IBufferService),r(1,h.ILogService),r(2,h.IOptionsService)],d)},9074:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(8055),r=i(8460),n=i(844),o=i(6106);let a=0,h=0;class c extends n.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new o.SortedList((e=>e?.marker.line)),this._onDecorationRegistered=this.register(new r.EventEmitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this.register(new r.EventEmitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this.register((0,n.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new l(e);if(t){const e=t.marker.onDispose((()=>t.dispose()));t.onDispose((()=>{t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())})),this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){let s=0,r=0;for(const n of this._decorations.getKeyIterator(t))s=n.options.x??0,r=s+(n.options.width??1),e>=s&&e{a=t.options.x??0,h=a+(t.options.width??1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(2585),r=i(8343);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7866:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.traceCall=t.setTraceLogger=t.LogService=void 0;const n=i(844),o=i(2585),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let h,c=t.LogService=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this.register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),h=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tJSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return h.trace(`GlyphRenderer#${s.name} return`,t),t}}},7302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(8460),r=i(844),n=i(6114);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"courier-new, courier, monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},rescaleOverlappingGlyphs:!1,rightClickSelectsWord:n.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRulerWidth:0};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends r.Disposable{constructor(e){super(),this._onOptionChange=this.register(new s.EventEmitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this.register((0,r.toDisposable)((()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null})))}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},2660:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(2585);let o=t.OscLinkService=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o=s([r(0,n.IBufferService)],o)},8343:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createDecorator=t.getServiceDependencies=t.serviceRegistry=void 0;const i="di$target",s="di$dependencies";t.serviceRegistry=new Map,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r.toString=()=>e,t.serviceRegistry.set(e,r),r}},2585:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(8343);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},1480:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8460),r=i(225);class n{static extractShouldJoin(e){return 0!=(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.EventEmitter,this.onChange=this._onChange.event;const e=new r.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let r=0;r=s)return t+this.wcwidth(o);const i=e.charCodeAt(r);56320<=i&&i<=57343?o=1024*(o-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(o,i);let h=n.extractWidth(a);n.extractShouldJoin(a)&&(h-=n.extractWidth(i)),t+=h,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=n}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(9042),r=i(3236),n=i(844),o=i(5741),a=i(8285),h=i(7975),c=i(7090),l=["cols","rows"];class d extends n.Disposable{constructor(e){super(),this._core=this.register(new r.Terminal(e)),this._addonManager=this.register(new o.AddonManager),this._publicOptions={...this._core.options};const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(l.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new h.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new c.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this.register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._checkProposedApi(),this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return t}_verifyIntegers(...e){for(const t of e)if(t===1/0||isNaN(t)||t%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(const t of e)if(t&&(t===1/0||isNaN(t)||t%1!=0||t<0))throw new Error("This API only accepts positive integers")}}e.Terminal=d})(),s})())); +//# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/stacks/vllm/.env.example b/stacks/vllm/.env.example new file mode 100644 index 0000000..789ae35 --- /dev/null +++ b/stacks/vllm/.env.example @@ -0,0 +1,23 @@ +# Data disk mount point +DATA_ROOT=/data + +# --- REQUIRED before start (no default model) --- +VLLM_MODEL= +SERVED_MODEL_NAME=qwen3.6-27b + +# --- Context and VRAM --- +MAX_MODEL_LEN=131072 +MAX_NUM_SEQS=1 +GPU_MEMORY_UTILIZATION=0.95 +KV_CACHE_DTYPE=fp8 + +# --- Model quantization (leave empty for full-precision models) --- +QUANTIZATION=awq + +# Model-specific vLLM flags (space-separated) +VLLM_EXTRA_ARGS=--language-model-only --enforce-eager --reasoning-parser qwen3 + +# Hugging Face token — only for gated models +HF_TOKEN= + +VLLM_PORT=8000 diff --git a/stacks/vllm/README.md b/stacks/vllm/README.md new file mode 100644 index 0000000..4de6891 --- /dev/null +++ b/stacks/vllm/README.md @@ -0,0 +1,182 @@ +# vLLM stack + +Serwer inference vLLM z API kompatybilnym z OpenAI. **Brak panelu UI** — konfiguracja przez plik `.env`, profile i katalog modeli. + +## Jak to działa + +```mermaid +flowchart LR + client["Klient curl / OpenAI SDK"] + api["vLLM :8000 /v1/*"] + gpu["RTX 3090 Ti"] + data["/data/apps/vllm/huggingface"] + + client --> api + api --> gpu + api --> data +``` + +| Element | Opis | +|---------|------| +| Obraz | `vllm/vllm-openai` | +| Port | `8000` (OpenAI-compatible) | +| Konfiguracja | `.env` + profile + `models.catalog.yaml` | +| Modele vLLM | Hugging Face AWQ → `/data/apps/vllm/huggingface` | +| Modele GGUF | Katalog + `/data/apps/gguf/` → przyszły [`stacks/llamacpp/`](../llamacpp/) | +| UI | **Brak** — opcjonalnie Open WebUI w przyszłości | + +## GGUF vs AWQ (ważne) + +| Źródło | Format | Runtime | +|--------|--------|---------| +| [lmstudio-community GGUF](https://huggingface.co/lmstudio-community) | `.gguf` Q4 | **llama.cpp** (planowany) | +| Hugging Face AWQ | safetensors INT4 | **vLLM** (teraz) | + +Standardowy `vllm/vllm-openai` **nie ładuje plików `.gguf`**. Linki GGUF z katalogu są pod przyszły host llama.cpp. Na vLLM używamy `Qwen/Qwen3.6-27B-Instruct-AWQ` jako odpowiednika Q4. + +vLLM = **jeden model w VRAM** na kontener. Kilka modeli może leżeć na dysku — przełączanie = zmiana profilu + restart. + +## Mapowanie z LM Studio / Ollama + +| LM Studio / Ollama | vLLM | +|--------------------|------| +| Model GGUF Q4 (lmstudio) | AWQ z HF + `QUANTIZATION=awq` (interim) | +| K Cache Q4_0 | `KV_CACHE_DTYPE=fp8` | +| V Cache Q4_0 | j.w. — vLLM nie ma flagi `Q4_0` | +| Context 128K | `MAX_MODEL_LEN=131072` | +| 1 wątek / 1 request | `MAX_NUM_SEQS=1` | +| GPU layers max | `GPU_MEMORY_UTILIZATION=0.95` | + +Docelowo GGUF + natywne K/V `q4_0`: [`stacks/llamacpp/README.md`](../llamacpp/README.md). + +## Struktura katalogów + +``` +stacks/vllm/ +├── README.md +├── models.catalog.yaml # lista modeli (bez auto-pobierania) +├── docker-compose.yml +├── .env.example +├── profiles/ +│ ├── _template.env +│ └── qwen3.6-27b-awq-128k.env +└── scripts/ + ├── catalog-lib.sh + ├── list-models.sh + ├── download-model.sh + ├── switch-model.sh + ├── start.sh + └── vllm-entrypoint.sh +``` + +Na dysku `/data`: + +``` +/data/apps/ +├── vllm/huggingface/ # cache HF (AWQ) +└── gguf/ # przyszłe GGUF (puste katalogi tworzone przez skrypty) + ├── qwen3.6-27b/ + └── gemma-4-12b/ +``` + +## Model catalog + +Plik `models.catalog.yaml` zawiera modele docelowe (GGUF) i interim (vLLM AWQ). **Nic nie pobiera się przy instalacji.** + +```bash +./scripts/list-models.sh +``` + +| ID | Runtime | Opis | +|----|---------|------| +| `qwen3.6-27b-q4-gguf` | llamacpp | Qwen3.6-27B Q4_K_M z lmstudio-community | +| `gemma-4-12b-q4-gguf` | llamacpp | Gemma 4 12B Q4_0 (+ mmproj) | +| `qwen3.6-27b-awq-vllm` | vllm | AWQ interim — użyj teraz | + +## Workflow + +### 1. Przygotuj stack (bez modelu) + +```bash +cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/vllm +cp .env.example .env +``` + +### 2. Zobacz katalog + +```bash +./scripts/list-models.sh +``` + +### 3. Pobierz model na żądanie + +```bash +# vLLM interim (AWQ → cache HF) +./scripts/download-model.sh qwen3.6-27b-awq-vllm + +# później — GGUF do /data/apps/gguf (dla llama.cpp) +# ./scripts/download-model.sh qwen3.6-27b-q4-gguf +``` + +### 4. Przełącz profil i uruchom + +```bash +./scripts/switch-model.sh qwen3.6-27b-awq-128k +# lub pierwszy start: +./scripts/start.sh qwen3.6-27b-awq-128k +``` + +`start.sh` odrzuca `.gguf` w `VLLM_MODEL` i wskazuje katalog. + +### 5. Logi i test + +```bash +docker compose --profile vllm logs -f vllm +curl -s http://localhost:8000/v1/models | jq . +``` + +## Switching models (A / B na dysku) + +1. Model B może już być pobrany (`download-model.sh`) — leży na dysku, nie w VRAM. +2. Przełącz profil: `./scripts/switch-model.sh ` — kopiuje profil → `.env`, restartuje kontener. +3. Tylko **jeden** model aktywny w VRAM naraz. + +Nowy profil vLLM: skopiuj `profiles/_template.env`, dostosuj `VLLM_EXTRA_ARGS` i `QUANTIZATION`. + +## Zmienne `.env` + +| Zmienna | Opis | Domyślnie | +|---------|------|-----------| +| `VLLM_MODEL` | **Wymagane** — ID modelu Hugging Face | *(pusty)* | +| `SERVED_MODEL_NAME` | Nazwa w API | `qwen3.6-27b` | +| `MAX_MODEL_LEN` | Okno kontekstu (tokeny) | `131072` (128K) | +| `MAX_NUM_SEQS` | Równoległe sekwencje | `1` | +| `GPU_MEMORY_UTILIZATION` | % VRAM dla vLLM | `0.95` | +| `KV_CACHE_DTYPE` | Kwantyzacja KV cache | `fp8` | +| `QUANTIZATION` | Typ kwantyzacji wag (`awq` lub pusty) | `awq` | +| `VLLM_EXTRA_ARGS` | Dodatkowe flagi vLLM (spacje) | `--language-model-only --enforce-eager --reasoning-parser qwen3` | +| `DATA_ROOT` | Mount dysku danych | `/data` | +| `HF_TOKEN` | Token Hugging Face (gated) | *(pusty)* | + +`QUANTIZATION` puste = model pełnej precyzji (bez `--quantization`). Flagi buduje `scripts/vllm-entrypoint.sh`. + +## Tuning po OOM + +1. `MAX_MODEL_LEN=98304` lub `65536` +2. `GPU_MEMORY_UTILIZATION=0.90` +3. `KV_CACHE_DTYPE=turboquant_k8v4` + +## Zarządzanie + +```bash +docker compose --profile vllm ps +docker compose --profile vllm logs -f vllm +docker compose --profile vllm restart vllm +docker compose --profile vllm down +``` + +## Dokumentacja + +Pełny tutorial: [manual-tutorial/04-vllm-stack.md](../../manual-tutorial/04-vllm-stack.md) (część B). + +GGUF (planowany): [stacks/llamacpp/README.md](../llamacpp/README.md). diff --git a/stacks/vllm/compose.yaml b/stacks/vllm/compose.yaml new file mode 120000 index 0000000..412c257 --- /dev/null +++ b/stacks/vllm/compose.yaml @@ -0,0 +1 @@ +docker-compose.yml \ No newline at end of file diff --git a/stacks/vllm/docker-compose.yml b/stacks/vllm/docker-compose.yml new file mode 100644 index 0000000..e32238f --- /dev/null +++ b/stacks/vllm/docker-compose.yml @@ -0,0 +1,26 @@ +services: + vllm: + image: vllm/vllm-openai:latest + container_name: vllm + profiles: + - vllm + restart: unless-stopped + ipc: host + ports: + - "${VLLM_PORT:-8000}:8000" + environment: + - HF_TOKEN=${HF_TOKEN:-} + - CUDA_VISIBLE_DEVICES=0 + - VLLM_MODEL=${VLLM_MODEL} + - SERVED_MODEL_NAME=${SERVED_MODEL_NAME:-qwen3.6-27b} + - MAX_MODEL_LEN=${MAX_MODEL_LEN:-131072} + - MAX_NUM_SEQS=${MAX_NUM_SEQS:-1} + - GPU_MEMORY_UTILIZATION=${GPU_MEMORY_UTILIZATION:-0.95} + - KV_CACHE_DTYPE=${KV_CACHE_DTYPE:-fp8} + - QUANTIZATION=${QUANTIZATION:-} + - VLLM_EXTRA_ARGS=${VLLM_EXTRA_ARGS:---language-model-only --enforce-eager --reasoning-parser qwen3} + volumes: + - ${DATA_ROOT:-/data}/apps/vllm/huggingface:/root/.cache/huggingface + - ./scripts/vllm-entrypoint.sh:/usr/local/bin/vllm-entrypoint.sh:ro + gpus: all + entrypoint: ["/bin/bash", "/usr/local/bin/vllm-entrypoint.sh"] diff --git a/stacks/vllm/models.catalog.yaml b/stacks/vllm/models.catalog.yaml new file mode 100644 index 0000000..fa2c0a8 --- /dev/null +++ b/stacks/vllm/models.catalog.yaml @@ -0,0 +1,32 @@ +# Model catalog — no automatic download on stack install. +# Use: ./scripts/list-models.sh +# ./scripts/download-model.sh + +models: + - id: qwen3.6-27b-q4-gguf + name: Qwen3.6-27B Q4_K_M + runtime: llamacpp + hf_repo: lmstudio-community/Qwen3.6-27B-GGUF + gguf_file: Qwen3.6-27B-Q4_K_M.gguf + download_url: https://huggingface.co/lmstudio-community/Qwen3.6-27B-GGUF/resolve/main/Qwen3.6-27B-Q4_K_M.gguf + size_gb: 17 + local_path: /data/apps/gguf/qwen3.6-27b/Qwen3.6-27B-Q4_K_M.gguf + + - id: gemma-4-12b-q4-gguf + name: Gemma 4 12B Q4_0 + runtime: llamacpp + hf_repo: lmstudio-community/gemma-4-12B-it-QAT-GGUF + gguf_file: gemma-4-12B-it-QAT-Q4_0.gguf + mmproj_file: mmproj-gemma-4-12B-it-QAT-BF16.gguf + download_url: https://huggingface.co/lmstudio-community/gemma-4-12B-it-QAT-GGUF/resolve/main/gemma-4-12B-it-QAT-Q4_0.gguf + mmproj_url: https://huggingface.co/lmstudio-community/gemma-4-12B-it-QAT-GGUF/resolve/main/mmproj-gemma-4-12B-it-QAT-BF16.gguf + size_gb: 7 + local_dir: /data/apps/gguf/gemma-4-12b + + - id: qwen3.6-27b-awq-vllm + name: Qwen3.6-27B AWQ (vLLM interim) + runtime: vllm + hf_model: Qwen/Qwen3.6-27B-Instruct-AWQ + profile: qwen3.6-27b-awq-128k + size_gb: 15 + note: AWQ on vLLM — Q4 equivalent until llama.cpp stack is ready diff --git a/stacks/vllm/profiles/_template.env b/stacks/vllm/profiles/_template.env new file mode 100644 index 0000000..091dfbd --- /dev/null +++ b/stacks/vllm/profiles/_template.env @@ -0,0 +1,18 @@ +# Template for a new vLLM profile +# Copy: cp profiles/_template.env profiles/my-model.env +# GGUF files from lmstudio-community require stacks/llamacpp (future), not vLLM. + +DATA_ROOT=/data + +VLLM_MODEL= +SERVED_MODEL_NAME=my-model +QUANTIZATION=awq +VLLM_EXTRA_ARGS=--language-model-only --enforce-eager + +MAX_MODEL_LEN=131072 +MAX_NUM_SEQS=1 +GPU_MEMORY_UTILIZATION=0.95 +KV_CACHE_DTYPE=fp8 + +HF_TOKEN= +VLLM_PORT=8000 diff --git a/stacks/vllm/profiles/qwen3.6-27b-awq-128k.env b/stacks/vllm/profiles/qwen3.6-27b-awq-128k.env new file mode 100644 index 0000000..5c9589b --- /dev/null +++ b/stacks/vllm/profiles/qwen3.6-27b-awq-128k.env @@ -0,0 +1,19 @@ +# Preset: Qwen3.6-27B AWQ, 128K context, RTX 3090 Ti 24GB +# vLLM interim — Q4 equivalent (not GGUF; use llamacpp for lmstudio GGUF files) +# Usage: ./scripts/switch-model.sh qwen3.6-27b-awq-128k + +DATA_ROOT=/data + +VLLM_MODEL=Qwen/Qwen3.6-27B-Instruct-AWQ + +SERVED_MODEL_NAME=qwen3.6-27b-awq +QUANTIZATION=awq +VLLM_EXTRA_ARGS=--language-model-only --enforce-eager --reasoning-parser qwen3 + +MAX_MODEL_LEN=131072 +MAX_NUM_SEQS=1 +GPU_MEMORY_UTILIZATION=0.95 +KV_CACHE_DTYPE=fp8 + +HF_TOKEN= +VLLM_PORT=8000 diff --git a/stacks/vllm/scripts/catalog-lib.sh b/stacks/vllm/scripts/catalog-lib.sh new file mode 100755 index 0000000..b9f5fcb --- /dev/null +++ b/stacks/vllm/scripts/catalog-lib.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Shared helpers for models.catalog.yaml (simple parser, no PyYAML required). + +CATALOG_FILE="${CATALOG_FILE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/models.catalog.yaml}" + +catalog_list_ids() { + grep '^ - id:' "${CATALOG_FILE}" | awk '{print $3}' +} + +catalog_get_field() { + local model_id="$1" + local field="$2" + awk -v id="${model_id}" -v key="${field}:" ' + $0 ~ "^ - id: " id "$" { found=1; next } + found && $0 ~ "^ - id:" { exit } + found && index($0, key) == 5 { sub(/^ [^:]+: /, ""); print; exit } + ' "${CATALOG_FILE}" +} + +catalog_ensure_dirs() { + local data_root="${1:-/data}" + mkdir -p \ + "${data_root}/apps/vllm/huggingface" \ + "${data_root}/apps/gguf/qwen3.6-27b" \ + "${data_root}/apps/gguf/gemma-4-12b" +} + +catalog_model_downloaded() { + local model_id="$1" + local runtime + runtime="$(catalog_get_field "${model_id}" runtime)" + + case "${runtime}" in + vllm) + local hf_model + hf_model="$(catalog_get_field "${model_id}" hf_model)" + local cache="${DATA_ROOT:-/data}/apps/vllm/huggingface" + # Heuristic: HF hub cache contains repo name + local repo_path + repo_path=$(echo "${hf_model}" | tr '/' '--') + if find "${cache}/hub" -maxdepth 3 -type d -name "models--${repo_path}" 2>/dev/null | grep -q .; then + return 0 + fi + return 1 + ;; + llamacpp) + local local_path local_dir gguf_file + local_path="$(catalog_get_field "${model_id}" local_path)" + local_dir="$(catalog_get_field "${model_id}" local_dir)" + gguf_file="$(catalog_get_field "${model_id}" gguf_file)" + if [[ -n "${local_path}" && -f "${local_path}" ]]; then + return 0 + fi + if [[ -n "${local_dir}" && -n "${gguf_file}" && -f "${local_dir}/${gguf_file}" ]]; then + return 0 + fi + return 1 + ;; + *) + return 1 + ;; + esac +} diff --git a/stacks/vllm/scripts/download-model.sh b/stacks/vllm/scripts/download-model.sh new file mode 100755 index 0000000..e4fb2a7 --- /dev/null +++ b/stacks/vllm/scripts/download-model.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/catalog-lib.sh" + +cd "${STACK_DIR}" + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " + echo "" + echo "Available models:" + catalog_list_ids | sed 's/^/ /' + exit 1 +fi + +MODEL_ID="$1" + +if ! catalog_list_ids | grep -qx "${MODEL_ID}"; then + echo "ERROR: Unknown model id: ${MODEL_ID}" + echo "Run: ./scripts/list-models.sh" + exit 1 +fi + +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +DATA_ROOT="${DATA_ROOT:-/data}" +catalog_ensure_dirs "${DATA_ROOT}" + +RUNTIME="$(catalog_get_field "${MODEL_ID}" runtime)" +NAME="$(catalog_get_field "${MODEL_ID}" name)" + +echo "=== Download: ${NAME} (${MODEL_ID}) ===" +echo "Runtime: ${RUNTIME}" +echo "" + +case "${RUNTIME}" in + vllm) + HF_MODEL="$(catalog_get_field "${MODEL_ID}" hf_model)" + echo "Target: Hugging Face cache at ${DATA_ROOT}/apps/vllm/huggingface" + echo "Model: ${HF_MODEL}" + echo "" + + HF_ENV=() + if [[ -n "${HF_TOKEN:-}" ]]; then + HF_ENV=(-e "HF_TOKEN=${HF_TOKEN}") + fi + + docker run --rm \ + "${HF_ENV[@]}" \ + -v "${DATA_ROOT}/apps/vllm/huggingface:/root/.cache/huggingface" \ + ghcr.io/huggingface/huggingface-cli:latest \ + download "${HF_MODEL}" + ;; + llamacpp) + DOWNLOAD_URL="$(catalog_get_field "${MODEL_ID}" download_url)" + LOCAL_PATH="$(catalog_get_field "${MODEL_ID}" local_path)" + LOCAL_DIR="$(catalog_get_field "${MODEL_ID}" local_dir)" + GGUF_FILE="$(catalog_get_field "${MODEL_ID}" gguf_file)" + MMPROJ_URL="$(catalog_get_field "${MODEL_ID}" mmproj_url)" + MMPROJ_FILE="$(catalog_get_field "${MODEL_ID}" mmproj_file)" + + if [[ -n "${LOCAL_PATH}" ]]; then + DEST="${LOCAL_PATH}" + mkdir -p "$(dirname "${DEST}")" + if [[ -f "${DEST}" ]]; then + echo "Already exists: ${DEST}" + else + echo "Downloading ${DEST} ..." + wget -c -O "${DEST}" "${DOWNLOAD_URL}" + fi + elif [[ -n "${LOCAL_DIR}" && -n "${GGUF_FILE}" ]]; then + mkdir -p "${LOCAL_DIR}" + DEST="${LOCAL_DIR}/${GGUF_FILE}" + if [[ -f "${DEST}" ]]; then + echo "Already exists: ${DEST}" + else + echo "Downloading ${DEST} ..." + wget -c -O "${DEST}" "${DOWNLOAD_URL}" + fi + if [[ -n "${MMPROJ_URL}" && -n "${MMPROJ_FILE}" ]]; then + MMPROJ_DEST="${LOCAL_DIR}/${MMPROJ_FILE}" + if [[ -f "${MMPROJ_DEST}" ]]; then + echo "Already exists: ${MMPROJ_DEST}" + else + echo "Downloading ${MMPROJ_DEST} ..." + wget -c -O "${MMPROJ_DEST}" "${MMPROJ_URL}" + fi + fi + else + echo "ERROR: Catalog entry missing local_path or local_dir/gguf_file" + exit 1 + fi + + echo "" + echo "GGUF saved for future llama.cpp stack (stacks/llamacpp/README.md)." + echo "Standard vLLM cannot load .gguf files." + ;; + *) + echo "ERROR: Unknown runtime: ${RUNTIME}" + exit 1 + ;; +esac + +echo "" +echo "Done." diff --git a/stacks/vllm/scripts/list-models.sh b/stacks/vllm/scripts/list-models.sh new file mode 100755 index 0000000..684af9d --- /dev/null +++ b/stacks/vllm/scripts/list-models.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/catalog-lib.sh" + +cd "${STACK_DIR}" + +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +DATA_ROOT="${DATA_ROOT:-/data}" +catalog_ensure_dirs "${DATA_ROOT}" + +echo "=== Model catalog ===" +echo "Catalog: ${CATALOG_FILE}" +echo "Data: ${DATA_ROOT}" +echo "" + +printf "%-24s %-10s %-8s %s\n" "ID" "RUNTIME" "ON DISK" "NAME" +printf "%-24s %-10s %-8s %s\n" "----" "-------" "-------" "----" + +while IFS= read -r model_id; do + name="$(catalog_get_field "${model_id}" name)" + runtime="$(catalog_get_field "${model_id}" runtime)" + if catalog_model_downloaded "${model_id}"; then + on_disk="yes" + else + on_disk="no" + fi + printf "%-24s %-10s %-8s %s\n" "${model_id}" "${runtime}" "${on_disk}" "${name}" +done < <(catalog_list_ids) + +echo "" +echo "Download on demand: ./scripts/download-model.sh " +echo "Switch vLLM profile: ./scripts/switch-model.sh " +echo "" +echo "Profiles in profiles/:" +ls -1 "${STACK_DIR}/profiles/"*.env 2>/dev/null | xargs -n1 basename | sed 's/^/ /' || true diff --git a/stacks/vllm/scripts/start.sh b/stacks/vllm/scripts/start.sh new file mode 100755 index 0000000..bd35d15 --- /dev/null +++ b/stacks/vllm/scripts/start.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/catalog-lib.sh" + +cd "${STACK_DIR}" + +PROFILE_ARG="${1:-}" + +if [[ -n "${PROFILE_ARG}" ]]; then + PROFILE_FILE="${STACK_DIR}/profiles/${PROFILE_ARG}.env" + if [[ ! -f "${PROFILE_FILE}" ]]; then + echo "ERROR: Profile not found: ${PROFILE_ARG}" + echo " Available:" + ls -1 "${STACK_DIR}/profiles/"*.env 2>/dev/null | xargs -n1 basename | sed 's/\.env$//' | sed 's/^/ /' + exit 1 + fi + cp "${PROFILE_FILE}" .env + echo "Applied profile: ${PROFILE_ARG}" +fi + +if [[ ! -f .env ]]; then + echo "ERROR: .env not found. Run: cp .env.example .env" + echo " Or use a profile: ./scripts/start.sh qwen3.6-27b-awq-128k" + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +source .env +set +a + +DATA_ROOT="${DATA_ROOT:-/data}" + +if [[ -z "${VLLM_MODEL:-}" ]]; then + echo "ERROR: VLLM_MODEL is empty in .env" + echo " Set a model, use a profile, or run:" + echo " ./scripts/download-model.sh qwen3.6-27b-awq-vllm" + echo " ./scripts/switch-model.sh qwen3.6-27b-awq-128k" + exit 1 +fi + +if [[ "${VLLM_MODEL}" == *".gguf"* ]]; then + echo "ERROR: VLLM_MODEL points to a .gguf file — standard vLLM cannot load GGUF." + echo " GGUF models are in models.catalog.yaml for future llama.cpp (stacks/llamacpp/)." + echo " For vLLM use AWQ from Hugging Face, e.g.:" + echo " ./scripts/download-model.sh qwen3.6-27b-awq-vllm" + echo " ./scripts/switch-model.sh qwen3.6-27b-awq-128k" + exit 1 +fi + +if ! mountpoint -q "${DATA_ROOT}" 2>/dev/null; then + echo "ERROR: ${DATA_ROOT} is not mounted" + echo " Complete disk setup (tutorial 04 part A) first" + exit 1 +fi + +catalog_ensure_dirs "${DATA_ROOT}" + +if ! docker info &>/dev/null; then + echo "ERROR: Docker is not running" + exit 1 +fi + +chmod +x "${SCRIPT_DIR}/vllm-entrypoint.sh" 2>/dev/null || true + +echo "=== vLLM stack ===" +echo "Model: ${VLLM_MODEL}" +echo "Served as: ${SERVED_MODEL_NAME:-qwen3.6-27b}" +echo "Context: ${MAX_MODEL_LEN:-131072} tokens" +echo "KV cache: ${KV_CACHE_DTYPE:-fp8}" +echo "Quantize: ${QUANTIZATION:-(none)}" +echo "Data root: ${DATA_ROOT}" +echo "" + +docker compose --profile vllm pull +docker compose --profile vllm up -d + +echo "" +echo "Started. Follow logs:" +echo " docker compose --profile vllm logs -f vllm" +echo "" +echo "Test when ready:" +echo " curl -s http://localhost:${VLLM_PORT:-8000}/v1/models | jq ." diff --git a/stacks/vllm/scripts/switch-model.sh b/stacks/vllm/scripts/switch-model.sh new file mode 100755 index 0000000..a4cdde8 --- /dev/null +++ b/stacks/vllm/scripts/switch-model.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/catalog-lib.sh" + +cd "${STACK_DIR}" + +PROFILE="${1:-}" + +if [[ -z "${PROFILE}" ]]; then + echo "Usage: $0 " + echo "" + echo "Profiles:" + ls -1 "${STACK_DIR}/profiles/"*.env 2>/dev/null | xargs -n1 basename | sed 's/\.env$//' | sed 's/^/ /' + exit 1 +fi + +PROFILE_FILE="${STACK_DIR}/profiles/${PROFILE}.env" +if [[ ! -f "${PROFILE_FILE}" ]]; then + echo "ERROR: Profile not found: ${PROFILE_FILE}" + echo "" + echo "Available profiles:" + ls -1 "${STACK_DIR}/profiles/"*.env 2>/dev/null | xargs -n1 basename | sed 's/\.env$//' | sed 's/^/ /' + exit 1 +fi + +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +DATA_ROOT="${DATA_ROOT:-/data}" +catalog_ensure_dirs "${DATA_ROOT}" + +echo "=== Switch vLLM profile: ${PROFILE} ===" +cp "${PROFILE_FILE}" .env + +set -a +# shellcheck disable=SC1091 +source .env +set +a + +if [[ "${VLLM_MODEL:-}" == *".gguf"* ]]; then + echo "ERROR: VLLM_MODEL points to a .gguf file — vLLM does not support GGUF." + echo " Use stacks/llamacpp (future) or an AWQ Hugging Face model." + echo " See: ./scripts/list-models.sh" + exit 1 +fi + +if [[ -z "${VLLM_MODEL:-}" ]]; then + echo "ERROR: Profile has empty VLLM_MODEL" + exit 1 +fi + +echo "Model: ${VLLM_MODEL}" +echo "Served as: ${SERVED_MODEL_NAME:-qwen3.6-27b}" +echo "Context: ${MAX_MODEL_LEN:-131072}" +echo "" + +if docker compose --profile vllm ps -q vllm 2>/dev/null | grep -q .; then + echo "Restarting vLLM container ..." + docker compose --profile vllm down + "${SCRIPT_DIR}/start.sh" +else + echo "vLLM not running. Start with:" + echo " ./scripts/start.sh" +fi diff --git a/stacks/vllm/scripts/vllm-entrypoint.sh b/stacks/vllm/scripts/vllm-entrypoint.sh new file mode 100755 index 0000000..b1e9c9c --- /dev/null +++ b/stacks/vllm/scripts/vllm-entrypoint.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ARGS=( + --model "${VLLM_MODEL}" + --served-model-name "${SERVED_MODEL_NAME:-qwen3.6-27b}" + --host 0.0.0.0 + --port 8000 + --max-model-len "${MAX_MODEL_LEN:-131072}" + --max-num-seqs "${MAX_NUM_SEQS:-1}" + --gpu-memory-utilization "${GPU_MEMORY_UTILIZATION:-0.95}" + --kv-cache-dtype "${KV_CACHE_DTYPE:-fp8}" + --trust-remote-code +) + +if [[ -n "${QUANTIZATION:-}" ]]; then + ARGS+=(--quantization "${QUANTIZATION}") +fi + +if [[ -n "${VLLM_EXTRA_ARGS:-}" ]]; then + # shellcheck disable=SC2206 + EXTRA=( ${VLLM_EXTRA_ARGS} ) + ARGS+=("${EXTRA[@]}") +fi + +exec vllm serve "${ARGS[@]}"