feat: in-app live log (SSE activity feed)
Turn the inert "Show Work" thinking panel into a real live activity log: - lyra/logbus.py: thread-safe in-memory ring buffer other modules publish to - chat.respond logs backend/model/embed per turn, recall counts, reply size; web layer logs chat errors - server: replace the keep-alive /stream/thinking stub with /stream/logs, an SSE endpoint that replays the recent buffer then streams new events - UI: repurpose the panel as a global "Live Log" — connects on load, renders level/time/msg/fields, drops the old per-session localStorage + dead popup Every turn now shows its backend + model in-app, so local-vs-cloud (free vs paid) is visible at a glance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+10
-1
@@ -6,7 +6,7 @@ session, then asks the model for a reply and persists both sides.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from lyra import llm, memory, persona
|
from lyra import config, llm, logbus, memory, persona
|
||||||
from lyra.llm import Backend, Message
|
from lyra.llm import Backend, Message
|
||||||
|
|
||||||
RECALL_K = 5
|
RECALL_K = 5
|
||||||
@@ -34,6 +34,7 @@ def build_messages(session_id: str, user_msg: str) -> list[Message]:
|
|||||||
recalled = [
|
recalled = [
|
||||||
ex for ex in memory.recall(user_msg, k=RECALL_K) if ex.id not in recent_ids
|
ex for ex in memory.recall(user_msg, k=RECALL_K) if ex.id not in recent_ids
|
||||||
]
|
]
|
||||||
|
logbus.log("debug", "context built", recent=len(recent), recalled=len(recalled))
|
||||||
if recalled:
|
if recalled:
|
||||||
messages.append(_memory_note(recalled))
|
messages.append(_memory_note(recalled))
|
||||||
|
|
||||||
@@ -46,8 +47,16 @@ def build_messages(session_id: str, user_msg: str) -> list[Message]:
|
|||||||
|
|
||||||
def respond(session_id: str, user_msg: str, backend: Backend = "cloud") -> str:
|
def respond(session_id: str, user_msg: str, backend: Backend = "cloud") -> str:
|
||||||
"""Produce Lyra's reply to a single user message and persist the exchange."""
|
"""Produce Lyra's reply to a single user message and persist the exchange."""
|
||||||
|
cfg = config.load()
|
||||||
|
model = cfg.local_model if backend == "local" else cfg.cloud_model
|
||||||
|
logbus.log(
|
||||||
|
"info", "chat request", session=session_id, backend=backend,
|
||||||
|
model=model, embed=cfg.embed_backend,
|
||||||
|
)
|
||||||
|
|
||||||
messages = build_messages(session_id, user_msg)
|
messages = build_messages(session_id, user_msg)
|
||||||
reply = llm.complete(messages, backend=backend)
|
reply = llm.complete(messages, backend=backend)
|
||||||
|
logbus.log("info", "reply", session=session_id, chars=len(reply))
|
||||||
|
|
||||||
memory.remember(session_id, "user", user_msg)
|
memory.remember(session_id, "user", user_msg)
|
||||||
memory.remember(session_id, "assistant", reply)
|
memory.remember(session_id, "assistant", reply)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""In-memory live log bus.
|
||||||
|
|
||||||
|
A thread-safe ring buffer that any part of Lyra can publish to and the web
|
||||||
|
server streams to the browser over SSE. Deliberately process-local and
|
||||||
|
ephemeral — it's an activity feed, not durable logging.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
_EVENTS: deque[dict] = deque(maxlen=500)
|
||||||
|
_SEQ = 0
|
||||||
|
|
||||||
|
|
||||||
|
def log(level: str, msg: str, **fields) -> None:
|
||||||
|
"""Publish an event. `level` is info/debug/error/system; fields are extras."""
|
||||||
|
global _SEQ
|
||||||
|
with _LOCK:
|
||||||
|
_SEQ += 1
|
||||||
|
_EVENTS.append(
|
||||||
|
{"seq": _SEQ, "ts": time.time(), "level": level, "msg": msg, "fields": fields}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def since(seq: int) -> list[dict]:
|
||||||
|
"""All buffered events with seq greater than `seq` (for SSE catch-up/polling)."""
|
||||||
|
with _LOCK:
|
||||||
|
return [e for e in _EVENTS if e["seq"] > seq]
|
||||||
+29
-8
@@ -10,15 +10,21 @@ re-stored).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from lyra import chat, memory
|
from lyra import chat, logbus, memory
|
||||||
from lyra.llm import Backend
|
from lyra.llm import Backend
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(event: dict) -> str:
|
||||||
|
return f"data: {json.dumps(event)}\n\n"
|
||||||
|
|
||||||
_STATIC = Path(__file__).parent / "static"
|
_STATIC = Path(__file__).parent / "static"
|
||||||
|
|
||||||
# UI backend labels -> our two backends. Cloud is the default.
|
# UI backend labels -> our two backends. Cloud is the default.
|
||||||
@@ -79,7 +85,11 @@ def create_app() -> FastAPI:
|
|||||||
user_msg = _last_user_message(body.get("messages", []))
|
user_msg = _last_user_message(body.get("messages", []))
|
||||||
|
|
||||||
memory.ensure_session(session_id)
|
memory.ensure_session(session_id)
|
||||||
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend)
|
try:
|
||||||
|
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend)
|
||||||
|
except Exception as exc:
|
||||||
|
logbus.log("error", "chat failed", session=session_id, error=str(exc))
|
||||||
|
reply = f"[error] {exc}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
@@ -92,14 +102,25 @@ def create_app() -> FastAPI:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/stream/thinking/{session_id}")
|
@app.get("/stream/logs")
|
||||||
async def thinking_stream(session_id: str) -> StreamingResponse:
|
async def stream_logs(request: Request) -> StreamingResponse:
|
||||||
# Inert until cognitive layers exist: open the stream, emit keep-alives only.
|
"""Live activity feed: replay the recent buffer, then stream new events."""
|
||||||
async def gen():
|
async def gen():
|
||||||
yield ": connected\n\n"
|
backlog = logbus.since(0)
|
||||||
|
last = backlog[-1]["seq"] if backlog else 0
|
||||||
|
for e in backlog:
|
||||||
|
yield _sse(e)
|
||||||
|
yield _sse(
|
||||||
|
{"seq": last, "ts": time.time(), "level": "system",
|
||||||
|
"msg": "live log connected", "fields": {}}
|
||||||
|
)
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(25)
|
if await request.is_disconnected():
|
||||||
yield ": keep-alive\n\n"
|
break
|
||||||
|
for e in logbus.since(last):
|
||||||
|
last = e["seq"]
|
||||||
|
yield _sse(e)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|||||||
+39
-151
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
<div class="mobile-menu-section">
|
<div class="mobile-menu-section">
|
||||||
<h4>Actions</h4>
|
<h4>Actions</h4>
|
||||||
<button id="mobileThinkingStreamBtn">🧠 Show Work</button>
|
<button id="mobileThinkingStreamBtn">📜 Live Log</button>
|
||||||
<button id="mobileSettingsBtn">⚙ Settings</button>
|
<button id="mobileSettingsBtn">⚙ Settings</button>
|
||||||
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
|
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
|
||||||
<button id="mobileForceReloadBtn">🔄 Force Reload</button>
|
<button id="mobileForceReloadBtn">🔄 Force Reload</button>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
<select id="sessions"></select>
|
<select id="sessions"></select>
|
||||||
<button id="newSessionBtn">➕ New</button>
|
<button id="newSessionBtn">➕ New</button>
|
||||||
<button id="renameSessionBtn">✏️ Rename</button>
|
<button id="renameSessionBtn">✏️ Rename</button>
|
||||||
<button id="thinkingStreamBtn" title="Show thinking stream panel">🧠 Show Work</button>
|
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
@@ -80,10 +80,10 @@
|
|||||||
<!-- Chat messages -->
|
<!-- Chat messages -->
|
||||||
<div id="messages"></div>
|
<div id="messages"></div>
|
||||||
|
|
||||||
<!-- Thinking Stream Panel (collapsible) -->
|
<!-- Live Log Panel (collapsible) -->
|
||||||
<div id="thinkingPanel" class="thinking-panel collapsed">
|
<div id="thinkingPanel" class="thinking-panel collapsed">
|
||||||
<div class="thinking-header" id="thinkingHeader">
|
<div class="thinking-header" id="thinkingHeader">
|
||||||
<span>🧠 Thinking Stream</span>
|
<span>📜 Live Log</span>
|
||||||
<div class="thinking-controls">
|
<div class="thinking-controls">
|
||||||
<span class="thinking-status-dot" id="thinkingStatusDot"></span>
|
<span class="thinking-status-dot" id="thinkingStatusDot"></span>
|
||||||
<button class="thinking-clear-btn" id="thinkingClearBtn" title="Clear events">🗑️</button>
|
<button class="thinking-clear-btn" id="thinkingClearBtn" title="Clear events">🗑️</button>
|
||||||
@@ -92,8 +92,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="thinking-content" id="thinkingContent">
|
<div class="thinking-content" id="thinkingContent">
|
||||||
<div class="thinking-empty" id="thinkingEmpty">
|
<div class="thinking-empty" id="thinkingEmpty">
|
||||||
<div class="thinking-empty-icon">🤔</div>
|
<div class="thinking-empty-icon">📡</div>
|
||||||
<p>Waiting for thinking events...</p>
|
<p>Waiting for activity...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -509,22 +509,6 @@
|
|||||||
addMessage("system", `Session renamed to: ${newName}`);
|
addMessage("system", `Session renamed to: ${newName}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Thinking Stream button
|
|
||||||
document.getElementById("thinkingStreamBtn").addEventListener("click", () => {
|
|
||||||
if (!currentSession) {
|
|
||||||
alert("Please select a session first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open thinking stream in new window
|
|
||||||
const streamUrl = `http://10.0.0.41:8081/thinking-stream.html?session=${currentSession}`;
|
|
||||||
const windowFeatures = "width=600,height=800,menubar=no,toolbar=no,location=no,status=no";
|
|
||||||
window.open(streamUrl, `thinking_${currentSession}`, windowFeatures);
|
|
||||||
|
|
||||||
addMessage("system", "🧠 Opened thinking stream in new window");
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Settings Modal
|
// Settings Modal
|
||||||
const settingsModal = document.getElementById("settingsModal");
|
const settingsModal = document.getElementById("settingsModal");
|
||||||
const settingsBtn = document.getElementById("settingsBtn");
|
const settingsBtn = document.getElementById("settingsBtn");
|
||||||
@@ -705,113 +689,63 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connectThinkingStream() {
|
function connectThinkingStream() {
|
||||||
if (!currentSession) return;
|
|
||||||
|
|
||||||
// Close existing connection
|
// Close existing connection
|
||||||
if (thinkingEventSource) {
|
if (thinkingEventSource) {
|
||||||
thinkingEventSource.close();
|
thinkingEventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load persisted events
|
// The server replays its recent buffer on connect, so start from a clean panel.
|
||||||
loadThinkingEvents();
|
thinkingContent.innerHTML = '';
|
||||||
|
thinkingEventCount = 0;
|
||||||
const url = `${CORTEX_BASE}/stream/thinking/${currentSession}`;
|
thinkingContent.appendChild(thinkingEmpty);
|
||||||
console.log('Connecting thinking stream:', url);
|
|
||||||
|
|
||||||
|
const url = `${RELAY_BASE}/stream/logs`; // global server activity feed
|
||||||
thinkingEventSource = new EventSource(url);
|
thinkingEventSource = new EventSource(url);
|
||||||
|
|
||||||
thinkingEventSource.onopen = () => {
|
thinkingEventSource.onopen = () => {
|
||||||
console.log('Thinking stream connected');
|
|
||||||
thinkingStatusDot.className = 'thinking-status-dot connected';
|
thinkingStatusDot.className = 'thinking-status-dot connected';
|
||||||
};
|
};
|
||||||
|
|
||||||
thinkingEventSource.onmessage = (event) => {
|
thinkingEventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
addLogEvent(JSON.parse(event.data));
|
||||||
addThinkingEvent(data);
|
|
||||||
saveThinkingEvent(data); // Persist event
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse thinking event:', e);
|
console.error('Failed to parse log event:', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
thinkingEventSource.onerror = (error) => {
|
thinkingEventSource.onerror = () => {
|
||||||
console.error('Thinking stream error:', error);
|
|
||||||
thinkingStatusDot.className = 'thinking-status-dot disconnected';
|
thinkingStatusDot.className = 'thinking-status-dot disconnected';
|
||||||
|
// EventSource auto-reconnects; nothing to do here.
|
||||||
// Retry connection after 2 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
if (thinkingEventSource && thinkingEventSource.readyState === EventSource.CLOSED) {
|
|
||||||
console.log('Reconnecting thinking stream...');
|
|
||||||
connectThinkingStream();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addThinkingEvent(event) {
|
function escapeHtml(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s == null ? '' : String(s);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLogEvent(event) {
|
||||||
// Remove empty state if present
|
// Remove empty state if present
|
||||||
if (thinkingEventCount === 0 && thinkingEmpty.parentNode) {
|
if (thinkingEventCount === 0 && thinkingEmpty.parentNode) {
|
||||||
thinkingContent.removeChild(thinkingEmpty);
|
thinkingContent.removeChild(thinkingEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const level = event.level || 'info';
|
||||||
|
const time = new Date((event.ts || 0) * 1000).toLocaleTimeString();
|
||||||
|
const fields = event.fields || {};
|
||||||
|
const fieldStr = Object.keys(fields).length
|
||||||
|
? Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(' ')
|
||||||
|
: '';
|
||||||
|
|
||||||
const eventDiv = document.createElement('div');
|
const eventDiv = document.createElement('div');
|
||||||
eventDiv.className = `thinking-event thinking-event-${event.type}`;
|
eventDiv.className = `log-line log-${level}`;
|
||||||
|
|
||||||
let icon = '';
|
|
||||||
let message = '';
|
|
||||||
let details = '';
|
|
||||||
|
|
||||||
switch (event.type) {
|
|
||||||
case 'connected':
|
|
||||||
icon = '✓';
|
|
||||||
message = 'Stream connected';
|
|
||||||
details = `Session: ${event.session_id}`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'thinking':
|
|
||||||
icon = '🤔';
|
|
||||||
message = event.data.message;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'tool_call':
|
|
||||||
icon = '🔧';
|
|
||||||
message = event.data.message;
|
|
||||||
if (event.data.args) {
|
|
||||||
details = JSON.stringify(event.data.args, null, 2);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'tool_result':
|
|
||||||
icon = '📊';
|
|
||||||
message = event.data.message;
|
|
||||||
if (event.data.result && event.data.result.stdout) {
|
|
||||||
details = `stdout: ${event.data.result.stdout}`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'done':
|
|
||||||
icon = '✅';
|
|
||||||
message = event.data.message;
|
|
||||||
if (event.data.final_answer) {
|
|
||||||
details = event.data.final_answer;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'error':
|
|
||||||
icon = '❌';
|
|
||||||
message = event.data.message;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
icon = '•';
|
|
||||||
message = JSON.stringify(event.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
eventDiv.innerHTML = `
|
eventDiv.innerHTML = `
|
||||||
<span class="thinking-event-icon">${icon}</span>
|
<span class="log-time">${escapeHtml(time)}</span>
|
||||||
<span>${message}</span>
|
<span class="log-level log-level-${level}">${escapeHtml(level)}</span>
|
||||||
${details ? `<div class="thinking-event-details">${details}</div>` : ''}
|
<span class="log-msg">${escapeHtml(event.msg || '')}</span>
|
||||||
|
${fieldStr ? `<span class="log-fields">${escapeHtml(fieldStr)}</span>` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
thinkingContent.appendChild(eventDiv);
|
thinkingContent.appendChild(eventDiv);
|
||||||
@@ -819,47 +753,9 @@
|
|||||||
thinkingEventCount++;
|
thinkingEventCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist thinking events to localStorage
|
// (Log events are server-side and replayed on connect; no localStorage needed.)
|
||||||
function saveThinkingEvent(event) {
|
|
||||||
if (!currentSession) return;
|
|
||||||
|
|
||||||
const key = `thinkingEvents_${currentSession}`;
|
// Live Log toggle button
|
||||||
let events = JSON.parse(localStorage.getItem(key) || '[]');
|
|
||||||
|
|
||||||
// Keep only last 50 events to avoid bloating localStorage
|
|
||||||
if (events.length >= 50) {
|
|
||||||
events = events.slice(-49);
|
|
||||||
}
|
|
||||||
|
|
||||||
events.push({
|
|
||||||
...event,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
localStorage.setItem(key, JSON.stringify(events));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load persisted thinking events
|
|
||||||
function loadThinkingEvents() {
|
|
||||||
if (!currentSession) return;
|
|
||||||
|
|
||||||
const key = `thinkingEvents_${currentSession}`;
|
|
||||||
const events = JSON.parse(localStorage.getItem(key) || '[]');
|
|
||||||
|
|
||||||
// Clear current display
|
|
||||||
thinkingContent.innerHTML = '';
|
|
||||||
thinkingEventCount = 0;
|
|
||||||
|
|
||||||
// Replay events
|
|
||||||
events.forEach(event => addThinkingEvent(event));
|
|
||||||
|
|
||||||
// Show empty state if no events
|
|
||||||
if (events.length === 0) {
|
|
||||||
thinkingContent.appendChild(thinkingEmpty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the old thinking stream button to toggle panel instead
|
|
||||||
document.getElementById("thinkingStreamBtn").addEventListener("click", () => {
|
document.getElementById("thinkingStreamBtn").addEventListener("click", () => {
|
||||||
thinkingPanel.classList.remove("collapsed");
|
thinkingPanel.classList.remove("collapsed");
|
||||||
localStorage.setItem("thinkingPanelCollapsed", "false");
|
localStorage.setItem("thinkingPanelCollapsed", "false");
|
||||||
@@ -872,18 +768,10 @@
|
|||||||
localStorage.setItem("thinkingPanelCollapsed", "false");
|
localStorage.setItem("thinkingPanelCollapsed", "false");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connect thinking stream when session loads
|
// Connect to the global live log on page load.
|
||||||
if (currentSession) {
|
connectThinkingStream();
|
||||||
connectThinkingStream();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconnect thinking stream when session changes
|
// The live log is global (server-wide), so it does not reconnect on session change.
|
||||||
const originalSessionChange = document.getElementById("sessions").onchange;
|
|
||||||
document.getElementById("sessions").addEventListener("change", () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
connectThinkingStream();
|
|
||||||
}, 500); // Wait for session to load
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup on page unload
|
// Cleanup on page unload
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
|
|||||||
@@ -907,3 +907,37 @@ select:hover {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Live Log lines ---- */
|
||||||
|
.log-line {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
border-left: 3px solid var(--text-fade);
|
||||||
|
animation: thinkingSlideIn 0.25s ease-out;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.log-time { color: var(--text-fade); flex-shrink: 0; }
|
||||||
|
.log-level {
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.log-msg { color: var(--text); }
|
||||||
|
.log-fields { color: var(--text-fade); width: 100%; padding-left: 4px; }
|
||||||
|
|
||||||
|
.log-info { border-left-color: #00bfff; }
|
||||||
|
.log-info .log-level { color: #7dd3fc; }
|
||||||
|
.log-debug { border-left-color: #8a2be2; }
|
||||||
|
.log-debug .log-level { color: #c79cff; }
|
||||||
|
.log-error { border-left-color: #ff3333; background: rgba(255,51,51,0.08); }
|
||||||
|
.log-error .log-level, .log-error .log-msg { color: #fca5a5; }
|
||||||
|
.log-system { border-left-color: #00ff66; }
|
||||||
|
.log-system .log-level { color: #00ff66; }
|
||||||
|
|||||||
Reference in New Issue
Block a user