Live log view over WebSocket, full log, scroll pinned to bottom
This commit is contained in:
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user