fix old log loading
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from 'svelte';import {backoff,mergeLines,type LogLine,type Page} from './lib';
|
import {onMount} from 'svelte';import {backoff,mergeLines,type LogLine,type Page} from './lib';
|
||||||
let lines:LogLine[]=[];let before:string|null=null;let hasMore=true;let state:'Connecting'|'Live'|'Reconnecting'|'Disconnected'|'Access denied'='Connecting';let viewport:HTMLDivElement;let atBottom=true;let unseen=0;let loading=false;let socket:WebSocket|null=null;let stopped=false;let clientIp:string|null=null;let showIp=false;
|
let lines:LogLine[]=[];let before:string|null=null;let hasMore=true;let state:'Connecting'|'Live'|'Reconnecting'|'Disconnected'|'Access denied'='Connecting';let viewport:HTMLDivElement;let atBottom=true;let unseen=0;let loading=false;let socket:WebSocket|null=null;let stopped=false;let clientIp:string|null=null;let showIp=false;
|
||||||
async function fetchPage(cursor:string|null,limit=1000){const endpoint=cursor?`/api/logs/history?before=${encodeURIComponent(cursor)}&limit=${limit}`:`/api/logs/recent?limit=${limit}`;const response=await fetch(endpoint);if(response.status===403){const denial=await response.json().catch(()=>null) as {client_ip?:string}|null;clientIp=denial?.client_ip??null;state='Access denied';throw new Error('denied')}if(!response.ok)throw new Error('history');return response.json() as Promise<Page>}
|
class HistoryError extends Error{status:number;code:string;constructor(status:number,code:string){super(code);this.status=status;this.code=code}}
|
||||||
|
async function fetchPage(cursor:string|null,limit=1000){const endpoint=cursor?`/api/logs/history?before=${encodeURIComponent(cursor)}&limit=${limit}`:`/api/logs/recent?limit=${limit}`;const response=await fetch(endpoint);if(response.status===403){const denial=await response.json().catch(()=>null) as {client_ip?:string}|null;clientIp=denial?.client_ip??null;state='Access denied';throw new HistoryError(403,'denied')}if(!response.ok){const failure=await response.json().catch(()=>null) as {error?:string}|null;throw new HistoryError(response.status,failure?.error??'history')}return response.json() as Promise<Page>}
|
||||||
async function initial(){const page=await fetchPage(null);lines=mergeLines([],page.lines);before=page.next_before;hasMore=page.has_more;requestAnimationFrame(scrollLive);}
|
async function initial(){const page=await fetchPage(null);lines=mergeLines([],page.lines);before=page.next_before;hasMore=page.has_more;requestAnimationFrame(scrollLive);}
|
||||||
async function older(){if(loading||!hasMore||!before)return;loading=true;const oldHeight=viewport.scrollHeight;try{const page=await fetchPage(before);const ids=new Set(lines.map(x=>x.id));lines=[...page.lines.filter(x=>!ids.has(x.id)),...lines];before=page.next_before;hasMore=page.has_more;requestAnimationFrame(()=>viewport.scrollTop+=viewport.scrollHeight-oldHeight);}finally{loading=false}}
|
async function older(){if(loading||!hasMore||!before)return;loading=true;const oldHeight=viewport.scrollHeight;try{const page=await fetchPage(before);const ids=new Set(lines.map(x=>x.id));lines=[...page.lines.filter(x=>!ids.has(x.id)),...lines];before=page.next_before;hasMore=page.has_more;requestAnimationFrame(()=>viewport.scrollTop+=viewport.scrollHeight-oldHeight);}catch(error){if(error instanceof HistoryError&&error.status===409&&error.code==='stale_cursor'){try{const page=await fetchPage(null);lines=mergeLines(lines,page.lines);before=page.next_before;hasMore=page.has_more;requestAnimationFrame(()=>older());}catch(recoveryError){console.error('Failed to refresh changed log history',recoveryError)}}else{console.error('Failed to load older logs',error)}}finally{loading=false}}
|
||||||
function connect(attempt=0){if(stopped||state==='Access denied')return;state=attempt?'Reconnecting':'Connecting';const scheme=location.protocol==='https:'?'wss':'ws';socket=new WebSocket(`${scheme}://${location.host}/api/live`);socket.onopen=async()=>{state='Live';try{const gap=await fetchPage(null,1000);lines=mergeLines(lines,gap.lines);if(atBottom)requestAnimationFrame(scrollLive)}catch{}};socket.onmessage=(event)=>{const message=JSON.parse(event.data);if(message.type==='log_lines'){lines=mergeLines(lines,message.lines);if(atBottom)requestAnimationFrame(scrollLive);else unseen+=message.lines.length}if(message.type==='resync_required')socket?.close()};socket.onclose=()=>{if(stopped||state==='Access denied')return;state='Reconnecting';setTimeout(()=>connect(attempt+1),backoff(attempt))};socket.onerror=()=>socket?.close()}
|
function connect(attempt=0){if(stopped||state==='Access denied')return;state=attempt?'Reconnecting':'Connecting';const scheme=location.protocol==='https:'?'wss':'ws';socket=new WebSocket(`${scheme}://${location.host}/api/live`);socket.onopen=async()=>{state='Live';try{const gap=await fetchPage(null,1000);lines=mergeLines(lines,gap.lines);if(atBottom)requestAnimationFrame(scrollLive)}catch{}};socket.onmessage=(event)=>{const message=JSON.parse(event.data);if(message.type==='log_lines'){lines=mergeLines(lines,message.lines);if(atBottom)requestAnimationFrame(scrollLive);else unseen+=message.lines.length}if(message.type==='resync_required')socket?.close()};socket.onclose=()=>{if(stopped||state==='Access denied')return;state='Reconnecting';setTimeout(()=>connect(attempt+1),backoff(attempt))};socket.onerror=()=>socket?.close()}
|
||||||
function scrollLive(){viewport?.scrollTo({top:viewport.scrollHeight});unseen=0}
|
function scrollLive(){viewport?.scrollTo({top:viewport.scrollHeight});unseen=0}
|
||||||
function scroll(){atBottom=viewport.scrollHeight-viewport.scrollTop-viewport.clientHeight<40;if(atBottom)unseen=0;if(viewport.scrollTop<200)older()}
|
function scroll(){atBottom=viewport.scrollHeight-viewport.scrollTop-viewport.clientHeight<40;if(atBottom)unseen=0;if(viewport.scrollTop<200)older()}
|
||||||
|
|||||||
+8
-2
@@ -82,8 +82,14 @@ async fn logs_response(
|
|||||||
match state.history.page(before, limit).await {
|
match state.history.page(before, limit).await {
|
||||||
Ok(page) => Json(page).into_response(),
|
Ok(page) => Json(page).into_response(),
|
||||||
Err(code) if code == "invalid_cursor" => error(StatusCode::BAD_REQUEST, "invalid_cursor"),
|
Err(code) if code == "invalid_cursor" => error(StatusCode::BAD_REQUEST, "invalid_cursor"),
|
||||||
Err(code) if code == "stale_cursor" => error(StatusCode::CONFLICT, "stale_cursor"),
|
Err(code) if code == "stale_cursor" => {
|
||||||
Err(_) => error(StatusCode::UNPROCESSABLE_ENTITY, "history_unavailable"),
|
tracing::debug!(event = "history_page_failed", reason = "stale_cursor");
|
||||||
|
error(StatusCode::CONFLICT, "stale_cursor")
|
||||||
|
}
|
||||||
|
Err(code) => {
|
||||||
|
tracing::warn!(event = "history_page_failed", reason = %code);
|
||||||
|
error(StatusCode::UNPROCESSABLE_ENTITY, "history_unavailable")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+134
-9
@@ -35,7 +35,10 @@ impl HistoryIndex {
|
|||||||
for entry in fs::read_dir(dir)? {
|
for entry in fs::read_dir(dir)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !entry.file_type()?.is_file() || archive::detect(&path).is_none() {
|
if !entry.file_type()?.is_file()
|
||||||
|
|| is_debug_log(&path)
|
||||||
|
|| archive::detect(&path).is_none()
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let meta = entry.metadata()?;
|
let meta = entry.metadata()?;
|
||||||
@@ -75,6 +78,38 @@ impl HistoryIndex {
|
|||||||
generation,
|
generation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A cursor remains valid while latest.log only grows: existing source and
|
||||||
|
// line positions do not move. Archive changes, rotation, and truncation do
|
||||||
|
// change pagination and must invalidate outstanding cursors.
|
||||||
|
fn is_append_compatible_with(&self, previous: &Self) -> bool {
|
||||||
|
self.sources.len() == previous.sources.len()
|
||||||
|
&& self
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.zip(&previous.sources)
|
||||||
|
.all(|(new, old)| {
|
||||||
|
new.label == old.label
|
||||||
|
&& new.path == old.path
|
||||||
|
&& new.device == old.device
|
||||||
|
&& new.inode == old.inode
|
||||||
|
&& if new.label == "latest.log" {
|
||||||
|
new.compressed_size >= old.compressed_size
|
||||||
|
} else {
|
||||||
|
new.modified_ms == old.modified_ms
|
||||||
|
&& new.compressed_size == old.compressed_size
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn is_debug_log(path: &Path) -> bool {
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
name == "debug.log"
|
||||||
|
|| (name.starts_with("debug-") && (name.ends_with(".log") || name.ends_with(".log.gz")))
|
||||||
}
|
}
|
||||||
fn filename_key(name: &str) -> String {
|
fn filename_key(name: &str) -> String {
|
||||||
name.chars().filter(|c| c.is_ascii_digit()).collect()
|
name.chars().filter(|c| c.is_ascii_digit()).collect()
|
||||||
@@ -105,7 +140,12 @@ impl ArchiveCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn get(&mut self, p: &Path) -> Option<Arc<Vec<String>>> {
|
fn get(&mut self, p: &Path) -> Option<Arc<Vec<String>>> {
|
||||||
self.items.get(p).cloned()
|
let value = self.items.get(p).cloned()?;
|
||||||
|
if let Some(position) = self.order.iter().position(|item| item == p) {
|
||||||
|
self.order.remove(position);
|
||||||
|
}
|
||||||
|
self.order.push_back(p.to_path_buf());
|
||||||
|
Some(value)
|
||||||
}
|
}
|
||||||
fn put(&mut self, p: PathBuf, v: Arc<Vec<String>>) {
|
fn put(&mut self, p: PathBuf, v: Arc<Vec<String>>) {
|
||||||
if self.max_files == 0 || self.max_bytes == 0 {
|
if self.max_files == 0 || self.max_bytes == 0 {
|
||||||
@@ -148,7 +188,12 @@ impl HistoryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub async fn refresh(&self, dir: &Path) -> std::io::Result<()> {
|
pub async fn refresh(&self, dir: &Path) -> std::io::Result<()> {
|
||||||
*self.index.write().await = HistoryIndex::scan(dir)?;
|
let mut next = HistoryIndex::scan(dir)?;
|
||||||
|
let mut index = self.index.write().await;
|
||||||
|
if next.is_append_compatible_with(&index) {
|
||||||
|
next.generation = index.generation;
|
||||||
|
}
|
||||||
|
*index = next;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub async fn page(&self, before: Option<&str>, limit: usize) -> Result<HistoryPage, String> {
|
pub async fn page(&self, before: Option<&str>, limit: usize) -> Result<HistoryPage, String> {
|
||||||
@@ -207,14 +252,22 @@ impl HistoryStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn load(&self, source: &Source) -> Result<Arc<Vec<String>>, archive::ArchiveError> {
|
fn load(&self, source: &Source) -> Result<Arc<Vec<String>>, archive::ArchiveError> {
|
||||||
if let Some(v) = self.cache.lock().unwrap().get(&source.path) {
|
let cacheable = matches!(
|
||||||
return Ok(v);
|
archive::detect(&source.path),
|
||||||
|
Some(archive::ArchiveKind::Gzip | archive::ArchiveKind::TarGzip)
|
||||||
|
);
|
||||||
|
if cacheable {
|
||||||
|
if let Some(v) = self.cache.lock().unwrap().get(&source.path) {
|
||||||
|
return Ok(v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let v = Arc::new(archive::read_lines(&source.path, self.max_archive_bytes)?);
|
let v = Arc::new(archive::read_lines(&source.path, self.max_archive_bytes)?);
|
||||||
self.cache
|
if cacheable {
|
||||||
.lock()
|
self.cache
|
||||||
.unwrap()
|
.lock()
|
||||||
.put(source.path.clone(), v.clone());
|
.unwrap()
|
||||||
|
.put(source.path.clone(), v.clone());
|
||||||
|
}
|
||||||
Ok(v)
|
Ok(v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +289,14 @@ fn make_line(source: &Source, index: usize, text: &str, redact: bool) -> LogLine
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use flate2::{write::GzEncoder, Compression};
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
fn write_gzip(path: &Path, contents: &[u8]) {
|
||||||
|
let mut gzip = GzEncoder::new(fs::File::create(path).unwrap(), Compression::default());
|
||||||
|
gzip.write_all(contents).unwrap();
|
||||||
|
gzip.finish().unwrap();
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn deterministic_filename_order() {
|
fn deterministic_filename_order() {
|
||||||
let d = tempfile::tempdir().unwrap();
|
let d = tempfile::tempdir().unwrap();
|
||||||
@@ -252,6 +313,23 @@ mod tests {
|
|||||||
let i = HistoryIndex::scan(d.path()).unwrap();
|
let i = HistoryIndex::scan(d.path()).unwrap();
|
||||||
assert_eq!(i.sources.last().unwrap().label, "latest.log");
|
assert_eq!(i.sources.last().unwrap().label, "latest.log");
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn excludes_minecraft_debug_logs() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(d.path().join("latest.log"), "normal\n").unwrap();
|
||||||
|
fs::write(d.path().join("debug.log"), "debug\n").unwrap();
|
||||||
|
write_gzip(&d.path().join("debug-1.log.gz"), b"rotated debug\n");
|
||||||
|
|
||||||
|
let index = HistoryIndex::scan(d.path()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
index
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.map(|source| source.label.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["latest.log"]
|
||||||
|
);
|
||||||
|
}
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn paginates_without_exposing_paths() {
|
async fn paginates_without_exposing_paths() {
|
||||||
let d = tempfile::tempdir().unwrap();
|
let d = tempfile::tempdir().unwrap();
|
||||||
@@ -278,4 +356,51 @@ mod tests {
|
|||||||
vec!["old", "new"]
|
vec!["old", "new"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cursor_survives_latest_log_append_but_not_truncation() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let latest = d.path().join("latest.log");
|
||||||
|
fs::write(&latest, "one\ntwo\n").unwrap();
|
||||||
|
let store = HistoryStore::new(HistoryIndex::scan(d.path()).unwrap(), 1024, 2, 1024, false);
|
||||||
|
let cursor = store.page(None, 1).await.unwrap().next_before.unwrap();
|
||||||
|
|
||||||
|
fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(&latest)
|
||||||
|
.unwrap()
|
||||||
|
.write_all(b"three\n")
|
||||||
|
.unwrap();
|
||||||
|
store.refresh(d.path()).await.unwrap();
|
||||||
|
assert!(store.page(Some(&cursor), 1).await.is_ok());
|
||||||
|
|
||||||
|
fs::write(&latest, "replacement\n").unwrap();
|
||||||
|
store.refresh(d.path()).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.page(Some(&cursor), 1).await.unwrap_err(),
|
||||||
|
"stale_cursor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evicted_archive_is_reloaded_on_demand() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let first_path = d.path().join("1.log.gz");
|
||||||
|
let second_path = d.path().join("2.log.gz");
|
||||||
|
write_gzip(&first_path, b"old\n");
|
||||||
|
write_gzip(&second_path, b"second\n");
|
||||||
|
let index = HistoryIndex::scan(d.path()).unwrap();
|
||||||
|
let first = index.sources.iter().find(|s| s.path == first_path).unwrap();
|
||||||
|
let second = index
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.path == second_path)
|
||||||
|
.unwrap();
|
||||||
|
let store = HistoryStore::new(index.clone(), 1024, 1, 1024, false);
|
||||||
|
|
||||||
|
assert_eq!(store.load(first).unwrap()[0], "old");
|
||||||
|
assert_eq!(store.load(second).unwrap()[0], "second"); // evicts first
|
||||||
|
write_gzip(&first_path, b"reloaded\n");
|
||||||
|
assert_eq!(store.load(first).unwrap()[0], "reloaded");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user