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:
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>
|
||||||
Reference in New Issue
Block a user