mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-25 14:20:05 -05:00
changes
This commit is contained in:
1 parent
6985b3d9dd
commit
066fb4ee39
24 files changed
+1123
-85
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
Router Name / ID,IP,API Username,API Password, API Port
|
||||
main,100.64.0.1,admin,password,8728
|
||||
|
+45
-17
@@ -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())
|
||||
@@ -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<Arc<Config>, 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<MikrotikIpv6RouterCredential>,
|
||||
}
|
||||
|
||||
/// 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<Vec<MikrotikIpv6RouterCredential>, 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<Vec<MikrotikIpv6RouterCredential>, 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<MikrotikIpv6CredentialsFile, MikrotikIpv6CredentialError> {
|
||||
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<Vec<MikrotikIpv6RouterCredential>, 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::<u16>()
|
||||
.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<File, MikrotikIpv6CredentialError> {
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
duplicate_legacy: &mut Vec<String>,
|
||||
removed_duplicate_legacy: &mut Vec<String>,
|
||||
) -> 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<String>,
|
||||
duplicate_legacy: &mut Vec<String>,
|
||||
removed_duplicate_legacy: &mut Vec<String>,
|
||||
) -> 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"
|
||||
|
||||
@@ -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<bool> {
|
||||
Ok(config.integration_common.use_mikrotik_ipv6)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn mikrotik_ipv6_config_path() -> PyResult<String> {
|
||||
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<String> {
|
||||
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<Vec<String>> {
|
||||
let config = lqos_config::load_config().unwrap();
|
||||
|
||||
@@ -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) {
|
||||
</span>`;
|
||||
}
|
||||
|
||||
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
|
||||
? `<div class="alert alert-danger mt-3 mb-0" role="alert"><i class="fa fa-triangle-exclamation me-2"></i>${escapeHtml(String(data.error).trim())}</div>`
|
||||
: "";
|
||||
const activityMarkup = activity.length
|
||||
? activity.map(line => `
|
||||
<div class="lqos-scheduler-activity-item">
|
||||
<i class="fa fa-angle-right" aria-hidden="true"></i>
|
||||
<div>${escapeHtml(line)}</div>
|
||||
</div>`).join("")
|
||||
: `<p class="lqos-scheduler-empty">No recent scheduler output recorded.</p>`;
|
||||
|
||||
return `
|
||||
<div class="lqos-scheduler-modal-summary">
|
||||
<div class="lqos-scheduler-modal-indicator">
|
||||
${schedulerRingMarkup(percent, descriptor.ringTone, descriptor.icon)}
|
||||
</div>
|
||||
<div class="lqos-scheduler-modal-copy">
|
||||
<div class="lqos-scheduler-modal-statusline">
|
||||
<span class="badge ${descriptor.badgeClass}">${escapeHtml(descriptor.label)}</span>
|
||||
<h2 class="lqos-scheduler-modal-title">${escapeHtml(descriptor.title)}</h2>
|
||||
</div>
|
||||
<p class="lqos-scheduler-modal-subtitle">${escapeHtml(phaseLabel)}</p>
|
||||
<div class="lqos-scheduler-modal-updated">${escapeHtml(updatedText)}</div>
|
||||
</div>
|
||||
</div>
|
||||
${alertMarkup}
|
||||
<div class="lqos-scheduler-progress-card">
|
||||
<div class="lqos-scheduler-progress-topline">
|
||||
<div>
|
||||
<h3 class="lqos-scheduler-progress-title">${escapeHtml(stepText)}</h3>
|
||||
<p class="lqos-scheduler-progress-meta mb-0">${escapeHtml(progressMeta)}</p>
|
||||
</div>
|
||||
<div class="fw-semibold">${escapeHtml(`${percent}%`)}</div>
|
||||
</div>
|
||||
<div class="progress lqos-scheduler-progress-bar" role="progressbar" aria-label="Scheduler progress" aria-valuenow="${percent}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar ${schedulerProgressBarClass(descriptor)}" style="width: ${percent}%"></div>
|
||||
</div>
|
||||
<div class="lqos-scheduler-stat-grid">
|
||||
<div class="lqos-scheduler-stat">
|
||||
<span class="lqos-scheduler-stat-label">Availability</span>
|
||||
<span class="lqos-scheduler-stat-value">${escapeHtml(availabilityText)}</span>
|
||||
</div>
|
||||
<div class="lqos-scheduler-stat">
|
||||
<span class="lqos-scheduler-stat-label">Current Phase</span>
|
||||
<span class="lqos-scheduler-stat-value">${escapeHtml(phaseLabel)}</span>
|
||||
</div>
|
||||
<div class="lqos-scheduler-stat">
|
||||
<span class="lqos-scheduler-stat-label">Progress</span>
|
||||
<span class="lqos-scheduler-stat-value">${escapeHtml(stepText)}</span>
|
||||
</div>
|
||||
<div class="lqos-scheduler-stat">
|
||||
<span class="lqos-scheduler-stat-label">Recent Result</span>
|
||||
<span class="lqos-scheduler-stat-value">${escapeHtml(recentResult)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lqos-scheduler-activity-card">
|
||||
<h3 class="lqos-scheduler-section-title">Recent Activity</h3>
|
||||
<div class="lqos-scheduler-activity-list">
|
||||
${activityMarkup}
|
||||
</div>
|
||||
</div>
|
||||
<details class="lqos-scheduler-debug-card">
|
||||
<summary class="lqos-scheduler-debug-toggle">Show raw scheduler details</summary>
|
||||
<pre class="lqos-scheduler-debug-pre">${escapeHtml(data?.details || "No raw scheduler details available.")}</pre>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
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(`
|
||||
<div class="d-flex align-items-center gap-2 text-secondary">
|
||||
<i class='fa fa-spinner fa-spin'></i>
|
||||
<span>Loading scheduler status...</span>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function renderSchedulerModalError(message) {
|
||||
updateSchedulerModalBody(`
|
||||
<div class="alert alert-danger mb-0" role="alert">
|
||||
<i class="fa fa-triangle-exclamation me-2"></i>${escapeHtml(message)}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
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("<i class='fa fa-spinner fa-spin'></i> 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() {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="useMikrotikIpv6">
|
||||
<label class="form-check-label" for="useMikrotikIpv6">Enrich IPv6 via Mikrotik</label>
|
||||
<div class="form-text">Use <code>mikrotikDHCPRouterList.csv</code> to map IPv4 subscribers to Mikrotik-advertised IPv6 prefixes.</div>
|
||||
<div class="form-text">Use <code>/etc/libreqos/mikrotik_ipv6.toml</code> to map IPv4 subscribers to Mikrotik-advertised IPv6 prefixes. Legacy CSV credentials are auto-migrated on upgrade.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-0">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
<button type='button' class='btn-close' data-bs-dismiss='modal' aria-label='Close'></button>
|
||||
</div>
|
||||
<div class='modal-body'>
|
||||
<pre id='schedulerDetailsBody' style='overflow: auto; height: 60vh; width: 100%; white-space: pre-wrap;'></pre>
|
||||
<div id='schedulerDetailsBody' class='lqos-scheduler-modal-body'></div>
|
||||
</div>
|
||||
<div class='modal-footer'>
|
||||
<button type='button' class='btn btn-secondary' data-bs-dismiss='modal'>Close</button>
|
||||
|
||||
@@ -23,32 +23,39 @@ async fn fetch_mikrotik_data(config: &Config) -> anyhow::Result<Vec<Ipv4ToIpv6>>
|
||||
));
|
||||
}
|
||||
|
||||
// Find the `mikrotikDHCPRouterList.csv` file.
|
||||
let mikrotik_dhcp_router_list_path = base_path.join("mikrotikDHCPRouterList.csv");
|
||||
if !mikrotik_dhcp_router_list_path.exists() {
|
||||
let mikrotik_config_path = config.resolved_mikrotik_ipv6_config_path();
|
||||
let legacy_csv_path = config.legacy_runtime_file_path("mikrotikDHCPRouterList.csv");
|
||||
if !mikrotik_config_path.exists() && !legacy_csv_path.exists() {
|
||||
tracing::error!(
|
||||
"Mikrotik DHCP router list not found at {:?}",
|
||||
mikrotik_dhcp_router_list_path
|
||||
"Mikrotik IPv6 credentials not found at {:?} or {:?}",
|
||||
mikrotik_config_path,
|
||||
legacy_csv_path
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Mikrotik DHCP router list not found at {:?}",
|
||||
mikrotik_dhcp_router_list_path
|
||||
"Mikrotik IPv6 credentials not found at {:?} or {:?}",
|
||||
mikrotik_config_path,
|
||||
legacy_csv_path
|
||||
));
|
||||
}
|
||||
|
||||
// Load the script
|
||||
let code = mikrotik_script_path.to_string_lossy().to_string();
|
||||
let csv_path = mikrotik_dhcp_router_list_path.to_string_lossy().to_string();
|
||||
|
||||
// Get the Python environment going
|
||||
let output = Command::new("/usr/bin/python3")
|
||||
.args([&code, &csv_path])
|
||||
.output();
|
||||
let output = Command::new("/usr/bin/python3").arg(&code).output();
|
||||
if let Err(e) = output {
|
||||
tracing::error!("Python error: {:?}", e);
|
||||
return Err(anyhow::anyhow!("Python error: {:?}", e));
|
||||
}
|
||||
let output = output?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Mikrotik helper failed: {}", stderr.trim());
|
||||
return Err(anyhow::anyhow!(
|
||||
"Mikrotik helper failed: {}",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
let json_from_python = String::from_utf8(output.stdout)?;
|
||||
|
||||
// Parse the JSON
|
||||
|
||||
@@ -22,29 +22,34 @@ async fn fetch_mikrotik_data(config: &Config) -> anyhow::Result<Vec<Ipv4ToIpv6>>
|
||||
));
|
||||
}
|
||||
|
||||
let mikrotik_dhcp_router_list_path = base_path.join("mikrotikDHCPRouterList.csv");
|
||||
if !mikrotik_dhcp_router_list_path.exists() {
|
||||
let mikrotik_config_path = config.resolved_mikrotik_ipv6_config_path();
|
||||
let legacy_csv_path = config.legacy_runtime_file_path("mikrotikDHCPRouterList.csv");
|
||||
if !mikrotik_config_path.exists() && !legacy_csv_path.exists() {
|
||||
tracing::error!(
|
||||
"Mikrotik DHCP router list not found at {:?}",
|
||||
mikrotik_dhcp_router_list_path
|
||||
"Mikrotik IPv6 credentials not found at {:?} or {:?}",
|
||||
mikrotik_config_path,
|
||||
legacy_csv_path
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Mikrotik DHCP router list not found at {:?}",
|
||||
mikrotik_dhcp_router_list_path
|
||||
"Mikrotik IPv6 credentials not found at {:?} or {:?}",
|
||||
mikrotik_config_path,
|
||||
legacy_csv_path
|
||||
));
|
||||
}
|
||||
|
||||
let code = mikrotik_script_path.to_string_lossy().to_string();
|
||||
let csv_path = mikrotik_dhcp_router_list_path.to_string_lossy().to_string();
|
||||
|
||||
let output = Command::new("/usr/bin/python3")
|
||||
.args([&code, &csv_path])
|
||||
.output();
|
||||
let output = Command::new("/usr/bin/python3").arg(&code).output();
|
||||
if let Err(e) = output {
|
||||
tracing::error!("Python error: {:?}", e);
|
||||
return Err(anyhow::anyhow!("Python error: {:?}", e));
|
||||
}
|
||||
let output = output?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Mikrotik helper failed: {}", stderr.trim());
|
||||
return Err(anyhow::anyhow!("Mikrotik helper failed: {}", stderr.trim()));
|
||||
}
|
||||
let json_from_python = String::from_utf8(output.stdout)?;
|
||||
let json = serde_json::from_str::<serde_json::Value>(&json_from_python)?;
|
||||
if let Some(map) = json.as_object() {
|
||||
|
||||
Reference in new issue
Block a user