feat: добавлен Makefile для управления проектом и обновлен README.md
- Создан Makefile с командами для сборки, запуска, остановки, перезапуска и просмотра логов - Добавлены команды: build, up, down, restart, logs, clean, status, shell, dev, rebuild - Обновлен README.md с информацией об авторе и инструкциями по использованию Makefile - Добавлена таблица команд Makefile для удобства пользователей - Автор: Сергей Антропов (https://devops.org.ru)
This commit is contained in:
commit
c74e5ec15e
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@ -0,0 +1,16 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir fastapi uvicorn[standard] docker
|
||||
|
||||
COPY app.py /app/app.py
|
||||
COPY templates /app/templates
|
||||
|
||||
# Non-root
|
||||
RUN useradd -m appuser
|
||||
USER appuser
|
||||
|
||||
EXPOSE 9001
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "9001"]
|
88
Makefile
Normal file
88
Makefile
Normal file
@ -0,0 +1,88 @@
|
||||
# Makefile для LogBoard+
|
||||
# Автор: Сергей Антропов
|
||||
# Сайт: https://devops.org.ru
|
||||
|
||||
.PHONY: help build up down restart logs clean status ps shell
|
||||
|
||||
# Переменные
|
||||
COMPOSE_FILE = docker-compose.yml
|
||||
SERVICE_NAME = logboard
|
||||
|
||||
# Цвета для вывода
|
||||
GREEN = \033[0;32m
|
||||
YELLOW = \033[1;33m
|
||||
RED = \033[0;31m
|
||||
NC = \033[0m # No Color
|
||||
|
||||
help: ## Показать справку по командам
|
||||
@echo "$(GREEN)LogBoard+ - Команды управления$(NC)"
|
||||
@echo ""
|
||||
@echo "$(YELLOW)Основные команды:$(NC)"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-15s$(NC) %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
@echo "$(YELLOW)Примеры использования:$(NC)"
|
||||
@echo " make build # Собрать образ"
|
||||
@echo " make up # Запустить сервисы"
|
||||
@echo " make down # Остановить сервисы"
|
||||
@echo " make restart # Перезапустить сервисы"
|
||||
@echo " make logs # Показать логи"
|
||||
|
||||
build: ## Собрать Docker образ
|
||||
@echo "$(GREEN)Сборка Docker образа...$(NC)"
|
||||
docker-compose -f $(COMPOSE_FILE) build --no-cache
|
||||
@echo "$(GREEN)Образ собран успешно!$(NC)"
|
||||
|
||||
up: ## Запустить сервисы в фоновом режиме
|
||||
@echo "$(GREEN)Запуск сервисов...$(NC)"
|
||||
docker-compose -f $(COMPOSE_FILE) up -d
|
||||
@echo "$(GREEN)Сервисы запущены!$(NC)"
|
||||
@echo "$(YELLOW)Приложение доступно по адресу: http://localhost:9001$(NC)"
|
||||
|
||||
down: ## Остановить и удалить сервисы
|
||||
@echo "$(YELLOW)Остановка сервисов...$(NC)"
|
||||
docker-compose -f $(COMPOSE_FILE) down
|
||||
@echo "$(GREEN)Сервисы остановлены!$(NC)"
|
||||
|
||||
restart: ## Перезапустить сервисы
|
||||
@echo "$(YELLOW)Перезапуск сервисов...$(NC)"
|
||||
docker-compose -f $(COMPOSE_FILE) restart
|
||||
@echo "$(GREEN)Сервисы перезапущены!$(NC)"
|
||||
|
||||
logs: ## Показать логи сервисов
|
||||
@echo "$(GREEN)Логи сервисов:$(NC)"
|
||||
docker-compose -f $(COMPOSE_FILE) logs -f
|
||||
|
||||
logs-tail: ## Показать последние 100 строк логов
|
||||
@echo "$(GREEN)Последние 100 строк логов:$(NC)"
|
||||
docker-compose -f $(COMPOSE_FILE) logs --tail=100
|
||||
|
||||
clean: ## Остановить сервисы и удалить образы
|
||||
@echo "$(RED)Очистка проекта...$(NC)"
|
||||
docker-compose -f $(COMPOSE_FILE) down --rmi all --volumes --remove-orphans
|
||||
@echo "$(GREEN)Очистка завершена!$(NC)"
|
||||
|
||||
status: ## Показать статус сервисов
|
||||
@echo "$(GREEN)Статус сервисов:$(NC)"
|
||||
docker-compose -f $(COMPOSE_FILE) ps
|
||||
|
||||
ps: status ## Алиас для команды status
|
||||
|
||||
shell: ## Подключиться к контейнеру сервиса
|
||||
@echo "$(GREEN)Подключение к контейнеру $(SERVICE_NAME)...$(NC)"
|
||||
docker-compose -f $(COMPOSE_FILE) exec $(SERVICE_NAME) /bin/bash
|
||||
|
||||
start: up ## Алиас для команды up
|
||||
|
||||
stop: down ## Алиас для команды down
|
||||
|
||||
dev: ## Запуск в режиме разработки (с выводом логов)
|
||||
@echo "$(GREEN)Запуск в режиме разработки...$(NC)"
|
||||
docker-compose -f $(COMPOSE_FILE) up --build
|
||||
|
||||
rebuild: ## Пересобрать и запустить сервисы
|
||||
@echo "$(YELLOW)Пересборка и запуск сервисов...$(NC)"
|
||||
docker-compose -f $(COMPOSE_FILE) down
|
||||
docker-compose -f $(COMPOSE_FILE) build --no-cache
|
||||
docker-compose -f $(COMPOSE_FILE) up -d
|
||||
@echo "$(GREEN)Сервисы пересобраны и запущены!$(NC)"
|
||||
@echo "$(YELLOW)Приложение доступно по адресу: http://localhost:9001$(NC)"
|
138
README.md
Normal file
138
README.md
Normal file
@ -0,0 +1,138 @@
|
||||
|
||||
# LogBoard+
|
||||
|
||||
**Автор:** Сергей Антропов
|
||||
**Сайт:** https://devops.org.ru
|
||||
|
||||
LogBoard+ — это веб-панель для просмотра логов микросервисов из `docker-compose` в **реальном времени** с поддержкой:
|
||||
- Вкладок по сервисам и репликам
|
||||
- Темная/светлая темы
|
||||
- Подсветка ANSI-цветов из логов
|
||||
- Фильтрация по уровням (`debug`, `info`, `warn`, `error`)
|
||||
- Снимки логов в файл (`snapshot`)
|
||||
- Объединение всех реплик сервиса в одну вкладку (**aggregate**)
|
||||
- Fan-in группировка нескольких сервисов в одну панель (**group**)
|
||||
- Цветовые теги по короткому ID контейнера
|
||||
- Sticky-фильтры по репликам
|
||||
- Пауза/возобновление стрима
|
||||
- Basic Auth для доступа
|
||||
- Автопереподключение вебсокетов
|
||||
- Поддержка нескольких клиентов одновременно
|
||||
|
||||
---
|
||||
|
||||
## Установка и запуск
|
||||
|
||||
### Быстрый старт с Makefile (рекомендуется)
|
||||
|
||||
```bash
|
||||
# Распаковать проект
|
||||
unzip logboard_plus_fanin_groups.zip
|
||||
cd logboard_plus
|
||||
|
||||
# Показать доступные команды
|
||||
make help
|
||||
|
||||
# Собрать и запустить проект
|
||||
make build
|
||||
make up
|
||||
|
||||
# Открыть в браузере
|
||||
http://localhost:9001
|
||||
```
|
||||
|
||||
### Команды Makefile
|
||||
|
||||
| Команда | Описание |
|
||||
|---------|----------|
|
||||
| `make help` | Показать справку по всем командам |
|
||||
| `make build` | Собрать Docker образ |
|
||||
| `make up` | Запустить сервисы в фоновом режиме |
|
||||
| `make down` | Остановить и удалить сервисы |
|
||||
| `make restart` | Перезапустить сервисы |
|
||||
| `make logs` | Показать логи сервисов в реальном времени |
|
||||
| `make logs-tail` | Показать последние 100 строк логов |
|
||||
| `make status` | Показать статус сервисов |
|
||||
| `make clean` | Остановить сервисы и удалить образы |
|
||||
| `make shell` | Подключиться к контейнеру |
|
||||
| `make dev` | Запуск в режиме разработки (с выводом логов) |
|
||||
| `make rebuild` | Пересобрать и запустить сервисы |
|
||||
|
||||
### Классический способ
|
||||
|
||||
```bash
|
||||
# Распаковать проект
|
||||
unzip logboard_plus_fanin_groups.zip
|
||||
cd logboard_plus
|
||||
|
||||
# Запуск через docker-compose
|
||||
docker compose up --build -d
|
||||
|
||||
# Открыть в браузере
|
||||
http://localhost:9001
|
||||
```
|
||||
|
||||
По умолчанию логин/пароль для Basic Auth задаются в `docker-compose.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
- LB_USER=admin
|
||||
- LB_PASS=admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Интерфейс
|
||||
|
||||
### Верхняя панель
|
||||
- **Тема** — переключатель тёмной/светлой темы
|
||||
- **aggregate** — собирает все реплики сервиса в одну вкладку
|
||||
- **group** — собирает несколько сервисов в один поток логов
|
||||
- **snapshot** — сохраняет текущие логи этой панели в файл
|
||||
- **tail** — количество строк истории при подключении
|
||||
|
||||
### Панель вкладок
|
||||
- Вкладки по сервисам и репликам
|
||||
- Клик по вкладке — открыть поток логов
|
||||
- Цветной чип `[id]` — уникальная реплика, цвет закреплён
|
||||
- Чекбоксы под вкладками — фильтр по репликам
|
||||
|
||||
### Логи
|
||||
- Цвета в логах из ANSI-кодов
|
||||
- Фильтрация по уровню (`debug`, `info`, `warn`, `error`)
|
||||
- Пауза/возобновление стрима
|
||||
- При выделении текста появляется кнопка "Копировать"
|
||||
|
||||
---
|
||||
|
||||
## Fan-in группировка
|
||||
|
||||
Позволяет объединить несколько разных сервисов в один поток:
|
||||
1. Нажмите кнопку **group** вверху
|
||||
2. Введите имена сервисов через запятую (например: `api, worker, scheduler`)
|
||||
3. Откроется панель с логами всех указанных сервисов, с префиксом `[id service]`
|
||||
4. Можно фильтровать по конкретным репликам внизу
|
||||
|
||||
---
|
||||
|
||||
## Советы
|
||||
|
||||
- Если контейнер перезапустился — поток автоматически переподключается
|
||||
- Можно открыть несколько вкладок браузера с разными сервисами — всё работает параллельно
|
||||
- Для больших проектов удобно держать одну панель с `aggregate`, вторую — с `group` по критическим сервисам
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
|
||||
|
||||
## Скриншоты
|
||||
|
||||
Тёмная тема:
|
||||
|
||||

|
||||
|
||||
Светлая тема:
|
||||
|
||||

|
414
app.py
Normal file
414
app.py
Normal file
@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
import docker
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, status, Body
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
APP_PORT = int(os.getenv("LOGBOARD_PORT", "9001"))
|
||||
DEFAULT_TAIL = int(os.getenv("LOGBOARD_TAIL", "500"))
|
||||
BASIC_USER = os.getenv("LOGBOARD_USER", "admin")
|
||||
BASIC_PASS = os.getenv("LOGBOARD_PASS", "admin")
|
||||
DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME") # filter by compose project
|
||||
|
||||
security = HTTPBasic()
|
||||
app = FastAPI(title="LogBoard+")
|
||||
|
||||
# serve snapshots directory (downloadable files)
|
||||
SNAP_DIR = os.getenv("LOGBOARD_SNAPSHOT_DIR", "./snapshots")
|
||||
os.makedirs(SNAP_DIR, exist_ok=True)
|
||||
app.mount("/snapshots", StaticFiles(directory=SNAP_DIR), name="snapshots")
|
||||
|
||||
docker_client = docker.from_env()
|
||||
|
||||
# ---------- AUTH ----------
|
||||
def check_basic(creds: HTTPBasicCredentials = Depends(security)):
|
||||
if creds.username == BASIC_USER and creds.password == BASIC_PASS:
|
||||
return creds
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Basic"})
|
||||
|
||||
def token_from_creds(creds: HTTPBasicCredentials) -> str:
|
||||
return base64.b64encode(f"{creds.username}:{creds.password}".encode()).decode()
|
||||
|
||||
def verify_ws_token(token: str) -> bool:
|
||||
try:
|
||||
raw = base64.b64decode(token.encode(), validate=True).decode()
|
||||
except Exception:
|
||||
return False
|
||||
return raw == f"{BASIC_USER}:{BASIC_PASS}"
|
||||
|
||||
# ---------- DOCKER HELPERS ----------
|
||||
def list_containers(project: Optional[str] = None, include_stopped: bool = False) -> List[Dict]:
|
||||
items = []
|
||||
for c in docker_client.containers.list(all=include_stopped):
|
||||
labels = c.labels or {}
|
||||
proj = labels.get("com.docker.compose.project")
|
||||
svc = labels.get("com.docker.compose.service") or c.name
|
||||
if project and proj != project:
|
||||
continue
|
||||
items.append({
|
||||
"id": c.id[:12],
|
||||
"name": c.name,
|
||||
"image": (c.image.tags[0] if c.image and c.image.tags else c.image.short_id),
|
||||
"status": c.status,
|
||||
"service": svc,
|
||||
"project": proj,
|
||||
})
|
||||
items.sort(key=lambda x: (x.get("project") or "", x.get("service") or "", x.get("name") or ""))
|
||||
return items
|
||||
|
||||
# ---------- HTML ----------
|
||||
INDEX_PATH = os.getenv("LOGBOARD_INDEX_HTML", "./templates/index.html")
|
||||
def load_index_html(token: str) -> str:
|
||||
with open(INDEX_PATH, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
return html.replace("__TOKEN__", token)
|
||||
|
||||
# ---------- ROUTES ----------
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(creds: HTTPBasicCredentials = Depends(check_basic)):
|
||||
token = token_from_creds(creds)
|
||||
return HTMLResponse(load_index_html(token))
|
||||
|
||||
@app.get("/healthz", response_class=PlainTextResponse)
|
||||
def healthz():
|
||||
return "ok"
|
||||
|
||||
@app.get("/api/services")
|
||||
def api_services(project: Optional[str] = Query(None), include_stopped: bool = Query(False),
|
||||
_: HTTPBasicCredentials = Depends(check_basic)):
|
||||
proj = project or DEFAULT_PROJECT
|
||||
return JSONResponse(list_containers(project=proj, include_stopped=include_stopped))
|
||||
|
||||
@app.post("/api/snapshot")
|
||||
def api_snapshot(
|
||||
creds: HTTPBasicCredentials = Depends(check_basic),
|
||||
container_id: str = Body(..., embed=True),
|
||||
service: str = Body("", embed=True),
|
||||
content: str = Body("", embed=True),
|
||||
):
|
||||
# Save posted content as a snapshot file
|
||||
safe_service = re.sub(r"[^a-zA-Z0-9_.-]+", "_", service or container_id[:12])
|
||||
ts = os.getenv("TZ_TS") or ""
|
||||
from datetime import datetime
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
fname = f"{safe_service}-{stamp}.log"
|
||||
fpath = os.path.join(SNAP_DIR, fname)
|
||||
with open(fpath, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
url = f"/snapshots/{fname}"
|
||||
return {"file": fname, "url": url}
|
||||
|
||||
# WebSocket: verify token (?token=base64(user:pass))
|
||||
|
||||
# WebSocket: verify token (?token=base64(user:pass)). Supports auto-reconnect by service label.
|
||||
@app.websocket("/ws/logs/{container_id}")
|
||||
async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
|
||||
service: Optional[str] = None, project: Optional[str] = None):
|
||||
await ws.accept()
|
||||
if not token or not verify_ws_token(token):
|
||||
await ws.send_text("ERROR: unauthorized")
|
||||
await ws.close(); return
|
||||
|
||||
def find_by_id_prefix(prefix: str):
|
||||
for c in docker_client.containers.list(all=True):
|
||||
if c.id.startswith(prefix):
|
||||
return c
|
||||
return None
|
||||
|
||||
def find_by_service(service_name: str, project_name: Optional[str] = None):
|
||||
# pick the "newest" container of that compose service (optionally same project)
|
||||
found = []
|
||||
for c in docker_client.containers.list(all=True):
|
||||
lbl = c.labels or {}
|
||||
if lbl.get("com.docker.compose.service") == service_name and (project_name is None or lbl.get("com.docker.compose.project")==project_name):
|
||||
found.append(c)
|
||||
if not found:
|
||||
return None
|
||||
# sort by Created desc
|
||||
try:
|
||||
found.sort(key=lambda x: x.attrs.get("Created",""), reverse=True)
|
||||
except Exception:
|
||||
pass
|
||||
return found[0]
|
||||
|
||||
# initial resolve
|
||||
container = None
|
||||
svc_label = None
|
||||
proj_label = None
|
||||
|
||||
# If service provided, prefer it for resolving container
|
||||
if service:
|
||||
container = find_by_service(service, project)
|
||||
if container is None:
|
||||
container = find_by_id_prefix(container_id)
|
||||
|
||||
if container:
|
||||
lbls = container.labels or {}
|
||||
svc_label = service or lbls.get("com.docker.compose.service")
|
||||
proj_label = project or lbls.get("com.docker.compose.project")
|
||||
else:
|
||||
# if cannot resolve anything - and we do have service, try waiting a bit (maybe recreating)
|
||||
svc_label = service
|
||||
proj_label = project
|
||||
|
||||
# streaming loop with reattach
|
||||
first_tail = tail
|
||||
try:
|
||||
while True:
|
||||
if container is None and svc_label:
|
||||
container = find_by_service(svc_label, proj_label)
|
||||
# if still none, wait and try again
|
||||
if container is None:
|
||||
try:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
except Exception:
|
||||
break
|
||||
if container is None:
|
||||
await ws.send_text("ERROR: container not found")
|
||||
break
|
||||
|
||||
try:
|
||||
# On first attach use requested tail; on reattach use tail=0 to avoid duplicate backlog
|
||||
use_tail = first_tail
|
||||
first_tail = 0
|
||||
stream = container.logs(stream=True, follow=True, tail=(use_tail if use_tail>0 else "all"))
|
||||
# stream loop
|
||||
for chunk in stream:
|
||||
if chunk is None:
|
||||
break
|
||||
try:
|
||||
await ws.send_text(chunk.decode(errors="ignore"))
|
||||
except RuntimeError:
|
||||
# client side closed
|
||||
stream.close()
|
||||
return
|
||||
# Normal EOF (container stopped or recreated). Try to re-resolve by service label.
|
||||
stream.close()
|
||||
# Re-resolve. If same ID and container stopped, wait; if new ID, reattach.
|
||||
old_id = container.id
|
||||
container = None
|
||||
# small backoff
|
||||
await asyncio.sleep(1.0)
|
||||
if svc_label:
|
||||
container = find_by_service(svc_label, proj_label)
|
||||
if container and container.id == old_id:
|
||||
# same container (probably stopped) — keep waiting until it comes back
|
||||
container = None
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
# else: will loop and attach to new container
|
||||
continue
|
||||
else:
|
||||
# No service label -> break
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception as e:
|
||||
try:
|
||||
await ws.send_text(f"ERROR: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
# try re-resolve and continue
|
||||
container = None
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
finally:
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try: await ws.close()
|
||||
except Exception: pass
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
try: await ws.send_text(f"ERROR: {e}")
|
||||
except Exception: pass
|
||||
try: await ws.close()
|
||||
except Exception: pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
print(f"LogBoard+ http://0.0.0.0:{APP_PORT}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=APP_PORT)
|
||||
|
||||
|
||||
# WebSocket: fan-in by compose service (aggregate all replicas), prefixing with short container id
|
||||
@app.websocket("/ws/fan/{service_name}")
|
||||
async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
|
||||
project: Optional[str] = None):
|
||||
await ws.accept()
|
||||
if not token or not verify_ws_token(token):
|
||||
await ws.send_text("ERROR: unauthorized")
|
||||
await ws.close(); return
|
||||
|
||||
# Track active streaming tasks by container id
|
||||
active = {}
|
||||
|
||||
def list_by_service(service_name: str, project_name: Optional[str] = None):
|
||||
found = []
|
||||
for c in docker_client.containers.list(all=True):
|
||||
lbl = c.labels or {}
|
||||
if lbl.get("com.docker.compose.service") == service_name and (project_name is None or lbl.get("com.docker.compose.project")==project_name):
|
||||
found.append(c)
|
||||
# sort by Created asc so first lines look ordered-ish
|
||||
try:
|
||||
found.sort(key=lambda x: x.attrs.get("Created",""))
|
||||
except Exception:
|
||||
pass
|
||||
return found
|
||||
|
||||
async def stream_container(cont):
|
||||
short = cont.id[:8]
|
||||
first_tail = tail
|
||||
while True:
|
||||
try:
|
||||
use_tail = first_tail
|
||||
first_tail = 0
|
||||
stream = cont.logs(stream=True, follow=True, tail=(use_tail if use_tail>0 else "all"))
|
||||
for chunk in stream:
|
||||
if chunk is None:
|
||||
break
|
||||
try:
|
||||
await ws.send_text(f"[{short}] " + chunk.decode(errors="ignore"))
|
||||
except RuntimeError:
|
||||
stream.close(); return
|
||||
stream.close()
|
||||
# container stopped -> wait and try to find same id again; if gone, exit loop and outer watcher will reassign
|
||||
await asyncio.sleep(1.0)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except Exception:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
async def watcher():
|
||||
# Periodically reconcile set of containers
|
||||
try:
|
||||
while True:
|
||||
desired = {c.id: c for c in list_by_service(service_name, project)}
|
||||
# start missing
|
||||
for cid, cont in desired.items():
|
||||
if cid not in active:
|
||||
task = asyncio.create_task(stream_container(cont))
|
||||
active[cid] = task
|
||||
# cancel removed
|
||||
for cid in list(active.keys()):
|
||||
if cid not in desired:
|
||||
task = active.pop(cid)
|
||||
task.cancel()
|
||||
await asyncio.sleep(2.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
watch_task = asyncio.create_task(watcher())
|
||||
try:
|
||||
# Keep ws open until disconnected; the tasks stream data
|
||||
while True:
|
||||
await asyncio.sleep(1.0)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
watch_task.cancel()
|
||||
for t in active.values():
|
||||
t.cancel()
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# WebSocket: fan-in for multiple compose services (comma-separated), optional project filter.
|
||||
@app.websocket("/ws/fan_group")
|
||||
async def ws_fan_group(ws: WebSocket, services: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
|
||||
project: Optional[str] = None):
|
||||
await ws.accept()
|
||||
if not token or not verify_ws_token(token):
|
||||
await ws.send_text("ERROR: unauthorized")
|
||||
await ws.close(); return
|
||||
|
||||
svc_set = {s.strip() for s in services.split(",") if s.strip()}
|
||||
if not svc_set:
|
||||
await ws.send_text("ERROR: no services provided")
|
||||
await ws.close(); return
|
||||
|
||||
active = {}
|
||||
|
||||
def list_by_services(names, project_name: Optional[str] = None):
|
||||
res = []
|
||||
for c in docker_client.containers.list(all=True):
|
||||
lbl = c.labels or {}
|
||||
if lbl.get("com.docker.compose.service") in names and (project_name is None or lbl.get("com.docker.compose.project")==project_name):
|
||||
res.append(c)
|
||||
try:
|
||||
res.sort(key=lambda x: x.attrs.get("Created",""))
|
||||
except Exception:
|
||||
pass
|
||||
return res
|
||||
|
||||
async def stream_container(cont):
|
||||
short = cont.id[:8]
|
||||
svc = (cont.labels or {}).get("com.docker.compose.service","")
|
||||
first_tail = tail
|
||||
while True:
|
||||
try:
|
||||
use_tail = first_tail
|
||||
first_tail = 0
|
||||
stream = cont.logs(stream=True, follow=True, tail=(use_tail if use_tail>0 else "all"))
|
||||
for chunk in stream:
|
||||
if chunk is None:
|
||||
break
|
||||
line = chunk.decode(errors="ignore")
|
||||
try:
|
||||
await ws.send_text(f"[{short} {svc}] " + line)
|
||||
except RuntimeError:
|
||||
stream.close(); return
|
||||
stream.close()
|
||||
await asyncio.sleep(1.0)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except Exception:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
async def watcher():
|
||||
try:
|
||||
while True:
|
||||
desired = {c.id: c for c in list_by_services(svc_set, project)}
|
||||
for cid, cont in desired.items():
|
||||
if cid not in active:
|
||||
task = asyncio.create_task(stream_container(cont))
|
||||
active[cid] = task
|
||||
for cid in list(active.keys()):
|
||||
if cid not in desired:
|
||||
task = active.pop(cid)
|
||||
task.cancel()
|
||||
await asyncio.sleep(2.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
watch_task = asyncio.create_task(watcher())
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1.0)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
watch_task.cancel()
|
||||
for t in active.values():
|
||||
t.cancel()
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@ -0,0 +1,19 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
logboard:
|
||||
build: .
|
||||
container_name: logboard
|
||||
environment:
|
||||
LOGBOARD_PORT: "9001"
|
||||
LOGBOARD_TAIL: "500"
|
||||
LOGBOARD_USER: "admin"
|
||||
LOGBOARD_PASS: "s3cret-change-me"
|
||||
# COMPOSE_PROJECT_NAME: "myproj" # filter only this compose stack
|
||||
LOGBOARD_SNAPSHOT_DIR: "/app/snapshots"
|
||||
ports:
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./snapshots:/app/snapshots
|
||||
restart: unless-stopped
|
BIN
screenshots/dark.png
Normal file
BIN
screenshots/dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 79 KiB |
BIN
screenshots/light.png
Normal file
BIN
screenshots/light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 80 KiB |
595
templates/index.html
Normal file
595
templates/index.html
Normal file
@ -0,0 +1,595 @@
|
||||
<!doctype html>
|
||||
<html lang="ru" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>LogBoard+ — compose logs</title>
|
||||
<meta name="x-token" content="__TOKEN__"/>
|
||||
<style>
|
||||
/* THEME TOKENS */
|
||||
:root{
|
||||
--bg:#0e0f13; --panel:#151821; --muted:#8b94a8; --accent:#7aa2f7; --ok:#9ece6a; --warn:#e0af68; --err:#f7768e; --fg:#e5e9f0;
|
||||
--border:#2a2f3a; --tab:#1b2030; --tab-active:#22283a; --chip:#2b3142; --link:#9ab8ff;
|
||||
}
|
||||
:root[data-theme="light"]{
|
||||
--bg:#f7f9fc; --panel:#ffffff; --muted:#667085; --accent:#3b82f6; --ok:#15803d; --warn:#b45309; --err:#b91c1c; --fg:#0f172a;
|
||||
--border:#e5e7eb; --tab:#eef2ff; --tab-active:#dbeafe; --chip:#eef2f7; --link:#1d4ed8;
|
||||
}
|
||||
|
||||
*{box-sizing:border-box} html,body{height:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.45 ui-monospace,Menlo,Consolas,monospace}
|
||||
a{color:var(--link)}
|
||||
header{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--panel);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:10}
|
||||
header h1{font-size:14px;margin:0;color:var(--muted)}
|
||||
.controls{margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.controls label{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px}
|
||||
select,button,input[type="text"]{
|
||||
background:var(--chip);color:var(--fg);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:12px}
|
||||
button{cursor:pointer} button.primary{background:var(--accent);color:#0b0d12;border:none}
|
||||
#tabs{display:flex;gap:6px;padding:8px 10px;background:var(--tab);border-bottom:1px solid var(--border);overflow:auto}
|
||||
.tab{border:1px solid var(--border);background:var(--chip);color:var(--fg);padding:6px 10px;border-radius:999px;cursor:pointer;white-space:nowrap;font-size:12px}
|
||||
.tab.active{background:var(--tab-active);border-color:var(--accent);color:var(--accent)}
|
||||
main{height:calc(100% - 110px);display:grid;grid-template-columns:1fr;grid-auto-rows:1fr;gap:8px;padding:8px}
|
||||
.grid-1{grid-template-columns:1fr}
|
||||
.grid-2{grid-template-columns:1fr 1fr}
|
||||
.grid-3{grid-template-columns:1fr 1fr 1fr}
|
||||
.grid-4{grid-template-columns:1fr 1fr;grid-auto-rows:45vh}
|
||||
.panel{border:1px solid var(--border);border-radius:10px;background:color-mix(in oklab, var(--panel) 96%, var(--bg));display:flex;flex-direction:column;min-height:0}
|
||||
.panel .title{padding:6px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px}
|
||||
.badge{padding:2px 8px;border-radius:999px;background:var(--chip);border:1px solid var(--border);margin-left:6px;color:var(--muted)}
|
||||
.controls .badge{margin-left:0}
|
||||
.toolbar{display:flex;gap:6px;margin-left:auto}
|
||||
.counter{font-size:11px;color:var(--muted)}
|
||||
.logwrap{flex:1;overflow:auto;padding:10px}
|
||||
.log{white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;margin:0;tab-size:2}
|
||||
.line{color:var(--fg)} .ok{color:var(--ok)} .warn{color:var(--warn)} .err{color:var(--err)} .dbg{color:#7dcfff} .ts{color:var(--muted)}
|
||||
footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
|
||||
.filterlvl{display:flex;gap:6px;align-items:center}
|
||||
/* Instance tag */
|
||||
.inst-tag{display:inline-block;padding:0 6px;margin-right:6px;border-radius:6px;border:1px solid var(--border);opacity:.9}
|
||||
/* ANSI */
|
||||
.ansi-black{color:#79808f} .ansi-red{color:#f7768e} .ansi-green{color:#22c55e} .ansi-yellow{color:#eab308}
|
||||
.ansi-blue{color:#3b82f6} .ansi-magenta{color:#a855f7} .ansi-cyan{color:#06b6d4} .ansi-white{color:var(--fg)}
|
||||
.ansi-bold{font-weight:bold} .ansi-italic{font-style:italic} .ansi-underline{text-decoration:underline}
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
|
||||
.theme-toggle input{appearance:none;width:36px;height:20px;border-radius:999px;position:relative;background:var(--chip);border:1px solid var(--border);cursor:pointer}
|
||||
.theme-toggle input::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--fg);transition:transform .2s ease}
|
||||
.theme-toggle input:checked::after{transform:translateX(16px)}
|
||||
|
||||
/* Floating copy button */
|
||||
.copy-fab{
|
||||
position:fixed; z-index:9999; display:none; padding:6px 10px; border-radius:8px;
|
||||
background:var(--accent); color:#0b0d12; border:none; box-shadow:0 6px 20px rgba(0,0,0,.25);
|
||||
font-size:12px;
|
||||
}
|
||||
.copy-fab.show{display:block}
|
||||
.copy-fab:active{transform:translateY(1px)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>LogBoard+</h1>
|
||||
<span id="projectBadge" class="badge">project: <em>all</em></span>
|
||||
<div class="controls">
|
||||
<span class="filterlvl">
|
||||
<label><input type="checkbox" id="lvlDebug" checked>DEBUG</label>
|
||||
<label><input type="checkbox" id="lvlInfo" checked>INFO</label>
|
||||
<label><input type="checkbox" id="lvlWarn" checked>WARN</label>
|
||||
<label><input type="checkbox" id="lvlErr" checked>ERROR</label>
|
||||
</span>
|
||||
<label>Tail:
|
||||
<select id="tail">
|
||||
<option value="200">200</option>
|
||||
<option value="500" selected>500</option>
|
||||
<option value="1000">1000</option>
|
||||
<option value="0">all</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><input id="autoscroll" type="checkbox" checked/> auto</label>
|
||||
<label><input id="wrap" type="checkbox" checked/> wrap</label>
|
||||
<label><input id="pause" type="checkbox"/> pause</label>
|
||||
<label><input id="aggregate" type="checkbox"/> aggregate</label>
|
||||
<label class="theme-toggle" title="Dark / Light">
|
||||
<span>theme</span>
|
||||
<input id="themeSwitch" type="checkbox" />
|
||||
</label>
|
||||
<input id="filter" type="text" placeholder="filter (regex)…"/>
|
||||
<button id="snapshot">snapshot</button>
|
||||
<button id="groupBtn">group</button>
|
||||
<button id="clear">clear</button>
|
||||
<button id="refresh">refresh</button>
|
||||
<span id="wsstate" class="badge">ws: off</span>
|
||||
<span id="layoutBadge" class="badge">view: tabs</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="tabs"></div>
|
||||
<div id="idFilters" style="padding:6px 10px; display:flex; gap:6px; flex-wrap:wrap;"></div>
|
||||
<main id="grid" class="grid-1"></main>
|
||||
<button id="copyFab" class="copy-fab" type="button">копировать</button>
|
||||
<footer>© LogBoard+</footer>
|
||||
|
||||
<script>
|
||||
const state = {
|
||||
token: (document.querySelector('meta[name="x-token"]')?.content)||'',
|
||||
services: [],
|
||||
current: null,
|
||||
open: {}, // id -> {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName}
|
||||
layout: 'tabs', // 'tabs' | 'grid2' | 'grid3' | 'grid4'
|
||||
filter: null,
|
||||
levels: {debug:true, info:true, warn:true, err:true},
|
||||
};
|
||||
|
||||
const els = {
|
||||
tabs: document.getElementById('tabs'),
|
||||
grid: document.getElementById('grid'),
|
||||
tail: document.getElementById('tail'),
|
||||
autoscroll: document.getElementById('autoscroll'),
|
||||
wrapToggle: document.getElementById('wrap'),
|
||||
pause: document.getElementById('pause'),
|
||||
filter: document.getElementById('filter'),
|
||||
wsstate: document.getElementById('wsstate'),
|
||||
projectBadge: document.getElementById('projectBadge'),
|
||||
clearBtn: document.getElementById('clear'),
|
||||
refreshBtn: document.getElementById('refresh'),
|
||||
snapshotBtn: document.getElementById('snapshot'),
|
||||
lvlDebug: document.getElementById('lvlDebug'),
|
||||
lvlInfo: document.getElementById('lvlInfo'),
|
||||
lvlWarn: document.getElementById('lvlWarn'),
|
||||
lvlErr: document.getElementById('lvlErr'),
|
||||
layoutBadge: document.getElementById('layoutBadge'),
|
||||
aggregate: document.getElementById('aggregate'),
|
||||
themeSwitch: document.getElementById('themeSwitch'),
|
||||
copyFab: document.getElementById('copyFab'),
|
||||
groupBtn: document.getElementById('groupBtn'),
|
||||
aggregate: document.getElementById('aggregate'),
|
||||
themeSwitch: document.getElementById('themeSwitch'),
|
||||
copyFab: document.getElementById('copyFab'),
|
||||
};
|
||||
|
||||
// ----- Theme toggle -----
|
||||
(function initTheme(){
|
||||
const saved = localStorage.lb_theme || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
els.themeSwitch.checked = (saved==='light');
|
||||
els.themeSwitch.addEventListener('change', ()=>{
|
||||
const t = els.themeSwitch.checked ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
localStorage.lb_theme = t;
|
||||
});
|
||||
})();
|
||||
|
||||
function setWsState(s){ els.wsstate.textContent = 'ws: ' + s; }
|
||||
function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
|
||||
|
||||
function classify(line){
|
||||
const l = line.toLowerCase();
|
||||
if (/\bdebug\b|level=debug| \[debug\]/.test(l)) return 'dbg';
|
||||
if (/\berr(or)?\b|level=error| \[error\]/.test(l)) return 'err';
|
||||
if (/\bwarn(ing)?\b|level=warn| \[warn\]/.test(l)) return 'warn';
|
||||
if (/\b(info|started|listening|ready|up)\b|level=info/.test(l)) return 'ok';
|
||||
return '';
|
||||
}
|
||||
function allowedByLevel(cls){
|
||||
if (cls==='dbg') return state.levels.debug;
|
||||
if (cls==='err') return state.levels.err;
|
||||
if (cls==='warn') return state.levels.warn;
|
||||
if (cls==='ok') return state.levels.info;
|
||||
return true;
|
||||
}
|
||||
function applyFilter(line){
|
||||
if(!state.filter) return true;
|
||||
try{ return new RegExp(state.filter, 'i').test(line); }catch(e){ return true; }
|
||||
}
|
||||
|
||||
// ANSI → HTML (SGR: 0/1/3/4, 30-37)
|
||||
|
||||
// ----- Instance color & filters -----
|
||||
const inst = { colors: {}, filters: {}, palette: [
|
||||
'#7aa2f7','#9ece6a','#e0af68','#f7768e','#bb9af7','#7dcfff','#c0caf5','#f6bd60',
|
||||
'#84cc16','#06b6d4','#fb923c','#ef4444','#22c55e','#a855f7'
|
||||
]};
|
||||
|
||||
function idColor(id8){
|
||||
if (inst.colors[id8]) return inst.colors[id8];
|
||||
// simple hash to pick from palette
|
||||
let h = 0; for (let i=0;i<id8.length;i++){ h = (h*31 + id8.charCodeAt(i))>>>0; }
|
||||
const color = inst.palette[h % inst.palette.length];
|
||||
inst.colors[id8] = color;
|
||||
return color;
|
||||
}
|
||||
|
||||
function updateIdFiltersBar(){
|
||||
const bar = document.getElementById('idFilters');
|
||||
bar.innerHTML = '';
|
||||
const ids = Object.keys(inst.filters);
|
||||
if (!ids.length){ bar.style.display='none'; return; }
|
||||
bar.style.display='flex';
|
||||
ids.forEach(id8=>{
|
||||
const wrap = document.createElement('label');
|
||||
wrap.style.display='inline-flex'; wrap.style.alignItems='center'; wrap.style.gap='6px';
|
||||
const cb = document.createElement('input'); cb.type='checkbox'; cb.checked = inst.filters[id8] !== false;
|
||||
cb.onchange = ()=> inst.filters[id8] = cb.checked;
|
||||
const chip = document.createElement('span');
|
||||
chip.className='inst-tag';
|
||||
chip.style.borderColor = idColor(id8);
|
||||
chip.style.color = idColor(id8);
|
||||
chip.textContent = id8;
|
||||
wrap.appendChild(cb); wrap.appendChild(chip);
|
||||
bar.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
function shouldShowInstance(id8){
|
||||
if (!Object.keys(inst.filters).length) return true;
|
||||
const val = inst.filters[id8];
|
||||
return val !== false;
|
||||
}
|
||||
|
||||
function parsePrefixAndStrip(line){
|
||||
// Accept "[id]" or "[id service]" prefixes from fan/fan_group
|
||||
const m = line.match(/^\[([0-9a-f]{8})(?:\s+[^\]]+)?\]\s(.*)$/i);
|
||||
if (!m) return null;
|
||||
return {id8: m[1], rest: m[2]};
|
||||
}
|
||||
|
||||
function ansiToHtml(text){
|
||||
const ESC = '\\u001b[';
|
||||
const parts = text.split(ESC);
|
||||
if (parts.length === 1) return escapeHtml(text);
|
||||
let html = escapeHtml(parts[0]);
|
||||
let classes = [];
|
||||
for (let i=1;i<parts.length;i++){
|
||||
const seg = parts[i];
|
||||
const m = seg.match(/^([0-9;]+)m(.*)$/s);
|
||||
if(!m){ html += escapeHtml(seg); continue; }
|
||||
const codes = m[1].split(';').map(Number);
|
||||
let rest = m[2];
|
||||
for(const c of codes){
|
||||
if (c===0) classes = [];
|
||||
else if (c===1) classes.push('ansi-bold');
|
||||
else if (c===3) classes.push('ansi-italic');
|
||||
else if (c===4) classes.push('ansi-underline');
|
||||
else if (c>=30 && c<=37){
|
||||
classes = classes.filter(x=>!x.startsWith('ansi-'));
|
||||
const map = {30:'black',31:'red',32:'green',33:'yellow',34:'blue',35:'magenta',36:'cyan',37:'white'};
|
||||
classes.push('ansi-'+map[c]);
|
||||
}
|
||||
}
|
||||
if (classes.length) html += `<span class="${classes.join(' ')}">` + escapeHtml(rest) + `</span>`;
|
||||
else html += escapeHtml(rest);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function panelTemplate(svc){
|
||||
const div = document.createElement('div'); div.className='panel'; div.dataset.cid = svc.id;
|
||||
div.innerHTML = `
|
||||
<div class="title">
|
||||
<span>${escapeHtml((svc.project?`[${svc.project}] `:'')+(svc.service||svc.name))}</span>
|
||||
<span class="counter">dbg:<b class="cdbg">0</b> info:<b class="cinfo">0</b> warn:<b class="cwarn">0</b> err:<b class="cerr">0</b></span>
|
||||
<div class="toolbar">
|
||||
<button class="primary t-reconnect">reconnect</button>
|
||||
<button class="t-snapshot">snapshot</button>
|
||||
<button class="t-close">close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logwrap"><pre class="log"></pre></div>`;
|
||||
return div;
|
||||
}
|
||||
|
||||
function buildTabs(){
|
||||
els.tabs.innerHTML='';
|
||||
state.services.forEach(svc=>{
|
||||
const b = document.createElement('button');
|
||||
b.className='tab' + ((state.current && svc.id===state.current.id) ? ' active':'');
|
||||
b.textContent = (svc.project?(`[${svc.project}] `):'') + (svc.service||svc.name);
|
||||
b.title = `${svc.name} • ${svc.image} • ${svc.status}`;
|
||||
b.onclick = ()=> switchToSingle(svc);
|
||||
els.tabs.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
function setLayout(cls){
|
||||
state.layout = cls;
|
||||
els.layoutBadge.textContent = 'view: ' + (cls==='tabs'?'tabs':cls);
|
||||
els.grid.className = cls==='tabs' ? 'grid-1' : (cls==='grid2'?'grid-2':cls==='grid3'?'grid-3':'grid-4');
|
||||
}
|
||||
|
||||
async function fetchServices(){
|
||||
const url = new URL(location.origin + '/api/services');
|
||||
if (localStorage.lb_project) url.searchParams.set('project', localStorage.lb_project);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok){ alert('Auth failed (HTTP)'); return; }
|
||||
const data = await res.json();
|
||||
state.services = data;
|
||||
const pj = localStorage.lb_project || 'all';
|
||||
els.projectBadge.innerHTML = 'project: <em>'+escapeHtml(pj)+'</em>';
|
||||
buildTabs();
|
||||
if (!state.current && state.services.length) switchToSingle(state.services[0]);
|
||||
}
|
||||
|
||||
function wsUrl(containerId, service, project){
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const tail = els.tail.value || '500';
|
||||
const token = encodeURIComponent(state.token || '');
|
||||
const sp = service?`&service=${encodeURIComponent(service)}`:'';
|
||||
const pj = project?`&project=${encodeURIComponent(project)}`:'';
|
||||
if (els.aggregate && els.aggregate.checked && service){
|
||||
// fan-in by service
|
||||
return `${proto}://${location.host}/ws/fan/${encodeURIComponent(service)}?tail=${tail}&token=${token}${pj}`;
|
||||
}
|
||||
return `${proto}://${location.host}/ws/logs/${encodeURIComponent(containerId)}?tail=${tail}&token=${token}${sp}${pj}`;
|
||||
}
|
||||
|
||||
function closeWs(id){
|
||||
const o = state.open[id];
|
||||
if (!o) return;
|
||||
try { o.ws.close(); } catch(e){}
|
||||
delete state.open[id];
|
||||
}
|
||||
|
||||
async function sendSnapshot(id){
|
||||
const o = state.open[id];
|
||||
if (!o){ alert('not open'); return; }
|
||||
const text = o.logEl.textContent;
|
||||
const payload = {container_id: id, service: o.serviceName || id, content: text};
|
||||
const res = await fetch('/api/snapshot', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
|
||||
if (!res.ok){ alert('snapshot failed'); return; }
|
||||
const js = await res.json();
|
||||
const a = document.createElement('a');
|
||||
a.href = js.url; a.download = js.file; a.click();
|
||||
}
|
||||
|
||||
function openWs(svc, panel){
|
||||
const id = svc.id;
|
||||
const logEl = panel.querySelector('.log');
|
||||
const wrapEl = panel.querySelector('.logwrap');
|
||||
const cdbg = panel.querySelector('.cdbg');
|
||||
const cinfo = panel.querySelector('.cinfo');
|
||||
const cwarn = panel.querySelector('.cwarn');
|
||||
const cerr = panel.querySelector('.cerr');
|
||||
const counters = {dbg:0,info:0,warn:0,err:0};
|
||||
|
||||
const ws = new WebSocket(wsUrl(id, (svc.service||svc.name), svc.project||''));
|
||||
state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: (svc.service||svc.name)};
|
||||
|
||||
ws.onopen = ()=> setWsState('on');
|
||||
ws.onclose = ()=> setWsState(Object.keys(state.open).length? 'on':'off');
|
||||
ws.onerror = ()=> setWsState('err');
|
||||
ws.onmessage = (ev)=>{
|
||||
const parts = (ev.data||'').split(/
?
|
||||
/);
|
||||
for (let i=0;i<parts.length;i++){
|
||||
if (parts[i].length===0 && i===parts.length-1) continue;
|
||||
// harvest instance ids if present
|
||||
const pr = parsePrefixAndStrip(parts[i]);
|
||||
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
|
||||
handleLine(id, parts[i]);
|
||||
}
|
||||
cdbg.textContent = counters.dbg;
|
||||
cinfo.textContent = counters.info;
|
||||
cwarn.textContent = counters.warn;
|
||||
cerr.textContent = counters.err;
|
||||
};
|
||||
|
||||
function handleLine(id, line){
|
||||
const cls = classify(line);
|
||||
if (cls==='dbg') counters.dbg++;
|
||||
if (cls==='ok') counters.info++;
|
||||
if (cls==='warn') counters.warn++;
|
||||
if (cls==='err') counters.err++;
|
||||
if (!allowedByLevel(cls)) return;
|
||||
if (!applyFilter(line)) return;
|
||||
const html = `<span class="line ${cls}">${ansiToHtml(line)}</span>\n`;
|
||||
const obj = state.open[id];
|
||||
if (!obj) return;
|
||||
if (els.pause.checked){
|
||||
obj.pausedBuffer.push(html);
|
||||
if (obj.pausedBuffer.length>5000) obj.pausedBuffer.shift();
|
||||
return;
|
||||
}
|
||||
obj.logEl.insertAdjacentHTML('beforeend', html);
|
||||
if (els.autoscroll.checked) obj.wrapEl.scrollTop = obj.wrapEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePanel(svc){
|
||||
let panel = els.grid.querySelector(\`.panel[data-cid="\${svc.id}"]\`);
|
||||
if (!panel){
|
||||
panel = panelTemplate(svc);
|
||||
els.grid.appendChild(panel);
|
||||
panel.querySelector('.t-reconnect').onclick = ()=>{
|
||||
const id = svc.id;
|
||||
const o = state.open[id];
|
||||
if (o){ o.logEl.textContent=''; closeWs(id); }
|
||||
openWs(svc, panel);
|
||||
};
|
||||
panel.querySelector('.t-close').onclick = ()=>{
|
||||
closeWs(svc.id);
|
||||
panel.remove();
|
||||
if (!Object.keys(state.open).length) setWsState('off');
|
||||
};
|
||||
panel.querySelector('.t-snapshot').onclick = ()=> sendSnapshot(svc.id);
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
function switchToSingle(svc){
|
||||
setLayout('tabs');
|
||||
els.grid.innerHTML='';
|
||||
const panel = ensurePanel(svc);
|
||||
panel.querySelector('.log').textContent='';
|
||||
closeWs(svc.id);
|
||||
openWs(svc, panel);
|
||||
state.current = svc;
|
||||
buildTabs();
|
||||
for (const p of [...els.grid.children]) if (p!==panel) p.remove();
|
||||
}
|
||||
|
||||
function openMulti(ids){
|
||||
els.grid.innerHTML='';
|
||||
const chosen = state.services.filter(s=> ids.includes(s.id));
|
||||
const n = chosen.length;
|
||||
if (n<=1){ if (n===1) switchToSingle(chosen[0]); return; }
|
||||
setLayout(n>=4 ? 'grid4' : n===3 ? 'grid3' : 'grid2');
|
||||
for (const svc of chosen){
|
||||
const panel = ensurePanel(svc);
|
||||
panel.querySelector('.log').textContent='';
|
||||
closeWs(svc.id);
|
||||
openWs(svc, panel);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Copy on selection -----
|
||||
function getSelectionText(){
|
||||
const sel = window.getSelection();
|
||||
return sel && sel.rangeCount ? sel.toString() : "";
|
||||
}
|
||||
function showCopyFabNearSelection(){
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount===0) return hideCopyFab();
|
||||
const text = sel.toString();
|
||||
if (!text.trim()) return hideCopyFab();
|
||||
// Only show if selection inside a .log or .logwrap
|
||||
const range = sel.getRangeAt(0);
|
||||
const common = range.commonAncestorContainer;
|
||||
const el = common.nodeType===1 ? common : common.parentElement;
|
||||
if (!el || !el.closest('.logwrap')) return hideCopyFab();
|
||||
const rect = range.getBoundingClientRect();
|
||||
const top = rect.bottom + 8 + window.scrollY;
|
||||
const left = rect.right + 8 + window.scrollX;
|
||||
els.copyFab.style.top = top + 'px';
|
||||
els.copyFab.style.left = left + 'px';
|
||||
els.copyFab.classList.add('show');
|
||||
}
|
||||
function hideCopyFab(){
|
||||
els.copyFab.classList.remove('show');
|
||||
}
|
||||
document.addEventListener('selectionchange', ()=>{
|
||||
// throttle-ish using requestAnimationFrame
|
||||
window.requestAnimationFrame(showCopyFabNearSelection);
|
||||
});
|
||||
document.addEventListener('scroll', hideCopyFab, true);
|
||||
els.copyFab.addEventListener('click', async ()=>{
|
||||
const text = getSelectionText();
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const old = els.copyFab.textContent;
|
||||
els.copyFab.textContent = 'скопировано';
|
||||
setTimeout(()=> els.copyFab.textContent = old, 1000);
|
||||
hideCopyFab();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
} catch(e){
|
||||
alert('не удалось скопировать: ' + e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function fanGroupUrl(servicesCsv, project){
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const tail = els.tail.value || '500';
|
||||
const token = encodeURIComponent(state.token || '');
|
||||
const pj = project?`&project=${encodeURIComponent(project)}`:'';
|
||||
return `${proto}://${location.host}/ws/fan_group?services=${encodeURIComponent(servicesCsv)}&tail=${tail}&token=${token}${pj}`;
|
||||
}
|
||||
|
||||
function openFanGroup(services){
|
||||
// Build a special panel named after the group
|
||||
els.grid.innerHTML='';
|
||||
const fake = { id: 'group-'+services.join(','), name:'group', service: 'group', project: (localStorage.lb_project||'') };
|
||||
const panel = ensurePanel(fake);
|
||||
panel.querySelector('.log').textContent='';
|
||||
closeWs(fake.id);
|
||||
|
||||
// Override ws creation to fan_group
|
||||
const id = fake.id;
|
||||
const logEl = panel.querySelector('.log');
|
||||
const wrapEl = panel.querySelector('.logwrap');
|
||||
const cdbg = panel.querySelector('.cdbg');
|
||||
const cinfo = panel.querySelector('.cinfo');
|
||||
const cwarn = panel.querySelector('.cwarn');
|
||||
const cerr = panel.querySelector('.cerr');
|
||||
const counters = {dbg:0,info:0,warn:0,err:0};
|
||||
|
||||
const ws = new WebSocket(fanGroupUrl(services.join(','), fake.project||''));
|
||||
state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: ('group:'+services.join(','))};
|
||||
|
||||
ws.onopen = ()=> setWsState('on');
|
||||
ws.onclose = ()=> setWsState(Object.keys(state.open).length? 'on':'off');
|
||||
ws.onerror = ()=> setWsState('err');
|
||||
ws.onmessage = (ev)=>{
|
||||
const parts = (ev.data||'').split(/\\r?\\n/);
|
||||
for (let i=0;i<parts.length;i++){
|
||||
if (parts[i].length===0 && i===parts.length-1) continue;
|
||||
const pr = parsePrefixAndStrip(parts[i]);
|
||||
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
|
||||
handleLine(id, parts[i]);
|
||||
}
|
||||
cdbg.textContent = counters.dbg;
|
||||
cinfo.textContent = counters.info;
|
||||
cwarn.textContent = counters.warn;
|
||||
cerr.textContent = counters.err;
|
||||
};
|
||||
|
||||
// Show filter bar and clear previous filters
|
||||
inst.filters = {};
|
||||
updateIdFiltersBar();
|
||||
}
|
||||
|
||||
els.groupBtn.onclick = ()=>{
|
||||
const list = state.services.map(s=> `${s.service}`).filter((v,i,a)=> a.indexOf(v)===i).join(', ');
|
||||
const ans = prompt('Введите имена сервисов через запятую:\n'+list);
|
||||
if (ans){
|
||||
const services = ans.split(',').map(x=>x.trim()).filter(Boolean);
|
||||
if (services.length) openFanGroup(services);
|
||||
}
|
||||
};
|
||||
|
||||
// Controls
|
||||
els.clearBtn.onclick = ()=> Object.values(state.open).forEach(o=> o.logEl.textContent='');
|
||||
els.refreshBtn.onclick = fetchServices;
|
||||
els.snapshotBtn.onclick = ()=>{ if (state.current) sendSnapshot(state.current.id); };
|
||||
els.tail.onchange = ()=> {
|
||||
Object.keys(state.open).forEach(id=>{
|
||||
const svc = state.services.find(s=> s.id===id);
|
||||
if (!svc) return;
|
||||
const panel = els.grid.querySelector(\`.panel[data-cid="\${id}"]\`);
|
||||
if (!panel) return;
|
||||
state.open[id].logEl.textContent='';
|
||||
closeWs(id); openWs(svc, panel);
|
||||
});
|
||||
};
|
||||
els.wrapToggle.onchange = ()=> document.querySelectorAll('.log').forEach(el=> el.style.whiteSpace = els.wrapToggle.checked ? 'pre-wrap' : 'pre');
|
||||
els.filter.oninput = ()=> { state.filter = els.filter.value.trim(); };
|
||||
els.lvlDebug.onchange = ()=> state.levels.debug = els.lvlDebug.checked;
|
||||
els.lvlInfo.onchange = ()=> state.levels.info = els.lvlInfo.checked;
|
||||
els.lvlWarn.onchange = ()=> state.levels.warn = els.lvlWarn.checked;
|
||||
els.lvlErr.onchange = ()=> state.levels.err = els.lvlErr.checked;
|
||||
|
||||
// Hotkeys: [ ] — tabs, M — multi
|
||||
window.addEventListener('keydown', (e)=>{
|
||||
if (e.key==='[' || (e.ctrlKey && e.key==='ArrowLeft')){
|
||||
e.preventDefault();
|
||||
const idx = state.services.findIndex(s=> s.id===state.current?.id);
|
||||
if (idx>0) switchToSingle(state.services[idx-1]);
|
||||
}
|
||||
if (e.key===']' || (e.ctrlKey && e.key==='ArrowRight')){
|
||||
e.preventDefault();
|
||||
const idx = state.services.findIndex(s=> s.id===state.current?.id);
|
||||
if (idx>=0 && idx<state.services.length-1) switchToSingle(state.services[idx+1]);
|
||||
}
|
||||
if (e.key.toLowerCase()==='m'){
|
||||
const list = state.services.map(s=> \`\${s.id}:\${s.service}\`).join(', ');
|
||||
const ans = prompt('IDs через запятую:\n'+list);
|
||||
if (ans) openMulti(ans.split(',').map(x=>x.trim()).filter(Boolean));
|
||||
}
|
||||
});
|
||||
|
||||
fetchServices();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Loading…
x
Reference in New Issue
Block a user