From 0d0b2d9b4610929fd57e43186775e8cdf51cb067 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Wed, 26 Apr 2023 18:04:17 +0000 Subject: [PATCH] Finish RTT integration with multiple hosts. Add a time period to graphs. Add a drop-down selector for visible time period. Remember your preferred setting from last time. --- .../lts_node/src/web/wss/mod.rs | 54 ++++++++-- .../lts_node/src/web/wss/queries/mod.rs | 1 + .../src/web/wss/queries/packet_counts/mod.rs | 20 ++-- .../lts_node/src/web/wss/queries/rtt/mod.rs | 100 ++++++++++++++++++ .../src/web/wss/queries/rtt/rtt_host.rs | 23 ++++ .../src/web/wss/queries/rtt/rtt_row.rs | 23 ++++ .../src/web/wss/queries/throughput/mod.rs | 20 ++-- .../src/web/wss/queries/time_period.rs | 55 ++++++++++ .../long_term_stats/site_build/src/app.ts | 18 +++- .../long_term_stats/site_build/src/bus.ts | 27 +++-- .../long_term_stats/site_build/src/main.html | 1 - .../site_build/src/menu/template.html | 40 +++++-- .../long_term_stats/site_build/src/style.css | 6 -- 13 files changed, 338 insertions(+), 50 deletions(-) create mode 100644 src/rust/long_term_stats/lts_node/src/web/wss/queries/rtt/mod.rs create mode 100644 src/rust/long_term_stats/lts_node/src/web/wss/queries/rtt/rtt_host.rs create mode 100644 src/rust/long_term_stats/lts_node/src/web/wss/queries/rtt/rtt_row.rs create mode 100644 src/rust/long_term_stats/lts_node/src/web/wss/queries/time_period.rs diff --git a/src/rust/long_term_stats/lts_node/src/web/wss/mod.rs b/src/rust/long_term_stats/lts_node/src/web/wss/mod.rs index a04d69d7..2434f6cd 100644 --- a/src/rust/long_term_stats/lts_node/src/web/wss/mod.rs +++ b/src/rust/long_term_stats/lts_node/src/web/wss/mod.rs @@ -1,15 +1,23 @@ +use crate::web::wss::queries::{ + send_packets_for_all_nodes, send_rtt_for_all_nodes, send_throughput_for_all_nodes, +}; use axum::{ - extract::{ws::{WebSocket, WebSocketUpgrade}, State}, + extract::{ + ws::{WebSocket, WebSocketUpgrade}, + State, + }, response::IntoResponse, }; use pgdb::sqlx::{Pool, Postgres}; use serde_json::Value; -use crate::web::wss::queries::{send_packets_for_all_nodes, send_throughput_for_all_nodes, send_rtt_for_all_nodes}; mod login; mod nodes; mod queries; -pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State>) -> impl IntoResponse { +pub async fn ws_handler( + ws: WebSocketUpgrade, + State(state): State>, +) -> impl IntoResponse { ws.on_upgrade(move |sock| handle_socket(sock, state)) } @@ -31,15 +39,20 @@ async fn handle_socket(mut socket: WebSocket, cnn: Pool) { let _ = pgdb::refresh_token(cnn.clone(), &credentials.token).await; } + let period = + queries::time_period::InfluxTimePeriod::new(json.get("period").cloned()); + if let Some(Value::String(msg_type)) = json.get("msg") { match msg_type.as_str() { - "login" => { // A full login request + "login" => { + // A full login request let result = login::on_login(&json, &mut socket, cnn).await; if let Some(result) = result { credentials = Some(result); } } - "auth" => { // Login with just a token + "auth" => { + // Login with just a token let result = login::on_token_auth(&json, &mut socket, cnn).await; if let Some(result) = result { credentials = Some(result); @@ -47,28 +60,51 @@ async fn handle_socket(mut socket: WebSocket, cnn: Pool) { } "nodeStatus" => { if let Some(credentials) = &credentials { - nodes::node_status(cnn.clone(), &mut socket, &credentials.license_key).await; + nodes::node_status( + cnn.clone(), + &mut socket, + &credentials.license_key, + ) + .await; } else { log::info!("Node status requested but no credentials provided"); } } "packetChart" => { if let Some(credentials) = &credentials { - let _ = send_packets_for_all_nodes(cnn.clone(), &mut socket, &credentials.license_key).await; + let _ = send_packets_for_all_nodes( + cnn.clone(), + &mut socket, + &credentials.license_key, + period, + ) + .await; } else { log::info!("Throughput requested but no credentials provided"); } } "throughputChart" => { if let Some(credentials) = &credentials { - let _ = send_throughput_for_all_nodes(cnn.clone(), &mut socket, &credentials.license_key).await; + let _ = send_throughput_for_all_nodes( + cnn.clone(), + &mut socket, + &credentials.license_key, + period, + ) + .await; } else { log::info!("Throughput requested but no credentials provided"); } } "rttChart" => { if let Some(credentials) = &credentials { - let _ = send_rtt_for_all_nodes(cnn.clone(), &mut socket, &credentials.license_key).await; + let _ = send_rtt_for_all_nodes( + cnn.clone(), + &mut socket, + &credentials.license_key, + period, + ) + .await; } else { log::info!("Throughput requested but no credentials provided"); } diff --git a/src/rust/long_term_stats/lts_node/src/web/wss/queries/mod.rs b/src/rust/long_term_stats/lts_node/src/web/wss/queries/mod.rs index 02b57bc9..5b736bb5 100644 --- a/src/rust/long_term_stats/lts_node/src/web/wss/queries/mod.rs +++ b/src/rust/long_term_stats/lts_node/src/web/wss/queries/mod.rs @@ -4,6 +4,7 @@ mod packet_counts; mod throughput; mod rtt; +pub mod time_period; pub use packet_counts::send_packets_for_all_nodes; pub use throughput::send_throughput_for_all_nodes; pub use rtt::send_rtt_for_all_nodes; \ No newline at end of file diff --git a/src/rust/long_term_stats/lts_node/src/web/wss/queries/packet_counts/mod.rs b/src/rust/long_term_stats/lts_node/src/web/wss/queries/packet_counts/mod.rs index 870f12be..d46c67e5 100644 --- a/src/rust/long_term_stats/lts_node/src/web/wss/queries/packet_counts/mod.rs +++ b/src/rust/long_term_stats/lts_node/src/web/wss/queries/packet_counts/mod.rs @@ -8,8 +8,10 @@ use futures::future::join_all; use influxdb2::{models::Query, Client}; use pgdb::sqlx::{Pool, Postgres}; -pub async fn send_packets_for_all_nodes(cnn: Pool, socket: &mut WebSocket, key: &str) -> anyhow::Result<()> { - let nodes = get_packets_for_all_nodes(cnn, key).await?; +use super::time_period::InfluxTimePeriod; + +pub async fn send_packets_for_all_nodes(cnn: Pool, socket: &mut WebSocket, key: &str, period: InfluxTimePeriod) -> anyhow::Result<()> { + let nodes = get_packets_for_all_nodes(cnn, key, period).await?; let chart = PacketChart { msg: "packetChart".to_string(), nodes }; let json = serde_json::to_string(&chart).unwrap(); @@ -22,7 +24,7 @@ pub async fn send_packets_for_all_nodes(cnn: Pool, socket: &mut WebSoc /// # Arguments /// * `cnn` - A connection pool to the database /// * `key` - The organization's license key -pub async fn get_packets_for_all_nodes(cnn: Pool, key: &str) -> anyhow::Result> { +pub async fn get_packets_for_all_nodes(cnn: Pool, key: &str, period: InfluxTimePeriod) -> anyhow::Result> { let node_status = pgdb::node_status(cnn.clone(), key).await?; let mut futures = Vec::new(); for node in node_status { @@ -31,6 +33,7 @@ pub async fn get_packets_for_all_nodes(cnn: Pool, key: &str) -> anyhow key, node.node_id.to_string(), node.node_name.to_string(), + period.clone(), )); } let all_nodes: anyhow::Result> = join_all(futures).await @@ -50,6 +53,7 @@ pub async fn get_packets_for_node( key: &str, node_id: String, node_name: String, + period: InfluxTimePeriod, ) -> anyhow::Result { if let Some(org) = get_org_details(cnn, key).await { let influx_url = format!("http://{}:8086", org.influx_host); @@ -57,13 +61,13 @@ pub async fn get_packets_for_node( let qs = format!( "from(bucket: \"{}\") - |> range(start: -5m) + |> {} |> filter(fn: (r) => r[\"_measurement\"] == \"packets\") |> filter(fn: (r) => r[\"organization_id\"] == \"{}\") |> filter(fn: (r) => r[\"host_id\"] == \"{}\") - |> aggregateWindow(every: 10s, fn: mean, createEmpty: false) + |> {} |> yield(name: \"last\")", - org.influx_bucket, org.key, node_id + org.influx_bucket, period.range(), org.key, node_id, period.aggregate_window() ); let query = Query::new(qs); @@ -84,7 +88,7 @@ pub async fn get_packets_for_node( for row in rows.iter().filter(|r| r.direction == "down") { down.push(Packets { value: row.avg, - date: row.time.format("%H:%M:%S").to_string(), + date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(), l: row.min, u: row.max - row.min, }); @@ -94,7 +98,7 @@ pub async fn get_packets_for_node( for row in rows.iter().filter(|r| r.direction == "up") { up.push(Packets { value: row.avg, - date: row.time.format("%H:%M:%S").to_string(), + date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(), l: row.min, u: row.max - row.min, }); diff --git a/src/rust/long_term_stats/lts_node/src/web/wss/queries/rtt/mod.rs b/src/rust/long_term_stats/lts_node/src/web/wss/queries/rtt/mod.rs new file mode 100644 index 00000000..6e4e7cc7 --- /dev/null +++ b/src/rust/long_term_stats/lts_node/src/web/wss/queries/rtt/mod.rs @@ -0,0 +1,100 @@ +use axum::extract::ws::{WebSocket, Message}; +use futures::future::join_all; +use influxdb2::{Client, models::Query}; +use pgdb::sqlx::{Pool, Postgres}; +use crate::submissions::get_org_details; +use self::{rtt_row::RttRow, rtt_host::{Rtt, RttHost, RttChart}}; + +use super::time_period::InfluxTimePeriod; +mod rtt_row; +mod rtt_host; + +pub async fn send_rtt_for_all_nodes(cnn: Pool, socket: &mut WebSocket, key: &str, period: InfluxTimePeriod) -> anyhow::Result<()> { + let nodes = get_rtt_for_all_nodes(cnn, key, period).await?; + + 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; + } + } + + let chart = RttChart { msg: "rttChart".to_string(), nodes, histogram }; + let json = serde_json::to_string(&chart).unwrap(); + socket.send(Message::Text(json)).await.unwrap(); + Ok(()) +} + +pub async fn get_rtt_for_all_nodes(cnn: Pool, key: &str, period: InfluxTimePeriod) -> anyhow::Result> { + let node_status = pgdb::node_status(cnn.clone(), key).await?; + let mut futures = Vec::new(); + for node in node_status { + futures.push(get_rtt_for_node( + cnn.clone(), + key, + node.node_id.to_string(), + node.node_name.to_string(), + period.clone(), + )); + } + let all_nodes: anyhow::Result> = join_all(futures).await + .into_iter().collect(); + all_nodes +} + +pub async fn get_rtt_for_node( + cnn: Pool, + key: &str, + node_id: String, + node_name: String, + 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\"] == \"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 query = Query::new(qs); + let rows = client.query::(Some(query)).await; + match rows { + Err(e) => { + tracing::error!("Error querying InfluxDB: {}", 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 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, + }); + } + } + } + Err(anyhow::Error::msg("Unable to query influx")) +} diff --git a/src/rust/long_term_stats/lts_node/src/web/wss/queries/rtt/rtt_host.rs b/src/rust/long_term_stats/lts_node/src/web/wss/queries/rtt/rtt_host.rs new file mode 100644 index 00000000..9b6b2797 --- /dev/null +++ b/src/rust/long_term_stats/lts_node/src/web/wss/queries/rtt/rtt_host.rs @@ -0,0 +1,23 @@ +use serde::Serialize; + +#[derive(Serialize, Debug)] +pub struct Rtt { + pub value: f64, + pub date: String, + pub l: f64, + pub u: f64, +} + +#[derive(Serialize, Debug)] +pub struct RttHost { + pub node_id: String, + pub node_name: String, + pub rtt: Vec, +} + +#[derive(Serialize, Debug)] +pub struct RttChart { + pub msg: String, + pub nodes: Vec, + pub histogram: Vec, +} \ No newline at end of file diff --git a/src/rust/long_term_stats/lts_node/src/web/wss/queries/rtt/rtt_row.rs b/src/rust/long_term_stats/lts_node/src/web/wss/queries/rtt/rtt_row.rs new file mode 100644 index 00000000..6f3de88b --- /dev/null +++ b/src/rust/long_term_stats/lts_node/src/web/wss/queries/rtt/rtt_row.rs @@ -0,0 +1,23 @@ +use chrono::{DateTime, FixedOffset, Utc}; +use influxdb2::FromDataPoint; + +#[derive(Debug, FromDataPoint)] +pub struct RttRow { + pub host_id: String, + pub min: f64, + pub max: f64, + pub avg: f64, + pub time: DateTime, +} + +impl Default for RttRow { + fn default() -> Self { + Self { + host_id: "".to_string(), + min: 0.0, + max: 0.0, + avg: 0.0, + time: DateTime::::MIN_UTC.into(), + } + } +} \ No newline at end of file diff --git a/src/rust/long_term_stats/lts_node/src/web/wss/queries/throughput/mod.rs b/src/rust/long_term_stats/lts_node/src/web/wss/queries/throughput/mod.rs index 68d28795..55a5ebd2 100644 --- a/src/rust/long_term_stats/lts_node/src/web/wss/queries/throughput/mod.rs +++ b/src/rust/long_term_stats/lts_node/src/web/wss/queries/throughput/mod.rs @@ -4,11 +4,13 @@ use influxdb2::{Client, models::Query}; use pgdb::sqlx::{Pool, Postgres}; use crate::submissions::get_org_details; use self::{throughput_host::{ThroughputHost, Throughput, ThroughputChart}, throughput_row::ThroughputRow}; + +use super::time_period::InfluxTimePeriod; mod throughput_host; mod throughput_row; -pub async fn send_throughput_for_all_nodes(cnn: Pool, socket: &mut WebSocket, key: &str) -> anyhow::Result<()> { - let nodes = get_throughput_for_all_nodes(cnn, key).await?; +pub async fn send_throughput_for_all_nodes(cnn: Pool, socket: &mut WebSocket, key: &str, period: InfluxTimePeriod) -> anyhow::Result<()> { + let nodes = get_throughput_for_all_nodes(cnn, key, period).await?; let chart = ThroughputChart { msg: "bitsChart".to_string(), nodes }; let json = serde_json::to_string(&chart).unwrap(); @@ -16,7 +18,7 @@ pub async fn send_throughput_for_all_nodes(cnn: Pool, socket: &mut Web Ok(()) } -pub async fn get_throughput_for_all_nodes(cnn: Pool, key: &str) -> anyhow::Result> { +pub async fn get_throughput_for_all_nodes(cnn: Pool, key: &str, period: InfluxTimePeriod) -> anyhow::Result> { let node_status = pgdb::node_status(cnn.clone(), key).await?; let mut futures = Vec::new(); for node in node_status { @@ -25,6 +27,7 @@ pub async fn get_throughput_for_all_nodes(cnn: Pool, key: &str) -> any key, node.node_id.to_string(), node.node_name.to_string(), + period.clone(), )); } let all_nodes: anyhow::Result> = join_all(futures).await @@ -37,6 +40,7 @@ pub async fn get_throughput_for_node( key: &str, node_id: String, node_name: String, + period: InfluxTimePeriod, ) -> anyhow::Result { if let Some(org) = get_org_details(cnn, key).await { let influx_url = format!("http://{}:8086", org.influx_host); @@ -44,13 +48,13 @@ pub async fn get_throughput_for_node( let qs = format!( "from(bucket: \"{}\") - |> range(start: -5m) + |> {} |> filter(fn: (r) => r[\"_measurement\"] == \"bits\") |> filter(fn: (r) => r[\"organization_id\"] == \"{}\") |> filter(fn: (r) => r[\"host_id\"] == \"{}\") - |> aggregateWindow(every: 10s, fn: mean, createEmpty: false) + |> {} |> yield(name: \"last\")", - org.influx_bucket, org.key, node_id + org.influx_bucket, period.range(), org.key, node_id, period.aggregate_window() ); let query = Query::new(qs); @@ -71,7 +75,7 @@ pub async fn get_throughput_for_node( for row in rows.iter().filter(|r| r.direction == "down") { down.push(Throughput { value: row.avg, - date: row.time.format("%H:%M:%S").to_string(), + date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(), l: row.min, u: row.max - row.min, }); @@ -81,7 +85,7 @@ pub async fn get_throughput_for_node( for row in rows.iter().filter(|r| r.direction == "up") { up.push(Throughput { value: row.avg, - date: row.time.format("%H:%M:%S").to_string(), + date: row.time.format("%Y-%m-%d %H:%M:%S").to_string(), l: row.min, u: row.max - row.min, }); diff --git a/src/rust/long_term_stats/lts_node/src/web/wss/queries/time_period.rs b/src/rust/long_term_stats/lts_node/src/web/wss/queries/time_period.rs new file mode 100644 index 00000000..921fd50f --- /dev/null +++ b/src/rust/long_term_stats/lts_node/src/web/wss/queries/time_period.rs @@ -0,0 +1,55 @@ +use serde_json::Value; + +#[derive(Clone)] +pub struct InfluxTimePeriod { + start: String, + aggregate: String, +} + +impl InfluxTimePeriod { + pub fn new(period: Option) -> Self { + if let Some(period) = period { + let start = match period.as_str() { + Some("5m") => "-5m", + Some("15m") => "-15m", + Some("1h") => "-60m", + Some("6h") => "-360m", + Some("12h") => "-720m", + Some("24h") => "-1440m", + Some("7d") => "-10080m", + Some("28d") => "-40320m", + _ => "-5m", + }; + + let aggregate = match period.as_str() { + Some("5m") => "10s", + Some("15m") => "10s", + Some("1h") => "10s", + Some("6h") => "1m", + Some("12h") => "2m", + Some("24h") => "4m", + Some("7d") => "30m", + Some("28d") => "1h", + _ => "10s" + }; + + Self { + start: start.to_string(), + aggregate: aggregate.to_string(), + } + } else { + Self { + start: "-5m".to_string(), + aggregate: "10s".to_string(), + } + } + } + + 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) + } +} \ No newline at end of file diff --git a/src/rust/long_term_stats/site_build/src/app.ts b/src/rust/long_term_stats/site_build/src/app.ts index b912fcc2..4406ee5e 100644 --- a/src/rust/long_term_stats/site_build/src/app.ts +++ b/src/rust/long_term_stats/site_build/src/app.ts @@ -1,4 +1,5 @@ import 'bootstrap/dist/css/bootstrap.css'; +import 'bootstrap/dist/js/bootstrap.js'; import { SiteRouter } from './router'; import { Bus } from './bus'; import { Auth } from './auth'; @@ -9,6 +10,8 @@ declare global { bus: Bus; auth: Auth; login: any; + graphPeriod: string; + changeGraphPeriod: any; } } @@ -17,7 +20,20 @@ window.bus = new Bus(); window.router = new SiteRouter(); window.bus.connect(); window.router.initialRoute(); +let graphPeriod = localStorage.getItem('graphPeriod'); +if (!graphPeriod) { + graphPeriod = "5m"; + localStorage.setItem('graphPeriod', graphPeriod); +} +window.graphPeriod = graphPeriod; +window.changeGraphPeriod = (period: string) => changeGraphPeriod(period); window.setInterval(() => { window.router.ontick(); -}, 1000); \ No newline at end of file + window.bus.updateConnected(); +}, 1000); + + function changeGraphPeriod(period: string) { + window.graphPeriod = period; + localStorage.setItem('graphPeriod', period); +} \ No newline at end of file diff --git a/src/rust/long_term_stats/site_build/src/bus.ts b/src/rust/long_term_stats/site_build/src/bus.ts index ffc01062..c5f4b069 100644 --- a/src/rust/long_term_stats/site_build/src/bus.ts +++ b/src/rust/long_term_stats/site_build/src/bus.ts @@ -3,24 +3,29 @@ import { SiteRouter } from "./router"; export class Bus { ws: WebSocket; + connected: boolean; constructor() { + this.connected = false; + } + + updateConnected() { + let indicator = document.getElementById("connStatus"); + if (indicator && this.connected) { + indicator.style.color = "green"; + } else if (indicator) { + indicator.style.color = "red"; + } } connect() { this.ws = new WebSocket("ws://192.168.100.10:9127/ws"); this.ws.onopen = () => { - let indicator = document.getElementById("connStatus"); - if (indicator) { - indicator.style.color = "green"; - } + this.connected = true; this.sendToken(); }; this.ws.onclose = (e) => { - let indicator = document.getElementById("connStatus"); - if (indicator) { - indicator.style.color = "red"; - } + this.connected = false; console.log("close", e) }; this.ws.onerror = (e) => { console.log("error", e) }; @@ -53,15 +58,15 @@ export class Bus { } requestPacketChart() { - this.ws.send("{ \"msg\": \"packetChart\" }"); + this.ws.send("{ \"msg\": \"packetChart\", \"period\": \"" + window.graphPeriod + "\" }"); } requestThroughputChart() { - this.ws.send("{ \"msg\": \"throughputChart\" }"); + this.ws.send("{ \"msg\": \"throughputChart\", \"period\": \"" + window.graphPeriod + "\" }"); } requestRttChart() { - this.ws.send("{ \"msg\": \"rttChart\" }"); + this.ws.send("{ \"msg\": \"rttChart\", \"period\": \"" + window.graphPeriod + "\" }"); } } diff --git a/src/rust/long_term_stats/site_build/src/main.html b/src/rust/long_term_stats/site_build/src/main.html index 222d40c2..191112dc 100644 --- a/src/rust/long_term_stats/site_build/src/main.html +++ b/src/rust/long_term_stats/site_build/src/main.html @@ -12,7 +12,6 @@ -
Copyright © 2023 LibreQoS
diff --git a/src/rust/long_term_stats/site_build/src/menu/template.html b/src/rust/long_term_stats/site_build/src/menu/template.html index b7ab90c9..c4bbede5 100644 --- a/src/rust/long_term_stats/site_build/src/menu/template.html +++ b/src/rust/long_term_stats/site_build/src/menu/template.html @@ -1,10 +1,38 @@ -
-
- +
+
+ LibreQoS -
+ + + + + +
+
+
+
+ +
\ No newline at end of file + \ No newline at end of file diff --git a/src/rust/long_term_stats/site_build/src/style.css b/src/rust/long_term_stats/site_build/src/style.css index 522f1d32..0769e6f0 100644 --- a/src/rust/long_term_stats/site_build/src/style.css +++ b/src/rust/long_term_stats/site_build/src/style.css @@ -1,10 +1,4 @@ @import 'bootstrap/dist/css/bootstrap.css'; -#connStatus { - position: fixed; - right: 0; - top: 0; - z-index: 1000; -} .b-example-divider { height: 3rem;