group by route

This commit is contained in:
Robin Appelman 2025-08-20 23:16:44 +02:00
commit e982a12df5
18 changed files with 2153 additions and 446 deletions

View file

@ -1,5 +1,6 @@
mod app;
mod remote;
mod route;
mod unique;
mod url;
mod user;
@ -8,6 +9,7 @@ mod useragent;
use crate::app::{Filter, LineSet};
use crate::grouping::app::{AppGrouping, APP_GROUPING_UI};
use crate::grouping::remote::{RemoteGrouping, REMOTE_GROUPING_UI};
use crate::grouping::route::{match_url, RouteGrouping, ROUTE_GROUPING_UI};
use crate::grouping::url::{UrlGrouping, URL_GROUPING_UI};
use crate::grouping::user::{UserGrouping, USER_GROUPING_UI};
use crate::grouping::useragent::{UserAgentGrouping, USER_AGENT_GROUPING_UI};
@ -105,7 +107,7 @@ impl<'logs> LogGrouping<'logs> {
pub fn by_identifier(&self) -> &[LineSet<'logs>] {
self.by_identifier
.get_or_init(|| group_lines_by(self.lines.iter(), LogLine::identity))
.get_or_init(|| group_lines_by(self.lines.iter(), |line| Some(line.identity())))
}
}
@ -113,17 +115,24 @@ pub fn group_lines_by<'logs, I, F, K>(indices: I, f: F) -> Vec<LineSet<'logs>>
where
I: Iterator<Item = &'logs LogLine<'logs>>,
K: Ord,
F: Fn(&'logs LogLine<'logs>) -> K,
F: Fn(&'logs LogLine<'logs>) -> Option<K>,
{
let mut map: BTreeMap<K, Vec<&'logs LogLine<'logs>>> = BTreeMap::new();
let mut unmatched: Vec<&'logs LogLine<'logs>> = Vec::new();
for line in indices {
map.entry(f(line)).or_default().push(line);
match f(line) {
Some(key) => map.entry(key).or_default().push(line),
None => unmatched.push(line),
}
}
let mut list: Vec<_> = map.into_values().map(LineSet::new).collect();
list.sort_by_key(|list| list.len());
list.reverse();
if !unmatched.is_empty() {
list.push(LineSet::new(unmatched));
}
list
}
@ -135,6 +144,7 @@ pub enum Groupings<'logs> {
User(UserGrouping<'logs>),
Remote(RemoteGrouping<'logs>),
UserAgent(UserAgentGrouping<'logs>),
Route(RouteGrouping<'logs>),
}
impl<'logs> GroupingResult<'logs> for Groupings<'logs> {
@ -146,6 +156,7 @@ impl<'logs> GroupingResult<'logs> for Groupings<'logs> {
Groupings::User(r) => r.matches(filter),
Groupings::Remote(r) => r.matches(filter),
Groupings::UserAgent(r) => r.matches(filter),
Groupings::Route(r) => r.matches(filter),
}
}
@ -159,6 +170,7 @@ impl<'logs> GroupingResult<'logs> for Groupings<'logs> {
Groupings::User(r) => Box::new(r.render()),
Groupings::Remote(r) => Box::new(r.render()),
Groupings::UserAgent(r) => Box::new(r.render()),
Groupings::Route(r) => Box::new(r.render()),
}
}
}
@ -170,6 +182,7 @@ pub enum GroupingOptions {
User,
Remote,
UserAgent,
Route,
}
impl GroupingOptions {
@ -180,11 +193,12 @@ impl GroupingOptions {
GroupingOptions::User,
GroupingOptions::Remote,
GroupingOptions::UserAgent,
GroupingOptions::Route,
]
.into_iter()
}
pub fn group_key(&self, line: &LogLine) -> u64 {
pub fn group_key(&self, line: &LogLine) -> Option<u64> {
let mut hasher = AHasher::default();
match self {
GroupingOptions::Url => line.url.hash(&mut hasher),
@ -192,27 +206,31 @@ impl GroupingOptions {
GroupingOptions::User => line.user.hash(&mut hasher),
GroupingOptions::Remote => line.remote.hash(&mut hasher),
GroupingOptions::UserAgent => line.user_agent.hash(&mut hasher),
GroupingOptions::Route => match_url(&line.method, &line.url)?.hash(&mut hasher),
}
hasher.finish()
Some(hasher.finish())
}
pub fn group<'logs>(&self, line: &'logs LogLine<'logs>) -> Groupings<'logs> {
pub fn group<'logs>(&self, line: &'logs LogLine<'logs>) -> Option<Groupings<'logs>> {
match self {
GroupingOptions::Url => Groupings::Url(UrlGrouping {
GroupingOptions::Url => Some(Groupings::Url(UrlGrouping {
url: line.url.as_ref(),
}),
GroupingOptions::App => Groupings::App(AppGrouping {
})),
GroupingOptions::App => Some(Groupings::App(AppGrouping {
app: line.app.as_ref(),
}),
GroupingOptions::User => Groupings::User(UserGrouping {
})),
GroupingOptions::User => Some(Groupings::User(UserGrouping {
user: line.user.as_str(),
}),
GroupingOptions::Remote => Groupings::Remote(RemoteGrouping {
})),
GroupingOptions::Remote => Some(Groupings::Remote(RemoteGrouping {
remote: line.remote.as_str(),
}),
GroupingOptions::UserAgent => Groupings::UserAgent(UserAgentGrouping {
})),
GroupingOptions::UserAgent => Some(Groupings::UserAgent(UserAgentGrouping {
user_agent: line.user_agent.as_ref(),
}),
})),
GroupingOptions::Route => Some(Groupings::Route(RouteGrouping {
route: match_url(&line.method, &line.url)?,
})),
}
}
@ -223,6 +241,7 @@ impl GroupingOptions {
GroupingOptions::User => "User",
GroupingOptions::Remote => "Remote",
GroupingOptions::UserAgent => "User Agent",
GroupingOptions::Route => "Route",
}
}
@ -233,6 +252,7 @@ impl GroupingOptions {
GroupingOptions::User => USER_GROUPING_UI,
GroupingOptions::Remote => REMOTE_GROUPING_UI,
GroupingOptions::UserAgent => USER_AGENT_GROUPING_UI,
GroupingOptions::Route => ROUTE_GROUPING_UI,
}
}
@ -241,9 +261,9 @@ impl GroupingOptions {
results.extend(
group_lines_by(lines.into_iter(), |line| self.group_key(line))
.into_iter()
.map(|lines| {
let group = self.group(lines.lines[0]);
LogGrouping::new(group, lines)
.map(|lines| match self.group(lines.lines[0]) {
Some(group) => LogGrouping::new(group, lines),
None => LogGrouping::named("Unmatched", lines.lines),
}),
);
results

146
src/grouping/route.rs Normal file
View file

@ -0,0 +1,146 @@
use crate::app::Filter;
use crate::grouping::{GroupingResult, GroupingUi};
use ahash::HashMap;
use logsmash_data::all_routes;
use ratatui::layout::{Alignment, Constraint};
use regex::Regex;
use std::borrow::Cow;
use std::sync::OnceLock;
type Router = matchit::Router<(&'static str, &'static str)>;
static ROUTER: OnceLock<HashMap<&'static str, Router>> = OnceLock::new();
static OCS_ROUTER: OnceLock<HashMap<&'static str, Router>> = OnceLock::new();
fn get_router(verb: &str) -> Option<&'static Router> {
ROUTER
.get_or_init(|| {
let mut routers: HashMap<&'static str, Router> = HashMap::default();
for route in all_routes().filter(|route| !route.ocs) {
let router = routers.entry(route.verb).or_insert_with(|| Router::new());
let _ = router.insert(route.url, (route.id, route.url));
}
routers
})
.get(verb)
}
fn get_ocs_router(verb: &str) -> Option<&'static Router> {
OCS_ROUTER
.get_or_init(|| {
let mut routers: HashMap<&'static str, Router> = HashMap::default();
for route in all_routes().filter(|route| route.ocs) {
let router = routers.entry(route.verb).or_insert_with(|| Router::new());
let _ = router.insert(route.url, (route.id, route.url));
}
routers
})
.get(verb)
}
pub fn match_url<'a>(verb: &str, url: &'a str) -> Option<RouteMatch<'a>> {
if url == "--" {
return Some(RouteMatch::None);
}
if url.starts_with("/remote.php/") {
let mut parts = url.split('/').skip(2);
let first = parts.next().unwrap_or_default();
if first == "webdav" {
return Some(RouteMatch::Remote(first, ""));
}
return Some(RouteMatch::Remote(first, parts.next().unwrap_or_default()));
}
if url.starts_with("/public.php/") {
let mut parts = url.split('/').skip(2);
let first = parts.next().unwrap_or_default();
if first == "webdav" {
return Some(RouteMatch::Public(first, ""));
}
return Some(RouteMatch::Public(first, parts.next().unwrap_or_default()));
}
let url = url.split_once('?').map(|(url, _)| url).unwrap_or(url);
let (router, url) = if let Some(ocs_url) = url.strip_prefix("/ocs/v2.php") {
(get_ocs_router(verb), ocs_url)
} else if let Some(ocs_url) = url.strip_prefix("/ocs/v1.php") {
(get_ocs_router(verb), ocs_url)
} else {
(
get_router(verb),
url.strip_prefix("/index.php").unwrap_or(url),
)
};
let Some(router) = router else {
return None;
};
router
.at(url)
.map(|m| RouteMatch::Route(m.value.0, m.value.1))
.ok()
}
#[derive(Hash, PartialEq, Clone)]
pub enum RouteMatch<'a> {
Unmatched,
Route(&'static str, &'static str),
Remote(&'a str, &'a str),
Public(&'a str, &'a str),
None,
}
impl<'a> RouteMatch<'a> {
pub fn matches(&self, filter_part: &Regex) -> bool {
let s: Cow<'a, str> = self.into();
filter_part.is_match(s.as_ref())
}
pub fn url(&self) -> Cow<'a, str> {
match self {
RouteMatch::Route(_, url) => (*url).into(),
RouteMatch::Remote(a, "") => format!("/remote.php/{a}").into(),
RouteMatch::Public(a, "") => format!("/public.php/{a}").into(),
RouteMatch::Remote(a, b) => format!("/remote.php/{a}/{b}/{{path}}").into(),
RouteMatch::Public(a, b) => format!("/public.php/{a}/{b}/{{path}}").into(),
_ => "".into(),
}
}
}
impl<'a> From<&RouteMatch<'a>> for Cow<'a, str> {
fn from(value: &RouteMatch<'a>) -> Self {
match value {
RouteMatch::Unmatched => "Umatched".into(),
RouteMatch::Route(route, _) => (*route).into(),
RouteMatch::Remote(a, "") => format!("remote.{a}").into(),
RouteMatch::Public(a, "") => format!("public.{a}").into(),
RouteMatch::Remote(a, b) => format!("remote.{a}.{b}").into(),
RouteMatch::Public(a, b) => format!("public.{a}.{b}").into(),
RouteMatch::None => "--".into(),
}
}
}
#[derive(PartialEq, Clone)]
pub struct RouteGrouping<'logs> {
pub route: RouteMatch<'logs>,
}
pub const ROUTE_GROUPING_UI: GroupingUi = GroupingUi {
header: &[("Route", Alignment::Left), ("Url", Alignment::Left)],
widths: &[Constraint::Percentage(50), Constraint::Percentage(50)],
};
impl<'a> GroupingResult<'a> for RouteGrouping<'a> {
fn matches(&self, filter: &Filter) -> bool {
if filter.is_empty() {
return true;
}
filter
.parts()
.all(|filter_part| self.route.matches(filter_part))
}
fn render(&self) -> impl Iterator<Item = Cow<'a, str>> {
[(&self.route).into(), self.route.url()].into_iter()
}
}