Add cli & lib crates
This commit is contained in:
63
server/src/auth.rs
Normal file
63
server/src/auth.rs
Normal 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))
|
||||
}
|
||||
43
server/src/database/migrations/mod.rs
Normal file
43
server/src/database/migrations/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
85
server/src/database/migrations/v1_to_v2.rs
Normal file
85
server/src/database/migrations/v1_to_v2.rs
Normal 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(())
|
||||
}
|
||||
9
server/src/database/mod.rs
Normal file
9
server/src/database/mod.rs
Normal 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;
|
||||
6
server/src/database/unversioned.rs
Normal file
6
server/src/database/unversioned.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub mod global {
|
||||
pub mod schema_version {
|
||||
pub const K: &str = "SCHEMA_VERSION";
|
||||
pub type V = u32;
|
||||
}
|
||||
}
|
||||
25
server/src/database/util/category.rs
Normal file
25
server/src/database/util/category.rs
Normal 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<_, _>, _>>()??)
|
||||
}
|
||||
1
server/src/database/util/mod.rs
Normal file
1
server/src/database/util/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod category;
|
||||
50
server/src/database/v1.rs
Normal file
50
server/src/database/v1.rs
Normal 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
63
server/src/database/v2.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
28
server/src/handlebars_util.rs
Normal file
28
server/src/handlebars_util.rs
Normal 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
88
server/src/main.rs
Normal 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
267
server/src/routes/api.rs
Normal 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
2
server/src/routes/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod api;
|
||||
pub mod pages;
|
||||
113
server/src/routes/pages.rs
Normal file
113
server/src/routes/pages.rs
Normal 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))
|
||||
}
|
||||
299
server/src/routes/pages/stats.rs
Normal file
299
server/src/routes/pages/stats.rs
Normal 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
88
server/src/status_json.rs
Normal 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
21
server/src/util.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user