Live log view over WebSocket, full log, scroll pinned to bottom

This commit is contained in:
2026-09-23 17:31:13 +00:00
parent 2c4baf693c
commit 603884c5a2
3 changed files with 64 additions and 52 deletions
-11
View File
@@ -436,14 +436,3 @@ export async function pickFolderAndAddRoot(): Promise<string | null> {
const folder: string | null = await api.pick_folder() const folder: string | null = await api.pick_folder()
return folder return folder
} }
/**
* Fetch the tail of the application log.
*/
export async function getLog(): Promise<string> {
const response = await fetch("/api/log")
if (!response.ok) {
throw new Error(`Failed to load log: ${response.statusText}`)
}
return response.text()
}
+37 -30
View File
@@ -306,9 +306,8 @@
<div class="diag-log-header"> <div class="diag-log-header">
<span class="diag-label">Application log</span> <span class="diag-label">Application log</span>
<button class="diag-refresh-btn" @click="refreshLog">Refresh</button>
</div> </div>
<pre class="diag-log">{{ appLog }}</pre> <pre ref="logEl" class="diag-log" @scroll="onLogScroll">{{ appLog }}</pre>
</section> </section>
</div> </div>
</div> </div>
@@ -317,11 +316,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, onMounted, onUnmounted } from "vue" import { ref, watch, computed, onMounted, onUnmounted, nextTick } from "vue"
import { useRouter, useRoute } from "vue-router" import { useRouter, useRoute } from "vue-router"
import { navAttrs } from "../composables/useKeyboardNavigation" import { navAttrs } from "../composables/useKeyboardNavigation"
import logoUrl from "../assets/mediahive.webp" import logoUrl from "../assets/mediahive.webp"
import { replaceRoots, pickFolderAndAddRoot, fetchPlayers, getLog } from "../api" import { replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api"
import type { PlayerInfo } from "../api" import type { PlayerInfo } from "../api"
import HexKeyboard from "./HexKeyboard.vue" import HexKeyboard from "./HexKeyboard.vue"
import { import {
@@ -425,15 +424,38 @@ async function refreshPlayers() {
} }
const appLog = ref("") const appLog = ref("")
let logFetched = false const logEl = ref<HTMLElement | null>(null)
let logSocket: WebSocket | null = null
let pinnedToBottom = true
async function refreshLog() { function onLogScroll() {
try { const el = logEl.value
appLog.value = await getLog() if (!el) return
} catch (e) { pinnedToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
console.error("Failed to fetch log:", e) }
appLog.value = "Failed to load log."
function connectLogSocket() {
if (logSocket) return
const proto = location.protocol === "https:" ? "wss" : "ws"
const ws = new WebSocket(`${proto}://${location.host}/api/log/ws`)
logSocket = ws
pinnedToBottom = true
ws.onmessage = async (ev) => {
appLog.value = String(ev.data)
await nextTick()
const el = logEl.value
if (el && pinnedToBottom) el.scrollTop = el.scrollHeight
} }
ws.onclose = () => {
if (logSocket === ws) logSocket = null
if (showSettings.value) setTimeout(connectLogSocket, 3000)
}
}
function disconnectLogSocket() {
const ws = logSocket
logSocket = null
ws?.close()
} }
async function removeRoot(rootId: string) { async function removeRoot(rootId: string) {
@@ -465,10 +487,9 @@ async function addRoot() {
watch(showSettings, (visible) => { watch(showSettings, (visible) => {
if (visible) { if (visible) {
void refreshPlayers() void refreshPlayers()
if (!logFetched) { connectLogSocket()
logFetched = true } else {
void refreshLog() disconnectLogSocket()
}
} }
}) })
@@ -582,6 +603,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener("keydown", handleKeydown) window.removeEventListener("keydown", handleKeydown)
window.removeEventListener("mediahive:gamepad-action", onGamepadAction) window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
disconnectLogSocket()
}) })
</script> </script>
@@ -912,21 +934,6 @@ onUnmounted(() => {
margin-bottom: 6px; margin-bottom: 6px;
} }
.diag-refresh-btn {
padding: 4px 12px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.2s;
}
.diag-refresh-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.diag-log { .diag-log {
font-family: ui-monospace, Menlo, Consolas, monospace; font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.75rem; font-size: 0.75rem;
+27 -11
View File
@@ -1089,21 +1089,37 @@ async def get_version():
return {"version": version} return {"version": version}
@app.get("/api/log") def _read_log() -> str:
async def get_log(): """Return the full application log file."""
"""Return the tail of the application log file (last ~64 KB)."""
path = log_dir() / "mediahive.log" path = log_dir() / "mediahive.log"
if not path.exists(): if not path.exists():
return PlainTextResponse("") return ""
try: try:
with path.open("rb") as f: return path.read_text(encoding="utf-8", errors="replace")
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 64 * 1024))
data = f.read()
return PlainTextResponse(data.decode("utf-8", errors="replace"))
except OSError: except OSError:
return PlainTextResponse("") return ""
@app.get("/api/log")
async def get_log():
"""Return the full application log file."""
return PlainTextResponse(_read_log())
@app.websocket("/api/log/ws")
async def ws_log(ws: WebSocket) -> None:
"""Stream the application log: full log on connect and on every change."""
await ws.accept()
last_sent: str | None = None
try:
while True:
current = _read_log()
if current != last_sent:
last_sent = current
await ws.send_text(current)
await asyncio.sleep(1.0)
except WebSocketDisconnect, OSError, RuntimeError:
pass
@app.post("/api/client-log", status_code=204) @app.post("/api/client-log", status_code=204)