This commit is contained in:
Mateusz Gruszczyński committed 2026-10-05 17:05:06 +02:00
1 parent d168ee2afa
commit ddc5c6593f
70 files changed
+2293 -594

No files matched your search

+5
View File
@@ -94,6 +94,11 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/ERROR_LOGGING.md"
)),
),
(
"docs/FILE_LOGS.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/FILE_LOGS.md")),
),
(
"docs/HTTP3.md",
"technical",
+426
View File
@@ -0,0 +1,426 @@
const FILE_LOG_SCAN_BYTES_PER_SOURCE: u64 = 2 * 1024 * 1024;
const FILE_LOG_SCAN_BYTES_TOTAL: u64 = 8 * 1024 * 1024;
const FILE_LOG_SCAN_LINES: usize = 20_000;
const FILE_LOG_MAX_SOURCES: usize = 32;
const FILE_LOG_MAX_LIMIT: usize = 500;
const FILE_LOG_MAX_OFFSET: usize = 10_000;
#[derive(Clone)]
struct FileLogSource {
id: String,
label: String,
kind: &'static str,
path: std::path::PathBuf,
enabled: bool,
format: &'static str,
exists: bool,
size: u64,
modified_ms: u64,
}
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct FileLogQuery {
source: Option<String>,
q: Option<String>,
event: Option<String>,
status_class: Option<String>,
client_ip: Option<String>,
vhost: Option<String>,
offset: Option<usize>,
limit: Option<usize>,
}
fn file_modified_ms(metadata: &std::fs::Metadata) -> u64 {
metadata
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
fn source_from_path(
id: String,
label: String,
kind: &'static str,
path: std::path::PathBuf,
enabled: bool,
format: &'static str,
) -> FileLogSource {
let metadata = std::fs::symlink_metadata(&path).ok();
let safe_regular = metadata
.as_ref()
.is_some_and(|meta| meta.file_type().is_file() && !meta.file_type().is_symlink());
FileLogSource {
id,
label,
kind,
path,
enabled,
format,
exists: safe_regular,
size: metadata
.as_ref()
.filter(|_| safe_regular)
.map_or(0, |m| m.len()),
modified_ms: metadata
.as_ref()
.filter(|_| safe_regular)
.map_or(0, file_modified_ms),
}
}
fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSource> {
let mut sources = Vec::new();
if config.logging.file.per_vhost {
let directory = std::path::PathBuf::from(&config.logging.file.directory);
let directory_safe = std::fs::symlink_metadata(&directory)
.ok()
.is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
if directory_safe {
let mut entries = std::fs::read_dir(&directory)
.ok()
.into_iter()
.flat_map(|iter| iter.filter_map(|entry| entry.ok()))
.filter_map(|entry| {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if !name.ends_with(".jsonl") || name.len() > 160 {
return None;
}
let file_type = entry.file_type().ok()?;
if !file_type.is_file() || file_type.is_symlink() {
return None;
}
let meta = entry.metadata().ok()?;
Some((name, path, file_modified_ms(&meta)))
})
.collect::<Vec<_>>();
entries.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
entries.truncate(256);
for (name, path, _) in entries {
let id = format!("vhost:{}", name.trim_end_matches(".jsonl"));
sources.push(source_from_path(
id,
name,
"access",
path,
config.logging.file.enabled,
"JSONL",
));
}
}
} else {
sources.push(source_from_path(
"access".into(),
"Access log".into(),
"access",
std::path::PathBuf::from(&config.logging.file.path),
config.logging.file.enabled,
"JSONL",
));
}
sources.push(source_from_path(
"error".into(),
"Error log".into(),
"error",
std::path::PathBuf::from(&config.error_log.path),
config.error_log.enabled,
match config.error_log.format {
crate::logging::ErrorLogFormat::Json => "JSONL",
crate::logging::ErrorLogFormat::Raw => "RAW",
},
));
sources
}
fn file_log_sources_json(sources: &[FileLogSource]) -> Value {
Value::Array(
sources
.iter()
.map(|source| {
json!({
"id": source.id,
"label": source.label,
"kind": source.kind,
"path": source.path.to_string_lossy(),
"enabled": source.enabled,
"format": source.format,
"exists": source.exists,
"size": source.size,
"modified_ms": source.modified_ms,
})
})
.collect(),
)
}
async fn file_logs(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
let config = state.config.load().envelope.config.clone();
let sources = tokio::task::spawn_blocking(move || configured_file_log_sources(&config))
.await
.map_err(|_| {
ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
"file log inventory worker failed".into(),
)
})?;
Ok(Json(json!({
"sources": file_log_sources_json(&sources),
"limits": {
"max_limit": FILE_LOG_MAX_LIMIT,
"max_offset": FILE_LOG_MAX_OFFSET,
"scan_bytes_per_source": FILE_LOG_SCAN_BYTES_PER_SOURCE,
"scan_bytes_total": FILE_LOG_SCAN_BYTES_TOTAL,
"scan_lines": FILE_LOG_SCAN_LINES,
"max_sources_per_query": FILE_LOG_MAX_SOURCES,
}
})))
}
fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.file_type().is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"configured log path is not a regular file",
));
}
Ok(file)
}
fn read_tail(
source: &FileLogSource,
budget: u64,
line_budget: usize,
) -> std::io::Result<(Vec<String>, u64, bool)> {
use std::io::{Read, Seek, SeekFrom};
let mut file = open_file_log(&source.path)?;
let length = file.metadata()?.len();
let bytes = length.min(budget).min(FILE_LOG_SCAN_BYTES_PER_SOURCE);
let start = length.saturating_sub(bytes);
file.seek(SeekFrom::Start(start))?;
let mut content = Vec::with_capacity(bytes as usize);
file.take(bytes).read_to_end(&mut content)?;
let truncated = start > 0;
if truncated {
if let Some(index) = content.iter().position(|byte| *byte == b'\n') {
content.drain(..=index);
} else {
content.clear();
}
}
let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev();
let lines = iter
.by_ref()
.take(line_budget)
.map(str::to_owned)
.collect::<Vec<_>>();
Ok((lines, bytes, truncated || iter.next().is_some()))
}
fn normalized_record(source: &FileLogSource, line: &str) -> Value {
let mut value = serde_json::from_str::<Value>(line).unwrap_or_else(|_| json!({"raw": line}));
if !value.is_object() {
value = json!({"value": value});
}
let object = value.as_object_mut().expect("record normalized to object");
object.entry("event").or_insert_with(|| {
Value::String(
if source.kind == "error" {
"error"
} else {
"request"
}
.into(),
)
});
object.insert("_source".into(), Value::String(source.id.clone()));
object.insert("_source_kind".into(), Value::String(source.kind.into()));
value
}
fn contains_ci(value: Option<&Value>, needle: &str) -> bool {
value
.and_then(Value::as_str)
.is_some_and(|text| text.to_ascii_lowercase().contains(needle))
}
fn record_matches(value: &Value, query: &FileLogQuery, q: &str) -> bool {
let Some(object) = value.as_object() else {
return false;
};
if !q.is_empty() && !value.to_string().to_ascii_lowercase().contains(q) {
return false;
}
if let Some(event) = query
.event
.as_deref()
.filter(|v| !v.is_empty() && *v != "all")
{
if object.get("event").and_then(Value::as_str) != Some(event) {
return false;
}
}
if let Some(class) = query
.status_class
.as_deref()
.filter(|v| !v.is_empty() && *v != "all")
{
let Some(status) = object.get("status").and_then(Value::as_u64) else {
return false;
};
let expected = class
.as_bytes()
.first()
.and_then(|v| v.checked_sub(b'0'))
.map(u64::from);
if expected != Some(status / 100) {
return false;
}
}
if let Some(client) = query.client_ip.as_deref().filter(|v| !v.is_empty()) {
if !contains_ci(object.get("client_ip"), &client.to_ascii_lowercase()) {
return false;
}
}
if let Some(vhost) = query.vhost.as_deref().filter(|v| !v.is_empty()) {
if !contains_ci(object.get("vhost"), &vhost.to_ascii_lowercase()) {
return false;
}
}
true
}
fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> {
let text_ok = |value: &Option<String>, max: usize| {
value
.as_ref()
.is_none_or(|v| v.len() <= max && !v.chars().any(char::is_control))
};
if !text_ok(&query.source, 192)
|| !text_ok(&query.q, 256)
|| !text_ok(&query.client_ip, 128)
|| !text_ok(&query.vhost, 256)
|| query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET
|| query
.limit
.is_some_and(|v| v == 0 || v > FILE_LOG_MAX_LIMIT)
|| query.event.as_ref().is_some_and(|v| {
!matches!(
v.as_str(),
"" | "all" | "request" | "protocol_error" | "error"
)
})
|| query.status_class.as_ref().is_some_and(|v| {
!matches!(
v.as_str(),
"" | "all" | "1xx" | "2xx" | "3xx" | "4xx" | "5xx"
)
})
{
return Err(ApiError(
StatusCode::BAD_REQUEST,
"invalid file log filter".into(),
));
}
Ok(())
}
async fn file_logs_query(
State(state): State<Arc<App>>,
Query(query): Query<FileLogQuery>,
) -> Result<Json<Value>, ApiError> {
validate_file_log_query(&query)?;
let config = state.config.load().envelope.config.clone();
tokio::task::spawn_blocking(move || {
let mut sources = configured_file_log_sources(&config);
let requested = query.source.as_deref().unwrap_or("all");
if requested != "all" {
sources.retain(|source| source.id == requested);
if sources.is_empty() {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"unknown file log source".into(),
));
}
} else {
sources.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms));
sources.truncate(FILE_LOG_MAX_SOURCES);
}
let q = query.q.clone().unwrap_or_default().to_ascii_lowercase();
let offset = query.offset.unwrap_or(0);
let limit = query.limit.unwrap_or(100).clamp(1, FILE_LOG_MAX_LIMIT);
let mut remaining_bytes = FILE_LOG_SCAN_BYTES_TOTAL;
let mut scanned_bytes = 0u64;
let mut remaining_lines = FILE_LOG_SCAN_LINES;
let mut scanned_lines = 0usize;
let mut truncated = false;
let mut records = Vec::new();
for (index, source) in sources.iter().enumerate() {
if remaining_bytes == 0 || remaining_lines == 0 {
truncated = true;
break;
}
let sources_left = sources.len().saturating_sub(index).max(1);
let line_budget = (remaining_lines / sources_left).max(1);
match read_tail(source, remaining_bytes, line_budget) {
Ok((lines, bytes, source_truncated)) => {
remaining_bytes = remaining_bytes.saturating_sub(bytes);
remaining_lines = remaining_lines.saturating_sub(lines.len());
scanned_bytes += bytes;
scanned_lines += lines.len();
truncated |= source_truncated;
for line in lines {
if line.is_empty() {
continue;
}
let value = normalized_record(source, &line);
if record_matches(&value, &query, &q) {
records.push(value);
}
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => truncated = true,
}
}
records.sort_by(|a, b| {
let time = |value: &Value| value.get("time_ms").and_then(Value::as_u64).unwrap_or(0);
time(b).cmp(&time(a))
});
let total = records.len();
let page = records
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
Ok(Json(json!({
"entries": page,
"total": total,
"offset": offset,
"limit": limit,
"truncated": truncated,
"scanned_bytes": scanned_bytes,
"scanned_lines": scanned_lines,
"sources": file_log_sources_json(&sources),
})))
})
.await
.map_err(|_| {
ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
"file log query worker failed".into(),
)
})?
}
+1
View File
@@ -5,6 +5,7 @@ include!("access.rs");
include!("session.rs");
include!("status.rs");
include!("logging_api.rs");
include!("file_logs.rs");
include!("operations.rs");
include!("certificates.rs");
include!("tail.rs");
+339 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.25.7",
"version": "0.26.0",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
@@ -4799,6 +4799,111 @@
}
}
}
},
"FileLogSource": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"kind": {
"type": "string",
"enum": [
"access",
"error"
]
},
"path": {
"type": "string",
"description": "Configured server-side path. Clients cannot supply filesystem paths."
},
"enabled": {
"type": "boolean"
},
"format": {
"type": "string",
"enum": [
"JSONL",
"RAW"
]
},
"exists": {
"type": "boolean"
},
"size": {
"type": "integer",
"format": "int64"
},
"modified_ms": {
"type": "integer",
"format": "int64"
}
},
"additionalProperties": false
},
"FileLogsInventory": {
"type": "object",
"properties": {
"sources": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileLogSource"
}
},
"limits": {
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "int64"
}
}
},
"additionalProperties": false
},
"FileLogsQuery": {
"type": "object",
"properties": {
"entries": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"total": {
"type": "integer",
"format": "int64"
},
"offset": {
"type": "integer",
"format": "int64"
},
"limit": {
"type": "integer",
"format": "int64"
},
"truncated": {
"type": "boolean"
},
"scanned_bytes": {
"type": "integer",
"format": "int64"
},
"scanned_lines": {
"type": "integer",
"format": "int64"
},
"sources": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileLogSource"
}
}
},
"additionalProperties": false
}
}
},
@@ -5161,7 +5266,7 @@
}
}
},
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7–30 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7\u201330 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
"security": [],
"parameters": [
{
@@ -9463,6 +9568,237 @@
}
}
}
},
"/api/file-logs": {
"get": {
"summary": "Configured on-disk log sources",
"description": "Lists only RustProxy log paths from the active configuration. This endpoint is independent from the Live Logs memory buffer and does not accept a filesystem path.",
"tags": [
"Monitoring"
],
"operationId": "get_api_file_logs",
"responses": {
"200": {
"description": "Configured file-log inventory",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileLogsInventory"
}
}
}
},
"401": {
"description": "Authentication required or revoked",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Host, origin or CSRF validation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Bounded worker/session/rate limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Control-plane storage/worker unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/file-logs/query": {
"get": {
"summary": "Query configured on-disk logs",
"description": "Reads bounded tails of configured RustProxy access/error log files using server-side source IDs only. The query never accepts arbitrary paths, follows no symlink on Unix, and is separate from /api/logs and /api/live-logs.",
"tags": [
"Monitoring"
],
"operationId": "get_api_file_logs_query",
"parameters": [
{
"name": "source",
"in": "query",
"required": false,
"description": "Source ID returned by /api/file-logs, or all. Never a filesystem path.",
"schema": {
"type": "string"
}
},
{
"name": "q",
"in": "query",
"required": false,
"description": "Case-insensitive text search in the bounded scanned records.",
"schema": {
"type": "string",
"maxLength": 256
}
},
{
"name": "event",
"in": "query",
"required": false,
"description": "Event class.",
"schema": {
"type": "string",
"enum": [
"all",
"request",
"protocol_error",
"error"
]
}
},
{
"name": "status_class",
"in": "query",
"required": false,
"description": "HTTP status class.",
"schema": {
"type": "string",
"enum": [
"all",
"1xx",
"2xx",
"3xx",
"4xx",
"5xx"
]
}
},
{
"name": "client_ip",
"in": "query",
"required": false,
"description": "Client IP substring filter.",
"schema": {
"type": "string",
"maxLength": 128
}
},
{
"name": "vhost",
"in": "query",
"required": false,
"description": "Vhost substring filter.",
"schema": {
"type": "string",
"maxLength": 256
}
},
{
"name": "offset",
"in": "query",
"required": false,
"description": "Result offset within the bounded scan.",
"schema": {
"type": "integer",
"minimum": 0,
"maximum": 10000,
"default": 0
}
},
{
"name": "limit",
"in": "query",
"required": false,
"description": "Page size.",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"default": 100
}
}
],
"responses": {
"200": {
"description": "Bounded filtered page",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileLogsQuery"
}
}
}
},
"400": {
"description": "Invalid filter or unknown source",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Authentication required or revoked",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Host, origin or CSRF validation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Bounded worker/session/rate limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Control-plane storage/worker unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
}
}
}
+12
View File
@@ -83,6 +83,7 @@ pub fn router(state: Arc<App>) -> Router {
.route("/cache", get(public::index))
.route("/transform", get(public::index))
.route("/logging", get(public::index))
.route("/logs", get(public::index))
.route("/settings", get(public::index))
.route(
"/assets/login.js",
@@ -183,6 +184,15 @@ pub fn router(state: Arc<App>) -> Router {
)
}),
)
.route(
"/assets/file-logs.js",
get(|| async {
(
[("content-type", "text/javascript; charset=utf-8")],
include_str!("web/file-logs.js"),
)
}),
)
.route(
"/assets/settings.js",
get(|| async {
@@ -348,6 +358,8 @@ pub fn router(state: Arc<App>) -> Router {
.route("/api/config/{section}", put(update_section))
.route("/api/logs", get(logs))
.route("/api/live-logs", get(live_logs_stream))
.route("/api/file-logs", get(file_logs))
.route("/api/file-logs/query", get(file_logs_query))
.route("/api/preferences", get(preferences).put(update_preferences))
.route("/api/audit", get(audit))
.route("/api/events", get(events))
+1
View File
@@ -4,4 +4,5 @@ mod tests {
include!("tests/part_03.rs");
include!("tests/part_04.rs");
include!("tests/part_05.rs");
include!("tests/part_06.rs");
}
+1
View File
@@ -383,6 +383,7 @@
"/cache",
"/transform",
"/logging",
"/logs",
"/settings",
] {
let public = router
+3
View File
@@ -78,6 +78,9 @@
for (path, expected) in [
("/api/logs?vhost=demo&limit=8", 200),
("/api/logs?cache=BAD", 400),
("/api/file-logs", 200),
("/api/file-logs/query?source=all&limit=8", 200),
("/api/file-logs/query?limit=0", 400),
("/api/traffic", 200),
("/api/cache", 200),
("/api/transform", 200),
+3
View File
@@ -293,6 +293,8 @@
"/api/cache?limit=invalid",
"/api/logs?limit=invalid",
"/api/live-logs",
"/api/file-logs",
"/api/file-logs/query?limit=invalid",
"/api/certificate-api",
"/api/preferences",
"/api/audit",
@@ -322,6 +324,7 @@
"/assets/redirects.js",
"/assets/host404.js",
"/assets/settings.js",
"/assets/file-logs.js",
"/assets/acme.js",
"/api/not-a-route",
"/not-a-route",
+69
View File
@@ -0,0 +1,69 @@
#[test]
fn stored_log_inventory_keeps_existing_files_visible_when_writer_is_disabled() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("access.jsonl");
let mut file = std::fs::File::create(&path).unwrap();
writeln!(file, r#"{{"event":"request","status":200}}"#).unwrap();
let mut config = Config::default();
config.logging.file.enabled = false;
config.logging.file.per_vhost = false;
config.logging.file.path = path.to_string_lossy().into_owned();
let sources = configured_file_log_sources(&config);
let access = sources.iter().find(|source| source.id == "access").unwrap();
assert!(!access.enabled);
assert!(access.exists);
assert!(access.size > 0);
assert_eq!(access.path, path);
}
#[test]
fn stored_log_tail_enforces_line_budget() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("access.jsonl");
let mut file = std::fs::File::create(&path).unwrap();
for id in 0..10 {
writeln!(file, r#"{{"time_ms":{id},"event":"request"}}"#).unwrap();
}
drop(file);
let source = source_from_path(
"access".into(),
"Access log".into(),
"access",
path,
false,
"JSONL",
);
let (lines, _bytes, truncated) = read_tail(&source, 1024 * 1024, 3).unwrap();
assert_eq!(lines.len(), 3);
assert!(lines[0].contains(r#""time_ms":9"#));
assert!(lines[2].contains(r#""time_ms":7"#));
assert!(truncated);
}
#[test]
fn stored_log_query_rejects_oversized_source_identifier() {
let query = FileLogQuery {
source: Some("x".repeat(193)),
..FileLogQuery::default()
};
assert!(validate_file_log_query(&query).is_err());
}
#[cfg(unix)]
#[test]
fn stored_log_reader_refuses_symlink_targets() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.jsonl");
let link = dir.path().join("link.jsonl");
std::fs::write(&real, b"{}\n").unwrap();
symlink(&real, &link).unwrap();
assert!(open_file_log(&link).is_err());
}
+5 -3
View File
@@ -61,7 +61,7 @@ function updateAdminPreferences(patch) {
}
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); }
window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme };
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", settings: "/settings" };
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", filelogs: "/logs", settings: "/settings" };
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], polling = false, toastTimer, editingBackend = null, editingRoute = null;
@@ -162,6 +162,7 @@ function apiArea(path) {
if (path.startsWith("/api/certificates") || path.startsWith("/api/acme")) return "tls";
if (path.startsWith("/api/certificate-api")) return "settings";
if (path.startsWith("/api/docker")) return "dockerVhosts";
if (path.startsWith("/api/file-logs")) return "filelogs";
if (path.startsWith("/api/logs")) return "logging";
if (path.startsWith("/api/audit") || path.startsWith("/api/preferences") || path.startsWith("/api/auth/password") || path.startsWith("/api/auth/logout")) return "security";
return null;
@@ -194,6 +195,7 @@ function setPanel(name, navigate = true) {
if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]);
if (name === "settings") window.rustProxySettings?.syncLocation?.();
if (name === "tls") window.rustProxyAcme?.syncLocation?.();
if (name === "filelogs") void window.rustProxyFileLogs?.refresh();
if (name === "traffic") void window.rustProxyTrafficPanel?.refresh(); if (name === "cache") void window.rustProxyCachePanel?.refresh(); if (name === "transform") void window.rustProxyTransformPanel?.refresh(); if (name === "docker-vhosts") void window.rustProxyDocker?.refresh(false); if (name === "tls") { void window.rustProxyAcme?.refresh(); void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); }
}
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
@@ -342,7 +344,7 @@ function authReturnDestination() {
}
function lock(notify = true) {
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); $("logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
$("workspace").querySelectorAll("form").forEach(form => form.reset());
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
@@ -353,7 +355,7 @@ function lock(notify = true) {
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
}
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-open").hidden = false; $("file-logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [new Date(item.time * 1000).toLocaleString(locale()), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
let tunnelRefreshTimer = null;
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
+3 -3
View File
@@ -6,7 +6,7 @@ function renderSettings() {
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.7";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.0";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
@@ -296,14 +296,14 @@ bindSettingsForm("transform-form", next => {
max_inflight: Number($("transform-max-inflight").value)
};
}, "transform");
const LOGGING_SOURCE_FIELDS = ["vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "user_agent", "country", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"];
const LOGGING_SOURCE_FIELDS = ["event", "protocol_error", "error_detail", "local_addr", "tls_sni", "vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "user_agent", "country", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"];
const LOGGING_PRESETS = {
standard: {},
minimal: { time_ms: "time_ms", method: "method", host: "host", path: "path", status: "status", duration_ms: "duration_ms" },
nginx: { time_ms: "time_ms", remote_addr: "client_ip", http_user_agent: "user_agent", country: "country", request_method: "method", host: "host", request_uri: "path", status: "status", request_time_ms: "duration_ms", request_length: "request_bytes", bytes_sent: "response_bytes", upstream: "backend", cache: "cache", cache_reason: "cache_reason" },
ecs: { "event.created_ms": "time_ms", "client.address": "client_ip", "user_agent.original": "user_agent", "client.geo.country_iso_code": "country", "http.request.method": "method", "server.address": "host", "url.path": "path", "http.response.status_code": "status", "event.duration_ms": "duration_ms", "http.request.body.bytes": "request_bytes", "http.response.body.bytes": "response_bytes", "service.target.name": "backend", "event.outcome": "outcome", "cache.result": "cache", "cache.reason": "cache_reason" }
};
const LOGGING_PREVIEW_VALUES = { vhost: "app", route: "root", cache_effective_enabled: true, transform_effective_enabled: false, backend: "origin-1", id: 1842, time_ms: 1769328123456, method: "CONNECT", host: "app.example", path: "/socket.io/", http_version: "3", transport: "h3", websocket_bootstrap: "h3_extended_connect", websocket_phase: "tunnel_closed", websocket_error: null, h3_reset_code: null, upstream_status: 101, client_ip: "203.0.113.24", user_agent: "Mozilla/5.0 RustProxy-Test", country: "PL", status: 200, duration_ms: 12, request_bytes: 0, response_bytes: 4821, outcome: "websocket_closed", cache: "BYPASS", cache_reason: "request_upgrade" };
const LOGGING_PREVIEW_VALUES = { event: "request", protocol_error: null, error_detail: null, local_addr: "192.0.2.1:443", tls_sni: "app.example", vhost: "app", route: "root", cache_effective_enabled: true, transform_effective_enabled: false, backend: "origin-1", id: 1842, time_ms: 1769328123456, method: "CONNECT", host: "app.example", path: "/socket.io/", http_version: "3", transport: "h3", websocket_bootstrap: "h3_extended_connect", websocket_phase: "tunnel_closed", websocket_error: null, h3_reset_code: null, upstream_status: 101, client_ip: "203.0.113.24", user_agent: "Mozilla/5.0 RustProxy-Test", country: "PL", status: 200, duration_ms: 12, request_bytes: 0, response_bytes: 4821, outcome: "websocket_closed", cache: "BYPASS", cache_reason: "request_upgrade" };
function parseJsonObjectValue(value, target = null) { let parsed; try { parsed = JSON.parse(value || "{}"); } catch { throw fieldError(target, t("loggingJsonInvalid")); } if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw fieldError(target, t("loggingJsonInvalid")); return parsed; }
function loggingMappingKey(mapping) { return JSON.stringify(Object.entries(mapping || {}).sort(([a], [b]) => a.localeCompare(b))); }
function detectLoggingPreset(mapping) { const key = loggingMappingKey(mapping); for (const [name, preset] of Object.entries(LOGGING_PRESETS)) if (loggingMappingKey(preset) === key) return name; return "custom"; }
+91
View File
@@ -0,0 +1,91 @@
(() => {
let inventory = null, offset = 0, lastTotal = 0, busy = false;
const sourceSelect = () => $("file-log-source");
const currentLimit = () => Math.max(1, Math.min(500, Number($("file-log-limit").value) || 100));
function sourceOption(source) {
const label = `${source.label} · ${source.format} · ${bytes(source.size || 0)}${source.enabled ? ` · ${t("enabled")}` : ""}`;
const option = el("option", label); option.value = source.id; return option;
}
function renderInventory(value) {
inventory = value;
const selected = sourceSelect().value || "all";
const sources = Array.isArray(value?.sources) ? value.sources : [];
const options = [Object.assign(el("option", t("fileLogsAllSources")), { value: "all" }), ...sources.map(sourceOption)];
sourceSelect().replaceChildren(...options);
sourceSelect().value = ["all", ...sources.map(source => source.id)].includes(selected) ? selected : "all";
$("file-log-inventory").textContent = sources.length
? sources.map(source => `${source.label}: ${source.path} (${bytes(source.size || 0)})`).join(" · ")
: t("fileLogsNoSources");
}
async function loadInventory() {
const value = await api("/api/file-logs"); renderInventory(value); return value;
}
function entryTime(entry) {
if (entry.time_ms) return new Date(Number(entry.time_ms)).toLocaleString(locale());
if (entry.timestamp) return String(entry.timestamp);
return "—";
}
function entrySummary(entry) {
if (entry.protocol_error) return entry.protocol_error;
if (entry.method || entry.path) return `${entry.method || ""} ${entry.host || ""}${entry.path || ""}`.trim();
if (entry.message) return entry.message;
if (entry.raw) return entry.raw;
return entry.outcome || "—";
}
function entryStatus(entry) {
const status = Number(entry.status) || 0;
return status ? String(status) : "—";
}
function renderRows(entries) {
const rows = entries.map(entry => {
const row = el("tr"); row.tabIndex = 0;
for (const value of [entryTime(entry), entry.event || "—", entryStatus(entry), entry.client_ip || "—", entry.vhost || "—", entrySummary(entry), entry._source || "—"]) {
const cell = el("td", String(value)); cell.title = String(value); row.append(cell);
}
const show = () => { $("file-log-detail").textContent = JSON.stringify(entry, null, 2); };
row.addEventListener("click", show); row.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); show(); } });
return row;
});
$("file-log-rows").replaceChildren(...rows);
$("file-logs-empty").hidden = rows.length !== 0;
if (!rows.length) $("file-log-detail").textContent = t("fileLogsSelectRecord");
}
function queryString() {
const params = new URLSearchParams();
params.set("source", sourceSelect().value || "all");
for (const [id, name] of [["file-log-q", "q"], ["file-log-event", "event"], ["file-log-status", "status_class"], ["file-log-client", "client_ip"], ["file-log-vhost", "vhost"]]) {
const value = $(id).value.trim(); if (value && value !== "all") params.set(name, value);
}
params.set("offset", String(offset)); params.set("limit", String(currentLimit()));
return params.toString();
}
async function refresh(reset = false) {
if (!authenticated || busy) return;
if (reset) offset = 0;
busy = true; $("file-logs-refresh").disabled = true;
try {
if (!inventory) await loadInventory();
const value = await api(`/api/file-logs/query?${queryString()}`);
lastTotal = Number(value.total) || 0; renderRows(Array.isArray(value.entries) ? value.entries : []);
const from = lastTotal ? offset + 1 : 0, to = Math.min(offset + currentLimit(), lastTotal);
$("file-log-summary").textContent = t("fileLogsSummary")
.replace("{from}", fmt(from)).replace("{to}", fmt(to)).replace("{total}", fmt(lastTotal))
.replace("{bytes}", bytes(Number(value.scanned_bytes) || 0)).replace("{lines}", fmt(Number(value.scanned_lines) || 0))
+ (value.truncated ? ` · ${t("fileLogsTruncated")}` : "");
$("file-logs-prev").disabled = offset === 0;
$("file-logs-next").disabled = offset + currentLimit() >= lastTotal;
} catch (error) { toast(error.message, true); }
finally { busy = false; $("file-logs-refresh").disabled = false; }
}
function reset() {
inventory = null; offset = 0; lastTotal = 0; $("file-log-rows").replaceChildren(); $("file-log-detail").textContent = "";
}
$("file-logs-open").addEventListener("click", () => setPanel("filelogs"));
$("settings-file-logs-open").addEventListener("click", () => setPanel("filelogs"));
$("file-logs-refresh").addEventListener("click", () => refresh(true));
$("file-logs-filter").addEventListener("submit", event => { event.preventDefault(); void refresh(true); });
$("file-logs-prev").addEventListener("click", () => { offset = Math.max(0, offset - currentLimit()); void refresh(); });
$("file-logs-next").addEventListener("click", () => { if (offset + currentLimit() < lastTotal) { offset += currentLimit(); void refresh(); } });
sourceSelect().addEventListener("change", () => refresh(true));
window.rustProxyFileLogs = { refresh: () => refresh(false), reset, inventory: loadInventory };
})();
+90 -4
View File
@@ -22,6 +22,7 @@
<script defer="" src="/assets/transform-panel.js"></script>
<script defer="" src="/assets/acme.js"></script>
<script defer="" src="/assets/settings.js"></script>
<script defer="" src="/assets/file-logs.js"></script>
</head>
<body>
@@ -63,7 +64,8 @@
<header class="topbar"><span class="breadcrumb">CONTROL ROOM <span>/</span> <b
id="page-label">Overview</b></span>
<div class="top-actions"><a class="quiet" href="/docs" data-i18n="documentation" id="api-docs"
hidden="">Documentation</a><button class="quiet" data-i18n="liveLogs" hidden="" id="logs-open"
hidden="">Documentation</a><button class="quiet" data-i18n="logs" hidden="" id="file-logs-open"
type="button">Logs</button><button class="quiet" data-i18n="liveLogs" hidden="" id="logs-open"
type="button">Live
access logs</button><select aria-label="Language" class="language-select"
data-i18n-aria="language" id="language"></select><button class="quiet" data-i18n="about"
@@ -1494,6 +1496,79 @@
yet.</p>
</article>
</section>
<section class="panel" hidden="" id="panel-filelogs">
<article class="card file-logs-workspace">
<div class="card-heading">
<div><span class="small-label" data-i18n="fileLogsMode">LOCAL / READ-ONLY / BOUNDED</span>
<h2 data-i18n="fileLogsTitle">Stored log explorer</h2>
</div>
<button class="outline" data-i18n="refresh" id="file-logs-refresh"
type="button">Refresh</button>
</div>
<p class="fine" data-i18n="fileLogsHelp">Reads only configured RustProxy log files. It is
independent from Live Logs and never accepts an arbitrary filesystem path.</p>
<form class="file-logs-filter" id="file-logs-filter">
<label><span data-i18n="fileLogsSource">Source</span><select id="file-log-source">
<option value="all">All configured files</option>
</select></label>
<label class="file-log-search"><span data-i18n="search">Search</span><input id="file-log-q"
maxlength="256" data-i18n-placeholder="fileLogsSearchPlaceholder"
placeholder="IP, path, error, request id..." /></label>
<label><span data-i18n="fileLogsEvent">Event</span><select id="file-log-event">
<option data-i18n="all" value="all">All</option>
<option value="request">request</option>
<option value="protocol_error">protocol_error</option>
<option value="error">error</option>
</select></label>
<label><span data-i18n="status">Status</span><select id="file-log-status">
<option data-i18n="all" value="all">All</option>
<option value="1xx">1xx</option>
<option value="2xx">2xx</option>
<option value="3xx">3xx</option>
<option value="4xx">4xx</option>
<option value="5xx">5xx</option>
</select></label>
<label><span data-i18n="clientIp">Client IP</span><input id="file-log-client"
maxlength="128" placeholder="192.0.2.10" /></label>
<label><span data-i18n="vhost">Vhost</span><input id="file-log-vhost"
maxlength="256" /></label>
<label><span data-i18n="fileLogsPageSize">Page size</span><select id="file-log-limit">
<option>50</option>
<option selected>100</option>
<option>250</option>
<option>500</option>
</select></label>
<button class="primary" data-i18n="applyFilters" type="submit">Apply filters</button>
</form>
<p class="fine file-log-inventory" id="file-log-inventory"></p>
<p class="fine" id="file-log-summary" role="status"></p>
<div class="table-scroll file-logs-table">
<table>
<thead>
<tr>
<th data-i18n="time">Time</th>
<th data-i18n="fileLogsEvent">Event</th>
<th data-i18n="status">Status</th>
<th data-i18n="clientIp">Client IP</th>
<th data-i18n="vhost">Vhost</th>
<th data-i18n="fileLogsRecord">Request / reason</th>
<th data-i18n="fileLogsSource">Source</th>
</tr>
</thead>
<tbody id="file-log-rows"></tbody>
</table>
</div>
<p class="empty" data-i18n="fileLogsEmpty" id="file-logs-empty">No records match the current
filters.</p>
<div class="file-logs-pager"><button class="quiet" data-i18n="previous" disabled
id="file-logs-prev" type="button">Previous</button><button class="quiet"
data-i18n="next" disabled id="file-logs-next" type="button">Next</button></div>
<details open class="file-log-detail-card">
<summary data-i18n="fileLogsDetails">Record details</summary>
<pre class="metrics-example" id="file-log-detail"></pre>
</details>
</article>
</section>
<section class="panel" hidden="" id="panel-settings">
<div class="settings-shortcuts">
<div><b data-i18n="security">Security</b>
@@ -1667,7 +1742,7 @@
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.7"
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.0"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -2065,6 +2140,17 @@
<p class="fine" data-i18n="loggingHelp">Live log viewing stays in memory.
External sinks
run on a bounded background queue and never block proxy request I/O.</p>
<article class="card soft-card file-logs-launcher">
<div>
<h3 class="subheading" data-i18n="fileLogsTitle">Stored log explorer
</h3>
<p class="fine" data-i18n="fileLogsSettingsHelp">Browse configured
access/error files with server-side filters. File logs can be off
while Live Logs continues to use its memory buffer.</p>
</div>
<button class="outline" data-i18n="openLogs" id="settings-file-logs-open"
type="button">Open Logs</button>
</article>
<article class="card soft-card">
<h3 class="subheading" data-i18n="loggingCaptureTitle">Captured request
dimensions</h3>
@@ -2455,7 +2541,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.25.7</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.26.0</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3166,7 +3252,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.25.7</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.26.0</strong></div>
</div>
<dl class="about-meta">
<div>
+1 -1
View File
@@ -4,7 +4,7 @@
const node = id => document.getElementById(id);
const themes = new Set(["dark", "light"]);
try { const cached = localStorage.getItem("rp-theme"); document.documentElement.dataset.theme = themes.has(cached) ? cached : "dark"; } catch { document.documentElement.dataset.theme = "dark"; }
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/traffic", "/cache", "/transform", "/logging", "/settings"]);
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/traffic", "/cache", "/transform", "/logging", "/logs", "/settings"]);
const settingsTabs = new Set(["listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
let packs = new Map(), fallback = null, language = null, setupRequired = false, ready = false;
try { language = localStorage.getItem("rp-language"); } catch { }
+80
View File
@@ -8198,4 +8198,84 @@ button.config-history-day.is-selected {
align-items: flex-start;
flex-direction: column;
}
}
.file-logs-workspace {
display: grid;
gap: 14px;
}
.file-logs-filter {
display: grid;
grid-template-columns: repeat(7, minmax(120px, 1fr));
gap: 10px;
align-items: end;
}
.file-logs-filter label {
min-width: 0;
}
.file-logs-filter .file-log-search {
grid-column: span 2;
}
.file-logs-table {
max-height: 52vh;
}
.file-logs-table tbody tr {
cursor: pointer;
}
.file-logs-table td {
max-width: 340px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-logs-pager {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.file-log-detail-card pre {
max-height: 32vh;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.file-logs-launcher {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.file-log-inventory {
overflow-wrap: anywhere;
}
@media (max-width: 1100px) {
.file-logs-filter {
grid-template-columns: repeat(3, minmax(140px, 1fr));
}
}
@media (max-width: 700px) {
.file-logs-filter {
grid-template-columns: 1fr;
}
.file-logs-filter .file-log-search {
grid-column: auto;
}
.file-logs-launcher {
align-items: stretch;
flex-direction: column;
}
}