mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-24 13:57:40 -05:00
Merge pull request #1047 from LibreQoE/feat/dynamic_circuits
Feat/dynamic circuits
This commit is contained in:
90 files changed
+7518
-2718
No files matched your search
@@ -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).
|
||||
|
||||
Generated
+442
-411
File diff suppressed because it is too large.
Load diff
+3
-1
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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, ¤t_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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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}"))?
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user