Add cli & lib crates

This commit is contained in:
2021-04-22 15:13:28 +02:00
parent e39cffa3f6
commit 3a9ecc398a
53 changed files with 5065 additions and 99 deletions

63
server/src/auth.rs Normal file
View File

@ -0,0 +1,63 @@
use crate::routes::pages;
use rocket::{
catch,
form::{Form, FromForm},
http::{Cookie, CookieJar, Status},
post,
request::{FromRequest, Outcome, Request},
response::Redirect,
uri, State,
};
use rocket_contrib::templates::Template;
pub struct MasterPassword(String);
impl From<String> for MasterPassword {
fn from(pass: String) -> Self {
Self(pass)
}
}
const AUTH_COOKIE_KEY: &str = "authorized";
const AUTH_COOKIE_VAL: &str = "true";
#[derive(Debug)]
pub struct Authorized;
#[derive(Debug)]
pub struct Unauthorized;
#[rocket::async_trait]
impl<'a> FromRequest<'a> for Authorized {
type Error = Unauthorized;
async fn from_request(request: &'a Request<'_>) -> Outcome<Self, Self::Error> {
let cookies = request.cookies();
match cookies.get_private(AUTH_COOKIE_KEY) {
Some(cookie) if cookie.value() == AUTH_COOKIE_VAL => Outcome::Success(Authorized),
_ => Outcome::Failure((Status::Unauthorized, Unauthorized)),
}
}
}
#[catch(401)]
pub fn login_page(_req: &Request) -> Template {
Template::render("login", &())
}
#[derive(FromForm)]
pub struct Login {
password: String,
}
#[post("/login", data = "<login>")]
pub fn login(
cookies: &CookieJar,
login: Form<Login>,
master_pass: State<MasterPassword>,
) -> Redirect {
if login.password == master_pass.0 {
cookies.add_private(Cookie::new(AUTH_COOKIE_KEY, AUTH_COOKIE_VAL));
}
Redirect::to(uri!(pages::index))
}

View File

@ -0,0 +1,43 @@
pub mod v1_to_v2;
use crate::database::unversioned::global::schema_version;
use duplicate::duplicate;
use sled::Db;
pub fn migrate(
db: &mut Db,
from: schema_version::V,
to: schema_version::V,
) -> Result<(), MigrationError> {
for current in from..to {
let next = current + 1;
println!("Will migrate from {} to {}", current, next);
match (current, next) {
(1, 2) => v1_to_v2::migrate(db)?,
_ => panic!(
"No valid migration from version {} to version {}",
current, next
),
}
}
Ok(())
}
#[derive(Debug)]
pub enum MigrationError {
Serde(bincode::Error),
Sled(sled::Error),
}
#[duplicate(
Variant Error;
[ Sled ] [ sled::Error ];
[ Serde ] [ bincode::Error ];
)]
impl From<Error> for MigrationError {
fn from(e: Error) -> MigrationError {
MigrationError::Variant(e)
}
}

View File

@ -0,0 +1,85 @@
use super::MigrationError;
use crate::database::v1;
use crate::database::v2;
use bincode::{deserialize, serialize};
use chrono::{DateTime, Utc};
use sled::Db;
/// Migrate from database version 1 to version 2
pub fn migrate(db: &mut Db) -> Result<(), MigrationError> {
{
// Migrate sessions
// Open old & new trees
let v1_past_sessions = db.open_tree(v1::trees::past_sessions::NAME)?;
let v2_sessions = db.open_tree(v2::trees::session::NAME)?;
// Iterate over old tree
for r in v1_past_sessions.iter() {
let (k, v) = r?;
// Deserialize old value
let v1::trees::past_sessions::V {
category,
started,
ended,
} = deserialize(&v)?;
// Convert to new value
let v2_value = v2::trees::session::V {
category,
started: DateTime::<Utc>::from_utc(started, Utc).into(),
ended: DateTime::<Utc>::from_utc(ended, Utc).into(),
deleted: false,
};
// Insert into new tree
v2_sessions.insert(k, serialize(&v2_value)?)?;
}
// Remove old tree
v1_past_sessions.clear()?;
}
{
// Migrate categories
// Open the old tree, and a TMP tree since the old tree name hasn't changed
let categories = db.open_tree(v1::trees::categories::NAME)?;
let v2_categories_tmp = db.open_tree("TEMP")?;
// Iterate over old tree
for r in categories.iter() {
let (k, v) = r?;
// Deserialize old value
let v1::trees::categories::V {
name,
color,
started,
} = deserialize(&v)?;
// Convert to new value
let v2_value = v2::trees::category::V {
name,
description: None,
color,
started: started.map(|ndt| DateTime::<Utc>::from_utc(ndt, Utc).into()),
parent: None,
deleted: false,
};
// Insert into temporary tree
v2_categories_tmp.insert(k, serialize(&v2_value)?)?;
}
// Copy data from temp-tree back into old tree
categories.clear()?;
for r in v2_categories_tmp.iter() {
let (k, v) = r?;
categories.insert(k, v)?;
}
v2_categories_tmp.clear()?;
}
Ok(())
}

View File

@ -0,0 +1,9 @@
pub mod unversioned;
pub mod util;
pub mod v1;
pub use stl_lib::v2;
pub mod migrations;
pub const SCHEMA_VERSION: unversioned::global::schema_version::V = 2;
pub use v2 as latest;

View File

@ -0,0 +1,6 @@
pub mod global {
pub mod schema_version {
pub const K: &str = "SCHEMA_VERSION";
pub type V = u32;
}
}

View File

@ -0,0 +1,25 @@
use crate::database::latest::trees::category;
use crate::status_json::StatusJson;
use bincode::{deserialize, serialize};
use std::collections::HashMap;
pub fn get_category(
tree: &sled::Tree,
key: &category::K,
) -> Result<Option<category::V>, StatusJson> {
Ok(match tree.get(serialize(key)?)? {
Some(raw) => Some(deserialize(&raw)?),
None => None,
})
}
pub fn get_all_categories(
tree: &sled::Tree,
) -> Result<HashMap<category::K, category::V>, StatusJson> {
Ok(tree
.iter()
.map(|result| {
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??)
}

View File

@ -0,0 +1 @@
pub mod category;

50
server/src/database/v1.rs Normal file
View File

@ -0,0 +1,50 @@
#![allow(dead_code)] // old schema, not used anymore
pub(self) use chrono::NaiveDateTime;
pub(self) use serde_derive::{Deserialize, Serialize};
pub(self) use uuid::Uuid;
/// Stuff in the default namespace
pub mod global {}
pub mod trees {
pub mod categories {
use super::super::*;
pub const NAME: &str = "CATEGORIES";
pub type K = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct V {
/// The name of the category
pub name: String,
/// The HTML color of the category in the rendered view
pub color: String,
/// If the session is not active, this will be None
pub started: Option<NaiveDateTime>,
}
}
pub mod past_sessions {
use super::super::*;
pub const NAME: &str = "PAST_SESSIONS";
pub type K = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct V {
/// The UUID of the category to which this session belongs
pub category: trees::categories::K,
/// The time when this session was started
pub started: NaiveDateTime,
/// The time when this session was ended
pub ended: NaiveDateTime,
}
}
}

63
server/src/database/v2.rs Normal file
View File

@ -0,0 +1,63 @@
pub(self) use chrono::{DateTime, Local};
pub(self) use serde_derive::{Deserialize, Serialize};
pub(self) use uuid::Uuid;
/// Stuff in the default namespace
pub mod global {}
pub mod trees {
pub mod categories {
use super::super::*;
pub const NAME: &str = "CATEGORIES";
pub type K = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct V {
/// The name of the category
pub name: String,
/// The description of the category
pub description: Option<String>,
/// The HTML color of the category in the rendered view
pub color: String,
/// If the session is not active, this will be None
pub started: Option<DateTime<Local>>,
/// The parent category of this category
/// If none, the category has no parent
pub parent: Option<K>,
// FIXME: this field is currently not used
/// Whether the item has been "deleted", e.g. it shoudn't be shown in the view
pub deleted: bool,
}
}
pub mod sessions {
use super::super::*;
pub const NAME: &str = "SESSIONS";
pub type K = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct V {
/// The UUID of the category to which this session belongs
pub category: trees::categories::K,
/// The time when this session was started
pub started: DateTime<Local>,
/// The time when this session was ended
pub ended: DateTime<Local>,
// FIXME: this field is currently not used
/// Whether the item has been "deleted", e.g. it shoudn't be shown in the view
pub deleted: bool,
}
}
}

View File

@ -0,0 +1,28 @@
use chrono::{DateTime, Local};
use handlebars::handlebars_helper;
use rocket_contrib::templates::Engines;
pub fn register_helpers(engines: &mut Engines) {
handlebars_helper!(pretty_datetime: |dt: str| {
let dt: DateTime<Local> = dt.parse().unwrap();
format!("{}", dt.format("%Y-%m-%d %H:%M"))
});
engines
.handlebars
.register_helper("pretty_datetime", Box::new(pretty_datetime));
handlebars_helper!(pretty_seconds: |secs: u64| {
let hours = secs / 60 / 60;
let minutes = secs / 60 % 60;
if hours == 0 {
format!("{}m", minutes)
} else if minutes == 0 {
format!("{}h", hours)
} else {
format!("{}h {}m", hours, minutes)
}
});
engines
.handlebars
.register_helper("pretty_seconds", Box::new(pretty_seconds));
}

88
server/src/main.rs Normal file
View File

@ -0,0 +1,88 @@
mod auth;
mod database;
mod handlebars_util;
mod routes;
mod status_json;
mod util;
use crate::auth::MasterPassword;
use crate::database::migrations::migrate;
use crate::database::unversioned::global::schema_version;
use crate::database::SCHEMA_VERSION;
use crate::util::EventNotifier;
use bincode::{deserialize, serialize};
use dotenv::dotenv;
use rocket_contrib::serve::StaticFiles;
use rocket_contrib::templates::Template;
use std::{env, io};
#[rocket::main]
async fn main() -> io::Result<()> {
dotenv().ok();
let db_path = env::var("DB_PATH").expect("DB_PATH not set");
let master_pass: MasterPassword = env::var("STL_PASSWORD")
.unwrap_or_else(|_| {
eprintln!(r#"STL_PASSWORD not set, defaulting to "password""#);
"password".into()
})
.into();
let mut sled = sled::open(db_path)?;
match sled.insert(
serialize(schema_version::K).unwrap(),
serialize(&SCHEMA_VERSION).unwrap(),
)? {
Some(prev_schema_version) => {
let prev_schema_version: schema_version::V = deserialize(&prev_schema_version).unwrap();
println!(
"Schema version: {}, previously: {}",
SCHEMA_VERSION, prev_schema_version
);
migrate(&mut sled, prev_schema_version, SCHEMA_VERSION).expect("Migration failed")
}
None => {
println!("Schema version: {}, previously: None", SCHEMA_VERSION);
}
}
let rocket = rocket::build()
.attach(Template::custom(|engines| {
handlebars_util::register_helpers(engines)
}))
.manage(sled)
.manage(master_pass)
.manage(EventNotifier::new())
.mount("/static", StaticFiles::from("static"))
.mount(
"/",
rocket::routes![
routes::pages::index,
routes::pages::history,
routes::pages::session_edit,
routes::pages::stats::single_stats,
routes::pages::stats::all_stats,
],
)
.mount(
"/api",
rocket::routes![
routes::api::edit_session,
routes::api::get_sessions,
routes::api::create_category,
routes::api::start_session,
routes::api::end_session,
routes::api::bump_session,
routes::api::delete_session,
routes::api::wait_for_event,
auth::login,
],
)
.register("/", rocket::catchers![auth::login_page,]);
rocket.launch().await.expect("rocket failed to launch");
Ok(())
}

267
server/src/routes/api.rs Normal file
View File

@ -0,0 +1,267 @@
use crate::auth::Authorized;
use crate::database::latest::trees::{category, session};
use crate::database::util::category::get_all_categories;
use crate::routes::pages;
use crate::status_json::StatusJson;
use crate::util::EventNotifier;
use bincode::{deserialize, serialize};
use chrono::{Duration, Local, NaiveDateTime, TimeZone};
use rocket::form::{Form, FromForm};
use rocket::http::Status;
use rocket::response::Redirect;
use rocket::{get, post, uri, State};
use rocket_contrib::json::Json;
use rocket_contrib::uuid::Uuid;
use sled::Transactional;
use std::collections::HashMap;
use stl_lib::wfe::WaitForEvent;
#[get("/sessions")]
pub fn get_sessions(
_auth: Authorized,
db: State<'_, sled::Db>,
) -> Result<Json<HashMap<category::K, category::V>>, StatusJson> {
let categories_tree = db.open_tree(category::NAME)?;
Ok(Json(get_all_categories(&categories_tree)?))
}
#[derive(FromForm)]
pub struct NewCategory {
name: String,
color: String,
}
#[post("/create_category", data = "<category>")]
pub fn create_category(
_auth: Authorized,
category: Form<NewCategory>,
db: State<'_, sled::Db>,
) -> Result<StatusJson, StatusJson> {
let category = category.into_inner();
let categories_tree = db.open_tree(category::NAME)?;
categories_tree.insert(
serialize(&uuid::Uuid::new_v4())?,
serialize(&category::V {
name: category.name,
description: None,
color: category.color,
started: None,
parent: None,
deleted: false,
})?,
)?;
Ok(Status::Ok.into())
}
#[post("/category/<category_uuid>/bump_session/minutes/<minutes>")]
pub fn bump_session(
_auth: Authorized,
category_uuid: Uuid,
minutes: i64,
db: State<'_, sled::Db>,
) -> Result<Redirect, StatusJson> {
let duration = Duration::minutes(minutes);
let category_uuid_s = sled::IVec::from(serialize(&category_uuid.into_inner())?);
let categories_tree = db.open_tree(category::NAME)?;
Ok((&categories_tree).transaction(|tx_categories| {
match tx_categories.get(&category_uuid_s)? {
None => return Ok(Err(Status::NotFound.into())),
Some(data) => {
let mut category: category::V = deserialize(&data).unwrap();
match category.started.as_mut() {
Some(started) => {
if let Some(new_started) = started.checked_sub_signed(duration) {
*started = new_started;
tx_categories
.insert(&category_uuid_s, serialize(&category).unwrap())?;
} else {
return Ok(Err(StatusJson::new(
Status::BadRequest,
"Duration subtract resulted in overflow",
)));
}
Ok(Ok(Redirect::to(uri!(pages::index))))
}
None => {
return Ok(Err(StatusJson::new(
Status::BadRequest,
"No active session",
)))
}
}
}
}
})??)
}
#[post("/category/<category_uuid>/start_session")]
pub fn start_session(
_auth: Authorized,
category_uuid: Uuid,
event_notifier: State<'_, EventNotifier>,
db: State<'_, sled::Db>,
) -> Result<StatusJson, StatusJson> {
toggle_category_session(category_uuid, true, event_notifier, db)
}
#[post("/category/<category_uuid>/end_session")]
pub fn end_session(
_auth: Authorized,
category_uuid: Uuid,
event_notifier: State<'_, EventNotifier>,
db: State<'_, sled::Db>,
) -> Result<StatusJson, StatusJson> {
toggle_category_session(category_uuid, false, event_notifier, db)
}
pub fn toggle_category_session(
category_uuid: Uuid,
set_active: bool,
event_notifier: State<'_, EventNotifier>,
db: State<'_, sled::Db>,
) -> Result<StatusJson, StatusJson> {
let category_uuid_s = sled::IVec::from(serialize(&category_uuid.into_inner())?);
let categories_tree = db.open_tree(category::NAME)?;
let sessions_tree = db.open_tree(session::NAME)?;
Ok(
(&categories_tree, &sessions_tree).transaction(|(tx_categories, tx_sessions)| {
match tx_categories.get(&category_uuid_s)? {
None => return Ok(Err(Status::NotFound)),
Some(data) => {
let mut category: category::V = deserialize(&data).unwrap();
let now = Local::now();
match (set_active, category.started.take()) {
(false, Some(started)) => {
// only save sessions longer than 5 minutes
let duration = now - started;
if duration > Duration::minutes(5) {
let session_uuid = serialize(&uuid::Uuid::new_v4()).unwrap();
let session = session::V {
category: category_uuid.into_inner(),
started,
ended: now,
deleted: category.deleted,
};
tx_sessions.insert(session_uuid, serialize(&session).unwrap())?;
}
}
(true, None) => {
category.started = Some(now);
}
_ => {
// Category is already in the correct state
return Ok(Ok(Status::Ok.into()));
}
}
tx_categories.insert(&category_uuid_s, serialize(&category).unwrap())?;
event_notifier.notify_event();
}
}
Ok(Ok(Status::Ok.into()))
})??,
)
}
#[derive(Debug, FromForm)]
pub struct EditSession {
category: Uuid,
started: String,
ended: String,
deleted: bool,
}
#[post("/session/<session_uuid>/edit", data = "<session>")]
pub fn edit_session(
_auth: Authorized,
session_uuid: Uuid,
session: Form<EditSession>,
db: State<'_, sled::Db>,
) -> Result<Redirect, StatusJson> {
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
let session = session::V {
category: session.category.into_inner(),
started: Local
.from_local_datetime(&NaiveDateTime::parse_from_str(
&session.started,
"%Y-%m-%d %H:%M",
)?)
.unwrap(),
ended: Local
.from_local_datetime(&NaiveDateTime::parse_from_str(
&session.ended,
"%Y-%m-%d %H:%M",
)?)
.unwrap(),
deleted: session.deleted,
};
if session.started >= session.ended {
return Err(StatusJson::new(
Status::BadRequest,
"started must be earlier than ended",
));
}
db.open_tree(session::NAME)?
.insert(session_uuid_s, serialize(&session)?)?;
// FIXME: Uuid does not implement FromUriParam for some reason... File an issue?
//Ok(Redirect::to(uri!(pages::session_edit: session_uuid)))
Ok(Redirect::to(format!("/session/{}/edit", session_uuid)))
}
#[post("/session/<session_uuid>/delete")]
pub fn delete_session(
_auth: Authorized,
session_uuid: Uuid,
db: State<'_, sled::Db>,
) -> Result<Redirect, StatusJson> {
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
let sessions_tree = db.open_tree(session::NAME)?;
match sessions_tree.remove(session_uuid_s)? {
Some(_) => Ok(Redirect::to(uri!(pages::history))),
None => Err(Status::NotFound.into()),
}
// TODO: mark as deleted instead of removing
// Ok(sessions_tree.transaction(|tx_sessions| {
// match tx_sessions.get(&session_uuid_s)? {
// None => return Ok(Err(Status::NotFound)),
// Some(data) => {
// let mut session: session::V = deserialize(&data).unwrap();
// }
// }
// Ok(Ok(Redirect::to(uri!(pages::history))))
// })??)
}
#[get("/wait_for_event?<timeout>")]
pub async fn wait_for_event(
_auth: Authorized,
timeout: Option<u64>,
event_notifier: State<'_, EventNotifier>,
) -> Json<WaitForEvent> {
use std::time::Duration;
match tokio::time::timeout(
Duration::from_secs(timeout.unwrap_or(30)),
event_notifier.wait_for_event(),
)
.await
{
Ok(_) => Json(WaitForEvent { timeout: false }),
Err(_) => Json(WaitForEvent { timeout: true }),
}
}

2
server/src/routes/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod api;
pub mod pages;

113
server/src/routes/pages.rs Normal file
View File

@ -0,0 +1,113 @@
pub mod stats;
use crate::auth::Authorized;
use crate::database::latest::trees::{category, session};
use crate::status_json::StatusJson;
use bincode::{deserialize, serialize};
use rocket::http::Status;
use rocket::{get, State};
use rocket_contrib::templates::Template;
use rocket_contrib::uuid::Uuid;
use serde_derive::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
#[get("/")]
pub fn index(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
#[derive(Debug, Serialize, Deserialize)]
struct TemplateContext {
categories: Vec<(category::K, category::V)>,
}
let categories_tree = db.open_tree(category::NAME)?;
let context = TemplateContext {
categories: categories_tree
.iter()
.map(|result| {
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??,
};
Ok(Template::render("index", &context))
}
#[get("/session/<session_uuid>/edit")]
pub fn session_edit(
_auth: Authorized,
session_uuid: Uuid,
db: State<'_, sled::Db>,
) -> Result<Template, StatusJson> {
#[derive(Debug, Serialize, Deserialize)]
struct SessionPageContext {
session: session::V,
session_id: uuid::Uuid,
}
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
let sessions_tree = db.open_tree(session::NAME)?;
match sessions_tree.get(session_uuid_s)? {
None => Err(Status::NotFound)?,
Some(data) => {
let context = SessionPageContext {
session: deserialize(&data).unwrap(),
session_id: session_uuid.into_inner(),
};
Ok(Template::render("edit_session", &context))
}
}
}
#[get("/history")]
pub fn history(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
#[derive(Debug, Serialize, Deserialize)]
struct HistoryEntryContext {
category: category::V,
session: session::V,
session_id: uuid::Uuid,
duration: Duration,
}
#[derive(Debug, Serialize, Deserialize)]
struct TemplateContext {
entries: Vec<HistoryEntryContext>,
}
let categories_tree = db.open_tree(category::NAME)?;
let sessions_tree = db.open_tree(session::NAME)?;
let categories: HashMap<category::K, category::V> = categories_tree
.iter()
.map(|result| {
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??;
let sessions: HashMap<session::K, session::V> = sessions_tree
.iter()
.map(|result| {
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??;
let mut context = TemplateContext {
entries: sessions
.into_iter()
.map(|(session_id, session)| HistoryEntryContext {
duration: (session.ended - session.started).to_std().unwrap(),
category: categories.get(&session.category).unwrap().clone(),
session,
session_id,
})
.collect(),
};
// Newest entries first
context.entries.sort_by_key(|entry| entry.session.started);
context.entries.reverse();
Ok(Template::render("history", &context))
}

View File

@ -0,0 +1,299 @@
use crate::auth::Authorized;
use crate::database::latest::trees::{category, session};
use crate::database::util::category::get_category;
use crate::status_json::StatusJson;
use bincode::deserialize;
use chrono::{DateTime, Local, Timelike};
use rocket::http::Status;
use rocket::{get, State};
use rocket_contrib::templates::Template;
use rocket_contrib::uuid::Uuid;
use serde_derive::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, Serialize, Deserialize)]
struct CategoryStatsContext {
category_id: category::K,
category: category::V,
last_session_start: Option<DateTime<Local>>,
secs_last_session: u64,
secs_last_week: u64,
secs_last_month: u64,
bars_max: f64,
bars: Vec<(u32, f64, f64)>,
}
fn sum_sessions<'a>(iter: impl IntoIterator<Item = &'a session::V>) -> u64 {
iter.into_iter()
.map(|session| session.ended - session.started)
.map(|duration| duration.num_seconds() as u64)
.sum()
}
#[get("/stats/<category_uuid>")]
pub fn single_stats(
_auth: Authorized,
category_uuid: Uuid,
db: State<'_, sled::Db>,
) -> Result<Template, StatusJson> {
let categories_tree = db.open_tree(category::NAME)?;
let sessions_tree = db.open_tree(session::NAME)?;
let category: category::V =
get_category(&categories_tree, &category_uuid)?.ok_or(Status::NotFound)?;
let sessions: HashMap<session::K, session::V> = sessions_tree
.iter()
.map(|result| {
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??;
let my_sessions = sessions
.values()
.filter(|session| session.category == *category_uuid);
let last_session = my_sessions.clone().max_by_key(|session| &session.started);
let secs_last_session = sum_sessions(last_session);
let now = Local::now();
let secs_last_week = sum_sessions(
my_sessions
.clone()
.filter(|session| (now - session.started) <= chrono::Duration::days(7)),
);
let secs_last_month = sum_sessions(
my_sessions
.clone()
.filter(|session| (now - session.started) <= chrono::Duration::days(30)),
);
let mut stats_per_hour = compute_percentage_per_hour(my_sessions);
let biggest_hour = *stats_per_hour
.values()
.max_by(|f1, f2| match () {
_ if f1 == f2 => Ordering::Equal,
_ if f1 > f2 => Ordering::Greater,
_ => Ordering::Less,
})
.unwrap_or(&1.0);
let context = CategoryStatsContext {
category_id: *category_uuid,
category,
last_session_start: last_session.map(|session| session.started),
secs_last_session,
secs_last_week,
secs_last_month,
bars_max: biggest_hour,
bars: (0..24)
.map(|hour| {
let percentage = *stats_per_hour.entry(hour).or_default();
(hour, percentage, biggest_hour - percentage)
})
.collect(),
};
Ok(Template::render("stats_single", &context))
}
#[get("/stats")]
pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
#[derive(Debug, Serialize, Deserialize)]
struct StatsContext {
categories_stats: Vec<CategoryStatsContext>,
}
let now = Local::now();
let categories_tree = db.open_tree(category::NAME)?;
let sessions_tree = db.open_tree(session::NAME)?;
let categories: HashMap<category::K, category::V> = categories_tree
.iter()
.map(|result| {
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??;
let sessions: HashMap<session::K, session::V> = sessions_tree
.iter()
.map(|result| {
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??;
let mut categories_stats: Vec<_> = categories
.into_iter()
.map(|(category_id, category)| {
let my_sessions = sessions
.values()
.filter(|session| session.category == category_id);
let last_session = my_sessions.clone().max_by_key(|session| &session.started);
let secs_last_session = sum_sessions(last_session);
let secs_last_week = sum_sessions(
my_sessions
.clone()
.filter(|session| (now - session.started) <= chrono::Duration::days(7)),
);
let secs_last_month = sum_sessions(
my_sessions
.clone()
.filter(|session| (now - session.started) <= chrono::Duration::days(30)),
);
let mut stats_per_hour = compute_percentage_per_hour(my_sessions);
let biggest_hour = *stats_per_hour
.values()
.max_by(|f1, f2| match () {
_ if f1 == f2 => Ordering::Equal,
_ if f1 > f2 => Ordering::Greater,
_ => Ordering::Less,
})
.unwrap_or(&1.0);
CategoryStatsContext {
category_id,
category,
last_session_start: last_session.map(|session| session.started),
secs_last_session,
secs_last_week,
secs_last_month,
bars_max: biggest_hour,
bars: (0..24)
.map(|hour| {
let percentage = *stats_per_hour.entry(hour).or_default();
(hour, percentage, biggest_hour - percentage)
})
.collect(),
}
})
.collect();
categories_stats.sort_by(|a, b| a.category.name.cmp(&b.category.name));
let context = StatsContext { categories_stats };
Ok(Template::render("stats_all", &context))
}
fn compute_percentage_per_hour<'a, I>(sessions: I) -> BTreeMap<u32, f64>
where
I: Iterator<Item = &'a session::V>,
{
let mut stats_per_hour = BTreeMap::new();
for session in sessions {
let an_hour = chrono::Duration::minutes(60);
let hour_of = |time: DateTime<Local>| {
time.with_minute(0)
.and_then(|time| time.with_second(0))
.and_then(|time| time.with_nanosecond(0))
.unwrap()
};
let next_hour_of =
|time: DateTime<Local>| hour_of(time).checked_add_signed(an_hour).unwrap();
let mut add_hour_stats =
|time: DateTime<Local>, hours| *stats_per_hour.entry(time.hour()).or_default() += hours;
let mut hour = hour_of(session.started);
loop {
if hour_of(session.started) == hour {
let minutes_started = (session.started - hour).num_minutes() as u32;
if hour_of(session.ended) == hour {
let minutes_ended = (session.ended - hour).num_minutes() as u32;
let minutes_last_hour = minutes_ended - minutes_started;
add_hour_stats(hour, minutes_last_hour as f64);
break;
} else {
let minutes_first_hour = 60 - minutes_started;
add_hour_stats(hour, minutes_first_hour as f64);
}
} else if hour_of(session.ended) == hour {
let minutes_last_hour = (session.ended - hour).num_minutes() as u32;
add_hour_stats(hour, minutes_last_hour as f64);
break;
} else {
add_hour_stats(hour, 60.0);
}
hour = next_hour_of(hour);
}
}
let sum_weight: f64 = stats_per_hour.values().sum();
for weight in stats_per_hour.values_mut() {
*weight = *weight * 100.0 / sum_weight;
*weight = (*weight * 10.0).trunc() / 10.0;
}
stats_per_hour
}
#[cfg(test)]
mod test {
use super::*;
use crate::database::latest::trees::sessions;
use chrono::{DateTime, Local};
#[test]
fn test_compute_percentage_per_hour() {
let today = Local::now();
let test_data = vec![
(
vec![((11, 20), (13, 20))],
vec![(11, 33.3), (12, 50.0), (13, 16.6)],
),
(vec![((09, 00), (09, 01))], vec![(09, 100.0)]),
(vec![((09, 00), (09, 59))], vec![(09, 100.0)]),
(
vec![((13, 00), (16, 00))],
vec![(13, 33.3), (14, 33.3), (15, 33.3), (16, 0.0)],
),
];
for (sessions, expected) in test_data {
let sessions: Vec<_> = sessions
.into_iter()
.map(|((h1, m1), (h2, m2))| {
let set_hm = |t: DateTime<Local>, h, m| {
t.with_hour(h)
.and_then(|t| t.with_minute(m))
.and_then(|t| t.with_second(0))
.and_then(|t| t.with_nanosecond(0))
.unwrap()
};
session::V {
category: Default::default(),
deleted: false,
started: set_hm(today, h1, m1),
ended: set_hm(today, h2, m2),
}
})
.collect();
let percentages = compute_percentage_per_hour(sessions.iter());
println!("{:#?}", percentages);
assert!(percentages.into_iter().eq(expected.into_iter()));
}
}
}

88
server/src/status_json.rs Normal file
View File

@ -0,0 +1,88 @@
#![allow(dead_code)]
use duplicate::duplicate;
use log::{info, warn};
use rocket::http::Status;
use rocket::response::{Responder, Response};
use rocket::Request;
use rocket_contrib::json;
use rocket_contrib::json::Json; // macro
/// An error message which can be serialized as JSON.
///
/// #### Example JSON
/// ```json
/// {
/// "status": 404,
/// "description": "Not Found"
/// }
/// ```
#[derive(Debug, Clone)]
pub struct StatusJson {
pub status: Status,
pub description: String,
}
impl StatusJson {
pub fn new<S: ToString>(status: Status, description: S) -> Self {
StatusJson {
status,
description: description.to_string(),
}
}
pub fn describe<S: ToString>(mut self, description: S) -> Self {
self.description = description.to_string();
self
}
}
impl<'r> Responder<'r, 'static> for StatusJson {
fn respond_to(self, req: &Request) -> Result<Response<'static>, Status> {
if self.status.code >= 400 {
warn!(
"Responding with status {}.\n\
Description: {}",
self.status, self.description,
);
} else {
info!("Responding with status {}", self.status);
}
let mut response = Json(json!({
"status": self.status.code,
"description": self.description,
}))
.respond_to(req)?;
response.set_status(self.status);
Ok(response)
}
}
#[duplicate(
status_code T;
[ Status::InternalServerError ] [ sled::Error ];
[ Status::InternalServerError ] [ sled::transaction::TransactionError ];
[ Status::InternalServerError ] [ bincode::Error ];
[ Status::BadRequest ] [ uuid::Error ];
[ Status::BadRequest ] [ chrono::ParseError ];
)]
impl From<T> for StatusJson {
fn from(e: T) -> StatusJson {
StatusJson {
status: status_code,
description: e.to_string(),
}
}
}
impl From<Status> for StatusJson {
fn from(status: Status) -> StatusJson {
StatusJson {
description: status.reason.to_string(),
status,
}
}
}

21
server/src/util.rs Normal file
View File

@ -0,0 +1,21 @@
use tokio::sync::Notify;
pub struct EventNotifier {
notify: Notify,
}
impl EventNotifier {
pub fn new() -> Self {
EventNotifier {
notify: Notify::new(),
}
}
pub async fn wait_for_event(&self) {
self.notify.notified().await;
}
pub fn notify_event(&self) {
self.notify.notify_waiters();
}
}