Merge pull request #1047 from LibreQoE/feat/dynamic_circuits

Feat/dynamic circuits
This commit is contained in:
Robert Chacón authored and GitHub committed 2026-04-13 13:42:09 -06:00
commit 1f1d5e82b7
90 files changed
+7518 -2718

No files matched your search

+26
View File
@@ -155,6 +155,32 @@ This can be used to exclude entire subscriber populations from LibreQoS shaping
Use this carefully: `ignore_subnets` is broader than a billing-only toggle. The same setting also affects other LibreQoS/Insight IP-policy handling.
#### Dynamic circuits (optional)
LibreQoS can be configured with an optional `[dynamic_circuits]` section. This is intended for the dynamic-circuit overlay layer (including unknown-IP promotion workflows).
Example:
```toml
[dynamic_circuits]
enabled = false
ttl_seconds = 300
enable_unknown_ip_promotion = false
[[dynamic_circuits.ranges]]
name = "Default"
ip_range = "0.0.0.0" # shorthand for 0.0.0.0/0
download_min_mbps = 10.0
upload_min_mbps = 10.0
download_max_mbps = 100.0
upload_max_mbps = 100.0
attach_to = "" # optional network.json node name
```
Notes:
- `ip_range` must be a CIDR. `0.0.0.0` (and `::`) are allowed shorthands for the match-all `/0` networks.
- `attach_to` is a `network.json` node name (optional; empty is allowed).
#### CRM/NMS Integrations
Learn more about [configuring integrations here](integrations.md).
+442 -411
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -24,6 +24,7 @@ codegen-units = 16
members = [
"lqos_sys", # System support for handling the XDP component
"lqos_config", # Configuration support
"lqos_network_devices", # Shared network.json + ShapedDevices.csv runtime access layer
"lqosd", # LibreQoS Daemon
"lqos_bus", # Bus data types
"lqtop", # A command line utility to show current activity
@@ -43,6 +44,7 @@ members = [
"lqos_overrides", # A CLI tool and library for unifying the override system and allowing API support for changing network.json and ShapedDevices.csv
"lqos_topology", # Shared topology runtime domain logic and health/effective-state helper.
"lqos_topology_compile", # Shared topology compilation and mode projection layer.
"lqos_support_tool", # Support/sanity-check CLI for gathering and submitting support dumps.
"lqos_netplan_helper", # Managed netplan inspection/transaction helper.
]
@@ -64,7 +66,7 @@ serde_json = "1"
csv = "1"
ip_network_table = "0"
ip_network = "0"
sha2 = "0"
sha2 = "0.10"
hmac = "0.12"
base64 = "0.22"
argon2 = "0.5"
+22
View File
@@ -231,6 +231,28 @@ pub enum BakeryCommands {
/// Optional per-circuit SQM override: "cake" or "fq_codel"
sqm_override: Option<String>,
},
/// Create or update a runtime-only dynamic circuit overlay entry.
///
/// This does not mutate `ShapedDevices.csv`. Overlay circuits are retained across full reloads
/// and are applied to the active shaping tree when possible.
UpsertDynamicCircuitOverlay {
/// Shaped-device-like definition for the dynamic circuit/device.
shaped_device: Box<lqos_config::ShapedDevice>,
/// Optional synchronous reply channel for immediate acceptance reporting.
#[allocative(skip)]
reply: Option<ReplySender<Result<Option<TcHandle>, String>>>,
},
/// Remove a runtime-only dynamic circuit overlay entry by circuit identifier.
///
/// This does not mutate `ShapedDevices.csv`. When the circuit exists in the active shaping
/// tree, it is removed live when possible.
RemoveDynamicCircuitOverlay {
/// Stable circuit identifier to remove.
circuit_id: String,
/// Optional synchronous reply channel for immediate acceptance reporting.
#[allocative(skip)]
reply: Option<ReplySender<Result<(), String>>>,
},
/// Change a specific HTB class rate on-the-fly; optionally dry-run.
StormGuardAdjustment {
/// If true, log the tc command instead of executing it.
+559 -1
View File
@@ -29,7 +29,7 @@ use crossbeam_channel::{Receiver, RecvTimeoutError, Sender};
use parking_lot::RwLock;
use std::collections::VecDeque;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering::Relaxed;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, OnceLock};
@@ -70,6 +70,7 @@ use lqos_config::{
plan_top_level_assignments,
};
use qdisc_handles::MqDeviceLayout;
use serde::Deserialize;
use serde_json::{Map, Value};
const TEST_FAULT_ONCE_PATH: &str = "/tmp/lqos_bakery_fail_purpose_once.txt";
@@ -481,6 +482,520 @@ fn parse_ip_list(s: &str) -> Vec<String> {
.collect()
}
const DYNAMIC_CIRCUITS_FILENAME: &str = "dynamic_circuits.json";
#[derive(Clone, Debug)]
struct DynamicCircuitOverlayEntry {
shaped_device: lqos_config::ShapedDevice,
class_minor: Option<u16>,
}
#[derive(Debug, Deserialize)]
struct PersistedDynamicCircuit {
shaped: lqos_config::ShapedDevice,
#[allow(dead_code)]
#[serde(default)]
last_seen_unix: u64,
}
#[derive(Debug, Default, Deserialize)]
struct PersistedDynamicCircuitsFile {
#[serde(default)]
#[allow(dead_code)]
schema_version: u32,
#[serde(default)]
circuits: Vec<PersistedDynamicCircuit>,
}
fn dynamic_circuits_path(config: &Config) -> PathBuf {
Path::new(&config.lqos_directory).join(DYNAMIC_CIRCUITS_FILENAME)
}
fn recompute_shaped_hashes(device: &mut lqos_config::ShapedDevice) {
device.circuit_hash = runtime_hash_to_i64(&device.circuit_id);
device.device_hash = runtime_hash_to_i64(&device.device_id);
device.parent_hash = runtime_hash_to_i64(&device.parent_node);
}
fn load_dynamic_circuit_overlays_from_disk(
config: &Config,
) -> HashMap<i64, DynamicCircuitOverlayEntry> {
let path = dynamic_circuits_path(config);
if !path.exists() {
return HashMap::new();
}
let raw = match std::fs::read_to_string(&path) {
Ok(value) => value,
Err(err) => {
warn!(
"Bakery: unable to read dynamic circuits file {} ({err}); treating as empty",
path.display()
);
return HashMap::new();
}
};
if raw.trim().is_empty() {
return HashMap::new();
}
let parsed: PersistedDynamicCircuitsFile = match serde_json::from_str(&raw) {
Ok(value) => value,
Err(err) => {
warn!(
"Bakery: unable to parse dynamic circuits file {} ({err}); treating as empty",
path.display()
);
return HashMap::new();
}
};
let mut overlays = HashMap::new();
for entry in parsed.circuits {
let mut shaped = entry.shaped;
if shaped.circuit_id.trim().is_empty() {
continue;
}
if shaped.device_id.trim().is_empty() {
continue;
}
recompute_shaped_hashes(&mut shaped);
let circuit_hash = shaped.circuit_hash;
overlays.insert(
circuit_hash,
DynamicCircuitOverlayEntry {
shaped_device: shaped,
class_minor: None,
},
);
}
overlays
}
fn ip_list_from_shaped_device(device: &lqos_config::ShapedDevice) -> String {
let mut ips = Vec::new();
for (ip, prefix) in device.ipv4.iter() {
ips.push(format!("{ip}/{prefix}"));
}
for (ip, prefix) in device.ipv6.iter() {
ips.push(format!("{ip}/{prefix}"));
}
ips.sort();
ips.dedup();
ips.join(",")
}
fn normalize_circuit_id_key(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn find_free_dynamic_circuit_minor(
used_down: &HashSet<u16>,
used_up: &HashSet<u16>,
) -> Option<u16> {
let start = ACTIVE_RUNTIME_MINOR_START.min(u16::MAX as u32) as u16;
(start..=0xFFFEu16).find(|m| !used_down.contains(m) && !used_up.contains(m))
}
fn append_dynamic_circuit_overlays_to_batch(
batch: &mut Vec<Arc<BakeryCommands>>,
overlays: &mut HashMap<i64, DynamicCircuitOverlayEntry>,
migrations: &HashMap<i64, Migration>,
) {
if overlays.is_empty() {
return;
}
let planned_sites: HashMap<i64, Arc<BakeryCommands>> = batch
.iter()
.filter_map(|cmd| match cmd.as_ref() {
BakeryCommands::AddSite { site_hash, .. } => Some((*site_hash, Arc::clone(cmd))),
_ => None,
})
.collect();
let planned_circuits: HashMap<i64, Arc<BakeryCommands>> = batch
.iter()
.filter_map(|cmd| match cmd.as_ref() {
BakeryCommands::AddCircuit { circuit_hash, .. } => {
Some((*circuit_hash, Arc::clone(cmd)))
}
_ => None,
})
.collect();
let mut overlay_keys: Vec<i64> = overlays.keys().copied().collect();
overlay_keys.sort_unstable();
let mut used_by_major: HashMap<(u16, u16), (HashSet<u16>, HashSet<u16>)> = HashMap::new();
for circuit_hash in overlay_keys {
let Some(entry) = overlays.get_mut(&circuit_hash) else {
continue;
};
if entry.shaped_device.circuit_id.trim().is_empty()
|| entry.shaped_device.device_id.trim().is_empty()
{
continue;
}
recompute_shaped_hashes(&mut entry.shaped_device);
let site_hash = runtime_hash_to_i64(&entry.shaped_device.parent_node);
let Some(site_cmd) = planned_sites.get(&site_hash) else {
warn!(
"Bakery: skipping dynamic circuit overlay {} because parent node '{}' is not present in the current batch sites",
entry.shaped_device.circuit_id, entry.shaped_device.parent_node
);
continue;
};
let Some((down_parent, up_parent)) = site_class_handles(site_cmd.as_ref()) else {
warn!(
"Bakery: skipping dynamic circuit overlay {} because parent node '{}' did not resolve to a valid site class",
entry.shaped_device.circuit_id, entry.shaped_device.parent_node
);
continue;
};
let down_major = down_parent.get_major_minor().0;
let up_major = up_parent.get_major_minor().0;
let (used_down, used_up) =
used_by_major
.entry((down_major, up_major))
.or_insert_with(|| {
let (mut used_down, mut used_up) =
used_site_minors_for_majors(&planned_sites, down_major, up_major);
let (used_circuit_down, used_circuit_up) =
used_circuit_minors_for_majors(&planned_circuits, down_major, up_major);
used_down.extend(used_circuit_down);
used_up.extend(used_circuit_up);
let (used_shadow_down, used_shadow_up) =
used_pending_migration_shadow_minors_for_majors(
migrations, down_major, up_major,
);
used_down.extend(used_shadow_down);
used_up.extend(used_shadow_up);
(used_down, used_up)
});
let mut desired_minor = entry.class_minor;
if let Some(minor) = desired_minor
&& (minor < ACTIVE_RUNTIME_MINOR_START as u16
|| used_down.contains(&minor)
|| used_up.contains(&minor))
{
desired_minor = None;
}
let class_minor = match desired_minor
.or_else(|| find_free_dynamic_circuit_minor(used_down, used_up))
{
Some(minor) => minor,
None => {
warn!(
"Bakery: unable to allocate a free class_minor for dynamic circuit overlay {} (down_major=0x{:x}, up_major=0x{:x})",
entry.shaped_device.circuit_id, down_major, up_major
);
continue;
}
};
entry.class_minor = Some(class_minor);
used_down.insert(class_minor);
used_up.insert(class_minor);
// Ensure the overlay command is the only circuit entry for this hash.
batch.retain(|cmd| match cmd.as_ref() {
BakeryCommands::AddCircuit {
circuit_hash: h, ..
} => *h != circuit_hash,
_ => true,
});
let ip_addresses = ip_list_from_shaped_device(&entry.shaped_device);
let circuit_name = (!entry.shaped_device.circuit_name.trim().is_empty())
.then(|| entry.shaped_device.circuit_name.clone());
let site_name = (!entry.shaped_device.parent_node.trim().is_empty())
.then(|| entry.shaped_device.parent_node.clone());
batch.push(Arc::new(BakeryCommands::AddCircuit {
circuit_hash,
circuit_name,
site_name,
parent_class_id: down_parent,
up_parent_class_id: up_parent,
class_minor,
download_bandwidth_min: entry.shaped_device.download_min_mbps,
upload_bandwidth_min: entry.shaped_device.upload_min_mbps,
download_bandwidth_max: entry.shaped_device.download_max_mbps,
upload_bandwidth_max: entry.shaped_device.upload_max_mbps,
class_major: down_major,
up_class_major: up_major,
down_qdisc_handle: None,
up_qdisc_handle: None,
ip_addresses,
sqm_override: entry.shaped_device.sqm_override.clone(),
}));
}
}
#[allow(clippy::too_many_arguments)]
fn handle_upsert_dynamic_circuit_overlay(
mut shaped_device: lqos_config::ShapedDevice,
overlays: &mut HashMap<i64, DynamicCircuitOverlayEntry>,
batch_in_progress: bool,
sites: &HashMap<i64, Arc<BakeryCommands>>,
circuits: &mut HashMap<i64, Arc<BakeryCommands>>,
live_circuits: &mut HashMap<i64, u64>,
mq_layout: &Option<MqDeviceLayout>,
qdisc_handles: &mut QdiscHandleState,
migrations: &HashMap<i64, Migration>,
) -> Result<Option<TcHandle>, String> {
if shaped_device.circuit_id.trim().is_empty() {
return Err("dynamic circuit requires circuit_id".to_string());
}
if shaped_device.device_id.trim().is_empty() {
return Err("dynamic circuit requires device_id".to_string());
}
if shaped_device.parent_node.trim().is_empty() {
return Err("dynamic circuit requires parent_node".to_string());
}
recompute_shaped_hashes(&mut shaped_device);
let circuit_hash = shaped_device.circuit_hash;
let entry = overlays
.entry(circuit_hash)
.or_insert_with(|| DynamicCircuitOverlayEntry {
shaped_device: shaped_device.clone(),
class_minor: None,
});
entry.shaped_device = shaped_device;
if batch_in_progress {
// Avoid mutating live TC while a full batch is being assembled; the overlay will be
// applied when the batch is committed.
return Ok(None);
}
let Ok(config) = lqos_config::load_config() else {
return Err("unable to load config".to_string());
};
if config.queues.queue_mode.is_observe() {
return Ok(None);
}
// If we don't yet have a baseline MQ layout or any sites, keep the overlay and let the next
// commit batch create the circuit.
if !MQ_CREATED.load(Ordering::Relaxed) || mq_layout.is_none() || sites.is_empty() {
return Ok(None);
}
// If the circuit is already present, don't attempt to live-update it here yet.
if let Some(existing) = circuits.get(&circuit_hash) {
if let BakeryCommands::AddCircuit {
class_major,
class_minor,
..
} = existing.as_ref()
{
return Ok(Some(TcHandle::from_u32(
((*class_major as u32) << 16) | (*class_minor as u32),
)));
}
return Ok(None);
}
let site_hash = runtime_hash_to_i64(&entry.shaped_device.parent_node);
let Some(site_cmd) = sites.get(&site_hash) else {
return Err(format!(
"parent node '{}' is not present in current bakery site state",
entry.shaped_device.parent_node
));
};
let Some((down_parent, up_parent)) = site_class_handles(site_cmd.as_ref()) else {
return Err(format!(
"parent node '{}' did not resolve to a valid site class",
entry.shaped_device.parent_node
));
};
let down_major = down_parent.get_major_minor().0;
let up_major = up_parent.get_major_minor().0;
let (mut used_down, mut used_up) = used_site_minors_for_majors(sites, down_major, up_major);
let (used_circuit_down, used_circuit_up) =
used_circuit_minors_for_majors(circuits, down_major, up_major);
used_down.extend(used_circuit_down);
used_up.extend(used_circuit_up);
let (used_shadow_down, used_shadow_up) =
used_pending_migration_shadow_minors_for_majors(migrations, down_major, up_major);
used_down.extend(used_shadow_down);
used_up.extend(used_shadow_up);
let mut desired_minor = entry.class_minor;
if let Some(minor) = desired_minor
&& (minor < ACTIVE_RUNTIME_MINOR_START as u16
|| used_down.contains(&minor)
|| used_up.contains(&minor))
{
desired_minor = None;
}
let class_minor = desired_minor
.or_else(|| find_free_dynamic_circuit_minor(&used_down, &used_up))
.ok_or_else(|| "unable to allocate free class_minor for dynamic circuit".to_string())?;
entry.class_minor = Some(class_minor);
let ip_addresses = ip_list_from_shaped_device(&entry.shaped_device);
let candidate = Arc::new(BakeryCommands::AddCircuit {
circuit_hash,
circuit_name: (!entry.shaped_device.circuit_name.trim().is_empty())
.then(|| entry.shaped_device.circuit_name.clone()),
site_name: (!entry.shaped_device.parent_node.trim().is_empty())
.then(|| entry.shaped_device.parent_node.clone()),
parent_class_id: down_parent,
up_parent_class_id: up_parent,
class_minor,
download_bandwidth_min: entry.shaped_device.download_min_mbps,
upload_bandwidth_min: entry.shaped_device.upload_min_mbps,
download_bandwidth_max: entry.shaped_device.download_max_mbps,
upload_bandwidth_max: entry.shaped_device.upload_max_mbps,
class_major: down_major,
up_class_major: up_major,
down_qdisc_handle: None,
up_qdisc_handle: None,
ip_addresses,
sqm_override: entry.shaped_device.sqm_override.clone(),
});
let mapped_limit = resolve_mapped_circuit_limit();
if is_mapped_add_circuit(candidate.as_ref())
&& let Some(limit) = mapped_limit.effective_limit
&& circuits
.values()
.filter(|c| is_mapped_add_circuit(c.as_ref()))
.count()
>= limit
{
let stats = MappedLimitStats {
enforced_limit: mapped_limit.effective_limit,
requested_mapped: 1,
allowed_mapped: 0,
dropped_mapped: 1,
};
warn!(
"Bakery mapped circuit cap enforced (dynamic circuit addition): requested={}, allowed={}, dropped={}, limit={} (licensed={}, max_circuits={:?})",
stats.requested_mapped,
stats.allowed_mapped,
stats.dropped_mapped,
format_mapped_limit(mapped_limit.effective_limit),
mapped_limit.licensed,
mapped_limit.max_circuits
);
maybe_emit_mapped_circuit_limit_urgent(&stats);
return Err("mapped circuit limit reached".to_string());
}
let Some(layout) = mq_layout.as_ref() else {
return Ok(None);
};
let live_reserved_handles =
snapshot_live_qdisc_handle_majors_or_empty(&config, "dynamic circuit additions");
let enriched = with_assigned_qdisc_handles_reserved(
&candidate,
&config,
layout,
qdisc_handles,
&live_reserved_handles,
);
let commands = enriched
.to_commands(&config, ExecutionMode::Builder)
.unwrap_or_default();
if !commands.is_empty() {
execute_and_record_live_change(&commands, "adding dynamic circuit");
}
circuits.insert(circuit_hash, enriched);
live_circuits.remove(&circuit_hash);
qdisc_handles.save(&config);
update_queue_distribution_snapshot(sites, circuits);
Ok(Some(TcHandle::from_u32(
((down_major as u32) << 16) | (class_minor as u32),
)))
}
#[allow(clippy::too_many_arguments)]
fn handle_remove_dynamic_circuit_overlay(
circuit_id: &str,
overlays: &mut HashMap<i64, DynamicCircuitOverlayEntry>,
batch_in_progress: bool,
sites: &HashMap<i64, Arc<BakeryCommands>>,
circuits: &mut HashMap<i64, Arc<BakeryCommands>>,
live_circuits: &mut HashMap<i64, u64>,
_mq_layout: &Option<MqDeviceLayout>,
qdisc_handles: &mut QdiscHandleState,
) -> Result<(), String> {
let normalized = normalize_circuit_id_key(circuit_id);
let overlay_key = overlays
.iter()
.find(|(_, entry)| normalize_circuit_id_key(&entry.shaped_device.circuit_id) == normalized)
.map(|(k, _)| *k)
.or_else(|| {
let computed = runtime_hash_to_i64(circuit_id);
overlays.contains_key(&computed).then_some(computed)
});
if let Some(key) = overlay_key {
overlays.remove(&key);
}
if batch_in_progress {
// Avoid mutating live TC while a full batch is being assembled; the next commit batch
// will reconcile the removal.
return Ok(());
}
let Ok(config) = lqos_config::load_config() else {
return Err("unable to load config".to_string());
};
let circuit_hash = overlay_key.unwrap_or_else(|| runtime_hash_to_i64(circuit_id));
if let Some(circuit) = circuits.remove(&circuit_hash) {
let was_activated = live_circuits.contains_key(&circuit_hash);
let commands = match config.queues.lazy_queues.as_ref() {
None | Some(LazyQueueMode::No) => circuit.to_prune(&config, true),
Some(LazyQueueMode::Htb) => {
if was_activated {
circuit.to_prune(&config, false)
} else {
None
}
}
Some(LazyQueueMode::Full) => {
if was_activated {
circuit.to_prune(&config, true)
} else {
None
}
}
};
if let Some(cmd) = commands {
execute_and_record_live_change(&cmd, "removing dynamic circuit");
}
live_circuits.remove(&circuit_hash);
qdisc_handles.release_circuit(&config.isp_interface(), circuit_hash);
if !config.on_a_stick_mode() {
qdisc_handles.release_circuit(&config.internet_interface(), circuit_hash);
}
qdisc_handles.save(&config);
update_queue_distribution_snapshot(sites, circuits);
}
Ok(())
}
/*fn parse_ip_and_prefix(ip: &str) -> (String, u32) {
if let Some((addr, pfx)) = ip.split_once('/') {
if let Ok(n) = pfx.parse::<u32>() {
@@ -3109,6 +3624,11 @@ fn bakery_main(rx: Receiver<BakeryCommands>, tx: Sender<BakeryCommands>) {
let mut virtualized_sites: HashMap<i64, VirtualizedSiteState> = HashMap::new();
let mut runtime_node_operations: HashMap<i64, RuntimeNodeOperation> = HashMap::new();
let mut next_runtime_operation_id: u64 = 1;
let mut dynamic_circuit_overlays: HashMap<i64, DynamicCircuitOverlayEntry> =
lqos_config::load_config()
.ok()
.map(|config| load_dynamic_circuit_overlays_from_disk(&config))
.unwrap_or_default();
// Mapping state
#[derive(Clone, Hash, PartialEq, Eq, Debug)]
@@ -4037,6 +4557,7 @@ fn bakery_main(rx: Receiver<BakeryCommands>, tx: Sender<BakeryCommands>) {
&mut batch,
&mut sites,
&mut circuits,
&mut dynamic_circuit_overlays,
&mut live_circuits,
&mut mq_layout,
&mut qdisc_handles,
@@ -4075,6 +4596,40 @@ fn bakery_main(rx: Receiver<BakeryCommands>, tx: Sender<BakeryCommands>) {
batch.push(Arc::new(command));
}
}
BakeryCommands::UpsertDynamicCircuitOverlay {
shaped_device,
reply,
} => {
let result = handle_upsert_dynamic_circuit_overlay(
*shaped_device,
&mut dynamic_circuit_overlays,
batch.is_some(),
&sites,
&mut circuits,
&mut live_circuits,
&mq_layout,
&mut qdisc_handles,
&migrations,
);
if let Some(reply) = reply {
let _ = reply.send(result);
}
}
BakeryCommands::RemoveDynamicCircuitOverlay { circuit_id, reply } => {
let result = handle_remove_dynamic_circuit_overlay(
&circuit_id,
&mut dynamic_circuit_overlays,
batch.is_some(),
&sites,
&mut circuits,
&mut live_circuits,
&mq_layout,
&mut qdisc_handles,
);
if let Some(reply) = reply {
let _ = reply.send(result);
}
}
BakeryCommands::OnCircuitActivity { circuit_ids } => {
handle_circuit_activity(circuit_ids, &circuits, &mut live_circuits);
}
@@ -4219,6 +4774,7 @@ fn handle_commit_batch(
batch: &mut Option<Vec<Arc<BakeryCommands>>>,
sites: &mut HashMap<i64, Arc<BakeryCommands>>,
circuits: &mut HashMap<i64, Arc<BakeryCommands>>,
dynamic_circuit_overlays: &mut HashMap<i64, DynamicCircuitOverlayEntry>,
live_circuits: &mut HashMap<i64, u64>,
mq_layout: &mut Option<MqDeviceLayout>,
qdisc_handles: &mut QdiscHandleState,
@@ -4238,6 +4794,8 @@ fn handle_commit_batch(
debug!("CommitBatch received without a batch to commit.");
return;
};
let mut raw_batch = raw_batch;
append_dynamic_circuit_overlays_to_batch(&mut raw_batch, dynamic_circuit_overlays, migrations);
let (baseline_sites, baseline_circuits) =
reconstruct_structural_baseline_state(sites, circuits, virtualized_sites);
let effective_new_batch =
+17
View File
@@ -218,6 +218,23 @@ pub enum BusRequest {
/// Requests that the configuration be updated
UpdateLqosdConfig(Box<lqos_config::Config>),
/// Create or update a runtime-only dynamic circuit overlay entry.
///
/// This does **not** mutate `ShapedDevices.csv`. The daemon may ignore this request when
/// dynamic circuits are disabled in the configuration.
CreateDynamicCircuit {
/// Shaped-device-like definition for the dynamic circuit/device.
shaped_device: Box<lqos_config::ShapedDevice>,
},
/// Remove a runtime-only dynamic circuit overlay entry.
///
/// The daemon may ignore this request when dynamic circuits are disabled in the configuration.
RemoveDynamicCircuit {
/// Stable circuit identifier to remove.
circuit_id: String,
},
/// Invalidate cached Node Manager authentication state.
InvalidateAuthCache,
+1 -1
View File
@@ -184,7 +184,7 @@ pub struct AsnHeatmapData {
/// Metrics for the Executive Summary header cards.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Allocative, Default)]
pub struct ExecutiveSummaryHeader {
/// Total number of unique circuits from SHAPED_DEVICES.
/// Total number of unique circuits from the shaped-devices snapshot.
pub circuit_count: u64,
/// Total number of shaped devices.
pub device_count: u64,
+87
View File
@@ -0,0 +1,87 @@
//! Bus types for dynamic circuit overlay operations.
//!
//! `DynamicAddCircuit` and `DynamicRemoveCircuit` are high-level requests used
//! to manage runtime-only dynamic circuits. These operations apply to the
//! overlay state and never mutate `ShapedDevices.csv`.
//!
//! When callers omit identity fields, the daemon allocates stable defaults
//! such as `Dynamic <u64>` for `circuit_id` and reuses that value for
//! `device_id`.
use allocative::Allocative;
use serde::{Deserialize, Serialize};
/// Attachment target for a dynamic circuit overlay request.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)]
pub struct DynamicCircuitAttachmentTarget {
/// Stable topology node identifier from `network.json` or `network.effective.json`.
pub node_id: Option<String>,
/// Human-readable topology node name used when a stable ID is unavailable.
pub node_name: Option<String>,
}
/// Optional caller-supplied identity fields for a dynamic circuit.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)]
pub struct DynamicCircuitIdentitySpec {
/// Circuit identifier override. The daemon allocates `Dynamic <n>` when omitted.
pub circuit_id: Option<String>,
/// Device identifier override. Defaults to `circuit_id` when omitted.
pub device_id: Option<String>,
/// Human-readable circuit display name.
pub circuit_name: Option<String>,
/// Human-readable device display name.
pub device_name: Option<String>,
}
/// Rate inputs for a dynamic circuit overlay request.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Allocative)]
pub struct DynamicCircuitRates {
/// Minimum guaranteed downstream rate in Mbps.
pub download_min_mbps: f32,
/// Minimum guaranteed upstream rate in Mbps.
pub upload_min_mbps: f32,
/// Maximum downstream rate in Mbps.
pub download_max_mbps: f32,
/// Maximum upstream rate in Mbps.
pub upload_max_mbps: f32,
/// Optional SQM override such as `cake` or `fq_codel`.
pub sqm_override: Option<String>,
}
/// Request payload for adding a dynamic circuit overlay entry.
///
/// This is a high-level overlay operation and never writes to
/// `ShapedDevices.csv`. When `identity` is omitted, the daemon allocates
/// defaults such as `Dynamic <u64>`.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Allocative)]
pub struct DynamicAddCircuitRequest {
/// Attachment target for the dynamic circuit.
pub attachment: DynamicCircuitAttachmentTarget,
/// Optional caller-supplied identity fields.
pub identity: Option<DynamicCircuitIdentitySpec>,
/// Requested shaping rates.
pub rates: DynamicCircuitRates,
/// IPs and CIDRs owned by this dynamic circuit.
pub ip_cidrs: Vec<String>,
/// Optional TTL in seconds. The daemon applies the default when omitted.
pub ttl_seconds: Option<u64>,
}
/// Request payload for removing a dynamic circuit overlay entry.
///
/// This removes only the runtime overlay entry; it never edits
/// `ShapedDevices.csv`.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)]
pub struct DynamicRemoveCircuitRequest {
/// Stable circuit identifier to remove from the dynamic overlay.
pub circuit_id: String,
}
/// Reply payload returned after creating a dynamic circuit overlay entry.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)]
pub struct DynamicAddCircuitReply {
/// Allocated or accepted circuit identifier.
pub circuit_id: String,
/// Allocated or accepted device identifier.
pub device_id: String,
}
+1 -1
View File
@@ -285,7 +285,7 @@ impl WebUsers {
let salted = format!("!x{password}{LEGACY_PASSWORD_PEPPER}");
let mut sha256 = Sha256::new();
sha256.update(salted);
format!("{:X}", sha256.finalize())
crate::hex_encoding::encode_hex_upper(sha256.finalize())
}
fn verify_password(
+600
View File
@@ -0,0 +1,600 @@
//! Dynamic circuit persistence for the runtime overlay.
//!
//! The dynamic-circuits store is an overlay on top of
//! `lqos_config::ConfigShapedDevices` and never mutates ShapedDevices.csv.
//! Static `ShapedDevices.csv` data remains authoritative; dynamic state exists
//! only to persist runtime overlay entries in `dynamic_status.json`.
//!
//! This module is intentionally small until the runtime overlay loader lands.
use allocative::Allocative;
use serde::{Deserialize, Serialize};
use std::{fs, path::{Path, PathBuf}};
use thiserror::Error;
/// File name used to persist dynamic circuit overlay state.
pub const DYNAMIC_STATUS_FILENAME: &str = "dynamic_status.json";
/// Current supported on-disk schema version for `dynamic_status.json`.
pub const DYNAMIC_STATUS_VERSION: u32 = 1;
/// Errors that can occur while resolving or loading `dynamic_status.json`.
#[derive(Debug, Error)]
pub enum DynamicStatusError {
/// The main LibreQoS config could not be loaded.
#[error("Unable to load LibreQoS config while resolving dynamic_status.json path")]
ConfigLoadError,
/// A `dynamic_status.json` file could not be read from disk.
#[error("Unable to read dynamic status file at {path}: {source}")]
CannotRead {
/// Full path to the file that failed.
path: String,
/// Source IO error.
source: std::io::Error,
},
/// A `dynamic_status.json` file existed but could not be parsed.
#[error("Unable to parse dynamic status file at {path}: {details}")]
ParseError {
/// Full path to the file that failed.
path: String,
/// Parse error details.
details: String,
},
/// A `dynamic_status.json` file could not be written to disk.
#[error("Unable to write dynamic status file at {path}: {source}")]
CannotWrite {
/// Full path to the file that failed.
path: String,
/// Source IO error.
source: std::io::Error,
},
/// Dynamic status data could not be serialized.
#[error("Unable to serialize dynamic status file: {details}")]
SerializeError {
/// Serialization error details.
details: String,
},
/// A dynamic circuit record is internally inconsistent or contains invalid data.
#[error("Invalid dynamic circuit record: {details}")]
InvalidRecord {
/// Specific validation or conversion details.
details: String,
},
}
/// Root persisted state for the dynamic circuit runtime overlay.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Allocative)]
pub struct DynamicStatusFile {
/// On-disk schema version for `dynamic_status.json`.
pub version: u32,
/// Monotonic ID allocator so `Dynamic <n>` values are never reused.
pub next_dynamic_id: u64,
/// Persisted dynamic circuits that can be materialized into overlay devices.
pub circuits: Vec<DynamicCircuitRecord>,
}
impl DynamicStatusFile {
/// Loads `dynamic_status.json`, returning an empty default state when the file is absent.
pub fn load_or_default() -> Result<Self, DynamicStatusError> {
let path = dynamic_status_path()?;
if !path.exists() {
return Ok(Self::default());
}
let raw = fs::read_to_string(&path).map_err(|source| DynamicStatusError::CannotRead {
path: path.display().to_string(),
source,
})?;
let parsed: Self = serde_json::from_str(&raw).map_err(|error| DynamicStatusError::ParseError {
path: path.display().to_string(),
details: error.to_string(),
})?;
if parsed.version != DYNAMIC_STATUS_VERSION {
return Err(DynamicStatusError::InvalidRecord {
details: format!(
"Unsupported dynamic_status.json version {} at {} (expected {})",
parsed.version,
path.display(),
DYNAMIC_STATUS_VERSION
),
});
}
Ok(parsed)
}
/// Saves `dynamic_status.json` using a same-directory temp file and atomic rename.
pub fn save_atomic(&self) -> Result<(), DynamicStatusError> {
let path = dynamic_status_path()?;
let tmp_path = path.with_file_name(format!("{DYNAMIC_STATUS_FILENAME}.tmp"));
let serialized =
serde_json::to_string_pretty(self).map_err(|error| DynamicStatusError::SerializeError {
details: error.to_string(),
})?;
fs::write(&tmp_path, serialized).map_err(|source| DynamicStatusError::CannotWrite {
path: tmp_path.display().to_string(),
source,
})?;
fs::rename(&tmp_path, &path).map_err(|source| DynamicStatusError::CannotWrite {
path: path.display().to_string(),
source,
})?;
Ok(())
}
/// Allocates default circuit/device identifiers when callers omit them.
pub fn allocate_default_ids(
&mut self,
circuit_id: Option<String>,
device_id: Option<String>,
) -> (String, String) {
let circuit_id = circuit_id.unwrap_or_else(|| {
let allocated = format!("Dynamic {}", self.next_dynamic_id);
self.next_dynamic_id += 1;
allocated
});
let device_id = device_id.unwrap_or_else(|| circuit_id.clone());
(circuit_id, device_id)
}
}
/// Persisted attachment target for a dynamic circuit.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Allocative)]
pub struct DynamicAttachmentTarget {
/// Stable topology node identifier when known.
pub node_id: Option<String>,
/// Human-readable parent node name.
pub node_name: String,
}
/// Persisted dynamic circuit record.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Allocative)]
pub struct DynamicCircuitRecord {
/// Stable circuit identifier.
pub circuit_id: String,
/// Stable device identifier.
pub device_id: String,
/// Optional human-readable circuit name.
pub circuit_name: Option<String>,
/// Optional human-readable device name.
pub device_name: Option<String>,
/// Attachment target used to place the circuit in the topology.
pub attachment: DynamicAttachmentTarget,
/// Optional MAC address associated with the device.
pub mac: Option<String>,
/// IP/CIDR strings assigned to the dynamic circuit.
pub ip_cidrs: Vec<String>,
/// Minimum guaranteed downstream rate in Mbps.
pub download_min_mbps: f32,
/// Minimum guaranteed upstream rate in Mbps.
pub upload_min_mbps: f32,
/// Maximum downstream rate in Mbps.
pub download_max_mbps: f32,
/// Maximum upstream rate in Mbps.
pub upload_max_mbps: f32,
/// Optional SQM override.
pub sqm_override: Option<String>,
/// TTL in seconds applied when extending expiry on activity.
pub ttl_seconds: u64,
/// Unix timestamp when the record was created.
pub created: u64,
/// Unix timestamp when the record was last observed active.
#[serde(alias = "last_seen")]
pub last_seen_at: u64,
/// Unix timestamp when the record should expire if not refreshed.
#[serde(alias = "expires")]
pub expires_at: u64,
}
impl DynamicCircuitRecord {
/// Converts the persisted record into a `ShapedDevice` for overlay indexing.
pub fn to_shaped_device(&self) -> Result<crate::ShapedDevice, DynamicStatusError> {
let mut ipv4 = Vec::new();
let mut ipv6 = Vec::new();
for cidr in &self.ip_cidrs {
if let Ok(parsed) = crate::ShapedDevice::parse_cidr_v4(cidr) {
ipv4.push(parsed);
continue;
}
if let Ok(parsed) = crate::ShapedDevice::parse_cidr_v6(cidr) {
ipv6.push(parsed);
continue;
}
return Err(DynamicStatusError::InvalidRecord {
details: format!("Unable to parse CIDR '{cidr}' for dynamic circuit '{}'", self.circuit_id),
});
}
let circuit_name = self
.circuit_name
.clone()
.unwrap_or_else(|| self.circuit_id.clone());
let device_name = self
.device_name
.clone()
.unwrap_or_else(|| self.device_id.clone());
let parent_node = self.attachment.node_name.clone();
Ok(crate::ShapedDevice {
circuit_hash: lqos_utils::hash_to_i64(&self.circuit_id),
device_hash: lqos_utils::hash_to_i64(&self.device_id),
parent_hash: lqos_utils::hash_to_i64(&parent_node),
circuit_id: self.circuit_id.clone(),
circuit_name,
device_id: self.device_id.clone(),
device_name,
parent_node,
mac: self.mac.clone().unwrap_or_default(),
ipv4,
ipv6,
download_min_mbps: self.download_min_mbps,
upload_min_mbps: self.upload_min_mbps,
download_max_mbps: self.download_max_mbps,
upload_max_mbps: self.upload_max_mbps,
comment: String::new(),
sqm_override: self.sqm_override.clone(),
})
}
}
fn dynamic_status_path_for_config(config: &crate::Config) -> PathBuf {
Path::new(&config.lqos_directory).join(DYNAMIC_STATUS_FILENAME)
}
/// Resolves the active `dynamic_status.json` path from the LibreQoS config.
pub fn dynamic_status_path() -> Result<PathBuf, DynamicStatusError> {
let config = crate::load_config().map_err(|_| DynamicStatusError::ConfigLoadError)?;
Ok(dynamic_status_path_for_config(&config))
}
#[cfg(test)]
mod tests {
use super::{
DYNAMIC_STATUS_FILENAME, DYNAMIC_STATUS_VERSION, DynamicAttachmentTarget,
DynamicCircuitRecord, DynamicStatusFile,
dynamic_status_path,
};
use crate::{Config, clear_cached_config};
use std::{
fs,
path::PathBuf,
sync::Mutex,
sync::atomic::{AtomicU64, Ordering},
};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
static TEST_ENV_LOCK: Mutex<()> = Mutex::new(());
fn unique_test_dir(prefix: &str) -> PathBuf {
let suffix = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("libreqos-{prefix}-{}-{suffix}", std::process::id()))
}
#[test]
fn dynamic_status_path_ends_with_filename() {
let _guard = TEST_ENV_LOCK.lock().expect("lock test env");
clear_cached_config();
let config_dir = unique_test_dir("dynamic-status-path");
let config_path = config_dir.join("lqos.conf");
fs::create_dir_all(&config_dir).expect("create config dir");
let mut config = Config::default();
config.lqos_directory = config_dir.to_string_lossy().to_string();
let config_toml = toml::to_string(&config).expect("serialize config");
fs::write(&config_path, config_toml).expect("write config");
let old_lqos_config = std::env::var_os("LQOS_CONFIG");
let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY");
unsafe {
std::env::set_var("LQOS_CONFIG", &config_path);
std::env::set_var("LQOS_DIRECTORY", &config_dir);
}
let resolved = dynamic_status_path().expect("dynamic_status_path");
assert!(resolved.ends_with(DYNAMIC_STATUS_FILENAME));
match old_lqos_config {
Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) },
None => unsafe { std::env::remove_var("LQOS_CONFIG") },
}
match old_lqos_directory {
Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) },
None => unsafe { std::env::remove_var("LQOS_DIRECTORY") },
}
clear_cached_config();
let _ = fs::remove_file(&config_path);
let _ = fs::remove_dir_all(&config_dir);
}
#[test]
fn load_or_default_missing_file_returns_empty_status() {
let _guard = TEST_ENV_LOCK.lock().expect("lock test env");
clear_cached_config();
let config_dir = unique_test_dir("dynamic-status-missing");
let config_path = config_dir.join("lqos.conf");
fs::create_dir_all(&config_dir).expect("create config dir");
let mut config = Config::default();
config.lqos_directory = config_dir.to_string_lossy().to_string();
let config_toml = toml::to_string(&config).expect("serialize config");
fs::write(&config_path, config_toml).expect("write config");
let old_lqos_config = std::env::var_os("LQOS_CONFIG");
let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY");
unsafe {
std::env::set_var("LQOS_CONFIG", &config_path);
std::env::set_var("LQOS_DIRECTORY", &config_dir);
}
let loaded = DynamicStatusFile::load_or_default().expect("load_or_default");
assert_eq!(loaded.circuits.len(), 0);
match old_lqos_config {
Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) },
None => unsafe { std::env::remove_var("LQOS_CONFIG") },
}
match old_lqos_directory {
Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) },
None => unsafe { std::env::remove_var("LQOS_DIRECTORY") },
}
clear_cached_config();
let _ = fs::remove_file(&config_path);
let _ = fs::remove_dir_all(&config_dir);
}
#[test]
fn save_atomic_then_load_round_trip() {
let _guard = TEST_ENV_LOCK.lock().expect("lock test env");
clear_cached_config();
let config_dir = unique_test_dir("dynamic-status-save");
let config_path = config_dir.join("lqos.conf");
fs::create_dir_all(&config_dir).expect("create config dir");
let mut config = Config::default();
config.lqos_directory = config_dir.to_string_lossy().to_string();
let config_toml = toml::to_string(&config).expect("serialize config");
fs::write(&config_path, config_toml).expect("write config");
let old_lqos_config = std::env::var_os("LQOS_CONFIG");
let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY");
unsafe {
std::env::set_var("LQOS_CONFIG", &config_path);
std::env::set_var("LQOS_DIRECTORY", &config_dir);
}
let status = DynamicStatusFile {
version: DYNAMIC_STATUS_VERSION,
next_dynamic_id: 43,
circuits: vec![super::DynamicCircuitRecord {
circuit_id: "Dynamic 42".to_string(),
device_id: "Dynamic 42".to_string(),
circuit_name: Some("Transient Subscriber".to_string()),
device_name: Some("Transient CPE".to_string()),
attachment: super::DynamicAttachmentTarget {
node_id: Some("tower-a-sector-3".to_string()),
node_name: "Tower A / Sector 3".to_string(),
},
mac: Some("aa:bb:cc:dd:ee:ff".to_string()),
ip_cidrs: vec!["192.0.2.10/32".to_string(), "2001:db8::1234/128".to_string()],
download_min_mbps: 25.0,
upload_min_mbps: 5.0,
download_max_mbps: 150.0,
upload_max_mbps: 30.0,
sqm_override: Some("cake".to_string()),
ttl_seconds: 3600,
created: 1_775_606_400,
last_seen_at: 1_775_608_200,
expires_at: 1_775_611_800,
}],
};
status.save_atomic().expect("save_atomic");
let loaded = DynamicStatusFile::load_or_default().expect("load_or_default");
assert_eq!(loaded, status);
match old_lqos_config {
Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) },
None => unsafe { std::env::remove_var("LQOS_CONFIG") },
}
match old_lqos_directory {
Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) },
None => unsafe { std::env::remove_var("LQOS_DIRECTORY") },
}
clear_cached_config();
let _ = fs::remove_file(config_dir.join(DYNAMIC_STATUS_FILENAME));
let _ = fs::remove_file(&config_path);
let _ = fs::remove_dir_all(&config_dir);
}
#[test]
fn allocate_default_ids_increments_next_dynamic_id() {
let mut status = DynamicStatusFile {
version: DYNAMIC_STATUS_VERSION,
next_dynamic_id: 42,
circuits: Vec::new(),
};
let first = status.allocate_default_ids(None, None);
let second = status.allocate_default_ids(None, None);
assert_eq!(first.0, "Dynamic 42");
assert_eq!(first.1, "Dynamic 42");
assert_eq!(second.0, "Dynamic 43");
assert_eq!(second.1, "Dynamic 43");
assert_eq!(status.next_dynamic_id, 44);
}
#[test]
fn to_shaped_device_sets_hashes_from_ids() {
let record = DynamicCircuitRecord {
circuit_id: "Dynamic 42".to_string(),
device_id: "Dynamic 42".to_string(),
circuit_name: Some("Transient Subscriber".to_string()),
device_name: Some("Transient CPE".to_string()),
attachment: DynamicAttachmentTarget {
node_id: Some("tower-a-sector-3".to_string()),
node_name: "Tower A / Sector 3".to_string(),
},
mac: Some("aa:bb:cc:dd:ee:ff".to_string()),
ip_cidrs: vec!["192.0.2.10/32".to_string(), "2001:db8::1234/128".to_string()],
download_min_mbps: 25.0,
upload_min_mbps: 5.0,
download_max_mbps: 150.0,
upload_max_mbps: 30.0,
sqm_override: Some("cake".to_string()),
ttl_seconds: 3600,
created: 1_775_606_400,
last_seen_at: 1_775_608_200,
expires_at: 1_775_611_800,
};
let device = record.to_shaped_device().expect("to_shaped_device");
assert_eq!(device.circuit_hash, lqos_utils::hash_to_i64(&device.circuit_id));
}
#[test]
fn to_shaped_device_ipv4_cidr_uses_ipv6_mapped_prefix() {
let record = DynamicCircuitRecord {
circuit_id: "Dynamic 7".to_string(),
device_id: "Dynamic 7".to_string(),
circuit_name: None,
device_name: None,
attachment: DynamicAttachmentTarget {
node_id: None,
node_name: "Orphans".to_string(),
},
mac: None,
ip_cidrs: vec!["192.0.2.10/24".to_string()],
download_min_mbps: 10.0,
upload_min_mbps: 10.0,
download_max_mbps: 20.0,
upload_max_mbps: 20.0,
sqm_override: None,
ttl_seconds: 3600,
created: 1,
last_seen_at: 1,
expires_at: 2,
};
let device = record.to_shaped_device().expect("to_shaped_device");
let ipv6_list = device.to_ipv6_list();
assert_eq!(ipv6_list.len(), 1);
let (mapped, prefix) = ipv6_list[0];
assert_eq!(mapped, std::net::Ipv4Addr::new(192, 0, 2, 10).to_ipv6_mapped());
assert_eq!(prefix, 120);
}
#[test]
fn to_shaped_device_invalid_ip_reports_offending_string() {
let record = DynamicCircuitRecord {
circuit_id: "Dynamic 99".to_string(),
device_id: "Dynamic 99".to_string(),
circuit_name: None,
device_name: None,
attachment: DynamicAttachmentTarget {
node_id: None,
node_name: "Orphans".to_string(),
},
mac: None,
ip_cidrs: vec!["not-an-ip".to_string()],
download_min_mbps: 10.0,
upload_min_mbps: 10.0,
download_max_mbps: 20.0,
upload_max_mbps: 20.0,
sqm_override: None,
ttl_seconds: 3600,
created: 1,
last_seen_at: 1,
expires_at: 2,
};
let error = record.to_shaped_device().expect_err("invalid CIDR should fail");
let message = error.to_string();
assert!(message.contains("not-an-ip"));
}
#[test]
fn example_fixture_parses_ttl_fields() {
let parsed: DynamicStatusFile =
serde_json::from_str(include_str!("testdata/dynamic_status.example.json"))
.expect("parse example fixture");
assert_eq!(parsed.version, DYNAMIC_STATUS_VERSION);
assert_eq!(parsed.circuits.len(), 1);
assert_eq!(parsed.circuits[0].ttl_seconds, 3600);
assert_eq!(parsed.circuits[0].last_seen_at, 1_775_608_200);
assert_eq!(parsed.circuits[0].expires_at, 1_775_611_800);
}
#[test]
fn legacy_ttl_field_names_still_parse() {
let parsed: DynamicStatusFile = serde_json::from_str(
r#"{
"version": 1,
"next_dynamic_id": 2,
"circuits": [
{
"circuit_id": "Dynamic 1",
"device_id": "Dynamic 1",
"attachment": { "node_id": null, "node_name": "AP-1" },
"mac": null,
"ip_cidrs": ["100.64.0.10/32"],
"download_min_mbps": 25.0,
"upload_min_mbps": 10.0,
"download_max_mbps": 50.0,
"upload_max_mbps": 20.0,
"sqm_override": null,
"ttl_seconds": 3600,
"created": 1,
"last_seen": 2,
"expires": 3602
}
]
}"#,
)
.expect("parse legacy ttl field names");
assert_eq!(parsed.circuits[0].last_seen_at, 2);
assert_eq!(parsed.circuits[0].expires_at, 3602);
}
#[test]
fn load_or_default_rejects_unsupported_version() {
let _guard = TEST_ENV_LOCK.lock().expect("lock test env");
clear_cached_config();
let config_dir = unique_test_dir("dynamic-status-bad-version");
let config_path = config_dir.join("lqos.conf");
fs::create_dir_all(&config_dir).expect("create config dir");
let mut config = Config::default();
config.lqos_directory = config_dir.to_string_lossy().to_string();
let config_toml = toml::to_string(&config).expect("serialize config");
fs::write(&config_path, config_toml).expect("write config");
let old_lqos_config = std::env::var_os("LQOS_CONFIG");
let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY");
unsafe {
std::env::set_var("LQOS_CONFIG", &config_path);
std::env::set_var("LQOS_DIRECTORY", &config_dir);
}
let status_path = config_dir.join(DYNAMIC_STATUS_FILENAME);
fs::write(
&status_path,
r#"{"version":99,"next_dynamic_id":1,"circuits":[]}"#,
)
.expect("write dynamic status");
let error = DynamicStatusFile::load_or_default().expect_err("unsupported version");
assert!(matches!(error, super::DynamicStatusError::InvalidRecord { .. }));
match old_lqos_config {
Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) },
None => unsafe { std::env::remove_var("LQOS_CONFIG") },
}
match old_lqos_directory {
Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) },
None => unsafe { std::env::remove_var("LQOS_DIRECTORY") },
}
clear_cached_config();
let _ = fs::remove_file(&status_path);
let _ = fs::remove_file(&config_path);
let _ = fs::remove_dir_all(&config_dir);
}
}
@@ -243,8 +243,9 @@ fn check_config(cfg_doc: &mut DocumentMut, cfg: &mut EtcLqos) {
&& let Ok(machine_id) = std::fs::read_to_string("/etc/machine-id")
{
let hash = sha2::Sha256::new().chain(machine_id).finalize();
cfg.node_id = Some(format!("{:x}", hash));
cfg_doc["node_id"] = value(format!("{:x}", hash));
let node_id = crate::hex_encoding::encode_hex_lower(hash);
cfg.node_id = Some(node_id.clone());
cfg_doc["node_id"] = value(node_id);
println!("Updating");
if let Err(e) = cfg.save(cfg_doc) {
error!("Unable to save /etc/lqos.conf");
+3 -2
View File
@@ -19,8 +19,9 @@ mod python_migration;
pub mod test_data;
mod v15;
pub use v15::{
BridgeConfig, IntegrationConfig, LazyQueueMode, MikrotikIpv6Config, QueueMode, RttThresholds,
SingleInterfaceConfig, StormguardConfig, StormguardStrategy, TopologyConfig,
BridgeConfig, DynamicCircuitRangeRule, DynamicCircuitsConfig, IntegrationConfig, LazyQueueMode,
MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig,
StormguardStrategy, TopologyConfig,
TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode,
TreeguardLinksConfig, TreeguardQooConfig, Tunables,
};
@@ -0,0 +1,210 @@
//! Dynamic circuit configuration.
//!
//! This module defines the configuration schema for the "dynamic circuits"
//! subsystem. Runtime behavior is implemented elsewhere; for now this is
//! configuration + validation only.
use allocative::Allocative;
use ip_network::IpNetwork;
use serde::{Deserialize, Serialize};
fn default_ttl_seconds() -> u64 {
300
}
fn deserialize_ip_network_allow_default<'de, D>(deserializer: D) -> Result<IpNetwork, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
let trimmed = raw.trim();
let normalized = match trimmed {
"0.0.0.0" => "0.0.0.0/0",
"::" => "::/0",
other => other,
};
normalized
.parse::<IpNetwork>()
.map_err(serde::de::Error::custom)
}
fn serialize_ip_network<S>(value: &IpNetwork, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&value.to_string())
}
/// One rule that applies a default set of rates + optional attachment to an IP range.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)]
pub struct DynamicCircuitRangeRule {
/// Human-readable rule name used in logs and diagnostics.
pub name: String,
/// IP network matched by this rule (CIDR notation).
///
/// For convenience, `0.0.0.0` and `::` are accepted shorthands for the default
/// routes (`0.0.0.0/0` and `::/0`).
#[allocative(skip)]
#[serde(
serialize_with = "serialize_ip_network",
deserialize_with = "deserialize_ip_network_allow_default"
)]
pub ip_range: IpNetwork,
/// Minimum guaranteed downstream rate in Mbps.
pub download_min_mbps: f32,
/// Minimum guaranteed upstream rate in Mbps.
pub upload_min_mbps: f32,
/// Maximum downstream rate in Mbps.
pub download_max_mbps: f32,
/// Maximum upstream rate in Mbps.
pub upload_max_mbps: f32,
/// Optional attachment target in `network.json` (node name). Empty means no attachment.
#[serde(default)]
pub attach_to: String,
}
/// Dynamic circuits configuration.
///
/// This section is optional in the top-level config so older installations can
/// upgrade without needing to add a new config section.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)]
pub struct DynamicCircuitsConfig {
/// Master switch for the dynamic circuits feature set.
#[serde(default)]
pub enabled: bool,
/// Time-to-live in seconds for runtime dynamic circuit state.
#[serde(default = "default_ttl_seconds")]
pub ttl_seconds: u64,
/// Enable promotion of unknown IPs into dynamic circuits (when a rule matches).
#[serde(default)]
pub enable_unknown_ip_promotion: bool,
/// Rules evaluated against unknown IPs (and future dynamic overlay logic).
#[serde(default)]
pub ranges: Vec<DynamicCircuitRangeRule>,
}
impl Default for DynamicCircuitsConfig {
fn default() -> Self {
Self {
enabled: false,
ttl_seconds: default_ttl_seconds(),
enable_unknown_ip_promotion: false,
ranges: Vec::new(),
}
}
}
impl DynamicCircuitsConfig {
/// Validates configured dynamic circuit settings.
pub fn validate(&self) -> Result<(), String> {
if self.ttl_seconds == 0 {
return Err("dynamic_circuits.ttl_seconds must be > 0".to_string());
}
for (index, rule) in self.ranges.iter().enumerate() {
let label = if rule.name.trim().is_empty() {
format!("dynamic_circuits.ranges[{index}]")
} else {
format!("dynamic_circuits.ranges[{index}] ('{}')", rule.name.trim())
};
if rule.name.trim().is_empty() {
return Err(format!("{label}: name must not be empty"));
}
if !rule.download_min_mbps.is_finite() || rule.download_min_mbps < 0.1 {
return Err(format!("{label}: download_min_mbps must be >= 0.1"));
}
if !rule.upload_min_mbps.is_finite() || rule.upload_min_mbps < 0.1 {
return Err(format!("{label}: upload_min_mbps must be >= 0.1"));
}
if !rule.download_max_mbps.is_finite() || rule.download_max_mbps < 0.2 {
return Err(format!("{label}: download_max_mbps must be >= 0.2"));
}
if !rule.upload_max_mbps.is_finite() || rule.upload_max_mbps < 0.2 {
return Err(format!("{label}: upload_max_mbps must be >= 0.2"));
}
if rule.download_min_mbps > rule.download_max_mbps {
return Err(format!(
"{label}: download_min_mbps must be <= download_max_mbps"
));
}
if rule.upload_min_mbps > rule.upload_max_mbps {
return Err(format!(
"{label}: upload_min_mbps must be <= upload_max_mbps"
));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::etc::v15::Config;
#[test]
fn parses_default_ipv4_shorthand() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(deserialize_with = "deserialize_ip_network_allow_default")]
ip_range: IpNetwork,
}
let parsed: Wrapper =
toml::from_str(r#"ip_range = "0.0.0.0""#).expect("parse ip_range shorthand");
assert_eq!(parsed.ip_range.to_string(), "0.0.0.0/0");
}
#[test]
fn parses_default_ipv6_shorthand() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(deserialize_with = "deserialize_ip_network_allow_default")]
ip_range: IpNetwork,
}
let parsed: Wrapper =
toml::from_str(r#"ip_range = "::""#).expect("parse ip_range shorthand");
assert_eq!(parsed.ip_range.to_string(), "::/0");
}
#[test]
fn config_roundtrips_with_dynamic_circuit_ranges() {
let mut config = Config::default();
let rule = DynamicCircuitRangeRule {
name: "Default".to_string(),
ip_range: "0.0.0.0/0"
.parse()
.expect("default ip network should parse"),
download_min_mbps: 10.0,
upload_min_mbps: 2.0,
download_max_mbps: 100.0,
upload_max_mbps: 20.0,
attach_to: String::new(),
};
config.dynamic_circuits = Some(DynamicCircuitsConfig {
enabled: true,
ttl_seconds: 300,
enable_unknown_ip_promotion: true,
ranges: vec![rule],
});
let raw = toml::to_string_pretty(&config).expect("config should serialize to TOML");
let parsed = Config::load_from_string(&raw).expect("config should deserialize");
assert!(parsed.dynamic_circuits.is_some());
assert_eq!(
parsed
.dynamic_circuits
.as_ref()
.expect("dynamic circuits should deserialize")
.ranges
.len(),
1
);
}
}
+2
View File
@@ -4,6 +4,7 @@ mod top_config;
pub use top_config::Config;
pub use top_config::RttThresholds;
mod bridge;
mod dynamic_circuits;
mod flows;
pub mod influxdb;
mod integration_common;
@@ -24,6 +25,7 @@ mod visp_integration;
mod wispgate;
pub use bridge::*;
pub use dynamic_circuits::*;
pub use integration_common::IntegrationConfig;
pub use long_term_stats::LongTermStats;
pub use mikrotik_ipv6::MikrotikIpv6Config;
+11 -1
View File
@@ -111,6 +111,12 @@ pub struct Config {
/// IP Range definitions
pub ip_ranges: super::ip_ranges::IpRanges,
/// Dynamic circuits configuration.
///
/// Optional so older configs without this section still deserialize cleanly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dynamic_circuits: Option<super::dynamic_circuits::DynamicCircuitsConfig>,
/// Network flows configuration
pub flows: Option<super::flows::FlowConfig>,
@@ -201,7 +207,7 @@ impl Config {
pub fn calculate_node_id() -> String {
if let Ok(machine_id) = std::fs::read_to_string("/etc/machine-id") {
let hash = sha2::Sha256::new().chain(machine_id).finalize();
format!("{:x}", hash)
crate::hex_encoding::encode_hex_lower(hash)
} else {
Uuid::new_v4().to_string()
}
@@ -279,6 +285,9 @@ impl Config {
stormguard.validate()?;
}
self.treeguard.validate()?;
if let Some(dynamic_circuits) = &self.dynamic_circuits {
dynamic_circuits.validate()?;
}
Ok(())
}
@@ -352,6 +361,7 @@ impl Default for Config {
queues: super::queues::QueueConfig::default(),
long_term_stats: super::long_term_stats::LongTermStats::default(),
ip_ranges: super::ip_ranges::IpRanges::default(),
dynamic_circuits: None,
integration_common: super::integration_common::IntegrationConfig::default(),
topology: super::topology::TopologyConfig::default(),
mikrotik_ipv6: super::mikrotik_ipv6::MikrotikIpv6Config::default(),
+19
View File
@@ -0,0 +1,19 @@
const HEX_LOWER: &[u8; 16] = b"0123456789abcdef";
const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF";
pub(crate) fn encode_hex_lower(bytes: impl AsRef<[u8]>) -> String {
encode_hex(bytes.as_ref(), HEX_LOWER)
}
pub(crate) fn encode_hex_upper(bytes: impl AsRef<[u8]>) -> String {
encode_hex(bytes.as_ref(), HEX_UPPER)
}
fn encode_hex(bytes: &[u8], table: &[u8; 16]) -> String {
let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
for &byte in bytes {
encoded.push(table[(byte >> 4) as usize] as char);
encoded.push(table[(byte & 0x0f) as usize] as char);
}
encoded
}
+4 -2
View File
@@ -15,6 +15,7 @@ mod circuit_ethernet_metadata;
mod cpu_topology;
mod etc;
mod ethernet_port_limits;
mod hex_encoding;
mod mikrotik_ipv6_credentials;
mod network_json;
mod planner;
@@ -40,8 +41,9 @@ pub use cpu_topology::{
CpuListParseError, ShapingCpuDetection, ShapingCpuSource, detect_shaping_cpus,
};
pub use etc::{
BridgeConfig, Config, LazyQueueMode, MikrotikIpv6Config, QueueMode, RttThresholds,
SingleInterfaceConfig, StormguardConfig, StormguardStrategy, TopologyConfig,
BridgeConfig, Config, DynamicCircuitRangeRule, DynamicCircuitsConfig, LazyQueueMode, QueueMode,
MikrotikIpv6Config, RttThresholds, SingleInterfaceConfig, StormguardConfig,
StormguardStrategy, TopologyConfig,
TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode,
TreeguardLinksConfig, TreeguardQooConfig, Tunables, clear_cached_config, disable_xdp_bridge,
enable_long_term_stats, load_config, treeguard_cpu_mode_migration_notice, update_config,
@@ -221,7 +221,7 @@ where
hasher.update([0]);
hasher.update(identifier.as_bytes());
}
Some(format!("{:x}", hasher.finalize()))
Some(crate::hex_encoding::encode_hex_lower(hasher.finalize()))
}
/// Returns a stable identity hash for imported topology facts and compile mode selection.
@@ -246,7 +246,7 @@ where
hasher.update([0]);
hasher.update(identifier.as_bytes());
}
Some(format!("{:x}", hasher.finalize()))
Some(crate::hex_encoding::encode_hex_lower(hasher.finalize()))
}
fn collect_network_json_ingress_tokens(map: &Map<String, Value>, out: &mut Vec<String>) {
@@ -582,7 +582,7 @@ pub fn compute_topology_source_generation(
hash_file_state(&mut hasher, TOPOLOGY_EDITOR_STATE_FILENAME, &editor_path)?;
}
Ok(format!("{:x}", hasher.finalize()))
Ok(crate::hex_encoding::encode_hex_lower(hasher.finalize()))
}
impl TopologyAttachmentHealthStateFile {
@@ -667,7 +667,7 @@ impl TopologyShapingInputsFile {
hasher.update(b"topology-runtime-shaping-generation");
hasher.update([0xff]);
hasher.update(payload);
Ok(format!("{:x}", hasher.finalize()))
Ok(crate::hex_encoding::encode_hex_lower(hasher.finalize()))
}
}
@@ -702,7 +702,7 @@ pub fn compute_effective_network_generation(
hasher.update(b"topology-runtime-effective-generation");
hasher.update([0xff]);
hasher.update(payload);
Ok(format!("{:x}", hasher.finalize()))
Ok(crate::hex_encoding::encode_hex_lower(hasher.finalize()))
}
impl TopologyRuntimeStatusFile {
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "lqos_network_devices"
version = "0.1.0"
edition = "2024"
license = "GPL-2.0-only"
[dependencies]
anyhow = { workspace = true }
arc-swap = { workspace = true }
crossbeam-channel = { workspace = true }
fxhash = "0.2.1"
ip_network = { workspace = true }
ip_network_table = { workspace = true }
lqos_config = { path = "../lqos_config" }
lqos_topology_compile = { path = "../lqos_topology_compile" }
lqos_utils = { path = "../lqos_utils" }
once_cell = { workspace = true }
parking_lot = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
+63
View File
@@ -0,0 +1,63 @@
# `lqos_network_devices`
Centralized, shared access to LibreQoS topology inputs:
- `ShapedDevices.csv` (and `ShapedDevices.insight.csv`)
- `network.json` (and `network.insight.json` / `network.effective.json`)
## Runtime model
In daemon mode, this crate starts:
- A single-thread actor that owns reload/apply commands.
- A config-directory watcher that coalesces filesystem events and requests reloads.
Callers access state through public accessor functions; the underlying channel sender is kept in a
private static.
## Public API (high level)
- `start_daemon_mode(hooks)`: starts actor + directory watcher.
- `shaped_devices_catalog()`: preferred shaped-devices snapshot handle with common lookup helpers.
- `shaped_devices_snapshot()`: low-level `Arc<ConfigShapedDevices>` snapshot.
- `shaped_device_hash_cache_snapshot()`: low-level hash→index cache snapshot (device/circuit hash lookups).
- `with_network_json_read(|net_json| ...)`: read-only access to in-memory `NetworkJson`.
- `with_network_json_write(|net_json| ...)`: mutable access to in-memory `NetworkJson` (used by runtime counters).
- `resolve_parent_node_reference(parent_node, parent_node_id)`: canonicalize shaped-device parent references against `network.json`.
- `request_reload_shaped_devices(reason)` / `request_reload_network_json(reason)`: ask actor to reload from disk.
- `apply_shaped_devices_snapshot(reason, shaped)`: publish a caller-provided shaped-devices snapshot via actor.
- `swap_shaped_devices_snapshot(reason, shaped_arc)`: replace shaped-devices snapshot without actor (intended for tests).
## `ShapedDevicesCatalog` helpers
`ShapedDevicesCatalog` exposes common verb-style operations over a consistent shaped-devices snapshot:
- `device_by_hashes(device_hash, circuit_hash)`: lookups using stable baked hashes.
- `devices_for_circuit_id(circuit_id)`: rows for one circuit.
- `configured_circuit_count()`: unique configured circuits.
- `circuit_rate_caps_by_circuit_id()`: per-circuit configured max Mbps.
- `device_longest_match_for_ip(ip)`: longest-prefix match via the LPM trie.
- `iter_ip_mappings()`: iterate the LPM trie mappings.
## Notes for `lqosd`
`lqosd` starts daemon mode and provides hooks to invalidate derived snapshots/caches on updates.
Runtime throughput tracking mutates the in-memory `NetworkJson` via `with_network_json_write`.
## Access & update catalog
Current (2026-04) call sites and update patterns:
- Daemon startup: `lqosd` calls `start_daemon_mode()` once at boot and provides `DaemonHooks`.
- Shaped-device updates:
- Directory watcher requests reload when `ShapedDevices.csv` or `ShapedDevices.insight.csv` changes.
- Node manager admin edits write `ShapedDevices.csv` and immediately publish the new snapshot via
`apply_shaped_devices_snapshot()`.
- Network topology updates:
- Directory watcher requests reload when `network.json`, `network.insight.json`, or
`network.effective.json` changes.
- Node manager admin edits write `network.json` and request reload via `request_reload_network_json()`.
- Hot-path reads (in-memory snapshots): node manager REST endpoints, throughput tracking, Insight/LTS2
control-channel snapshotting, and shaped-device/circuit views.
- One-shot disk loads: tools/sidecars (e.g. `lqos_python`, `lqos_stormguard`, support tooling) should
use `load_shaped_devices()` / `load_network_json()` rather than reaching into config modules directly.
+684
View File
@@ -0,0 +1,684 @@
use crate::dynamic::{
CircuitObservation, expired_dynamic_circuit_ids, is_superseded_by_shaped_devices,
};
use crate::dynamic_store::{load_dynamic_circuits_from_disk, persist_dynamic_circuits_to_disk};
use crate::state;
use crate::{DaemonHooks, ShapedDevicesCatalog, load_network_json, load_shaped_devices};
use anyhow::{Context, Result, anyhow};
use crossbeam_channel::{Receiver, Sender};
use ip_network_table::IpNetworkTable;
use once_cell::sync::OnceCell;
use std::collections::HashSet;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::oneshot;
use tracing::{debug, error, warn};
const RELOAD_RETRY_DELAY_MS: u64 = 500;
const RELOAD_ATTEMPTS: usize = 2;
const DYNAMIC_CIRCUITS_MAINTENANCE_TICK_SECONDS: u64 = 60;
const MAX_UNKNOWN_IP_PROMOTIONS_PER_OBSERVATION: usize = 32;
static ACTOR_SENDER: OnceCell<Sender<NetworkDevicesCommand>> = OnceCell::new();
pub(crate) fn start_actor(hooks: Option<Arc<dyn DaemonHooks>>) -> Result<()> {
if ACTOR_SENDER.get().is_some() {
return Ok(());
}
let (tx, rx) = crossbeam_channel::bounded::<NetworkDevicesCommand>(64);
let _ = ACTOR_SENDER.set(tx);
std::thread::Builder::new()
.name("lqos_network_devices".to_string())
.spawn(move || actor_loop(rx, hooks))?;
Ok(())
}
fn sender() -> Result<Sender<NetworkDevicesCommand>> {
ACTOR_SENDER
.get()
.cloned()
.ok_or_else(|| anyhow!("lqos_network_devices runtime actor is not running"))
}
pub(crate) fn request_reload_shaped_devices(reason: &str) -> Result<()> {
let (reply_tx, reply_rx) = oneshot::channel();
sender()?.send(NetworkDevicesCommand::ReloadShapedDevices {
reason: reason.to_string(),
reply: reply_tx,
})?;
reply_rx
.blocking_recv()
.map_err(|_| anyhow!("ShapedDevices reload reply channel closed"))?
}
pub(crate) fn request_reload_network_json(reason: &str) -> Result<()> {
let (reply_tx, reply_rx) = oneshot::channel();
sender()?.send(NetworkDevicesCommand::ReloadNetworkJson {
reason: reason.to_string(),
reply: reply_tx,
})?;
reply_rx
.blocking_recv()
.map_err(|_| anyhow!("NetworkJson reload reply channel closed"))?
}
pub(crate) fn apply_shaped_devices_snapshot(
reason: &str,
shaped: lqos_config::ConfigShapedDevices,
) -> Result<()> {
let (reply_tx, reply_rx) = oneshot::channel();
sender()?.send(NetworkDevicesCommand::ApplyShapedDevicesSnapshot {
reason: reason.to_string(),
shaped: Box::new(shaped),
reply: reply_tx,
})?;
reply_rx
.blocking_recv()
.map_err(|_| anyhow!("Apply shaped devices reply channel closed"))?
}
pub(crate) fn report_observations(observations: &[CircuitObservation]) {
if observations.is_empty() {
return;
}
let Some(sender) = ACTOR_SENDER.get().cloned() else {
return;
};
let _ = sender.try_send(NetworkDevicesCommand::ReportObservations {
observations: observations.to_vec(),
});
}
pub(crate) fn upsert_dynamic_circuit(shaped_device: lqos_config::ShapedDevice) -> Result<()> {
let (reply_tx, reply_rx) = oneshot::channel();
sender()?.send(NetworkDevicesCommand::UpsertDynamicCircuit {
shaped_device: Box::new(shaped_device),
reply: reply_tx,
})?;
reply_rx
.blocking_recv()
.map_err(|_| anyhow!("Upsert dynamic circuit reply channel closed"))?
}
pub(crate) fn remove_dynamic_circuit(circuit_id: &str) -> Result<bool> {
let (reply_tx, reply_rx) = oneshot::channel();
sender()?.send(NetworkDevicesCommand::RemoveDynamicCircuit {
circuit_id: circuit_id.to_string(),
reply: reply_tx,
})?;
reply_rx
.blocking_recv()
.map_err(|_| anyhow!("Remove dynamic circuit reply channel closed"))?
}
enum NetworkDevicesCommand {
ReloadShapedDevices {
reason: String,
reply: oneshot::Sender<Result<()>>,
},
ReloadNetworkJson {
reason: String,
reply: oneshot::Sender<Result<()>>,
},
ApplyShapedDevicesSnapshot {
reason: String,
shaped: Box<lqos_config::ConfigShapedDevices>,
reply: oneshot::Sender<Result<()>>,
},
ReportObservations {
observations: Vec<CircuitObservation>,
},
UpsertDynamicCircuit {
shaped_device: Box<lqos_config::ShapedDevice>,
reply: oneshot::Sender<Result<()>>,
},
RemoveDynamicCircuit {
circuit_id: String,
reply: oneshot::Sender<Result<bool>>,
},
}
fn actor_loop(rx: Receiver<NetworkDevicesCommand>, hooks: Option<Arc<dyn DaemonHooks>>) {
debug!("lqos_network_devices actor starting");
if let Err(err) = reload_shaped_devices_inner("startup", hooks.as_deref()) {
warn!("Initial shaped-devices load failed: {err}");
}
if let Err(err) = reload_network_json_inner("startup", hooks.as_deref()) {
warn!("Initial network-json load failed: {err}");
}
reload_dynamic_circuits_inner("startup");
prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks.as_deref());
let tick = crossbeam_channel::tick(Duration::from_secs(
DYNAMIC_CIRCUITS_MAINTENANCE_TICK_SECONDS,
));
loop {
crossbeam_channel::select! {
recv(rx) -> msg => {
let command = match msg {
Ok(value) => value,
Err(_) => break,
};
match command {
NetworkDevicesCommand::ReloadShapedDevices { reason, reply } => {
let result = reload_shaped_devices_inner(&reason, hooks.as_deref());
let _ = reply.send(result);
}
NetworkDevicesCommand::ReloadNetworkJson { reason, reply } => {
let result = reload_network_json_inner(&reason, hooks.as_deref());
let _ = reply.send(result);
}
NetworkDevicesCommand::ApplyShapedDevicesSnapshot {
reason,
shaped,
reply,
} => {
debug!("Publishing shaped-devices snapshot reason={reason}");
state::publish_shaped_devices(*shaped);
prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks.as_deref());
if let Some(hooks) = &hooks {
hooks.on_shaped_devices_updated();
}
let _ = reply.send(Ok(()));
}
NetworkDevicesCommand::ReportObservations { observations } => {
handle_observations(&observations, hooks.as_deref());
}
NetworkDevicesCommand::UpsertDynamicCircuit {
shaped_device,
reply,
} => {
let result = upsert_dynamic_circuit_inner(*shaped_device);
let _ = reply.send(result);
}
NetworkDevicesCommand::RemoveDynamicCircuit { circuit_id, reply } => {
let result = remove_dynamic_circuit_inner(&circuit_id);
let _ = reply.send(result);
}
}
}
recv(tick) -> _ => {
prune_expired_dynamic_circuits_inner(hooks.as_deref());
}
}
}
error!("lqos_network_devices actor stopped");
}
fn normalize_circuit_id_key(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn recompute_hashes(device: &mut lqos_config::ShapedDevice) {
device.circuit_hash = lqos_utils::hash_to_i64(&device.circuit_id);
device.device_hash = lqos_utils::hash_to_i64(&device.device_id);
device.parent_hash = lqos_utils::hash_to_i64(&device.parent_node);
}
fn reload_dynamic_circuits_inner(reason: &str) {
let circuits = load_dynamic_circuits_from_disk();
debug!("Loaded {} dynamic circuits reason={reason}", circuits.len());
state::publish_dynamic_circuits_snapshot(circuits);
}
fn prune_expired_dynamic_circuits_inner(hooks: Option<&dyn DaemonHooks>) {
let Ok(config) = lqos_config::load_config() else {
return;
};
let Some(dynamic_cfg) = config.dynamic_circuits.as_ref() else {
return;
};
if !dynamic_cfg.enabled {
return;
}
let ttl_seconds = dynamic_cfg.ttl_seconds;
if ttl_seconds == 0 {
return;
}
let Ok(now_unix) = lqos_utils::unix_time::unix_now() else {
return;
};
let snapshot = state::dynamic_circuits_snapshot();
if snapshot.is_empty() {
return;
}
let mut expired_ids = expired_dynamic_circuit_ids(snapshot.as_ref(), now_unix, ttl_seconds);
if expired_ids.is_empty() {
return;
}
expired_ids.sort();
expired_ids.dedup();
let expired_keys: HashSet<String> = expired_ids
.iter()
.map(|id| normalize_circuit_id_key(id))
.collect();
let updated: Vec<_> = snapshot
.iter()
.filter(|circuit| {
let key = normalize_circuit_id_key(&circuit.shaped.circuit_id);
!expired_keys.contains(&key)
})
.cloned()
.collect();
if let Err(err) = persist_dynamic_circuits_to_disk(&updated) {
warn!("Unable to persist dynamic circuits after TTL pruning: {err:?}");
return;
}
state::publish_dynamic_circuits_snapshot(updated);
debug!(
"Pruned {} expired dynamic circuits ttl_seconds={ttl_seconds}",
expired_ids.len()
);
if let Some(hooks) = hooks {
hooks.on_dynamic_circuits_expired(&expired_ids);
}
}
fn upsert_dynamic_circuit_inner(mut shaped_device: lqos_config::ShapedDevice) -> Result<()> {
recompute_hashes(&mut shaped_device);
let now_unix = lqos_utils::unix_time::unix_now().context("get unix time")?;
let snapshot = state::dynamic_circuits_snapshot();
let mut updated = snapshot.as_ref().clone();
let key = normalize_circuit_id_key(&shaped_device.circuit_id);
let mut should_persist = false;
if let Some(pos) = updated
.iter()
.position(|c| normalize_circuit_id_key(&c.shaped.circuit_id) == key)
{
if updated[pos].shaped != shaped_device {
should_persist = true;
}
updated[pos].shaped = shaped_device;
updated[pos].last_seen_unix = now_unix;
} else {
updated.push(crate::DynamicCircuit {
shaped: shaped_device,
last_seen_unix: now_unix,
});
should_persist = true;
}
if should_persist {
persist_dynamic_circuits_to_disk(&updated).context("persist dynamic circuits to disk")?;
}
state::publish_dynamic_circuits_snapshot(updated);
Ok(())
}
fn remove_dynamic_circuit_inner(circuit_id: &str) -> Result<bool> {
let snapshot = state::dynamic_circuits_snapshot();
if snapshot.is_empty() {
return Ok(false);
}
let key = normalize_circuit_id_key(circuit_id);
let updated: Vec<_> = snapshot
.iter()
.filter(|c| normalize_circuit_id_key(&c.shaped.circuit_id) != key)
.cloned()
.collect();
if updated.len() == snapshot.len() {
return Ok(false);
}
persist_dynamic_circuits_to_disk(&updated).context("persist dynamic circuits to disk")?;
state::publish_dynamic_circuits_snapshot(updated);
Ok(true)
}
fn handle_observations(observations: &[CircuitObservation], hooks: Option<&dyn DaemonHooks>) {
if observations.is_empty() {
return;
}
let catalog = state::shaped_devices_catalog();
let dynamic_snapshot = state::dynamic_circuits_snapshot();
let mut dynamic_device_hashes: HashSet<i64> = HashSet::new();
let mut dynamic_circuit_hashes: HashSet<i64> = HashSet::new();
dynamic_device_hashes.reserve(dynamic_snapshot.len());
dynamic_circuit_hashes.reserve(dynamic_snapshot.len());
for circuit in dynamic_snapshot.iter() {
dynamic_device_hashes.insert(circuit.shaped.device_hash);
dynamic_circuit_hashes.insert(circuit.shaped.circuit_hash);
}
let mut seen_device_hashes: HashSet<i64> = HashSet::new();
let mut seen_circuit_hashes: HashSet<i64> = HashSet::new();
let mut unknown_candidates: Vec<CircuitObservation> = Vec::new();
for observation in observations {
if catalog
.device_by_hashes(observation.device_hash, observation.circuit_hash)
.is_some()
{
continue;
}
if let Some(device_hash) = observation.device_hash
&& dynamic_device_hashes.contains(&device_hash)
{
seen_device_hashes.insert(device_hash);
continue;
}
if let Some(circuit_hash) = observation.circuit_hash
&& dynamic_circuit_hashes.contains(&circuit_hash)
{
seen_circuit_hashes.insert(circuit_hash);
continue;
}
unknown_candidates.push(*observation);
}
if (!seen_device_hashes.is_empty() || !seen_circuit_hashes.is_empty())
&& let Ok(now_unix) = lqos_utils::unix_time::unix_now()
{
state::refresh_dynamic_circuits_last_seen_for_hashes(
&seen_device_hashes,
&seen_circuit_hashes,
now_unix,
);
}
if !unknown_candidates.is_empty() {
maybe_promote_unknown_ips(&unknown_candidates, &catalog, hooks);
}
}
fn maybe_promote_unknown_ips(
unknown_candidates: &[CircuitObservation],
catalog: &ShapedDevicesCatalog,
hooks: Option<&dyn DaemonHooks>,
) {
let Ok(config) = lqos_config::load_config() else {
return;
};
let Some(dynamic_cfg) = config.dynamic_circuits.as_ref() else {
return;
};
if !dynamic_cfg.enabled || !dynamic_cfg.enable_unknown_ip_promotion {
return;
}
if dynamic_cfg.ranges.is_empty() {
return;
}
let mut trie: IpNetworkTable<usize> = IpNetworkTable::new();
for (idx, rule) in dynamic_cfg.ranges.iter().enumerate() {
trie.insert(rule.ip_range, idx);
}
let mut promoted = 0usize;
for observation in unknown_candidates {
if promoted >= MAX_UNKNOWN_IP_PROMOTIONS_PER_OBSERVATION {
break;
}
// Never promote an IP that is already covered by static shaped devices.
if catalog
.device_longest_match_for_ip(&observation.ip)
.is_some()
{
continue;
}
let ip: IpAddr = observation.ip.as_ip();
let Some((_net, rule_idx)) = trie.longest_match(ip) else {
continue;
};
let Some(rule) = dynamic_cfg.ranges.get(*rule_idx) else {
continue;
};
let label = format!("[dyn] ({}) {}", rule.name.trim(), observation.ip);
let parent_node = resolve_unknown_promotion_parent(rule.attach_to.trim());
if parent_node.trim().is_empty() {
continue;
}
let shaped_device = shaped_device_from_unknown_ip(&label, &parent_node, ip, rule);
if let Err(err) = upsert_dynamic_circuit_inner(shaped_device.clone()) {
warn!(
"Unable to persist dynamic circuit from unknown IP promotion for {}: {err:?}",
observation.ip
);
continue;
}
if let Some(hooks) = hooks {
hooks.on_unknown_ip_promoted(&shaped_device);
}
promoted += 1;
}
if promoted > 0 {
debug!(
"Promoted {promoted} unknown IP(s) into dynamic circuits (candidates={})",
unknown_candidates.len()
);
}
}
fn resolve_unknown_promotion_parent(attach_to: &str) -> String {
let trimmed_attach = attach_to.trim();
if !trimmed_attach.is_empty() {
if let Some(resolved) = crate::resolve_parent_node(trimmed_attach) {
return resolved.name;
}
// In "flat" setups (no meaningful `network.json`), attachments are just site-name strings.
let is_flat = state::with_network_json_read(|net_json| {
let nodes = net_json.get_nodes_when_ready();
let has_non_root = nodes
.iter()
.any(|node| node.name.trim() != "Root" && !node.name.trim().is_empty());
nodes.len() <= 1 || !has_non_root
});
if is_flat {
return trimmed_attach.to_string();
}
}
let (node_len, orphans, first_non_root) = state::with_network_json_read(|net_json| {
let nodes = net_json.get_nodes_when_ready();
let first_non_root = nodes
.iter()
.find(|node| node.name.trim() != "Root" && !node.name.trim().is_empty())
.map(|node| node.name.clone());
let orphans = nodes
.iter()
.find(|node| node.name.trim().eq_ignore_ascii_case("ORPHANS"))
.map(|node| node.name.clone());
(nodes.len(), orphans, first_non_root)
});
// "Flat" means `network.json` had no meaningful tree beyond Root.
let is_flat = node_len <= 1 || first_non_root.is_none();
if !is_flat {
if let Some(orphans) = orphans {
return orphans;
}
if let Some(first) = first_non_root {
return first;
}
}
let shaped_parent = state::shaped_devices_snapshot()
.devices
.iter()
.find_map(|dev| {
let trimmed = dev.parent_node.trim();
(!trimmed.is_empty()).then_some(trimmed.to_string())
});
shaped_parent.unwrap_or_else(|| "Root".to_string())
}
fn shaped_device_from_unknown_ip(
label: &str,
parent_node: &str,
ip: IpAddr,
rule: &lqos_config::DynamicCircuitRangeRule,
) -> lqos_config::ShapedDevice {
let mut device = lqos_config::ShapedDevice {
circuit_id: label.to_string(),
circuit_name: label.to_string(),
device_id: label.to_string(),
device_name: label.to_string(),
parent_node: parent_node.to_string(),
download_min_mbps: rule.download_min_mbps,
upload_min_mbps: rule.upload_min_mbps,
download_max_mbps: rule.download_max_mbps,
upload_max_mbps: rule.upload_max_mbps,
..Default::default()
};
match ip {
IpAddr::V4(ip) => device.ipv4.push((ip, 32)),
IpAddr::V6(ip) => device.ipv6.push((ip, 128)),
}
device
}
fn reload_shaped_devices_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> Result<()> {
for attempt in 1..=RELOAD_ATTEMPTS {
match load_shaped_devices() {
Ok(shaped) => {
debug!("Loaded shaped devices reason={reason}");
state::publish_shaped_devices(shaped);
prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks);
if let Some(hooks) = hooks {
hooks.on_shaped_devices_updated();
}
return Ok(());
}
Err(err) if attempt < RELOAD_ATTEMPTS => {
warn!(
"ShapedDevices reload reason={reason} attempt {attempt}/{} failed: {err}. Retrying after {} ms.",
RELOAD_ATTEMPTS, RELOAD_RETRY_DELAY_MS
);
std::thread::sleep(Duration::from_millis(RELOAD_RETRY_DELAY_MS));
}
Err(err) => {
warn!(
"ShapedDevices reload reason={reason} failed after {} attempts: {err}. Keeping last-known-good snapshot with {} devices.",
RELOAD_ATTEMPTS,
state::shaped_devices_snapshot().devices.len()
);
return Err(err);
}
}
}
unreachable!("reload loop must return");
}
fn prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks: Option<&dyn DaemonHooks>) {
let catalog = state::shaped_devices_catalog();
let snapshot = state::dynamic_circuits_snapshot();
if snapshot.is_empty() {
return;
}
let mut removed_ids: Vec<String> = Vec::new();
let mut removed_keys: HashSet<String> = HashSet::new();
for circuit in snapshot.iter() {
if !is_superseded_by_shaped_devices(circuit, &catalog) {
continue;
}
let circuit_id = circuit.shaped.circuit_id.trim();
if circuit_id.is_empty() {
continue;
}
let key = normalize_circuit_id_key(circuit_id);
if removed_keys.insert(key) {
removed_ids.push(circuit_id.to_string());
}
}
if removed_keys.is_empty() {
return;
}
removed_ids.sort();
removed_ids.dedup();
let updated: Vec<_> = snapshot
.iter()
.filter(|circuit| {
let circuit_id = circuit.shaped.circuit_id.trim();
if circuit_id.is_empty() {
return true;
}
let key = normalize_circuit_id_key(circuit_id);
!removed_keys.contains(&key)
})
.cloned()
.collect();
if let Err(err) = persist_dynamic_circuits_to_disk(&updated) {
warn!("Unable to persist dynamic circuits after shaped-devices conflict prune: {err:?}");
}
state::publish_dynamic_circuits_snapshot(updated);
debug!(
"Pruned {} dynamic circuits superseded by ShapedDevices.csv changes",
removed_ids.len()
);
if let Some(hooks) = hooks {
hooks.on_dynamic_circuits_expired(&removed_ids);
}
}
fn reload_network_json_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> Result<()> {
for attempt in 1..=RELOAD_ATTEMPTS {
match load_network_json() {
Ok(net_json) => {
debug!("Loaded network json reason={reason}");
state::publish_network_json(net_json);
if let Some(hooks) = hooks {
hooks.on_network_json_updated();
}
return Ok(());
}
Err(err) if attempt < RELOAD_ATTEMPTS => {
warn!(
"NetworkJson reload reason={reason} attempt {attempt}/{} failed: {err}. Retrying after {} ms.",
RELOAD_ATTEMPTS, RELOAD_RETRY_DELAY_MS
);
std::thread::sleep(Duration::from_millis(RELOAD_RETRY_DELAY_MS));
}
Err(err) => {
warn!(
"NetworkJson reload reason={reason} failed after {} attempts: {err}. Keeping last-known-good snapshot.",
RELOAD_ATTEMPTS
);
return Err(err);
}
}
}
unreachable!("reload loop must return");
}
@@ -0,0 +1,182 @@
use crate::hash_cache::ShapedDeviceHashCache;
use fxhash::FxHashSet;
use ip_network::IpNetwork;
use lqos_config::{ConfigShapedDevices, ShapedDevice};
use lqos_utils::XdpIpAddress;
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
/// Snapshot handle for shaped devices plus derived lookup structures.
///
/// Prefer using this catalog over reaching into `ConfigShapedDevices` directly.
/// It keeps related snapshots consistent (shaped devices + hash cache) and exposes
/// common operations as verb-style methods.
#[derive(Clone)]
pub struct ShapedDevicesCatalog {
shaped: Arc<ConfigShapedDevices>,
cache: Arc<ShapedDeviceHashCache>,
generation: u64,
}
impl ShapedDevicesCatalog {
pub(crate) fn new(
shaped: Arc<ConfigShapedDevices>,
cache: Arc<ShapedDeviceHashCache>,
generation: u64,
) -> Self {
Self {
shaped,
cache,
generation,
}
}
/// Builds a catalog from an arbitrary shaped-devices snapshot.
///
/// This is intended for one-shot tools that load `ShapedDevices.csv` from disk
/// and want the higher-level lookup helpers without starting the runtime actor.
pub fn from_shaped_devices(shaped: Arc<ConfigShapedDevices>) -> Self {
let cache = Arc::new(ShapedDeviceHashCache::from_devices(&shaped.devices));
Self {
shaped,
cache,
generation: 0,
}
}
/// Returns the monotonic generation number of the underlying shaped-devices snapshot.
pub fn generation(&self) -> u64 {
self.generation
}
/// Returns the number of shaped-device rows in the snapshot.
pub fn devices_len(&self) -> usize {
self.shaped.devices.len()
}
/// Iterates over all shaped-device rows in the snapshot.
pub fn iter_devices(&self) -> impl Iterator<Item = &ShapedDevice> {
self.shaped.devices.iter()
}
/// Returns an owned copy of all shaped-device rows.
pub fn clone_all_devices(&self) -> Vec<ShapedDevice> {
self.shaped.devices.clone()
}
/// Looks up a shaped device by the stable `device_hash` field.
pub fn device_by_device_hash(&self, device_hash: i64) -> Option<&ShapedDevice> {
self.cache
.index_by_device_hash(&self.shaped, device_hash)
.and_then(|idx| self.shaped.devices.get(idx))
}
/// Looks up a shaped device by the stable `circuit_hash` field.
pub fn device_by_circuit_hash(&self, circuit_hash: i64) -> Option<&ShapedDevice> {
self.cache
.index_by_circuit_hash(&self.shaped, circuit_hash)
.and_then(|idx| self.shaped.devices.get(idx))
}
/// Looks up a shaped device using optional hashes, preferring `device_hash`
/// over `circuit_hash` when both are present.
pub fn device_by_hashes(
&self,
device_hash: Option<i64>,
circuit_hash: Option<i64>,
) -> Option<&ShapedDevice> {
if let Some(device_hash) = device_hash
&& let Some(device) = self.device_by_device_hash(device_hash)
{
return Some(device);
}
if let Some(circuit_hash) = circuit_hash
&& let Some(device) = self.device_by_circuit_hash(circuit_hash)
{
return Some(device);
}
None
}
/// Returns a cloned list of shaped-device rows that belong to a circuit identifier.
pub fn devices_for_circuit_id(&self, circuit_id: &str) -> Vec<ShapedDevice> {
let safe_id = circuit_id.to_lowercase().trim().to_string();
self.shaped
.devices
.iter()
.filter(|device| device.circuit_id.to_lowercase().trim() == safe_id)
.cloned()
.collect()
}
/// Returns the count of unique configured circuits present in the shaped-devices snapshot.
pub fn configured_circuit_count(&self) -> usize {
let mut circuits: FxHashSet<&str> = FxHashSet::default();
circuits.reserve(self.shaped.devices.len());
for device in &self.shaped.devices {
let circuit_id = device.circuit_id.trim();
if circuit_id.is_empty() {
continue;
}
circuits.insert(circuit_id);
}
circuits.len()
}
/// Returns a per-circuit map of maximum configured rates, using the first-seen parent node.
pub fn circuit_rate_caps_by_circuit_id(&self) -> HashMap<String, CircuitRateCaps> {
let mut by_circuit_id: HashMap<String, CircuitRateCaps> = HashMap::new();
for device in &self.shaped.devices {
let entry = by_circuit_id
.entry(device.circuit_id.clone())
.or_insert_with(|| CircuitRateCaps {
parent_node: device.parent_node.clone(),
download_max_mbps: 0.0,
upload_max_mbps: 0.0,
});
entry.download_max_mbps = entry.download_max_mbps.max(device.download_max_mbps);
entry.upload_max_mbps = entry.upload_max_mbps.max(device.upload_max_mbps);
}
by_circuit_id
}
/// Looks up the `circuit_hash` for an IP address using the shaped-devices LPM trie.
pub fn circuit_hash_for_ip(&self, ip: &XdpIpAddress) -> Option<i64> {
self.shaped.get_circuit_hash_from_ip(ip)
}
/// Looks up a `(circuit_id, circuit_name)` pair for an IP address using the shaped-devices LPM trie.
pub fn circuit_id_and_name_for_ip(&self, ip: &XdpIpAddress) -> Option<(String, String)> {
self.shaped.get_circuit_id_and_name_from_ip(ip)
}
/// Returns the longest-prefix match entry for an IP address using the shaped-devices LPM trie.
pub fn device_longest_match_for_ip(
&self,
ip: &XdpIpAddress,
) -> Option<(IpNetwork, &ShapedDevice)> {
let lookup = match ip.as_ip() {
IpAddr::V4(ip) => ip.to_ipv6_mapped(),
IpAddr::V6(ip) => ip,
};
let (net, idx) = self.shaped.trie.longest_match(lookup)?;
self.shaped.devices.get(*idx).map(|dev| (net, dev))
}
/// Iterates over all IP network mappings in the shaped-devices LPM trie.
pub fn iter_ip_mappings(&self) -> impl Iterator<Item = (IpNetwork, &ShapedDevice)> {
self.shaped
.trie
.iter()
.filter_map(|(net, &idx)| self.shaped.devices.get(idx).map(|dev| (net, dev)))
}
}
/// Canonical per-circuit configured rate caps derived from shaped-device rows.
#[derive(Clone, Debug, PartialEq)]
pub struct CircuitRateCaps {
pub parent_node: String,
pub download_max_mbps: f32,
pub upload_max_mbps: f32,
}
@@ -0,0 +1,162 @@
use crate::{DynamicCircuit, ShapedDevicesCatalog};
use fxhash::FxHashMap;
use ip_network::IpNetwork;
use ip_network_table::IpNetworkTable;
use lqos_config::ShapedDevice;
use lqos_utils::XdpIpAddress;
use std::sync::Arc;
use std::net::IpAddr;
fn normalize_circuit_id_key(circuit_id: &str) -> String {
circuit_id.trim().to_ascii_lowercase()
}
/// Snapshot handle for shaped devices plus runtime dynamic circuits.
///
/// This catalog is intended for read-heavy paths (dashboards, APIs) that need to
/// treat dynamic circuits as first-class circuits alongside `ShapedDevices.csv`.
pub struct NetworkDevicesCatalog {
shaped: ShapedDevicesCatalog,
dynamic: Arc<Vec<DynamicCircuit>>,
dyn_by_device_hash: FxHashMap<i64, usize>,
dyn_by_circuit_hash: FxHashMap<i64, usize>,
dyn_by_circuit_id: FxHashMap<String, usize>,
dyn_ip_table: IpNetworkTable<usize>,
}
impl Clone for NetworkDevicesCatalog {
fn clone(&self) -> Self {
Self::from_snapshots(self.shaped.clone(), self.dynamic.clone())
}
}
impl NetworkDevicesCatalog {
/// Builds a combined catalog from explicit snapshots.
pub fn from_snapshots(
shaped: ShapedDevicesCatalog,
dynamic: Arc<Vec<DynamicCircuit>>,
) -> Self {
let mut dyn_by_device_hash = FxHashMap::default();
let mut dyn_by_circuit_hash = FxHashMap::default();
let mut dyn_by_circuit_id = FxHashMap::default();
let mut dyn_ip_table = IpNetworkTable::new();
for (idx, circuit) in dynamic.iter().enumerate() {
dyn_by_device_hash.insert(circuit.shaped.device_hash, idx);
dyn_by_circuit_hash.insert(circuit.shaped.circuit_hash, idx);
dyn_by_circuit_id.insert(normalize_circuit_id_key(&circuit.shaped.circuit_id), idx);
for (ipv4, prefix) in &circuit.shaped.ipv4 {
let prefix = prefix.saturating_add(96).min(128);
if let Ok(net) = IpNetwork::new(ipv4.to_ipv6_mapped(), prefix as u8) {
dyn_ip_table.insert(net, idx);
}
}
for (ipv6, prefix) in &circuit.shaped.ipv6 {
if *prefix <= 128 {
if let Ok(net) = IpNetwork::new(*ipv6, *prefix as u8) {
dyn_ip_table.insert(net, idx);
}
}
}
}
Self {
shaped,
dynamic,
dyn_by_device_hash,
dyn_by_circuit_hash,
dyn_by_circuit_id,
dyn_ip_table,
}
}
/// Returns the underlying static shaped-devices catalog (`ShapedDevices.csv`).
pub fn shaped_devices(&self) -> &ShapedDevicesCatalog {
&self.shaped
}
/// Returns the dynamic circuit overlay snapshot.
pub fn dynamic_circuits(&self) -> &[DynamicCircuit] {
self.dynamic.as_ref()
}
/// Iterates over static shaped-device rows (`ShapedDevices.csv`).
pub fn iter_static_devices(&self) -> impl Iterator<Item = &ShapedDevice> {
self.shaped.iter_devices()
}
/// Iterates over dynamic circuit overlay entries as shaped-device rows.
pub fn iter_dynamic_devices(&self) -> impl Iterator<Item = &ShapedDevice> {
self.dynamic.iter().map(|circuit| &circuit.shaped)
}
/// Iterates over both static and dynamic shaped-device rows.
pub fn iter_all_devices(&self) -> impl Iterator<Item = &ShapedDevice> {
self.iter_static_devices().chain(self.iter_dynamic_devices())
}
/// Returns the longest-prefix match entry for an IP address.
///
/// This prefers static shaped devices, then falls back to dynamic circuits.
pub fn device_longest_match_for_ip(
&self,
ip: &XdpIpAddress,
) -> Option<(IpNetwork, &ShapedDevice)> {
if let Some((net, device)) = self.shaped.device_longest_match_for_ip(ip) {
return Some((net, device));
}
let lookup = match ip.as_ip() {
IpAddr::V4(ip) => ip.to_ipv6_mapped(),
IpAddr::V6(ip) => ip,
};
let (net, idx) = self.dyn_ip_table.longest_match(lookup)?;
self.dynamic.get(*idx).map(|circuit| (net, &circuit.shaped))
}
/// Returns true if the device hash is currently tracked as a dynamic circuit.
pub fn is_dynamic_device_hash(&self, device_hash: i64) -> bool {
self.dyn_by_device_hash.contains_key(&device_hash)
}
/// Returns true if the circuit hash is currently tracked as a dynamic circuit.
pub fn is_dynamic_circuit_hash(&self, circuit_hash: i64) -> bool {
self.dyn_by_circuit_hash.contains_key(&circuit_hash)
}
/// Looks up a shaped device using optional hashes, preferring static shaped devices.
///
/// When the hashes are not present in `ShapedDevices.csv`, this falls back to the
/// runtime dynamic circuit overlay snapshot.
pub fn device_by_hashes(
&self,
device_hash: Option<i64>,
circuit_hash: Option<i64>,
) -> Option<&ShapedDevice> {
if let Some(device) = self.shaped.device_by_hashes(device_hash, circuit_hash) {
return Some(device);
}
if let Some(device_hash) = device_hash
&& let Some(idx) = self.dyn_by_device_hash.get(&device_hash)
{
return self.dynamic.get(*idx).map(|circuit| &circuit.shaped);
}
if let Some(circuit_hash) = circuit_hash
&& let Some(idx) = self.dyn_by_circuit_hash.get(&circuit_hash)
{
return self.dynamic.get(*idx).map(|circuit| &circuit.shaped);
}
None
}
/// Looks up a dynamic circuit overlay entry by circuit id.
pub fn dynamic_device_by_circuit_id(&self, circuit_id: &str) -> Option<&ShapedDevice> {
let key = normalize_circuit_id_key(circuit_id);
let idx = self.dyn_by_circuit_id.get(&key)?;
self.dynamic.get(*idx).map(|circuit| &circuit.shaped)
}
}
@@ -0,0 +1,111 @@
use crate::{request_reload_network_json, request_reload_shaped_devices};
use anyhow::Result;
use lqos_utils::directory_watcher::{DirectoryWatcher, WatchedDirectoryError};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use tracing::{debug, error, warn};
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
enum DirectoryReloadEvent {
ShapedDevices,
NetworkJson,
}
pub(crate) fn start_network_devices_directory_watch() -> Result<()> {
std::thread::Builder::new()
.name("Network Devices Dir Watcher".to_string())
.spawn(|| {
let mut watcher = match NetworkDevicesDirectoryWatcher::new() {
Ok(watcher) => watcher,
Err(err) => {
error!("Failed to start Network Devices directory watcher: {err}");
return;
}
};
debug!("Watching LibreQoS config directory for network/shaped updates");
loop {
match watcher.watch_relevant() {
Ok(events) => {
for event in events {
if let Err(err) = dispatch_reload(event) {
warn!("Network Devices directory reload dispatch failed: {err}");
}
}
}
Err(err) => {
warn!("Network Devices directory watcher returned: {err}");
}
}
}
})?;
Ok(())
}
struct NetworkDevicesDirectoryWatcher {
inner: DirectoryWatcher,
}
impl NetworkDevicesDirectoryWatcher {
fn new() -> Result<Self> {
let config = lqos_config::load_config()?;
let path = PathBuf::from(&config.lqos_directory);
Ok(Self {
inner: DirectoryWatcher::new("Network Devices Directory", path),
})
}
fn watch(&mut self) -> Result<Vec<PathBuf>, WatchedDirectoryError> {
self.inner.watch()
}
fn watch_relevant(&mut self) -> Result<Vec<DirectoryReloadEvent>, WatchedDirectoryError> {
loop {
let events = classify_changed_paths(&self.watch()?);
if !events.is_empty() {
return Ok(events);
}
}
}
}
fn classify_changed_paths(paths: &[PathBuf]) -> Vec<DirectoryReloadEvent> {
paths
.iter()
.filter_map(|path| classify_changed_path(path))
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn classify_changed_path(path: &Path) -> Option<DirectoryReloadEvent> {
let file_name = path.file_name()?.to_str()?;
if is_ignored_filename(file_name) {
return None;
}
match file_name {
"ShapedDevices.csv" | "ShapedDevices.insight.csv" | "topology_import.json" => {
Some(DirectoryReloadEvent::ShapedDevices)
}
"network.effective.json" | "network.insight.json" | "network.json" => {
Some(DirectoryReloadEvent::NetworkJson)
}
_ => None,
}
}
fn is_ignored_filename(file_name: &str) -> bool {
file_name.ends_with(".lock")
|| file_name.ends_with(".tmp")
|| file_name.ends_with(".swp")
|| file_name.ends_with('~')
}
fn dispatch_reload(event: DirectoryReloadEvent) -> Result<()> {
match event {
DirectoryReloadEvent::ShapedDevices => {
request_reload_shaped_devices("dirwatch:ShapedDevices.csv")
}
DirectoryReloadEvent::NetworkJson => request_reload_network_json("dirwatch:network.json"),
}
}
@@ -0,0 +1,75 @@
use lqos_config::ShapedDevice;
use lqos_utils::XdpIpAddress;
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
/// Runtime-only dynamic circuit overlay entry.
///
/// This is an overlay on top of `ShapedDevices.csv` and never mutates the static file.
/// It is intended for circuits/devices that are created and managed dynamically at runtime.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DynamicCircuit {
/// Shaped-device-like fields describing the dynamic circuit/device.
pub shaped: ShapedDevice,
/// Unix timestamp (seconds) when this dynamic circuit was last observed active.
pub last_seen_unix: u64,
}
/// Minimal kernel observation used to drive dynamic-circuit activity updates.
///
/// Hash fields come from the kernel IP map; when present they should be preferred over
/// any trie-derived lookups.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CircuitObservation {
pub ip: XdpIpAddress,
pub device_hash: Option<i64>,
pub circuit_hash: Option<i64>,
}
pub(crate) fn is_superseded_by_shaped_devices(
circuit: &DynamicCircuit,
shaped_devices: &crate::ShapedDevicesCatalog,
) -> bool {
if shaped_devices
.device_by_hashes(
Some(circuit.shaped.device_hash),
Some(circuit.shaped.circuit_hash),
)
.is_some()
{
return true;
}
for (ip, _) in circuit.shaped.ipv4.iter() {
let probe = XdpIpAddress::from_ip(IpAddr::V4(*ip));
if shaped_devices.device_longest_match_for_ip(&probe).is_some() {
return true;
}
}
for (ip, _) in circuit.shaped.ipv6.iter() {
let probe = XdpIpAddress::from_ip(IpAddr::V6(*ip));
if shaped_devices.device_longest_match_for_ip(&probe).is_some() {
return true;
}
}
false
}
pub(crate) fn expired_dynamic_circuit_ids(
circuits: &[DynamicCircuit],
now_unix: u64,
ttl_seconds: u64,
) -> Vec<String> {
circuits
.iter()
.filter_map(|circuit| {
let circuit_id = circuit.shaped.circuit_id.trim();
if circuit_id.is_empty() {
return None;
}
let age_seconds = now_unix.saturating_sub(circuit.last_seen_unix);
(age_seconds > ttl_seconds).then_some(circuit_id.to_string())
})
.collect()
}
@@ -0,0 +1,123 @@
use crate::DynamicCircuit;
use anyhow::{Context, Result};
use lqos_config::ShapedDevice;
use lqos_utils::hash_to_i64;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::warn;
const DYNAMIC_CIRCUITS_FILENAME: &str = "dynamic_circuits.json";
fn dynamic_circuits_path(config: &lqos_config::Config) -> PathBuf {
Path::new(&config.lqos_directory).join(DYNAMIC_CIRCUITS_FILENAME)
}
fn default_dynamic_circuits_schema_version() -> u32 {
1
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
struct DynamicCircuitsFile {
#[serde(default = "default_dynamic_circuits_schema_version")]
schema_version: u32,
#[serde(default)]
circuits: Vec<DynamicCircuit>,
}
fn recompute_hashes(device: &mut ShapedDevice) {
device.circuit_hash = hash_to_i64(&device.circuit_id);
device.device_hash = hash_to_i64(&device.device_id);
device.parent_hash = hash_to_i64(&device.parent_node);
}
fn normalize_circuit_id_key(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
let raw = serde_json::to_string_pretty(value).context("serialize dynamic circuits JSON")?;
let temp_path = path.with_extension("tmp");
let mut file = File::create(&temp_path)
.with_context(|| format!("create temp dynamic circuits file {}", temp_path.display()))?;
file.write_all(raw.as_bytes())
.with_context(|| format!("write temp dynamic circuits file {}", temp_path.display()))?;
file.sync_all()
.with_context(|| format!("sync temp dynamic circuits file {}", temp_path.display()))?;
std::fs::rename(&temp_path, path).with_context(|| {
format!(
"rename temp dynamic circuits file {} to {}",
temp_path.display(),
path.display()
)
})?;
Ok(())
}
pub(crate) fn load_dynamic_circuits_from_disk() -> Vec<DynamicCircuit> {
let Ok(config) = lqos_config::load_config() else {
warn!("Unable to load lqos config while loading dynamic circuits; treating as empty");
return Vec::new();
};
let path = dynamic_circuits_path(config.as_ref());
if !path.exists() {
return Vec::new();
}
let Ok(raw) = std::fs::read_to_string(&path) else {
warn!(
"Unable to read dynamic circuits file {}; treating as empty",
path.display()
);
return Vec::new();
};
if raw.trim().is_empty() {
return Vec::new();
}
let mut parsed: DynamicCircuitsFile = match serde_json::from_str(&raw) {
Ok(value) => value,
Err(err) => {
warn!(
"Unable to parse dynamic circuits file {} ({err}); treating as empty",
path.display()
);
return Vec::new();
}
};
for circuit in &mut parsed.circuits {
recompute_hashes(&mut circuit.shaped);
}
// De-duplicate by circuit_id (case-insensitive), keeping last entry.
parsed
.circuits
.into_iter()
.fold(Vec::<DynamicCircuit>::new(), |mut acc, circuit| {
let key = normalize_circuit_id_key(&circuit.shaped.circuit_id);
if let Some(pos) = acc
.iter()
.position(|c| normalize_circuit_id_key(&c.shaped.circuit_id) == key)
{
acc[pos] = circuit;
} else {
acc.push(circuit);
}
acc
})
}
pub(crate) fn persist_dynamic_circuits_to_disk(circuits: &[DynamicCircuit]) -> Result<()> {
let config = lqos_config::load_config().context("load lqos config")?;
let path = dynamic_circuits_path(config.as_ref());
let file = DynamicCircuitsFile {
schema_version: default_dynamic_circuits_schema_version(),
circuits: circuits.to_vec(),
};
atomic_write_json(&path, &file)
.with_context(|| format!("write dynamic circuits file {}", path.display()))
}
@@ -0,0 +1,63 @@
use fxhash::FxHashMap;
use lqos_config::ShapedDevice;
#[derive(Debug, Default)]
pub struct ShapedDeviceHashCache {
by_device_hash: FxHashMap<i64, usize>,
by_circuit_hash: FxHashMap<i64, usize>,
}
impl ShapedDeviceHashCache {
pub fn from_devices(devices: &[ShapedDevice]) -> Self {
let mut by_device_hash = FxHashMap::default();
by_device_hash.reserve(devices.len());
let mut by_circuit_hash = FxHashMap::default();
by_circuit_hash.reserve(devices.len());
for (idx, dev) in devices.iter().enumerate() {
by_device_hash.insert(dev.device_hash, idx);
by_circuit_hash.entry(dev.circuit_hash).or_insert(idx);
}
Self {
by_device_hash,
by_circuit_hash,
}
}
pub fn index_by_device_hash(
&self,
shaped: &lqos_config::ConfigShapedDevices,
device_hash: i64,
) -> Option<usize> {
if let Some(idx) = self.by_device_hash.get(&device_hash).copied()
&& shaped
.devices
.get(idx)
.is_some_and(|d| d.device_hash == device_hash)
{
return Some(idx);
}
shaped
.devices
.iter()
.position(|d| d.device_hash == device_hash)
}
pub fn index_by_circuit_hash(
&self,
shaped: &lqos_config::ConfigShapedDevices,
circuit_hash: i64,
) -> Option<usize> {
if let Some(idx) = self.by_circuit_hash.get(&circuit_hash).copied()
&& shaped
.devices
.get(idx)
.is_some_and(|d| d.circuit_hash == circuit_hash)
{
return Some(idx);
}
shaped
.devices
.iter()
.position(|d| d.circuit_hash == circuit_hash)
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Shared, centralized access to `network.json` and `ShapedDevices.csv`.
//!
//! This crate provides:
//! - Disk-load helpers used by one-shot tools.
//! - An optional runtime actor + directory watcher used by `lqosd` to keep
//! in-memory snapshots up to date.
mod actor;
mod catalog;
mod combined_catalog;
mod directory_watcher;
mod dynamic;
mod dynamic_store;
mod hash_cache;
mod state;
mod topology;
#[cfg(test)]
mod tests;
use anyhow::{Context, Result};
use lqos_config::{ConfigShapedDevices, NetworkJson};
use std::sync::Arc;
use tracing::debug;
pub use catalog::{CircuitRateCaps, ShapedDevicesCatalog};
pub use combined_catalog::NetworkDevicesCatalog;
pub use dynamic::{CircuitObservation, DynamicCircuit};
pub use hash_cache::ShapedDeviceHashCache;
pub use topology::{ResolvedParentNode, resolve_parent_node, resolve_parent_node_reference};
/// Hooks that `lqosd` can provide to run side effects after updates.
pub trait DaemonHooks: Send + Sync + 'static {
/// Called after a new `ShapedDevices.csv` snapshot is published.
fn on_shaped_devices_updated(&self);
/// Called after a new `network.json` snapshot is published.
fn on_network_json_updated(&self);
/// Called after one or more dynamic circuits are expired and pruned by the runtime actor.
///
/// Default implementation does nothing so non-daemon callers don't need to handle it.
fn on_dynamic_circuits_expired(&self, _circuit_ids: &[String]) {}
/// Called when the runtime actor promotes an unknown IP into a dynamic circuit overlay entry.
///
/// Default implementation does nothing so non-daemon callers don't need to handle it.
fn on_unknown_ip_promoted(&self, _shaped_device: &lqos_config::ShapedDevice) {}
}
/// Starts the runtime actor and directory watcher used by long-running daemons.
///
/// This function has side effects: it spawns background threads and installs global state.
pub fn start_daemon_mode(hooks: Option<Arc<dyn DaemonHooks>>) -> Result<()> {
actor::start_actor(hooks)?;
directory_watcher::start_network_devices_directory_watch()
}
fn integration_ingress_enabled(config: &lqos_config::Config) -> bool {
config.uisp_integration.enable_uisp
|| config.splynx_integration.enable_splynx
|| config
.netzur_integration
.as_ref()
.is_some_and(|integration| integration.enable_netzur)
|| config
.visp_integration
.as_ref()
.is_some_and(|integration| integration.enable_visp)
|| config.powercode_integration.enable_powercode
|| config.sonar_integration.enable_sonar
|| config
.wispgate_integration
.as_ref()
.is_some_and(|integration| integration.enable_wispgate)
}
/// Loads shaped-device configuration from disk.
///
/// When an integration-backed topology ingress is enabled, this loads shaped devices from the
/// active `topology_import.json` bundle. Otherwise, it loads `ShapedDevices.csv`.
pub fn load_shaped_devices() -> Result<ConfigShapedDevices> {
let config = lqos_config::load_config().context("Unable to load /etc/lqos.conf")?;
if integration_ingress_enabled(config.as_ref()) {
match lqos_topology_compile::TopologyImportFile::load(config.as_ref()) {
Ok(Some(topology_import)) => {
let shaped_devices = topology_import.into_imported_bundle().shaped_devices;
if !shaped_devices.devices.is_empty() {
return Ok(shaped_devices);
}
debug!(
"topology_import.json contained 0 shaped devices; falling back to ShapedDevices.csv"
);
}
Ok(None) => {
debug!("topology_import.json missing; falling back to ShapedDevices.csv");
}
Err(err) => {
debug!(
"Unable to load topology_import.json ({err}); falling back to ShapedDevices.csv"
);
}
}
}
ConfigShapedDevices::load().context("Unable to load ShapedDevices.csv")
}
/// Loads `network.json` from disk.
pub fn load_network_json() -> Result<NetworkJson> {
NetworkJson::load().context("Unable to load network.json")
}
/// Returns the current in-memory `ShapedDevices.csv` snapshot.
///
/// Note: If the runtime actor has not been started, this returns an empty default snapshot.
pub fn shaped_devices_snapshot() -> Arc<ConfigShapedDevices> {
state::shaped_devices_snapshot()
}
/// Returns a higher-level shaped-devices catalog snapshot.
///
/// Prefer this API over directly iterating `shaped_devices_snapshot().devices` or separately
/// fetching the hash cache snapshot.
pub fn shaped_devices_catalog() -> ShapedDevicesCatalog {
state::shaped_devices_catalog()
}
/// Returns a combined catalog snapshot for both shaped devices and dynamic circuits.
pub fn network_devices_catalog() -> NetworkDevicesCatalog {
NetworkDevicesCatalog::from_snapshots(
state::shaped_devices_catalog(),
state::dynamic_circuits_snapshot(),
)
}
/// Returns the current in-memory shaped-device hash cache snapshot.
pub fn shaped_device_hash_cache_snapshot() -> Arc<ShapedDeviceHashCache> {
state::shaped_device_hash_cache_snapshot()
}
/// Returns the current in-memory dynamic circuit overlay snapshot.
///
/// Note: This is a runtime-only overlay and does not mutate `ShapedDevices.csv`.
pub fn dynamic_circuits_snapshot() -> Arc<Vec<DynamicCircuit>> {
state::dynamic_circuits_snapshot()
}
/// Run a closure with a read-only view of the current in-memory `NetworkJson`.
pub fn with_network_json_read<R>(f: impl FnOnce(&NetworkJson) -> R) -> R {
state::with_network_json_read(f)
}
/// Run a closure with mutable access to the in-memory `NetworkJson`.
///
/// Side effects: this mutates the in-memory snapshot used by `lqosd`.
pub fn with_network_json_write<R>(f: impl FnOnce(&mut NetworkJson) -> R) -> R {
state::with_network_json_write(f)
}
/// Requests the runtime actor reload `ShapedDevices.csv` from disk.
pub fn request_reload_shaped_devices(reason: &str) -> Result<()> {
actor::request_reload_shaped_devices(reason)
}
/// Requests the runtime actor reload `network.json` from disk.
pub fn request_reload_network_json(reason: &str) -> Result<()> {
actor::request_reload_network_json(reason)
}
/// Publishes a caller-provided shaped-devices snapshot through the runtime actor.
pub fn apply_shaped_devices_snapshot(reason: &str, shaped: ConfigShapedDevices) -> Result<()> {
actor::apply_shaped_devices_snapshot(reason, shaped)
}
/// Reports kernel observations that may indicate dynamic circuit activity.
///
/// This function is not pure: it sends a message to the runtime actor when running.
pub fn report_observations(observations: &[CircuitObservation]) {
actor::report_observations(observations)
}
/// Creates or updates a runtime-only dynamic circuit overlay entry.
///
/// Side effects:
/// - Updates the in-memory dynamic circuit snapshot.
/// - Writes `dynamic_circuits.json` in the LibreQoS directory.
///
/// This function does **not** mutate `ShapedDevices.csv`.
pub fn upsert_dynamic_circuit(shaped_device: lqos_config::ShapedDevice) -> Result<()> {
actor::upsert_dynamic_circuit(shaped_device)
}
/// Removes a runtime-only dynamic circuit overlay entry by circuit id.
///
/// Side effects:
/// - Updates the in-memory dynamic circuit snapshot.
/// - Writes `dynamic_circuits.json` in the LibreQoS directory when a circuit is removed.
///
/// This function does **not** mutate `ShapedDevices.csv`.
pub fn remove_dynamic_circuit(circuit_id: &str) -> Result<bool> {
actor::remove_dynamic_circuit(circuit_id)
}
/// Replaces the in-memory `ShapedDevices.csv` snapshot without involving the runtime actor.
///
/// Side effects:
/// - Updates the shaped-device hash cache snapshot.
/// - Does **not** write to disk.
/// - Does **not** invoke any daemon hooks.
///
/// This is primarily intended for tests and for callers that already hold an `Arc` snapshot.
pub fn swap_shaped_devices_snapshot(
reason: &str,
shaped: Arc<ConfigShapedDevices>,
) -> Arc<ConfigShapedDevices> {
debug!("Swapping shaped-devices snapshot reason={reason}");
state::swap_shaped_devices_snapshot(shaped)
}
+119
View File
@@ -0,0 +1,119 @@
use crate::catalog::ShapedDevicesCatalog;
use crate::dynamic::DynamicCircuit;
use crate::hash_cache::ShapedDeviceHashCache;
use arc_swap::ArcSwap;
use lqos_config::{ConfigShapedDevices, NetworkJson};
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Clone)]
struct PublishedShapedState {
shaped: Arc<ConfigShapedDevices>,
cache: Arc<ShapedDeviceHashCache>,
generation: u64,
}
static NEXT_SHAPED_GENERATION: AtomicU64 = AtomicU64::new(1);
static SHAPED_STATE: Lazy<ArcSwap<PublishedShapedState>> = Lazy::new(|| {
ArcSwap::new(Arc::new(PublishedShapedState {
shaped: Arc::new(ConfigShapedDevices::default()),
cache: Arc::new(ShapedDeviceHashCache::default()),
generation: 0,
}))
});
static DYNAMIC_CIRCUITS: Lazy<ArcSwap<Vec<DynamicCircuit>>> =
Lazy::new(|| ArcSwap::new(Arc::new(Vec::new())));
static NETWORK_JSON: Lazy<RwLock<NetworkJson>> = Lazy::new(|| RwLock::new(NetworkJson::default()));
pub(crate) fn shaped_devices_snapshot() -> Arc<ConfigShapedDevices> {
SHAPED_STATE.load_full().shaped.clone()
}
pub(crate) fn shaped_device_hash_cache_snapshot() -> Arc<ShapedDeviceHashCache> {
SHAPED_STATE.load_full().cache.clone()
}
pub(crate) fn shaped_devices_catalog() -> ShapedDevicesCatalog {
let state = SHAPED_STATE.load_full();
ShapedDevicesCatalog::new(state.shaped.clone(), state.cache.clone(), state.generation)
}
pub(crate) fn dynamic_circuits_snapshot() -> Arc<Vec<DynamicCircuit>> {
DYNAMIC_CIRCUITS.load_full()
}
pub(crate) fn with_network_json_read<R>(f: impl FnOnce(&NetworkJson) -> R) -> R {
let reader = NETWORK_JSON.read();
f(&reader)
}
pub(crate) fn with_network_json_write<R>(f: impl FnOnce(&mut NetworkJson) -> R) -> R {
let mut writer = NETWORK_JSON.write();
f(&mut writer)
}
pub(crate) fn publish_shaped_devices(new_file: ConfigShapedDevices) {
let generation = NEXT_SHAPED_GENERATION.fetch_add(1, Ordering::Relaxed);
let shaped = Arc::new(new_file);
let cache = Arc::new(ShapedDeviceHashCache::from_devices(&shaped.devices));
SHAPED_STATE.store(Arc::new(PublishedShapedState {
shaped,
cache,
generation,
}));
}
pub(crate) fn swap_shaped_devices_snapshot(
new_snapshot: Arc<ConfigShapedDevices>,
) -> Arc<ConfigShapedDevices> {
let generation = NEXT_SHAPED_GENERATION.fetch_add(1, Ordering::Relaxed);
let cache = Arc::new(ShapedDeviceHashCache::from_devices(&new_snapshot.devices));
let new_state = Arc::new(PublishedShapedState {
shaped: new_snapshot,
cache,
generation,
});
let old = SHAPED_STATE.swap(new_state);
old.shaped.clone()
}
pub(crate) fn publish_network_json(new_file: NetworkJson) {
let mut writer = NETWORK_JSON.write();
*writer = new_file;
}
pub(crate) fn publish_dynamic_circuits_snapshot(new_snapshot: Vec<DynamicCircuit>) {
DYNAMIC_CIRCUITS.store(Arc::new(new_snapshot));
}
pub(crate) fn refresh_dynamic_circuits_last_seen_for_hashes(
seen_device_hashes: &HashSet<i64>,
seen_circuit_hashes: &HashSet<i64>,
now_unix: u64,
) -> bool {
let snapshot = dynamic_circuits_snapshot();
if snapshot.is_empty() {
return false;
}
let mut updated = snapshot.as_ref().clone();
let mut changed = false;
for circuit in updated.iter_mut() {
let is_seen = seen_device_hashes.contains(&circuit.shaped.device_hash)
|| seen_circuit_hashes.contains(&circuit.shaped.circuit_hash);
if is_seen && circuit.last_seen_unix != now_unix {
circuit.last_seen_unix = now_unix;
changed = true;
}
}
if changed {
publish_dynamic_circuits_snapshot(updated);
}
changed
}
+236
View File
@@ -0,0 +1,236 @@
use crate::{
DynamicCircuit, ShapedDevicesCatalog, resolve_parent_node_reference, with_network_json_write,
};
use lqos_config::{ConfigShapedDevices, NetworkJsonNode, ShapedDevice};
use lqos_utils::rtt::RttBuffer;
use lqos_utils::units::DownUpOrder;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use std::collections::HashSet;
use std::net::Ipv4Addr;
use std::sync::Arc;
static TEST_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
fn make_node(
name: &str,
id: Option<&str>,
active_attachment_name: Option<&str>,
) -> NetworkJsonNode {
NetworkJsonNode {
name: name.to_string(),
id: id.map(|value| value.to_string()),
virtual_node: false,
max_throughput: (0.0, 0.0),
current_throughput: DownUpOrder::zeroed(),
current_packets: DownUpOrder::zeroed(),
current_tcp_packets: DownUpOrder::zeroed(),
current_udp_packets: DownUpOrder::zeroed(),
current_icmp_packets: DownUpOrder::zeroed(),
current_tcp_retransmits: DownUpOrder::zeroed(),
current_tcp_retransmit_packets: DownUpOrder::zeroed(),
current_marks: DownUpOrder::zeroed(),
current_drops: DownUpOrder::zeroed(),
rtt_buffer: RttBuffer::default(),
parents: Vec::new(),
immediate_parent: None,
node_type: None,
latitude: None,
longitude: None,
active_attachment_name: active_attachment_name.map(|value| value.to_string()),
heatmap: None,
qoq_heatmap: None,
}
}
#[test]
fn catalog_device_by_hashes_prefers_device_hash() {
let _guard = TEST_LOCK.lock();
let a = ShapedDevice {
circuit_id: "circuit-a".into(),
device_id: "device-a".into(),
..Default::default()
};
let b = ShapedDevice {
circuit_id: "circuit-b".into(),
device_id: "device-b".into(),
..Default::default()
};
let mut shaped = ConfigShapedDevices::default();
shaped.replace_with_new_data(vec![a.clone(), b.clone()]);
let catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped));
let a_device_hash = lqos_utils::hash_to_i64(&a.device_id);
let b_circuit_hash = lqos_utils::hash_to_i64(&b.circuit_id);
let resolved = catalog
.device_by_hashes(Some(a_device_hash), Some(b_circuit_hash))
.expect("Expected shaped device match");
assert_eq!(resolved.device_id, a.device_id);
let fallback = catalog
.device_by_hashes(Some(999), Some(b_circuit_hash))
.expect("Expected circuit-hash fallback match");
assert_eq!(fallback.device_id, b.device_id);
}
#[test]
fn resolve_parent_node_reference_prefers_id_then_name_then_alias() {
let _guard = TEST_LOCK.lock();
with_network_json_write(|net_json| {
net_json.nodes = vec![
make_node("Root", Some("root"), None),
make_node("Site A", Some("node-a"), None),
make_node("Site B", Some("node-b"), Some("B-alias")),
];
});
let by_id = resolve_parent_node_reference("ignored", Some("node-a"))
.expect("Expected node id lookup to resolve");
assert_eq!(by_id.name, "Site A");
let by_name = resolve_parent_node_reference("Site B", None)
.expect("Expected node name lookup to resolve");
assert_eq!(by_name.id.as_deref(), Some("node-b"));
let by_alias = resolve_parent_node_reference("B-alias", None)
.expect("Expected active attachment alias to resolve");
assert_eq!(by_alias.name, "Site B");
}
#[test]
fn dynamic_circuit_last_seen_updates_for_seen_hashes() {
let _guard = TEST_LOCK.lock();
let original = crate::state::dynamic_circuits_snapshot();
let shaped = ShapedDevice {
circuit_id: "dynamic-circuit".into(),
device_id: "dynamic-device".into(),
circuit_hash: 10,
device_hash: 100,
..Default::default()
};
crate::state::publish_dynamic_circuits_snapshot(vec![DynamicCircuit {
shaped: shaped.clone(),
last_seen_unix: 0,
}]);
let mut seen_device_hashes: HashSet<i64> = HashSet::new();
seen_device_hashes.insert(shaped.device_hash);
let seen_circuit_hashes: HashSet<i64> = HashSet::new();
let now_unix = 1234;
let changed = crate::state::refresh_dynamic_circuits_last_seen_for_hashes(
&seen_device_hashes,
&seen_circuit_hashes,
now_unix,
);
assert!(changed);
let updated = crate::state::dynamic_circuits_snapshot();
assert_eq!(updated.len(), 1);
assert_eq!(updated[0].last_seen_unix, now_unix);
crate::state::publish_dynamic_circuits_snapshot(original.as_ref().clone());
}
#[test]
fn dynamic_circuit_expiration_helper_respects_ttl_boundary() {
let _guard = TEST_LOCK.lock();
let now_unix = 1_000;
let ttl_seconds = 300;
let alive = ShapedDevice {
circuit_id: "alive".into(),
device_id: "device-alive".into(),
..Default::default()
};
let expired = ShapedDevice {
circuit_id: "expired".into(),
device_id: "device-expired".into(),
..Default::default()
};
let circuits = vec![
DynamicCircuit {
shaped: alive,
// age == ttl => not expired
last_seen_unix: now_unix - ttl_seconds,
},
DynamicCircuit {
shaped: expired,
// age > ttl => expired
last_seen_unix: now_unix - ttl_seconds - 1,
},
];
let mut ids = crate::dynamic::expired_dynamic_circuit_ids(&circuits, now_unix, ttl_seconds);
ids.sort();
assert_eq!(ids, vec!["expired".to_string()]);
}
#[test]
fn dynamic_circuit_expiration_helper_expires_zero_last_seen() {
let _guard = TEST_LOCK.lock();
let now_unix = 1_000;
let ttl_seconds = 300;
let shaped = ShapedDevice {
circuit_id: "zero".into(),
device_id: "device-zero".into(),
..Default::default()
};
let circuits = vec![DynamicCircuit {
shaped,
last_seen_unix: 0,
}];
let ids = crate::dynamic::expired_dynamic_circuit_ids(&circuits, now_unix, ttl_seconds);
assert_eq!(ids, vec!["zero".to_string()]);
}
#[test]
fn dynamic_circuit_is_superseded_when_shaped_devices_now_cover_it() {
let _guard = TEST_LOCK.lock();
let mut static_device = ShapedDevice {
circuit_id: "static".into(),
device_id: "static-device".into(),
..Default::default()
};
static_device.ipv4.push((Ipv4Addr::new(192, 0, 2, 42), 32));
let mut shaped = ConfigShapedDevices::default();
shaped.replace_with_new_data(vec![static_device]);
let catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped));
let mut dynamic_shaped = ShapedDevice {
circuit_id: "[dyn] (test) 192.0.2.42".into(),
device_id: "[dyn] (test) 192.0.2.42".into(),
circuit_hash: 123,
device_hash: 456,
..Default::default()
};
dynamic_shaped.ipv4.push((Ipv4Addr::new(192, 0, 2, 42), 32));
let dyn_circuit = DynamicCircuit {
shaped: dynamic_shaped,
last_seen_unix: 0,
};
assert!(crate::dynamic::is_superseded_by_shaped_devices(
&dyn_circuit,
&catalog
));
}
@@ -0,0 +1,64 @@
use crate::state;
use lqos_config::NetworkJsonNode;
/// Canonical parent-node metadata resolved from `network.json`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolvedParentNode {
/// Canonical node name from `network.json`.
pub name: String,
/// Optional stable node identifier from `network.json` metadata.
pub id: Option<String>,
}
/// Resolve a shaped-device parent reference into canonical `network.json`
/// parent metadata, preferring a stable node ID when one is available.
pub fn resolve_parent_node_reference(
parent_node: &str,
parent_node_id: Option<&str>,
) -> Option<ResolvedParentNode> {
let trimmed_id = parent_node_id.map(str::trim).filter(|id| !id.is_empty());
let trimmed = parent_node.trim();
if trimmed.is_empty() && trimmed_id.is_none() {
return None;
}
state::with_network_json_read(|net_json| {
let nodes = net_json.get_nodes_when_ready();
if let Some(parent_node_id) = trimmed_id
&& let Some(node) = find_node_by_id(nodes, parent_node_id)
{
return Some(ResolvedParentNode {
name: node.name.clone(),
id: node.id.clone(),
});
}
if let Some(node) = nodes.iter().find(|node| node.name == trimmed) {
return Some(ResolvedParentNode {
name: node.name.clone(),
id: node.id.clone(),
});
}
nodes.iter().find_map(|node| {
node.active_attachment_name
.as_deref()
.filter(|alias| alias.trim() == trimmed)
.map(|_| ResolvedParentNode {
name: node.name.clone(),
id: node.id.clone(),
})
})
})
}
/// Resolve a shaped-device parent node or active attachment alias into canonical `network.json`
/// parent metadata.
pub fn resolve_parent_node(parent_node: &str) -> Option<ResolvedParentNode> {
resolve_parent_node_reference(parent_node, None)
}
fn find_node_by_id<'a>(nodes: &'a [NetworkJsonNode], id: &str) -> Option<&'a NetworkJsonNode> {
nodes.iter().find(|node| node.id.as_deref() == Some(id))
}
+2
View File
@@ -9,4 +9,6 @@ serde_json.workspace = true
anyhow.workspace = true
nix.workspace = true
lqos_config = { path = "../lqos_config" }
lqos_bus = { path = "../lqos_bus" }
clap = { workspace = true, features = ["derive"] }
tokio.workspace = true
+269 -209
View File
@@ -3,6 +3,7 @@ use std::net::{Ipv4Addr, Ipv6Addr};
use anyhow::{Result, anyhow};
use clap::{Args, Parser, Subcommand};
use lqos_bus::{BusRequest, BusResponse, LibreqosBusClient};
use lqos_config::ShapedDevice;
use lqos_overrides::{CircuitAdjustment, NetworkAdjustment, OverrideFile};
@@ -36,6 +37,11 @@ enum Commands {
#[command(subcommand)]
command: UispCommand,
},
/// Manage runtime-only dynamic circuit overlay entries (via lqosd bus)
DynamicCircuits {
#[command(subcommand)]
command: DynamicCircuitsCommand,
},
}
#[derive(Subcommand, Debug)]
@@ -148,6 +154,17 @@ enum UispCommand {
RouteList,
}
#[derive(Subcommand, Debug)]
enum DynamicCircuitsCommand {
/// Add or update a dynamic circuit overlay entry
Add(Box<AddArgs>),
/// Remove a dynamic circuit overlay entry by circuit ID
Remove {
#[arg(long)]
circuit_id: String,
},
}
#[derive(Args, Debug)]
struct AddArgs {
#[arg(long)]
@@ -318,222 +335,265 @@ struct AddSiteSpeedArgs {
fn main() -> Result<()> {
let cli = Cli::parse();
// Load overrides file at start
let mut overrides = OverrideFile::load()?;
fn send_bus_request(requests: Vec<BusRequest>) -> Result<Vec<BusResponse>> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
rt.block_on(async {
let mut bus = LibreqosBusClient::new().await?;
Ok(bus.request(requests).await?)
})
}
match cli.command {
Commands::PersistentDevices { command: cmd } => match cmd {
PersistentDevicesCommand::Add(args) => {
let device = args.into_device()?;
let changed = overrides.add_persistent_shaped_device_return_changed(device);
if changed {
overrides.save()?;
println!("Added device; overrides saved.");
} else {
println!("No changes (device already present).");
Commands::DynamicCircuits { command: cmd } => match cmd {
DynamicCircuitsCommand::Add(args) => {
let device = (*args).into_device()?;
let mut replies = send_bus_request(vec![BusRequest::CreateDynamicCircuit {
shaped_device: Box::new(device),
}])?;
match replies.pop() {
Some(BusResponse::Ack) => println!("Dynamic circuit created/updated."),
Some(BusResponse::Fail(message)) => return Err(anyhow!(message)),
Some(other) => println!("Unexpected bus response: {other:?}"),
None => println!("No bus response."),
}
}
PersistentDevicesCommand::DeleteCircuitId { circuit_id } => {
let removed =
overrides.remove_persistent_shaped_device_by_circuit_count(&circuit_id);
if removed > 0 {
overrides.save()?;
println!("Removed {removed} device(s) by circuit_id; overrides saved.");
} else {
println!("No devices matched circuit_id {circuit_id}.");
}
}
PersistentDevicesCommand::DeleteDeviceId { device_id } => {
let removed = overrides.remove_persistent_shaped_device_by_device_count(&device_id);
if removed > 0 {
overrides.save()?;
println!("Removed {removed} device(s) by device_id; overrides saved.");
} else {
println!("No devices matched device_id {device_id}.");
}
}
PersistentDevicesCommand::List => {
let list = overrides.persistent_devices();
println!("{}", serde_json::to_string_pretty(&list)?);
}
},
Commands::Adjustments { command: cmd } => match cmd {
AdjustmentsCommand::AddCircuitSpeed(args) => {
let adj = CircuitAdjustment::CircuitAdjustSpeed {
circuit_id: args.circuit_id,
min_download_bandwidth: args.min_download_bandwidth,
max_download_bandwidth: args.max_download_bandwidth,
min_upload_bandwidth: args.min_upload_bandwidth,
max_upload_bandwidth: args.max_upload_bandwidth,
};
overrides.add_circuit_adjustment(adj);
overrides.save()?;
println!("Added circuit speed adjustment; overrides saved.");
}
AdjustmentsCommand::AddDeviceSpeed(args) => {
let adj = CircuitAdjustment::DeviceAdjustSpeed {
device_id: args.device_id,
min_download_bandwidth: args.min_download_bandwidth,
max_download_bandwidth: args.max_download_bandwidth,
min_upload_bandwidth: args.min_upload_bandwidth,
max_upload_bandwidth: args.max_upload_bandwidth,
};
overrides.add_circuit_adjustment(adj);
overrides.save()?;
println!("Added device speed adjustment; overrides saved.");
}
AdjustmentsCommand::AddRemoveCircuit { circuit_id } => {
let adj = CircuitAdjustment::RemoveCircuit { circuit_id };
overrides.add_circuit_adjustment(adj);
overrides.save()?;
println!("Added remove-circuit adjustment; overrides saved.");
}
AdjustmentsCommand::AddRemoveDevice { device_id } => {
let adj = CircuitAdjustment::RemoveDevice { device_id };
overrides.add_circuit_adjustment(adj);
overrides.save()?;
println!("Added remove-device adjustment; overrides saved.");
}
AdjustmentsCommand::AddReparentCircuit {
circuit_id,
parent_node,
} => {
let adj = CircuitAdjustment::ReparentCircuit {
circuit_id,
parent_node,
};
overrides.add_circuit_adjustment(adj);
overrides.save()?;
println!("Added reparent-circuit adjustment; overrides saved.");
}
AdjustmentsCommand::DeleteIndex { index } => {
let ok = overrides.remove_circuit_adjustment_by_index(index);
if ok {
overrides.save()?;
println!("Removed adjustment at index {index}; overrides saved.");
} else {
println!("No adjustment at index {index}.");
}
}
AdjustmentsCommand::List => {
let list = overrides.circuit_adjustments();
println!("{}", serde_json::to_string_pretty(&list)?);
}
},
Commands::NetworkAdjustments { command: cmd } => match cmd {
NetworkAdjustmentsCommand::AddSiteSpeed(args) => {
let adj = NetworkAdjustment::AdjustSiteSpeed {
node_id: args.node_id,
site_name: args.site_name,
download_bandwidth_mbps: args.download_bandwidth_mbps,
upload_bandwidth_mbps: args.upload_bandwidth_mbps,
};
overrides.add_network_adjustment(adj);
overrides.save()?;
println!("Added site speed adjustment; overrides saved.");
}
NetworkAdjustmentsCommand::SetVirtual {
node_name,
virtual_node,
} => {
overrides.set_network_node_virtual(node_name, virtual_node);
overrides.save()?;
println!("Set node virtual flag; overrides saved.");
}
NetworkAdjustmentsCommand::DeleteVirtual { node_name } => {
let removed = overrides.remove_network_node_virtual_by_name_count(&node_name);
if removed > 0 {
overrides.save()?;
println!(
"Removed {removed} virtual override(s) for node '{node_name}'; overrides saved."
);
} else {
println!("No virtual override found for node '{node_name}'.");
}
}
NetworkAdjustmentsCommand::DeleteIndex { index } => {
let ok = overrides.remove_network_adjustment_by_index(index);
if ok {
overrides.save()?;
println!("Removed network adjustment at index {index}; overrides saved.");
} else {
println!("No network adjustment at index {index}.");
}
}
NetworkAdjustmentsCommand::List => {
let list = overrides.network_adjustments();
println!("{}", serde_json::to_string_pretty(&list)?);
}
},
Commands::Uisp { command: cmd } => match cmd {
UispCommand::BandwidthSet {
site_name,
down,
up,
} => {
overrides.set_uisp_bandwidth_override(site_name, down, up);
overrides.save()?;
println!(
"Added deprecated UISP bandwidth override entry; overrides saved. Current UISP builds ignore these entries and use AdjustSiteSpeed overrides instead."
);
}
UispCommand::BandwidthRemove { site_name } => {
let removed = overrides.remove_uisp_bandwidth_override(&site_name);
if removed {
overrides.save()?;
println!(
"Removed deprecated UISP bandwidth override for {site_name}; overrides saved."
);
} else {
println!("No UISP bandwidth override found for {site_name}.");
}
}
UispCommand::BandwidthList => {
println!(
"Deprecated UISP bandwidth override entries. Current UISP builds ignore these entries and use AdjustSiteSpeed overrides instead."
);
if let Some(uisp) = overrides.uisp() {
println!(
"{}",
serde_json::to_string_pretty(&uisp.bandwidth_overrides)?
);
} else {
println!("{{}}");
}
}
UispCommand::RouteAdd {
from_site,
to_site,
cost,
} => {
overrides.add_uisp_route_override(from_site, to_site, cost);
overrides.save()?;
println!(
"Added deprecated UISP route override entry; overrides saved. Current UISP builds ignore these overrides."
);
}
UispCommand::RouteRemoveIndex { index } => {
let removed = overrides.remove_uisp_route_by_index(index);
if removed {
overrides.save()?;
println!(
"Removed deprecated UISP route override at index {index}; overrides saved."
);
} else {
println!("No UISP route override at index {index}.");
}
}
UispCommand::RouteList => {
println!(
"Deprecated UISP route override entries. Current UISP builds ignore these overrides."
);
if let Some(uisp) = overrides.uisp() {
println!("{}", serde_json::to_string_pretty(&uisp.route_overrides)?);
} else {
println!("[]");
DynamicCircuitsCommand::Remove { circuit_id } => {
let mut replies =
send_bus_request(vec![BusRequest::RemoveDynamicCircuit { circuit_id }])?;
match replies.pop() {
Some(BusResponse::Ack) => println!("Dynamic circuit removed."),
Some(BusResponse::Fail(message)) => return Err(anyhow!(message)),
Some(other) => println!("Unexpected bus response: {other:?}"),
None => println!("No bus response."),
}
}
},
command => {
// All other commands operate on the overrides file.
let mut overrides = OverrideFile::load()?;
match command {
Commands::PersistentDevices { command: cmd } => match cmd {
PersistentDevicesCommand::Add(args) => {
let device = args.into_device()?;
let changed = overrides.add_persistent_shaped_device_return_changed(device);
if changed {
overrides.save()?;
println!("Added device; overrides saved.");
} else {
println!("No changes (device already present).");
}
}
PersistentDevicesCommand::DeleteCircuitId { circuit_id } => {
let removed =
overrides.remove_persistent_shaped_device_by_circuit_count(&circuit_id);
if removed > 0 {
overrides.save()?;
println!("Removed {removed} device(s) by circuit_id; overrides saved.");
} else {
println!("No devices matched circuit_id {circuit_id}.");
}
}
PersistentDevicesCommand::DeleteDeviceId { device_id } => {
let removed =
overrides.remove_persistent_shaped_device_by_device_count(&device_id);
if removed > 0 {
overrides.save()?;
println!("Removed {removed} device(s) by device_id; overrides saved.");
} else {
println!("No devices matched device_id {device_id}.");
}
}
PersistentDevicesCommand::List => {
let list = overrides.persistent_devices();
println!("{}", serde_json::to_string_pretty(&list)?);
}
},
Commands::Adjustments { command: cmd } => match cmd {
AdjustmentsCommand::AddCircuitSpeed(args) => {
let adj = CircuitAdjustment::CircuitAdjustSpeed {
circuit_id: args.circuit_id,
min_download_bandwidth: args.min_download_bandwidth,
max_download_bandwidth: args.max_download_bandwidth,
min_upload_bandwidth: args.min_upload_bandwidth,
max_upload_bandwidth: args.max_upload_bandwidth,
};
overrides.add_circuit_adjustment(adj);
overrides.save()?;
println!("Added circuit speed adjustment; overrides saved.");
}
AdjustmentsCommand::AddDeviceSpeed(args) => {
let adj = CircuitAdjustment::DeviceAdjustSpeed {
device_id: args.device_id,
min_download_bandwidth: args.min_download_bandwidth,
max_download_bandwidth: args.max_download_bandwidth,
min_upload_bandwidth: args.min_upload_bandwidth,
max_upload_bandwidth: args.max_upload_bandwidth,
};
overrides.add_circuit_adjustment(adj);
overrides.save()?;
println!("Added device speed adjustment; overrides saved.");
}
AdjustmentsCommand::AddRemoveCircuit { circuit_id } => {
let adj = CircuitAdjustment::RemoveCircuit { circuit_id };
overrides.add_circuit_adjustment(adj);
overrides.save()?;
println!("Added remove-circuit adjustment; overrides saved.");
}
AdjustmentsCommand::AddRemoveDevice { device_id } => {
let adj = CircuitAdjustment::RemoveDevice { device_id };
overrides.add_circuit_adjustment(adj);
overrides.save()?;
println!("Added remove-device adjustment; overrides saved.");
}
AdjustmentsCommand::AddReparentCircuit {
circuit_id,
parent_node,
} => {
let adj = CircuitAdjustment::ReparentCircuit {
circuit_id,
parent_node,
};
overrides.add_circuit_adjustment(adj);
overrides.save()?;
println!("Added reparent-circuit adjustment; overrides saved.");
}
AdjustmentsCommand::DeleteIndex { index } => {
let ok = overrides.remove_circuit_adjustment_by_index(index);
if ok {
overrides.save()?;
println!("Removed adjustment at index {index}; overrides saved.");
} else {
println!("No adjustment at index {index}.");
}
}
AdjustmentsCommand::List => {
let list = overrides.circuit_adjustments();
println!("{}", serde_json::to_string_pretty(&list)?);
}
},
Commands::NetworkAdjustments { command: cmd } => match cmd {
NetworkAdjustmentsCommand::AddSiteSpeed(args) => {
let adj = NetworkAdjustment::AdjustSiteSpeed {
node_id: args.node_id,
site_name: args.site_name,
download_bandwidth_mbps: args.download_bandwidth_mbps,
upload_bandwidth_mbps: args.upload_bandwidth_mbps,
};
overrides.add_network_adjustment(adj);
overrides.save()?;
println!("Added site speed adjustment; overrides saved.");
}
NetworkAdjustmentsCommand::SetVirtual {
node_name,
virtual_node,
} => {
overrides.set_network_node_virtual(node_name, virtual_node);
overrides.save()?;
println!("Set node virtual flag; overrides saved.");
}
NetworkAdjustmentsCommand::DeleteVirtual { node_name } => {
let removed =
overrides.remove_network_node_virtual_by_name_count(&node_name);
if removed > 0 {
overrides.save()?;
println!(
"Removed {removed} virtual override(s) for node '{node_name}'; overrides saved."
);
} else {
println!("No virtual override found for node '{node_name}'.");
}
}
NetworkAdjustmentsCommand::DeleteIndex { index } => {
let ok = overrides.remove_network_adjustment_by_index(index);
if ok {
overrides.save()?;
println!(
"Removed network adjustment at index {index}; overrides saved."
);
} else {
println!("No network adjustment at index {index}.");
}
}
NetworkAdjustmentsCommand::List => {
let list = overrides.network_adjustments();
println!("{}", serde_json::to_string_pretty(&list)?);
}
},
Commands::Uisp { command: cmd } => match cmd {
UispCommand::BandwidthSet {
site_name,
down,
up,
} => {
overrides.set_uisp_bandwidth_override(site_name, down, up);
overrides.save()?;
println!(
"Added deprecated UISP bandwidth override entry; overrides saved. Current UISP builds ignore these entries and use AdjustSiteSpeed overrides instead."
);
}
UispCommand::BandwidthRemove { site_name } => {
let removed = overrides.remove_uisp_bandwidth_override(&site_name);
if removed {
overrides.save()?;
println!(
"Removed deprecated UISP bandwidth override for {site_name}; overrides saved."
);
} else {
println!("No UISP bandwidth override found for {site_name}.");
}
}
UispCommand::BandwidthList => {
println!(
"Deprecated UISP bandwidth override entries. Current UISP builds ignore these entries and use AdjustSiteSpeed overrides instead."
);
if let Some(uisp) = overrides.uisp() {
println!(
"{}",
serde_json::to_string_pretty(&uisp.bandwidth_overrides)?
);
} else {
println!("{{}}");
}
}
UispCommand::RouteAdd {
from_site,
to_site,
cost,
} => {
overrides.add_uisp_route_override(from_site, to_site, cost);
overrides.save()?;
println!(
"Added deprecated UISP route override entry; overrides saved. Current UISP builds ignore these overrides."
);
}
UispCommand::RouteRemoveIndex { index } => {
let removed = overrides.remove_uisp_route_by_index(index);
if removed {
overrides.save()?;
println!(
"Removed deprecated UISP route override at index {index}; overrides saved."
);
} else {
println!("No UISP route override at index {index}.");
}
}
UispCommand::RouteList => {
println!(
"Deprecated UISP route override entries. Current UISP builds ignore these overrides."
);
if let Some(uisp) = overrides.uisp() {
println!("{}", serde_json::to_string_pretty(&uisp.route_overrides)?);
} else {
println!("[]");
}
}
},
Commands::DynamicCircuits { .. } => unreachable!("dynamic circuits handled above"),
}
}
}
Ok(())
+1
View File
@@ -15,6 +15,7 @@ lqos_bus = { path = "../lqos_bus" }
lqos_sys = { path = "../lqos_sys" }
lqos_utils = { path = "../lqos_utils" }
lqos_config = { path = "../lqos_config" }
lqos_network_devices = { path = "../lqos_network_devices" }
lqos_overrides = { path = "../lqos_overrides" }
lqos_topology_compile = { path = "../lqos_topology_compile" }
tokio = { workspace = true }
+4 -4
View File
@@ -13,7 +13,7 @@
use anyhow::Result;
use lqos_bus::{BusRequest, BusResponse};
use lqos_config::{ConfigShapedDevices, ShapedDevice, load_config};
use lqos_config::{ShapedDevice, load_config};
use pyo3::pyclass;
use serde::{Deserialize, Serialize};
use std::time::Duration;
@@ -84,7 +84,7 @@ fn get_weights_from_lts(
/// This function is used to get the device weights from the ShapedDevices.csv file
fn get_weights_from_shaped_devices() -> Result<Vec<DeviceWeightResponse>> {
let mut devices_list = ConfigShapedDevices::load()?.devices;
let mut devices_list = lqos_network_devices::load_shaped_devices()?.devices;
devices_list.sort_by(|a, b| a.circuit_id.cmp(&b.circuit_id));
let mut result = vec![];
let mut prev_id = String::new();
@@ -211,9 +211,9 @@ pub struct NetworkNodeWeight {
/// Calculate the top-level network tree nodes and then
/// calculate the weights for each node
pub(crate) fn calculate_tree_weights() -> Result<Vec<NetworkNodeWeight>> {
let device_list = ConfigShapedDevices::load()?.devices;
let device_list = lqos_network_devices::load_shaped_devices()?.devices;
let device_weights = get_weights_rust()?;
let network = lqos_config::NetworkJson::load()?;
let network = lqos_network_devices::load_network_json()?;
let root_index = network
.get_nodes_when_ready()
.iter()
+1 -1
View File
@@ -2820,7 +2820,7 @@ pub fn get_libreqos_state_directory() -> PyResult<String> {
#[pyfunction]
/// Returns `true` when the loaded network graph contains only a single root node.
pub fn is_network_flat() -> PyResult<bool> {
Ok(lqos_config::NetworkJson::load()
Ok(lqos_network_devices::load_network_json()
.unwrap()
.get_nodes_when_ready()
.len()
@@ -4,7 +4,7 @@ use lqos_bus::TcHandle;
use lqos_sys::num_possible_cpus;
use lqos_utils::unix_time::unix_now;
use once_cell::sync::Lazy;
use tracing::{info, warn};
use tracing::{debug, info, warn};
pub(crate) static WATCHED_QUEUES: Lazy<DashMap<String, WatchedQueue>> = Lazy::new(DashMap::new);
@@ -74,7 +74,7 @@ pub fn add_watched_queue(circuit_id: &str) {
WATCHED_QUEUES.insert(circuit_id.to_string(), new_watch);
//info!("Added {circuit_id} to watched queues. Now watching {} queues.", WATCHED_QUEUES.len());
} else {
warn!("No circuit ID of {circuit_id}");
debug!("Circuit queue not found for watch request: {circuit_id}");
}
} else {
warn!("Unable to access watched queue list. Try again later.");
+1
View File
@@ -11,6 +11,7 @@ lqos_bus = { path = "../lqos_bus" }
tokio.workspace = true
lqos_queue_tracker = { path = "../lqos_queue_tracker" }
lqos_utils = { path = "../lqos_utils" }
lqos_network_devices = { path = "../lqos_network_devices" }
lqos_bakery = { path = "../lqos_bakery" }
lqos_overrides = { path = "../lqos_overrides" }
lqos_probe = { path = "../lqos_probe" }
@@ -1,6 +1,6 @@
use anyhow::{Result, anyhow};
use lqos_bakery::BakeryCommands;
use lqos_config::{ConfigShapedDevices, ShapedDevice};
use lqos_config::ShapedDevice;
use lqos_overrides::{OverrideFile, OverrideLayer, OverrideStore};
use lqos_queue_tracker::QUEUE_STRUCTURE;
use lqos_utils::hash_to_i64;
@@ -136,7 +136,7 @@ pub fn clear_circuit_fallback(
pub fn load_persisted_circuit_fallbacks() -> Result<HashMap<String, PersistedCircuitFallback>> {
let overrides = OverrideStore::load_layer(OverrideLayer::Stormguard)?;
let current_devices = ConfigShapedDevices::load()?.devices;
let current_devices = lqos_network_devices::load_shaped_devices()?.devices;
Ok(group_circuit_fallbacks(&overrides, &current_devices))
}
@@ -159,7 +159,7 @@ fn current_site_override(
}
fn load_devices_for_circuit(circuit_id: &str) -> Result<Vec<ShapedDevice>> {
let shaped_devices = ConfigShapedDevices::load()?;
let shaped_devices = lqos_network_devices::load_shaped_devices()?;
Ok(shaped_devices
.devices
.into_iter()
+1
View File
@@ -8,6 +8,7 @@ anyhow = { workspace = true }
clap = { workspace = true }
colored = { workspace = true }
lqos_config = { path = "../lqos_config" }
lqos_network_devices = { path = "../lqos_network_devices" }
serde = { workspace = true }
serde_cbor = { workspace = true }
miniz_oxide = { workspace = true }
@@ -47,7 +47,7 @@ pub fn can_we_load_net_json(results: &mut Vec<SanityCheck>) {
}
pub fn can_we_parse_net_json(results: &mut Vec<SanityCheck>) {
match lqos_config::NetworkJson::load() {
match lqos_network_devices::load_network_json() {
Ok(_json) => {
results.push(SanityCheck {
name: "network.json is valid JSON".to_string(),
@@ -19,7 +19,7 @@ pub fn shaped_devices_exists(results: &mut Vec<SanityCheck>) {
}
pub fn can_we_read_shaped_devices(results: &mut Vec<SanityCheck>) {
match lqos_config::ConfigShapedDevices::load() {
match lqos_network_devices::load_shaped_devices() {
Ok(sd) => {
results.push(SanityCheck {
name: "ShapedDevices.csv Loads?".to_string(),
@@ -38,7 +38,7 @@ pub fn can_we_read_shaped_devices(results: &mut Vec<SanityCheck>) {
}
pub fn parent_check(results: &mut Vec<SanityCheck>) {
if let Ok(net_json) = lqos_config::NetworkJson::load() {
if let Ok(net_json) = lqos_network_devices::load_network_json() {
if net_json.get_nodes_when_ready().len() < 2 {
results.push(SanityCheck {
name: "Flat Network - Skipping Parent Check".to_string(),
@@ -48,7 +48,7 @@ pub fn parent_check(results: &mut Vec<SanityCheck>) {
return;
}
if let Ok(shaped_devices) = lqos_config::ConfigShapedDevices::load() {
if let Ok(shaped_devices) = lqos_network_devices::load_shaped_devices() {
for sd in shaped_devices.devices.iter() {
if !net_json
.get_nodes_when_ready()
@@ -0,0 +1,135 @@
use notify::{Config, Event, RecursiveMode, Watcher};
use std::{
collections::BTreeSet,
path::PathBuf,
time::{Duration, Instant},
};
use thiserror::Error;
use tracing::{debug, error};
const DIRECTORY_WATCH_DEBOUNCE_MILLIS: u64 = 250;
/// Watches one directory and returns the coalesced changed paths for each batch
/// of file-system notifications.
pub struct DirectoryWatcher {
nice_name: String,
path: PathBuf,
debounce_window: Duration,
rx: Option<crossbeam_channel::Receiver<Result<Event, notify::Error>>>,
watcher: Option<notify::RecommendedWatcher>,
}
impl DirectoryWatcher {
/// Creates a new directory watcher with the default debounce window.
pub fn new<S: ToString>(nice_name: S, path: PathBuf) -> Self {
Self {
nice_name: nice_name.to_string(),
path,
debounce_window: Duration::from_millis(DIRECTORY_WATCH_DEBOUNCE_MILLIS),
rx: None,
watcher: None,
}
}
/// Waits for the next batch of changed paths in the watched directory.
pub fn watch(&mut self) -> Result<Vec<PathBuf>, WatchedDirectoryError> {
if !self.path.is_dir() {
return Err(WatchedDirectoryError::NotADirectory(self.path.clone()));
}
self.ensure_watcher_started()?;
let Some(rx) = &self.rx else {
return Err(WatchedDirectoryError::WatchReceiveError);
};
loop {
let first_event = rx
.recv()
.map_err(|_| WatchedDirectoryError::WatchReceiveError)?;
let mut changed_paths = BTreeSet::new();
collect_paths(first_event, &mut changed_paths);
let mut deadline = Instant::now() + self.debounce_window;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match rx.recv_timeout(remaining) {
Ok(event) => {
collect_paths(event, &mut changed_paths);
deadline = Instant::now() + self.debounce_window;
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => break,
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
return Err(WatchedDirectoryError::WatchReceiveError);
}
}
}
if !changed_paths.is_empty() {
debug!("{} changed: {:?}", self.nice_name, changed_paths);
return Ok(changed_paths.into_iter().collect());
}
}
}
fn ensure_watcher_started(&mut self) -> Result<(), WatchedDirectoryError> {
if self.watcher.is_some() {
return Ok(());
}
let (tx, rx) = crossbeam_channel::bounded(64);
let maybe_watcher = notify::RecommendedWatcher::new(tx, Config::default());
let Ok(mut watcher) = maybe_watcher else {
if let Err(err) = maybe_watcher {
error!("Unable to create watcher for {}", self.nice_name);
error!("{err:?}");
}
return Err(WatchedDirectoryError::CreateWatcherError);
};
watcher
.watch(&self.path, RecursiveMode::NonRecursive)
.map_err(|_| WatchedDirectoryError::StartWatcherError)?;
self.rx = Some(rx);
self.watcher = Some(watcher);
Ok(())
}
}
fn collect_paths(
event: Result<Event, notify::Error>,
changed_paths: &mut BTreeSet<PathBuf>,
) {
match event {
Ok(event) => {
changed_paths.extend(event.paths);
}
Err(err) => {
error!("Directory watcher event error: {err:?}");
}
}
}
/// Errors that can occur while watching a directory.
#[derive(Debug, Error)]
pub enum WatchedDirectoryError {
/// The requested watch path does not exist as a directory.
#[error("Watch path is not a directory: {0}")]
NotADirectory(PathBuf),
/// Unable to create the underlying notify watcher.
#[error("Unable to create watcher")]
CreateWatcherError,
/// Unable to start the underlying notify watcher.
#[error("Unable to start watcher")]
StartWatcherError,
/// The watcher channel stopped delivering events.
#[error("Unable to receive watcher events")]
WatchReceiveError,
}
+3
View File
@@ -10,6 +10,9 @@ pub mod fdtimer;
/// Wrapper for watching when a file has changed.
pub mod file_watcher;
/// Watches a directory and coalesces filesystem events.
pub mod directory_watcher;
/// Utilities for handling strings in hex format
pub mod hex_string;
+1
View File
@@ -12,6 +12,7 @@ flamegraphs = []
[dependencies]
anyhow = { workspace = true }
lqos_config = { path = "../lqos_config" }
lqos_network_devices = { path = "../lqos_network_devices" }
lqos_sys = { path = "../lqos_sys" }
lqos_queue_tracker = { path = "../lqos_queue_tracker" }
lqos_utils = { path = "../lqos_utils" }
+102
View File
@@ -0,0 +1,102 @@
use lqos_bus::BusResponse;
use lqos_config::ShapedDevice;
use std::sync::mpsc;
use std::time::Duration;
use tracing::{error, warn};
fn dynamic_circuits_enabled() -> bool {
let Ok(config) = lqos_config::load_config() else {
return false;
};
config
.dynamic_circuits
.as_ref()
.is_some_and(|cfg| cfg.enabled)
}
pub(crate) fn create_dynamic_circuit(shaped_device: ShapedDevice) -> BusResponse {
if !dynamic_circuits_enabled() {
return BusResponse::Ack;
}
if shaped_device.circuit_id.trim().is_empty() {
return BusResponse::Fail("dynamic circuit requires circuit_id".to_string());
}
if shaped_device.device_id.trim().is_empty() {
return BusResponse::Fail("dynamic circuit requires device_id".to_string());
}
if shaped_device.parent_node.trim().is_empty() {
return BusResponse::Fail("dynamic circuit requires parent_node".to_string());
}
if let Err(err) = lqos_network_devices::upsert_dynamic_circuit(shaped_device.clone()) {
error!("Dynamic circuit persist failed: {err:?}");
return BusResponse::Fail(format!("dynamic circuit persist failed: {err}"));
}
if let Err(err) = upsert_bakery_overlay(shaped_device.clone()) {
warn!("Dynamic circuit bakery apply failed: {err}");
// Best-effort rollback: keep network_devices and bakery in sync.
let _ = lqos_network_devices::remove_dynamic_circuit(&shaped_device.circuit_id);
return BusResponse::Fail(err);
}
BusResponse::Ack
}
pub(crate) fn remove_dynamic_circuit(circuit_id: &str) -> BusResponse {
if !dynamic_circuits_enabled() {
return BusResponse::Ack;
}
if circuit_id.trim().is_empty() {
return BusResponse::Fail("dynamic circuit removal requires circuit_id".to_string());
}
if let Err(err) = remove_bakery_overlay(circuit_id) {
warn!("Dynamic circuit bakery removal failed: {err}");
return BusResponse::Fail(err);
}
if let Err(err) = lqos_network_devices::remove_dynamic_circuit(circuit_id) {
error!("Dynamic circuit removal persist failed: {err:?}");
return BusResponse::Fail(format!("dynamic circuit removal persist failed: {err}"));
}
BusResponse::Ack
}
fn upsert_bakery_overlay(shaped_device: ShapedDevice) -> Result<(), String> {
let Some(sender) = lqos_bakery::BAKERY_SENDER.get() else {
return Err("Bakery not initialized".to_string());
};
let (tx, rx) = mpsc::channel::<Result<Option<lqos_bus::TcHandle>, String>>();
sender
.send(lqos_bakery::BakeryCommands::UpsertDynamicCircuitOverlay {
shaped_device: Box::new(shaped_device),
reply: Some(tx),
})
.map_err(|e| format!("send to bakery failed: {e}"))?;
rx.recv_timeout(Duration::from_secs(10))
.map_err(|e| format!("bakery reply timeout: {e}"))?
.map(|_| ())
}
fn remove_bakery_overlay(circuit_id: &str) -> Result<(), String> {
let Some(sender) = lqos_bakery::BAKERY_SENDER.get() else {
return Err("Bakery not initialized".to_string());
};
let (tx, rx) = mpsc::channel::<Result<(), String>>();
sender
.send(lqos_bakery::BakeryCommands::RemoveDynamicCircuitOverlay {
circuit_id: circuit_id.to_string(),
reply: Some(tx),
})
.map_err(|e| format!("send to bakery failed: {e}"))?;
rx.recv_timeout(Duration::from_secs(10))
.map_err(|e| format!("bakery reply timeout: {e}"))?
}
+71 -83
View File
@@ -1464,17 +1464,16 @@ async fn circuit_snapshot_streaming(
}
}
// Load "shaped devices" snapshot and pick devices in the target circuit
let shaped = crate::shaped_devices_tracker::SHAPED_DEVICES.load();
let shaped_cache = crate::shaped_devices_tracker::SHAPED_DEVICE_HASH_CACHE.load();
let mut device_indexes: Vec<usize> = Vec::new();
for (idx, dev) in shaped.devices.iter().enumerate() {
if dev.circuit_hash == circuit_hash {
device_indexes.push(idx);
}
}
// Load network devices catalog and pick devices in the target circuit.
// This includes dynamic circuits so circuit streaming works consistently for runtime overlays.
let catalog = lqos_network_devices::network_devices_catalog();
let circuit_devices = catalog
.iter_all_devices()
.filter(|dev| dev.circuit_hash == circuit_hash)
.cloned()
.collect::<Vec<_>>();
// Aggregation holders per device index
// Aggregation holders per device hash
use lqos_utils::units::{DownUpOrder, down_up_divide};
struct Agg {
bps_bytes: DownUpOrder<u64>,
@@ -1482,18 +1481,7 @@ async fn circuit_snapshot_streaming(
tcp_retries: DownUpOrder<u64>,
rtts: Vec<f32>,
}
let mut aggregates: std::collections::HashMap<usize, Agg> = std::collections::HashMap::new();
for idx in &device_indexes {
aggregates.insert(
*idx,
Agg {
bps_bytes: DownUpOrder::zeroed(),
tcp_packets: DownUpOrder::zeroed(),
tcp_retries: DownUpOrder::zeroed(),
rtts: Vec::new(),
},
);
}
let mut aggregates: std::collections::HashMap<i64, Agg> = std::collections::HashMap::new();
// Walk raw throughput data and fold into devices of this circuit
{
@@ -1501,34 +1489,32 @@ async fn circuit_snapshot_streaming(
.raw_data
.lock();
for (xdp_ip, te) in raw.iter() {
let device = crate::shaped_devices_tracker::shaped_device_from_hashes_or_ip(
&shaped,
&shaped_cache,
xdp_ip,
te.device_hash,
te.circuit_hash,
);
let matches_desired = te.circuit_hash == Some(circuit_hash)
|| device.is_some_and(|device| device.circuit_hash == circuit_hash);
let device = catalog
.device_by_hashes(te.device_hash, te.circuit_hash)
.or_else(|| catalog.device_longest_match_for_ip(xdp_ip).map(|(_, device)| device));
let matches_desired =
te.circuit_hash == Some(circuit_hash)
|| device.is_some_and(|device| device.circuit_hash == circuit_hash);
if !matches_desired {
continue;
}
// retire_check is local; use the same heuristic: require most_recent_cycle >= tp_cycle - RETIRE_AFTER_SECONDS
// We don't have RETIRE_AFTER_SECONDS here; accept all entries for snapshot.
if let Some(id) = device.and_then(|device| {
shaped
.devices
.iter()
.position(|candidate| std::ptr::eq(candidate, device))
}) && let Some(agg) = aggregates.get_mut(&id)
{
// bytes_per_second -> later convert to bits
agg.bps_bytes += te.bytes_per_second;
agg.tcp_packets += te.tcp_packets;
agg.tcp_retries += te.tcp_retransmits;
if let Some(rtt) = te.median_latency() {
agg.rtts.push(rtt);
}
let Some(device_hash) = device.map(|device| device.device_hash) else {
continue;
};
let agg = aggregates.entry(device_hash).or_insert_with(|| Agg {
bps_bytes: DownUpOrder::zeroed(),
tcp_packets: DownUpOrder::zeroed(),
tcp_retries: DownUpOrder::zeroed(),
rtts: Vec::new(),
});
// bytes_per_second -> later convert to bits
agg.bps_bytes += te.bytes_per_second;
agg.tcp_packets += te.tcp_packets;
agg.tcp_retries += te.tcp_retransmits;
if let Some(rtt) = te.median_latency() {
agg.rtts.push(rtt);
}
}
// raw lock dropped here
@@ -1536,9 +1522,8 @@ async fn circuit_snapshot_streaming(
// Build device snapshots
let mut devices_out: Vec<DeviceSnapshot> = Vec::new();
for idx in device_indexes {
let dev = &shaped.devices[idx];
let agg = aggregates.remove(&idx).unwrap_or(Agg {
for dev in circuit_devices {
let agg = aggregates.remove(&dev.device_hash).unwrap_or(Agg {
bps_bytes: DownUpOrder::zeroed(),
tcp_packets: DownUpOrder::zeroed(),
tcp_retries: DownUpOrder::zeroed(),
@@ -1607,15 +1592,16 @@ async fn circuit_snapshot_streaming(
if local.last_seen < five_minutes_ago {
continue;
}
let device = crate::shaped_devices_tracker::shaped_device_from_hashes_or_ip(
&shaped,
&shaped_cache,
&key.local_ip,
local.device_hash,
local.circuit_hash,
);
let matches_desired = local.circuit_hash == Some(circuit_hash)
|| device.is_some_and(|device| device.circuit_hash == circuit_hash);
let device = catalog
.device_by_hashes(local.device_hash, local.circuit_hash)
.or_else(|| {
catalog
.device_longest_match_for_ip(&key.local_ip)
.map(|(_, device)| device)
});
let matches_desired =
local.circuit_hash == Some(circuit_hash)
|| device.is_some_and(|device| device.circuit_hash == circuit_hash);
if !matches_desired {
continue;
}
@@ -1696,32 +1682,34 @@ async fn tree_snapshot_streaming(
}
// Use the same data source as local_api::network_tree
let net_json = crate::shaped_devices_tracker::NETWORK_JSON.read();
let result: Vec<(usize, LiveNetworkTransport)> = net_json
.get_nodes_when_ready()
.iter()
.enumerate()
.map(|(i, n)| {
let t = n.clone_to_transit();
let mapped = LiveNetworkTransport {
name: t.name,
max_throughput: t.max_throughput,
current_throughput: t.current_throughput,
current_packets: t.current_packets,
current_tcp_packets: t.current_tcp_packets,
current_udp_packets: t.current_udp_packets,
current_icmp_packets: t.current_icmp_packets,
current_retransmits: t.current_retransmits,
current_marks: t.current_marks,
current_drops: t.current_drops,
rtts: t.rtts,
parents: t.parents,
immediate_parent: t.immediate_parent,
node_type: t.node_type,
};
(i, mapped)
})
.collect();
let result: Vec<(usize, LiveNetworkTransport)> =
lqos_network_devices::with_network_json_read(|net_json| {
net_json
.get_nodes_when_ready()
.iter()
.enumerate()
.map(|(i, n)| {
let t = n.clone_to_transit();
let mapped = LiveNetworkTransport {
name: t.name,
max_throughput: t.max_throughput,
current_throughput: t.current_throughput,
current_packets: t.current_packets,
current_tcp_packets: t.current_tcp_packets,
current_udp_packets: t.current_udp_packets,
current_icmp_packets: t.current_icmp_packets,
current_retransmits: t.current_retransmits,
current_marks: t.current_marks,
current_drops: t.current_drops,
rtts: t.rtts,
parents: t.parents,
immediate_parent: t.immediate_parent,
node_type: t.node_type,
};
(i, mapped)
})
.collect()
});
let Ok(bytes) = serde_cbor::to_vec(&result) else {
error!("Failed to serialize LiveNetworkTransport payload");
+73 -64
View File
@@ -4,11 +4,13 @@
#![deny(clippy::unwrap_used)]
mod blackboard;
mod dynamic_circuits;
mod file_lock;
mod ip_mapping;
#[cfg(feature = "equinix_tests")]
mod lqos_daht_test;
pub mod lts2_sys;
mod network_devices_hooks;
mod node_manager;
mod preflight_checks;
mod probe_provider;
@@ -230,6 +232,9 @@ fn main() -> Result<()> {
} else {
info!("Insight client started successfully");
}
lqos_network_devices::start_daemon_mode(Some(std::sync::Arc::new(
network_devices_hooks::LqosdNetworkDevicesHooks,
)))?;
let system_usage_tx = system_stats::start_system_stats()?;
// Handle signals
@@ -251,6 +256,7 @@ fn main() -> Result<()> {
.spawn(move || {
let Ok(tokio_runtime) = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_stack_size(8 * 1024 * 1024)
.build()
else {
error!("Unable to start Tokio runtime. Not much is going to work");
@@ -375,8 +381,6 @@ fn main() -> Result<()> {
let _ = throughput_tracker::flow_data::setup_flow_analysis();
start_heimdall()?;
spawn_queue_structure_monitor()?;
shaped_devices_tracker::shaped_devices_watcher()?;
shaped_devices_tracker::network_json_watcher()?;
shaped_devices_tracker::shaping_inputs_watcher()?;
throughput_tracker::spawn_throughput_monitor(
flow_tx,
@@ -493,7 +497,7 @@ fn memory_debug() {
// fb.visit_root(&*THROUGHPUT_TRACKER);
// fb.visit_root(&*ALL_FLOWS);
// fb.visit_root(&*RECENT_FLOWS);
//fb.visit_root(&*NETWORK_JSON);
// lqos_network_devices::with_network_json_read(|net_json| fb.visit_root(net_json));
let flamegraph_src = fb.finish();
let flamegraph_src = flamegraph_src.flamegraph();
let Ok(mut file) = std::fs::File::create("/tmp/lqosd-mem.svg") else {
@@ -622,6 +626,12 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec<BusResponse>
}
BusResponse::Ack
}
BusRequest::CreateDynamicCircuit { shaped_device } => {
crate::dynamic_circuits::create_dynamic_circuit((**shaped_device).clone())
}
BusRequest::RemoveDynamicCircuit { circuit_id } => {
crate::dynamic_circuits::remove_dynamic_circuit(circuit_id)
}
BusRequest::InvalidateAuthCache => {
crate::node_manager::invalidate_auth_cache();
BusResponse::Ack
@@ -1215,7 +1225,6 @@ fn queue_stats_total_data() -> lqos_bus::QueueStatsTotal {
}
fn circuit_capacity_data() -> Vec<lqos_bus::CircuitCapacityRow> {
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use crate::throughput_tracker::THROUGHPUT_TRACKER;
use lqos_utils::units::DownUpOrder;
use std::collections::HashMap;
@@ -1250,13 +1259,12 @@ fn circuit_capacity_data() -> Vec<lqos_bus::CircuitCapacityRow> {
}
});
let shaped_devices = SHAPED_DEVICES.load();
let catalog = lqos_network_devices::shaped_devices_catalog();
circuits
.iter()
.filter_map(|(circuit_id, accumulator)| {
shaped_devices
.devices
.iter()
catalog
.iter_devices()
.find(|sd| sd.circuit_id == *circuit_id)
.map(|device| {
let down_mbps = (accumulator.bytes.down as f64 * 8.0) / 1_000_000.0;
@@ -1276,42 +1284,41 @@ fn circuit_capacity_data() -> Vec<lqos_bus::CircuitCapacityRow> {
}
fn tree_capacity_data() -> Vec<lqos_bus::NodeCapacity> {
use crate::shaped_devices_tracker::NETWORK_JSON;
let net_json = NETWORK_JSON.read();
net_json
.get_nodes_when_ready()
.iter()
.enumerate()
.map(|(id, node)| {
let node = crate::shaped_devices_tracker::node_to_transport(node);
let down = node.current_throughput.0 as f64 * 8.0 / 1_000_000.0;
let up = node.current_throughput.1 as f64 * 8.0 / 1_000_000.0;
let effective_max = node.effective_max_throughput.unwrap_or(node.max_throughput);
let max_down = effective_max.0;
let max_up = effective_max.1;
let median_rtt = if node.rtts.is_empty() {
0.0
} else {
let n = node.rtts.len() / 2;
if node.rtts.len().is_multiple_of(2) {
(node.rtts[n - 1] + node.rtts[n]) / 2.0
lqos_network_devices::with_network_json_read(|net_json| {
net_json
.get_nodes_when_ready()
.iter()
.enumerate()
.map(|(id, node)| {
let node = crate::shaped_devices_tracker::node_to_transport(node);
let down = node.current_throughput.0 as f64 * 8.0 / 1_000_000.0;
let up = node.current_throughput.1 as f64 * 8.0 / 1_000_000.0;
let effective_max = node.effective_max_throughput.unwrap_or(node.max_throughput);
let max_down = effective_max.0;
let max_up = effective_max.1;
let median_rtt = if node.rtts.is_empty() {
0.0
} else {
node.rtts[n]
}
};
let n = node.rtts.len() / 2;
if node.rtts.len().is_multiple_of(2) {
(node.rtts[n - 1] + node.rtts[n]) / 2.0
} else {
node.rtts[n]
}
};
lqos_bus::NodeCapacity {
id,
name: node.name.clone(),
down,
up,
max_down,
max_up,
median_rtt,
}
})
.collect()
lqos_bus::NodeCapacity {
id,
name: node.name.clone(),
down,
up,
max_down,
max_up,
median_rtt,
}
})
.collect()
})
}
fn retransmit_summary_data() -> lqos_bus::RetransmitSummary {
@@ -1325,37 +1332,39 @@ fn retransmit_summary_data() -> lqos_bus::RetransmitSummary {
}
fn tree_summary_l2_data() -> Vec<(usize, Vec<(usize, lqos_config::NetworkJsonTransport)>)> {
use crate::shaped_devices_tracker::NETWORK_JSON;
use std::collections::BTreeMap;
let net_json = NETWORK_JSON.read();
let nodes = net_json.get_nodes_when_ready();
let mut candidates: Vec<(usize, usize, lqos_config::NetworkJsonTransport, u64)> = Vec::new();
lqos_network_devices::with_network_json_read(|net_json| {
let nodes = net_json.get_nodes_when_ready();
let mut candidates: Vec<(usize, usize, lqos_config::NetworkJsonTransport, u64)> =
Vec::new();
for (p_idx, p_node) in nodes.iter().enumerate() {
if p_node.immediate_parent == Some(0) {
for (c_idx, c_node) in nodes.iter().enumerate() {
if c_node.immediate_parent == Some(p_idx) {
let t = crate::shaped_devices_tracker::node_to_transport(c_node);
let total = t.current_throughput.0 + t.current_throughput.1;
candidates.push((p_idx, c_idx, t, total));
for (p_idx, p_node) in nodes.iter().enumerate() {
if p_node.immediate_parent == Some(0) {
for (c_idx, c_node) in nodes.iter().enumerate() {
if c_node.immediate_parent == Some(p_idx) {
let t = crate::shaped_devices_tracker::node_to_transport(c_node);
let total = t.current_throughput.0 + t.current_throughput.1;
candidates.push((p_idx, c_idx, t, total));
}
}
}
}
}
candidates.sort_by(|a, b| b.3.cmp(&a.3));
let n: usize = 10;
if candidates.len() > n {
candidates.truncate(n);
}
candidates.sort_by(|a, b| b.3.cmp(&a.3));
let n: usize = 10;
if candidates.len() > n {
candidates.truncate(n);
}
let mut map: BTreeMap<usize, Vec<(usize, lqos_config::NetworkJsonTransport)>> = BTreeMap::new();
for (p_idx, c_idx, t, _total) in candidates.into_iter() {
map.entry(p_idx).or_default().push((c_idx, t));
}
let mut map: BTreeMap<usize, Vec<(usize, lqos_config::NetworkJsonTransport)>> =
BTreeMap::new();
for (p_idx, c_idx, t, _total) in candidates.into_iter() {
map.entry(p_idx).or_default().push((c_idx, t));
}
map.into_iter().collect()
map.into_iter().collect()
})
}
fn search_result_to_bus(entry: node_manager::SearchResult) -> lqos_bus::SearchResultEntry {
+166
View File
@@ -0,0 +1,166 @@
use crate::shaped_devices_tracker;
use crate::throughput_tracker::THROUGHPUT_TRACKER;
use lqos_bus::TcHandle;
use lqos_config::ShapedDevice;
use std::sync::mpsc;
use std::time::Duration;
use tracing::warn;
pub(crate) struct LqosdNetworkDevicesHooks;
impl lqos_network_devices::DaemonHooks for LqosdNetworkDevicesHooks {
fn on_shaped_devices_updated(&self) {
shaped_devices_tracker::invalidate_circuit_live_snapshot();
shaped_devices_tracker::invalidate_executive_cache_snapshot();
lqos_network_devices::with_network_json_read(|net_json| {
THROUGHPUT_TRACKER.refresh_circuit_ids(net_json);
});
}
fn on_network_json_updated(&self) {
shaped_devices_tracker::invalidate_circuit_live_snapshot();
shaped_devices_tracker::invalidate_executive_cache_snapshot();
lqos_network_devices::with_network_json_read(|net_json| {
THROUGHPUT_TRACKER.refresh_circuit_ids(net_json);
});
}
fn on_dynamic_circuits_expired(&self, circuit_ids: &[String]) {
let Some(sender) = lqos_bakery::BAKERY_SENDER.get() else {
return;
};
for circuit_id in circuit_ids {
let result = sender.send(lqos_bakery::BakeryCommands::RemoveDynamicCircuitOverlay {
circuit_id: circuit_id.clone(),
reply: None,
});
if let Err(err) = result {
warn!(
"Unable to enqueue dynamic circuit overlay removal for '{circuit_id}': {err}"
);
}
}
}
fn on_unknown_ip_promoted(&self, shaped_device: &ShapedDevice) {
let shaped_device = shaped_device.clone();
let circuit_id = shaped_device.circuit_id.clone();
let result = std::thread::Builder::new()
.name("dyn-circuit-unknown-ip".to_string())
.spawn(move || {
let Some(sender) = lqos_bakery::BAKERY_SENDER.get() else {
return;
};
let (tx, rx) = mpsc::channel::<Result<Option<TcHandle>, String>>();
if let Err(err) =
sender.send(lqos_bakery::BakeryCommands::UpsertDynamicCircuitOverlay {
shaped_device: Box::new(shaped_device.clone()),
reply: Some(tx),
})
{
warn!(
"Unable to enqueue dynamic circuit overlay for unknown IP promotion '{}': {err}",
shaped_device.circuit_id
);
return;
}
let handle = match rx.recv_timeout(Duration::from_secs(10)) {
Ok(Ok(Some(handle))) => handle,
Ok(Ok(None)) => {
// Bakery accepted the overlay but could not yet allocate a concrete class
// handle (e.g., baseline not ready). We'll retry on future observations.
return;
}
Ok(Err(err)) => {
warn!(
"Bakery rejected dynamic circuit overlay for unknown IP promotion '{}': {err}",
shaped_device.circuit_id
);
return;
}
Err(err) => {
warn!(
"Timeout waiting for Bakery reply while promoting unknown IP '{}': {err}",
shaped_device.circuit_id
);
return;
}
};
let circuit_hash = if shaped_device.circuit_hash != 0 {
shaped_device.circuit_hash
} else {
lqos_utils::hash_to_i64(&shaped_device.circuit_id)
};
let device_hash = if shaped_device.device_hash != 0 {
shaped_device.device_hash
} else {
lqos_utils::hash_to_i64(&shaped_device.device_id)
};
let cpu_count = lqos_sys::num_possible_cpus()
.map(|n| n.max(1))
.unwrap_or(1);
let cpu = ((circuit_hash as u64) % (cpu_count as u64)) as u32;
for (ip, prefix) in shaped_device.ipv4.iter() {
let addr = if *prefix == 32 {
ip.to_string()
} else {
format!("{ip}/{prefix}")
};
if let Err(err) = lqos_sys::add_ip_to_tc(
&addr,
handle,
cpu,
false,
circuit_hash as u64,
device_hash as u64,
) {
warn!(
"Unable to map unknown IP dynamic circuit '{}' for {addr}: {err:?}",
shaped_device.circuit_id
);
}
}
for (ip, prefix) in shaped_device.ipv6.iter() {
let addr = if *prefix == 128 {
ip.to_string()
} else {
format!("{ip}/{prefix}")
};
if let Err(err) = lqos_sys::add_ip_to_tc(
&addr,
handle,
cpu,
false,
circuit_hash as u64,
device_hash as u64,
) {
warn!(
"Unable to map unknown IP dynamic circuit '{}' for {addr}: {err:?}",
shaped_device.circuit_id
);
}
}
if let Err(err) = lqos_sys::clear_hot_cache() {
warn!(
"Unable to clear hot cache after mapping unknown IP dynamic circuit '{}': {err:?}",
shaped_device.circuit_id
);
}
});
if let Err(err) = result {
warn!(
"Unable to spawn unknown IP promotion helper thread for '{}': {err}",
circuit_id
);
}
}
}
@@ -25,6 +25,7 @@ config_treeguard.js
config_lts.js
config_iprange.js
config_flows.js
config_dynamic_circuits.js
config_integration.js
config_splynx.js
config_netzur.js
@@ -639,6 +639,7 @@ export function renderConfigMenu(currentPage) {
{ href: "config_lts.html", icon: "fa-line-chart", text: "License & Services", id: "lts" },
{ href: "config_iprange.html", icon: "fa-address-card", text: "IP Ranges", id: "iprange" },
{ href: "config_flows.html", icon: "fa-arrow-circle-down", text: "Flow Tracking", id: "flows" },
{ href: "config_dynamic_circuits.html", icon: "fa-layer-group", text: "Dynamic Circuits", id: "dynamic_circuits" },
{ href: "config_integration.html", icon: "fa-link", text: "Integration - Common", id: "integration" },
{ href: "config_splynx.html", icon: "fa-link", text: "Splynx", id: "splynx" },
{ href: "config_netzur.html", icon: "fa-link", text: "Netzur", id: "netzur" },
@@ -0,0 +1,378 @@
import {
loadConfig,
loadNetworkJson,
renderConfigMenu,
saveConfig,
validNodeList,
} from "./config/config_helper";
const DEFAULT_TTL_SECONDS = 300;
function normalizeIpRangeInput(value) {
const raw = String(value ?? "").trim();
if (!raw) return "";
if (raw === "0.0.0.0") return "0.0.0.0/0";
if (raw === "::") return "::/0";
return raw;
}
function isValidIPv4(ip) {
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false;
const parts = ip.split(".").map((p) => parseInt(p, 10));
return parts.length === 4 && !parts.some((p) => Number.isNaN(p) || p < 0 || p > 255);
}
function isValidIPv6(ip) {
return ip.includes(":") && /^[0-9a-fA-F:]+$/.test(ip);
}
function isValidCIDR(cidr) {
try {
const [ip, mask, extra] = String(cidr).trim().split("/");
if (!ip || !mask || extra !== undefined) return false;
if (ip.includes(":")) {
if (!isValidIPv6(ip)) return false;
} else if (!isValidIPv4(ip)) {
return false;
}
const maskNum = parseInt(mask, 10);
if (Number.isNaN(maskNum)) return false;
if (ip.includes(":")) {
if (maskNum < 0 || maskNum > 128) return false;
} else {
if (maskNum < 0 || maskNum > 32) return false;
}
return true;
} catch {
return false;
}
}
function parseFiniteFloat(value) {
const num = parseFloat(String(value ?? "").trim());
return Number.isFinite(num) ? num : null;
}
function parsePositiveInt(value) {
const num = parseInt(String(value ?? "").trim(), 10);
if (!Number.isFinite(num)) return null;
if (num <= 0) return null;
return num;
}
function getDefaultRule() {
return {
name: "",
ip_range: "0.0.0.0/0",
download_min_mbps: "10.0",
upload_min_mbps: "10.0",
download_max_mbps: "100.0",
upload_max_mbps: "100.0",
attach_to: "",
};
}
function ensureDynamicCircuitsConfig(config) {
if (!config || typeof config !== "object") return;
if (!config.dynamic_circuits || typeof config.dynamic_circuits !== "object") {
config.dynamic_circuits = {
enabled: false,
ttl_seconds: DEFAULT_TTL_SECONDS,
enable_unknown_ip_promotion: false,
ranges: [],
};
return;
}
const dyn = config.dynamic_circuits;
if (typeof dyn.enabled !== "boolean") dyn.enabled = false;
if (!Number.isFinite(Number(dyn.ttl_seconds))) dyn.ttl_seconds = DEFAULT_TTL_SECONDS;
if (typeof dyn.enable_unknown_ip_promotion !== "boolean") dyn.enable_unknown_ip_promotion = false;
if (!Array.isArray(dyn.ranges)) dyn.ranges = [];
}
function setNodeDatalist(networkJson) {
const datalist = document.getElementById("networkNodesDatalist");
if (!datalist) return;
datalist.innerHTML = "";
if (!networkJson || typeof networkJson !== "object") {
return;
}
for (const name of validNodeList(networkJson)) {
const option = document.createElement("option");
option.value = name;
datalist.appendChild(option);
}
}
function renderRangesTable() {
const tbody = document.getElementById("dynamicRangesBody");
if (!tbody) return;
tbody.innerHTML = "";
const ranges = window.config?.dynamic_circuits?.ranges;
if (!Array.isArray(ranges) || ranges.length === 0) {
const empty = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 6;
cell.className = "text-muted";
cell.textContent = "No rules configured.";
empty.appendChild(cell);
tbody.appendChild(empty);
return;
}
ranges.forEach((rule, index) => {
const tr = document.createElement("tr");
const mkText = (value, onChange, placeholder = "") => {
const input = document.createElement("input");
input.type = "text";
input.className = "form-control form-control-sm";
input.placeholder = placeholder;
input.value = String(value ?? "");
input.addEventListener("input", (ev) => onChange(ev.target.value));
return input;
};
const mkNumber = (value, onChange, step = "0.1") => {
const input = document.createElement("input");
input.type = "number";
input.className = "form-control form-control-sm";
input.step = step;
input.value = String(value ?? "");
input.addEventListener("input", (ev) => onChange(ev.target.value));
return input;
};
const mkRatePair = (
downValue,
onDownChange,
upValue,
onUpChange,
) => {
const container = document.createElement("div");
container.className = "d-grid gap-1";
const mkDir = (icon, ariaLabel, value, onChange) => {
const group = document.createElement("div");
group.className = "input-group input-group-sm";
const prefix = document.createElement("span");
prefix.className = "input-group-text";
const i = document.createElement("i");
i.className = `fa ${icon}`;
prefix.appendChild(i);
const input = mkNumber(value, onChange);
input.setAttribute("aria-label", ariaLabel);
group.appendChild(prefix);
group.appendChild(input);
return group;
};
container.appendChild(mkDir("fa-arrow-down", "Down Mbps", downValue, onDownChange));
container.appendChild(mkDir("fa-arrow-up", "Up Mbps", upValue, onUpChange));
return container;
};
const addCell = (label, child) => {
const td = document.createElement("td");
td.dataset.label = label;
td.appendChild(child);
tr.appendChild(td);
};
addCell(
"Name",
mkText(rule.name, (v) => {
window.config.dynamic_circuits.ranges[index].name = v;
}, "Rule name"),
);
addCell(
"IP Range",
mkText(rule.ip_range, (v) => {
window.config.dynamic_circuits.ranges[index].ip_range = v;
}, "192.168.0.0/24"),
);
addCell(
"Min Mbps",
mkRatePair(
rule.download_min_mbps,
(v) => {
window.config.dynamic_circuits.ranges[index].download_min_mbps = v;
},
rule.upload_min_mbps,
(v) => {
window.config.dynamic_circuits.ranges[index].upload_min_mbps = v;
},
),
);
addCell(
"Max Mbps",
mkRatePair(
rule.download_max_mbps,
(v) => {
window.config.dynamic_circuits.ranges[index].download_max_mbps = v;
},
rule.upload_max_mbps,
(v) => {
window.config.dynamic_circuits.ranges[index].upload_max_mbps = v;
},
),
);
const attach = mkText(rule.attach_to, (v) => {
window.config.dynamic_circuits.ranges[index].attach_to = v;
}, "network.json node (optional)");
attach.setAttribute("list", "networkNodesDatalist");
addCell("Attach To", attach);
const removeTd = document.createElement("td");
removeTd.dataset.label = "Actions";
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "btn btn-sm btn-outline-danger";
removeBtn.textContent = "Remove";
removeBtn.addEventListener("click", () => {
window.config.dynamic_circuits.ranges.splice(index, 1);
renderRangesTable();
});
removeTd.appendChild(removeBtn);
tr.appendChild(removeTd);
tbody.appendChild(tr);
});
}
function validateConfig() {
const errors = [];
const dyn = window.config?.dynamic_circuits;
if (!dyn || typeof dyn !== "object") {
errors.push("dynamic_circuits section is missing");
} else {
const ttl = parsePositiveInt(dyn.ttl_seconds);
if (ttl === null) {
errors.push("Time to live must be a positive integer (seconds)");
}
const ranges = Array.isArray(dyn.ranges) ? dyn.ranges : [];
ranges.forEach((rule, index) => {
const label = (rule?.name ?? "").trim()
? `Rule '${String(rule.name).trim()}'`
: `Rule #${index + 1}`;
if (!String(rule?.name ?? "").trim()) {
errors.push(`${label}: Name is required`);
}
const ipRange = normalizeIpRangeInput(rule?.ip_range);
if (!ipRange) {
errors.push(`${label}: IP Range is required`);
} else if (!isValidCIDR(ipRange)) {
errors.push(`${label}: IP Range must be a valid CIDR (e.g. 192.168.1.0/24). 0.0.0.0 and :: are allowed.`);
}
const dmin = parseFiniteFloat(rule?.download_min_mbps);
const umin = parseFiniteFloat(rule?.upload_min_mbps);
const dmax = parseFiniteFloat(rule?.download_max_mbps);
const umax = parseFiniteFloat(rule?.upload_max_mbps);
if (dmin === null || umin === null || dmax === null || umax === null) {
errors.push(`${label}: All rate fields must be valid numbers`);
return;
}
if (dmin < 0.1) errors.push(`${label}: Min Download must be >= 0.1 Mbps`);
if (umin < 0.1) errors.push(`${label}: Min Upload must be >= 0.1 Mbps`);
if (dmax < 0.2) errors.push(`${label}: Max Download must be >= 0.2 Mbps`);
if (umax < 0.2) errors.push(`${label}: Max Upload must be >= 0.2 Mbps`);
if (dmin > dmax) errors.push(`${label}: Min Download must be <= Max Download`);
if (umin > umax) errors.push(`${label}: Min Upload must be <= Max Upload`);
});
}
if (errors.length === 0) {
return true;
}
alert("Validation errors:\n" + errors.join("\n"));
return false;
}
function updateConfigFromUi() {
const dyn = window.config.dynamic_circuits;
dyn.enabled = !!document.getElementById("dynamicCircuitsEnabled")?.checked;
dyn.enable_unknown_ip_promotion = !!document.getElementById("unknownIpPromotionEnabled")?.checked;
const ttlValue = document.getElementById("dynamicCircuitsTtlSeconds")?.value;
dyn.ttl_seconds = parsePositiveInt(ttlValue) ?? DEFAULT_TTL_SECONDS;
// Normalize IP ranges in-place so saves canonicalize shorthand.
if (Array.isArray(dyn.ranges)) {
dyn.ranges = dyn.ranges.map((rule) => ({
...rule,
name: String(rule?.name ?? "").trim(),
ip_range: normalizeIpRangeInput(rule?.ip_range),
attach_to: String(rule?.attach_to ?? "").trim(),
}));
}
}
function finalizeConfigForSave() {
const dyn = window.config.dynamic_circuits;
if (!dyn || typeof dyn !== "object") return;
if (!Array.isArray(dyn.ranges)) return;
dyn.ranges = dyn.ranges.map((rule) => ({
...rule,
download_min_mbps: parseFloat(String(rule?.download_min_mbps ?? "").trim()),
upload_min_mbps: parseFloat(String(rule?.upload_min_mbps ?? "").trim()),
download_max_mbps: parseFloat(String(rule?.download_max_mbps ?? "").trim()),
upload_max_mbps: parseFloat(String(rule?.upload_max_mbps ?? "").trim()),
}));
}
// Render the configuration menu.
renderConfigMenu("dynamic_circuits");
loadConfig(() => {
ensureDynamicCircuitsConfig(window.config);
const dyn = window.config.dynamic_circuits;
document.getElementById("dynamicCircuitsEnabled").checked = dyn.enabled ?? false;
document.getElementById("dynamicCircuitsTtlSeconds").value = dyn.ttl_seconds ?? DEFAULT_TTL_SECONDS;
document.getElementById("unknownIpPromotionEnabled").checked = dyn.enable_unknown_ip_promotion ?? false;
// Load network nodes for the attach_to datalist (UX-only).
loadNetworkJson(
(networkJson) => {
if (typeof networkJson === "object" && networkJson !== null) {
setNodeDatalist(networkJson);
}
},
() => {},
);
renderRangesTable();
document.getElementById("addDynamicRange").addEventListener("click", () => {
window.config.dynamic_circuits.ranges.push(getDefaultRule());
renderRangesTable();
});
document.getElementById("saveButton").addEventListener("click", () => {
updateConfigFromUi();
if (!validateConfig()) return;
finalizeConfigForSave();
saveConfig(() => {
alert("Configuration saved successfully!");
});
});
});
@@ -13,6 +13,7 @@ let shapedDevices = [];
let devicesPerPage = 24;
let page = 0;
let searchTerm = "";
let shapedDevicesKind = "Static";
let metricElsByCircuitId = new Map();
const latestByCircuitId = new Map();
const planByCircuitId = new Map();
@@ -78,6 +79,52 @@ function countCircuits() {
return totalCircuits;
}
function updateKindTabs() {
const staticTab = document.getElementById("sdTabStatic");
const dynamicTab = document.getElementById("sdTabDynamic");
if (!staticTab || !dynamicTab) {
return;
}
const isDynamic = shapedDevicesKind === "Dynamic";
staticTab.classList.toggle("active", !isDynamic);
dynamicTab.classList.toggle("active", isDynamic);
staticTab.setAttribute("aria-selected", (!isDynamic).toString());
dynamicTab.setAttribute("aria-selected", isDynamic.toString());
}
function renderCounts() {
const countEl = $("#count");
const circuitEl = $("#countCircuit");
if (shapedDevicesKind === "Dynamic") {
countEl.text(totalRows + " dynamic circuits");
circuitEl.text("");
circuitEl.addClass("d-none");
} else {
countEl.text(totalRows + " devices");
circuitEl.text(totalCircuits + " circuits");
circuitEl.removeClass("d-none");
}
}
function setShapedDevicesKind(kind) {
if (!kind || (kind !== "Static" && kind !== "Dynamic")) {
kind = "Static";
}
if (shapedDevicesKind === kind) {
return;
}
shapedDevicesKind = kind;
page = 0;
searchTerm = "";
const searchBox = document.getElementById("sdSearch");
if (searchBox) {
searchBox.value = "";
}
circuitMetricsWatchSignature = null;
updateKindTabs();
requestShapedDevicesPage();
}
function filterDevices() {
page = 0;
requestShapedDevicesPage();
@@ -550,6 +597,7 @@ function currentShapedDevicesPageQuery() {
const query = {
page,
page_size: devicesPerPage,
kind: shapedDevicesKind,
};
if (searchTerm && searchTerm.trim() !== "") {
query.search = searchTerm;
@@ -610,13 +658,13 @@ async function requestShapedDevicesPage() {
});
renderCards();
requestCircuitMetricsWatch(true);
$("#count").text(totalRows + " devices");
$("#countCircuit").text(totalCircuits + " circuits");
renderCounts();
} catch (_error) {
shapedDevices = [];
totalRows = 0;
totalCircuits = 0;
renderCards();
renderCounts();
}
}
@@ -676,7 +724,16 @@ function handleCircuitMetrics(msg) {
wsClient.on("CircuitMetricsSnapshot", handleCircuitMetrics);
wsClient.on("CircuitMetricsUpdate", handleCircuitMetrics);
wsClient.on("join", () => {
updateKindTabs();
requestShapedDevicesPage();
});
document.getElementById("sdTabStatic")?.addEventListener("click", () => {
setShapedDevicesKind("Static");
});
document.getElementById("sdTabDynamic")?.addEventListener("click", () => {
setShapedDevicesKind("Dynamic");
});
updateKindTabs();
requestShapedDevicesPage();
@@ -1,7 +1,5 @@
use crate::node_manager::local_api::ethernet_caps::ethernet_advisory_for_circuit;
use crate::shaped_devices_tracker::{
SHAPED_DEVICES, effective_parent_for_circuit, resolve_parent_node_reference,
};
use crate::shaped_devices_tracker::effective_parent_for_circuit;
use lqos_config::{CircuitEthernetMetadata, ShapedDevice};
use serde::{Deserialize, Serialize};
@@ -50,9 +48,10 @@ fn canonical_parent_node(devices: &mut [ShapedDevice]) -> Option<CircuitParentNo
let mut resolved_parent = None;
for device in devices.iter_mut() {
let Some(resolved) =
resolve_parent_node_reference(&device.parent_node, device.parent_node_id.as_deref())
else {
let Some(resolved) = lqos_network_devices::resolve_parent_node_reference(
&device.parent_node,
device.parent_node_id.as_deref(),
) else {
continue;
};
if resolved_parent.is_none() {
@@ -87,13 +86,15 @@ fn queue_stats_mode() -> CircuitQueueStatsMode {
pub fn circuit_by_id_data(id: &str) -> Option<CircuitByIdData> {
let safe_id = id.to_lowercase().trim().to_string();
let reader = SHAPED_DEVICES.load();
let mut devices: Vec<ShapedDevice> = reader
.devices
.iter()
.filter(|d| d.circuit_id.to_lowercase().trim() == safe_id)
.cloned()
.collect();
let catalog = lqos_network_devices::shaped_devices_catalog();
let mut devices: Vec<ShapedDevice> = catalog.devices_for_circuit_id(&safe_id);
if devices.is_empty() {
let catalog = lqos_network_devices::network_devices_catalog();
if let Some(device) = catalog.dynamic_device_by_circuit_id(&safe_id) {
devices.push(device.clone());
}
}
if devices.is_empty() {
None
@@ -1,6 +1,3 @@
use crate::shaped_devices_tracker::{
SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES, shaped_device_from_hashes_or_ip,
};
use crate::throughput_tracker::flow_data::{ALL_FLOWS, FlowbeeLocalData, get_asn_name_and_country};
use lqos_utils::hash_to_i64;
use lqos_utils::units::{DownUpOrder, TcpRetransmitSample};
@@ -166,13 +163,13 @@ fn sanitized_plan_ceiling_bps(plan_mbps: f32) -> u32 {
}
fn circuit_display_rate_ceiling_bps(
shaped: &lqos_config::ConfigShapedDevices,
catalog: &lqos_network_devices::NetworkDevicesCatalog,
circuit_hash: i64,
) -> Option<DownUpOrder<u32>> {
let mut max_down_mbps = 0.0_f32;
let mut max_up_mbps = 0.0_f32;
for device in &shaped.devices {
for device in catalog.iter_all_devices() {
if device.circuit_hash != circuit_hash {
continue;
}
@@ -222,9 +219,8 @@ fn flow_qoo(local: &FlowbeeLocalData) -> DownUpOrder<Option<f32>> {
fn flow_snapshot_rows(circuit_id: &str) -> Vec<CircuitFlowSnapshotRow> {
let circuit_hash = hash_to_i64(circuit_id);
let shaped = SHAPED_DEVICES.load();
let cache = SHAPED_DEVICE_HASH_CACHE.load();
let display_rate_ceiling = circuit_display_rate_ceiling_bps(&shaped, circuit_hash);
let catalog = lqos_network_devices::network_devices_catalog();
let display_rate_ceiling = circuit_display_rate_ceiling_bps(&catalog, circuit_hash);
let Ok(now) = time_since_boot() else {
return Vec::new();
};
@@ -239,13 +235,13 @@ fn flow_snapshot_rows(circuit_id: &str) -> Vec<CircuitFlowSnapshotRow> {
if local.last_seen < recent_cutoff {
return None;
}
let device = shaped_device_from_hashes_or_ip(
&shaped,
&cache,
&key.local_ip,
local.device_hash,
local.circuit_hash,
);
let device = catalog
.device_by_hashes(local.device_hash, local.circuit_hash)
.or_else(|| {
catalog
.device_longest_match_for_ip(&key.local_ip)
.map(|(_, device)| device)
});
let matches_desired = local.circuit_hash == Some(circuit_hash)
|| device.is_some_and(|device| device.circuit_id == circuit_id);
if !matches_desired {
@@ -1,4 +1,3 @@
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use crate::throughput_tracker::THROUGHPUT_TRACKER;
use lqos_utils::unix_time::time_since_boot;
use serde::Serialize;
@@ -35,23 +34,24 @@ pub fn circuit_count_data() -> CircuitCount {
.filter_map(|(_k, d)| d.circuit_id.clone())
.collect();
// Get configured circuits from ShapedDevices
let shaped_devices = SHAPED_DEVICES.load();
let configured_circuits: HashSet<&str> = shaped_devices
.devices
.iter()
.map(|device| device.circuit_id.as_str())
.collect();
// Get configured circuits from ShapedDevices + dynamic overlay
let catalog = lqos_network_devices::network_devices_catalog();
let configured_count = catalog
.iter_all_devices()
.map(|device| device.circuit_id.trim())
.filter(|id| !id.is_empty())
.collect::<HashSet<_>>()
.len();
// Use active count if > 0, otherwise fall back to configured count
let count = if !active_circuits.is_empty() {
active_circuits.len()
} else {
configured_circuits.len()
configured_count
};
CircuitCount {
count,
configured_count: configured_circuits.len(),
configured_count,
}
}
@@ -1,6 +1,5 @@
use crate::node_manager::auth::LoginResult;
use crate::node_manager::local_api::network_mode::NetworkModeInspection;
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use crate::shaping_runtime::ShapingRuntimeStatus;
use axum::http::StatusCode;
use default_net::get_interfaces;
@@ -10,7 +9,6 @@ use lqos_utils::hash_to_i64;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc;
type TopologySourceIntegration = (&'static str, fn(&Config) -> bool);
@@ -382,7 +380,7 @@ pub fn network_json_data() -> Value {
}
pub fn all_shaped_devices_data() -> Vec<ShapedDevice> {
SHAPED_DEVICES.load().devices.clone()
lqos_network_devices::shaped_devices_catalog().clone_all_devices()
}
/// Returns the enabled integration names that act as the source of truth for
@@ -626,7 +624,11 @@ fn persist_shaped_devices(mut devices: Vec<ShapedDevice>) -> Result<(), String>
copied
.write_csv("ShapedDevices.csv")
.map_err(|e| format!("Unable to write ShapedDevices.csv: {e}"))?;
SHAPED_DEVICES.store(Arc::new(copied));
lqos_network_devices::apply_shaped_devices_snapshot(
"node_manager:persist_shaped_devices",
copied,
)
.map_err(|e| format!("Unable to publish ShapedDevices.csv snapshot: {e}"))?;
Ok(())
}
@@ -663,6 +665,8 @@ pub fn update_network_and_devices_data(
ensure_topology_editor_unlocked()?;
persist_network_json(&network_json)?;
persist_shaped_devices(shaped_devices)?;
lqos_network_devices::request_reload_network_json("node_manager:update_network_and_devices")
.map_err(|e| format!("Unable to reload network.json: {e}"))?;
Ok(())
}
@@ -682,6 +686,8 @@ pub fn update_network_json_only_data(
ensure_topology_editor_unlocked()?;
persist_network_json(&network_json)?;
lqos_network_devices::request_reload_network_json("node_manager:update_network_json_only")
.map_err(|e| format!("Unable to reload network.json: {e}"))?;
Ok(())
}
@@ -696,10 +702,8 @@ pub fn get_shaped_device_data(
return Err(StatusCode::FORBIDDEN);
}
let wanted = device_id.trim();
Ok(SHAPED_DEVICES
.load()
.devices
.iter()
Ok(lqos_network_devices::shaped_devices_catalog()
.iter_devices()
.find(|device| device.device_id == wanted)
.cloned())
}
@@ -716,7 +720,7 @@ pub fn create_shaped_device_data(
return Err("Unauthorized".to_string());
}
ensure_topology_editor_unlocked()?;
let mut devices = SHAPED_DEVICES.load().devices.clone();
let mut devices = lqos_network_devices::shaped_devices_catalog().clone_all_devices();
devices.push(device.clone());
persist_shaped_devices(devices)?;
let created = get_shaped_device_data(login, device.device_id.clone())
@@ -739,7 +743,7 @@ pub fn update_shaped_device_data(
return Err("Unauthorized".to_string());
}
ensure_topology_editor_unlocked()?;
let mut devices = SHAPED_DEVICES.load().devices.clone();
let mut devices = lqos_network_devices::shaped_devices_catalog().clone_all_devices();
let wanted = original_device_id.trim();
let Some(index) = devices.iter().position(|row| row.device_id == wanted) else {
return Err("Not found".to_string());
@@ -763,7 +767,7 @@ pub fn delete_shaped_device_data(login: LoginResult, device_id: String) -> Resul
}
ensure_topology_editor_unlocked()?;
let wanted = device_id.trim();
let mut devices = SHAPED_DEVICES.load().devices.clone();
let mut devices = lqos_network_devices::shaped_devices_catalog().clone_all_devices();
let before = devices.len();
devices.retain(|device| device.device_id != wanted);
if devices.len() == before {
@@ -1,7 +1,6 @@
use crate::node_manager::local_api::network_tree_lite::{
NetworkTreeLiteNode, network_tree_lite_data,
};
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use crate::system_stats::{CPU_USAGE, NUM_CPUS};
use lqos_config::{ShapingCpuDetection, detect_shaping_cpus, load_config};
use serde::Serialize;
@@ -558,10 +557,10 @@ fn derive_runtime_node_placements(
}
fn direct_circuit_counts_by_node() -> HashMap<String, u32> {
let shaped = SHAPED_DEVICES.load();
let catalog = lqos_network_devices::shaped_devices_catalog();
let mut circuits_by_node: HashMap<String, BTreeSet<i64>> = HashMap::new();
for device in &shaped.devices {
for device in catalog.iter_devices() {
let node_name = device.parent_node.trim();
if node_name.is_empty() {
continue;
@@ -1,5 +1,4 @@
use crate::node_manager::local_api::unknown_ips::get_unknown_ips;
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use serde::Serialize;
use std::collections::BTreeSet;
@@ -11,16 +10,15 @@ pub struct DeviceCount {
}
pub fn device_count() -> DeviceCount {
let shaped_devices = SHAPED_DEVICES.load();
let shaped_devices = lqos_network_devices::shaped_devices_catalog();
let mapped_circuits = shaped_devices
.devices
.iter()
.iter_devices()
.map(|device| device.circuit_hash)
.collect::<BTreeSet<_>>()
.len();
DeviceCount {
shaped_devices: shaped_devices.devices.len(),
shaped_devices: shaped_devices.devices_len(),
unknown_ips: get_unknown_ips().len(),
mapped_circuits,
}
@@ -1,5 +1,4 @@
use crate::node_manager::local_api::network_tree;
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -85,10 +84,10 @@ pub fn circuit_directory_page(query: CircuitDirectoryQuery) -> CircuitDirectoryP
let page = query.page.unwrap_or(0);
let page_size = normalized_page_size(&query);
let search = query.search.as_deref().unwrap_or("").trim().to_lowercase();
let devices = SHAPED_DEVICES.load();
let catalog = lqos_network_devices::network_devices_catalog();
let mut circuits: BTreeMap<String, CircuitDirectoryRow> = BTreeMap::new();
for device in &devices.devices {
for device in catalog.iter_all_devices() {
let circuit_id = device.circuit_id.trim().to_string();
if circuit_id.is_empty() {
continue;
@@ -210,10 +209,10 @@ pub fn treeguard_metadata_summary() -> TreeGuardMetadataSummary {
})
.count();
let devices = SHAPED_DEVICES.load();
let catalog = lqos_network_devices::network_devices_catalog();
let mut circuit_ids = BTreeSet::new();
let mut fq_codel_circuit_ids = BTreeSet::new();
for device in &devices.devices {
for device in catalog.iter_all_devices() {
let circuit_id = device.circuit_id.trim().to_string();
if circuit_id.is_empty() {
continue;
@@ -1,4 +1,3 @@
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use lqos_config::{CircuitEthernetMetadata, CircuitEthernetMetadataFile, load_config};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
@@ -139,9 +138,9 @@ fn load_advisory_file() -> Option<CircuitEthernetMetadataFile> {
}
fn parent_node_by_circuit_id() -> HashMap<String, String> {
let devices = SHAPED_DEVICES.load();
let catalog = lqos_network_devices::shaped_devices_catalog();
let mut parent_nodes = HashMap::new();
for device in &devices.devices {
for device in catalog.iter_devices() {
let circuit_id = device.circuit_id.trim();
if circuit_id.is_empty() {
continue;
@@ -4,7 +4,6 @@ use crate::node_manager::local_api::executive::{
ExecutiveOversubscribedSiteRow, ExecutiveRttBlocks, ExecutiveScalarBlocks,
ExecutiveSplitBlocks, ExecutiveTopAsnRow, ExecutiveTreeLocator,
};
use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES};
use crate::throughput_tracker::{
THROUGHPUT_TRACKER, asn_heatmaps, circuit_heatmaps, executive_summary_header, global_heatmap,
site_heatmaps,
@@ -595,23 +594,24 @@ fn node_path_names(idx: usize, nodes: &[lqos_config::NetworkJsonNode]) -> Option
}
fn node_locator_map() -> FxHashMap<String, ExecutiveTreeLocator> {
let reader = NETWORK_JSON.read();
let nodes = reader.get_nodes_when_ready();
let mut locators = FxHashMap::default();
for (idx, node) in nodes.iter().enumerate() {
if node.name == "Root" || locators.contains_key(&node.name) {
continue;
lqos_network_devices::with_network_json_read(|net_json| {
let nodes = net_json.get_nodes_when_ready();
let mut locators = FxHashMap::default();
for (idx, node) in nodes.iter().enumerate() {
if node.name == "Root" || locators.contains_key(&node.name) {
continue;
}
locators.insert(
node.name.clone(),
ExecutiveTreeLocator {
node_id: node.id.clone(),
node_path: node_path_names(idx, nodes),
parent_index: Some(idx),
},
);
}
locators.insert(
node.name.clone(),
ExecutiveTreeLocator {
node_id: node.id.clone(),
node_path: node_path_names(idx, nodes),
parent_index: Some(idx),
},
);
}
locators
locators
})
}
fn entity_snapshots(
@@ -686,30 +686,17 @@ fn entity_snapshots(
fn build_oversubscribed_sites(
entities: &[ExecutiveEntitySnapshot],
) -> Vec<ExecutiveOversubscribedSiteRow> {
let devices = SHAPED_DEVICES.load();
let mut circuit_map: std::collections::HashMap<String, (String, f32, f32)> =
std::collections::HashMap::new();
for device in &devices.devices {
let entry = circuit_map
.entry(device.circuit_id.clone())
.or_insert_with(|| (device.parent_node.clone(), 0.0_f32, 0.0_f32));
entry.1 = entry.1.max(device.download_max_mbps);
entry.2 = entry.2.max(device.upload_max_mbps);
}
let reader = NETWORK_JSON.read();
let nodes = reader.get_nodes_when_ready();
if nodes.is_empty() {
return Vec::new();
}
let mut children: Vec<Vec<usize>> = vec![Vec::new(); nodes.len()];
for (idx, node) in nodes.iter().enumerate() {
if let Some(parent) = node.immediate_parent
&& parent < children.len()
{
children[parent].push(idx);
}
let mut circuit_caps =
lqos_network_devices::shaped_devices_catalog().circuit_rate_caps_by_circuit_id();
for circuit in lqos_network_devices::dynamic_circuits_snapshot().iter() {
let shaped = &circuit.shaped;
circuit_caps.entry(shaped.circuit_id.clone()).or_insert_with(|| {
lqos_network_devices::CircuitRateCaps {
parent_node: shaped.parent_node.clone(),
download_max_mbps: shaped.download_max_mbps,
upload_max_mbps: shaped.upload_max_mbps,
}
});
}
let entity_map = entities
@@ -718,85 +705,101 @@ fn build_oversubscribed_sites(
.map(|entity| (entity.label.clone(), entity))
.collect::<FxHashMap<_, _>>();
let mut results = Vec::new();
for (idx, node) in nodes.iter().enumerate() {
if node.name == "Root" {
continue;
}
let node_type = node.node_type.as_deref().unwrap_or("").to_ascii_lowercase();
if node_type != "site" && !node_type.is_empty() && node_type != "ap" {
continue;
lqos_network_devices::with_network_json_read(|net_json| {
let nodes = net_json.get_nodes_when_ready();
if nodes.is_empty() {
return Vec::new();
}
let mut stack = vec![idx];
let mut visited = FxHashSet::default();
let mut descendant_names = FxHashSet::default();
while let Some(next_idx) = stack.pop() {
if !visited.insert(next_idx) {
let mut children: Vec<Vec<usize>> = vec![Vec::new(); nodes.len()];
for (idx, node) in nodes.iter().enumerate() {
if let Some(parent) = node.immediate_parent
&& parent < children.len()
{
children[parent].push(idx);
}
}
let mut results = Vec::new();
for (idx, node) in nodes.iter().enumerate() {
if node.name == "Root" {
continue;
}
if let Some(name) = nodes.get(next_idx).map(|entry| entry.name.clone()) {
descendant_names.insert(name);
let node_type = node.node_type.as_deref().unwrap_or("").to_ascii_lowercase();
if node_type != "site" && !node_type.is_empty() && node_type != "ap" {
continue;
}
if let Some(child_nodes) = children.get(next_idx) {
for child in child_nodes {
stack.push(*child);
let mut stack = vec![idx];
let mut visited = FxHashSet::default();
let mut descendant_names = FxHashSet::default();
while let Some(next_idx) = stack.pop() {
if !visited.insert(next_idx) {
continue;
}
if let Some(name) = nodes.get(next_idx).map(|entry| entry.name.clone()) {
descendant_names.insert(name);
}
if let Some(child_nodes) = children.get(next_idx) {
for child in child_nodes {
stack.push(*child);
}
}
}
}
let cap_down = node.max_throughput.0 as f32;
let cap_up = node.max_throughput.1 as f32;
let cap_down = node.max_throughput.0 as f32;
let cap_up = node.max_throughput.1 as f32;
let mut sub_down = 0.0_f32;
let mut sub_up = 0.0_f32;
for (parent_name, circuit_down, circuit_up) in circuit_map.values() {
if descendant_names.contains(parent_name) {
sub_down += *circuit_down;
sub_up += *circuit_up;
let mut sub_down = 0.0_f32;
let mut sub_up = 0.0_f32;
for caps in circuit_caps.values() {
if descendant_names.contains(&caps.parent_node) {
sub_down += caps.download_max_mbps;
sub_up += caps.upload_max_mbps;
}
}
let Some(stats) = entity_map.get(&node.name) else {
continue;
};
let (avg_down_util, _) = average_with_count(&stats.heatmap.download);
let (avg_up_util, _) = average_with_count(&stats.heatmap.upload);
let median_rtt_ms = median_value(&stats.heatmap.rtt);
let ratio_down = (cap_down > 0.0).then_some(sub_down / cap_down);
let ratio_up = (cap_up > 0.0).then_some(sub_up / cap_up);
let ratio_max = match (ratio_down, ratio_up) {
(Some(down), Some(up)) => Some(down.max(up)),
(Some(down), None) => Some(down),
(None, Some(up)) => Some(up),
(None, None) => None,
};
results.push(ExecutiveOversubscribedSiteRow {
row_key: stats.row_key.clone(),
site_name: node.name.clone(),
tree: stats.tree.clone(),
ratio_down,
ratio_up,
ratio_max,
cap_down,
cap_up,
sub_down,
sub_up,
avg_down_util,
avg_up_util,
median_rtt_ms,
});
}
let Some(stats) = entity_map.get(&node.name) else {
continue;
};
let (avg_down_util, _) = average_with_count(&stats.heatmap.download);
let (avg_up_util, _) = average_with_count(&stats.heatmap.upload);
let median_rtt_ms = median_value(&stats.heatmap.rtt);
let ratio_down = (cap_down > 0.0).then_some(sub_down / cap_down);
let ratio_up = (cap_up > 0.0).then_some(sub_up / cap_up);
let ratio_max = match (ratio_down, ratio_up) {
(Some(down), Some(up)) => Some(down.max(up)),
(Some(down), None) => Some(down),
(None, Some(up)) => Some(up),
(None, None) => None,
};
results.push(ExecutiveOversubscribedSiteRow {
row_key: stats.row_key.clone(),
site_name: node.name.clone(),
tree: stats.tree.clone(),
ratio_down,
ratio_up,
ratio_max,
cap_down,
cap_up,
sub_down,
sub_up,
avg_down_util,
avg_up_util,
median_rtt_ms,
results.sort_by(|left, right| {
right
.ratio_max
.unwrap_or(0.0)
.total_cmp(&left.ratio_max.unwrap_or(0.0))
});
}
results.sort_by(|left, right| {
right
.ratio_max
.unwrap_or(0.0)
.total_cmp(&left.ratio_max.unwrap_or(0.0))
});
results.truncate(EXECUTIVE_OVERSUBSCRIBED_LIMIT);
results
results.truncate(EXECUTIVE_OVERSUBSCRIBED_LIMIT);
results
})
}
/// Invalidates the shared executive cache so the next read rebuilds immediately.
@@ -1,3 +1,4 @@
use crate::throughput_tracker::THROUGHPUT_TRACKER;
use crate::throughput_tracker::flow_data::{
AsnCountryListEntry, AsnListEntry, AsnProtocolListEntry, FlowAnalysis, FlowbeeLocalData,
RECENT_FLOWS, RttData,
@@ -1,5 +1,6 @@
use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES};
use ip_network::IpNetwork;
use ip_network::{Ipv4Network, Ipv6Network};
use lqos_utils::XdpIpAddress;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::net::{IpAddr, Ipv4Addr};
@@ -69,6 +70,53 @@ pub enum SearchResult {
},
}
fn dynamic_longest_match_for_ip(
ip: IpAddr,
dynamic: &[lqos_network_devices::DynamicCircuit],
) -> Option<(IpNetwork, &lqos_config::ShapedDevice)> {
let mut best: Option<(IpNetwork, &lqos_config::ShapedDevice, u8)> = None;
for circuit in dynamic {
let dev = &circuit.shaped;
match ip {
IpAddr::V4(ip4) => {
for (addr, prefix) in &dev.ipv4 {
let Some(prefix_u8) = u8::try_from(*prefix).ok() else {
continue;
};
let Ok(net) = Ipv4Network::new(*addr, prefix_u8) else {
continue;
};
if !net.contains(ip4) {
continue;
}
if best.as_ref().map(|(_, _, p)| *p).unwrap_or(0) < prefix_u8 {
best = Some((IpNetwork::V4(net), dev, prefix_u8));
}
}
}
IpAddr::V6(ip6) => {
for (addr, prefix) in &dev.ipv6 {
let Some(prefix_u8) = u8::try_from(*prefix).ok() else {
continue;
};
let Ok(net) = Ipv6Network::new(*addr, prefix_u8) else {
continue;
};
if !net.contains(ip6) {
continue;
}
if best.as_ref().map(|(_, _, p)| *p).unwrap_or(0) < prefix_u8 {
best = Some((IpNetwork::V6(net), dev, prefix_u8));
}
}
}
}
}
best.map(|(net, dev, _)| (net, dev))
}
pub fn search_results(search: SearchRequest) -> Vec<SearchResult> {
const MAX_RESULTS: usize = 50;
let mut results: Vec<SearchResult> = Vec::new();
@@ -78,6 +126,8 @@ pub fn search_results(search: SearchRequest) -> Vec<SearchResult> {
let term_lc = raw_term.to_lowercase();
let exact_ip: Option<IpAddr> = raw_term.parse::<IpAddr>().ok();
let looks_like_ip_prefix = raw_term.contains('.') || raw_term.contains(':');
let catalog = lqos_network_devices::shaped_devices_catalog();
let dynamic_snapshot = lqos_network_devices::dynamic_circuits_snapshot();
// Helper to add results with de-dup and cap
fn push_result(
@@ -103,13 +153,20 @@ pub fn search_results(search: SearchRequest) -> Vec<SearchResult> {
// First pass: exact IP matches using the LPM trie
if let Some(ip) = exact_ip {
let sd_reader = SHAPED_DEVICES.load();
let query_v6 = match ip {
IpAddr::V4(v4) => v4.to_ipv6_mapped(),
IpAddr::V6(v6) => v6,
};
if let Some((net, &idx)) = sd_reader.trie.longest_match(query_v6)
&& let Some(dev) = sd_reader.devices.get(idx)
let xdp_ip = XdpIpAddress::from_ip(ip);
if let Some((net, dev)) = catalog.device_longest_match_for_ip(&xdp_ip) {
let name = format!("{} ({})", dev.device_name, pretty_net(&net));
push_result(
&mut results,
&mut seen,
SearchResult::Device {
circuit_id: dev.circuit_id.clone(),
name,
circuit_name: dev.circuit_name.clone(),
},
MAX_RESULTS,
);
} else if let Some((net, dev)) = dynamic_longest_match_for_ip(ip, dynamic_snapshot.as_ref())
{
let name = format!("{} ({})", dev.device_name, pretty_net(&net));
push_result(
@@ -143,14 +200,11 @@ pub fn search_results(search: SearchRequest) -> Vec<SearchResult> {
IpNetwork::V6(v6net) => Some(IpNetwork::V6(v6net)),
};
if let Some(query_v6) = net_v6.as_ref() {
let sd_reader = SHAPED_DEVICES.load();
for (n, &idx) in sd_reader.trie.iter() {
for (n, dev) in catalog.iter_ip_mappings() {
if results.len() >= MAX_RESULTS {
break;
}
if ipv6_overlap(&n, query_v6)
&& let Some(dev) = sd_reader.devices.get(idx)
{
if ipv6_overlap(&n, query_v6) {
let name = format!("{} ({})", dev.device_name, pretty_net(&n));
push_result(
&mut results,
@@ -164,19 +218,79 @@ pub fn search_results(search: SearchRequest) -> Vec<SearchResult> {
);
}
}
for circuit in dynamic_snapshot.iter() {
if results.len() >= MAX_RESULTS {
break;
}
let dev = &circuit.shaped;
for (addr, prefix) in &dev.ipv4 {
if results.len() >= MAX_RESULTS {
break;
}
let Some(prefix_u8) = u8::try_from(*prefix).ok() else {
continue;
};
let Ok(v4net) = Ipv4Network::new(*addr, prefix_u8) else {
continue;
};
let addr = v4net.network_address().to_ipv6_mapped();
let mapped_pref = prefix_u8.saturating_add(96);
let Ok(v6net) = Ipv6Network::new(addr, mapped_pref) else {
continue;
};
let n = IpNetwork::V6(v6net);
if ipv6_overlap(&n, query_v6) {
let name =
format!("{} ({})", dev.device_name, pretty_net(&n));
push_result(
&mut results,
&mut seen,
SearchResult::Device {
circuit_id: dev.circuit_id.clone(),
name,
circuit_name: dev.circuit_name.clone(),
},
MAX_RESULTS,
);
}
}
for (addr, prefix) in &dev.ipv6 {
if results.len() >= MAX_RESULTS {
break;
}
let Some(prefix_u8) = u8::try_from(*prefix).ok() else {
continue;
};
let Ok(v6net) = Ipv6Network::new(*addr, prefix_u8) else {
continue;
};
let n = IpNetwork::V6(v6net);
if ipv6_overlap(&n, query_v6) {
let name =
format!("{} ({})", dev.device_name, pretty_net(&n));
push_result(
&mut results,
&mut seen,
SearchResult::Device {
circuit_id: dev.circuit_id.clone(),
name,
circuit_name: dev.circuit_name.clone(),
},
MAX_RESULTS,
);
}
}
}
}
}
} else {
// Fallback: textual prefix (e.g., "10.1.")
let sd_reader = SHAPED_DEVICES.load();
for (n, &idx) in sd_reader.trie.iter() {
for (n, dev) in catalog.iter_ip_mappings() {
if results.len() >= MAX_RESULTS {
break;
}
let s = pretty_net(&n);
if s.starts_with(raw_term)
&& let Some(dev) = sd_reader.devices.get(idx)
{
if s.starts_with(raw_term) {
let name = format!("{} ({})", dev.device_name, s);
push_result(
&mut results,
@@ -190,13 +304,70 @@ pub fn search_results(search: SearchRequest) -> Vec<SearchResult> {
);
}
}
for circuit in dynamic_snapshot.iter() {
if results.len() >= MAX_RESULTS {
break;
}
let dev = &circuit.shaped;
for (addr, prefix) in &dev.ipv4 {
if results.len() >= MAX_RESULTS {
break;
}
let Some(prefix_u8) = u8::try_from(*prefix).ok() else {
continue;
};
let Ok(net) = Ipv4Network::new(*addr, prefix_u8) else {
continue;
};
let n = IpNetwork::V4(net);
let s = pretty_net(&n);
if s.starts_with(raw_term) {
let name = format!("{} ({})", dev.device_name, s);
push_result(
&mut results,
&mut seen,
SearchResult::Device {
circuit_id: dev.circuit_id.clone(),
name,
circuit_name: dev.circuit_name.clone(),
},
MAX_RESULTS,
);
}
}
for (addr, prefix) in &dev.ipv6 {
if results.len() >= MAX_RESULTS {
break;
}
let Some(prefix_u8) = u8::try_from(*prefix).ok() else {
continue;
};
let Ok(net) = Ipv6Network::new(*addr, prefix_u8) else {
continue;
};
let n = IpNetwork::V6(net);
let s = pretty_net(&n);
if s.starts_with(raw_term) {
let name = format!("{} ({})", dev.device_name, s);
push_result(
&mut results,
&mut seen,
SearchResult::Device {
circuit_id: dev.circuit_id.clone(),
name,
circuit_name: dev.circuit_name.clone(),
},
MAX_RESULTS,
);
}
}
}
}
}
// Third pass: Circuit/Device name substring matches
if results.len() < MAX_RESULTS && term_lc.len() >= 3 {
let sd_reader = SHAPED_DEVICES.load();
for sd in sd_reader.devices.iter() {
for sd in catalog.iter_devices() {
if results.len() >= MAX_RESULTS {
break;
}
@@ -229,27 +400,63 @@ pub fn search_results(search: SearchRequest) -> Vec<SearchResult> {
);
}
}
for circuit in dynamic_snapshot.iter() {
if results.len() >= MAX_RESULTS {
break;
}
let sd = &circuit.shaped;
let circuit_name_lc = sd.circuit_name.to_lowercase();
if circuit_name_lc.contains(&term_lc) {
push_result(
&mut results,
&mut seen,
SearchResult::Circuit {
id: sd.circuit_id.clone(),
name: sd.circuit_name.clone(),
},
MAX_RESULTS,
);
}
if results.len() >= MAX_RESULTS {
break;
}
let device_name_lc = sd.device_name.to_lowercase();
if device_name_lc.contains(&term_lc) {
push_result(
&mut results,
&mut seen,
SearchResult::Device {
circuit_id: sd.circuit_id.clone(),
name: sd.device_name.clone(),
circuit_name: sd.circuit_name.clone(),
},
MAX_RESULTS,
);
}
}
}
// Fourth pass: Site name substring matches
if results.len() < MAX_RESULTS && term_lc.len() >= 3 {
let net_reader = NETWORK_JSON.read();
for (idx, n) in net_reader.get_nodes_when_ready().iter().enumerate() {
if results.len() >= MAX_RESULTS {
break;
lqos_network_devices::with_network_json_read(|net_reader| {
for (idx, n) in net_reader.get_nodes_when_ready().iter().enumerate() {
if results.len() >= MAX_RESULTS {
break;
}
if n.name.to_lowercase().contains(&term_lc) {
push_result(
&mut results,
&mut seen,
SearchResult::Site {
idx,
name: n.name.clone(),
},
MAX_RESULTS,
);
}
}
if n.name.to_lowercase().contains(&term_lc) {
push_result(
&mut results,
&mut seen,
SearchResult::Site {
idx,
name: n.name.clone(),
},
MAX_RESULTS,
);
}
}
});
}
results
@@ -1,6 +1,5 @@
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use lqos_config::ShapedDevice;
pub fn all_shaped_devices_data() -> Vec<ShapedDevice> {
SHAPED_DEVICES.load().devices.clone()
lqos_network_devices::shaped_devices_catalog().clone_all_devices()
}
@@ -1,4 +1,3 @@
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use lqos_config::ShapedDevice;
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashSet;
@@ -30,6 +29,16 @@ pub struct ShapedDevicesPageQuery {
pub page_size: Option<usize>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
pub search: Option<String>,
/// Which inventory surface to display.
#[serde(default)]
pub kind: Option<ShapedDevicesPageKind>,
}
/// Which shaped-device inventory source to query.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum ShapedDevicesPageKind {
Static,
Dynamic,
}
/// A server-paged slice of shaped devices plus total result counts.
@@ -53,37 +62,48 @@ pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage {
let page = query.page.unwrap_or(0);
let page_size = normalized_page_size(&query);
let search = query.search.as_deref().unwrap_or("").trim().to_lowercase();
let devices = SHAPED_DEVICES.load();
let kind = query.kind.clone().unwrap_or(ShapedDevicesPageKind::Static);
let mut filtered: Vec<ShapedDevice> = devices
.devices
.iter()
.filter(|device| {
if search.is_empty() {
return true;
}
device.device_name.to_lowercase().contains(&search)
|| device.circuit_name.to_lowercase().contains(&search)
|| device.parent_node.to_lowercase().contains(&search)
|| device.circuit_id.to_lowercase().contains(&search)
|| device.device_id.to_lowercase().contains(&search)
|| device.mac.to_lowercase().contains(&search)
|| device.comment.to_lowercase().contains(&search)
|| device
.sqm_override
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&search)
|| device.ipv4.iter().any(|(addr, prefix)| {
format!("{addr}/{prefix}").to_lowercase().contains(&search)
})
|| device.ipv6.iter().any(|(addr, prefix)| {
format!("{addr}/{prefix}").to_lowercase().contains(&search)
})
})
.cloned()
.collect();
let matches_search = |device: &ShapedDevice| {
if search.is_empty() {
return true;
}
device.device_name.to_lowercase().contains(&search)
|| device.circuit_name.to_lowercase().contains(&search)
|| device.parent_node.to_lowercase().contains(&search)
|| device.circuit_id.to_lowercase().contains(&search)
|| device.device_id.to_lowercase().contains(&search)
|| device.mac.to_lowercase().contains(&search)
|| device.comment.to_lowercase().contains(&search)
|| device
.sqm_override
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&search)
|| device
.ipv4
.iter()
.any(|(addr, prefix)| format!("{addr}/{prefix}").to_lowercase().contains(&search))
|| device
.ipv6
.iter()
.any(|(addr, prefix)| format!("{addr}/{prefix}").to_lowercase().contains(&search))
};
let mut filtered: Vec<ShapedDevice> = match kind {
ShapedDevicesPageKind::Static => lqos_network_devices::shaped_devices_catalog()
.iter_devices()
.filter(|device| matches_search(device))
.cloned()
.collect(),
ShapedDevicesPageKind::Dynamic => lqos_network_devices::dynamic_circuits_snapshot()
.iter()
.map(|circuit| &circuit.shaped)
.filter(|device| matches_search(device))
.cloned()
.collect(),
};
filtered.sort_by(|left, right| {
left.circuit_name
.cmp(&right.circuit_name)
@@ -114,6 +134,7 @@ pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage {
} else {
query.search
},
kind: Some(kind),
},
total_rows,
total_circuits,
@@ -1,7 +1,3 @@
use crate::shaped_devices_tracker::{
SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES, ShapedDeviceMatchSource,
shaped_device_match_from_hashes_or_ip,
};
use crate::throughput_tracker::THROUGHPUT_TRACKER;
use axum::Json;
use serde::Serialize;
@@ -9,6 +5,13 @@ use std::time::Duration;
const RECENT_WINDOW_SECONDS: u64 = 5 * 60;
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
pub(crate) enum ShapedDeviceMatchSource {
DeviceHash,
CircuitHash,
IpFallback,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub(crate) struct ThroughputAttributionDebug {
window_seconds: u64,
@@ -61,8 +64,7 @@ pub(crate) fn throughput_attribution_debug_data() -> ThroughputAttributionDebug
let now = Duration::from(time_since_boot).as_nanos() as u64;
let recent_cutoff_nanos = RECENT_WINDOW_SECONDS * 1_000_000_000;
let shaped = SHAPED_DEVICES.load();
let cache = SHAPED_DEVICE_HASH_CACHE.load();
let catalog = lqos_network_devices::network_devices_catalog();
let raw = THROUGHPUT_TRACKER.raw_data.lock();
let mut stats = ThroughputAttributionDebug {
@@ -108,13 +110,7 @@ pub(crate) fn throughput_attribution_debug_data() -> ThroughputAttributionDebug
stats.recent_entries_missing_both_hashes += 1;
}
match shaped_device_match_from_hashes_or_ip(
&shaped,
&cache,
ip,
entry.device_hash,
entry.circuit_hash,
) {
match match_source_for_entry(&catalog, ip, entry.device_hash, entry.circuit_hash) {
Some((_device, ShapedDeviceMatchSource::DeviceHash)) => {
stats.recent_entries_resolved_by_device_hash += 1;
}
@@ -144,3 +140,26 @@ pub(crate) fn throughput_attribution_debug_data() -> ThroughputAttributionDebug
stats
}
fn match_source_for_entry<'a>(
catalog: &'a lqos_network_devices::NetworkDevicesCatalog,
ip: &lqos_utils::XdpIpAddress,
device_hash: Option<i64>,
circuit_hash: Option<i64>,
) -> Option<(&'a lqos_config::ShapedDevice, ShapedDeviceMatchSource)> {
if let Some(hash) = device_hash {
if let Some(device) = catalog.device_by_hashes(Some(hash), None) {
return Some((device, ShapedDeviceMatchSource::DeviceHash));
}
}
if let Some(hash) = circuit_hash {
if let Some(device) = catalog.device_by_hashes(None, Some(hash)) {
return Some((device, ShapedDeviceMatchSource::CircuitHash));
}
}
catalog
.device_longest_match_for_ip(ip)
.map(|(_net, device)| (device, ShapedDeviceMatchSource::IpFallback))
}
@@ -1,10 +1,8 @@
use crate::node_manager::local_api::ethernet_caps::{EthernetCapBadge, ethernet_cap_badge_map};
use crate::shaped_devices_tracker::circuit_live::fresh_circuit_live_snapshot;
use crate::shaped_devices_tracker::{
NETWORK_JSON, SHAPED_DEVICES, effective_parent_for_circuit, resolve_parent_node_reference,
};
use crate::shaped_devices_tracker::effective_parent_for_circuit;
use fxhash::{FxHashMap, FxHashSet};
use lqos_config::{ConfigShapedDevices, ShapedDevice};
use lqos_config::ShapedDevice;
use lqos_utils::units::{DownUpOrder, TcpRetransmitSample};
use serde::{Deserialize, Deserializer, Serialize};
use std::net::{Ipv4Addr, Ipv6Addr};
@@ -101,54 +99,55 @@ fn normalized_page_size(query: &TreeAttachedCircuitsQuery) -> usize {
.clamp(1, MAX_ATTACHED_CIRCUITS_PAGE_SIZE)
}
fn resolve_selected_node_index(query: &TreeAttachedCircuitsQuery) -> Option<usize> {
let reader = NETWORK_JSON.read();
let nodes = reader.get_nodes_when_ready();
if let Some(node_id) = query.node_id.as_deref()
&& let Some(node) = nodes
.iter()
.position(|node| node.id.as_deref() == Some(node_id))
{
return Some(node);
}
let node_path = query.node_path.as_ref()?;
if node_path.is_empty() {
return None;
}
nodes.iter().enumerate().find_map(|(idx, node)| {
let path = if node.name == "Root" {
vec!["Root".to_string()]
} else {
let parent_indexes = if node.parents.is_empty() {
vec![node.immediate_parent.unwrap_or(0)]
} else {
node.parents.clone()
};
let mut names = Vec::new();
for idx in parent_indexes {
let parent = nodes.get(idx)?;
names.push(parent.name.clone());
}
if names.last() != Some(&node.name) {
names.push(node.name.clone());
}
names
};
if &path == node_path { Some(idx) } else { None }
})
}
fn build_selected_node_matcher(query: &TreeAttachedCircuitsQuery) -> Option<SelectedNodeMatcher> {
let selected_idx = resolve_selected_node_index(query)?;
let reader = NETWORK_JSON.read();
let nodes = reader.get_nodes_when_ready();
let selected = nodes.get(selected_idx)?;
lqos_network_devices::with_network_json_read(|net_json| {
let nodes = net_json.get_nodes_when_ready();
Some(SelectedNodeMatcher {
node_name: selected.name.clone(),
node_id: selected.id.clone(),
if let Some(node_id) = query.node_id.as_deref()
&& let Some(node) = nodes
.iter()
.find(|node| node.id.as_deref() == Some(node_id))
{
return Some(SelectedNodeMatcher {
node_name: node.name.clone(),
node_id: node.id.clone(),
});
}
let node_path = query.node_path.as_ref()?;
if node_path.is_empty() {
return None;
}
nodes.iter().find_map(|node| {
let path = if node.name == "Root" {
vec!["Root".to_string()]
} else {
let parent_indexes = if node.parents.is_empty() {
vec![node.immediate_parent.unwrap_or(0)]
} else {
node.parents.clone()
};
let mut names = Vec::new();
for idx in parent_indexes {
let parent = nodes.get(idx)?;
names.push(parent.name.clone());
}
if names.last() != Some(&node.name) {
names.push(node.name.clone());
}
names
};
if &path == node_path {
Some(SelectedNodeMatcher {
node_name: node.name.clone(),
node_id: node.id.clone(),
})
} else {
None
}
})
})
}
@@ -192,7 +191,10 @@ fn effective_or_canonical_parent(device: &ShapedDevice) -> Option<(String, Optio
return Some((runtime_parent.name, runtime_parent.id));
}
resolve_parent_node_reference(&device.parent_node, device.parent_node_id.as_deref())
lqos_network_devices::resolve_parent_node_reference(
&device.parent_node,
device.parent_node_id.as_deref(),
)
.map(|resolved| (resolved.name, resolved.id))
.or_else(|| {
let trimmed = device.parent_node.trim();
@@ -204,13 +206,13 @@ fn effective_or_canonical_parent(device: &ShapedDevice) -> Option<(String, Optio
})
}
fn aggregate_attached_circuit_rows(
fn aggregate_attached_circuit_rows<'a>(
matcher: &SelectedNodeMatcher,
shaped_devices: &ConfigShapedDevices,
shaped_devices: impl IntoIterator<Item = &'a ShapedDevice>,
) -> Vec<CircuitRowAccumulator> {
let mut rows: FxHashMap<String, CircuitRowAccumulator> = FxHashMap::default();
for device in &shaped_devices.devices {
for device in shaped_devices {
if device.circuit_id.trim().is_empty() {
continue;
}
@@ -271,11 +273,11 @@ pub fn tree_attached_circuits(query: TreeAttachedCircuitsQuery) -> TreeAttachedC
let snapshot = fresh_circuit_live_snapshot();
let ethernet_badges = ethernet_cap_badge_map();
let shaped_devices = SHAPED_DEVICES.load();
let catalog = lqos_network_devices::network_devices_catalog();
let mut rows: Vec<TreeAttachedCircuitRow> = matcher
.as_ref()
.map(|matcher| {
aggregate_attached_circuit_rows(matcher, &shaped_devices)
aggregate_attached_circuit_rows(matcher, catalog.iter_all_devices())
.into_iter()
.map(|row| {
let live = snapshot.by_circuit_id.get(&row.circuit_id);
@@ -391,6 +393,7 @@ pub fn tree_attached_circuits(query: TreeAttachedCircuitsQuery) -> TreeAttachedC
mod tests {
use super::*;
use std::str::FromStr;
use lqos_config::ConfigShapedDevices;
fn sample_device(
circuit_id: &str,
@@ -471,7 +474,7 @@ mod tests {
..ConfigShapedDevices::default()
};
let rows = aggregate_attached_circuit_rows(&matcher, &shaped_devices);
let rows = aggregate_attached_circuit_rows(&matcher, shaped_devices.devices.iter());
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].circuit_id, "circuit-1");
assert_eq!(rows[0].circuit_name, "Circuit One");
@@ -0,0 +1,100 @@
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Dynamic Circuits</h5>
<div class="lqos-config-panel-subtitle">
Configure dynamic circuit promotion and default shaping rates for unknown IPs. This section is optional and safe to omit on systems that do not use dynamic circuits yet.
</div>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Operation</h6>
<div class="lqos-config-section-subtitle">Enable the subsystem and choose retention and promotion behavior.</div>
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="mb-0 form-check form-switch">
<input type="checkbox" class="form-check-input" id="dynamicCircuitsEnabled">
<label class="form-check-label" for="dynamicCircuitsEnabled">Enable Dynamic Circuits</label>
<div class="form-text">Master switch for dynamic circuit tracking and promotion.</div>
</div>
</div>
<div class="col-12 col-xl-6">
<label for="dynamicCircuitsTtlSeconds" class="form-label">Time to Live</label>
<div class="input-group">
<input type="number" class="form-control" id="dynamicCircuitsTtlSeconds" min="1" step="1" value="300">
<span class="input-group-text">seconds</span>
</div>
<div class="form-text">How long dynamic circuit state remains valid without being seen.</div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Range Rules</h6>
<div class="lqos-config-section-subtitle">Define the IP networks that should receive default rates and an optional topology attachment.</div>
<div class="d-flex align-items-start justify-content-between flex-wrap gap-3 mb-3">
<div class="form-check form-switch mb-0">
<input type="checkbox" class="form-check-input" id="unknownIpPromotionEnabled">
<label class="form-check-label" for="unknownIpPromotionEnabled">Enable Unknown IP Promotion</label>
<div class="form-text">When enabled, unknown IP traffic is evaluated against the range rules below.</div>
</div>
<div class="d-flex align-items-center gap-2">
<button type="button" id="addDynamicRange" class="btn btn-outline-primary btn-sm">
<i class="fa fa-plus me-1"></i>Add Rule
</button>
</div>
</div>
<datalist id="networkNodesDatalist"></datalist>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle lqos-config-responsive-table">
<thead>
<tr>
<th style="min-width: 9rem;">Name</th>
<th style="min-width: 10rem;">
IP Range
<span
class="ms-1 text-muted"
role="button"
tabindex="0"
aria-label="IP range help"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="CIDR notation (for example 192.168.200.0/24). 0.0.0.0 and :: mean match-all."
><i class="fas fa-info-circle"></i></span>
</th>
<th style="min-width: 9rem;">Min Mbps</th>
<th style="min-width: 9rem;">Max Mbps</th>
<th style="min-width: 11rem;">Attach To</th>
<th style="min-width: 5rem;">Actions</th>
</tr>
</thead>
<tbody id="dynamicRangesBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</div>
</form>
</div>
<script src="config_dynamic_circuits.js%CACHEBUSTERS%"></script>
@@ -2218,6 +2218,54 @@ table tr td a {
color: var(--bs-secondary-color) !important;
}
/* Responsive inline tables for config editors */
@media (max-width: 767.98px) {
.lqos-config-responsive-table thead {
display: none;
}
.lqos-config-responsive-table tbody tr {
display: block;
border: 1px solid var(--lqos-border);
border-radius: var(--lqos-radius-md);
background: color-mix(in srgb, var(--lqos-surface) 70%, transparent);
padding: 0.65rem 0.75rem;
margin-bottom: 0.75rem;
}
.lqos-config-responsive-table tbody td {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.35rem 0;
border: 0;
}
.lqos-config-responsive-table tbody td::before {
content: attr(data-label);
flex: 0 0 7.25rem;
color: var(--bs-secondary-color);
font-weight: 600;
font-size: 0.85rem;
}
.lqos-config-responsive-table tbody td > * {
flex: 1 1 auto;
min-width: 0;
}
.lqos-config-responsive-table tbody td[colspan] {
display: block;
padding: 0.25rem 0;
}
.lqos-config-responsive-table tbody td[colspan]::before {
content: "";
}
}
.lqos-config-network-editor {
display: grid;
gap: 0.9rem;
@@ -1,6 +1,22 @@
<div class="row">
<div class="col-12">
<h5><i class="fa fa-group"></i> Shaped Devices <span class="badge" id="count">?</span> <span class="badge" id="countCircuit">?</span></h5>
<h5>
<i class="fa fa-group"></i> Circuits
<span class="badge" id="count">?</span>
<span class="badge" id="countCircuit">?</span>
</h5>
<ul class="nav nav-tabs lqos-pill-tabs mb-2" id="shapedDevicesTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="sdTabStatic" type="button" role="tab" aria-selected="true">
<i class="fa fa-list me-1"></i> Circuits
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="sdTabDynamic" type="button" role="tab" aria-selected="false">
<i class="fa fa-bolt me-1"></i> Dynamic Circuits
</button>
</li>
</ul>
</div>
<div class="col-12">
<div id="deviceTable">
@@ -9,4 +25,4 @@
</div>
</div>
<script src="shaped-devices.js%CACHEBUSTERS%"></script>
<script src="shaped-devices.js%CACHEBUSTERS%"></script>
@@ -65,6 +65,7 @@ pub(super) fn static_routes() -> Result<Router> {
"config_lts.html",
"config_iprange.html",
"config_flows.html",
"config_dynamic_circuits.html",
"config_integration.html",
"config_splynx.html",
"config_netzur.html",
@@ -2,33 +2,32 @@ use crate::node_manager::local_api::circuit_activity::{CircuitSummaryData, circu
use crate::node_manager::ws::messages::{CircuitDevicesResult, WsResponse, encode_ws_message};
use crate::node_manager::ws::ticker::all_circuits;
use crate::rtt_exclusions;
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use crate::throughput_tracker::{circuit_current_qoo, circuit_current_rtt_p50_nanos};
use lqos_bus::{BusRequest, Circuit};
use lqos_utils::hash_to_i64;
use lqos_utils::units::{DownUpOrder, down_up_retransmit_sample};
use std::time::Duration;
use tokio::time::MissedTickBehavior;
use tracing::info;
fn qoo_score_for_circuit(circuit: &str) -> Option<f32> {
circuit_hash_for_id(circuit).and_then(|hash| {
let qoo = circuit_current_qoo(hash);
match (qoo.down, qoo.up) {
(Some(d), Some(u)) => Some(d.min(u)),
(Some(d), None) => Some(d),
(None, Some(u)) => Some(u),
(None, None) => None,
}
})
let circuit_hash = circuit_hash_for_id(circuit)?;
let qoo = circuit_current_qoo(circuit_hash);
match (qoo.down, qoo.up) {
(Some(d), Some(u)) => Some(d.min(u)),
(Some(d), None) => Some(d),
(None, Some(u)) => Some(u),
(None, None) => None,
}
}
fn circuit_hash_for_id(circuit: &str) -> Option<i64> {
let shaped = SHAPED_DEVICES.load();
shaped
.devices
.iter()
.find(|d| d.circuit_id == circuit)
.map(|d| d.circuit_hash)
let trimmed = circuit.trim();
if trimmed.is_empty() {
None
} else {
Some(hash_to_i64(trimmed))
}
}
fn summarize_circuit_devices(circuit: &str, devices: &[Circuit]) -> CircuitSummaryData {
@@ -5,7 +5,6 @@ use crate::node_manager::ws::messages::{
};
use crate::node_manager::ws::publish_subscribe::PubSub;
use crate::node_manager::ws::published_channels::PublishedChannels;
use crate::shaped_devices_tracker::NETWORK_JSON;
use lqos_bakery::{
BakeryActivityEntry as BakeryActivitySnapshot, BakeryApplyType, BakeryMode,
BakeryPreflightSnapshot, BakeryRuntimeNodeOperationAction, BakeryRuntimeNodeOperationStatus,
@@ -79,11 +78,12 @@ fn map_preflight(snapshot: BakeryPreflightSnapshot) -> BakeryPreflightData {
}
fn resolve_site_name(site_hash: i64) -> Option<String> {
let reader = NETWORK_JSON.read();
reader
.get_nodes_when_ready()
.iter()
.find_map(|node| (hash_to_i64(&node.name) == site_hash).then(|| node.name.clone()))
lqos_network_devices::with_network_json_read(|net_json| {
net_json
.get_nodes_when_ready()
.iter()
.find_map(|node| (hash_to_i64(&node.name) == site_hash).then(|| node.name.clone()))
})
}
fn resolve_runtime_site_name(site_hash: i64) -> Option<String> {
@@ -1,7 +1,6 @@
use crate::node_manager::ws::messages::{CircuitCapacityRow, WsResponse};
use crate::node_manager::ws::publish_subscribe::PubSub;
use crate::node_manager::ws::published_channels::PublishedChannels;
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use crate::throughput_tracker::THROUGHPUT_TRACKER;
use lqos_utils::units::DownUpOrder;
use std::collections::HashMap;
@@ -47,14 +46,13 @@ pub async fn circuit_capacity(channels: Arc<PubSub>) {
});
// Map circuits to capacities
let shaped_devices = SHAPED_DEVICES.load();
let catalog = lqos_network_devices::network_devices_catalog();
let capacities: Vec<CircuitCapacityRow> = {
circuits
.iter()
.filter_map(|(circuit_id, accumulator)| {
if let Some(device) = shaped_devices
.devices
.iter()
if let Some(device) = catalog
.iter_all_devices()
.find(|sd| sd.circuit_id == *circuit_id)
{
let down_mbps = (accumulator.bytes.down as f64 * 8.0) / 1_000_000.0;
@@ -1,6 +1,6 @@
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use lqos_bus::{IpStats, TcHandle};
use lqos_config::{ConfigShapedDevices, ShapedDevice};
use lqos_config::ShapedDevice;
use lqos_network_devices::NetworkDevicesCatalog;
use lqos_utils::XdpIpAddress;
use lqos_utils::units::{DownUpOrder, TcpRetransmitSample};
use serde::{Deserialize, Serialize};
@@ -12,19 +12,20 @@ fn truncate_by_chars(input: &str, max_chars: usize) -> String {
fn shaped_device_for_ip_stats<'a>(
stat: &IpStats,
shaped_devices: &'a ConfigShapedDevices,
catalog: &'a NetworkDevicesCatalog,
) -> Option<&'a ShapedDevice> {
if !stat.circuit_id.is_empty()
&& let Some(circuit) = shaped_devices
.devices
.iter()
&& let Some(circuit) = catalog
.iter_all_devices()
.find(|sd| sd.circuit_id == stat.circuit_id)
{
return Some(circuit);
}
let ip = stat.ip_address.parse::<IpAddr>().ok()?;
shaped_devices.get_device_from_ip(&XdpIpAddress::from_ip(ip))
catalog
.device_longest_match_for_ip(&XdpIpAddress::from_ip(ip))
.map(|(_net, device)| device)
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -52,8 +53,8 @@ impl From<&IpStats> for IpStatsWithPlan {
tcp_retransmit_sample: i.tcp_retransmit_sample,
};
let shaped_devices_reader = SHAPED_DEVICES.load();
if let Some(circuit) = shaped_device_for_ip_stats(i, &shaped_devices_reader) {
let catalog = lqos_network_devices::network_devices_catalog();
if let Some(circuit) = shaped_device_for_ip_stats(i, &catalog) {
if result.circuit_id.is_empty() {
result.circuit_id = circuit.circuit_id.clone();
}
@@ -80,6 +81,7 @@ mod tests {
use lqos_config::{ConfigShapedDevices, ShapedDevice};
use lqos_utils::units::{DownUpOrder, TcpRetransmitSample};
use std::net::Ipv4Addr;
use std::sync::Arc;
#[test]
fn truncates_ascii_to_exact_length() {
@@ -114,6 +116,13 @@ mod tests {
..Default::default()
}]);
let shaped_catalog =
lqos_network_devices::ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped));
let catalog = lqos_network_devices::NetworkDevicesCatalog::from_snapshots(
shaped_catalog,
Arc::new(Vec::new()),
);
let stat = IpStats {
ip_address: "192.168.1.10".to_string(),
circuit_id: String::new(),
@@ -127,7 +136,7 @@ mod tests {
),
};
let matched = shaped_device_for_ip_stats(&stat, &shaped).expect("lookup should resolve");
let matched = shaped_device_for_ip_stats(&stat, &catalog).expect("lookup should resolve");
assert_eq!(matched.circuit_id, "circuit-1");
}
}
@@ -1,7 +1,7 @@
use crate::node_manager::ws::messages::{NodeCapacity, WsResponse};
use crate::node_manager::ws::publish_subscribe::PubSub;
use crate::node_manager::ws::published_channels::PublishedChannels;
use crate::shaped_devices_tracker::{NETWORK_JSON, node_to_transport};
use crate::shaped_devices_tracker::node_to_transport;
use std::sync::Arc;
pub async fn tree_capacity(channels: Arc<PubSub>) {
@@ -12,40 +12,42 @@ pub async fn tree_capacity(channels: Arc<PubSub>) {
return;
}
let capacities: Vec<NodeCapacity> = {
let net_json = NETWORK_JSON.read();
net_json
.get_nodes_when_ready()
.iter()
.enumerate()
.map(|(id, node)| {
let node = node_to_transport(node);
let down = node.current_throughput.0 as f64 * 8.0 / 1_000_000.0;
let up = node.current_throughput.1 as f64 * 8.0 / 1_000_000.0;
let effective_max = node.effective_max_throughput.unwrap_or(node.max_throughput);
let max_down = effective_max.0;
let max_up = effective_max.1;
let median_rtt = if node.rtts.is_empty() {
0.0
} else {
let n = node.rtts.len() / 2;
if node.rtts.len().is_multiple_of(2) {
(node.rtts[n - 1] + node.rtts[n]) / 2.0
lqos_network_devices::with_network_json_read(|net_json| {
net_json
.get_nodes_when_ready()
.iter()
.enumerate()
.map(|(id, node)| {
let node = node_to_transport(node);
let down = node.current_throughput.0 as f64 * 8.0 / 1_000_000.0;
let up = node.current_throughput.1 as f64 * 8.0 / 1_000_000.0;
let effective_max =
node.effective_max_throughput.unwrap_or(node.max_throughput);
let max_down = effective_max.0;
let max_up = effective_max.1;
let median_rtt = if node.rtts.is_empty() {
0.0
} else {
node.rtts[n]
}
};
let n = node.rtts.len() / 2;
if node.rtts.len().is_multiple_of(2) {
(node.rtts[n - 1] + node.rtts[n]) / 2.0
} else {
node.rtts[n]
}
};
NodeCapacity {
id,
name: node.name.clone(),
down,
up,
max_down,
max_up,
median_rtt,
}
})
.collect()
NodeCapacity {
id,
name: node.name.clone(),
down,
up,
max_down,
max_up,
median_rtt,
}
})
.collect()
})
};
let message = WsResponse::TreeCapacity { data: capacities };
@@ -1,7 +1,7 @@
use crate::node_manager::ws::messages::WsResponse;
use crate::node_manager::ws::publish_subscribe::PubSub;
use crate::node_manager::ws::published_channels::PublishedChannels;
use crate::shaped_devices_tracker::{NETWORK_JSON, node_to_transport};
use crate::shaped_devices_tracker::node_to_transport;
use lqos_config::NetworkJsonTransport;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -24,38 +24,39 @@ pub async fn tree_summary_l2(channels: Arc<PubSub>) {
// Build top-N second-level children across all first-level parents
let grouped: Vec<(usize, Vec<(usize, NetworkJsonTransport)>)> = {
let net_json = NETWORK_JSON.read();
let nodes = net_json.get_nodes_when_ready();
// Collect candidates as (parent_idx, child_idx, transport, total_bytes_per_sec)
let mut candidates: Vec<(usize, usize, NetworkJsonTransport, u64)> = Vec::new();
lqos_network_devices::with_network_json_read(|net_json| {
let nodes = net_json.get_nodes_when_ready();
// Collect candidates as (parent_idx, child_idx, transport, total_bytes_per_sec)
let mut candidates: Vec<(usize, usize, NetworkJsonTransport, u64)> = Vec::new();
// Identify first-level parents (immediate_parent == Some(0))
for (p_idx, p_node) in nodes.iter().enumerate() {
if p_node.immediate_parent == Some(0) {
// For each child-of-child under this parent
for (c_idx, c_node) in nodes.iter().enumerate() {
if c_node.immediate_parent == Some(p_idx) {
let t = node_to_transport(c_node);
let total = t.current_throughput.0 + t.current_throughput.1;
candidates.push((p_idx, c_idx, t, total));
// Identify first-level parents (immediate_parent == Some(0))
for (p_idx, p_node) in nodes.iter().enumerate() {
if p_node.immediate_parent == Some(0) {
// For each child-of-child under this parent
for (c_idx, c_node) in nodes.iter().enumerate() {
if c_node.immediate_parent == Some(p_idx) {
let t = node_to_transport(c_node);
let total = t.current_throughput.0 + t.current_throughput.1;
candidates.push((p_idx, c_idx, t, total));
}
}
}
}
}
// Sort by total throughput descending and cap to N
candidates.sort_by(|a, b| b.3.cmp(&a.3));
let n: usize = 10;
if candidates.len() > n {
candidates.truncate(n);
}
// Sort by total throughput descending and cap to N
candidates.sort_by(|a, b| b.3.cmp(&a.3));
let n: usize = 10;
if candidates.len() > n {
candidates.truncate(n);
}
// Group by parent id
let mut map: BTreeMap<usize, Vec<(usize, NetworkJsonTransport)>> = BTreeMap::new();
for (p_idx, c_idx, t, _total) in candidates.into_iter() {
map.entry(p_idx).or_default().push((c_idx, t));
}
map.into_iter().collect()
// Group by parent id
let mut map: BTreeMap<usize, Vec<(usize, NetworkJsonTransport)>> = BTreeMap::new();
for (p_idx, c_idx, t, _total) in candidates.into_iter() {
map.entry(p_idx).or_default().push((c_idx, t));
}
map.into_iter().collect()
})
};
let message = WsResponse::TreeSummaryL2 { data: grouped };
@@ -9,10 +9,7 @@ use std::net::IpAddr;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use super::{
CIRCUIT_LIVE_LAST_REFRESH_SECS, CIRCUIT_LIVE_REFRESH_LOCK, CIRCUIT_LIVE_SNAPSHOT,
SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES, shaped_device_from_hashes_or_ip,
};
use super::{CIRCUIT_LIVE_LAST_REFRESH_SECS, CIRCUIT_LIVE_REFRESH_LOCK, CIRCUIT_LIVE_SNAPSHOT};
/// Per-circuit live metrics aggregated from the device-level throughput tracker.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -87,18 +84,13 @@ pub fn rebuild_circuit_live_snapshot() -> Arc<CircuitLiveSnapshot> {
};
let kernel_now: std::time::Duration = kernel_now.into();
let shaped_devices = SHAPED_DEVICES.load();
let cache = SHAPED_DEVICE_HASH_CACHE.load();
let catalog = lqos_network_devices::network_devices_catalog();
let mut by_circuit_id: FxHashMap<String, CircuitAccumulator> = FxHashMap::default();
for (ip_key, data) in THROUGHPUT_TRACKER.raw_data.lock().iter() {
let device = shaped_device_from_hashes_or_ip(
&shaped_devices,
&cache,
ip_key,
data.device_hash,
data.circuit_hash,
);
let device = catalog
.device_by_hashes(data.device_hash, data.circuit_hash)
.or_else(|| catalog.device_longest_match_for_ip(ip_key).map(|(_, dev)| dev));
let Some(device) = device else {
continue;
};
File diff suppressed because it is too large. Load diff
@@ -1,61 +0,0 @@
use anyhow::Result;
use lqos_config::NetworkJson;
use lqos_utils::file_watcher::FileWatcher;
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use std::path::PathBuf;
use tracing::{debug, error, info, warn};
pub static NETWORK_JSON: Lazy<RwLock<NetworkJson>> =
Lazy::new(|| RwLock::new(NetworkJson::default()));
pub fn network_json_watcher() -> Result<()> {
std::thread::Builder::new()
.name("Active Network Tree Watcher".to_string())
.spawn(|| {
debug!("Watching for active network tree changes");
if let Err(e) = watch_for_network_json_changing() {
error!("Error watching for active network tree changes: {:?}", e);
}
})?;
Ok(())
}
fn active_network_tree_path() -> Result<PathBuf> {
NetworkJson::path()
.map_err(|_| anyhow::Error::msg("Unable to create path for active network tree"))
}
/// Fires up a Linux file system watcher that notifies
/// when the active runtime tree changes and triggers a reload.
fn watch_for_network_json_changing() -> Result<()> {
let watch_path = active_network_tree_path()?;
let watch_name = watch_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("active network tree")
.to_string();
let mut watcher = FileWatcher::new(&watch_name, watch_path);
watcher.set_file_exists_callback(load_network_json);
watcher.set_file_created_callback(load_network_json);
watcher.set_file_changed_callback(load_network_json);
loop {
let result = watcher.watch();
info!("active network tree watcher returned: {result:?}");
}
}
fn load_network_json() {
let njs = NetworkJson::load();
if let Ok(mut njs) = njs {
let mut nj = NETWORK_JSON.write();
njs.carry_forward_heatmaps_from(&nj);
*nj = njs;
super::invalidate_circuit_live_snapshot();
super::invalidate_executive_cache_snapshot();
crate::throughput_tracker::THROUGHPUT_TRACKER.refresh_circuit_ids(&nj);
} else {
warn!("Unable to load active runtime network tree");
}
}
@@ -2,7 +2,6 @@ use super::{
FlowAnalysis, FlowbeeEffectiveDirection, get_asn_lat_lon, get_asn_name_and_country,
get_asn_name_by_id,
};
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use crate::throughput_tracker::flow_data::FlowbeeLocalData;
use allocative_derive::Allocative;
use crossbeam_channel::Sender;
@@ -476,9 +475,7 @@ fn enqueue(key: FlowbeeKey, data: FlowbeeLocalData, analysis: FlowAnalysis) {
.get(&key.local_ip)
.and_then(|te| te.circuit_hash)
.or_else(|| {
SHAPED_DEVICES
.load()
.get_circuit_hash_from_ip(&key.local_ip)
lqos_network_devices::shaped_devices_catalog().circuit_hash_for_ip(&key.local_ip)
})
});
+109 -134
View File
@@ -11,7 +11,7 @@ use self::throughput_entry::ThroughputEntry;
use crate::system_stats::SystemStats;
use crate::throughput_tracker::flow_data::FlowbeeEffectiveDirection;
use crate::{
shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES},
lts2_sys::{get_lts_license_status, shared_types::LtsStatus},
stats::TIME_TO_POLL_HOSTS,
throughput_tracker::tracking_data::{FlowApplyContext, ThroughputTracker},
};
@@ -75,34 +75,18 @@ pub(crate) fn circuit_current_qoo(circuit_hash: i64) -> DownUpOrder<Option<f32>>
}
}
fn shaped_device_for_entry<'a>(
shaped: &'a lqos_config::ConfigShapedDevices,
cache: &crate::shaped_devices_tracker::ShapedDeviceHashCache,
ip: &XdpIpAddress,
entry: &ThroughputEntry,
) -> Option<&'a lqos_config::ShapedDevice> {
entry
.device_hash
.and_then(|hash| cache.index_by_device_hash(shaped, hash))
.or_else(|| {
entry
.circuit_hash
.and_then(|hash| cache.index_by_circuit_hash(shaped, hash))
})
.and_then(|idx| shaped.devices.get(idx))
.or_else(|| shaped.get_device_from_ip(ip))
}
fn resolve_circuit_metadata_for_entry(
shaped: &lqos_config::ConfigShapedDevices,
cache: &crate::shaped_devices_tracker::ShapedDeviceHashCache,
catalog: &lqos_network_devices::NetworkDevicesCatalog,
ip: &XdpIpAddress,
entry: &ThroughputEntry,
) -> (String, String) {
let mut circuit_id = entry.circuit_id.clone().unwrap_or_default();
let mut circuit_name = String::new();
if let Some(device) = shaped_device_for_entry(shaped, cache, ip, entry) {
if let Some(device) = catalog
.device_by_hashes(entry.device_hash, entry.circuit_hash)
.or_else(|| catalog.device_longest_match_for_ip(ip).map(|(_, dev)| dev))
{
if circuit_id.is_empty() {
circuit_id = device.circuit_id.clone();
}
@@ -113,13 +97,12 @@ fn resolve_circuit_metadata_for_entry(
}
pub(crate) fn resolve_circuit_metadata_for_ip(ip: &XdpIpAddress) -> (String, String) {
let shaped = SHAPED_DEVICES.load();
let cache = SHAPED_DEVICE_HASH_CACHE.load();
let catalog = lqos_network_devices::network_devices_catalog();
let throughput = THROUGHPUT_TRACKER.raw_data.lock();
let Some(entry) = throughput.get(ip) else {
return (String::new(), String::new());
};
resolve_circuit_metadata_for_entry(&shaped, &cache, ip, entry)
resolve_circuit_metadata_for_entry(&catalog, ip, entry)
}
/// Create the throughput monitor thread, and begin polling for
@@ -270,59 +253,60 @@ fn throughput_task(
// Formerly a "spawn blocking" blob
{
let mut net_json_calc = NETWORK_JSON.write();
timer_metrics.update_cycle = timer_metrics.start.elapsed().as_secs_f64();
net_json_calc.zero_throughput_and_rtt();
timer_metrics.zero_throughput_and_rtt = timer_metrics.start.elapsed().as_secs_f64();
THROUGHPUT_TRACKER.copy_previous_and_reset_rtt();
timer_metrics.copy_previous_and_reset_rtt = timer_metrics.start.elapsed().as_secs_f64();
THROUGHPUT_TRACKER
.apply_new_throughput_counters(&mut net_json_calc, bakery_sender.clone());
timer_metrics.apply_new_throughput_counters =
timer_metrics.start.elapsed().as_secs_f64();
THROUGHPUT_TRACKER.apply_flow_data(FlowApplyContext {
timeout_seconds,
sender: netflow_sender.clone(),
net_json_calc: &mut net_json_calc,
rtt_circuit_tracker: &mut rtt_circuit_tracker,
rtt_by_circuit: &mut rtt_by_circuit,
tcp_retries: &mut tcp_retries,
tcp_retry_packets: &mut tcp_retry_packets,
expired_keys: &mut expired_flows,
lqos_network_devices::with_network_json_write(|net_json_calc| {
timer_metrics.update_cycle = timer_metrics.start.elapsed().as_secs_f64();
net_json_calc.zero_throughput_and_rtt();
timer_metrics.zero_throughput_and_rtt = timer_metrics.start.elapsed().as_secs_f64();
THROUGHPUT_TRACKER.copy_previous_and_reset_rtt();
timer_metrics.copy_previous_and_reset_rtt =
timer_metrics.start.elapsed().as_secs_f64();
THROUGHPUT_TRACKER
.apply_new_throughput_counters(net_json_calc, bakery_sender.clone());
timer_metrics.apply_new_throughput_counters =
timer_metrics.start.elapsed().as_secs_f64();
THROUGHPUT_TRACKER.apply_flow_data(FlowApplyContext {
timeout_seconds,
sender: netflow_sender.clone(),
net_json_calc,
rtt_circuit_tracker: &mut rtt_circuit_tracker,
rtt_by_circuit: &mut rtt_by_circuit,
tcp_retries: &mut tcp_retries,
tcp_retry_packets: &mut tcp_retry_packets,
expired_keys: &mut expired_flows,
});
CIRCUIT_RTT_BUFFERS.store(Arc::new(rtt_by_circuit.clone()));
THROUGHPUT_TRACKER.record_circuit_heatmaps();
let enable_site_heatmaps = lqos_config::load_config()
.map(|config| config.enable_site_heatmaps)
.unwrap_or(true);
net_json_calc.record_site_heatmaps(enable_site_heatmaps);
// Clean up work tables
rtt_circuit_tracker.clear();
rtt_by_circuit.clear();
tcp_retries.clear();
tcp_retry_packets.clear();
expired_flows.clear();
rtt_circuit_tracker.shrink_to_fit();
rtt_by_circuit.shrink_to_fit();
tcp_retries.shrink_to_fit();
tcp_retry_packets.shrink_to_fit();
expired_flows.shrink_to_fit();
timer_metrics.apply_flow_data = timer_metrics.start.elapsed().as_secs_f64();
if bakery_reload_in_progress {
debug!(
"Throughput monitor: skipping queue-stat application during Bakery full reload"
);
} else {
THROUGHPUT_TRACKER.apply_queue_stats(net_json_calc);
}
timer_metrics.apply_queue_stats = timer_metrics.start.elapsed().as_secs_f64();
THROUGHPUT_TRACKER.update_totals();
timer_metrics.update_totals = timer_metrics.start.elapsed().as_secs_f64();
THROUGHPUT_TRACKER.next_cycle();
timer_metrics.next_cycle = timer_metrics.start.elapsed().as_secs_f64();
});
CIRCUIT_RTT_BUFFERS.store(Arc::new(rtt_by_circuit.clone()));
THROUGHPUT_TRACKER.record_circuit_heatmaps();
let enable_site_heatmaps = lqos_config::load_config()
.map(|config| config.enable_site_heatmaps)
.unwrap_or(true);
net_json_calc.record_site_heatmaps(enable_site_heatmaps);
// Clean up work tables
rtt_circuit_tracker.clear();
rtt_by_circuit.clear();
tcp_retries.clear();
tcp_retry_packets.clear();
expired_flows.clear();
rtt_circuit_tracker.shrink_to_fit();
rtt_by_circuit.shrink_to_fit();
tcp_retries.shrink_to_fit();
tcp_retry_packets.shrink_to_fit();
expired_flows.shrink_to_fit();
timer_metrics.apply_flow_data = timer_metrics.start.elapsed().as_secs_f64();
if bakery_reload_in_progress {
debug!(
"Throughput monitor: skipping queue-stat application during Bakery full reload"
);
} else {
THROUGHPUT_TRACKER.apply_queue_stats(&mut net_json_calc);
}
timer_metrics.apply_queue_stats = timer_metrics.start.elapsed().as_secs_f64();
THROUGHPUT_TRACKER.update_totals();
timer_metrics.update_totals = timer_metrics.start.elapsed().as_secs_f64();
THROUGHPUT_TRACKER.next_cycle();
timer_metrics.next_cycle = timer_metrics.start.elapsed().as_secs_f64();
std::mem::drop(net_json_calc);
timer_metrics.finish_update_cycle = timer_metrics.start.elapsed().as_secs_f64();
let duration_ms = start.elapsed().as_micros();
TIME_TO_POLL_HOSTS.store(duration_ms as u64, std::sync::atomic::Ordering::Relaxed);
@@ -441,8 +425,7 @@ pub fn top_n(start: u32, end: u32) -> BusResponse {
let tp_cycle = THROUGHPUT_TRACKER
.cycle
.load(std::sync::atomic::Ordering::Relaxed);
let shaped = SHAPED_DEVICES.load();
let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load();
let catalog = lqos_network_devices::network_devices_catalog();
THROUGHPUT_TRACKER
.raw_data
.lock()
@@ -451,7 +434,7 @@ pub fn top_n(start: u32, end: u32) -> BusResponse {
.filter(|(_k, d)| retire_check(tp_cycle, d.most_recent_cycle))
.map(|(k, te)| {
let (circuit_id, _circuit_name) =
resolve_circuit_metadata_for_entry(&shaped, &shaped_cache, k, te);
resolve_circuit_metadata_for_entry(&catalog, k, te);
(
*k,
te.actual_bytes_per_second,
@@ -489,8 +472,7 @@ pub fn top_n_up(start: u32, end: u32) -> BusResponse {
let tp_cycle = THROUGHPUT_TRACKER
.cycle
.load(std::sync::atomic::Ordering::Relaxed);
let shaped = SHAPED_DEVICES.load();
let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load();
let catalog = lqos_network_devices::network_devices_catalog();
THROUGHPUT_TRACKER
.raw_data
.lock()
@@ -499,7 +481,7 @@ pub fn top_n_up(start: u32, end: u32) -> BusResponse {
.filter(|(_k, d)| retire_check(tp_cycle, d.most_recent_cycle))
.map(|(k, te)| {
let (circuit_id, _circuit_name) =
resolve_circuit_metadata_for_entry(&shaped, &shaped_cache, k, te);
resolve_circuit_metadata_for_entry(&catalog, k, te);
(
*k,
te.actual_bytes_per_second,
@@ -541,9 +523,9 @@ pub fn circuit_heatmaps() -> BusResponse {
return BusResponse::CircuitHeatmaps(Vec::new());
}
let devices = SHAPED_DEVICES.load();
let catalog = lqos_network_devices::network_devices_catalog();
let mut circuit_meta: FxHashMap<i64, (String, String)> = FxHashMap::default();
devices.devices.iter().for_each(|device| {
catalog.iter_all_devices().for_each(|device| {
circuit_meta
.entry(device.circuit_hash)
.or_insert_with(|| (device.circuit_id.clone(), device.circuit_name.clone()));
@@ -580,25 +562,26 @@ pub fn site_heatmaps() -> BusResponse {
return BusResponse::SiteHeatmaps(Vec::new());
}
let reader = NETWORK_JSON.read();
let mut rows: Vec<SiteHeatmapData> = reader
.get_nodes_when_ready()
.iter()
.filter_map(|node| {
if node.name == "Root" || node.name.parse::<std::net::IpAddr>().is_ok() {
return None;
}
node.heatmap.as_ref().map(|heatmap| SiteHeatmapData {
site_name: node.name.clone(),
node_type: node.node_type.clone(),
depth: node.parents.len().saturating_sub(1),
blocks: heatmap.blocks(),
qoq_blocks: node.qoq_heatmap.as_ref().map(|heatmap| heatmap.blocks()),
lqos_network_devices::with_network_json_read(|net_json| {
let mut rows: Vec<SiteHeatmapData> = net_json
.get_nodes_when_ready()
.iter()
.filter_map(|node| {
if node.name == "Root" || node.name.parse::<std::net::IpAddr>().is_ok() {
return None;
}
node.heatmap.as_ref().map(|heatmap| SiteHeatmapData {
site_name: node.name.clone(),
node_type: node.node_type.clone(),
depth: node.parents.len().saturating_sub(1),
blocks: heatmap.blocks(),
qoq_blocks: node.qoq_heatmap.as_ref().map(|heatmap| heatmap.blocks()),
})
})
})
.collect();
rows.sort_by(|a, b| a.site_name.cmp(&b.site_name));
BusResponse::SiteHeatmaps(rows)
.collect();
rows.sort_by(|a, b| a.site_name.cmp(&b.site_name));
BusResponse::SiteHeatmaps(rows)
})
}
/// Retrieve per-ASN heatmap data for the executive summary.
@@ -640,8 +623,7 @@ pub fn worst_n(start: u32, end: u32) -> BusResponse {
let tp_cycle = THROUGHPUT_TRACKER
.cycle
.load(std::sync::atomic::Ordering::Relaxed);
let shaped = SHAPED_DEVICES.load();
let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load();
let catalog = lqos_network_devices::network_devices_catalog();
THROUGHPUT_TRACKER
.raw_data
.lock()
@@ -656,7 +638,7 @@ pub fn worst_n(start: u32, end: u32) -> BusResponse {
.filter(|(_k, te)| te.median_latency().is_some())
.map(|(k, te)| {
let (circuit_id, _circuit_name) =
resolve_circuit_metadata_for_entry(&shaped, &shaped_cache, k, te);
resolve_circuit_metadata_for_entry(&catalog, k, te);
(
*k,
te.actual_bytes_per_second,
@@ -694,8 +676,7 @@ pub fn worst_n_retransmits(start: u32, end: u32) -> BusResponse {
let tp_cycle = THROUGHPUT_TRACKER
.cycle
.load(std::sync::atomic::Ordering::Relaxed);
let shaped = SHAPED_DEVICES.load();
let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load();
let catalog = lqos_network_devices::network_devices_catalog();
THROUGHPUT_TRACKER
.raw_data
.lock()
@@ -705,7 +686,7 @@ pub fn worst_n_retransmits(start: u32, end: u32) -> BusResponse {
.filter(|(_k, te)| te.median_latency().is_some())
.map(|(k, te)| {
let (circuit_id, _circuit_name) =
resolve_circuit_metadata_for_entry(&shaped, &shaped_cache, k, te);
resolve_circuit_metadata_for_entry(&catalog, k, te);
(
*k,
te.actual_bytes_per_second,
@@ -751,8 +732,7 @@ pub fn best_n(start: u32, end: u32) -> BusResponse {
let tp_cycle = THROUGHPUT_TRACKER
.cycle
.load(std::sync::atomic::Ordering::Relaxed);
let shaped = SHAPED_DEVICES.load();
let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load();
let catalog = lqos_network_devices::network_devices_catalog();
THROUGHPUT_TRACKER
.raw_data
.lock()
@@ -767,7 +747,7 @@ pub fn best_n(start: u32, end: u32) -> BusResponse {
.filter(|(_k, te)| te.median_latency().is_some())
.map(|(k, te)| {
let (circuit_id, _circuit_name) =
resolve_circuit_metadata_for_entry(&shaped, &shaped_cache, k, te);
resolve_circuit_metadata_for_entry(&catalog, k, te);
(
*k,
te.actual_bytes_per_second,
@@ -991,21 +971,20 @@ fn current_host_counts() -> (u32, u32) {
/// Gather headline metrics for the Executive Summary header cards.
pub fn executive_summary_header() -> BusResponse {
let devices = SHAPED_DEVICES.load();
let circuit_count = devices
.devices
.iter()
.map(|device| device.circuit_hash)
.collect::<FxHashSet<_>>()
.len() as u64;
let device_count = devices.devices.len() as u64;
let catalog = lqos_network_devices::network_devices_catalog();
let mut circuits: FxHashSet<i64> = FxHashSet::default();
let mut device_count = 0_u64;
for device in catalog.iter_all_devices() {
device_count = device_count.saturating_add(1);
circuits.insert(device.circuit_hash);
}
let circuit_count = circuits.len() as u64;
let site_count = {
let reader = NETWORK_JSON.read();
let total_nodes = reader.get_nodes_when_ready().len();
let site_count = lqos_network_devices::with_network_json_read(|net_json| {
let total_nodes = net_json.get_nodes_when_ready().len();
// Remove the synthetic root node when counting sites.
total_nodes.saturating_sub(1) as u64
};
});
let (total_hosts, shaped_hosts) = current_host_counts();
let mapped_ip_count = shaped_hosts as u64;
@@ -1206,8 +1185,7 @@ pub fn top_flows(n: u32, flow_type: TopFlowType) -> BusResponse {
}
}
let shaped = SHAPED_DEVICES.load();
let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load();
let catalog = lqos_network_devices::network_devices_catalog();
let throughput = THROUGHPUT_TRACKER.raw_data.lock();
let result = table
@@ -1215,12 +1193,9 @@ pub fn top_flows(n: u32, flow_type: TopFlowType) -> BusResponse {
.take(n as usize)
.map(|(ip, flow)| {
let geo = get_asn_name_and_country(ip.remote_ip.as_ip());
let (circuit_id, circuit_name) = throughput
.get(&ip.local_ip)
.map(|te| {
resolve_circuit_metadata_for_entry(&shaped, &shaped_cache, &ip.local_ip, te)
})
.map(|te| resolve_circuit_metadata_for_entry(&catalog, &ip.local_ip, te))
.unwrap_or_default();
lqos_bus::FlowbeeSummaryData {
@@ -1424,12 +1399,12 @@ mod compatibility_tests {
CIRCUIT_RTT_BUFFERS, Lts2Circuit, circuit_current_qoo, circuit_current_rtt_p50_nanos,
resolve_circuit_metadata_for_entry,
};
use crate::shaped_devices_tracker::ShapedDeviceHashCache;
use crate::throughput_tracker::flow_data::{FlowbeeEffectiveDirection, RttData};
use crate::throughput_tracker::throughput_entry::ThroughputEntry;
use fxhash::FxHashMap;
use lqos_bus::TcHandle;
use lqos_config::{ConfigShapedDevices, ShapedDevice};
use lqos_network_devices::{NetworkDevicesCatalog, ShapedDevicesCatalog};
use lqos_utils::XdpIpAddress;
use lqos_utils::qoo::QoqScores;
use lqos_utils::qoq_heatmap::TemporalQoqHeatmap;
@@ -1504,7 +1479,8 @@ mod compatibility_tests {
ipv4: vec![(Ipv4Addr::new(192, 168, 1, 10), 32)],
..Default::default()
}]);
let cache = ShapedDeviceHashCache::default();
let shaped_catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped));
let catalog = NetworkDevicesCatalog::from_snapshots(shaped_catalog, Arc::new(Vec::new()));
let ip = XdpIpAddress::from_ip("192.168.1.10".parse().expect("test IP should parse"));
let entry = ThroughputEntry {
circuit_id: None,
@@ -1538,8 +1514,7 @@ mod compatibility_tests {
qoq: QoqScores::default(),
};
let (circuit_id, circuit_name) =
resolve_circuit_metadata_for_entry(&shaped, &cache, &ip, &entry);
let (circuit_id, circuit_name) = resolve_circuit_metadata_for_entry(&catalog, &ip, &entry);
assert_eq!(circuit_id, "circuit-1");
assert_eq!(circuit_name, "Circuit Alpha");
@@ -1,7 +1,4 @@
use crate::lts2_sys::shared_types::{CircuitCakeDrops, CircuitCakeMarks};
use crate::shaped_devices_tracker::{
NETWORK_JSON, SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES, shaped_device_from_hashes_or_ip,
};
use crate::system_stats::SystemStats;
use crate::throughput_tracker::flow_data::ALL_FLOWS;
use crate::throughput_tracker::flow_data::FlowbeeEffectiveDirection;
@@ -79,13 +76,14 @@ fn queue_metrics_available(config: &lqos_config::Config) -> bool {
}
fn resolved_circuit_hash_for_submission(
shaped: &lqos_config::ConfigShapedDevices,
cache: &crate::shaped_devices_tracker::ShapedDeviceHashCache,
catalog: &lqos_network_devices::NetworkDevicesCatalog,
ip: &XdpIpAddress,
entry: &ThroughputEntry,
) -> Option<i64> {
entry.circuit_hash.or_else(|| {
shaped_device_from_hashes_or_ip(shaped, cache, ip, entry.device_hash, entry.circuit_hash)
catalog
.device_by_hashes(entry.device_hash, entry.circuit_hash)
.or_else(|| catalog.device_longest_match_for_ip(ip).map(|(_, dev)| dev))
.map(|device| device.circuit_hash)
})
}
@@ -321,23 +319,14 @@ pub(crate) fn submit_throughput_stats(
let mut circuit_throughput: FxHashMap<i64, CircuitThroughputTemp> = FxHashMap::default();
let mut circuit_retransmits: FxHashMap<i64, DownUpOrder<u64>> = FxHashMap::default();
let shaped_devices = SHAPED_DEVICES.load();
let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load();
let catalog = lqos_network_devices::network_devices_catalog();
const CRAZY_LIMIT: u64 = 8; // 8x the max bandwidth
let plan_lookup: FxHashMap<i64, (u64, u64)> = shaped_devices
.devices
.iter()
// Bandwidth: mbps * 1_000_000 to bytes
.map(|d| {
(
d.circuit_hash,
(
d.download_max_mbps.round() as u64 * 1_000_000 * CRAZY_LIMIT,
d.upload_max_mbps.round() as u64 * 1_000_000 * CRAZY_LIMIT,
),
)
})
.collect();
let mut plan_lookup: FxHashMap<i64, (u64, u64)> = FxHashMap::default();
for device in catalog.iter_all_devices() {
let entry = plan_lookup.entry(device.circuit_hash).or_insert((0, 0));
entry.0 = entry.0.max(device.download_max_mbps.round() as u64 * 1_000_000 * CRAZY_LIMIT);
entry.1 = entry.1.max(device.upload_max_mbps.round() as u64 * 1_000_000 * CRAZY_LIMIT);
}
THROUGHPUT_TRACKER
.raw_data
@@ -345,7 +334,7 @@ pub(crate) fn submit_throughput_stats(
.iter()
.filter_map(|(ip, h)| {
let circuit_hash =
resolved_circuit_hash_for_submission(&shaped_devices, &shaped_cache, ip, h)?;
resolved_circuit_hash_for_submission(&catalog, ip, h)?;
h.actual_bytes_per_second
.not_zero()
.then_some((circuit_hash, h))
@@ -388,7 +377,7 @@ pub(crate) fn submit_throughput_stats(
.iter()
.filter_map(|(ip, h)| {
let circuit_hash =
resolved_circuit_hash_for_submission(&shaped_devices, &shaped_cache, ip, h)?;
resolved_circuit_hash_for_submission(&catalog, ip, h)?;
(h.tcp_retransmits.not_zero() && !crazy_values.contains(&circuit_hash))
.then_some((circuit_hash, h))
})
@@ -496,10 +485,9 @@ pub(crate) fn submit_throughput_stats(
}
// Network tree stats
let tree = {
let reader = NETWORK_JSON.read();
reader.get_nodes_when_ready().clone()
};
let tree = lqos_network_devices::with_network_json_read(|net_json| {
net_json.get_nodes_when_ready().clone()
});
let mut site_throughput: Vec<crate::lts2_sys::shared_types::SiteThroughput> = Vec::new();
let mut site_retransmits: Vec<crate::lts2_sys::shared_types::SiteRetransmits> = Vec::new();
let mut site_rtt: Vec<crate::lts2_sys::shared_types::SiteRtt> = Vec::new();
@@ -648,7 +636,24 @@ fn combined_devices_network_hash() -> anyhow::Result<i64> {
let sd_path = std::path::Path::new(&cfg.lqos_directory).join("ShapedDevices.csv");
read_to_string(sd_path)?
};
let combined = format!("{}\n{}", nj_as_string, sd_as_string);
let dynamic_snapshot = lqos_network_devices::dynamic_circuits_snapshot();
let mut dynamic_devices: Vec<ShapedDevice> = dynamic_snapshot
.iter()
.map(|circuit| circuit.shaped.clone())
.collect();
dynamic_devices.sort_by(|a, b| {
(
a.circuit_id.trim().to_ascii_lowercase(),
a.device_id.trim().to_ascii_lowercase(),
)
.cmp(&(
b.circuit_id.trim().to_ascii_lowercase(),
b.device_id.trim().to_ascii_lowercase(),
))
});
let dynamic_as_string = serde_json::to_string(&dynamic_devices)?;
let combined = format!("{}\n{}\n{}", nj_as_string, sd_as_string, dynamic_as_string);
let hash = hash_to_i64(&combined);
Ok(hash)
@@ -668,13 +673,13 @@ static LTS2_HASH: AtomicI64 = AtomicI64::new(0);
/// transmission to Insight
fn load_local_shaped_devices() -> anyhow::Result<Vec<ShapedDevice>> {
let cfg = load_config()?;
let dynamic_snapshot = lqos_network_devices::dynamic_circuits_snapshot();
if integration_ingress_enabled(cfg.as_ref()) {
let topology_import = TopologyImportFile::load(cfg.as_ref())?
.ok_or_else(|| anyhow::anyhow!("topology_import.json does not exist"))?;
return Ok(topology_import
.into_imported_bundle()
.shaped_devices
.devices);
let mut devices = topology_import.into_imported_bundle().shaped_devices.devices;
devices.extend(dynamic_snapshot.iter().map(|circuit| circuit.shaped.clone()));
return Ok(devices);
}
let sd_path = Path::new(&cfg.lqos_directory).join("ShapedDevices.csv");
if !sd_path.exists() {
@@ -695,6 +700,7 @@ fn load_local_shaped_devices() -> anyhow::Result<Vec<ShapedDevice>> {
anyhow::bail!("Error reading ShapedDevices.csv");
}
}
devices.extend(dynamic_snapshot.iter().map(|circuit| circuit.shaped.clone()));
Ok(devices)
}
@@ -723,10 +729,12 @@ mod tests {
use crate::throughput_tracker::throughput_entry::ThroughputEntry;
use lqos_bus::TcHandle;
use lqos_config::{ConfigShapedDevices, QueueMode, ShapedDevice};
use lqos_network_devices::{NetworkDevicesCatalog, ShapedDevicesCatalog};
use lqos_utils::XdpIpAddress;
use lqos_utils::qoo::QoqScores;
use lqos_utils::units::DownUpOrder;
use std::net::Ipv4Addr;
use std::sync::Arc;
#[test]
fn test_rate_for_submission_small_rates() {
@@ -793,9 +801,12 @@ mod tests {
device_id: "device-1".to_string(),
parent_node: "Parent-A".to_string(),
ipv4: vec![(Ipv4Addr::new(192, 168, 1, 10), 32)],
circuit_hash: hash_to_i64("circuit-1"),
device_hash: hash_to_i64("device-1"),
..Default::default()
}]);
let cache = crate::shaped_devices_tracker::ShapedDeviceHashCache::default();
let shaped_catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped));
let catalog = NetworkDevicesCatalog::from_snapshots(shaped_catalog, Arc::new(Vec::new()));
let ip = XdpIpAddress::from_ip("192.168.1.10".parse().expect("test IP should parse"));
let entry = ThroughputEntry {
circuit_id: Some("circuit-1".to_string()),
@@ -829,7 +840,7 @@ mod tests {
qoq: QoqScores::default(),
};
let circuit_hash = resolved_circuit_hash_for_submission(&shaped, &cache, &ip, &entry);
let circuit_hash = resolved_circuit_hash_for_submission(&catalog, &ip, &entry);
assert_eq!(circuit_hash, Some(hash_to_i64("circuit-1")));
}
@@ -8,11 +8,10 @@ use super::{
};
use crate::throughput_tracker::CIRCUIT_RTT_BUFFERS;
use crate::{
shaped_devices_tracker::{SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES},
stats::HIGH_WATERMARK,
throughput_tracker::flow_data::{FlowbeeEffectiveDirection, expire_rtt_flows, flowbee_rtt_map},
};
use fxhash::FxHashMap;
use fxhash::{FxHashMap, FxHashSet};
use lqos_bakery::BakeryCommands;
use lqos_bus::TcHandle;
use lqos_config::NetworkJson;
@@ -172,10 +171,12 @@ impl ThroughputTracker {
return;
}
let shaped_devices = SHAPED_DEVICES.load();
let catalog = lqos_network_devices::network_devices_catalog();
let mut capacity_lookup: FxHashMap<i64, (f32, f32)> = FxHashMap::default();
capacity_lookup.reserve(shaped_devices.devices.len());
shaped_devices.devices.iter().for_each(|device| {
capacity_lookup.reserve(
catalog.shaped_devices().devices_len() + catalog.dynamic_circuits().len(),
);
catalog.iter_all_devices().for_each(|device| {
let entry = capacity_lookup
.entry(device.circuit_hash)
.or_insert((device.download_max_mbps, device.upload_max_mbps));
@@ -409,45 +410,36 @@ impl ThroughputTracker {
}
fn shaped_device_for_ip_or_hashes<'a>(
shaped: &'a lqos_config::ConfigShapedDevices,
cache: &crate::shaped_devices_tracker::ShapedDeviceHashCache,
catalog: &'a lqos_network_devices::NetworkDevicesCatalog,
ip: &XdpIpAddress,
device_hash: Option<i64>,
circuit_hash: Option<i64>,
) -> Option<&'a lqos_config::ShapedDevice> {
if let Some(device_hash) = device_hash
&& let Some(idx) = cache.index_by_device_hash(shaped, device_hash)
{
return shaped.devices.get(idx);
if let Some(device) = catalog.device_by_hashes(device_hash, circuit_hash) {
return Some(device);
}
if let Some(circuit_hash) = circuit_hash
&& let Some(idx) = cache.index_by_circuit_hash(shaped, circuit_hash)
{
return shaped.devices.get(idx);
}
shaped.get_device_from_ip(ip)
catalog
.device_longest_match_for_ip(ip)
.map(|(_net, device)| device)
}
fn lookup_network_parents_from_ip_or_hashes(
shaped: &lqos_config::ConfigShapedDevices,
cache: &crate::shaped_devices_tracker::ShapedDeviceHashCache,
catalog: &lqos_network_devices::NetworkDevicesCatalog,
ip: &XdpIpAddress,
device_hash: Option<i64>,
circuit_hash: Option<i64>,
lock: &NetworkJson,
) -> Option<Vec<usize>> {
Self::shaped_device_for_ip_or_hashes(shaped, cache, ip, device_hash, circuit_hash)
Self::shaped_device_for_ip_or_hashes(catalog, ip, device_hash, circuit_hash)
.and_then(|device| lock.get_parents_for_circuit_id(&device.parent_node))
}
pub(crate) fn refresh_circuit_ids(&self, lock: &NetworkJson) {
let shaped = SHAPED_DEVICES.load();
let cache = SHAPED_DEVICE_HASH_CACHE.load();
let catalog = lqos_network_devices::network_devices_catalog();
let mut raw_data = self.raw_data.lock();
raw_data.iter_mut().for_each(|(ip, data)| {
let shaped_device = Self::shaped_device_for_ip_or_hashes(
&shaped,
&cache,
&catalog,
ip,
data.device_hash,
data.circuit_hash,
@@ -473,12 +465,56 @@ impl ThroughputTracker {
net_json_calc: &mut NetworkJson,
bakery_sender: crossbeam_channel::Sender<BakeryCommands>,
) {
const MAX_UNKNOWN_OBSERVATIONS_PER_TICK: usize = 256;
let mut reported_dynamic_device_hashes: FxHashSet<i64> = FxHashSet::default();
let mut reported_dynamic_circuit_hashes: FxHashSet<i64> = FxHashSet::default();
let mut observations: Vec<lqos_network_devices::CircuitObservation> = Vec::new();
let mut changed_circuits = HashSet::new();
let self_cycle = self.cycle.load(std::sync::atomic::Ordering::Relaxed);
let shaped = SHAPED_DEVICES.load();
let cache = SHAPED_DEVICE_HASH_CACHE.load();
let catalog = lqos_network_devices::network_devices_catalog();
let mut raw_data = self.raw_data.lock();
#[allow(clippy::too_many_arguments)]
fn record_dynamic_observation(
observations: &mut Vec<lqos_network_devices::CircuitObservation>,
reported_dynamic_device_hashes: &mut FxHashSet<i64>,
reported_dynamic_circuit_hashes: &mut FxHashSet<i64>,
catalog: &lqos_network_devices::NetworkDevicesCatalog,
ip: XdpIpAddress,
device_hash: Option<i64>,
circuit_hash: Option<i64>,
) {
if catalog.dynamic_circuits().is_empty() {
return;
}
if let Some(device_hash) = device_hash
&& catalog.is_dynamic_device_hash(device_hash)
&& reported_dynamic_device_hashes.insert(device_hash)
{
observations.push(lqos_network_devices::CircuitObservation {
ip,
device_hash: Some(device_hash),
circuit_hash,
});
return;
}
if let Some(circuit_hash) = circuit_hash
&& catalog.is_dynamic_circuit_hash(circuit_hash)
&& reported_dynamic_circuit_hashes.insert(circuit_hash)
{
observations.push(lqos_network_devices::CircuitObservation {
ip,
device_hash,
circuit_hash: Some(circuit_hash),
});
}
}
throughput_for_each(&mut |xdp_ip, counts| {
let reduced = ReducedHostCounters::from_counters(counts);
if let Some(entry) = raw_data.get_mut(xdp_ip) {
@@ -493,8 +529,7 @@ impl ThroughputTracker {
entry.tc_handle = reduced.tc_handle;
let shaped_device = Self::shaped_device_for_ip_or_hashes(
&shaped,
&cache,
&catalog,
xdp_ip,
reduced.device_hash,
reduced.circuit_hash,
@@ -510,10 +545,35 @@ impl ThroughputTracker {
entry.circuit_hash = resolved_circuit_hash;
entry.device_hash = resolved_device_hash;
if hashes_changed {
let shaped_device = Self::shaped_device_for_ip_or_hashes(
&catalog,
xdp_ip,
entry.device_hash,
entry.circuit_hash,
);
entry.circuit_id = shaped_device.map(|d| d.circuit_id.clone());
entry.network_json_parents = shaped_device.and_then(|device| {
net_json_calc.get_parents_for_circuit_id(&device.parent_node)
});
if shaped_device.is_none() {
record_dynamic_observation(
&mut observations,
&mut reported_dynamic_device_hashes,
&mut reported_dynamic_circuit_hashes,
&catalog,
*xdp_ip,
entry.device_hash,
entry.circuit_hash,
);
if observations.len() < MAX_UNKNOWN_OBSERVATIONS_PER_TICK {
observations.push(lqos_network_devices::CircuitObservation {
ip: *xdp_ip,
device_hash: entry.device_hash,
circuit_hash: entry.circuit_hash,
});
}
}
}
if entry.packets != entry.prev_packets {
entry.most_recent_cycle = self_cycle;
@@ -522,6 +582,25 @@ impl ThroughputTracker {
changed_circuits.insert(circuit_hash);
}
record_dynamic_observation(
&mut observations,
&mut reported_dynamic_device_hashes,
&mut reported_dynamic_circuit_hashes,
&catalog,
*xdp_ip,
entry.device_hash,
entry.circuit_hash,
);
if shaped_device.is_none()
&& observations.len() < MAX_UNKNOWN_OBSERVATIONS_PER_TICK
{
observations.push(lqos_network_devices::CircuitObservation {
ip: *xdp_ip,
device_hash: entry.device_hash,
circuit_hash: entry.circuit_hash,
});
}
if let Some(parents) = &entry.network_json_parents {
net_json_calc.add_throughput_cycle(
parents,
@@ -574,8 +653,7 @@ impl ThroughputTracker {
}
} else {
let shaped_device = Self::shaped_device_for_ip_or_hashes(
&shaped,
&cache,
&catalog,
xdp_ip,
reduced.device_hash,
reduced.circuit_hash,
@@ -587,6 +665,24 @@ impl ThroughputTracker {
.device_hash
.or_else(|| shaped_device.map(|device| device.device_hash));
let circuit_id = shaped_device.map(|d| d.circuit_id.clone());
record_dynamic_observation(
&mut observations,
&mut reported_dynamic_device_hashes,
&mut reported_dynamic_circuit_hashes,
&catalog,
*xdp_ip,
device_hash,
circuit_hash,
);
if shaped_device.is_none() && observations.len() < MAX_UNKNOWN_OBSERVATIONS_PER_TICK {
observations.push(lqos_network_devices::CircuitObservation {
ip: *xdp_ip,
device_hash,
circuit_hash,
});
}
// Call the Bakery Queue Creation for new circuits
if let Some(circuit_hash) = circuit_hash
&& let Ok(config) = lqos_config::load_config()
@@ -610,8 +706,7 @@ impl ThroughputTracker {
circuit_hash,
device_hash,
network_json_parents: Self::lookup_network_parents_from_ip_or_hashes(
&shaped,
&cache,
&catalog,
xdp_ip,
device_hash,
circuit_hash,
@@ -647,6 +742,10 @@ impl ThroughputTracker {
}
});
if !observations.is_empty() {
lqos_network_devices::report_observations(&observations);
}
if !changed_circuits.is_empty()
&& let Err(e) = bakery_sender.send(BakeryCommands::OnCircuitActivity {
circuit_ids: changed_circuits,
@@ -1319,20 +1418,19 @@ fn tcp_retransmit_loss_proxy(retransmits: u64, packets: u64) -> Option<LossMeasu
#[cfg(test)]
mod tests {
use super::{ThroughputEntry, ThroughputTracker};
use crate::shaped_devices_tracker::{SHAPED_DEVICES, ShapedDeviceHashCache};
use crate::test_support::runtime_config_test_lock;
use crate::throughput_tracker::flow_data::{RttBuffer, RttData};
use lqos_bus::TcHandle;
use lqos_config::{ConfigShapedDevices, ShapedDevice};
use lqos_network_devices::{NetworkDevicesCatalog, ShapedDevicesCatalog};
use lqos_utils::{XdpIpAddress, hash_to_i64};
use lqos_utils::qoo::QoqScores;
use lqos_utils::units::DownUpOrder;
use std::ffi::OsString;
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, MutexGuard};
use crate::throughput_tracker::flow_data::{RttBuffer, RttData};
use lqos_utils::qoo::QoqScores;
use lqos_utils::units::DownUpOrder;
fn test_runtime_dir(name: &str) -> PathBuf {
let unique = format!(
"{}-{}-{}",
@@ -1349,10 +1447,18 @@ mod tests {
fn write_test_config(runtime_dir: &Path) -> PathBuf {
let config_path = runtime_dir.join("lqos.test.toml");
let raw = include_str!("../../../lqos_config/src/etc/v15/example.toml").replace(
"lqos_directory = \"/opt/libreqos/src\"",
&format!("lqos_directory = \"{}\"", runtime_dir.display()),
);
let raw = include_str!("../../../lqos_config/src/etc/v15/example.toml")
.replace(
"lqos_directory = \"/opt/libreqos/src\"",
&format!("lqos_directory = \"{}\"", runtime_dir.display()),
)
.replace(
"state_directory = \"/opt/libreqos/state\"",
&format!(
"state_directory = \"{}\"",
runtime_dir.join("state").display()
),
);
std::fs::write(&config_path, raw).expect("test config should be written");
config_path
}
@@ -1361,7 +1467,6 @@ mod tests {
_guard: MutexGuard<'static, ()>,
old_lqos_config: Option<OsString>,
old_lqos_directory: Option<OsString>,
old_shaped: Arc<ConfigShapedDevices>,
}
impl HeatmapTestContext {
@@ -1373,7 +1478,6 @@ mod tests {
let config_path = write_test_config(&runtime_dir);
let old_lqos_config = std::env::var_os("LQOS_CONFIG");
let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY");
let old_shaped = SHAPED_DEVICES.load_full();
unsafe {
std::env::set_var("LQOS_CONFIG", &config_path);
std::env::set_var("LQOS_DIRECTORY", &runtime_dir);
@@ -1383,7 +1487,6 @@ mod tests {
_guard: guard,
old_lqos_config,
old_lqos_directory,
old_shaped,
}
}
}
@@ -1398,7 +1501,6 @@ mod tests {
Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) },
None => unsafe { std::env::remove_var("LQOS_DIRECTORY") },
}
SHAPED_DEVICES.store(self.old_shaped.clone());
lqos_config::clear_cached_config();
}
}
@@ -1459,12 +1561,12 @@ mod tests {
ipv4: vec![(Ipv4Addr::new(192, 168, 1, 10), 32)],
..Default::default()
}]);
let cache = ShapedDeviceHashCache::default();
let shaped_catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped));
let catalog = NetworkDevicesCatalog::from_snapshots(shaped_catalog, Arc::new(Vec::new()));
let ip = XdpIpAddress::from_ip("192.168.1.10".parse().expect("test IP should parse"));
let matched =
ThroughputTracker::shaped_device_for_ip_or_hashes(&shaped, &cache, &ip, None, None)
.expect("lookup should resolve by IP");
let matched = ThroughputTracker::shaped_device_for_ip_or_hashes(&catalog, &ip, None, None)
.expect("lookup should resolve by IP");
assert_eq!(matched.circuit_hash, hash_to_i64("circuit-1"));
assert_eq!(matched.device_hash, hash_to_i64("device-1"));
@@ -1473,17 +1575,7 @@ mod tests {
#[test]
fn circuit_and_global_heatmaps_suppress_low_confidence_retransmits() {
let _ctx = HeatmapTestContext::new("retransmit-suppressed");
let mut shaped = ConfigShapedDevices::default();
shaped.replace_with_new_data(vec![ShapedDevice {
circuit_id: "circuit-1".to_string(),
device_id: "device-1".to_string(),
parent_node: "Parent-A".to_string(),
download_max_mbps: 100.0,
upload_max_mbps: 100.0,
..Default::default()
}]);
let circuit_hash = shaped.devices[0].circuit_hash;
SHAPED_DEVICES.store(Arc::new(shaped));
let circuit_hash = hash_to_i64("circuit-1");
let tracker = ThroughputTracker::new();
tracker.raw_data.lock().insert(
@@ -1519,17 +1611,7 @@ mod tests {
#[test]
fn circuit_and_global_heatmaps_keep_qualified_retransmits() {
let _ctx = HeatmapTestContext::new("retransmit-qualified");
let mut shaped = ConfigShapedDevices::default();
shaped.replace_with_new_data(vec![ShapedDevice {
circuit_id: "circuit-2".to_string(),
device_id: "device-2".to_string(),
parent_node: "Parent-B".to_string(),
download_max_mbps: 100.0,
upload_max_mbps: 100.0,
..Default::default()
}]);
let circuit_hash = shaped.devices[0].circuit_hash;
SHAPED_DEVICES.store(Arc::new(shaped));
let circuit_hash = hash_to_i64("circuit-2");
let tracker = ThroughputTracker::new();
tracker.raw_data.lock().insert(
File diff suppressed because it is too large. Load diff
+1 -37
View File
@@ -1,45 +1,9 @@
use lqos_bus::BusResponse;
use lqos_config::ConfigShapedDevices;
use lqos_topology_compile::TopologyImportFile;
pub fn validate_shaped_devices_csv() -> BusResponse {
let result: anyhow::Result<ConfigShapedDevices> = match lqos_config::load_config() {
Ok(config) => {
if integration_ingress_enabled(config.as_ref()) {
match TopologyImportFile::load(config.as_ref()) {
Ok(Some(topology_import)) => {
Ok(topology_import.into_imported_bundle().shaped_devices)
}
Ok(None) => Ok(ConfigShapedDevices::default()),
Err(err) => Err(err),
}
} else {
ConfigShapedDevices::load().map_err(anyhow::Error::from)
}
}
Err(err) => Err(anyhow::Error::from(err)),
};
let result = lqos_network_devices::load_shaped_devices();
match result {
Ok(..) => BusResponse::Ack,
Err(e) => BusResponse::ShapedDevicesValidation(format!("{e:#?}")),
}
}
fn integration_ingress_enabled(config: &lqos_config::Config) -> bool {
config.uisp_integration.enable_uisp
|| config.splynx_integration.enable_splynx
|| config
.netzur_integration
.as_ref()
.is_some_and(|integration| integration.enable_netzur)
|| config
.visp_integration
.as_ref()
.is_some_and(|integration| integration.enable_visp)
|| config.powercode_integration.enable_powercode
|| config.sonar_integration.enable_sonar
|| config
.wispgate_integration
.as_ref()
.is_some_and(|integration| integration.enable_wispgate)
}
+19
View File
@@ -3,6 +3,23 @@ use lqos_bus::{BusResponse, FlowbeeSummaryData};
use lqos_utils::packet_scale::scale_bits;
use ratatui::prelude::*;
fn truncate_by_chars(input: &str, max_chars: usize) -> String {
input.chars().take(max_chars).collect()
}
fn flow_circuit_label(flow: &FlowbeeSummaryData) -> String {
let candidate = if !flow.circuit_name.trim().is_empty() {
flow.circuit_name.trim()
} else {
flow.circuit_id.trim()
};
if candidate.is_empty() {
"-".to_string()
} else {
truncate_by_chars(candidate, 28)
}
}
pub struct TopFlows {
bus_link: tokio::sync::mpsc::Sender<crate::bus::BusMessage>,
rx: std::sync::mpsc::Receiver<BusResponse>,
@@ -38,6 +55,7 @@ impl TopWidget for TopFlows {
fn render_to_frame(&mut self, frame: &mut Frame) {
let mut t = TableHelper::new([
"Circuit",
"Src IP",
"Dst IP",
"Type",
@@ -49,6 +67,7 @@ impl TopWidget for TopFlows {
]);
for flow in self.flows.iter() {
t.add_row([
flow_circuit_label(flow),
flow.local_ip.to_string(),
flow.remote_ip.to_string(),
flow.analysis.to_string(),
+15
View File
@@ -3,6 +3,19 @@ use lqos_bus::{BusResponse, IpStats};
use lqos_utils::packet_scale::{scale_bits, scale_packets};
use ratatui::prelude::*;
fn truncate_by_chars(input: &str, max_chars: usize) -> String {
input.chars().take(max_chars).collect()
}
fn circuit_label(circuit_id: &str) -> String {
let trimmed = circuit_id.trim();
if trimmed.is_empty() {
"-".to_string()
} else {
truncate_by_chars(trimmed, 28)
}
}
pub struct TopHosts {
bus_link: tokio::sync::mpsc::Sender<crate::bus::BusMessage>,
rx: std::sync::mpsc::Receiver<BusResponse>,
@@ -38,6 +51,7 @@ impl TopWidget for TopHosts {
fn render_to_frame(&mut self, frame: &mut Frame) {
let mut t = TableHelper::new([
"Circuit",
"IP Address",
"Down (bps)",
"Up (bps)",
@@ -49,6 +63,7 @@ impl TopWidget for TopHosts {
for host in self.stats.iter() {
t.add_row([
circuit_label(&host.circuit_id),
host.ip_address.to_string(),
scale_bits(host.bits_per_second.down),
scale_bits(host.bits_per_second.up),