|
61 | 61 | background: #3a2f10; color: #f2c94c; border: 1px solid #5e4d1e; border-radius: 8px; padding: 10px 14px; |
62 | 62 | font-size: 13px; margin-bottom: 16px; |
63 | 63 | } |
| 64 | + .log-card { padding: 0; overflow: hidden; } |
| 65 | + .log-toolbar { |
| 66 | + display: flex; align-items: center; justify-content: space-between; gap: 10px; |
| 67 | + padding: 10px 14px; border-bottom: 1px solid #21262d; flex-wrap: wrap; |
| 68 | + } |
| 69 | + .log-filters { display: flex; gap: 6px; flex-wrap: wrap; } |
| 70 | + .log-filter-chip { |
| 71 | + font-size: 12px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--chip-color, #30363d); |
| 72 | + background: transparent; color: var(--chip-color, #8b949e); cursor: pointer; font-weight: 600; |
| 73 | + } |
| 74 | + .log-filter-chip.muted { opacity: 0.35; } |
| 75 | + .log-scroll-wrap { position: relative; } |
| 76 | + .log-output { |
| 77 | + height: 60vh; overflow-y: auto; background: #0d1117; padding: 8px 14px; |
| 78 | + font: 12.5px/1.55 "JetBrains Mono", Consolas, monospace; |
| 79 | + } |
| 80 | + .log-line { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; padding: 1px 0; } |
| 81 | + .log-line .lg-time { color: #586069; flex-shrink: 0; } |
| 82 | + .log-line .lg-svc { |
| 83 | + flex-shrink: 0; font-weight: 700; padding: 0 6px; border-radius: 4px; font-size: 11px; |
| 84 | + align-self: flex-start; line-height: 1.6; |
| 85 | + } |
| 86 | + .log-line .lg-text { flex: 1; color: #c9d1d9; } |
| 87 | + .log-line.lvl-error .lg-text { color: #f28b8b; } |
| 88 | + .log-line.lvl-warn .lg-text { color: #f2c94c; } |
| 89 | + .log-jump { |
| 90 | + position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); |
| 91 | + background: #1f6feb; color: white; border: none; border-radius: 999px; padding: 6px 16px; |
| 92 | + font-size: 12px; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,.5); |
| 93 | + } |
64 | 94 | </style> |
65 | 95 | </head> |
66 | 96 | <body> |
@@ -130,12 +160,15 @@ <h1>robot-orchestrator</h1> |
130 | 160 | </section> |
131 | 161 |
|
132 | 162 | <section id="sec-logs"> |
133 | | - <div class="card"> |
134 | | - <div class="field"> |
135 | | - <label>Сервис</label> |
136 | | - <select id="log-service-select" onchange="loadLogs()"></select> |
| 163 | + <div class="card log-card"> |
| 164 | + <div class="log-toolbar"> |
| 165 | + <div id="log-filters" class="log-filters"></div> |
| 166 | + <button class="action secondary" onclick="clearLogView()">Очистить вид</button> |
| 167 | + </div> |
| 168 | + <div class="log-scroll-wrap"> |
| 169 | + <div id="log-output" class="log-output"></div> |
| 170 | + <button id="btn-log-jump" class="log-jump" style="display:none;" onclick="logJumpToBottom()">↓ Новые записи</button> |
137 | 171 | </div> |
138 | | - <pre id="log-output">—</pre> |
139 | 172 | </div> |
140 | 173 | </section> |
141 | 174 |
|
@@ -225,6 +258,8 @@ <h3 style="margin-top:0;">Самообновление оркестратора< |
225 | 258 | } |
226 | 259 |
|
227 | 260 | function selectTab(id) { |
| 261 | + const previous = document.querySelector("nav button.active")?.dataset.tab; |
| 262 | + if (previous === "logs" && id !== "logs") stopLogs(); |
228 | 263 | for (const el of document.querySelectorAll("section")) el.classList.remove("active"); |
229 | 264 | for (const el of document.querySelectorAll("nav button")) el.classList.remove("active"); |
230 | 265 | document.getElementById("sec-" + id).classList.add("active"); |
@@ -275,6 +310,7 @@ <h3 style="margin-top:0;">Самообновление оркестратора< |
275 | 310 | } |
276 | 311 |
|
277 | 312 | function logout() { |
| 313 | + stopLogs(); |
278 | 314 | clearToken(); |
279 | 315 | showLogin(); |
280 | 316 | } |
@@ -381,19 +417,139 @@ <h3 style="margin-top:0;">Самообновление оркестратора< |
381 | 417 | loadFirmware(); |
382 | 418 | } |
383 | 419 |
|
| 420 | +const LOG_MAX_LINES = 2000; |
| 421 | +const LOG_COLOR_PALETTE = ["#58a6ff", "#3fb950", "#d29922", "#bc8cff", "#f778ba", "#39c5cf", "#ff7b72"]; |
| 422 | +let logEventSource = null; |
| 423 | +let logAutoScroll = true; |
| 424 | +const logServiceColors = {}; |
| 425 | +const logHiddenServices = new Set(); |
| 426 | + |
| 427 | +function logColorFor(service) { |
| 428 | + if (!logServiceColors[service]) { |
| 429 | + const idx = Object.keys(logServiceColors).length % LOG_COLOR_PALETTE.length; |
| 430 | + logServiceColors[service] = LOG_COLOR_PALETTE[idx]; |
| 431 | + } |
| 432 | + return logServiceColors[service]; |
| 433 | +} |
| 434 | + |
| 435 | +function ensureLogFilterChip(service) { |
| 436 | + const container = document.getElementById("log-filters"); |
| 437 | + if (container.querySelector(`[data-service="${service}"]`)) return; |
| 438 | + const chip = document.createElement("button"); |
| 439 | + chip.className = "log-filter-chip"; |
| 440 | + chip.dataset.service = service; |
| 441 | + chip.textContent = service; |
| 442 | + chip.style.setProperty("--chip-color", logColorFor(service)); |
| 443 | + chip.onclick = () => toggleLogFilter(service, chip); |
| 444 | + container.appendChild(chip); |
| 445 | +} |
| 446 | + |
| 447 | +function toggleLogFilter(service, chip) { |
| 448 | + if (logHiddenServices.has(service)) { |
| 449 | + logHiddenServices.delete(service); |
| 450 | + chip.classList.remove("muted"); |
| 451 | + } else { |
| 452 | + logHiddenServices.add(service); |
| 453 | + chip.classList.add("muted"); |
| 454 | + } |
| 455 | + const output = document.getElementById("log-output"); |
| 456 | + for (const el of output.children) { |
| 457 | + el.style.display = logHiddenServices.has(el.dataset.service) ? "none" : "flex"; |
| 458 | + } |
| 459 | +} |
| 460 | + |
| 461 | +function logLevelClass(text) { |
| 462 | + const t = text.toLowerCase(); |
| 463 | + if (t.includes("traceback") || t.includes("segmentation fault") || t.includes("critical") |
| 464 | + || /\berr\b|\berror\b/.test(t)) return "lvl-error"; |
| 465 | + if (/\bwarn/.test(t)) return "lvl-warn"; |
| 466 | + return ""; |
| 467 | +} |
| 468 | + |
| 469 | +function isLogAtBottom() { |
| 470 | + const el = document.getElementById("log-output"); |
| 471 | + return el.scrollHeight - el.scrollTop - el.clientHeight < 40; |
| 472 | +} |
| 473 | + |
| 474 | +function scrollLogToBottom() { |
| 475 | + const el = document.getElementById("log-output"); |
| 476 | + el.scrollTop = el.scrollHeight; |
| 477 | + document.getElementById("btn-log-jump").style.display = "none"; |
| 478 | +} |
| 479 | + |
| 480 | +function logJumpToBottom() { |
| 481 | + logAutoScroll = true; |
| 482 | + scrollLogToBottom(); |
| 483 | +} |
| 484 | + |
| 485 | +function clearLogView() { |
| 486 | + document.getElementById("log-output").innerHTML = ""; |
| 487 | +} |
| 488 | + |
| 489 | +function appendLogLine(item) { |
| 490 | + ensureLogFilterChip(item.service); |
| 491 | + const output = document.getElementById("log-output"); |
| 492 | + const div = document.createElement("div"); |
| 493 | + div.className = "log-line " + logLevelClass(item.text); |
| 494 | + div.dataset.service = item.service; |
| 495 | + if (logHiddenServices.has(item.service)) div.style.display = "none"; |
| 496 | + const color = logColorFor(item.service); |
| 497 | + const time = item.ts ? new Date(item.ts * 1000).toLocaleTimeString() : ""; |
| 498 | + const timeEl = document.createElement("span"); |
| 499 | + timeEl.className = "lg-time"; |
| 500 | + timeEl.textContent = time; |
| 501 | + const svcEl = document.createElement("span"); |
| 502 | + svcEl.className = "lg-svc"; |
| 503 | + svcEl.textContent = item.service; |
| 504 | + svcEl.style.background = color + "33"; |
| 505 | + svcEl.style.color = color; |
| 506 | + const textEl = document.createElement("span"); |
| 507 | + textEl.className = "lg-text"; |
| 508 | + textEl.textContent = item.text; |
| 509 | + div.append(timeEl, svcEl, textEl); |
| 510 | + output.appendChild(div); |
| 511 | + while (output.children.length > LOG_MAX_LINES) output.removeChild(output.firstChild); |
| 512 | + if (logAutoScroll) { |
| 513 | + scrollLogToBottom(); |
| 514 | + } else { |
| 515 | + document.getElementById("btn-log-jump").style.display = "block"; |
| 516 | + } |
| 517 | +} |
| 518 | + |
| 519 | +function stopLogs() { |
| 520 | + if (logEventSource) { |
| 521 | + logEventSource.close(); |
| 522 | + logEventSource = null; |
| 523 | + } |
| 524 | +} |
| 525 | + |
384 | 526 | async function loadLogs() { |
385 | | - const select = document.getElementById("log-service-select"); |
386 | | - if (!select.dataset.filled) { |
| 527 | + if (logEventSource) return; |
| 528 | + document.getElementById("log-output").innerHTML = ""; |
| 529 | + document.getElementById("log-filters").innerHTML = ""; |
| 530 | + logHiddenServices.clear(); |
| 531 | + Object.keys(logServiceColors).forEach((k) => delete logServiceColors[k]); |
| 532 | + logAutoScroll = true; |
| 533 | + const outputEl = document.getElementById("log-output"); |
| 534 | + if (!outputEl.dataset.scrollBound) { |
| 535 | + outputEl.dataset.scrollBound = "1"; |
| 536 | + outputEl.addEventListener("scroll", () => { |
| 537 | + logAutoScroll = isLogAtBottom(); |
| 538 | + if (logAutoScroll) document.getElementById("btn-log-jump").style.display = "none"; |
| 539 | + }); |
| 540 | + } |
| 541 | + try { |
387 | 542 | const res = await api("/api/services"); |
388 | 543 | const services = await res.json(); |
389 | | - select.innerHTML = services.map((s) => `<option value="${s.name}">${s.name}</option>`).join(""); |
390 | | - select.dataset.filled = "1"; |
391 | | - } |
392 | | - const name = select.value; |
393 | | - if (!name) return; |
394 | | - const res = await api(`/api/logs/${name}?lines=300`); |
395 | | - const body = await res.json(); |
396 | | - document.getElementById("log-output").textContent = body.lines.join("\n") || "(пусто)"; |
| 544 | + services.forEach((s) => ensureLogFilterChip(s.name)); |
| 545 | + } catch (e) { /* filters will still populate from incoming lines */ } |
| 546 | + const url = `/api/logs/all/stream?token=${encodeURIComponent(token())}`; |
| 547 | + logEventSource = new EventSource(url); |
| 548 | + logEventSource.onmessage = (ev) => { |
| 549 | + try { |
| 550 | + appendLogLine(JSON.parse(ev.data)); |
| 551 | + } catch (e) { /* ignore malformed event */ } |
| 552 | + }; |
397 | 553 | } |
398 | 554 |
|
399 | 555 | async function loadSecrets() { |
|
0 commit comments