From 9472dd8fbe532718be277cdee01483d690e55c4c Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sun, 22 Mar 2026 21:02:21 +0000 Subject: [PATCH 01/10] feat: add log viewer/inspector to admin UI Add a new log viewer in the admin section with: - Backend API: GET /api/v1/logs for paginated log reading with byte-offset paging, direction (forward/backward), and level+text filtering. GET /api/v1/logs/stream SSE endpoint for live tail. - Frontend: LogsView component with filter bar (text search + level dropdown), pagination (Older/Newer/Latest), and live tail toggle with auto-scroll. - Admin-only access via access_all_sessions permission. - E2E tests covering navigation, filtering, pagination, and API responses. Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- Cargo.lock | 23 ++ apps/skit/Cargo.toml | 1 + apps/skit/src/lib.rs | 1 + apps/skit/src/log_viewer.rs | 514 ++++++++++++++++++++++++++++++++ apps/skit/src/main.rs | 1 + apps/skit/src/server.rs | 2 + e2e/tests/auth-helpers.ts | 1 + e2e/tests/logs.spec.ts | 186 ++++++++++++ ui/src/App.tsx | 2 + ui/src/services/logs.ts | 62 ++++ ui/src/views/LogsView.styles.ts | 204 +++++++++++++ ui/src/views/LogsView.tsx | 395 ++++++++++++++++++++++++ ui/src/views/admin/AdminNav.tsx | 3 + 13 files changed, 1395 insertions(+) create mode 100644 apps/skit/src/log_viewer.rs create mode 100644 e2e/tests/logs.spec.ts create mode 100644 ui/src/services/logs.ts create mode 100644 ui/src/views/LogsView.styles.ts create mode 100644 ui/src/views/LogsView.tsx diff --git a/Cargo.lock b/Cargo.lock index 429141b1..cd83854c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,6 +225,28 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -4798,6 +4820,7 @@ name = "streamkit-server" version = "0.2.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "aws-lc-rs", "axum", diff --git a/apps/skit/Cargo.toml b/apps/skit/Cargo.toml index dd64b420..26cede45 100644 --- a/apps/skit/Cargo.toml +++ b/apps/skit/Cargo.toml @@ -132,6 +132,7 @@ aws-lc-rs = "1" # For MoQ auth path matching (optional, with moq feature) moq-lite = { version = "0.15.2", optional = true } blake2 = "0.10.6" +async-stream = "0.3.6" [features] default = ["script", "compositor", "moq"] diff --git a/apps/skit/src/lib.rs b/apps/skit/src/lib.rs index 8c33abea..d882d539 100644 --- a/apps/skit/src/lib.rs +++ b/apps/skit/src/lib.rs @@ -7,6 +7,7 @@ pub mod auth; pub mod cli; pub mod config; pub mod file_security; +pub mod log_viewer; pub mod logging; pub mod marketplace; pub mod marketplace_installer; diff --git a/apps/skit/src/log_viewer.rs b/apps/skit/src/log_viewer.rs new file mode 100644 index 00000000..c439964c --- /dev/null +++ b/apps/skit/src/log_viewer.rs @@ -0,0 +1,514 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +use axum::{ + extract::{Query, State}, + http::{HeaderMap, StatusCode}, + response::{ + sse::{Event, KeepAlive}, + IntoResponse, Sse, + }, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::convert::Infallible; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncSeekExt, BufReader}; +use tracing::{debug, warn}; + +use crate::state::AppState; + +/// Maximum number of lines that can be requested in a single page. +const MAX_LINE_LIMIT: usize = 5000; + +/// Default number of lines per page. +const DEFAULT_LINE_LIMIT: usize = 500; + +/// Interval between file polls during live tail (milliseconds). +const TAIL_POLL_INTERVAL_MS: u64 = 500; + +/// Maximum number of lines to buffer per SSE event during live tail. +const TAIL_MAX_LINES_PER_EVENT: usize = 200; + +/// Query parameters for paginated log retrieval with filtering. +#[derive(Deserialize)] +pub struct LogQuery { + /// Byte offset to start reading from. Default: 0 for forward, end-of-file for backward. + offset: Option, + /// Maximum number of lines to return (default: 500, max: 5000). + limit: Option, + /// Reading direction: "forward" (default) or "backward". + direction: Option, + /// Case-insensitive substring filter applied to each line. + filter: Option, + /// Filter by log level: "error", "warn", "info", "debug", "trace". + level: Option, +} + +/// Response for paginated log reading. +#[derive(Serialize)] +pub struct LogResponse { + /// The log lines (after filtering). + lines: Vec, + /// Byte offset for the next page in the current direction. + next_offset: u64, + /// Whether more data exists in the given direction. + has_more: bool, + /// Total log file size in bytes. + file_size: u64, +} + +/// Query parameters for the live-tail SSE stream. +#[derive(Deserialize)] +pub struct LogStreamQuery { + /// Case-insensitive substring filter applied to each line. + filter: Option, + /// Filter by log level: "error", "warn", "info", "debug", "trace". + level: Option, +} + +/// Resolve the log file path from config, canonicalizing relative paths against cwd. +fn resolve_log_path(config_path: &str) -> Result { + let path = Path::new(config_path); + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + let cwd = std::env::current_dir().map_err(|e| { + warn!("Failed to get cwd for log path resolution: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(cwd.join(path)) + } +} + +/// Check if a log line matches the given level filter. +/// +/// Looks for the tracing level token (e.g. " INFO ", " WARN ") in the line. +/// For JSON-formatted logs, looks for `"level":""`. +fn matches_level(line: &str, level: &str) -> bool { + let level_upper = level.to_ascii_uppercase(); + + // Text format: "2024-01-01T00:00:00Z INFO skit::server: ..." + // The level token is typically surrounded by whitespace. + let text_token = format!(" {level_upper} "); + if line.contains(&text_token) { + return true; + } + + // Also match " INFO " (double-space before, common in tracing output) + let text_token_double = format!(" {level_upper} "); + if line.contains(&text_token_double) { + return true; + } + + // JSON format: {"level":"INFO", ...} + let json_token = format!("\"level\":\"{level_upper}\""); + if line.contains(&json_token) { + return true; + } + + // JSON with lowercase + let json_token_lower = format!("\"level\":\"{}\"", level.to_ascii_lowercase()); + line.contains(&json_token_lower) +} + +/// Check if a line passes both filters (level + substring). +fn line_passes_filters(line: &str, level: Option<&str>, filter: Option<&str>) -> bool { + if let Some(lvl) = level { + if !lvl.is_empty() && !matches_level(line, lvl) { + return false; + } + } + if let Some(f) = filter { + if !f.is_empty() && !line.to_ascii_lowercase().contains(&f.to_ascii_lowercase()) { + return false; + } + } + true +} + +/// Handler: reads a page of log lines from the configured log file. +/// +/// RBAC: requires `access_all_sessions` (admin only). +/// +/// # Errors +/// +/// Returns `StatusCode::FORBIDDEN` if the caller lacks admin permissions, +/// `StatusCode::NOT_FOUND` if file logging is disabled or the log file does not exist, +/// or `StatusCode::INTERNAL_SERVER_ERROR` on I/O failures. +pub async fn get_logs_handler( + State(app_state): State>, + headers: HeaderMap, + Query(query): Query, +) -> Result { + // Check permissions — admin only + let perms = crate::role_extractor::get_permissions(&headers, &app_state); + if !perms.access_all_sessions { + return Err(StatusCode::FORBIDDEN); + } + + // Check that file logging is enabled + if !app_state.config.log.file_enable { + return Err(StatusCode::NOT_FOUND); + } + + let log_path = resolve_log_path(&app_state.config.log.file_path)?; + + if !log_path.exists() { + return Err(StatusCode::NOT_FOUND); + } + + let limit = query.limit.unwrap_or(DEFAULT_LINE_LIMIT).min(MAX_LINE_LIMIT); + + let direction = query.direction.as_deref().unwrap_or("forward"); + + let file = tokio::fs::File::open(&log_path).await.map_err(|e| { + warn!("Failed to open log file {}: {e}", log_path.display()); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let metadata = file.metadata().await.map_err(|e| { + warn!("Failed to read log file metadata: {e}",); + StatusCode::INTERNAL_SERVER_ERROR + })?; + let file_size = metadata.len(); + + let response = if direction == "backward" { + read_backward( + file, + file_size, + query.offset, + limit, + query.level.as_deref(), + query.filter.as_deref(), + ) + .await? + } else { + read_forward( + file, + file_size, + query.offset.unwrap_or(0), + limit, + query.level.as_deref(), + query.filter.as_deref(), + ) + .await? + }; + + debug!( + lines = response.lines.len(), + next_offset = response.next_offset, + has_more = response.has_more, + file_size = response.file_size, + "Log viewer: served page" + ); + + Ok(Json(response)) +} + +/// Read lines forward from the given byte offset. +async fn read_forward( + mut file: tokio::fs::File, + file_size: u64, + offset: u64, + limit: usize, + level: Option<&str>, + filter: Option<&str>, +) -> Result { + let seek_to = offset.min(file_size); + file.seek(std::io::SeekFrom::Start(seek_to)).await.map_err(|e| { + warn!("Failed to seek log file: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let reader = BufReader::new(file); + let mut lines_iter = reader.lines(); + let mut lines = Vec::with_capacity(limit.min(256)); + let mut bytes_read: u64 = 0; + + // If we started mid-file and the offset isn't 0, skip the first partial line + if seek_to > 0 { + if let Some(partial) = lines_iter.next_line().await.map_err(|e| { + warn!("Failed to read log line: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })? { + bytes_read += partial.len() as u64 + 1; // +1 for newline + } + } + + while lines.len() < limit { + match lines_iter.next_line().await { + Ok(Some(line)) => { + bytes_read += line.len() as u64 + 1; + if line_passes_filters(&line, level, filter) { + lines.push(line); + } + }, + Ok(None) => break, + Err(e) => { + warn!("Error reading log line: {e}"); + break; + }, + } + } + + let next_offset = seek_to + bytes_read; + let has_more = next_offset < file_size; + + Ok(LogResponse { lines, next_offset, has_more, file_size }) +} + +/// Read lines backward from the given byte offset (or end of file). +/// +/// Reads a chunk backward from the offset, splits into lines, and returns +/// up to `limit` lines (in chronological order). +async fn read_backward( + mut file: tokio::fs::File, + file_size: u64, + offset: Option, + limit: usize, + level: Option<&str>, + filter: Option<&str>, +) -> Result { + let end = offset.unwrap_or(file_size).min(file_size); + + if end == 0 { + return Ok(LogResponse { lines: Vec::new(), next_offset: 0, has_more: false, file_size }); + } + + let mut collected: Vec = Vec::with_capacity(limit.min(256)); + let mut current_end = end; + + // Read in chunks working backward until we have enough lines or reach start of file + while collected.len() < limit && current_end > 0 { + // Read a chunk large enough to likely contain the lines we need. + // Start with 8KB per remaining line needed, min 32KB. + let remaining_lines = limit - collected.len(); + let chunk_size: u64 = (remaining_lines as u64 * 8192).max(32768).min(current_end); + let chunk_start = current_end.saturating_sub(chunk_size); + + file.seek(std::io::SeekFrom::Start(chunk_start)).await.map_err(|e| { + warn!("Failed to seek log file: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let read_len = usize::try_from(current_end - chunk_start).unwrap_or(usize::MAX); + let mut buf = vec![0u8; read_len]; + file.read_exact(&mut buf).await.map_err(|e| { + warn!("Failed to read log file chunk: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let text = String::from_utf8_lossy(&buf); + let mut chunk_lines: Vec<&str> = text.split('\n').collect(); + + // If we're not at the start of the file, the first element is a partial line — drop it + if chunk_start > 0 && !chunk_lines.is_empty() { + chunk_lines.remove(0); + } + + // Remove trailing empty element from split + if chunk_lines.last().is_some_and(|l| l.is_empty()) { + chunk_lines.pop(); + } + + // Filter and prepend (we're going backward, so prepend to maintain order) + let filtered: Vec = chunk_lines + .into_iter() + .filter(|line| !line.is_empty() && line_passes_filters(line, level, filter)) + .map(String::from) + .collect(); + + // Take from the end of filtered to fill our remaining capacity + let take_count = remaining_lines.min(filtered.len()); + let start_idx = filtered.len().saturating_sub(take_count); + let mut new_lines: Vec = filtered[start_idx..].to_vec(); + new_lines.append(&mut collected); + collected = new_lines; + + current_end = chunk_start; + } + + // Truncate to limit if we over-collected + if collected.len() > limit { + let excess = collected.len() - limit; + collected = collected[excess..].to_vec(); + } + + Ok(LogResponse { + lines: collected, + next_offset: current_end, + has_more: current_end > 0, + file_size, + }) +} + +/// SSE endpoint: streams new log lines as they are appended to the log file. +/// +/// RBAC: requires `access_all_sessions` (admin only). +/// +/// # Errors +/// +/// Returns `StatusCode::FORBIDDEN` if the caller lacks admin permissions, +/// `StatusCode::NOT_FOUND` if file logging is disabled or the log file does not exist, +/// or `StatusCode::INTERNAL_SERVER_ERROR` on I/O failures. +pub async fn stream_logs_handler( + State(app_state): State>, + headers: HeaderMap, + Query(query): Query, +) -> Result>>, StatusCode> { + // Check permissions — admin only + let perms = crate::role_extractor::get_permissions(&headers, &app_state); + if !perms.access_all_sessions { + return Err(StatusCode::FORBIDDEN); + } + + if !app_state.config.log.file_enable { + return Err(StatusCode::NOT_FOUND); + } + + let log_path = resolve_log_path(&app_state.config.log.file_path)?; + + if !log_path.exists() { + return Err(StatusCode::NOT_FOUND); + } + + let filter = query.filter; + let level = query.level; + + let stream = async_stream::stream! { + // Open file and seek to end + let Ok(mut file) = tokio::fs::File::open(&log_path).await else { + yield Ok(Event::default().data("[error] Failed to open log file")); + return; + }; + + let Ok(metadata) = file.metadata().await else { + yield Ok(Event::default().data("[error] Failed to read log file metadata")); + return; + }; + + let mut last_size = metadata.len(); + if file.seek(std::io::SeekFrom::End(0)).await.is_err() { + yield Ok(Event::default().data("[error] Failed to seek to end of log file")); + return; + } + + loop { + tokio::time::sleep(tokio::time::Duration::from_millis(TAIL_POLL_INTERVAL_MS)).await; + + // Check current file size + let Ok(metadata) = tokio::fs::metadata(&log_path).await else { + continue; + }; + let current_size = metadata.len(); + + if current_size < last_size { + // File was truncated (rotated) — reopen from start + let Ok(new_file) = tokio::fs::File::open(&log_path).await else { + continue; + }; + file = new_file; + last_size = 0; + yield Ok(Event::default().event("truncated").data("Log file was rotated")); + continue; + } + + if current_size == last_size { + continue; + } + + // Read new data + let new_bytes = usize::try_from(current_size - last_size).unwrap_or(usize::MAX); + let mut buf = vec![0u8; new_bytes]; + if file.read_exact(&mut buf).await.is_err() { + // Re-seek if read fails + let _ = file.seek(std::io::SeekFrom::Start(current_size)).await; + last_size = current_size; + continue; + } + + last_size = current_size; + + let text = String::from_utf8_lossy(&buf); + let new_lines: Vec<&str> = text.split('\n') + .filter(|line| { + !line.is_empty() + && line_passes_filters(line, level.as_deref(), filter.as_deref()) + }) + .collect(); + + if new_lines.is_empty() { + continue; + } + + // Send lines in batches to avoid overwhelming the client + for chunk in new_lines.chunks(TAIL_MAX_LINES_PER_EVENT) { + let payload = chunk.join("\n"); + yield Ok(Event::default().data(payload)); + } + } + }; + + Ok(Sse::new(stream).keep_alive(KeepAlive::default())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_matches_level_text_format() { + let line = "2024-01-01T00:00:00Z INFO skit::server: Starting server"; + assert!(matches_level(line, "info")); + assert!(matches_level(line, "INFO")); + assert!(!matches_level(line, "warn")); + assert!(!matches_level(line, "error")); + } + + #[test] + fn test_matches_level_json_format() { + let line = r#"{"timestamp":"2024-01-01T00:00:00Z","level":"WARN","message":"test"}"#; + assert!(matches_level(line, "warn")); + assert!(matches_level(line, "WARN")); + assert!(!matches_level(line, "info")); + } + + #[test] + fn test_line_passes_filters_no_filters() { + assert!(line_passes_filters("any line", None, None)); + } + + #[test] + fn test_line_passes_filters_level_only() { + let line = "2024-01-01T00:00:00Z WARN skit::server: Something happened"; + assert!(line_passes_filters(line, Some("warn"), None)); + assert!(!line_passes_filters(line, Some("error"), None)); + } + + #[test] + fn test_line_passes_filters_text_only() { + let line = "2024-01-01T00:00:00Z INFO skit::server: Starting server"; + assert!(line_passes_filters(line, None, Some("starting"))); + assert!(line_passes_filters(line, None, Some("SERVER"))); + assert!(!line_passes_filters(line, None, Some("shutdown"))); + } + + #[test] + fn test_line_passes_filters_both() { + let line = "2024-01-01T00:00:00Z INFO skit::server: Starting server"; + assert!(line_passes_filters(line, Some("info"), Some("starting"))); + assert!(!line_passes_filters(line, Some("warn"), Some("starting"))); + assert!(!line_passes_filters(line, Some("info"), Some("shutdown"))); + } + + #[test] + fn test_line_passes_filters_empty_strings() { + let line = "any line"; + assert!(line_passes_filters(line, Some(""), None)); + assert!(line_passes_filters(line, None, Some(""))); + assert!(line_passes_filters(line, Some(""), Some(""))); + } +} diff --git a/apps/skit/src/main.rs b/apps/skit/src/main.rs index 451b76e1..c86df8f7 100644 --- a/apps/skit/src/main.rs +++ b/apps/skit/src/main.rs @@ -35,6 +35,7 @@ mod auth; mod cli; mod config; mod file_security; +mod log_viewer; mod logging; mod marketplace; mod marketplace_installer; diff --git a/apps/skit/src/server.rs b/apps/skit/src/server.rs index ba151091..fe1fce62 100644 --- a/apps/skit/src/server.rs +++ b/apps/skit/src/server.rs @@ -3115,6 +3115,8 @@ pub fn create_app( .route("/api/v1/config", get(get_config_handler)) .route("/api/v1/schema/nodes", get(list_node_definitions_handler)) .route("/api/v1/schema/packets", get(list_packet_types_handler)) + .route("/api/v1/logs", get(crate::log_viewer::get_logs_handler)) + .route("/api/v1/logs/stream", get(crate::log_viewer::stream_logs_handler)) .route("/api/v1/sessions", get(list_sessions_handler).post(create_session_handler)) .route("/api/v1/sessions/{id}", delete(destroy_session_handler)) .route("/api/v1/sessions/{id}/pipeline", get(get_pipeline_handler)) diff --git a/e2e/tests/auth-helpers.ts b/e2e/tests/auth-helpers.ts index 7f85a148..845473a9 100644 --- a/e2e/tests/auth-helpers.ts +++ b/e2e/tests/auth-helpers.ts @@ -66,6 +66,7 @@ export async function ensureLoggedIn(page: Page): Promise { page.getByTestId('convert-view'), page.getByTestId('stream-view'), page.getByTestId('tokens-view'), + page.getByTestId('logs-view'), ]; // Wait for the app to settle on either: diff --git a/e2e/tests/logs.spec.ts b/e2e/tests/logs.spec.ts new file mode 100644 index 00000000..d20ec126 --- /dev/null +++ b/e2e/tests/logs.spec.ts @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +import { test, expect } from '@playwright/test'; + +import { ensureLoggedIn, getAuthHeaders } from './auth-helpers'; + +test.describe('Log Viewer', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/admin/logs'); + await ensureLoggedIn(page); + }); + + test('navigates to log viewer and displays logs', async ({ page }) => { + await expect(page.getByTestId('logs-view')).toBeVisible(); + + // Should show the title + await expect(page.getByText('Logs')).toBeVisible(); + + // Should show the admin nav with Logs link active + await expect(page.getByRole('link', { name: 'Logs' })).toBeVisible(); + + // Log container should be present + await expect(page.getByTestId('logs-container')).toBeVisible(); + + // Wait for logs to load (the server generates logs, so there should be some) + await expect(page.getByTestId('logs-container')).not.toBeEmpty({ timeout: 10000 }); + }); + + test('filter controls are present and functional', async ({ page }) => { + await expect(page.getByTestId('logs-view')).toBeVisible(); + + // Filter input should be visible + const filterInput = page.getByTestId('logs-filter-input'); + await expect(filterInput).toBeVisible(); + + // Level select should be visible + const levelSelect = page.getByTestId('logs-level-select'); + await expect(levelSelect).toBeVisible(); + + // Filter button should be visible + const filterButton = page.getByTestId('logs-apply-filter'); + await expect(filterButton).toBeVisible(); + + // Live tail button should be visible + const liveTailButton = page.getByTestId('logs-live-tail'); + await expect(liveTailButton).toBeVisible(); + }); + + test('pagination buttons are present', async ({ page }) => { + await expect(page.getByTestId('logs-view')).toBeVisible(); + + // Wait for initial load + await expect(page.getByTestId('logs-container')).not.toBeEmpty({ timeout: 10000 }); + + // Pagination buttons should be visible + await expect(page.getByTestId('logs-load-older')).toBeVisible(); + await expect(page.getByTestId('logs-load-newer')).toBeVisible(); + await expect(page.getByTestId('logs-load-latest')).toBeVisible(); + }); + + test('level filter changes displayed logs', async ({ page }) => { + await expect(page.getByTestId('logs-view')).toBeVisible(); + + // Wait for initial load + await expect(page.getByTestId('logs-container')).not.toBeEmpty({ timeout: 10000 }); + + // Select "Error" level filter + await page.getByTestId('logs-level-select').selectOption('error'); + await page.getByTestId('logs-apply-filter').click(); + + // Wait briefly for the filtered results + await page.waitForTimeout(1000); + + // The log container should still be present (may be empty if no errors) + await expect(page.getByTestId('logs-container')).toBeVisible(); + }); + + test('text filter applies correctly', async ({ page }) => { + await expect(page.getByTestId('logs-view')).toBeVisible(); + + // Wait for initial load + await expect(page.getByTestId('logs-container')).not.toBeEmpty({ timeout: 10000 }); + + // Type a filter that should match some logs + await page.getByTestId('logs-filter-input').fill('skit'); + await page.getByTestId('logs-apply-filter').click(); + + // Wait for filtered results + await page.waitForTimeout(1000); + + // Container should still be visible + await expect(page.getByTestId('logs-container')).toBeVisible(); + }); + + test('log API returns valid response', async ({ page }) => { + const headers = getAuthHeaders(); + + // Test the logs API directly + const response = await page.request.get('/api/v1/logs?limit=10&direction=backward', { + headers, + }); + + expect(response.ok()).toBeTruthy(); + + const body = (await response.json()) as { + lines: string[]; + next_offset: number; + has_more: boolean; + file_size: number; + }; + + expect(body).toHaveProperty('lines'); + expect(body).toHaveProperty('next_offset'); + expect(body).toHaveProperty('has_more'); + expect(body).toHaveProperty('file_size'); + expect(Array.isArray(body.lines)).toBeTruthy(); + expect(typeof body.next_offset).toBe('number'); + expect(typeof body.has_more).toBe('boolean'); + expect(typeof body.file_size).toBe('number'); + expect(body.file_size).toBeGreaterThan(0); + }); + + test('log API supports forward pagination', async ({ page }) => { + const headers = getAuthHeaders(); + + // First request: get first page + const firstPage = await page.request.get('/api/v1/logs?limit=5&direction=forward&offset=0', { + headers, + }); + expect(firstPage.ok()).toBeTruthy(); + + const firstBody = (await firstPage.json()) as { + lines: string[]; + next_offset: number; + has_more: boolean; + }; + + if (firstBody.has_more) { + // Second request: get next page using next_offset + const secondPage = await page.request.get( + `/api/v1/logs?limit=5&direction=forward&offset=${firstBody.next_offset}`, + { headers } + ); + expect(secondPage.ok()).toBeTruthy(); + + const secondBody = (await secondPage.json()) as { + lines: string[]; + next_offset: number; + }; + + // next_offset should advance + expect(secondBody.next_offset).toBeGreaterThanOrEqual(firstBody.next_offset); + } + }); + + test('log API supports level filtering', async ({ page }) => { + const headers = getAuthHeaders(); + + const response = await page.request.get('/api/v1/logs?limit=100&direction=backward&level=info', { + headers, + }); + + expect(response.ok()).toBeTruthy(); + + const body = (await response.json()) as { lines: string[] }; + + // All returned lines should contain INFO level marker + for (const line of body.lines) { + const hasInfoLevel = / INFO /i.test(line) || /"level":"INFO"/i.test(line); + expect(hasInfoLevel).toBeTruthy(); + } + }); + + test('admin nav shows Logs link on all admin pages', async ({ page }) => { + // Check logs link is present on plugins page + await page.goto('/admin/plugins'); + await ensureLoggedIn(page); + await expect(page.getByRole('link', { name: 'Logs' })).toBeVisible(); + + // Check logs link is present on tokens page + await page.goto('/admin/tokens'); + await expect(page.getByRole('link', { name: 'Logs' })).toBeVisible(); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ba59e3d3..641c12dc 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -20,6 +20,7 @@ import { getLogger } from './utils/logger'; import ConvertView from './views/ConvertView'; import DesignView from './views/DesignView'; import LoginView from './views/LoginView'; +import LogsView from './views/LogsView'; import MonitorView from './views/MonitorView'; import PluginsView from './views/PluginsView'; import StreamView from './views/StreamView'; @@ -124,6 +125,7 @@ const App: React.FC = () => { /> } /> } /> + } /> diff --git a/ui/src/services/logs.ts b/ui/src/services/logs.ts new file mode 100644 index 00000000..e2cdb89b --- /dev/null +++ b/ui/src/services/logs.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +import { fetchApi, getApiUrl } from './base'; + +export interface LogResponse { + lines: string[]; + next_offset: number; + has_more: boolean; + file_size: number; +} + +export interface LogQueryParams { + offset?: number; + limit?: number; + direction?: 'forward' | 'backward'; + filter?: string; + level?: string; +} + +/** + * Fetch a page of log lines from the server. + */ +export async function fetchLogs(params: LogQueryParams = {}): Promise { + const searchParams = new URLSearchParams(); + if (params.offset !== undefined) searchParams.set('offset', String(params.offset)); + if (params.limit !== undefined) searchParams.set('limit', String(params.limit)); + if (params.direction) searchParams.set('direction', params.direction); + if (params.filter) searchParams.set('filter', params.filter); + if (params.level) searchParams.set('level', params.level); + + const qs = searchParams.toString(); + const path = qs ? `/api/v1/logs?${qs}` : '/api/v1/logs'; + + const response = await fetchApi(path); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Log file not available. File logging may be disabled.'); + } + throw new Error(`Failed to fetch logs: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Create an EventSource for live-tailing the log file via SSE. + */ +export function createLogStream(params?: { filter?: string; level?: string }): EventSource { + const searchParams = new URLSearchParams(); + if (params?.filter) searchParams.set('filter', params.filter); + if (params?.level) searchParams.set('level', params.level); + + const qs = searchParams.toString(); + const apiUrl = getApiUrl(); + const base = `${apiUrl}/api/v1/logs/stream`; + const url = qs ? `${base}?${qs}` : base; + + return new EventSource(url, { withCredentials: true }); +} diff --git a/ui/src/views/LogsView.styles.ts b/ui/src/views/LogsView.styles.ts new file mode 100644 index 00000000..b9e03784 --- /dev/null +++ b/ui/src/views/LogsView.styles.ts @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +import styled from '@emotion/styled'; + +export const Container = styled.div` + box-sizing: border-box; + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--sk-bg); +`; + +export const ContentArea = styled.div` + flex: 1; + overflow-y: auto; + display: flex; + justify-content: center; + min-width: 0; + min-height: 0; +`; + +export const ContentWrapper = styled.div` + width: 100%; + max-width: 1200px; + padding: 40px; + box-sizing: border-box; + display: flex; + flex-direction: column; + min-height: 0; + + @media (max-width: 768px) { + padding: 24px; + } +`; + +export const Card = styled.div` + box-sizing: border-box; + width: 100%; + background: var(--sk-panel-bg); + border: 1px solid var(--sk-border); + border-radius: 12px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; + flex: 1; + min-height: 0; +`; + +export const TitleRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +`; + +export const Title = styled.h1` + margin: 0; + font-size: 20px; + font-weight: 700; + color: var(--sk-text); +`; + +export const Subtle = styled.div` + color: var(--sk-text-muted); + font-size: 13px; +`; + +export const FilterBar = styled.div` + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +`; + +export const SearchInput = styled.input` + flex: 1; + min-width: 200px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--sk-border); + background: var(--sk-bg); + color: var(--sk-text); + font-size: 13px; + + &:focus { + outline: none; + border-color: var(--sk-primary); + box-shadow: var(--sk-focus-ring); + } + + &::placeholder { + color: var(--sk-text-muted); + } +`; + +export const LevelSelect = styled.select` + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--sk-border); + background: var(--sk-bg); + color: var(--sk-text); + font-size: 13px; +`; + +export const LogContainer = styled.div` + flex: 1; + min-height: 200px; + max-height: 600px; + overflow-y: auto; + background: var(--sk-bg); + border: 1px solid var(--sk-border); + border-radius: 8px; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + font-size: 12px; + line-height: 1.6; + padding: 12px; +`; + +export const LogLine = styled.div<{ $level?: string }>` + white-space: pre-wrap; + word-break: break-all; + padding: 1px 0; + + ${(props) => { + switch (props.$level) { + case 'error': + return 'color: var(--sk-danger);'; + case 'warn': + return 'color: var(--sk-warning);'; + case 'debug': + case 'trace': + return 'color: var(--sk-text-muted);'; + default: + return 'color: var(--sk-text);'; + } + }} +`; + +export const PaginationRow = styled.div` + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +`; + +export const PaginationInfo = styled.span` + font-size: 12px; + color: var(--sk-text-muted); +`; + +export const EmptyState = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + color: var(--sk-text-muted); + font-size: 14px; +`; + +export const ErrorBox = styled.div` + padding: 12px; + border-radius: 10px; + border: 1px solid var(--sk-border); + background: color-mix(in srgb, var(--sk-danger) 10%, transparent); + color: var(--sk-text); + font-size: 13px; +`; + +export const LiveIndicator = styled.span<{ $active: boolean }>` + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: ${(props) => (props.$active ? 'var(--sk-success)' : 'var(--sk-text-muted)')}; + + &::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: ${(props) => (props.$active ? 'var(--sk-success)' : 'var(--sk-text-muted)')}; + ${(props) => + props.$active + ? `animation: pulse 1.5s ease-in-out infinite; + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + }` + : ''} + } +`; diff --git a/ui/src/views/LogsView.tsx b/ui/src/views/LogsView.tsx new file mode 100644 index 00000000..5c1fa3fd --- /dev/null +++ b/ui/src/views/LogsView.tsx @@ -0,0 +1,395 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { Button } from '@/components/ui/Button'; +import { usePermissions } from '@/hooks/usePermissions'; +import { createLogStream, fetchLogs, type LogResponse } from '@/services/logs'; +import { getLogger } from '@/utils/logger'; + +import AdminNav from './admin/AdminNav'; +import { + Card, + Container, + ContentArea, + ContentWrapper, + EmptyState, + ErrorBox, + FilterBar, + LevelSelect, + LiveIndicator, + LogContainer, + LogLine, + PaginationInfo, + PaginationRow, + SearchInput, + Subtle, + Title, + TitleRow, +} from './LogsView.styles'; + +const logger = getLogger('LogsView'); + +const PAGE_SIZE = 500; + +function detectLevel(line: string): string | undefined { + if (/ ERROR /i.test(line) || /"level":"ERROR"/i.test(line)) return 'error'; + if (/ WARN /i.test(line) || /"level":"WARN"/i.test(line)) return 'warn'; + if (/ DEBUG /i.test(line) || /"level":"DEBUG"/i.test(line)) return 'debug'; + if (/ TRACE /i.test(line) || /"level":"TRACE"/i.test(line)) return 'trace'; + return undefined; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +interface UseLogViewerResult { + lines: string[]; + isLoading: boolean; + error: string | null; + fileSize: number; + hasMore: boolean; + liveTail: boolean; + filterText: string; + levelFilter: string; + logContainerRef: React.RefObject; + setFilterText: (v: string) => void; + setLevelFilter: (v: string) => void; + handleApplyFilters: () => void; + handleKeyDown: (e: React.KeyboardEvent) => void; + handleLoadNewer: () => void; + handleLoadOlder: () => void; + handleLoadLatest: () => void; + handleToggleLiveTail: () => void; + handleScroll: () => void; + handleLevelChange: (e: React.ChangeEvent) => void; +} + +function useLogViewer(shouldLoad: boolean): UseLogViewerResult { + const [lines, setLines] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [fileSize, setFileSize] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [nextOffset, setNextOffset] = useState(0); + const [currentOffset, setCurrentOffset] = useState(undefined); + + const [filterText, setFilterText] = useState(''); + const [levelFilter, setLevelFilter] = useState(''); + const [appliedFilter, setAppliedFilter] = useState(''); + const [appliedLevel, setAppliedLevel] = useState(''); + + const [liveTail, setLiveTail] = useState(false); + const eventSourceRef = useRef(null); + const logContainerRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + + const scrollToBottom = useCallback(() => { + if (logContainerRef.current && autoScroll) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [autoScroll]); + + const loadLogs = useCallback( + async (direction: 'forward' | 'backward', offset?: number) => { + setIsLoading(true); + setError(null); + try { + const response: LogResponse = await fetchLogs({ + offset, + limit: PAGE_SIZE, + direction, + filter: appliedFilter || undefined, + level: appliedLevel || undefined, + }); + setLines(response.lines); + setFileSize(response.file_size); + setHasMore(response.has_more); + setNextOffset(response.next_offset); + setCurrentOffset(offset); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load logs'; + logger.error('Failed to load logs:', err); + setError(message); + } finally { + setIsLoading(false); + } + }, + [appliedFilter, appliedLevel] + ); + + // Load latest logs on mount + useEffect(() => { + if (shouldLoad) { + loadLogs('backward'); + } + }, [loadLogs, shouldLoad]); + + // Scroll to bottom when lines change + useEffect(() => { + scrollToBottom(); + }, [lines, scrollToBottom]); + + // Live tail management + useEffect(() => { + if (!liveTail) { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + return; + } + + const es = createLogStream({ + filter: appliedFilter || undefined, + level: appliedLevel || undefined, + }); + + es.onmessage = (event: MessageEvent) => { + const newLines = (event.data as string).split('\n').filter(Boolean); + if (newLines.length > 0) { + setLines((prev) => { + const combined = [...prev, ...newLines]; + // Keep a reasonable buffer during live tail + if (combined.length > 5000) { + return combined.slice(combined.length - 5000); + } + return combined; + }); + } + }; + + es.onerror = () => { + logger.error('Log stream connection error'); + }; + + es.addEventListener('truncated', () => { + setLines([]); + }); + + eventSourceRef.current = es; + + return () => { + es.close(); + eventSourceRef.current = null; + }; + }, [liveTail, appliedFilter, appliedLevel]); + + const handleApplyFilters = useCallback(() => { + setAppliedFilter(filterText); + setAppliedLevel(levelFilter); + setLiveTail(false); + }, [filterText, levelFilter]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleApplyFilters(); + } + }, + [handleApplyFilters] + ); + + const handleLoadNewer = useCallback(() => { + loadLogs('forward', nextOffset); + }, [loadLogs, nextOffset]); + + const handleLoadOlder = useCallback(() => { + if (currentOffset !== undefined && currentOffset > 0) { + loadLogs('backward', currentOffset); + } else { + loadLogs('backward'); + } + }, [loadLogs, currentOffset]); + + const handleLoadLatest = useCallback(() => { + loadLogs('backward'); + }, [loadLogs]); + + const handleToggleLiveTail = useCallback(() => { + if (!liveTail) { + // Starting live tail — first load latest, then enable streaming + loadLogs('backward').then(() => { + setLiveTail(true); + setAutoScroll(true); + }); + } else { + setLiveTail(false); + } + }, [liveTail, loadLogs]); + + const handleScroll = useCallback(() => { + const container = logContainerRef.current; + if (!container) return; + const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50; + setAutoScroll(isAtBottom); + }, []); + + const handleLevelChange = useCallback((e: React.ChangeEvent) => { + setLevelFilter(e.target.value); + }, []); + + return { + lines, + isLoading, + error, + fileSize, + hasMore, + liveTail, + filterText, + levelFilter, + logContainerRef, + setFilterText, + setLevelFilter, + handleApplyFilters, + handleKeyDown, + handleLoadNewer, + handleLoadOlder, + handleLoadLatest, + handleToggleLiveTail, + handleScroll, + handleLevelChange, + }; +} + +const LogsView: React.FC = () => { + const { role, isAdmin } = usePermissions(); + const admin = isAdmin(); + const lv = useLogViewer(admin); + + if (!admin) { + return ( + + + + + +
+ Logs + Role: {role ?? 'unknown'} +
+
+ + Admin role required to view logs. +
+
+
+
+ ); + } + + return ( + + + + + +
+ Logs + + Server log viewer{lv.fileSize > 0 && ` \u2022 ${formatFileSize(lv.fileSize)}`} + +
+ {lv.liveTail ? 'Live' : 'Paused'} +
+ + + + {lv.error && {lv.error}} + + + lv.setFilterText(e.target.value)} + onKeyDown={lv.handleKeyDown} + data-testid="logs-filter-input" + /> + + + + + + + + + + + + + {lv.lines.length === 0 && !lv.isLoading && ( + No log lines to display. + )} + {lv.lines.map((line, i) => ( + + {line} + + ))} + + + +
+ + + +
+ + {lv.lines.length} lines + {lv.fileSize > 0 && ` \u2022 ${formatFileSize(lv.fileSize)}`} + +
+
+
+
+
+ ); +}; + +export default LogsView; diff --git a/ui/src/views/admin/AdminNav.tsx b/ui/src/views/admin/AdminNav.tsx index 48791ebf..3b642f25 100644 --- a/ui/src/views/admin/AdminNav.tsx +++ b/ui/src/views/admin/AdminNav.tsx @@ -15,6 +15,9 @@ const AdminNav: React.FC = () => { (isActive ? 'active' : '')}> Tokens + (isActive ? 'active' : '')}> + Logs + ); }; From 8cb7d9d849447636a3cab0d827f0b3e1ab25d3c0 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sun, 22 Mar 2026 21:06:54 +0000 Subject: [PATCH 02/10] style: format e2e logs test with prettier Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- e2e/tests/logs.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/e2e/tests/logs.spec.ts b/e2e/tests/logs.spec.ts index d20ec126..b9c6b6bf 100644 --- a/e2e/tests/logs.spec.ts +++ b/e2e/tests/logs.spec.ts @@ -158,9 +158,12 @@ test.describe('Log Viewer', () => { test('log API supports level filtering', async ({ page }) => { const headers = getAuthHeaders(); - const response = await page.request.get('/api/v1/logs?limit=100&direction=backward&level=info', { - headers, - }); + const response = await page.request.get( + '/api/v1/logs?limit=100&direction=backward&level=info', + { + headers, + } + ); expect(response.ok()).toBeTruthy(); From 25fc06848e935308777dc7febe1a146683219b10 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sun, 22 Mar 2026 21:20:50 +0000 Subject: [PATCH 03/10] fix: resolve log viewer bugs and E2E test failures - Fix backward multi-chunk reading dropping lines at chunk boundaries by using a carry buffer to join partial lines across chunks - Fix handleLoadOlder using wrong offset (currentOffset was undefined after initial backward load; now uses nextOffset) - Remove unused currentOffset state variable - Fix E2E tests: use heading role for ambiguous 'Logs' text selector, remove getAuthHeaders() (cookies from ensureLoggedIn suffice), skip API tests gracefully when file logging is disabled (CI), fix admin nav test to avoid plugins page (no testid in appViews) - Add unit test for backward multi-chunk reading to prevent regression Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- apps/skit/src/log_viewer.rs | 78 +++++++++++++++++++++++++++--------- e2e/tests/logs.spec.ts | 80 +++++++++++++++---------------------- ui/src/views/LogsView.tsx | 10 ++--- 3 files changed, 96 insertions(+), 72 deletions(-) diff --git a/apps/skit/src/log_viewer.rs b/apps/skit/src/log_viewer.rs index c439964c..57d4d8c7 100644 --- a/apps/skit/src/log_viewer.rs +++ b/apps/skit/src/log_viewer.rs @@ -262,8 +262,8 @@ async fn read_forward( /// Read lines backward from the given byte offset (or end of file). /// -/// Reads a chunk backward from the offset, splits into lines, and returns -/// up to `limit` lines (in chronological order). +/// Reads in chunks working backward from the offset, joining partial lines +/// at chunk boundaries via a carry buffer so no lines are lost. async fn read_backward( mut file: tokio::fs::File, file_size: u64, @@ -280,6 +280,9 @@ async fn read_backward( let mut collected: Vec = Vec::with_capacity(limit.min(256)); let mut current_end = end; + // Partial line fragment carried from the start of the previously-read (higher-offset) chunk. + // It is the continuation of the last split element of the current chunk. + let mut carry = String::new(); // Read in chunks working backward until we have enough lines or reach start of file while collected.len() < limit && current_end > 0 { @@ -302,29 +305,38 @@ async fn read_backward( })?; let text = String::from_utf8_lossy(&buf); - let mut chunk_lines: Vec<&str> = text.split('\n').collect(); - - // If we're not at the start of the file, the first element is a partial line — drop it - if chunk_start > 0 && !chunk_lines.is_empty() { - chunk_lines.remove(0); - } - - // Remove trailing empty element from split - if chunk_lines.last().is_some_and(|l| l.is_empty()) { - chunk_lines.pop(); + let mut parts: Vec<&str> = text.split('\n').collect(); + + // The last split element may be a partial line whose continuation is in `carry`. + // Join them to form the complete line at this chunk's upper boundary. + let last = parts.pop().unwrap_or(""); + let completed_tail = + if carry.is_empty() { last.to_string() } else { format!("{last}{carry}") }; + + // If we're not at the start of the file, the first split element is a partial line + // whose beginning is in an earlier chunk. Save it as carry for the next iteration. + if chunk_start > 0 && !parts.is_empty() { + carry = parts.remove(0).to_string(); + } else { + carry = String::new(); } - // Filter and prepend (we're going backward, so prepend to maintain order) - let filtered: Vec = chunk_lines + // `parts` now contains only complete lines (no boundary partials). + // Filter them, then append the completed tail line. + let mut chunk_lines: Vec = parts .into_iter() .filter(|line| !line.is_empty() && line_passes_filters(line, level, filter)) .map(String::from) .collect(); - // Take from the end of filtered to fill our remaining capacity - let take_count = remaining_lines.min(filtered.len()); - let start_idx = filtered.len().saturating_sub(take_count); - let mut new_lines: Vec = filtered[start_idx..].to_vec(); + if !completed_tail.is_empty() && line_passes_filters(&completed_tail, level, filter) { + chunk_lines.push(completed_tail); + } + + // Take from the end of chunk_lines to fill remaining capacity, then prepend to collected + let take_count = remaining_lines.min(chunk_lines.len()); + let start_idx = chunk_lines.len().saturating_sub(take_count); + let mut new_lines: Vec = chunk_lines[start_idx..].to_vec(); new_lines.append(&mut collected); collected = new_lines; @@ -511,4 +523,34 @@ mod tests { assert!(line_passes_filters(line, None, Some(""))); assert!(line_passes_filters(line, Some(""), Some(""))); } + + /// Verify that backward reading with a small chunk size (forcing multi-chunk) + /// correctly joins partial lines at chunk boundaries instead of dropping them. + #[tokio::test] + async fn test_read_backward_multi_chunk_preserves_lines() { + use tokio::io::AsyncWriteExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.log"); + + // Write enough lines so that a small forced chunk triggers multi-chunk reading. + let mut f = tokio::fs::File::create(&path).await.unwrap(); + let mut expected: Vec = Vec::new(); + for i in 0..20 { + let line = format!("2024-01-01T00:00:00Z INFO test: line number {i}"); + f.write_all(line.as_bytes()).await.unwrap(); + f.write_all(b"\n").await.unwrap(); + expected.push(line); + } + f.flush().await.unwrap(); + drop(f); + + let file = tokio::fs::File::open(&path).await.unwrap(); + let file_size = file.metadata().await.unwrap().len(); + + let resp = read_backward(file, file_size, None, 100, None, None).await.unwrap(); + + assert_eq!(resp.lines, expected, "all lines should be returned in order"); + assert!(!resp.has_more); + } } diff --git a/e2e/tests/logs.spec.ts b/e2e/tests/logs.spec.ts index b9c6b6bf..2e83904b 100644 --- a/e2e/tests/logs.spec.ts +++ b/e2e/tests/logs.spec.ts @@ -4,7 +4,7 @@ import { test, expect } from '@playwright/test'; -import { ensureLoggedIn, getAuthHeaders } from './auth-helpers'; +import { ensureLoggedIn } from './auth-helpers'; test.describe('Log Viewer', () => { test.beforeEach(async ({ page }) => { @@ -12,20 +12,17 @@ test.describe('Log Viewer', () => { await ensureLoggedIn(page); }); - test('navigates to log viewer and displays logs', async ({ page }) => { + test('navigates to log viewer and displays UI', async ({ page }) => { await expect(page.getByTestId('logs-view')).toBeVisible(); // Should show the title - await expect(page.getByText('Logs')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Logs' })).toBeVisible(); // Should show the admin nav with Logs link active await expect(page.getByRole('link', { name: 'Logs' })).toBeVisible(); // Log container should be present await expect(page.getByTestId('logs-container')).toBeVisible(); - - // Wait for logs to load (the server generates logs, so there should be some) - await expect(page.getByTestId('logs-container')).not.toBeEmpty({ timeout: 10000 }); }); test('filter controls are present and functional', async ({ page }) => { @@ -51,21 +48,15 @@ test.describe('Log Viewer', () => { test('pagination buttons are present', async ({ page }) => { await expect(page.getByTestId('logs-view')).toBeVisible(); - // Wait for initial load - await expect(page.getByTestId('logs-container')).not.toBeEmpty({ timeout: 10000 }); - // Pagination buttons should be visible await expect(page.getByTestId('logs-load-older')).toBeVisible(); await expect(page.getByTestId('logs-load-newer')).toBeVisible(); await expect(page.getByTestId('logs-load-latest')).toBeVisible(); }); - test('level filter changes displayed logs', async ({ page }) => { + test('level filter can be changed', async ({ page }) => { await expect(page.getByTestId('logs-view')).toBeVisible(); - // Wait for initial load - await expect(page.getByTestId('logs-container')).not.toBeEmpty({ timeout: 10000 }); - // Select "Error" level filter await page.getByTestId('logs-level-select').selectOption('error'); await page.getByTestId('logs-apply-filter').click(); @@ -77,13 +68,10 @@ test.describe('Log Viewer', () => { await expect(page.getByTestId('logs-container')).toBeVisible(); }); - test('text filter applies correctly', async ({ page }) => { + test('text filter can be applied', async ({ page }) => { await expect(page.getByTestId('logs-view')).toBeVisible(); - // Wait for initial load - await expect(page.getByTestId('logs-container')).not.toBeEmpty({ timeout: 10000 }); - - // Type a filter that should match some logs + // Type a filter await page.getByTestId('logs-filter-input').fill('skit'); await page.getByTestId('logs-apply-filter').click(); @@ -94,13 +82,15 @@ test.describe('Log Viewer', () => { await expect(page.getByTestId('logs-container')).toBeVisible(); }); - test('log API returns valid response', async ({ page }) => { - const headers = getAuthHeaders(); + test('log API returns valid response when file logging is enabled', async ({ page }) => { + // Test the logs API directly (cookies from ensureLoggedIn provide auth) + const response = await page.request.get('/api/v1/logs?limit=10&direction=backward'); - // Test the logs API directly - const response = await page.request.get('/api/v1/logs?limit=10&direction=backward', { - headers, - }); + // File logging may be disabled in CI — skip gracefully + if (response.status() === 404) { + test.skip(true, 'File logging is disabled on this server'); + return; + } expect(response.ok()).toBeTruthy(); @@ -119,16 +109,16 @@ test.describe('Log Viewer', () => { expect(typeof body.next_offset).toBe('number'); expect(typeof body.has_more).toBe('boolean'); expect(typeof body.file_size).toBe('number'); - expect(body.file_size).toBeGreaterThan(0); }); - test('log API supports forward pagination', async ({ page }) => { - const headers = getAuthHeaders(); + test('log API supports forward pagination when file logging is enabled', async ({ page }) => { + const firstPage = await page.request.get('/api/v1/logs?limit=5&direction=forward&offset=0'); + + if (firstPage.status() === 404) { + test.skip(true, 'File logging is disabled on this server'); + return; + } - // First request: get first page - const firstPage = await page.request.get('/api/v1/logs?limit=5&direction=forward&offset=0', { - headers, - }); expect(firstPage.ok()).toBeTruthy(); const firstBody = (await firstPage.json()) as { @@ -138,10 +128,8 @@ test.describe('Log Viewer', () => { }; if (firstBody.has_more) { - // Second request: get next page using next_offset const secondPage = await page.request.get( - `/api/v1/logs?limit=5&direction=forward&offset=${firstBody.next_offset}`, - { headers } + `/api/v1/logs?limit=5&direction=forward&offset=${firstBody.next_offset}` ); expect(secondPage.ok()).toBeTruthy(); @@ -155,15 +143,13 @@ test.describe('Log Viewer', () => { } }); - test('log API supports level filtering', async ({ page }) => { - const headers = getAuthHeaders(); + test('log API supports level filtering when file logging is enabled', async ({ page }) => { + const response = await page.request.get('/api/v1/logs?limit=100&direction=backward&level=info'); - const response = await page.request.get( - '/api/v1/logs?limit=100&direction=backward&level=info', - { - headers, - } - ); + if (response.status() === 404) { + test.skip(true, 'File logging is disabled on this server'); + return; + } expect(response.ok()).toBeTruthy(); @@ -176,14 +162,14 @@ test.describe('Log Viewer', () => { } }); - test('admin nav shows Logs link on all admin pages', async ({ page }) => { - // Check logs link is present on plugins page - await page.goto('/admin/plugins'); - await ensureLoggedIn(page); + test('admin nav shows Logs link on admin pages', async ({ page }) => { + // Already on /admin/logs from beforeEach + await expect(page.getByTestId('logs-view')).toBeVisible(); await expect(page.getByRole('link', { name: 'Logs' })).toBeVisible(); - // Check logs link is present on tokens page + // Navigate to tokens page (already in ensureLoggedIn's appViews) await page.goto('/admin/tokens'); + await expect(page.getByTestId('tokens-view')).toBeVisible(); await expect(page.getByRole('link', { name: 'Logs' })).toBeVisible(); }); }); diff --git a/ui/src/views/LogsView.tsx b/ui/src/views/LogsView.tsx index 5c1fa3fd..a21f3140 100644 --- a/ui/src/views/LogsView.tsx +++ b/ui/src/views/LogsView.tsx @@ -77,7 +77,6 @@ function useLogViewer(shouldLoad: boolean): UseLogViewerResult { const [fileSize, setFileSize] = useState(0); const [hasMore, setHasMore] = useState(false); const [nextOffset, setNextOffset] = useState(0); - const [currentOffset, setCurrentOffset] = useState(undefined); const [filterText, setFilterText] = useState(''); const [levelFilter, setLevelFilter] = useState(''); @@ -111,7 +110,6 @@ function useLogViewer(shouldLoad: boolean): UseLogViewerResult { setFileSize(response.file_size); setHasMore(response.has_more); setNextOffset(response.next_offset); - setCurrentOffset(offset); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to load logs'; logger.error('Failed to load logs:', err); @@ -200,12 +198,10 @@ function useLogViewer(shouldLoad: boolean): UseLogViewerResult { }, [loadLogs, nextOffset]); const handleLoadOlder = useCallback(() => { - if (currentOffset !== undefined && currentOffset > 0) { - loadLogs('backward', currentOffset); - } else { - loadLogs('backward'); + if (nextOffset > 0) { + loadLogs('backward', nextOffset); } - }, [loadLogs, currentOffset]); + }, [loadLogs, nextOffset]); const handleLoadLatest = useCallback(() => { loadLogs('backward'); From feda36b466f7e54af6b3729b87423c2c122099d1 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sun, 22 Mar 2026 21:24:26 +0000 Subject: [PATCH 04/10] fix: allow unwrap_used in log_viewer tests Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- apps/skit/src/log_viewer.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/skit/src/log_viewer.rs b/apps/skit/src/log_viewer.rs index 57d4d8c7..07a5e240 100644 --- a/apps/skit/src/log_viewer.rs +++ b/apps/skit/src/log_viewer.rs @@ -468,6 +468,7 @@ pub async fn stream_logs_handler( } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; From 6f14d8546b47c2c2fdf740ce181ccf44dcd25f7d Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sun, 22 Mar 2026 21:36:25 +0000 Subject: [PATCH 05/10] fix: re-navigate to /admin/logs after ensureLoggedIn in E2E tests Login flow redirects to /design, not back to the original page. Follow the same pattern as monitor.spec.ts: check URL after login and re-navigate if needed. Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- e2e/tests/logs.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/e2e/tests/logs.spec.ts b/e2e/tests/logs.spec.ts index 2e83904b..c219c55e 100644 --- a/e2e/tests/logs.spec.ts +++ b/e2e/tests/logs.spec.ts @@ -10,6 +10,10 @@ test.describe('Log Viewer', () => { test.beforeEach(async ({ page }) => { await page.goto('/admin/logs'); await ensureLoggedIn(page); + if (!page.url().includes('/admin/logs')) { + await page.goto('/admin/logs'); + } + await expect(page.getByTestId('logs-view')).toBeVisible(); }); test('navigates to log viewer and displays UI', async ({ page }) => { From 34fed181fe2cf73d34ac70f85948ce9c0db00825 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Mon, 23 Mar 2026 11:56:50 +0000 Subject: [PATCH 06/10] fix: improve log viewer pagination, UX, and readability - Fix backward pagination carry buffer lost between requests by tracking byte positions instead of chunk boundaries - Track separate forward/backward cursors for correct Older/Newer button states - Level filter applies immediately on select (no Filter button needed) - Text filter applies as user types with 300ms debounce - Add line wrap toggle (wrap vs horizontal scroll) - Add configurable page size selector (100-2000 lines) - Improve color readability with background tints for error/warn levels - Use full available width/height (remove max-width/max-height limits) - Extract useDebouncedValue, useLiveTail, LogsToolbar, LogsPagination to satisfy ESLint complexity limits - Add unit test for cross-request backward pagination gap detection - Update E2E tests for new filter/control behavior Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- apps/skit/src/log_viewer.rs | 161 +++++++++---- e2e/tests/logs.spec.ts | 22 +- ui/src/views/LogsView.styles.ts | 50 ++-- ui/src/views/LogsView.tsx | 405 ++++++++++++++++---------------- 4 files changed, 367 insertions(+), 271 deletions(-) diff --git a/apps/skit/src/log_viewer.rs b/apps/skit/src/log_viewer.rs index 07a5e240..dcde2355 100644 --- a/apps/skit/src/log_viewer.rs +++ b/apps/skit/src/log_viewer.rs @@ -262,8 +262,10 @@ async fn read_forward( /// Read lines backward from the given byte offset (or end of file). /// -/// Reads in chunks working backward from the offset, joining partial lines -/// at chunk boundaries via a carry buffer so no lines are lost. +/// Reads in chunks working backward from the offset, tracking each line's +/// byte position in the file. This ensures correct pagination across +/// multiple requests: `next_offset` always points to the byte position of +/// the oldest returned line, so the next backward page ends exactly before it. async fn read_backward( mut file: tokio::fs::File, file_size: u64, @@ -278,16 +280,14 @@ async fn read_backward( return Ok(LogResponse { lines: Vec::new(), next_offset: 0, has_more: false, file_size }); } - let mut collected: Vec = Vec::with_capacity(limit.min(256)); + // Each entry: (byte_offset_in_file, line_text). + let mut collected: Vec<(u64, String)> = Vec::with_capacity(limit.min(256)); let mut current_end = end; - // Partial line fragment carried from the start of the previously-read (higher-offset) chunk. - // It is the continuation of the last split element of the current chunk. + // Tail fragment carried from the start of the previously-read (higher-offset) + // chunk. It is the continuation of the current chunk's last line. let mut carry = String::new(); - // Read in chunks working backward until we have enough lines or reach start of file while collected.len() < limit && current_end > 0 { - // Read a chunk large enough to likely contain the lines we need. - // Start with 8KB per remaining line needed, min 32KB. let remaining_lines = limit - collected.len(); let chunk_size: u64 = (remaining_lines as u64 * 8192).max(32768).min(current_end); let chunk_start = current_end.saturating_sub(chunk_size); @@ -304,55 +304,66 @@ async fn read_backward( StatusCode::INTERNAL_SERVER_ERROR })?; - let text = String::from_utf8_lossy(&buf); - let mut parts: Vec<&str> = text.split('\n').collect(); - - // The last split element may be a partial line whose continuation is in `carry`. - // Join them to form the complete line at this chunk's upper boundary. - let last = parts.pop().unwrap_or(""); - let completed_tail = - if carry.is_empty() { last.to_string() } else { format!("{last}{carry}") }; - - // If we're not at the start of the file, the first split element is a partial line - // whose beginning is in an earlier chunk. Save it as carry for the next iteration. - if chunk_start > 0 && !parts.is_empty() { - carry = parts.remove(0).to_string(); - } else { - carry = String::new(); + // Parse lines from the raw buffer, recording each line's file offset. + let mut chunk_lines: Vec<(u64, String)> = Vec::new(); + let mut seg_start: usize = 0; + for (i, &byte) in buf.iter().enumerate() { + if byte == b'\n' { + let text = String::from_utf8_lossy(&buf[seg_start..i]).into_owned(); + chunk_lines.push((chunk_start + seg_start as u64, text)); + seg_start = i + 1; + } + } + if seg_start < buf.len() { + let text = String::from_utf8_lossy(&buf[seg_start..]).into_owned(); + chunk_lines.push((chunk_start + seg_start as u64, text)); } - // `parts` now contains only complete lines (no boundary partials). - // Filter them, then append the completed tail line. - let mut chunk_lines: Vec = parts - .into_iter() - .filter(|line| !line.is_empty() && line_passes_filters(line, level, filter)) - .map(String::from) - .collect(); + // The last element extends to current_end. If carry is non-empty it is the + // tail continuation from a higher-offset chunk — join to complete the line. + if !carry.is_empty() { + if let Some(last) = chunk_lines.last_mut() { + last.1.push_str(&carry); + } + carry.clear(); + } - if !completed_tail.is_empty() && line_passes_filters(&completed_tail, level, filter) { - chunk_lines.push(completed_tail); + // If chunk_start > 0, the first element is a partial line whose beginning + // is in an earlier chunk. Save it as carry for the next iteration. + if chunk_start > 0 && !chunk_lines.is_empty() { + carry = chunk_lines.remove(0).1; } - // Take from the end of chunk_lines to fill remaining capacity, then prepend to collected - let take_count = remaining_lines.min(chunk_lines.len()); - let start_idx = chunk_lines.len().saturating_sub(take_count); - let mut new_lines: Vec = chunk_lines[start_idx..].to_vec(); - new_lines.append(&mut collected); - collected = new_lines; + // Collect filtered lines in reverse (newest first). + for (pos, text) in chunk_lines.into_iter().rev() { + if !text.is_empty() && line_passes_filters(&text, level, filter) { + collected.push((pos, text)); + if collected.len() >= limit { + break; + } + } + } current_end = chunk_start; } - // Truncate to limit if we over-collected - if collected.len() > limit { - let excess = collected.len() - limit; - collected = collected[excess..].to_vec(); + // If carry is non-empty the loop reached byte 0 and the first file line + // was saved as carry. Include it if we still have capacity. + if !carry.is_empty() && collected.len() < limit && line_passes_filters(&carry, level, filter) { + collected.push((0, carry)); } + // collected is newest-first; reverse to chronological order. + collected.reverse(); + + // next_offset = byte position of the oldest returned line so the next + // backward page ends right before it with no gaps or overlaps. + let next_offset = collected.first().map_or(0, |(pos, _)| *pos); + Ok(LogResponse { - lines: collected, - next_offset: current_end, - has_more: current_end > 0, + lines: collected.into_iter().map(|(_, text)| text).collect(), + next_offset, + has_more: next_offset > 0, file_size, }) } @@ -525,8 +536,8 @@ mod tests { assert!(line_passes_filters(line, Some(""), Some(""))); } - /// Verify that backward reading with a small chunk size (forcing multi-chunk) - /// correctly joins partial lines at chunk boundaries instead of dropping them. + /// Verify that backward reading correctly joins partial lines at chunk + /// boundaries instead of dropping them. #[tokio::test] async fn test_read_backward_multi_chunk_preserves_lines() { use tokio::io::AsyncWriteExt; @@ -534,7 +545,6 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.log"); - // Write enough lines so that a small forced chunk triggers multi-chunk reading. let mut f = tokio::fs::File::create(&path).await.unwrap(); let mut expected: Vec = Vec::new(); for i in 0..20 { @@ -554,4 +564,59 @@ mod tests { assert_eq!(resp.lines, expected, "all lines should be returned in order"); assert!(!resp.has_more); } + + /// Verify that paginating backward across multiple requests produces every + /// line exactly once with no truncation or gaps. + #[tokio::test] + async fn test_read_backward_paginated_no_gaps() { + use tokio::io::AsyncWriteExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.log"); + + // Write 20 lines of varying lengths so chunk boundaries are likely to + // fall mid-line. + let mut f = tokio::fs::File::create(&path).await.unwrap(); + let mut expected: Vec = Vec::new(); + for i in 0..20 { + let line = format!( + "2024-01-01T00:00:{i:02}Z INFO test: line {i} padding={}", + "x".repeat(i * 10) + ); + f.write_all(line.as_bytes()).await.unwrap(); + f.write_all(b"\n").await.unwrap(); + expected.push(line); + } + f.flush().await.unwrap(); + drop(f); + + let file_size = tokio::fs::metadata(&path).await.unwrap().len(); + + // Read backward in small pages of 5 and collect all results. + let mut all_lines: Vec = Vec::new(); + let mut offset: Option = None; + let mut pages = 0; + + loop { + let file = tokio::fs::File::open(&path).await.unwrap(); + let resp = read_backward(file, file_size, offset, 5, None, None).await.unwrap(); + + // Prepend: each page is older than the previously collected lines. + let mut page = resp.lines; + page.append(&mut all_lines); + all_lines = page; + + pages += 1; + + if !resp.has_more { + break; + } + offset = Some(resp.next_offset); + + assert!(pages <= 10, "too many pages — possible infinite loop"); + } + + assert_eq!(all_lines, expected, "all lines must appear exactly once across pages"); + assert_eq!(pages, 4, "20 lines / 5 per page = 4 pages"); + } } diff --git a/e2e/tests/logs.spec.ts b/e2e/tests/logs.spec.ts index c219c55e..6e7eb228 100644 --- a/e2e/tests/logs.spec.ts +++ b/e2e/tests/logs.spec.ts @@ -40,9 +40,13 @@ test.describe('Log Viewer', () => { const levelSelect = page.getByTestId('logs-level-select'); await expect(levelSelect).toBeVisible(); - // Filter button should be visible - const filterButton = page.getByTestId('logs-apply-filter'); - await expect(filterButton).toBeVisible(); + // Page size select should be visible + const pageSizeSelect = page.getByTestId('logs-page-size'); + await expect(pageSizeSelect).toBeVisible(); + + // Wrap toggle should be visible + const wrapToggle = page.getByTestId('logs-wrap-toggle'); + await expect(wrapToggle).toBeVisible(); // Live tail button should be visible const liveTailButton = page.getByTestId('logs-live-tail'); @@ -58,12 +62,11 @@ test.describe('Log Viewer', () => { await expect(page.getByTestId('logs-load-latest')).toBeVisible(); }); - test('level filter can be changed', async ({ page }) => { + test('level filter applies immediately on change', async ({ page }) => { await expect(page.getByTestId('logs-view')).toBeVisible(); - // Select "Error" level filter + // Select "Error" level filter — should reload without needing a button click await page.getByTestId('logs-level-select').selectOption('error'); - await page.getByTestId('logs-apply-filter').click(); // Wait briefly for the filtered results await page.waitForTimeout(1000); @@ -72,14 +75,13 @@ test.describe('Log Viewer', () => { await expect(page.getByTestId('logs-container')).toBeVisible(); }); - test('text filter can be applied', async ({ page }) => { + test('text filter updates as user types', async ({ page }) => { await expect(page.getByTestId('logs-view')).toBeVisible(); - // Type a filter + // Type a filter — should apply after debounce (no button click needed) await page.getByTestId('logs-filter-input').fill('skit'); - await page.getByTestId('logs-apply-filter').click(); - // Wait for filtered results + // Wait for debounce + request await page.waitForTimeout(1000); // Container should still be visible diff --git a/ui/src/views/LogsView.styles.ts b/ui/src/views/LogsView.styles.ts index b9e03784..ebb452de 100644 --- a/ui/src/views/LogsView.styles.ts +++ b/ui/src/views/LogsView.styles.ts @@ -17,24 +17,23 @@ export const Container = styled.div` export const ContentArea = styled.div` flex: 1; - overflow-y: auto; display: flex; justify-content: center; min-width: 0; min-height: 0; + overflow: hidden; `; export const ContentWrapper = styled.div` width: 100%; - max-width: 1200px; - padding: 40px; + padding: 24px 32px; box-sizing: border-box; display: flex; flex-direction: column; min-height: 0; @media (max-width: 768px) { - padding: 24px; + padding: 16px; } `; @@ -44,10 +43,10 @@ export const Card = styled.div` background: var(--sk-panel-bg); border: 1px solid var(--sk-border); border-radius: 12px; - padding: 24px; + padding: 20px; display: flex; flex-direction: column; - gap: 16px; + gap: 12px; min-width: 0; flex: 1; min-height: 0; @@ -75,7 +74,7 @@ export const Subtle = styled.div` export const FilterBar = styled.div` display: flex; - gap: 10px; + gap: 8px; align-items: center; flex-wrap: wrap; `; @@ -110,11 +109,20 @@ export const LevelSelect = styled.select` font-size: 13px; `; -export const LogContainer = styled.div` +export const PageSizeSelect = styled.select` + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--sk-border); + background: var(--sk-bg); + color: var(--sk-text); + font-size: 13px; +`; + +export const LogContainer = styled.div<{ $wrap?: boolean }>` flex: 1; - min-height: 200px; - max-height: 600px; + min-height: 120px; overflow-y: auto; + overflow-x: ${(props) => (props.$wrap !== false ? 'hidden' : 'auto')}; background: var(--sk-bg); border: 1px solid var(--sk-border); border-radius: 8px; @@ -122,21 +130,29 @@ export const LogContainer = styled.div` ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 12px; - line-height: 1.6; - padding: 12px; + line-height: 1.5; + padding: 8px 0; + --log-wrap: ${(props) => (props.$wrap !== false ? 'pre-wrap' : 'pre')}; + --log-word-break: ${(props) => (props.$wrap !== false ? 'break-all' : 'normal')}; `; export const LogLine = styled.div<{ $level?: string }>` - white-space: pre-wrap; - word-break: break-all; - padding: 1px 0; + white-space: var(--log-wrap, pre-wrap); + word-break: var(--log-word-break, break-all); + padding: 1px 12px; ${(props) => { switch (props.$level) { case 'error': - return 'color: var(--sk-danger);'; + return ` + color: var(--sk-danger); + background: color-mix(in srgb, var(--sk-danger) 10%, transparent); + `; case 'warn': - return 'color: var(--sk-warning);'; + return ` + color: var(--sk-warning); + background: color-mix(in srgb, var(--sk-warning) 8%, transparent); + `; case 'debug': case 'trace': return 'color: var(--sk-text-muted);'; diff --git a/ui/src/views/LogsView.tsx b/ui/src/views/LogsView.tsx index a21f3140..3f1b2fc4 100644 --- a/ui/src/views/LogsView.tsx +++ b/ui/src/views/LogsView.tsx @@ -22,6 +22,7 @@ import { LiveIndicator, LogContainer, LogLine, + PageSizeSelect, PaginationInfo, PaginationRow, SearchInput, @@ -32,7 +33,7 @@ import { const logger = getLogger('LogsView'); -const PAGE_SIZE = 500; +const DEFAULT_PAGE_SIZE = 500; function detectLevel(line: string): string | undefined { if (/ ERROR /i.test(line) || /"level":"ERROR"/i.test(line)) return 'error'; @@ -48,24 +49,86 @@ function formatFileSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } +function useDebouncedValue(value: string, delayMs: number): string { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + return debounced; +} + +function useLiveTail( + debouncedFilter: string, + levelFilter: string, + setLines: React.Dispatch> +) { + const [liveTail, setLiveTail] = useState(false); + const eventSourceRef = useRef(null); + + useEffect(() => { + if (!liveTail) { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + return; + } + + const es = createLogStream({ + filter: debouncedFilter || undefined, + level: levelFilter || undefined, + }); + + es.onmessage = (event: MessageEvent) => { + const newLines = (event.data as string).split('\n').filter(Boolean); + if (newLines.length > 0) { + setLines((prev) => { + const combined = [...prev, ...newLines]; + return combined.length > 5000 ? combined.slice(combined.length - 5000) : combined; + }); + } + }; + + es.onerror = () => { + logger.error('Log stream connection error'); + }; + + es.addEventListener('truncated', () => { + setLines([]); + }); + + eventSourceRef.current = es; + + return () => { + es.close(); + eventSourceRef.current = null; + }; + }, [liveTail, debouncedFilter, levelFilter, setLines]); + + return { liveTail, setLiveTail }; +} + interface UseLogViewerResult { lines: string[]; isLoading: boolean; error: string | null; fileSize: number; - hasMore: boolean; + canGoOlder: boolean; + canGoNewer: boolean; liveTail: boolean; filterText: string; levelFilter: string; + wrapLines: boolean; + pageSize: number; logContainerRef: React.RefObject; setFilterText: (v: string) => void; - setLevelFilter: (v: string) => void; - handleApplyFilters: () => void; - handleKeyDown: (e: React.KeyboardEvent) => void; handleLoadNewer: () => void; handleLoadOlder: () => void; handleLoadLatest: () => void; handleToggleLiveTail: () => void; + handleToggleWrap: () => void; + handlePageSizeChange: (e: React.ChangeEvent) => void; handleScroll: () => void; handleLevelChange: (e: React.ChangeEvent) => void; } @@ -75,18 +138,18 @@ function useLogViewer(shouldLoad: boolean): UseLogViewerResult { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [fileSize, setFileSize] = useState(0); - const [hasMore, setHasMore] = useState(false); - const [nextOffset, setNextOffset] = useState(0); - + const [backwardOffset, setBackwardOffset] = useState(0); + const [forwardOffset, setForwardOffset] = useState(0); + const [isAtLatest, setIsAtLatest] = useState(true); const [filterText, setFilterText] = useState(''); const [levelFilter, setLevelFilter] = useState(''); - const [appliedFilter, setAppliedFilter] = useState(''); - const [appliedLevel, setAppliedLevel] = useState(''); - - const [liveTail, setLiveTail] = useState(false); - const eventSourceRef = useRef(null); - const logContainerRef = useRef(null); + const [wrapLines, setWrapLines] = useState(true); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [autoScroll, setAutoScroll] = useState(true); + const logContainerRef = useRef(null); + + const debouncedFilter = useDebouncedValue(filterText, 300); + const { liveTail, setLiveTail } = useLiveTail(debouncedFilter, levelFilter, setLines); const scrollToBottom = useCallback(() => { if (logContainerRef.current && autoScroll) { @@ -101,15 +164,23 @@ function useLogViewer(shouldLoad: boolean): UseLogViewerResult { try { const response: LogResponse = await fetchLogs({ offset, - limit: PAGE_SIZE, + limit: pageSize, direction, - filter: appliedFilter || undefined, - level: appliedLevel || undefined, + filter: debouncedFilter || undefined, + level: levelFilter || undefined, }); setLines(response.lines); setFileSize(response.file_size); - setHasMore(response.has_more); - setNextOffset(response.next_offset); + + if (direction === 'backward') { + setBackwardOffset(response.next_offset); + setForwardOffset(offset ?? response.file_size); + setIsAtLatest(offset === undefined || offset >= response.file_size); + } else { + setForwardOffset(response.next_offset); + setBackwardOffset(offset ?? 0); + setIsAtLatest(!response.has_more); + } } catch (err) { const message = err instanceof Error ? err.message : 'Failed to load logs'; logger.error('Failed to load logs:', err); @@ -118,141 +189,147 @@ function useLogViewer(shouldLoad: boolean): UseLogViewerResult { setIsLoading(false); } }, - [appliedFilter, appliedLevel] + [debouncedFilter, levelFilter, pageSize] ); - // Load latest logs on mount useEffect(() => { if (shouldLoad) { loadLogs('backward'); } }, [loadLogs, shouldLoad]); - // Scroll to bottom when lines change useEffect(() => { scrollToBottom(); }, [lines, scrollToBottom]); - // Live tail management - useEffect(() => { - if (!liveTail) { - if (eventSourceRef.current) { - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - return; - } - - const es = createLogStream({ - filter: appliedFilter || undefined, - level: appliedLevel || undefined, - }); - - es.onmessage = (event: MessageEvent) => { - const newLines = (event.data as string).split('\n').filter(Boolean); - if (newLines.length > 0) { - setLines((prev) => { - const combined = [...prev, ...newLines]; - // Keep a reasonable buffer during live tail - if (combined.length > 5000) { - return combined.slice(combined.length - 5000); - } - return combined; - }); - } - }; - - es.onerror = () => { - logger.error('Log stream connection error'); - }; - - es.addEventListener('truncated', () => { - setLines([]); - }); - - eventSourceRef.current = es; - - return () => { - es.close(); - eventSourceRef.current = null; - }; - }, [liveTail, appliedFilter, appliedLevel]); - - const handleApplyFilters = useCallback(() => { - setAppliedFilter(filterText); - setAppliedLevel(levelFilter); - setLiveTail(false); - }, [filterText, levelFilter]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleApplyFilters(); - } - }, - [handleApplyFilters] - ); - - const handleLoadNewer = useCallback(() => { - loadLogs('forward', nextOffset); - }, [loadLogs, nextOffset]); - - const handleLoadOlder = useCallback(() => { - if (nextOffset > 0) { - loadLogs('backward', nextOffset); - } - }, [loadLogs, nextOffset]); - - const handleLoadLatest = useCallback(() => { - loadLogs('backward'); - }, [loadLogs]); - - const handleToggleLiveTail = useCallback(() => { - if (!liveTail) { - // Starting live tail — first load latest, then enable streaming - loadLogs('backward').then(() => { - setLiveTail(true); - setAutoScroll(true); - }); - } else { - setLiveTail(false); - } - }, [liveTail, loadLogs]); - - const handleScroll = useCallback(() => { - const container = logContainerRef.current; - if (!container) return; - const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50; - setAutoScroll(isAtBottom); - }, []); - - const handleLevelChange = useCallback((e: React.ChangeEvent) => { - setLevelFilter(e.target.value); - }, []); - return { lines, isLoading, error, fileSize, - hasMore, + canGoOlder: backwardOffset > 0, + canGoNewer: !isAtLatest, liveTail, filterText, levelFilter, + wrapLines, + pageSize, logContainerRef, setFilterText, - setLevelFilter, - handleApplyFilters, - handleKeyDown, - handleLoadNewer, - handleLoadOlder, - handleLoadLatest, - handleToggleLiveTail, - handleScroll, - handleLevelChange, + handleLoadNewer: useCallback(() => { + if (forwardOffset < fileSize) loadLogs('forward', forwardOffset); + }, [loadLogs, forwardOffset, fileSize]), + handleLoadOlder: useCallback(() => { + if (backwardOffset > 0) loadLogs('backward', backwardOffset); + }, [loadLogs, backwardOffset]), + handleLoadLatest: useCallback(() => loadLogs('backward'), [loadLogs]), + handleToggleLiveTail: useCallback(() => { + if (!liveTail) { + loadLogs('backward').then(() => { + setLiveTail(true); + setAutoScroll(true); + }); + } else { + setLiveTail(false); + } + }, [liveTail, loadLogs, setLiveTail]), + handleToggleWrap: useCallback(() => setWrapLines((prev) => !prev), []), + handlePageSizeChange: useCallback( + (e: React.ChangeEvent) => setPageSize(Number(e.target.value)), + [] + ), + handleScroll: useCallback(() => { + const container = logContainerRef.current; + if (!container) return; + const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50; + setAutoScroll(nearBottom); + }, []), + handleLevelChange: useCallback( + (e: React.ChangeEvent) => setLevelFilter(e.target.value), + [] + ), }; } +const LogsToolbar: React.FC<{ lv: UseLogViewerResult }> = ({ lv }) => ( + + lv.setFilterText(e.target.value)} + data-testid="logs-filter-input" + /> + + + + + + + + + + + + + + + + + +); + +const LogsPagination: React.FC<{ lv: UseLogViewerResult }> = ({ lv }) => ( + +
+ + + +
+ + {lv.lines.length} lines + {lv.fileSize > 0 && ` \u2022 ${formatFileSize(lv.fileSize)}`} + +
+); + const LogsView: React.FC = () => { const { role, isAdmin } = usePermissions(); const admin = isAdmin(); @@ -298,45 +375,12 @@ const LogsView: React.FC = () => { {lv.error && {lv.error}} - - lv.setFilterText(e.target.value)} - onKeyDown={lv.handleKeyDown} - data-testid="logs-filter-input" - /> - - - - - - - - - - + {lv.lines.length === 0 && !lv.isLoading && ( @@ -349,38 +393,7 @@ const LogsView: React.FC = () => { ))} - -
- - - -
- - {lv.lines.length} lines - {lv.fileSize > 0 && ` \u2022 ${formatFileSize(lv.fileSize)}`} - -
+ From 2d908cc62d798d36f253d5123e1c26c9a7308786 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Mon, 23 Mar 2026 12:57:38 +0000 Subject: [PATCH 07/10] feat(logs): add expand/collapse toggle and click-to-copy log lines - Default width now matches other admin sections (max-width: 1200px) - Expand button toggles to full-width layout for wider log viewing - Click any log line to copy it to clipboard with toast confirmation - Hover highlight on log lines for visual feedback - E2E tests for expand toggle and clipboard copy Co-Authored-By: Claudio Costa --- e2e/tests/logs.spec.ts | 46 +++++++++++++++++++++++++++++++++ ui/src/views/LogsView.styles.ts | 32 +++++++++++++++++++++-- ui/src/views/LogsView.tsx | 37 ++++++++++++++++++++++++-- 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/e2e/tests/logs.spec.ts b/e2e/tests/logs.spec.ts index 6e7eb228..ef6f33dc 100644 --- a/e2e/tests/logs.spec.ts +++ b/e2e/tests/logs.spec.ts @@ -48,11 +48,57 @@ test.describe('Log Viewer', () => { const wrapToggle = page.getByTestId('logs-wrap-toggle'); await expect(wrapToggle).toBeVisible(); + // Expand toggle should be visible + const expandToggle = page.getByTestId('logs-expand-toggle'); + await expect(expandToggle).toBeVisible(); + await expect(expandToggle).toHaveText('Expand'); + // Live tail button should be visible const liveTailButton = page.getByTestId('logs-live-tail'); await expect(liveTailButton).toBeVisible(); }); + test('expand toggle switches between constrained and full-width layout', async ({ page }) => { + await expect(page.getByTestId('logs-view')).toBeVisible(); + + const expandToggle = page.getByTestId('logs-expand-toggle'); + + // Initially should show "Expand" (constrained width) + await expect(expandToggle).toHaveText('Expand'); + + // Click to expand + await expandToggle.click(); + await expect(expandToggle).toHaveText('Collapse'); + + // Click again to collapse + await expandToggle.click(); + await expect(expandToggle).toHaveText('Expand'); + }); + + test('clicking a log line copies it to clipboard', async ({ page, context }) => { + await expect(page.getByTestId('logs-view')).toBeVisible(); + + // Grant clipboard permissions + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + + // Wait for log lines to load + const container = page.getByTestId('logs-container'); + await expect(container).toBeVisible(); + + // Get the first log line and click it + const firstLogLine = container.locator('div[title="Click to copy"]').first(); + const lineCount = await container.locator('div[title="Click to copy"]').count(); + + if (lineCount > 0) { + const lineText = await firstLogLine.textContent(); + await firstLogLine.click(); + + // Verify the clipboard contents match + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText).toBe(lineText); + } + }); + test('pagination buttons are present', async ({ page }) => { await expect(page.getByTestId('logs-view')).toBeVisible(); diff --git a/ui/src/views/LogsView.styles.ts b/ui/src/views/LogsView.styles.ts index ebb452de..06fd49b5 100644 --- a/ui/src/views/LogsView.styles.ts +++ b/ui/src/views/LogsView.styles.ts @@ -24,13 +24,15 @@ export const ContentArea = styled.div` overflow: hidden; `; -export const ContentWrapper = styled.div` +export const ContentWrapper = styled.div<{ $expanded?: boolean }>` width: 100%; - padding: 24px 32px; + max-width: ${(props) => (props.$expanded ? 'none' : '1200px')}; + padding: ${(props) => (props.$expanded ? '24px 32px' : '40px')}; box-sizing: border-box; display: flex; flex-direction: column; min-height: 0; + transition: max-width 0.2s ease; @media (max-width: 768px) { padding: 16px; @@ -140,6 +142,12 @@ export const LogLine = styled.div<{ $level?: string }>` white-space: var(--log-wrap, pre-wrap); word-break: var(--log-word-break, break-all); padding: 1px 12px; + cursor: pointer; + position: relative; + + &:hover { + background: color-mix(in srgb, var(--sk-primary) 6%, transparent) !important; + } ${(props) => { switch (props.$level) { @@ -193,6 +201,26 @@ export const ErrorBox = styled.div` font-size: 13px; `; +export const CopyToast = styled.div<{ $visible: boolean }>` + position: fixed; + bottom: 24px; + right: 24px; + padding: 8px 16px; + border-radius: 8px; + background: var(--sk-panel-bg); + border: 1px solid var(--sk-border); + color: var(--sk-text); + font-size: 13px; + font-weight: 500; + opacity: ${(props) => (props.$visible ? 1 : 0)}; + transform: translateY(${(props) => (props.$visible ? '0' : '8px')}); + transition: + opacity 0.2s ease, + transform 0.2s ease; + pointer-events: none; + z-index: 1000; +`; + export const LiveIndicator = styled.span<{ $active: boolean }>` display: inline-flex; align-items: center; diff --git a/ui/src/views/LogsView.tsx b/ui/src/views/LogsView.tsx index 3f1b2fc4..ddab4978 100644 --- a/ui/src/views/LogsView.tsx +++ b/ui/src/views/LogsView.tsx @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MPL-2.0 import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; import { Button } from '@/components/ui/Button'; import { usePermissions } from '@/hooks/usePermissions'; @@ -15,6 +16,7 @@ import { Container, ContentArea, ContentWrapper, + CopyToast, EmptyState, ErrorBox, FilterBar, @@ -121,6 +123,8 @@ interface UseLogViewerResult { levelFilter: string; wrapLines: boolean; pageSize: number; + expanded: boolean; + copyToastVisible: boolean; logContainerRef: React.RefObject; setFilterText: (v: string) => void; handleLoadNewer: () => void; @@ -128,6 +132,8 @@ interface UseLogViewerResult { handleLoadLatest: () => void; handleToggleLiveTail: () => void; handleToggleWrap: () => void; + handleToggleExpand: () => void; + handleCopyLine: (line: string) => void; handlePageSizeChange: (e: React.ChangeEvent) => void; handleScroll: () => void; handleLevelChange: (e: React.ChangeEvent) => void; @@ -146,6 +152,9 @@ function useLogViewer(shouldLoad: boolean): UseLogViewerResult { const [wrapLines, setWrapLines] = useState(true); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [autoScroll, setAutoScroll] = useState(true); + const [expanded, setExpanded] = useState(false); + const [copyToastVisible, setCopyToastVisible] = useState(false); + const copyTimeoutRef = useRef | null>(null); const logContainerRef = useRef(null); const debouncedFilter = useDebouncedValue(filterText, 300); @@ -214,6 +223,8 @@ function useLogViewer(shouldLoad: boolean): UseLogViewerResult { levelFilter, wrapLines, pageSize, + expanded, + copyToastVisible, logContainerRef, setFilterText, handleLoadNewer: useCallback(() => { @@ -234,6 +245,19 @@ function useLogViewer(shouldLoad: boolean): UseLogViewerResult { } }, [liveTail, loadLogs, setLiveTail]), handleToggleWrap: useCallback(() => setWrapLines((prev) => !prev), []), + handleToggleExpand: useCallback(() => setExpanded((prev) => !prev), []), + handleCopyLine: useCallback((line: string) => { + navigator.clipboard + .writeText(line) + .then(() => { + if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); + flushSync(() => setCopyToastVisible(true)); + copyTimeoutRef.current = setTimeout(() => setCopyToastVisible(false), 1500); + }) + .catch((err) => { + logger.error('Failed to copy log line:', err); + }); + }, []), handlePageSizeChange: useCallback( (e: React.ChangeEvent) => setPageSize(Number(e.target.value)), [] @@ -285,6 +309,9 @@ const LogsToolbar: React.FC<{ lv: UseLogViewerResult }> = ({ lv }) => ( +