diff --git a/README.md b/README.md index 0ab35d1..85e0e9c 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,11 @@ Access Logs use the shared table pagination controls with 15 rows by default and 15, 25, 50 and 100. Filtering and paging run in the controller. Changing filters or page size, or choosing Refresh, starts again on the first page. +The searchable host dropdown includes configured domains and hosts found in the retained +log scan. The status dropdown lists status codes found in that scan. General search matches +host, method, or path and applies after a short typing pause; Apply filters also applies the +current host and status selection immediately. + Each result has a short-lived snapshot so new requests and log rotation cannot move rows between pages. Snapshots expire after two minutes and may be evicted sooner under load; the UI announces a reset and returns to the first page. Refresh loads the latest logs. The controller @@ -218,6 +223,12 @@ keeps at most eight snapshots within a 16 MiB cache. They are temporary, are los and are excluded from backups. The existing bounded log scan and visible truncation notice still apply; pagination does not promise access to logs beyond that retained window. +Access logs render a country flag when the stored entry supplies a country code. +The web service and UI do not perform GeoIP lookups. Unknown countries have no flag. +Country flags in access logs and language settings use +[country-flag-icons](https://github.com/catamphetamine/country-flag-icons) under the MIT license. +Language flags do not depend on a GeoIP database. + ### Certificates requested from proxy hosts Create and Edit offer a new ACME certificate using the host's domains. The host row also diff --git a/bun.lock b/bun.lock index eaab4c5..91feb69 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@tanstack/react-router": "1.170.35", "@tanstack/react-start": "1.168.52", "@tanstack/react-table": "9.2.4", + "country-flag-icons": "1.6.20", "drizzle-orm": "0.45.2", "i18next": "26.4.2", "i18next-resources-to-backend": "1.2.3", @@ -608,6 +609,8 @@ "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + "country-flag-icons": ["country-flag-icons@1.6.20", "", {}, "sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], diff --git a/controller/src/runtime/access_logs.rs b/controller/src/runtime/access_logs.rs index 9ab6b4b..c39ccd3 100644 --- a/controller/src/runtime/access_logs.rs +++ b/controller/src/runtime/access_logs.rs @@ -17,6 +17,7 @@ pub(crate) const MAX_SNAPSHOTS: usize = 8; pub(crate) const MAX_SNAPSHOT_BYTES: usize = 16 * 1024 * 1024; pub(crate) const MAX_SINGLE_SNAPSHOT_BYTES: usize = MAX_LOG_BYTES; const MAX_SNAPSHOT_ID: usize = 64; +const MAX_AVAILABLE_HOSTS: usize = MAX_RECORDS; const MAX_PATH: usize = 2048; const MAX_HOST: usize = 253; const MAX_METHOD: usize = 32; @@ -139,6 +140,10 @@ pub(crate) struct AccessLogResponse { pub limit: usize, pub offset: usize, pub total: usize, + #[serde(rename = "availableHosts")] + pub available_hosts: Vec, + #[serde(rename = "availableStatuses")] + pub available_statuses: Vec, #[serde(rename = "hasMore")] pub has_more: bool, pub truncated: bool, @@ -165,6 +170,15 @@ pub(crate) struct AccessLogEntry { pub protocol: String, } +fn bounded_available_hosts(hosts: impl IntoIterator) -> Vec { + let mut bounded = hosts + .into_iter() + .take(MAX_AVAILABLE_HOSTS) + .collect::>(); + bounded.shrink_to_fit(); + bounded +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct SnapshotQuery { host: Option, @@ -269,6 +283,11 @@ fn matches_query(entry: &AccessLogEntry, query: &ValidatedAccessLogQuery) -> boo .is_none_or(|host| entry.host.eq_ignore_ascii_case(host)) && query.status.is_none_or(|status| entry.status == status) && query.search.as_ref().is_none_or(|search| { + let search = search.trim(); + if search.is_empty() { + return true; + } + let search = search.to_ascii_lowercase(); [ entry.host.as_str(), entry.method.as_str(), @@ -278,7 +297,7 @@ fn matches_query(entry: &AccessLogEntry, query: &ValidatedAccessLogQuery) -> boo entry.upstream.as_deref().unwrap_or(""), ] .into_iter() - .any(|field| field.contains(search)) + .any(|field| field.to_ascii_lowercase().contains(&search)) }) } diff --git a/controller/src/runtime/access_logs/cache.rs b/controller/src/runtime/access_logs/cache.rs index 42cfa83..59ce7a7 100644 --- a/controller/src/runtime/access_logs/cache.rs +++ b/controller/src/runtime/access_logs/cache.rs @@ -25,6 +25,8 @@ struct CachedSnapshot { entries: Vec, total: usize, truncated: bool, + available_hosts: Vec, + available_statuses: Vec, expires_at: Instant, expires_at_utc: String, bytes: usize, @@ -88,6 +90,8 @@ impl SnapshotCache { let bytes = snapshot_memory_bytes( capture_query, &capture.entries, + &capture.available_hosts, + &capture.available_statuses, snapshot_id.capacity(), expires_at_utc.capacity(), ); @@ -111,6 +115,8 @@ impl SnapshotCache { total: capture.entries.len(), entries: capture.entries, truncated: capture.truncated, + available_hosts: capture.available_hosts, + available_statuses: capture.available_statuses, expires_at, expires_at_utc, bytes, @@ -183,6 +189,8 @@ fn page_response( limit: query.limit, offset: query.offset, total: snapshot.total, + available_hosts: snapshot.available_hosts.clone(), + available_statuses: snapshot.available_statuses.clone(), has_more, truncated: snapshot.truncated, snapshot: snapshot_id.to_owned(), @@ -194,6 +202,8 @@ fn page_response( fn snapshot_memory_bytes( query: &ValidatedAccessLogQuery, entries: &Vec, + available_hosts: &[String], + available_statuses: &[u16], snapshot_id_capacity: usize, expires_at_capacity: usize, ) -> usize { @@ -202,6 +212,10 @@ fn snapshot_memory_bytes( .saturating_add(snapshot_id_capacity) .saturating_add(expires_at_capacity) .saturating_add(query_memory_bytes(query)) + .saturating_add(available_filters_memory_bytes( + available_hosts, + available_statuses, + )) .saturating_add( entries .capacity() @@ -239,9 +253,15 @@ pub(super) fn cap_snapshot_entries( entries: Vec, query: &ValidatedAccessLogQuery, mut truncated: bool, + available_hosts: &[String], + available_statuses: &[u16], ) -> (Vec, bool) { let base = SNAPSHOT_FIXED_BYTES .saturating_add(query_memory_bytes(query)) + .saturating_add(available_filters_memory_bytes( + available_hosts, + available_statuses, + )) .saturating_add(SNAPSHOT_ID_BYTES * 2) .saturating_add(64); let mut used = base; @@ -261,7 +281,8 @@ pub(super) fn cap_snapshot_entries( let mut bounded = Vec::with_capacity(keep); bounded.extend(entries.into_iter().take(keep)); bounded.shrink_to_fit(); - while snapshot_memory_bytes(query, &bounded, 64, 64) > MAX_SINGLE_SNAPSHOT_BYTES + while snapshot_memory_bytes(query, &bounded, available_hosts, available_statuses, 64, 64) + > MAX_SINGLE_SNAPSHOT_BYTES && !bounded.is_empty() { let next_len = bounded.len().saturating_sub(1); @@ -271,6 +292,14 @@ pub(super) fn cap_snapshot_entries( (bounded, truncated) } +fn available_filters_memory_bytes(hosts: &[String], statuses: &[u16]) -> usize { + hosts + .len() + .saturating_mul(size_of::()) + .saturating_add(hosts.iter().map(String::capacity).sum::()) + .saturating_add(statuses.len().saturating_mul(size_of::())) +} + #[cfg(test)] mod tests { use super::*; @@ -303,11 +332,56 @@ mod tests { bytes: 0, protocol: "HTTP/2".into(), }); - assert!(snapshot_memory_bytes(&query, &entries, 64, 64) > MAX_SINGLE_SNAPSHOT_BYTES); - let (bounded, truncated) = cap_snapshot_entries(entries, &query, false); + assert!( + snapshot_memory_bytes(&query, &entries, &[], &[], 64, 64) > MAX_SINGLE_SNAPSHOT_BYTES + ); + let (bounded, truncated) = cap_snapshot_entries(entries, &query, false, &[], &[]); assert!(!truncated); assert_eq!(bounded.len(), 1); - assert!(snapshot_memory_bytes(&query, &bounded, 64, 64) < 4096); + assert!(snapshot_memory_bytes(&query, &bounded, &[], &[], 64, 64) < 4096); + } + + #[test] + fn metadata_is_reserved_before_truncating_snapshot_entries() { + let query = query(); + let hosts = (0..1000) + .map(|index| format!("host-{index}.{}.example", "a".repeat(200))) + .collect::>(); + let statuses = vec![200]; + let entry = AccessLogEntry { + timestamp: "2026-09-13T00:00:00Z".into(), + host: "app.example".into(), + method: "GET".into(), + path: format!("/{}", "a".repeat(2047)), + status: 200, + duration_ms: 1, + client_ip: "192.0.2.1".into(), + upstream: None, + bytes: 0, + protocol: "HTTP/2".into(), + }; + let (entries, truncated) = + cap_snapshot_entries(vec![entry; 2000], &query, false, &hosts, &statuses); + assert!(truncated); + assert!(!entries.is_empty()); + let cache = SnapshotCache::new(); + let response = cache + .insert( + &query, + &query, + CapturedLogs { + entries, + truncated, + available_hosts: hosts, + available_statuses: statuses, + }, + false, + Instant::now(), + ) + .unwrap(); + assert_eq!(response.available_hosts.len(), 1000); + assert!(response.truncated); + assert!(cache.state.lock().unwrap().bytes <= MAX_SINGLE_SNAPSHOT_BYTES); } #[test] @@ -323,6 +397,8 @@ mod tests { CapturedLogs { entries, truncated: false, + available_hosts: Vec::new(), + available_statuses: Vec::new(), }, false, Instant::now(), diff --git a/controller/src/runtime/access_logs/reader.rs b/controller/src/runtime/access_logs/reader.rs index 8969107..718a1c8 100644 --- a/controller/src/runtime/access_logs/reader.rs +++ b/controller/src/runtime/access_logs/reader.rs @@ -1,5 +1,6 @@ use std::{ cmp::{Ordering, Reverse}, + collections::BTreeSet, fs::{self, File}, io::{ErrorKind, Read, Seek, SeekFrom}, path::Path, @@ -28,6 +29,8 @@ pub(crate) enum ReadError { pub(crate) struct CapturedLogs { pub(crate) entries: Vec, pub(crate) truncated: bool, + pub(crate) available_hosts: Vec, + pub(crate) available_statuses: Vec, } struct IndexedEntry { @@ -91,6 +94,8 @@ pub(crate) fn read_blocking( limit: query.limit, offset: query.offset, total, + available_hosts: capture.available_hosts, + available_statuses: capture.available_statuses, has_more: query.offset.saturating_add(page_len) < total, truncated: capture.truncated, snapshot: String::new(), @@ -103,6 +108,8 @@ fn empty_capture() -> CapturedLogs { CapturedLogs { entries: Vec::new(), truncated: false, + available_hosts: Vec::new(), + available_statuses: Vec::new(), } } @@ -153,6 +160,8 @@ fn read_snapshot_blocking( let mut scanned = 0usize; let mut truncated = inventory_truncated || file_limit_truncated; let mut matches = Vec::new(); + let mut available_hosts = BTreeSet::new(); + let mut available_statuses = BTreeSet::new(); for (_, name) in files { if remaining == 0 || scanned >= MAX_RECORDS { truncated = true; @@ -188,6 +197,8 @@ fn read_snapshot_blocking( let Some(entry) = parse_entry(&value, cutoff) else { continue; }; + available_hosts.insert(entry.host.clone()); + available_statuses.insert(entry.status); if matches_query(&entry, query) { matches.push(IndexedEntry { timestamp_nanos: OffsetDateTime::parse(&entry.timestamp, &Rfc3339) @@ -202,8 +213,21 @@ fn read_snapshot_blocking( } matches.sort_by(compare_indexed_entries); let matches = matches.into_iter().map(|indexed| indexed.entry).collect(); - let (entries, truncated) = cap_snapshot_entries(matches, query, truncated); - Ok(CapturedLogs { entries, truncated }) + let available_hosts = super::bounded_available_hosts(available_hosts); + let available_statuses = available_statuses.into_iter().collect::>(); + let (entries, truncated) = cap_snapshot_entries( + matches, + query, + truncated, + &available_hosts, + &available_statuses, + ); + Ok(CapturedLogs { + entries, + truncated, + available_hosts, + available_statuses, + }) } fn compare_indexed_entries(left: &IndexedEntry, right: &IndexedEntry) -> Ordering { diff --git a/controller/src/runtime/access_logs/tests.rs b/controller/src/runtime/access_logs/tests.rs index 29c41aa..b61497d 100644 --- a/controller/src/runtime/access_logs/tests.rs +++ b/controller/src/runtime/access_logs/tests.rs @@ -164,6 +164,32 @@ fn filters_and_pagination_report_filtered_indexes() { assert_eq!(response.entries[0].path, "/two"); } +#[test] +fn search_matches_partial_host_without_returning_unrelated_rows() { + let state = TempState::new(true); + let now = OffsetDateTime::now_utc().unix_timestamp() as f64; + state.write( + "access.log", + &format!( + "{}\n{}\n", + line(now, "ts-laser.example", "/laser", 200, "laser"), + line(now, "other.example", "/other", 200, "other") + ), + ); + let mut filtered = query(20, 0); + filtered.search = Some("ts-laser".into()); + + let response = read_blocking(state.path(), &filtered).unwrap(); + + assert_eq!(response.total, 1); + assert_eq!(response.entries[0].host, "ts-laser.example"); + assert_eq!( + response.available_hosts, + vec!["other.example", "ts-laser.example"] + ); + assert_eq!(response.available_statuses, vec![200]); +} + #[test] fn malformed_and_partial_lines_are_ignored() { let state = TempState::new(true); @@ -336,6 +362,8 @@ fn snapshot_cache_expiry_is_fixed_and_evicts_lru_entries() { CapturedLogs { entries: Vec::new(), truncated: false, + available_hosts: Vec::new(), + available_statuses: Vec::new(), }, false, now, @@ -374,6 +402,8 @@ fn snapshot_cache_expiry_is_fixed_and_evicts_lru_entries() { CapturedLogs { entries: Vec::new(), truncated: false, + available_hosts: Vec::new(), + available_statuses: Vec::new(), }, false, now, @@ -392,6 +422,8 @@ fn snapshot_cache_expiry_is_fixed_and_evicts_lru_entries() { CapturedLogs { entries: Vec::new(), truncated: false, + available_hosts: Vec::new(), + available_statuses: Vec::new(), }, false, now, diff --git a/package.json b/package.json index 2710232..36962bd 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@tanstack/react-router": "1.170.35", "@tanstack/react-start": "1.168.52", "@tanstack/react-table": "9.2.4", + "country-flag-icons": "1.6.20", "drizzle-orm": "0.45.2", "i18next": "26.4.2", "i18next-resources-to-backend": "1.2.3", diff --git a/web/public/images/flags/de.svg b/web/public/images/flags/de.svg deleted file mode 100644 index c73090c..0000000 --- a/web/public/images/flags/de.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/web/public/images/flags/en.svg b/web/public/images/flags/en.svg deleted file mode 100644 index 33ff9ec..0000000 --- a/web/public/images/flags/en.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/web/public/images/flags/es.svg b/web/public/images/flags/es.svg deleted file mode 100644 index 5d78e58..0000000 --- a/web/public/images/flags/es.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/web/public/images/flags/fr.svg b/web/public/images/flags/fr.svg deleted file mode 100644 index 71cc823..0000000 --- a/web/public/images/flags/fr.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/web/src/config/language.config.ts b/web/src/config/language.config.ts index b4f7ff1..4991216 100644 --- a/web/src/config/language.config.ts +++ b/web/src/config/language.config.ts @@ -21,11 +21,11 @@ export const LANGUAGE_LOCALES: Record = { fr: 'fr-FR', } -export const FLAG_IMAGES: Record = { - en: '/images/flags/en.svg', - de: '/images/flags/de.svg', - es: '/images/flags/es.svg', - fr: '/images/flags/fr.svg', +export const LANGUAGE_COUNTRY_CODES: Record = { + en: 'GB', + de: 'DE', + es: 'ES', + fr: 'FR', } export const PUBLIC_ENGLISH: Record = { diff --git a/web/src/features/Admin/ProxyAccessLogs/Components/ClientIpCountry.tsx b/web/src/features/Admin/ProxyAccessLogs/Components/ClientIpCountry.tsx new file mode 100644 index 0000000..4614d67 --- /dev/null +++ b/web/src/features/Admin/ProxyAccessLogs/Components/ClientIpCountry.tsx @@ -0,0 +1,28 @@ +import { hasFlag } from 'country-flag-icons' +import useTranslationStore from '../../../../language/useTranslationStore' +import { Tooltip } from '../../../../shared/Tooltip' +import type { ProxyAccessLogEntry } from '../../../../shared/Types/proxy-access-logs.types' + +export default function ClientIpCountry({ entry }: { readonly entry: ProxyAccessLogEntry }) { + const { locale } = useTranslationStore() + const code = entry.countryCode + const country = + code && /^[A-Z]{2}$/u.test(code) && hasFlag(code) + ? new Intl.DisplayNames([locale], { type: 'region' }).of(code) + : undefined + + return ( + + {entry.clientIp} + {country && code ? ( + + + {country} + + + ) : null} + + ) +} diff --git a/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsHostFilter.tsx b/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsHostFilter.tsx new file mode 100644 index 0000000..69d95c9 --- /dev/null +++ b/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsHostFilter.tsx @@ -0,0 +1,152 @@ +import { ChevronDown } from 'lucide-react' + +import useProxyAccessLogsHostFilter from '../Hooks/useProxyAccessLogsHostFilter' + +interface ProxyAccessLogsHostFilterProps { + readonly allHostsLabel: string + readonly ariaDescribedBy?: string | undefined + readonly invalid: boolean + readonly label: string + readonly noResultsLabel: string + readonly onChange: (value: string) => void + readonly options: readonly string[] + readonly placeholder: string + readonly searchPlaceholder: string + readonly value: string +} + +const controlClassName = + 'box-border inline-flex h-12 w-full min-w-0 items-center justify-between gap-2 rounded-xl border border-input-border bg-surface-raised px-3 text-left text-sm text-ink outline-hidden transition-[border-color,box-shadow,background-color] hover:border-border-strong focus:border-brand-600 focus:ring-[3px] focus:ring-brand-500/20 aria-invalid:border-red-500' +const optionClassName = + 'flex min-h-10 w-full cursor-pointer items-center rounded-lg px-3 py-2 text-left text-sm font-bold text-ink-soft transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-hidden' + +export default function ProxyAccessLogsHostFilter({ + allHostsLabel, + ariaDescribedBy, + invalid, + label, + noResultsLabel, + onChange, + options, + placeholder, + searchPlaceholder, + value, +}: ProxyAccessLogsHostFilterProps) { + const { + activeIndex, + filteredOptions, + handleOptionKeyDown, + handleSearchChange, + handleSearchKeyDown, + handleTriggerKeyDown, + listboxId, + open, + search, + setOptionRef, + setRootRef, + setSearchRef, + setTriggerRef, + selectOption, + toggle, + } = useProxyAccessLogsHostFilter({ onChange, options }) + + return ( +
+ + {open ? ( +
+
+ handleSearchChange(event.target.value)} + onKeyDown={handleSearchKeyDown} + className="box-border h-10 w-full rounded-lg border border-input-border bg-surface px-3 text-sm text-ink outline-hidden placeholder:text-muted-soft focus:border-brand-600 focus:ring-[3px] focus:ring-brand-500/20" + /> +
+
+ + {filteredOptions.length > 0 ? ( + filteredOptions.map((option, optionIndex) => { + const index = optionIndex + 1 + return ( + + ) + }) + ) : ( +
+ {noResultsLabel} +
+ )} +
+
+ ) : null} +
+ ) +} diff --git a/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsPageView.tsx b/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsPageView.tsx index 82f3f66..697b6a6 100644 --- a/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsPageView.tsx +++ b/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsPageView.tsx @@ -43,6 +43,8 @@ export default function ProxyAccessLogsPageView({ ) : (
{ event.preventDefault() onApplyFilters() }} > -