Merge pull request 'Reintroduce status and power-related templates and routes' (#140) from refactor_stats into main

Reviewed-on: #140
This commit is contained in:
glyph 2022-11-28 07:18:00 +00:00
commit 6cc8faa0c3
14 changed files with 803 additions and 243 deletions

10
Cargo.lock generated
View File

@ -2333,7 +2333,7 @@ dependencies = [
[[package]]
name = "peach-web"
version = "0.6.19"
version = "0.6.21"
dependencies = [
"async-std",
"base64 0.13.0",
@ -2347,8 +2347,10 @@ dependencies = [
"maud",
"peach-lib",
"peach-network",
"peach-stats",
"rouille",
"temporary",
"vnstat_parse",
"xdg",
]
@ -3814,6 +3816,12 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "vnstat_parse"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75334377a2918b45b5b8da023080375a4ec0aa04b0bc88f896ea93cf4b32feff"
[[package]]
name = "void"
version = "1.0.2"

View File

@ -1,6 +1,6 @@
[package]
name = "peach-web"
version = "0.6.20"
version = "0.6.21"
authors = ["Andrew Reid <gnomad@cryptolab.net>", "Max Fowler <max@mfowler.info>"]
edition = "2018"
description = "peach-web is a web application which provides a web interface for monitoring and interacting with the PeachCloud device. This allows administration of the single-board computer (ie. Raspberry Pi) running PeachCloud, as well as the ssb-server and related plugins."
@ -44,11 +44,9 @@ lazy_static = "1.4"
log = "0.4"
maud = "0.23"
peach-lib = { path = "../peach-lib" }
# these will be reintroduced when the full peachcloud mode is added
peach-network = { path = "../peach-network" }
#peach-stats = { path = "../peach-stats" }
peach-stats = { path = "../peach-stats" }
rouille = { version = "3.5", default-features = false }
temporary = "0.6"
# TODO: uncomment this when data usage feature is in place
#vnstat_parse = "0.1.0"
vnstat_parse = "0.1.0"
xdg = "2.2"

View File

@ -166,6 +166,18 @@ pub fn mount_peachpub_routes(
routes::settings::admin::delete::handle_form(request)
},
(GET) (/settings/power) => {
Response::html(routes::settings::power::menu::build_template(request))
},
(GET) (/settings/power/reboot) => {
routes::settings::power::reboot::handle_reboot()
},
(GET) (/settings/power/shutdown) => {
routes::settings::power::shutdown::handle_shutdown()
},
(GET) (/settings/scuttlebutt) => {
Response::html(routes::settings::scuttlebutt::menu::build_template(request))
.reset_flash()
@ -248,10 +260,18 @@ pub fn mount_peachpub_routes(
routes::settings::theme::set_theme(theme)
},
(GET) (/status) => {
Response::html(routes::status::device::build_template())
},
(GET) (/status/scuttlebutt) => {
Response::html(routes::status::scuttlebutt::build_template()).add_cookie("back_url=/status/scuttlebutt")
},
(GET) (/status/network) => {
Response::html(routes::status::network::build_template())
},
// render the not_found template and set a 404 status code if none of
// the other blocks matches the request
_ => Response::html(templates::not_found::build_template()).with_status_code(404)

View File

@ -1,7 +1,7 @@
use maud::{html, PreEscaped};
use peach_lib::sbot::SbotStatus;
use crate::{templates, utils::theme};
use crate::{templates, utils::theme, SERVER_CONFIG};
/// Read the state of the go-sbot process and define status-related
/// elements accordingly.
@ -24,9 +24,23 @@ fn render_status_elements<'a>() -> (&'a str, &'a str, &'a str) {
}
}
/// Render the URL for the status element (icon / link).
///
/// If the application is running in standalone mode then the element links
/// directly to the Scuttlebutt status page. If not, it links to the device
/// status page.
fn render_status_url<'a>() -> &'a str {
if SERVER_CONFIG.standalone_mode {
"/status/scuttlebutt"
} else {
"/status"
}
}
/// Home template builder.
pub fn build_template() -> PreEscaped<String> {
let (circle_color, center_circle_text, circle_border) = render_status_elements();
let status_url = render_status_url();
// render the home template html
let home_template = html! {
@ -63,7 +77,7 @@ pub fn build_template() -> PreEscaped<String> {
}
(PreEscaped("<!-- bottom-left -->"))
(PreEscaped("<!-- SYSTEM STATUS LINK AND ICON -->"))
a class="bottom-left" href="/status/scuttlebutt" title="Status" {
a class="bottom-left" href=(status_url) title="Status" {
div class={ "circle circle-small border-circle-small " (circle_border) } {
img class="icon-medium" src="/icons/heart-pulse.svg";
}

View File

@ -11,8 +11,10 @@ pub fn build_template() -> PreEscaped<String> {
div class="card center" {
(PreEscaped("<!-- BUTTONS -->"))
div id="settingsButtons" {
// render the network settings button if we're not in standalone mode
// render the network settings and power menu buttons if we're
// not in standalone mode
@if !SERVER_CONFIG.standalone_mode {
a id="power" class="button button-primary center" href="/settings/power" title="Power Menu" { "Power" }
a id="network" class="button button-primary center" href="/settings/network" title="Network Settings" { "Network" }
}
a id="scuttlebutt" class="button button-primary center" href="/settings/scuttlebutt" title="Scuttlebutt Settings" { "Scuttlebutt" }

View File

@ -2,5 +2,6 @@ pub mod admin;
//pub mod dns;
pub mod menu;
pub mod network;
pub mod power;
pub mod scuttlebutt;
pub mod theme;

View File

@ -0,0 +1,37 @@
use maud::{html, PreEscaped};
use rouille::Request;
use crate::{
templates,
utils::{flash::FlashRequest, theme},
};
/// Power menu template builder.
///
/// Presents options for rebooting or shutting down the device.
pub fn build_template(request: &Request) -> PreEscaped<String> {
let (flash_name, flash_msg) = request.retrieve_flash();
let power_menu_template = html! {
(PreEscaped("<!-- POWER MENU -->"))
div class="card center" {
div class="card-container" {
div id="buttons" {
a id="rebootBtn" class="button button-primary center" href="/reboot" title="Reboot Device" { "Reboot" }
a id="shutdownBtn" class="button button-warning center" href="/shutdown" title="Shutdown Device" { "Shutdown" }
a id="cancelBtn" class="button button-secondary center" href="/settings" title="Cancel" { "Cancel" }
}
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
}
};
let body = templates::nav::build_template(power_menu_template, "Power Menu", Some("/"));
let theme = theme::get_theme();
templates::base::build_template(body, theme)
}

View File

@ -0,0 +1,3 @@
pub mod menu;
pub mod reboot;
pub mod shutdown;

View File

@ -0,0 +1,36 @@
use log::info;
use rouille::Response;
use std::{
io::Result,
process::{Command, Output},
};
use crate::utils::flash::FlashResponse;
/// Executes a system command to reboot the device immediately.
fn reboot() -> Result<Output> {
info!("Rebooting the device");
// ideally, we'd like to reboot after 5 seconds to allow time for JSON
// response but this is not possible with the `shutdown` command alone.
// TODO: send "rebooting..." message to `peach-oled` for display
Command::new("sudo")
.arg("shutdown")
.arg("-r")
.arg("now")
.output()
}
pub fn handle_reboot() -> Response {
let (name, msg) = match reboot() {
Ok(_) => ("success".to_string(), "Rebooting the device".to_string()),
Err(err) => (
"error".to_string(),
format!("Failed to reboot the device: {}", err),
),
};
let (flash_name, flash_msg) = (format!("flash_name={}", name), format!("flash_msg={}", msg));
Response::redirect_303("/power").add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,35 @@
use log::info;
use rouille::Response;
use std::{
io::Result,
process::{Command, Output},
};
use crate::utils::flash::FlashResponse;
/// Executes a system command to shutdown the device immediately.
fn shutdown() -> Result<Output> {
info!("Shutting down the device");
// ideally, we'd like to shutdown after 5 seconds to allow time for JSON
// response but this is not possible with the `shutdown` command alone.
// TODO: send "shutting down..." message to `peach-oled` for display
Command::new("sudo").arg("shutdown").arg("now").output()
}
pub fn handle_shutdown() -> Response {
let (name, msg) = match shutdown() {
Ok(_) => (
"success".to_string(),
"Shutting down the device".to_string(),
),
Err(err) => (
"error".to_string(),
format!("Failed to shutdown the device: {}", err),
),
};
let (flash_name, flash_msg) = (format!("flash_name={}", name), format!("flash_msg={}", msg));
Response::redirect_303("/power").add_flash(flash_name, flash_msg)
}

View File

@ -1,238 +1,381 @@
use log::info;
use rocket::{
get,
request::FlashMessage,
response::{Flash, Redirect},
};
use rocket_dyn_templates::Template;
use serde::Serialize;
use std::{
io,
process::{Command, Output},
};
use std::process::Command;
use peach_lib::{
config_manager::load_peach_config, dyndns_client, network_client, oled_client, sbot::SbotStatus,
};
use maud::{html, Markup, PreEscaped};
use peach_lib::{config_manager, dyndns_client, oled_client};
use peach_stats::{
stats,
stats::{CpuStatPercentages, DiskUsage, LoadAverage, MemStat},
stats::{CpuStatPercentages, MemStat},
};
use crate::routes::authentication::Authenticated;
use crate::{templates, utils::theme};
// HELPERS AND ROUTES FOR /status
// ROUTE: /status
/// System statistics data.
#[derive(Debug, Serialize)]
pub struct StatusContext {
pub back: Option<String>,
pub cpu_stat_percent: Option<CpuStatPercentages>,
pub disk_stats: Vec<DiskUsage>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub load_average: Option<LoadAverage>,
pub mem_stats: Option<MemStat>,
pub network_ping: String,
pub oled_ping: String,
pub dyndns_enabled: bool,
pub dyndns_is_online: bool,
pub config_is_valid: bool,
pub sbot_is_online: bool,
pub title: Option<String>,
pub uptime: Option<i32>,
/// Query systemd to determine the state of the networking service.
fn retrieve_networking_state() -> Option<String> {
// call: `systemctl show networking.service --no-page`
let networking_service_output = Command::new("systemctl")
.arg("show")
.arg("networking.service")
.arg("--no-page")
.output()
.ok()?;
let service_info = std::str::from_utf8(&networking_service_output.stdout).ok()?;
// find the line starting with "ActiveState=" and return the value
service_info
.lines()
.find(|line| line.starts_with("ActiveState="))
.and_then(|line| line.strip_prefix("ActiveState="))
.map(|state| state.to_string())
}
impl StatusContext {
pub fn build() -> StatusContext {
// convert result to Option<CpuStatPercentages>, discard any error
let cpu_stat_percent = stats::cpu_stats_percent().ok();
let load_average = stats::load_average().ok();
let mem_stats = stats::mem_stats().ok();
// TODO: add `wpa_supplicant_status` to peach_network to replace this ping call
// instead of: "is the network json-rpc server running?", we want to ask:
// "is the wpa_supplicant systemd service functioning correctly?"
let network_ping = match network_client::ping() {
Ok(_) => "ONLINE".to_string(),
Err(_) => "OFFLINE".to_string(),
};
let oled_ping = match oled_client::ping() {
Ok(_) => "ONLINE".to_string(),
Err(_) => "OFFLINE".to_string(),
};
/// Query systemd to determine the state of the sbot service.
fn retrieve_sbot_state() -> Option<String> {
// retrieve the name of the go-sbot service or set default
let go_sbot_service = config_manager::get_config_value("GO_SBOT_SERVICE")
.unwrap_or_else(|_| "go-sbot.service".to_string());
let uptime = match stats::uptime() {
Ok(secs) => {
let uptime_mins = secs / 60;
uptime_mins.to_string()
}
Err(_) => "Unavailable".to_string(),
};
let sbot_service_output = Command::new("systemctl")
.arg("show")
.arg(go_sbot_service)
.arg("--no-page")
.output()
.ok()?;
// parse the uptime string to a signed integer (for math)
let uptime_parsed = uptime.parse::<i32>().ok();
let service_info = std::str::from_utf8(&sbot_service_output.stdout).ok()?;
// serialize disk usage data into Vec<DiskUsage>
let disk_usage_stats = match stats::disk_usage() {
Ok(disks) => disks,
Err(_) => Vec::new(),
};
// find the line starting with "ActiveState=" and return the value
service_info
.lines()
.find(|line| line.starts_with("ActiveState="))
.and_then(|line| line.strip_prefix("ActiveState="))
.map(|state| state.to_string())
}
let mut disk_stats = Vec::new();
// select only the partition we're interested in: /dev/mmcblk0p2 ("/")
for disk in disk_usage_stats {
if disk.mountpoint == "/" {
disk_stats.push(disk);
fn retrieve_device_status_data() -> (Option<u64>, String) {
let uptime = stats::uptime().ok();
let oled_ping = match oled_client::ping() {
Ok(_) => "ONLINE".to_string(),
Err(_) => "OFFLINE".to_string(),
};
(uptime, oled_ping)
}
fn retrieve_device_usage_data() -> (Option<CpuStatPercentages>, Option<MemStat>) {
// convert result to Option<CpuStatPercentages>, discard any error
let cpu_stat_percent = stats::cpu_stats_percent().ok();
let mem_stats = stats::mem_stats().ok();
(cpu_stat_percent, mem_stats)
}
fn render_network_capsule() -> Markup {
let (state, stack_class, img_class) = match retrieve_networking_state() {
Some(state) if state.as_str() == "active" => {
("active", "stack capsule border-success", "icon icon-medium")
}
Some(state) if state.as_str() == "inactive" => (
"inactive",
"stack capsule border-warning",
"icon icon-inactive icon-medium",
),
Some(state) if state.as_str() == "failed" => (
"failed",
"stack capsule border-danger",
"icon icon-inactive icon-medium",
),
_ => (
"error",
"stack capsule border-danger",
"icon icon-inactive icon-medium",
),
};
html! {
(PreEscaped("<!-- PEACH-NETWORK STATUS STACK -->"))
div class=(stack_class) {
img id="networkIcon" class=(img_class) alt="Network" title="Networking service status" src="icons/wifi.svg";
div class="stack" style="padding-top: 0.5rem;" {
label class="label-small font-near-black" { "Networking" }
label class="label-small font-near-black" { (state.to_uppercase()) }
}
}
}
}
// dyndns_is_online & config_is_valid
let dyndns_enabled: bool;
let dyndns_is_online: bool;
let config_is_valid: bool;
let load_peach_config_result = load_peach_config();
match load_peach_config_result {
Ok(peach_config) => {
dyndns_enabled = peach_config.dyn_enabled;
config_is_valid = true;
if dyndns_enabled {
let is_dyndns_online_result = dyndns_client::is_dns_updater_online();
match is_dyndns_online_result {
Ok(is_online) => {
dyndns_is_online = is_online;
}
Err(_err) => {
dyndns_is_online = false;
}
fn render_oled_capsule(state: String) -> Markup {
let (stack_class, img_class) = match state.as_str() {
"ONLINE" => ("stack capsule border-success", "icon icon-medium"),
_ => (
"stack capsule border-warning",
"icon icon-inactive icon-medium",
),
};
html! {
(PreEscaped("<!-- PEACH-OLED STATUS STACK -->"))
div class=(stack_class) {
img id="oledIcon" class=(img_class) alt="Display" title="OLED display microservice status" src="icons/lcd.svg";
div class="stack" style="padding-top: 0.5rem;" {
label class="label-small font-near-black" { "Display" }
label class="label-small font-near-black" { (state) }
}
}
}
}
fn render_diagnostics_capsule() -> Markup {
// TODO: write a diagnostics module (maybe in peach-lib)
let diagnostics_state = "CLEAR";
html! {
(PreEscaped("<!-- DIAGNOSTICS AND LOGS STACK -->"))
div class="stack capsule border-success" {
img id="statsIcon" class="icon icon-medium" alt="Line chart" title="System diagnostics and logs" src="icons/chart.svg";
div class="stack" style="padding-top: 0.5rem;" {
label class="label-small font-near-black" { "Diagnostics" }
label class="label-small font-near-black" { (diagnostics_state) };
}
}
}
}
fn render_dyndns_capsule() -> Markup {
let (state, stack_class, img_class) = match dyndns_client::is_dns_updater_online() {
Ok(true) => ("ONLINE", "stack capsule border-success", "icon icon-medium"),
Ok(false) => (
"OFFLINE",
"stack capsule border-warning",
"icon icon-inactive icon-medium",
),
Err(_) => (
"ERROR",
"stack capsule border-danger",
"icon icon-inactive icon-medium",
),
};
html! {
(PreEscaped("<!-- DYNDNS STATUS STACK -->"))
div class=(stack_class) {
img id="dnsIcon" class=(img_class) alt="Dyndns" title="Dyndns status" src="icons/dns.png";
div class="stack" style="padding-top: 0.5rem;" {
label class="label-small font-near-black" { "Dyn DNS" }
label class="label-small font-near-black" { (state) }
}
}
}
}
fn render_config_capsule() -> Markup {
let (state, stack_class, img_class) =
match config_manager::load_peach_config_from_disc().is_ok() {
true => ("LOADED", "stack capsule border-success", "icon icon-medium"),
false => (
"INVALID",
"stack capsule border-warning",
"icon icon-inactive icon-medium",
),
};
html! {
(PreEscaped("<!-- CONFIG STATUS STACK -->"))
div class=(stack_class) {
img id="configIcon" class=(img_class) alt="Config" title="Config status" src="icons/clipboard.png";
div class="stack" style="padding-top: 0.5rem;" {
label class="label-small font-near-black" { "Config" }
label class="label-small font-near-black" { (state) }
}
}
}
}
fn render_sbot_capsule() -> Markup {
let (state, stack_class, img_class) = match retrieve_sbot_state() {
Some(state) if state.as_str() == "active" => {
("active", "stack capsule border-success", "icon icon-medium")
}
Some(state) if state.as_str() == "inactive" => (
"inactive",
"stack capsule border-warning",
"icon icon-inactive icon-medium",
),
Some(state) if state.as_str() == "failed" => (
"failed",
"stack capsule border-danger",
"icon icon-inactive icon-medium",
),
_ => (
"error",
"stack capsule border-danger",
"icon icon-inactive icon-medium",
),
};
html! {
(PreEscaped("<!-- SBOT STATUS STACK -->"))
div class=(stack_class) {
img id="sbotIcon" class=(img_class) alt="Sbot" title="Sbot status" src="icons/hermies.svg";
div class="stack" style="padding-top: 0.5rem;" {
label class="label-small font-near-black" { "Sbot" }
label class="label-small font-near-black" { (state.to_uppercase()) }
}
}
}
}
fn render_cpu_usage_meter(cpu_usage_percent: Option<CpuStatPercentages>) -> Markup {
html! {
@if let Some(cpu_usage) = cpu_usage_percent {
@let cpu_usage_total = (cpu_usage.nice + cpu_usage.system + cpu_usage.user).round();
div class="flex-grid" {
span class="card-text" { "CPU" }
span class="label-small push-right" { (cpu_usage_total) "%" }
}
meter value=(cpu_usage_total) min="0" max="100" title="CPU usage" {
div class="meter-gauge" {
span style={ "width: " (cpu_usage_total) "%;" } {
"CPU Usage"
}
} else {
dyndns_is_online = false;
}
}
Err(_err) => {
dyndns_enabled = false;
dyndns_is_online = false;
config_is_valid = false;
} @else {
p class="card-text" { "CPU usage data unavailable" }
}
}
}
fn render_mem_usage_meter(mem_stats: Option<MemStat>) -> Markup {
html! {
@if let Some(mem) = mem_stats {
// convert kilobyte values to megabyte values
@let mem_free_mb = mem.free / 1024;
@let mem_total_mb = mem.total / 1024;
@let mem_used_mb = mem.used / 1024;
// calculate memory usage as a percentage
@let mem_used_percent = mem_used_mb * 100 / mem_total_mb;
// render disk free value as megabytes or gigabytes based on size
@let mem_free_value = if mem_free_mb > 1024 {
format!("{} GB", (mem_free_mb / 1024))
} else {
format!("{} MB", mem_free_mb)
};
div class="flex-grid" {
span class="card-text" { "Memory" }
span class="label-small push-right" { (mem_used_percent) "% (" (mem_free_value) " free)" }
}
meter value=(mem_used_mb) min="0" max=(mem_total_mb) title="Memory usage" {
div class="meter-gauge" {
span style={ "width: " (mem_used_percent) "%;" } { "Memory Usage" }
}
}
} @else {
p class="card-text" { "Memory usage data unavailable" }
}
}
}
fn render_disk_usage_meter() -> Markup {
let disk_usage_stats = match stats::disk_usage() {
Ok(disks) => disks,
Err(_) => Vec::new(),
};
// select only the partition we're interested in: /dev/mmcblk0p2 ("/")
let disk_usage = disk_usage_stats.iter().find(|disk| disk.mountpoint == "/");
html! {
@if let Some(disk) = disk_usage {
// calculate free disk space in megabytes
@let disk_free_mb = disk.one_k_blocks_free / 1024;
// calculate free disk space in gigabytes
@let disk_free_gb = disk_free_mb / 1024;
// render disk free value as megabytes or gigabytes based on size
@let disk_free_value = if disk_free_mb > 1024 {
format!("{} GB", disk_free_gb)
} else {
format!("{} MB", disk_free_mb)
};
div class="flex-grid" {
span class="card-text" { "Disk" }
span class="label-small push-right" { (disk.used_percentage) "% (" (disk_free_value) " free)" }
}
meter value=(disk.used_percentage) min="0" max="100" title="Disk usage" {
div class="meter-gauge" {
span style={ "width: " (disk.used_percentage) "%;" } {
"Disk Usage"
}
}
}
} @else {
p class="card-text" { "Disk usage data unavailable" }
}
}
}
/// Display system uptime in hours and minutes.
fn render_uptime_capsule(uptime: Option<u64>) -> Markup {
html! {
@if let Some(uptime_secs) = uptime {
@let uptime_mins = uptime_secs / 60;
@if uptime_mins < 60 {
// display system uptime in minutes
p class="capsule center-text" {
"Uptime: " (uptime_mins) " minutes"
}
} @else {
// display system uptime in hours and minutes
@let hours = uptime_mins / 60;
@let mins = uptime_mins % 60;
p class="capsule center-text" {
"Uptime: " (hours) " hours, " (mins) " minutes"
}
}
} @else {
p class="card-text" { "Uptime data unavailable" }
}
}
}
/// Device status template builder.
pub fn build_template() -> PreEscaped<String> {
let (uptime, oled_state) = retrieve_device_status_data();
let (cpu_usage, mem_usage) = retrieve_device_usage_data();
let device_status_template = html! {
(PreEscaped("<!-- DEVICE STATUS CARD -->"))
div class="card center" {
div class="card-container" {
// display status capsules for network, oled and diagnostics
div class="three-grid" {
(render_network_capsule())
(render_oled_capsule(oled_state))
(render_diagnostics_capsule())
}
// display status capsules for dyndns, config and sbot
div class="three-grid" style="padding-top: 1rem; padding-bottom: 1rem;" {
(render_dyndns_capsule())
(render_config_capsule())
(render_sbot_capsule())
}
(render_cpu_usage_meter(cpu_usage))
(render_mem_usage_meter(mem_usage))
(render_disk_usage_meter())
(render_uptime_capsule(uptime))
}
}
// test if go-sbot is running
let sbot_status = SbotStatus::read();
let sbot_is_online: bool = match sbot_status {
// return true if state is active
Ok(status) => matches!(status.state == Some("active".to_string()), true),
_ => false,
};
StatusContext {
back: None,
cpu_stat_percent,
disk_stats,
flash_name: None,
flash_msg: None,
load_average,
mem_stats,
network_ping,
oled_ping,
dyndns_enabled,
dyndns_is_online,
config_is_valid,
sbot_is_online,
title: None,
uptime: uptime_parsed,
}
}
}
#[get("/")]
pub fn device_status(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// assign context through context_builder call
let mut context = StatusContext::build();
context.back = Some("/".to_string());
context.title = Some("Device Status".to_string());
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
// template_dir is set in Rocket.toml
Template::render("status/device", &context)
}
// HELPERS AND ROUTES FOR /power/reboot
/// Executes a system command to reboot the device immediately.
pub fn reboot() -> io::Result<Output> {
info!("Rebooting the device");
// ideally, we'd like to reboot after 5 seconds to allow time for JSON
// response but this is not possible with the `shutdown` command alone.
// TODO: send "rebooting..." message to `peach-oled` for display
Command::new("sudo")
.arg("shutdown")
.arg("-r")
.arg("now")
.output()
}
#[get("/power/reboot")]
pub fn reboot_cmd(_auth: Authenticated) -> Flash<Redirect> {
match reboot() {
Ok(_) => Flash::success(Redirect::to("/power"), "Rebooting the device"),
Err(_) => Flash::error(Redirect::to("/power"), "Failed to reboot the device"),
}
}
// HELPERS AND ROUTES FOR /power/shutdown
/// Executes a system command to shutdown the device immediately.
pub fn shutdown() -> io::Result<Output> {
info!("Shutting down the device");
// ideally, we'd like to reboot after 5 seconds to allow time for JSON
// response but this is not possible with the `shutdown` command alone.
// TODO: send "shutting down..." message to `peach-oled` for display
Command::new("sudo").arg("shutdown").arg("now").output()
}
#[get("/power/shutdown")]
pub fn shutdown_cmd(_auth: Authenticated) -> Flash<Redirect> {
match shutdown() {
Ok(_) => Flash::success(Redirect::to("/power"), "Shutting down the device"),
Err(_) => Flash::error(Redirect::to("/power"), "Failed to shutdown the device"),
}
}
// HELPERS AND ROUTES FOR /power
#[derive(Debug, Serialize)]
pub struct PowerContext {
pub back: Option<String>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
}
impl PowerContext {
pub fn build() -> PowerContext {
PowerContext {
back: None,
flash_name: None,
flash_msg: None,
title: None,
}
}
}
#[get("/power")]
pub fn power_menu(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
let mut context = PowerContext::build();
context.back = Some("/".to_string());
context.title = Some("Power Menu".to_string());
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("power", &context)
let body = templates::nav::build_template(device_status_template, "Device Status", Some("/"));
let theme = theme::get_theme();
templates::base::build_template(body, theme)
}

View File

@ -1,3 +1,3 @@
//pub mod device;
//pub mod network;
pub mod device;
pub mod network;
pub mod scuttlebutt;

View File

@ -1,21 +1,285 @@
use rocket::{get, request::FlashMessage};
use rocket_dyn_templates::Template;
use maud::{html, Markup, PreEscaped};
use peach_network::network;
use vnstat_parse::Vnstat;
use crate::context::network::NetworkStatusContext;
use crate::routes::authentication::Authenticated;
use crate::{templates, utils::theme, AP_IFACE, WLAN_IFACE};
// HELPERS AND ROUTES FOR /status/network
enum NetworkState {
AccessPoint,
WiFiClient,
}
#[get("/network")]
pub fn network_status(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
let mut context = NetworkStatusContext::build();
context.back = Some("/status".to_string());
context.title = Some("Network Status".to_string());
// ROUTE: /status/network
if let Some(flash) = flash {
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
/// Render the cog icon which is used as a link to the network settings page.
fn render_network_config_icon() -> Markup {
html! {
(PreEscaped("<!-- top-right config icon -->"))
a class="link two-grid-top-right" href="/settings/network" title="Configure network settings" {
img id="configureNetworking" class="icon-small" src="/icons/cog.svg" alt="Configure";
}
}
}
/// Render the network mode icon, either a WiFi signal or router, based
/// on the state of the AP and WiFi interfaces.
///
/// A router icon is shown if the AP is online (interface is "up").
///
/// A WiFi signal icon is shown if the AP interface is down. The colour of
/// the icon is black if the WLAN interface is up and gray if it's down.
fn render_network_mode_icon(state: &NetworkState) -> Markup {
// TODO: make this DRYer
let (icon_class, icon_src, icon_alt, label_title, label_value) = match state {
NetworkState::AccessPoint => (
"center icon icon-active",
"/icons/router.svg",
"WiFi router",
"Access Point Online",
"ONLINE",
),
NetworkState::WiFiClient => match network::state(WLAN_IFACE) {
Ok(Some(state)) if state == "up" => (
"center icon icon-active",
"/icons/wifi.svg",
"WiFi signal",
"WiFi Client Online",
"ONLINE",
),
_ => (
"center icon icon-inactive",
"/icons/wifi.svg",
"WiFi signal",
"WiFi Client Offline",
"OFFLINE",
),
},
};
Template::render("status/network", &context)
html! {
(PreEscaped("<!-- network mode icon with label -->"))
div class="grid-column-1" {
img id="netModeIcon" class=(icon_class) src=(icon_src) alt=(icon_alt);
label id="netModeLabel" for="netModeIcon" class="center label-small font-gray" title=(label_title) { (label_value) }
}
}
}
/// Render the network data associated with the deployed access point or
/// connected WiFi client depending on active mode.
///
/// Data includes the network mode (access point or WiFi client), SSID and IP
/// address.
fn render_network_data(state: &NetworkState, ssid: String, ip: String) -> Markup {
let (mode_value, mode_title, ssid_value, ip_title) = match state {
NetworkState::AccessPoint => (
"Access Point",
"Access Point SSID",
// TODO: remove hardcoding of this value (query interface instead)
"peach",
"Access Point IP Address",
),
NetworkState::WiFiClient => (
"WiFi Client",
"WiFi SSID",
ssid.as_str(),
"WiFi Client IP Address",
),
};
html! {
(PreEscaped("<!-- network mode, ssid & ip with labels -->"))
div class="grid-column-2" {
label class="label-small font-gray" for="netMode" title="Network Mode" { "MODE" }
p id="netMode" class="card-text" title="Network Mode" { (mode_value) }
label class="label-small font-gray" for="netSsid" title=(mode_title) { "SSID" }
p id="netSsid" class="card-text" title="SSID" { (ssid_value) }
label class="label-small font-gray" for="netIp" title=(ip_title) { "IP" }
p id="netIp" class="card-text" title="IP" { (ip) }
}
}
}
/// Render the network status grid comprised of the network config icon,
/// network mode icon and network data text.
fn render_network_status_grid(state: &NetworkState, ssid: String, ip: String) -> Markup {
html! {
(PreEscaped("<!-- NETWORK STATUS GRID -->"))
div class="two-grid" title="PeachCloud network mode and status" {
(render_network_config_icon())
(PreEscaped("<!-- left column -->"))
(render_network_mode_icon(state))
(PreEscaped("<!-- right column -->"))
(render_network_data(state, ssid, ip))
}
}
}
/// Render the signal strength stack comprised of a signal icon, RSSI value
/// and label.
///
/// This stack is displayed when the network mode is set to WiFi
/// client (ie. the value reported is the strength of the connection of the
/// local WiFi interface to a remote access point).
fn render_signal_strength_stack() -> Markup {
let wlan_rssi = match network::rssi(WLAN_IFACE) {
Ok(Some(rssi)) => rssi,
_ => 0.to_string(),
};
html! {
div class="stack" {
img id="netSignal" class="icon icon-medium" alt="Signal" title="WiFi Signal (%)" src="/icons/low-signal.svg";
div class="flex-grid" style="padding-top: 0.5rem;" {
label class="label-medium" for="netSignal" style="padding-right: 3px;" title="Signal strength of WiFi connection (%)" { (wlan_rssi) }
}
label class="label-small font-gray" { "SIGNAL" }
}
}
}
/// Render the connected devices stack comprised of a devices icon, value
/// of connected devices and label.
///
/// This stack is displayed when the network mode is set to access point
/// (ie. the value reported is the number of remote devices connected to the
/// local access point).
fn render_connected_devices_stack() -> Markup {
html! {
div class="stack" {
img id="devices" class="icon icon-medium" title="Connected devices" src="/icons/devices.svg" alt="Digital devices";
div class="flex-grid" style="padding-top: 0.5rem;" {
label class="label-medium" for="devices" style="padding-right: 3px;" title="Number of connected devices";
}
label class="label-small font-gray" { "DEVICES" }
}
}
}
/// Render the data download stack comprised of a download icon, traffic value
/// and label.
///
/// A zero value is displayed if no interface traffic is available for the
/// WLAN interface.
fn render_data_download_stack(iface_traffic: &Option<Vnstat>) -> Markup {
html! {
div class="stack" {
img id="dataDownload" class="icon icon-medium" title="Download" src="/icons/down-arrow.svg" alt="Download";
div class="flex-grid" style="padding-top: 0.5rem;" {
@if let Some(traffic) = iface_traffic {
label class="label-medium" for="dataDownload" style="padding-right: 3px;" title={ "Data download total in " (traffic.all_time_rx_unit) } { (traffic.all_time_rx) }
label class="label-small font-near-black" { (traffic.all_time_rx_unit) }
} @else {
label class="label-medium" for="dataDownload" style="padding-right: 3px;" title="Data download total" { "0" }
label class="label-small font-near-black";
}
}
label class="label-small font-gray" { "DOWNLOAD" }
}
}
}
/// Render the data upload stack comprised of an upload icon, traffic value
/// and label.
///
/// A zero value is displayed if no interface traffic is available for the
/// WLAN interface.
fn render_data_upload_stack(iface_traffic: Option<Vnstat>) -> Markup {
html! {
div class="stack" {
img id="dataUpload" class="icon icon-medium" title="Upload" src="/icons/up-arrow.svg" alt="Upload";
div class="flex-grid" style="padding-top: 0.5rem;" {
@if let Some(traffic) = iface_traffic {
label class="label-medium" for="dataUpload" style="padding-right: 3px;" title={ "Data upload total in " (traffic.all_time_tx_unit) } { (traffic.all_time_tx) }
label class="label-small font-near-black" { (traffic.all_time_tx_unit) }
} @else {
label class="label-medium" for="dataUpload" style="padding-right: 3px;" title="Data upload total" { "0" }
label class="label-small font-near-black";
}
}
label class="label-small font-gray" { "UPLOAD" }
}
}
}
/// Render the device / signal and traffic grid.
///
/// The connected devices stack is displayed if the network mode is set to
/// access point and the signal strength stack is displayed if the network
/// mode is set to WiFi client.
fn render_device_and_traffic_grid(state: NetworkState, iface_traffic: Option<Vnstat>) -> Markup {
html! {
div class="three-grid card-container" {
@match state {
NetworkState::AccessPoint => (render_connected_devices_stack()),
NetworkState::WiFiClient => (render_signal_strength_stack()),
}
(render_data_download_stack(&iface_traffic))
(render_data_upload_stack(iface_traffic))
}
}
}
/// Network state data retrieval.
///
/// This data is injected into the template rendering functions.
fn retrieve_network_data() -> (NetworkState, Option<Vnstat>, String, String) {
// if the access point interface is "up",
// retrieve the traffic stats, ip and ssidfor the ap interface.
// otherwise retrieve the stats and ip for the wlan interface.
let (state, traffic, ip, ssid) = match network::state(AP_IFACE) {
Ok(Some(state)) if state == "up" => {
let ap_traffic = Vnstat::get(AP_IFACE).ok();
let ap_ip = match network::ip(AP_IFACE) {
Ok(Some(ip)) => ip,
_ => String::from("x.x.x.x"),
};
let ap_ssid = String::from("peach");
(NetworkState::AccessPoint, ap_traffic, ap_ip, ap_ssid)
}
_ => {
let wlan_traffic = Vnstat::get(WLAN_IFACE).ok();
let wlan_ip = match network::ip(WLAN_IFACE) {
Ok(Some(ip)) => ip,
_ => String::from("x.x.x.x"),
};
let wlan_ssid = match network::ssid(WLAN_IFACE) {
Ok(Some(ssid)) => ssid,
_ => String::from("Not connected"),
};
(NetworkState::WiFiClient, wlan_traffic, wlan_ip, wlan_ssid)
}
};
(state, traffic, ip, ssid)
}
/// Network status template builder.
pub fn build_template() -> PreEscaped<String> {
let (state, traffic, ip, ssid) = retrieve_network_data();
let network_status_template = html! {
(PreEscaped("<!-- NETWORK STATUS CARD -->"))
div class="card center" {
(PreEscaped("<!-- NETWORK INFO BOX -->"))
div class="capsule capsule-container success-border" {
(render_network_status_grid(&state, ssid, ip))
hr style="color: var(--light-gray);";
(render_device_and_traffic_grid(state, traffic))
}
}
};
let body =
templates::nav::build_template(network_status_template, "Network Status", Some("/status"));
let theme = theme::get_theme();
templates::base::build_template(body, theme)
}

View File

@ -14,7 +14,7 @@ pub fn build_template(
let theme = theme::get_theme();
// conditionally render the hermies icon and theme-switcher icon with correct link
let (hermies, switcher) = match theme.as_str() {
let (hermies, theme_switcher) = match theme.as_str() {
// if we're using the dark theme, render light icons and "light" query param
"dark" => (
"/icons/hermies_hex_light.svg",
@ -56,8 +56,7 @@ pub fn build_template(
a class="nav-item" href="/" {
img class="icon nav-icon-left" src="/icons/peach-icon.png" alt="PeachCloud" title="Home";
}
// render the pre-defined theme-switcher icon
(switcher)
(theme_switcher)
}
}
}