Skip to content

Commit ae17605

Browse files
committed
redesign logs tab
1 parent 0db4dd1 commit ae17605

2 files changed

Lines changed: 198 additions & 27 deletions

File tree

‎robot_orchestrator/web/routes_sse.py‎

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from fastapi.responses import StreamingResponse
66
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
77

8+
from robot_orchestrator.supervisor.logsink import parse_line
89
from robot_orchestrator.web import auth
910
from robot_orchestrator.web.context import AdminContext
1011

@@ -30,26 +31,40 @@ def require_sse_auth(
3031
async def stream_logs(service: str, claims: dict = Depends(require_sse_auth)):
3132
if context is None:
3233
raise HTTPException(status_code=503, detail="orchestrator context not available")
33-
sink = context.supervisor.log_sinks.get(service)
34-
if sink is None:
35-
raise HTTPException(status_code=404, detail=f"unknown service {service!r}")
34+
35+
if service == "all":
36+
sinks = dict(context.supervisor.log_sinks)
37+
else:
38+
sink = context.supervisor.log_sinks.get(service)
39+
if sink is None:
40+
raise HTTPException(status_code=404, detail=f"unknown service {service!r}")
41+
sinks = {service: sink}
3642

3743
queue: asyncio.Queue = asyncio.Queue()
3844
loop = asyncio.get_running_loop()
45+
subscriptions = []
3946

40-
def on_line(line: str) -> None:
41-
loop.call_soon_threadsafe(queue.put_nowait, line)
42-
43-
sink.subscribe(on_line)
47+
for sink_name, sink in sinks.items():
48+
def on_line(line: str, sink_name=sink_name) -> None:
49+
loop.call_soon_threadsafe(queue.put_nowait, (sink_name, line))
50+
sink.subscribe(on_line)
51+
subscriptions.append((sink, on_line))
4452

4553
async def event_stream():
4654
try:
47-
for line in sink.tail(50):
48-
yield f"data: {json.dumps(line)}\n\n"
55+
initial = [
56+
parse_line(sink_name, raw)
57+
for sink_name, sink in sinks.items()
58+
for raw in sink.tail(50)
59+
]
60+
initial.sort(key=lambda item: item["ts"])
61+
for item in initial:
62+
yield f"data: {json.dumps(item)}\n\n"
4963
while True:
50-
line = await queue.get()
51-
yield f"data: {json.dumps(line)}\n\n"
64+
sink_name, raw = await queue.get()
65+
yield f"data: {json.dumps(parse_line(sink_name, raw))}\n\n"
5266
finally:
53-
sink.unsubscribe(on_line)
67+
for sink, on_line in subscriptions:
68+
sink.unsubscribe(on_line)
5469

5570
return StreamingResponse(event_stream(), media_type="text/event-stream")

‎robot_orchestrator/web/static/admin/index.html‎

Lines changed: 171 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,36 @@
6161
background: #3a2f10; color: #f2c94c; border: 1px solid #5e4d1e; border-radius: 8px; padding: 10px 14px;
6262
font-size: 13px; margin-bottom: 16px;
6363
}
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+
}
6494
</style>
6595
</head>
6696
<body>
@@ -130,12 +160,15 @@ <h1>robot-orchestrator</h1>
130160
</section>
131161

132162
<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>
137171
</div>
138-
<pre id="log-output">—</pre>
139172
</div>
140173
</section>
141174

@@ -225,6 +258,8 @@ <h3 style="margin-top:0;">Самообновление оркестратора<
225258
}
226259

227260
function selectTab(id) {
261+
const previous = document.querySelector("nav button.active")?.dataset.tab;
262+
if (previous === "logs" && id !== "logs") stopLogs();
228263
for (const el of document.querySelectorAll("section")) el.classList.remove("active");
229264
for (const el of document.querySelectorAll("nav button")) el.classList.remove("active");
230265
document.getElementById("sec-" + id).classList.add("active");
@@ -275,6 +310,7 @@ <h3 style="margin-top:0;">Самообновление оркестратора<
275310
}
276311

277312
function logout() {
313+
stopLogs();
278314
clearToken();
279315
showLogin();
280316
}
@@ -381,19 +417,139 @@ <h3 style="margin-top:0;">Самообновление оркестратора<
381417
loadFirmware();
382418
}
383419

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+
384526
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 {
387542
const res = await api("/api/services");
388543
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+
};
397553
}
398554

399555
async function loadSecrets() {

0 commit comments

Comments
 (0)