- Добавлен API эндпоинт /api/projects для получения списка проектов - Обновлен API /api/services для поддержки фильтрации по множественным проектам - Добавлен селектор проектов в веб-интерфейс - Добавлена переменная окружения LOGBOARD_PROJECTS - Обновлен HTML шаблон с JavaScript функциональностью - Добавлена функция fetchProjects() для загрузки списка проектов - Обновлена функция fetchServices() для работы с выбранными проектами Автор: Сергей Антропов Сайт: https://devops.org.ru
633 lines
25 KiB
HTML
633 lines
25 KiB
HTML
<!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">
|
|
<label>Projects:
|
|
<select id="projectSelect">
|
|
<option value="all">All Projects</option>
|
|
</select>
|
|
</label>
|
|
<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'),
|
|
projectSelect: document.getElementById('projectSelect'),
|
|
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'),
|
|
};
|
|
|
|
// ----- 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 fetchProjects(){
|
|
const url = new URL(location.origin + '/api/projects');
|
|
const res = await fetch(url);
|
|
if (!res.ok){ console.error('Failed to fetch projects'); return; }
|
|
const projects = await res.json();
|
|
|
|
// Обновляем селектор проектов
|
|
const select = els.projectSelect;
|
|
select.innerHTML = '<option value="all">All Projects</option>';
|
|
projects.forEach(project => {
|
|
const option = document.createElement('option');
|
|
option.value = project;
|
|
option.textContent = project;
|
|
select.appendChild(option);
|
|
});
|
|
|
|
// Устанавливаем сохраненный проект
|
|
if (localStorage.lb_project && projects.includes(localStorage.lb_project)) {
|
|
select.value = localStorage.lb_project;
|
|
}
|
|
}
|
|
|
|
async function fetchServices(){
|
|
const url = new URL(location.origin + '/api/services');
|
|
const selectedProject = els.projectSelect.value;
|
|
|
|
if (selectedProject && selectedProject !== 'all') {
|
|
url.searchParams.set('projects', selectedProject);
|
|
localStorage.lb_project = selectedProject;
|
|
} else {
|
|
localStorage.removeItem('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 = selectedProject === 'all' ? 'all' : selectedProject;
|
|
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(/\r?\n/);
|
|
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.projectSelect.onchange = 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));
|
|
}
|
|
});
|
|
|
|
// Инициализация
|
|
(async function init() {
|
|
await fetchProjects();
|
|
await fetchServices();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|