Update container documentation to reflect disk space adjustments and Docker log management

Expand the root disk size from 35 GB to 50 GB and implement log size limits for Docker containers. Add details about the new monitoring dashboard for homelab services, including deployment instructions and access URL. Ensure clarity on log rotation policies and risks associated with disk space usage.
This commit is contained in:
2026-02-28 17:10:34 +03:00
parent 53769e6832
commit 604f0c705f
10 changed files with 683 additions and 10 deletions

View File

@@ -14,8 +14,8 @@
- **Доступ:** SSH под пользователем **admin** (не root): `ssh admin@192.168.1.200` с хоста 192.168.1.150 или из LAN. Для выполнения команд с правами root: `sudo ...`.
**Диски:**
- **Корневой диск** (sda1): 35 GB, занято **~29 GB (87%)** — система, образы/кэш в пределах корня. **Критично:** мало свободного места; при росте логов или обновлениях возможны сбои. Следить за местом и логированием (см. TODO).
- **Данные** (sdb1): 344 GB, смонтирован в **/mnt/data**, занято ~177 GB (55%). Здесь: библиотека Immich, БД PostgreSQL, Docker root, containerd, Ollama, данные deduper.
- **Корневой диск** (sda1): 50 GB — система, образы/кэш в пределах корня. Логи Docker ограничены (см. ниже).
- **Данные** (sdb1): 350 GB, смонтирован в **/mnt/data**. Здесь: библиотека Immich, БД PostgreSQL, Docker root, containerd, Ollama, данные deduper.
---
@@ -184,10 +184,10 @@ sudo resize2fs /dev/sdb1
## Логи и ротация
- **Базовая политика (как в LXC):** на ВМ настроен logrotate `/etc/logrotate.d/homelab-lxc.conf` — 14 дней, 50 MB, 5 архивов, сжатие (системные логи в `/var/log`). На ВМ 200 пакет `logrotate` был установлен вручную (в образе по умолчанию не было); после установки активен таймер `logrotate.timer`. Подробнее: [Logrotate — базовая политика homelab](../maintenance/logrotate/README.md).
- **Docker:** данные Docker (образы, контейнеры, overlay) хранятся в **/mnt/data/docker** (Docker Root Dir). Логи контейнеров — драйвер **json-file** без ограничения размера и количества файлов (Config:{} у immich_server и immich_postgres). При активной работе логи могут разрастаться и занимать место на **корневом** разделе (если логи пишутся на корень) или в overlay на /mnt/data — уточнить расположение логов контейнеров (часто в /mnt/data/docker/containers). В любом случае ограничение логов не задано (см. TODO).
- **Docker:** данные Docker (образы, контейнеры, overlay) хранятся в **/mnt/data/docker** (Docker Root Dir). Логи контейнеров — драйвер **json-file** с ограничениями в `/etc/docker/daemon.json`: `max-size: "10m"`, `max-file: "3"` (до 30 MB на контейнер). Логи пишутся в `/mnt/data/docker/containers`.
- **Системный logrotate:** стандартные правила (apt, dpkg, cloud-init, unattended-upgrades, wtmp) плюс homelab-lxc.conf. Отдельных правил для Immich или Docker нет.
**Риск:** корневой диск заполнен на 87%. Рост логов, обновления и кэш могут привести к нехватке места. Необходимо ограничить логи Docker и следить за местом на корне (см. TODO).
Корневой диск расширен до 50 GB; логи Docker ограничены.
---
@@ -208,18 +208,17 @@ sudo resize2fs /dev/sdb1
## Уязвимости и риски
1. **Секреты в .env:** В `/opt/immich/.env` и `/opt/immich-deduper/.env` хранятся пароли БД, API-ключи (IMMICH_API_KEY, GEMINI_API_KEY), креды для deduper (PSQL_*). Файлы не должны попадать в публичный репозиторий. Ограничить права (chmod 600), хранить бэкапы в защищённом месте.
2. **Корневой диск 87%:** Критично мало свободного места. При 100% возможны сбои обновлений и работы сервисов. Срочно: освободить место и/или перенести часть данных на /mnt/data, ограничить логи Docker (см. TODO).
3. **Логи Docker без лимитов:** Ротация не настроена — возможен рост логов и заполнение диска.
4. **Доступ по портам:** Сервисы доступны по 2283, 2284, 8001, 8501, 8086 из LAN. Снаружи доступ только через NPM (https://immich.katykhin.ru → 2283). Не пробрасывать порты напрямую в интернет.
5. **GPU и PCI-passthrough:** ВМ использует hostpci0 (VGA). Убедиться, что драйверы NVIDIA и доступ к GPU корректны для immich_machine_learning.
2. **Корневой диск:** Расширен до 50 GB; логи Docker ограничены (10m × 3 файла на контейнер). Следить за местом при обновлениях.
3. **Доступ по портам:** Сервисы доступны по 2283, 2284, 8001, 8501, 8086 из LAN. Снаружи доступ только через NPM (https://immich.katykhin.ru → 2283). Не пробрасывать порты напрямую в интернет.
4. **GPU и PCI-passthrough:** ВМ использует hostpci0 (VGA). Убедиться, что драйверы NVIDIA и доступ к GPU корректны для immich_machine_learning.
---
## TODO по ВМ 200
- [x] **Базовая политика logrotate:** для системных логов настроена (homelab-lxc.conf — 14 дней, 50 MB, 5 архивов, как в LXC). См. [Logrotate — базовая политика homelab](../maintenance/logrotate/README.md).
- [ ] **Корневой диск:** Снизить использование корня (87%). Варианты: перенести логи Docker на /mnt/data (если сейчас пишутся на корень), очистить старые образы/кэш (`docker system prune` с осторожностью), увеличить размер корневого диска ВМ в Proxmox. Настроить мониторинг и оповещение при заполнении >90%.
- [ ] **Логи Docker:** Включить ограничение размера логов для всех контейнеров Immich и deduper: в `docker-compose.yml` добавить для каждого сервиса `logging: driver: json-file options: max-size: "100m" max-file: "3"` или задать default в `/etc/docker/daemon.json`. Убедиться, что Docker Root Dir остаётся на /mnt/data и логи не пишутся на корень. После изменений перезапустить контейнеры.
- [x] **Корневой диск:** Расширен до 50 GB (было 35 GB). Логи Docker ограничены.
- [x] **Логи Docker:** В `/etc/docker/daemon.json` заданы `log-driver: json-file`, `max-size: "10m"`, `max-file: "3"`. Логи в /mnt/data/docker/containers.
- [ ] **Права на конфиги:** Ограничить доступ к .env (chmod 600), не коммитить в публичные репозитории.
- [ ] **Резервное копирование:** Регулярный бэкап критичных данных (оценка размеров на момент документации):
- **`/mnt/data/library`** — библиотека Immich (фото, видео, превью). ~148 GB. Основной объём; бэкап обязателен (внешний диск, сетевое хранилище).

View File

@@ -45,6 +45,17 @@
| backup-restic-yandex.sh | 04:00 | restic → Yandex (без photos) |
| backup-restic-yandex-photos.sh | 04:10 | restic → Yandex (только photos) |
### Дашборд мониторинга
| Компонент | Назначение |
|-----------|------------|
| homelab-dashboard.service | Дашборд homelab (хост, контейнеры, сервисы) на порту 19998 |
| /root/scripts/dashboard/ | Скрипты: dashboard-exporter.py, dashboard-server.py, index.html |
| deploy-dashboard.sh | Деплой дашборда на хост |
| add-to-homepage.sh | Добавить ссылку в Homepage (CT 103) |
**URL:** http://192.168.1.150:19998
### Мониторинг и уведомления
| Скрипт | Назначение |

View File

@@ -0,0 +1,146 @@
# План реализации дашборда мониторинга homelab
Дашборд для Netdata (http://192.168.1.150:19999) с блоками: хост, контейнеры, критические сервисы.
---
## Текущее состояние (по результатам проверки на сервере)
### Netdata
- **Версия:** v2.9.0
- **Режим:** локальный, Cloud отключён
- **API:** http://localhost:19999/api/v1/ — доступен
### Доступные метрики
| Блок | Метрика | Chart / источник | Статус |
|------|---------|------------------|--------|
| **Хост** | CPU total | `system.cpu` (user, system, nice, iowait, …) | ✅ |
| | RAM total | `system.ram` | ✅ |
| | Disk usage | `disk_space./`, `disk_space./mnt/backup`, `disk_space./mnt/nextcloud-hdd`, `disk_space./tank`, … (в API: URL-encode слэши) | ✅ |
| | iowait | `system.cpu` dimension `iowait` | ✅ |
| | load | `system.load` (load1, load5, load15) | ✅ |
| **Контейнеры** | CPU % | `cgroup_<name>.cpu_limit` (used) | ✅ |
| | RAM % | `cgroup_<name>.mem_utilization` | ✅ |
| | Disk % | скрипт `pct exec ID -- df -P /` | ✅ кастомный экспортер |
| | OOM count | `/sys/fs/cgroup/lxc/ID/memory.events` (oom_kill) | ✅ кастомный экспортер |
| **Сервисы** | Immich, Nextcloud, nginx, VPN | ссылки на charts Netdata | ✅ без response time/connections |
### Контейнеры в cgroups (по данным Netdata)
- `nginx` (CT 100)
- `nextcloud` (CT 101)
- `gitea` (CT 103)
- `paperless` (CT 104)
- `rag-service` (CT 105)
- `misc` (CT 107, Invidious)
- `galene` (CT 108)
- `local-vpn` (CT 109)
- `qemu_immich` (VM 200)
---
## Решения (по ответам пользователя)
1. **Disk % по контейнерам** — в приоритете. I/O не нужен. Реализация: скрипт на хосте, `pct exec ID -- df -P /` для каждого LXC, VM 200 — отдельно (`qm guest exec` или аналог).
2. **OOM**`cgroup memory.events` (oom_kill) по каждому контейнеру. Путь: `/sys/fs/cgroup/lxc/ID/memory.events` (LXC), для VM — cgroup QEMU.
3. **Response time / open connections** — отложено, не требуется.
4. **Размещение** — на хосте (192.168.1.150).
5. **Netdata Cloud** — не рассматривается.
---
## Варианты реализации дашборда
### Вариант A: Netdata Cloud — не используется (Cloud отключён)
### Вариант B: Кастомная HTML-страница (выбран)
- Страница на хосте (192.168.1.150), которая:
- запрашивает Netdata API (`/api/v1/data?chart=...`)
- рендерит блоки: хост, таблица контейнеров, сервисы
- Плюсы: полный контроль, работает без Cloud
- Минусы: нужна разработка и хостинг страницы
### Вариант C: Доработка стандартного дашборда Netdata
- `dashboard_info.js` — изменение порядка/группировки charts
- Плюсы: используем встроенный UI
- Минусы: ограниченная кастомизация, в v2 подход мог измениться
---
## Рекомендуемый план (поэтапно)
### Этап 1: Дашборд на базе Netdata API (Вариант B)
Создать кастомную HTML-страницу с тремя блоками.
**Блок 1 — Хост**
- CPU total: `system.cpu` (сумма user+system или 100-idle)
- RAM total: `system.ram` (used, cached, free)
- Disk usage: `disk_space./`, `disk_space./mnt/backup`, `disk_space./mnt/nextcloud-hdd`, `disk_space./tank` (avail/used %)
- iowait: `system.cpu` dimension iowait
- load: `system.load` (load15)
**Блок 2 — Контейнеры (таблица)**
- Колонки: имя, CPU %, RAM %, Disk %, OOM count
- CPU/RAM: `cgroup_<name>.cpu_limit`, `cgroup_<name>.mem_utilization` (Netdata API)
- Disk %: кастомный API (скрипт `pct exec ID -- df -P /` + парсинг)
- OOM: кастомный API (LXC: `/sys/fs/cgroup/lxc/ID/memory.events`, VM 200: `/sys/fs/cgroup/qemu.slice/200.scope/memory.events` → oom_kill)
**Блок 3 — Критические сервисы**
- Immich, Nextcloud, nginx, VPN — ссылки на charts Netdata (cgroup_*, app.nginx_*)
- Response time / open connections — не требуются
### Этап 2: Кастомный экспортер (скрипт + HTTP API)
Скрипт на хосте, запускаемый по таймеру или по запросу:
- **Disk %:** для каждого LXC (100109) — `pct exec ID -- df -P /`; для VM 200 — `qm guest exec` или fallback (lvs/zfs)
- **OOM:** чтение `oom_kill` из `/sys/fs/cgroup/lxc/ID/memory.events` (LXC), `/sys/fs/cgroup/qemu.slice/200.scope/memory.events` (VM 200)
- Отдача JSON на HTTP (например, порт 19998 или через nginx на хосте)
### Этап 3: Размещение и интеграция
- Дашборд: статика на хосте (nginx или python -m http.server), запросы к Netdata API (localhost:19999) и кастомному API
- Добавить ссылку в Homepage (services.yaml)
---
## VM 200 (Immich) — RAM после увеличения
При увеличении RAM с 6 до 10 GB «потребление» может визуально «упасть» по нескольким причинам:
1. **Процент vs абсолютное значение** — 32% от 10 GB ≈ 3.2 GB. Та же нагрузка при 6 GB давала бы ~53%. Дашборд показывает RAM % (cgroup mem_utilization).
2. **Сброс кэша** — при нехватке памяти гость держит кэш; после добавления RAM ядро может освободить кэш, и «used» уменьшается.
3. **Balloon** — virtio-balloon мог забирать память при 6 GB; после увеличения лимита balloon отдаёт память гостю, но реальное использование приложений может остаться ~3 GB.
**Проверка:** `qm guest exec 200 -- free -h` (требует qemu-guest-agent в гостевой ОС) — смотреть `Mem: used` внутри гостя.
---
## Реализовано (2026-02-28)
- **URL дашборда:** http://192.168.1.150:19998
- **Ссылка в Homepage:** добавлена (Сервисы → Homelab Dashboard)
- **Скрипты:** `scripts/dashboard/` (exporter, server, index.html, deploy, add-to-homepage)
- **Systemd:** `homelab-dashboard.service` (порт 19998)
---
## Маппинг CT/VM → cgroup name (Netdata)
| ID | Назначение | cgroup name |
|----|------------|-------------|
| 100 | nginx | cgroup_nginx |
| 101 | nextcloud | cgroup_nextcloud |
| 103 | gitea | cgroup_gitea |
| 104 | paperless | cgroup_paperless |
| 105 | rag-service | cgroup_rag-service |
| 107 | misc (Invidious) | cgroup_misc |
| 108 | galene | cgroup_galene |
| 109 | local-vpn | cgroup_local-vpn |
| 200 | immich (VM) | cgroup_qemu_immich |
---
## Связанные документы
- [netdata-proxmox-setup.md](netdata-proxmox-setup.md) — установка и алерты
- [smartd-setup.md](smartd-setup.md) — SMART дисков
- [container-100](../containers/container-100.md) — NPM, log-dashboard
- [architecture](../architecture/architecture.md) — обзор контейнеров

View File

@@ -196,7 +196,16 @@ Netdata на хосте видит общие метрики (CPU, RAM, диск
---
## Дашборд homelab
Кастомный дашборд с метриками хоста, контейнеров и сервисов: **http://192.168.1.150:19998**
Ссылка добавлена в Homepage (Сервисы → Homelab Dashboard). Деплой: `scripts/dashboard/deploy-dashboard.sh`. Подробнее: [dashboard-plan.md](dashboard-plan.md).
---
## Связанные документы
- [dashboard-plan.md](dashboard-plan.md) — план и реализация кастомного дашборда
- [smartd-setup.md](smartd-setup.md) — SMART и диски
- [backup-howto](../backup/backup-howto.md) — бэкапы

View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Добавить Homelab Dashboard в Homepage (services.yaml на CT 103)
# Запуск: с хоста Proxmox — pct exec 103 -- bash -s < /root/scripts/dashboard/add-to-homepage.sh
set -e
SERVICES_YAML="${SERVICES_YAML:-/opt/docker/homepage/config/services.yaml}"
if [ ! -f "$SERVICES_YAML" ]; then
echo "ERROR: $SERVICES_YAML not found"
exit 1
fi
if grep -q "Homelab Dashboard" "$SERVICES_YAML" 2>/dev/null; then
echo "Homelab Dashboard already in services.yaml"
exit 0
fi
# Вставить после блока Netdata (ping: http://192.168.1.150:19999)
sed -i '/ping: http:\/\/192.168.1.150:19999$/a\
- Homelab Dashboard:\
icon: mdi-chart-box\
href: http://192.168.1.150:19998\
description: Мониторинг хоста, контейнеров, сервисов\
target: _blank
' "$SERVICES_YAML"
echo "Added Homelab Dashboard to services.yaml"

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
Экспортер метрик для дашборда homelab: disk % и OOM по контейнерам/VM.
Запуск: python3 dashboard-exporter.py (выводит JSON в stdout)
"""
import json
import subprocess
import sys
from pathlib import Path
# Маппинг: (vmid, type) -> (name, cgroup_name для Netdata)
CONTAINERS = [
(100, "lxc", "nginx", "cgroup_nginx"),
(101, "lxc", "nextcloud", "cgroup_nextcloud"),
(103, "lxc", "gitea", "cgroup_gitea"),
(104, "lxc", "paperless", "cgroup_paperless"),
(105, "lxc", "rag-service", "cgroup_rag-service"),
(107, "lxc", "misc", "cgroup_misc"),
(108, "lxc", "galene", "cgroup_galene"),
(109, "lxc", "local-vpn", "cgroup_local-vpn"),
(200, "qemu", "immich", "cgroup_qemu_immich"),
]
LXC_CGROUP = Path("/sys/fs/cgroup/lxc")
QEMU_CGROUP_200 = Path("/sys/fs/cgroup/qemu.slice/200.scope")
def get_disk_pct_lxc(vmid: int) -> float | None:
"""Disk % для LXC через pct exec df."""
try:
r = subprocess.run(
["pct", "exec", str(vmid), "--", "df", "-P", "/"],
capture_output=True,
text=True,
timeout=10,
)
if r.returncode != 0:
return None
lines = r.stdout.strip().split("\n")
if len(lines) < 2:
return None
# Формат: Filesystem 1K-blocks Used Available Use% Mounted
parts = lines[-1].split()
if len(parts) >= 5:
use_pct = parts[4].rstrip("%")
return float(use_pct)
except (subprocess.TimeoutExpired, ValueError):
pass
return None
def get_disk_pct_vm200() -> float | None:
"""Disk % для VM 200 через lvs (fallback, т.к. qm guest exec часто недоступен)."""
try:
r = subprocess.run(
["lvs", "-o", "data_percent", "--noheadings", "pve/vm-200-disk-0"],
capture_output=True,
text=True,
timeout=5,
)
if r.returncode != 0:
return None
val = r.stdout.strip()
if val:
return float(val)
except (subprocess.TimeoutExpired, ValueError):
pass
return None
def get_oom_count(vmid: int, vmtype: str) -> int | None:
"""OOM count из cgroup memory.events."""
if vmtype == "lxc":
path = LXC_CGROUP / str(vmid) / "memory.events"
elif vmtype == "qemu" and vmid == 200:
path = QEMU_CGROUP_200 / "memory.events"
else:
return None
if not path.exists():
return None
try:
text = path.read_text()
for line in text.splitlines():
if line.startswith("oom_kill "):
return int(line.split()[1])
except (OSError, ValueError):
pass
return None
def main() -> None:
result = {"containers": [], "ok": True}
for vmid, vmtype, name, cgroup_name in CONTAINERS:
disk_pct = None
if vmtype == "lxc":
disk_pct = get_disk_pct_lxc(vmid)
elif vmtype == "qemu" and vmid == 200:
disk_pct = get_disk_pct_vm200()
oom = get_oom_count(vmid, vmtype)
result["containers"].append({
"vmid": vmid,
"name": name,
"cgroup_name": cgroup_name,
"disk_pct": disk_pct,
"oom_count": oom,
})
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
HTTP-сервер дашборда homelab: статика, /api/containers, прокси к Netdata.
Порт: 19998 (по умолчанию).
"""
import json
import os
import subprocess
import sys
import urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
PORT = int(os.environ.get("DASHBOARD_PORT", "19998"))
NETDATA_URL = os.environ.get("NETDATA_URL", "http://127.0.0.1:19999")
SCRIPT_DIR = Path(__file__).resolve().parent
EXPORTER = SCRIPT_DIR / "dashboard-exporter.py"
class DashboardHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass # подавить вывод в консоль
def send_json(self, data: dict, status: int = 200):
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
def send_html(self, html: bytes, status: int = 200):
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(html)
def do_GET(self):
path = self.path.split("?")[0].rstrip("/") or "/"
if path == "/":
self.serve_index()
elif path == "/api/containers":
self.serve_containers()
elif path.startswith("/api/netdata"):
self.proxy_netdata()
else:
self.send_error(404)
def serve_index(self):
html_file = SCRIPT_DIR / "index.html"
if html_file.exists():
self.send_html(html_file.read_bytes())
else:
self.send_error(404, "index.html not found")
def serve_containers(self):
try:
r = subprocess.run(
[sys.executable, str(EXPORTER)],
capture_output=True,
text=True,
timeout=30,
cwd=str(SCRIPT_DIR),
)
if r.returncode != 0:
self.send_json({"ok": False, "error": r.stderr or "exporter failed"}, 500)
return
data = json.loads(r.stdout)
self.send_json(data)
except subprocess.TimeoutExpired:
self.send_json({"ok": False, "error": "timeout"}, 504)
except json.JSONDecodeError as e:
self.send_json({"ok": False, "error": str(e)}, 500)
except Exception as e:
self.send_json({"ok": False, "error": str(e)}, 500)
def proxy_netdata(self):
qs = self.path.split("?", 1)[1] if "?" in self.path else ""
url = f"{NETDATA_URL}/api/v1/data?{qs}" if qs else f"{NETDATA_URL}/api/v1/data"
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10) as resp:
data = resp.read()
self.send_response(200)
self.send_header("Content-Type", resp.headers.get("Content-Type", "application/json"))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
except Exception as e:
self.send_json({"error": str(e)}, 502)
def main():
server = HTTPServer(("0.0.0.0", PORT), DashboardHandler)
print(f"Dashboard server on http://0.0.0.0:{PORT}", file=sys.stderr)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,35 @@
#!/bin/bash
# Деплой дашборда homelab на хост Proxmox
# Запуск: с хоста Proxmox или ssh root@192.168.1.150 'bash -s' < scripts/dashboard/deploy-dashboard.sh
# Или из репозитория: ./scripts/dashboard/deploy-dashboard.sh (копирует из текущей директории)
set -e
# REPO_ROOT: корень репозитория (содержит scripts/dashboard/)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="${REPO_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
DASHBOARD_SRC="${REPO_ROOT}/scripts/dashboard"
DEST="/root/scripts/dashboard"
SYSTEMD_DEST="/etc/systemd/system"
log() { echo "[$(date -Iseconds)] $*"; }
log "Deploying homelab dashboard..."
mkdir -p "$DEST"
if [ "$(realpath "$DASHBOARD_SRC")" != "$(realpath "$DEST")" ]; then
cp -v "${DASHBOARD_SRC}/dashboard-exporter.py" "$DEST/"
cp -v "${DASHBOARD_SRC}/dashboard-server.py" "$DEST/"
cp -v "${DASHBOARD_SRC}/index.html" "$DEST/"
fi
chmod +x "${DEST}/dashboard-exporter.py" "${DEST}/dashboard-server.py"
if [ -f "${REPO_ROOT}/scripts/systemd/homelab-dashboard.service" ]; then
cp -v "${REPO_ROOT}/scripts/systemd/homelab-dashboard.service" "$SYSTEMD_DEST/"
fi
systemctl daemon-reload
systemctl enable homelab-dashboard.service
systemctl restart homelab-dashboard.service
log "Dashboard deployed. URL: http://192.168.1.150:19998"
log "Status: $(systemctl is-active homelab-dashboard.service)"

View File

@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab Dashboard</title>
<style>
:root { --bg: #0d1117; --card: #161b22; --text: #e6edf3; --muted: #8b949e; --accent: #58a6ff; --ok: #3fb950; --warn: #d29922; --err: #f85149; }
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 1rem; line-height: 1.5; }
h1 { font-size: 1.25rem; margin: 0 0 1rem; }
h2 { font-size: 1rem; margin: 0 0 0.5rem; color: var(--muted); font-weight: 500; }
.card { background: var(--card); border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem; }
.metric { text-align: center; }
.metric-value { font-size: 1.5rem; font-weight: 600; }
.metric-label { font-size: 0.75rem; color: var(--muted); }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #30363d; }
th { color: var(--muted); font-weight: 500; font-size: 0.85rem; }
.pct-ok { color: var(--ok); }
.pct-warn { color: var(--warn); }
.pct-err { color: var(--err); }
.links { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; }
.links a { color: var(--accent); text-decoration: none; font-size: 0.9rem; }
.links a:hover { text-decoration: underline; }
.loading { color: var(--muted); }
.error { color: var(--err); }
.updated { font-size: 0.75rem; color: var(--muted); margin-top: 0.5rem; }
</style>
</head>
<body>
<h1>Homelab Dashboard</h1>
<div class="card">
<h2>Блок 1 — Хост</h2>
<div class="grid" id="host-metrics">
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">CPU %</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">RAM</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">Load</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">iowait %</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">Disk /</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">Disk backup</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">Disk nextcloud-hdd</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">Disk tank</span></div>
</div>
</div>
<div class="card">
<h2>Блок 2 — Контейнеры</h2>
<table>
<thead>
<tr><th>Контейнер</th><th>CPU %</th><th>RAM %</th><th>Disk %</th><th>OOM</th></tr>
</thead>
<tbody id="containers-table"></tbody>
</table>
</div>
<div class="card">
<h2>Блок 3 — Критические сервисы</h2>
<div class="links">
<a href="http://192.168.1.150:19999/#menu_system_submenu_cpu;netdata" target="_blank">Netdata (CPU)</a>
<a href="http://192.168.1.150:19999/#menu_cgroup_nginx;netdata" target="_blank">nginx (CT 100)</a>
<a href="http://192.168.1.150:19999/#menu_cgroup_nextcloud;netdata" target="_blank">Nextcloud (CT 101)</a>
<a href="http://192.168.1.150:19999/#menu_cgroup_qemu_immich;netdata" target="_blank">Immich (VM 200)</a>
<a href="http://192.168.1.150:19999/#menu_cgroup_local-vpn;netdata" target="_blank">VPN (CT 109)</a>
</div>
</div>
<div class="updated" id="updated"></div>
<div id="status" class="updated" style="color:var(--muted)"></div>
<script>
const API = window.location.origin; // явно использовать текущий origin
function pctClass(v) {
if (v == null) return '';
if (v >= 90) return 'pct-err';
if (v >= 75) return 'pct-warn';
return 'pct-ok';
}
function fmt(v, suffix = '') {
if (v == null || v === undefined) return '—';
if (typeof v === 'number') return v.toFixed(1) + suffix;
return String(v) + suffix;
}
async function fetchNetdata(chart, points = 1) {
const url = `${API}/api/netdata?chart=${encodeURIComponent(chart)}&points=${points}&format=json`;
const r = await fetch(url);
if (!r.ok) throw new Error(`${chart}: ${r.status}`);
return r.json();
}
async function loadHost() {
try {
const results = await Promise.allSettled([
fetchNetdata('system.cpu'),
fetchNetdata('system.ram'),
fetchNetdata('system.load'),
fetchNetdata('disk_space./'),
fetchNetdata('disk_space./mnt/backup'),
fetchNetdata('disk_space./mnt/nextcloud-hdd'),
fetchNetdata('disk_space./tank'),
]);
const [cpu, ram, load, diskRoot, diskBackup, diskNextcloud, diskTank] = results.map(r => r.status === 'fulfilled' ? r.value : null);
const cpuData = cpu?.data?.[0];
const ramData = ram?.data?.[0];
const loadData = load?.data?.[0];
const li = cpu?.labels || [];
const cpuTotal = cpuData ? (li.indexOf('user') >= 0 ? (cpuData[li.indexOf('user')] || 0) + (cpuData[li.indexOf('system')] || 0) + (cpuData[li.indexOf('nice')] || 0) + (cpuData[li.indexOf('iowait')] || 0) + (cpuData[li.indexOf('irq')] || 0) + (cpuData[li.indexOf('softirq')] || 0) + (cpuData[li.indexOf('steal')] || 0) + (cpuData[li.indexOf('guest')] || 0) + (cpuData[li.indexOf('guest_nice')] || 0) : 0) : null;
const iowait = cpuData && li.indexOf('iowait') >= 0 ? cpuData[li.indexOf('iowait')] : null;
const ramUsed = ramData && ram?.labels ? ramData[ram.labels.indexOf('used')] : null;
const load15 = loadData && load?.labels ? loadData[load.labels.indexOf('load15')] : null;
// disk_space возвращает avail/used в GiB, считаем %: used/(used+avail)*100
const diskPct = (d) => {
if (!d?.data?.[0] || !d?.labels) return null;
const idxU = d.labels.indexOf('used'), idxA = d.labels.indexOf('avail');
if (idxU < 0 || idxA < 0) return null;
const used = d.data[0][idxU], avail = d.data[0][idxA];
const total = used + avail;
return total > 0 ? (used / total * 100) : null;
};
const diskRootUsed = diskPct(diskRoot);
const diskBackupUsed = diskPct(diskBackup);
const diskNextcloudUsed = diskPct(diskNextcloud);
const diskTankUsed = diskPct(diskTank);
document.getElementById('host-metrics').innerHTML = `
<div class="metric"><span class="metric-value">${fmt(cpuTotal, '%')}</span><span class="metric-label">CPU %</span></div>
<div class="metric"><span class="metric-value">${fmt(ramUsed, ' MiB')}</span><span class="metric-label">RAM used</span></div>
<div class="metric"><span class="metric-value">${fmt(load15)}</span><span class="metric-label">Load 15</span></div>
<div class="metric"><span class="metric-value">${fmt(iowait, '%')}</span><span class="metric-label">iowait %</span></div>
<div class="metric"><span class="metric-value ${pctClass(diskRootUsed)}">${fmt(diskRootUsed, '%')}</span><span class="metric-label">Disk /</span></div>
<div class="metric"><span class="metric-value ${pctClass(diskBackupUsed)}">${fmt(diskBackupUsed, '%')}</span><span class="metric-label">Disk backup</span></div>
<div class="metric"><span class="metric-value ${pctClass(diskNextcloudUsed)}">${fmt(diskNextcloudUsed, '%')}</span><span class="metric-label">Disk nextcloud-hdd</span></div>
<div class="metric"><span class="metric-value ${pctClass(diskTankUsed)}">${fmt(diskTankUsed, '%')}</span><span class="metric-label">Disk tank</span></div>
`;
} catch (e) {
document.getElementById('host-metrics').innerHTML = `<div class="error">Ошибка: ${e.message}</div>`;
}
}
const CGROUP_CHARTS = {
'cgroup_nginx': { cpu: 'cgroup_nginx.cpu_limit', mem: 'cgroup_nginx.mem_utilization' },
'cgroup_nextcloud': { cpu: 'cgroup_nextcloud.cpu_limit', mem: 'cgroup_nextcloud.mem_utilization' },
'cgroup_gitea': { cpu: 'cgroup_gitea.cpu_limit', mem: 'cgroup_gitea.mem_utilization' },
'cgroup_paperless': { cpu: 'cgroup_paperless.cpu_limit', mem: 'cgroup_paperless.mem_utilization' },
'cgroup_rag-service': { cpu: 'cgroup_rag-service.cpu_limit', mem: 'cgroup_rag-service.mem_utilization' },
'cgroup_misc': { cpu: 'cgroup_misc.cpu_limit', mem: 'cgroup_misc.mem_utilization' },
'cgroup_galene': { cpu: 'cgroup_galene.cpu_limit', mem: 'cgroup_galene.mem_utilization' },
'cgroup_local-vpn': { cpu: 'cgroup_local-vpn.cpu_limit', mem: 'cgroup_local-vpn.mem_utilization' },
'cgroup_qemu_immich': { cpu: 'cgroup_qemu_immich.cpu_limit', mem: 'cgroup_qemu_immich.mem_utilization' },
};
async function loadContainers() {
try {
const containersRes = await fetch(`${API}/api/containers`);
if (!containersRes.ok) throw new Error(`API ${containersRes.status}`);
const containersData = await containersRes.json();
if (!containersData.ok || !containersData.containers) {
document.getElementById('containers-table').innerHTML = `<tr><td colspan="5" class="error">Ошибка загрузки</td></tr>`;
return;
}
const containers = containersData.containers;
const cpuPromises = containers.map(c => {
const charts = CGROUP_CHARTS[c.cgroup_name];
if (!charts) return [null, null];
return Promise.all([
fetchNetdata(charts.cpu).then(d => d.data?.[0]?.[d.labels?.indexOf('used') ?? 0] != null ? d.data[0][d.labels.indexOf('used')] * 100 : null),
fetchNetdata(charts.mem).then(d => d.data?.[0]?.[d.labels?.indexOf('utilization') ?? 0] != null ? d.data[0][d.labels.indexOf('utilization')] : null),
]);
});
const netdataRows = await Promise.all(cpuPromises);
const rows = containers.map((c, i) => {
const [cpuPct, ramPct] = netdataRows[i] || [null, null];
return `<tr>
<td>${c.name} (${c.vmid})</td>
<td class="${pctClass(cpuPct)}">${fmt(cpuPct, '%')}</td>
<td class="${pctClass(ramPct)}">${fmt(ramPct, '%')}</td>
<td class="${pctClass(c.disk_pct)}">${fmt(c.disk_pct, '%')}</td>
<td>${c.oom_count != null ? c.oom_count : '—'}</td>
</tr>`;
});
document.getElementById('containers-table').innerHTML = rows.join('');
} catch (e) {
document.getElementById('containers-table').innerHTML = `<tr><td colspan="5" class="error">Ошибка: ${e.message}. Проверьте доступ к ${API}</td></tr>`;
}
}
async function refresh() {
const statusEl = document.getElementById('status');
statusEl.textContent = 'Загрузка...';
try {
await Promise.all([loadHost(), loadContainers()]);
document.getElementById('updated').textContent = 'Обновлено: ' + new Date().toLocaleString('ru');
statusEl.textContent = '';
} catch (e) {
document.getElementById('updated').textContent = '';
statusEl.textContent = 'Ошибка: ' + e.message;
statusEl.style.color = 'var(--err)';
}
}
refresh();
setInterval(refresh, 30000);
</script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
# Дашборд мониторинга homelab (хост, контейнеры, сервисы)
# Порт 19998, статика + API + прокси к Netdata
[Unit]
Description=Homelab Dashboard (monitoring)
After=network-online.target netdata.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /root/scripts/dashboard/dashboard-server.py
WorkingDirectory=/root/scripts/dashboard
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target