This commit is contained in:
Mateusz Gruszczyński committed 2026-10-05 19:05:55 +02:00
1 parent ddc5c6593f
commit b9d137eee1
63 files changed
+1232 -445

No files matched your search

+8
View File
@@ -399,6 +399,14 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
(
"docs/V0.26.1_IMPLEMENTATION.md",
"technical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.26.1_IMPLEMENTATION.md"
)),
),
(
"docs/V0.25.5_AUDIT.md",
"audits",
+75 -6
View File
@@ -229,12 +229,62 @@ fn read_tail(
Ok((lines, bytes, truncated || iter.next().is_some()))
}
fn normalized_record(source: &FileLogSource, line: &str) -> Value {
fn copy_mapped_field(
object: &mut serde_json::Map<String, Value>,
format: &crate::logging::JsonFormat,
canonical: &str,
) {
if object.contains_key(canonical) {
return;
}
let mapped = format
.fields
.iter()
.find_map(|(output, source)| (source == canonical).then_some(output));
if let Some(value) = mapped.and_then(|output| object.get(output)).cloned() {
object.insert(canonical.to_owned(), value);
}
}
fn normalized_record(
source: &FileLogSource,
line: &str,
format: &crate::logging::JsonFormat,
) -> 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");
if source.kind == "access" {
for field in [
"event", "time_ms", "status", "client_ip", "vhost", "method", "host", "path",
"transport", "http_version", "protocol_error", "error_detail", "tls_sni", "reason",
"error", "sni", "authority", "matched_by",
] {
copy_mapped_field(object, format, field);
}
if !object.contains_key("client_ip") {
if let Some(value) = object.get("source_ip").cloned() {
object.insert("client_ip".into(), value);
}
}
if !object.contains_key("reason") {
if let Some(value) = object.get("protocol_error").cloned() {
object.insert("reason".into(), value);
}
}
if !object.contains_key("error") {
if let Some(value) = object.get("error_detail").cloned() {
object.insert("error".into(), value);
}
}
if !object.contains_key("sni") {
if let Some(value) = object.get("tls_sni").cloned() {
object.insert("sni".into(), value);
}
}
}
object.entry("event").or_insert_with(|| {
Value::String(
if source.kind == "error" {
@@ -256,12 +306,27 @@ fn contains_ci(value: Option<&Value>, needle: &str) -> bool {
.is_some_and(|text| text.to_ascii_lowercase().contains(needle))
}
fn append_search_values(value: &Value, output: &mut String) {
match value {
Value::Null => {}
Value::Bool(value) => { output.push_str(if *value { " true" } else { " false" }); }
Value::Number(value) => { output.push(' '); output.push_str(&value.to_string()); }
Value::String(value) => { output.push(' '); output.push_str(&value.to_ascii_lowercase()); }
Value::Array(values) => values.iter().for_each(|value| append_search_values(value, output)),
Value::Object(values) => values.values().for_each(|value| append_search_values(value, output)),
}
}
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 !q.is_empty() {
let mut searchable = String::new();
append_search_values(value, &mut searchable);
if !q.split_whitespace().all(|term| searchable.contains(term)) {
return false;
}
}
if let Some(event) = query
.event
@@ -345,7 +410,9 @@ async fn file_logs_query(
let config = state.config.load().envelope.config.clone();
tokio::task::spawn_blocking(move || {
let mut sources = configured_file_log_sources(&config);
let log_format = config.logging.format.clone();
let requested = query.source.as_deref().unwrap_or("all");
let mut truncated = false;
if requested != "all" {
sources.retain(|source| source.id == requested);
if sources.is_empty() {
@@ -356,7 +423,10 @@ async fn file_logs_query(
}
} else {
sources.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms));
sources.truncate(FILE_LOG_MAX_SOURCES);
if sources.len() > FILE_LOG_MAX_SOURCES {
truncated = true;
sources.truncate(FILE_LOG_MAX_SOURCES);
}
}
let q = query.q.clone().unwrap_or_default().to_ascii_lowercase();
let offset = query.offset.unwrap_or(0);
@@ -365,7 +435,6 @@ async fn file_logs_query(
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 {
@@ -385,7 +454,7 @@ async fn file_logs_query(
if line.is_empty() {
continue;
}
let value = normalized_record(source, &line);
let value = normalized_record(source, &line, &log_format);
if record_matches(&value, &query, &q) {
records.push(value);
}
+1
View File
@@ -3,6 +3,7 @@
include!("router.rs");
include!("access.rs");
include!("session.rs");
include!("preferences.rs");
include!("status.rs");
include!("logging_api.rs");
include!("file_logs.rs");
+33 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.26.0",
"version": "0.26.1",
"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": [
@@ -2909,6 +2909,38 @@
},
"description": "Account-scoped Live Logs columns shown in the dashboard. JSON log export still contains every field present in each entry."
},
"file_log_columns": {
"type": "array",
"minItems": 1,
"maxItems": 11,
"uniqueItems": true,
"items": {
"type": "string",
"enum": [
"time",
"event",
"status",
"client_ip",
"vhost",
"transport",
"matched_by",
"sni",
"authority",
"record",
"source"
]
},
"default": [
"time",
"event",
"status",
"client_ip",
"vhost",
"record",
"source"
],
"description": "Account-scoped Stored Logs explorer columns shown in the dashboard."
},
"theme": {
"type": "string",
"enum": [
+197
View File
@@ -0,0 +1,197 @@
const LIVE_LOG_COLUMNS: &[&str] = &[
"time",
"request",
"host",
"source_ip",
"vhost",
"backend",
"status",
"duration",
"cache",
"outcome",
"transport",
"websocket",
"upstream_error",
];
const FILE_LOG_COLUMNS: &[&str] = &[
"time", "event", "status", "client_ip", "vhost", "transport", "matched_by", "sni",
"authority", "record", "source",
];
const BACKEND_TABLE_COLUMNS: &[&str] = &[
"vhost",
"backend",
"status",
"active",
"requests",
"errors",
"latency",
"traffic",
"generation",
"generationRequests",
"generationAge",
"rotations",
"lastFailure",
"lastRecovery",
];
fn default_admin_theme() -> String {
"dark".to_owned()
}
fn default_file_log_columns() -> Vec<String> {
["time", "event", "status", "client_ip", "vhost", "record", "source"]
.into_iter()
.map(str::to_owned)
.collect()
}
fn default_backend_table_columns() -> Vec<String> {
BACKEND_TABLE_COLUMNS
.iter()
.map(|value| (*value).to_owned())
.collect()
}
fn default_backend_table_sort() -> String {
"vhost".to_owned()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct AdminPreferences {
live_log_columns: Vec<String>,
#[serde(default = "default_admin_theme")]
theme: String,
#[serde(default = "default_file_log_columns")]
file_log_columns: Vec<String>,
#[serde(default = "default_backend_table_columns")]
backend_table_columns: Vec<String>,
#[serde(default = "default_backend_table_sort")]
backend_table_sort: String,
#[serde(default)]
backend_table_descending: bool,
}
impl Default for AdminPreferences {
fn default() -> Self {
Self {
live_log_columns: LIVE_LOG_COLUMNS
.iter()
.map(|value| (*value).to_owned())
.collect(),
theme: default_admin_theme(),
file_log_columns: default_file_log_columns(),
backend_table_columns: default_backend_table_columns(),
backend_table_sort: default_backend_table_sort(),
backend_table_descending: false,
}
}
}
fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> {
if preferences.live_log_columns.is_empty()
|| preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len()
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"at least one live-log column must be visible".into(),
));
}
let mut seen = std::collections::HashSet::new();
for column in &preferences.live_log_columns {
if !LIVE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid live-log column preference".into(),
));
}
}
if preferences.file_log_columns.is_empty()
|| preferences.file_log_columns.len() > FILE_LOG_COLUMNS.len()
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"at least one stored-log column must be visible".into(),
));
}
let mut seen = std::collections::HashSet::new();
for column in &preferences.file_log_columns {
if !FILE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid stored-log column preference".into(),
));
}
}
if !matches!(preferences.theme.as_str(), "dark" | "light") {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid administration theme preference".into(),
));
}
if preferences.backend_table_columns.is_empty()
|| preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len()
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"at least one backend table column must be visible".into(),
));
}
let mut seen = std::collections::HashSet::new();
for column in &preferences.backend_table_columns {
if !BACKEND_TABLE_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid backend table column preference".into(),
));
}
}
if !BACKEND_TABLE_COLUMNS.contains(&preferences.backend_table_sort.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid backend table sort preference".into(),
));
}
Ok(())
}
async fn preferences(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<AdminPreferences>, ApiError> {
let stored = state
.store
.admin_preferences(&session.username)
.await
.map_err(auth::internal)?;
let preferences = match stored {
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences,
Ok(_) | Err(_) => {
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
AdminPreferences::default()
}
},
None => AdminPreferences::default(),
};
Ok(Json(preferences))
}
async fn update_preferences(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
) -> Result<Json<AdminPreferences>, ApiError> {
let Json(preferences) = input.map_err(public::invalid_json)?;
validate_admin_preferences(&preferences)?;
let document = serde_json::to_string(&preferences).map_err(auth::internal)?;
if !state
.store
.save_admin_preferences(&session.username, &document)
.await
.map_err(auth::internal)?
{
return Err(ApiError(
StatusCode::UNAUTHORIZED,
"auth.invalid_credentials".into(),
));
}
Ok(Json(preferences))
}
-167
View File
@@ -252,173 +252,6 @@ async fn session(
Json(session_payload(&session, session_seconds))
}
const LIVE_LOG_COLUMNS: &[&str] = &[
"time",
"request",
"host",
"source_ip",
"vhost",
"backend",
"status",
"duration",
"cache",
"outcome",
"transport",
"websocket",
"upstream_error",
];
const BACKEND_TABLE_COLUMNS: &[&str] = &[
"vhost",
"backend",
"status",
"active",
"requests",
"errors",
"latency",
"traffic",
"generation",
"generationRequests",
"generationAge",
"rotations",
"lastFailure",
"lastRecovery",
];
fn default_admin_theme() -> String {
"dark".to_owned()
}
fn default_backend_table_columns() -> Vec<String> {
BACKEND_TABLE_COLUMNS
.iter()
.map(|value| (*value).to_owned())
.collect()
}
fn default_backend_table_sort() -> String {
"vhost".to_owned()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct AdminPreferences {
live_log_columns: Vec<String>,
#[serde(default = "default_admin_theme")]
theme: String,
#[serde(default = "default_backend_table_columns")]
backend_table_columns: Vec<String>,
#[serde(default = "default_backend_table_sort")]
backend_table_sort: String,
#[serde(default)]
backend_table_descending: bool,
}
impl Default for AdminPreferences {
fn default() -> Self {
Self {
live_log_columns: LIVE_LOG_COLUMNS
.iter()
.map(|value| (*value).to_owned())
.collect(),
theme: default_admin_theme(),
backend_table_columns: default_backend_table_columns(),
backend_table_sort: default_backend_table_sort(),
backend_table_descending: false,
}
}
}
fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> {
if preferences.live_log_columns.is_empty()
|| preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len()
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"at least one live-log column must be visible".into(),
));
}
let mut seen = std::collections::HashSet::new();
for column in &preferences.live_log_columns {
if !LIVE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid live-log column preference".into(),
));
}
}
if !matches!(preferences.theme.as_str(), "dark" | "light") {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid administration theme preference".into(),
));
}
if preferences.backend_table_columns.is_empty()
|| preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len()
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"at least one backend table column must be visible".into(),
));
}
let mut seen = std::collections::HashSet::new();
for column in &preferences.backend_table_columns {
if !BACKEND_TABLE_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid backend table column preference".into(),
));
}
}
if !BACKEND_TABLE_COLUMNS.contains(&preferences.backend_table_sort.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid backend table sort preference".into(),
));
}
Ok(())
}
async fn preferences(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<AdminPreferences>, ApiError> {
let stored = state
.store
.admin_preferences(&session.username)
.await
.map_err(auth::internal)?;
let preferences = match stored {
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences,
Ok(_) | Err(_) => {
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
AdminPreferences::default()
}
},
None => AdminPreferences::default(),
};
Ok(Json(preferences))
}
async fn update_preferences(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
) -> Result<Json<AdminPreferences>, ApiError> {
let Json(preferences) = input.map_err(public::invalid_json)?;
validate_admin_preferences(&preferences)?;
let document = serde_json::to_string(&preferences).map_err(auth::internal)?;
if !state
.store
.save_admin_preferences(&session.username, &document)
.await
.map_err(auth::internal)?
{
return Err(ApiError(
StatusCode::UNAUTHORIZED,
"auth.invalid_credentials".into(),
));
}
Ok(Json(preferences))
}
fn logged_out(secure_cookie: bool) -> Response {
let mut response = Json(json!({"logged_out":true})).into_response();
response.headers_mut().insert(
+6
View File
@@ -20,6 +20,10 @@
LIVE_LOG_COLUMNS.len()
);
assert_eq!(initial["theme"], "dark");
assert_eq!(
initial["file_log_columns"].as_array().unwrap().len(),
default_file_log_columns().len()
);
assert_eq!(
initial["backend_table_columns"].as_array().unwrap().len(),
BACKEND_TABLE_COLUMNS.len()
@@ -45,6 +49,7 @@
let legacy = json_body(response).await;
assert_eq!(legacy["live_log_columns"], json!(["time", "status"]));
assert_eq!(legacy["theme"], "dark");
assert_eq!(legacy["file_log_columns"], json!(default_file_log_columns()));
assert_eq!(
legacy["backend_table_columns"].as_array().unwrap().len(),
BACKEND_TABLE_COLUMNS.len()
@@ -55,6 +60,7 @@
let selected = json!({
"live_log_columns":["time","request","status","upstream_error"],
"theme":"light",
"file_log_columns":["time","event","client_ip","record"],
"backend_table_columns":["vhost","backend","status","requests","latency"],
"backend_table_sort":"requests",
"backend_table_descending":true
+31
View File
@@ -55,6 +55,37 @@
assert!(validate_file_log_query(&query).is_err());
}
#[test]
fn stored_log_normalizes_mapped_client_ip_and_searches_diagnostics() {
let dir = tempfile::tempdir().unwrap();
let source = source_from_path(
"vhost:app".into(),
"app.jsonl".into(),
"access",
dir.path().join("app.jsonl"),
true,
"JSONL",
);
let mut format = crate::logging::JsonFormat::default();
format.fields.remove("client_ip");
format
.fields
.insert("remote_addr".into(), "client_ip".into());
let record = normalized_record(
&source,
r#"{"event":"protocol_error","remote_addr":"192.0.2.10","reason":"tls_handshake_error","error":"HTTP parser error","sni":"app.example.test","matched_by":"sni","vhost":"app"}"#,
&format,
);
assert_eq!(record["client_ip"], "192.0.2.10");
assert_eq!(record["sni"], "app.example.test");
let query = FileLogQuery::default();
assert!(record_matches(&record, &query, "error"));
assert!(record_matches(&record, &query, "http parser"));
assert!(record_matches(&record, &query, "app.example.test"));
assert!(!record_matches(&record, &query, "missing"));
}
#[cfg(unix)]
#[test]
fn stored_log_reader_refuses_symlink_targets() {
+1 -1
View File
@@ -355,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; $("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 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.rustProxyFileLogs?.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); }
+1 -1
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.26.0";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.1";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
+95 -15
View File
@@ -1,7 +1,28 @@
(() => {
let inventory = null, offset = 0, lastTotal = 0, busy = false;
let currentEntries = [], selectedIndex = null;
let preferencesLoaded = false, preferenceSaveRunning = false, preferenceSaveQueued = false;
let searchTimer = null;
const columns = [
{ id: "time", label: "time", value: entry => entryTime(entry) },
{ id: "event", label: "fileLogsEvent", value: entry => entry.event || "—" },
{ id: "status", label: "status", value: entry => entryStatus(entry) },
{ id: "client_ip", label: "clientIp", value: entry => entry.client_ip || entry.source_ip || "—" },
{ id: "vhost", label: "vhost", value: entry => entry.vhost || "—" },
{ id: "transport", label: "logTransport", value: entry => entry.transport ? `${entry.transport}${entry.http_version ? ` / HTTP ${entry.http_version}` : ""}` : "—" },
{ id: "matched_by", label: "fileLogsMatchedBy", value: entry => entry.matched_by || "—" },
{ id: "sni", label: "fileLogsSni", value: entry => entry.sni || entry.tls_sni || "—" },
{ id: "authority", label: "fileLogsAuthority", value: entry => entry.authority || entry.host || "—" },
{ id: "record", label: "fileLogsRecord", value: entry => entrySummary(entry) },
{ id: "source", label: "fileLogsSource", value: entry => entry._source || "—" },
];
const defaultColumnIds = ["time", "event", "status", "client_ip", "vhost", "record", "source"];
let visibleColumns = new Set(defaultColumnIds);
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;
@@ -26,8 +47,9 @@
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.reason || entry.protocol_error) return entry.reason || entry.protocol_error;
if (entry.method || entry.path) return `${entry.method || ""} ${entry.host || entry.authority || ""}${entry.path || ""}`.trim();
if (entry.error || entry.error_detail) return entry.error || entry.error_detail;
if (entry.message) return entry.message;
if (entry.raw) return entry.raw;
return entry.outcome || "—";
@@ -36,19 +58,72 @@
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 || "—"]) {
function syncHeaders() {
document.querySelectorAll("#file-logs-table-head [data-file-log-column]").forEach(header => {
header.hidden = !visibleColumns.has(header.dataset.fileLogColumn);
});
}
function syncColumnPicker() {
const picker = $("file-log-column-options");
picker.replaceChildren(...columns.map(column => {
const label = el("label", undefined, "check log-column-option");
const input = document.createElement("input"); input.type = "checkbox"; input.checked = visibleColumns.has(column.id); input.dataset.column = column.id;
const text = el("span", t(column.label)); text.dataset.i18n = column.label;
input.addEventListener("change", () => {
if (!input.checked && visibleColumns.size === 1) { input.checked = true; toast(t("fileLogsColumnsRequired"), true); return; }
if (input.checked) visibleColumns.add(column.id); else visibleColumns.delete(column.id);
syncHeaders(); renderRows(); void savePreferences();
});
label.append(input, text); return label;
}));
}
function applyPreferences(preferences) {
const selected = Array.isArray(preferences?.file_log_columns)
? preferences.file_log_columns.filter(id => columns.some(column => column.id === id))
: [];
visibleColumns = new Set(selected.length ? selected : defaultColumnIds);
preferencesLoaded = true;
syncColumnPicker(); syncHeaders(); renderRows();
}
async function savePreferences() {
if (!authenticated || !preferencesLoaded) return;
if (preferenceSaveRunning) { preferenceSaveQueued = true; return; }
preferenceSaveRunning = true;
try {
do {
preferenceSaveQueued = false;
const selected = columns.filter(column => visibleColumns.has(column.id)).map(column => column.id);
const preferences = await window.rustProxyPreferences.update({ file_log_columns: selected });
if (!preferenceSaveQueued) applyPreferences(preferences);
} while (preferenceSaveQueued);
} catch (error) { toast(error.message, true); }
finally { preferenceSaveRunning = false; }
}
function renderRows(entries = currentEntries) {
currentEntries = entries;
const nodes = [];
entries.forEach((entry, index) => {
const row = el("tr"); row.tabIndex = 0; row.classList.toggle("selected", selectedIndex === index);
row.setAttribute("aria-expanded", selectedIndex === index ? "true" : "false");
for (const column of columns) {
if (!visibleColumns.has(column.id)) continue;
const value = column.value(entry);
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;
const select = () => { selectedIndex = selectedIndex === index ? null : index; renderRows(); };
row.addEventListener("click", select);
row.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); select(); } });
nodes.push(row);
if (selectedIndex === index) {
const detailRow = el("tr", undefined, "file-log-detail-row");
const cell = el("td"); cell.colSpan = Math.max(1, visibleColumns.size);
const heading = el("strong", t("fileLogsDetails")); heading.dataset.i18n = "fileLogsDetails";
const pre = el("pre", JSON.stringify(entry, null, 2), "metrics-example");
const detail = el("div", undefined, "file-log-inline-detail"); detail.append(heading, pre); cell.append(detail); detailRow.append(cell); nodes.push(detailRow);
}
});
$("file-log-rows").replaceChildren(...rows);
$("file-logs-empty").hidden = rows.length !== 0;
if (!rows.length) $("file-log-detail").textContent = t("fileLogsSelectRecord");
$("file-log-rows").replaceChildren(...nodes);
$("file-logs-empty").hidden = entries.length !== 0;
}
function queryString() {
const params = new URLSearchParams();
@@ -66,7 +141,8 @@
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 : []);
lastTotal = Number(value.total) || 0; selectedIndex = null;
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))
@@ -78,14 +154,18 @@
finally { busy = false; $("file-logs-refresh").disabled = false; }
}
function reset() {
inventory = null; offset = 0; lastTotal = 0; $("file-log-rows").replaceChildren(); $("file-log-detail").textContent = "";
inventory = null; offset = 0; lastTotal = 0; currentEntries = []; selectedIndex = null;
preferencesLoaded = false; preferenceSaveRunning = false; preferenceSaveQueued = false;
visibleColumns = new Set(defaultColumnIds); $("file-log-rows").replaceChildren(); syncColumnPicker(); syncHeaders();
}
$("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-log-q").addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => void refresh(true), 250); });
$("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 };
syncColumnPicker(); syncHeaders();
window.rustProxyFileLogs = { refresh: () => refresh(false), reset, inventory: loadInventory, applyPreferences };
})();
+23 -16
View File
@@ -1539,20 +1539,31 @@
<option>500</option>
</select></label>
<button class="primary" data-i18n="applyFilters" type="submit">Apply filters</button>
<details class="log-column-picker file-log-column-picker">
<summary data-i18n="logColumns">Columns</summary>
<div class="log-column-options" id="file-log-column-options"></div>
</details>
</form>
<p class="fine file-log-inventory" id="file-log-inventory"></p>
<details class="file-log-inventory-details">
<summary data-i18n="fileLogsSourcesRead">Configured files</summary>
<p class="fine file-log-inventory" id="file-log-inventory"></p>
</details>
<p class="fine" id="file-log-summary" role="status"></p>
<div class="table-scroll file-logs-table">
<table>
<thead>
<thead id="file-logs-table-head">
<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>
<th data-file-log-column="time" data-i18n="time">Time</th>
<th data-file-log-column="event" data-i18n="fileLogsEvent">Event</th>
<th data-file-log-column="status" data-i18n="status">Status</th>
<th data-file-log-column="client_ip" data-i18n="clientIp">Client IP</th>
<th data-file-log-column="vhost" data-i18n="vhost">Vhost</th>
<th data-file-log-column="transport" data-i18n="logTransport" hidden>Transport</th>
<th data-file-log-column="matched_by" data-i18n="fileLogsMatchedBy" hidden>Matched by</th>
<th data-file-log-column="sni" data-i18n="fileLogsSni" hidden>SNI</th>
<th data-file-log-column="authority" data-i18n="fileLogsAuthority" hidden>Authority</th>
<th data-file-log-column="record" data-i18n="fileLogsRecord">Request / reason</th>
<th data-file-log-column="source" data-i18n="fileLogsSource">Source</th>
</tr>
</thead>
<tbody id="file-log-rows"></tbody>
@@ -1563,10 +1574,6 @@
<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">
@@ -1742,7 +1749,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.26.0"
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.1"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -2541,7 +2548,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.26.0</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.26.1</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3252,7 +3259,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.26.0</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.26.1</strong></div>
</div>
<dl class="about-meta">
<div>
+49 -11
View File
@@ -8207,7 +8207,7 @@ button.config-history-day.is-selected {
.file-logs-filter {
display: grid;
grid-template-columns: repeat(7, minmax(120px, 1fr));
grid-template-columns: repeat(8, minmax(120px, 1fr));
gap: 10px;
align-items: end;
}
@@ -8220,14 +8220,31 @@ button.config-history-day.is-selected {
grid-column: span 2;
}
.file-logs-table {
max-height: 52vh;
.file-log-column-picker {
align-self: end;
}
.file-logs-table tbody tr {
.file-logs-table {
max-height: 62vh;
}
.file-logs-table thead th {
position: sticky;
top: 0;
z-index: 4;
background: var(--panel);
box-shadow: 0 1px 0 var(--line);
}
.file-logs-table tbody tr:not(.file-log-detail-row) {
cursor: pointer;
}
.file-logs-table tbody tr.selected > td {
background: var(--panel2);
box-shadow: inset 3px 0 0 var(--accent);
}
.file-logs-table td {
max-width: 340px;
overflow: hidden;
@@ -8235,19 +8252,33 @@ button.config-history-day.is-selected {
white-space: nowrap;
}
.file-log-detail-row td {
max-width: none;
padding: 0;
background: var(--panel2);
white-space: normal;
}
.file-log-inline-detail {
padding: 12px;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.file-log-inline-detail pre {
max-height: 32vh;
margin: 8px 0 0;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.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;
@@ -8255,7 +8286,14 @@ button.config-history-day.is-selected {
gap: 16px;
}
.file-log-inventory-details > summary {
cursor: pointer;
color: var(--muted);
font-size: 0.86rem;
}
.file-log-inventory {
margin: 8px 0 0;
overflow-wrap: anywhere;
}