fix old log loading

This commit is contained in:
2026-08-11 02:41:18 +08:00
parent b62c74b883
commit d81d3675f8
3 changed files with 145 additions and 13 deletions
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
} }