Add live log buffering support and endpoint; enhance log display functionality.
This commit is contained in:
1
Software/MainBoard/rust/.idea/vcs.xml
generated
1
Software/MainBoard/rust/.idea/vcs.xml
generated
@@ -2,6 +2,5 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
|
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
|
||||||
<mapping directory="$PROJECT_DIR$/../../../website/themes/blowfish" vcs="Git" />
|
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -88,15 +88,33 @@ impl<'a> TankSensor<'a> {
|
|||||||
//multisample should be moved to water_temperature_c
|
//multisample should be moved to water_temperature_c
|
||||||
let mut attempt = 1;
|
let mut attempt = 1;
|
||||||
let mut delay = Delay::new();
|
let mut delay = Delay::new();
|
||||||
self.one_wire_bus.reset(&mut delay)?;
|
|
||||||
|
let presence = self.one_wire_bus.reset(&mut delay)?;
|
||||||
|
println!("OneWire: reset presence pulse = {}", presence);
|
||||||
|
if !presence {
|
||||||
|
println!("OneWire: no device responded to reset — check pull-up resistor and wiring");
|
||||||
|
}
|
||||||
|
|
||||||
let mut search = DeviceSearch::new();
|
let mut search = DeviceSearch::new();
|
||||||
let mut water_temp_sensor: Option<Device> = None;
|
let mut water_temp_sensor: Option<Device> = None;
|
||||||
|
let mut devices_found = 0u8;
|
||||||
while let Some(device) = self.one_wire_bus.search_next(&mut search, &mut delay)? {
|
while let Some(device) = self.one_wire_bus.search_next(&mut search, &mut delay)? {
|
||||||
|
devices_found += 1;
|
||||||
|
println!(
|
||||||
|
"OneWire: found device #{} family=0x{:02X} addr={:02X?}",
|
||||||
|
devices_found, device.address[0], device.address
|
||||||
|
);
|
||||||
if device.address[0] == ds18b20::FAMILY_CODE {
|
if device.address[0] == ds18b20::FAMILY_CODE {
|
||||||
water_temp_sensor = Some(device);
|
water_temp_sensor = Some(device);
|
||||||
break;
|
break;
|
||||||
|
} else {
|
||||||
|
println!("OneWire: skipping device — not a DS18B20 (family 0x{:02X} != 0x{:02X})", device.address[0], ds18b20::FAMILY_CODE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if devices_found == 0 {
|
||||||
|
println!("OneWire: search found zero devices on the bus");
|
||||||
|
}
|
||||||
|
|
||||||
match water_temp_sensor {
|
match water_temp_sensor {
|
||||||
Some(device) => {
|
Some(device) => {
|
||||||
println!("Found one wire device: {:?}", device);
|
println!("Found one wire device: {:?}", device);
|
||||||
|
|||||||
@@ -2,42 +2,84 @@ use alloc::string::String;
|
|||||||
use alloc::vec::Vec;
|
use alloc::vec::Vec;
|
||||||
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||||
use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
|
use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
|
||||||
use embassy_sync::mutex::Mutex;
|
use log::{LevelFilter, Log, Metadata, Record};
|
||||||
use log::{error, LevelFilter, Log, Metadata, Record};
|
|
||||||
|
const MAX_LIVE_LOG_ENTRIES: usize = 64;
|
||||||
|
|
||||||
|
struct LiveLogBuffer {
|
||||||
|
entries: Vec<(u64, String)>,
|
||||||
|
next_seq: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LiveLogBuffer {
|
||||||
|
const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: Vec::new(),
|
||||||
|
next_seq: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, text: String) {
|
||||||
|
if self.entries.len() >= MAX_LIVE_LOG_ENTRIES {
|
||||||
|
self.entries.remove(0);
|
||||||
|
}
|
||||||
|
self.entries.push((self.next_seq, text));
|
||||||
|
self.next_seq += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_after(&self, after: Option<u64>) -> (Vec<(u64, String)>, bool, u64) {
|
||||||
|
let next_seq = self.next_seq;
|
||||||
|
match after {
|
||||||
|
None => (self.entries.clone(), false, next_seq),
|
||||||
|
Some(after_seq) => {
|
||||||
|
let result: Vec<_> = self.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|(seq, _)| *seq > after_seq)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Dropped if there are entries that should exist (seq > after_seq) but
|
||||||
|
// the oldest retained entry has a higher seq than after_seq + 1.
|
||||||
|
let dropped = if next_seq > after_seq.saturating_add(1) {
|
||||||
|
if let Some((oldest_seq, _)) = self.entries.first() {
|
||||||
|
*oldest_seq > after_seq.saturating_add(1)
|
||||||
|
} else {
|
||||||
|
// Buffer empty but entries were written — all dropped
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
(result, dropped, next_seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct InterceptorLogger {
|
pub struct InterceptorLogger {
|
||||||
// Async mutex for start/stop capture from async context
|
live_log: BlockingMutex<CriticalSectionRawMutex, core::cell::RefCell<LiveLogBuffer>>,
|
||||||
async_capture: Mutex<CriticalSectionRawMutex, ()>,
|
|
||||||
// Blocking mutex for the actual data to be used in sync log()
|
|
||||||
sync_capture: BlockingMutex<CriticalSectionRawMutex, core::cell::RefCell<Option<Vec<String>>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InterceptorLogger {
|
impl InterceptorLogger {
|
||||||
pub const fn new() -> Self {
|
pub const fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
async_capture: Mutex::new(()),
|
live_log: BlockingMutex::new(core::cell::RefCell::new(LiveLogBuffer::new())),
|
||||||
sync_capture: BlockingMutex::new(core::cell::RefCell::new(None)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_capture(&self) {
|
/// Returns (entries_after, dropped, next_seq).
|
||||||
let _guard = self.async_capture.lock().await;
|
/// Pass `after = None` to retrieve the entire current buffer.
|
||||||
self.sync_capture.lock(|capture| {
|
/// Pass `after = Some(seq)` to retrieve only entries with seq > that value.
|
||||||
*capture.borrow_mut() = Some(Vec::new());
|
pub fn get_live_logs(&self, after: Option<u64>) -> (Vec<(u64, String)>, bool, u64) {
|
||||||
});
|
self.live_log.lock(|buf| buf.borrow().get_after(after))
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn stop_capture(&self) -> Option<Vec<String>> {
|
|
||||||
let _guard = self.async_capture.lock().await;
|
|
||||||
self.sync_capture
|
|
||||||
.lock(|capture| capture.borrow_mut().take())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(&'static self) {
|
pub fn init(&'static self) {
|
||||||
match log::set_logger(self).map(|()| log::set_max_level(LevelFilter::Info)) {
|
match log::set_logger(self).map(|()| log::set_max_level(LevelFilter::Info)) {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) => {
|
Err(_e) => {
|
||||||
error!("Logger already set: {}", e);
|
esp_println::println!("ERROR: Logger already set");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,16 +92,14 @@ impl Log for InterceptorLogger {
|
|||||||
|
|
||||||
fn log(&self, record: &Record) {
|
fn log(&self, record: &Record) {
|
||||||
if self.enabled(record.metadata()) {
|
if self.enabled(record.metadata()) {
|
||||||
let message = alloc::format!("{}", record.args());
|
let message = alloc::format!("{}: {}", record.level(), record.args());
|
||||||
|
|
||||||
// Print to serial using esp_println
|
// Print to serial
|
||||||
esp_println::println!("{}: {}", record.level(), message);
|
esp_println::println!("{}", message);
|
||||||
|
|
||||||
// Capture if active
|
// Store in live log ring buffer
|
||||||
self.sync_capture.lock(|capture| {
|
self.live_log.lock(|buf| {
|
||||||
if let Some(ref mut buffer) = *capture.borrow_mut() {
|
buf.borrow_mut().push(message);
|
||||||
buffer.push(alloc::format!("{}: {}", record.level(), message));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use crate::fat_error::FatResult;
|
use crate::fat_error::FatResult;
|
||||||
use crate::log::LOG_ACCESS;
|
use crate::log::LOG_ACCESS;
|
||||||
|
use alloc::string::String;
|
||||||
|
use alloc::vec::Vec;
|
||||||
use edge_http::io::server::Connection;
|
use edge_http::io::server::Connection;
|
||||||
use edge_nal::io::{Read, Write};
|
use edge_nal::io::{Read, Write};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
pub(crate) async fn get_log<T, const N: usize>(
|
pub(crate) async fn get_log<T, const N: usize>(
|
||||||
conn: &mut Connection<'_, T, N>,
|
conn: &mut Connection<'_, T, N>,
|
||||||
@@ -34,3 +37,29 @@ where
|
|||||||
conn.write_all("]".as_bytes()).await?;
|
conn.write_all("]".as_bytes()).await?;
|
||||||
Ok(Some(200))
|
Ok(Some(200))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct LiveLogEntry {
|
||||||
|
seq: u64,
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct LiveLogResponse {
|
||||||
|
entries: Vec<LiveLogEntry>,
|
||||||
|
dropped: bool,
|
||||||
|
next_seq: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_live_log(after: Option<u64>) -> FatResult<Option<String>> {
|
||||||
|
let (raw_entries, dropped, next_seq) = crate::log::INTERCEPTOR.get_live_logs(after);
|
||||||
|
let response = LiveLogResponse {
|
||||||
|
entries: raw_entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(seq, text)| LiveLogEntry { seq, text })
|
||||||
|
.collect(),
|
||||||
|
dropped,
|
||||||
|
next_seq,
|
||||||
|
};
|
||||||
|
Ok(Some(serde_json::to_string(&response)?))
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::webserver::get_json::{
|
|||||||
delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config,
|
delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config,
|
||||||
get_solar_state, get_time, get_timezones, get_version_web, list_saves, tank_info,
|
get_solar_state, get_time, get_timezones, get_version_web, list_saves, tank_info,
|
||||||
};
|
};
|
||||||
use crate::webserver::get_log::get_log;
|
use crate::webserver::get_log::{get_live_log, get_log};
|
||||||
use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index};
|
use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index};
|
||||||
use crate::webserver::ota::ota_operations;
|
use crate::webserver::ota::ota_operations;
|
||||||
use crate::webserver::post_json::{
|
use crate::webserver::post_json::{
|
||||||
@@ -64,7 +64,6 @@ impl Handler for HTTPRequestRouter {
|
|||||||
e
|
e
|
||||||
})?
|
})?
|
||||||
} else {
|
} else {
|
||||||
crate::log::INTERCEPTOR.start_capture().await;
|
|
||||||
match method {
|
match method {
|
||||||
Method::Get => match path {
|
Method::Get => match path {
|
||||||
"/favicon.ico" => serve_favicon(conn).await?,
|
"/favicon.ico" => serve_favicon(conn).await?,
|
||||||
@@ -84,6 +83,14 @@ impl Handler for HTTPRequestRouter {
|
|||||||
"/timezones" => Some(get_timezones().await),
|
"/timezones" => Some(get_timezones().await),
|
||||||
"/moisture" => Some(get_live_moisture(conn).await),
|
"/moisture" => Some(get_live_moisture(conn).await),
|
||||||
"/list_saves" => Some(list_saves(conn).await),
|
"/list_saves" => Some(list_saves(conn).await),
|
||||||
|
// /live_log accepts an optional ?after=N query parameter
|
||||||
|
p if p == "/live_log" || p.starts_with("/live_log?") => {
|
||||||
|
let after: Option<u64> = p
|
||||||
|
.find("after=")
|
||||||
|
.and_then(|pos| p[pos + 6..].split('&').next())
|
||||||
|
.and_then(|s| s.parse().ok());
|
||||||
|
Some(get_live_log(after).await)
|
||||||
|
}
|
||||||
// /get_config accepts an optional ?saveidx=N query parameter
|
// /get_config accepts an optional ?saveidx=N query parameter
|
||||||
p if p == "/get_config" || p.starts_with("/get_config?") => {
|
p if p == "/get_config" || p.starts_with("/get_config?") => {
|
||||||
let saveidx: Option<usize> = p
|
let saveidx: Option<usize> = p
|
||||||
@@ -167,7 +174,6 @@ impl Handler for HTTPRequestRouter {
|
|||||||
let response_time = Instant::now().duration_since(start).as_millis();
|
let response_time = Instant::now().duration_since(start).as_millis();
|
||||||
|
|
||||||
info!("\"{method} {path}\" {code} {response_time}ms");
|
info!("\"{method} {path}\" {code} {response_time}ms");
|
||||||
crate::log::INTERCEPTOR.stop_capture().await;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,17 +271,9 @@ where
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let mut error_text = err.to_string();
|
let error_text = err.to_string();
|
||||||
info!("error handling process {error_text}");
|
info!("error handling process {error_text}");
|
||||||
|
|
||||||
if let Some(logs) = crate::log::INTERCEPTOR.stop_capture().await {
|
|
||||||
error_text.push_str("\n\nCaptured Logs:\n");
|
|
||||||
for log in logs {
|
|
||||||
error_text.push_str(&log);
|
|
||||||
error_text.push('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.initiate_response(
|
conn.initiate_response(
|
||||||
500,
|
500,
|
||||||
Some("OK"),
|
Some("OK"),
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
export interface LogArray extends Array<LogEntry> {
|
export interface LogArray extends Array<LogEntry> {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LiveLogEntry {
|
||||||
|
seq: number,
|
||||||
|
text: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveLogResponse {
|
||||||
|
entries: LiveLogEntry[],
|
||||||
|
dropped: boolean,
|
||||||
|
next_seq: number,
|
||||||
|
}
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
timestamp: string,
|
timestamp: string,
|
||||||
message_id: number,
|
message_id: number,
|
||||||
|
|||||||
@@ -1,7 +1,48 @@
|
|||||||
<style>
|
<style>
|
||||||
|
#livelogpanel {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 8px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #444;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.livelog-dropped {
|
||||||
|
color: #f0a500;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-accordion-header {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-accordion-header::before {
|
||||||
|
content: "▶";
|
||||||
|
font-size: 0.75em;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-accordion-header.open::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#logpanel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<button id="loadLog">Load Logs</button>
|
|
||||||
<div id="logpanel">
|
|
||||||
|
|
||||||
</div>
|
<h4 id="logAccordionHeader" class="log-accordion-header">Application Log</h4>
|
||||||
|
<div id="logpanel"></div>
|
||||||
|
|
||||||
|
<h4>Live Log</h4>
|
||||||
|
<div id="livelogpanel"></div>
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
import { Controller } from "./main";
|
import { Controller } from "./main";
|
||||||
import {LogArray, LogLocalisation} from "./api";
|
import {LiveLogResponse, LogArray, LogLocalisation} from "./api";
|
||||||
|
|
||||||
|
const LIVE_LOG_POLL_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
export class LogView {
|
export class LogView {
|
||||||
private readonly logpanel: HTMLElement;
|
private readonly logpanel: HTMLElement;
|
||||||
private readonly loadLog: HTMLButtonElement;
|
private readonly livelogpanel: HTMLElement;
|
||||||
|
private readonly accordionHeader: HTMLElement;
|
||||||
loglocale: LogLocalisation | undefined;
|
loglocale: LogLocalisation | undefined;
|
||||||
|
|
||||||
|
private liveLogNextSeq: number | undefined = undefined;
|
||||||
|
private liveLogTimer: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||||
|
private structuredLogLoaded = false;
|
||||||
|
|
||||||
constructor(controller: Controller) {
|
constructor(controller: Controller) {
|
||||||
(document.getElementById("logview") as HTMLElement).innerHTML = require('./log.html') as string;
|
(document.getElementById("logview") as HTMLElement).innerHTML = require('./log.html') as string;
|
||||||
this.logpanel = document.getElementById("logpanel") as HTMLElement
|
this.logpanel = document.getElementById("logpanel") as HTMLElement;
|
||||||
this.loadLog = document.getElementById("loadLog") as HTMLButtonElement
|
this.livelogpanel = document.getElementById("livelogpanel") as HTMLElement;
|
||||||
|
this.accordionHeader = document.getElementById("logAccordionHeader") as HTMLElement;
|
||||||
|
|
||||||
this.loadLog.onclick = () => {
|
this.accordionHeader.onclick = () => {
|
||||||
controller.loadLog();
|
const isOpen = this.logpanel.style.display !== "none";
|
||||||
}
|
if (isOpen) {
|
||||||
|
this.logpanel.style.display = "none";
|
||||||
|
this.accordionHeader.classList.remove("open");
|
||||||
|
} else {
|
||||||
|
this.logpanel.style.display = "";
|
||||||
|
this.accordionHeader.classList.add("open");
|
||||||
|
if (!this.structuredLogLoaded) {
|
||||||
|
this.structuredLogLoaded = true;
|
||||||
|
controller.loadLog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setLogLocalisation(loglocale: LogLocalisation) {
|
setLogLocalisation(loglocale: LogLocalisation) {
|
||||||
@@ -21,10 +40,10 @@ export class LogView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLog(logs: LogArray) {
|
setLog(logs: LogArray) {
|
||||||
this.logpanel.textContent = ""
|
this.logpanel.textContent = "";
|
||||||
logs.forEach(entry => {
|
logs.forEach(entry => {
|
||||||
let message = this.loglocale!![entry.message_id];
|
let message = this.loglocale!![entry.message_id];
|
||||||
let template = message.message
|
let template = message.message;
|
||||||
template = template.replace("${number_a}", entry.a.toString());
|
template = template.replace("${number_a}", entry.a.toString());
|
||||||
template = template.replace("${number_b}", entry.b.toString());
|
template = template.replace("${number_b}", entry.b.toString());
|
||||||
template = template.replace("${txt_short}", entry.txt_short.toString());
|
template = template.replace("${txt_short}", entry.txt_short.toString());
|
||||||
@@ -32,15 +51,67 @@ export class LogView {
|
|||||||
|
|
||||||
let ts = new Date(entry.timestamp);
|
let ts = new Date(entry.timestamp);
|
||||||
|
|
||||||
let div = document.createElement("div")
|
let div = document.createElement("div");
|
||||||
let timestampDiv = document.createElement("div")
|
let timestampDiv = document.createElement("div");
|
||||||
let messageDiv = document.createElement("div")
|
let messageDiv = document.createElement("div");
|
||||||
timestampDiv.innerText = ts.toISOString();
|
timestampDiv.innerText = ts.toISOString();
|
||||||
messageDiv.innerText = template;
|
messageDiv.innerText = template;
|
||||||
div.appendChild(timestampDiv)
|
div.appendChild(timestampDiv);
|
||||||
div.appendChild(messageDiv)
|
div.appendChild(messageDiv);
|
||||||
this.logpanel.appendChild(div)
|
this.logpanel.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startLivePoll(publicUrl: string) {
|
||||||
|
if (this.liveLogTimer !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const url = this.liveLogNextSeq !== undefined
|
||||||
|
? `${publicUrl}/live_log?after=${this.liveLogNextSeq}`
|
||||||
|
: `${publicUrl}/live_log`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json() as LiveLogResponse;
|
||||||
|
this.appendLiveLog(data);
|
||||||
|
} catch (_e) {
|
||||||
|
// network error — silently ignore, will retry next interval
|
||||||
|
}
|
||||||
|
this.liveLogTimer = setTimeout(poll, LIVE_LOG_POLL_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
// Kick off immediately
|
||||||
|
this.liveLogTimer = setTimeout(poll, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopLivePoll() {
|
||||||
|
if (this.liveLogTimer !== undefined) {
|
||||||
|
clearTimeout(this.liveLogTimer);
|
||||||
|
this.liveLogTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendLiveLog(data: LiveLogResponse) {
|
||||||
|
const panel = this.livelogpanel;
|
||||||
|
const wasAtBottom = panel.scrollHeight - panel.scrollTop <= panel.clientHeight + 4;
|
||||||
|
|
||||||
|
if (data.dropped) {
|
||||||
|
const marker = document.createElement("div");
|
||||||
|
marker.className = "livelog-dropped";
|
||||||
|
marker.textContent = "[..]";
|
||||||
|
panel.appendChild(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of data.entries) {
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.textContent = entry.text;
|
||||||
|
panel.appendChild(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.liveLogNextSeq = data.next_seq;
|
||||||
|
|
||||||
|
// Auto-scroll to bottom only if user was already at the bottom
|
||||||
|
if (wasAtBottom) {
|
||||||
|
panel.scrollTop = panel.scrollHeight;
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -668,7 +668,8 @@ async function executeTasksSequentially() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
executeTasksSequentially().then(() => {
|
executeTasksSequentially().then(() => {
|
||||||
controller.progressview.removeProgress("initial")
|
controller.progressview.removeProgress("initial");
|
||||||
|
controller.logView.startLivePoll(PUBLIC_URL);
|
||||||
});
|
});
|
||||||
|
|
||||||
controller.progressview.removeProgress("rebooting");
|
controller.progressview.removeProgress("rebooting");
|
||||||
|
|||||||
Reference in New Issue
Block a user