diff --git a/src/app/core/reports.rs b/src/app/core/reports.rs index 5610bf5..0b84233 100644 --- a/src/app/core/reports.rs +++ b/src/app/core/reports.rs @@ -62,6 +62,7 @@ pub enum Dimension { UtmCampaign, UtmContent, UtmTerm, + ScreenResolution, } #[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord)] @@ -181,6 +182,9 @@ fn filter_sql(filters: &[DimensionFilter]) -> Result<(String, ParamVec<'_>)> { Dimension::UtmCampaign => format!("utm_campaign {filter_value}"), Dimension::UtmContent => format!("utm_content {filter_value}"), Dimension::UtmTerm => format!("utm_term {filter_value}"), + Dimension::ScreenResolution => { + format!("concat_ws('x', screen_width::text, screen_height::text) {filter_value}") + } }) }) .collect::>>()?; @@ -457,6 +461,9 @@ pub fn dimension_report( Dimension::UtmCampaign => ("utm_campaign", "utm_campaign"), Dimension::UtmContent => ("utm_content", "utm_content"), Dimension::UtmTerm => ("utm_term", "utm_term"), + Dimension::ScreenResolution => { + ("nullif(concat_ws('x', screen_width::text, screen_height::text), '')", "screen_width, screen_height") + } }; params.push(range.start); diff --git a/src/app/models.rs b/src/app/models.rs index 6d376fc..0d7bcdd 100644 --- a/src/app/models.rs +++ b/src/app/models.rs @@ -23,6 +23,8 @@ pub struct Event { pub utm_campaign: Option, pub utm_content: Option, pub utm_term: Option, + pub screen_width: Option, + pub screen_height: Option, } #[derive(Debug, Clone)] @@ -100,6 +102,8 @@ macro_rules! event_params { $event.utm_term, None::, None::, + $event.screen_width, + $event.screen_height, ] }; } diff --git a/src/migrations/events/V5__screen_size.sql b/src/migrations/events/V5__screen_size.sql new file mode 100644 index 0000000..8833cc1 --- /dev/null +++ b/src/migrations/events/V5__screen_size.sql @@ -0,0 +1,2 @@ +alter table events add column screen_width integer; +alter table events add column screen_height integer; diff --git a/src/utils/seed.rs b/src/utils/seed.rs index 1a490d1..fd38b01 100644 --- a/src/utils/seed.rs +++ b/src/utils/seed.rs @@ -125,6 +125,8 @@ pub fn random_events( utm_medium: Some(random_el(UTM_MEDIUMS, 0.6).to_string()), utm_source: Some(random_el(UTM_SOURCES, 0.6).to_string()), utm_term: Some(random_el(UTM_TERMS, 0.6).to_string()), + screen_width: None, + screen_height: None, }) }) } diff --git a/src/web/routes/dashboard.rs b/src/web/routes/dashboard.rs index dfc75f1..e1f234b 100644 --- a/src/web/routes/dashboard.rs +++ b/src/web/routes/dashboard.rs @@ -227,6 +227,9 @@ async fn project_detailed_handler( let city = city.filter(|city| !city.is_empty()); data.push(DimensionTableRow { dimension_value: key, value, display_name: city, icon: country }); } + Dimension::ScreenResolution => { + data.push(DimensionTableRow { dimension_value: key, value, display_name: None, icon: None }); + } _ => { data.push(DimensionTableRow { dimension_value: key, value, display_name: None, icon: None }); } diff --git a/src/web/routes/event.rs b/src/web/routes/event.rs index 82d121f..0ec406f 100644 --- a/src/web/routes/event.rs +++ b/src/web/routes/event.rs @@ -30,6 +30,8 @@ struct EventRequest { url: String, referrer: Option, utm: Option, + screen_width: Option, + screen_height: Option, } #[derive(serde::Deserialize, JsonSchema)] @@ -124,6 +126,8 @@ fn process_event( utm_medium: event.utm.as_ref().and_then(|u| u.medium.clone()), utm_source: event.utm.as_ref().and_then(|u| u.source.clone()), utm_term: event.utm.as_ref().and_then(|u| u.term.clone()), + screen_width: event.screen_width, + screen_height: event.screen_height, }; events.send(event)?; diff --git a/tests/dashboard.rs b/tests/dashboard.rs index 9a02a42..df3cc89 100644 --- a/tests/dashboard.rs +++ b/tests/dashboard.rs @@ -35,6 +35,9 @@ async fn test_dashboard() -> Result<()> { json!({"dimension":"url","filters":[{"dimension":"fqdn","filterType":"equal","value":"example.org"},{"dimension":"url","filterType":"equal","value":"example.org/contact"},{"dimension":"referrer","filterType":"equal","value":"liwan.dev"},{"dimension":"country","filterType":"equal","value":"AU"},{"dimension":"city","filterType":"equal","value":"Sydney"},{"dimension":"platform","filterType":"equal","value":"iOS"},{"dimension":"browser","filterType":"equal","value":"Safari"},{"dimension":"mobile","filterType":"is_true"}],"metric":"views","range":{"start": start_date ,"end": end_date}}), json!({"dimension":"city","filters":[{"dimension":"fqdn","filterType":"equal","value":"example.org"},{"dimension":"url","filterType":"equal","value":"example.org/contact"},{"dimension":"referrer","filterType":"equal","value":"liwan.dev"},{"dimension":"country","filterType":"equal","value":"AU"},{"dimension":"city","filterType":"equal","value":"Sydney"},{"dimension":"platform","filterType":"equal","value":"iOS"},{"dimension":"browser","filterType":"equal","value":"Safari"},{"dimension":"mobile","filterType":"is_true"}],"metric":"views","range":{"start": start_date ,"end": end_date}}), json!({"dimension":"browser","filters":[{"dimension":"fqdn","filterType":"equal","value":"example.org"},{"dimension":"url","filterType":"equal","value":"example.org/contact"},{"dimension":"referrer","filterType":"equal","value":"liwan.dev"},{"dimension":"country","filterType":"equal","value":"AU"},{"dimension":"city","filterType":"equal","value":"Sydney"},{"dimension":"platform","filterType":"equal","value":"iOS"},{"dimension":"browser","filterType":"equal","value":"Safari"},{"dimension":"mobile","filterType":"is_true"}],"metric":"views","range":{"start": start_date ,"end": end_date}}), + json!({"dimension":"screen_resolution","filters":[],"metric":"views","range":{"start": start_date ,"end": end_date}}), + json!({"dimension":"screen_resolution","filters":[],"metric":"unique_visitors","range":{"start": start_date ,"end": end_date}}), + json!({"dimension":"url","filters":[{"dimension":"screen_resolution","filterType":"equal","value":"1920x1080"}],"metric":"views","range":{"start": start_date ,"end": end_date}}), ]; for request in stats_requests.iter() { diff --git a/tests/event.rs b/tests/event.rs index 950d18c..5d46b9a 100644 --- a/tests/event.rs +++ b/tests/event.rs @@ -26,3 +26,138 @@ async fn test_event() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn test_event_screen_size_accepted() -> Result<()> { + let app = common::app(); + let (tx, _rx) = common::events(); + let client = common::TestClient::new(app.clone(), tx); + app.entities.create(&Entity { display_name: "Entity 1".to_string(), id: "entity-1".to_string() }, &[])?; + + let user_agent = vec![("user-agent".to_string(), "Mozilla/5.0 (Linux x86_64)".to_string())]; + let event_desktop_screen_size = json!({ + "entity_id": "entity-1", + "name": "pageview", + "url": "https://example.com/", + "screen_width": 1920, + "screen_height": 1080 + }); + + let res = client.post_with_headers("/api/event", event_desktop_screen_size.clone(), user_agent.clone()).await; + res.assert_status_success(); + + let event_mobile_screen_size = json!({ + "entity_id": "entity-1", + "name": "pageview", + "url": "https://example.com/", + "screen_width": 390, + "screen_height": 844 + }); + + let res = client.post_with_headers("/api/event", event_mobile_screen_size.clone(), user_agent.clone()).await; + res.assert_status_success(); + + Ok(()) +} + +#[tokio::test] +async fn test_event_screen_size_read_write() -> Result<()> { + use chrono::Utc; + use liwan::app::models::Event; + + let app = common::app(); + let (tx, _rx) = common::events(); + let client = common::TestClient::new(app.clone(), tx); + + app.seed_database(0)?; + + let events_to_insert = vec![ + Event { + entity_id: "entity-1".to_string(), + visitor_id: "visitor-desktop".to_string(), + event: "pageview".to_string(), + created_at: Utc::now(), + fqdn: Some("example.com".to_string()), + path: Some("/".to_string()), + referrer: None, + platform: None, + browser: None, + mobile: Some(false), + country: None, + city: None, + utm_source: None, + utm_medium: None, + utm_campaign: None, + utm_content: None, + utm_term: None, + screen_width: Some(1920), + screen_height: Some(1080), + }, + Event { + entity_id: "entity-1".to_string(), + visitor_id: "visitor-mobile".to_string(), + event: "pageview".to_string(), + created_at: Utc::now(), + fqdn: Some("example.com".to_string()), + path: Some("/".to_string()), + referrer: None, + platform: None, + browser: None, + mobile: Some(true), + country: None, + city: None, + utm_source: None, + utm_medium: None, + utm_campaign: None, + utm_content: None, + utm_term: None, + screen_width: Some(390), + screen_height: Some(844), + }, + Event { + entity_id: "entity-1".to_string(), + visitor_id: "visitor-old".to_string(), + event: "pageview".to_string(), + created_at: Utc::now(), + fqdn: Some("example.com".to_string()), + path: Some("/".to_string()), + referrer: None, + platform: None, + browser: None, + mobile: None, + country: None, + city: None, + utm_source: None, + utm_medium: None, + utm_campaign: None, + utm_content: None, + utm_term: None, + screen_width: None, + screen_height: None, + }, + ]; + app.events.append(events_to_insert.into_iter())?; + + let start = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339(); + let end = Utc::now().to_rfc3339(); + + let query = json!({ + "dimension": "screen_resolution", + "filters": [], + "metric": "views", + "range": { "start": start, "end": end } + }); + + let res = client.post("/api/dashboard/project/public-project/dimension", query.clone()).await; + res.assert_status_success(); + + let body: serde_json::Value = res.json(); + let rows = body["data"].as_array().expect("expected data array"); + let values: Vec<&str> = rows.iter().filter_map(|r| r["dimensionValue"].as_str()).collect(); + + assert!(values.contains(&"1920x1080"), "expected 1920x1080 in results, got: {values:?}"); + assert!(values.contains(&"390x844"), "expected 390x844 in results, got: {values:?}"); + assert!(values.contains(&"Unknown"), "expected Unknown for NULL screen data, got: {values:?}"); + + Ok(()) +} diff --git a/tracker/script.min.js b/tracker/script.min.js index f577d3a..a8180fe 100644 --- a/tracker/script.min.js +++ b/tracker/script.min.js @@ -1 +1 @@ -let i=null,a=null,l=null,s=null;const u=typeof window>"u";typeof document<"u"&&(i=document.querySelector(`script[src^="${import.meta.url}"]`),a=i?.getAttribute("data-api")||i&&`${new URL(i.src).origin}/api/event`,l=i?.getAttribute("data-entity")||null,s=document.referrer);const d=t=>console.info(`[liwan]: ${t}`),c=t=>d(`Ignoring event: ${t}`),g=t=>{throw new Error(`Failed to send event: ${t}`)};async function p(t="pageview",e){const o=e?.endpoint||a;if(!o)return g("endpoint is required");if(localStorage?.getItem("disable-liwan"))return c("localStorage flag");if(/^localhost$|^127(?:\.\d+){0,2}\.\d+$|^\[::1?\]$/.test(location.hostname)||location.protocol==="file:")return c("localhost");const n=new URLSearchParams(location.search),r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:t,entity_id:e?.entity||l,referrer:e?.referrer||s,url:e?.url||location.origin+location.pathname,utm:{campaign:n.get("utm_campaign"),content:n.get("utm_content"),medium:n.get("utm_medium"),source:n.get("utm_source"),term:n.get("utm_term")}})});r.ok||(d(`Failed to send event: ${r.statusText}`),g(r.statusText))}const m=()=>{window.__liwan_loaded=!0;let t;const e=()=>{t!==location.pathname&&(t=location.pathname,p("pageview"))};window.navigation?window.navigation.addEventListener("navigate",()=>e()):(window.history.pushState=new Proxy(window.history.pushState,{apply:(o,n,r)=>{Reflect.apply(o,n,r),e()}}),window.addEventListener("popstate",e),document.addEventListener("astro:page-load",()=>e())),e()};!u&&!window.__liwan_loaded&&i&&m();export{p as event}; +let i=null,l=null,s=null,d=null;const a=typeof window>"u";typeof document<"u"&&(i=document.querySelector(`script[src^="${import.meta.url}"]`),l=i?.getAttribute("data-api")||i&&`${new URL(i.src).origin}/api/event`,s=i?.getAttribute("data-entity")||null,d=document.referrer);const c=e=>console.info(`[liwan]: ${e}`),g=e=>c(`Ignoring event: ${e}`),u=e=>{throw new Error(`Failed to send event: ${e}`)};async function p(e="pageview",t){const o=t?.endpoint||l;if(!o)return u("endpoint is required");if(localStorage?.getItem("disable-liwan"))return g("localStorage flag");if(/^localhost$|^127(?:\.\d+){0,2}\.\d+$|^\[::1?\]$/.test(location.hostname)||location.protocol==="file:")return g("localhost");const n=new URLSearchParams(location.search),r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:e,entity_id:t?.entity||s,referrer:t?.referrer||d,url:t?.url||location.origin+location.pathname,screen_width:a?void 0:window.screen?.width,screen_height:a?void 0:window.screen?.height,utm:{campaign:n.get("utm_campaign"),content:n.get("utm_content"),medium:n.get("utm_medium"),source:n.get("utm_source"),term:n.get("utm_term")}})});r.ok||(c(`Failed to send event: ${r.statusText}`),u(r.statusText))}const w=()=>{window.__liwan_loaded=!0;let e;const t=()=>{e!==location.pathname&&(e=location.pathname,p("pageview"))};window.navigation?window.navigation.addEventListener("navigate",()=>t()):(window.history.pushState=new Proxy(window.history.pushState,{apply:(o,n,r)=>{Reflect.apply(o,n,r),t()}}),window.addEventListener("popstate",t),document.addEventListener("astro:page-load",()=>t())),t()};!a&&!window.__liwan_loaded&&i&&w();export{p as event}; diff --git a/tracker/script.ts b/tracker/script.ts index 175080e..33ed692 100644 --- a/tracker/script.ts +++ b/tracker/script.ts @@ -10,6 +10,8 @@ type Payload = { name: string; url: string; referrer?: string; + screen_width?: number; + screen_height?: number; utm?: { campaign?: string; content?: string; medium?: string; source?: string; term?: string }; }; @@ -100,6 +102,8 @@ export async function event(name: string = "pageview", options?: EventOptions): entity_id: options?.entity || entity, referrer: options?.referrer || referrer, url: options?.url || location.origin + location.pathname, + screen_width: !noWindow ? window.screen?.width : undefined, + screen_height: !noWindow ? window.screen?.height : undefined, utm: { campaign: utm.get("utm_campaign"), content: utm.get("utm_content"), diff --git a/web/src/api/constants.ts b/web/src/api/constants.ts index 2572747..3d29814 100644 --- a/web/src/api/constants.ts +++ b/web/src/api/constants.ts @@ -22,6 +22,7 @@ export const dimensionNames: Record = { utm_medium: "UTM Medium", utm_source: "UTM Source", utm_term: "UTM Term", + screen_resolution: "Screen Resolution", }; export const filterNames: Record = { diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts index 71babc9..3cf6e16 100644 --- a/web/src/api/dashboard.ts +++ b/web/src/api/dashboard.ts @@ -1 +1 @@ -export default {"openapi":"3.1.0","info":{"title":"Liwan API","version":""},"paths":{"/api/dashboard/users":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsersResponse"}}}}}}},"/api/dashboard/user/{username}":{"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true}},"delete":{}},"/api/dashboard/user/{username}/password":{"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePasswordRequest"}}},"required":true}}},"/api/dashboard/user":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true}}},"/api/dashboard/project/{project_id}":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}}}},"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectRequest"}}},"required":true}},"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectRequest"}}},"required":true}},"delete":{}},"/api/dashboard/projects":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectsResponse"}}}}}}},"/api/dashboard/entities":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntitiesResponse"}}}}}}},"/api/dashboard/entity":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEntityRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityResponse"}}}}}}},"/api/dashboard/entity/{entity_id}":{"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEntityRequest"}}},"required":true}},"delete":{}},"/api/dashboard/auth/me":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeResponse"}}}}}}},"/api/dashboard/auth/setup":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupRequest"}}},"required":true}}},"/api/dashboard/auth/login":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true}}},"/api/dashboard/auth/logout":{"post":{}},"/api/dashboard/config":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigResponse"}}}}}}},"/api/dashboard/project/{project_id}/earliest":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EarliestResponse"}}}}}}},"/api/dashboard/project/{project_id}/graph":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphResponse"}}}}}}},"/api/dashboard/project/{project_id}/stats":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsResponse"}}}}}}},"/api/dashboard/project/{project_id}/dimension":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionResponse"}}}}}}}},"components":{"schemas":{"ConfigResponse":{"type":"object","properties":{"disableFavicons":{"type":"boolean"}},"required":["disableFavicons"]},"CreateEntityRequest":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"},"projects":{"type":"array","items":{"type":"string"}}},"required":["id","displayName","projects"]},"CreateProjectRequest":{"type":"object","properties":{"displayName":{"type":"string"},"entities":{"type":"array","items":{"type":"string"}},"public":{"type":"boolean"},"secret":{"type":["string","null"]}},"required":["displayName","public","entities"]},"CreateUserRequest":{"type":"object","properties":{"password":{"type":"string"},"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","password","role"]},"DateRange":{"type":"object","properties":{"end":{"type":"string","format":"date-time"},"start":{"type":"string","format":"date-time"}},"required":["start","end"]},"Dimension":{"type":"string","enum":["url","fqdn","path","referrer","platform","browser","mobile","country","city","utm_source","utm_medium","utm_campaign","utm_content","utm_term"]},"DimensionFilter":{"type":"object","properties":{"dimension":{"description":"The dimension to filter by","allOf":[{"$ref":"#/components/schemas/Dimension"}]},"filterType":{"description":"The type of filter to apply\nNote that some filters may not be applicable to all dimensions","allOf":[{"$ref":"#/components/schemas/FilterType"}]},"inversed":{"description":"Whether to invert the filter (e.g. not equal, not contains)\nDefaults to false","type":["boolean","null"]},"strict":{"description":"Whether to filter by the strict value (case-sensitive, exact match)","type":["boolean","null"]},"value":{"description":"The value to filter by\nFor `FilterType::IsNull` this should be `None`","type":["string","null"]}},"required":["dimension","filterType"]},"DimensionRequest":{"type":"object","properties":{"dimension":{"$ref":"#/components/schemas/Dimension"},"filters":{"type":"array","items":{"$ref":"#/components/schemas/DimensionFilter"}},"metric":{"$ref":"#/components/schemas/Metric"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters","metric","dimension"]},"DimensionResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DimensionTableRow"}}},"required":["data"]},"DimensionTableRow":{"type":"object","properties":{"dimensionValue":{"type":"string"},"displayName":{"type":["string","null"]},"icon":{"type":["string","null"]},"value":{"type":"number","format":"double"}},"required":["dimensionValue","value"]},"EarliestResponse":{"type":"object","properties":{"earliest":{"type":["string","null"],"format":"date-time"}}},"EntitiesResponse":{"type":"object","properties":{"entities":{"type":"array","items":{"$ref":"#/components/schemas/EntityResponse"}}},"required":["entities"]},"EntityProject":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"},"public":{"type":"boolean"}},"required":["id","displayName","public"]},"EntityResponse":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"},"projects":{"type":"array","items":{"$ref":"#/components/schemas/EntityProject"}}},"required":["id","displayName","projects"]},"EventRequest":{"type":"object","properties":{"entity_id":{"type":"string"},"name":{"type":"string"},"referrer":{"type":["string","null"]},"url":{"type":"string"},"utm":{"anyOf":[{"$ref":"#/components/schemas/Utm"},{"type":"null"}]}},"required":["entity_id","name","url"]},"FilterType":{"type":"string","enum":["is_null","equal","contains","starts_with","ends_with","is_true","is_false"]},"GraphRequest":{"type":"object","properties":{"dataPoints":{"type":"integer","format":"uint32","minimum":0},"filters":{"type":"array","items":{"$ref":"#/components/schemas/DimensionFilter"}},"metric":{"$ref":"#/components/schemas/Metric"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters","dataPoints","metric"]},"GraphResponse":{"type":"object","properties":{"data":{"type":"array","items":{"type":"number","format":"double"}}},"required":["data"]},"LoginRequest":{"type":"object","properties":{"password":{"type":"string"},"username":{"type":"string"}},"required":["username","password"]},"MeResponse":{"type":"object","properties":{"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","role"]},"Metric":{"type":"string","enum":["views","unique_visitors","bounce_rate","avg_time_on_site"]},"ProjectEntity":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"}},"required":["id","displayName"]},"ProjectResponse":{"type":"object","properties":{"displayName":{"type":"string"},"entities":{"type":"array","items":{"$ref":"#/components/schemas/ProjectEntity"}},"id":{"type":"string"},"public":{"type":"boolean"}},"required":["id","displayName","entities","public"]},"ProjectsResponse":{"type":"object","properties":{"projects":{"type":"array","items":{"$ref":"#/components/schemas/ProjectResponse"}}},"required":["projects"]},"ReportStats":{"type":"object","properties":{"avgTimeOnSite":{"type":"number","format":"double"},"bounceRate":{"type":"number","format":"double"},"totalViews":{"type":"integer","format":"uint64","minimum":0},"uniqueVisitors":{"type":"integer","format":"uint64","minimum":0}},"required":["totalViews","uniqueVisitors","bounceRate","avgTimeOnSite"]},"SetupRequest":{"type":"object","properties":{"password":{"type":"string"},"token":{"type":"string"},"username":{"type":"string"}},"required":["token","username","password"]},"StatsRequest":{"type":"object","properties":{"filters":{"type":"array","items":{"$ref":"#/components/schemas/DimensionFilter"}},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters"]},"StatsResponse":{"type":"object","properties":{"currentVisitors":{"type":"integer","format":"uint64","minimum":0},"stats":{"$ref":"#/components/schemas/ReportStats"},"statsPrev":{"$ref":"#/components/schemas/ReportStats"}},"required":["currentVisitors","stats","statsPrev"]},"UpdateEntityRequest":{"type":"object","properties":{"displayName":{"type":["string","null"]},"projects":{"type":["array","null"],"items":{"type":"string"}}}},"UpdatePasswordRequest":{"type":"object","properties":{"password":{"type":"string"}},"required":["password"]},"UpdateProjectInfo":{"type":"object","properties":{"displayName":{"type":"string"},"public":{"type":"boolean"},"secret":{"type":["string","null"]}},"required":["displayName","public"]},"UpdateProjectRequest":{"type":"object","properties":{"entities":{"type":["array","null"],"items":{"type":"string"}},"project":{"anyOf":[{"$ref":"#/components/schemas/UpdateProjectInfo"},{"type":"null"}]}}},"UpdateUserRequest":{"type":"object","properties":{"projects":{"type":"array","items":{"type":"string"}},"role":{"$ref":"#/components/schemas/UserRole"}},"required":["role","projects"]},"UserResponse":{"type":"object","properties":{"projects":{"type":"array","items":{"type":"string"}},"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","role","projects"]},"UserRole":{"type":"string","enum":["admin","user"]},"UsersResponse":{"type":"object","properties":{"users":{"type":"array","items":{"$ref":"#/components/schemas/UserResponse"}}},"required":["users"]},"Utm":{"type":"object","properties":{"campaign":{"type":["string","null"]},"content":{"type":["string","null"]},"medium":{"type":["string","null"]},"source":{"type":["string","null"]},"term":{"type":["string","null"]}}}}}} as const; +export default {"openapi":"3.1.0","info":{"title":"Liwan API","version":""},"paths":{"/api/dashboard/users":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsersResponse"}}}}}}},"/api/dashboard/user/{username}":{"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true}},"delete":{}},"/api/dashboard/user/{username}/password":{"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePasswordRequest"}}},"required":true}}},"/api/dashboard/user":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true}}},"/api/dashboard/project/{project_id}":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}}}},"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectRequest"}}},"required":true}},"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectRequest"}}},"required":true}},"delete":{}},"/api/dashboard/projects":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectsResponse"}}}}}}},"/api/dashboard/entities":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntitiesResponse"}}}}}}},"/api/dashboard/entity":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEntityRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityResponse"}}}}}}},"/api/dashboard/entity/{entity_id}":{"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEntityRequest"}}},"required":true}},"delete":{}},"/api/dashboard/auth/me":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeResponse"}}}}}}},"/api/dashboard/auth/setup":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupRequest"}}},"required":true}}},"/api/dashboard/auth/login":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true}}},"/api/dashboard/auth/logout":{"post":{}},"/api/dashboard/config":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigResponse"}}}}}}},"/api/dashboard/project/{project_id}/earliest":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EarliestResponse"}}}}}}},"/api/dashboard/project/{project_id}/graph":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphResponse"}}}}}}},"/api/dashboard/project/{project_id}/stats":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsResponse"}}}}}}},"/api/dashboard/project/{project_id}/dimension":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionResponse"}}}}}}}},"components":{"schemas":{"ConfigResponse":{"type":"object","properties":{"disableFavicons":{"type":"boolean"}},"required":["disableFavicons"]},"CreateEntityRequest":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"},"projects":{"type":"array","items":{"type":"string"}}},"required":["id","displayName","projects"]},"CreateProjectRequest":{"type":"object","properties":{"displayName":{"type":"string"},"entities":{"type":"array","items":{"type":"string"}},"public":{"type":"boolean"},"secret":{"type":["string","null"]}},"required":["displayName","public","entities"]},"CreateUserRequest":{"type":"object","properties":{"password":{"type":"string"},"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","password","role"]},"DateRange":{"type":"object","properties":{"end":{"type":"string","format":"date-time"},"start":{"type":"string","format":"date-time"}},"required":["start","end"]},"Dimension":{"type":"string","enum":["url","fqdn","path","referrer","platform","browser","mobile","country","city","utm_source","utm_medium","utm_campaign","utm_content","utm_term","screen_resolution"]},"DimensionFilter":{"type":"object","properties":{"dimension":{"description":"The dimension to filter by","allOf":[{"$ref":"#/components/schemas/Dimension"}]},"filterType":{"description":"The type of filter to apply\nNote that some filters may not be applicable to all dimensions","allOf":[{"$ref":"#/components/schemas/FilterType"}]},"inversed":{"description":"Whether to invert the filter (e.g. not equal, not contains)\nDefaults to false","type":["boolean","null"]},"strict":{"description":"Whether to filter by the strict value (case-sensitive, exact match)","type":["boolean","null"]},"value":{"description":"The value to filter by\nFor `FilterType::IsNull` this should be `None`","type":["string","null"]}},"required":["dimension","filterType"]},"DimensionRequest":{"type":"object","properties":{"dimension":{"$ref":"#/components/schemas/Dimension"},"filters":{"type":"array","items":{"$ref":"#/components/schemas/DimensionFilter"}},"metric":{"$ref":"#/components/schemas/Metric"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters","metric","dimension"]},"DimensionResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DimensionTableRow"}}},"required":["data"]},"DimensionTableRow":{"type":"object","properties":{"dimensionValue":{"type":"string"},"displayName":{"type":["string","null"]},"icon":{"type":["string","null"]},"value":{"type":"number","format":"double"}},"required":["dimensionValue","value"]},"EarliestResponse":{"type":"object","properties":{"earliest":{"type":["string","null"],"format":"date-time"}}},"EntitiesResponse":{"type":"object","properties":{"entities":{"type":"array","items":{"$ref":"#/components/schemas/EntityResponse"}}},"required":["entities"]},"EntityProject":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"},"public":{"type":"boolean"}},"required":["id","displayName","public"]},"EntityResponse":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"},"projects":{"type":"array","items":{"$ref":"#/components/schemas/EntityProject"}}},"required":["id","displayName","projects"]},"EventRequest":{"type":"object","properties":{"entity_id":{"type":"string"},"name":{"type":"string"},"referrer":{"type":["string","null"]},"screen_height":{"type":["integer","null"],"format":"int32"},"screen_width":{"type":["integer","null"],"format":"int32"},"url":{"type":"string"},"utm":{"anyOf":[{"$ref":"#/components/schemas/Utm"},{"type":"null"}]}},"required":["entity_id","name","url"]},"FilterType":{"type":"string","enum":["is_null","equal","contains","starts_with","ends_with","is_true","is_false"]},"GraphRequest":{"type":"object","properties":{"dataPoints":{"type":"integer","format":"uint32","minimum":0},"filters":{"type":"array","items":{"$ref":"#/components/schemas/DimensionFilter"}},"metric":{"$ref":"#/components/schemas/Metric"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters","dataPoints","metric"]},"GraphResponse":{"type":"object","properties":{"data":{"type":"array","items":{"type":"number","format":"double"}}},"required":["data"]},"LoginRequest":{"type":"object","properties":{"password":{"type":"string"},"username":{"type":"string"}},"required":["username","password"]},"MeResponse":{"type":"object","properties":{"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","role"]},"Metric":{"type":"string","enum":["views","unique_visitors","bounce_rate","avg_time_on_site"]},"ProjectEntity":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"}},"required":["id","displayName"]},"ProjectResponse":{"type":"object","properties":{"displayName":{"type":"string"},"entities":{"type":"array","items":{"$ref":"#/components/schemas/ProjectEntity"}},"id":{"type":"string"},"public":{"type":"boolean"}},"required":["id","displayName","entities","public"]},"ProjectsResponse":{"type":"object","properties":{"projects":{"type":"array","items":{"$ref":"#/components/schemas/ProjectResponse"}}},"required":["projects"]},"ReportStats":{"type":"object","properties":{"avgTimeOnSite":{"type":"number","format":"double"},"bounceRate":{"type":"number","format":"double"},"totalViews":{"type":"integer","format":"uint64","minimum":0},"uniqueVisitors":{"type":"integer","format":"uint64","minimum":0}},"required":["totalViews","uniqueVisitors","bounceRate","avgTimeOnSite"]},"SetupRequest":{"type":"object","properties":{"password":{"type":"string"},"token":{"type":"string"},"username":{"type":"string"}},"required":["token","username","password"]},"StatsRequest":{"type":"object","properties":{"filters":{"type":"array","items":{"$ref":"#/components/schemas/DimensionFilter"}},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters"]},"StatsResponse":{"type":"object","properties":{"currentVisitors":{"type":"integer","format":"uint64","minimum":0},"stats":{"$ref":"#/components/schemas/ReportStats"},"statsPrev":{"$ref":"#/components/schemas/ReportStats"}},"required":["currentVisitors","stats","statsPrev"]},"UpdateEntityRequest":{"type":"object","properties":{"displayName":{"type":["string","null"]},"projects":{"type":["array","null"],"items":{"type":"string"}}}},"UpdatePasswordRequest":{"type":"object","properties":{"password":{"type":"string"}},"required":["password"]},"UpdateProjectInfo":{"type":"object","properties":{"displayName":{"type":"string"},"public":{"type":"boolean"},"secret":{"type":["string","null"]}},"required":["displayName","public"]},"UpdateProjectRequest":{"type":"object","properties":{"entities":{"type":["array","null"],"items":{"type":"string"}},"project":{"anyOf":[{"$ref":"#/components/schemas/UpdateProjectInfo"},{"type":"null"}]}}},"UpdateUserRequest":{"type":"object","properties":{"projects":{"type":"array","items":{"type":"string"}},"role":{"$ref":"#/components/schemas/UserRole"}},"required":["role","projects"]},"UserResponse":{"type":"object","properties":{"projects":{"type":"array","items":{"type":"string"}},"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","role","projects"]},"UserRole":{"type":"string","enum":["admin","user"]},"UsersResponse":{"type":"object","properties":{"users":{"type":"array","items":{"$ref":"#/components/schemas/UserResponse"}}},"required":["users"]},"Utm":{"type":"object","properties":{"campaign":{"type":["string","null"]},"content":{"type":["string","null"]},"medium":{"type":["string","null"]},"source":{"type":["string","null"]},"term":{"type":["string","null"]}}}}}} as const; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index fe2d66e..460f810 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -23,6 +23,7 @@ export const dimensions = [ "utm_medium", "utm_source", "utm_term", + "screen_resolution", ] as const satisfies Dimension[]; export const filterTypes = [ diff --git a/web/src/components/dimensions/index.tsx b/web/src/components/dimensions/index.tsx index 583993d..66914c4 100644 --- a/web/src/components/dimensions/index.tsx +++ b/web/src/components/dimensions/index.tsx @@ -1,5 +1,5 @@ import { Tabs } from "@base-ui/react/tabs"; -import { LinkIcon, PinIcon, SquareArrowOutUpRightIcon } from "lucide-react"; +import { LinkIcon, MonitorIcon, PinIcon, SquareArrowOutUpRightIcon } from "lucide-react"; import styles from "./dimensions.module.css"; import { type Dimension, type DimensionTableRow, dimensionNames, metricNames, useDimension } from "../../api"; @@ -290,6 +290,12 @@ const dimensionLabels: Record{value.dimensionValue} ), + screen_resolution: (value, onSelect) => ( + <> + + {value.dimensionValue || "Unknown"} + + ), }; const isValidFqdn = (fqdn: string) => { diff --git a/web/src/components/project.tsx b/web/src/components/project.tsx index 4073202..5e71dba 100644 --- a/web/src/components/project.tsx +++ b/web/src/components/project.tsx @@ -121,6 +121,7 @@ export const Project = () => { onSelectDimRow(v, "mobile")} /> + onSelectDimRow(v, "screen_resolution")} />