Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion data/licenses-npm.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/app/core/reports/mod.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
mod dimension;
mod graph;
mod sessions;
mod shared;
mod stats;

pub use dimension::dimension_report;
pub use graph::{build_graph_buckets, overall_report};
pub use sessions::{SessionEvent, SessionRow, session_list_report, session_timeline_report};
pub use stats::{earliest_timestamp, online_users, overall_stats};

use anyhow::{Result, bail};
Expand Down
144 changes: 144 additions & 0 deletions src/app/core/reports/sessions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::app::DuckDBConn;
use crate::utils::duckdb::{ParamVec, repeat_vars};
use duckdb::params_from_iter;

use super::shared::SESSION_DURATION_SQL;
use super::DateRange;

/// One visitor group: the Umami-sessions-style row.
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SessionRow {
/// Hashed visitor group id (rotates daily server-side; shortened for display by callers)
pub visitor_group_id: String,
pub first_seen: DateTime<Utc>,
pub last_seen: DateTime<Utc>,
/// Session windows: gaps > 30 min start a new visit
pub visits: i64,
/// event = 'pageview' rows
pub views: i64,
/// non-pageview (custom) events
pub events: i64,
pub browser: Option<String>,
pub platform: Option<String>,
pub mobile: Option<bool>,
pub country: Option<String>,
pub city: Option<String>,
}

/// One event in a visitor timeline, oldest first.
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SessionEvent {
pub created_at: DateTime<Utc>,
pub event: String,
pub fqdn: Option<String>,
pub path: Option<String>,
pub referrer: Option<String>,
}

/// Most-recent visitor groups in range, newest first. Capped by `limit`.
pub fn session_list_report(
conn: &DuckDBConn,
entities: &[String],
range: &DateRange,
limit: usize,
) -> Result<Vec<SessionRow>> {
if entities.is_empty() {
return Ok(Vec::new());
}
let entity_vars = repeat_vars(entities.len());
let mut params = ParamVec::new();
params.push(range.start);
params.push(range.end);
params.extend(entities);

let mut stmt = conn.prepare_cached(&format!(
"--sql
select
visitor_group_id,
min(created_at) as first_seen,
max(created_at) as last_seen,
sum(case when time_from_last_event is null or time_from_last_event > {SESSION_DURATION_SQL} then 1 else 0 end) as visits,
sum(case when event = 'pageview' then 1 else 0 end) as views,
sum(case when event <> 'pageview' then 1 else 0 end) as events,
mode(browser) as browser,
mode(platform) as platform,
mode(mobile) as mobile,
mode(country) as country,
mode(city) as city
from events
where
created_at >= ?::timestamp and created_at < ?::timestamp and
entity_id in ({entity_vars})
group by visitor_group_id
order by last_seen desc
limit {limit};
"
))?;

let rows = stmt.query_map(params_from_iter(params), |row| {
Ok(SessionRow {
visitor_group_id: row.get(0)?,
first_seen: row.get(1)?,
last_seen: row.get(2)?,
visits: row.get(3)?,
views: row.get(4)?,
events: row.get(5)?,
browser: row.get(6)?,
platform: row.get(7)?,
mobile: row.get(8)?,
country: row.get(9)?,
city: row.get(10)?,
})
})?;
rows.collect::<Result<Vec<_>, duckdb::Error>>().map_err(anyhow::Error::from)
}

/// Timeline for one visitor group in range, oldest first. Capped by `limit`.
pub fn session_timeline_report(
conn: &DuckDBConn,
entities: &[String],
visitor_group_id: &str,
range: &DateRange,
limit: usize,
) -> Result<Vec<SessionEvent>> {
if entities.is_empty() {
return Ok(Vec::new());
}
let entity_vars = repeat_vars(entities.len());
let mut params = ParamVec::new();
params.push(visitor_group_id);
params.push(range.start);
params.push(range.end);
params.extend(entities);

let mut stmt = conn.prepare_cached(&format!(
"--sql
select created_at, event, fqdn, path, referrer
from events
where
visitor_group_id = ?::text and
created_at >= ?::timestamp and created_at < ?::timestamp and
entity_id in ({entity_vars})
order by created_at asc
limit {limit};
"
))?;

let rows = stmt.query_map(params_from_iter(params), |row| {
Ok(SessionEvent {
created_at: row.get(0)?,
event: row.get(1)?,
fqdn: row.get(2)?,
path: row.get(3)?,
referrer: row.get(4)?,
})
})?;
rows.collect::<Result<Vec<_>, duckdb::Error>>().map_err(anyhow::Error::from)
}
85 changes: 85 additions & 0 deletions src/web/routes/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ pub fn router() -> ApiRouter<RouterState> {
.api_route("/project/{project_id}/graph", post(project_graph_handler))
.api_route("/project/{project_id}/stats", post(project_stats_handler))
.api_route("/project/{project_id}/dimension", post(project_detailed_handler))
.api_route("/project/{project_id}/sessions", post(project_sessions_handler))
.api_route(
"/project/{project_id}/sessions/{visitor_group_id}/timeline",
post(project_session_timeline_handler),
)
}

#[derive(Serialize, Deserialize, JsonSchema, Clone)]
Expand Down Expand Up @@ -96,6 +101,86 @@ struct DimensionResponse {
data: Vec<DimensionTableRow>,
}

#[derive(Serialize, Deserialize, JsonSchema, Clone)]
#[serde(rename_all = "camelCase")]
struct SessionsRequest {
range: DateRange,
#[serde(default = "default_sessions_limit")]
limit: usize,
}

fn default_sessions_limit() -> usize {
50
}

#[derive(Serialize, Deserialize, JsonSchema, Clone)]
#[serde(rename_all = "camelCase")]
struct SessionsResponse {
data: Vec<reports::SessionRow>,
}

#[derive(Serialize, Deserialize, JsonSchema, Clone)]
#[serde(rename_all = "camelCase")]
struct SessionTimelineRequest {
range: DateRange,
#[serde(default = "default_timeline_limit")]
limit: usize,
}

fn default_timeline_limit() -> usize {
200
}

#[derive(Serialize, Deserialize, JsonSchema, Clone)]
#[serde(rename_all = "camelCase")]
struct SessionTimelineResponse {
data: Vec<reports::SessionEvent>,
}

async fn project_sessions_handler(
app: State<RouterState>,
MaybeAuth(user): MaybeAuth,
Path(project_id): Path<String>,
Json(req): Json<SessionsRequest>,
) -> ApiResult<Json<SessionsResponse>> {
let project = app.projects.get(&project_id).http_status(StatusCode::NOT_FOUND)?;
if !can_view_project(&project, user.as_ref()) {
http_bail!(StatusCode::NOT_FOUND, "Project not found")
}
let entities = app.projects.entity_ids(&project.id).http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
reports::validate_request(&req.range, &[], &app.config.limits).http_status(StatusCode::BAD_REQUEST)?;
let limit = req.limit.clamp(1, 200);

let data = run_report(&app, move |conn| {
reports::session_list_report(conn, &entities, &req.range, limit)
})
.await?;

Ok(Json(SessionsResponse { data }))
}

async fn project_session_timeline_handler(
app: State<RouterState>,
MaybeAuth(user): MaybeAuth,
Path((project_id, visitor_group_id)): Path<(String, String)>,
Json(req): Json<SessionTimelineRequest>,
) -> ApiResult<Json<SessionTimelineResponse>> {
let project = app.projects.get(&project_id).http_status(StatusCode::NOT_FOUND)?;
if !can_view_project(&project, user.as_ref()) {
http_bail!(StatusCode::NOT_FOUND, "Project not found")
}
let entities = app.projects.entity_ids(&project.id).http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
reports::validate_request(&req.range, &[], &app.config.limits).http_status(StatusCode::BAD_REQUEST)?;
let limit = req.limit.clamp(1, 1000);

let data = run_report(&app, move |conn| {
reports::session_timeline_report(conn, &entities, &visitor_group_id, &req.range, limit)
})
.await?;

Ok(Json(SessionTimelineResponse { data }))
}

#[derive(Serialize, Deserialize, JsonSchema, Clone)]
#[serde(rename_all = "camelCase")]
struct DimensionTableRow {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
display: flex;
flex-direction: column;
margin-bottom: 0;
height: fit-content;
}

.tabs {
Expand Down Expand Up @@ -100,11 +101,11 @@
}

.dimensionTable {
flex: 1;
display: flex;
flex-direction: column;
min-height: calc(var(--count) * (2.1rem + 0.2rem));
position: relative;
transition: min-height 0.2s ease-in-out;

.spinner {
position: absolute;
Expand All @@ -123,8 +124,8 @@
all: unset;
cursor: pointer;
color: var(--pico-contrast);
margin-top: auto;
padding-top: 0.4rem;
margin-top: 0.6rem;
padding-top: 0.2rem;
display: flex;
justify-content: center;
align-items: center;
Expand All @@ -139,8 +140,7 @@
}

.showMoreHidden {
opacity: 0;
pointer-events: none;
display: none;
}

.dimensionRow {
Expand Down Expand Up @@ -185,11 +185,10 @@
}

.dimensionEmpty {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 1rem;
padding: 0.8rem 0;
opacity: 0.6;
}

Expand Down
6 changes: 4 additions & 2 deletions web/src/components/dashboard/project/dimensions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,14 @@ export const DimensionTable = (props: DimensionProps) => {
...props.query,
});
const dataTruncated = data?.slice(0, 6);
const rowCount = dataTruncated?.length ?? 0;
const count = isLoading && rowCount === 0 ? 2 : Math.max(rowCount, 1);

return (
<>
<div
className={cls(styles.dimensionTable, isLoading && styles.loading)}
style={{ "--count": 6 } as React.CSSProperties}
style={{ "--count": count } as React.CSSProperties}
>
{isLoading && <div className={cls("loading-spinner", styles.spinner)} />}
{dataTruncated?.map((d) => {
Expand Down Expand Up @@ -196,7 +198,7 @@ const renderUrlDimensionLabel = (value: DimensionTableRow, onSelect: () => void)
<a href={getHref(url)} target="_blank" rel="noreferrer" className={styles.external}>
<SquareArrowOutUpRightIcon size={16} />
</a>
{typeof url !== "string" && <span className={styles.hostname}>{formatHost(url)}</span>}
{typeof url !== "string" && formatHost(url) ? <span className={styles.hostname}>{formatHost(url)}</span> : null}
</>
);
};
Expand Down
Loading