Initial analytics #36
@ -82,6 +82,7 @@ features = [
|
|||||||
"LucideList",
|
"LucideList",
|
||||||
"LucideMenu",
|
"LucideMenu",
|
||||||
"LucidePencil",
|
"LucidePencil",
|
||||||
|
"LucideRefreshCw",
|
||||||
"LucideRss",
|
"LucideRss",
|
||||||
"LucideX"
|
"LucideX"
|
||||||
]
|
]
|
||||||
@ -93,6 +94,7 @@ features = [
|
|||||||
"Document",
|
"Document",
|
||||||
"DomRect",
|
"DomRect",
|
||||||
"Element",
|
"Element",
|
||||||
|
"HtmlSelectElement",
|
||||||
"IntersectionObserver",
|
"IntersectionObserver",
|
||||||
"IntersectionObserverEntry",
|
"IntersectionObserverEntry",
|
||||||
"Navigator",
|
"Navigator",
|
||||||
|
26
analytics/lambda/local.sh
Executable file
26
analytics/lambda/local.sh
Executable file
@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
DB_CONTAINER_NAME="blakerain-analytics-db"
|
||||||
|
DB_CONTAINER_PORT=5101
|
||||||
|
|
||||||
|
# Stop the database docker container (if it is already running).
|
||||||
|
docker stop "$DB_CONTAINER_NAME" || true
|
||||||
|
|
||||||
|
# Start the local database, passing in defaults that correspond to those in 'local.toml'
|
||||||
|
# configuration file.
|
||||||
|
docker run --rm --name "$DB_CONTAINER_NAME" -d \
|
||||||
|
-e POSTGRES_USER=analytics_local \
|
||||||
|
-e POSTGRES_PASSWORD=analytics_local \
|
||||||
|
-e POSTGRES_DB=analytics_local \
|
||||||
|
-p $DB_CONTAINER_PORT:5432 \
|
||||||
|
postgres:alpine \
|
||||||
|
-c log_statement=all
|
||||||
|
|
||||||
|
# Make sure that 'cargo watch' is installed
|
||||||
|
cargo install cargo-watch
|
||||||
|
|
||||||
|
# Runt he language function, reloading any changes.
|
||||||
|
cargo watch -B 1 -L debug -- cargo run --features local --bin analytics
|
||||||
|
|
9
analytics/lambda/local.toml
Normal file
9
analytics/lambda/local.toml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[db]
|
||||||
|
endpoint = "localhost"
|
||||||
|
port = 5101
|
||||||
|
username = "analytics_local"
|
||||||
|
password = "analytics_local"
|
||||||
|
dbname = "analytics_local"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
token_key = "q7AGUNUgiPsj776lnwYCLcfOLx8Fswu07UZDqttEJPs="
|
@ -1,14 +1,16 @@
|
|||||||
use analytics_lambda::{
|
use analytics_lambda::{
|
||||||
config::{load_from_env, load_from_file},
|
config::{load_from_env, load_from_file},
|
||||||
|
endpoints::auth::AuthContext,
|
||||||
env::Env,
|
env::Env,
|
||||||
handlers::{
|
handlers::{
|
||||||
auth::{new_password, signin},
|
auth::{new_password, signin, validate_token},
|
||||||
page_view::{append_page_view, record_page_view},
|
page_view::{append_page_view, record_page_view},
|
||||||
|
query::query_month_view,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use analytics_model::MIGRATOR;
|
use analytics_model::MIGRATOR;
|
||||||
use lambda_runtime::Error;
|
use lambda_runtime::Error;
|
||||||
use poem::{middleware, post, Endpoint, EndpointExt, Route};
|
use poem::{get, middleware, post, Endpoint, EndpointExt, Route};
|
||||||
|
|
||||||
async fn create() -> Result<impl Endpoint, Error> {
|
async fn create() -> Result<impl Endpoint, Error> {
|
||||||
let config = if cfg!(feature = "local") {
|
let config = if cfg!(feature = "local") {
|
||||||
@ -25,9 +27,12 @@ async fn create() -> Result<impl Endpoint, Error> {
|
|||||||
.at("/page_view/:id", post(append_page_view))
|
.at("/page_view/:id", post(append_page_view))
|
||||||
.at("/auth/sign_in", post(signin))
|
.at("/auth/sign_in", post(signin))
|
||||||
.at("/auth/new_password", post(new_password))
|
.at("/auth/new_password", post(new_password))
|
||||||
.data(env)
|
.at("/auth/validate", post(validate_token))
|
||||||
|
.at("/query/month/:year/:month", get(query_month_view))
|
||||||
|
.with(AuthContext::new(&["/auth", "/page_view"], env.clone()))
|
||||||
.with(middleware::Cors::new())
|
.with(middleware::Cors::new())
|
||||||
.with(middleware::Tracing))
|
.with(middleware::Tracing)
|
||||||
|
.data(env))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
|
||||||
|
use fernet::Fernet;
|
||||||
use lambda_runtime::Error;
|
use lambda_runtime::Error;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@ -41,7 +42,10 @@ pub fn load_from_file() -> Result<Config, Error> {
|
|||||||
pub async fn load_from_env() -> Result<Config, Error> {
|
pub async fn load_from_env() -> Result<Config, Error> {
|
||||||
let endpoint = std::env::var("DATABASE_ENDPOINT")?;
|
let endpoint = std::env::var("DATABASE_ENDPOINT")?;
|
||||||
let password = std::env::var("DATABASE_PASSWORD")?;
|
let password = std::env::var("DATABASE_PASSWORD")?;
|
||||||
let token_key = std::env::var("TOKEN_KEY")?;
|
let token_key = std::env::var("TOKEN_KEY").unwrap_or_else(|_| {
|
||||||
|
log::info!("Unable to find TOKEN_KEY environment variable; falling back to generated key");
|
||||||
|
Fernet::generate_key()
|
||||||
|
});
|
||||||
|
|
||||||
let db = DbConfig {
|
let db = DbConfig {
|
||||||
endpoint,
|
endpoint,
|
||||||
|
@ -5,23 +5,46 @@ use poem::{
|
|||||||
error::InternalServerError,
|
error::InternalServerError,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
web::headers::{self, authorization::Bearer, HeaderMapExt},
|
web::headers::{self, authorization::Bearer, HeaderMapExt},
|
||||||
Endpoint, Request,
|
Endpoint, Middleware, Request,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::env::Env;
|
||||||
|
|
||||||
|
pub struct AuthContext {
|
||||||
|
skip_prefixes: Vec<String>,
|
||||||
|
env: Env,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthContext {
|
||||||
|
pub fn new(skip_prefixes: &[&str], env: Env) -> Self {
|
||||||
|
Self {
|
||||||
|
skip_prefixes: skip_prefixes.iter().map(ToString::to_string).collect(),
|
||||||
|
env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: Endpoint> Middleware<E> for AuthContext {
|
||||||
|
type Output = AuthEndpoint<E>;
|
||||||
|
|
||||||
|
fn transform(&self, ep: E) -> Self::Output {
|
||||||
|
AuthEndpoint::new(self.skip_prefixes.clone(), self.env.clone(), ep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AuthEndpoint<E: Endpoint> {
|
pub struct AuthEndpoint<E: Endpoint> {
|
||||||
pool: PgPool,
|
skip_prefixes: Vec<String>,
|
||||||
fernet: Fernet,
|
env: Env,
|
||||||
endpoint: E,
|
endpoint: E,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E: Endpoint> AuthEndpoint<E> {
|
impl<E: Endpoint> AuthEndpoint<E> {
|
||||||
pub fn new(pool: PgPool, fernet: Fernet, endpoint: E) -> Self {
|
fn new(skip_prefixes: Vec<String>, env: Env, endpoint: E) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pool,
|
skip_prefixes,
|
||||||
fernet,
|
env,
|
||||||
endpoint,
|
endpoint,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -32,6 +55,12 @@ impl<E: Endpoint> Endpoint for AuthEndpoint<E> {
|
|||||||
type Output = E::Output;
|
type Output = E::Output;
|
||||||
|
|
||||||
async fn call(&self, mut request: Request) -> poem::Result<Self::Output> {
|
async fn call(&self, mut request: Request) -> poem::Result<Self::Output> {
|
||||||
|
for skip_prefix in &self.skip_prefixes {
|
||||||
|
if request.uri().path().starts_with(skip_prefix) {
|
||||||
|
return self.endpoint.call(request).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Make sure that we have an 'Authorization' header that has a 'Bearer' token.
|
// Make sure that we have an 'Authorization' header that has a 'Bearer' token.
|
||||||
let Some(auth) = request.headers().typed_get::<headers::Authorization<Bearer>>() else {
|
let Some(auth) = request.headers().typed_get::<headers::Authorization<Bearer>>() else {
|
||||||
log::info!("Missing 'Authorization' header with 'Bearer' token");
|
log::info!("Missing 'Authorization' header with 'Bearer' token");
|
||||||
@ -39,7 +68,7 @@ impl<E: Endpoint> Endpoint for AuthEndpoint<E> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Ensure that we can decrypt the token using the provided Fernet key.
|
// Ensure that we can decrypt the token using the provided Fernet key.
|
||||||
let Token { user_id } = match Token::decode(&self.fernet, auth.token()) {
|
let Token { user_id } = match Token::decode(&self.env.fernet, auth.token()) {
|
||||||
Some(token) => token,
|
Some(token) => token,
|
||||||
None => {
|
None => {
|
||||||
log::error!("Failed to decode authentication token");
|
log::error!("Failed to decode authentication token");
|
||||||
@ -49,7 +78,7 @@ impl<E: Endpoint> Endpoint for AuthEndpoint<E> {
|
|||||||
|
|
||||||
// If the user no longer exists, then a simple 401 will suffice.
|
// If the user no longer exists, then a simple 401 will suffice.
|
||||||
let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
||||||
.bind(user_id).fetch_optional(&self.pool).await.map_err(InternalServerError)? else {
|
.bind(user_id).fetch_optional(&self.env.pool).await.map_err(InternalServerError)? else {
|
||||||
log::error!("User '{user_id}' no longer exists");
|
log::error!("User '{user_id}' no longer exists");
|
||||||
return Err(poem::Error::from_status(StatusCode::UNAUTHORIZED));
|
return Err(poem::Error::from_status(StatusCode::UNAUTHORIZED));
|
||||||
};
|
};
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod page_view;
|
pub mod page_view;
|
||||||
|
pub mod query;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
use analytics_model::user::{authenticate, reset_password};
|
use analytics_model::user::{authenticate, reset_password, User};
|
||||||
use poem::{
|
use poem::{
|
||||||
error::InternalServerError,
|
error::InternalServerError,
|
||||||
handler,
|
handler,
|
||||||
@ -31,6 +31,18 @@ pub struct NewPasswordBody {
|
|||||||
new_password: String,
|
new_password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ValidateTokenBody {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum ValidateTokenResponse {
|
||||||
|
Invalid,
|
||||||
|
Valid { token: String },
|
||||||
|
}
|
||||||
|
|
||||||
#[handler]
|
#[handler]
|
||||||
pub async fn signin(
|
pub async fn signin(
|
||||||
env: Data<&Env>,
|
env: Data<&Env>,
|
||||||
@ -70,3 +82,30 @@ pub async fn new_password(
|
|||||||
let token = token.encode(&env.fernet);
|
let token = token.encode(&env.fernet);
|
||||||
Ok(Json(SignInResponse::Successful { token }))
|
Ok(Json(SignInResponse::Successful { token }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[handler]
|
||||||
|
pub async fn validate_token(
|
||||||
|
env: Data<&Env>,
|
||||||
|
Json(ValidateTokenBody { token }): Json<ValidateTokenBody>,
|
||||||
|
) -> poem::Result<Json<ValidateTokenResponse>> {
|
||||||
|
let Some(Token { user_id }) = Token::decode(&env.fernet, &token) else {
|
||||||
|
log::error!("Failed to decode authentication token");
|
||||||
|
return Ok(Json(ValidateTokenResponse::Invalid));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
||||||
|
.bind(user_id).fetch_optional(&env.pool).await.map_err(InternalServerError)? else {
|
||||||
|
log::error!("User '{user_id}' no longer exists");
|
||||||
|
return Ok(Json(ValidateTokenResponse::Invalid));
|
||||||
|
};
|
||||||
|
|
||||||
|
if !user.enabled {
|
||||||
|
log::error!("User '{user_id}' is not enabled");
|
||||||
|
return Ok(Json(ValidateTokenResponse::Invalid));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = Token::new(user.id);
|
||||||
|
let token = token.encode(&env.fernet);
|
||||||
|
|
||||||
|
Ok(Json(ValidateTokenResponse::Valid { token }))
|
||||||
|
}
|
||||||
|
70
analytics/lambda/src/handlers/query.rs
Normal file
70
analytics/lambda/src/handlers/query.rs
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
use analytics_model::{user::User, view::PageViewsMonth};
|
||||||
|
use poem::{
|
||||||
|
error::InternalServerError,
|
||||||
|
handler,
|
||||||
|
web::{Data, Json, Path},
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::env::Env;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||||
|
pub struct PageViewsPathCount {
|
||||||
|
pub path: String,
|
||||||
|
pub count: i64,
|
||||||
|
pub beacons: i64,
|
||||||
|
pub avg_duration: f64,
|
||||||
|
pub avg_scroll: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PageViewsMonthResult {
|
||||||
|
pub site: PageViewsPathCount,
|
||||||
|
pub views: Vec<PageViewsMonth>,
|
||||||
|
pub paths: Vec<PageViewsPathCount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[handler]
|
||||||
|
pub async fn query_month_view(
|
||||||
|
env: Data<&Env>,
|
||||||
|
_: Data<&User>,
|
||||||
|
Path((year, month)): Path<(i32, i32)>,
|
||||||
|
) -> poem::Result<Json<PageViewsMonthResult>> {
|
||||||
|
let views = sqlx::query_as::<_, PageViewsMonth>(
|
||||||
|
"SELECT * FROM page_views_month WHERE path = $1 AND year = $2 AND month = $3 ORDER BY day",
|
||||||
|
)
|
||||||
|
.bind("")
|
||||||
|
.bind(year)
|
||||||
|
.bind(month)
|
||||||
|
.fetch_all(&env.pool)
|
||||||
|
.await
|
||||||
|
.map_err(InternalServerError)?;
|
||||||
|
|
||||||
|
let mut paths = sqlx::query_as::<_, PageViewsPathCount>(
|
||||||
|
"SELECT path,
|
||||||
|
SUM(count) AS count,
|
||||||
|
SUM(total_beacon) AS beacons,
|
||||||
|
SUM(total_duration) / SUM(total_beacon) AS avg_duration,
|
||||||
|
SUM(total_scroll) / SUM(total_beacon) AS avg_scroll
|
||||||
|
FROM page_views_month WHERE year = $1 AND month = $2 GROUP BY path",
|
||||||
|
)
|
||||||
|
.bind(year)
|
||||||
|
.bind(month)
|
||||||
|
.fetch_all(&env.pool)
|
||||||
|
.await
|
||||||
|
.map_err(InternalServerError)?;
|
||||||
|
|
||||||
|
let site = if let Some(index) = paths.iter().position(|count| count.path.is_empty()) {
|
||||||
|
paths.swap_remove(index)
|
||||||
|
} else {
|
||||||
|
PageViewsPathCount {
|
||||||
|
path: String::new(),
|
||||||
|
count: 0,
|
||||||
|
beacons: 0,
|
||||||
|
avg_duration: 0.0,
|
||||||
|
avg_scroll: 0.0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(PageViewsMonthResult { site, views, paths }))
|
||||||
|
}
|
@ -10,8 +10,8 @@ CREATE TABLE IF NOT EXISTS page_views (
|
|||||||
timezone TEXT,
|
timezone TEXT,
|
||||||
referrer TEXT,
|
referrer TEXT,
|
||||||
beacon BOOLEAN NOT NULL,
|
beacon BOOLEAN NOT NULL,
|
||||||
duration REAL,
|
duration FLOAT8,
|
||||||
scroll REAL
|
scroll FLOAT8
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS page_views_day (
|
CREATE TABLE IF NOT EXISTS page_views_day (
|
||||||
@ -23,8 +23,8 @@ CREATE TABLE IF NOT EXISTS page_views_day (
|
|||||||
hour INTEGER NOT NULL,
|
hour INTEGER NOT NULL,
|
||||||
count INTEGER NOT NULL,
|
count INTEGER NOT NULL,
|
||||||
total_beacon INTEGER NOT NULL,
|
total_beacon INTEGER NOT NULL,
|
||||||
total_scroll REAL NOT NULL,
|
total_scroll FLOAT8 NOT NULL,
|
||||||
total_duration REAL NOT NULL,
|
total_duration FLOAT8 NOT NULL,
|
||||||
|
|
||||||
CONSTRAINT unique_page_views_day
|
CONSTRAINT unique_page_views_day
|
||||||
UNIQUE (path, year, month, day, hour)
|
UNIQUE (path, year, month, day, hour)
|
||||||
@ -38,8 +38,8 @@ CREATE TABLE IF NOT EXISTS page_views_week (
|
|||||||
dow INTEGER NOT NULL,
|
dow INTEGER NOT NULL,
|
||||||
count INTEGER NOT NULL,
|
count INTEGER NOT NULL,
|
||||||
total_beacon INTEGER NOT NULL,
|
total_beacon INTEGER NOT NULL,
|
||||||
total_scroll REAL NOT NULL,
|
total_scroll FLOAT8 NOT NULL,
|
||||||
total_duration REAL NOT NULL,
|
total_duration FLOAT8 NOT NULL,
|
||||||
|
|
||||||
CONSTRAINT unique_page_views_week
|
CONSTRAINT unique_page_views_week
|
||||||
UNIQUE (path, year, week, dow)
|
UNIQUE (path, year, week, dow)
|
||||||
@ -53,8 +53,8 @@ CREATE TABLE IF NOT EXISTS page_views_month (
|
|||||||
day INTEGER NOT NULL,
|
day INTEGER NOT NULL,
|
||||||
count INTEGER NOT NULL,
|
count INTEGER NOT NULL,
|
||||||
total_beacon INTEGER NOT NULL,
|
total_beacon INTEGER NOT NULL,
|
||||||
total_scroll REAL NOT NULL,
|
total_scroll FLOAT8 NOT NULL,
|
||||||
total_duration REAL NOT NULL,
|
total_duration FLOAT8 NOT NULL,
|
||||||
|
|
||||||
CONSTRAINT unique_page_views_month
|
CONSTRAINT unique_page_views_month
|
||||||
UNIQUE (path, year, month, day)
|
UNIQUE (path, year, month, day)
|
||||||
|
@ -9,6 +9,6 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
UNIQUE (username)
|
UNIQUE (username)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Create an intial user that has a temporary password
|
-- Create an intial user that has a temporary password. The password is: admin
|
||||||
INSERT INTO users (username, password, enabled, reset_password)
|
INSERT INTO users (username, password, enabled, reset_password)
|
||||||
VALUES("admin", "admin", TRUE, TRUE);
|
VALUES('admin', '$pbkdf2-sha256$i=600000,l=32$V62SYtsc1HWC2hV3jbevjg$OrOHoTwo1YPmNrPUnAUy3Vfg4Lrw90mxOTTISVHmjnk', TRUE, TRUE);
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
use serde::Serialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -19,9 +20,9 @@ pub struct PageView {
|
|||||||
pub scroll: Option<f64>,
|
pub scroll: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||||
pub struct PageViewsDay {
|
pub struct PageViewsDay {
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub year: i32,
|
pub year: i32,
|
||||||
pub month: i32,
|
pub month: i32,
|
||||||
@ -33,9 +34,9 @@ pub struct PageViewsDay {
|
|||||||
pub total_duration: f64,
|
pub total_duration: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||||
pub struct PageViewsWeek {
|
pub struct PageViewsWeek {
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub year: i32,
|
pub year: i32,
|
||||||
pub week: i32,
|
pub week: i32,
|
||||||
@ -46,9 +47,9 @@ pub struct PageViewsWeek {
|
|||||||
pub total_duration: f64,
|
pub total_duration: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||||
pub struct PageViewsMonth {
|
pub struct PageViewsMonth {
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub year: i32,
|
pub year: i32,
|
||||||
pub month: i32,
|
pub month: i32,
|
||||||
@ -134,7 +135,6 @@ async fn update_count_accumulators(
|
|||||||
.bind(time.year())
|
.bind(time.year())
|
||||||
.bind(time.iso_week() as i32)
|
.bind(time.iso_week() as i32)
|
||||||
.bind(time.weekday().number_days_from_sunday() as i32)
|
.bind(time.weekday().number_days_from_sunday() as i32)
|
||||||
.bind(time.hour() as i32)
|
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -153,7 +153,6 @@ async fn update_count_accumulators(
|
|||||||
.bind(time.year())
|
.bind(time.year())
|
||||||
.bind(time.month() as i32)
|
.bind(time.month() as i32)
|
||||||
.bind(time.day() as i32)
|
.bind(time.day() as i32)
|
||||||
.bind(time.hour() as i32)
|
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
@ -90,6 +90,11 @@ impl Env {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn render_route(&self, route: Route) -> String {
|
async fn render_route(&self, route: Route) -> String {
|
||||||
|
assert!(
|
||||||
|
route.shoud_render(),
|
||||||
|
"Route {route:?} should not be rendered"
|
||||||
|
);
|
||||||
|
|
||||||
let head = HeadContext::default();
|
let head = HeadContext::default();
|
||||||
|
|
||||||
let render = {
|
let render = {
|
||||||
@ -135,7 +140,11 @@ struct RenderRoute {
|
|||||||
|
|
||||||
fn collect_routes() -> Vec<RenderRoute> {
|
fn collect_routes() -> Vec<RenderRoute> {
|
||||||
enum_iterator::all::<Route>()
|
enum_iterator::all::<Route>()
|
||||||
.map(|route| {
|
.filter_map(|route| {
|
||||||
|
if !route.should_render() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let path = route.to_path();
|
let path = route.to_path();
|
||||||
let path = if path == "/" {
|
let path = if path == "/" {
|
||||||
PathBuf::from("index.html")
|
PathBuf::from("index.html")
|
||||||
@ -143,7 +152,7 @@ fn collect_routes() -> Vec<RenderRoute> {
|
|||||||
PathBuf::from(&path[1..]).with_extension("html")
|
PathBuf::from(&path[1..]).with_extension("html")
|
||||||
};
|
};
|
||||||
|
|
||||||
RenderRoute { route, path }
|
Some(RenderRoute { route, path })
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
pub mod blog;
|
pub mod blog;
|
||||||
pub mod content;
|
pub mod content;
|
||||||
|
pub mod display;
|
||||||
pub mod head;
|
pub mod head;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
|
@ -274,7 +274,7 @@ impl AnalyticsBeaconData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_analytics_host() -> String {
|
pub fn get_analytics_host() -> String {
|
||||||
let mut host = std::option_env!("ANALYTICS_HOST")
|
let mut host = std::option_env!("ANALYTICS_HOST")
|
||||||
.unwrap_or("https://analytics.blakerain.com")
|
.unwrap_or("https://analytics.blakerain.com")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
1
src/components/display.rs
Normal file
1
src/components/display.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod bar_chart;
|
146
src/components/display/bar_chart.rs
Normal file
146
src/components/display/bar_chart.rs
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
use yew::{classes, function_component, html, use_state, Callback, Html, Properties};
|
||||||
|
|
||||||
|
pub struct AxisScale {
|
||||||
|
pub height: f32,
|
||||||
|
pub min_value: f32,
|
||||||
|
pub max_value: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AxisScale {
|
||||||
|
pub fn scale(&self, value: f32) -> f32 {
|
||||||
|
self.height * (value - self.min_value) / (self.max_value - self.min_value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct BarChartProps {
|
||||||
|
pub labels: Vec<String>,
|
||||||
|
pub data: Vec<f32>,
|
||||||
|
pub onhover: Option<Callback<usize>>,
|
||||||
|
pub onleave: Option<Callback<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_WIDTH: f32 = 1000.0;
|
||||||
|
const CHART_HEIGHT: f32 = 562.0;
|
||||||
|
const TOP_OFFSET: f32 = 40.0;
|
||||||
|
const AXIS_OFFSET_X: f32 = 60.0;
|
||||||
|
const AXIS_OFFSET_Y: f32 = 40.0;
|
||||||
|
const CHART_AREA_WIDTH: f32 = CHART_WIDTH - AXIS_OFFSET_X;
|
||||||
|
const CHART_AREA_HEIGHT: f32 = CHART_HEIGHT - (TOP_OFFSET + AXIS_OFFSET_Y);
|
||||||
|
const AXIS_GRADUATION_COUNT: usize = 15;
|
||||||
|
|
||||||
|
#[function_component(BarChart)]
|
||||||
|
pub fn bar_chart(props: &BarChartProps) -> Html {
|
||||||
|
debug_assert_eq!(props.labels.len(), props.data.len());
|
||||||
|
let highlight = use_state(|| None::<usize>);
|
||||||
|
|
||||||
|
let mut min_value = f32::MAX;
|
||||||
|
let mut max_value = f32::MIN;
|
||||||
|
|
||||||
|
for value in &props.data {
|
||||||
|
min_value = min_value.min(*value);
|
||||||
|
max_value = max_value.max(*value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let scale = AxisScale {
|
||||||
|
height: CHART_AREA_HEIGHT,
|
||||||
|
min_value,
|
||||||
|
max_value,
|
||||||
|
};
|
||||||
|
|
||||||
|
let graduations =
|
||||||
|
((15f32.min(max_value - min_value)).round() as usize).min(AXIS_GRADUATION_COUNT);
|
||||||
|
let graduation_step = CHART_AREA_HEIGHT / graduations as f32;
|
||||||
|
let bar_width = CHART_AREA_WIDTH / props.data.len() as f32;
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<svg viewBox={format!("0 0 {} {}", CHART_WIDTH, CHART_HEIGHT)}
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform={format!("translate({}, {})", AXIS_OFFSET_X, CHART_HEIGHT - TOP_OFFSET)}>
|
||||||
|
<line x="0" y="0"
|
||||||
|
x2={CHART_AREA_WIDTH.to_string()} y2="0"
|
||||||
|
stroke-width="1"
|
||||||
|
class="stroke-black dark:stroke-white" />
|
||||||
|
|
||||||
|
{ for props.labels.iter().enumerate().map(|(index, label)| html! {
|
||||||
|
<g transform={format!("translate({}, 0)", (index as f32 * bar_width) + (0.5 * bar_width))}>
|
||||||
|
<line y2="10" x2="0" class="stroke-black dark:stroke-white" />
|
||||||
|
<text dy="0.71em" y="16" x="0" style="text-anchor: middle" class="fill-black dark:fill-white">
|
||||||
|
{label.clone()}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
<g transform={format!("translate(0,{})", TOP_OFFSET)}>
|
||||||
|
<line
|
||||||
|
x1={AXIS_OFFSET_X.to_string()}
|
||||||
|
y1="0"
|
||||||
|
x2={AXIS_OFFSET_X.to_string()}
|
||||||
|
y2={CHART_AREA_HEIGHT.to_string()}
|
||||||
|
stroke-width="1"
|
||||||
|
class="stroke-black dark:stroke-white" />
|
||||||
|
|
||||||
|
<g transform={format!("translate({}, 0)", AXIS_OFFSET_X)}>
|
||||||
|
{ for (0..graduations).map(|index| {
|
||||||
|
let value = scale.max_value -
|
||||||
|
(index as f32 * (scale.max_value - scale.min_value) / graduations as f32);
|
||||||
|
html! {
|
||||||
|
<g transform={format!("translate(0, {})", index as f32 * graduation_step)}>
|
||||||
|
<line x2="-10" y2="0" class="stroke-black dark:stroke-white" />
|
||||||
|
<text dy="0.32em" x="-16" y="0" style="text-anchor: end" class="fill-black dark:fill-white">
|
||||||
|
{format!("{:.0}", value)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g transform={format!("translate(0,{})", TOP_OFFSET)}>
|
||||||
|
{ for props.data.iter().enumerate().map(|(index, value)| {
|
||||||
|
let onhover = props.onhover.clone();
|
||||||
|
let onleave = props.onleave.clone();
|
||||||
|
|
||||||
|
if (scale.min_value - value).abs() < 0.01 {
|
||||||
|
return html! {}
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<rect
|
||||||
|
x={((index as f32 * bar_width) + AXIS_OFFSET_X).to_string()}
|
||||||
|
y={(CHART_AREA_HEIGHT - scale.scale(*value)).to_string()}
|
||||||
|
width={bar_width.to_string()}
|
||||||
|
height={scale.scale(*value).to_string()}
|
||||||
|
class={
|
||||||
|
classes!(
|
||||||
|
"cursor-pointer",
|
||||||
|
if *highlight == Some(index) {
|
||||||
|
"fill-slate-700 dark:fill-slate-500"
|
||||||
|
} else {
|
||||||
|
"fill-slate-800 dark:fill-slate-400"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onmouseover={
|
||||||
|
let highlight = highlight.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
highlight.set(Some(index));
|
||||||
|
if let Some(onhover) = &onhover {
|
||||||
|
onhover.emit(index);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onmouseout={
|
||||||
|
let highlight = highlight.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
highlight.set(None);
|
||||||
|
if let Some(onleave) = &onleave {
|
||||||
|
onleave.emit(());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} />
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
}
|
@ -40,6 +40,9 @@ pub fn footer(_: &FooterProps) -> Html {
|
|||||||
rel="noreferrer">
|
rel="noreferrer">
|
||||||
{"Mastodon"}
|
{"Mastodon"}
|
||||||
</a>
|
</a>
|
||||||
|
<Link<Route> classes="hover:text-neutral-50" to={Route::AnalyticsRoot}>
|
||||||
|
{"Analytics"}
|
||||||
|
</Link<Route>>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{"Powered by "}
|
{"Powered by "}
|
||||||
|
@ -5,6 +5,7 @@ use yew::{function_component, html, use_memo, Children, ContextProvider, Html, P
|
|||||||
|
|
||||||
macros::tags!("content/tags.yaml");
|
macros::tags!("content/tags.yaml");
|
||||||
|
|
||||||
|
pub mod analytics;
|
||||||
pub mod blog;
|
pub mod blog;
|
||||||
pub mod pages;
|
pub mod pages;
|
||||||
|
|
||||||
|
31
src/model/analytics.rs
Normal file
31
src/model/analytics.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
pub struct PageViewsMonth {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub path: String,
|
||||||
|
pub year: i32,
|
||||||
|
pub month: i32,
|
||||||
|
pub day: i32,
|
||||||
|
pub count: i32,
|
||||||
|
pub total_beacon: i32,
|
||||||
|
pub total_scroll: f64,
|
||||||
|
pub total_duration: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
pub struct PageViewsPathCount {
|
||||||
|
pub path: String,
|
||||||
|
pub count: i64,
|
||||||
|
pub beacons: i64,
|
||||||
|
pub avg_duration: f64,
|
||||||
|
pub avg_scroll: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
pub struct PageViewsMonthResult {
|
||||||
|
pub site: PageViewsPathCount,
|
||||||
|
pub views: Vec<PageViewsMonth>,
|
||||||
|
pub paths: Vec<PageViewsPathCount>,
|
||||||
|
}
|
16
src/pages.rs
16
src/pages.rs
@ -1,8 +1,11 @@
|
|||||||
use enum_iterator::Sequence;
|
use enum_iterator::Sequence;
|
||||||
use yew::{html, Html};
|
use yew::{html, Html};
|
||||||
use yew_router::Routable;
|
use yew_router::{Routable, Switch};
|
||||||
|
|
||||||
|
use self::analytics::AnalyticsRoute;
|
||||||
|
|
||||||
mod about;
|
mod about;
|
||||||
|
mod analytics;
|
||||||
mod blog;
|
mod blog;
|
||||||
mod blog_post;
|
mod blog_post;
|
||||||
mod disclaimer;
|
mod disclaimer;
|
||||||
@ -21,6 +24,10 @@ pub enum Route {
|
|||||||
BlogPost { doc_id: crate::model::blog::DocId },
|
BlogPost { doc_id: crate::model::blog::DocId },
|
||||||
#[at("/disclaimer")]
|
#[at("/disclaimer")]
|
||||||
Disclaimer,
|
Disclaimer,
|
||||||
|
#[at("/analytics")]
|
||||||
|
AnalyticsRoot,
|
||||||
|
#[at("/analytics/*")]
|
||||||
|
Analytics,
|
||||||
#[not_found]
|
#[not_found]
|
||||||
#[at("/404")]
|
#[at("/404")]
|
||||||
NotFound,
|
NotFound,
|
||||||
@ -31,6 +38,10 @@ impl Route {
|
|||||||
!matches!(self, Self::Disclaimer)
|
!matches!(self, Self::Disclaimer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn should_render(&self) -> bool {
|
||||||
|
!matches!(self, Self::AnalyticsRoot | Self::Analytics)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn switch(self) -> Html {
|
pub fn switch(self) -> Html {
|
||||||
match self {
|
match self {
|
||||||
Self::Home => html! { <home::Page /> },
|
Self::Home => html! { <home::Page /> },
|
||||||
@ -39,6 +50,9 @@ impl Route {
|
|||||||
Self::BlogPost { doc_id } => html! { <blog_post::Page {doc_id} /> },
|
Self::BlogPost { doc_id } => html! { <blog_post::Page {doc_id} /> },
|
||||||
Self::Disclaimer => html! { <disclaimer::Page /> },
|
Self::Disclaimer => html! { <disclaimer::Page /> },
|
||||||
Self::NotFound => html! { <not_found::Page /> },
|
Self::NotFound => html! { <not_found::Page /> },
|
||||||
|
Self::AnalyticsRoot | Self::Analytics => {
|
||||||
|
html! { <Switch<AnalyticsRoute> render={AnalyticsRoute::switch} /> }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
26
src/pages/analytics.rs
Normal file
26
src/pages/analytics.rs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
use enum_iterator::Sequence;
|
||||||
|
use yew::{html, Html};
|
||||||
|
use yew_router::{prelude::Redirect, Routable};
|
||||||
|
|
||||||
|
use super::Route;
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
mod dashboard;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Routable, Sequence, PartialEq)]
|
||||||
|
pub enum AnalyticsRoute {
|
||||||
|
#[at("/analytics")]
|
||||||
|
Dashboard,
|
||||||
|
#[not_found]
|
||||||
|
#[at("/analytics/404")]
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnalyticsRoute {
|
||||||
|
pub fn switch(self) -> Html {
|
||||||
|
match self {
|
||||||
|
Self::Dashboard => html! { <dashboard::Page /> },
|
||||||
|
Self::NotFound => html! { <Redirect<Route> to={Route::NotFound} /> },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
509
src/pages/analytics/auth.rs
Normal file
509
src/pages/analytics/auth.rs
Normal file
@ -0,0 +1,509 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gloo::storage::{errors::StorageError, Storage};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::{HtmlInputElement, InputEvent, SubmitEvent};
|
||||||
|
use yew::{
|
||||||
|
function_component, html, use_reducer, Callback, Children, ContextProvider, Html, Properties,
|
||||||
|
Reducible, UseReducerHandle,
|
||||||
|
};
|
||||||
|
use yew_hooks::{use_async, use_async_with_options, use_interval, UseAsyncHandle, UseAsyncOptions};
|
||||||
|
use yew_icons::{Icon, IconId};
|
||||||
|
|
||||||
|
use crate::components::analytics::get_analytics_host;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
enum AuthState {
|
||||||
|
// There is no authentication information
|
||||||
|
Empty,
|
||||||
|
// We have a stored authentication token that we want to validate.
|
||||||
|
Validating { token: String },
|
||||||
|
// We have a valid authentication token.
|
||||||
|
Valid { token: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthStateAction {
|
||||||
|
UseToken(String),
|
||||||
|
Clear,
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORED_TOKEN_ID: &str = "blakerain-analytics-token";
|
||||||
|
|
||||||
|
impl AuthState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
if let Some(token) = Self::get_stored_token() {
|
||||||
|
Self::Validating { token }
|
||||||
|
} else {
|
||||||
|
Self::Empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_stored_token() -> Option<String> {
|
||||||
|
match gloo::storage::LocalStorage::get(STORED_TOKEN_ID) {
|
||||||
|
Ok(token) => Some(token),
|
||||||
|
Err(err) => match err {
|
||||||
|
StorageError::KeyNotFound(_) => None,
|
||||||
|
StorageError::SerdeError(err) => {
|
||||||
|
log::error!("Failed to deserialize stored authentication token: {err:?}");
|
||||||
|
Self::remove_stored_token();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
StorageError::JsError(err) => {
|
||||||
|
log::info!("Failed to load stored authentication token: {err:?}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_stored_token(token: &str) {
|
||||||
|
gloo::storage::LocalStorage::set(STORED_TOKEN_ID, token)
|
||||||
|
.expect("SessionStorage to be writable")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_stored_token() {
|
||||||
|
gloo::storage::LocalStorage::delete(STORED_TOKEN_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reducible for AuthState {
|
||||||
|
type Action = AuthStateAction;
|
||||||
|
|
||||||
|
fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
|
||||||
|
match action {
|
||||||
|
AuthStateAction::UseToken(token) => {
|
||||||
|
Self::set_stored_token(&token);
|
||||||
|
Self::Valid { token }
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthStateAction::Clear => {
|
||||||
|
Self::remove_stored_token();
|
||||||
|
Self::Empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum SignInResponse {
|
||||||
|
InvalidCredentials,
|
||||||
|
NewPassword,
|
||||||
|
Successful { token: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum ValidateTokenResponse {
|
||||||
|
Invalid,
|
||||||
|
Valid { token: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct AuthTokenContext(pub String);
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct WithAuthProps {
|
||||||
|
#[prop_or_default]
|
||||||
|
pub children: Children,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(WithAuth)]
|
||||||
|
pub fn with_auth(props: &WithAuthProps) -> Html {
|
||||||
|
let host = get_analytics_host();
|
||||||
|
let state = use_reducer(AuthState::new);
|
||||||
|
|
||||||
|
let submission: UseAsyncHandle<(), &'static str> = {
|
||||||
|
// If we have a token already in our state, then we want to validate it automatically.
|
||||||
|
let options = if let AuthState::Validating { .. } = *state {
|
||||||
|
UseAsyncOptions::enable_auto()
|
||||||
|
} else {
|
||||||
|
UseAsyncOptions::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let host = host.clone();
|
||||||
|
let state = state.clone();
|
||||||
|
|
||||||
|
use_async_with_options(
|
||||||
|
async move {
|
||||||
|
if let AuthState::Validating { token } = &*state {
|
||||||
|
log::info!("Validating and regenerating authentication token");
|
||||||
|
|
||||||
|
let res = reqwest::Client::new()
|
||||||
|
.post(format!("{host}auth/validate"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"token": token
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
log::error!(
|
||||||
|
"Unable to validate analytics authentication token: {err:?}"
|
||||||
|
);
|
||||||
|
"Unable to validate analytics authentication token"
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let res = res.json::<ValidateTokenResponse>().await.map_err(|err| {
|
||||||
|
log::error!("Unable to parse analytics token validation response: {err:?}");
|
||||||
|
"Unable to parse analytics token validation response"
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
ValidateTokenResponse::Invalid => {
|
||||||
|
log::error!("Stored token was invalid; clearing state");
|
||||||
|
state.dispatch(AuthStateAction::Clear);
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateTokenResponse::Valid { token } => {
|
||||||
|
log::info!("Stored token was valid and regenerated");
|
||||||
|
state.dispatch(AuthStateAction::UseToken(token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::warn!("No analytics token was present to validate");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every five minutes: refresh and revalidate the token.
|
||||||
|
use_interval(move || submission.run(), 5 * 60 * 1000);
|
||||||
|
|
||||||
|
match &*state {
|
||||||
|
AuthState::Empty => {
|
||||||
|
html! {
|
||||||
|
<SignIn host={host.clone()} state={state} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthState::Validating { .. } => {
|
||||||
|
html! {
|
||||||
|
<div class="container mx-auto my-10">
|
||||||
|
<b class="text-xl font-semibold text-center">{"Validating Authentication ..."}</b>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthState::Valid { token } => {
|
||||||
|
html! {
|
||||||
|
<ContextProvider<AuthTokenContext> context={AuthTokenContext(token.clone())}>
|
||||||
|
{props.children.clone()}
|
||||||
|
</ContextProvider<AuthTokenContext>>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct AuthContainerProps {
|
||||||
|
title: String,
|
||||||
|
message: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
#[prop_or_default]
|
||||||
|
children: Children,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(AuthContainer)]
|
||||||
|
fn auth_container(
|
||||||
|
AuthContainerProps {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
error,
|
||||||
|
children,
|
||||||
|
}: &AuthContainerProps,
|
||||||
|
) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="container mx-auto my-10 flex flex-row justify-center">
|
||||||
|
<div class="flex flex-col gap-4 basis-full md:basis-2/3 lg:basis-1/2 xl:basis-1/3 p-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold">{title}</h1>
|
||||||
|
if let Some(message) = message {
|
||||||
|
<h2>{message}</h2>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if let Some(error) = error {
|
||||||
|
<h2 class="dark:text-red-500 text-red-700">{error}</h2>
|
||||||
|
}
|
||||||
|
{children.clone()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct SignInProps {
|
||||||
|
pub host: String,
|
||||||
|
pub state: UseReducerHandle<AuthState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SignInState {
|
||||||
|
processing: bool,
|
||||||
|
message: &'static str,
|
||||||
|
error: Option<String>,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
new_password: Option<String>,
|
||||||
|
complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SignInStateAction {
|
||||||
|
SetProcessing,
|
||||||
|
SetError(String),
|
||||||
|
SetUsername(String),
|
||||||
|
SetPassword(String),
|
||||||
|
SetNewPassword(String),
|
||||||
|
InvalidCredentials,
|
||||||
|
RequireNewPassword,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignInState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
processing: false,
|
||||||
|
message: "Sign in using your username and password",
|
||||||
|
error: None,
|
||||||
|
username: String::new(),
|
||||||
|
password: String::new(),
|
||||||
|
new_password: None,
|
||||||
|
complete: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_complete(username: &String, password: &String, new_password: Option<&String>) -> bool {
|
||||||
|
!username.is_empty()
|
||||||
|
&& !password.is_empty()
|
||||||
|
&& !new_password.map(String::is_empty).unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reducible for SignInState {
|
||||||
|
type Action = SignInStateAction;
|
||||||
|
|
||||||
|
fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
|
||||||
|
match action {
|
||||||
|
SignInStateAction::SetProcessing => Self {
|
||||||
|
processing: true,
|
||||||
|
..(*self).clone()
|
||||||
|
},
|
||||||
|
|
||||||
|
SignInStateAction::SetError(error) => Self {
|
||||||
|
error: Some(error),
|
||||||
|
..(*self).clone()
|
||||||
|
},
|
||||||
|
|
||||||
|
SignInStateAction::SetUsername(username) => Self {
|
||||||
|
complete: Self::is_complete(&username, &self.password, self.new_password.as_ref()),
|
||||||
|
username,
|
||||||
|
..(*self).clone()
|
||||||
|
},
|
||||||
|
|
||||||
|
SignInStateAction::SetPassword(password) => Self {
|
||||||
|
complete: Self::is_complete(&self.username, &password, self.new_password.as_ref()),
|
||||||
|
password,
|
||||||
|
..(*self).clone()
|
||||||
|
},
|
||||||
|
|
||||||
|
SignInStateAction::SetNewPassword(new_password) => Self {
|
||||||
|
complete: Self::is_complete(&self.username, &self.password, Some(&new_password)),
|
||||||
|
new_password: Some(new_password),
|
||||||
|
..(*self).clone()
|
||||||
|
},
|
||||||
|
|
||||||
|
SignInStateAction::InvalidCredentials => Self {
|
||||||
|
processing: false,
|
||||||
|
error: Some("Invalid username or password".to_string()),
|
||||||
|
username: String::new(),
|
||||||
|
password: String::new(),
|
||||||
|
new_password: self.new_password.as_ref().map(|_| String::new()),
|
||||||
|
complete: false,
|
||||||
|
..(*self).clone()
|
||||||
|
},
|
||||||
|
|
||||||
|
SignInStateAction::RequireNewPassword => Self {
|
||||||
|
processing: false,
|
||||||
|
message: "Please enter a new password",
|
||||||
|
error: None,
|
||||||
|
password: String::new(),
|
||||||
|
new_password: Some(String::new()),
|
||||||
|
complete: false,
|
||||||
|
..(*self).clone()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(SignIn)]
|
||||||
|
fn sign_in(SignInProps { host, state }: &SignInProps) -> Html {
|
||||||
|
let sign_in_state = use_reducer(SignInState::new);
|
||||||
|
|
||||||
|
let username_change = {
|
||||||
|
let sign_in_state = sign_in_state.clone();
|
||||||
|
Callback::from(move |event: InputEvent| {
|
||||||
|
let target = event
|
||||||
|
.target()
|
||||||
|
.expect("event target")
|
||||||
|
.dyn_into::<HtmlInputElement>()
|
||||||
|
.expect("input element");
|
||||||
|
|
||||||
|
sign_in_state.dispatch(SignInStateAction::SetUsername(target.value()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let password_change = {
|
||||||
|
let sign_in_state = sign_in_state.clone();
|
||||||
|
Callback::from(move |event: InputEvent| {
|
||||||
|
let target = event
|
||||||
|
.target()
|
||||||
|
.expect("event target")
|
||||||
|
.dyn_into::<HtmlInputElement>()
|
||||||
|
.expect("input element");
|
||||||
|
|
||||||
|
sign_in_state.dispatch(SignInStateAction::SetPassword(target.value()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_password_change = {
|
||||||
|
let sign_in_state = sign_in_state.clone();
|
||||||
|
Callback::from(move |event: InputEvent| {
|
||||||
|
let target = event
|
||||||
|
.target()
|
||||||
|
.expect("event target")
|
||||||
|
.dyn_into::<HtmlInputElement>()
|
||||||
|
.expect("input element");
|
||||||
|
|
||||||
|
sign_in_state.dispatch(SignInStateAction::SetNewPassword(target.value()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let submit: UseAsyncHandle<(), &'static str> = {
|
||||||
|
let host = host.clone();
|
||||||
|
let state = state.clone();
|
||||||
|
let sign_in_state = sign_in_state.clone();
|
||||||
|
|
||||||
|
let payload = if let Some(new_password) = &sign_in_state.new_password {
|
||||||
|
serde_json::json!({
|
||||||
|
"username": sign_in_state.username,
|
||||||
|
"oldPassword": sign_in_state.password,
|
||||||
|
"newPassword": new_password
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
serde_json::json!({
|
||||||
|
"username": sign_in_state.username,
|
||||||
|
"password": sign_in_state.password
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
use_async(async move {
|
||||||
|
{
|
||||||
|
let res = reqwest::Client::new()
|
||||||
|
.post(if sign_in_state.new_password.is_some() {
|
||||||
|
format!("{host}auth/new_password")
|
||||||
|
} else {
|
||||||
|
format!("{host}auth/sign_in")
|
||||||
|
})
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
log::error!("Failed to send authentication request: {err:?}");
|
||||||
|
"Error communicating with authentication server"
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let res = res.json::<SignInResponse>().await.map_err(|err| {
|
||||||
|
log::error!("Failed to decode sign in response: {err:?}");
|
||||||
|
"Error communicating with authentication server"
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
SignInResponse::InvalidCredentials => {
|
||||||
|
sign_in_state.dispatch(SignInStateAction::InvalidCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
SignInResponse::NewPassword => {
|
||||||
|
sign_in_state.dispatch(SignInStateAction::RequireNewPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
SignInResponse::Successful { token } => {
|
||||||
|
state.dispatch(AuthStateAction::UseToken(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.map_err(|err: &'static str| {
|
||||||
|
sign_in_state.dispatch(SignInStateAction::SetError(err.to_string()));
|
||||||
|
err
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let onsubmit = {
|
||||||
|
let sign_in_state = sign_in_state.clone();
|
||||||
|
|
||||||
|
Callback::from(move |event: SubmitEvent| {
|
||||||
|
event.prevent_default();
|
||||||
|
|
||||||
|
if !sign_in_state.complete {
|
||||||
|
log::error!("Attempt to submit form without completing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sign_in_state.dispatch(SignInStateAction::SetProcessing);
|
||||||
|
submit.run()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<form {onsubmit}>
|
||||||
|
<AuthContainer
|
||||||
|
title="Sign In"
|
||||||
|
message={Some(sign_in_state.message.to_string())}
|
||||||
|
error={sign_in_state.error.clone()}>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label>{"Username"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="username"
|
||||||
|
disabled={sign_in_state.processing}
|
||||||
|
value={sign_in_state.username.clone()}
|
||||||
|
oninput={username_change} />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label>{"Password"}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="password"
|
||||||
|
disabled={sign_in_state.processing}
|
||||||
|
value={sign_in_state.password.clone()}
|
||||||
|
oninput={password_change} />
|
||||||
|
</div>
|
||||||
|
if let Some(new_password) = &sign_in_state.new_password {
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label>{"New Password"}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="new password"
|
||||||
|
disabled={sign_in_state.processing}
|
||||||
|
value={new_password.clone()}
|
||||||
|
oninput={new_password_change} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="button mt-4"
|
||||||
|
disabled={!sign_in_state.complete || sign_in_state.processing}>
|
||||||
|
<Icon icon_id={IconId::LucideCheck} />
|
||||||
|
{"Sign In"}
|
||||||
|
</button>
|
||||||
|
</AuthContainer>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
271
src/pages/analytics/dashboard.rs
Normal file
271
src/pages/analytics/dashboard.rs
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
use time::{Month, OffsetDateTime};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use yew::{function_component, html, use_context, use_state, Callback, Html, UseStateHandle};
|
||||||
|
use yew_hooks::{use_async_with_options, UseAsyncHandle, UseAsyncOptions};
|
||||||
|
use yew_icons::{Icon, IconId};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
components::{analytics::get_analytics_host, display::bar_chart::BarChart},
|
||||||
|
model::analytics::{PageViewsMonth, PageViewsMonthResult},
|
||||||
|
pages::analytics::auth::{AuthTokenContext, WithAuth},
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn get_month_views(
|
||||||
|
host: &str,
|
||||||
|
token: &str,
|
||||||
|
year: i32,
|
||||||
|
month: i32,
|
||||||
|
) -> Result<PageViewsMonthResult, &'static str> {
|
||||||
|
reqwest::Client::new()
|
||||||
|
.get(format!("{host}query/month/{year}/{month}"))
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
log::error!("Unable to query analytics API: {err:?}");
|
||||||
|
"Unable to query analytics API"
|
||||||
|
})?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
log::error!("Unable to deserialize response from analytics API: {err:?}");
|
||||||
|
"Unable to deserialize response from analytics API"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn month_view_chart(
|
||||||
|
year: i32,
|
||||||
|
month: Month,
|
||||||
|
bar_hover: UseStateHandle<Option<usize>>,
|
||||||
|
mut views: &[PageViewsMonth],
|
||||||
|
) -> Html {
|
||||||
|
let ndays = time::util::days_in_year_month(year, month) as i32;
|
||||||
|
|
||||||
|
let mut labels = Vec::new();
|
||||||
|
let mut padded = Vec::new();
|
||||||
|
for i in 0..ndays {
|
||||||
|
labels.push((i + 1).to_string());
|
||||||
|
|
||||||
|
if let Some(view) = views.first() {
|
||||||
|
if view.day == i + 1 {
|
||||||
|
padded.push(view.count as f32);
|
||||||
|
views = &views[1..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
padded.push(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug_assert_eq!(padded.len(), ndays as usize);
|
||||||
|
|
||||||
|
let onhover = {
|
||||||
|
let bar_hover = bar_hover.clone();
|
||||||
|
Callback::from(move |index| bar_hover.set(Some(index)))
|
||||||
|
};
|
||||||
|
|
||||||
|
let onleave = {
|
||||||
|
let bar_hover = bar_hover.clone();
|
||||||
|
Callback::from(move |_| bar_hover.set(None))
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<BarChart labels={labels} data={padded} {onhover} {onleave} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn month_select_options(active: Month) -> Html {
|
||||||
|
let mut month = Month::January;
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
|
||||||
|
for _ in 0..12 {
|
||||||
|
nodes.push(html! {
|
||||||
|
<option
|
||||||
|
value={month.to_string()}
|
||||||
|
selected={month == active}>
|
||||||
|
{month.to_string()}
|
||||||
|
</option>
|
||||||
|
});
|
||||||
|
|
||||||
|
month = month.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.into_iter().collect::<Html>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(DashboardContent)]
|
||||||
|
fn dashboard_content() -> Html {
|
||||||
|
let now = OffsetDateTime::now_local().expect("local time");
|
||||||
|
let host = get_analytics_host();
|
||||||
|
let token = use_context::<AuthTokenContext>().expect("AuthTokenContext to be provided");
|
||||||
|
|
||||||
|
let year = use_state(|| now.year());
|
||||||
|
let month = use_state(|| now.month());
|
||||||
|
let month_result = use_state(PageViewsMonthResult::default);
|
||||||
|
let bar_hover = use_state(|| None::<usize>);
|
||||||
|
|
||||||
|
let load_dashboard: UseAsyncHandle<(), &'static str> = {
|
||||||
|
let year = year.clone();
|
||||||
|
let month = month.clone();
|
||||||
|
let month_result = month_result.clone();
|
||||||
|
|
||||||
|
use_async_with_options(
|
||||||
|
async move {
|
||||||
|
let mut result = get_month_views(&host, &token.0, *year, (*month) as i32).await?;
|
||||||
|
|
||||||
|
result.paths.sort_by(|a, b| {
|
||||||
|
let a = a.count + a.beacons;
|
||||||
|
(b.count + b.beacons).cmp(&a)
|
||||||
|
});
|
||||||
|
|
||||||
|
month_result.set(result);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
UseAsyncOptions::enable_auto(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let onrefresh = {
|
||||||
|
let load_dashboard = load_dashboard.clone();
|
||||||
|
Callback::from(move |_| load_dashboard.run())
|
||||||
|
};
|
||||||
|
|
||||||
|
let year_change = {
|
||||||
|
let year = year.clone();
|
||||||
|
let load_dashboard = load_dashboard.clone();
|
||||||
|
|
||||||
|
Callback::from(move |event: yew::Event| {
|
||||||
|
let input = event
|
||||||
|
.target()
|
||||||
|
.unwrap()
|
||||||
|
.dyn_into::<web_sys::HtmlInputElement>()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
year.set(input.value().parse().unwrap_or(now.year()));
|
||||||
|
load_dashboard.run();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let month_change = {
|
||||||
|
let month = month.clone();
|
||||||
|
let load_dashboard = load_dashboard.clone();
|
||||||
|
|
||||||
|
Callback::from(move |event: yew::Event| {
|
||||||
|
let input = event
|
||||||
|
.target()
|
||||||
|
.unwrap()
|
||||||
|
.dyn_into::<web_sys::HtmlSelectElement>()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
month.set(input.value().parse().unwrap_or(now.month()));
|
||||||
|
load_dashboard.run();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="container mx-auto flex flex-col gap-4 my-10">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex flex-row items-center gap-2">
|
||||||
|
<h1 class="text-2xl font-semibold">{"Analytics"}</h1>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="w-[8rem]"
|
||||||
|
onchange={year_change}
|
||||||
|
value={(*year).to_string()} />
|
||||||
|
<select onchange={month_change}>
|
||||||
|
{month_select_options(*month)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="button" onclick={onrefresh}>
|
||||||
|
<Icon icon_id={IconId::LucideRefreshCw} />
|
||||||
|
{"Refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid xl:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="border border-primary rounded-md pr-4">
|
||||||
|
{month_view_chart(*year, *month, bar_hover.clone(), &month_result.views)}
|
||||||
|
</div>
|
||||||
|
<div class="h-4 text-sm mt-2">
|
||||||
|
if let Some(index) = *bar_hover {
|
||||||
|
if let Some(day) = month_result.views.iter().find(|view| view.day == (index as i32) + 1) {
|
||||||
|
{ format!(
|
||||||
|
"{:04}-{:02}-{:02}: {} views, {} beacons, {:.2}s avg. duration, {:.2}% avg. scroll",
|
||||||
|
*year,
|
||||||
|
*month as u8,
|
||||||
|
day.day,
|
||||||
|
day.count,
|
||||||
|
day.total_beacon,
|
||||||
|
if day.total_beacon != 0 {
|
||||||
|
day.total_scroll / day.total_beacon as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
if day.total_beacon != 0 {
|
||||||
|
day.total_scroll / day.total_beacon as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="table">
|
||||||
|
<table class="table tight">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="left">{"Path"}</th>
|
||||||
|
<th class="right">{"View Count"}</th>
|
||||||
|
<th class="right">{"Total Beacons"}</th>
|
||||||
|
<th class="right">{"Avg. Duration"}</th>
|
||||||
|
<th class="right">{"Avg. Scroll"}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{for month_result.paths.iter().map(|path| html! {
|
||||||
|
<tr>
|
||||||
|
<td><code>{ path.path.clone() }</code></td>
|
||||||
|
<td class="right">{ path.count.to_string() }</td>
|
||||||
|
<td class="right">{ path.beacons.to_string() }</td>
|
||||||
|
<td class="right">
|
||||||
|
{ format!("{:.0} s", path.avg_duration) }
|
||||||
|
</td>
|
||||||
|
<td class="right">
|
||||||
|
{ format!("{:.0}%", path.avg_scroll) }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
})}
|
||||||
|
<tr class="dark:bg-neutral-800">
|
||||||
|
<td class="font-bold">{"Total"}</td>
|
||||||
|
<td class="font-bold right">
|
||||||
|
{ month_result.site.count.to_string() }
|
||||||
|
</td>
|
||||||
|
<td class="font-bold right">
|
||||||
|
{ month_result.site.beacons.to_string() }
|
||||||
|
</td>
|
||||||
|
<td class="font-bold right">
|
||||||
|
{ format!("{:.0} s", month_result.site.avg_duration) }
|
||||||
|
</td>
|
||||||
|
<td class="font-bold right">
|
||||||
|
{ format!("{:.0}%", month_result.site.avg_scroll) }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Page)]
|
||||||
|
pub fn page() -> Html {
|
||||||
|
html! {
|
||||||
|
<WithAuth>
|
||||||
|
<DashboardContent />
|
||||||
|
</WithAuth>
|
||||||
|
}
|
||||||
|
}
|
144
style/main.css
144
style/main.css
@ -6,6 +6,35 @@
|
|||||||
@apply dark:bg-zinc-900 dark:text-neutral-200;
|
@apply dark:bg-zinc-900 dark:text-neutral-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
@apply inline-flex items-center justify-center border border-transparent;
|
||||||
|
@apply px-4 py-2;
|
||||||
|
@apply rounded-md shadow-sm text-sm text-gray-300 bg-primary;
|
||||||
|
@apply disabled:bg-slate-300 dark:disabled:bg-gray-600 dark:disabled:text-gray-400;
|
||||||
|
@apply hover:text-white dark:disabled:hover:text-gray-400;
|
||||||
|
@apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400;
|
||||||
|
@apply transition-colors;
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
@apply mr-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="password"] {
|
||||||
|
@apply border-primary rounded-md;
|
||||||
|
@apply text-neutral-800 placeholder:text-neutral-300;
|
||||||
|
@apply dark:bg-zinc-800 dark:text-neutral-200 dark:placeholder:text-neutral-700;
|
||||||
|
@apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
@apply text-neutral-500 dark:text-neutral-500;
|
||||||
|
@apply dark:bg-zinc-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.markdown {
|
.markdown {
|
||||||
@apply flex flex-col;
|
@apply flex flex-col;
|
||||||
@apply font-text text-xl print:text-base;
|
@apply font-text text-xl print:text-base;
|
||||||
@ -239,60 +268,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.table {
|
|
||||||
@apply flex overflow-x-scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
@apply min-w-full mb-8 border-collapse table-auto;
|
|
||||||
|
|
||||||
thead {
|
|
||||||
@apply bg-transparent dark:bg-neutral-800;
|
|
||||||
@apply dark:text-white;
|
|
||||||
@apply border-b border-neutral-500;
|
|
||||||
|
|
||||||
tr {
|
|
||||||
th {
|
|
||||||
@apply px-6 py-4;
|
|
||||||
|
|
||||||
&.left {
|
|
||||||
@apply text-left;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.right {
|
|
||||||
@apply text-right;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.center {
|
|
||||||
@apply text-center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody {
|
|
||||||
tr {
|
|
||||||
@apply border-b border-neutral-400 dark:border-neutral-600;
|
|
||||||
|
|
||||||
td {
|
|
||||||
@apply whitespace-nowrap px-6 py-4;
|
|
||||||
|
|
||||||
&.left {
|
|
||||||
@apply text-left;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.right {
|
|
||||||
@apply text-right;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.center {
|
|
||||||
@apply text-center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.callout {
|
.callout {
|
||||||
@apply flex flex-col gap-2 rounded-md p-4 text-base mb-8;
|
@apply flex flex-col gap-2 rounded-md p-4 text-base mb-8;
|
||||||
@apply print:border-2 print:p-2;
|
@apply print:border-2 print:p-2;
|
||||||
@ -374,6 +349,67 @@
|
|||||||
@apply text-violet-600 dark:text-violet-400;
|
@apply text-violet-600 dark:text-violet-400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} /* .callout */
|
||||||
|
} /* .markdown */
|
||||||
|
|
||||||
|
div.table {
|
||||||
|
@apply flex overflow-x-scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
@apply min-w-full mb-8 border-collapse table-auto;
|
||||||
|
|
||||||
|
thead {
|
||||||
|
@apply bg-transparent dark:bg-neutral-800;
|
||||||
|
@apply dark:text-white;
|
||||||
|
@apply border-b border-neutral-500;
|
||||||
|
|
||||||
|
tr {
|
||||||
|
th {
|
||||||
|
@apply px-6 py-4;
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
@apply text-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
@apply text-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.center {
|
||||||
|
@apply text-center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} /* thead */
|
||||||
|
|
||||||
|
tbody {
|
||||||
|
tr {
|
||||||
|
@apply border-b border-neutral-400 dark:border-neutral-600;
|
||||||
|
|
||||||
|
td {
|
||||||
|
@apply whitespace-nowrap px-6 py-4;
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
@apply text-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
@apply text-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.center {
|
||||||
|
@apply text-center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} /* tbody */
|
||||||
|
|
||||||
|
&.tight {
|
||||||
|
thead tr th,
|
||||||
|
tbody tr td {
|
||||||
|
@apply px-3 py-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} /* table */
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user