Merge pull request #389 from LibreQoE/long_term_stats

Long term stats > develop
This commit is contained in:
Robert Chacón
2023-08-11 12:01:59 -06:00
committed by GitHub
59 changed files with 2752 additions and 1570 deletions

View File

@@ -2,7 +2,7 @@
//! collection.
use lqos_config::ShapedDevice;
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
use uisp::Device;
/// Type that provides a minimum, maximum and average value
@@ -133,8 +133,18 @@ impl From<Device> for UispExtDevice {
fn from(d: Device) -> Self {
let device_id = d.identification.id.to_string();
let device_name = d.get_name().as_ref().unwrap_or(&"".to_string()).to_string();
let model = d.identification.modelName.as_ref().unwrap_or(&"".to_string()).to_string();
let firmware = d.identification.firmwareVersion.as_ref().unwrap_or(&"".to_string()).to_string();
let model = d
.identification
.modelName
.as_ref()
.unwrap_or(&"".to_string())
.to_string();
let firmware = d
.identification
.firmwareVersion
.as_ref()
.unwrap_or(&"".to_string())
.to_string();
let mode = d.mode.as_ref().unwrap_or(&"".to_string()).to_string();
let status;
let frequency;
@@ -165,26 +175,59 @@ impl From<Device> for UispExtDevice {
let mut iflist = Vec::new();
if let Some(interfaces) = &d.interfaces {
interfaces.iter().for_each(|i| {
if let (Some(id), Some(status), Some(wireless), Some(addr)) = (&i.identification, &i.status, &i.wireless, &i.addresses) {
if let Some(wireless) = &i.wireless {
if let Some(nf) = wireless.noiseFloor {
noise_floor = nf;
}
}
if let Some(addr) = &i.addresses {
let mut ip = Vec::new();
addr.iter().for_each(|a| {
if let Some(ipaddr) = &a.cidr {
ip.push(ipaddr.to_string());
}
});
}
iflist.push(UispExtDeviceInterface {
name: id.name.as_ref().unwrap_or(&"".to_string()).to_string(),
mac: id.mac.as_ref().unwrap_or(&"".to_string()).to_string(),
status: status.status.as_ref().unwrap_or(&"".to_string()).to_string(),
speed: status.speed.as_ref().unwrap_or(&"".to_string()).to_string(),
ip,
let mut if_name = "".to_string();
let mut if_mac = "".to_string();
if let Some(id) = &i.identification {
if let Some(name) = &id.name {
if_name = name.to_string();
}
if let Some(mac) = &id.mac {
if_mac = mac.to_string();
}
}
let mut if_status = "".to_string();
let mut if_speed = "".to_string();
if let Some(status) = &i.status {
if let Some(s) = &status.status {
if_status = s.to_string();
}
if let Some(s) = &status.speed {
if_speed = s.to_string();
}
}
let mut if_ip = Vec::new();
if let Some(addr) = &i.addresses {
addr.iter().for_each(|a| {
if let Some(ipaddr) = &a.cidr {
if_ip.push(ipaddr.to_string());
}
});
}
iflist.push(UispExtDeviceInterface {
name: if_name,
mac: if_mac,
status: if_status,
speed: if_speed,
ip: if_ip,
});
});
}
@@ -205,4 +248,4 @@ impl From<Device> for UispExtDevice {
interfaces: iflist,
}
}
}
}

View File

@@ -4,6 +4,10 @@ version = "0.1.0"
edition = "2021"
license = "GPL-2.0-only"
[features]
default = []
tokio-console = ["console-subscriber"]
[dependencies]
tokio = { version = "1.25.0", features = ["full"] }
anyhow = "1"
@@ -26,6 +30,6 @@ chrono = "0"
miniz_oxide = "0.7.1"
tokio-util = { version = "0.7.8", features = ["io"] }
wasm_pipe_types = { path = "../wasm_pipe_types" }
console-subscriber = "0.1.10"
console-subscriber = {version = "0.1.10", optional = true }
itertools = "0.11.0"
urlencoding = "2.1.3"

View File

@@ -2,8 +2,8 @@ mod web;
use tracing::{error, info};
use tracing_subscriber::fmt::format::FmtSpan;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
#[cfg(not(feature="tokio-console"))]
fn set_console_logging() -> anyhow::Result<()> {
// install global collector configured based on RUST_LOG env var.
let subscriber = tracing_subscriber::fmt()
// Use a more compact, abbreviated log format
@@ -23,9 +23,29 @@ async fn main() -> anyhow::Result<()> {
// Set the subscriber as the default
tracing::subscriber::set_global_default(subscriber)?;
Ok(())
}
#[cfg(feature="tokio-console")]
fn set_tokio_console() {
// Initialize the Tokio Console subscription
//console_subscriber::init();
console_subscriber::init();
}
#[cfg(not(feature="tokio-console"))]
fn setup_tracing() {
set_console_logging().unwrap();
}
#[cfg(feature="tokio-console")]
fn setup_tracing() {
set_tokio_console();
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
setup_tracing();
// Get the database connection pool
let pool = pgdb::get_connection_pool(5).await;

View File

@@ -1,12 +1,10 @@
use axum::extract::ws::WebSocket;
use pgdb::sqlx::{Pool, Postgres};
use serde::Serialize;
use tokio::sync::mpsc::Sender;
use tracing::instrument;
use wasm_pipe_types::WasmResponse;
use super::send_response;
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Clone)]
pub struct LoginResult {
pub msg: String,
pub token: String,
@@ -14,8 +12,8 @@ pub struct LoginResult {
pub license_key: String,
}
#[instrument(skip(license, username, password, socket, cnn))]
pub async fn on_login(license: &str, username: &str, password: &str, socket: &mut WebSocket, cnn: Pool<Postgres>) -> Option<LoginResult> {
#[instrument(skip(license, username, password, tx, cnn))]
pub async fn on_login(license: &str, username: &str, password: &str, tx: Sender<WasmResponse>, cnn: Pool<Postgres>) -> Option<LoginResult> {
let login = pgdb::try_login(cnn, license, username, password).await;
if let Ok(login) = login {
let lr = WasmResponse::LoginOk {
@@ -23,7 +21,7 @@ pub async fn on_login(license: &str, username: &str, password: &str, socket: &mu
name: login.name.clone(),
license_key: license.to_string(),
};
send_response(socket, lr).await;
tx.send(lr).await.unwrap();
return Some(LoginResult {
msg: "Login Ok".to_string(),
token: login.token.to_string(),
@@ -32,13 +30,13 @@ pub async fn on_login(license: &str, username: &str, password: &str, socket: &mu
});
} else {
let lr = WasmResponse::LoginFail;
send_response(socket, lr).await;
tx.send(lr).await.unwrap();
}
None
}
#[instrument(skip(token_id, socket, cnn))]
pub async fn on_token_auth(token_id: &str, socket: &mut WebSocket, cnn: Pool<Postgres>) -> Option<LoginResult> {
#[instrument(skip(token_id, tx, cnn))]
pub async fn on_token_auth(token_id: &str, tx: Sender<WasmResponse>, cnn: Pool<Postgres>) -> Option<LoginResult> {
let login = pgdb::token_to_credentials(cnn, token_id).await;
if let Ok(login) = login {
let lr = WasmResponse::AuthOk {
@@ -46,7 +44,7 @@ pub async fn on_token_auth(token_id: &str, socket: &mut WebSocket, cnn: Pool<Pos
name: login.name.clone(),
license_key: login.license.clone(),
};
send_response(socket, lr).await;
tx.send(lr).await.unwrap();
return Some(LoginResult {
msg: "Login Ok".to_string(),
token: login.token.to_string(),
@@ -54,7 +52,7 @@ pub async fn on_token_auth(token_id: &str, socket: &mut WebSocket, cnn: Pool<Pos
license_key: login.license.to_string(),
});
} else {
send_response(socket, WasmResponse::AuthFail).await;
tx.send(WasmResponse::AuthFail).await.unwrap();
}
None
}

View File

@@ -1,3 +1,4 @@
use std::sync::Arc;
use crate::web::wss::{
nodes::node_status,
queries::{
@@ -5,16 +6,14 @@ use crate::web::wss::{
send_extended_device_capacity_graph, send_extended_device_info,
send_extended_device_snr_graph,
},
omnisearch, root_heat_map, send_circuit_info, send_packets_for_all_nodes,
send_packets_for_node, send_perf_for_node, send_rtt_for_all_nodes,
send_rtt_for_all_nodes_circuit, send_rtt_for_all_nodes_site, send_rtt_for_node,
send_rtt_histogram_for_all_nodes,
send_site_info, send_site_parents, send_site_stack_map, send_throughput_for_all_nodes,
omnisearch, root_heat_map, send_circuit_info, send_circuit_parents,
send_packets_for_all_nodes, send_packets_for_node, send_perf_for_node, send_root_parents,
send_rtt_for_all_nodes, send_rtt_for_all_nodes_circuit, send_rtt_for_all_nodes_site,
send_rtt_for_node, send_rtt_histogram_for_all_nodes, send_site_info, send_site_parents,
send_site_stack_map, send_throughput_for_all_nodes,
send_throughput_for_all_nodes_by_circuit, send_throughput_for_all_nodes_by_site,
send_throughput_for_node, site_heat_map,
site_tree::send_site_tree,
time_period::InfluxTimePeriod,
send_circuit_parents, send_root_parents,
},
};
use axum::{
@@ -25,12 +24,13 @@ use axum::{
response::IntoResponse,
};
use pgdb::sqlx::{Pool, Postgres};
use tokio::sync::{mpsc::Sender, Mutex};
use tracing::instrument;
use wasm_pipe_types::{WasmRequest, WasmResponse};
use self::queries::InfluxTimePeriod;
mod login;
mod nodes;
mod queries;
mod influx_query_builder;
pub async fn ws_handler(
ws: WebSocketUpgrade,
@@ -39,292 +39,354 @@ pub async fn ws_handler(
ws.on_upgrade(move |sock| handle_socket(sock, state))
}
#[instrument(skip(socket, cnn), name = "handle_wss")]
async fn handle_socket(mut socket: WebSocket, cnn: Pool<Postgres>) {
tracing::info!("WebSocket Connected");
let mut credentials: Option<login::LoginResult> = None;
while let Some(msg) = socket.recv().await {
let cnn = cnn.clone();
let msg = msg.unwrap();
let credentials: Arc<Mutex<Option<login::LoginResult>>> = Arc::new(Mutex::new(None));
// Get the binary message and decompress it
tracing::info!("Received a message: {:?}", msg);
let raw = msg.into_data();
let uncompressed = miniz_oxide::inflate::decompress_to_vec(&raw).unwrap();
let msg = lts_client::cbor::from_slice::<WasmRequest>(&uncompressed).unwrap();
tracing::info!("{msg:?}");
// Setup the send/receive channel
let (tx, mut rx) = tokio::sync::mpsc::channel::<WasmResponse>(10);
// Update the token credentials (if there are any)
if let Some(credentials) = &credentials {
let _ = pgdb::refresh_token(cnn.clone(), &credentials.token).await;
loop {
tokio::select! {
msg = socket.recv() => {
match msg {
Some(msg) => {
tokio::spawn(
handle_socket_message(msg.unwrap(), cnn.clone(), credentials.clone(), tx.clone())
);
}
None => {
tracing::info!("WebSocket Disconnected");
break;
}
}
},
msg = rx.recv() => {
match msg {
Some(msg) => {
let serialized = serialize_response(msg);
socket.send(Message::Binary(serialized)).await.unwrap();
}
None => {
tracing::info!("WebSocket Disconnected");
break;
}
}
},
}
}
}
// Handle the message by type
let matcher = (&msg, &mut credentials);
let wss = &mut socket;
match matcher {
// Handle login with just a token
(WasmRequest::Auth { token }, _) => {
let result = login::on_token_auth(token, &mut socket, cnn).await;
if let Some(result) = result {
credentials = Some(result);
}
}
// Handle login with a username and password
(
WasmRequest::Login {
license,
username,
password,
},
_,
) => {
let result = login::on_login(license, username, password, &mut socket, cnn).await;
if let Some(result) = result {
credentials = Some(result);
}
}
// Node status for dashboard
(WasmRequest::GetNodeStatus, Some(credentials)) => {
node_status(&cnn, wss, &credentials.license_key).await;
}
// Packet chart for dashboard
(WasmRequest::PacketChart { period }, Some(credentials)) => {
let _ =
send_packets_for_all_nodes(&cnn, wss, &credentials.license_key, period.into())
.await;
}
// Packet chart for individual node
(
WasmRequest::PacketChartSingle {
period,
node_id,
node_name,
},
Some(credentials),
) => {
let _ = send_packets_for_node(
&cnn,
wss,
&credentials.license_key,
period.into(),
node_id,
node_name,
)
.await;
}
// Throughput chart for the dashboard
(WasmRequest::ThroughputChart { period }, Some(credentials)) => {
let _ = send_throughput_for_all_nodes(
&cnn,
wss,
&credentials.license_key,
InfluxTimePeriod::new(period),
)
.await;
}
// Throughput chart for a single shaper node
(
WasmRequest::ThroughputChartSingle {
period,
node_id,
node_name,
},
Some(credentials),
) => {
let _ = send_throughput_for_node(
&cnn,
wss,
&credentials.license_key,
InfluxTimePeriod::new(period),
node_id.to_string(),
node_name.to_string(),
)
.await;
}
(WasmRequest::ThroughputChartSite { period, site_id }, Some(credentials)) => {
let site_id = urlencoding::decode(site_id).unwrap();
let _ = send_throughput_for_all_nodes_by_site(
&cnn,
wss,
&credentials.license_key,
site_id.to_string(),
InfluxTimePeriod::new(period),
)
.await;
}
(WasmRequest::ThroughputChartCircuit { period, circuit_id }, Some(credentials)) => {
let _ = send_throughput_for_all_nodes_by_circuit(
&cnn,
wss,
&credentials.license_key,
circuit_id.to_string(),
InfluxTimePeriod::new(period),
)
.await;
}
// Rtt Chart
(WasmRequest::RttChart { period }, Some(credentials)) => {
let _ = send_rtt_for_all_nodes(
&cnn,
wss,
&credentials.license_key,
InfluxTimePeriod::new(period),
)
.await;
}
(WasmRequest::RttHistogram { period }, Some(credentials)) => {
let _ = send_rtt_histogram_for_all_nodes(
&cnn,
wss,
&credentials.license_key,
InfluxTimePeriod::new(period),
)
.await;
}
(WasmRequest::RttChartSite { period, site_id }, Some(credentials)) => {
let site_id = urlencoding::decode(site_id).unwrap();
let _ = send_rtt_for_all_nodes_site(
&cnn,
wss,
&credentials.license_key,
site_id.to_string(),
InfluxTimePeriod::new(period),
)
.await;
}
(
WasmRequest::RttChartSingle {
period,
node_id,
node_name,
},
Some(credentials),
) => {
let _ = send_rtt_for_node(
&cnn,
wss,
&credentials.license_key,
InfluxTimePeriod::new(period),
node_id.to_string(),
node_name.to_string(),
)
.await;
}
(WasmRequest::RttChartCircuit { period, circuit_id }, Some(credentials)) => {
let _ = send_rtt_for_all_nodes_circuit(
&cnn,
wss,
&credentials.license_key,
circuit_id.to_string(),
InfluxTimePeriod::new(period),
)
.await;
}
// Site Stack
(WasmRequest::SiteStack { period, site_id }, Some(credentials)) => {
let site_id = urlencoding::decode(site_id).unwrap();
let _ = send_site_stack_map(
&cnn,
wss,
&credentials.license_key,
InfluxTimePeriod::new(period),
site_id.to_string(),
)
.await;
}
(WasmRequest::RootHeat { period }, Some(credentials)) => {
let _ = root_heat_map(
&cnn,
wss,
&credentials.license_key,
InfluxTimePeriod::new(period),
)
.await;
}
(WasmRequest::SiteHeat { period, site_id }, Some(credentials)) => {
let _ = site_heat_map(
&cnn,
wss,
&credentials.license_key,
site_id,
InfluxTimePeriod::new(period),
)
.await;
}
(
WasmRequest::NodePerfChart {
period,
node_id,
node_name,
},
Some(credentials),
) => {
let _ = send_perf_for_node(
&cnn,
wss,
&credentials.license_key,
InfluxTimePeriod::new(period),
node_id.to_string(),
node_name.to_string(),
)
.await;
}
(WasmRequest::Tree { parent }, Some(credentials)) => {
send_site_tree(&cnn, wss, &credentials.license_key, parent).await;
}
(WasmRequest::SiteInfo { site_id }, Some(credentials)) => {
send_site_info(&cnn, wss, &credentials.license_key, site_id).await;
}
(WasmRequest::SiteParents { site_id }, Some(credentials)) => {
let site_id = urlencoding::decode(site_id).unwrap();
send_site_parents(&cnn, wss, &credentials.license_key, &site_id).await;
}
(WasmRequest::CircuitParents { circuit_id }, Some(credentials)) => {
let circuit_id = urlencoding::decode(circuit_id).unwrap();
send_circuit_parents(&cnn, wss, &credentials.license_key, &circuit_id).await;
}
(WasmRequest::RootParents, Some(credentials)) => {
send_root_parents(&cnn, wss, &credentials.license_key).await;
}
(WasmRequest::Search { term }, Some(credentials)) => {
let _ = omnisearch(&cnn, wss, &credentials.license_key, term).await;
}
(WasmRequest::CircuitInfo { circuit_id }, Some(credentials)) => {
send_circuit_info(&cnn, wss, &credentials.license_key, circuit_id).await;
}
(WasmRequest::ExtendedDeviceInfo { circuit_id }, Some(credentials)) => {
send_extended_device_info(&cnn, wss, &credentials.license_key, circuit_id).await;
}
(WasmRequest::SignalNoiseChartExt { period, device_id }, Some(credentials)) => {
let _ = send_extended_device_snr_graph(
&cnn,
wss,
&credentials.license_key,
device_id,
InfluxTimePeriod::new(period),
)
.await;
}
(WasmRequest::DeviceCapacityChartExt { period, device_id }, Some(credentials)) => {
let _ = send_extended_device_capacity_graph(
&cnn,
wss,
&credentials.license_key,
device_id,
InfluxTimePeriod::new(period),
)
.await;
}
(WasmRequest::ApSignalExt { period, site_name }, Some(credentials)) => {
#[instrument(skip(credentials, cnn))]
async fn update_token_credentials(
credentials: Arc<Mutex<Option<login::LoginResult>>>,
cnn: Pool<Postgres>,
) {
let mut credentials = credentials.lock().await;
if let Some(credentials) = &mut *credentials {
let _ = pgdb::refresh_token(cnn, &credentials.token).await;
}
}
async fn set_credentials(
credentials: Arc<Mutex<Option<login::LoginResult>>>,
result: login::LoginResult,
) {
let mut credentials = credentials.lock().await;
*credentials = Some(result);
}
fn extract_message(msg: Message) -> WasmRequest {
let raw = msg.into_data();
let uncompressed = miniz_oxide::inflate::decompress_to_vec(&raw).unwrap();
lts_client::cbor::from_slice::<WasmRequest>(&uncompressed).unwrap()
}
async fn handle_auth_message(
msg: &WasmRequest,
credentials: Arc<Mutex<Option<login::LoginResult>>>,
tx: Sender<WasmResponse>,
cnn: Pool<Postgres>,
) {
match msg {
// Handle login with just a token
WasmRequest::Auth { token } => {
let result = login::on_token_auth(token, tx, cnn).await;
if let Some(result) = result {
set_credentials(credentials, result).await;
}
(WasmRequest::ApCapacityExt { period, site_name }, Some(credentials)) => {
}
(_, None) => {
tracing::error!("No credentials");
}
// Handle a full login
WasmRequest::Login { license, username, password } => {
let result = login::on_login(license, username, password, tx, cnn).await;
if let Some(result) = result {
set_credentials(credentials, result).await;
}
}
_ => {}
}
}
async fn handle_socket_message(
msg: Message,
cnn: Pool<Postgres>,
credentials: Arc<Mutex<Option<login::LoginResult>>>,
tx: Sender<WasmResponse>,
) {
// Get the binary message and decompress it
let msg = extract_message(msg);
update_token_credentials(credentials.clone(), cnn.clone()).await;
// Handle the message by type
handle_auth_message(&msg, credentials.clone(), tx.clone(), cnn.clone()).await;
let my_credentials = {
let lock = credentials.lock().await;
lock.clone()
};
let matcher = (&msg, &my_credentials);
match matcher {
// Node status for dashboard
(WasmRequest::GetNodeStatus, Some(credentials)) => {
node_status(&cnn, tx, &credentials.license_key).await;
}
// Packet chart for dashboard
(WasmRequest::PacketChart { period }, Some(credentials)) => {
let _ =
send_packets_for_all_nodes(&cnn, tx, &credentials.license_key, period.into()).await;
}
// Packet chart for individual node
(
WasmRequest::PacketChartSingle {
period,
node_id,
node_name,
},
Some(credentials),
) => {
let _ = send_packets_for_node(
&cnn,
tx,
&credentials.license_key,
period.into(),
node_id,
node_name,
)
.await;
}
// Throughput chart for the dashboard
(WasmRequest::ThroughputChart { period }, Some(credentials)) => {
let _ = send_throughput_for_all_nodes(
&cnn,
tx,
&credentials.license_key,
InfluxTimePeriod::new(period),
)
.await;
}
// Throughput chart for a single shaper node
(
WasmRequest::ThroughputChartSingle {
period,
node_id,
node_name,
},
Some(credentials),
) => {
let _ = send_throughput_for_node(
&cnn,
tx,
&credentials.license_key,
InfluxTimePeriod::new(period),
node_id.to_string(),
node_name.to_string(),
)
.await;
}
(WasmRequest::ThroughputChartSite { period, site_id }, Some(credentials)) => {
let site_id = urlencoding::decode(site_id).unwrap();
let _ = send_throughput_for_all_nodes_by_site(
&cnn,
tx,
&credentials.license_key,
site_id.to_string(),
InfluxTimePeriod::new(period),
)
.await;
}
(WasmRequest::ThroughputChartCircuit { period, circuit_id }, Some(credentials)) => {
let _ = send_throughput_for_all_nodes_by_circuit(
&cnn,
tx,
&credentials.license_key,
circuit_id.to_string(),
InfluxTimePeriod::new(period),
)
.await;
}
// Rtt Chart
(WasmRequest::RttChart { period }, Some(credentials)) => {
let _ = send_rtt_for_all_nodes(
&cnn,
tx,
&credentials.license_key,
InfluxTimePeriod::new(period),
)
.await;
}
(WasmRequest::RttHistogram { period }, Some(credentials)) => {
let _ = send_rtt_histogram_for_all_nodes(
&cnn,
tx,
&credentials.license_key,
InfluxTimePeriod::new(period),
)
.await;
}
(WasmRequest::RttChartSite { period, site_id }, Some(credentials)) => {
let site_id = urlencoding::decode(site_id).unwrap();
let _ = send_rtt_for_all_nodes_site(
&cnn,
tx,
&credentials.license_key,
site_id.to_string(),
InfluxTimePeriod::new(period),
)
.await;
}
(
WasmRequest::RttChartSingle {
period,
node_id,
node_name,
},
Some(credentials),
) => {
let _ = send_rtt_for_node(
&cnn,
tx,
&credentials.license_key,
InfluxTimePeriod::new(period),
node_id.to_string(),
node_name.to_string(),
)
.await;
}
(WasmRequest::RttChartCircuit { period, circuit_id }, Some(credentials)) => {
let _ = send_rtt_for_all_nodes_circuit(
&cnn,
tx,
&credentials.license_key,
circuit_id.to_string(),
InfluxTimePeriod::new(period),
)
.await;
}
// Site Stack
(WasmRequest::SiteStack { period, site_id }, Some(credentials)) => {
let site_id = urlencoding::decode(site_id).unwrap();
let _ = send_site_stack_map(
&cnn,
tx,
&credentials.license_key,
InfluxTimePeriod::new(period),
site_id.to_string(),
)
.await;
}
(WasmRequest::RootHeat { period }, Some(credentials)) => {
let _ = root_heat_map(
&cnn,
tx,
&credentials.license_key,
InfluxTimePeriod::new(period),
)
.await;
}
(WasmRequest::SiteHeat { period, site_id }, Some(credentials)) => {
let _ = site_heat_map(
&cnn,
tx,
&credentials.license_key,
site_id,
InfluxTimePeriod::new(period),
)
.await;
}
(
WasmRequest::NodePerfChart {
period,
node_id,
node_name,
},
Some(credentials),
) => {
let _ = send_perf_for_node(
&cnn,
tx,
&credentials.license_key,
InfluxTimePeriod::new(period),
node_id.to_string(),
node_name.to_string(),
)
.await;
}
(WasmRequest::Tree { parent }, Some(credentials)) => {
send_site_tree(&cnn, tx, &credentials.license_key, parent).await;
}
(WasmRequest::SiteInfo { site_id }, Some(credentials)) => {
send_site_info(&cnn, tx, &credentials.license_key, site_id).await;
}
(WasmRequest::SiteParents { site_id }, Some(credentials)) => {
let site_id = urlencoding::decode(site_id).unwrap();
send_site_parents(&cnn, tx, &credentials.license_key, &site_id).await;
}
(WasmRequest::CircuitParents { circuit_id }, Some(credentials)) => {
let circuit_id = urlencoding::decode(circuit_id).unwrap();
send_circuit_parents(&cnn, tx, &credentials.license_key, &circuit_id).await;
}
(WasmRequest::RootParents, Some(credentials)) => {
send_root_parents(&cnn, tx, &credentials.license_key).await;
}
(WasmRequest::Search { term }, Some(credentials)) => {
let _ = omnisearch(&cnn, tx, &credentials.license_key, term).await;
}
(WasmRequest::CircuitInfo { circuit_id }, Some(credentials)) => {
send_circuit_info(&cnn, tx, &credentials.license_key, circuit_id).await;
}
(WasmRequest::ExtendedDeviceInfo { circuit_id }, Some(credentials)) => {
send_extended_device_info(&cnn, tx, &credentials.license_key, circuit_id).await;
}
(WasmRequest::SignalNoiseChartExt { period, device_id }, Some(credentials)) => {
let _ = send_extended_device_snr_graph(
&cnn,
tx,
&credentials.license_key,
device_id,
&InfluxTimePeriod::new(period),
)
.await;
}
(WasmRequest::DeviceCapacityChartExt { period, device_id }, Some(credentials)) => {
let _ = send_extended_device_capacity_graph(
&cnn,
tx,
&credentials.license_key,
device_id,
&InfluxTimePeriod::new(period),
)
.await;
}
(WasmRequest::ApSignalExt { period, site_name }, Some(credentials)) => {}
(WasmRequest::ApCapacityExt { period, site_name }, Some(credentials)) => {}
(_, None) => {
tracing::error!("No credentials");
}
_ => {
let error = format!("Unknown message: {msg:?}");
tracing::error!(error);
}
}
}
@@ -332,9 +394,3 @@ fn serialize_response(response: WasmResponse) -> Vec<u8> {
let cbor = lts_client::cbor::to_vec(&response).unwrap();
miniz_oxide::deflate::compress_to_vec(&cbor, 8)
}
#[instrument(skip(socket, response))]
pub async fn send_response(socket: &mut WebSocket, response: WasmResponse) {
let serialized = serialize_response(response);
socket.send(Message::Binary(serialized)).await.unwrap();
}

View File

@@ -1,9 +1,7 @@
use axum::extract::ws::WebSocket;
use pgdb::sqlx::{Pool, Postgres};
use tokio::sync::mpsc::Sender;
use tracing::instrument;
use wasm_pipe_types::Node;
use crate::web::wss::send_response;
use wasm_pipe_types::{Node, WasmResponse};
fn convert(ns: pgdb::NodeStatus) -> Node {
Node {
@@ -13,14 +11,14 @@ fn convert(ns: pgdb::NodeStatus) -> Node {
}
}
#[instrument(skip(cnn, socket, key))]
pub async fn node_status(cnn: &Pool<Postgres>, socket: &mut WebSocket, key: &str) {
#[instrument(skip(cnn, tx, key))]
pub async fn node_status(cnn: &Pool<Postgres>, tx: Sender<WasmResponse>, key: &str) {
tracing::info!("Fetching node status, {key}");
let nodes = pgdb::node_status(cnn, key).await;
match nodes {
Ok(nodes) => {
let nodes: Vec<Node> = nodes.into_iter().map(convert).collect();
send_response(socket, wasm_pipe_types::WasmResponse::NodeStatus { nodes }).await;
tx.send(wasm_pipe_types::WasmResponse::NodeStatus { nodes }).await.unwrap();
},
Err(e) => {
tracing::error!("Unable to obtain node status: {}", e);

View File

@@ -1,8 +1,6 @@
use axum::extract::ws::WebSocket;
use pgdb::sqlx::{Pool, Postgres};
use wasm_pipe_types::CircuitList;
use crate::web::wss::send_response;
use tokio::sync::mpsc::Sender;
use wasm_pipe_types::{CircuitList, WasmResponse};
fn from(circuit: pgdb::CircuitInfo) -> CircuitList {
CircuitList {
@@ -21,9 +19,10 @@ fn from(circuit: pgdb::CircuitInfo) -> CircuitList {
}
}
pub async fn send_circuit_info(cnn: &Pool<Postgres>, socket: &mut WebSocket, key: &str, circuit_id: &str) {
#[tracing::instrument(skip(cnn, tx, key, circuit_id))]
pub async fn send_circuit_info(cnn: &Pool<Postgres>, tx: Sender<WasmResponse>, key: &str, circuit_id: &str) {
if let Ok(hosts) = pgdb::get_circuit_info(cnn, key, circuit_id).await {
let hosts = hosts.into_iter().map(from).collect::<Vec<_>>();
send_response(socket, wasm_pipe_types::WasmResponse::CircuitInfo { data: hosts }).await;
tx.send(WasmResponse::CircuitInfo { data: hosts }).await.unwrap();
}
}

View File

@@ -1,19 +1,22 @@
use std::collections::HashSet;
use axum::extract::ws::WebSocket;
use chrono::{DateTime, FixedOffset};
use influxdb2::{FromDataPoint, models::Query, Client};
use pgdb::{sqlx::{Pool, Postgres}, organization_cache::get_org_details};
use crate::web::wss::send_response;
use super::time_period::InfluxTimePeriod;
use influxdb2::FromDataPoint;
use pgdb::sqlx::{Pool, Postgres};
use tokio::sync::mpsc::Sender;
use wasm_pipe_types::{WasmResponse, SignalNoiseChartExt, CapacityChartExt};
use super::{influx::InfluxTimePeriod, QueryBuilder};
#[tracing::instrument(skip(cnn, tx, key, circuit_id))]
pub async fn send_extended_device_info(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
circuit_id: &str,
) {
// Get devices for circuit
if let Ok(hosts_list) = pgdb::get_circuit_info(cnn, key, circuit_id).await {
tracing::error!("Got hosts list: {:?}", hosts_list);
// Get the hosts known to be in this circuit
let mut hosts = HashSet::new();
hosts_list.into_iter().for_each(|h| {
@@ -60,49 +63,40 @@ pub async fn send_extended_device_info(
// If there is any, send it
println!("{extended_data:?}");
if !extended_data.is_empty() {
send_response(socket, wasm_pipe_types::WasmResponse::DeviceExt { data: extended_data }).await;
tx.send(WasmResponse::DeviceExt { data: extended_data }).await.unwrap();
}
}
}
#[tracing::instrument(skip(cnn, tx, key, device_id, period))]
pub async fn send_extended_device_snr_graph(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
device_id: &str,
period: InfluxTimePeriod,
period: &InfluxTimePeriod,
) -> anyhow::Result<()> {
if let Some(org) = get_org_details(cnn, key).await {
let influx_url = format!("http://{}:8086", org.influx_host);
let client = Client::new(influx_url, &org.influx_org, &org.influx_token);
let qs = format!(
"from(bucket: \"{}\")
|> {}
|> filter(fn: (r) => r[\"_measurement\"] == \"device_ext\")
|> filter(fn: (r) => r[\"organization_id\"] == \"{}\")
|> filter(fn: (r) => r[\"device_id\"] == \"{}\")
|> filter(fn: (r) => r[\"_field\"] == \"noise_floor\" or r[\"_field\"] == \"rx_signal\")
|> {}
|> yield(name: \"last\")",
org.influx_bucket, period.range(), org.key, device_id, period.aggregate_window()
);
//println!("{qs}");
let query = Query::new(qs);
let rows = client.query::<SnrRow>(Some(query)).await?;
let mut sn = Vec::new();
rows.iter().for_each(|row| {
let snr = wasm_pipe_types::SignalNoiseChartExt {
let rows = QueryBuilder::new()
.with_period(period)
.derive_org(cnn, key)
.await
.bucket()
.range()
.measure_fields_org("device_ext", &["noise_floor", "rx_signal"])
.filter(&format!("r[\"device_id\"] == \"{}\"", device_id))
.aggregate_window()
.execute::<SnrRow>()
.await?
.into_iter()
.map(|row| {
wasm_pipe_types::SignalNoiseChartExt {
noise: row.noise_floor,
signal: row.rx_signal,
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
};
sn.push(snr);
});
send_response(socket, wasm_pipe_types::WasmResponse::DeviceExtSnr { data: sn, device_id: device_id.to_string() }).await;
}
}
})
.collect::<Vec<SignalNoiseChartExt>>();
tx.send(WasmResponse::DeviceExtSnr { data: rows, device_id: device_id.to_string() }).await?;
Ok(())
}
@@ -129,44 +123,35 @@ pub struct SnrRow {
pub time: DateTime<FixedOffset>,
}
#[tracing::instrument(skip(cnn, tx, key, device_id, period))]
pub async fn send_extended_device_capacity_graph(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
device_id: &str,
period: InfluxTimePeriod,
period: &InfluxTimePeriod,
) -> anyhow::Result<()> {
if let Some(org) = get_org_details(cnn, key).await {
let influx_url = format!("http://{}:8086", org.influx_host);
let client = Client::new(influx_url, &org.influx_org, &org.influx_token);
let qs = format!(
"from(bucket: \"{}\")
|> {}
|> filter(fn: (r) => r[\"_measurement\"] == \"device_ext\")
|> filter(fn: (r) => r[\"organization_id\"] == \"{}\")
|> filter(fn: (r) => r[\"device_id\"] == \"{}\")
|> filter(fn: (r) => r[\"_field\"] == \"dl_capacity\" or r[\"_field\"] == \"ul_capacity\")
|> {}
|> yield(name: \"last\")",
org.influx_bucket, period.range(), org.key, device_id, period.aggregate_window()
);
//println!("{qs}");
let query = Query::new(qs);
let rows = client.query::<CapacityRow>(Some(query)).await?;
let mut sn = Vec::new();
rows.iter().for_each(|row| {
let snr = wasm_pipe_types::CapacityChartExt {
let rows = QueryBuilder::new()
.with_period(period)
.derive_org(cnn, key)
.await
.bucket()
.range()
.measure_fields_org("device_ext", &["dl_capacity", "ul_capacity"])
.filter(&format!("r[\"device_id\"] == \"{}\"", device_id))
.aggregate_window()
.execute::<CapacityRow>()
.await?
.into_iter()
.map(|row| {
wasm_pipe_types::CapacityChartExt {
dl: row.dl_capacity,
ul: row.ul_capacity,
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
};
sn.push(snr);
});
send_response(socket, wasm_pipe_types::WasmResponse::DeviceExtCapacity { data: sn, device_id: device_id.to_string() }).await;
}
}
})
.collect::<Vec<CapacityChartExt>>();
tx.send(WasmResponse::DeviceExtCapacity { data: rows, device_id: device_id.to_string() }).await?;
Ok(())
}

View File

@@ -4,7 +4,7 @@ use influxdb2_structmap::FromMap;
use pgdb::{sqlx::{Pool, Postgres}, organization_cache::get_org_details, OrganizationDetails};
use anyhow::{Result, Error};
use tracing::instrument;
use super::queries::time_period::InfluxTimePeriod;
use super::InfluxTimePeriod;
#[derive(Debug)]
pub struct InfluxQueryBuilder {
@@ -139,7 +139,7 @@ impl InfluxQueryBuilder {
}
// Add any other filters
for filter in self.filters.iter() {
for filter in self.filters.iter().filter(|f| !f.is_empty()) {
lines.push(format!("|> filter(fn: (r) => {})", filter));
}

View File

@@ -0,0 +1,8 @@
//! InfluxDB query builder and support code.
mod influx_query_builder;
pub use influx_query_builder::*;
mod time_period;
pub use time_period::*;
mod query_builder2;
pub use query_builder2::*;

View File

@@ -0,0 +1,155 @@
use super::InfluxTimePeriod;
use influxdb2::{models::Query, Client};
use influxdb2_structmap::FromMap;
use pgdb::{
organization_cache::get_org_details,
sqlx::{Pool, Postgres},
OrganizationDetails,
};
pub struct QueryBuilder<'a> {
lines: Vec<String>,
period: Option<&'a InfluxTimePeriod>,
org: Option<OrganizationDetails>,
}
#[allow(dead_code)]
impl<'a> QueryBuilder<'a> {
/// Construct a new, completely empty query.
pub fn new() -> Self {
Self {
lines: Vec::new(),
period: None,
org: None,
}
}
pub fn with_period(mut self, period: &'a InfluxTimePeriod) -> Self {
self.period = Some(period);
self
}
pub fn with_org(mut self, org: OrganizationDetails) -> Self {
self.org = Some(org);
self
}
pub async fn derive_org(mut self, cnn: &Pool<Postgres>, key: &str) -> QueryBuilder<'a> {
let org = get_org_details(cnn, key).await;
self.org = org;
self
}
pub fn add_line(mut self, line: &str) -> Self {
self.lines.push(line.to_string());
self
}
pub fn add_lines(mut self, lines: &[&str]) -> Self {
for line in lines.iter() {
self.lines.push(line.to_string());
}
self
}
pub fn bucket(mut self) -> Self {
if let Some(org) = &self.org {
self.lines
.push(format!("from(bucket: \"{}\")", org.influx_bucket));
} else {
tracing::warn!("No organization in query, cannot add bucket");
}
self
}
pub fn range(mut self) -> Self {
if let Some(period) = &self.period {
self.lines.push(format!("|> {}", period.range()));
} else {
tracing::warn!("No period in query, cannot add range");
}
self
}
pub fn filter(mut self, filter: &str) -> Self {
if !filter.is_empty() {
self.lines.push(format!("|> filter(fn: (r) => {})", filter));
}
self
}
pub fn filter_and(mut self, filters: &[&str]) -> Self {
let all_filters = filters.join(" and ");
self.lines
.push(format!("|> filter(fn: (r) => {})", all_filters));
self
}
pub fn measure_field_org(mut self, measurement: &str, field: &str) -> Self {
if let Some(org) = &self.org {
self.lines.push(format!("|> filter(fn: (r) => r[\"_field\"] == \"{}\" and r[\"_measurement\"] == \"{}\" and r[\"organization_id\"] == \"{}\")", field, measurement, org.key));
} else {
tracing::warn!("No organization in query, cannot add measure_field_org");
}
self
}
pub fn measure_fields_org(mut self, measurement: &str, fields: &[&str]) -> Self {
if let Some(org) = &self.org {
let mut filters = Vec::new();
for field in fields.iter() {
filters.push(format!("r[\"_field\"] == \"{}\"", field));
}
self.lines.push(format!("|> filter(fn: (r) => r[\"_measurement\"] == \"{}\" and r[\"organization_id\"] == \"{}\" and ({}))", measurement, org.key, filters.join(" or ")));
} else {
tracing::warn!("No organization in query, cannot add measure_field_org");
}
self
}
pub fn aggregate_window(mut self) -> Self {
if let Some(period) = &self.period {
self.lines.push(format!("|> {}", period.aggregate_window()));
} else {
tracing::warn!("No period in query, cannot add aggregate_window");
}
self
}
pub fn group(mut self, columns: &[&str]) -> Self {
let group_by = columns.join(", ");
self.lines
.push(format!("|> group(columns: [\"{}\"])", group_by));
self
}
pub fn with_host_id(mut self, host_id: &str) -> Self {
self.lines.push(format!(
"|> filter(fn: (r) => r[\"host_id\"] == \"{}\")",
host_id
));
self
}
pub async fn execute<T>(&self) -> anyhow::Result<Vec<T>>
where
T: FromMap + std::fmt::Debug,
{
let qs = self.lines.join("\n");
tracing::info!("Query:\n{}", qs);
if let Some(org) = &self.org {
let influx_url = format!("http://{}:8086", org.influx_host);
let client = Client::new(influx_url, &org.influx_org, &org.influx_token);
let query = Query::new(qs.clone());
let rows = client.query::<T>(Some(query)).await;
if let Ok(rows) = rows {
Ok(rows)
} else {
tracing::error!("InfluxDb query error: {rows:?} for: {qs}");
anyhow::bail!("Influx query error");
}
} else {
anyhow::bail!("No organization in query, cannot execute");
}
}
}

View File

@@ -0,0 +1,122 @@
#![allow(dead_code)]
#[derive(Clone, Debug)]
pub struct InfluxTimePeriod {
pub start: String,
pub aggregate: String,
sample: i32,
}
const fn minutes_to_seconds(minutes: i32) -> i32 {
minutes * 60
}
const fn hours_to_seconds(hours: i32) -> i32 {
minutes_to_seconds(hours * 60)
}
const fn days_to_seconds(days: i32) -> i32 {
hours_to_seconds(days * 24)
}
const SAMPLES_PER_GRAPH: i32 = 30;
const fn aggregate_window(seconds: i32) -> i32 {
seconds / SAMPLES_PER_GRAPH
}
fn period_string_to_seconds(period: &str) -> i32 {
let last_char = period.chars().last().unwrap();
let number_part = &period[..period.len() - 1];
let number = number_part.parse::<i32>().unwrap_or(5);
let start_seconds = match last_char {
's' => number,
'm' => minutes_to_seconds(number),
'h' => hours_to_seconds(number),
'd' => days_to_seconds(number),
_ => {
tracing::warn!("Unknown time unit: {last_char}");
minutes_to_seconds(5)
}
};
start_seconds
}
impl InfluxTimePeriod {
pub fn new(period: &str) -> Self {
let last_char = period.chars().last().unwrap();
let number_part = &period[..period.len() - 1];
let number = number_part.parse::<i32>().unwrap_or(5);
let start_seconds = match last_char {
's' => number,
'm' => minutes_to_seconds(number),
'h' => hours_to_seconds(number),
'd' => days_to_seconds(number),
_ => {
tracing::warn!("Unknown time unit: {last_char}");
minutes_to_seconds(5)
}
};
let start = format!("-{}s", start_seconds);
let aggregate_seconds = aggregate_window(start_seconds);
let aggregate = format!("{}s", aggregate_seconds);
let sample = start_seconds / 100;
Self {
start: start.to_string(),
aggregate: aggregate.to_string(),
sample
}
}
pub fn range(&self) -> String {
format!("range(start: {})", self.start)
}
pub fn aggregate_window(&self) -> String {
format!(
"aggregateWindow(every: {}, fn: mean, createEmpty: false)",
self.aggregate
)
}
pub fn aggregate_window_empty(&self) -> String {
format!(
"aggregateWindow(every: {}, fn: mean, createEmpty: true)",
self.aggregate
)
}
pub fn aggregate_window_fn(&self, mode: &str) -> String {
format!(
"aggregateWindow(every: {}, fn: {mode}, createEmpty: false)",
self.aggregate
)
}
pub fn sample(&self) -> String {
format!("sample(n: {}, pos: 1)", self.sample)
}
}
impl From<&String> for InfluxTimePeriod {
fn from(period: &String) -> Self {
Self::new(period)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_period_to_seconds() {
assert_eq!(period_string_to_seconds("5s"), 5);
assert_eq!(period_string_to_seconds("5m"), 300);
assert_eq!(period_string_to_seconds("5h"), 18000);
assert_eq!(period_string_to_seconds("5d"), 432000);
// Test that an unknown returns the default
assert_eq!(period_string_to_seconds("5x"), 300);
}
}

View File

@@ -1,6 +1,8 @@
//! Provides pre-packaged queries for obtaining data, that will
//! then be used by the web server to respond to requests.
mod influx;
pub(crate) use influx::*;
mod circuit_info;
pub mod ext_device;
mod node_perf;
@@ -12,7 +14,6 @@ mod site_info;
mod site_parents;
pub mod site_tree;
mod throughput;
pub mod time_period;
pub use circuit_info::send_circuit_info;
pub use node_perf::send_perf_for_node;
pub use packet_counts::{send_packets_for_all_nodes, send_packets_for_node};

View File

@@ -1,10 +1,9 @@
use axum::extract::ws::WebSocket;
use chrono::{DateTime, FixedOffset, Utc};
use influxdb2::{Client, FromDataPoint, models::Query};
use pgdb::{sqlx::{Pool, Postgres}, organization_cache::get_org_details};
use wasm_pipe_types::{PerfHost, Perf};
use crate::web::wss::send_response;
use super::time_period::InfluxTimePeriod;
use influxdb2::FromDataPoint;
use pgdb::sqlx::{Pool, Postgres};
use tokio::sync::mpsc::Sender;
use wasm_pipe_types::{Perf, PerfHost, WasmResponse};
use super::{influx::InfluxTimePeriod, QueryBuilder};
#[derive(Debug, FromDataPoint)]
pub struct PerfRow {
@@ -29,14 +28,15 @@ impl Default for PerfRow {
pub async fn send_perf_for_node(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
period: InfluxTimePeriod,
node_id: String,
node_name: String,
) -> anyhow::Result<()> {
let node = get_perf_for_node(cnn, key, node_id, node_name, period).await?;
send_response(socket, wasm_pipe_types::WasmResponse::NodePerfChart { nodes: vec![node] }).await;
let node = get_perf_for_node(cnn, key, node_id, node_name, &period).await?;
tx.send(WasmResponse::NodePerfChart { nodes: vec![node] })
.await?;
Ok(())
}
@@ -45,53 +45,35 @@ pub async fn get_perf_for_node(
key: &str,
node_id: String,
node_name: String,
period: InfluxTimePeriod,
period: &InfluxTimePeriod,
) -> anyhow::Result<PerfHost> {
if let Some(org) = get_org_details(cnn, key).await {
let influx_url = format!("http://{}:8086", org.influx_host);
let client = Client::new(influx_url, &org.influx_org, &org.influx_token);
let rows = QueryBuilder::new()
.with_period(period)
.derive_org(cnn, key)
.await
.bucket()
.range()
.measure_fields_org("perf", &["cpu", "cpu_max", "ram"])
.filter(&format!("r[\"host_id\"] == \"{}\"", node_id))
.aggregate_window()
.execute::<PerfRow>()
.await?;
let qs = format!(
"from(bucket: \"{}\")
|> {}
|> filter(fn: (r) => r[\"_measurement\"] == \"perf\")
|> filter(fn: (r) => r[\"organization_id\"] == \"{}\")
|> filter(fn: (r) => r[\"host_id\"] == \"{}\")
|> {}
|> yield(name: \"last\")",
org.influx_bucket, period.range(), org.key, node_id, period.aggregate_window()
);
let mut stats = Vec::new();
let query = Query::new(qs);
let rows = client.query::<PerfRow>(Some(query)).await;
match rows {
Err(e) => {
tracing::error!("Error querying InfluxDB (node-perf): {}", e);
return Err(anyhow::Error::msg("Unable to query influx"));
}
Ok(rows) => {
// Parse and send the data
//println!("{rows:?}");
let mut stats = Vec::new();
// Fill download
for row in rows.iter() {
stats.push(Perf {
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
cpu: row.cpu,
cpu_max: row.cpu_max,
ram: row.ram,
});
}
return Ok(PerfHost{
node_id,
node_name,
stats,
});
}
}
// Fill download
for row in rows.iter() {
stats.push(Perf {
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
cpu: row.cpu,
cpu_max: row.cpu_max,
ram: row.ram,
});
}
Err(anyhow::Error::msg("Unable to query influx"))
Ok(PerfHost {
node_id,
node_name,
stats,
})
}

View File

@@ -1,12 +1,11 @@
//! Packet-per-second data queries
mod packet_row;
use self::packet_row::PacketRow;
use super::time_period::InfluxTimePeriod;
use crate::web::wss::{influx_query_builder::InfluxQueryBuilder, send_response};
use axum::extract::ws::WebSocket;
use pgdb::sqlx::{Pool, Postgres};
use tokio::sync::mpsc::Sender;
use tracing::instrument;
use wasm_pipe_types::{PacketHost, Packets};
use wasm_pipe_types::{PacketHost, Packets, WasmResponse};
use super::{influx::{InfluxTimePeriod, InfluxQueryBuilder}, QueryBuilder};
fn add_by_direction(direction: &str, down: &mut Vec<Packets>, up: &mut Vec<Packets>, row: &PacketRow) {
match direction {
@@ -30,10 +29,10 @@ fn add_by_direction(direction: &str, down: &mut Vec<Packets>, up: &mut Vec<Packe
}
}
#[instrument(skip(cnn, socket, key, period))]
#[instrument(skip(cnn, tx, key, period))]
pub async fn send_packets_for_all_nodes(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
period: InfluxTimePeriod,
) -> anyhow::Result<()> {
@@ -69,14 +68,14 @@ pub async fn send_packets_for_all_nodes(
});
}
});
send_response(socket, wasm_pipe_types::WasmResponse::PacketChart { nodes }).await;
tx.send(wasm_pipe_types::WasmResponse::PacketChart { nodes }).await?;
Ok(())
}
#[instrument(skip(cnn, socket, key, period))]
#[instrument(skip(cnn, tx, key, period))]
pub async fn send_packets_for_node(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
period: InfluxTimePeriod,
node_id: &str,
@@ -85,11 +84,7 @@ pub async fn send_packets_for_node(
let node =
get_packets_for_node(cnn, key, node_id.to_string(), node_name.to_string(), period).await?;
send_response(
socket,
wasm_pipe_types::WasmResponse::PacketChart { nodes: vec![node] },
)
.await;
tx.send(wasm_pipe_types::WasmResponse::PacketChart { nodes: vec![node] }).await?;
Ok(())
}
@@ -107,15 +102,20 @@ pub async fn get_packets_for_node(
node_name: String,
period: InfluxTimePeriod,
) -> anyhow::Result<PacketHost> {
let rows = InfluxQueryBuilder::new(period.clone())
.with_measurement("packets")
let rows = QueryBuilder::new()
.with_period(&period)
.derive_org(cnn, key)
.await
.bucket()
.range()
.measure_fields_org("packets", &["min", "max", "avg"])
.with_host_id(&node_id)
.with_field("min")
.with_field("max")
.with_field("avg")
.execute::<PacketRow>(cnn, key)
.aggregate_window()
.execute::<PacketRow>()
.await;
match rows {
Err(e) => {
tracing::error!("Error querying InfluxDB (packets by node): {}", e);

View File

@@ -3,19 +3,24 @@ pub use per_node::*;
mod per_site;
pub use per_site::*;
use axum::extract::ws::WebSocket;
use futures::future::join_all;
use influxdb2::{Client, models::Query};
use pgdb::{sqlx::{Pool, Postgres}, organization_cache::get_org_details};
use tracing::instrument;
use wasm_pipe_types::{RttHost, Rtt};
use crate::web::wss::{queries::rtt::rtt_row::RttCircuitRow, send_response};
use self::rtt_row::RttRow;
use super::time_period::InfluxTimePeriod;
use super::{influx::InfluxTimePeriod, QueryBuilder};
use crate::web::wss::queries::rtt::rtt_row::RttCircuitRow;
use futures::future::join_all;
use pgdb::sqlx::{Pool, Postgres};
use tokio::sync::mpsc::Sender;
use tracing::instrument;
use wasm_pipe_types::{Rtt, RttHost, WasmResponse};
mod rtt_row;
#[instrument(skip(cnn, socket, key, site_id, period))]
pub async fn send_rtt_for_all_nodes_circuit(cnn: &Pool<Postgres>, socket: &mut WebSocket, key: &str, site_id: String, period: InfluxTimePeriod) -> anyhow::Result<()> {
#[instrument(skip(cnn, tx, key, site_id, period))]
pub async fn send_rtt_for_all_nodes_circuit(
cnn: &Pool<Postgres>,
tx: Sender<WasmResponse>,
key: &str,
site_id: String,
period: InfluxTimePeriod,
) -> anyhow::Result<()> {
let nodes = get_rtt_for_all_nodes_circuit(cnn, key, &site_id, period).await?;
let mut histogram = vec![0; 20];
@@ -26,27 +31,32 @@ pub async fn send_rtt_for_all_nodes_circuit(cnn: &Pool<Postgres>, socket: &mut W
}
}
send_response(socket, wasm_pipe_types::WasmResponse::RttChartCircuit { nodes, histogram }).await;
tx.send(WasmResponse::RttChartCircuit { nodes, histogram })
.await?;
Ok(())
}
pub async fn send_rtt_for_node(cnn: &Pool<Postgres>, socket: &mut WebSocket, key: &str, period: InfluxTimePeriod, node_id: String, node_name: String) -> anyhow::Result<()> {
let node = get_rtt_for_node(cnn, key, node_id, node_name, period).await?;
pub async fn send_rtt_for_node(
cnn: &Pool<Postgres>,
tx: Sender<WasmResponse>,
key: &str,
period: InfluxTimePeriod,
node_id: String,
node_name: String,
) -> anyhow::Result<()> {
let node = get_rtt_for_node(cnn, key, node_id, node_name, &period).await?;
let nodes = vec![node];
/*let mut histogram = vec![0; 20];
for node in nodes.iter() {
for rtt in node.rtt.iter() {
let bucket = usize::min(19, (rtt.value / 200.0) as usize);
histogram[bucket] += 1;
}
}*/
send_response(socket, wasm_pipe_types::WasmResponse::RttChart { nodes }).await;
tx.send(WasmResponse::RttChart { nodes }).await?;
Ok(())
}
pub async fn get_rtt_for_all_nodes_circuit(cnn: &Pool<Postgres>, key: &str, circuit_id: &str, period: InfluxTimePeriod) -> anyhow::Result<Vec<RttHost>> {
pub async fn get_rtt_for_all_nodes_circuit(
cnn: &Pool<Postgres>,
key: &str,
circuit_id: &str,
period: InfluxTimePeriod,
) -> anyhow::Result<Vec<RttHost>> {
let node_status = pgdb::node_status(cnn, key).await?;
let mut futures = Vec::new();
for node in node_status {
@@ -56,11 +66,10 @@ pub async fn get_rtt_for_all_nodes_circuit(cnn: &Pool<Postgres>, key: &str, circ
node.node_id.to_string(),
node.node_name.to_string(),
circuit_id.to_string(),
period.clone(),
&period,
));
}
let all_nodes: anyhow::Result<Vec<RttHost>> = join_all(futures).await
.into_iter().collect();
let all_nodes: anyhow::Result<Vec<RttHost>> = join_all(futures).await.into_iter().collect();
all_nodes
}
@@ -69,55 +78,37 @@ pub async fn get_rtt_for_node(
key: &str,
node_id: String,
node_name: String,
period: InfluxTimePeriod,
period: &InfluxTimePeriod,
) -> anyhow::Result<RttHost> {
if let Some(org) = get_org_details(cnn, key).await {
let influx_url = format!("http://{}:8086", org.influx_host);
let client = Client::new(influx_url, &org.influx_org, &org.influx_token);
let rows = QueryBuilder::new()
.with_period(period)
.derive_org(cnn, key)
.await
.bucket()
.range()
.measure_fields_org("rtt", &["avg", "min", "max"])
.filter(&format!("r[\"host_id\"] == \"{}\"", node_id))
.aggregate_window()
.execute::<RttRow>()
.await?;
let qs = format!(
"from(bucket: \"{}\")
|> {}
|> filter(fn: (r) => r[\"_measurement\"] == \"rtt\")
|> filter(fn: (r) => r[\"organization_id\"] == \"{}\")
|> filter(fn: (r) => r[\"host_id\"] == \"{}\")
|> {}
|> yield(name: \"last\")",
org.influx_bucket, period.range(), org.key, node_id, period.aggregate_window()
);
let mut rtt = Vec::new();
let query = Query::new(qs);
let rows = client.query::<RttRow>(Some(query)).await;
match rows {
Err(e) => {
tracing::error!("Error querying InfluxDB (rtt node): {}", e);
return Err(anyhow::Error::msg("Unable to query influx"));
}
Ok(rows) => {
// Parse and send the data
//println!("{rows:?}");
let mut rtt = Vec::new();
// Fill RTT
for row in rows.iter() {
rtt.push(Rtt {
value: f64::min(200.0, row.avg),
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
l: f64::min(200.0, row.min),
u: f64::min(200.0, row.max) - f64::min(200.0, row.min),
});
}
return Ok(RttHost{
node_id,
node_name,
rtt,
});
}
}
// Fill RTT
for row in rows.iter() {
rtt.push(Rtt {
value: f64::min(200.0, row.avg),
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
l: f64::min(200.0, row.min),
u: f64::min(200.0, row.max) - f64::min(200.0, row.min),
});
}
Err(anyhow::Error::msg("Unable to query influx"))
Ok(RttHost {
node_id,
node_name,
rtt,
})
}
pub async fn get_rtt_for_node_circuit(
@@ -126,55 +117,36 @@ pub async fn get_rtt_for_node_circuit(
node_id: String,
node_name: String,
circuit_id: String,
period: InfluxTimePeriod,
period: &InfluxTimePeriod,
) -> anyhow::Result<RttHost> {
if let Some(org) = get_org_details(cnn, key).await {
let influx_url = format!("http://{}:8086", org.influx_host);
let client = Client::new(influx_url, &org.influx_org, &org.influx_token);
let rows = QueryBuilder::new()
.with_period(period)
.derive_org(cnn, key)
.await
.bucket()
.range()
.measure_fields_org("rtt", &["avg", "min", "max"])
.filter(&format!("r[\"host_id\"] == \"{}\"", node_id))
.filter(&format!("r[\"circuit_id\"] == \"{}\"", circuit_id))
.aggregate_window()
.execute::<RttCircuitRow>()
.await?;
let qs = format!(
"from(bucket: \"{}\")
|> {}
|> filter(fn: (r) => r[\"_measurement\"] == \"rtt\")
|> filter(fn: (r) => r[\"organization_id\"] == \"{}\")
|> filter(fn: (r) => r[\"host_id\"] == \"{}\")
|> filter(fn: (r) => r[\"circuit_id\"] == \"{}\")
|> filter(fn: (r) => r[\"_field\"] == \"avg\" or r[\"_field\"] == \"max\" or r[\"_field\"] == \"min\")
|> {}
|> yield(name: \"last\")",
org.influx_bucket, period.range(), org.key, node_id, circuit_id, period.aggregate_window()
);
//log::warn!("{qs}");
let query = Query::new(qs);
let rows = client.query::<RttCircuitRow>(Some(query)).await;
match rows {
Err(e) => {
tracing::error!("Error querying InfluxDB (rtt_node_circuit): {}", e);
return Err(anyhow::Error::msg("Unable to query influx"));
}
Ok(rows) => {
// Parse and send the data
//println!("{rows:?}");
let mut rtt = Vec::new();
let mut rtt = Vec::new();
// Fill download
for row in rows.iter() {
rtt.push(Rtt {
value: row.avg,
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
l: row.min,
u: row.max - row.min,
});
}
return Ok(RttHost{
node_id,
node_name,
rtt,
});
}
}
// Fill download
for row in rows.iter() {
rtt.push(Rtt {
value: row.avg,
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
l: row.min,
u: row.max - row.min,
});
}
Err(anyhow::Error::msg("Unable to query influx"))
Ok(RttHost {
node_id,
node_name,
rtt,
})
}

View File

@@ -1,18 +1,17 @@
use crate::web::wss::{queries::time_period::InfluxTimePeriod, send_response, influx_query_builder::InfluxQueryBuilder};
use axum::extract::ws::WebSocket;
use pgdb::{
sqlx::{Pool, Postgres},
NodeStatus
};
use tokio::sync::mpsc::Sender;
use tracing::instrument;
use wasm_pipe_types::{Rtt, RttHost};
use wasm_pipe_types::{Rtt, RttHost, WasmResponse};
use crate::web::wss::queries::influx::{InfluxTimePeriod, InfluxQueryBuilder};
use super::rtt_row::{RttRow, RttHistoRow};
#[instrument(skip(cnn, socket, key, period))]
#[instrument(skip(cnn, tx, key, period))]
pub async fn send_rtt_for_all_nodes(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
period: InfluxTimePeriod,
) -> anyhow::Result<()> {
@@ -24,15 +23,15 @@ pub async fn send_rtt_for_all_nodes(
.await?;
let node_status = pgdb::node_status(cnn, key).await?;
let nodes = rtt_rows_to_result(rows, node_status);
send_response(socket, wasm_pipe_types::WasmResponse::RttChart { nodes }).await;
tx.send(WasmResponse::RttChart { nodes }).await?;
Ok(())
}
#[instrument(skip(cnn, socket, key, period))]
#[instrument(skip(cnn, tx, key, period))]
pub async fn send_rtt_histogram_for_all_nodes(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
period: InfluxTimePeriod,
) -> anyhow::Result<()> {
@@ -50,7 +49,7 @@ pub async fn send_rtt_histogram_for_all_nodes(
histo[bucket] += 1;
});
send_response(socket, wasm_pipe_types::WasmResponse::RttHistogram { histogram: histo }).await;
tx.send(WasmResponse::RttHistogram { histogram: histo }).await?;
Ok(())
}

View File

@@ -1,16 +1,16 @@
use crate::web::wss::{queries::time_period::InfluxTimePeriod, send_response, influx_query_builder::InfluxQueryBuilder};
use axum::extract::ws::WebSocket;
use pgdb::{
sqlx::{Pool, Postgres},
NodeStatus
};
use tokio::sync::mpsc::Sender;
use tracing::instrument;
use wasm_pipe_types::{Rtt, RttHost};
use wasm_pipe_types::{Rtt, RttHost, WasmResponse};
use crate::web::wss::queries::influx::{InfluxTimePeriod, InfluxQueryBuilder};
use super::rtt_row::RttSiteRow;
#[instrument(skip(cnn, socket, key, period))]
#[instrument(skip(cnn, tx, key, period))]
pub async fn send_rtt_for_all_nodes_site(
cnn: &Pool<Postgres>, socket: &mut WebSocket, key: &str, site_name: String, period: InfluxTimePeriod
cnn: &Pool<Postgres>, tx: Sender<WasmResponse>, key: &str, site_name: String, period: InfluxTimePeriod
) -> anyhow::Result<()> {
let rows = InfluxQueryBuilder::new(period.clone())
.with_measurement("tree")
@@ -21,7 +21,7 @@ pub async fn send_rtt_for_all_nodes_site(
.await?;
let node_status = pgdb::node_status(cnn, key).await?;
let nodes = rtt_rows_to_result(rows, node_status);
send_response(socket, wasm_pipe_types::WasmResponse::RttChartSite { nodes }).await;
tx.send(WasmResponse::RttChartSite { nodes }).await?;
Ok(())
}

View File

@@ -1,12 +1,11 @@
use axum::extract::ws::WebSocket;
use pgdb::sqlx::{Pool, Postgres};
use wasm_pipe_types::SearchResult;
use crate::web::wss::send_response;
use tokio::sync::mpsc::Sender;
use wasm_pipe_types::{SearchResult, WasmResponse};
#[tracing::instrument(skip(cnn, tx, key, term))]
pub async fn omnisearch(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
term: &str,
) -> anyhow::Result<()> {
@@ -25,7 +24,7 @@ pub async fn omnisearch(
hits.dedup_by(|a,b| a.name == b.name && a.url == b.url);
hits.sort_by(|a,b| a.score.partial_cmp(&b.score).unwrap());
send_response(socket, wasm_pipe_types::WasmResponse::SearchResult { hits }).await;
tx.send(WasmResponse::SearchResult { hits }).await?;
Ok(())
}

View File

@@ -1,16 +1,15 @@
use super::time_period::InfluxTimePeriod;
use crate::web::wss::influx_query_builder::InfluxQueryBuilder;
use crate::web::wss::send_response;
use axum::extract::ws::WebSocket;
use chrono::{DateTime, FixedOffset, Utc};
use influxdb2::FromDataPoint;
use pgdb::sqlx::{Pool, Postgres};
use serde::Serialize;
use tokio::sync::mpsc::Sender;
use tracing::instrument;
use std::collections::HashMap;
use wasm_pipe_types::WasmResponse;
use itertools::Itertools;
use super::influx::{InfluxTimePeriod, InfluxQueryBuilder};
fn headings_sorter<T: HeatMapData>(rows: Vec<T>) -> HashMap<String, Vec<(DateTime<FixedOffset>, f64)>> {
let mut headings = rows.iter().map(|r| r.time()).collect::<Vec<_>>();
headings.sort();
@@ -27,10 +26,10 @@ fn headings_sorter<T: HeatMapData>(rows: Vec<T>) -> HashMap<String, Vec<(DateTim
sorter
}
#[instrument(skip(cnn,socket,key,period))]
#[instrument(skip(cnn,tx,key,period))]
pub async fn root_heat_map(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
period: InfluxTimePeriod,
) -> anyhow::Result<()> {
@@ -47,7 +46,7 @@ pub async fn root_heat_map(
.await?;
let sorter = headings_sorter(rows);
send_response(socket, WasmResponse::RootHeat { data: sorter }).await;
tx.send(WasmResponse::RootHeat { data: sorter }).await?;
Ok(())
}
@@ -82,10 +81,10 @@ async fn site_circuits_heat_map(
Ok(rows)
}
#[instrument(skip(cnn, socket, key, period))]
#[instrument(skip(cnn, tx, key, period))]
pub async fn site_heat_map(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
site_name: &str,
period: InfluxTimePeriod,
@@ -118,7 +117,7 @@ pub async fn site_heat_map(
});
let sorter = headings_sorter(rows);
send_response(socket, WasmResponse::SiteHeat { data: sorter }).await;
tx.send(WasmResponse::SiteHeat { data: sorter }).await?;
Ok(())
}

View File

@@ -1,8 +1,7 @@
use super::site_tree::tree_to_host;
use crate::web::wss::send_response;
use axum::extract::ws::WebSocket;
use pgdb::sqlx::{Pool, Postgres};
use serde::Serialize;
use tokio::sync::mpsc::Sender;
use wasm_pipe_types::{SiteTree, WasmResponse, SiteOversubscription};
#[derive(Serialize)]
@@ -13,7 +12,7 @@ struct SiteInfoMessage {
pub async fn send_site_info(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
site_id: &str,
) {
@@ -30,7 +29,7 @@ pub async fn send_site_info(
dlmin: oversub.dlmin,
devicecount: oversub.devicecount,
};
send_response(socket, WasmResponse::SiteInfo { data: host, oversubscription }).await;
tx.send(WasmResponse::SiteInfo { data: host, oversubscription }).await.unwrap();
} else {
tracing::error!("{oversub:?}");
}

View File

@@ -1,21 +1,21 @@
use axum::extract::ws::WebSocket;
use pgdb::sqlx::{Pool, Postgres};
use tokio::sync::mpsc::Sender;
use wasm_pipe_types::WasmResponse;
use crate::web::wss::send_response;
#[tracing::instrument(skip(cnn, tx, key, site_name))]
pub async fn send_site_parents(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
site_name: &str,
) {
if let Ok(parents) = pgdb::get_parent_list(cnn, key, site_name).await {
send_response(socket, wasm_pipe_types::WasmResponse::SiteParents { data: parents }).await;
tx.send(WasmResponse::SiteParents { data: parents }).await.unwrap();
}
let child_result = pgdb::get_child_list(cnn, key, site_name).await;
if let Ok(children) = child_result {
send_response(socket, wasm_pipe_types::WasmResponse::SiteChildren { data: children }).await;
tx.send(WasmResponse::SiteChildren { data: children }).await.unwrap();
} else {
tracing::error!("Error getting children: {:?}", child_result);
}
@@ -23,24 +23,24 @@ pub async fn send_site_parents(
pub async fn send_circuit_parents(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
circuit_id: &str,
) {
if let Ok(parents) = pgdb::get_circuit_parent_list(cnn, key, circuit_id).await {
send_response(socket, wasm_pipe_types::WasmResponse::SiteParents { data: parents }).await;
tx.send(WasmResponse::SiteParents { data: parents }).await.unwrap();
}
}
pub async fn send_root_parents(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
) {
let site_name = "Root";
let child_result = pgdb::get_child_list(cnn, key, site_name).await;
if let Ok(children) = child_result {
send_response(socket, wasm_pipe_types::WasmResponse::SiteChildren { data: children }).await;
tx.send(WasmResponse::SiteChildren { data: children }).await.unwrap();
} else {
tracing::error!("Error getting children: {:?}", child_result);
}

View File

@@ -1,19 +1,19 @@
use axum::extract::ws::WebSocket;
use pgdb::{
sqlx::{Pool, Postgres},
TreeNode,
};
use wasm_pipe_types::SiteTree;
use crate::web::wss::send_response;
use tokio::sync::mpsc::Sender;
use wasm_pipe_types::{SiteTree, WasmResponse};
pub async fn send_site_tree(cnn: &Pool<Postgres>, socket: &mut WebSocket, key: &str, parent: &str) {
#[tracing::instrument(skip(cnn, tx, key, parent))]
pub async fn send_site_tree(cnn: &Pool<Postgres>, tx: Sender<WasmResponse>, key: &str, parent: &str) {
let tree = pgdb::get_site_tree(cnn, key, parent).await.unwrap();
let tree = tree
.into_iter()
.map(tree_to_host)
.collect::<Vec<SiteTree>>();
send_response(socket, wasm_pipe_types::WasmResponse::SiteTree { data: tree }).await;
tx.send(WasmResponse::SiteTree { data: tree }).await.unwrap();
}
pub(crate) fn tree_to_host(row: TreeNode) -> SiteTree {
@@ -24,8 +24,8 @@ pub(crate) fn tree_to_host(row: TreeNode) -> SiteTree {
parent: row.parent,
max_down: row.max_down,
max_up: row.max_up,
current_down: row.current_down,
current_up: row.current_up,
current_down: row.current_down * 8,
current_up: row.current_up * 8,
current_rtt: row.current_rtt,
}
}

View File

@@ -1,18 +1,24 @@
use std::collections::HashMap;
mod site_stack;
use axum::extract::ws::WebSocket;
use self::throughput_row::{ThroughputRow, ThroughputRowByCircuit, ThroughputRowBySite};
use futures::future::join_all;
use influxdb2::{Client, models::Query};
use pgdb::{sqlx::{Pool, Postgres}, organization_cache::get_org_details};
use pgdb::sqlx::{Pool, Postgres};
use tokio::sync::mpsc::Sender;
use tracing::instrument;
use wasm_pipe_types::{ThroughputHost, Throughput};
use crate::web::wss::{send_response, influx_query_builder::InfluxQueryBuilder};
use self::throughput_row::{ThroughputRow, ThroughputRowBySite, ThroughputRowByCircuit};
use super::time_period::InfluxTimePeriod;
use wasm_pipe_types::{Throughput, ThroughputHost, WasmResponse};
mod throughput_row;
use super::{
influx::{InfluxQueryBuilder, InfluxTimePeriod},
QueryBuilder,
};
pub use site_stack::send_site_stack_map;
fn add_by_direction(direction: &str, down: &mut Vec<Throughput>, up: &mut Vec<Throughput>, row: &ThroughputRow) {
fn add_by_direction(
direction: &str,
down: &mut Vec<Throughput>,
up: &mut Vec<Throughput>,
row: &ThroughputRow,
) {
match direction {
"down" => {
down.push(Throughput {
@@ -34,7 +40,12 @@ fn add_by_direction(direction: &str, down: &mut Vec<Throughput>, up: &mut Vec<Th
}
}
fn add_by_direction_site(direction: &str, down: &mut Vec<Throughput>, up: &mut Vec<Throughput>, row: &ThroughputRowBySite) {
fn add_by_direction_site(
direction: &str,
down: &mut Vec<Throughput>,
up: &mut Vec<Throughput>,
row: &ThroughputRowBySite,
) {
match direction {
"down" => {
down.push(Throughput {
@@ -56,8 +67,13 @@ fn add_by_direction_site(direction: &str, down: &mut Vec<Throughput>, up: &mut V
}
}
#[instrument(skip(cnn, socket, key, period))]
pub async fn send_throughput_for_all_nodes(cnn: &Pool<Postgres>, socket: &mut WebSocket, key: &str, period: InfluxTimePeriod) -> anyhow::Result<()> {
#[instrument(skip(cnn, tx, key, period))]
pub async fn send_throughput_for_all_nodes(
cnn: &Pool<Postgres>,
tx: Sender<WasmResponse>,
key: &str,
period: InfluxTimePeriod,
) -> anyhow::Result<()> {
let node_status = pgdb::node_status(cnn, key).await?;
let mut nodes = Vec::<ThroughputHost>::new();
InfluxQueryBuilder::new(period.clone())
@@ -76,21 +92,33 @@ pub async fn send_throughput_for_all_nodes(cnn: &Pool<Postgres>, socket: &mut We
add_by_direction(&row.direction, &mut down, &mut up, &row);
let node_name = if let Some(node) = node_status.iter().find(|n| n.node_id == row.host_id) {
node.node_name.clone()
} else {
row.host_id.clone()
};
let node_name =
if let Some(node) = node_status.iter().find(|n| n.node_id == row.host_id) {
node.node_name.clone()
} else {
row.host_id.clone()
};
nodes.push(ThroughputHost { node_id: row.host_id, node_name, down, up });
nodes.push(ThroughputHost {
node_id: row.host_id,
node_name,
down,
up,
});
}
});
send_response(socket, wasm_pipe_types::WasmResponse::BitsChart { nodes }).await;
Ok(())
tx.send(WasmResponse::BitsChart { nodes }).await?;
Ok(())
}
#[instrument(skip(cnn, socket, key, period, site_name))]
pub async fn send_throughput_for_all_nodes_by_site(cnn: &Pool<Postgres>, socket: &mut WebSocket, key: &str, site_name: String, period: InfluxTimePeriod) -> anyhow::Result<()> {
#[instrument(skip(cnn, tx, key, period, site_name))]
pub async fn send_throughput_for_all_nodes_by_site(
cnn: &Pool<Postgres>,
tx: Sender<WasmResponse>,
key: &str,
site_name: String,
period: InfluxTimePeriod,
) -> anyhow::Result<()> {
let node_status = pgdb::node_status(cnn, key).await?;
let mut nodes = Vec::<ThroughputHost>::new();
InfluxQueryBuilder::new(period.clone())
@@ -110,16 +138,22 @@ pub async fn send_throughput_for_all_nodes_by_site(cnn: &Pool<Postgres>, socket:
add_by_direction_site(&row.direction, &mut down, &mut up, &row);
let node_name = if let Some(node) = node_status.iter().find(|n| n.node_id == row.host_id) {
node.node_name.clone()
} else {
row.host_id.clone()
};
let node_name =
if let Some(node) = node_status.iter().find(|n| n.node_id == row.host_id) {
node.node_name.clone()
} else {
row.host_id.clone()
};
nodes.push(ThroughputHost { node_id: row.host_id, node_name, down, up });
nodes.push(ThroughputHost {
node_id: row.host_id,
node_name,
down,
up,
});
}
});
send_response(socket, wasm_pipe_types::WasmResponse::BitsChart { nodes }).await;
tx.send(WasmResponse::BitsChart { nodes }).await?;
Ok(())
}
@@ -131,19 +165,38 @@ pub async fn send_throughput_for_all_nodes_by_site(cnn: &Pool<Postgres>, socket:
Ok(())
}*/
pub async fn send_throughput_for_all_nodes_by_circuit(cnn: &Pool<Postgres>, socket: &mut WebSocket, key: &str, circuit_id: String, period: InfluxTimePeriod) -> anyhow::Result<()> {
pub async fn send_throughput_for_all_nodes_by_circuit(
cnn: &Pool<Postgres>,
tx: Sender<WasmResponse>,
key: &str,
circuit_id: String,
period: InfluxTimePeriod,
) -> anyhow::Result<()> {
let nodes = get_throughput_for_all_nodes_by_circuit(cnn, key, period, &circuit_id).await?;
send_response(socket, wasm_pipe_types::WasmResponse::BitsChart { nodes }).await;
tx.send(WasmResponse::BitsChart { nodes }).await?;
Ok(())
}
pub async fn send_throughput_for_node(cnn: &Pool<Postgres>, socket: &mut WebSocket, key: &str, period: InfluxTimePeriod, node_id: String, node_name: String) -> anyhow::Result<()> {
let node = get_throughput_for_node(cnn, key, node_id, node_name, period).await?;
send_response(socket, wasm_pipe_types::WasmResponse::BitsChart { nodes: vec![node] }).await;
pub async fn send_throughput_for_node(
cnn: &Pool<Postgres>,
tx: Sender<WasmResponse>,
key: &str,
period: InfluxTimePeriod,
node_id: String,
node_name: String,
) -> anyhow::Result<()> {
let node = get_throughput_for_node(cnn, key, node_id, node_name, &period).await?;
tx.send(WasmResponse::BitsChart { nodes: vec![node] })
.await?;
Ok(())
}
pub async fn get_throughput_for_all_nodes_by_circuit(cnn: &Pool<Postgres>, key: &str, period: InfluxTimePeriod, circuit_id: &str) -> anyhow::Result<Vec<ThroughputHost>> {
pub async fn get_throughput_for_all_nodes_by_circuit(
cnn: &Pool<Postgres>,
key: &str,
period: InfluxTimePeriod,
circuit_id: &str,
) -> anyhow::Result<Vec<ThroughputHost>> {
let node_status = pgdb::node_status(cnn, key).await?;
let mut futures = Vec::new();
for node in node_status {
@@ -153,7 +206,7 @@ pub async fn get_throughput_for_all_nodes_by_circuit(cnn: &Pool<Postgres>, key:
node.node_id.to_string(),
node.node_name.to_string(),
circuit_id.to_string(),
period.clone(),
&period,
));
}
let mut all_nodes = Vec::new();
@@ -168,67 +221,48 @@ pub async fn get_throughput_for_node(
key: &str,
node_id: String,
node_name: String,
period: InfluxTimePeriod,
period: &InfluxTimePeriod,
) -> anyhow::Result<ThroughputHost> {
if let Some(org) = get_org_details(cnn, key).await {
let influx_url = format!("http://{}:8086", org.influx_host);
let client = Client::new(influx_url, &org.influx_org, &org.influx_token);
let rows = QueryBuilder::new()
.with_period(period)
.derive_org(cnn, key)
.await
.bucket()
.range()
.measure_fields_org("bits", &["avg", "min", "max"])
.aggregate_window()
.execute::<ThroughputRow>()
.await?;
let qs = format!(
"from(bucket: \"{}\")
|> {}
|> filter(fn: (r) => r[\"_measurement\"] == \"bits\")
|> filter(fn: (r) => r[\"organization_id\"] == \"{}\")
|> filter(fn: (r) => r[\"host_id\"] == \"{}\")
|> {}
|> yield(name: \"last\")",
org.influx_bucket, period.range(), org.key, node_id, period.aggregate_window()
);
let mut down = Vec::new();
let mut up = Vec::new();
let query = Query::new(qs);
let rows = client.query::<ThroughputRow>(Some(query)).await;
match rows {
Err(e) => {
tracing::error!("Error querying InfluxDB (throughput node): {}", e);
return Err(anyhow::Error::msg("Unable to query influx"));
}
Ok(rows) => {
// Parse and send the data
//println!("{rows:?}");
let mut down = Vec::new();
let mut up = Vec::new();
// Fill download
for row in rows.iter().filter(|r| r.direction == "down") {
down.push(Throughput {
value: row.avg,
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
l: row.min,
u: row.max - row.min,
});
}
// Fill upload
for row in rows.iter().filter(|r| r.direction == "up") {
up.push(Throughput {
value: row.avg,
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
l: row.min,
u: row.max - row.min,
});
}
return Ok(ThroughputHost{
node_id,
node_name,
down,
up,
});
}
}
// Fill download
for row in rows.iter().filter(|r| r.direction == "down") {
down.push(Throughput {
value: row.avg,
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
l: row.min,
u: row.max - row.min,
});
}
Err(anyhow::Error::msg("Unable to query influx"))
// Fill upload
for row in rows.iter().filter(|r| r.direction == "up") {
up.push(Throughput {
value: row.avg,
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
l: row.min,
u: row.max - row.min,
});
}
Ok(ThroughputHost {
node_id,
node_name,
down,
up,
})
}
pub async fn get_throughput_for_node_by_circuit(
@@ -237,83 +271,64 @@ pub async fn get_throughput_for_node_by_circuit(
node_id: String,
node_name: String,
circuit_id: String,
period: InfluxTimePeriod,
period: &InfluxTimePeriod,
) -> anyhow::Result<Vec<ThroughputHost>> {
if let Some(org) = get_org_details(cnn, key).await {
let influx_url = format!("http://{}:8086", org.influx_host);
let client = Client::new(influx_url, &org.influx_org, &org.influx_token);
let rows = QueryBuilder::new()
.with_period(period)
.derive_org(cnn, key)
.await
.bucket()
.range()
.measure_fields_org("host_bits", &["avg", "min", "max"])
.with_host_id(&node_id)
.filter(&format!("r[\"circuit_id\"] == \"{}\"", circuit_id))
.aggregate_window()
.execute::<ThroughputRowByCircuit>()
.await?;
let qs = format!(
"from(bucket: \"{}\")
|> {}
|> filter(fn: (r) => r[\"_measurement\"] == \"host_bits\")
|> filter(fn: (r) => r[\"organization_id\"] == \"{}\")
|> filter(fn: (r) => r[\"host_id\"] == \"{}\")
|> filter(fn: (r) => r[\"circuit_id\"] == \"{}\")
|> filter(fn: (r) => r[\"_field\"] == \"avg\" or r[\"_field\"] == \"max\" or r[\"_field\"] == \"min\")
|> {}
|> yield(name: \"last\")",
org.influx_bucket, period.range(), org.key, node_id, circuit_id, period.aggregate_window()
);
let mut sorter: HashMap<String, (Vec<Throughput>, Vec<Throughput>)> =
HashMap::new();
let query = Query::new(qs);
//println!("{:?}", query);
let rows = client.query::<ThroughputRowByCircuit>(Some(query)).await;
match rows {
Err(e) => {
tracing::error!(" (throughput circuit): {}", e);
return Err(anyhow::Error::msg("Unable to query influx"));
}
Ok(rows) => {
// Parse and send the data
//println!("{rows:?}");
let mut sorter: HashMap<String, (Vec<Throughput>, Vec<Throughput>)> = HashMap::new();
// Fill download
for row in rows.iter().filter(|r| r.direction == "down") {
let tp = Throughput {
value: row.avg,
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
l: row.min,
u: row.max - row.min,
};
if let Some(hat) = sorter.get_mut(&row.ip) {
hat.0.push(tp);
} else {
sorter.insert(row.ip.clone(), (vec![tp], Vec::new()));
}
}
// Fill upload
for row in rows.iter().filter(|r| r.direction == "up") {
let tp = Throughput {
value: row.avg,
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
l: row.min,
u: row.max - row.min,
};
if let Some(hat) = sorter.get_mut(&row.ip) {
hat.1.push(tp);
} else {
sorter.insert(row.ip.clone(), (Vec::new(), vec![tp]));
}
}
let mut result = Vec::new();
for (ip, (down, up)) in sorter.iter() {
result.push(ThroughputHost{
node_id: node_id.clone(),
node_name: format!("{ip} {node_name}"),
down: down.clone(),
up: up.clone(),
});
}
return Ok(result);
}
// Fill download
for row in rows.iter().filter(|r| r.direction == "down") {
let tp = Throughput {
value: row.avg,
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
l: row.min,
u: row.max - row.min,
};
if let Some(hat) = sorter.get_mut(&row.ip) {
hat.0.push(tp);
} else {
sorter.insert(row.ip.clone(), (vec![tp], Vec::new()));
}
}
Err(anyhow::Error::msg("Unable to query influx"))
// Fill upload
for row in rows.iter().filter(|r| r.direction == "up") {
let tp = Throughput {
value: row.avg,
date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
l: row.min,
u: row.max - row.min,
};
if let Some(hat) = sorter.get_mut(&row.ip) {
hat.1.push(tp);
} else {
sorter.insert(row.ip.clone(), (Vec::new(), vec![tp]));
}
}
let mut result = Vec::new();
for (ip, (down, up)) in sorter.iter() {
result.push(ThroughputHost {
node_id: node_id.clone(),
node_name: format!("{ip} {node_name}"),
down: down.clone(),
up: up.clone(),
});
}
Ok(result)
}

View File

@@ -1,18 +1,18 @@
use crate::web::wss::{queries::time_period::InfluxTimePeriod, send_response};
use axum::extract::ws::WebSocket;
use crate::web::wss::queries::{influx::InfluxTimePeriod, QueryBuilder};
use pgdb::{
organization_cache::get_org_details,
sqlx::{Pool, Postgres},
OrganizationDetails,
};
use tokio::sync::mpsc::Sender;
use tracing::{error, instrument};
use wasm_pipe_types::SiteStackHost;
use wasm_pipe_types::{SiteStackHost, WasmResponse};
#[derive(Debug, influxdb2::FromDataPoint)]
pub struct SiteStackRow {
pub node_name: String,
pub node_parents: String,
pub bits_max: i64,
pub bits_max: f64,
pub time: chrono::DateTime<chrono::FixedOffset>,
pub direction: String,
}
@@ -22,7 +22,7 @@ impl Default for SiteStackRow {
Self {
node_name: "".to_string(),
node_parents: "".to_string(),
bits_max: 0,
bits_max: 0.0,
time: chrono::DateTime::<chrono::Utc>::MIN_UTC.into(),
direction: "".to_string(),
}
@@ -32,7 +32,7 @@ impl Default for SiteStackRow {
#[derive(Debug, influxdb2::FromDataPoint)]
pub struct CircuitStackRow {
pub circuit_id: String,
pub max: i64,
pub max: f64,
pub time: chrono::DateTime<chrono::FixedOffset>,
pub direction: String,
}
@@ -41,17 +41,17 @@ impl Default for CircuitStackRow {
fn default() -> Self {
Self {
circuit_id: "".to_string(),
max: 0,
max: 0.0,
time: chrono::DateTime::<chrono::Utc>::MIN_UTC.into(),
direction: "".to_string(),
}
}
}
#[instrument(skip(cnn, socket, key, period))]
#[instrument(skip(cnn, tx, key, period))]
pub async fn send_site_stack_map(
cnn: &Pool<Postgres>,
socket: &mut WebSocket,
tx: Sender<WasmResponse>,
key: &str,
period: InfluxTimePeriod,
site_id: String,
@@ -78,11 +78,7 @@ pub async fn send_site_stack_map(
reduce_to_x_entries(&mut result);
// Send the reply
send_response(
socket,
wasm_pipe_types::WasmResponse::SiteStack { nodes: result },
)
.await;
tx.send(WasmResponse::SiteStack { nodes: result }).await?;
}
}
}
@@ -97,29 +93,34 @@ async fn query_circuits_influx(
hosts: &[(String, String)],
host_filter: &str,
) -> anyhow::Result<Vec<SiteStackRow>> {
let influx_url = format!("http://{}:8086", org.influx_host);
let client = influxdb2::Client::new(influx_url, &org.influx_org, &org.influx_token);
let qs = format!("from(bucket: \"{}\")
|> {}
|> filter(fn: (r) => r[\"_field\"] == \"max\" and r[\"_measurement\"] == \"host_bits\" and r[\"organization_id\"] == \"{}\")
|> {}
|> filter(fn: (r) => {} )
|> group(columns: [\"circuit_id\", \"_field\", \"direction\"])
|> yield(name: \"last\")",
org.influx_bucket, period.range(), org.key, period.sample(), host_filter);
let query = influxdb2::models::Query::new(qs);
//let rows = client.query_raw(Some(query)).await;
let rows = client.query::<CircuitStackRow>(Some(query)).await?;
let rows = rows.into_iter().map(|row| {
SiteStackRow {
node_name: hosts.iter().find(|h| h.0 == row.circuit_id).unwrap().1.clone(),
if host_filter.is_empty() {
return Ok(Vec::new());
}
let rows = QueryBuilder::new()
.with_period(period)
.with_org(org.clone())
.bucket()
.range()
.measure_field_org("host_bits", "max")
.aggregate_window()
.filter(host_filter)
.group(&["circuit_id", "_field", "direction"])
.execute::<CircuitStackRow>()
.await?
.into_iter()
.map(|row| SiteStackRow {
node_name: hosts
.iter()
.find(|h| h.0 == row.circuit_id)
.unwrap()
.1
.clone(),
node_parents: "".to_string(),
bits_max: row.max / 8,
bits_max: row.max / 8.0,
time: row.time,
direction: row.direction,
}
}).collect();
})
.collect();
Ok(rows)
}
@@ -129,25 +130,20 @@ async fn query_site_stack_influx(
period: &InfluxTimePeriod,
site_index: i32,
) -> anyhow::Result<Vec<SiteStackRow>> {
let influx_url = format!("http://{}:8086", org.influx_host);
let client = influxdb2::Client::new(influx_url, &org.influx_org, &org.influx_token);
let qs = format!("import \"strings\"
from(bucket: \"{}\")
|> {}
|> filter(fn: (r) => r[\"_field\"] == \"bits_max\" and r[\"_measurement\"] == \"tree\" and r[\"organization_id\"] == \"{}\")
|> {}
|> filter(fn: (r) => exists r[\"node_parents\"] and exists r[\"node_index\"])
|> filter(fn: (r) => strings.hasSuffix(v: r[\"node_parents\"], suffix: \"S{}S\" + r[\"node_index\"] + \"S\" ))
|> group(columns: [\"node_name\", \"node_parents\", \"_field\", \"node_index\", \"direction\"])
|> yield(name: \"last\")",
org.influx_bucket, period.range(), org.key, period.sample(), site_index);
//println!("{qs}");
let query = influxdb2::models::Query::new(qs);
//let rows = client.query_raw(Some(query)).await;
Ok(client.query::<SiteStackRow>(Some(query)).await?)
Ok(QueryBuilder::new()
.add_line("import \"strings\"")
.with_period(period)
.with_org(org.clone())
.bucket()
.range()
.measure_field_org("tree", "bits_max")
.filter_and(&["exists r[\"node_parents\"]", "exists r[\"node_index\"]"])
.aggregate_window()
.filter(&format!("strings.hasSuffix(v: r[\"node_parents\"], suffix: \"S{}S\" + r[\"node_index\"] + \"S\")", site_index))
.group(&["node_name", "node_parents", "_field", "node_index", "direction"])
.execute::<SiteStackRow>()
.await?
)
}
fn site_rows_to_hosts(rows: Vec<SiteStackRow>) -> Vec<SiteStackHost> {
@@ -157,12 +153,12 @@ fn site_rows_to_hosts(rows: Vec<SiteStackRow>) -> Vec<SiteStackHost> {
if row.direction == "down" {
r.download.push((
row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
row.bits_max,
row.bits_max as i64,
));
} else {
r.upload.push((
row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
row.bits_max,
row.bits_max as i64,
));
}
} else if row.direction == "down" {
@@ -170,7 +166,7 @@ fn site_rows_to_hosts(rows: Vec<SiteStackRow>) -> Vec<SiteStackHost> {
node_name: row.node_name.clone(),
download: vec![(
row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
row.bits_max,
row.bits_max as i64,
)],
upload: vec![],
});
@@ -179,7 +175,7 @@ fn site_rows_to_hosts(rows: Vec<SiteStackRow>) -> Vec<SiteStackHost> {
node_name: row.node_name.clone(),
upload: vec![(
row.time.format("%Y-%m-%d %H:%M:%S").to_string(),
row.bits_max,
row.bits_max as i64,
)],
download: vec![],
});
@@ -219,7 +215,7 @@ fn reduce_to_x_entries(result: &mut Vec<SiteStackHost>) {
}
});
});
result.truncate(MAX_HOSTS-1);
result.truncate(MAX_HOSTS - 1);
result.push(others);
}
}

View File

@@ -1,89 +0,0 @@
#![allow(dead_code)]
#[derive(Clone, Debug)]
pub struct InfluxTimePeriod {
pub start: String,
pub aggregate: String,
sample: i32,
}
impl InfluxTimePeriod {
pub fn new(period: &str) -> Self {
let start = match period {
"5m" => "-5m",
"15m" => "-15m",
"1h" => "-60m",
"6h" => "-360m",
"12h" => "-720m",
"24h" => "-1440m",
"7d" => "-10080m",
"28d" => "-40320m",
_ => "-5m",
};
let aggregate = match period {
"5m" => "10s",
"15m" => "30s",
"1h" => "1m",
"6h" => "6m",
"12h" => "12m",
"24h" => "24m",
"7d" => "210m",
"28d" => "4h",
_ => "10s",
};
let sample = match period {
"5m" => 3,
"15m" => 10,
"1h" => 40,
"6h" => 100,
"12h" => 200,
"24h" => 400,
"7d" => 2100,
"28d" => 4400,
_ => 1
};
Self {
start: start.to_string(),
aggregate: aggregate.to_string(),
sample
}
}
pub fn range(&self) -> String {
format!("range(start: {})", self.start)
}
pub fn aggregate_window(&self) -> String {
format!(
"aggregateWindow(every: {}, fn: mean, createEmpty: false)",
self.aggregate
)
}
pub fn aggregate_window_empty(&self) -> String {
format!(
"aggregateWindow(every: {}, fn: mean, createEmpty: true)",
self.aggregate
)
}
pub fn aggregate_window_fn(&self, mode: &str) -> String {
format!(
"aggregateWindow(every: {}, fn: {mode}, createEmpty: false)",
self.aggregate
)
}
pub fn sample(&self) -> String {
format!("sample(n: {}, pos: 1)", self.sample)
}
}
impl From<&String> for InfluxTimePeriod {
fn from(period: &String) -> Self {
Self::new(period)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,18 +1,440 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>LibreQoS Long-Term Statistics</title>
<link rel="shortcut icon" href="#" />
<!doctype html>
<html lang="en" data-bs-theme="auto">
<head>
<script>
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
(() => {
'use strict'
const getStoredTheme = () => localStorage.getItem('theme')
const setStoredTheme = theme => localStorage.setItem('theme', theme)
const getPreferredTheme = () => {
const storedTheme = getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = theme => {
if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
setTheme(getPreferredTheme())
const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector('#bd-theme')
if (!themeSwitcher) {
return
}
const themeSwitcherText = document.querySelector('#bd-theme-text')
const activeThemeIcon = document.querySelector('.theme-icon-active use')
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
const svgOfActiveBtn = btnToActive.querySelector('svg use').getAttribute('href')
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active')
element.setAttribute('aria-pressed', 'false')
})
btnToActive.classList.add('active')
btnToActive.setAttribute('aria-pressed', 'true')
activeThemeIcon.setAttribute('href', svgOfActiveBtn)
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`
themeSwitcher.setAttribute('aria-label', themeSwitcherLabel)
if (focus) {
themeSwitcher.focus()
}
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') {
setTheme(getPreferredTheme())
}
})
window.addEventListener('DOMContentLoaded', () => {
showActiveTheme(getPreferredTheme())
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()
</script>
<!-- Import color switcher here -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>LibreQoS Long-Term Statistics</title>
<link rel="shortcut icon" href="#" />
<!-- From Theme -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@docsearch/css@3">
<!-- From BootStrap -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
.b-example-divider {
width: 100%;
height: 3rem;
background-color: rgba(0, 0, 0, .1);
border: solid rgba(0, 0, 0, .15);
border-width: 1px 0;
box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15);
}
.b-example-vr {
flex-shrink: 0;
width: 1.5rem;
height: 100vh;
}
.bi {
vertical-align: -.125em;
fill: currentColor;
}
.nav-scroller {
position: relative;
z-index: 2;
height: 2.75rem;
overflow-y: hidden;
}
.nav-scroller .nav {
display: flex;
flex-wrap: nowrap;
padding-bottom: 1rem;
margin-top: -1px;
overflow-x: auto;
text-align: center;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
.btn-bd-primary {
--bd-violet-bg: #712cf9;
--bd-violet-rgb: 112.520718, 44.062154, 249.437846;
--bs-btn-font-weight: 600;
--bs-btn-color: var(--bs-white);
--bs-btn-bg: var(--bd-violet-bg);
--bs-btn-border-color: var(--bd-violet-bg);
--bs-btn-hover-color: var(--bs-white);
--bs-btn-hover-bg: #6528e0;
--bs-btn-hover-border-color: #6528e0;
--bs-btn-focus-shadow-rgb: var(--bd-violet-rgb);
--bs-btn-active-color: var(--bs-btn-hover-color);
--bs-btn-active-bg: #5a23c8;
--bs-btn-active-border-color: #5a23c8;
}
.bd-mode-toggle {
z-index: 1500;
}
</style>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" class="d-none">
<symbol id="check2" viewBox="0 0 16 16">
<path
d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
</symbol>
<symbol id="circle-half" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 0 8 1v14zm0 1A8 8 0 1 1 8 0a8 8 0 0 1 0 16z" />
</symbol>
<symbol id="moon-stars-fill" viewBox="0 0 16 16">
<path
d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z" />
<path
d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.156 1.156 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z" />
</symbol>
<symbol id="sun-fill" viewBox="0 0 16 16">
<path
d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z" />
</symbol>
</svg>
<!-- Theme Switcher -->
<div class="dropdown position-fixed bottom-0 end-0 mb-3 me-3 bd-mode-toggle">
<button class="btn btn-bd-primary py-2 dropdown-toggle d-flex align-items-center" id="bd-theme" type="button"
aria-expanded="false" data-bs-toggle="dropdown" aria-label="Toggle theme (auto)">
<svg class="bi my-1 theme-icon-active" width="1em" height="1em">
<use href="#circle-half"></use>
</svg>
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text">
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="light"
aria-pressed="false">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#sun-fill"></use>
</svg>
Light
<svg class="bi ms-auto d-none" width="1em" height="1em">
<use href="#check2"></use>
</svg>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark"
aria-pressed="false">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#moon-stars-fill"></use>
</svg>
Dark
<svg class="bi ms-auto d-none" width="1em" height="1em">
<use href="#check2"></use>
</svg>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="auto"
aria-pressed="true">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#circle-half"></use>
</svg>
Auto
<svg class="bi ms-auto d-none" width="1em" height="1em">
<use href="#check2"></use>
</svg>
</button>
</li>
</ul>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="d-none">
<symbol id="calendar3" viewBox="0 0 16 16">
<path
d="M14 0H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zM1 3.857C1 3.384 1.448 3 2 3h12c.552 0 1 .384 1 .857v10.286c0 .473-.448.857-1 .857H2c-.552 0-1-.384-1-.857V3.857z" />
<path
d="M6.5 7a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm-9 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm-9 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2z" />
</symbol>
<symbol id="cart" viewBox="0 0 16 16">
<path
d="M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .49.598l-1 5a.5.5 0 0 1-.465.401l-9.397.472L4.415 11H13a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-.5zM3.102 4l.84 4.479 9.144-.459L13.89 4H3.102zM5 12a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm7 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-7 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
</symbol>
<symbol id="chevron-right" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z" />
</symbol>
<symbol id="door-closed" viewBox="0 0 16 16">
<path d="M3 2a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v13h1.5a.5.5 0 0 1 0 1h-13a.5.5 0 0 1 0-1H3V2zm1 13h8V2H4v13z" />
<path d="M9 9a1 1 0 1 0 2 0 1 1 0 0 0-2 0z" />
</symbol>
<symbol id="file-earmark" viewBox="0 0 16 16">
<path
d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
</symbol>
<symbol id="file-earmark-text" viewBox="0 0 16 16">
<path
d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1h-5zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5z" />
<path
d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5L9.5 0zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z" />
</symbol>
<symbol id="gear-wide-connected" viewBox="0 0 16 16">
<path
d="M7.068.727c.243-.97 1.62-.97 1.864 0l.071.286a.96.96 0 0 0 1.622.434l.205-.211c.695-.719 1.888-.03 1.613.931l-.08.284a.96.96 0 0 0 1.187 1.187l.283-.081c.96-.275 1.65.918.931 1.613l-.211.205a.96.96 0 0 0 .434 1.622l.286.071c.97.243.97 1.62 0 1.864l-.286.071a.96.96 0 0 0-.434 1.622l.211.205c.719.695.03 1.888-.931 1.613l-.284-.08a.96.96 0 0 0-1.187 1.187l.081.283c.275.96-.918 1.65-1.613.931l-.205-.211a.96.96 0 0 0-1.622.434l-.071.286c-.243.97-1.62.97-1.864 0l-.071-.286a.96.96 0 0 0-1.622-.434l-.205.211c-.695.719-1.888.03-1.613-.931l.08-.284a.96.96 0 0 0-1.186-1.187l-.284.081c-.96.275-1.65-.918-.931-1.613l.211-.205a.96.96 0 0 0-.434-1.622l-.286-.071c-.97-.243-.97-1.62 0-1.864l.286-.071a.96.96 0 0 0 .434-1.622l-.211-.205c-.719-.695-.03-1.888.931-1.613l.284.08a.96.96 0 0 0 1.187-1.186l-.081-.284c-.275-.96.918-1.65 1.613-.931l.205.211a.96.96 0 0 0 1.622-.434l.071-.286zM12.973 8.5H8.25l-2.834 3.779A4.998 4.998 0 0 0 12.973 8.5zm0-1a4.998 4.998 0 0 0-7.557-3.779l2.834 3.78h4.723zM5.048 3.967c-.03.021-.058.043-.087.065l.087-.065zm-.431.355A4.984 4.984 0 0 0 3.002 8c0 1.455.622 2.765 1.615 3.678L7.375 8 4.617 4.322zm.344 7.646.087.065-.087-.065z" />
</symbol>
<symbol id="graph-up" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M0 0h1v15h15v1H0V0Zm14.817 3.113a.5.5 0 0 1 .07.704l-4.5 5.5a.5.5 0 0 1-.74.037L7.06 6.767l-3.656 5.027a.5.5 0 0 1-.808-.588l4-5.5a.5.5 0 0 1 .758-.06l2.609 2.61 4.15-5.073a.5.5 0 0 1 .704-.07Z" />
</symbol>
<symbol id="house-fill" viewBox="0 0 16 16">
<path
d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L8 2.207l6.646 6.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.707 1.5Z" />
<path d="m8 3.293 6 6V13.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 13.5V9.293l6-6Z" />
</symbol>
<symbol id="list" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z" />
</symbol>
<symbol id="people" viewBox="0 0 16 16">
<path
d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8Zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022ZM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816ZM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275ZM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" />
</symbol>
<symbol id="plus-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
</symbol>
<symbol id="puzzle" viewBox="0 0 16 16">
<path
d="M3.112 3.645A1.5 1.5 0 0 1 4.605 2H7a.5.5 0 0 1 .5.5v.382c0 .696-.497 1.182-.872 1.469a.459.459 0 0 0-.115.118.113.113 0 0 0-.012.025L6.5 4.5v.003l.003.01c.004.01.014.028.036.053a.86.86 0 0 0 .27.194C7.09 4.9 7.51 5 8 5c.492 0 .912-.1 1.19-.24a.86.86 0 0 0 .271-.194.213.213 0 0 0 .039-.063v-.009a.112.112 0 0 0-.012-.025.459.459 0 0 0-.115-.118c-.375-.287-.872-.773-.872-1.469V2.5A.5.5 0 0 1 9 2h2.395a1.5 1.5 0 0 1 1.493 1.645L12.645 6.5h.237c.195 0 .42-.147.675-.48.21-.274.528-.52.943-.52.568 0 .947.447 1.154.862C15.877 6.807 16 7.387 16 8s-.123 1.193-.346 1.638c-.207.415-.586.862-1.154.862-.415 0-.733-.246-.943-.52-.255-.333-.48-.48-.675-.48h-.237l.243 2.855A1.5 1.5 0 0 1 11.395 14H9a.5.5 0 0 1-.5-.5v-.382c0-.696.497-1.182.872-1.469a.459.459 0 0 0 .115-.118.113.113 0 0 0 .012-.025L9.5 11.5v-.003a.214.214 0 0 0-.039-.064.859.859 0 0 0-.27-.193C8.91 11.1 8.49 11 8 11c-.491 0-.912.1-1.19.24a.859.859 0 0 0-.271.194.214.214 0 0 0-.039.063v.003l.001.006a.113.113 0 0 0 .012.025c.016.027.05.068.115.118.375.287.872.773.872 1.469v.382a.5.5 0 0 1-.5.5H4.605a1.5 1.5 0 0 1-1.493-1.645L3.356 9.5h-.238c-.195 0-.42.147-.675.48-.21.274-.528.52-.943.52-.568 0-.947-.447-1.154-.862C.123 9.193 0 8.613 0 8s.123-1.193.346-1.638C.553 5.947.932 5.5 1.5 5.5c.415 0 .733.246.943.52.255.333.48.48.675.48h.238l-.244-2.855zM4.605 3a.5.5 0 0 0-.498.55l.001.007.29 3.4A.5.5 0 0 1 3.9 7.5h-.782c-.696 0-1.182-.497-1.469-.872a.459.459 0 0 0-.118-.115.112.112 0 0 0-.025-.012L1.5 6.5h-.003a.213.213 0 0 0-.064.039.86.86 0 0 0-.193.27C1.1 7.09 1 7.51 1 8c0 .491.1.912.24 1.19.07.14.14.225.194.271a.213.213 0 0 0 .063.039H1.5l.006-.001a.112.112 0 0 0 .025-.012.459.459 0 0 0 .118-.115c.287-.375.773-.872 1.469-.872H3.9a.5.5 0 0 1 .498.542l-.29 3.408a.5.5 0 0 0 .497.55h1.878c-.048-.166-.195-.352-.463-.557-.274-.21-.52-.528-.52-.943 0-.568.447-.947.862-1.154C6.807 10.123 7.387 10 8 10s1.193.123 1.638.346c.415.207.862.586.862 1.154 0 .415-.246.733-.52.943-.268.205-.415.39-.463.557h1.878a.5.5 0 0 0 .498-.55l-.001-.007-.29-3.4A.5.5 0 0 1 12.1 8.5h.782c.696 0 1.182.497 1.469.872.05.065.091.099.118.115.013.008.021.01.025.012a.02.02 0 0 0 .006.001h.003a.214.214 0 0 0 .064-.039.86.86 0 0 0 .193-.27c.14-.28.24-.7.24-1.191 0-.492-.1-.912-.24-1.19a.86.86 0 0 0-.194-.271.215.215 0 0 0-.063-.039H14.5l-.006.001a.113.113 0 0 0-.025.012.459.459 0 0 0-.118.115c-.287.375-.773.872-1.469.872H12.1a.5.5 0 0 1-.498-.543l.29-3.407a.5.5 0 0 0-.497-.55H9.517c.048.166.195.352.463.557.274.21.52.528.52.943 0 .568-.447.947-.862 1.154C9.193 5.877 8.613 6 8 6s-1.193-.123-1.638-.346C5.947 5.447 5.5 5.068 5.5 4.5c0-.415.246-.733.52-.943.268-.205.415-.39.463-.557H4.605z" />
</symbol>
<symbol id="search" viewBox="0 0 16 16">
<path
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z" />
</symbol>
</svg>
<header class="navbar sticky-top bg-dark flex-md-nowrap p-0 shadow" data-bs-theme="dark">
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3 fs-6 text-white" href="#"
onclick="window.router.goto('dashboard')">LibreQoS</a>
<ul class="navbar-nav flex-row">
<li class="nav-item text-nowrap">
<div class="dropdown" style="padding: 6px;">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false" id="graphPeriodBtn">
Graph Period
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('5m')">5 minutes</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('15m')">15 minutes</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('1h')">1 hour</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('6h')">6 hours</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('12h')">12 hours</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('24h')">24 hours</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('7d')">7 days</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('28d')">28 days</a></li>
</ul>
</div>
</li>
<li class="nav-item text-nowrap">
<form class="d-flex" role="search" onkeydown="return event.key != 'Enter';">
<input id="txtSearch" class="form-control me-2" type="search" placeholder="Search" aria-label="Search"
autocomplete="off">
<div id="searchResults"
style="position: fixed; display: none; color: black; background-color: #eee; top:55px; left: 0x; width: 300px; height: auto; z-index: 2000;">
Blah</div>
</form>
</li>
<li class="nav-item text-nowrap">
<div id="connStatus" class="fs-4" style="color: red; padding: 6px;"><i class="fa-sharp fa-solid fa-plug"></i>
</div>
</li>
</ul>
<ul class="navbar-nav flex-row d-md-none">
<li class="nav-item text-nowrap">
<button class="nav-link px-3 text-white" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebarMenu"
aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
<svg class="bi">
<use xlink:href="#list" />
</svg>
</button>
</li>
</ul>
<div id="navbarSearch" class="navbar-search w-100 collapse">
<input class="form-control w-100 rounded-0 border-0" type="text" placeholder="Search" aria-label="Search">
</div>
</header>
<div class="container-fluid">
<div class="row">
<div class="sidebar border border-right col-md-3 col-lg-2 p-0 bg-body-tertiary">
<div class="offcanvas-md offcanvas-end bg-body-tertiary" tabindex="-1" id="sidebarMenu"
aria-labelledby="sidebarMenuLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="sidebarMenuLabel">LibreQoS</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#sidebarMenu"
aria-label="Close"></button>
</div>
<div class="offcanvas-body d-md-flex flex-column p-0 pt-lg-3 overflow-y-auto">
<ul class="nav nav-pills flex-column mb-auto">
<li class="nav-item text-white">
<span class="nav-link" aria-current="page" id="menuDash" onclick="window.router.goto('dashboard')">
<i class="fa-solid fa-gauge"></i> Dashboard
</span>
</li>
<li class="nav-item">
<span class="nav-link" aria-current="page" id="nodesDash" onclick="window.router.goto('shapernodes')">
<i class="fa-solid fa-server"></i> Shaper Nodes
</span>
</li>
<li class="nav-item">
<span class="nav-link" aria-current="page" id="sitetreeDash" onclick="window.router.goto('sitetree')">
<i class="fa-solid fa-tree"></i> Site Tree
</span>
</li>
<li class="nav-item">
<a href="#" class="nav-link" aria-current="page" id="menuUser">
Username
</a>
</li>
</ul>
<div id="nodeStatus">
</div>
</div>
</div>
</div>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
id="mainContent">
<i class="fa-solid fa-spinner fa-spin"></i>
</div>
</div>
</main>
</div>
</div>
<div id="SpinLoad"
style="z-index: 2000; position: absolute; left: 0px; right: 0px; top: 0px; bottom: 0px; background: #ddd; font-size: 48px; color: #555; text-align: center;">
<p>
Loading, Please Wait...
<i class="fa-solid fa-spinner fa-spin"></i>
</p>
</div>
<footer>Copyright &copy; 2023 LibreQoS</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js"
integrity="sha384-HwwvtgBNo3bZJJLYd8oVXjrBZt8cqVSpeBNS5n7C8IVInixGAoxmnlMuBnhbgrkm"
crossorigin="anonymous"></script>
<script type="module" src="/app.js"></script>
</body>
<script type="module" src="/app.js"></script>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
</head>
<body>
<div id="main"></div>
<footer>Copyright &copy; 2023 LibreQoS</footer>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -75,7 +75,7 @@ pub async fn get_device_interfaces_ext(
device_id: &str,
) -> Result<Vec<DeviceInterfaceExt>, StatsHostError>
{
sqlx::query_as::<_, DeviceInterfaceExt>("SELECT name, mac, status, speed, ip_list FROM uis_devices_interfaces WHERE key=$1 AND device_id=$2")
sqlx::query_as::<_, DeviceInterfaceExt>("SELECT name, mac, status, speed, ip_list FROM uisp_devices_interfaces WHERE key=$1 AND device_id=$2")
.bind(key)
.bind(device_id)
.fetch_all(cnn)

View File

@@ -1,8 +1,6 @@
{
"dependencies": {
"@types/bootstrap": "^5.2.6",
"@types/echarts": "^4.9.17",
"bootstrap": "^5.2.3",
"echarts": "^5.4.2",
"esbuild": "^0.17.17",
"mermaid": "^10.1.0"

View File

@@ -9,6 +9,7 @@ import { RttHistoSite } from '../components/rtt_histo_site';
import { SiteBreadcrumbs } from '../components/site_breadcrumbs';
import { SiteHeat } from '../components/site_heat';
import { SiteStackChart } from '../components/site_stack';
import { request_ext_device_info } from '../../wasm/wasm_pipe';
export class AccessPointPage implements Page {
menu: MenuPage;
@@ -52,6 +53,8 @@ export class AccessPointPage implements Page {
this.components.forEach(component => {
component.onmessage(event);
});
}
}
}

View File

@@ -20,6 +20,8 @@
</div>
</div>
<div id="ext"></div>
<div class="row">
<div class="col-6">
<div class="card">

View File

@@ -1,5 +1,5 @@
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap/dist/js/bootstrap.js';
//import 'bootstrap/dist/css/bootstrap.css';
//import 'bootstrap/dist/js/bootstrap.js';
import { SiteRouter } from './router';
import { Bus, onAuthFail, onAuthOk, onMessage } from './bus';
import { Auth } from './auth';
@@ -16,6 +16,8 @@ declare global {
login: any;
graphPeriod: string;
changeGraphPeriod: any;
toggleThroughput: any;
toggleLatency: any;
}
}
(window as any).onAuthFail = onAuthFail;
@@ -45,7 +47,11 @@ window.setInterval(() => {
window.setInterval(() => {
updateDisplayedInterval();
window.bus.updateConnected();
window.bus.sendQueue();
try {
window.bus.sendQueue();
} catch (e) {
//console.log("Error sending queue: " + e);
}
}, 500);
function updateDisplayedInterval() {

View File

@@ -16,6 +16,12 @@ export class Bus {
let indicator = document.getElementById("connStatus");
if (indicator && is_wasm_connected()) {
indicator.style.color = "green";
// Clear the loader
let loader = document.getElementById('SpinLoad');
if (loader) {
loader.style.display = "none";
}
} else if (indicator) {
indicator.style.color = "red";
}

View File

@@ -61,17 +61,32 @@ export class CircuitPage implements Page {
let d = event.DeviceExt.data[i];
html += "<div class='row'>";
html += "<div class='col-4'>";
let name = d.name;
name += " (" + formatStatus(d.status);
name += ", " + d.model;
name += ", " + d.mode + " mode";
name += ", " + d.firmware;
name += ")";
html += "<div class='col-12'>";
html += "<div class='card'>";
html += "<div class='card-body' style='height: 250px'>";
html += "<h4>" + d.name + "</h4>";
html += "<strong>Status</strong>: " + d.status + "<br>";
html += "<strong>Model</strong>: " + d.model + "<br>";
html += "<strong>Mode</strong>: " + d.mode + "<br>";
html += "<strong>Firmware</strong>: " + d.firmware + "<br>";
html += "<div class='card-body'>";
html += "<h5 class='card-title'><i class='fa fa-wifi'></i> " + name + "</h5>";
console.log(d);
for (let j=0; j<d.interfaces.length; j++) {
let iface = d.interfaces[j];
html += iface.name;
html += " (" + iface.mac + ")";
html += iface.ip_list;
html += iface.status;
html += "<br />";
}
html += "</div>";
html += "</div>";
html += "</div>";
html += "</div><div class='row'>";
html += "<div class='col-4'>";
html += "<div class='card'>";
@@ -211,3 +226,11 @@ export class CircuitPage implements Page {
}
}
}
function formatStatus(status: String): String {
switch (status) {
case "active": return "<i class='fa fa-plug' style='color: green'></i> Active";
case "disconnected" : return "<i class='fa fa-times' style='color: red'></i> Disconnected";
default: return status;
}
}

View File

@@ -3,6 +3,8 @@ import { makeUrl } from "../helpers";
import { Component } from "./component";
export class RootBreadcrumbs implements Component {
loaded: boolean = false;
constructor() {
}
@@ -13,6 +15,9 @@ export class RootBreadcrumbs implements Component {
}
ontick(): void {
if (!this.loaded) {
request_root_parents();
}
}
onmessage(event: any): void {
@@ -30,6 +35,7 @@ export class RootBreadcrumbs implements Component {
let select = document.getElementById("siteChildren") as HTMLSelectElement;
window.router.goto(select.value);
};
this.loaded = true;
}
}
}

View File

@@ -46,7 +46,7 @@ export class RttHisto implements Component {
},
yAxis: {
type: 'value',
name: 'ms',
name: 'frequency',
},
series: [
{

View File

@@ -22,11 +22,11 @@ export class SiteBreadcrumbs implements Component {
let div = document.getElementById("siteName") as HTMLDivElement;
let html = "";
let crumbs = event.SiteParents.data.reverse();
html += "<a onclick='window.router.goto(\"#dashboard\")'>Root</a> |";
for (let i = 0; i < crumbs.length-1; i++) {
let url = makeUrl(crumbs[i][0], crumbs[i][1]);
html += "<a onclick='window.router.goto(\"" + url + "\")'>" + crumbs[i][1] + "</a> | ";
}
html += "<a onclick='window.router.goto(\"#dashboard\")'>Root</a> |";
html += crumbs[crumbs.length-1][1] + " | ";
html += "<select id='siteChildren'></select>";
div.innerHTML = html;

View File

@@ -25,7 +25,7 @@ export class SiteInfo implements Component {
let html = "";
html += "<table class='table table-striped'>";
html += "<tr><td>Max:</td><td>" + scaleNumber(event.SiteInfo.data.max_down * mbps_to_bps) + " / " + scaleNumber(event.SiteInfo.data.max_up * mbps_to_bps) + "</td></tr>";
html += "<tr><td>Current:</td><td>" + scaleNumber(event.SiteInfo.data.current_down * 8) + " / " + scaleNumber(event.SiteInfo.data.current_up) + "</td></tr>";
html += "<tr><td>Current:</td><td>" + scaleNumber(event.SiteInfo.data.current_down) + " / " + scaleNumber(event.SiteInfo.data.current_up) + "</td></tr>";
html += "<tr><td>Current RTT:</td><td>" + event.SiteInfo.data.current_rtt / 100.0 + " ms</td></tr>";
html += "</table>";
div.innerHTML = html;

View File

@@ -22,7 +22,6 @@ export class DashboardPage implements Page {
container.innerHTML = html;
}
this.components = [
new NodeStatus(),
new RootBreadcrumbs(),
new PacketsChart(),
new ThroughputChart(),
@@ -31,6 +30,8 @@ export class DashboardPage implements Page {
new RootHeat(),
new SiteStackChart("root"),
];
window.toggleThroughput = toggleThroughput;
window.toggleLatency = toggleLatency;
}
wireup() {
@@ -55,4 +56,60 @@ export class DashboardPage implements Page {
});
}
}
}
function toggleThroughput(mode: string) {
let elements = [
document.getElementById("bitsholder"),
document.getElementById("packetsholder"),
document.getElementById("sitestackholder"),
];
// Clear all
elements.forEach(element => {
if (element) {
element.style.height = "0px";
element.style.overflow = "none";
}
});
var element = 0;
switch (mode) {
case "tp": { var element = 0 } break;
case "pk": { var element = 1 } break;
case "st": { var element = 2 } break;
}
let e = elements[element];
if (e) {
e.style.height = "250px";
e.style.overflow = "auto";
}
}
function toggleLatency(mode: string) {
let elements = [
document.getElementById("rttHistoHolder"),
document.getElementById("rttChartHolder"),
];
// Clear all
elements.forEach(element => {
if (element) {
element.style.height = "0px";
element.style.overflow = "none";
}
});
var element = 0;
switch (mode) {
case "histo": { var element = 0 } break;
case "line": { var element = 1 } break;
}
let e = elements[element];
if (e) {
e.style.height = "250px";
e.style.overflow = "auto";
}
}

View File

@@ -1,44 +1,32 @@
<div class="container">
<div class="row">
<div class="col-12" id="nodeStatus">
<h1><i class="fa-solid fa-gauge"></i> Dashboard</h1>
</div>
</div>
<div class="container">
<div class="row">
<div class="col-12" id="siteName">
</div>
</div>
<div class="row">
<div class="col-6">
<div class="col-12">
<div class="card">
<div class="card-body">
<div id="packetsChart" style="height: 250px"></div>
</div>
</div>
</div>
<div class="col-6">
<div class="card">
<div class="card-body">
<div id="throughputChart" style="height: 250px"></div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-6">
<div class="card">
<div class="card-body">
<div id="rttChart" style="height: 250px"></div>
</div>
</div>
</div>
<div class="col-6">
<div class="card">
<div class="card-body">
<div id="rttHisto" style="height: 250px"></div>
<h5 class="card-title">
<i class="fa-solid fa-gauge"></i> Throughput
<div class="btn-group" role="group" aria-label="Throughput Graph Selector">
<button type="button" class="btn btn-sm btn-secondary"
onclick="window.toggleThroughput('tp')">Throughput</button>
<button type="button" class="btn btn-sm btn-secondary"
onclick="window.toggleThroughput('pk')">Packets</button>
<button type="button" class="btn btn-sm btn-secondary"
onclick="window.toggleThroughput('st')">Sites</button>
</div>
</h5>
<div id="bitsholder" style="height: 250px; overflow: auto;">
<div id="throughputChart" style="height: 250px"></div>
</div>
<div id="packetsholder" style="height: 0; overflow: hidden;">
<div id="packetsChart" style="height: 250px"></div>
</div>
<div id="sitestackholder" style="height: 0; overflow: hidden;">
<div id="siteStack" style="height: 250px"></div>
</div>
</div>
</div>
</div>
@@ -48,7 +36,21 @@
<div class="col-12">
<div class="card">
<div class="card-body">
<div id="siteStack" style="height: 250px"></div>
<h5 class="card-title">
<i class="fa-solid fa-clock"></i> Latency (TCP Round-Trip Time)
<div class="btn-group" role="group" aria-label="Latency Graph Selector">
<button type="button" class="btn btn-sm btn-secondary"
onclick="window.toggleLatency('histo')">Histogram</button>
<button type="button" class="btn btn-sm btn-secondary"
onclick="window.toggleLatency('line')">Time-Series</button>
</div>
</h5>
</div>
<div id="rttHistoHolder" style="height: 250px; overflow: auto;">
<div id="rttHisto" style="height: 250px"></div>
</div>
<div id="rttChartHolder" style="height: 0; overflow: hidden;">
<div id="rttChart" style="height: 250px"></div>
</div>
</div>
</div>
@@ -63,4 +65,4 @@
</div>
</div>
</div>
</div>
</div>

View File

@@ -5,7 +5,7 @@ import { send_login } from '../../wasm/wasm_pipe';
export class LoginPage implements Page {
constructor() {
let container = document.getElementById('main');
let container = document.getElementById('mainContent');
if (container) {
container.innerHTML = html;
}

View File

@@ -1,8 +1,8 @@
<div id="container" class="pad4">
<div id="container" class="pad4" style="width: 100%">
<div class="row">
<div class="col-sm-4"></div>
<div class="col-sm-4">
<div class="card bg-light">
<div class="card">
<div class="card-body">
<h5 class="card-title">Login</h5>
<p>Please your license code, a username and password to access LibreQoS Long-Term Statistics.</p>

View File

@@ -1,18 +1,440 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>LibreQoS Long-Term Statistics</title>
<link rel="shortcut icon" href="#" />
<!doctype html>
<html lang="en" data-bs-theme="auto">
<head>
<script>
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
(() => {
'use strict'
const getStoredTheme = () => localStorage.getItem('theme')
const setStoredTheme = theme => localStorage.setItem('theme', theme)
const getPreferredTheme = () => {
const storedTheme = getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = theme => {
if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
setTheme(getPreferredTheme())
const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector('#bd-theme')
if (!themeSwitcher) {
return
}
const themeSwitcherText = document.querySelector('#bd-theme-text')
const activeThemeIcon = document.querySelector('.theme-icon-active use')
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
const svgOfActiveBtn = btnToActive.querySelector('svg use').getAttribute('href')
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active')
element.setAttribute('aria-pressed', 'false')
})
btnToActive.classList.add('active')
btnToActive.setAttribute('aria-pressed', 'true')
activeThemeIcon.setAttribute('href', svgOfActiveBtn)
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`
themeSwitcher.setAttribute('aria-label', themeSwitcherLabel)
if (focus) {
themeSwitcher.focus()
}
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') {
setTheme(getPreferredTheme())
}
})
window.addEventListener('DOMContentLoaded', () => {
showActiveTheme(getPreferredTheme())
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()
</script>
<!-- Import color switcher here -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>LibreQoS Long-Term Statistics</title>
<link rel="shortcut icon" href="#" />
<!-- From Theme -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@docsearch/css@3">
<!-- From BootStrap -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
.b-example-divider {
width: 100%;
height: 3rem;
background-color: rgba(0, 0, 0, .1);
border: solid rgba(0, 0, 0, .15);
border-width: 1px 0;
box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15);
}
.b-example-vr {
flex-shrink: 0;
width: 1.5rem;
height: 100vh;
}
.bi {
vertical-align: -.125em;
fill: currentColor;
}
.nav-scroller {
position: relative;
z-index: 2;
height: 2.75rem;
overflow-y: hidden;
}
.nav-scroller .nav {
display: flex;
flex-wrap: nowrap;
padding-bottom: 1rem;
margin-top: -1px;
overflow-x: auto;
text-align: center;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
.btn-bd-primary {
--bd-violet-bg: #712cf9;
--bd-violet-rgb: 112.520718, 44.062154, 249.437846;
--bs-btn-font-weight: 600;
--bs-btn-color: var(--bs-white);
--bs-btn-bg: var(--bd-violet-bg);
--bs-btn-border-color: var(--bd-violet-bg);
--bs-btn-hover-color: var(--bs-white);
--bs-btn-hover-bg: #6528e0;
--bs-btn-hover-border-color: #6528e0;
--bs-btn-focus-shadow-rgb: var(--bd-violet-rgb);
--bs-btn-active-color: var(--bs-btn-hover-color);
--bs-btn-active-bg: #5a23c8;
--bs-btn-active-border-color: #5a23c8;
}
.bd-mode-toggle {
z-index: 1500;
}
</style>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" class="d-none">
<symbol id="check2" viewBox="0 0 16 16">
<path
d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
</symbol>
<symbol id="circle-half" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 0 8 1v14zm0 1A8 8 0 1 1 8 0a8 8 0 0 1 0 16z" />
</symbol>
<symbol id="moon-stars-fill" viewBox="0 0 16 16">
<path
d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z" />
<path
d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.156 1.156 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z" />
</symbol>
<symbol id="sun-fill" viewBox="0 0 16 16">
<path
d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z" />
</symbol>
</svg>
<!-- Theme Switcher -->
<div class="dropdown position-fixed bottom-0 end-0 mb-3 me-3 bd-mode-toggle">
<button class="btn btn-bd-primary py-2 dropdown-toggle d-flex align-items-center" id="bd-theme" type="button"
aria-expanded="false" data-bs-toggle="dropdown" aria-label="Toggle theme (auto)">
<svg class="bi my-1 theme-icon-active" width="1em" height="1em">
<use href="#circle-half"></use>
</svg>
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text">
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="light"
aria-pressed="false">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#sun-fill"></use>
</svg>
Light
<svg class="bi ms-auto d-none" width="1em" height="1em">
<use href="#check2"></use>
</svg>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark"
aria-pressed="false">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#moon-stars-fill"></use>
</svg>
Dark
<svg class="bi ms-auto d-none" width="1em" height="1em">
<use href="#check2"></use>
</svg>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="auto"
aria-pressed="true">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#circle-half"></use>
</svg>
Auto
<svg class="bi ms-auto d-none" width="1em" height="1em">
<use href="#check2"></use>
</svg>
</button>
</li>
</ul>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="d-none">
<symbol id="calendar3" viewBox="0 0 16 16">
<path
d="M14 0H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zM1 3.857C1 3.384 1.448 3 2 3h12c.552 0 1 .384 1 .857v10.286c0 .473-.448.857-1 .857H2c-.552 0-1-.384-1-.857V3.857z" />
<path
d="M6.5 7a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm-9 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm-9 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2z" />
</symbol>
<symbol id="cart" viewBox="0 0 16 16">
<path
d="M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .49.598l-1 5a.5.5 0 0 1-.465.401l-9.397.472L4.415 11H13a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-.5zM3.102 4l.84 4.479 9.144-.459L13.89 4H3.102zM5 12a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm7 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-7 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
</symbol>
<symbol id="chevron-right" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z" />
</symbol>
<symbol id="door-closed" viewBox="0 0 16 16">
<path d="M3 2a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v13h1.5a.5.5 0 0 1 0 1h-13a.5.5 0 0 1 0-1H3V2zm1 13h8V2H4v13z" />
<path d="M9 9a1 1 0 1 0 2 0 1 1 0 0 0-2 0z" />
</symbol>
<symbol id="file-earmark" viewBox="0 0 16 16">
<path
d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
</symbol>
<symbol id="file-earmark-text" viewBox="0 0 16 16">
<path
d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1h-5zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5z" />
<path
d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5L9.5 0zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z" />
</symbol>
<symbol id="gear-wide-connected" viewBox="0 0 16 16">
<path
d="M7.068.727c.243-.97 1.62-.97 1.864 0l.071.286a.96.96 0 0 0 1.622.434l.205-.211c.695-.719 1.888-.03 1.613.931l-.08.284a.96.96 0 0 0 1.187 1.187l.283-.081c.96-.275 1.65.918.931 1.613l-.211.205a.96.96 0 0 0 .434 1.622l.286.071c.97.243.97 1.62 0 1.864l-.286.071a.96.96 0 0 0-.434 1.622l.211.205c.719.695.03 1.888-.931 1.613l-.284-.08a.96.96 0 0 0-1.187 1.187l.081.283c.275.96-.918 1.65-1.613.931l-.205-.211a.96.96 0 0 0-1.622.434l-.071.286c-.243.97-1.62.97-1.864 0l-.071-.286a.96.96 0 0 0-1.622-.434l-.205.211c-.695.719-1.888.03-1.613-.931l.08-.284a.96.96 0 0 0-1.186-1.187l-.284.081c-.96.275-1.65-.918-.931-1.613l.211-.205a.96.96 0 0 0-.434-1.622l-.286-.071c-.97-.243-.97-1.62 0-1.864l.286-.071a.96.96 0 0 0 .434-1.622l-.211-.205c-.719-.695-.03-1.888.931-1.613l.284.08a.96.96 0 0 0 1.187-1.186l-.081-.284c-.275-.96.918-1.65 1.613-.931l.205.211a.96.96 0 0 0 1.622-.434l.071-.286zM12.973 8.5H8.25l-2.834 3.779A4.998 4.998 0 0 0 12.973 8.5zm0-1a4.998 4.998 0 0 0-7.557-3.779l2.834 3.78h4.723zM5.048 3.967c-.03.021-.058.043-.087.065l.087-.065zm-.431.355A4.984 4.984 0 0 0 3.002 8c0 1.455.622 2.765 1.615 3.678L7.375 8 4.617 4.322zm.344 7.646.087.065-.087-.065z" />
</symbol>
<symbol id="graph-up" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M0 0h1v15h15v1H0V0Zm14.817 3.113a.5.5 0 0 1 .07.704l-4.5 5.5a.5.5 0 0 1-.74.037L7.06 6.767l-3.656 5.027a.5.5 0 0 1-.808-.588l4-5.5a.5.5 0 0 1 .758-.06l2.609 2.61 4.15-5.073a.5.5 0 0 1 .704-.07Z" />
</symbol>
<symbol id="house-fill" viewBox="0 0 16 16">
<path
d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L8 2.207l6.646 6.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.707 1.5Z" />
<path d="m8 3.293 6 6V13.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 13.5V9.293l6-6Z" />
</symbol>
<symbol id="list" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z" />
</symbol>
<symbol id="people" viewBox="0 0 16 16">
<path
d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8Zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022ZM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816ZM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275ZM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" />
</symbol>
<symbol id="plus-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
</symbol>
<symbol id="puzzle" viewBox="0 0 16 16">
<path
d="M3.112 3.645A1.5 1.5 0 0 1 4.605 2H7a.5.5 0 0 1 .5.5v.382c0 .696-.497 1.182-.872 1.469a.459.459 0 0 0-.115.118.113.113 0 0 0-.012.025L6.5 4.5v.003l.003.01c.004.01.014.028.036.053a.86.86 0 0 0 .27.194C7.09 4.9 7.51 5 8 5c.492 0 .912-.1 1.19-.24a.86.86 0 0 0 .271-.194.213.213 0 0 0 .039-.063v-.009a.112.112 0 0 0-.012-.025.459.459 0 0 0-.115-.118c-.375-.287-.872-.773-.872-1.469V2.5A.5.5 0 0 1 9 2h2.395a1.5 1.5 0 0 1 1.493 1.645L12.645 6.5h.237c.195 0 .42-.147.675-.48.21-.274.528-.52.943-.52.568 0 .947.447 1.154.862C15.877 6.807 16 7.387 16 8s-.123 1.193-.346 1.638c-.207.415-.586.862-1.154.862-.415 0-.733-.246-.943-.52-.255-.333-.48-.48-.675-.48h-.237l.243 2.855A1.5 1.5 0 0 1 11.395 14H9a.5.5 0 0 1-.5-.5v-.382c0-.696.497-1.182.872-1.469a.459.459 0 0 0 .115-.118.113.113 0 0 0 .012-.025L9.5 11.5v-.003a.214.214 0 0 0-.039-.064.859.859 0 0 0-.27-.193C8.91 11.1 8.49 11 8 11c-.491 0-.912.1-1.19.24a.859.859 0 0 0-.271.194.214.214 0 0 0-.039.063v.003l.001.006a.113.113 0 0 0 .012.025c.016.027.05.068.115.118.375.287.872.773.872 1.469v.382a.5.5 0 0 1-.5.5H4.605a1.5 1.5 0 0 1-1.493-1.645L3.356 9.5h-.238c-.195 0-.42.147-.675.48-.21.274-.528.52-.943.52-.568 0-.947-.447-1.154-.862C.123 9.193 0 8.613 0 8s.123-1.193.346-1.638C.553 5.947.932 5.5 1.5 5.5c.415 0 .733.246.943.52.255.333.48.48.675.48h.238l-.244-2.855zM4.605 3a.5.5 0 0 0-.498.55l.001.007.29 3.4A.5.5 0 0 1 3.9 7.5h-.782c-.696 0-1.182-.497-1.469-.872a.459.459 0 0 0-.118-.115.112.112 0 0 0-.025-.012L1.5 6.5h-.003a.213.213 0 0 0-.064.039.86.86 0 0 0-.193.27C1.1 7.09 1 7.51 1 8c0 .491.1.912.24 1.19.07.14.14.225.194.271a.213.213 0 0 0 .063.039H1.5l.006-.001a.112.112 0 0 0 .025-.012.459.459 0 0 0 .118-.115c.287-.375.773-.872 1.469-.872H3.9a.5.5 0 0 1 .498.542l-.29 3.408a.5.5 0 0 0 .497.55h1.878c-.048-.166-.195-.352-.463-.557-.274-.21-.52-.528-.52-.943 0-.568.447-.947.862-1.154C6.807 10.123 7.387 10 8 10s1.193.123 1.638.346c.415.207.862.586.862 1.154 0 .415-.246.733-.52.943-.268.205-.415.39-.463.557h1.878a.5.5 0 0 0 .498-.55l-.001-.007-.29-3.4A.5.5 0 0 1 12.1 8.5h.782c.696 0 1.182.497 1.469.872.05.065.091.099.118.115.013.008.021.01.025.012a.02.02 0 0 0 .006.001h.003a.214.214 0 0 0 .064-.039.86.86 0 0 0 .193-.27c.14-.28.24-.7.24-1.191 0-.492-.1-.912-.24-1.19a.86.86 0 0 0-.194-.271.215.215 0 0 0-.063-.039H14.5l-.006.001a.113.113 0 0 0-.025.012.459.459 0 0 0-.118.115c-.287.375-.773.872-1.469.872H12.1a.5.5 0 0 1-.498-.543l.29-3.407a.5.5 0 0 0-.497-.55H9.517c.048.166.195.352.463.557.274.21.52.528.52.943 0 .568-.447.947-.862 1.154C9.193 5.877 8.613 6 8 6s-1.193-.123-1.638-.346C5.947 5.447 5.5 5.068 5.5 4.5c0-.415.246-.733.52-.943.268-.205.415-.39.463-.557H4.605z" />
</symbol>
<symbol id="search" viewBox="0 0 16 16">
<path
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z" />
</symbol>
</svg>
<header class="navbar sticky-top bg-dark flex-md-nowrap p-0 shadow" data-bs-theme="dark">
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3 fs-6 text-white" href="#"
onclick="window.router.goto('dashboard')">LibreQoS</a>
<ul class="navbar-nav flex-row">
<li class="nav-item text-nowrap">
<div class="dropdown" style="padding: 6px;">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false" id="graphPeriodBtn">
Graph Period
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('5m')">5 minutes</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('15m')">15 minutes</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('1h')">1 hour</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('6h')">6 hours</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('12h')">12 hours</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('24h')">24 hours</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('7d')">7 days</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('28d')">28 days</a></li>
</ul>
</div>
</li>
<li class="nav-item text-nowrap">
<form class="d-flex" role="search" onkeydown="return event.key != 'Enter';">
<input id="txtSearch" class="form-control me-2" type="search" placeholder="Search" aria-label="Search"
autocomplete="off">
<div id="searchResults"
style="position: fixed; display: none; color: black; background-color: #eee; top:55px; left: 0x; width: 300px; height: auto; z-index: 2000;">
Blah</div>
</form>
</li>
<li class="nav-item text-nowrap">
<div id="connStatus" class="fs-4" style="color: red; padding: 6px;"><i class="fa-sharp fa-solid fa-plug"></i>
</div>
</li>
</ul>
<ul class="navbar-nav flex-row d-md-none">
<li class="nav-item text-nowrap">
<button class="nav-link px-3 text-white" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebarMenu"
aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
<svg class="bi">
<use xlink:href="#list" />
</svg>
</button>
</li>
</ul>
<div id="navbarSearch" class="navbar-search w-100 collapse">
<input class="form-control w-100 rounded-0 border-0" type="text" placeholder="Search" aria-label="Search">
</div>
</header>
<div class="container-fluid">
<div class="row">
<div class="sidebar border border-right col-md-3 col-lg-2 p-0 bg-body-tertiary">
<div class="offcanvas-md offcanvas-end bg-body-tertiary" tabindex="-1" id="sidebarMenu"
aria-labelledby="sidebarMenuLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="sidebarMenuLabel">LibreQoS</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#sidebarMenu"
aria-label="Close"></button>
</div>
<div class="offcanvas-body d-md-flex flex-column p-0 pt-lg-3 overflow-y-auto">
<ul class="nav nav-pills flex-column mb-auto">
<li class="nav-item text-white">
<span class="nav-link" aria-current="page" id="menuDash" onclick="window.router.goto('dashboard')">
<i class="fa-solid fa-gauge"></i> Dashboard
</span>
</li>
<li class="nav-item">
<span class="nav-link" aria-current="page" id="nodesDash" onclick="window.router.goto('shapernodes')">
<i class="fa-solid fa-server"></i> Shaper Nodes
</span>
</li>
<li class="nav-item">
<span class="nav-link" aria-current="page" id="sitetreeDash" onclick="window.router.goto('sitetree')">
<i class="fa-solid fa-tree"></i> Site Tree
</span>
</li>
<li class="nav-item">
<a href="#" class="nav-link" aria-current="page" id="menuUser">
Username
</a>
</li>
</ul>
<div id="nodeStatus">
</div>
</div>
</div>
</div>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
id="mainContent">
<i class="fa-solid fa-spinner fa-spin"></i>
</div>
</div>
</main>
</div>
</div>
<div id="SpinLoad"
style="z-index: 2000; position: absolute; left: 0px; right: 0px; top: 0px; bottom: 0px; background: #ddd; font-size: 48px; color: #555; text-align: center;">
<p>
Loading, Please Wait...
<i class="fa-solid fa-spinner fa-spin"></i>
</p>
</div>
<footer>Copyright &copy; 2023 LibreQoS</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js"
integrity="sha384-HwwvtgBNo3bZJJLYd8oVXjrBZt8cqVSpeBNS5n7C8IVInixGAoxmnlMuBnhbgrkm"
crossorigin="anonymous"></script>
<script type="module" src="/app.js"></script>
</body>
<script type="module" src="/app.js"></script>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
</head>
<body>
<div id="main"></div>
<footer>Copyright &copy; 2023 LibreQoS</footer>
</body>
</html>

View File

@@ -1,21 +1,33 @@
import html from './template.html';
import { Page } from '../page'
import { siteIcon } from '../helpers';
import { request_search } from "../../wasm/wasm_pipe";
import { NodeStatus } from '../components/node_status';
const menuElements = [ "menuDash", "nodesDash", "sitetreeDash", "menuUser" ];
export class MenuPage implements Page {
activePanel: string;
searchButton: HTMLButtonElement;
//searchButton: HTMLButtonElement;
searchBar: HTMLInputElement;
nodeStatus: NodeStatus;
constructor(activeElement: string) {
let container = document.getElementById('main');
let container = document.getElementById('mainContent');
if (container) {
container.innerHTML = html;
//container.innerHTML = html;
menuElements.forEach(element => {
let e = document.getElementById(element);
if (e) {
e.classList.remove('active');
e.style.color = "";
}
});
let activePanel = document.getElementById(activeElement);
if (activePanel) {
activePanel.classList.add('active');
activePanel.style.color = "white";
}
let username = document.getElementById('menuUser');
@@ -28,8 +40,8 @@ export class MenuPage implements Page {
}
this.searchBar = <HTMLInputElement>document.getElementById("txtSearch");
this.searchButton = <HTMLButtonElement>document.getElementById("btnSearch");
//this.searchButton = <HTMLButtonElement>document.getElementById("btnSearch");
this.nodeStatus = new NodeStatus();
this.wireup();
}
}
@@ -41,14 +53,14 @@ export class MenuPage implements Page {
r.style.display = "none";
}
let searchText = this.searchBar.value;
if (searchText.length > 3) {
if (searchText.length > 2) {
this.doSearch(searchText);
}
}
this.searchButton.onclick = () => {
/*this.searchButton.onclick = () => {
let searchText = this.searchBar.value;
this.doSearch(searchText);
}
}*/
}
doSearch(term: string) {
@@ -63,6 +75,9 @@ export class MenuPage implements Page {
onmessage(event: any) {
if (event.msg) {
switch (event.msg) {
case "NodeStatus" : {
this.nodeStatus.onmessage(event);
} break;
case "authOk": {
let username = document.getElementById('menuUser');
if (username) {
@@ -100,5 +115,6 @@ export class MenuPage implements Page {
ontick(): void {
// Do nothing
this.nodeStatus.ontick();
}
}

View File

@@ -1,69 +0,0 @@
<div class="d-flex flex-nowrap">
<div class="d-flex flex-row flex-fill text-bg-dark" style="padding: 6px;">
<a href="#" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none">
<!-- Logo -->
<span class="fs-4" onclick="window.router.goto('dashboard')">LibreQoS</span>
</a>
<div class="dropdown" style="padding: 6px;">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" id="graphPeriodBtn">
Graph Period
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('5m')">5 minutes</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('15m')">15 minutes</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('1h')">1 hour</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('6h')">6 hours</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('12h')">12 hours</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('24h')">24 hours</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('7d')">7 days</a></li>
<li><a class="dropdown-item" href="#" onclick="window.changeGraphPeriod('28d')">28 days</a></li>
</ul>
</div>
<form class="d-flex" role="search">
<input id="txtSearch" class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
<button id="btnSearch" class="btn btn-outline-success" type="submit">Search</button>
<div id="searchResults" style="position: fixed; display: none; color: black; background-color: #eee; top:55px; left: 0x; width: 300px; height: auto; z-index: 2000;">Blah</div>
</form>
<div id="connStatus" class="fs-4" style="color: red; padding: 6px;"><i class="fa-sharp fa-solid fa-plug"></i>
</div>
</div>
</div>
<div class="d-flex flex-nowrap">
<div class="d-flex flex-column flex-shrink-0 p-3 text-bg-dark" style="width: 280px">
<ul class="nav nav-pills flex-column mb-auto">
<li class="nav-item">
<span class="nav-link text-white" aria-current="page" id="menuDash" onclick="window.router.goto('dashboard')">
Dashboard
</span>
</li>
<li class="nav-item">
<span class="nav-link text-white" aria-current="page" id="nodesDash" onclick="window.router.goto('shapernodes')">
Shaper Nodes
</span>
</li>
<li class="nav-item">
<span class="nav-link text-white" aria-current="page" id="sitetreeDash" onclick="window.router.goto('sitetree')">
Site Tree
</span>
</li>
</ul>
<hr>
<ul class="nav nav-pills flex-column mb-auto">
<li class="nav-item">
<a href="#" class="nav-link text-white" aria-current="page" id="menuUser">
Username
</a>
</li>
</ul>
</div>
<div class="b-example-divider b-example-vr"></div>
<div class="d-flex flex-row p-2 flex-fill" id="mainContent">
</div>
</div>

View File

@@ -44,6 +44,12 @@ export class SiteRouter {
// Handle actual navigation between pages
goto(page: string) {
// Clear any search results
let r = document.getElementById("searchResults");
if (r) {
r.style.display = "none";
}
page = page.replace('#', '');
//console.log("Navigate to " + page)
let split = page.split(':');

View File

@@ -33,7 +33,7 @@ export class ShaperNodePage implements Page {
];
let name = document.getElementById('nodeName');
if (name) {
name.innerText = "Shaper Node: " + this.node_name;
name.innerText = "Shaper Node: " + decodeURI(this.node_name);
}
}

View File

@@ -1,24 +1,74 @@
@import 'bootstrap/dist/css/bootstrap.css';
/*@import 'bootstrap/dist/css/bootstrap.css';*/
.b-example-divider {
height: 3rem;
background-color: rgba(0, 0, 0, .1);
border: solid rgba(0, 0, 0, .15);
border-width: 1px 0;
box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15);
height: 3rem;
background-color: rgba(0, 0, 0, .1);
border: solid rgba(0, 0, 0, .15);
border-width: 1px 0;
box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15);
}
.b-example-vr {
flex-shrink: 0;
width: 0.5rem;
height: 100vh;
}
.break {
flex-basis: 100%;
height: 0;
}
.row {
margin-bottom: 2px;
}
.bi {
display: inline-block;
width: 1rem;
height: 1rem;
}
/*
* Sidebar
*/
@media (min-width: 768px) {
.sidebar .offcanvas-lg {
position: -webkit-sticky;
position: sticky;
top: 48px;
}
.b-example-vr {
flex-shrink: 0;
width: 0.5rem;
height: 100vh;
.navbar-search {
display: block;
}
}
.break {
flex-basis: 100%;
height: 0;
}
.sidebar .nav-link {
font-size: .875rem;
font-weight: 500;
}
.row {
margin-bottom: 2px;
}
.sidebar .nav-link.active {
color: #2470dc;
}
.sidebar-heading {
font-size: .75rem;
}
/*
* Navbar
*/
.navbar-brand {
padding-top: .75rem;
padding-bottom: .75rem;
background-color: rgba(0, 0, 0, .25);
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
}
.navbar .form-control {
padding: .75rem 1rem;
}

View File

@@ -139,6 +139,16 @@ export function request_ext_snr_graph(period: string, device_id: string): void;
* @param {string} device_id
*/
export function request_ext_capacity_graph(period: string, device_id: string): void;
/**
* @param {string} period
* @param {string} site_name
*/
export function request_ext_capacity_ap(period: string, site_name: string): void;
/**
* @param {string} period
* @param {string} site_name
*/
export function request_ext_signal_ap(period: string, site_name: string): void;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
@@ -175,6 +185,8 @@ export interface InitOutput {
readonly request_ext_device_info: (a: number, b: number) => void;
readonly request_ext_snr_graph: (a: number, b: number, c: number, d: number) => void;
readonly request_ext_capacity_graph: (a: number, b: number, c: number, d: number) => void;
readonly request_ext_capacity_ap: (a: number, b: number, c: number, d: number) => void;
readonly request_ext_signal_ap: (a: number, b: number, c: number, d: number) => void;
readonly __wbindgen_export_0: (a: number) => number;
readonly __wbindgen_export_1: (a: number, b: number, c: number) => number;
readonly __wbindgen_export_2: WebAssembly.Table;

View File

@@ -528,6 +528,30 @@ export function request_ext_capacity_graph(period, device_id) {
wasm.request_ext_capacity_graph(ptr0, len0, ptr1, len1);
}
/**
* @param {string} period
* @param {string} site_name
*/
export function request_ext_capacity_ap(period, site_name) {
const ptr0 = passStringToWasm0(period, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(site_name, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1);
const len1 = WASM_VECTOR_LEN;
wasm.request_ext_capacity_ap(ptr0, len0, ptr1, len1);
}
/**
* @param {string} period
* @param {string} site_name
*/
export function request_ext_signal_ap(period, site_name) {
const ptr0 = passStringToWasm0(period, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(site_name, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1);
const len1 = WASM_VECTOR_LEN;
wasm.request_ext_signal_ap(ptr0, len0, ptr1, len1);
}
function handleError(f, args) {
try {
return f.apply(this, args);

View File

@@ -32,6 +32,8 @@ export function request_circuit_info(a: number, b: number): void;
export function request_ext_device_info(a: number, b: number): void;
export function request_ext_snr_graph(a: number, b: number, c: number, d: number): void;
export function request_ext_capacity_graph(a: number, b: number, c: number, d: number): void;
export function request_ext_capacity_ap(a: number, b: number, c: number, d: number): void;
export function request_ext_signal_ap(a: number, b: number, c: number, d: number): void;
export function __wbindgen_export_0(a: number): number;
export function __wbindgen_export_1(a: number, b: number, c: number): number;
export const __wbindgen_export_2: WebAssembly.Table;