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:
Сергей Антропов 2025-08-16 11:15:56 +03:00
commit c74e5ec15e
8 changed files with 1270 additions and 0 deletions

16
Dockerfile Normal file
View 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
View 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
View 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
## Скриншоты
Тёмная тема:
![Dark](screenshots/dark.png)
Светлая тема:
![Light](screenshots/light.png)

414
app.py Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
screenshots/light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

595
templates/index.html Normal file
View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>