feat: создать современный интерфейс с боковым меню

- Полностью переработан дизайн интерфейса
- Добавлено боковое меню слева с контролами
- Область логов перемещена вправо
- Добавлены иконки Font Awesome
- Современный CSS с переменными и анимациями
- Адаптивный дизайн для мобильных устройств
- Улучшенная навигация по контейнерам
- Современные кнопки и элементы управления
- Поддержка темной и светлой темы
- Индикаторы статуса контейнеров

Автор: Сергей Антропов
Сайт: https://devops.org.ru
This commit is contained in:
Сергей Антропов 2025-08-16 12:34:44 +03:00
parent a1572d470c
commit 43f19d32e1

View File

@ -3,33 +3,400 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/> <meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>LogBoard+ — compose logs</title> <title>LogBoard+ — Modern Log Viewer</title>
<meta name="x-token" content="__TOKEN__"/> <meta name="x-token" content="__TOKEN__"/>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style> <style>
/* THEME TOKENS */ /* THEME TOKENS */
:root{ :root{
--bg:#0e0f13; --panel:#151821; --muted:#8b94a8; --accent:#7aa2f7; --ok:#9ece6a; --warn:#e0af68; --err:#f7768e; --fg:#e5e9f0; --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; --border:#2a2f3a; --tab:#1b2030; --tab-active:#22283a; --chip:#2b3142; --link:#9ab8ff;
--sidebar-width: 280px; --header-height: 60px;
} }
:root[data-theme="light"]{ :root[data-theme="light"]{
--bg:#f7f9fc; --panel:#ffffff; --muted:#667085; --accent:#3b82f6; --ok:#15803d; --warn:#b45309; --err:#b91c1c; --fg:#0f172a; --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; --border:#e5e7eb; --tab:#eef2ff; --tab-active:#dbeafe; --chip:#eef2f7; --link:#1d4ed8;
} }
*{box-sizing:border-box} html,body{height:100%} *{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.45 ui-monospace,Menlo,Consolas,monospace} html,body{height:100%; margin: 0; padding: 0;}
body{background:var(--bg);color:var(--fg);font:13px/1.45 ui-monospace,Menlo,Consolas,monospace; overflow: hidden;}
a{color:var(--link)} 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)} /* Modern Layout */
.controls{margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap} .app-container {
.controls label{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px} display: flex;
select,button,input[type="text"]{ height: 100vh;
background:var(--chip);color:var(--fg);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:12px} overflow: hidden;
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} /* Sidebar */
.tab.active{background:var(--tab-active);border-color:var(--accent);color:var(--accent)} .sidebar {
main{height:calc(100% - 110px);display:grid;grid-template-columns:1fr;grid-auto-rows:1fr;gap:8px;padding:8px} width: var(--sidebar-width);
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.sidebar-header h1 {
font-size: 18px;
margin: 0 0 8px 0;
color: var(--accent);
font-weight: 600;
}
.sidebar-header .subtitle {
font-size: 12px;
color: var(--muted);
margin: 0;
}
/* Sidebar Controls */
.sidebar-controls {
padding: 16px;
border-bottom: 1px solid var(--border);
}
.control-group {
margin-bottom: 16px;
}
.control-group:last-child {
margin-bottom: 0;
}
.control-group label {
display: block;
font-size: 11px;
color: var(--muted);
margin-bottom: 6px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.control-group select,
.control-group input[type="text"] {
width: 100%;
background: var(--chip);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
transition: border-color 0.2s ease;
}
.control-group select:focus,
.control-group input[type="text"]:focus {
outline: none;
border-color: var(--accent);
}
/* Checkbox Groups */
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--muted);
}
.checkbox-item input[type="checkbox"] {
margin: 0;
}
/* Buttons */
.btn {
background: var(--chip);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:hover {
background: var(--tab-active);
border-color: var(--accent);
}
.btn-primary {
background: var(--accent);
color: #0b0d12;
border-color: var(--accent);
font-weight: 500;
}
.btn-primary:hover {
background: #6b8fd8;
}
.btn-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.header {
height: var(--header-height);
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--fg);
margin: 0;
}
.header-badge {
background: var(--chip);
color: var(--muted);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
border: 1px solid var(--border);
}
.header-controls {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
/* Theme Toggle */
.theme-toggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.theme-toggle input {
appearance: none;
width: 40px;
height: 20px;
border-radius: 999px;
position: relative;
background: var(--chip);
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.2s ease;
}
.theme-toggle input::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--fg);
transition: transform 0.2s ease;
}
.theme-toggle input:checked::after {
transform: translateX(20px);
}
.theme-toggle input:checked {
background: var(--accent);
}
/* Container List */
.container-list {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.container-item {
background: var(--chip);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.container-item:hover {
background: var(--tab-active);
border-color: var(--accent);
}
.container-item.active {
background: var(--tab-active);
border-color: var(--accent);
color: var(--accent);
}
.container-item.active::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent);
border-radius: 0 2px 2px 0;
}
.container-name {
font-size: 13px;
font-weight: 500;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.container-service {
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
}
.container-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ok);
}
.status-indicator.running { background: var(--ok); }
.status-indicator.stopped { background: var(--err); }
.status-indicator.paused { background: var(--warn); }
/* Log Area */
.log-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.log-header {
padding: 12px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
}
.log-title {
font-size: 14px;
font-weight: 500;
color: var(--fg);
margin: 0;
}
.log-controls {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.log-content {
flex: 1;
overflow: auto;
padding: 16px;
background: var(--bg);
}
.log {
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.5;
margin: 0;
tab-size: 2;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 100%;
position: absolute;
z-index: 1000;
height: 100%;
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.sidebar.open {
transform: translateX(0);
}
.main-content {
width: 100%;
}
.mobile-toggle {
display: block;
}
}
.mobile-toggle {
display: none;
background: none;
border: none;
color: var(--fg);
font-size: 18px;
cursor: pointer;
padding: 8px;
}
/* Legacy styles for compatibility */
#tabs{display:none}
main{display:none}
.grid-1{grid-template-columns:1fr} .grid-1{grid-template-columns:1fr}
.grid-2{grid-template-columns:1fr 1fr} .grid-2{grid-template-columns:1fr 1fr}
.grid-3{grid-template-columns:1fr 1fr 1fr} .grid-3{grid-template-columns:1fr 1fr 1fr}
@ -69,52 +436,140 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
</style> </style>
</head> </head>
<body> <body>
<header> <div class="app-container">
<h1>LogBoard+</h1> <!-- Sidebar -->
<span id="projectBadge" class="badge">project: <em>all</em></span> <div class="sidebar">
<div class="controls"> <div class="sidebar-header">
<label>Projects: <h1><i class="fas fa-terminal"></i> LogBoard+</h1>
<select id="projectSelect"> <p class="subtitle">Modern Log Viewer</p>
<option value="all">All Projects</option> </div>
</select>
</label> <div class="sidebar-controls">
<span class="filterlvl"> <div class="control-group">
<label><input type="checkbox" id="lvlDebug" checked>DEBUG</label> <label>Project</label>
<label><input type="checkbox" id="lvlInfo" checked>INFO</label> <select id="projectSelect">
<label><input type="checkbox" id="lvlWarn" checked>WARN</label> <option value="all">All Projects</option>
<label><input type="checkbox" id="lvlErr" checked>ERROR</label> </select>
</span> </div>
<label>Tail:
<select id="tail"> <div class="control-group">
<option value="200">200</option> <label>Log Levels</label>
<option value="500" selected>500</option> <div class="checkbox-group">
<option value="1000">1000</option> <div class="checkbox-item">
<option value="0">all</option> <input type="checkbox" id="lvlDebug" checked>
</select> <label for="lvlDebug">DEBUG</label>
</label> </div>
<label><input id="autoscroll" type="checkbox" checked/> auto</label> <div class="checkbox-item">
<label><input id="wrap" type="checkbox" checked/> wrap</label> <input type="checkbox" id="lvlInfo" checked>
<label><input id="pause" type="checkbox"/> pause</label> <label for="lvlInfo">INFO</label>
<label><input id="aggregate" type="checkbox"/> aggregate</label> </div>
<label class="theme-toggle" title="Dark / Light"> <div class="checkbox-item">
<span>theme</span> <input type="checkbox" id="lvlWarn" checked>
<input id="themeSwitch" type="checkbox" /> <label for="lvlWarn">WARN</label>
</label> </div>
<input id="filter" type="text" placeholder="filter (regex)…"/> <div class="checkbox-item">
<button id="snapshot">snapshot</button> <input type="checkbox" id="lvlErr" checked>
<button id="groupBtn">group</button> <label for="lvlErr">ERROR</label>
<button id="clear">clear</button> </div>
<button id="refresh">refresh</button> </div>
<span id="wsstate" class="badge">ws: off</span> </div>
<span id="layoutBadge" class="badge">view: tabs</span>
<div class="control-group">
<label>Tail Lines</label>
<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>
</div>
<div class="control-group">
<label>Options</label>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="autoscroll" checked>
<label for="autoscroll">Auto-scroll</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="wrap" checked>
<label for="wrap">Wrap text</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="pause">
<label for="pause">Pause</label>
</div>
</div>
</div>
<div class="control-group">
<label>Filter</label>
<input id="filter" type="text" placeholder="Filter logs (regex)…"/>
</div>
<div class="control-group">
<label>Actions</label>
<div class="btn-group">
<button id="refresh" class="btn"><i class="fas fa-sync-alt"></i> Refresh</button>
<button id="clear" class="btn"><i class="fas fa-trash"></i> Clear</button>
<button id="snapshot" class="btn"><i class="fas fa-download"></i> Snapshot</button>
</div>
</div>
</div>
<!-- Container List -->
<div class="container-list" id="containerList">
<div class="container-item placeholder">
<div class="container-name">
<i class="fas fa-info-circle"></i>
Loading containers...
</div>
</div>
</div>
</div> </div>
</header>
<!-- Main Content -->
<div class="main-content">
<!-- Header -->
<div class="header">
<button class="mobile-toggle" id="mobileToggle">
<i class="fas fa-bars"></i>
</button>
<h2 class="header-title">Logs</h2>
<span class="header-badge" id="projectBadge">All Projects</span>
<div class="header-controls">
<div class="theme-toggle">
<span>Theme</span>
<input id="themeSwitch" type="checkbox" />
</div>
<span id="wsstate">ws: off</span>
</div>
</div>
<!-- Log Area -->
<div class="log-area">
<div class="log-header">
<h3 class="log-title" id="logTitle">Select a container to view logs</h3>
<div class="log-controls">
<span class="counter">DEBUG: <span class="cdbg">0</span></span>
<span class="counter">INFO: <span class="cinfo">0</span></span>
<span class="counter">WARN: <span class="cwarn">0</span></span>
<span class="counter">ERROR: <span class="cerr">0</span></span>
</div>
</div>
<div class="log-content">
<pre class="log" id="logContent">No container selected</pre>
</div>
</div>
</div>
</div>
<div id="tabs"></div> <!-- Legacy elements for compatibility -->
<div id="idFilters" style="padding:6px 10px; display:flex; gap:6px; flex-wrap:wrap;"></div> <div id="tabs" style="display: none;"></div>
<main id="grid" class="grid-1"></main> <div id="idFilters" style="display: none;"></div>
<main id="grid" style="display: none;"></main>
<button id="copyFab" class="copy-fab" type="button">копировать</button> <button id="copyFab" class="copy-fab" type="button">копировать</button>
<footer>© LogBoard+</footer> <footer style="display: none;">© LogBoard+</footer>
<script> <script>
const state = { const state = {
@ -128,6 +583,7 @@ const state = {
}; };
const els = { const els = {
// Legacy elements
tabs: document.getElementById('tabs'), tabs: document.getElementById('tabs'),
grid: document.getElementById('grid'), grid: document.getElementById('grid'),
tail: document.getElementById('tail'), tail: document.getElementById('tail'),
@ -150,6 +606,12 @@ const els = {
themeSwitch: document.getElementById('themeSwitch'), themeSwitch: document.getElementById('themeSwitch'),
copyFab: document.getElementById('copyFab'), copyFab: document.getElementById('copyFab'),
groupBtn: document.getElementById('groupBtn'), groupBtn: document.getElementById('groupBtn'),
// New modern elements
containerList: document.getElementById('containerList'),
logTitle: document.getElementById('logTitle'),
logContent: document.getElementById('logContent'),
mobileToggle: document.getElementById('mobileToggle'),
}; };
// ----- Theme toggle ----- // ----- Theme toggle -----
@ -284,6 +746,7 @@ function panelTemplate(svc){
} }
function buildTabs(){ function buildTabs(){
// Legacy tabs (hidden)
els.tabs.innerHTML=''; els.tabs.innerHTML='';
state.services.forEach(svc=>{ state.services.forEach(svc=>{
const b = document.createElement('button'); const b = document.createElement('button');
@ -293,6 +756,38 @@ function buildTabs(){
b.onclick = ()=> switchToSingle(svc); b.onclick = ()=> switchToSingle(svc);
els.tabs.appendChild(b); els.tabs.appendChild(b);
}); });
// Modern container list
els.containerList.innerHTML = '';
state.services.forEach(svc => {
const item = document.createElement('div');
item.className = 'container-item';
if (state.current && svc.id === state.current.id) {
item.classList.add('active');
}
item.setAttribute('data-cid', svc.id);
const statusClass = svc.status === 'running' ? 'running' :
svc.status === 'stopped' ? 'stopped' : 'paused';
item.innerHTML = `
<div class="container-name">
<i class="fas fa-cube"></i>
${escapeHtml(svc.name)}
</div>
<div class="container-service">
${escapeHtml(svc.service || svc.name)}
${svc.project ? ` • ${escapeHtml(svc.project)}` : ''}
</div>
<div class="container-status">
<span class="status-indicator ${statusClass}"></span>
${escapeHtml(svc.status)}
</div>
`;
item.onclick = () => switchToSingle(svc);
els.containerList.appendChild(item);
});
} }
function setLayout(cls){ function setLayout(cls){
@ -425,6 +920,15 @@ function openWs(svc, panel){
} }
obj.logEl.insertAdjacentHTML('beforeend', html); obj.logEl.insertAdjacentHTML('beforeend', html);
if (els.autoscroll.checked) obj.wrapEl.scrollTop = obj.wrapEl.scrollHeight; if (els.autoscroll.checked) obj.wrapEl.scrollTop = obj.wrapEl.scrollHeight;
// Update modern interface
if (state.current && state.current.id === id) {
els.logContent.innerHTML = obj.logEl.innerHTML;
const logContent = document.querySelector('.log-content');
if (els.autoscroll.checked) {
logContent.scrollTop = logContent.scrollHeight;
}
}
} }
} }
@ -450,6 +954,7 @@ function ensurePanel(svc){
} }
function switchToSingle(svc){ function switchToSingle(svc){
// Legacy functionality
setLayout('tabs'); setLayout('tabs');
els.grid.innerHTML=''; els.grid.innerHTML='';
const panel = ensurePanel(svc); const panel = ensurePanel(svc);
@ -459,6 +964,19 @@ function switchToSingle(svc){
state.current = svc; state.current = svc;
buildTabs(); buildTabs();
for (const p of [...els.grid.children]) if (p!==panel) p.remove(); for (const p of [...els.grid.children]) if (p!==panel) p.remove();
// Modern interface updates
els.logTitle.textContent = `${svc.name} (${svc.service || svc.name})`;
els.logContent.textContent = 'Connecting...';
// Update active state in container list
document.querySelectorAll('.container-item').forEach(item => {
item.classList.remove('active');
});
const activeItem = document.querySelector(`.container-item[data-cid="${svc.id}"]`);
if (activeItem) {
activeItem.classList.add('active');
}
} }
function openMulti(ids){ function openMulti(ids){
@ -585,7 +1103,18 @@ els.groupBtn.onclick = ()=>{
els.clearBtn.onclick = ()=> Object.values(state.open).forEach(o=> o.logEl.textContent=''); els.clearBtn.onclick = ()=> Object.values(state.open).forEach(o=> o.logEl.textContent='');
els.refreshBtn.onclick = fetchServices; els.refreshBtn.onclick = fetchServices;
els.projectSelect.onchange = fetchServices; els.projectSelect.onchange = fetchServices;
els.snapshotBtn.onclick = ()=>{ if (state.current) sendSnapshot(state.current.id); };
// Mobile menu toggle
els.mobileToggle.onclick = () => {
document.querySelector('.sidebar').classList.toggle('open');
};
els.snapshotBtn.onclick = ()=>{
if (state.current) {
sendSnapshot(state.current.id);
} else {
alert('No container selected');
}
};
els.tail.onchange = ()=> { els.tail.onchange = ()=> {
Object.keys(state.open).forEach(id=>{ Object.keys(state.open).forEach(id=>{
const svc = state.services.find(s=> s.id===id); const svc = state.services.find(s=> s.id===id);
@ -596,7 +1125,10 @@ els.tail.onchange = ()=> {
closeWs(id); openWs(svc, panel); closeWs(id); openWs(svc, panel);
}); });
}; };
els.wrapToggle.onchange = ()=> document.querySelectorAll('.log').forEach(el=> el.style.whiteSpace = els.wrapToggle.checked ? 'pre-wrap' : 'pre'); els.wrapToggle.onchange = ()=> {
document.querySelectorAll('.log').forEach(el=> el.style.whiteSpace = els.wrapToggle.checked ? 'pre-wrap' : 'pre');
els.logContent.style.whiteSpace = els.wrapToggle.checked ? 'pre-wrap' : 'pre';
};
els.filter.oninput = ()=> { state.filter = els.filter.value.trim(); }; els.filter.oninput = ()=> { state.filter = els.filter.value.trim(); };
els.lvlDebug.onchange = ()=> state.levels.debug = els.lvlDebug.checked; els.lvlDebug.onchange = ()=> state.levels.debug = els.lvlDebug.checked;
els.lvlInfo.onchange = ()=> state.levels.info = els.lvlInfo.checked; els.lvlInfo.onchange = ()=> state.levels.info = els.lvlInfo.checked;