From 066fb4ee39dae10c6c5aeeaea41af003e3c45258 Mon Sep 17 00:00:00 2001 From: rchac Date: Sat, 11 Apr 2026 19:28:05 +0000 Subject: [PATCH] changes --- docs-es/v2.0/TechnicalDocs/integrations.md | 2 +- docs-es/v2.0/integrations-reference-es.md | 2 +- docs/v2.0/integrations-netzur.md | 2 +- docs/v2.0/integrations-reference.md | 2 +- src/build_dpkg.sh | 7 +- src/integrationCommon.py | 5 +- src/lqos.example | 3 + src/mikrotikDHCPRouterList.template.csv | 2 - src/mikrotikFindIPv6.py | 62 ++- src/rust/lqos_config/src/etc/mod.rs | 12 +- src/rust/lqos_config/src/etc/v15/example.toml | 3 + .../lqos_config/src/etc/v15/mikrotik_ipv6.rs | 22 + src/rust/lqos_config/src/etc/v15/mod.rs | 2 + .../lqos_config/src/etc/v15/top_config.rs | 13 + src/rust/lqos_config/src/lib.rs | 15 +- .../src/mikrotik_ipv6_credentials.rs | 412 ++++++++++++++++++ .../src/runtime_state_migration.rs | 47 +- src/rust/lqos_python/src/lib.rs | 19 + .../src/node_manager/js_build/src/template.js | 327 +++++++++++++- .../static2/config_integration.html | 2 +- .../src/node_manager/static2/node_manager.css | 191 ++++++++ .../src/node_manager/static2/template.html | 2 +- .../src/strategies/full/mikrotik.rs | 29 +- .../src/strategies/legacy_mikrotik.rs | 25 +- 24 files changed, 1123 insertions(+), 85 deletions(-) delete mode 100644 src/mikrotikDHCPRouterList.template.csv create mode 100644 src/rust/lqos_config/src/etc/v15/mikrotik_ipv6.rs create mode 100644 src/rust/lqos_config/src/mikrotik_ipv6_credentials.rs diff --git a/docs-es/v2.0/TechnicalDocs/integrations.md b/docs-es/v2.0/TechnicalDocs/integrations.md index e5462850..db57eb03 100644 --- a/docs-es/v2.0/TechnicalDocs/integrations.md +++ b/docs-es/v2.0/TechnicalDocs/integrations.md @@ -101,7 +101,7 @@ ltu_capacity = 1.0 exclude_sites = [] # If you use DHCPv6, and want to pull in IPv6 CIDRs corresponding to each customer's IPv4 -# address, you can do so with this. If enabled, be sure to fill out mikrotikDHCPRouterList.csv +# address, you can do so with this. If enabled, configure `/etc/libreqos/mikrotik_ipv6.toml` # and run `python3 mikrotikFindIPv6.py` to test its functionality. ipv6_with_mikrotik = false diff --git a/docs-es/v2.0/integrations-reference-es.md b/docs-es/v2.0/integrations-reference-es.md index 25971d17..0b779816 100644 --- a/docs-es/v2.0/integrations-reference-es.md +++ b/docs-es/v2.0/integrations-reference-es.md @@ -153,7 +153,7 @@ use_mikrotik_ipv6 = false - `api_key` es el token Bearer generado dentro de Netzur. - `api_url` debe devolver un JSON con los arreglos `zones` (convertidos en sitios) y `customers` (convertidos en circuitos y dispositivos). - `timeout_secs` permite incrementar el tiempo de espera de la petición cuando el API responde lentamente (por defecto 60 segundos). -- `use_mikrotik_ipv6` agrega prefijos IPv6 obtenidos de `mikrotikDHCPRouterList.csv`. +- `use_mikrotik_ipv6` agrega prefijos IPv6 obtenidos de `/etc/libreqos/mikrotik_ipv6.toml`. Para una importación manual: diff --git a/docs/v2.0/integrations-netzur.md b/docs/v2.0/integrations-netzur.md index ee9284ca..a4fb385b 100644 --- a/docs/v2.0/integrations-netzur.md +++ b/docs/v2.0/integrations-netzur.md @@ -15,7 +15,7 @@ use_mikrotik_ipv6 = false - `api_key` is the Bearer token generated inside Netzur. - `api_url` must return JSON containing `zones` (mapped to sites) and `customers` (mapped to client circuits and devices). - `timeout_secs` overrides the default HTTP timeout (60 seconds) when the API is slow. -- `use_mikrotik_ipv6` enriches subscriber devices with IPv6 prefixes discovered via `mikrotikDHCPRouterList.csv`. +- `use_mikrotik_ipv6` enriches subscriber devices with IPv6 prefixes discovered via `/etc/libreqos/mikrotik_ipv6.toml`. Run a manual import with: diff --git a/docs/v2.0/integrations-reference.md b/docs/v2.0/integrations-reference.md index 80c82e32..1675b18e 100644 --- a/docs/v2.0/integrations-reference.md +++ b/docs/v2.0/integrations-reference.md @@ -155,7 +155,7 @@ use_mikrotik_ipv6 = false - `api_key` is the Bearer token generated inside Netzur. - `api_url` must return JSON containing `zones` (mapped to sites) and `customers` (mapped to client circuits and devices). - `timeout_secs` overrides the default HTTP timeout (60 seconds) when the API is slow. -- `use_mikrotik_ipv6` enriches subscriber devices with IPv6 prefixes discovered via `mikrotikDHCPRouterList.csv`. +- `use_mikrotik_ipv6` enriches subscriber devices with IPv6 prefixes discovered via `/etc/libreqos/mikrotik_ipv6.toml`. Run a manual import with: diff --git a/src/build_dpkg.sh b/src/build_dpkg.sh index 8e45ec03..19346921 100755 --- a/src/build_dpkg.sh +++ b/src/build_dpkg.sh @@ -17,6 +17,7 @@ DEBIAN_DIR=$DPKG_DIR/DEBIAN LQOS_DIR=$DPKG_DIR/opt/libreqos/src LQOS_STATE_DIR=$DPKG_DIR/opt/libreqos/state ETC_DIR=$DPKG_DIR/etc +ETC_LIBREQOS_DIR=$DPKG_DIR/etc/libreqos MOTD_DIR=$DPKG_DIR/etc/update-motd.d LQOS_FILES=( csvToNetworkJSON.py @@ -33,6 +34,7 @@ LQOS_FILES=( lqos.example lqTools.py mikrotikFindIPv6.py + mikrotik_ipv6.example.toml network.example.json pythonCheck.py qoo_profiles.json @@ -42,7 +44,6 @@ LQOS_FILES=( shaping_skip_report.py systemd_hotfix.sh virtual_tree_nodes.py - mikrotikDHCPRouterList.template.csv integrationUISPbandwidths.template.csv manualNetwork.template.csv integrationUISProutes.template.csv @@ -81,7 +82,7 @@ rm -rf dist # The Debian Packaging Bit # Create the basic directory structure -mkdir -p "$LQOS_DIR"/bin/static2 "$DEBIAN_DIR" "$ETC_DIR" "$LQOS_DIR"/rust "$LQOS_DIR"/bin/dashboards +mkdir -p "$LQOS_DIR"/bin/static2 "$DEBIAN_DIR" "$ETC_DIR" "$ETC_LIBREQOS_DIR" "$LQOS_DIR"/rust "$LQOS_DIR"/bin/dashboards mkdir -p "$LQOS_STATE_DIR"/topology "$LQOS_STATE_DIR"/shaping "$LQOS_STATE_DIR"/stats "$LQOS_STATE_DIR"/cache "$LQOS_STATE_DIR"/debug "$LQOS_STATE_DIR"/quarantine # shellcheck disable=SC2086 @@ -188,6 +189,8 @@ if [ -f deb-requirements-constraints.txt ]; then cp deb-requirements-constraints.txt "$LQOS_DIR" fi +cp mikrotik_ipv6.example.toml "$ETC_LIBREQOS_DIR"/mikrotik_ipv6.example.toml + # Ensure helper scripts are executable in the package if [ -f "$LQOS_DIR/update_api.sh" ]; then chmod a+x "$LQOS_DIR/update_api.sh" || true diff --git a/src/integrationCommon.py b/src/integrationCommon.py index b8377c99..754b754b 100644 --- a/src/integrationCommon.py +++ b/src/integrationCommon.py @@ -213,15 +213,14 @@ class NetworkGraph: self._cache_valid = False if find_ipv6_using_mikrotik(): - csv_path = "mikrotikDHCPRouterList.csv" try: from mikrotikFindIPv6 import pullMikrotikIPv6 - mikrotik_map = pullMikrotikIPv6(csv_path) + mikrotik_map = pullMikrotikIPv6() if isinstance(mikrotik_map, str): mikrotik_map = json.loads(mikrotik_map) self.ipv4ToIPv6 = mikrotik_map except FileNotFoundError: - self.errors.append("Mikrotik IPv6 enrichment skipped: missing mikrotikDHCPRouterList.csv") + self.errors.append("Mikrotik IPv6 enrichment skipped: missing /etc/libreqos/mikrotik_ipv6.toml") self.ipv4ToIPv6 = {} except json.JSONDecodeError as exc: self.errors.append(f"Mikrotik IPv6 enrichment skipped: unable to parse Mikrotik data ({exc})") diff --git a/src/lqos.example b/src/lqos.example index 4583c2ec..c3c4ef3b 100644 --- a/src/lqos.example +++ b/src/lqos.example @@ -72,6 +72,9 @@ circuit_name_as_address = false queue_refresh_interval_mins = 30 use_mikrotik_ipv6 = false +[mikrotik_ipv6] +config_path = "/etc/libreqos/mikrotik_ipv6.toml" + [splynx_integration] enable_splynx = false api_key = "" diff --git a/src/mikrotikDHCPRouterList.template.csv b/src/mikrotikDHCPRouterList.template.csv deleted file mode 100644 index 1d7374b7..00000000 --- a/src/mikrotikDHCPRouterList.template.csv +++ /dev/null @@ -1,2 +0,0 @@ -Router Name / ID,IP,API Username,API Password, API Port -main,100.64.0.1,admin,password,8728 diff --git a/src/mikrotikFindIPv6.py b/src/mikrotikFindIPv6.py index d99c1686..afc4874d 100644 --- a/src/mikrotikFindIPv6.py +++ b/src/mikrotikFindIPv6.py @@ -1,24 +1,51 @@ #!/usr/bin/python3 -import routeros_api import csv +import json import sys -def pullMikrotikIPv6(CsvPath): - import routeros_api - import csv - import json +import routeros_api - ipv4ToIPv6 = {} - routerList = [] - with open(CsvPath) as csv_file: +from liblqos_python import load_mikrotik_ipv6_routers_json, mikrotik_ipv6_config_path + +def _load_legacy_router_csv(csv_path): + router_list = [] + with open(csv_path) as csv_file: csv_reader = csv.reader(csv_file, delimiter=',') next(csv_reader) for row in csv_reader: RouterName, IP, Username, Password, apiPort = row - routerList.append((RouterName, IP, Username, Password, int(apiPort))) + router_list.append({ + "name": RouterName, + "host": IP, + "username": Username, + "password": Password, + "port": int(apiPort), + "use_ssl": False, + "plaintext_login": True, + }) + return router_list + +def _load_router_list(configPath=None): + if configPath is None: + return json.loads(load_mikrotik_ipv6_routers_json()) + if configPath.endswith('.csv'): + return _load_legacy_router_csv(configPath) + raise ValueError("Explicit Mikrotik IPv6 config overrides must currently point to a legacy .csv file") + +def pullMikrotikIPv6(configPath=None): + import routeros_api + + ipv4ToIPv6 = {} + routerList = _load_router_list(configPath) for router in routerList: - RouterName, IP, inputUsername, inputPassword, apiPort = router - connection = routeros_api.RouterOsApiPool(IP, username=inputUsername, password=inputPassword, port=apiPort, use_ssl=False, ssl_verify=False, ssl_verify_hostname=False, plaintext_login=True) + RouterName = router['name'] + IP = router['host'] + inputUsername = router['username'] + inputPassword = router['password'] + apiPort = int(router.get('port', 8728)) + use_ssl = bool(router.get('use_ssl', False)) + plaintext_login = bool(router.get('plaintext_login', True)) + connection = routeros_api.RouterOsApiPool(IP, username=inputUsername, password=inputPassword, port=apiPort, use_ssl=use_ssl, ssl_verify=False, ssl_verify_hostname=False, plaintext_login=plaintext_login) api = connection.get_api() macToIPv4 = {} macToIPv6 = {} @@ -77,12 +104,13 @@ def pullMikrotikIPv6_Mock(CsvPath): return "{\n\"172.29.200.2\": \"2602:fdca:800:1500::/56\"\n}" if __name__ == '__main__': - # If the first argument is a string, that's the path to the csv file - import sys - if len(sys.argv) > 1: - CsvPath = sys.argv[1] - print(pullMikrotikIPv6(CsvPath)) + # If the first argument is a string, it's treated as a legacy CSV path override. + if len(sys.argv) > 1 and sys.argv[1] == '--show-config': + print("Configured secrets file: " + mikrotik_ipv6_config_path()) + elif len(sys.argv) > 1: + configPath = sys.argv[1] + print(pullMikrotikIPv6(configPath)) else: - print("Mikrotik IPv6 Finder") + print(pullMikrotikIPv6()) #print(pullMikrotikIPv6()) diff --git a/src/rust/lqos_config/src/etc/mod.rs b/src/rust/lqos_config/src/etc/mod.rs index 9553a258..b0c8285c 100644 --- a/src/rust/lqos_config/src/etc/mod.rs +++ b/src/rust/lqos_config/src/etc/mod.rs @@ -19,7 +19,7 @@ mod python_migration; pub mod test_data; mod v15; pub use v15::{ - BridgeConfig, IntegrationConfig, LazyQueueMode, QueueMode, RttThresholds, + BridgeConfig, IntegrationConfig, LazyQueueMode, MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig, StormguardStrategy, TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables, @@ -434,6 +434,16 @@ fn actually_load_from_disk() -> Result, LibreQoSConfigError> { details: e.to_string(), } })?; + crate::migrate_legacy_mikrotik_ipv6_credentials(&final_config).map_err(|e| { + error!("Unable to migrate legacy Mikrotik IPv6 credentials: {e:?}"); + LibreQoSConfigError::MigrationFailed { + path: final_config + .resolved_mikrotik_ipv6_config_path() + .display() + .to_string(), + details: e.to_string(), + } + })?; debug!("Set cached version of config file"); let new_config = Arc::new(final_config); diff --git a/src/rust/lqos_config/src/etc/v15/example.toml b/src/rust/lqos_config/src/etc/v15/example.toml index 6cc9e0ec..19212615 100644 --- a/src/rust/lqos_config/src/etc/v15/example.toml +++ b/src/rust/lqos_config/src/etc/v15/example.toml @@ -105,6 +105,9 @@ ethernet_port_limits_enabled = true # Optional override. If omitted, LibreQoS uses a conservative default of 0.94. # ethernet_port_limit_multiplier = 0.94 +[mikrotik_ipv6] +config_path = "/etc/libreqos/mikrotik_ipv6.toml" + [integration_common.topology_attachment_health] enabled = true probe_interval_seconds = 1 diff --git a/src/rust/lqos_config/src/etc/v15/mikrotik_ipv6.rs b/src/rust/lqos_config/src/etc/v15/mikrotik_ipv6.rs new file mode 100644 index 00000000..b7d24552 --- /dev/null +++ b/src/rust/lqos_config/src/etc/v15/mikrotik_ipv6.rs @@ -0,0 +1,22 @@ +use allocative::Allocative; +use serde::{Deserialize, Serialize}; + +fn default_mikrotik_ipv6_config_path() -> String { + "/etc/libreqos/mikrotik_ipv6.toml".to_string() +} + +/// Dedicated Mikrotik IPv6 enrichment secrets/config file location. +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Allocative)] +pub struct MikrotikIpv6Config { + /// Path to the Mikrotik IPv6 enrichment router credential file. + #[serde(default = "default_mikrotik_ipv6_config_path")] + pub config_path: String, +} + +impl Default for MikrotikIpv6Config { + fn default() -> Self { + Self { + config_path: default_mikrotik_ipv6_config_path(), + } + } +} diff --git a/src/rust/lqos_config/src/etc/v15/mod.rs b/src/rust/lqos_config/src/etc/v15/mod.rs index 6d4938ec..f0876422 100644 --- a/src/rust/lqos_config/src/etc/v15/mod.rs +++ b/src/rust/lqos_config/src/etc/v15/mod.rs @@ -9,6 +9,7 @@ pub mod influxdb; mod integration_common; mod ip_ranges; mod long_term_stats; +mod mikrotik_ipv6; mod netzur_integration; mod powercode_integration; mod queues; @@ -25,6 +26,7 @@ mod wispgate; pub use bridge::*; pub use integration_common::IntegrationConfig; pub use long_term_stats::LongTermStats; +pub use mikrotik_ipv6::MikrotikIpv6Config; pub use queues::{LazyQueueMode, QueueMode}; pub use stormguard::{StormguardConfig, StormguardStrategy}; pub use topology::{TopologyConfig, normalize_topology_compile_mode}; diff --git a/src/rust/lqos_config/src/etc/v15/top_config.rs b/src/rust/lqos_config/src/etc/v15/top_config.rs index c981405b..ab41bbbd 100644 --- a/src/rust/lqos_config/src/etc/v15/top_config.rs +++ b/src/rust/lqos_config/src/etc/v15/top_config.rs @@ -122,6 +122,10 @@ pub struct Config { #[serde(default)] pub topology: super::topology::TopologyConfig, + /// Dedicated Mikrotik IPv6 enrichment secrets/config path. + #[serde(default)] + pub mikrotik_ipv6: super::mikrotik_ipv6::MikrotikIpv6Config, + /// Splynx Integration configuration. Optional so older configs without this /// section still deserialize cleanly. #[serde(default)] @@ -227,6 +231,9 @@ impl Config { { return Err("state_directory must not be empty when configured".to_string()); } + if self.mikrotik_ipv6.config_path.trim().is_empty() { + return Err("mikrotik_ipv6.config_path must not be empty".to_string()); + } if let Some(rtt) = &self.rtt_thresholds { if rtt.red_ms == 0 { return Err("rtt_thresholds.red_ms must be > 0".to_string()); @@ -347,6 +354,7 @@ impl Default for Config { ip_ranges: super::ip_ranges::IpRanges::default(), integration_common: super::integration_common::IntegrationConfig::default(), topology: super::topology::TopologyConfig::default(), + mikrotik_ipv6: super::mikrotik_ipv6::MikrotikIpv6Config::default(), splynx_integration: super::splynx_integration::SplynxIntegration::default(), netzur_integration: Some(super::netzur_integration::NetzurIntegration::default()), visp_integration: Some(super::visp_integration::VispIntegration::default()), @@ -394,6 +402,11 @@ impl Config { base.join("state") } + /// Returns the configured Mikrotik IPv6 secrets/config path. + pub fn resolved_mikrotik_ipv6_config_path(&self) -> PathBuf { + PathBuf::from(self.mikrotik_ipv6.config_path.trim()) + } + /// Returns the preferred topology-state path for `filename`. pub fn topology_state_file_path(&self, filename: &str) -> PathBuf { self.resolved_state_directory() diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs index dba30a8d..89da3326 100644 --- a/src/rust/lqos_config/src/lib.rs +++ b/src/rust/lqos_config/src/lib.rs @@ -15,6 +15,7 @@ mod circuit_ethernet_metadata; mod cpu_topology; mod etc; mod ethernet_port_limits; +mod mikrotik_ipv6_credentials; mod network_json; mod planner; mod program_control; @@ -39,16 +40,20 @@ pub use cpu_topology::{ CpuListParseError, ShapingCpuDetection, ShapingCpuSource, detect_shaping_cpus, }; pub use etc::{ - BridgeConfig, Config, LazyQueueMode, QueueMode, 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, + BridgeConfig, Config, LazyQueueMode, MikrotikIpv6Config, QueueMode, 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, }; pub use ethernet_port_limits::{ DEFAULT_ETHERNET_PORT_LIMIT_MULTIPLIER, EthernetPortLimitPolicy, EthernetPortObservation, EthernetRateDecision, RequestedCircuitRates, apply_ethernet_rate_cap, usable_ethernet_cap_mbps, }; +pub use mikrotik_ipv6_credentials::{ + MikrotikIpv6CredentialError, MikrotikIpv6CredentialsFile, MikrotikIpv6RouterCredential, + load_mikrotik_ipv6_router_credentials, migrate_legacy_mikrotik_ipv6_credentials, +}; pub use network_json::{NetworkJson, NetworkJsonNode, NetworkJsonTransport}; pub use planner::{ CircuitIdentityAssignment, CircuitIdentityGroupInput, ClassIdentityPlannerConstraints, diff --git a/src/rust/lqos_config/src/mikrotik_ipv6_credentials.rs b/src/rust/lqos_config/src/mikrotik_ipv6_credentials.rs new file mode 100644 index 00000000..d257e2f8 --- /dev/null +++ b/src/rust/lqos_config/src/mikrotik_ipv6_credentials.rs @@ -0,0 +1,412 @@ +use crate::Config; +use serde::{Deserialize, Serialize}; +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use tracing::{info, warn}; + +const LEGACY_MIKROTIK_CSV_FILENAME: &str = "mikrotikDHCPRouterList.csv"; + +fn default_mikrotik_credentials_version() -> u32 { + 1 +} + +fn default_mikrotik_router_port() -> u16 { + 8728 +} + +fn default_use_ssl() -> bool { + false +} + +fn default_plaintext_login() -> bool { + true +} + +/// One Mikrotik router credential entry used for IPv6 enrichment. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct MikrotikIpv6RouterCredential { + /// Operator-facing name for the router. + pub name: String, + /// Hostname or IP address of the Mikrotik API endpoint. + pub host: String, + /// API port. + #[serde(default = "default_mikrotik_router_port")] + pub port: u16, + /// API username. + pub username: String, + /// API password. + pub password: String, + /// Whether to use SSL for the API connection. + #[serde(default = "default_use_ssl")] + pub use_ssl: bool, + /// Whether to use legacy plaintext login mode for older RouterOS releases. + #[serde(default = "default_plaintext_login")] + pub plaintext_login: bool, +} + +/// On-disk Mikrotik IPv6 credential file. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct MikrotikIpv6CredentialsFile { + /// Schema version for this file. + #[serde(default = "default_mikrotik_credentials_version")] + pub version: u32, + /// Router credential entries. + #[serde(default)] + pub router: Vec, +} + +/// Errors returned while loading or migrating Mikrotik IPv6 credential data. +#[derive(Debug, Error)] +pub enum MikrotikIpv6CredentialError { + /// The preferred Mikrotik IPv6 credential file was not found. + #[error("Mikrotik IPv6 credential file was not found at {path}")] + NotFound { + /// Missing credential file path. + path: String, + }, + /// The credential file could not be read. + #[error("Unable to read Mikrotik IPv6 credential file at {path}: {source}")] + Read { + /// Path that failed to read. + path: String, + /// Source I/O error. + #[source] + source: std::io::Error, + }, + /// The TOML credential file could not be parsed. + #[error("Unable to parse Mikrotik IPv6 credential TOML at {path}: {details}")] + ParseToml { + /// Path that failed to parse. + path: String, + /// Parse failure details. + details: String, + }, + /// The legacy CSV credential file could not be parsed. + #[error("Unable to parse legacy Mikrotik CSV at {path}: {details}")] + ParseCsv { + /// Path that failed to parse. + path: String, + /// Parse failure details. + details: String, + }, + /// A parent directory for the credential file could not be created. + #[error("Unable to create directory {path}: {source}")] + CreateDirectory { + /// Directory path that failed to create. + path: String, + /// Source I/O error. + #[source] + source: std::io::Error, + }, + /// The TOML credential document could not be serialized. + #[error("Unable to serialize Mikrotik IPv6 credentials: {details}")] + Serialize { + /// Serialization failure details. + details: String, + }, + /// The TOML credential file could not be written. + #[error("Unable to write Mikrotik IPv6 credentials to {path}: {source}")] + Write { + /// Path that failed to write. + path: String, + /// Source I/O error. + #[source] + source: std::io::Error, + }, + /// The legacy CSV file could not be removed after a successful migration. + #[error("Unable to remove legacy Mikrotik CSV at {path}: {source}")] + DeleteLegacyCsv { + /// Legacy CSV path that failed to delete. + path: String, + /// Source I/O error. + #[source] + source: std::io::Error, + }, +} + +/// Loads Mikrotik IPv6 router credentials, auto-migrating the legacy CSV file if necessary. +pub fn load_mikrotik_ipv6_router_credentials( + config: &Config, +) -> Result, MikrotikIpv6CredentialError> { + migrate_legacy_mikrotik_ipv6_credentials(config)?; + + let config_path = config.resolved_mikrotik_ipv6_config_path(); + if config_path.exists() { + let credentials = read_credentials_toml(&config_path)?; + let legacy_csv = config.legacy_runtime_file_path(LEGACY_MIKROTIK_CSV_FILENAME); + if legacy_csv.exists() { + warn!( + "Legacy Mikrotik IPv6 CSV remains at {}; using {} instead.", + legacy_csv.display(), + config_path.display() + ); + } + return Ok(credentials.router); + } + + let legacy_csv = config.legacy_runtime_file_path(LEGACY_MIKROTIK_CSV_FILENAME); + if legacy_csv.exists() { + return migrate_legacy_csv_to_toml(&legacy_csv, &config_path); + } + + Err(MikrotikIpv6CredentialError::NotFound { + path: config_path.display().to_string(), + }) +} + +/// Migrates the legacy Mikrotik IPv6 CSV into TOML if needed. +/// +/// This function has side effects: it may create the configured TOML file under `/etc/libreqos` +/// and delete the legacy CSV from `lqos_directory` after successful validation. +pub fn migrate_legacy_mikrotik_ipv6_credentials( + config: &Config, +) -> Result<(), MikrotikIpv6CredentialError> { + let config_path = config.resolved_mikrotik_ipv6_config_path(); + if config_path.exists() { + return Ok(()); + } + + let legacy_csv = config.legacy_runtime_file_path(LEGACY_MIKROTIK_CSV_FILENAME); + if legacy_csv.exists() { + let _ = migrate_legacy_csv_to_toml(&legacy_csv, &config_path)?; + } + + Ok(()) +} + +fn migrate_legacy_csv_to_toml( + csv_path: &Path, + config_path: &Path, +) -> Result, MikrotikIpv6CredentialError> { + let routers = read_credentials_csv(csv_path)?; + let document = MikrotikIpv6CredentialsFile { + version: default_mikrotik_credentials_version(), + router: routers.clone(), + }; + + write_credentials_toml(config_path, &document)?; + let validated = read_credentials_toml(config_path)?; + fs::remove_file(csv_path).map_err(|source| MikrotikIpv6CredentialError::DeleteLegacyCsv { + path: csv_path.display().to_string(), + source, + })?; + + info!( + "Migrated legacy Mikrotik IPv6 CSV {} to {} and deleted the old CSV.", + csv_path.display(), + config_path.display() + ); + + Ok(validated.router) +} + +fn read_credentials_toml( + path: &Path, +) -> Result { + let raw = fs::read_to_string(path).map_err(|source| MikrotikIpv6CredentialError::Read { + path: path.display().to_string(), + source, + })?; + toml::from_str(&raw).map_err(|e| MikrotikIpv6CredentialError::ParseToml { + path: path.display().to_string(), + details: e.to_string(), + }) +} + +fn read_credentials_csv( + path: &Path, +) -> Result, MikrotikIpv6CredentialError> { + let file = File::open(path).map_err(|source| MikrotikIpv6CredentialError::Read { + path: path.display().to_string(), + source, + })?; + let mut reader = csv::ReaderBuilder::new() + .trim(csv::Trim::All) + .from_reader(file); + let mut routers = Vec::new(); + + for result in reader.records() { + let record = result.map_err(|e| MikrotikIpv6CredentialError::ParseCsv { + path: path.display().to_string(), + details: e.to_string(), + })?; + if record.len() != 5 { + return Err(MikrotikIpv6CredentialError::ParseCsv { + path: path.display().to_string(), + details: format!("expected 5 columns, found {}", record.len()), + }); + } + let port = + record[4] + .trim() + .parse::() + .map_err(|e| MikrotikIpv6CredentialError::ParseCsv { + path: path.display().to_string(), + details: format!("invalid API port '{}': {e}", record[4].trim()), + })?; + routers.push(MikrotikIpv6RouterCredential { + name: record[0].trim().to_string(), + host: record[1].trim().to_string(), + username: record[2].trim().to_string(), + password: record[3].trim().to_string(), + port, + use_ssl: default_use_ssl(), + plaintext_login: default_plaintext_login(), + }); + } + + Ok(routers) +} + +fn write_credentials_toml( + path: &Path, + document: &MikrotikIpv6CredentialsFile, +) -> Result<(), MikrotikIpv6CredentialError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| { + MikrotikIpv6CredentialError::CreateDirectory { + path: parent.display().to_string(), + source, + } + })?; + } + + let raw = + toml::to_string_pretty(document).map_err(|e| MikrotikIpv6CredentialError::Serialize { + details: e.to_string(), + })?; + let temp_path = temp_config_path(path); + { + let mut file = create_secure_file(&temp_path)?; + file.write_all(raw.as_bytes()) + .map_err(|source| MikrotikIpv6CredentialError::Write { + path: temp_path.display().to_string(), + source, + })?; + } + fs::rename(&temp_path, path).map_err(|source| MikrotikIpv6CredentialError::Write { + path: path.display().to_string(), + source, + })?; + Ok(()) +} + +fn create_secure_file(path: &Path) -> Result { + #[cfg(unix)] + { + use std::fs::OpenOptions; + use std::os::unix::fs::OpenOptionsExt; + + OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(path) + .map_err(|source| MikrotikIpv6CredentialError::Write { + path: path.display().to_string(), + source, + }) + } + + #[cfg(not(unix))] + { + File::create(path).map_err(|source| MikrotikIpv6CredentialError::Write { + path: path.display().to_string(), + source, + }) + } +} + +fn temp_config_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("mikrotik_ipv6.toml"); + path.with_file_name(format!("{file_name}.tmp")) +} + +#[cfg(test)] +mod tests { + use super::{ + LEGACY_MIKROTIK_CSV_FILENAME, load_mikrotik_ipv6_router_credentials, read_credentials_toml, + }; + use crate::Config; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_path(label: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("libreqos-mikrotik-{label}-{stamp}")) + } + + fn test_config(root: &PathBuf) -> Config { + let mut config = Config::default(); + config.lqos_directory = root.join("src").display().to_string(); + config.mikrotik_ipv6.config_path = root + .join("etc/libreqos/mikrotik_ipv6.toml") + .display() + .to_string(); + config + } + + #[test] + fn migrates_legacy_csv_and_deletes_it() { + let root = temp_path("migrate"); + let src = root.join("src"); + fs::create_dir_all(&src).unwrap(); + fs::write( + src.join(LEGACY_MIKROTIK_CSV_FILENAME), + "Router Name / ID,IP,API Username,API Password, API Port\nmain,100.64.0.1,admin,password,8728\n", + ) + .unwrap(); + + let config = test_config(&root); + let routers = load_mikrotik_ipv6_router_credentials(&config).unwrap(); + + assert_eq!(routers.len(), 1); + assert_eq!(routers[0].name, "main"); + assert_eq!(routers[0].host, "100.64.0.1"); + assert!(root.join("etc/libreqos/mikrotik_ipv6.toml").exists()); + assert!(!src.join(LEGACY_MIKROTIK_CSV_FILENAME).exists()); + + let saved = read_credentials_toml(&root.join("etc/libreqos/mikrotik_ipv6.toml")).unwrap(); + assert_eq!(saved.router, routers); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prefers_existing_toml_file() { + let root = temp_path("prefer-toml"); + let src = root.join("src"); + let etc = root.join("etc/libreqos"); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&etc).unwrap(); + fs::write( + etc.join("mikrotik_ipv6.toml"), + "version = 1\n\n[[router]]\nname = \"main\"\nhost = \"100.64.0.1\"\nport = 8728\nusername = \"admin\"\npassword = \"secret\"\nuse_ssl = false\nplaintext_login = true\n", + ) + .unwrap(); + fs::write( + src.join(LEGACY_MIKROTIK_CSV_FILENAME), + "Router Name / ID,IP,API Username,API Password, API Port\nlegacy,100.64.0.2,user,pass,8728\n", + ) + .unwrap(); + + let config = test_config(&root); + let routers = load_mikrotik_ipv6_router_credentials(&config).unwrap(); + + assert_eq!(routers.len(), 1); + assert_eq!(routers[0].name, "main"); + assert!(src.join(LEGACY_MIKROTIK_CSV_FILENAME).exists()); + + let _ = fs::remove_dir_all(root); + } +} diff --git a/src/rust/lqos_config/src/runtime_state_migration.rs b/src/rust/lqos_config/src/runtime_state_migration.rs index 8cf44e5b..aff5f5fd 100644 --- a/src/rust/lqos_config/src/runtime_state_migration.rs +++ b/src/rust/lqos_config/src/runtime_state_migration.rs @@ -10,7 +10,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; -use tracing::{info, warn}; +use tracing::info; #[derive(Debug, Error)] pub(crate) enum RuntimeStateMigrationError { @@ -64,7 +64,7 @@ pub(crate) fn migrate_legacy_runtime_state( ensure_state_directories(config)?; let mut migrated = Vec::new(); - let mut duplicate_legacy = Vec::new(); + let mut removed_duplicate_legacy = Vec::new(); let mut quarantined = Vec::new(); for item in legacy_migrations(config) { @@ -73,11 +73,11 @@ pub(crate) fn migrate_legacy_runtime_state( &item.destination, item.kind, &mut migrated, - &mut duplicate_legacy, + &mut removed_duplicate_legacy, )?; } - migrate_legacy_token_caches(config, &mut migrated, &mut duplicate_legacy)?; + migrate_legacy_token_caches(config, &mut migrated, &mut removed_duplicate_legacy)?; quarantine_legacy_backups(config, &mut quarantined)?; if !migrated.is_empty() { @@ -86,10 +86,10 @@ pub(crate) fn migrate_legacy_runtime_state( migrated.join(", ") ); } - if !duplicate_legacy.is_empty() { - warn!( - "Found duplicate legacy runtime artifacts in lqos_directory after state migration; keeping them for compatibility during this release: {}", - duplicate_legacy.join(", ") + if !removed_duplicate_legacy.is_empty() { + info!( + "Removed duplicate legacy runtime artifacts from lqos_directory after confirming canonical state copies exist: {}", + removed_duplicate_legacy.join(", ") ); } if !quarantined.is_empty() { @@ -269,7 +269,7 @@ fn migrate_legacy_item( destination: &Path, kind: LegacyArtifactKind, migrated: &mut Vec, - duplicate_legacy: &mut Vec, + removed_duplicate_legacy: &mut Vec, ) -> Result<(), RuntimeStateMigrationError> { if !source.exists() { return Ok(()); @@ -282,7 +282,8 @@ fn migrate_legacy_item( } if destination.exists() { - duplicate_legacy.push(source.display().to_string()); + remove_path(source)?; + removed_duplicate_legacy.push(source.display().to_string()); return Ok(()); } @@ -294,7 +295,7 @@ fn migrate_legacy_item( fn migrate_legacy_token_caches( config: &Config, migrated: &mut Vec, - duplicate_legacy: &mut Vec, + removed_duplicate_legacy: &mut Vec, ) -> Result<(), RuntimeStateMigrationError> { let legacy_root = Path::new(&config.lqos_directory); let entries = @@ -321,7 +322,7 @@ fn migrate_legacy_token_caches( &destination, LegacyArtifactKind::File, migrated, - duplicate_legacy, + removed_duplicate_legacy, )?; } @@ -434,6 +435,21 @@ fn move_path(source: &Path, destination: &Path) -> Result<(), RuntimeStateMigrat } } +fn remove_path(path: &Path) -> Result<(), RuntimeStateMigrationError> { + if path.is_dir() { + fs::remove_dir_all(path).map_err(|source| RuntimeStateMigrationError::RemoveDirectory { + path: path.display().to_string(), + source, + })?; + } else { + fs::remove_file(path).map_err(|source| RuntimeStateMigrationError::RemoveFile { + path: path.display().to_string(), + source, + })?; + } + Ok(()) +} + fn copy_dir_all(source: &Path, destination: &Path) -> Result<(), RuntimeStateMigrationError> { fs::create_dir_all(destination).map_err(|source_err| { RuntimeStateMigrationError::CreateDirectory { @@ -524,7 +540,7 @@ mod tests { } #[test] - fn preserves_duplicate_legacy_file_when_new_state_file_exists() { + fn removes_duplicate_legacy_file_when_new_state_file_exists() { let root = temp_path("duplicate"); let src = root.join("src"); let state = root.join("state/shaping"); @@ -536,10 +552,7 @@ mod tests { let config = test_config(&root); migrate_legacy_runtime_state(&config).unwrap(); - assert_eq!( - fs::read_to_string(src.join("lastGoodConfig.csv")).unwrap(), - "legacy\n" - ); + assert!(!src.join("lastGoodConfig.csv").exists()); assert_eq!( fs::read_to_string(state.join("lastGoodConfig.csv")).unwrap(), "state\n" diff --git a/src/rust/lqos_python/src/lib.rs b/src/rust/lqos_python/src/lib.rs index 6c98c3c6..be25fa40 100644 --- a/src/rust/lqos_python/src/lib.rs +++ b/src/rust/lqos_python/src/lib.rs @@ -976,6 +976,8 @@ fn liblqos_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(circuit_name_use_address, m)?)?; m.add_function(wrap_pyfunction!(find_ipv6_using_mikrotik, m)?)?; m.add_function(wrap_pyfunction!(integration_common_use_mikrotik_ipv6, m)?)?; + m.add_function(wrap_pyfunction!(mikrotik_ipv6_config_path, m)?)?; + m.add_function(wrap_pyfunction!(load_mikrotik_ipv6_routers_json, m)?)?; m.add_function(wrap_pyfunction!(exclude_sites, m)?)?; m.add_function(wrap_pyfunction!(bandwidth_overhead_factor, m)?)?; m.add_function(wrap_pyfunction!(committed_bandwidth_multiplier, m)?)?; @@ -2199,6 +2201,23 @@ fn integration_common_use_mikrotik_ipv6() -> PyResult { Ok(config.integration_common.use_mikrotik_ipv6) } +#[pyfunction] +fn mikrotik_ipv6_config_path() -> PyResult { + let config = lqos_config::load_config().unwrap(); + Ok(config + .resolved_mikrotik_ipv6_config_path() + .display() + .to_string()) +} + +#[pyfunction] +fn load_mikrotik_ipv6_routers_json() -> PyResult { + let config = lqos_config::load_config().unwrap(); + let routers = lqos_config::load_mikrotik_ipv6_router_credentials(&config) + .map_err(|e| PyOSError::new_err(e.to_string()))?; + serde_json::to_string(&routers).map_err(|e| PyOSError::new_err(e.to_string())) +} + #[pyfunction] fn exclude_sites() -> PyResult> { let config = lqos_config::load_config().unwrap(); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/template.js b/src/rust/lqosd/src/node_manager/js_build/src/template.js index 76ad97cb..094a99db 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/template.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/template.js @@ -23,12 +23,21 @@ function escapeAttr(text) { .replaceAll('>', '>'); } +function escapeHtml(text) { + return escapeAttr(text); +} + const SCHEDULER_STATUS_POLL_MS = 2000; const SCHEDULER_STATUS_TIMEOUT_MS = 2500; const SCHEDULER_STATUS_STARTING_GRACE_MS = 30000; +const SCHEDULER_MODAL_IDLE_POLL_MS = 3000; +const SCHEDULER_MODAL_ACTIVE_POLL_MS = 1000; let schedulerStatusPollTimer = null; let schedulerStatusRequestInFlight = false; let schedulerStatusFirstRequestedAt = null; +let schedulerModalPollTimer = null; +let schedulerModalRequestInFlight = false; +let schedulerModalInstance = null; let cachedQueueModeConfig = null; function listenOnceWithTimeout(eventName, timeoutMs, handler, onTimeout) { @@ -94,6 +103,207 @@ function schedulerRingMarkup(progressPercent, tone, centerIcon) { `; } +function schedulerRelativeTime(updatedUnix) { + const unixSeconds = Number(updatedUnix); + if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) { + return "Update time unavailable"; + } + const deltaSeconds = Math.max(0, Math.round((Date.now() / 1000) - unixSeconds)); + if (deltaSeconds < 5) return "Updated just now"; + if (deltaSeconds < 60) return `Updated ${deltaSeconds}s ago`; + const deltaMinutes = Math.round(deltaSeconds / 60); + if (deltaMinutes < 60) return `Updated ${deltaMinutes}m ago`; + const deltaHours = Math.round(deltaMinutes / 60); + if (deltaHours < 24) return `Updated ${deltaHours}h ago`; + const deltaDays = Math.round(deltaHours / 24); + return `Updated ${deltaDays}d ago`; +} + +function schedulerStateDescriptor(data) { + const progress = data?.progress || null; + const hasError = !!(data?.error && String(data.error).trim().length > 0); + const available = !!data?.available; + const active = !!progress?.active; + + if (hasError) { + return { + tone: "danger", + badgeClass: "text-bg-danger", + label: "Error", + title: "Scheduler reported an internal error", + subtitle: progress?.phase_label || progress?.phase || "Scheduler needs attention", + ringTone: "tone-danger", + icon: "fa-triangle-exclamation", + }; + } + if (active) { + return { + tone: "info", + badgeClass: "text-bg-info", + label: "Running", + title: progress?.phase_label || progress?.phase || "Scheduler is running", + subtitle: "Work is in progress right now", + ringTone: "tone-progress", + icon: "fa-arrows-rotate", + }; + } + if (available) { + return { + tone: "success", + badgeClass: "text-bg-success", + label: "Idle", + title: progress?.phase_label || progress?.phase || "Scheduler ready", + subtitle: "No active scheduler work at the moment", + ringTone: "tone-success", + icon: "fa-check", + }; + } + return { + tone: "warning", + badgeClass: "text-bg-warning", + label: "Starting", + title: "Scheduler is not ready yet", + subtitle: progress?.phase_label || progress?.phase || "Waiting for scheduler status", + ringTone: "tone-warning", + icon: "fa-clock", + }; +} + +function schedulerProgressBarClass(descriptor) { + if (descriptor.tone === "danger") return "bg-danger"; + if (descriptor.tone === "warning") return "bg-warning"; + if (descriptor.tone === "success") return "bg-success"; + return "bg-info"; +} + +function summarizeSchedulerOutput(output, error) { + if (error && String(error).trim().length > 0) { + return String(error).trim(); + } + const lines = String(output || "") + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + if (!lines.length) { + return "No recent scheduler output recorded"; + } + return lines[lines.length - 1]; +} + +function schedulerActivityItems(output, error) { + if (error && String(error).trim().length > 0) { + return [String(error).trim()]; + } + return String(output || "") + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .slice(-4) + .reverse(); +} + +function renderSchedulerDetails(data) { + const progress = data?.progress || null; + const descriptor = schedulerStateDescriptor(data); + const percent = progress ? schedulerProgressPercent(progress) : (data?.available ? 100 : 0); + const phaseLabel = progress?.phase_label || progress?.phase || descriptor.title; + const stepCount = Number(progress?.step_count); + const stepIndex = Number(progress?.step_index); + const stepText = Number.isFinite(stepIndex) && Number.isFinite(stepCount) && stepCount > 0 + ? `Step ${stepIndex} of ${stepCount}` + : "No active step reported"; + const updatedText = progress?.updated_unix + ? schedulerRelativeTime(progress.updated_unix) + : "Update time unavailable"; + const availabilityText = data?.available ? "Healthy" : "Unavailable"; + const recentResult = summarizeSchedulerOutput(data?.output, data?.error); + const activity = schedulerActivityItems(data?.output, data?.error); + const progressMeta = progress?.active + ? `${percent}% complete` + : descriptor.label === "Idle" + ? "Last run complete" + : "Waiting for activity"; + const alertMarkup = data?.error + ? `` + : ""; + const activityMarkup = activity.length + ? activity.map(line => ` +
+ +
${escapeHtml(line)}
+
`).join("") + : `

No recent scheduler output recorded.

`; + + return ` +
+
+ ${schedulerRingMarkup(percent, descriptor.ringTone, descriptor.icon)} +
+
+
+ ${escapeHtml(descriptor.label)} +

${escapeHtml(descriptor.title)}

+
+

${escapeHtml(phaseLabel)}

+
${escapeHtml(updatedText)}
+
+
+ ${alertMarkup} +
+
+
+

${escapeHtml(stepText)}

+

${escapeHtml(progressMeta)}

+
+
${escapeHtml(`${percent}%`)}
+
+
+
+
+
+
+ Availability + ${escapeHtml(availabilityText)} +
+
+ Current Phase + ${escapeHtml(phaseLabel)} +
+
+ Progress + ${escapeHtml(stepText)} +
+
+ Recent Result + ${escapeHtml(recentResult)} +
+
+
+
+

Recent Activity

+
+ ${activityMarkup} +
+
+
+ Show raw scheduler details +
${escapeHtml(data?.details || "No raw scheduler details available.")}
+
`; +} + +function updateSchedulerModalBody(contentHtml) { + const body = document.getElementById('schedulerDetailsBody'); + if (!body) return; + const debugOpen = !!body.querySelector('.lqos-scheduler-debug-card[open]'); + body.innerHTML = contentHtml; + if (debugOpen) { + const details = body.querySelector('.lqos-scheduler-debug-card'); + if (details) { + details.open = true; + } + } +} + function renderSchedulerStatus(container, state, progress) { if (!container) return; @@ -217,20 +427,115 @@ function restartSchedulerStatusPolling() { loadSchedulerStatus(true); } +function isSchedulerModalOpen() { + const modalEl = document.getElementById('schedulerModal'); + return !!(modalEl && modalEl.classList.contains('show')); +} + +function schedulerModalPollDelay(data) { + const progressActive = !!(data?.progress && data.progress.active); + return progressActive ? SCHEDULER_MODAL_ACTIVE_POLL_MS : SCHEDULER_MODAL_IDLE_POLL_MS; +} + +function stopSchedulerModalPolling() { + schedulerModalRequestInFlight = false; + if (schedulerModalPollTimer) { + clearTimeout(schedulerModalPollTimer); + schedulerModalPollTimer = null; + } +} + +function scheduleNextSchedulerModalPoll(delayMs) { + if (!isSchedulerModalOpen()) { + stopSchedulerModalPolling(); + return; + } + if (schedulerModalPollTimer) { + clearTimeout(schedulerModalPollTimer); + } + schedulerModalPollTimer = setTimeout(() => { + schedulerModalPollTimer = null; + refreshSchedulerModalDetails(); + }, delayMs); +} + +function renderSchedulerModalLoading() { + updateSchedulerModalBody(` +
+ + Loading scheduler status... +
`); +} + +function renderSchedulerModalError(message) { + updateSchedulerModalBody(` + `); +} + +function ensureSchedulerModalLifecycle() { + const modalEl = document.getElementById('schedulerModal'); + if (!modalEl || modalEl.dataset.schedulerLiveBound === "1") { + return; + } + modalEl.dataset.schedulerLiveBound = "1"; + modalEl.addEventListener('hidden.bs.modal', () => { + stopSchedulerModalPolling(); + }); +} + +function refreshSchedulerModalDetails(showLoading = false) { + if (!isSchedulerModalOpen() || schedulerModalRequestInFlight) { + return; + } + if (showLoading) { + renderSchedulerModalLoading(); + } + schedulerModalRequestInFlight = true; + listenOnceWithTimeout("SchedulerDetails", SCHEDULER_STATUS_TIMEOUT_MS, (msg) => { + schedulerModalRequestInFlight = false; + if (!isSchedulerModalOpen()) { + stopSchedulerModalPolling(); + return; + } + if (!msg || !msg.data) { + renderSchedulerModalError("Failed to load scheduler details"); + scheduleNextSchedulerModalPoll(SCHEDULER_MODAL_IDLE_POLL_MS); + return; + } + updateSchedulerModalBody(renderSchedulerDetails(msg.data)); + scheduleNextSchedulerModalPoll(schedulerModalPollDelay(msg.data)); + }, () => { + schedulerModalRequestInFlight = false; + if (!isSchedulerModalOpen()) { + stopSchedulerModalPolling(); + return; + } + renderSchedulerModalError("Timed out while loading scheduler details"); + scheduleNextSchedulerModalPoll(SCHEDULER_MODAL_IDLE_POLL_MS); + }); + wsClient.send({ SchedulerDetails: {} }); +} + function openSchedulerModal() { const modalEl = document.getElementById('schedulerModal'); if (!modalEl) return; - const myModal = new bootstrap.Modal(modalEl, { focus: true }); - myModal.show(); - $("#schedulerDetailsBody").html(" Loading scheduler status..."); - listenOnce("SchedulerDetails", (msg) => { - if (!msg || !msg.data) { - $("#schedulerDetailsBody").text('Failed to load scheduler details'); - return; - } - $("#schedulerDetailsBody").text(msg.data.details); - }); - wsClient.send({ SchedulerDetails: {} }); + ensureSchedulerModalLifecycle(); + stopSchedulerModalPolling(); + renderSchedulerModalLoading(); + if (!schedulerModalInstance) { + schedulerModalInstance = new bootstrap.Modal(modalEl, { focus: true }); + } + if (isSchedulerModalOpen()) { + schedulerModalInstance.show(); + refreshSchedulerModalDetails(); + return; + } + modalEl.addEventListener('shown.bs.modal', () => { + refreshSchedulerModalDetails(); + }, { once: true }); + schedulerModalInstance.show(); } function getDeviceCounts() { diff --git a/src/rust/lqosd/src/node_manager/static2/config_integration.html b/src/rust/lqosd/src/node_manager/static2/config_integration.html index cf1d214a..3dacba3b 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_integration.html +++ b/src/rust/lqosd/src/node_manager/static2/config_integration.html @@ -42,7 +42,7 @@
-
Use mikrotikDHCPRouterList.csv to map IPv4 subscribers to Mikrotik-advertised IPv6 prefixes.
+
Use /etc/libreqos/mikrotik_ipv6.toml to map IPv4 subscribers to Mikrotik-advertised IPv6 prefixes. Legacy CSV credentials are auto-migrated on upgrade.
diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index 589d91f8..fb9954db 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -204,6 +204,197 @@ html[data-bs-theme="dark"] .lqos_logo { font-size: 0.6rem; line-height: 1; } +.lqos-scheduler-modal-body { + min-height: 20rem; +} +.lqos-scheduler-modal-summary { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1rem 1.1rem; + border: 1px solid var(--lqos-border); + border-radius: 1rem; + background: linear-gradient(180deg, var(--lqos-surface-1), var(--lqos-surface-2)); +} +.lqos-scheduler-modal-indicator { + flex: 0 0 auto; +} +.lqos-scheduler-modal-indicator .lqos-scheduler-ring { + width: 3.25rem; + height: 3.25rem; + flex-basis: 3.25rem; +} +.lqos-scheduler-modal-indicator .lqos-scheduler-ring-track, +.lqos-scheduler-modal-indicator .lqos-scheduler-ring-value { + stroke-width: 2.6; +} +.lqos-scheduler-modal-indicator .lqos-scheduler-ring-icon { + font-size: 1rem; +} +.lqos-scheduler-modal-copy { + flex: 1 1 auto; + min-width: 0; +} +.lqos-scheduler-modal-statusline { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} +.lqos-scheduler-modal-title { + margin: 0; + font-size: 1.2rem; + font-weight: 700; +} +.lqos-scheduler-modal-subtitle { + margin: 0.35rem 0 0; + color: var(--bs-secondary-color); +} +.lqos-scheduler-modal-updated { + margin-top: 0.5rem; + font-size: 0.9rem; + color: var(--bs-secondary-color); +} +.lqos-scheduler-progress-card, +.lqos-scheduler-activity-card, +.lqos-scheduler-debug-card { + margin-top: 1rem; + border: 1px solid var(--lqos-border); + border-radius: 1rem; + background: var(--lqos-surface-1); +} +.lqos-scheduler-progress-card { + padding: 1rem 1.1rem; +} +.lqos-scheduler-progress-topline { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.7rem; + flex-wrap: wrap; +} +.lqos-scheduler-progress-title { + margin: 0; + font-size: 0.98rem; + font-weight: 700; +} +.lqos-scheduler-progress-meta { + margin: 0; + font-size: 0.9rem; + color: var(--bs-secondary-color); +} +.lqos-scheduler-progress-bar { + height: 0.85rem; + border-radius: 999px; + background: var(--lqos-surface-2); +} +.lqos-scheduler-progress-bar .progress-bar { + border-radius: 999px; +} +.lqos-scheduler-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.85rem; + margin-top: 1rem; +} +.lqos-scheduler-stat { + padding: 0.9rem 1rem; + border: 1px solid var(--lqos-border); + border-radius: 0.95rem; + background: var(--lqos-surface-2); +} +.lqos-scheduler-stat-label { + display: block; + margin-bottom: 0.4rem; + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--bs-secondary-color); +} +.lqos-scheduler-stat-value { + display: block; + font-size: 1rem; + font-weight: 600; + line-height: 1.35; +} +.lqos-scheduler-activity-card, +.lqos-scheduler-debug-card { + padding: 1rem 1.1rem; +} +.lqos-scheduler-section-title { + margin: 0 0 0.75rem; + font-size: 0.98rem; + font-weight: 700; +} +.lqos-scheduler-activity-list { + display: flex; + flex-direction: column; + gap: 0.65rem; +} +.lqos-scheduler-activity-item { + display: flex; + align-items: flex-start; + gap: 0.7rem; + padding: 0.7rem 0.85rem; + border-radius: 0.9rem; + background: var(--lqos-surface-2); + border: 1px solid var(--lqos-border); +} +.lqos-scheduler-activity-item i { + margin-top: 0.1rem; + color: var(--bs-secondary-color); +} +.lqos-scheduler-empty { + margin: 0; + padding: 0.9rem 1rem; + border-radius: 0.9rem; + background: var(--lqos-surface-2); + color: var(--bs-secondary-color); + border: 1px dashed var(--lqos-border); +} +.lqos-scheduler-debug-toggle { + cursor: pointer; + font-weight: 600; + color: var(--bs-body-color); + list-style: none; +} +.lqos-scheduler-debug-toggle::-webkit-details-marker { + display: none; +} +.lqos-scheduler-debug-toggle::before { + content: "\f078"; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + margin-right: 0.55rem; + color: var(--bs-secondary-color); +} +.lqos-scheduler-debug-card[open] .lqos-scheduler-debug-toggle::before { + content: "\f077"; +} +.lqos-scheduler-debug-pre { + margin: 0.85rem 0 0; + padding: 0.9rem 1rem; + max-height: 18rem; + overflow: auto; + border-radius: 0.9rem; + background: var(--bs-tertiary-bg); + border: 1px solid var(--lqos-border); + white-space: pre-wrap; + word-break: break-word; + font-size: 0.9rem; +} +@media (max-width: 575.98px) { + .lqos-scheduler-modal-summary { + flex-direction: column; + } + .lqos-scheduler-modal-indicator .lqos-scheduler-ring { + width: 2.8rem; + height: 2.8rem; + flex-basis: 2.8rem; + } +} .navbar-toggler { border: 1px solid var(--lqos-border); border-radius: 0.75rem; diff --git a/src/rust/lqosd/src/node_manager/static2/template.html b/src/rust/lqosd/src/node_manager/static2/template.html index cf617011..28bba6a3 100644 --- a/src/rust/lqosd/src/node_manager/static2/template.html +++ b/src/rust/lqosd/src/node_manager/static2/template.html @@ -239,7 +239,7 @@