feat: added beacon operation config editor

This commit is contained in:
Andrew Rioux 2025-01-31 02:00:22 -05:00
parent 66b59531c5
commit 71b2f70686
Signed by: andrew.rioux
GPG Key ID: 9B8BAC47C17ABB94
11 changed files with 398 additions and 20 deletions

12
Cargo.lock generated
View File

@ -630,6 +630,17 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "cron"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740"
dependencies = [
"chrono",
"once_cell",
"winnow",
]
[[package]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.12" version = "0.3.12"
@ -2984,6 +2995,7 @@ dependencies = [
"chrono", "chrono",
"codee", "codee",
"console_error_panic_hook", "console_error_panic_hook",
"cron",
"futures", "futures",
"futures-util", "futures-util",
"hex", "hex",

View File

@ -40,6 +40,7 @@ hex = { version = "0.4", optional = true }
serde = "1.0" serde = "1.0"
cfg-if = "1.0.0" cfg-if = "1.0.0"
rcgen = { version = "0.13.2", optional = true } rcgen = { version = "0.13.2", optional = true }
cron = { version = "0.15.0", optional = true }
[features] [features]
hydrate = ["leptos/hydrate", "chrono/wasmbind"] hydrate = ["leptos/hydrate", "chrono/wasmbind"]
@ -62,6 +63,7 @@ ssr = [
"dep:sha2", "dep:sha2",
"dep:hex", "dep:hex",
"dep:rcgen", "dep:rcgen",
"dep:cron",
"leptos/ssr", "leptos/ssr",
"leptos_meta/ssr", "leptos_meta/ssr",
"leptos_router/ssr", "leptos_router/ssr",

View File

@ -0,0 +1,15 @@
DROP TABLE beacon_config;
CREATE TABLE beacon_config (
config_id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
config_name varchar NOT NULL,
mode text check (mode in ('single', 'regular', 'random', 'cron')),
regular_interval int,
random_min_time int,
random_max_time int,
cron_schedule varchar,
cron_mode text check (cron_mode in ('local', 'utc'))
);

View File

@ -22,9 +22,12 @@ pub struct BeaconResources {
add_category: ServerAction<categories::AddCategory>, add_category: ServerAction<categories::AddCategory>,
remove_category: ServerAction<categories::RemoveCategory>, remove_category: ServerAction<categories::RemoveCategory>,
rename_category: ServerAction<categories::RenameCategory>, rename_category: ServerAction<categories::RenameCategory>,
add_beacon_config: ServerAction<configs::AddBeaconConfig>,
remove_beacon_config: ServerAction<configs::RemoveBeaconConfig>,
listeners: Resource<Result<Vec<listeners::PubListener>, ServerFnError>>, listeners: Resource<Result<Vec<listeners::PubListener>, ServerFnError>>,
categories: Resource<Result<Vec<categories::Category>, ServerFnError>>, categories: Resource<Result<Vec<categories::Category>, ServerFnError>>,
configs: Resource<Result<Vec<configs::BeaconConfig>, ServerFnError>>
} }
pub fn provide_beacon_resources() { pub fn provide_beacon_resources() {
@ -37,6 +40,9 @@ pub fn provide_beacon_resources() {
let remove_category = ServerAction::<categories::RemoveCategory>::new(); let remove_category = ServerAction::<categories::RemoveCategory>::new();
let rename_category = ServerAction::<categories::RenameCategory>::new(); let rename_category = ServerAction::<categories::RenameCategory>::new();
let add_beacon_config = ServerAction::<configs::AddBeaconConfig>::new();
let remove_beacon_config = ServerAction::<configs::RemoveBeaconConfig>::new();
let listeners = Resource::new( let listeners = Resource::new(
move || ( move || (
user.get(), user.get(),
@ -56,15 +62,27 @@ pub fn provide_beacon_resources() {
|_| async { categories::get_categories().await } |_| async { categories::get_categories().await }
); );
let configs = Resource::new(
move || (
user.get(),
add_beacon_config.version().get(),
remove_beacon_config.version().get(),
),
|_| async { configs::get_beacon_configs().await }
);
provide_context(BeaconResources { provide_context(BeaconResources {
add_listener, add_listener,
remove_listener, remove_listener,
add_category, add_category,
remove_category, remove_category,
rename_category, rename_category,
add_beacon_config,
remove_beacon_config,
listeners, listeners,
categories categories,
configs
}); });
} }
@ -86,7 +104,6 @@ pub fn BeaconView() -> impl IntoView {
<li><A href="/beacons/configs">"Configs"</A></li> <li><A href="/beacons/configs">"Configs"</A></li>
<li><A href="/beacons/categories">"Categories"</A></li> <li><A href="/beacons/categories">"Categories"</A></li>
<li><A href="/beacons/templates">"Templates"</A></li> <li><A href="/beacons/templates">"Templates"</A></li>
<li><A href="/beacons/instances">"Instances"</A></li>
<li><A href="/beacons/commands">"Commands"</A></li> <li><A href="/beacons/commands">"Commands"</A></li>
</ul> </ul>
</aside> </aside>

View File

@ -225,6 +225,7 @@ fn DisplayCategories(categories: Vec<Category>) -> impl IntoView {
<label>"Name"</label> <label>"Name"</label>
<input name="name" bind:value=target_rename_name /> <input name="name" bind:value=target_rename_name />
<input type="hidden" name="id" value=move||target_rename_id.get() /> <input type="hidden" name="id" value=move||target_rename_id.get() />
<div></div>
<input type="submit" value="Submit" disabled=move||rename_category.pending()/> <input type="submit" value="Submit" disabled=move||rename_category.pending()/>
</fieldset> </fieldset>
</ActionForm> </ActionForm>

View File

@ -1,10 +1,300 @@
use leptos::prelude::*; use leptos::{either::Either, prelude::*};
use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use {
std::str::FromStr,
sqlx::{sqlite::SqliteRow, FromRow, Row, SqlitePool},
leptos::server_fn::error::NoCustomError,
crate::db::user
};
use super::BeaconResources;
#[derive(Clone, Serialize, Deserialize)]
pub enum BeaconConfigTypes {
Single,
Regular(i64),
Random(i64, i64),
CronSchedule(String, String),
}
#[cfg(feature = "ssr")]
impl FromRow<'_, SqliteRow> for BeaconConfigTypes {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
match row.try_get("mode")? {
"single" => Ok(Self::Single),
"regular" => Ok(Self::Regular(row.try_get("regular_interval")?)),
"random" => Ok(Self::Random(
row.try_get("random_min_time")?,
row.try_get("random_max_time")?,
)),
"cron" => Ok(Self::CronSchedule(
row.try_get("cron_schedule")?,
row.try_get("cron_mode")?
)),
type_name => Err(sqlx::Error::TypeNotFound {
type_name: type_name.to_string(),
}),
}
}
}
#[cfg_attr(feature = "ssr", derive(FromRow))]
#[derive(Clone, Serialize, Deserialize)]
pub struct BeaconConfig {
config_id: i64,
config_name: String,
#[cfg_attr(feature = "ssr", sqlx(flatten))]
config_type: BeaconConfigTypes,
}
#[server]
pub async fn get_beacon_configs() -> Result<Vec<BeaconConfig>, ServerFnError> {
let user = user::get_auth_session().await?;
if user.is_none() {
return Err(ServerFnError::<NoCustomError>::ServerError("You are not signed in!".to_owned()));
}
let db = expect_context::<SqlitePool>();
Ok(sqlx::query_as("SELECT * FROM beacon_config")
.fetch_all(&db)
.await?)
}
#[server]
pub async fn add_beacon_config(
name: String,
mode: String,
regular_interval: i64,
random_min_time: i64,
random_max_time: i64,
cron_schedule: String,
cron_mode: String,
) -> Result<(), ServerFnError> {
let user = user::get_auth_session().await?;
if user.is_none() {
return Err(ServerFnError::<NoCustomError>::ServerError("You are not signed in!".to_owned()));
}
let db = expect_context::<SqlitePool>();
match &*mode {
"single" => {
sqlx::query!(
"INSERT INTO beacon_config (config_name, mode) VALUES (?, 'single')",
name
)
.execute(&db)
.await?;
Ok(())
},
"regular" => {
if regular_interval < 1 {
return Err(ServerFnError::<NoCustomError>::ServerError("Invalid interval provided".to_owned()))
}
sqlx::query!(
"INSERT INTO beacon_config (config_name, mode, regular_interval) VALUES (?, 'regular', ?)",
name,
regular_interval
)
.execute(&db)
.await?;
Ok(())
},
"random" => {
if random_min_time < 1 || random_max_time < random_min_time {
return Err(ServerFnError::<NoCustomError>::ServerError("Invalid random interval provided".to_owned()))
}
sqlx::query!(
"INSERT INTO beacon_config (config_name, mode, random_min_time, random_max_time) VALUES (?, 'random', ?, ?)",
name,
random_min_time,
random_max_time
)
.execute(&db)
.await?;
Ok(())
},
"cron" => {
if let Err(e) = cron::Schedule::from_str(&cron_schedule) {
return Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Could not parse cron expression: {}",
e
)))
}
match &*cron_mode {
"local" | "utc" => {},
other => {
return Err(ServerFnError::<NoCustomError>::ServerError("Unrecognized timezone specifier for cron".to_string()))
}
}
sqlx::query!(
"INSERT INTO beacon_config (config_name, mode, cron_schedule, cron_mode) VALUES (?, 'cron', ?, ?)",
name,
cron_schedule,
cron_mode
)
.execute(&db)
.await?;
Ok(())
},
_ => {
Err(ServerFnError::<NoCustomError>::ServerError("Invalid mode supplied".to_owned()))
}
}
}
#[server]
pub async fn remove_beacon_config(id: i64) -> Result<(), ServerFnError> {
unimplemented!()
}
#[component] #[component]
pub fn ConfigsView() -> impl IntoView { pub fn ConfigsView() -> impl IntoView {
view! { let BeaconResources { add_beacon_config, configs, .. } = expect_context();
<div>
view! {
<div class="config">
<h2>"Beacon configuration"</h2>
<div>
<p>
"Beacon configuration controls the mode of operation of a beacon. Currently, there are 4 modes:"
</p>
<ul>
<li>"Single: When the beacon runs, perform a single callback and exit"</li>
<li>"Regular: After launching, stay launched and perform a callback every X seconds"</li>
<li>"Random: After launching, stay launched and perform a callback a random amount of time every X seconds between the specified minimum and maximum"</li>
<li>
"Cron: Operate on a crontab schedule (as shown "
<a href="https://docs.rs/cron/0.15.0/cron/">"here"</a>
")"
</li>
</ul>
</div>
<ActionForm action=add_beacon_config>
<fieldset>
{move || match add_beacon_config.value().get() {
Some(Ok(_)) => Either::Right(()),
None => Either::Right(()),
Some(Err(e)) => Either::Left(view! {
<p>"Error creating config:"</p>
<p>{format!("{e:?}")}</p>
})
}}
<legend>"Add beacon configuration"</legend>
<label>"Configuration name"</label>
<input name="name" />
<label>"Configuration type"</label>
<select name="mode">
<option value="single">"Single"</option>
<option value="regular">"Regular"</option>
<option value="random">"Random"</option>
<option value="cron">"Cron"</option>
</select>
<label class="mode-regular">"Interval"</label>
<input class="mode-regular" name="regular_interval" type="number" value="0" />
<label class="mode-random">"Random interval (min)"</label>
<input class="mode-random" name="random_min_time" type="number" value="0" />
<label class="mode-random">"Random interval (max)"</label>
<input class="mode-random" name="random_max_time" type="number" value="0" />
<label class="mode-cron">"Schedule"</label>
<input class="mode-cron" name="cron_schedule" />
<label class="mode-cron">"Cron timezone"</label>
<select class="mode-cron" name="cron_mode">
<option value="local">"Local to target"</option>
<option value="utc">"UTC"</option>
</select>
<div></div>
<input type="submit" value="Submit" disabled=move||add_beacon_config.pending() />
</fieldset>
</ActionForm>
<Suspense fallback=|| view! { <p>"Loading..."</p> }>
{ move || match configs.get() {
Some(inner) => Either::Right(match inner {
Err(e) => Either::Left(view! {
<p>"There was an error loading configs:"</p>
<p>{format!("error: {}", e)}</p>
}),
Ok(cs) => Either::Right(view! {
<DisplayConfigs configs=cs />
})
}),
None => Either::Left(view! {
<p>"Loading..."</p>
})
}}
</Suspense>
</div> </div>
} }
} }
#[component]
fn DisplayConfigs(configs: Vec<BeaconConfig>) -> impl IntoView {
let BeaconResources { remove_beacon_config, .. } = expect_context();
let configs_view = configs
.iter()
.map(|config| view! {
<li>
{config.config_id}
": "
{config.config_name.clone()}
" ("
{match &config.config_type {
BeaconConfigTypes::Single => {
format!("Single")
},
BeaconConfigTypes::Regular(int) => {
format!("Regular; every {int} seconds")
},
BeaconConfigTypes::Random(min, max) => {
format!("Random; regularly between {min} and {max} seconds")
},
BeaconConfigTypes::CronSchedule(sch, mode) => {
format!("{mode} cron; schedule: {sch}")
}
}}
") "
<button
on:click={
let id = config.config_id;
move |_| {
remove_beacon_config.dispatch(RemoveBeaconConfig { id });
}
}
>
"delete"
</button>
</li>
})
.collect_view();
view! {
{move || match remove_beacon_config.value().get() {
Some(Ok(_)) => Either::Right(()),
None => Either::Right(()),
Some(Err(e)) => Either::Left(view! {
<p>"Error deleting config:"</p>
<p>{format!("{e:?}")}</p>
})
}}
<ul>
{configs_view}
</ul>
}
}

View File

@ -1,3 +1,4 @@
#[cfg(feature = "ssr")]
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use leptos::{either::Either, prelude::*}; use leptos::{either::Either, prelude::*};
@ -196,17 +197,6 @@ fn DisplayListeners(listeners: Vec<PubListener>) -> impl IntoView {
{match listener.active { {match listener.active {
true => Either::Left(view! { true => Either::Left(view! {
<span>"active!"</span> <span>"active!"</span>
<button
on:click={
let id = listener.listener_id;
move |e| {
let _ = e.prevent_default();
remove_listener.dispatch(RemoveListener { listener_id: id });
}
}
>
"delete"
</button>
}), }),
false => Either::Right(view! { false => Either::Right(view! {
<button <button
@ -222,6 +212,17 @@ fn DisplayListeners(listeners: Vec<PubListener>) -> impl IntoView {
</button> </button>
}) })
}} }}
<button
on:click={
let id = listener.listener_id;
move |e| {
let _ = e.prevent_default();
remove_listener.dispatch(RemoveListener { listener_id: id });
}
}
>
"delete"
</button>
</li> </li>
}) })
.collect_view(); .collect_view();

View File

@ -0,0 +1,12 @@
main.beacons div.categories {
padding: 10px;
fieldset {
display: grid;
grid-template-columns: 150px 200px;
input, label {
margin: 10px;
}
}
}

View File

@ -0,0 +1,28 @@
main.beacons div.config {
padding: 10px;
fieldset {
display: grid;
grid-template-columns: 300px 200px;
input, label {
margin: 10px;
}
}
.mode-regular, .mode-random, .mode-cron {
display: none;
}
select:has(> option[value="regular"]:checked) ~ .mode-regular {
display: block;
}
select:has(> option[value="random"]:checked) ~ .mode-random {
display: block;
}
select:has(> option[value="cron"]:checked) ~ .mode-cron {
display: block;
}
}

View File

@ -1,5 +1,5 @@
main.beacons div.listeners { main.beacons div.listeners {
form { form, p, h2 {
margin: 10px; margin: 10px;
} }

View File

@ -72,11 +72,11 @@ input[type="submit"],
input[type="button"], input[type="button"],
button { button {
background-color: #fff; background-color: #fff;
border: 1px solid transparent; border: 1px solid black;
cursor: pointer; cursor: pointer;
margin: 5px; margin: 5px;
padding: 2px 4px; padding: 5px 7px;
border-radius: 2px; border-radius: 4px;
box-shadow: #fff7 0 1px 0 inset; box-shadow: #fff7 0 1px 0 inset;
&:hover { &:hover {