diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 85d66077e49..0f4601c1e25 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include // lib includes #include @@ -1093,6 +1095,30 @@ namespace confighttp { } } + /** + * @brief Try to read only the new tail of the log file and append to existing content. + * @return New content on success, nullptr on any failure (caller should fall back to full read). + */ + static std::shared_ptr try_incremental_log_read( + const std::filesystem::path &log_path, + std::uintmax_t prev_size, + std::uintmax_t current_size, + const std::shared_ptr &old_content) { + if (current_size <= prev_size || prev_size == 0 || !old_content) { + return nullptr; + } + std::ifstream in(log_path.string(), std::ios::binary); + if (!in || !in.seekg(static_cast(prev_size))) { + return nullptr; + } + const auto tail_len = current_size - prev_size; + std::string tail(tail_len, '\0'); + if (!in.read(tail.data(), static_cast(tail_len))) { + return nullptr; + } + return std::make_shared(*old_content + tail); + } + /** * @brief Get the logs from the log file. * @param response The HTTP response object. @@ -1107,12 +1133,95 @@ namespace confighttp { print_req(request); - std::string content = file_handler::read_file(config::sunshine.log_file.c_str()); + // Log caching: avoid reading disk unnecessarily when file hasn't changed + // Use std::atomic to ensure thread-safe access (no locks) + static std::atomic> cached_log; + static std::atomic cached_log_size { 0 }; + static std::atomic cached_log_mtime_ns { 0 }; + + const std::filesystem::path log_path(config::sunshine.log_file); + + // Check file status + std::error_code ec; + auto current_size = std::filesystem::file_size(log_path, ec); + if (ec) { + response->write(SimpleWeb::StatusCode::server_error_internal_server_error, "Failed to read log file"); + return; + } + auto current_mtime = std::filesystem::last_write_time(log_path, ec); + if (ec) { + response->write(SimpleWeb::StatusCode::server_error_internal_server_error, "Failed to read log file"); + return; + } + auto current_mtime_ns = current_mtime.time_since_epoch().count(); + + const auto prev_size = cached_log_size.load(); + const bool cache_stale = (current_size != prev_size || current_mtime_ns != cached_log_mtime_ns.load()); + if (cache_stale) { + auto new_content = try_incremental_log_read(log_path, prev_size, current_size, cached_log.load()); + if (!new_content) { + new_content = std::make_shared(file_handler::read_file(log_path.string().c_str())); + } + // If read returned empty, ensure file still exists (e.g. not deleted during read) + if (new_content->empty() && !std::filesystem::exists(log_path, ec)) { + response->write(SimpleWeb::StatusCode::server_error_internal_server_error, "Log file not available"); + return; + } + cached_log.store(new_content); + cached_log_size.store(current_size); + cached_log_mtime_ns.store(current_mtime_ns); + } + + // Atomic load shared_ptr, subsequent operations based on this snapshot + auto content = cached_log.load(); + if (!content) { + response->write(SimpleWeb::StatusCode::server_error_internal_server_error, "Log not available"); + return; + } + + // Read client's offset from request header (trim whitespace; invalid values => 0, then full response) + std::uintmax_t client_offset = 0; + auto it = request->header.find("X-Log-Offset"); + if (it != request->header.end()) { + try { + std::string offset_str(it->second); + boost::algorithm::trim(offset_str); + if (!offset_str.empty()) { + client_offset = std::stoull(offset_str); + } + } + catch (const std::invalid_argument &) { + client_offset = 0; + } + catch (const std::out_of_range &) { + client_offset = 0; + } + } + SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "text/plain"); + headers.emplace("X-Log-Size", std::to_string(content->size())); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - response->write(SimpleWeb::StatusCode::success_ok, content, headers); + + // offset equals current size: no change in logs, return 304 + if (client_offset > 0 && client_offset == content->size()) { + headers.emplace("X-Log-Range", "unchanged"); + response->write(SimpleWeb::StatusCode::redirection_not_modified, headers); + return; + } + + // Valid offset and within range: return increment + if (client_offset > 0 && client_offset < content->size()) { + headers.emplace("X-Log-Range", "incremental"); + auto delta = content->substr(client_offset); + response->write(SimpleWeb::StatusCode::success_ok, delta, headers); + } + else { + // Invalid offset (file rotation/first request): return full content + headers.emplace("X-Log-Range", "full"); + response->write(SimpleWeb::StatusCode::success_ok, *content, headers); + } } /** diff --git a/src_assets/common/assets/web/index.html b/src_assets/common/assets/web/index.html index 7a02b34fc36..57729b789cb 100644 --- a/src_assets/common/assets/web/index.html +++ b/src_assets/common/assets/web/index.html @@ -205,9 +205,14 @@
{{ githubVersion.release.name }}
console.error(e); } try { - this.logs = (await fetch("./api/logs").then(r => r.text())) + const response = await fetch("./api/logs"); + if (response.ok) { + this.logs = await response.text(); + } else { + console.error('Failed to fetch logs: HTTP', response.status); + } } catch (e) { - console.error(e); + console.error('Failed to fetch logs:', e); } this.loading = false; }, diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index c33b3db24bc..164db272581 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -237,6 +237,7 @@

{{ $t('troubleshooting.logs') }}

logs: 'Loading...', logFilter: null, logInterval: null, + logOffset: 0, restartPressed: false, showApplyMessage: false, platform: "", @@ -386,22 +387,69 @@

{{ $t('troubleshooting.logs') }}

} }); - this.logInterval = setInterval(() => { - this.refreshLogs(); - }, 5000); this.refreshLogs(); + this.startLogRefresh(); this.refreshClients(); + + const handleVisibilityChange = () => { + if (document.hidden) { + this.stopLogRefresh(); + } else { + this.refreshLogs(); + this.startLogRefresh(); + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + this._visibilityCleanup = () => document.removeEventListener('visibilitychange', handleVisibilityChange); }, beforeDestroy() { - clearInterval(this.logInterval); + this.stopLogRefresh(); + if (this._visibilityCleanup) this._visibilityCleanup(); }, methods: { - refreshLogs() { - fetch("./api/logs",) - .then((r) => r.text()) - .then((r) => { - this.logs = r; - }); + startLogRefresh() { + this.stopLogRefresh(); + this.logInterval = setInterval(() => this.refreshLogs(), 5000); + }, + stopLogRefresh() { + if (this.logInterval != null) { + clearInterval(this.logInterval); + this.logInterval = null; + } + }, + async refreshLogs() { + try { + const offset = Number(this.logOffset); + const headers = (!Number.isNaN(offset) && offset > 0) ? { 'X-Log-Offset': String(offset) } : {}; + const response = await fetch('./api/logs', { headers }); + + if (response.status === 304) { + const sizeHeader = response.headers.get('X-Log-Size'); + const size = Number.parseInt(sizeHeader || '0', 10); + this.logOffset = Number.isNaN(size) || size < 0 ? 0 : size; + return; + } + + if (!response.ok) { + console.error('Failed to refresh logs: HTTP', response.status); + return; + } + + const rawSize = Number.parseInt(response.headers.get('X-Log-Size') || '0', 10); + const newSize = Number.isNaN(rawSize) || rawSize < 0 ? 0 : rawSize; + const logRange = (response.headers.get('X-Log-Range') || '').trim().toLowerCase(); + const text = await response.text(); + + if (logRange === 'incremental' && text.length > 0) { + this.logs += text; + } else { + this.logs = text; + } + + this.logOffset = newSize; + } catch (e) { + console.error('Failed to refresh logs:', e); + } }, closeApp() { this.closeAppPressed = true;