diff --git a/TODO.md b/TODO.md index 36bc58975f..6e4ddfc9da 100644 --- a/TODO.md +++ b/TODO.md @@ -147,6 +147,21 @@ feature that would otherwise write to the controller. - Threshold tuning based on real-world data collection - **Consistent wireless bottleneck attribution across test types:** LAN client speed tests show the bottleneck relative to the AP (e.g., "[AP] Back Yard (wireless)") while WAN client speed tests show it relative to the client (e.g., "[Phone] TJ iPhone (wireless)"). This is because WAN client paths reverse hops and swap ingress/egress, which flips the perspective. The wireless link is the same physical connection - both descriptions are technically correct but inconsistent. Investigate unifying to always name the AP side, since that's what users can control. Relevant code: `CalculateWanClientPathAsync` hop reversal/swap and `CalculateBottleneck` wireless link attribution. +## WAN Speed Test + +### Run from an individual site agent +Both the page and its schedule run a WAN speed test from one vantage per site: the server, or the +on-site agent where one owns path measurement. A site with several agents - one behind each WAN - +cannot say which of them runs the test, so a secondary WAN's throughput cannot be measured the way +its latency already is. + +Wanted: choose the agent, and therefore the WAN, from both the WAN Speed Test page and a schedule. +The Network Tools vantage picker already models this - one entry per (agent, vantage) carrying that +vantage's binding - so the shape is settled and this is wiring it into the speed test paths and the +schedule config. + +Not now. The Gateway SSH launched WAN speed test covers the per-WAN case today. + ## Alerts & Scheduling ### DST-Aware Schedule Time Display diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index 3cbb411e97..26198a2e88 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -37,6 +37,20 @@ Write-Host "" # Step 1: Publish self-contained single-file application Write-Host "[1/5] Publishing self-contained single-file application for win-x64..." -ForegroundColor Yellow + +# Always start from an empty publish folder. Publishing incrementally over a warm +# tree - no compilable change since the last build, e.g. a docs-only release or a +# rebuild after a failed upload - recreates package content folders such as +# LatoFont EMPTY. WiX then harvests the empty folder and packages an MSI that is +# missing files, with no warning and a successful build. That silently cost the +# v2.5.3 MSI its 19 Lato font files. Removing the folder forces the publish target +# to repopulate it; the build output is untouched, so this costs a file copy +# rather than a recompile. +if (Test-Path $PublishDir) { + Write-Host " Cleaning previous publish output..." -ForegroundColor DarkGray + Remove-Item -Recurse -Force $PublishDir +} + dotnet publish $WebProject ` -c $Configuration ` -r win-x64 ` diff --git a/scripts/install-macos-native.sh b/scripts/install-macos-native.sh index 57453e795b..465f956e51 100755 --- a/scripts/install-macos-native.sh +++ b/scripts/install-macos-native.sh @@ -276,16 +276,20 @@ if command -v go &> /dev/null; then GO_ARCH="arm64" fi - CFSPEEDTEST_SRC="$REPO_ROOT/src/cfspeedtest" - if [ -d "$CFSPEEDTEST_SRC" ]; then - cd "$CFSPEEDTEST_SRC" - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -a -trimpath \ - -ldflags "-s -w -X main.version=$GO_VERSION" \ - -o "$INSTALL_DIR/tools/cfspeedtest-linux-arm64" . - echo "Built cfspeedtest for linux/arm64" - else - echo "Warning: cfspeedtest source not found at $CFSPEEDTEST_SRC" - fi + # cfspeedtest is no longer deployed: nothing in the app invokes the standalone + # binary any more, uwnspeedtest below superseded it for gateway WAN tests. The + # src/cfspeedtest module itself stays, since uwnspeedtest imports its speedtest + # package. Left commented rather than deleted in case the binary is wanted again. + # CFSPEEDTEST_SRC="$REPO_ROOT/src/cfspeedtest" + # if [ -d "$CFSPEEDTEST_SRC" ]; then + # cd "$CFSPEEDTEST_SRC" + # CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -a -trimpath \ + # -ldflags "-s -w -X main.version=$GO_VERSION" \ + # -o "$INSTALL_DIR/tools/cfspeedtest-linux-arm64" . + # echo "Built cfspeedtest for linux/arm64" + # else + # echo "Warning: cfspeedtest source not found at $CFSPEEDTEST_SRC" + # fi UWNSPEEDTEST_SRC="$REPO_ROOT/src/uwnspeedtest" if [ -d "$UWNSPEEDTEST_SRC" ]; then diff --git a/scripts/proxmox/install-agent.sh b/scripts/proxmox/install-agent.sh new file mode 100644 index 0000000000..7310b667e2 --- /dev/null +++ b/scripts/proxmox/install-agent.sh @@ -0,0 +1,510 @@ +#!/usr/bin/env bash + +# Network Optimizer on-site agent - Proxmox LXC Installation Script +# https://github.com/Ozark-Connect/NetworkOptimizer +# +# Creates a small Debian LXC on this Proxmox host and installs the on-site agent +# inside it. The container is the only thing this script builds - the agent itself +# is installed by the standard installer (scripts/agent/install-native.sh), so +# there is one agent install path however you get there. +# +# Generate the enrollment token in the server's web UI under +# Settings > Multi-Site > (site) > Agents > Set up agent. +# +# Usage: +# bash -c "$(wget -qLO - https://raw.githubusercontent.com/Ozark-Connect/NetworkOptimizer/main/scripts/proxmox/install-agent.sh)" +# +# Every prompt below is also an option. Supplying it skips that question, so +# building one agent per WAN is a flag-driven run each rather than an interview +# each. --unattended takes the default for anything not supplied and asks nothing. +# +# Options: +# --ct-id N Container ID (default: next free) +# --hostname NAME Container hostname (default: netopt-agent) +# --debian-version N Debian major version for the template (default: 13) +# --ram MB / --swap MB / --cores N / --disk GB +# --storage NAME Storage for the container rootfs +# --template-storage NAME Storage holding container templates +# --bridge NAME Network bridge (default: vmbr0) +# --vlan TAG VLAN tag for the container's interface +# --ip ADDR CIDR address, or "dhcp" (default: dhcp) +# --gateway ADDR Gateway, required with a static --ip +# --dns ADDR Nameserver for a static --ip +# --server URL Network Optimizer server this agent reports to +# --token TOKEN One-time enrollment token +# --lan-speed-test Host the LAN speed test page and iperf3 in this container +# --speed-test-port N Serve the speed test page on N instead of 24443 +# --insecure Accept a self-signed cert on the server's reverse proxy +# --unattended Never prompt; take defaults for anything not supplied +# +# Requirements: +# - Proxmox VE 7.0 or later +# - Internet access for the container template and the agent binary + +set -Eeuo pipefail + +# ============================================================================= +# Configuration Defaults +# ============================================================================= +APP_NAME="Network Optimizer agent" +GITHUB_REPO="Ozark-Connect/NetworkOptimizer" +GITHUB_BRANCH="main" + +# The agent is a single self-contained binary with no database and no Docker, so +# it needs a fraction of what the server container does. +DEFAULT_HOSTNAME="netopt-agent" +DEFAULT_DISK_SIZE="4" +DEFAULT_RAM="512" +DEFAULT_SWAP="256" +DEFAULT_CPU="1" +DEFAULT_BRIDGE="vmbr0" +DEFAULT_STORAGE="local-lvm" +DEFAULT_TEMPLATE_STORAGE="local" +DEFAULT_DEBIAN_VERSION="13" +DEFAULT_SPEED_TEST_PORT="24443" + +# ============================================================================= +# Colors and Formatting +# ============================================================================= +readonly RD='\033[0;31m' +readonly GN='\033[0;32m' +readonly YW='\033[0;33m' +readonly BL='\033[0;34m' +readonly CY='\033[0;36m' +readonly BLD='\033[1m' +readonly DIM='\033[2m' +readonly CL='\033[0m' + +# ============================================================================= +# Helper Functions +# ============================================================================= +msg_info() { echo -e "${BL}[INFO]${CL} $1"; } +msg_ok() { echo -e "${GN}[ OK ]${CL} $1"; } +msg_warn() { echo -e "${YW}[WARN]${CL} $1"; } +msg_error() { echo -e "${RD}[FAIL]${CL} $1"; } + +header() { + echo + echo -e "${BLD}${CY}=== $1 ===${CL}" + echo +} + +# Anything created before a failure is removed, so a half-built container is not +# left behind for the next run to trip over. +CT_CREATED=false +cleanup() { + local code=$? + if [[ $code -ne 0 ]] && [[ "$CT_CREATED" == "true" ]] && [[ -n "${CT_ID:-}" ]]; then + msg_warn "Install failed - removing container $CT_ID" + pct stop "$CT_ID" &>/dev/null || true + pct destroy "$CT_ID" &>/dev/null || true + fi + exit $code +} +trap cleanup EXIT + +check_root() { + if [[ $EUID -ne 0 ]]; then + msg_error "This script must be run as root on Proxmox VE." + exit 1 + fi +} + +check_proxmox() { + if ! command -v pveversion &>/dev/null; then + msg_error "This script must be run on Proxmox VE." + echo -e "${DIM}To install the agent on a machine you already have, use scripts/agent/install-native.sh instead.${CL}" + exit 1 + fi + local pve_version + pve_version=$(pveversion --verbose | grep "pve-manager" | awk '{print $2}' | cut -d'/' -f1) + msg_ok "Proxmox VE $pve_version detected" +} + +get_next_ct_id() { + local id=100 + while pct status "$id" &>/dev/null || qm status "$id" &>/dev/null 2>&1; do + ((id++)) + done + echo "$id" +} + +validate_ct_id() { + local id=$1 + if ! [[ "$id" =~ ^[0-9]+$ ]]; then + msg_error "Container ID must be a number." + return 1 + fi + if [[ "$id" -lt 100 ]]; then + msg_error "Container ID must be 100 or greater." + return 1 + fi + if pct status "$id" &>/dev/null || qm status "$id" &>/dev/null 2>&1; then + msg_error "ID $id already exists (VM or container)." + return 1 + fi + return 0 +} + +validate_hostname() { + if ! [[ "$1" =~ ^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$ ]]; then + msg_error "Invalid hostname: $1" + return 1 + fi + return 0 +} + +get_storage_list() { pvesm status -content rootdir 2>/dev/null | awk 'NR>1 {print $1}' | tr '\n' ' '; } +get_template_storage_list() { pvesm status -content vztmpl 2>/dev/null | awk 'NR>1 {print $1}' | tr '\n' ' '; } +get_bridge_list() { ip -o link show type bridge 2>/dev/null | awk -F': ' '{print $2}' | tr '\n' ' '; } + +validate_storage() { + pvesm status -content "$2" 2>/dev/null | awk 'NR>1 {print $1}' | grep -qw "$1" +} + +find_debian_template() { + local storage=$1 version=${2:-13} + pveam update &>/dev/null || true + local template + template=$(pveam available --section system 2>/dev/null \ + | awk '{print $2}' | grep "^debian-${version}-standard" | sort -V | tail -n1) + if [[ -z "$template" ]]; then + template=$(pveam list "$storage" 2>/dev/null \ + | awk '{print $1}' | grep "debian-${version}-standard" | sed 's|.*/||' | sort -V | tail -n1) + fi + if [[ -z "$template" ]]; then + msg_error "No Debian ${version} template found." + exit 1 + fi + echo "$template" +} + +# ============================================================================= +# Options +# ============================================================================= +UNATTENDED=false +CT_ID=""; CT_HOSTNAME=""; DEBIAN_VERSION="" +CT_RAM=""; CT_SWAP=""; CT_CPU=""; CT_DISK="" +CT_STORAGE=""; TEMPLATE_STORAGE=""; CT_BRIDGE=""; CT_VLAN_TAG="" +CT_IP=""; CT_GW=""; CT_DNS="" +AGENT_SERVER=""; AGENT_TOKEN="" +AGENT_LAN_SPEED_TEST=""; AGENT_SPEED_TEST_PORT=""; AGENT_INSECURE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --ct-id) CT_ID="$2"; shift 2 ;; + --hostname) CT_HOSTNAME="$2"; shift 2 ;; + --debian-version) DEBIAN_VERSION="$2"; shift 2 ;; + --ram) CT_RAM="$2"; shift 2 ;; + --swap) CT_SWAP="$2"; shift 2 ;; + --cores) CT_CPU="$2"; shift 2 ;; + --disk) CT_DISK="$2"; shift 2 ;; + --storage) CT_STORAGE="$2"; shift 2 ;; + --template-storage) TEMPLATE_STORAGE="$2"; shift 2 ;; + --bridge) CT_BRIDGE="$2"; shift 2 ;; + --vlan) CT_VLAN_TAG="$2"; shift 2 ;; + --ip) CT_IP="$2"; shift 2 ;; + --gateway) CT_GW="$2"; shift 2 ;; + --dns) CT_DNS="$2"; shift 2 ;; + --server) AGENT_SERVER="$2"; shift 2 ;; + --token) AGENT_TOKEN="$2"; shift 2 ;; + --lan-speed-test) AGENT_LAN_SPEED_TEST=true; shift ;; + --speed-test-port) AGENT_SPEED_TEST_PORT="$2"; AGENT_LAN_SPEED_TEST=true; shift 2 ;; + --insecure) AGENT_INSECURE=true; shift ;; + --unattended) UNATTENDED=true; shift ;; + -h|--help) sed -n '3,42p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) msg_error "Unknown option: $1"; exit 1 ;; + esac +done + +# Ask only for what was not supplied. In unattended mode nothing is asked and the +# default stands, which is what makes this scriptable for several WANs at once. +ask() { + local prompt=$1 default=$2 current=$3 answer + if [[ -n "$current" ]]; then echo "$current"; return; fi + if [[ "$UNATTENDED" == "true" ]]; then echo "$default"; return; fi + read -rp "$(echo -e "${BLD}${prompt}${CL} [${default}]: ")" answer /dev/null || true + echo -e "${CY}${BLD}" + echo " Network Optimizer - on-site agent" + echo " Proxmox LXC installer" + echo -e "${CL}" + echo -e "${DIM} Creates a container and installs the agent inside it.${CL}" + echo +} + +configure_container() { + header "Container Configuration" + + local default_id + default_id=$(get_next_ct_id) + while true; do + CT_ID=$(ask "Container ID" "$default_id" "$CT_ID") + validate_ct_id "$CT_ID" && break + [[ "$UNATTENDED" == "true" ]] && exit 1 + CT_ID="" + done + + while true; do + CT_HOSTNAME=$(ask "Hostname" "$DEFAULT_HOSTNAME" "$CT_HOSTNAME") + validate_hostname "$CT_HOSTNAME" && break + [[ "$UNATTENDED" == "true" ]] && exit 1 + CT_HOSTNAME="" + done + + DEBIAN_VERSION=$(ask "Debian version" "$DEFAULT_DEBIAN_VERSION" "$DEBIAN_VERSION") + CT_RAM=$(ask "RAM in MB" "$DEFAULT_RAM" "$CT_RAM") + CT_SWAP=$(ask "Swap in MB" "$DEFAULT_SWAP" "$CT_SWAP") + CT_CPU=$(ask "CPU cores" "$DEFAULT_CPU" "$CT_CPU") + CT_DISK=$(ask "Disk size in GB" "$DEFAULT_DISK_SIZE" "$CT_DISK") + + if [[ -z "$CT_STORAGE" ]] && [[ "$UNATTENDED" != "true" ]]; then + echo -e "${DIM}Available: $(get_storage_list)${CL}" + fi + CT_STORAGE=$(ask "Storage for container" "$DEFAULT_STORAGE" "$CT_STORAGE") + if ! validate_storage "$CT_STORAGE" rootdir; then + msg_error "Storage '$CT_STORAGE' cannot hold containers." + exit 1 + fi + + if [[ -z "$TEMPLATE_STORAGE" ]] && [[ "$UNATTENDED" != "true" ]]; then + echo -e "${DIM}Available: $(get_template_storage_list)${CL}" + fi + TEMPLATE_STORAGE=$(ask "Storage for templates" "$DEFAULT_TEMPLATE_STORAGE" "$TEMPLATE_STORAGE") + if ! validate_storage "$TEMPLATE_STORAGE" vztmpl; then + msg_error "Storage '$TEMPLATE_STORAGE' cannot hold templates." + exit 1 + fi + + if [[ -z "$CT_BRIDGE" ]] && [[ "$UNATTENDED" != "true" ]]; then + echo -e "${DIM}Available: $(get_bridge_list)${CL}" + fi + CT_BRIDGE=$(ask "Network bridge" "$DEFAULT_BRIDGE" "$CT_BRIDGE") + + # A WAN-context agent is often on its own VLAN, so this is asked rather than + # buried in a flag. + CT_VLAN_TAG=$(ask "VLAN tag (blank for none)" "" "$CT_VLAN_TAG") + + CT_IP=$(ask "IP address (CIDR, or dhcp)" "dhcp" "$CT_IP") + if [[ "$CT_IP" != "dhcp" ]]; then + CT_GW=$(ask "Gateway" "" "$CT_GW") + if [[ -z "$CT_GW" ]]; then + msg_error "A static IP needs a gateway." + exit 1 + fi + CT_DNS=$(ask "DNS server" "$CT_GW" "$CT_DNS") + fi +} + +configure_agent() { + header "Agent Configuration" + echo -e "${DIM}The token comes from the server's web UI: Settings > Multi-Site > (site) > Agents.${CL}" + echo + + AGENT_SERVER=$(ask "Server URL (https://...)" "" "$AGENT_SERVER") + if [[ -z "$AGENT_SERVER" ]]; then + msg_error "The agent needs the server URL to report to." + exit 1 + fi + + AGENT_TOKEN=$(ask "Enrollment token" "" "$AGENT_TOKEN") + if [[ -z "$AGENT_TOKEN" ]]; then + msg_error "The agent needs a one-time enrollment token." + exit 1 + fi + + if [[ -z "$AGENT_LAN_SPEED_TEST" ]]; then + local answer + answer=$(ask "Host the LAN speed test in this container? (y/n)" "n" "") + [[ "$answer" =~ ^[Yy] ]] && AGENT_LAN_SPEED_TEST=true || AGENT_LAN_SPEED_TEST=false + fi + if [[ "$AGENT_LAN_SPEED_TEST" == "true" ]]; then + AGENT_SPEED_TEST_PORT=$(ask "Speed test port" "$DEFAULT_SPEED_TEST_PORT" "$AGENT_SPEED_TEST_PORT") + fi +} + +confirm_settings() { + [[ "$UNATTENDED" == "true" ]] && return 0 + + header "Review" + echo -e " Container: ${CY}${CT_ID}${CL} (${CT_HOSTNAME}), Debian ${DEBIAN_VERSION}" + echo -e " Resources: ${CT_CPU} core(s), ${CT_RAM} MB RAM, ${CT_DISK} GB disk" + echo -e " Storage: ${CT_STORAGE} (templates: ${TEMPLATE_STORAGE})" + echo -e " Network: ${CT_BRIDGE}${CT_VLAN_TAG:+ VLAN ${CT_VLAN_TAG}}, ${CT_IP}" + echo -e " Server: ${CY}${AGENT_SERVER}${CL}" + echo -e " Speed test: $([[ "$AGENT_LAN_SPEED_TEST" == "true" ]] && echo "yes (port ${AGENT_SPEED_TEST_PORT})" || echo "no")" + echo + local answer + read -rp "$(echo -e "${BLD}Create it? (y/n)${CL} [y]: ")" answer /dev/null || echo "") + if [[ -f "$template_path" ]]; then + msg_ok "Already downloaded" + return 0 + fi + + msg_info "Downloading..." + if ! pveam download "$TEMPLATE_STORAGE" "$CT_TEMPLATE_FILE"; then + msg_error "Failed to download the container template." + exit 1 + fi + msg_ok "Downloaded" +} + +create_container() { + header "Creating Container" + msg_info "Creating $CT_ID ($CT_HOSTNAME)..." + + local net_config="name=eth0,bridge=$CT_BRIDGE" + if [[ "$CT_IP" == "dhcp" ]]; then + net_config="${net_config},ip=dhcp" + else + net_config="${net_config},ip=${CT_IP},gw=${CT_GW}" + fi + [[ -n "$CT_VLAN_TAG" ]] && net_config="${net_config},tag=${CT_VLAN_TAG}" + + # Unprivileged, no nesting: the agent is a plain systemd service with no Docker + # under it, so it needs none of the concessions the server container makes. + pct create "$CT_ID" "$TEMPLATE_STORAGE:vztmpl/$CT_TEMPLATE_FILE" \ + --hostname "$CT_HOSTNAME" \ + --memory "$CT_RAM" \ + --swap "$CT_SWAP" \ + --cores "$CT_CPU" \ + --rootfs "$CT_STORAGE:$CT_DISK" \ + --net0 "$net_config" \ + --ostype debian \ + --unprivileged 1 \ + --onboot 1 \ + --start 0 + CT_CREATED=true + + if [[ "$CT_IP" != "dhcp" ]] && [[ -n "$CT_DNS" ]]; then + pct set "$CT_ID" --nameserver "$CT_DNS" + fi + + msg_ok "Container created" +} + +start_container() { + msg_info "Starting container..." + pct start "$CT_ID" + + local max_wait=60 waited=0 + while ! pct exec "$CT_ID" -- test -f /etc/os-release 2>/dev/null; do + sleep 1 + ((waited++)) + if [[ $waited -ge $max_wait ]]; then + msg_error "Container failed to start within ${max_wait}s" + exit 1 + fi + done + sleep 3 + msg_ok "Container started" +} + +install_agent() { + header "Installing the Agent" + + msg_info "Installing prerequisites..." + pct exec "$CT_ID" -- bash -c "apt-get update -qq && apt-get install -y -qq curl ca-certificates iputils-ping traceroute" >/dev/null + msg_ok "Prerequisites installed" + + # The agent's service runs as root, and root inside a container holds CAP_NET_RAW over its own + # user namespace, so ICMP already works. This is for the case where it does not: Debian 13 + # dropped the CAP_NET_RAW file capability from ping entirely and relies on ICMP datagram + # sockets, which are gated by this sysctl - and systemd's stock value (0 2147483647) is + # REJECTED in an unprivileged container because the upper GID falls outside Proxmox's id map, + # leaving the kernel default of "no group may create these sockets". 65534 is the top of the + # mapped range. Costs nothing today and means a hardened or non-root agent still pings. + pct exec "$CT_ID" -- bash -c "echo 'net.ipv4.ping_group_range = 0 65534' > /etc/sysctl.d/99-ping-group-range.conf && sysctl -q -w 'net.ipv4.ping_group_range=0 65534'" >/dev/null 2>&1 || msg_warn "Could not set ping_group_range - ICMP still works for the root-run agent" + + # The standard installer does the actual work, so a container agent and a + # bare-metal agent are the same install with the same layout and the same + # upgrade path. + local args="--server '${AGENT_SERVER}' --token '${AGENT_TOKEN}'" + [[ "$AGENT_LAN_SPEED_TEST" == "true" ]] && args="$args --lan-speed-test" + [[ -n "$AGENT_SPEED_TEST_PORT" ]] && args="$args --speed-test-port '${AGENT_SPEED_TEST_PORT}'" + [[ "$AGENT_INSECURE" == "true" ]] && args="$args --insecure" + + msg_info "Running the agent installer inside the container..." + if ! pct exec "$CT_ID" -- bash -c \ + "curl -fsSL https://raw.githubusercontent.com/${GITHUB_REPO}/${GITHUB_BRANCH}/scripts/agent/install-native.sh | bash -s -- ${args}"; then + msg_error "The agent installer failed inside the container." + echo -e "${DIM}The container is left in place so you can look: pct enter ${CT_ID}${CL}" + CT_CREATED=false + exit 1 + fi + msg_ok "Agent installed" +} + +get_container_ip() { + pct exec "$CT_ID" -- hostname -I 2>/dev/null | awk '{print $1}' +} + +show_completion() { + header "Done" + + local ip mac + ip=$(get_container_ip) + mac=$(pct config "$CT_ID" | awk -F'hwaddr=' '/^net0:/ {split($2,a,","); print a[1]}') + + echo -e "${GN}${BLD}The agent is installed and enrolled.${CL}\n" + echo -e "${BLD}Container:${CL}" + echo -e " ID / hostname: ${CY}${CT_ID}${CL} (${CT_HOSTNAME})" + echo -e " Address: ${CY}${ip:-pending}${CL}" + echo -e " MAC: ${CY}${mac:-unknown}${CL}" + if [[ "$AGENT_LAN_SPEED_TEST" == "true" ]]; then + echo -e " Speed test: ${CY}https://${ip}:${AGENT_SPEED_TEST_PORT}${CL}" + fi + echo + echo -e "${BLD}Check on it:${CL}" + echo -e " ${DIM}pct exec ${CT_ID} -- systemctl status netopt-agent${CL}" + echo -e " ${DIM}pct exec ${CT_ID} -- journalctl -u netopt-agent -f${CL}" + echo + echo -e "${BLD}Monitoring a second WAN with this agent?${CL}" + echo -e " In UniFi Network, add a Policy-Based Route sending this container out that WAN:" + echo -e " ${DIM}Settings > Policy Table > Policy-Based Route - the WAN as the interface,${CL}" + echo -e " ${DIM}this container's Client Device (MAC ${mac:-above}) as the source, Any as the destination.${CL}" + echo -e " Then give the WAN a context in Monitoring > Setup and assign this agent to it." + echo +} + +main() { + check_root + check_proxmox + show_banner + configure_container + configure_agent + confirm_settings + download_template + create_container + start_container + install_agent + show_completion + trap - EXIT +} + +main "$@" diff --git a/scripts/reset-password.ps1 b/scripts/reset-password.ps1 index e8b963339a..b9648c23b6 100644 --- a/scripts/reset-password.ps1 +++ b/scripts/reset-password.ps1 @@ -126,6 +126,100 @@ if (-not $sqlite3) { Write-Host "sqlite3: $sqlite3Path" -ForegroundColor Green Write-Host "" +# Set by Invoke-Sql so a caller that cannot tolerate a failed write can tell. Errors are +# swallowed rather than thrown because some of these run against tables an older install +# predates, and a missing table exits sqlite3 non-zero - which PowerShell 7.4+ turns into a +# terminating error under $ErrorActionPreference = 'Stop'. Swallowing keeps that from aborting +# a reset that has already succeeded; the flag keeps it from being mistaken for success. +$script:SqlSucceeded = $true + +function Invoke-Sql { + param([string]$Query) + $script:SqlSucceeded = $true + try { + # The app may be running and writing; wait for our turn rather than fail instantly + # with "database is locked". Set through .timeout rather than "PRAGMA busy_timeout = + # ...", which returns the new value as a result row and would prepend 15000 to the + # output of every query that reads one back. + $output = & $sqlite3Path -cmd ".timeout 15000" $dbPath $Query 2>$null + if ($LASTEXITCODE -ne 0) { $script:SqlSucceeded = $false } + $output + } catch { + $script:SqlSucceeded = $false + } +} + +# Sign-in reads AspNetUsers.PasswordHash, and the app only copies the legacy password across +# when the admin account does not exist yet. So on an install that has already migrated, +# clearing AdminSettings alone regenerates and prints a password that is then refused at the +# login page. Copy the freshly generated hash across ourselves. +# +# The hash is copied, never re-derived, so this needs no crypto and no plaintext. It is written +# in the old dotted PBKDF2 format, which the app still accepts and quietly upgrades on first +# sign-in. The account is updated, never deleted: deleting it cannot be undone and would take +# the admin's site memberships and roles with it. +function Sync-IdentityAdmin { + $hasIdentity = Invoke-Sql "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='AspNetUsers';" + if ($hasIdentity -ne '1') { + return # pre-Identity install: the legacy row is the whole story + } + + # The app writes the new hash while it starts; wait for it rather than race it. + $legacyHash = $null + $deadline = (Get-Date).AddSeconds(20) + while ((Get-Date) -lt $deadline) { + $legacyHash = Invoke-Sql "SELECT ifnull(Password,'') FROM AdminSettings LIMIT 1;" + if ($legacyHash) { break } + Start-Sleep -Seconds 1 + } + + if (-not $legacyHash) { + Write-Host "WARNING: Could not read the regenerated password; the admin account was left unchanged." -ForegroundColor Yellow + return + } + + Write-Host "Applying the new password to the admin account..." -NoNewline + Invoke-Sql @" +UPDATE AspNetUsers + SET PasswordHash = '$legacyHash', + PasswordIsTemporary = 1, + IsEnabled = 1, + LockoutEnd = NULL, + AccessFailedCount = 0, + SecurityStamp = lower(hex(randomblob(16))) + WHERE NormalizedUserName = 'ADMIN'; +"@ | Out-Null + if (-not $script:SqlSucceeded) { + Write-Host " FAILED." -ForegroundColor Red + Write-Host "The password below will NOT work. Re-run this script to try again." -ForegroundColor Red + return + } + Write-Host " done." -ForegroundColor Green +} + +# The reset only restores the password. Two other settings can still turn the login away, +# and from the login page both look exactly like a wrong password - so say so here rather +# than leave someone retyping a password that was never the problem. Neither is changed +# automatically: one would weaken the install's SSO policy, the other would throw away an +# MFA enrollment. +function Write-BlockerWarnings { + $ssoOnly = Invoke-Sql "SELECT Value FROM SystemSettings WHERE Key = 'auth.local_login_disabled';" + if ($ssoOnly -eq 'true') { + Write-Host "" + Write-Host "WARNING: Local logins are disabled on this install (single sign-on only)." -ForegroundColor Yellow + Write-Host " The password below will be refused until an administrator re-enables" -ForegroundColor Yellow + Write-Host " local login, or you restart with NETOPT_RECOVERY=1 to bypass it once." -ForegroundColor Yellow + } + + $mfaOn = Invoke-Sql "SELECT TwoFactorEnabled FROM AspNetUsers WHERE NormalizedUserName = 'ADMIN';" + if ($mfaOn -eq '1') { + Write-Host "" + Write-Host "WARNING: The admin account has two-factor authentication enabled." -ForegroundColor Yellow + Write-Host " You will still be asked for your authenticator code after signing in." -ForegroundColor Yellow + Write-Host " Use a saved recovery code if you no longer have the authenticator." -ForegroundColor Yellow + } +} + # ============================================================================= # Confirm with user # ============================================================================= @@ -167,10 +261,11 @@ if ($svcObj.Status -eq 'Running') { # Clear admin password # ============================================================================= Write-Host "Clearing admin password..." -NoNewline -& $sqlite3Path $dbPath "UPDATE AdminSettings SET Password = NULL, Enabled = 0;" +& $sqlite3Path -cmd ".timeout 15000" $dbPath "UPDATE AdminSettings SET Password = NULL, Enabled = 0;" if ($LASTEXITCODE -ne 0) { Write-Host " FAILED." -ForegroundColor Red - Write-Host "sqlite3 returned exit code $LASTEXITCODE" + Write-Host "sqlite3 returned exit code $LASTEXITCODE - the database may be busy." + Write-Host "Nothing was changed. Wait a moment and run this script again." exit 1 } Write-Host " done." -ForegroundColor Green @@ -218,6 +313,10 @@ $logDir = Join-Path $InstallDir "logs" $today = (Get-Date).ToString("yyyyMMdd") $logFile = Join-Path $logDir "networkoptimizer-$today.log" +Sync-IdentityAdmin +Write-BlockerWarnings +Write-Host "" + $password = $null if (Test-Path $logFile) { # Find the last occurrence of the password line after AUTO-GENERATED banner diff --git a/scripts/reset-password.sh b/scripts/reset-password.sh index 8f3e3bf1fb..1e96612b7e 100755 --- a/scripts/reset-password.sh +++ b/scripts/reset-password.sh @@ -48,6 +48,26 @@ DATA_DIR="" FORCE=false TIMEOUT=60 HEALTH_URL="http://localhost:8042/api/health" +DB_PATH="" # resolved by the native modes +DOCKER_DB_PATH="/app/data/network_optimizer.db" + +# Runs one statement against the application database, whichever way this mode reaches it. +# +# The app is usually running and writing while we do this - the Docker path never stops the +# container at all - so an unqualified write loses a coin toss against the app's own +# transaction and dies with "database is locked". busy_timeout makes sqlite wait for its turn +# instead of failing instantly, which is the difference between a reset that works and one the +# operator has to keep re-running. +# +# Set through .timeout rather than "PRAGMA busy_timeout = ...", which returns the new value as +# a result row and would prepend 15000 to the output of every query that reads one back. +run_sql() { + if [[ "$MODE" == "docker" ]]; then + docker exec "$CONTAINER" sqlite3 -cmd ".timeout 15000" "$DOCKER_DB_PATH" "$1" + else + sqlite3 -cmd ".timeout 15000" "$DB_PATH" "$1" + fi +} # ============================================================================= # Parse Arguments @@ -234,8 +254,11 @@ reset_docker() { # Clear password via docker exec msg_info "Clearing admin password..." - docker exec "$CONTAINER" sqlite3 /app/data/network_optimizer.db \ - "UPDATE AdminSettings SET Password = NULL, Enabled = 0;" + if ! run_sql "UPDATE AdminSettings SET Password = NULL, Enabled = 0;"; then + msg_error "Could not clear the password - the database is busy." + msg_error "Nothing was changed. Wait a moment and run this script again." + exit 1 + fi msg_ok "Password cleared" # Restart container @@ -266,6 +289,7 @@ reset_macos() { local plist="$HOME/Library/LaunchAgents/net.ozarkconnect.networkoptimizer.plist" local db_dir="${DATA_DIR:-$HOME/Library/Application Support/NetworkOptimizer}" local db_path="$db_dir/network_optimizer.db" + DB_PATH="$db_path" # Detect install directory from plist WorkingDirectory or running process local install_dir="" @@ -312,7 +336,11 @@ reset_macos() { # Clear password msg_info "Clearing admin password..." - sqlite3 "$db_path" "UPDATE AdminSettings SET Password = NULL, Enabled = 0;" + if ! run_sql "UPDATE AdminSettings SET Password = NULL, Enabled = 0;"; then + msg_error "Could not clear the password - the database is busy." + msg_error "Nothing was changed. Wait a moment and run this script again." + exit 1 + fi msg_ok "Password cleared" # Start service @@ -432,7 +460,11 @@ reset_linux() { # Clear password msg_info "Clearing admin password..." - sqlite3 "$db_path" "UPDATE AdminSettings SET Password = NULL, Enabled = 0;" + if ! run_sql "UPDATE AdminSettings SET Password = NULL, Enabled = 0;"; then + msg_error "Could not clear the password - the database is busy." + msg_error "Nothing was changed. Wait a moment and run this script again." + exit 1 + fi msg_ok "Password cleared" # Start service @@ -477,12 +509,101 @@ reset_linux() { show_result "$password" } +# ============================================================================= +# Apply the regenerated password to the Identity admin account +# ============================================================================= +# Sign-in reads AspNetUsers.PasswordHash, and the app only copies the legacy password +# across when the admin account does not exist yet. So on an install that has already +# migrated, clearing AdminSettings alone regenerates and prints a password that is then +# refused at the login page. Copy the freshly generated hash across ourselves. +# +# The hash is copied, never re-derived, so this needs no crypto and no plaintext. It is +# written in the old dotted PBKDF2 format, which the app still accepts and quietly +# upgrades on first sign-in. +# +# The account is updated, never deleted: deleting it cannot be undone and would take the +# admin's site memberships and roles with it. +sync_identity_admin() { + # Older installs have no Identity tables - there the legacy row is the whole story and + # the reset above is already complete. + local has_identity + has_identity=$(run_sql "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='AspNetUsers';" 2>/dev/null || echo "0") + if [[ "$has_identity" != "1" ]]; then + return 0 + fi + + # The app writes the new hash while it starts; wait for it rather than race it. + local legacy_hash="" deadline=$((SECONDS + 20)) + while [[ $SECONDS -lt $deadline ]]; do + legacy_hash=$(run_sql "SELECT ifnull(Password,'') FROM AdminSettings LIMIT 1;" 2>/dev/null || echo "") + if [[ -n "$legacy_hash" ]]; then + break + fi + sleep 1 + done + + if [[ -z "$legacy_hash" ]]; then + msg_warn "Could not read the regenerated password; the admin account was left unchanged." + return 1 + fi + + msg_info "Applying the new password to the admin account..." + # Checked explicitly rather than left to set -e: this function is called as part of an + # || list, which switches errexit off for everything inside it, so a failed write would + # otherwise fall straight through to the success message and print a password that does + # not work - the one outcome this whole script exists to avoid. + if ! run_sql "UPDATE AspNetUsers + SET PasswordHash = '${legacy_hash}', + PasswordIsTemporary = 1, + IsEnabled = 1, + LockoutEnd = NULL, + AccessFailedCount = 0, + SecurityStamp = lower(hex(randomblob(16))) + WHERE NormalizedUserName = 'ADMIN';" >/dev/null; then + msg_error "Could not update the admin account." + msg_error "The password below will NOT work. Re-run this script to try again." + return 1 + fi + msg_ok "Admin account updated" +} + +# ============================================================================= +# Warn about settings that refuse the new password even after a successful reset +# ============================================================================= +# The reset only restores the password. Two other settings can still turn the login +# away, and from the login page both look exactly like a wrong password - so say so +# here rather than leave someone retyping a password that was never the problem. +# Neither is changed automatically: one would weaken the install's SSO policy, the +# other would throw away an MFA enrollment. +warn_about_blockers() { + local sso_only mfa_on + + sso_only=$(run_sql "SELECT Value FROM SystemSettings WHERE Key = 'auth.local_login_disabled';" 2>/dev/null || echo "") + if [[ "$sso_only" == "true" ]]; then + echo "" + msg_warn "Local logins are disabled on this install (single sign-on only)." + msg_warn "The password below will be refused until an administrator re-enables" + msg_warn "local login, or you restart with NETOPT_RECOVERY=1 to bypass it once." + fi + + mfa_on=$(run_sql "SELECT TwoFactorEnabled FROM AspNetUsers WHERE NormalizedUserName = 'ADMIN';" 2>/dev/null || echo "") + if [[ "$mfa_on" == "1" ]]; then + echo "" + msg_warn "The admin account has two-factor authentication enabled." + msg_warn "You will still be asked for your authenticator code after signing in." + msg_warn "Use a saved recovery code if you no longer have the authenticator." + fi +} + # ============================================================================= # Display Result # ============================================================================= show_result() { local password="$1" + sync_identity_admin || true + warn_about_blockers + if [[ -n "$password" ]]; then echo -e "${GN}===================================${CL}" echo -e "${GN} Password reset successful!${CL}" diff --git a/src/NetworkOptimizer.Agent/Program.cs b/src/NetworkOptimizer.Agent/Program.cs index ebb068fdf0..8c56a673ab 100644 --- a/src/NetworkOptimizer.Agent/Program.cs +++ b/src/NetworkOptimizer.Agent/Program.cs @@ -105,6 +105,10 @@ static int SpeedTestPagePort(NetworkOptimizer.Agent.AgentConfig cfg) => var lanIp = !string.IsNullOrWhiteSpace(lanIpOverride) ? lanIpOverride.Trim() : NetworkOptimizer.Core.Helpers.NetworkUtilities.DetectLocalIpFromInterfaces(); +// lanIp is one address chosen for the server to reach this agent back on. The full set goes +// alongside it so the server can recognise the host by an address it already knows, which the +// single choice cannot do on a gateway - see LocalUnicastAddresses. +var localIps = NetworkOptimizer.Core.Helpers.NetworkUtilities.LocalUnicastAddresses(); var handler = new HttpClientHandler(); if (config.IgnoreSslErrors) @@ -311,9 +315,14 @@ void SaveSpool() // Announce the port only when a speed test server is actually up. An agent that // serves none - a gateway install, or one where the server failed to start - has no // port to give, and claiming a port would advertise a listener that is not there. - await tunnel.RunAsync(config.TunnelUrl, config.AgentKey!, version, lanIp, + // Source binding is a platform capability, not a setting: it rides the + // native ping binary, so the server only offers a WAN context an + // interface bind where the agent can actually honor one. + await tunnel.RunAsync(config.TunnelUrl, config.AgentKey!, version, lanIp, localIps, speedTestServer != null ? SpeedTestPagePort(config) : 0, - speedTestServer != null, config.IgnoreSslErrors, cts.Token); + speedTestServer != null, + NetworkOptimizer.Monitoring.Probes.LocalProbeExecutor.SupportsSourceBinding, + config.IgnoreSslErrors, cts.Token); Console.Error.WriteLine("Tunnel closed by server, reconnecting..."); } catch (OperationCanceledException) when (cts.IsCancellationRequested) diff --git a/src/NetworkOptimizer.Agent/README.md b/src/NetworkOptimizer.Agent/README.md index 8b0d87120f..988e4abd3e 100644 --- a/src/NetworkOptimizer.Agent/README.md +++ b/src/NetworkOptimizer.Agent/README.md @@ -353,6 +353,18 @@ journalctl -u netopt-agent -f ## Reverse proxy +The central server never serves TLS itself - it binds plain HTTP on 8042 by +design, and a reverse proxy in front terminates TLS and manages certificates. +That proxy is a prerequisite for agents, not a finishing touch: the agent speaks +HTTPS only and refuses to start against an `http://` server URL. + +If you don't already run one, +**[NetworkOptimizer-Proxy](https://github.com/Ozark-Connect/NetworkOptimizer-Proxy)** +is a ready-to-use Traefik setup (Let's Encrypt certificates via Cloudflare +DNS-01) that ships the agent tunnel route **enabled by default** - point it at +your hostname and there is nothing else to configure for agents. The rest of +this section is for folding the tunnel into a proxy you already run. + The tunnel listener speaks HTTP/2 over TLS with an ephemeral self-signed certificate: the reverse proxy fronting the central server terminates the agent's public TLS and re-encrypts to the tunnel port, skipping verification on @@ -397,8 +409,11 @@ serversTransports: ```caddyfile optimizer.example.com { @grpc path /networkoptimizer.agent.v1.AgentTunnel/* - reverse_proxy @grpc https://127.0.0.1:8043 { - transport http { tls_insecure_skip_verify } + reverse_proxy @grpc https://localhost:8043 { + transport http { + tls_insecure_skip_verify + } + header_up Host {http.request.host} } reverse_proxy 127.0.0.1:8042 } @@ -410,9 +425,11 @@ optimizer.example.com { location /networkoptimizer.agent.v1.AgentTunnel/ { grpc_pass grpcs://127.0.0.1:8043; grpc_ssl_verify off; + grpc_set_header Host $host; } location / { proxy_pass http://127.0.0.1:8042; + proxy_set_header Host $host; } ``` @@ -423,6 +440,26 @@ Everything rides that one TLS session: heartbeats, probe and SNMP traffic (including SNMP credentials pushed to the agent), and proxied UniFi Console connections - which are additionally HTTPS end-to-end inside the tunnel. +### After the proxy is up: tell the app its address + +Set **`REVERSE_PROXIED_HOST_NAME`** on the central server to the proxy's +hostname (plus `REVERSE_PROXIED_PORT` if the proxy's front end is not on 443), +then restart it. This is what the agent's server URL is derived from, so until +it is set, **Settings > Multi-Site > (site) > Agents** has no address to put in +the install command and shows a placeholder instead. Substituting the app's own +LAN address there does not work: the agent would dial that host on 443, where +the app does not listen and the proxy is not running. + +Verify before enrolling an agent - from the site, or anywhere outside the +server's own box: + +```bash +curl -sSf https://optimizer.example.com/api/health +``` + +That has to succeed over HTTPS on the hostname you configured. If it does not, +fix the proxy first; the agent has no fallback to plain HTTP by design. + ## Security and hardening The agent dials out only, so the site never exposes an inbound port - a real diff --git a/src/NetworkOptimizer.Agent/TunnelClient.cs b/src/NetworkOptimizer.Agent/TunnelClient.cs index 913c074180..a9524db2f4 100644 --- a/src/NetworkOptimizer.Agent/TunnelClient.cs +++ b/src/NetworkOptimizer.Agent/TunnelClient.cs @@ -116,7 +116,7 @@ public async ValueTask SendAsync(AgentMessage message, CancellationToken c /// Connects and runs the tunnel until it drops or is /// cancelled. Throws on connection failure so the caller can back off and retry. /// - public async Task RunAsync(string tunnelUrl, string agentKey, string version, string? lanIp, int speedTestPort, bool servesSpeedTest, bool ignoreSslErrors, CancellationToken ct) + public async Task RunAsync(string tunnelUrl, string agentKey, string version, string? lanIp, IReadOnlyList localIps, int speedTestPort, bool servesSpeedTest, bool supportsSourceBind, bool ignoreSslErrors, CancellationToken ct) { // Belt-and-braces with the startup config validation: the tunnel carries // SNMP credentials and proxied console traffic, so cleartext is never OK. @@ -168,7 +168,16 @@ public async Task RunAsync(string tunnelUrl, string agentKey, string version, st await call.RequestStream.WriteAsync(new AgentMessage { - Hello = new AgentHello { AgentKey = agentKey, Version = version, LanIp = lanIp ?? "", SpeedTestPort = speedTestPort, ServesSpeedTest = servesSpeedTest } + Hello = new AgentHello + { + AgentKey = agentKey, + Version = version, + LanIp = lanIp ?? "", + SpeedTestPort = speedTestPort, + ServesSpeedTest = servesSpeedTest, + SupportsSourceBind = supportsSourceBind, + LocalIps = { localIps } + } }, helloCts.Token); if (!await call.ResponseStream.MoveNext(helloCts.Token) || call.ResponseStream.Current.Hello is not { } hello) diff --git a/src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto b/src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto index 466090896e..5be6c8e908 100644 --- a/src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto +++ b/src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto @@ -142,6 +142,21 @@ message AgentHello { // absent and the server falls back to deciding for itself, while a gateway // install answers a definite no without being guessed at by its location. optional bool serves_speed_test = 5; + // Whether this agent can bind a probe to a source address or interface, which + // needs the native ping binary (Linux/macOS). Explicitly optional for the same + // reason as above: an agent predating this leaves it absent, and the server + // reads "did not say" as "do not offer interface binding" rather than guessing + // a capability whose absence fails every probe that relies on it. + optional bool supports_source_bind = 6; + // Every unicast address this agent's host holds. lan_ip is one address chosen + // out of these, and on a gateway the choice is arbitrary: an agent on a UniFi + // gateway may report an uplink address the console never lists as the gateway's + // own, so matching that single address against the addresses the console knows + // answers "is this the gateway" with a false no. Sending all of them lets the + // server ask whether ANY of them is one it recognises, without widening what it + // treats as a gateway address. Empty from an agent that predates this, which + // leaves the server on the single-address comparison it has always done. + repeated string local_ips = 7; } message ServerHello { diff --git a/src/NetworkOptimizer.Alerts/AlertCorrelationService.cs b/src/NetworkOptimizer.Alerts/AlertCorrelationService.cs index 669be20576..853f95edd7 100644 --- a/src/NetworkOptimizer.Alerts/AlertCorrelationService.cs +++ b/src/NetworkOptimizer.Alerts/AlertCorrelationService.cs @@ -36,6 +36,65 @@ public static (AlertStatus Status, DateTime? ResolvedAt) DeriveIncidentStatus(Li return (AlertStatus.Active, null); } + /// + /// Re-derives the status of the incident an alert belongs to from the statuses of every alert + /// in that incident, and persists it when it changed. No-op for an uncorrelated alert. Shared + /// by the UI's acknowledge/resolve actions and by the pipeline's automatic resolution. + /// + /// + /// Re-derives many incidents at once: one read for the incidents, one for every alert on them, + /// and one save. Doing it per incident costs two round trips and a commit each, which is what + /// the bulk buttons still paid after the alerts themselves were batched - a few hundred alerts + /// usually means nearly as many incidents, since most incidents hold one alert. + /// + public static async Task RecalculateIncidentStatusesAsync( + IReadOnlyCollection incidentIds, + IAlertRepository repository, + CancellationToken cancellationToken = default) + { + if (incidentIds.Count == 0) return; + + var incidents = await repository.GetIncidentsByIdsAsync(incidentIds, cancellationToken); + if (incidents.Count == 0) return; + + var alertsByIncident = (await repository.GetAlertsByIncidentIdsAsync(incidentIds, cancellationToken)) + .GroupBy(a => a.IncidentId!.Value) + .ToDictionary(g => g.Key, g => g.ToList()); + + var changed = new List(); + foreach (var incident in incidents) + { + if (!alertsByIncident.TryGetValue(incident.Id, out var alerts)) continue; + var (status, resolvedAt) = DeriveIncidentStatus(alerts); + if (status == incident.Status) continue; + incident.Status = status; + incident.ResolvedAt = resolvedAt; + changed.Add(incident); + } + + await repository.UpdateIncidentsAsync(changed, cancellationToken); + } + + public static async Task RecalculateIncidentStatusAsync( + AlertHistoryEntry alert, + IAlertRepository repository, + CancellationToken cancellationToken = default) + { + if (!alert.IncidentId.HasValue) return; + + var incident = await repository.GetIncidentAsync(alert.IncidentId.Value, cancellationToken); + if (incident == null) return; + + var incidentAlerts = await repository.GetAlertsByIncidentIdAsync(incident.Id, cancellationToken); + var (newStatus, resolvedAt) = DeriveIncidentStatus(incidentAlerts); + + if (newStatus == incident.Status) return; + + incident.Status = newStatus; + incident.ResolvedAt = resolvedAt; + await repository.UpdateIncidentAsync(incident, cancellationToken); + } + /// /// Derive a correlation key from an alert event. /// Events with the same key within the correlation window will be grouped. diff --git a/src/NetworkOptimizer.Alerts/AlertProcessingService.cs b/src/NetworkOptimizer.Alerts/AlertProcessingService.cs index d19dd869ff..324865aaf2 100644 --- a/src/NetworkOptimizer.Alerts/AlertProcessingService.cs +++ b/src/NetworkOptimizer.Alerts/AlertProcessingService.cs @@ -127,6 +127,10 @@ private async Task ProcessEventAsync(AlertEvent alertEvent, CancellationToken ca scope.ServiceProvider.GetRequiredService().UseSite(alertEvent.SiteSlug); var repository = scope.ServiceProvider.GetRequiredService(); + // Close whatever this event supersedes before any rule is consulted, so an install with + // no rule for the recovery event still gets its open alerts closed. + await ResolveSupersededAlertsAsync(alertEvent, repository, cancellationToken); + var siteKey = alertEvent.SiteSlug ?? ""; var rules = await GetRulesAsync(repository, siteKey, cancellationToken); @@ -160,6 +164,150 @@ private async Task ProcessEventAsync(AlertEvent alertEvent, CancellationToken ca } } + // --- WAN outage alerts: one of two open/close families, unlike the rest of the alert catalog --- + // + // The three monitoring.wan_* event types maintain at most one open alert per (WAN, kind). + // A WAN raises a partial outage while part of the path out is unreachable but traffic still + // flows, and a total outage once the whole WAN is confirmed down; "all-wans" is the site-level + // rollup raised when every WAN is out at once. Both kinds close themselves: monitoring.wan_recovered + // closes the WAN's open outage alerts (and invalidates any rollup, since a rollup asserts that + // every WAN was out), and a confirmed total outage supersedes the partial it grew out of rather + // than stacking a second open alert on the same WAN. + // + // Per-target monitoring alerts (monitoring.target_offline and friends) have no such pairing and + // stay open until a user resolves them, so this closing step applies to the two families that + // do: monitoring.wan_* here and starlink.* below. + + internal const string WanOutageEventType = "monitoring.wan_outage"; + internal const string WanOutagePartialEventType = "monitoring.wan_outage_partial"; + internal const string WanRecoveredEventType = "monitoring.wan_recovered"; + + /// DeviceId carried by the site-level rollup alert, instead of a single WAN's key. + internal const string AllWansDeviceId = "all-wans"; + + /// + /// Decides which open alerts an incoming event closes: each entry is the set of event types to + /// resolve for one DeviceId, or for EVERY device when the DeviceId is null. Empty for every + /// event outside the WAN outage family. + /// + internal static List<(string[] EventTypes, string? DeviceId)> GetWanAlertsToResolve(string eventType, string? deviceId) + { + var targets = new List<(string[] EventTypes, string? DeviceId)>(); + var wanKey = deviceId?.Trim(); + + // The site rollup supersedes every per-WAN alert: it says the whole site is down, which + // is the same outage those alerts were each describing a piece of. + if (eventType == WanOutageEventType && wanKey == AllWansDeviceId) + { + targets.Add(([WanOutageEventType, WanOutagePartialEventType], null)); + return targets; + } + + if (eventType == WanRecoveredEventType) + { + // A recovery closes this WAN's own outage alerts, whichever kind is open... + if (!string.IsNullOrEmpty(wanKey)) + targets.Add(([WanOutageEventType, WanOutagePartialEventType], wanKey)); + + // ...and invalidates the site rollup, which claimed every WAN was out. + targets.Add(([WanOutageEventType], AllWansDeviceId)); + } + else if (eventType == WanOutageEventType && !string.IsNullOrEmpty(wanKey) && wanKey != AllWansDeviceId) + { + // The total outage supersedes the partial that preceded it on the same WAN. + targets.Add(([WanOutagePartialEventType], wanKey)); + } + + return targets; + } + + // --- Starlink dish alerts: the other open/close family --- + // + // The dish poll keeps at most one open alert per (dish, condition). A condition that is + // already alerting republishes only on new evidence, and a starlink.recovered event names the + // condition it closes in its context, so a dish that clears its obstruction keeps whatever + // other alert it still has open. + + internal const string StarlinkRecoveredEventType = "starlink.recovered"; + + /// + /// Context key on a starlink.recovered event naming the event type it closes. Written by + /// StarlinkAlertEvaluator.RecoveredTypeKey, which lives in the Web project and so cannot be + /// shared with this one; the two must stay in step. + /// + internal const string StarlinkRecoveredTypeKey = "recovered_type"; + + private const string StarlinkEventPrefix = "starlink."; + + /// + /// Which open Starlink alerts an incoming starlink.* event closes. A recovery closes the one + /// condition it names on that dish; any other Starlink event supersedes the open alert of its + /// own type on the same dish, which is what keeps one condition to one open alert. Empty for + /// every event outside the family, and for one carrying no dish. + /// + internal static List<(string[] EventTypes, string? DeviceId)> GetStarlinkAlertsToResolve( + string eventType, string? deviceId, IReadOnlyDictionary? context) + { + var targets = new List<(string[] EventTypes, string? DeviceId)>(); + var dishId = deviceId?.Trim(); + if (string.IsNullOrEmpty(dishId) || !eventType.StartsWith(StarlinkEventPrefix, StringComparison.Ordinal)) + return targets; + + if (eventType == StarlinkRecoveredEventType) + { + if (context != null + && context.TryGetValue(StarlinkRecoveredTypeKey, out var recovered) + && !string.IsNullOrWhiteSpace(recovered)) + { + targets.Add(([recovered], dishId)); + } + return targets; + } + + targets.Add(([eventType], dishId)); + return targets; + } + + /// + /// Closes the open alerts this event supersedes and re-derives the status of any incident they + /// belonged to. Failures are logged and swallowed: resolution is housekeeping and must never + /// stop the event from being processed into an alert. + /// + internal async Task ResolveSupersededAlertsAsync( + AlertEvent alertEvent, + IAlertRepository repository, + CancellationToken cancellationToken) + { + var targets = GetWanAlertsToResolve(alertEvent.EventType, alertEvent.DeviceId); + targets.AddRange(GetStarlinkAlertsToResolve( + alertEvent.EventType, alertEvent.DeviceId, alertEvent.Context)); + if (targets.Count == 0) + return; + + try + { + foreach (var (eventTypes, deviceId) in targets) + { + var resolved = deviceId == null + ? await repository.ResolveActiveAlertsAnyDeviceAsync(eventTypes, cancellationToken) + : await repository.ResolveActiveAlertsAsync(eventTypes, deviceId, cancellationToken); + if (resolved.Count == 0) + continue; + + _logger.LogDebug("Closed {Count} open alert(s) for {DeviceId} on {EventType}", + resolved.Count, deviceId, alertEvent.EventType); + + foreach (var alert in resolved) + await AlertCorrelationService.RecalculateIncidentStatusAsync(alert, repository, cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to close open alerts for event {EventType} ({DeviceId})", + alertEvent.EventType, alertEvent.DeviceId); + } + } + private async Task ProcessRuleMatchAsync( AlertEvent alertEvent, AlertRule rule, diff --git a/src/NetworkOptimizer.Alerts/DefaultAlertRules.cs b/src/NetworkOptimizer.Alerts/DefaultAlertRules.cs index a1bcc6ed22..9aff39416f 100644 --- a/src/NetworkOptimizer.Alerts/DefaultAlertRules.cs +++ b/src/NetworkOptimizer.Alerts/DefaultAlertRules.cs @@ -251,6 +251,35 @@ public static List GetDefaults() => MinSeverity = AlertSeverity.Warning, CooldownSeconds = 1800 // 30 minutes }, + new AlertRule + { + Name = "Monitoring: WAN Outage", + IsEnabled = true, + EventTypePattern = "monitoring.wan_outage", + Source = "monitoring", + MinSeverity = AlertSeverity.Warning, + CooldownSeconds = 600 // 10 minutes - the WAN outage evaluator opens one alert per outage + }, + new AlertRule + { + // Info on purpose: a partial outage on a non-primary WAN publishes at Info, and this + // rule has to match it. + Name = "Monitoring: WAN Partial Outage", + IsEnabled = true, + EventTypePattern = "monitoring.wan_outage_partial", + Source = "monitoring", + MinSeverity = AlertSeverity.Info, + CooldownSeconds = 600 // 10 minutes - the WAN outage evaluator opens one alert per outage + }, + new AlertRule + { + Name = "Monitoring: WAN Recovered", + IsEnabled = true, + EventTypePattern = "monitoring.wan_recovered", + Source = "monitoring", + MinSeverity = AlertSeverity.Info, + CooldownSeconds = 60 // 1 minute - recoveries are paired with outage events + }, // --- SFP / PON threshold alerts (enabled - auto-managed for detected modules) --- new AlertRule @@ -448,6 +477,98 @@ public static List GetDefaults() => Source = "cellular", MinSeverity = AlertSeverity.Warning, CooldownSeconds = 3600 // 1 hour + }, + + // --- Starlink dish (disabled until user configures a dish) --- + // Severity here does NOT follow the per-WAN outage table, which rates a backup's troubles + // lower. Starlink is usually the backup, and a backup that nothing else monitors is + // discovered broken at the moment it is needed - so its problems keep real severity. + // + // EVERY rule in this block carries NO cooldown, which is deliberate and load-bearing. + // Two reasons: + // + // 1. It would silently drop the alert that replaces a superseded one. These types keep + // one open alert per (dish, condition) by having a re-publish supersede its own + // predecessor, and AlertProcessingService resolves the old row BEFORE rules are + // consulted. Cooldown keys are per (site, rule, device), so a replacement shares the + // key of the alert it just closed: an obstruction escalating Warning -> Critical + // inside the cooldown would resolve the Warning and then have the Critical suppressed, + // leaving a critically obstructed dish with no open alert at all. The WAN outage family + // is immune only because a total supersedes a PARTIAL - a different rule, a different + // key. + // 2. It is redundant anyway. StarlinkAlertEvaluator publishes on state changes only, and + // is where the real throttling lives: sustain windows and hysteresis on the gated + // conditions, "new evidence only" on the dish's own codes, and edge-triggering on + // restriction. Nothing here can produce a stream to damp. + new AlertRule + { + // The dish's own verdict on itself: its alert codes, a self-test that started failing, + // and being taken out of service. Warning, or Critical when it publishes as disabled. + Name = "Starlink: Dish Fault", + IsEnabled = false, + EventTypePattern = "starlink.dish_alert", + Source = "starlink", + MinSeverity = AlertSeverity.Warning, + CooldownSeconds = 0 // republishes only when a new code appears or it goes out of service + }, + new AlertRule + { + Name = "Starlink: Obstructed", + IsEnabled = false, + EventTypePattern = "starlink.obstructed", + Source = "starlink", + MinSeverity = AlertSeverity.Warning, + CooldownSeconds = 0 // 15 minute sustain to open, and at most one escalation per episode + }, + new AlertRule + { + Name = "Starlink: Alignment Drift", + IsEnabled = false, + EventTypePattern = "starlink.alignment_drift", + Source = "starlink", + MinSeverity = AlertSeverity.Warning, + CooldownSeconds = 0 // opens once per episode; it cannot raise again without recovering first + }, + new AlertRule + { + Name = "Starlink: Ethernet Speed Degraded", + IsEnabled = false, + EventTypePattern = "starlink.eth_speed_degraded", + Source = "starlink", + MinSeverity = AlertSeverity.Warning, + CooldownSeconds = 0 // 5 minute sustain to open, then once per episode + }, + new AlertRule + { + Name = "Starlink: Repeated Outages", + IsEnabled = false, + EventTypePattern = "starlink.outage_burst", + Source = "starlink", + MinSeverity = AlertSeverity.Warning, + CooldownSeconds = 0 // opens once when the rolling day crosses the bar, closes when it drops back + }, + new AlertRule + { + // Info on purpose: crossing into a rate limit is a change worth a quiet note, not a + // fault, and this rule has to match the Info the evaluator publishes. + Name = "Starlink: Service Rate Limited", + IsEnabled = false, + EventTypePattern = "starlink.service_restricted", + Source = "starlink", + MinSeverity = AlertSeverity.Info, + CooldownSeconds = 0 // edge-triggered; a permanently restricted dish never publishes at all + }, + new AlertRule + { + // Unlike every other recovery rule, this one type closes SIX different conditions and + // they all share the dish's device id - so a cooldown here would swallow the second + // condition's recovery whenever two clear together. + Name = "Starlink: Recovered", + IsEnabled = false, + EventTypePattern = "starlink.recovered", + Source = "starlink", + MinSeverity = AlertSeverity.Info, + CooldownSeconds = 0 } ]; } diff --git a/src/NetworkOptimizer.Alerts/Interfaces/IAlertRepository.cs b/src/NetworkOptimizer.Alerts/Interfaces/IAlertRepository.cs index 4e0a478a99..32b2db2ede 100644 --- a/src/NetworkOptimizer.Alerts/Interfaces/IAlertRepository.cs +++ b/src/NetworkOptimizer.Alerts/Interfaces/IAlertRepository.cs @@ -29,11 +29,61 @@ public interface IAlertRepository Task UpdateAlertAsync(AlertHistoryEntry alert, CancellationToken cancellationToken = default); Task> GetActiveAlertsAsync(CancellationToken cancellationToken = default); Task> GetAlertHistoryAsync(int limit = 100, string? source = null, AlertSeverity? minSeverity = null, CancellationToken cancellationToken = default); + + /// + /// One page of alert history, newest first, with the total the filters match so the caller can + /// say how many pages there are. Paged in SQL: the history of a site that has been running a + /// while is far more than a page, and the flat take showed only its newest slice with nothing + /// to say the rest existed. + /// + Task<(List Items, int Total)> GetAlertHistoryPageAsync(int skip, int take, string? source = null, AlertSeverity? minSeverity = null, CancellationToken cancellationToken = default); Task GetAlertAsync(int id, CancellationToken cancellationToken = default); Task> GetAlertsForDigestAsync(DateTime since, CancellationToken cancellationToken = default); Task> GetUnresolvedAlertsAsync(CancellationToken cancellationToken = default); Task> GetAlertsByIncidentIdAsync(int incidentId, CancellationToken cancellationToken = default); + /// + /// Sets the status of many alerts in ONE round trip, stamping the timestamp that goes with it. + /// The bulk buttons used to call per alert, and each of those is + /// a SaveChanges - a SQLite commit - against a change tracker that grew by an entity every + /// iteration. Returns the number of rows changed. + /// + Task SetAlertStatusAsync(IReadOnlyCollection alertIds, AlertStatus status, DateTime timestamp, CancellationToken cancellationToken = default); + + /// Incidents by id, in one query - the batch counterpart of GetIncidentAsync. + Task> GetIncidentsByIdsAsync(IReadOnlyCollection incidentIds, CancellationToken cancellationToken = default); + + /// + /// The incidents that are not resolved, newest first. Filtered in SQL rather than by the + /// caller: taking the newest N and filtering afterwards hides an unresolved incident the + /// moment N newer ones have been resolved, which on a busy site is permanent. + /// + Task> GetUnresolvedIncidentsAsync(int limit = 50, CancellationToken cancellationToken = default); + + /// + /// Every alert belonging to any of these incidents, in one query. Re-deriving a few hundred + /// incidents one at a time is two round trips each, which is the bulk buttons' remaining cost + /// once the alerts themselves are written together. + /// + Task> GetAlertsByIncidentIdsAsync(IReadOnlyCollection incidentIds, CancellationToken cancellationToken = default); + + /// Saves several incidents in one round trip. + Task UpdateIncidentsAsync(IReadOnlyCollection incidents, CancellationToken cancellationToken = default); + + /// + /// Marks every active alert of the given event types on the given device as resolved and + /// returns the entries that were closed. Used by the alert pipeline to close open WAN outage + /// alerts when their recovery - or a superseding total outage - event arrives. + /// + Task> ResolveActiveAlertsAsync(IReadOnlyCollection eventTypes, string deviceId, CancellationToken cancellationToken = default); + + /// + /// Marks every active alert of the given event types as resolved, whatever device it names, + /// and returns the entries that were closed. Used when a site-wide alert supersedes the + /// per-device alerts describing pieces of the same event. + /// + Task> ResolveActiveAlertsAnyDeviceAsync(IReadOnlyCollection eventTypes, CancellationToken cancellationToken = default); + // --- Alert Incidents --- Task SaveIncidentAsync(AlertIncident incident, CancellationToken cancellationToken = default); Task UpdateIncidentAsync(AlertIncident incident, CancellationToken cancellationToken = default); diff --git a/src/NetworkOptimizer.Alerts/Models/SeededAlertRule.cs b/src/NetworkOptimizer.Alerts/Models/SeededAlertRule.cs new file mode 100644 index 0000000000..0db5985d20 --- /dev/null +++ b/src/NetworkOptimizer.Alerts/Models/SeededAlertRule.cs @@ -0,0 +1,23 @@ +namespace NetworkOptimizer.Alerts.Models; + +/// +/// Records that a default alert rule pattern has been seeded into this database once, so +/// deleting the rule is honored across restarts. Startup only inserts a default whose pattern +/// is missing from both AlertRules and this table; without the record, every restart brought +/// back rules the user had deliberately deleted. +/// +public class SeededAlertRule +{ + public int Id { get; set; } + + /// + /// Event type pattern of the default rule that was seeded. + /// + public string EventTypePattern { get; set; } = string.Empty; + + /// + /// When the pattern was seeded, or when it was backfilled for an install whose rules + /// predate this record. + /// + public DateTime SeededAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/NetworkOptimizer.Audit/Services/IeeeOuiDatabase.cs b/src/NetworkOptimizer.Audit/Services/IeeeOuiDatabase.cs index da820e1664..3a7a9740d3 100644 --- a/src/NetworkOptimizer.Audit/Services/IeeeOuiDatabase.cs +++ b/src/NetworkOptimizer.Audit/Services/IeeeOuiDatabase.cs @@ -89,10 +89,31 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) if (string.IsNullOrEmpty(macOrOui)) return null; + // Before the OUI fold: these are matched on the WHOLE address, not a prefix. + if (KnownFixedMacs.TryGetValue(NormalizeMac(macOrOui), out var fixedVendor)) + return fixedVendor; + var oui = NormalizeToOui(macOrOui); return _ouiToVendor.TryGetValue(oui, out var vendor) ? vendor : null; } + /// + /// Vendors for specific locally-administered MACs. Such an address is by definition absent + /// from the IEEE registry, so no amount of registry data will ever name it - but kit that + /// ships with a FIXED one is still identifiable by the whole address. Every Starlink dish of + /// this generation presents 26:12:AC:1A:80:01 on its LAN side, so a gateway neighbor lookup + /// that came back blank was a name we already had and were not using. Keyed on the full MAC + /// deliberately: the prefix is locally administered and says nothing on its own. + /// + private static readonly Dictionary KnownFixedMacs = + new(StringComparer.OrdinalIgnoreCase) { ["2612AC1A8001"] = "Starlink" }; + + private static string NormalizeMac(string input) => input + .Replace(":", "") + .Replace("-", "") + .Replace(".", "") + .ToUpperInvariant(); + /// /// Check if a vendor exists in the database /// diff --git a/src/NetworkOptimizer.Core/Helpers/NetworkUtilities.cs b/src/NetworkOptimizer.Core/Helpers/NetworkUtilities.cs index a3a365100c..d6ffc074ff 100644 --- a/src/NetworkOptimizer.Core/Helpers/NetworkUtilities.cs +++ b/src/NetworkOptimizer.Core/Helpers/NetworkUtilities.cs @@ -39,6 +39,51 @@ public static class NetworkUtilities return DetectLocalIpFromInterfaces(); } + /// + /// Every IPv4 unicast address this host holds, across all interfaces that are up. + /// + /// Distinct from , which picks the ONE address that + /// best represents the host. That choice is arbitrary when something else has to recognise the + /// host by an address it already knows: on a UniFi gateway the best-looking address can be an + /// uplink the console never lists as the gateway's own, so a single-address comparison answers + /// "is this that machine" with a false no. + /// + /// + /// Bridges and virtual interfaces are INCLUDED here, unlike the single-address detection that + /// skips them: a gateway's LAN address lives on a bridge, and it is one of the addresses a + /// console does report. Loopback and link-local (169.254/16) are excluded - neither identifies + /// a host, and both would be held by every machine, so comparing them could only produce a + /// false match. + /// + /// + public static IReadOnlyList LocalUnicastAddresses() + { + var addresses = new List(); + try + { + foreach (var ni in NetworkInterface.GetAllNetworkInterfaces()) + { + if (ni.OperationalStatus != OperationalStatus.Up) continue; + if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; + foreach (var unicast in ni.GetIPProperties().UnicastAddresses) + { + var address = unicast.Address; + if (address.AddressFamily != AddressFamily.InterNetwork) continue; + if (IPAddress.IsLoopback(address)) continue; + var text = address.ToString(); + if (text.StartsWith("169.254.", StringComparison.Ordinal)) continue; + if (!addresses.Contains(text, StringComparer.OrdinalIgnoreCase)) + addresses.Add(text); + } + } + } + catch + { + // Enumeration is best effort: the caller still has its single detected address. + } + return addresses; + } + /// /// Detect local IP address from network interfaces (ignores HOST_IP env var). /// Prioritizes: Physical Ethernet > WiFi > Other. @@ -858,4 +903,23 @@ public static bool IsPppoeInterface(string? uplinkIfName) if (string.IsNullOrWhiteSpace(uplinkIfName)) return false; return Regex.IsMatch(uplinkIfName.Trim(), @"^ppp(oe)?\d+$", RegexOptions.IgnoreCase); } + + /// + /// True when a WAN's data-path interface name is the GRE tunnel a UniFi gateway uses to reach an + /// attached UniFi Cellular Modem - "gre0", "gre1". Nothing else on a UniFi gateway presents a WAN + /// that way, so a match identifies the medium as cellular without asking. + /// + /// The implication runs one way only. A third-party LTE/5G modem, a carrier router in bridge + /// mode, or a USB dongle is every bit as cellular and appears as an ordinary Ethernet or PPPoE + /// WAN, so FALSE says nothing about the medium - do not read it as "not cellular". + /// + /// Anchored for the same reason as : "gretap0", or any name merely + /// beginning with those letters, is not one of these. Pass the data-path interface (uplink_ifname, + /// which a UniFi Cellular Modem WAN reports as gre1 for both it and ifname). + /// + public static bool IsUniFiCellularModemTunnel(string? dataPathIfName) + { + if (string.IsNullOrWhiteSpace(dataPathIfName)) return false; + return Regex.IsMatch(dataPathIfName.Trim(), @"^gre\d+$", RegexOptions.IgnoreCase); + } } diff --git a/src/NetworkOptimizer.Diagnostics/Analyzers/PerformanceAnalyzer.cs b/src/NetworkOptimizer.Diagnostics/Analyzers/PerformanceAnalyzer.cs index 4f148df636..1e208f53cf 100644 --- a/src/NetworkOptimizer.Diagnostics/Analyzers/PerformanceAnalyzer.cs +++ b/src/NetworkOptimizer.Diagnostics/Analyzers/PerformanceAnalyzer.cs @@ -44,7 +44,8 @@ public List Analyze( JsonDocument? wanEnrichedData = null, bool runPerformanceChecks = true, bool runCellularChecks = true, - List? portProfiles = null) + List? portProfiles = null, + List? wanShaperStates = null) { var issues = new List(); @@ -54,6 +55,7 @@ public List Analyze( issues.AddRange(CheckJumboFrames(devices, settingsData)); issues.AddRange(CheckFlowControl(devices, networks, clients, settingsData, portProfiles)); issues.AddRange(CheckSqmFirmwareRegression(devices, networks)); + issues.AddRange(CheckSqmNotShaping(devices, wanShaperStates)); } if (runCellularChecks) @@ -490,6 +492,93 @@ internal List CheckSqmFirmwareRegression( return issues; } + /// + /// Check whether the WANs that have Smart Queues enabled are actually being shaped. + /// + /// UniFi Network regularly accepts the Smart Queues toggle without provisioning the queues: + /// the setting reads as on, no shaper is ever created, and the connection runs unshaped with + /// nothing on screen to say so. The gateway's own traffic control is the only place the truth + /// shows, so the states come from an SSH read (GatewayShaperProbeService) and are empty + /// whenever the gateway cannot be reached - a site we cannot see raises nothing. + /// + /// Egress rides the WAN's data-path interface (upload), ingress rides its "ifb" companion + /// (download), and a direction UniFi was explicitly told to shape at 0 is not expected to + /// have a shaper at all. + /// + [VendorSpecific("UniFi", "UniFi Network's Smart Queues provisioning and its ifb ingress device naming")] + internal List CheckSqmNotShaping( + List devices, + List? wanShaperStates) + { + var issues = new List(); + + if (wanShaperStates == null || wanShaperStates.Count == 0) + return issues; + + var gatewayName = devices.FirstOrDefault(d => d.DeviceType == DeviceType.Gateway)?.Name; + + foreach (var state in wanShaperStates) + { + // The WAN's own interface missing means we asked about a device this box does not + // have, so the readout says nothing about UniFi's provisioning. + if (!state.Egress.DeviceFound) + { + _logger?.LogDebug( + "Skipping Smart Queues shaper check for {Wan}: {Interface} not found on the gateway", + state.WanName, state.Interface); + continue; + } + + var uploadExpected = state.UpRateMbps != 0; + var downloadExpected = state.DownRateMbps != 0; + + var uploadShaped = state.Egress.HasRootHtb; + var downloadShaped = state.Ingress.DeviceFound && state.Ingress.HasRootHtb; + + var uploadMissing = uploadExpected && !uploadShaped; + var downloadMissing = downloadExpected && !downloadShaped; + + if (!uploadMissing && !downloadMissing) + continue; + + var preamble = $"Smart Queues is enabled for {state.WanName} in UniFi Network, but the gateway "; + string description; + + if (uploadMissing && downloadMissing) + { + description = preamble + + $"has no shaper on {state.Interface} or {state.IfbInterface}. UniFi Network took the setting " + + "without provisioning the queues, so this connection is running unshaped."; + } + else if (uploadMissing) + { + description = downloadShaped + ? preamble + $"is only shaping download. {state.Interface} has no shaper, so upload traffic is running unshaped." + : preamble + $"has no shaper on {state.Interface}, so upload traffic is running unshaped."; + } + else + { + description = uploadShaped + ? preamble + $"is only shaping upload. {state.IfbInterface} has no shaper, so download traffic is running unshaped." + : preamble + $"has no shaper on {state.IfbInterface}, so download traffic is running unshaped."; + } + + issues.Add(new PerformanceIssue + { + Title = $"Smart Queues Not Shaping on {state.WanName}", + Description = description, + Recommendation = "Add any QoS rule in UniFi Network under Settings > Policy Engine > Policy Table > QoS Rules. " + + "It does not matter what the rule targets - creating one makes UniFi Network provision the queues. " + + "Give it about 45 seconds, then run Analyze again.", + Severity = PerformanceSeverity.Recommendation, + Category = PerformanceCategory.Performance, + DeviceName = gatewayName + }); + } + + return issues; + } + /// /// Check if cellular WAN is present and QoS rules cover bandwidth-heavy app categories. /// @@ -558,7 +647,7 @@ internal List CheckCellularQos( { Title = "Streaming Video Not Rate-Limited", Description = streamingGap, - Recommendation = "Create a QoS Rule under Policy Engine > Policy Table > QoS Rules to limit " + + Recommendation = "Create a QoS Rule under Settings > Policy Engine > Policy Table > QoS Rules to limit " + "streaming video apps when on cellular. " + "
How-To Guide", Severity = severity, @@ -575,7 +664,7 @@ internal List CheckCellularQos( { Title = "Cloud Sync Not Rate-Limited", Description = cloudGap, - Recommendation = "Create a QoS Rule under Policy Engine > Policy Table > QoS Rules to limit cloud storage sync speed when on cellular. " + + Recommendation = "Create a QoS Rule under Settings > Policy Engine > Policy Table > QoS Rules to limit cloud storage sync speed when on cellular. " + "This prevents large uploads/downloads from burning through your data plan. " + "
How-To Guide", Severity = severity, @@ -592,7 +681,7 @@ internal List CheckCellularQos( { Title = "Game/App Downloads Not Rate-Limited", Description = downloadGap, - Recommendation = "Create a QoS Rule under Policy Engine > Policy Table > QoS Rules to limit or block game/app downloads when on cellular. " + + Recommendation = "Create a QoS Rule under Settings > Policy Engine > Policy Table > QoS Rules to limit or block game/app downloads when on cellular. " + "Game updates alone can exceed monthly data caps in a single download. " + "
How-To Guide", Severity = severity, diff --git a/src/NetworkOptimizer.Diagnostics/DiagnosticsEngine.cs b/src/NetworkOptimizer.Diagnostics/DiagnosticsEngine.cs index cfb5f71436..39c48dbd45 100644 --- a/src/NetworkOptimizer.Diagnostics/DiagnosticsEngine.cs +++ b/src/NetworkOptimizer.Diagnostics/DiagnosticsEngine.cs @@ -84,6 +84,10 @@ public DiagnosticsEngine( /// Optional historical clients for offline device detection /// Raw settings JSON for global switch settings /// Raw QoS rules JSON for cellular bandwidth checks + /// + /// Gateway traffic control state for WANs with Smart Queues enabled, read over SSH. Null or + /// empty whenever the gateway could not be read, which simply skips that check. + /// /// Complete diagnostics result public DiagnosticsResult RunDiagnostics( IEnumerable clients, @@ -94,7 +98,8 @@ public DiagnosticsResult RunDiagnostics( IEnumerable? clientHistory = null, JsonDocument? settingsData = null, JsonDocument? qosRulesData = null, - JsonDocument? wanEnrichedData = null) + JsonDocument? wanEnrichedData = null, + List? wanShaperStates = null) { options ??= new DiagnosticsOptions(); var stopwatch = Stopwatch.StartNew(); @@ -190,7 +195,8 @@ public DiagnosticsResult RunDiagnostics( deviceList, networkList, clientList, settingsData, qosRulesData, wanEnrichedData, runPerformanceChecks: options.RunPerformanceAnalyzer, runCellularChecks: options.RunCellularDataSavings, - portProfiles: profileList); + portProfiles: profileList, + wanShaperStates: wanShaperStates); result.CellularWanDetected = _performanceAnalyzer.CellularWanDetected; _logger?.LogDebug("Performance Analyzer found {Count} issues", result.PerformanceIssues.Count); } diff --git a/src/NetworkOptimizer.Diagnostics/Models/WanShaperState.cs b/src/NetworkOptimizer.Diagnostics/Models/WanShaperState.cs new file mode 100644 index 0000000000..d326b26f75 --- /dev/null +++ b/src/NetworkOptimizer.Diagnostics/Models/WanShaperState.cs @@ -0,0 +1,59 @@ +namespace NetworkOptimizer.Diagnostics.Models; + +/// +/// What the gateway's traffic control actually looks like on one WAN that has UniFi Smart Queues +/// turned on. Read over SSH and handed to the analyzer as plain data, so the check itself stays +/// free of any SSH or controller dependency. +/// +/// Both directions are described because UniFi shapes them on different devices: egress rides the +/// WAN's own data-path interface, ingress rides the mirred "ifb" companion. A WAN can end up with +/// one and not the other. +/// +public class WanShaperState +{ + /// The WAN's display name in UniFi Network, used in the finding. + public string WanName { get; init; } = string.Empty; + + /// + /// The data-path interface: "eth6" plain, "eth6.100" VLAN-tagged, "ppp0" for PPPoE. This is + /// the egress (upload) shaper's device. + /// + public string Interface { get; init; } = string.Empty; + + /// + /// The ingress (download) shaper's device - "ifb" plus . UniFi creates + /// it when it provisions Smart Queues, so its absence is itself the symptom. + /// + public string IfbInterface { get; init; } = string.Empty; + + /// Configured Smart Queue download rate in Mbps, null or 0 when UniFi has none. + public int? DownRateMbps { get; init; } + + /// Configured Smart Queue upload rate in Mbps, null or 0 when UniFi has none. + public int? UpRateMbps { get; init; } + + /// What tc reported for . + public TcDeviceState Egress { get; init; } = new(); + + /// What tc reported for . + public TcDeviceState Ingress { get; init; } = new(); +} + +/// +/// One interface's traffic control state, as read from "tc class show dev <name>". +/// +public class TcDeviceState +{ + /// + /// False when tc could not find the device at all. On the ifb companion that means UniFi never + /// created it; on the WAN's own interface it means we resolved a name this box does not have, + /// which is our problem rather than a finding. + /// + public bool DeviceFound { get; init; } + + /// + /// True when tc reported an htb root class - the shaper actually running. A device with only + /// the kernel's default "mq" classes is not being shaped. + /// + public bool HasRootHtb { get; init; } +} diff --git a/src/NetworkOptimizer.Installer/NetworkOptimizer.Installer.wixproj b/src/NetworkOptimizer.Installer/NetworkOptimizer.Installer.wixproj index 24b9e3a927..d333e5588b 100644 --- a/src/NetworkOptimizer.Installer/NetworkOptimizer.Installer.wixproj +++ b/src/NetworkOptimizer.Installer/NetworkOptimizer.Installer.wixproj @@ -56,7 +56,11 @@ - + + diff --git a/src/NetworkOptimizer.Installer/Traefik/Download-Traefik.ps1 b/src/NetworkOptimizer.Installer/Traefik/Download-Traefik.ps1 index 069790ab97..2cc22319f9 100644 --- a/src/NetworkOptimizer.Installer/Traefik/Download-Traefik.ps1 +++ b/src/NetworkOptimizer.Installer/Traefik/Download-Traefik.ps1 @@ -12,53 +12,62 @@ $TraefikZip = "traefik_v${Version}_windows_amd64.zip" $TraefikUrl = "https://github.com/traefik/traefik/releases/download/v${Version}/$TraefikZip" $TempFile = Join-Path $env:TEMP $TraefikZip -Write-Host "Downloading Traefik v$Version for Windows..." +# Ensure output directory exists +if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir | Out-Null +} -# Download Traefik -if (-not (Test-Path $TempFile)) { - try { - Invoke-WebRequest -Uri $TraefikUrl -OutFile $TempFile - Write-Host "Downloaded to $TempFile" - } - catch { - Write-Error "Failed to download Traefik from $TraefikUrl. Error: $_" - exit 1 - } +# The binary is version-pinned and 170 MB, so fetch it only when it is missing. +# The templates below are refreshed on every build instead - they track the +# companion repo and are the part that actually drifts. +$TraefikExePath = Join-Path $OutputDir "traefik.exe" +if (Test-Path $TraefikExePath) { + Write-Host "traefik.exe already staged, skipping binary download" } else { - Write-Host "Using cached download at $TempFile" -} + Write-Host "Downloading Traefik v$Version for Windows..." -# Extract to temp directory -$ExtractPath = Join-Path $env:TEMP "traefik-extract" -if (Test-Path $ExtractPath) { - Remove-Item -Recurse -Force $ExtractPath -} + # Download Traefik + if (-not (Test-Path $TempFile)) { + try { + Invoke-WebRequest -Uri $TraefikUrl -OutFile $TempFile + Write-Host "Downloaded to $TempFile" + } + catch { + Write-Error "Failed to download Traefik from $TraefikUrl. Error: $_" + exit 1 + } + } + else { + Write-Host "Using cached download at $TempFile" + } -Write-Host "Extracting..." -Expand-Archive -Path $TempFile -DestinationPath $ExtractPath -Force + # Extract to temp directory + $ExtractPath = Join-Path $env:TEMP "traefik-extract" + if (Test-Path $ExtractPath) { + Remove-Item -Recurse -Force $ExtractPath + } -# Find traefik.exe in the extracted contents -$TraefikExe = Get-ChildItem -Path $ExtractPath -Recurse -Filter "traefik.exe" | Select-Object -First 1 + Write-Host "Extracting..." + Expand-Archive -Path $TempFile -DestinationPath $ExtractPath -Force -if (-not $TraefikExe) { - Write-Error "traefik.exe not found in downloaded archive" - exit 1 -} + # Find traefik.exe in the extracted contents + $TraefikExe = Get-ChildItem -Path $ExtractPath -Recurse -Filter "traefik.exe" | Select-Object -First 1 -# Ensure output directory exists -if (-not (Test-Path $OutputDir)) { - New-Item -ItemType Directory -Path $OutputDir | Out-Null -} + if (-not $TraefikExe) { + Write-Error "traefik.exe not found in downloaded archive" + exit 1 + } -# Copy traefik.exe to output -Copy-Item $TraefikExe.FullName -Destination $OutputDir -Force -Write-Host "Copied traefik.exe to $OutputDir" + # Copy traefik.exe to output + Copy-Item $TraefikExe.FullName -Destination $OutputDir -Force + Write-Host "Copied traefik.exe to $OutputDir" -# Cleanup -Remove-Item -Recurse -Force $ExtractPath + # Cleanup + Remove-Item -Recurse -Force $ExtractPath -Write-Host "Traefik v$Version ready at $OutputDir" + Write-Host "Traefik v$Version ready at $OutputDir" +} # Download config templates from NetworkOptimizer-Proxy repo $TemplatesDir = Join-Path $OutputDir "templates" @@ -69,22 +78,35 @@ if (-not (Test-Path $TemplatesDir)) { $BaseUrl = "https://raw.githubusercontent.com/Ozark-Connect/NetworkOptimizer-Proxy/main/windows" $Templates = @("traefik.yml.template", "config.yml.template") +# Always re-fetch the templates. They previously downloaded only when absent, so +# the first MSI build on a machine froze them forever - a build box shipped +# five-month-old templates that way, missing the multi-site agent tunnel route +# the companion repo had since added. They are a few KB, so refreshing every +# build is free. +# +# Download to a temp file and move into place only on success, so a failed or +# partial fetch can never truncate a good staged template. If the fetch fails and +# a copy is already staged, keep it and warn: that keeps offline builds working. +# With no staged copy there is nothing to fall back to, so that stays fatal. foreach ($Template in $Templates) { $DestPath = Join-Path $TemplatesDir $Template - if (-not (Test-Path $DestPath)) { - Write-Host "Downloading $Template..." - try { - Invoke-WebRequest -Uri "$BaseUrl/$Template" -OutFile $DestPath - Write-Host " Saved to $DestPath" + $TmpPath = "$DestPath.download" + Write-Host "Downloading $Template..." + try { + Invoke-WebRequest -Uri "$BaseUrl/$Template" -OutFile $TmpPath + Move-Item -Path $TmpPath -Destination $DestPath -Force + Write-Host " Saved to $DestPath" + } + catch { + Remove-Item $TmpPath -Force -ErrorAction SilentlyContinue + if (Test-Path $DestPath) { + Write-Warning "Could not refresh $Template ($_). Using the staged copy at $DestPath." } - catch { + else { Write-Error "Failed to download $Template from $BaseUrl/$Template. Error: $_" exit 1 } } - else { - Write-Host "Template already exists: $DestPath" - } } # List contents diff --git a/src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs b/src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs index 6147a768f4..449940cf84 100644 --- a/src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs +++ b/src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs @@ -26,6 +26,7 @@ public class LocalProbeExecutor : IProbeExecutor private ProbeCapability? _capability; private readonly SemaphoreSlim _capabilityLock = new(1, 1); private bool _tracerouteBinaryAvailable; + private TracerouteBinaryTraits _tracerouteTraits = TracerouteBinaryTraits.FullyBindable; // Throttle native Process.Start. macOS ARM64 has a .NET 10 runtime bug // (dotnet/runtime#112167) where concurrent Process.Start with redirected @@ -43,6 +44,15 @@ public LocalProbeExecutor(ILogger logger) public ProbeVantage Vantage => ProbeVantage.Server; + /// + /// Whether probes on this host can be bound to a source address or interface. + /// Binding rides on the native ping binary's source options, so it is exactly + /// the platforms where is the ping path: .NET's + /// managed Ping cannot bind at all. Agents announce this in their hello so the + /// server only offers a bind mechanism the agent can actually honor. + /// + public static bool SupportsSourceBinding => !OperatingSystem.IsWindows(); + public async Task GetCapabilityAsync(CancellationToken ct = default) { if (_capability != null) return _capability; @@ -64,7 +74,7 @@ public async Task GetCapabilityAsync(CancellationToken ct = def CanUdpTraceroute = _tracerouteBinaryAvailable, // only the native binary does UDP CanTcpProbe = true, // .NET sockets IsBusyBoxPing = false, - IsBusyBoxTraceroute = false + IsBusyBoxTraceroute = _tracerouteBinaryAvailable && _tracerouteTraits.IsBusyBox }; _logger.LogInformation( @@ -94,7 +104,17 @@ private async Task IsTracerouteInstalledAsync(CancellationToken ct) if (probe == null) return false; using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(2)); + // Both streams: GNU traceroute prints its version on stdout, while BusyBox and + // BSD answer an unknown -V with their usage on stderr. That usage text is the + // only evidence available for which source-bind options this build actually has. + // On the same 2s budget as the exit wait, so a binary that says nothing and never + // returns costs the same as it did when nothing read its output at all. + var stdoutTask = probe.StandardOutput.ReadToEndAsync(cts.Token); + var stderrTask = probe.StandardError.ReadToEndAsync(cts.Token); try { await probe.WaitForExitAsync(cts.Token); } catch { } + var banner = (await SafeReadAsync(stdoutTask) ?? string.Empty) + + "\n" + (await SafeReadAsync(stderrTask) ?? string.Empty); + _tracerouteTraits = InterpretTracerouteBanner(banner); return true; } catch (Exception ex) @@ -104,6 +124,47 @@ private async Task IsTracerouteInstalledAsync(CancellationToken ct) } } + /// + /// What the installed traceroute can be told about where a probe leaves from. GNU + /// traceroute and BSD traceroute both take -s (source address) and -i + /// (source interface), so anything that isn't BusyBox is taken as fully bindable. + /// BusyBox's applet is compile-configurable and may carry neither, so its usage text - + /// which it prints in place of a version - is read for the two options before either is + /// offered. A probe that cannot bind must fail rather than leave by the default route: + /// that would record another WAN's latency under this one's name. + /// + internal readonly record struct TracerouteBinaryTraits(bool IsBusyBox, bool CanBindAddress, bool CanBindInterface) + { + /// A GNU/BSD traceroute: both bind options present. Also the assumption before detection runs. + public static TracerouteBinaryTraits FullyBindable => new(false, true, true); + } + + /// Reads a traceroute binary's version/usage output into the bind options it advertises. + /// Combined stdout+stderr from traceroute -V; empty when it could not be read. + internal static TracerouteBinaryTraits InterpretTracerouteBanner(string? banner) + { + if (string.IsNullOrWhiteSpace(banner)) return TracerouteBinaryTraits.FullyBindable; + if (banner.IndexOf("busybox", StringComparison.OrdinalIgnoreCase) < 0) + return TracerouteBinaryTraits.FullyBindable; + + return new TracerouteBinaryTraits( + IsBusyBox: true, + CanBindAddress: MentionsOption(banner, 's'), + CanBindInterface: MentionsOption(banner, 'i')); + } + + /// Whether a usage line lists a single-letter option, either bare or inside a bundled flag group. + private static bool MentionsOption(string banner, char option) + { + for (var i = 0; i < banner.Length - 1; i++) + { + if (banner[i] != '-') continue; + for (var j = i + 1; j < banner.Length && char.IsAsciiLetterOrDigit(banner[j]); j++) + if (banner[j] == option) return true; + } + return false; + } + public async Task PingAsync( ProbeTarget target, int count = 10, @@ -122,7 +183,7 @@ public async Task PingAsync( // ("ping" says 0.2 ms, dashboard says 1.5 ms). Windows ping has different output // and gives less useful data, so the managed Ping + Stopwatch path is the // Windows MSI fallback. - if (!OperatingSystem.IsWindows()) + if (SupportsSourceBinding) { return await NativePingAsync(target, count, perPingTimeout ?? TimeSpan.FromSeconds(2), ct); } @@ -300,16 +361,19 @@ public async Task TcpProbeAsync( using var tcp = new TcpClient(); if (!string.IsNullOrEmpty(target.SourceInterface)) { - // TCP source binding only works with an address (SO_BINDTODEVICE - // for interface names needs CAP_NET_RAW; not worth it here). - if (!System.Net.IPAddress.TryParse(target.SourceInterface, out var sourceIp)) + // TCP source binding takes an address, not a device (SO_BINDTODEVICE + // for interface names needs CAP_NET_RAW), so an interface name is + // resolved to its current address here rather than at push time: a + // DHCP or PPPoE WAN moves, and a stale address binds nothing. + var (sourceIp, error) = ResolveTcpBindAddress(target.SourceInterface, LookupInterfaceIPv4); + if (sourceIp == null) { return new TcpProbeResult { Target = target, Vantage = Vantage, Connected = false, - ErrorMessage = $"TCP probes need an IP address as the probe source, got '{target.SourceInterface}'", + ErrorMessage = error, Timestamp = DateTime.UtcNow }; } @@ -369,12 +433,20 @@ public async Task TracerouteAsync( var deadlineDuration = totalDeadline ?? TimeSpan.FromSeconds(10); if (!_tracerouteBinaryAvailable || OperatingSystem.IsWindows()) { + // The managed Ping-with-TTL traceroute cannot bind a source, exactly as the + // managed ping path cannot. Tracing out the default route would attribute + // another WAN's path to this one, so say so instead of tracing anyway. + if (!string.IsNullOrEmpty(target.SourceInterface)) + return FailTrace(target, "Source-bound traceroute needs the native traceroute binary (Linux/macOS)"); + using var managedCts = CancellationTokenSource.CreateLinkedTokenSource(ct); managedCts.CancelAfter(deadlineDuration); return await _managedTraceroute.RunAsync(target, Vantage, maxHops, perHopTimeout, 3, managedCts.Token); } - var (exe, args) = BuildTracerouteCommand(target, maxHops, perHopTimeout); + var (exe, args, buildError) = BuildTracerouteCommand(target, maxHops, perHopTimeout, _tracerouteTraits); + if (buildError != null) + return FailTrace(target, buildError); // Acquire the throttle FIRST, THEN start the per-trace deadline. The // deadline must bound process execution, not time spent queued behind // the semaphore - otherwise queued traces in a big sweep (18 in the @@ -523,6 +595,52 @@ private static double StdDev(IReadOnlyCollection v) /// The probe source goes into a process argument, so restrict it to the /// characters valid in IPv4/IPv6 addresses and interface names. ///
+ /// + /// Turns a probe source value into the address a TCP socket can bind to: an IP + /// literal is taken as-is, an interface name is resolved to that interface's + /// current IPv4 address through . + /// + /// An interface with no IPv4 address returns an error rather than a null bind. + /// Probing unbound would leave by the default route and record another WAN's + /// latency under this one's name, which reads as data rather than as a failure. + /// + /// Source IP or interface name from the WAN context. + /// Looks up an interface's addresses by name; empty when it has none or does not exist. + /// The address to bind, or null with the reason the probe cannot run. + internal static (System.Net.IPAddress? Address, string? Error) ResolveTcpBindAddress( + string source, + Func> interfaceAddresses) + { + if (System.Net.IPAddress.TryParse(source, out var literal)) + return (literal, null); + + if (!IsSafeSourceValue(source)) + return (null, $"Invalid probe source '{source}'"); + + var addresses = interfaceAddresses(source); + var ipv4 = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork); + if (ipv4 != null) + return (ipv4, null); + + return (null, $"Interface '{source}' has no IPv4 address to bind the TCP probe to"); + } + + /// Current unicast IPv4/IPv6 addresses of a local interface by name; empty when there is no such interface. + private static IReadOnlyList LookupInterfaceIPv4(string interfaceName) + { + try + { + var nic = NetworkInterface.GetAllNetworkInterfaces() + .FirstOrDefault(n => string.Equals(n.Name, interfaceName, StringComparison.OrdinalIgnoreCase)); + if (nic == null) return Array.Empty(); + return nic.GetIPProperties().UnicastAddresses.Select(a => a.Address).ToList(); + } + catch (NetworkInformationException) + { + return Array.Empty(); + } + } + private static bool IsSafeSourceValue(string value) => value.Length <= 64 && value.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or ':' or '-' or '_' or '%'); @@ -536,6 +654,17 @@ private static bool IsSafeSourceValue(string value) => Timestamp = DateTime.UtcNow }; + private TracerouteResult FailTrace(ProbeTarget target, string error) => new() + { + Target = target, + Vantage = Vantage, + ModeUsed = target.Mode, + Hops = Array.Empty(), + Reached = false, + ErrorMessage = error, + Timestamp = DateTime.UtcNow + }; + private static (string exe, string args) ChooseTracerouteBinary() { if (OperatingSystem.IsWindows()) @@ -545,13 +674,49 @@ private static (string exe, string args) ChooseTracerouteBinary() return ("traceroute", "-V"); } - private static (string exe, string args) BuildTracerouteCommand(ProbeTarget target, int maxHops, TimeSpan? perHopTimeout) + /// + /// Builds the traceroute invocation for a target, including the source bind a WAN context + /// asks for: an IP literal becomes -s, an interface name becomes -i, mirroring + /// the ping path's -I/-S/-b handling. Returns an error instead of a + /// command whenever the bind cannot be honored - an unbound trace would map another WAN's + /// upstream onto this one, which reads as a discovery result rather than as a failure. + /// + /// Probe target; its SourceInterface carries the context's bind, if any. + /// TTL ceiling. + /// Per-hop wait; floored at one second, which is the flag's unit. + /// What the installed binary can bind, from . + /// Which platform's traceroute to build for; defaults to this host's. + /// The executable and arguments, or an error explaining why the probe cannot run. + internal static (string Exe, string Args, string? Error) BuildTracerouteCommand( + ProbeTarget target, int maxHops, TimeSpan? perHopTimeout, TracerouteBinaryTraits traits, bool? isWindows = null) { var wait = (int)Math.Max(1, (perHopTimeout ?? TimeSpan.FromSeconds(2)).TotalSeconds); - if (OperatingSystem.IsWindows()) + if (isWindows ?? OperatingSystem.IsWindows()) { + // tracert.exe has no source option at all, so a bound probe cannot run here. + if (!string.IsNullOrEmpty(target.SourceInterface)) + return ("tracert.exe", string.Empty, "Source-bound traceroute needs the native traceroute binary (Linux/macOS)"); // tracert: -h max hops, -w wait ms, -d no DNS resolution to speed up - return ("tracert.exe", $"-h {maxHops} -w {wait * 1000} {target.Address}"); + return ("tracert.exe", $"-h {maxHops} -w {wait * 1000} {target.Address}", null); + } + + var sourceArg = string.Empty; + if (!string.IsNullOrEmpty(target.SourceInterface)) + { + if (!IsSafeSourceValue(target.SourceInterface)) + return ("traceroute", string.Empty, $"Invalid probe source '{target.SourceInterface}'"); + + var isAddress = System.Net.IPAddress.TryParse(target.SourceInterface, out _); + if (isAddress && !traits.CanBindAddress) + return ("traceroute", string.Empty, + "This host's traceroute takes no source address, so the probe would go out the default route"); + if (!isAddress && !traits.CanBindInterface) + return ("traceroute", string.Empty, + $"This host's traceroute takes no source interface, so the probe would not go out '{target.SourceInterface}'"); + + sourceArg = isAddress + ? $"-s {target.SourceInterface} " + : $"-i {target.SourceInterface} "; } var protoFlag = target.Mode switch @@ -563,7 +728,7 @@ private static (string exe, string args) BuildTracerouteCommand(ProbeTarget targ // PTR resolution stays ON — hostnames like "cr1.stl1.example.net" are gold for the // wizard's hop-labelling logic (spec 5.5). Linux's resolver times out fast, so the // cost is bounded by the per-probe deadline anyway. - var args = $"-m {maxHops} -q 2 -w {wait} {protoFlag} {target.Address}".Trim(); - return ("traceroute", args); + var args = $"-m {maxHops} -q 2 -w {wait} {protoFlag} {sourceArg}{target.Address}".Trim(); + return ("traceroute", args, null); } } diff --git a/src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.Designer.cs new file mode 100644 index 0000000000..3275340c37 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.Designer.cs @@ -0,0 +1,3387 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + [DbContext(typeof(NetworkOptimizerDbContext))] + [Migration("20260803193154_AddWanContextInterfaceBinding")] + partial class AddWanContextInterfaceBinding + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("ContextJson") + .HasColumnType("TEXT"); + + b.Property("DeliveredToChannels") + .HasColumnType("TEXT"); + + b.Property("DeliveryError") + .HasColumnType("TEXT"); + + b.Property("DeliverySucceeded") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("DeviceIp") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IncidentId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("INTEGER"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId"); + + b.HasIndex("RuleId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.HasIndex("Source", "TriggeredAt"); + + b.ToTable("AlertHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertCount") + .HasColumnType("INTEGER"); + + b.Property("CorrelationKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationKey"); + + b.HasIndex("Status"); + + b.ToTable("AlertIncidents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CooldownSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestOnly") + .HasColumnType("INTEGER"); + + b.Property("EscalationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EscalationSeverity") + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("TargetDevices") + .HasColumnType("TEXT"); + + b.Property("ThresholdPercent") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.DeliveryChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelType") + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("DigestSchedule") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DeliveryChannels", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.HasIndex("TaskType"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastSeenAppVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SfpOntHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ChangedAtUtc") + .HasColumnType("TEXT"); + + b.Property("NewChannel") + .HasColumnType("INTEGER"); + + b.Property("NewWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("PreviousChannel") + .HasColumnType("INTEGER"); + + b.Property("PreviousWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAtUtc"); + + b.HasIndex("ApMac", "Band", "ChangedAtUtc"); + + b.ToTable("ApChannelChanges", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BucketDate") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("InterferenceSum") + .HasColumnType("REAL"); + + b.Property("LastSampleUtc") + .HasColumnType("TEXT"); + + b.Property("SampleCount") + .HasColumnType("INTEGER"); + + b.Property("TxRetrySum") + .HasColumnType("REAL"); + + b.Property("UtilizationSum") + .HasColumnType("REAL"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BucketDate"); + + b.HasIndex("ApMac", "Band", "Channel", "WidthMhz", "BucketDate") + .IsUnique(); + + b.ToTable("ApChannelOutcomes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("HeightM") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApNeighborSighting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Bssid") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("SightingCount") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Ssid") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenUtc"); + + b.HasIndex("ApMac", "Band", "Bssid", "Channel") + .IsUnique(); + + b.ToTable("ApNeighborSightings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditDate") + .HasColumnType("TEXT"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ComplianceScore") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FailedChecks") + .HasColumnType("INTEGER"); + + b.Property("FindingsJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CenterLatitude") + .HasColumnType("REAL"); + + b.Property("CenterLongitude") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Buildings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ClientSignalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApChannel") + .HasColumnType("INTEGER"); + + b.Property("ApClientCount") + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("ApModel") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApRadioBand") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ApTxPower") + .HasColumnType("INTEGER"); + + b.Property("Band") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BottleneckLinkSpeedMbps") + .HasColumnType("REAL"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("ChannelWidth") + .HasColumnType("INTEGER"); + + b.Property("ClientIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("ClientMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopCount") + .HasColumnType("INTEGER"); + + b.Property("IsMlo") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MloLinksJson") + .HasColumnType("TEXT"); + + b.Property("NoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TraceHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TraceJson") + .HasColumnType("TEXT"); + + b.Property("TxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TraceHash"); + + b.HasIndex("ClientMac", "Timestamp"); + + b.ToTable("ClientSignalLogs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CmConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StatusPagePath") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("CmConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CustomOidConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Oid") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "Oid") + .IsUnique(); + + b.ToTable("CustomOidConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DeviceSshConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3BinaryPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Iperf3DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Iperf3ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SshPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshPrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartIperf3Server") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("DeviceSshConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DismissedAt") + .HasColumnType("TEXT"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IssueKey") + .IsUnique(); + + b.ToTable("DismissedIssues", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ExternalSpeedTestServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ServerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId") + .IsUnique(); + + b.ToTable("ExternalSpeedTestServers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildingId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorMaterial") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FloorNumber") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.ToTable("FloorPlans", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CropJson") + .HasColumnType("TEXT"); + + b.Property("FloorPlanId") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("RotationDeg") + .HasColumnType("REAL"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FloorPlanId"); + + b.ToTable("FloorPlanImages", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.GatewaySshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3Port") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TcMonitorPort") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.InterfaceNameMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IfAlias") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IfIndex") + .HasColumnType("INTEGER"); + + b.Property("IfName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsSfp") + .HasColumnType("INTEGER"); + + b.Property("IsWan") + .HasColumnType("INTEGER"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("PortNumber") + .HasColumnType("INTEGER"); + + b.Property("SpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "IfName") + .IsUnique(); + + b.ToTable("InterfaceNameMaps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Iperf3Result", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("DownloadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("DownloadBytes") + .HasColumnType("INTEGER"); + + b.Property("DownloadJitterMs") + .HasColumnType("REAL"); + + b.Property("DownloadLatencyMs") + .HasColumnType("REAL"); + + b.Property("DownloadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExternalServerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("PathAnalysisJson") + .HasColumnType("TEXT"); + + b.Property("PingMs") + .HasColumnType("REAL"); + + b.Property("RawDownloadJson") + .HasColumnType("TEXT"); + + b.Property("RawUploadJson") + .HasColumnType("TEXT"); + + b.Property("Success") + .HasColumnType("INTEGER"); + + b.Property("TestTime") + .HasColumnType("TEXT"); + + b.Property("UploadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("UploadBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadJitterMs") + .HasColumnType("REAL"); + + b.Property("UploadLatencyMs") + .HasColumnType("REAL"); + + b.Property("UploadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanNetworkGroup") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WifiChannel") + .HasColumnType("INTEGER"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceHost"); + + b.HasIndex("Direction"); + + b.HasIndex("TestTime"); + + b.HasIndex("DeviceHost", "TestTime"); + + b.ToTable("Iperf3Results", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("FeaturesJson") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LicensedTo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxAgents") + .HasColumnType("INTEGER"); + + b.Property("MaxDevices") + .HasColumnType("INTEGER"); + + b.Property("Organization") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("IsActive"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseKeyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntitlementJson") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("NextCheckAt") + .HasColumnType("TEXT"); + + b.Property("Org") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PaidThrough") + .HasColumnType("TEXT"); + + b.Property("PerpetualConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SiteAllowance") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.HasIndex("NextCheckAt"); + + b.HasIndex("Status"); + + b.ToTable("LicenseKeyRecords", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ModemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("ModemType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("ModemConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoredSfp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsMonitoredOnt") + .HasColumnType("INTEGER"); + + b.Property("LinkSpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("PortName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SfpPart") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SfpVendor") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMonitoredOnt"); + + b.HasIndex("DeviceMac", "PortName") + .IsUnique(); + + b.ToTable("MonitoredSfps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringInterface", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AliasIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("GatewayLocalIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("SnatEnabled") + .HasColumnType("INTEGER"); + + b.Property("SubnetPrefix") + .HasColumnType("INTEGER"); + + b.Property("TargetIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanIfName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanKey") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WanVlanId") + .HasColumnType("INTEGER"); + + b.Property("WatchdogIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AliasIp") + .IsUnique(); + + b.HasIndex("GatewayLocalIp") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("TargetIp"); + + b.ToTable("MonitoringInterfaces", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("AeRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("AeTempHighC") + .HasColumnType("REAL"); + + b.Property("AeTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FastPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Flex25GLatencyMigrated") + .HasColumnType("INTEGER"); + + b.Property("GatewayTempHighC") + .HasColumnType("REAL"); + + b.Property("InfluxDbBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbLongtermBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbOrg") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbReachable") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InfluxDbUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IspHealthScoreWindowHours") + .HasColumnType("INTEGER"); + + b.Property("LastInfluxDbCheck") + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastSnmpDetection") + .HasColumnType("TEXT"); + + b.Property("LastSnmpSuccess") + .HasColumnType("TEXT"); + + b.Property("LastUpstreamDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("MediumPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("PhysicalLinkSourceKey") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PonRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("PonTempHighC") + .HasColumnType("REAL"); + + b.Property("PonTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("SfpTempHighGenericC") + .HasColumnType("REAL"); + + b.Property("ShowCellularTab") + .HasColumnType("INTEGER"); + + b.Property("ShowCmTab") + .HasColumnType("INTEGER"); + + b.Property("ShowOntTab") + .HasColumnType("INTEGER"); + + b.Property("ShowStarlinkTab") + .HasColumnType("INTEGER"); + + b.Property("SlowPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SnmpCommunity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpDetectionState") + .HasColumnType("INTEGER"); + + b.Property("SnmpV3AuthPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpV3Username") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SnmpVersion") + .HasColumnType("INTEGER"); + + b.Property("SwitchTempHighC") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpstreamDiscoveryNeedsReview") + .HasColumnType("INTEGER"); + + b.Property("WanNeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanNeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MonitoringSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AutoDiscovered") + .HasColumnType("INTEGER"); + + b.Property("AutoLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DiscoveredProbeMode") + .HasColumnType("INTEGER"); + + b.Property("DiscoveryMethod") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LanFlakyHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PingCount") + .HasColumnType("INTEGER"); + + b.Property("PollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProbeMode") + .HasColumnType("INTEGER"); + + b.Property("PtrHostname") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("VantagePoint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanContextId") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TargetId") + .IsUnique(); + + b.HasIndex("TargetType"); + + b.HasIndex("WanInterface"); + + b.ToTable("MonitoringTargets", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OntConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachedSfpId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("OntConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OuiVendor", b => + { + b.Property("OuiPrefix") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("OuiPrefix"); + + b.ToTable("OuiVendors", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OutageAcknowledgement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("OutageStartUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OutageStartUtc"); + + b.ToTable("OutageAcknowledgements", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PerfTweakSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("TweakId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TweakId") + .IsUnique(); + + b.ToTable("PerfTweakSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Sites", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteAgent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentKeyHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EnrolledAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LanIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("TokenCreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentKeyHash"); + + b.HasIndex("EnrollmentTokenHash"); + + b.HasIndex("SiteId"); + + b.ToTable("SiteAgents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LicenseKeyRecordId") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKeyRecordId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("SiteLicenseAssignments", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmBaseline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgBytesIn") + .HasColumnType("INTEGER"); + + b.Property("AvgBytesOut") + .HasColumnType("INTEGER"); + + b.Property("AvgJitter") + .HasColumnType("REAL"); + + b.Property("AvgLatency") + .HasColumnType("REAL"); + + b.Property("AvgPacketLoss") + .HasColumnType("REAL"); + + b.Property("AvgUtilization") + .HasColumnType("REAL"); + + b.Property("BaselineEnd") + .HasColumnType("TEXT"); + + b.Property("BaselineHours") + .HasColumnType("INTEGER"); + + b.Property("BaselineStart") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HourlyDataJson") + .HasColumnType("TEXT"); + + b.Property("InterfaceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxJitter") + .HasColumnType("REAL"); + + b.Property("MaxPacketLoss") + .HasColumnType("REAL"); + + b.Property("MedianBytesIn") + .HasColumnType("INTEGER"); + + b.Property("MedianBytesOut") + .HasColumnType("INTEGER"); + + b.Property("P95Latency") + .HasColumnType("REAL"); + + b.Property("P99Latency") + .HasColumnType("REAL"); + + b.Property("PeakBytesIn") + .HasColumnType("INTEGER"); + + b.Property("PeakBytesOut") + .HasColumnType("INTEGER"); + + b.Property("PeakLatency") + .HasColumnType("REAL"); + + b.Property("PeakUtilization") + .HasColumnType("REAL"); + + b.Property("RecommendedDownloadMbps") + .HasColumnType("REAL"); + + b.Property("RecommendedUploadMbps") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NominalDownloadMbps") + .HasColumnType("INTEGER"); + + b.Property("NominalUploadMbps") + .HasColumnType("INTEGER"); + + b.Property("PingHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RateProportionalDownloadBurst") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestServerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanNumber") + .IsUnique(); + + b.ToTable("SqmWanConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SshKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("KeyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PassphraseProtected") + .HasColumnType("TEXT"); + + b.Property("PrivateKeyProtected") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SshKeys"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.StarlinkConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("StarlinkConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.TourState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DismissedTours") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeenTourSteps") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TourOffers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToursDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Subject") + .IsUnique(); + + b.ToTable("TourStates", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiConnectionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ControllerUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IgnoreControllerSSLErrors") + .HasColumnType("INTEGER"); + + b.Property("IsConfigured") + .HasColumnType("INTEGER"); + + b.Property("LastConnectedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RememberCredentials") + .HasColumnType("INTEGER"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiConnectionSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostIp", "Port", "Protocol") + .IsUnique(); + + b.ToTable("UpnpNotes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpstreamDiscovery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AncestorHopIps") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("HopIp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HopNumber") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTracerouteAt") + .HasColumnType("TEXT"); + + b.Property("LastValidated") + .HasColumnType("TEXT"); + + b.Property("MonitoringTargetId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AsnNumber"); + + b.HasIndex("IsActive"); + + b.HasIndex("MonitoringTargetId"); + + b.ToTable("UpstreamDiscoveries", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProbeSourceIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WanContexts"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastResetAt") + .HasColumnType("TEXT"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ResetMode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapGb") + .HasColumnType("REAL"); + + b.Property("CycleEnd") + .HasColumnType("TEXT"); + + b.Property("CycleStart") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RecordedAt") + .HasColumnType("TEXT"); + + b.Property("UsedGb") + .HasColumnType("REAL"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "CycleStart") + .IsUnique(); + + b.ToTable("WanDataUsageHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDiscoveryContext", b => + { + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("L2NeighborIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("NeedsReview") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("WanInterface"); + + b.ToTable("WanDiscoveryContexts", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DataPathInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadMbps") + .HasColumnType("REAL"); + + b.Property("GatewayMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UploadMbps") + .HasColumnType("REAL"); + + b.Property("WanNetworkgroup") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanNetworkgroup") + .IsUnique(); + + b.ToTable("WanProfiles"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DstCidrsJson") + .HasColumnType("TEXT"); + + b.Property("DstPortsJson") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Probability") + .HasColumnType("REAL"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SrcCidrsJson") + .HasColumnType("TEXT"); + + b.Property("SrcMacsJson") + .HasColumnType("TEXT"); + + b.Property("SrcPortsJson") + .HasColumnType("TEXT"); + + b.Property("TargetWanKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("WanSteerTrafficClasses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("KillChainStage"); + + b.HasIndex("PatternId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.ToTable("ThreatEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ThreatNoiseFilters", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("PatternType") + .HasColumnType("INTEGER"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Building", "Building") + .WithMany("Floors") + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.FloorPlan", "FloorPlan") + .WithMany("Images") + .HasForeignKey("FloorPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FloorPlan"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.LicenseKeyRecord", null) + .WithMany() + .HasForeignKey("LicenseKeyRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetworkOptimizer.Storage.Models.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.HasOne("NetworkOptimizer.Threats.Models.ThreatPattern", "Pattern") + .WithMany("Events") + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.cs b/src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.cs new file mode 100644 index 0000000000..f4e5bfa083 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + /// + public partial class AddWanContextInterfaceBinding : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "InterfaceName", + table: "WanContexts", + type: "TEXT", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "WanInterface", + table: "WanContexts", + type: "TEXT", + maxLength: 50, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "InterfaceName", + table: "WanContexts"); + + migrationBuilder.DropColumn( + name: "WanInterface", + table: "WanContexts"); + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.Designer.cs new file mode 100644 index 0000000000..7f91e9d553 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.Designer.cs @@ -0,0 +1,3387 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + [DbContext(typeof(NetworkOptimizerDbContext))] + [Migration("20260803210000_BackfillWanContextTargetWan")] + partial class BackfillWanContextTargetWan + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("ContextJson") + .HasColumnType("TEXT"); + + b.Property("DeliveredToChannels") + .HasColumnType("TEXT"); + + b.Property("DeliveryError") + .HasColumnType("TEXT"); + + b.Property("DeliverySucceeded") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("DeviceIp") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IncidentId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("INTEGER"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId"); + + b.HasIndex("RuleId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.HasIndex("Source", "TriggeredAt"); + + b.ToTable("AlertHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertCount") + .HasColumnType("INTEGER"); + + b.Property("CorrelationKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationKey"); + + b.HasIndex("Status"); + + b.ToTable("AlertIncidents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CooldownSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestOnly") + .HasColumnType("INTEGER"); + + b.Property("EscalationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EscalationSeverity") + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("TargetDevices") + .HasColumnType("TEXT"); + + b.Property("ThresholdPercent") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.DeliveryChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelType") + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("DigestSchedule") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DeliveryChannels", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.HasIndex("TaskType"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastSeenAppVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SfpOntHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ChangedAtUtc") + .HasColumnType("TEXT"); + + b.Property("NewChannel") + .HasColumnType("INTEGER"); + + b.Property("NewWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("PreviousChannel") + .HasColumnType("INTEGER"); + + b.Property("PreviousWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAtUtc"); + + b.HasIndex("ApMac", "Band", "ChangedAtUtc"); + + b.ToTable("ApChannelChanges", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BucketDate") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("InterferenceSum") + .HasColumnType("REAL"); + + b.Property("LastSampleUtc") + .HasColumnType("TEXT"); + + b.Property("SampleCount") + .HasColumnType("INTEGER"); + + b.Property("TxRetrySum") + .HasColumnType("REAL"); + + b.Property("UtilizationSum") + .HasColumnType("REAL"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BucketDate"); + + b.HasIndex("ApMac", "Band", "Channel", "WidthMhz", "BucketDate") + .IsUnique(); + + b.ToTable("ApChannelOutcomes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("HeightM") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApNeighborSighting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Bssid") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("SightingCount") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Ssid") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenUtc"); + + b.HasIndex("ApMac", "Band", "Bssid", "Channel") + .IsUnique(); + + b.ToTable("ApNeighborSightings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditDate") + .HasColumnType("TEXT"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ComplianceScore") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FailedChecks") + .HasColumnType("INTEGER"); + + b.Property("FindingsJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CenterLatitude") + .HasColumnType("REAL"); + + b.Property("CenterLongitude") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Buildings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ClientSignalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApChannel") + .HasColumnType("INTEGER"); + + b.Property("ApClientCount") + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("ApModel") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApRadioBand") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ApTxPower") + .HasColumnType("INTEGER"); + + b.Property("Band") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BottleneckLinkSpeedMbps") + .HasColumnType("REAL"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("ChannelWidth") + .HasColumnType("INTEGER"); + + b.Property("ClientIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("ClientMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopCount") + .HasColumnType("INTEGER"); + + b.Property("IsMlo") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MloLinksJson") + .HasColumnType("TEXT"); + + b.Property("NoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TraceHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TraceJson") + .HasColumnType("TEXT"); + + b.Property("TxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TraceHash"); + + b.HasIndex("ClientMac", "Timestamp"); + + b.ToTable("ClientSignalLogs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CmConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StatusPagePath") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("CmConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CustomOidConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Oid") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "Oid") + .IsUnique(); + + b.ToTable("CustomOidConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DeviceSshConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3BinaryPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Iperf3DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Iperf3ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SshPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshPrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartIperf3Server") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("DeviceSshConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DismissedAt") + .HasColumnType("TEXT"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IssueKey") + .IsUnique(); + + b.ToTable("DismissedIssues", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ExternalSpeedTestServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ServerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId") + .IsUnique(); + + b.ToTable("ExternalSpeedTestServers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildingId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorMaterial") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FloorNumber") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.ToTable("FloorPlans", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CropJson") + .HasColumnType("TEXT"); + + b.Property("FloorPlanId") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("RotationDeg") + .HasColumnType("REAL"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FloorPlanId"); + + b.ToTable("FloorPlanImages", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.GatewaySshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3Port") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TcMonitorPort") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.InterfaceNameMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IfAlias") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IfIndex") + .HasColumnType("INTEGER"); + + b.Property("IfName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsSfp") + .HasColumnType("INTEGER"); + + b.Property("IsWan") + .HasColumnType("INTEGER"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("PortNumber") + .HasColumnType("INTEGER"); + + b.Property("SpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "IfName") + .IsUnique(); + + b.ToTable("InterfaceNameMaps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Iperf3Result", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("DownloadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("DownloadBytes") + .HasColumnType("INTEGER"); + + b.Property("DownloadJitterMs") + .HasColumnType("REAL"); + + b.Property("DownloadLatencyMs") + .HasColumnType("REAL"); + + b.Property("DownloadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExternalServerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("PathAnalysisJson") + .HasColumnType("TEXT"); + + b.Property("PingMs") + .HasColumnType("REAL"); + + b.Property("RawDownloadJson") + .HasColumnType("TEXT"); + + b.Property("RawUploadJson") + .HasColumnType("TEXT"); + + b.Property("Success") + .HasColumnType("INTEGER"); + + b.Property("TestTime") + .HasColumnType("TEXT"); + + b.Property("UploadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("UploadBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadJitterMs") + .HasColumnType("REAL"); + + b.Property("UploadLatencyMs") + .HasColumnType("REAL"); + + b.Property("UploadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanNetworkGroup") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WifiChannel") + .HasColumnType("INTEGER"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceHost"); + + b.HasIndex("Direction"); + + b.HasIndex("TestTime"); + + b.HasIndex("DeviceHost", "TestTime"); + + b.ToTable("Iperf3Results", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("FeaturesJson") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LicensedTo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxAgents") + .HasColumnType("INTEGER"); + + b.Property("MaxDevices") + .HasColumnType("INTEGER"); + + b.Property("Organization") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("IsActive"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseKeyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntitlementJson") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("NextCheckAt") + .HasColumnType("TEXT"); + + b.Property("Org") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PaidThrough") + .HasColumnType("TEXT"); + + b.Property("PerpetualConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SiteAllowance") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.HasIndex("NextCheckAt"); + + b.HasIndex("Status"); + + b.ToTable("LicenseKeyRecords", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ModemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("ModemType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("ModemConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoredSfp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsMonitoredOnt") + .HasColumnType("INTEGER"); + + b.Property("LinkSpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("PortName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SfpPart") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SfpVendor") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMonitoredOnt"); + + b.HasIndex("DeviceMac", "PortName") + .IsUnique(); + + b.ToTable("MonitoredSfps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringInterface", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AliasIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("GatewayLocalIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("SnatEnabled") + .HasColumnType("INTEGER"); + + b.Property("SubnetPrefix") + .HasColumnType("INTEGER"); + + b.Property("TargetIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanIfName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanKey") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WanVlanId") + .HasColumnType("INTEGER"); + + b.Property("WatchdogIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AliasIp") + .IsUnique(); + + b.HasIndex("GatewayLocalIp") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("TargetIp"); + + b.ToTable("MonitoringInterfaces", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("AeRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("AeTempHighC") + .HasColumnType("REAL"); + + b.Property("AeTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FastPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Flex25GLatencyMigrated") + .HasColumnType("INTEGER"); + + b.Property("GatewayTempHighC") + .HasColumnType("REAL"); + + b.Property("InfluxDbBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbLongtermBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbOrg") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbReachable") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InfluxDbUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IspHealthScoreWindowHours") + .HasColumnType("INTEGER"); + + b.Property("LastInfluxDbCheck") + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastSnmpDetection") + .HasColumnType("TEXT"); + + b.Property("LastSnmpSuccess") + .HasColumnType("TEXT"); + + b.Property("LastUpstreamDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("MediumPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("PhysicalLinkSourceKey") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PonRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("PonTempHighC") + .HasColumnType("REAL"); + + b.Property("PonTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("SfpTempHighGenericC") + .HasColumnType("REAL"); + + b.Property("ShowCellularTab") + .HasColumnType("INTEGER"); + + b.Property("ShowCmTab") + .HasColumnType("INTEGER"); + + b.Property("ShowOntTab") + .HasColumnType("INTEGER"); + + b.Property("ShowStarlinkTab") + .HasColumnType("INTEGER"); + + b.Property("SlowPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SnmpCommunity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpDetectionState") + .HasColumnType("INTEGER"); + + b.Property("SnmpV3AuthPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpV3Username") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SnmpVersion") + .HasColumnType("INTEGER"); + + b.Property("SwitchTempHighC") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpstreamDiscoveryNeedsReview") + .HasColumnType("INTEGER"); + + b.Property("WanNeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanNeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MonitoringSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AutoDiscovered") + .HasColumnType("INTEGER"); + + b.Property("AutoLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DiscoveredProbeMode") + .HasColumnType("INTEGER"); + + b.Property("DiscoveryMethod") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LanFlakyHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PingCount") + .HasColumnType("INTEGER"); + + b.Property("PollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProbeMode") + .HasColumnType("INTEGER"); + + b.Property("PtrHostname") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("VantagePoint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanContextId") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TargetId") + .IsUnique(); + + b.HasIndex("TargetType"); + + b.HasIndex("WanInterface"); + + b.ToTable("MonitoringTargets", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OntConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachedSfpId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("OntConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OuiVendor", b => + { + b.Property("OuiPrefix") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("OuiPrefix"); + + b.ToTable("OuiVendors", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OutageAcknowledgement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("OutageStartUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OutageStartUtc"); + + b.ToTable("OutageAcknowledgements", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PerfTweakSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("TweakId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TweakId") + .IsUnique(); + + b.ToTable("PerfTweakSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Sites", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteAgent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentKeyHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EnrolledAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LanIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("TokenCreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentKeyHash"); + + b.HasIndex("EnrollmentTokenHash"); + + b.HasIndex("SiteId"); + + b.ToTable("SiteAgents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LicenseKeyRecordId") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKeyRecordId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("SiteLicenseAssignments", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmBaseline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgBytesIn") + .HasColumnType("INTEGER"); + + b.Property("AvgBytesOut") + .HasColumnType("INTEGER"); + + b.Property("AvgJitter") + .HasColumnType("REAL"); + + b.Property("AvgLatency") + .HasColumnType("REAL"); + + b.Property("AvgPacketLoss") + .HasColumnType("REAL"); + + b.Property("AvgUtilization") + .HasColumnType("REAL"); + + b.Property("BaselineEnd") + .HasColumnType("TEXT"); + + b.Property("BaselineHours") + .HasColumnType("INTEGER"); + + b.Property("BaselineStart") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HourlyDataJson") + .HasColumnType("TEXT"); + + b.Property("InterfaceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxJitter") + .HasColumnType("REAL"); + + b.Property("MaxPacketLoss") + .HasColumnType("REAL"); + + b.Property("MedianBytesIn") + .HasColumnType("INTEGER"); + + b.Property("MedianBytesOut") + .HasColumnType("INTEGER"); + + b.Property("P95Latency") + .HasColumnType("REAL"); + + b.Property("P99Latency") + .HasColumnType("REAL"); + + b.Property("PeakBytesIn") + .HasColumnType("INTEGER"); + + b.Property("PeakBytesOut") + .HasColumnType("INTEGER"); + + b.Property("PeakLatency") + .HasColumnType("REAL"); + + b.Property("PeakUtilization") + .HasColumnType("REAL"); + + b.Property("RecommendedDownloadMbps") + .HasColumnType("REAL"); + + b.Property("RecommendedUploadMbps") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NominalDownloadMbps") + .HasColumnType("INTEGER"); + + b.Property("NominalUploadMbps") + .HasColumnType("INTEGER"); + + b.Property("PingHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RateProportionalDownloadBurst") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestServerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanNumber") + .IsUnique(); + + b.ToTable("SqmWanConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SshKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("KeyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PassphraseProtected") + .HasColumnType("TEXT"); + + b.Property("PrivateKeyProtected") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SshKeys"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.StarlinkConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("StarlinkConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.TourState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DismissedTours") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeenTourSteps") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TourOffers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToursDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Subject") + .IsUnique(); + + b.ToTable("TourStates", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiConnectionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ControllerUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IgnoreControllerSSLErrors") + .HasColumnType("INTEGER"); + + b.Property("IsConfigured") + .HasColumnType("INTEGER"); + + b.Property("LastConnectedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RememberCredentials") + .HasColumnType("INTEGER"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiConnectionSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostIp", "Port", "Protocol") + .IsUnique(); + + b.ToTable("UpnpNotes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpstreamDiscovery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AncestorHopIps") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("HopIp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HopNumber") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTracerouteAt") + .HasColumnType("TEXT"); + + b.Property("LastValidated") + .HasColumnType("TEXT"); + + b.Property("MonitoringTargetId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AsnNumber"); + + b.HasIndex("IsActive"); + + b.HasIndex("MonitoringTargetId"); + + b.ToTable("UpstreamDiscoveries", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProbeSourceIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WanContexts"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastResetAt") + .HasColumnType("TEXT"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ResetMode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapGb") + .HasColumnType("REAL"); + + b.Property("CycleEnd") + .HasColumnType("TEXT"); + + b.Property("CycleStart") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RecordedAt") + .HasColumnType("TEXT"); + + b.Property("UsedGb") + .HasColumnType("REAL"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "CycleStart") + .IsUnique(); + + b.ToTable("WanDataUsageHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDiscoveryContext", b => + { + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("L2NeighborIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("NeedsReview") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("WanInterface"); + + b.ToTable("WanDiscoveryContexts", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DataPathInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadMbps") + .HasColumnType("REAL"); + + b.Property("GatewayMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UploadMbps") + .HasColumnType("REAL"); + + b.Property("WanNetworkgroup") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanNetworkgroup") + .IsUnique(); + + b.ToTable("WanProfiles"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DstCidrsJson") + .HasColumnType("TEXT"); + + b.Property("DstPortsJson") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Probability") + .HasColumnType("REAL"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SrcCidrsJson") + .HasColumnType("TEXT"); + + b.Property("SrcMacsJson") + .HasColumnType("TEXT"); + + b.Property("SrcPortsJson") + .HasColumnType("TEXT"); + + b.Property("TargetWanKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("WanSteerTrafficClasses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("KillChainStage"); + + b.HasIndex("PatternId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.ToTable("ThreatEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ThreatNoiseFilters", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("PatternType") + .HasColumnType("INTEGER"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Building", "Building") + .WithMany("Floors") + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.FloorPlan", "FloorPlan") + .WithMany("Images") + .HasForeignKey("FloorPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FloorPlan"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.LicenseKeyRecord", null) + .WithMany() + .HasForeignKey("LicenseKeyRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetworkOptimizer.Storage.Models.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.HasOne("NetworkOptimizer.Threats.Models.ThreatPattern", "Pattern") + .WithMany("Events") + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.cs b/src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.cs new file mode 100644 index 0000000000..882ef0be8e --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + /// + /// Reconciles the two WAN keys a target can carry. MonitoringTarget.WanContextId says who + /// probes a target (the routing key, set by hand in the per-target WAN dropdown), while + /// MonitoringTarget.WanInterface says which WAN its data describes (the reading key, written + /// by upstream discovery). Contexts predate the WanInterface column on WanContext, so a + /// target assigned to a secondary WAN's context has been carrying no WAN at all, or the + /// primary's - and no per-WAN reader could find it under the WAN it actually measures. + /// + /// Data-only, so there is no schema change and no model change: it copies each context's WAN + /// onto the targets assigned to that context. The context assignment is always the user's own + /// statement about a target (discovery never set it before this release), so it is the + /// authority here and overwrites a WanInterface left over from an earlier primary-WAN + /// discovery. Targets with no context - every target on a single-WAN install - are untouched. + /// + public partial class BackfillWanContextTargetWan : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" +UPDATE MonitoringTargets +SET WanInterface = (SELECT c.WanInterface FROM WanContexts c WHERE c.Id = MonitoringTargets.WanContextId) +WHERE WanContextId IS NOT NULL + AND (SELECT c.WanInterface FROM WanContexts c WHERE c.Id = MonitoringTargets.WanContextId) IS NOT NULL + AND (SELECT c.WanInterface FROM WanContexts c WHERE c.Id = MonitoringTargets.WanContextId) <> '' + AND IFNULL(WanInterface, '') <> IFNULL((SELECT c.WanInterface FROM WanContexts c WHERE c.Id = MonitoringTargets.WanContextId), '');"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // The prior WanInterface values were unrecoverable guesses (null, or the primary's + // key from a discovery that never knew about this WAN), so there is nothing truthful + // to restore. Leaving the corrected values in place is the honest no-op. + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.Designer.cs new file mode 100644 index 0000000000..436f513a02 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.Designer.cs @@ -0,0 +1,3387 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + [DbContext(typeof(NetworkOptimizerDbContext))] + [Migration("20260804120000_NormalizeLegacyWan1Key")] + partial class NormalizeLegacyWan1Key + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("ContextJson") + .HasColumnType("TEXT"); + + b.Property("DeliveredToChannels") + .HasColumnType("TEXT"); + + b.Property("DeliveryError") + .HasColumnType("TEXT"); + + b.Property("DeliverySucceeded") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("DeviceIp") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IncidentId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("INTEGER"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId"); + + b.HasIndex("RuleId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.HasIndex("Source", "TriggeredAt"); + + b.ToTable("AlertHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertCount") + .HasColumnType("INTEGER"); + + b.Property("CorrelationKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationKey"); + + b.HasIndex("Status"); + + b.ToTable("AlertIncidents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CooldownSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestOnly") + .HasColumnType("INTEGER"); + + b.Property("EscalationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EscalationSeverity") + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("TargetDevices") + .HasColumnType("TEXT"); + + b.Property("ThresholdPercent") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.DeliveryChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelType") + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("DigestSchedule") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DeliveryChannels", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.HasIndex("TaskType"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastSeenAppVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SfpOntHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ChangedAtUtc") + .HasColumnType("TEXT"); + + b.Property("NewChannel") + .HasColumnType("INTEGER"); + + b.Property("NewWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("PreviousChannel") + .HasColumnType("INTEGER"); + + b.Property("PreviousWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAtUtc"); + + b.HasIndex("ApMac", "Band", "ChangedAtUtc"); + + b.ToTable("ApChannelChanges", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BucketDate") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("InterferenceSum") + .HasColumnType("REAL"); + + b.Property("LastSampleUtc") + .HasColumnType("TEXT"); + + b.Property("SampleCount") + .HasColumnType("INTEGER"); + + b.Property("TxRetrySum") + .HasColumnType("REAL"); + + b.Property("UtilizationSum") + .HasColumnType("REAL"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BucketDate"); + + b.HasIndex("ApMac", "Band", "Channel", "WidthMhz", "BucketDate") + .IsUnique(); + + b.ToTable("ApChannelOutcomes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("HeightM") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApNeighborSighting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Bssid") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("SightingCount") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Ssid") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenUtc"); + + b.HasIndex("ApMac", "Band", "Bssid", "Channel") + .IsUnique(); + + b.ToTable("ApNeighborSightings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditDate") + .HasColumnType("TEXT"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ComplianceScore") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FailedChecks") + .HasColumnType("INTEGER"); + + b.Property("FindingsJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CenterLatitude") + .HasColumnType("REAL"); + + b.Property("CenterLongitude") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Buildings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ClientSignalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApChannel") + .HasColumnType("INTEGER"); + + b.Property("ApClientCount") + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("ApModel") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApRadioBand") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ApTxPower") + .HasColumnType("INTEGER"); + + b.Property("Band") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BottleneckLinkSpeedMbps") + .HasColumnType("REAL"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("ChannelWidth") + .HasColumnType("INTEGER"); + + b.Property("ClientIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("ClientMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopCount") + .HasColumnType("INTEGER"); + + b.Property("IsMlo") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MloLinksJson") + .HasColumnType("TEXT"); + + b.Property("NoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TraceHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TraceJson") + .HasColumnType("TEXT"); + + b.Property("TxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TraceHash"); + + b.HasIndex("ClientMac", "Timestamp"); + + b.ToTable("ClientSignalLogs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CmConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StatusPagePath") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("CmConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CustomOidConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Oid") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "Oid") + .IsUnique(); + + b.ToTable("CustomOidConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DeviceSshConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3BinaryPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Iperf3DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Iperf3ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SshPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshPrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartIperf3Server") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("DeviceSshConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DismissedAt") + .HasColumnType("TEXT"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IssueKey") + .IsUnique(); + + b.ToTable("DismissedIssues", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ExternalSpeedTestServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ServerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId") + .IsUnique(); + + b.ToTable("ExternalSpeedTestServers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildingId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorMaterial") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FloorNumber") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.ToTable("FloorPlans", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CropJson") + .HasColumnType("TEXT"); + + b.Property("FloorPlanId") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("RotationDeg") + .HasColumnType("REAL"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FloorPlanId"); + + b.ToTable("FloorPlanImages", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.GatewaySshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3Port") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TcMonitorPort") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.InterfaceNameMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IfAlias") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IfIndex") + .HasColumnType("INTEGER"); + + b.Property("IfName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsSfp") + .HasColumnType("INTEGER"); + + b.Property("IsWan") + .HasColumnType("INTEGER"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("PortNumber") + .HasColumnType("INTEGER"); + + b.Property("SpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "IfName") + .IsUnique(); + + b.ToTable("InterfaceNameMaps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Iperf3Result", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("DownloadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("DownloadBytes") + .HasColumnType("INTEGER"); + + b.Property("DownloadJitterMs") + .HasColumnType("REAL"); + + b.Property("DownloadLatencyMs") + .HasColumnType("REAL"); + + b.Property("DownloadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExternalServerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("PathAnalysisJson") + .HasColumnType("TEXT"); + + b.Property("PingMs") + .HasColumnType("REAL"); + + b.Property("RawDownloadJson") + .HasColumnType("TEXT"); + + b.Property("RawUploadJson") + .HasColumnType("TEXT"); + + b.Property("Success") + .HasColumnType("INTEGER"); + + b.Property("TestTime") + .HasColumnType("TEXT"); + + b.Property("UploadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("UploadBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadJitterMs") + .HasColumnType("REAL"); + + b.Property("UploadLatencyMs") + .HasColumnType("REAL"); + + b.Property("UploadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanNetworkGroup") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WifiChannel") + .HasColumnType("INTEGER"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceHost"); + + b.HasIndex("Direction"); + + b.HasIndex("TestTime"); + + b.HasIndex("DeviceHost", "TestTime"); + + b.ToTable("Iperf3Results", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("FeaturesJson") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LicensedTo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxAgents") + .HasColumnType("INTEGER"); + + b.Property("MaxDevices") + .HasColumnType("INTEGER"); + + b.Property("Organization") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("IsActive"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseKeyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntitlementJson") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("NextCheckAt") + .HasColumnType("TEXT"); + + b.Property("Org") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PaidThrough") + .HasColumnType("TEXT"); + + b.Property("PerpetualConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SiteAllowance") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.HasIndex("NextCheckAt"); + + b.HasIndex("Status"); + + b.ToTable("LicenseKeyRecords", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ModemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("ModemType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("ModemConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoredSfp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsMonitoredOnt") + .HasColumnType("INTEGER"); + + b.Property("LinkSpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("PortName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SfpPart") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SfpVendor") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMonitoredOnt"); + + b.HasIndex("DeviceMac", "PortName") + .IsUnique(); + + b.ToTable("MonitoredSfps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringInterface", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AliasIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("GatewayLocalIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("SnatEnabled") + .HasColumnType("INTEGER"); + + b.Property("SubnetPrefix") + .HasColumnType("INTEGER"); + + b.Property("TargetIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanIfName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanKey") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WanVlanId") + .HasColumnType("INTEGER"); + + b.Property("WatchdogIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AliasIp") + .IsUnique(); + + b.HasIndex("GatewayLocalIp") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("TargetIp"); + + b.ToTable("MonitoringInterfaces", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("AeRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("AeTempHighC") + .HasColumnType("REAL"); + + b.Property("AeTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FastPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Flex25GLatencyMigrated") + .HasColumnType("INTEGER"); + + b.Property("GatewayTempHighC") + .HasColumnType("REAL"); + + b.Property("InfluxDbBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbLongtermBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbOrg") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbReachable") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InfluxDbUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IspHealthScoreWindowHours") + .HasColumnType("INTEGER"); + + b.Property("LastInfluxDbCheck") + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastSnmpDetection") + .HasColumnType("TEXT"); + + b.Property("LastSnmpSuccess") + .HasColumnType("TEXT"); + + b.Property("LastUpstreamDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("MediumPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("PhysicalLinkSourceKey") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PonRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("PonTempHighC") + .HasColumnType("REAL"); + + b.Property("PonTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("SfpTempHighGenericC") + .HasColumnType("REAL"); + + b.Property("ShowCellularTab") + .HasColumnType("INTEGER"); + + b.Property("ShowCmTab") + .HasColumnType("INTEGER"); + + b.Property("ShowOntTab") + .HasColumnType("INTEGER"); + + b.Property("ShowStarlinkTab") + .HasColumnType("INTEGER"); + + b.Property("SlowPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SnmpCommunity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpDetectionState") + .HasColumnType("INTEGER"); + + b.Property("SnmpV3AuthPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpV3Username") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SnmpVersion") + .HasColumnType("INTEGER"); + + b.Property("SwitchTempHighC") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpstreamDiscoveryNeedsReview") + .HasColumnType("INTEGER"); + + b.Property("WanNeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanNeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MonitoringSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AutoDiscovered") + .HasColumnType("INTEGER"); + + b.Property("AutoLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DiscoveredProbeMode") + .HasColumnType("INTEGER"); + + b.Property("DiscoveryMethod") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LanFlakyHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PingCount") + .HasColumnType("INTEGER"); + + b.Property("PollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProbeMode") + .HasColumnType("INTEGER"); + + b.Property("PtrHostname") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("VantagePoint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanContextId") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TargetId") + .IsUnique(); + + b.HasIndex("TargetType"); + + b.HasIndex("WanInterface"); + + b.ToTable("MonitoringTargets", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OntConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachedSfpId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("OntConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OuiVendor", b => + { + b.Property("OuiPrefix") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("OuiPrefix"); + + b.ToTable("OuiVendors", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OutageAcknowledgement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("OutageStartUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OutageStartUtc"); + + b.ToTable("OutageAcknowledgements", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PerfTweakSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("TweakId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TweakId") + .IsUnique(); + + b.ToTable("PerfTweakSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Sites", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteAgent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentKeyHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EnrolledAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LanIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("TokenCreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentKeyHash"); + + b.HasIndex("EnrollmentTokenHash"); + + b.HasIndex("SiteId"); + + b.ToTable("SiteAgents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LicenseKeyRecordId") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKeyRecordId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("SiteLicenseAssignments", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmBaseline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgBytesIn") + .HasColumnType("INTEGER"); + + b.Property("AvgBytesOut") + .HasColumnType("INTEGER"); + + b.Property("AvgJitter") + .HasColumnType("REAL"); + + b.Property("AvgLatency") + .HasColumnType("REAL"); + + b.Property("AvgPacketLoss") + .HasColumnType("REAL"); + + b.Property("AvgUtilization") + .HasColumnType("REAL"); + + b.Property("BaselineEnd") + .HasColumnType("TEXT"); + + b.Property("BaselineHours") + .HasColumnType("INTEGER"); + + b.Property("BaselineStart") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HourlyDataJson") + .HasColumnType("TEXT"); + + b.Property("InterfaceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxJitter") + .HasColumnType("REAL"); + + b.Property("MaxPacketLoss") + .HasColumnType("REAL"); + + b.Property("MedianBytesIn") + .HasColumnType("INTEGER"); + + b.Property("MedianBytesOut") + .HasColumnType("INTEGER"); + + b.Property("P95Latency") + .HasColumnType("REAL"); + + b.Property("P99Latency") + .HasColumnType("REAL"); + + b.Property("PeakBytesIn") + .HasColumnType("INTEGER"); + + b.Property("PeakBytesOut") + .HasColumnType("INTEGER"); + + b.Property("PeakLatency") + .HasColumnType("REAL"); + + b.Property("PeakUtilization") + .HasColumnType("REAL"); + + b.Property("RecommendedDownloadMbps") + .HasColumnType("REAL"); + + b.Property("RecommendedUploadMbps") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NominalDownloadMbps") + .HasColumnType("INTEGER"); + + b.Property("NominalUploadMbps") + .HasColumnType("INTEGER"); + + b.Property("PingHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RateProportionalDownloadBurst") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestServerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanNumber") + .IsUnique(); + + b.ToTable("SqmWanConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SshKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("KeyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PassphraseProtected") + .HasColumnType("TEXT"); + + b.Property("PrivateKeyProtected") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SshKeys"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.StarlinkConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("StarlinkConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.TourState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DismissedTours") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeenTourSteps") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TourOffers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToursDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Subject") + .IsUnique(); + + b.ToTable("TourStates", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiConnectionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ControllerUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IgnoreControllerSSLErrors") + .HasColumnType("INTEGER"); + + b.Property("IsConfigured") + .HasColumnType("INTEGER"); + + b.Property("LastConnectedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RememberCredentials") + .HasColumnType("INTEGER"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiConnectionSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostIp", "Port", "Protocol") + .IsUnique(); + + b.ToTable("UpnpNotes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpstreamDiscovery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AncestorHopIps") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("HopIp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HopNumber") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTracerouteAt") + .HasColumnType("TEXT"); + + b.Property("LastValidated") + .HasColumnType("TEXT"); + + b.Property("MonitoringTargetId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AsnNumber"); + + b.HasIndex("IsActive"); + + b.HasIndex("MonitoringTargetId"); + + b.ToTable("UpstreamDiscoveries", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProbeSourceIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WanContexts"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastResetAt") + .HasColumnType("TEXT"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ResetMode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapGb") + .HasColumnType("REAL"); + + b.Property("CycleEnd") + .HasColumnType("TEXT"); + + b.Property("CycleStart") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RecordedAt") + .HasColumnType("TEXT"); + + b.Property("UsedGb") + .HasColumnType("REAL"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "CycleStart") + .IsUnique(); + + b.ToTable("WanDataUsageHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDiscoveryContext", b => + { + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("L2NeighborIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("NeedsReview") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("WanInterface"); + + b.ToTable("WanDiscoveryContexts", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DataPathInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadMbps") + .HasColumnType("REAL"); + + b.Property("GatewayMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UploadMbps") + .HasColumnType("REAL"); + + b.Property("WanNetworkgroup") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanNetworkgroup") + .IsUnique(); + + b.ToTable("WanProfiles"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DstCidrsJson") + .HasColumnType("TEXT"); + + b.Property("DstPortsJson") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Probability") + .HasColumnType("REAL"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SrcCidrsJson") + .HasColumnType("TEXT"); + + b.Property("SrcMacsJson") + .HasColumnType("TEXT"); + + b.Property("SrcPortsJson") + .HasColumnType("TEXT"); + + b.Property("TargetWanKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("WanSteerTrafficClasses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("KillChainStage"); + + b.HasIndex("PatternId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.ToTable("ThreatEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ThreatNoiseFilters", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("PatternType") + .HasColumnType("INTEGER"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Building", "Building") + .WithMany("Floors") + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.FloorPlan", "FloorPlan") + .WithMany("Images") + .HasForeignKey("FloorPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FloorPlan"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.LicenseKeyRecord", null) + .WithMany() + .HasForeignKey("LicenseKeyRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetworkOptimizer.Storage.Models.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.HasOne("NetworkOptimizer.Threats.Models.ThreatPattern", "Pattern") + .WithMany("Events") + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.cs b/src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.cs new file mode 100644 index 0000000000..5cd7f00ea9 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + /// + /// Folds the legacy 'wan1' WAN key into 'wan'. Migration 20260521500000 stamped existing rows + /// 'wan1' when per-WAN discovery contexts arrived; everything written since uses 'wan', which + /// is what GatewayWanHelper produces for the first WAN group. The two spellings named the same + /// WAN and nothing minded while only one WAN was ever read. + /// + /// Per-WAN reading makes them disagree. A discovery run committing 'wan' does not recognize a + /// 'wan1' row as its own, so it creates a second, WAN-qualified target beside it - a legacy + /// single-WAN install would quietly double its access and transit targets on the next run. The + /// per-WAN scorer likewise excludes 'wan1' rows from the 'wan' report, taking their upstream + /// hops and their access technology with them. + /// + /// The runtime paths normalize both spellings, so this migration is about the stored data: + /// one key per WAN, so a row means what it says. Data-only - no schema or model change. + /// + /// WanDiscoveryContexts is keyed by WanInterface, so a site holding both spellings cannot + /// simply have its 'wan1' row renamed. The newer row wins (it describes the more recent + /// discovery) and the stale one is dropped. + /// + public partial class NormalizeLegacyWan1Key : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + // Discovery contexts: drop the older spelling where both exist, then rename what is left. + migrationBuilder.Sql(@" +DELETE FROM WanDiscoveryContexts +WHERE WanInterface = 'wan1' + AND EXISTS (SELECT 1 FROM WanDiscoveryContexts w WHERE w.WanInterface = 'wan') + AND IFNULL(LastDiscoveryAt, '') <= IFNULL( + (SELECT w.LastDiscoveryAt FROM WanDiscoveryContexts w WHERE w.WanInterface = 'wan'), '');"); + + migrationBuilder.Sql(@" +DELETE FROM WanDiscoveryContexts +WHERE WanInterface = 'wan' + AND EXISTS (SELECT 1 FROM WanDiscoveryContexts w WHERE w.WanInterface = 'wan1');"); + + migrationBuilder.Sql(@" +UPDATE WanDiscoveryContexts SET WanInterface = 'wan' WHERE WanInterface = 'wan1';"); + + // Targets and discoveries carry no uniqueness on the WAN key, so a plain rename is safe. + migrationBuilder.Sql(@" +UPDATE MonitoringTargets SET WanInterface = 'wan' WHERE WanInterface = 'wan1';"); + + migrationBuilder.Sql(@" +UPDATE UpstreamDiscoveries SET WanInterface = 'wan' WHERE WanInterface = 'wan1';"); + + // A context created against the legacy spelling reads under it too. + migrationBuilder.Sql(@" +UPDATE WanContexts SET WanInterface = 'wan' WHERE WanInterface = 'wan1';"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // 'wan' is the spelling every writer has used since 20260521500000, so the rows this + // migration touched are indistinguishable from the ones it did not. Restoring 'wan1' + // would rename both, which is worse than leaving the normalized key in place - and an + // older build reads 'wan' correctly anyway. + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.Designer.cs new file mode 100644 index 0000000000..2779c18e73 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.Designer.cs @@ -0,0 +1,3393 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + [DbContext(typeof(NetworkOptimizerDbContext))] + [Migration("20260804140000_AddWanProfileRoleMarkers")] + partial class AddWanProfileRoleMarkers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("ContextJson") + .HasColumnType("TEXT"); + + b.Property("DeliveredToChannels") + .HasColumnType("TEXT"); + + b.Property("DeliveryError") + .HasColumnType("TEXT"); + + b.Property("DeliverySucceeded") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("DeviceIp") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IncidentId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("INTEGER"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId"); + + b.HasIndex("RuleId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.HasIndex("Source", "TriggeredAt"); + + b.ToTable("AlertHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertCount") + .HasColumnType("INTEGER"); + + b.Property("CorrelationKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationKey"); + + b.HasIndex("Status"); + + b.ToTable("AlertIncidents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CooldownSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestOnly") + .HasColumnType("INTEGER"); + + b.Property("EscalationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EscalationSeverity") + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("TargetDevices") + .HasColumnType("TEXT"); + + b.Property("ThresholdPercent") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.DeliveryChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelType") + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("DigestSchedule") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DeliveryChannels", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.HasIndex("TaskType"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastSeenAppVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SfpOntHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ChangedAtUtc") + .HasColumnType("TEXT"); + + b.Property("NewChannel") + .HasColumnType("INTEGER"); + + b.Property("NewWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("PreviousChannel") + .HasColumnType("INTEGER"); + + b.Property("PreviousWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAtUtc"); + + b.HasIndex("ApMac", "Band", "ChangedAtUtc"); + + b.ToTable("ApChannelChanges", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BucketDate") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("InterferenceSum") + .HasColumnType("REAL"); + + b.Property("LastSampleUtc") + .HasColumnType("TEXT"); + + b.Property("SampleCount") + .HasColumnType("INTEGER"); + + b.Property("TxRetrySum") + .HasColumnType("REAL"); + + b.Property("UtilizationSum") + .HasColumnType("REAL"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BucketDate"); + + b.HasIndex("ApMac", "Band", "Channel", "WidthMhz", "BucketDate") + .IsUnique(); + + b.ToTable("ApChannelOutcomes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("HeightM") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApNeighborSighting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Bssid") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("SightingCount") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Ssid") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenUtc"); + + b.HasIndex("ApMac", "Band", "Bssid", "Channel") + .IsUnique(); + + b.ToTable("ApNeighborSightings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditDate") + .HasColumnType("TEXT"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ComplianceScore") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FailedChecks") + .HasColumnType("INTEGER"); + + b.Property("FindingsJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CenterLatitude") + .HasColumnType("REAL"); + + b.Property("CenterLongitude") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Buildings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ClientSignalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApChannel") + .HasColumnType("INTEGER"); + + b.Property("ApClientCount") + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("ApModel") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApRadioBand") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ApTxPower") + .HasColumnType("INTEGER"); + + b.Property("Band") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BottleneckLinkSpeedMbps") + .HasColumnType("REAL"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("ChannelWidth") + .HasColumnType("INTEGER"); + + b.Property("ClientIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("ClientMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopCount") + .HasColumnType("INTEGER"); + + b.Property("IsMlo") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MloLinksJson") + .HasColumnType("TEXT"); + + b.Property("NoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TraceHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TraceJson") + .HasColumnType("TEXT"); + + b.Property("TxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TraceHash"); + + b.HasIndex("ClientMac", "Timestamp"); + + b.ToTable("ClientSignalLogs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CmConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StatusPagePath") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("CmConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CustomOidConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Oid") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "Oid") + .IsUnique(); + + b.ToTable("CustomOidConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DeviceSshConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3BinaryPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Iperf3DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Iperf3ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SshPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshPrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartIperf3Server") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("DeviceSshConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DismissedAt") + .HasColumnType("TEXT"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IssueKey") + .IsUnique(); + + b.ToTable("DismissedIssues", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ExternalSpeedTestServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ServerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId") + .IsUnique(); + + b.ToTable("ExternalSpeedTestServers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildingId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorMaterial") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FloorNumber") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.ToTable("FloorPlans", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CropJson") + .HasColumnType("TEXT"); + + b.Property("FloorPlanId") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("RotationDeg") + .HasColumnType("REAL"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FloorPlanId"); + + b.ToTable("FloorPlanImages", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.GatewaySshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3Port") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TcMonitorPort") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.InterfaceNameMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IfAlias") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IfIndex") + .HasColumnType("INTEGER"); + + b.Property("IfName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsSfp") + .HasColumnType("INTEGER"); + + b.Property("IsWan") + .HasColumnType("INTEGER"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("PortNumber") + .HasColumnType("INTEGER"); + + b.Property("SpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "IfName") + .IsUnique(); + + b.ToTable("InterfaceNameMaps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Iperf3Result", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("DownloadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("DownloadBytes") + .HasColumnType("INTEGER"); + + b.Property("DownloadJitterMs") + .HasColumnType("REAL"); + + b.Property("DownloadLatencyMs") + .HasColumnType("REAL"); + + b.Property("DownloadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExternalServerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("PathAnalysisJson") + .HasColumnType("TEXT"); + + b.Property("PingMs") + .HasColumnType("REAL"); + + b.Property("RawDownloadJson") + .HasColumnType("TEXT"); + + b.Property("RawUploadJson") + .HasColumnType("TEXT"); + + b.Property("Success") + .HasColumnType("INTEGER"); + + b.Property("TestTime") + .HasColumnType("TEXT"); + + b.Property("UploadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("UploadBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadJitterMs") + .HasColumnType("REAL"); + + b.Property("UploadLatencyMs") + .HasColumnType("REAL"); + + b.Property("UploadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanNetworkGroup") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WifiChannel") + .HasColumnType("INTEGER"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceHost"); + + b.HasIndex("Direction"); + + b.HasIndex("TestTime"); + + b.HasIndex("DeviceHost", "TestTime"); + + b.ToTable("Iperf3Results", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("FeaturesJson") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LicensedTo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxAgents") + .HasColumnType("INTEGER"); + + b.Property("MaxDevices") + .HasColumnType("INTEGER"); + + b.Property("Organization") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("IsActive"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseKeyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntitlementJson") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("NextCheckAt") + .HasColumnType("TEXT"); + + b.Property("Org") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PaidThrough") + .HasColumnType("TEXT"); + + b.Property("PerpetualConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SiteAllowance") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.HasIndex("NextCheckAt"); + + b.HasIndex("Status"); + + b.ToTable("LicenseKeyRecords", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ModemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("ModemType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("ModemConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoredSfp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsMonitoredOnt") + .HasColumnType("INTEGER"); + + b.Property("LinkSpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("PortName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SfpPart") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SfpVendor") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMonitoredOnt"); + + b.HasIndex("DeviceMac", "PortName") + .IsUnique(); + + b.ToTable("MonitoredSfps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringInterface", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AliasIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("GatewayLocalIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("SnatEnabled") + .HasColumnType("INTEGER"); + + b.Property("SubnetPrefix") + .HasColumnType("INTEGER"); + + b.Property("TargetIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanIfName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanKey") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WanVlanId") + .HasColumnType("INTEGER"); + + b.Property("WatchdogIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AliasIp") + .IsUnique(); + + b.HasIndex("GatewayLocalIp") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("TargetIp"); + + b.ToTable("MonitoringInterfaces", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("AeRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("AeTempHighC") + .HasColumnType("REAL"); + + b.Property("AeTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FastPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Flex25GLatencyMigrated") + .HasColumnType("INTEGER"); + + b.Property("GatewayTempHighC") + .HasColumnType("REAL"); + + b.Property("InfluxDbBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbLongtermBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbOrg") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbReachable") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InfluxDbUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IspHealthScoreWindowHours") + .HasColumnType("INTEGER"); + + b.Property("LastInfluxDbCheck") + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastSnmpDetection") + .HasColumnType("TEXT"); + + b.Property("LastSnmpSuccess") + .HasColumnType("TEXT"); + + b.Property("LastUpstreamDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("MediumPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("PhysicalLinkSourceKey") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PonRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("PonTempHighC") + .HasColumnType("REAL"); + + b.Property("PonTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("SfpTempHighGenericC") + .HasColumnType("REAL"); + + b.Property("ShowCellularTab") + .HasColumnType("INTEGER"); + + b.Property("ShowCmTab") + .HasColumnType("INTEGER"); + + b.Property("ShowOntTab") + .HasColumnType("INTEGER"); + + b.Property("ShowStarlinkTab") + .HasColumnType("INTEGER"); + + b.Property("SlowPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SnmpCommunity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpDetectionState") + .HasColumnType("INTEGER"); + + b.Property("SnmpV3AuthPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpV3Username") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SnmpVersion") + .HasColumnType("INTEGER"); + + b.Property("SwitchTempHighC") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpstreamDiscoveryNeedsReview") + .HasColumnType("INTEGER"); + + b.Property("WanNeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanNeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MonitoringSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AutoDiscovered") + .HasColumnType("INTEGER"); + + b.Property("AutoLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DiscoveredProbeMode") + .HasColumnType("INTEGER"); + + b.Property("DiscoveryMethod") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LanFlakyHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PingCount") + .HasColumnType("INTEGER"); + + b.Property("PollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProbeMode") + .HasColumnType("INTEGER"); + + b.Property("PtrHostname") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("VantagePoint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanContextId") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TargetId") + .IsUnique(); + + b.HasIndex("TargetType"); + + b.HasIndex("WanInterface"); + + b.ToTable("MonitoringTargets", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OntConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachedSfpId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("OntConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OuiVendor", b => + { + b.Property("OuiPrefix") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("OuiPrefix"); + + b.ToTable("OuiVendors", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OutageAcknowledgement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("OutageStartUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OutageStartUtc"); + + b.ToTable("OutageAcknowledgements", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PerfTweakSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("TweakId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TweakId") + .IsUnique(); + + b.ToTable("PerfTweakSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Sites", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteAgent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentKeyHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EnrolledAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LanIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("TokenCreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentKeyHash"); + + b.HasIndex("EnrollmentTokenHash"); + + b.HasIndex("SiteId"); + + b.ToTable("SiteAgents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LicenseKeyRecordId") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKeyRecordId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("SiteLicenseAssignments", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmBaseline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgBytesIn") + .HasColumnType("INTEGER"); + + b.Property("AvgBytesOut") + .HasColumnType("INTEGER"); + + b.Property("AvgJitter") + .HasColumnType("REAL"); + + b.Property("AvgLatency") + .HasColumnType("REAL"); + + b.Property("AvgPacketLoss") + .HasColumnType("REAL"); + + b.Property("AvgUtilization") + .HasColumnType("REAL"); + + b.Property("BaselineEnd") + .HasColumnType("TEXT"); + + b.Property("BaselineHours") + .HasColumnType("INTEGER"); + + b.Property("BaselineStart") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HourlyDataJson") + .HasColumnType("TEXT"); + + b.Property("InterfaceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxJitter") + .HasColumnType("REAL"); + + b.Property("MaxPacketLoss") + .HasColumnType("REAL"); + + b.Property("MedianBytesIn") + .HasColumnType("INTEGER"); + + b.Property("MedianBytesOut") + .HasColumnType("INTEGER"); + + b.Property("P95Latency") + .HasColumnType("REAL"); + + b.Property("P99Latency") + .HasColumnType("REAL"); + + b.Property("PeakBytesIn") + .HasColumnType("INTEGER"); + + b.Property("PeakBytesOut") + .HasColumnType("INTEGER"); + + b.Property("PeakLatency") + .HasColumnType("REAL"); + + b.Property("PeakUtilization") + .HasColumnType("REAL"); + + b.Property("RecommendedDownloadMbps") + .HasColumnType("REAL"); + + b.Property("RecommendedUploadMbps") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NominalDownloadMbps") + .HasColumnType("INTEGER"); + + b.Property("NominalUploadMbps") + .HasColumnType("INTEGER"); + + b.Property("PingHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RateProportionalDownloadBurst") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestServerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanNumber") + .IsUnique(); + + b.ToTable("SqmWanConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SshKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("KeyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PassphraseProtected") + .HasColumnType("TEXT"); + + b.Property("PrivateKeyProtected") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SshKeys"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.StarlinkConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("StarlinkConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.TourState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DismissedTours") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeenTourSteps") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TourOffers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToursDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Subject") + .IsUnique(); + + b.ToTable("TourStates", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiConnectionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ControllerUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IgnoreControllerSSLErrors") + .HasColumnType("INTEGER"); + + b.Property("IsConfigured") + .HasColumnType("INTEGER"); + + b.Property("LastConnectedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RememberCredentials") + .HasColumnType("INTEGER"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiConnectionSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostIp", "Port", "Protocol") + .IsUnique(); + + b.ToTable("UpnpNotes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpstreamDiscovery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AncestorHopIps") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("HopIp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HopNumber") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTracerouteAt") + .HasColumnType("TEXT"); + + b.Property("LastValidated") + .HasColumnType("TEXT"); + + b.Property("MonitoringTargetId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AsnNumber"); + + b.HasIndex("IsActive"); + + b.HasIndex("MonitoringTargetId"); + + b.ToTable("UpstreamDiscoveries", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProbeSourceIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WanContexts"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastResetAt") + .HasColumnType("TEXT"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ResetMode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapGb") + .HasColumnType("REAL"); + + b.Property("CycleEnd") + .HasColumnType("TEXT"); + + b.Property("CycleStart") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RecordedAt") + .HasColumnType("TEXT"); + + b.Property("UsedGb") + .HasColumnType("REAL"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "CycleStart") + .IsUnique(); + + b.ToTable("WanDataUsageHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDiscoveryContext", b => + { + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("L2NeighborIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("NeedsReview") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("WanInterface"); + + b.ToTable("WanDiscoveryContexts", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DataPathInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("SiteLoadBalances") + .HasColumnType("INTEGER"); + + b.Property("DownloadMbps") + .HasColumnType("REAL"); + + b.Property("GatewayMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UploadMbps") + .HasColumnType("REAL"); + + b.Property("WanNetworkgroup") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanNetworkgroup") + .IsUnique(); + + b.ToTable("WanProfiles"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DstCidrsJson") + .HasColumnType("TEXT"); + + b.Property("DstPortsJson") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Probability") + .HasColumnType("REAL"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SrcCidrsJson") + .HasColumnType("TEXT"); + + b.Property("SrcMacsJson") + .HasColumnType("TEXT"); + + b.Property("SrcPortsJson") + .HasColumnType("TEXT"); + + b.Property("TargetWanKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("WanSteerTrafficClasses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("KillChainStage"); + + b.HasIndex("PatternId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.ToTable("ThreatEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ThreatNoiseFilters", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("PatternType") + .HasColumnType("INTEGER"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Building", "Building") + .WithMany("Floors") + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.FloorPlan", "FloorPlan") + .WithMany("Images") + .HasForeignKey("FloorPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FloorPlan"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.LicenseKeyRecord", null) + .WithMany() + .HasForeignKey("LicenseKeyRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetworkOptimizer.Storage.Models.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.HasOne("NetworkOptimizer.Threats.Models.ThreatPattern", "Pattern") + .WithMany("Events") + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.cs b/src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.cs new file mode 100644 index 0000000000..d9131d5a73 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + /// + /// Records which WAN holds the primary role, and whether the site load balances, so the answer + /// survives away from a console. Primary is a role rather than a name - any WAN group can hold + /// it - and the paths that need it most cannot ask: the probe-push path runs on the tunnel's + /// background thread with no console call available, and the offline scoring fallbacks would + /// otherwise guess at the conventional first group and be wrong on a WAN2-primary site. + /// + /// Both are nullable on purpose: null means no connected compute has resolved the role yet, and + /// readers must treat that as unknown - falling back to their documented guess - rather than as + /// a negative answer. + /// + public partial class AddWanProfileRoleMarkers : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsPrimary", table: "WanProfiles", type: "INTEGER", nullable: true); + migrationBuilder.AddColumn( + name: "SiteLoadBalances", table: "WanProfiles", type: "INTEGER", nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "IsPrimary", table: "WanProfiles"); + migrationBuilder.DropColumn(name: "SiteLoadBalances", table: "WanProfiles"); + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260805120000_AddSeededAlertRules.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260805120000_AddSeededAlertRules.Designer.cs new file mode 100644 index 0000000000..99e7727270 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260805120000_AddSeededAlertRules.Designer.cs @@ -0,0 +1,3415 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + [DbContext(typeof(NetworkOptimizerDbContext))] + [Migration("20260805120000_AddSeededAlertRules")] + partial class AddSeededAlertRules + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("ContextJson") + .HasColumnType("TEXT"); + + b.Property("DeliveredToChannels") + .HasColumnType("TEXT"); + + b.Property("DeliveryError") + .HasColumnType("TEXT"); + + b.Property("DeliverySucceeded") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("DeviceIp") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IncidentId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("INTEGER"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId"); + + b.HasIndex("RuleId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.HasIndex("Source", "TriggeredAt"); + + b.ToTable("AlertHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertCount") + .HasColumnType("INTEGER"); + + b.Property("CorrelationKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationKey"); + + b.HasIndex("Status"); + + b.ToTable("AlertIncidents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CooldownSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestOnly") + .HasColumnType("INTEGER"); + + b.Property("EscalationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EscalationSeverity") + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("TargetDevices") + .HasColumnType("TEXT"); + + b.Property("ThresholdPercent") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.DeliveryChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelType") + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("DigestSchedule") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DeliveryChannels", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.HasIndex("TaskType"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.SeededAlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SeededAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventTypePattern") + .IsUnique(); + + b.ToTable("SeededAlertRules", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastSeenAppVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SfpOntHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ChangedAtUtc") + .HasColumnType("TEXT"); + + b.Property("NewChannel") + .HasColumnType("INTEGER"); + + b.Property("NewWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("PreviousChannel") + .HasColumnType("INTEGER"); + + b.Property("PreviousWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAtUtc"); + + b.HasIndex("ApMac", "Band", "ChangedAtUtc"); + + b.ToTable("ApChannelChanges", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BucketDate") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("InterferenceSum") + .HasColumnType("REAL"); + + b.Property("LastSampleUtc") + .HasColumnType("TEXT"); + + b.Property("SampleCount") + .HasColumnType("INTEGER"); + + b.Property("TxRetrySum") + .HasColumnType("REAL"); + + b.Property("UtilizationSum") + .HasColumnType("REAL"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BucketDate"); + + b.HasIndex("ApMac", "Band", "Channel", "WidthMhz", "BucketDate") + .IsUnique(); + + b.ToTable("ApChannelOutcomes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("HeightM") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApNeighborSighting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Bssid") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("SightingCount") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Ssid") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenUtc"); + + b.HasIndex("ApMac", "Band", "Bssid", "Channel") + .IsUnique(); + + b.ToTable("ApNeighborSightings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditDate") + .HasColumnType("TEXT"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ComplianceScore") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FailedChecks") + .HasColumnType("INTEGER"); + + b.Property("FindingsJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CenterLatitude") + .HasColumnType("REAL"); + + b.Property("CenterLongitude") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Buildings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ClientSignalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApChannel") + .HasColumnType("INTEGER"); + + b.Property("ApClientCount") + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("ApModel") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApRadioBand") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ApTxPower") + .HasColumnType("INTEGER"); + + b.Property("Band") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BottleneckLinkSpeedMbps") + .HasColumnType("REAL"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("ChannelWidth") + .HasColumnType("INTEGER"); + + b.Property("ClientIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("ClientMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopCount") + .HasColumnType("INTEGER"); + + b.Property("IsMlo") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MloLinksJson") + .HasColumnType("TEXT"); + + b.Property("NoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TraceHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TraceJson") + .HasColumnType("TEXT"); + + b.Property("TxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TraceHash"); + + b.HasIndex("ClientMac", "Timestamp"); + + b.ToTable("ClientSignalLogs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CmConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StatusPagePath") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("CmConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CustomOidConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Oid") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "Oid") + .IsUnique(); + + b.ToTable("CustomOidConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DeviceSshConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3BinaryPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Iperf3DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Iperf3ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SshPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshPrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartIperf3Server") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("DeviceSshConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DismissedAt") + .HasColumnType("TEXT"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IssueKey") + .IsUnique(); + + b.ToTable("DismissedIssues", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ExternalSpeedTestServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ServerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId") + .IsUnique(); + + b.ToTable("ExternalSpeedTestServers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildingId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorMaterial") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FloorNumber") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.ToTable("FloorPlans", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CropJson") + .HasColumnType("TEXT"); + + b.Property("FloorPlanId") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("RotationDeg") + .HasColumnType("REAL"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FloorPlanId"); + + b.ToTable("FloorPlanImages", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.GatewaySshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3Port") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TcMonitorPort") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.InterfaceNameMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IfAlias") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IfIndex") + .HasColumnType("INTEGER"); + + b.Property("IfName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsSfp") + .HasColumnType("INTEGER"); + + b.Property("IsWan") + .HasColumnType("INTEGER"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("PortNumber") + .HasColumnType("INTEGER"); + + b.Property("SpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "IfName") + .IsUnique(); + + b.ToTable("InterfaceNameMaps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Iperf3Result", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("DownloadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("DownloadBytes") + .HasColumnType("INTEGER"); + + b.Property("DownloadJitterMs") + .HasColumnType("REAL"); + + b.Property("DownloadLatencyMs") + .HasColumnType("REAL"); + + b.Property("DownloadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExternalServerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("PathAnalysisJson") + .HasColumnType("TEXT"); + + b.Property("PingMs") + .HasColumnType("REAL"); + + b.Property("RawDownloadJson") + .HasColumnType("TEXT"); + + b.Property("RawUploadJson") + .HasColumnType("TEXT"); + + b.Property("Success") + .HasColumnType("INTEGER"); + + b.Property("TestTime") + .HasColumnType("TEXT"); + + b.Property("UploadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("UploadBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadJitterMs") + .HasColumnType("REAL"); + + b.Property("UploadLatencyMs") + .HasColumnType("REAL"); + + b.Property("UploadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanNetworkGroup") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WifiChannel") + .HasColumnType("INTEGER"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceHost"); + + b.HasIndex("Direction"); + + b.HasIndex("TestTime"); + + b.HasIndex("DeviceHost", "TestTime"); + + b.ToTable("Iperf3Results", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("FeaturesJson") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LicensedTo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxAgents") + .HasColumnType("INTEGER"); + + b.Property("MaxDevices") + .HasColumnType("INTEGER"); + + b.Property("Organization") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("IsActive"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseKeyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntitlementJson") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("NextCheckAt") + .HasColumnType("TEXT"); + + b.Property("Org") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PaidThrough") + .HasColumnType("TEXT"); + + b.Property("PerpetualConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SiteAllowance") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.HasIndex("NextCheckAt"); + + b.HasIndex("Status"); + + b.ToTable("LicenseKeyRecords", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ModemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("ModemType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("ModemConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoredSfp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsMonitoredOnt") + .HasColumnType("INTEGER"); + + b.Property("LinkSpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("PortName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SfpPart") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SfpVendor") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMonitoredOnt"); + + b.HasIndex("DeviceMac", "PortName") + .IsUnique(); + + b.ToTable("MonitoredSfps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringInterface", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AliasIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("GatewayLocalIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("SnatEnabled") + .HasColumnType("INTEGER"); + + b.Property("SubnetPrefix") + .HasColumnType("INTEGER"); + + b.Property("TargetIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanIfName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanKey") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WanVlanId") + .HasColumnType("INTEGER"); + + b.Property("WatchdogIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AliasIp") + .IsUnique(); + + b.HasIndex("GatewayLocalIp") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("TargetIp"); + + b.ToTable("MonitoringInterfaces", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("AeRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("AeTempHighC") + .HasColumnType("REAL"); + + b.Property("AeTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FastPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Flex25GLatencyMigrated") + .HasColumnType("INTEGER"); + + b.Property("GatewayTempHighC") + .HasColumnType("REAL"); + + b.Property("InfluxDbBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbLongtermBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbOrg") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbReachable") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InfluxDbUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IspHealthScoreWindowHours") + .HasColumnType("INTEGER"); + + b.Property("LastInfluxDbCheck") + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastSnmpDetection") + .HasColumnType("TEXT"); + + b.Property("LastSnmpSuccess") + .HasColumnType("TEXT"); + + b.Property("LastUpstreamDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("MediumPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("PhysicalLinkSourceKey") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PonRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("PonTempHighC") + .HasColumnType("REAL"); + + b.Property("PonTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("SfpTempHighGenericC") + .HasColumnType("REAL"); + + b.Property("ShowCellularTab") + .HasColumnType("INTEGER"); + + b.Property("ShowCmTab") + .HasColumnType("INTEGER"); + + b.Property("ShowOntTab") + .HasColumnType("INTEGER"); + + b.Property("ShowStarlinkTab") + .HasColumnType("INTEGER"); + + b.Property("SlowPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SnmpCommunity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpDetectionState") + .HasColumnType("INTEGER"); + + b.Property("SnmpV3AuthPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpV3Username") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SnmpVersion") + .HasColumnType("INTEGER"); + + b.Property("SwitchTempHighC") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpstreamDiscoveryNeedsReview") + .HasColumnType("INTEGER"); + + b.Property("WanNeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanNeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MonitoringSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AutoDiscovered") + .HasColumnType("INTEGER"); + + b.Property("AutoLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DiscoveredProbeMode") + .HasColumnType("INTEGER"); + + b.Property("DiscoveryMethod") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LanFlakyHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PingCount") + .HasColumnType("INTEGER"); + + b.Property("PollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProbeMode") + .HasColumnType("INTEGER"); + + b.Property("PtrHostname") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("VantagePoint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanContextId") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TargetId") + .IsUnique(); + + b.HasIndex("TargetType"); + + b.HasIndex("WanInterface"); + + b.ToTable("MonitoringTargets", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OntConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachedSfpId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("OntConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OuiVendor", b => + { + b.Property("OuiPrefix") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("OuiPrefix"); + + b.ToTable("OuiVendors", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OutageAcknowledgement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("OutageStartUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OutageStartUtc"); + + b.ToTable("OutageAcknowledgements", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PerfTweakSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("TweakId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TweakId") + .IsUnique(); + + b.ToTable("PerfTweakSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Sites", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteAgent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentKeyHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EnrolledAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LanIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("TokenCreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentKeyHash"); + + b.HasIndex("EnrollmentTokenHash"); + + b.HasIndex("SiteId"); + + b.ToTable("SiteAgents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LicenseKeyRecordId") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKeyRecordId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("SiteLicenseAssignments", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmBaseline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgBytesIn") + .HasColumnType("INTEGER"); + + b.Property("AvgBytesOut") + .HasColumnType("INTEGER"); + + b.Property("AvgJitter") + .HasColumnType("REAL"); + + b.Property("AvgLatency") + .HasColumnType("REAL"); + + b.Property("AvgPacketLoss") + .HasColumnType("REAL"); + + b.Property("AvgUtilization") + .HasColumnType("REAL"); + + b.Property("BaselineEnd") + .HasColumnType("TEXT"); + + b.Property("BaselineHours") + .HasColumnType("INTEGER"); + + b.Property("BaselineStart") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HourlyDataJson") + .HasColumnType("TEXT"); + + b.Property("InterfaceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxJitter") + .HasColumnType("REAL"); + + b.Property("MaxPacketLoss") + .HasColumnType("REAL"); + + b.Property("MedianBytesIn") + .HasColumnType("INTEGER"); + + b.Property("MedianBytesOut") + .HasColumnType("INTEGER"); + + b.Property("P95Latency") + .HasColumnType("REAL"); + + b.Property("P99Latency") + .HasColumnType("REAL"); + + b.Property("PeakBytesIn") + .HasColumnType("INTEGER"); + + b.Property("PeakBytesOut") + .HasColumnType("INTEGER"); + + b.Property("PeakLatency") + .HasColumnType("REAL"); + + b.Property("PeakUtilization") + .HasColumnType("REAL"); + + b.Property("RecommendedDownloadMbps") + .HasColumnType("REAL"); + + b.Property("RecommendedUploadMbps") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NominalDownloadMbps") + .HasColumnType("INTEGER"); + + b.Property("NominalUploadMbps") + .HasColumnType("INTEGER"); + + b.Property("PingHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RateProportionalDownloadBurst") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestServerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanNumber") + .IsUnique(); + + b.ToTable("SqmWanConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SshKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("KeyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PassphraseProtected") + .HasColumnType("TEXT"); + + b.Property("PrivateKeyProtected") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SshKeys"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.StarlinkConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("StarlinkConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.TourState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DismissedTours") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeenTourSteps") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TourOffers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToursDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Subject") + .IsUnique(); + + b.ToTable("TourStates", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiConnectionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ControllerUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IgnoreControllerSSLErrors") + .HasColumnType("INTEGER"); + + b.Property("IsConfigured") + .HasColumnType("INTEGER"); + + b.Property("LastConnectedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RememberCredentials") + .HasColumnType("INTEGER"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiConnectionSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostIp", "Port", "Protocol") + .IsUnique(); + + b.ToTable("UpnpNotes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpstreamDiscovery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AncestorHopIps") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("HopIp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HopNumber") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTracerouteAt") + .HasColumnType("TEXT"); + + b.Property("LastValidated") + .HasColumnType("TEXT"); + + b.Property("MonitoringTargetId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AsnNumber"); + + b.HasIndex("IsActive"); + + b.HasIndex("MonitoringTargetId"); + + b.ToTable("UpstreamDiscoveries", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProbeSourceIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WanContexts"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastResetAt") + .HasColumnType("TEXT"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ResetMode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapGb") + .HasColumnType("REAL"); + + b.Property("CycleEnd") + .HasColumnType("TEXT"); + + b.Property("CycleStart") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RecordedAt") + .HasColumnType("TEXT"); + + b.Property("UsedGb") + .HasColumnType("REAL"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "CycleStart") + .IsUnique(); + + b.ToTable("WanDataUsageHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDiscoveryContext", b => + { + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("L2NeighborIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("NeedsReview") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("WanInterface"); + + b.ToTable("WanDiscoveryContexts", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DataPathInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("SiteLoadBalances") + .HasColumnType("INTEGER"); + + b.Property("DownloadMbps") + .HasColumnType("REAL"); + + b.Property("GatewayMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UploadMbps") + .HasColumnType("REAL"); + + b.Property("WanNetworkgroup") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanNetworkgroup") + .IsUnique(); + + b.ToTable("WanProfiles"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DstCidrsJson") + .HasColumnType("TEXT"); + + b.Property("DstPortsJson") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Probability") + .HasColumnType("REAL"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SrcCidrsJson") + .HasColumnType("TEXT"); + + b.Property("SrcMacsJson") + .HasColumnType("TEXT"); + + b.Property("SrcPortsJson") + .HasColumnType("TEXT"); + + b.Property("TargetWanKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("WanSteerTrafficClasses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("KillChainStage"); + + b.HasIndex("PatternId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.ToTable("ThreatEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ThreatNoiseFilters", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("PatternType") + .HasColumnType("INTEGER"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Building", "Building") + .WithMany("Floors") + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.FloorPlan", "FloorPlan") + .WithMany("Images") + .HasForeignKey("FloorPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FloorPlan"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.LicenseKeyRecord", null) + .WithMany() + .HasForeignKey("LicenseKeyRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetworkOptimizer.Storage.Models.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.HasOne("NetworkOptimizer.Threats.Models.ThreatPattern", "Pattern") + .WithMany("Events") + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260805120000_AddSeededAlertRules.cs b/src/NetworkOptimizer.Storage/Migrations/20260805120000_AddSeededAlertRules.cs new file mode 100644 index 0000000000..579ced722a --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260805120000_AddSeededAlertRules.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + /// + /// Records which default alert rule patterns have already been seeded into this database, so + /// startup seeds each pattern at most once. Seeding previously inserted any default whose + /// pattern was missing from AlertRules, which brought deleted rules back on every restart. + /// + public partial class AddSeededAlertRules : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SeededAlertRules", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + EventTypePattern = table.Column(type: "TEXT", maxLength: 200, nullable: false), + SeededAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SeededAlertRules", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_SeededAlertRules_EventTypePattern", + table: "SeededAlertRules", + column: "EventTypePattern", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SeededAlertRules"); + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.Designer.cs new file mode 100644 index 0000000000..3fd1cb3d60 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.Designer.cs @@ -0,0 +1,767 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models.Identity; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations.Auth +{ + [DbContext(typeof(AuthDbContext))] + [Migration("20260804180000_AddUserUiHints")] + partial class AddUserUiHints + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasColumnType("BLOB"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RequireMfa") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LastLoginMethod") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MembershipVersion") + .HasColumnType("INTEGER"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PasswordIsTemporary") + .HasColumnType("INTEGER"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ActorAuthMethod") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ActorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ActorUserId") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SiteSlug") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TargetName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TimestampUtc") + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("Category"); + + b.HasIndex("SiteSlug"); + + b.HasIndex("TimestampUtc"); + + b.ToTable("AuditEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.FederationProvider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcrValues") + .HasColumnType("TEXT"); + + b.Property("AllowIdpInitiated") + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("TEXT"); + + b.Property("ButtonLabel") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientSecretProtected") + .HasColumnType("TEXT"); + + b.Property("ClockSkewSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DisplayNameClaim") + .HasColumnType("TEXT"); + + b.Property("EmailClaim") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EndSessionSupport") + .HasColumnType("INTEGER"); + + b.Property("GetClaimsFromUserInfo") + .HasColumnType("INTEGER"); + + b.Property("GroupsClaim") + .HasColumnType("TEXT"); + + b.Property("IdpMetadataUrl") + .HasColumnType("TEXT"); + + b.Property("IdpMetadataXml") + .HasColumnType("TEXT"); + + b.Property("JitProvisioning") + .HasColumnType("INTEGER"); + + b.Property("ManagedByConfigFile") + .HasColumnType("INTEGER"); + + b.Property("ResponseType") + .HasColumnType("TEXT"); + + b.Property("RoleMappingMode") + .HasColumnType("INTEGER"); + + b.Property("SamlDecryptionCertProtected") + .HasColumnType("TEXT"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Scopes") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SpEntityId") + .HasColumnType("TEXT"); + + b.Property("SubjectClaim") + .HasColumnType("TEXT"); + + b.Property("TrustIdpMfa") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UsePkce") + .HasColumnType("INTEGER"); + + b.Property("UsernameClaim") + .HasColumnType("TEXT"); + + b.Property("WantAssertionsEncrypted") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Scheme") + .IsUnique(); + + b.ToTable("FederationProviders", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.FederationRoleMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GlobalRole") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("GroupOrClaimValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("FederationRoleMappings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.FederationSiteMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GroupOrClaimValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("INTEGER"); + + b.Property("SiteRole") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("TargetValue") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("FederationSiteMappings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.SiteGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SiteGroups", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.SiteGroupMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("SiteSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteSlug"); + + b.HasIndex("GroupId", "SiteSlug") + .IsUnique(); + + b.ToTable("SiteGroupMembers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.UserUiHint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("HintKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TimesShown") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "HintKey") + .IsUnique(); + + b.ToTable("UserUiHints"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.SiteMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SiteRole") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "TargetType", "TargetId") + .IsUnique(); + + b.ToTable("SiteMemberships", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId"); + + b1.Property("AttestationObject") + .IsRequired(); + + b1.Property("ClientDataJson") + .IsRequired(); + + b1.Property("CreatedAt"); + + b1.Property("IsBackedUp"); + + b1.Property("IsBackupEligible"); + + b1.Property("IsUserVerified"); + + b1.Property("Name"); + + b1.Property("PublicKey") + .IsRequired(); + + b1.Property("SignCount"); + + b1.PrimitiveCollection("Transports"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("AspNetUserPasskeys"); + + b1 + .ToJson("Data") + .HasColumnType("TEXT"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.FederationRoleMapping", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.FederationProvider", null) + .WithMany("RoleMappings") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.FederationSiteMapping", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.FederationProvider", null) + .WithMany("SiteMappings") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.SiteGroupMember", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.SiteGroup", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.SiteMembership", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.FederationProvider", b => + { + b.Navigation("RoleMappings"); + + b.Navigation("SiteMappings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.cs b/src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.cs new file mode 100644 index 0000000000..b93f8465e8 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations.Auth +{ + /// + /// Per-user counts of teaching hints shown, so a hint that exists to reveal a non-obvious + /// gesture can stop repeating once the user has plainly seen it. Per user rather than per site + /// or per install: what someone has learned travels with them, and one operator learning a + /// gesture says nothing about their colleagues. + /// + public partial class AddUserUiHints : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserUiHints", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "TEXT", maxLength: 450, nullable: false), + HintKey = table.Column(type: "TEXT", maxLength: 100, nullable: false), + TimesShown = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserUiHints", x => x.Id); + }); + + // One row per user per hint - the upsert relies on it, and a duplicate would let a + // hint count twice as slowly and outstay its welcome. + migrationBuilder.CreateIndex( + name: "IX_UserUiHints_UserId_HintKey", + table: "UserUiHints", + columns: new[] { "UserId", "HintKey" }, + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "UserUiHints"); + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/Auth/AuthDbContextModelSnapshot.cs b/src/NetworkOptimizer.Storage/Migrations/Auth/AuthDbContextModelSnapshot.cs index 0b7b121582..2617588c7c 100644 --- a/src/NetworkOptimizer.Storage/Migrations/Auth/AuthDbContextModelSnapshot.cs +++ b/src/NetworkOptimizer.Storage/Migrations/Auth/AuthDbContextModelSnapshot.cs @@ -557,6 +557,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SiteGroupMembers", (string)null); }); + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.UserUiHint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("HintKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TimesShown") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "HintKey") + .IsUnique(); + + b.ToTable("UserUiHints"); + }); + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.SiteMembership", b => { b.Property("Id") diff --git a/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs b/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs index 30cc360c87..c569c3fcae 100644 --- a/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs +++ b/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs @@ -299,6 +299,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ScheduledTasks", (string)null); }); + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.SeededAlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SeededAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventTypePattern") + .IsUnique(); + + b.ToTable("SeededAlertRules", (string)null); + }); + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AdminSettings", b => { b.Property("Id") @@ -2497,7 +2519,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("SshKeys", (string)null); + b.ToTable("SshKeys"); }); modelBuilder.Entity("NetworkOptimizer.Storage.Models.StarlinkConfiguration", b => @@ -2820,62 +2842,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(500) .HasColumnType("TEXT"); - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("ProbeSourceIp") + b.Property("InterfaceName") .HasMaxLength(50) .HasColumnType("TEXT"); - b.HasKey("Id"); - - b.ToTable("WanContexts"); - }); - - modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("DownloadMbps") - .HasColumnType("REAL"); - - b.Property("CounterInterface") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("DataPathInterface") + b.Property("Name") + .IsRequired() .HasMaxLength(100) .HasColumnType("TEXT"); - b.Property("GatewayMac") + b.Property("ProbeSourceIp") .HasMaxLength(50) .HasColumnType("TEXT"); - b.Property("Name") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UploadMbps") - .HasColumnType("REAL"); - - b.Property("WanNetworkgroup") - .IsRequired() + b.Property("WanInterface") .HasMaxLength(50) .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("WanNetworkgroup") - .IsUnique(); - - b.ToTable("WanProfiles"); + b.ToTable("WanContexts"); }); modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => @@ -3042,6 +3028,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("WanDiscoveryContexts", (string)null); }); + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DataPathInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("SiteLoadBalances") + .HasColumnType("INTEGER"); + + b.Property("DownloadMbps") + .HasColumnType("REAL"); + + b.Property("GatewayMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UploadMbps") + .HasColumnType("REAL"); + + b.Property("WanNetworkgroup") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanNetworkgroup") + .IsUnique(); + + b.ToTable("WanProfiles"); + }); + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => { b.Property("Id") diff --git a/src/NetworkOptimizer.Storage/Models/Identity/AuthDbContext.cs b/src/NetworkOptimizer.Storage/Models/Identity/AuthDbContext.cs index 6818c89009..090c9ac70c 100644 --- a/src/NetworkOptimizer.Storage/Models/Identity/AuthDbContext.cs +++ b/src/NetworkOptimizer.Storage/Models/Identity/AuthDbContext.cs @@ -46,6 +46,9 @@ public AuthDbContext(DbContextOptions options) /// WebAuthn passkey credentials (.NET 10 Identity passkey store; design doc 02). public DbSet> Passkeys { get; set; } + /// Per-user counts of teaching hints shown, so a hint can retire once it is learned. + public DbSet UserUiHints { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -61,6 +64,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.OwnsOne(p => p.Data, d => d.ToJson()); }); + // One row per user per hint - the upsert relies on it, and a duplicate would let a hint + // count twice as slowly and outstay its welcome. + modelBuilder.Entity(entity => + { + entity.HasIndex(h => new { h.UserId, h.HintKey }).IsUnique(); + }); + modelBuilder.Entity(entity => { entity.Property(e => e.DisplayName).HasMaxLength(200); diff --git a/src/NetworkOptimizer.Storage/Models/Identity/UserUiHint.cs b/src/NetworkOptimizer.Storage/Models/Identity/UserUiHint.cs new file mode 100644 index 0000000000..6d624ecbe2 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Models/Identity/UserUiHint.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace NetworkOptimizer.Storage.Models.Identity; + +/// +/// How many times one user has been shown a particular teaching hint, so a hint that exists only +/// to reveal a non-obvious gesture can stop repeating once they plainly know it. +/// +/// Per USER rather than per site or per install: what someone has learned travels with them across +/// every site they can see, and one operator learning a gesture says nothing about their +/// colleagues. Site-scoped state lives in SystemSettings and install-wide state in AdminSettings; +/// neither can answer "has this person seen it". +/// +/// +/// The pattern is deliberately general - key it, count it, stop at the threshold - so the next +/// hint that wears out its welcome does not need its own table or its own flag. Nothing here is +/// security-relevant: losing a row costs the user one extra tooltip. +/// +/// +public class UserUiHint +{ + public int Id { get; set; } + + /// The Identity user this count belongs to (). + [Required] + [MaxLength(450)] + public string UserId { get; set; } = string.Empty; + + /// + /// Stable identifier for the hint, e.g. wan-filter-compare. Chosen by the caller and + /// never parsed - renaming one simply starts its count over, which is the harmless outcome. + /// + [Required] + [MaxLength(100)] + public string HintKey { get; set; } = string.Empty; + + /// How many times the hint has been shown to this user. + public int TimesShown { get; set; } + + /// When the count last moved, for diagnosing a hint that will not settle. + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs b/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs index 7a0767c10f..9a49596d63 100644 --- a/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs +++ b/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs @@ -37,6 +37,7 @@ public NetworkOptimizerDbContext(DbContextOptions opt public DbSet FloorPlanImages { get; set; } public DbSet ClientSignalLogs { get; set; } public DbSet AlertRules { get; set; } + public DbSet SeededAlertRules { get; set; } public DbSet DeliveryChannels { get; set; } public DbSet AlertHistory { get; set; } public DbSet AlertIncidents { get; set; } @@ -427,6 +428,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.EscalationSeverity).HasConversion(); }); + // SeededAlertRule configuration + modelBuilder.Entity(entity => + { + entity.ToTable("SeededAlertRules"); + entity.Property(e => e.EventTypePattern).HasMaxLength(200); + entity.HasIndex(e => e.EventTypePattern).IsUnique(); + }); + // DeliveryChannel configuration modelBuilder.Entity(entity => { diff --git a/src/NetworkOptimizer.Storage/Models/WanContext.cs b/src/NetworkOptimizer.Storage/Models/WanContext.cs index fce4bdddf1..e507ceb821 100644 --- a/src/NetworkOptimizer.Storage/Models/WanContext.cs +++ b/src/NetworkOptimizer.Storage/Models/WanContext.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; namespace NetworkOptimizer.Storage.Models; @@ -9,9 +10,9 @@ namespace NetworkOptimizer.Storage.Models; /// existing installs unchanged. Additional contexts describe a secondary WAN: /// probes for targets in the context either bind to /// locally (the gateway policy-routes that source IP out the WAN) or run on the -/// assigned probe-only agent. Lives in each site's own database; the context -/// name becomes the `wan` tag on latency points, emitted only for non-default -/// contexts so the Influx schema stays additive-only. +/// assigned probe-only agent. Lives in each site's own database; +/// becomes the `wan` tag on latency points, emitted +/// only for non-default contexts so the Influx schema stays additive-only. ///
public class WanContext { @@ -41,5 +42,39 @@ public class WanContext /// public int? AgentId { get; set; } + /// + /// Exact interface the assigned agent binds its probes to (eth8, + /// ppp0), for an agent running on the gateway itself: the probe + /// leaves by that WAN's own data path rather than by whatever the routing + /// table prefers. Only meaningful alongside - the + /// server does not sit on the gateway, so an interface name it cannot see + /// binds nothing. Null for source-IP contexts and for agents that probe on + /// their own default route. + /// + [MaxLength(50)] + public string? InterfaceName { get; set; } + + /// + /// The UniFi WAN key this context measures (wan, wan2), picked + /// from the site's real WANs rather than typed. It is what says where the + /// context's data belongs: the Influx wan tag, the ISP Health report + /// it associates with, and the scope of its upstream discovery. Required on + /// every new context regardless of bind mechanism; nullable only because + /// contexts created before this column existed have no value to backfill + /// from. + /// + [MaxLength(50)] + public string? WanInterface { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Value written to the Influx wan tag for this context's points: the + /// stable UniFi WAN key when the context has one, falling back to the + /// display name for contexts predating . The key + /// survives a rename, which the name does not - a renamed context used to + /// orphan its own history under the old tag value. + /// + [NotMapped] + public string InfluxWanTag => string.IsNullOrEmpty(WanInterface) ? Name : WanInterface!; } diff --git a/src/NetworkOptimizer.Storage/Models/WanProfile.cs b/src/NetworkOptimizer.Storage/Models/WanProfile.cs index 068dc80d8d..dee976e7de 100644 --- a/src/NetworkOptimizer.Storage/Models/WanProfile.cs +++ b/src/NetworkOptimizer.Storage/Models/WanProfile.cs @@ -64,6 +64,32 @@ public class WanProfile /// Expected upload in Mbps, null when the console reported none. public double? UploadMbps { get; set; } + /// + /// Whether this WAN held the primary role when the console last said so. + /// + /// Primary is a ROLE - failover priority and load-balance weight decide it, and any group can + /// hold it - so it cannot be read off the name. Everything that needs the answer away from a + /// console reads it here: the probe-push path (which has no console call available at all) and + /// the offline fallbacks that would otherwise guess at the conventional first group and be + /// wrong on a WAN2-primary site. Exactly one row should carry true; the writer clears the + /// others as it sets one. + /// + /// + /// Null means no connected compute has ever resolved the role for this site - readers must + /// treat that as "unknown" and fall back to their documented guess, not as "not primary". + /// + /// + public bool? IsPrimary { get; set; } + + /// + /// Whether the site load balances across WANs rather than running one primary with failover. + /// Recorded per WAN because it is read per WAN, and because it changes what unpinned probing + /// means: on a failover-only site every unpinned probe leaves by the primary, so it measures + /// the primary honestly; under load balancing it is spread across WANs and attributable to + /// none of them. Null when no connected compute has said. + /// + public bool? SiteLoadBalances { get; set; } + /// When the console last confirmed these figures. public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/src/NetworkOptimizer.Storage/Repositories/AlertRepository.cs b/src/NetworkOptimizer.Storage/Repositories/AlertRepository.cs index 4db9318527..51e5c15b95 100644 --- a/src/NetworkOptimizer.Storage/Repositories/AlertRepository.cs +++ b/src/NetworkOptimizer.Storage/Repositories/AlertRepository.cs @@ -334,6 +334,41 @@ public async Task> GetAlertHistoryAsync( } } + public async Task<(List Items, int Total)> GetAlertHistoryPageAsync( + int skip, + int take, + string? source = null, + AlertSeverity? minSeverity = null, + CancellationToken cancellationToken = default) + { + try + { + var query = _context.AlertHistory.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrEmpty(source)) + query = query.Where(a => a.Source == source); + + if (minSeverity.HasValue) + query = query.Where(a => a.Severity >= minSeverity.Value); + + // Counted against the same filters, before paging: the page controls need to know how + // much there is, not how much this page holds. + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderByDescending(a => a.TriggeredAt) + .Skip(Math.Max(0, skip)) + .Take(Math.Max(1, take)) + .ToListAsync(cancellationToken); + + return (items, total); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get a page of alert history"); + throw; + } + } + public async Task GetAlertAsync(int id, CancellationToken cancellationToken = default) { try @@ -399,6 +434,148 @@ public async Task> GetAlertsByIncidentIdAsync(int incide } } + public async Task> GetIncidentsByIdsAsync( + IReadOnlyCollection incidentIds, CancellationToken cancellationToken = default) + { + if (incidentIds.Count == 0) return []; + var ids = incidentIds.ToList(); + return await _context.AlertIncidents + .Where(i => ids.Contains(i.Id)) + .ToListAsync(cancellationToken); + } + + public async Task> GetAlertsByIncidentIdsAsync( + IReadOnlyCollection incidentIds, CancellationToken cancellationToken = default) + { + if (incidentIds.Count == 0) return []; + var ids = incidentIds.ToList(); + return await _context.AlertHistory + .AsNoTracking() + .Where(a => a.IncidentId != null && ids.Contains(a.IncidentId.Value)) + .ToListAsync(cancellationToken); + } + + public async Task UpdateIncidentsAsync( + IReadOnlyCollection incidents, CancellationToken cancellationToken = default) + { + if (incidents.Count == 0) return; + try + { + // Callers pass either the tracked rows from GetIncidentsByIdsAsync or detached copies + // the page is holding, so each is attached unless this very instance is already + // tracked. The tracked set is read once: scanning it per incident is the quadratic + // walk these batch methods exist to avoid. + var tracked = _context.ChangeTracker.Entries() + .GroupBy(e => e.Entity.Id) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var incident in incidents) + { + if (tracked.TryGetValue(incident.Id, out var entry)) + { + if (ReferenceEquals(entry.Entity, incident)) continue; + entry.State = EntityState.Detached; + } + _context.AlertIncidents.Update(incident); + } + + // One commit for the lot rather than one per incident. + await _context.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update {Count} incident(s)", incidents.Count); + throw; + } + } + + public async Task SetAlertStatusAsync( + IReadOnlyCollection alertIds, + AlertStatus status, + DateTime timestamp, + CancellationToken cancellationToken = default) + { + if (alertIds.Count == 0) return 0; + + try + { + var ids = alertIds.ToList(); + var rows = await _context.AlertHistory + .Where(a => ids.Contains(a.Id)) + .ToListAsync(cancellationToken); + + foreach (var row in rows) + { + row.Status = status; + if (status == AlertStatus.Acknowledged) row.AcknowledgedAt = timestamp; + else if (status == AlertStatus.Resolved) row.ResolvedAt = timestamp; + } + + // One commit for the lot. Per-alert saves meant a SQLite transaction each, and the + // tracked graph grew every iteration, so a few hundred alerts turned a button press + // into hundreds of fsyncs and a quadratic walk of the change tracker. + await _context.SaveChangesAsync(cancellationToken); + _logger.LogInformation("Set {Count} alert(s) to {Status}", rows.Count, status); + return rows.Count; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to set {Count} alert(s) to {Status}", alertIds.Count, status); + throw; + } + } + + public Task> ResolveActiveAlertsAnyDeviceAsync( + IReadOnlyCollection eventTypes, + CancellationToken cancellationToken = default) => + ResolveActiveAlertsCoreAsync(eventTypes, "", anyDevice: true, cancellationToken); + + public Task> ResolveActiveAlertsAsync( + IReadOnlyCollection eventTypes, + string deviceId, + CancellationToken cancellationToken = default) => + ResolveActiveAlertsCoreAsync(eventTypes, deviceId, anyDevice: false, cancellationToken); + + private async Task> ResolveActiveAlertsCoreAsync( + IReadOnlyCollection eventTypes, + string deviceId, + bool anyDevice, + CancellationToken cancellationToken) + { + if (eventTypes.Count == 0 || (!anyDevice && string.IsNullOrEmpty(deviceId))) + return []; + + try + { + // Tracked on purpose: these rows are read to be written back in the same call. + var types = eventTypes.ToList(); + var open = await _context.AlertHistory + .Where(a => a.Status == AlertStatus.Active + && (anyDevice || a.DeviceId == deviceId) + && types.Contains(a.EventType)) + .ToListAsync(cancellationToken); + + if (open.Count == 0) + return []; + + var resolvedAt = DateTime.UtcNow; + foreach (var alert in open) + { + alert.Status = AlertStatus.Resolved; + alert.ResolvedAt = resolvedAt; + } + + await _context.SaveChangesAsync(cancellationToken); + _logger.LogInformation("Resolved {Count} active alert(s) for {DeviceId}", open.Count, deviceId); + return open; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to resolve active alerts for {DeviceId}", deviceId); + throw; + } + } + #endregion #region Alert Incidents @@ -466,6 +643,25 @@ public async Task> GetIncidentsAsync(int limit = 50, Cancell } } + public async Task> GetUnresolvedIncidentsAsync( + int limit = 50, CancellationToken cancellationToken = default) + { + try + { + return await _context.AlertIncidents + .AsNoTracking() + .Where(i => i.Status != AlertStatus.Resolved) + .OrderByDescending(i => i.LastTriggeredAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get unresolved alert incidents"); + throw; + } + } + public async Task GetIncidentAsync(int id, CancellationToken cancellationToken = default) { try diff --git a/src/NetworkOptimizer.Storage/Services/MonitoringInfluxClient.cs b/src/NetworkOptimizer.Storage/Services/MonitoringInfluxClient.cs index bdb1af5ec8..9913ea84af 100644 --- a/src/NetworkOptimizer.Storage/Services/MonitoringInfluxClient.cs +++ b/src/NetworkOptimizer.Storage/Services/MonitoringInfluxClient.cs @@ -534,7 +534,9 @@ public Task WriteLatencyAsync( if (rttMaxMs.HasValue) point = point.Field("rtt_max_ms", rttMaxMs.Value); if (jitterMs.HasValue) point = point.Field("jitter_ms", jitterMs.Value); // Multi-WAN context tag, emitted only for non-default contexts so the - // schema stays additive-only: single-WAN installs never see it. + // schema stays additive-only: single-WAN installs never see it. The value + // is the context's UniFi WAN key where it has one (WanContext.InfluxWanTag), + // so renaming a context does not orphan its own history under the old tag. if (!string.IsNullOrEmpty(wanContext)) point = point.Tag("wan", wanContext); Enqueue(point, longterm: false); @@ -1522,6 +1524,21 @@ public int GetHashCode((string DeviceMac, string IfName) obj) => HashCode.Combine(obj.DeviceMac.ToLowerInvariant(), obj.IfName); } + /// + /// Gateway WAN throughput from the SNMP interface counters, for the interface(s) named. + /// + /// CONTRACT: passing more than one interface SUMS them into a single combined series + /// (grouped per (_time, _field)). That is correct for exactly ONE caller class - the + /// all-WAN usage fingerprint, which asks "was the user doing anything on any WAN" - and + /// wrong for every load/utilization computation, because a summed multi-WAN series divided + /// by one WAN's plan speeds silently understates or overstates load (and ISP Health's + /// packet-loss ceiling scales with load QUADRATICALLY, so the damage compounds). Per-WAN + /// load callers must resolve the one counter interface of the WAN they are pairing with + /// plan speeds and pass exactly that. Callers that intend the sum must say so via + /// ; a multi-interface call without it asserts in + /// debug builds and logs a warning in release (behavior is unchanged so an existing + /// caller cannot break, but the mispairing is named at the choke point). + /// public async Task> QueryGatewayWanRatesAsync( string deviceMac, IReadOnlyList wanIfNames, @@ -1529,10 +1546,22 @@ public async Task> QueryGatewayWanRatesAsync( DateTime to, TimeSpan? aggregateWindow = null, int sampleIntervalSeconds = 5, + bool sumAcrossInterfaces = false, CancellationToken ct = default) { if (!IsConfigured) await ReconfigureAsync(ct); if (!IsConfigured || wanIfNames.Count == 0) return Array.Empty(); + if (wanIfNames.Count > 1 && !sumAcrossInterfaces) + { + System.Diagnostics.Debug.Assert(false, + "QueryGatewayWanRatesAsync sums multiple interfaces into one series; that is only " + + "valid for the all-WAN usage fingerprint. Pass sumAcrossInterfaces: true if the sum " + + "is intended, or resolve the single counter interface of the WAN being measured."); + _logger.LogWarning( + "QueryGatewayWanRatesAsync called with {Count} interfaces without sumAcrossInterfaces; " + + "the result is a summed multi-interface series ({IfNames})", + wanIfNames.Count, string.Join(",", wanIfNames)); + } var window = aggregateWindow ?? PickAggregateWindow(to - from, sampleIntervalSeconds); var mac = NormalizeMac(deviceMac); var ifFilter = string.Join(" or ", wanIfNames.Select(n => @@ -1752,11 +1781,13 @@ public async Task> QueryLatencyByTargetTypeRawAsync( } /// Time-series of RTT and loss for multiple monitoring targets, keyed by target_id. + /// Which WAN's points count; null reads every WAN, as it always did. public async Task>> QueryLatencyByTargetTypeAsync( MonitoringTargetType targetType, DateTime from, DateTime to, TimeSpan? aggregateWindow = null, + LatencyWanScope? wanScope = null, CancellationToken ct = default) { if (!IsConfigured) await ReconfigureAsync(ct); @@ -1772,7 +1803,7 @@ public async Task>> QueryLatencyByTargetTy from(bucket: ""{_bucket}"") |> range(start: {ToFluxInstant(from)}, stop: {ToFluxInstant(to)}) |> filter(fn: (r) => r._measurement == ""latency"") - |> filter(fn: (r) => {typeFilter}) + |> filter(fn: (r) => {typeFilter}){BuildWanScopeFilter(wanScope)} |> filter(fn: (r) => r._field == ""rtt_avg_ms"" or r._field == ""loss_percent"") |> aggregateWindow(every: {ToFluxDuration(window)}, fn: mean, createEmpty: false) |> pivot(rowKey:[""_time""], columnKey: [""_field""], valueColumn: ""_value"") @@ -1801,16 +1832,71 @@ public async Task>> QueryLatencyByTargetTy return results; } + /// + /// Which WAN's latency series a group-level (target_type) read should return, expressed + /// against the Influx wan tag. The tag is ABSENT on every point the primary path + /// writes (single-WAN installs never emit it - additive-only schema), and carries + /// WanContext.InfluxWanTag (the UniFi wan key, e.g. "wan2") on points probed + /// through a WAN context. Null scope = no wan filter, today's behavior for every + /// non-ISP-Health caller. + /// + /// Include points with NO wan tag (the primary path's points). + /// Tag values to include (a scoped WAN's key, plus any context display + /// names that tagged its points before the stable-key tagging landed). + public sealed record LatencyWanScope(bool IncludeUntagged, IReadOnlyList WanTags) + { + /// Scope for the primary WAN: untagged points, plus any contexts bound to it. + public static LatencyWanScope Primary(IReadOnlyList? primaryContextTags = null) => + new(true, primaryContextTags ?? Array.Empty()); + + /// Scope for a non-primary WAN: only points tagged with its wan-key/context tags. + public static LatencyWanScope ForWan(IReadOnlyList wanTags) => new(false, wanTags); + } + + /// + /// The Flux filter stage for a , or "" for no filter. + /// + /// Filter shape is deliberate - keep it a plain predicate the storage engine can push down: + /// - Primary with no contexts: not exists r.wan. Tag ABSENCE, not empty-string - a + /// series that never wrote the tag has no "wan" column at all, so r.wan == "" would + /// match nothing (the comparison against the missing column is null and the row is dropped). + /// - Non-primary: plain r.wan == "..." equality chain (indexed tag equality; series + /// without the tag simply never match). No regex, no client-side post-filtering. + /// - Primary with contexts bound to the primary WAN (rare): the OR of both shapes, so a + /// primary probed both untagged (server default route) and through a primary context keeps + /// all its points. + /// Do not "simplify" the absence check into an equality against "" - it changes matches, and + /// the mixed OR shape is only emitted when primary-WAN contexts actually exist. + /// + internal static string BuildWanScopeFilter(LatencyWanScope? scope) + { + if (scope == null) return string.Empty; + var clauses = new List(); + if (scope.IncludeUntagged) clauses.Add("not exists r.wan"); + clauses.AddRange(scope.WanTags + .Where(t => !string.IsNullOrEmpty(t)) + .Distinct(StringComparer.Ordinal) + .Select(t => $@"r.wan == ""{SanitizeFluxString(t)}""")); + if (clauses.Count == 0) + // A tags-only scope with no usable tag values can match nothing; emit an + // always-false predicate rather than silently returning every WAN's data. + return "\n |> filter(fn: (r) => exists r.wan and not exists r.wan)"; + return $"\n |> filter(fn: (r) => {string.Join(" or ", clauses)})"; + } + /// /// Like QueryLatencyByTargetTypeAsync but also pivots max RTT and jitter, which the /// ISP Health scorer and congestion/step detectors need. Kept separate so existing - /// chart callers keep the leaner LatencyPoint shape. + /// chart callers keep the leaner LatencyPoint shape. + /// restricts the read to one WAN's series via the wan tag (see + /// ); null keeps today's unscoped read. /// public async Task>> QueryLatencyDetailByTargetTypeAsync( MonitoringTargetType targetType, DateTime from, DateTime to, TimeSpan? aggregateWindow = null, + LatencyWanScope? wanScope = null, CancellationToken ct = default) { if (!IsConfigured) await ReconfigureAsync(ct); @@ -1825,7 +1911,7 @@ public async Task>> QueryLatencyDeta from(bucket: ""{_bucket}"") |> range(start: {ToFluxInstant(from)}, stop: {ToFluxInstant(to)}) |> filter(fn: (r) => r._measurement == ""latency"") - |> filter(fn: (r) => {typeFilter}) + |> filter(fn: (r) => {typeFilter}){BuildWanScopeFilter(wanScope)} |> filter(fn: (r) => r._field == ""rtt_avg_ms"" or r._field == ""rtt_max_ms"" or r._field == ""jitter_ms"" or r._field == ""loss_percent"") |> aggregateWindow(every: {ToFluxDuration(window)}, fn: mean, createEmpty: false) |> pivot(rowKey:[""_time""], columnKey: [""_field""], valueColumn: ""_value"") @@ -1888,11 +1974,18 @@ public record LatencySeriesPoint /// intervals), then averages within each target_type, then averages the two category /// means - the same weighting as /api/monitoring/live-stats, so the WAN live chart /// doesn't jump when its buffer swaps between history and live samples. + /// + /// Which WAN's points count. Filtering by target id alone is not enough: a host reachable from + /// two WANs is probed under each, and a row that has changed context keeps its older points + /// under the tag they were written with - so one id can hold more than one WAN's readings, and + /// an unscoped read draws another WAN's loss on this one's chart. + /// public async Task> QueryMeanIspTransitLatencyAsync( DateTime from, DateTime to, IReadOnlyList? enabledTargetIds = null, TimeSpan? aggregateWindow = null, + LatencyWanScope? wanScope = null, CancellationToken ct = default) { if (!IsConfigured) await ReconfigureAsync(ct); @@ -1917,7 +2010,7 @@ public async Task> QueryMeanIspTransitLatencyAsync( base = from(bucket: ""{_bucket}"") |> range(start: {ToFluxInstant(queryFrom)}, stop: {ToFluxInstant(to)}) |> filter(fn: (r) => r._measurement == ""latency"") - |> filter(fn: (r) => r.target_type == ""accessisp"" or r.target_type == ""transit""){targetFilter} + |> filter(fn: (r) => r.target_type == ""accessisp"" or r.target_type == ""transit""){targetFilter}{BuildWanScopeFilter(wanScope)} rtt = base |> filter(fn: (r) => r._field == ""rtt_avg_ms"") @@ -2667,7 +2760,7 @@ private static string ToFluxDuration(TimeSpan window) => $"{Math.Max(1, (long)Math.Round(window.TotalSeconds))}s"; private static string SanitizeFluxString(string value) => - value.Replace("\"", "").Replace("\\", "").Replace(")", "").Replace("|>", ""); + value.Replace("\"", "").Replace("\\", "").Replace(")", "").Replace("|>", "").Replace("${", ""); private static DateTime ToUtc(DateTime t) => t.Kind == DateTimeKind.Utc ? t : DateTime.SpecifyKind(t, DateTimeKind.Utc); diff --git a/src/NetworkOptimizer.UniFi/GatewayWanHelper.cs b/src/NetworkOptimizer.UniFi/GatewayWanHelper.cs index bbe7b7770b..856bb33f74 100644 --- a/src/NetworkOptimizer.UniFi/GatewayWanHelper.cs +++ b/src/NetworkOptimizer.UniFi/GatewayWanHelper.cs @@ -13,6 +13,61 @@ namespace NetworkOptimizer.UniFi; /// public static class GatewayWanHelper { + /// + /// UniFi's interface key for the first WAN group, and the conventional stand-in for "the WAN" + /// on a site that has only ever had one. + /// + /// This is UniFi's key space, not ours - it belongs here with the rest of the console's + /// conventions. Our own WAN-keyed columns (MonitoringTarget.WanInterface, + /// WanDiscoveryContext.WanInterface, WanContext.WanInterface) deliberately STORE that key + /// rather than inventing a parallel one, which is why storage-side fallbacks may reference + /// this constant. Normalize anything read from storage through + /// first: rows written before that normalization + /// existed can still say "wan1". + /// + /// + /// NOT a synonym for the primary WAN. Group names are arbitrary in UniFi Network and any + /// group can hold the primary role, so this is only ever a last-resort guess for when the + /// console cannot say which one does - it is wrong on a site whose primary is WAN2. Ask + /// UniFiConnectionService.ResolvePrimaryWanNetwork first, and where this value is used as a + /// fallback, say in a comment that it is a guess and what it costs when it misses. + /// + /// + public const string DefaultWanKey = "wan"; + + /// + /// Splits a label produced by back into the connection's name and + /// its WAN token ("Acme Fiber WAN2" -> "Acme Fiber", "WAN2"), so a caller can style the two + /// differently. Name is null when the label carries no name to separate. + /// + /// Exact rather than heuristic for the labels this codebase builds for WAN pickers, which pass + /// no interface or port and therefore have no suffix. A label with a suffix, or one that does + /// not end in its own WAN token, comes back whole as the name so nothing is silently trimmed. + /// + /// + public static (string? Name, string? WanToken) SplitWanLabel(string? label, int wanIndex) + { + if (string.IsNullOrWhiteSpace(label)) return (null, null); + var token = wanIndex >= 1 ? $"WAN{wanIndex}" : null; + if (token == null || !label.EndsWith(token, StringComparison.OrdinalIgnoreCase)) + return (label.Trim(), null); + var name = label[..^token.Length].Trim(); + return (string.IsNullOrEmpty(name) ? null : name, token); + } + + /// + /// A WAN label for running prose, with the WAN token in parentheses after the connection's + /// name ("Acme Fiber (WAN2)"). The pill form runs them together because the pill is a label; + /// a sentence needs the qualifier set apart or it reads as part of the name. Falls back to + /// whatever there is when a label carries no name or no token. + /// + public static string FormatWanLabelInProse(string? label, int wanIndex) + { + var (name, token) = SplitWanLabel(label, wanIndex); + if (string.IsNullOrEmpty(name)) return token ?? label ?? ""; + return string.IsNullOrEmpty(token) ? name! : $"{name} ({token})"; + } + /// /// UniFi network-group convention for a 1-based WAN index: wan1 → "WAN", /// wanN → "WANn". @@ -36,6 +91,34 @@ public static string WanInterfaceKeyFromKey(string wanKey) ? "wan" : wanKey.ToLowerInvariant(); + /// + /// 1-based WAN index from an interface key or wan object key ("wan" and "wan1" → 1, + /// "wan2" → 2). Zero for anything that is not a wan key, which + /// reads as "no WAN label". + /// + /// + /// A WAN token cut down to its index for tight layouts ("WAN2" -> "2"), where the column is + /// narrow enough that repeating "WAN" on every row costs more than it says. Anything that is + /// not a token - a connection name, from a label that carried none - comes back untouched. + /// + public static string ShortWanToken(string? label) + { + if (string.IsNullOrWhiteSpace(label)) return label ?? ""; + var index = WanIndexFromKey(label); + return index >= 1 ? index.ToString(System.Globalization.CultureInfo.InvariantCulture) : label; + } + + public static int WanIndexFromKey(string? wanKey) + { + if (string.IsNullOrWhiteSpace(wanKey)) return 0; + var trimmed = wanKey.Trim(); + if (string.Equals(trimmed, "wan", StringComparison.OrdinalIgnoreCase)) return 1; + return trimmed.StartsWith("wan", StringComparison.OrdinalIgnoreCase) + && int.TryParse(trimmed[3..], out var index) && index >= 1 + ? index + : 0; + } + /// /// Enumerates a gateway's wan1..wan6 objects from raw device JSON as typed /// values (Key set to the source property), diff --git a/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs b/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs index bc90946528..728590fea6 100644 --- a/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs +++ b/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs @@ -258,6 +258,13 @@ public class UniFiDeviceResponse [JsonPropertyName("config_network")] public ConfigNetwork? ConfigNetwork { get; set; } + /// + /// The gateway's address on its LAN side. Present on gateways; absent on everything else, and + /// not the same as , which is the WAN address on a gateway. + /// + [JsonPropertyName("lan_ip")] + public string? LanIp { get; set; } + /// /// LAN network configuration - only present on devices acting as the network gateway. /// UDM-family devices (including UX Express) won't have this when operating as APs. diff --git a/src/NetworkOptimizer.UniFi/UniFiApiClient.cs b/src/NetworkOptimizer.UniFi/UniFiApiClient.cs index 6bd17e6764..7ae1f0b04d 100644 --- a/src/NetworkOptimizer.UniFi/UniFiApiClient.cs +++ b/src/NetworkOptimizer.UniFi/UniFiApiClient.cs @@ -1,6 +1,6 @@ using System.Net; -using System.Net.Sockets; using System.Net.Http.Json; +using System.Net.Sockets; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -2182,6 +2182,19 @@ public async Task LogoutAsync(CancellationToken cancellationToken = defaul var body = await response.Content.ReadAsStringAsync(cancellationToken); + // A console mid-reboot or mid-firmware-upgrade serves its web UI - or a proxy's holding + // page - to every request, including API ones. Parsing that raised the JSON reader's + // own words at the user ("'<' is an invalid start of a value. LineNumber: 0"), which + // describes our parser rather than their console and reads like a bug in us. The + // condition is temporary and resolves with no action, so say that. + if (LooksLikeHtml(body)) + { + _logger.LogInformation( + "Site validation got a web page instead of API data - console likely restarting or upgrading"); + return (false, "The UniFi Console returned a web page instead of API data, which usually " + + "means it is restarting or upgrading. This clears on its own once it is back."); + } + // Parse the response to check for API-level errors using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("meta", out var meta)) @@ -2210,6 +2223,14 @@ public async Task LogoutAsync(CancellationToken cancellationToken = defaul _logger.LogDebug("Site '{Site}' validated successfully", _site); return (true, null); } + catch (JsonException ex) + { + // Same situation reached by a shape LooksLikeHtml does not catch - a redirect stub, a + // captive portal, a truncated body. The reader's message is never useful to a user. + _logger.LogInformation(ex, "Site validation could not parse the console's response as JSON"); + return (false, "The UniFi Console did not return valid API data, which usually means it is " + + "restarting or upgrading. This clears on its own once it is back."); + } catch (Exception ex) { _logger.LogError(ex, "Exception during site validation"); @@ -2217,6 +2238,18 @@ public async Task LogoutAsync(CancellationToken cancellationToken = defaul } } + /// + /// Whether a response body is a web page rather than API data. A UniFi Console serves its UI + /// to every request while it reboots or applies a firmware update, so this is the ordinary + /// shape of "come back in a minute", not a malformed reply. + /// + private static bool LooksLikeHtml(string? body) + { + var trimmed = body?.TrimStart(); + return !string.IsNullOrEmpty(trimmed) + && (trimmed[0] == '<' || trimmed.StartsWith(" diff --git a/src/NetworkOptimizer.UniFi/UniFiDiscovery.cs b/src/NetworkOptimizer.UniFi/UniFiDiscovery.cs index 95d6dd0160..8572c0deee 100644 --- a/src/NetworkOptimizer.UniFi/UniFiDiscovery.cs +++ b/src/NetworkOptimizer.UniFi/UniFiDiscovery.cs @@ -819,11 +819,14 @@ public class DiscoveredDevice public int PortCount { get; set; } /// - /// Counter-bearing interface of the PRIMARY WAN only (single entry, by - /// design - do not add secondary/cellular WANs). Feeds the WAN Live View - /// and Monitoring overview throughput, which sit alongside ISP / transit - /// latency cards measured for that one connection; mixing other WANs into - /// the throughput would disagree with them. See + /// Counter-bearing interface of the PRIMARY WAN only (single entry, by design). Feeds the + /// live WAN throughput tiles, which sit alongside ISP / transit latency measured over that + /// one connection, and serves as ISP Health's last-resort counter fallback. Multi-WAN + /// surfaces do NOT widen this list: per-WAN throughput resolves each WAN's own counter + /// interface (UniFiConnectionService.GetWanInterfacesForGroupAsync / the remembered + /// WanProfile row) behind the multi-WAN UI gate, and summing WANs into one series is + /// reserved for the usage fingerprint alone (see + /// MonitoringInfluxClient.QueryGatewayWanRatesAsync's contract). See /// UniFiDiscovery.GetWanInterfaceNames for the selection rules. /// public List WanInterfaceNames { get; set; } = new(); diff --git a/src/NetworkOptimizer.Web/Components/Pages/Alerts.razor b/src/NetworkOptimizer.Web/Components/Pages/Alerts.razor index 02ca4c3bab..55c5739330 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Alerts.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Alerts.razor @@ -26,6 +26,7 @@ @inject UniFiConnectionService ConnectionService @inject IGatewaySshService GatewaySshService @inject AgentOnGatewayDetector OnGatewayDetector +@inject NetworkOptimizer.Web.Services.Monitoring.ProbeExecutorFactory ProbeExecutors @inject WanDataUsageService DataUsageService @inject IJSRuntime JS @inject PullToRefreshState PtrState @@ -204,7 +205,7 @@ } - @{ var (wanBaseName, wanDetail) = SplitTaskName(task.Name); } + @{ var (wanBaseName, wanDetail) = SplitTaskName(task.Name); wanDetail = LabelServerVantage(wanDetail); } @wanBaseName @if (wanDetail != null) { @@ -244,7 +245,7 @@
@{ var wanConfig = ParseTargetConfig(task.TargetConfig); } - @(GetConfigValue(wanConfig, "testType") == "server" ? "Server" : "Gateway") + @(GetConfigValue(wanConfig, "testType") == "server" ? ServerVantageLabel : "Gateway") @if (GetConfigValue(wanConfig, "wanName") is string wanName && !string.IsNullOrEmpty(wanName)) { @wanName @@ -712,9 +713,19 @@ @* ========== Active Alerts Tab ========== *@ @if (_activeTab == "active") { + @* Outside the empty/non-empty split below: held alerts must always be reachable, and + inside the else they were unreachable exactly when the list was empty. *@ + @if (PendingAlertCount > 0) + { + + } + @* The guided tour anchors on whichever of these two actually renders - they are mutually + exclusive, so the selector always finds exactly one and no wrapper element is needed. *@ @if (_unresolvedAlerts.Count == 0) { -
+

All Clear

No active alerts at this time.

@@ -729,18 +740,24 @@
@if (_canOperate) { - + } @if (_canOperate) { - + }
-
+
@foreach (var alert in _unresolvedAlerts.Where(a => a.Status == AlertStatus.Active)) { -
+
@((MarkupString)GetSeverityIcon(alert.Severity))
@@ -774,7 +791,7 @@ } @if (_canOperate) { - } @@ -797,14 +814,17 @@
@if (_canOperate) { - + }
-
+
@foreach (var alert in _unresolvedAlerts.Where(a => a.Status == AlertStatus.Acknowledged)) { -
+
@((MarkupString)GetSeverityIcon(alert.Severity))
@@ -859,7 +879,7 @@
- @@ -871,7 +891,7 @@
- @@ -936,6 +956,18 @@
+ @if (_historyTotal > HistoryPageSize) + { + + }
} } @@ -943,7 +975,7 @@ @* ========== Rules Tab ========== *@ @if (_activeTab == "rules") { -
+

Alert Rules

@if (_canConfigure) @@ -1116,6 +1148,13 @@ @* ========== Incidents Tab ========== *@ @if (_activeTab == "incidents") { + @* Outside the empty/non-empty split, for the same reason as the alerts pill. *@ + @if (PendingIncidentCount > 0) + { + + } @if (!_incidents.Any(i => i.Status != AlertStatus.Resolved)) {
@@ -1132,18 +1171,24 @@ { @if (_canOperate) { - + } } @if (_canOperate) { - + }
@foreach (var incident in _incidents.Where(i => i.Status != AlertStatus.Resolved)) { -
+
@@ -1710,6 +1755,9 @@
monitoring.target_offline Probe target went offline
monitoring.target_recovered Probe target came back online
monitoring.target_sustained_loss Sustained packet loss detected
+
monitoring.wan_outage Internet down on a WAN - one alert per outage instead of one per target
+
monitoring.wan_outage_partial Partial internet outage on a WAN - some destinations unreachable while the connection still passes traffic
+
monitoring.wan_recovered A WAN's internet connection is back
monitoring.sfp_rx_power SFP/PON RX power below threshold
monitoring.sfp_tx_power SFP/PON TX power above threshold
monitoring.sfp_temperature SFP temperature above threshold
@@ -1756,6 +1804,19 @@
cellular.* All cellular events
+
+

Starlink

+
+
starlink.dish_alert Dish reporting a fault or out of service
+
starlink.obstructed Sky view blocked or signal persistently low
+
starlink.alignment_drift Dish pointing away from where it normally sits
+
starlink.eth_speed_degraded Ethernet link negotiated below its usual speed
+
starlink.outage_burst Dish outage seconds piling up over a day
+
starlink.service_restricted Service became rate limited
+
starlink.recovered A dish condition cleared
+
starlink.* All Starlink events
+
+

WAN Data Usage

@@ -1830,6 +1891,55 @@ private int _newWanFrequency = 1440; private int _newLanFrequency = 1440; private bool _creatingSchedule; + + /// + /// A bulk acknowledge or resolve is running. All five bulk buttons share it: they act on the + /// same lists, so a second press while one is in flight would work from a set that is already + /// being written. + /// + private bool _bulkBusy; + + /// + /// Alerts and incidents the poll has found but has NOT put on screen. The list only changes + /// when the user says so: entries arrive newest-first, so folding them in unprompted pushes + /// everything down - past the row someone was reading, or under the button they were about to + /// press. Held here instead, counted in a pill they can take when they are ready. + /// + private List? _pendingAlerts; + private List? _pendingIncidents; + + /// + /// How many held entries are new to the list on screen, which is what the pill offers. Counted + /// against a set rather than by scanning the visible list per held entry: this is read on every + /// render, and the pairwise form is quadratic in the size of a list that reaches the hundreds. + /// + private int PendingAlertCount + { + get + { + if (_pendingAlerts is null) return 0; + var shown = _unresolvedAlerts.Select(a => a.Id).ToHashSet(); + return _pendingAlerts.Count(p => !shown.Contains(p.Id)); + } + } + + private int PendingIncidentCount + { + get + { + if (_pendingIncidents is null) return 0; + var shown = _incidents.Select(i => i.Id).ToHashSet(); + return _pendingIncidents.Count(p => !shown.Contains(p.Id)); + } + } + + /// + /// Whether a refresh may redraw the list without interrupting anything: no bulk action in + /// flight, and no modal or inline edit open on top of it. + /// + private bool SafeToRedraw => + !_bulkBusy && !_showEventTypeKey + && !_editingWanScheduleId.HasValue && !_editingLanScheduleId.HasValue; private int? _editingWanScheduleId; private int? _editingLanScheduleId; private string? _scheduleMessage; @@ -1859,7 +1969,7 @@ } @if (!_agentOnGateway || _newWanTestType == "server") { - + }
@@ -2134,8 +2244,13 @@ { await InvokeAsync(async () => { + // The alert and incident lists are read into a holding buffer and only take + // the screen when the user asks (see PendingAlertCount). Everything else here + // is a whole-panel redraw with nothing to lose its place. if (_activeTab == "active") - await LoadActiveAlerts(); + await PollUnresolvedAlertsAsync(); + else if (_activeTab == "incidents") + await PollIncidentsAsync(); else if (_activeTab == "schedule") await LoadSchedules(); else if (_activeTab == "data-usage") @@ -2371,6 +2486,8 @@ try { _unresolvedAlerts = await AlertRepository.GetUnresolvedAlertsAsync(); + // This read IS the list now, so nothing the poll was holding is still pending. + _pendingAlerts = null; } catch (Exception ex) { @@ -2388,15 +2505,56 @@ severity = s; var source = string.IsNullOrEmpty(_historySourceFilter) ? null : _historySourceFilter; - _historyAlerts = await AlertRepository.GetAlertHistoryAsync(200, source, severity); + // A filter change re-reads from the first page: page 4 of the old filter is a + // different set of alerts, and usually past the end of the new one. + if (_historyPage < 0) _historyPage = 0; + var (items, total) = await AlertRepository.GetAlertHistoryPageAsync( + _historyPage * HistoryPageSize, HistoryPageSize, source, severity); + // A page that has fallen off the end (the filter narrowed, or alerts aged out) steps + // back to the last one that exists rather than showing nothing. + if (items.Count == 0 && total > 0 && _historyPage > 0) + { + _historyPage = Math.Max(0, (total - 1) / HistoryPageSize); + (items, total) = await AlertRepository.GetAlertHistoryPageAsync( + _historyPage * HistoryPageSize, HistoryPageSize, source, severity); + } + _historyAlerts = items; + _historyTotal = total; } catch (Exception ex) { Logger.LogError(ex, "Error loading alert history"); _historyAlerts = []; + _historyTotal = 0; } } + private const int HistoryPageSize = 50; + + /// Zero-based page the History tab is showing. + private int _historyPage; + + /// How many alerts the current filters match, across every page. + private int _historyTotal; + + private int HistoryPageCount => _historyTotal == 0 ? 1 : (_historyTotal + HistoryPageSize - 1) / HistoryPageSize; + + private async Task GoToHistoryPage(int page) + { + var last = HistoryPageCount - 1; + var target = Math.Clamp(page, 0, last); + if (target == _historyPage) return; + _historyPage = target; + await LoadHistory(); + } + + /// Filter changes start again from the first page. + private async Task ReloadHistoryFromFirstPage() + { + _historyPage = 0; + await LoadHistory(); + } + private async Task LoadRules() { try @@ -2410,11 +2568,69 @@ } } + /// + /// Reads the unresolved alerts on the poll without disturbing the list on screen. A first + /// load, or one where nothing new turned up, is applied straight away - there is nothing to + /// push down. Anything genuinely new waits behind the pill. + /// + private async Task PollUnresolvedAlertsAsync() + { + if (!SafeToRedraw) return; + try + { + var latest = await AlertRepository.GetUnresolvedAlertsAsync(); + var shown = _unresolvedAlerts.Select(a => a.Id).ToHashSet(); + var isNew = latest.Any(p => !shown.Contains(p.Id)); + _pendingAlerts = latest; + // Held back only when there is a list to disturb. Nothing new pushes nothing down, and + // an EMPTY list has nothing to push either - withholding there just left the tab + // reading All Clear with the alerts parked out of sight. + if (!isNew || _unresolvedAlerts.Count == 0) ApplyPendingAlerts(); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Polling unresolved alerts failed; the list on screen stands"); + } + } + + private async Task PollIncidentsAsync() + { + if (!SafeToRedraw) return; + try + { + var latest = await AlertRepository.GetUnresolvedIncidentsAsync(); + var shown = _incidents.Select(i => i.Id).ToHashSet(); + var isNew = latest.Any(p => !shown.Contains(p.Id)); + _pendingIncidents = latest; + if (!isNew || _incidents.Count == 0) ApplyPendingIncidents(); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Polling incidents failed; the list on screen stands"); + } + } + + /// Puts the held read on screen - what the pill does. + private void ApplyPendingAlerts() + { + if (_pendingAlerts is null) return; + _unresolvedAlerts = _pendingAlerts; + _pendingAlerts = null; + } + + private void ApplyPendingIncidents() + { + if (_pendingIncidents is null) return; + _incidents = _pendingIncidents; + _pendingIncidents = null; + } + private async Task LoadIncidents() { try { - _incidents = await AlertRepository.GetIncidentsAsync(); + _incidents = await AlertRepository.GetUnresolvedIncidentsAsync(); + _pendingIncidents = null; } catch (Exception ex) { @@ -2459,27 +2675,35 @@ private async Task AcknowledgeAllActive() { + if (_bulkBusy) return; + _bulkBusy = true; try { var active = _unresolvedAlerts.Where(a => a.Status == AlertStatus.Active).ToList(); var now = DateTime.UtcNow; + await AlertConfig.SetAlertStatusAsync(active.Select(a => a.Id).ToList(), AlertStatus.Acknowledged, now); foreach (var alert in active) { alert.Status = AlertStatus.Acknowledged; alert.AcknowledgedAt = now; - await AlertConfig.UpdateAlertAsync(alert); - await RecalculateIncidentStatusAsync(alert); } + await RecalculateIncidentsOnceAsync(active); await LoadActiveAlerts(); } catch (Exception ex) { Logger.LogError(ex, "Error acknowledging all active alerts"); } + finally + { + _bulkBusy = false; + } } private async Task ResolveAllActive() { + if (_bulkBusy) return; + _bulkBusy = true; try { // Everything unresolved, not just what is still Active. Acknowledge All moves the whole @@ -2489,41 +2713,69 @@ // read this way. var active = _unresolvedAlerts.Where(a => a.Status != AlertStatus.Resolved).ToList(); var now = DateTime.UtcNow; + await AlertConfig.SetAlertStatusAsync(active.Select(a => a.Id).ToList(), AlertStatus.Resolved, now); foreach (var alert in active) { alert.Status = AlertStatus.Resolved; alert.ResolvedAt = now; - await AlertConfig.UpdateAlertAsync(alert); - await RecalculateIncidentStatusAsync(alert); } + await RecalculateIncidentsOnceAsync(active); await LoadActiveAlerts(); } catch (Exception ex) { Logger.LogError(ex, "Error resolving all active alerts"); } + finally + { + _bulkBusy = false; + } } private async Task ResolveAllAcknowledged() { + if (_bulkBusy) return; + _bulkBusy = true; try { - // Same reasoning: whichever section's Resolve All you press, it clears the unresolved list. - var acknowledged = _unresolvedAlerts.Where(a => a.Status != AlertStatus.Resolved).ToList(); + // This section's button, and this section's alerts. Resolve All in the Active section + // above still takes the whole unresolved list, so acknowledging everything and then + // resolving in one go is still one press - which is what the wider scope here was for + // before the button said which alerts it meant. + var acknowledged = _unresolvedAlerts.Where(a => a.Status == AlertStatus.Acknowledged).ToList(); var now = DateTime.UtcNow; + await AlertConfig.SetAlertStatusAsync(acknowledged.Select(a => a.Id).ToList(), AlertStatus.Resolved, now); foreach (var alert in acknowledged) { alert.Status = AlertStatus.Resolved; alert.ResolvedAt = now; - await AlertConfig.UpdateAlertAsync(alert); - await RecalculateIncidentStatusAsync(alert); } + await RecalculateIncidentsOnceAsync(acknowledged); await LoadActiveAlerts(); } catch (Exception ex) { Logger.LogError(ex, "Error resolving all acknowledged alerts"); } + finally + { + _bulkBusy = false; + } + } + + /// + /// Re-derives each affected incident's status ONCE, after the alerts have been written. + /// Recalculating per alert re-read the incident and every alert on it for each of its members, + /// so a list where twenty alerts shared one incident did that work twenty times - and every + /// read and write is its own SQLite transaction, which is what made Resolve All crawl. + /// + private async Task RecalculateIncidentsOnceAsync(IReadOnlyList resolved) + { + var incidentIds = resolved.Where(a => a.IncidentId.HasValue) + .Select(a => a.IncidentId!.Value) + .Distinct() + .ToList(); + await AlertCorrelationService.RecalculateIncidentStatusesAsync(incidentIds, AlertRepository); } private async Task RecalculateIncidentStatusAsync(AlertHistoryEntry alert) @@ -2592,22 +2844,24 @@ private async Task AcknowledgeAllIncidents() { + if (_bulkBusy) return; + _bulkBusy = true; try { var now = DateTime.UtcNow; - foreach (var incident in _incidents.Where(i => i.Status == AlertStatus.Active).ToList()) - { - var alerts = await AlertRepository.GetAlertsByIncidentIdAsync(incident.Id); - foreach (var alert in alerts.Where(a => a.Status == AlertStatus.Active)) - { - alert.Status = AlertStatus.Acknowledged; - alert.AcknowledgedAt = now; - await AlertConfig.UpdateAlertAsync(alert); - } - + // Every incident's alerts are gathered first and written in one go: a save per alert + // is a database commit per alert, which is what made these buttons crawl on a site + // with a few hundred of them. + var incidents = _incidents.Where(i => i.Status == AlertStatus.Active).ToList(); + var incidentIds = incidents.Select(i => i.Id).ToList(); + var alertIds = (await AlertRepository.GetAlertsByIncidentIdsAsync(incidentIds)) + .Where(a => a.Status == AlertStatus.Active) + .Select(a => a.Id) + .ToList(); + await AlertConfig.SetAlertStatusAsync(alertIds, AlertStatus.Acknowledged, now); + foreach (var incident in incidents) incident.Status = AlertStatus.Acknowledged; - await AlertConfig.UpdateIncidentAsync(incident); - } + await AlertConfig.UpdateIncidentsAsync(incidents); await LoadIncidents(); await LoadActiveAlerts(); } @@ -2615,27 +2869,33 @@ { Logger.LogError(ex, "Error acknowledging all incidents"); } + finally + { + _bulkBusy = false; + } } private async Task ResolveAllIncidents() { + if (_bulkBusy) return; + _bulkBusy = true; try { var now = DateTime.UtcNow; - foreach (var incident in _incidents.Where(i => i.Status != AlertStatus.Resolved).ToList()) + // Same as Acknowledge All Incidents: gather every incident's alerts, write them once. + var incidents = _incidents.Where(i => i.Status != AlertStatus.Resolved).ToList(); + var incidentIds = incidents.Select(i => i.Id).ToList(); + var alertIds = (await AlertRepository.GetAlertsByIncidentIdsAsync(incidentIds)) + .Where(a => a.Status != AlertStatus.Resolved) + .Select(a => a.Id) + .ToList(); + await AlertConfig.SetAlertStatusAsync(alertIds, AlertStatus.Resolved, now); + foreach (var incident in incidents) { - var alerts = await AlertRepository.GetAlertsByIncidentIdAsync(incident.Id); - foreach (var alert in alerts.Where(a => a.Status != AlertStatus.Resolved)) - { - alert.Status = AlertStatus.Resolved; - alert.ResolvedAt = now; - await AlertConfig.UpdateAlertAsync(alert); - } - incident.Status = AlertStatus.Resolved; incident.ResolvedAt = now; - await AlertConfig.UpdateIncidentAsync(incident); } + await AlertConfig.UpdateIncidentsAsync(incidents); await LoadIncidents(); await LoadActiveAlerts(); } @@ -2643,6 +2903,10 @@ { Logger.LogError(ex, "Error resolving all incidents"); } + finally + { + _bulkBusy = false; + } } // ========== Rule CRUD ========== @@ -3312,6 +3576,26 @@ _ => "status-badge" }; + /// + /// What runs a "server" WAN speed test on THIS site: the server itself, or the on-site agent + /// where one owns path measurement. The stored task name says Server either way, because that + /// is the vantage's name in configuration - but on an agent-covered site the server never + /// touches the WAN, so reading "Server" beside a result the agent produced named the wrong box. + /// + private string ServerVantageLabel => ProbeExecutors.ServerVantageIsAgent ? "Agent" : "Server"; + + /// + /// Renames the stored "Server" detail for display. Applied at render rather than to the task + /// name so schedules made before a site gained its agent read correctly too, with nothing to + /// migrate and the configured vantage unchanged. + /// + private string? LabelServerVantage(string? detail) => + detail is null || !ProbeExecutors.ServerVantageIsAgent + ? detail + : detail == "Server" ? "Agent" + : detail.StartsWith("Server, ", StringComparison.Ordinal) ? $"Agent, {detail["Server, ".Length..]}" + : detail; + private static (string BaseName, string? Detail) SplitTaskName(string name) { // Names are "WAN Speed Test (...)" or "LAN Speed Test (...)" diff --git a/src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor b/src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor index c6d6a3e3d0..3d68637825 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor @@ -34,6 +34,9 @@ @inject NavigationManager NavigationManager @inject PersistentComponentState PersistState @inject NetworkOptimizer.Web.Services.Monitoring.UpstreamTracerService UpstreamTracer +@inject NetworkOptimizer.Web.Services.Monitoring.MonitoringPathView PathView +@inject NetworkOptimizer.Web.Services.Monitoring.LiveWanScope LiveWan +@inject NetworkOptimizer.Web.Services.Monitoring.IspHealth.IspHealthRegistry IspHealthRegistry @inject ISystemSettingsService SystemSettings @inject DashboardLayoutService DashboardLayout @inject CableModemMonitorService CmMonitorService @@ -48,6 +51,7 @@ @inject NetworkOptimizer.Web.Services.Monitoring.FlakyTargetService FlakyTargets @inject NetworkOptimizer.Web.Services.LanFlowMap.LanFlowMapCache LanFlowMapCache @inject ILogger Logger +@inject NetworkOptimizer.Web.Services.UiHintService UiHints @@ -274,29 +278,68 @@
} +
+ @if (LiveWan.HasChoice) + { + @* Separate scope from the analysis selectors on purpose - watching a WAN here must not move them. *@ +
+ @foreach (var w in LiveWan.Options) + { + + } + + @if (LiveWanIsNarrowed) + { + + } +
+ } + +
@{ var wanRates = GetWanRates(); } + @{ var wanDown = FormatRateParts(wanRates.download); var wanUp = FormatRateParts(wanRates.upload); }
-
@FormatRate(wanRates.download)
-
WAN Download
+
@wanDown.Value@if (wanDown.Unit != null) {@wanDown.Unit}
+
WAN Download@(WanRateCount())↓@WanRateCountShort()
-
@FormatRate(wanRates.upload)
-
WAN Upload
+
@wanUp.Value@if (wanUp.Unit != null) {@wanUp.Unit}
+
WAN Upload@(WanRateCount())↑@WanRateCountShort()
- @{ var ispHealth = IspHealthService.GetCachedScore(); } + @{ var ispHealth = CurrentIspHealth(); }
@ispHealth.TileText
-
ISP Health
+
@IspHealthTileLabel()
@if (ispHealth.Status == IspHealthStatus.Ready && ispHealth.Score.HasValue) {
}
- @{ var ispTarget = GetBestTargetStats(MonitoringTargetType.AccessIsp); } -
- @if (hasIspTargets) + @{ var ispTarget = ScopedTargetStats(MonitoringTargetType.AccessIsp, meanAcrossTargets: false); } +
+ @if (LiveWan.IsComparing) + { +
+ @foreach (var r in PerWanTargetStats(MonitoringTargetType.AccessIsp, meanAcrossTargets: false)) + { +
@r.Label@NetworkOptimizer.UniFi.GatewayWanHelper.ShortWanToken(r.Label)@FormatRtt(r.Rtt)
+ } +
+ } + else if (hasIspTargets) {
@FormatRtt(ispTarget.rtt)
} @@ -306,8 +349,17 @@ }
ISP RTT
-
- @if (hasIspTargets) +
+ @if (LiveWan.IsComparing) + { +
+ @foreach (var r in PerWanTargetStats(MonitoringTargetType.AccessIsp, meanAcrossTargets: false)) + { +
@r.Label@NetworkOptimizer.UniFi.GatewayWanHelper.ShortWanToken(r.Label)@FormatLoss(r.Loss)
+ } +
+ } + else if (hasIspTargets) {
@FormatLoss(ispTarget.loss)
} @@ -317,9 +369,18 @@ }
ISP Loss
- @{ var transitTarget = GetMeanTargetStats(MonitoringTargetType.Transit); } -
- @if (hasTransitTargets) + @{ var transitTarget = ScopedTargetStats(MonitoringTargetType.Transit, meanAcrossTargets: true); } +
+ @if (LiveWan.IsComparing) + { +
+ @foreach (var r in PerWanTargetStats(MonitoringTargetType.Transit, meanAcrossTargets: true)) + { +
@r.Label@NetworkOptimizer.UniFi.GatewayWanHelper.ShortWanToken(r.Label)@FormatRtt(r.Rtt)
+ } +
+ } + else if (hasTransitTargets) {
@FormatRtt(transitTarget.rtt)
} @@ -329,8 +390,17 @@ }
Transit RTT
-
- @if (hasTransitTargets) +
+ @if (LiveWan.IsComparing) + { +
+ @foreach (var r in PerWanTargetStats(MonitoringTargetType.Transit, meanAcrossTargets: true)) + { +
@r.Label@NetworkOptimizer.UniFi.GatewayWanHelper.ShortWanToken(r.Label)@FormatLoss(r.Loss)
+ } +
+ } + else if (hasTransitTargets) {
@FormatLoss(transitTarget.loss)
} @@ -441,16 +511,36 @@
-
+

Latency & Packet Loss

-
- @* Default the category filter to ISP when there are no enabled LAN (Fabric) - targets, so a monitoring-only/agent site opens on a populated chart. mount() - in latency-charts.js seeds currentCategory from whichever button is active. *@ - @{ var hasLanTargets = HasUpstreamTargets(MonitoringTargetType.Fabric); } + @if (_multiWanUiVisible && _wanOptions.Count > 1) + { +
+ @foreach (var w in _wanOptions) + { + + } + + @if (WanFilterIsNarrowed) + { + + } +
+ } +
+ @* No active class here: the chart module owns which category is current, and + Blazor re-rendering this header for any other reason would otherwise assert a + stale one over it. The opening choice is passed to mount() instead. *@
- - + + @@ -488,6 +578,10 @@
+
@@ -500,7 +594,7 @@
@@ -547,23 +641,29 @@
- +
- +
-
- +
+
- @* Multi-WAN contexts management card hidden for now (GA). Re-enable by - uncommenting; the rest of the page already tolerates empty contexts. -
- -
- *@ + @if (_multiWanUiVisible) + { +
+ +
+ } } @* ──────────────── Tab: Device Stats ──────────────── *@ @@ -572,7 +672,7 @@

Device Health

-
+
@@ -703,7 +803,7 @@

SFP Diagnostics

-
+
@@ -910,7 +1010,7 @@

Cable Modem Signal History

-
+
@@ -1041,7 +1141,7 @@

ONT Signal History

-
+
@@ -1196,7 +1296,7 @@

Cellular Signal History

-
+
@@ -1301,7 +1401,7 @@
private bool _canOperate; - protected override async Task OnInitializedAsync() + // Per-WAN report selection: the panel talks to the selected WAN's own IspHealthService + // instance; the injected (primary) service is the default and the only one a single-WAN + // site ever uses. + private IspHealthService? _wanSvc; + private IspHealthService Svc => _wanSvc ?? IspHealth; + private sealed record WanChoice(string Key, string Label, bool IsPrimary); + private List _wanOptions = new(); + // Which WANs a context names. A secondary WAN without one is not "not discovered yet" - it is + // not probed at all, and discovery cannot change that. + private HashSet _wansWithContext = new(StringComparer.OrdinalIgnoreCase); + private string _selectedWanKey = ""; + + /// + /// Whether the selected WAN has an enabled scheduled WAN speed test. Read alongside the + /// report so the collecting-data banner can point a freshly monitored WAN at Alerts & + /// Schedule while its latency history builds. Defaults to true so the nudge never shows + /// on an unknown answer. + /// + private bool _wanHasSpeedTestSchedule = true; + + /// + /// Whether this user could act on the schedule nudge. Creating a scheduled speed test is a + /// site administrator's to do, so anyone else is left with the plain collecting-data line + /// rather than being sent to a page that will not let them do it. + /// + private bool _canConfigureSchedules; + + /// Whether to offer the schedule nudge: this WAN has none, and the user could add one. + private bool ShowScheduleNudge => !_wanHasSpeedTestSchedule && _canConfigureSchedules; + private string SharedWanScopeKey => SiteContext.ScopeStorageKey("monitoringWanScope"); + + /// + /// WAN choices for the selector: the live WAN list, plus any context-bound WAN the console + /// currently omits (a down WAN's history is still worth reading). One entry = gate closed. + /// + private async Task LoadWanChoicesAsync() { - _canOperate = AuthState is null - || (await Authz.AuthorizeAsync((await AuthState).User, SiteContext.Slug, Policies.SiteOperator)).Succeeded; + var options = new List(); + try + { + foreach (var wan in await PathView.GetWansAsync()) + options.Add(new WanChoice(wan.WanInterface.ToLowerInvariant(), + NetworkOptimizer.UniFi.GatewayWanHelper.FormatWanLabel( + wan.FriendlyName, NetworkOptimizer.UniFi.GatewayWanHelper.WanIndexFromKey(wan.WanInterface), null, null), + wan.IsPrimary)); + } + catch { } + try + { + await using var db = SiteDb.CreateForSite(SiteContext.Slug, SiteContext.IsDefault); + var contexts = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync( + Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.AsNoTracking(db.WanContexts)); + _wansWithContext = contexts + .Where(c => !string.IsNullOrEmpty(c.WanInterface)) + .Select(c => c.WanInterface!.ToLowerInvariant()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var ctx in contexts) + { + if (string.IsNullOrEmpty(ctx.WanInterface)) continue; + var key = ctx.WanInterface!.ToLowerInvariant(); + if (!options.Any(o => o.Key == key)) + options.Add(new WanChoice(key, + NetworkOptimizer.UniFi.GatewayWanHelper.FormatWanLabel( + ctx.Name, NetworkOptimizer.UniFi.GatewayWanHelper.WanIndexFromKey(key), null, null), + IsPrimary: false)); + } + } + catch { } + _wanOptions = options; + _selectedWanKey = options.FirstOrDefault(o => o.IsPrimary)?.Key ?? options.FirstOrDefault()?.Key ?? ""; + } - // Pull-to-refresh on this tab recomputes the scorecard (the Refresh button's action) - // instead of the layout's full-page-reload fallback. - PtrState.RefreshCallback = RefreshAsync; - PtrState.NotifyStateChanged = StateHasChanged; + /// Loads the selected WAN's report, leaving it null on a transient query failure. + /// + /// Waits out the first compute for a WAN the user just switched to, then shows it. Bounded and + /// abandoned if the selection moves on - a compute that outlives the user's interest in it + /// should not redraw the panel underneath whatever they are looking at now. + /// + private async Task PollForFirstReportAsync(string forWanKey) + { + for (var attempt = 0; attempt < 20; attempt++) + { + await Task.Delay(TimeSpan.FromSeconds(3)); + if (!string.Equals(_selectedWanKey, forWanKey, StringComparison.OrdinalIgnoreCase)) return; + try + { + var report = await Svc.GetReportAsync(); + if (report == null) continue; + if (!string.Equals(_selectedWanKey, forWanKey, StringComparison.OrdinalIgnoreCase)) return; + _report = report; + await InvokeAsync(StateHasChanged); + return; + } + catch { return; } + } + } + private async Task LoadReportAsync() + { try { - _report = await IspHealth.GetReportAsync(); + _report = _windowStart.HasValue && _windowEnd.HasValue + ? await Svc.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value) + : await Svc.GetReportAsync(); } catch { - // A transient InfluxDB/query failure must not fault the circuit; leave the - // report null and let the status funnels render the fallback message. + // A transient InfluxDB/query failure must not fault the circuit; leave the report null + // and let the status funnels render the fallback message. _report = null; } - finally + await RefreshWanScheduleHintAsync(); + } + + /// + /// Rereads whether the selected WAN is covered by an enabled scheduled WAN speed test. A + /// gateway schedule carries its network group(s) in TargetConfig ("WAN", "WAN+WAN2"); a + /// server-vantage schedule carries none and rides the default route, so it counts for the + /// primary. Any failure reads as covered - the banner nudge must never appear on a guess. + /// + private async Task RefreshWanScheduleHintAsync() + { + try { - _loading = false; + var choice = _wanOptions.FirstOrDefault(w => string.Equals(w.Key, _selectedWanKey, StringComparison.OrdinalIgnoreCase)); + var wanKey = string.IsNullOrEmpty(_selectedWanKey) ? NetworkOptimizer.UniFi.GatewayWanHelper.DefaultWanKey : _selectedWanKey; + var wanGroup = NetworkOptimizer.UniFi.GatewayWanHelper.WanNetworkGroupFromKey(wanKey); + var isPrimary = choice?.IsPrimary ?? true; + + await using var db = SiteDb.CreateForSite(SiteContext.Slug, SiteContext.IsDefault); + var configs = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync( + System.Linq.Queryable.Select( + System.Linq.Queryable.Where(db.ScheduledTasks, + t => t.Enabled && t.TaskType == "wan_speedtest"), + t => t.TargetConfig)); + _wanHasSpeedTestSchedule = configs.Any(c => ScheduleCoversWan(c, wanGroup, isPrimary)); + } + catch + { + _wanHasSpeedTestSchedule = true; + } + } + + private static bool ScheduleCoversWan(string? targetConfig, string wanGroup, bool wanIsPrimary) + { + if (string.IsNullOrEmpty(targetConfig)) return wanIsPrimary; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(targetConfig); + if (!doc.RootElement.TryGetProperty("wanGroup", out var g) || g.ValueKind != System.Text.Json.JsonValueKind.String) + return wanIsPrimary; + return (g.GetString() ?? "").Split('+') + .Any(part => string.Equals(part.Trim(), wanGroup, StringComparison.OrdinalIgnoreCase)); + } + catch + { + return wanIsPrimary; + } + } + + private async Task SelectWanAsync(string key, bool persist = true) + { + // Same WAN AND a report already in hand is the no-op this guard is for. Without the + // second half it also swallowed the first selection of the WAN the panel opens on, which + // is the primary: _selectedWanKey is seeded with it, so ?wan= matched and + // returned before fetching anything. The render then found no report, read a status of + // Ready rather than Computing, and fell through to the catch-all telling the operator + // their monitoring was broken - repeatably, and only ever for the primary. + if (string.Equals(key, _selectedWanKey, StringComparison.OrdinalIgnoreCase) && _report != null) return; + var choice = _wanOptions.FirstOrDefault(w => string.Equals(w.Key, key, StringComparison.OrdinalIgnoreCase)); + if (choice == null) return; + _selectedWanKey = choice.Key; + _wanSvc = choice.IsPrimary ? null : IspHealthRegistry.GetFor(SiteContext.Slug, choice.Key); + // Held across the fetch: the moment Svc points at the new WAN, every status funnel below + // is answering for a WAN whose report has not been read yet, and the catch-all among them + // says the site's monitoring is broken. + _loading = true; + // The whole report - the chart element with it - is behind @if (!_loading), so showing the + // spinner tears that div out of the DOM and leaves ApexCharts holding a detached node. The + // mount flag has to come off with it, or the after-render path sees "already mounted", + // skips the re-mount, and the WAN switch lands on an empty chart area. Pushing a new WAN + // to the old instance cannot help: the element it drew into is gone. + await DropChartMountAsync(); + StateHasChanged(); + if (persist) + { + try { await JS.InvokeVoidAsync("localStorage.setItem", SharedWanScopeKey, choice.IsPrimary ? "" : choice.Key); } + catch { } + } + try + { + _report = _windowStart.HasValue && _windowEnd.HasValue + ? await Svc.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value) + : await Svc.GetReportAsync(); + } + catch { _report = null; } + await RefreshWanScheduleHintAsync(); + // The chart fetches its own series and scopes them by WAN, so it has to be told. The + // chart itself is dropped right now (the content render below re-mounts it), but the WAN + // key is module-level JS state that survives the remount - pushing it here means the + // fresh mount's very first fetch is already scoped to the new WAN. + await PushChartWanAsync(); + _loading = false; + // A WAN that has never been scored comes back null with a compute now running behind it. + // Waiting for the 30 s age tick to notice leaves the user on a spinner-less funnel for + // most of a minute, so poll briefly for the result the switch just asked for. + if (_report == null && Svc.Status == IspHealthStatus.Computing) + _ = PollForFirstReportAsync(choice.Key); + StateHasChanged(); + } + + /// Applies the WAN remembered for this site once localStorage is reachable. + + /// + /// The WAN named by a ?wan= query parameter, or null. Unknown values are ignored by the + /// caller rather than erroring: a stale link to a WAN the site no longer has should land on + /// the default report, not on nothing. + /// + /// + /// The WAN Speed Test link for the report on screen, filtered to the same WAN. The speeds in + /// this tile are that WAN's, so the history behind them should be too rather than every WAN's + /// results mixed together. Single-WAN sites get the plain page: the parameter resolves to the + /// one series there and the filter bar does not render at all. + /// + private string WanSpeedTestHref() => + string.IsNullOrEmpty(_selectedWanKey) + ? "/wan-speedtest" + : $"/wan-speedtest?wan={Uri.EscapeDataString(_selectedWanKey)}"; + + private string? LinkedWanKey() + { + try + { + var query = new Uri(NavigationManager.Uri).Query; + var value = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(query) + .TryGetValue("wan", out var v) ? v.ToString() : null; + if (string.IsNullOrWhiteSpace(value)) return null; + + var key = value.Trim().ToLowerInvariant(); + // "primary" names the role rather than a WAN group, for links from somewhere that + // shows the primary's figures without knowing which WAN holds that role - it is a + // role in UniFi Network and any group can hold it, so those links cannot spell a key. + return string.Equals(key, Services.Monitoring.LiveWanScope.PrimaryWanToken, StringComparison.Ordinal) + ? _wanOptions.FirstOrDefault(w => w.IsPrimary)?.Key + : key; + } + catch { return null; } + } + + /// + /// A secondary WAN is discovered THROUGH its context - that is what binds a probe to it - so a + /// WAN without one cannot be traced no matter how many times discovery is run. Saying "run + /// discovery" there would send the user somewhere that cannot help them. + /// + private bool NeedsContextFirst + { + get + { + var wan = _wanOptions.FirstOrDefault( + w => string.Equals(w.Key, _selectedWanKey, StringComparison.OrdinalIgnoreCase)); + return wan is { IsPrimary: false } && !_wansWithContext.Contains(wan.Key); } + } + + /// The selected WAN in prose, for a sentence that names it. + private string SelectedWanLabel + { + get + { + var wan = _wanOptions.FirstOrDefault(w => string.Equals(w.Key, _selectedWanKey, StringComparison.OrdinalIgnoreCase)); + return wan == null + ? "This WAN" + : NetworkOptimizer.UniFi.GatewayWanHelper.FormatWanLabelInProse( + wan.Label, NetworkOptimizer.UniFi.GatewayWanHelper.WanIndexFromKey(wan.Key)); + } + } + + /// + /// Carries the report's WAN into a link out of this panel, so the destination opens filtered to + /// the WAN the report was about. Empty on a single-WAN site, where the parameter says nothing. + /// + private string WanLinkQuery() => + string.IsNullOrEmpty(_selectedWanKey) || _wanOptions.Count <= 1 + ? "" : $"&wan={Uri.EscapeDataString(_selectedWanKey)}"; + + /// True when the selection moved, in which case the report has been loaded with it. + private async Task RestoreWanSelectionAsync() + { + if (_wanOptions.Count <= 1) return false; + + // An explicit ?wan= beats the stored selection for this visit: arriving by a link that + // names a WAN is a statement about which report you want, where the stored value is only + // where you happened to be last. NOT persisted - following a link is not the same as + // choosing a filter, and writing it meant one click from a tile silently became the WAN + // this panel opened on from then on. Coming back without the link reads the stored one + // again, the same rule the Network Performance filter follows. + var linked = LinkedWanKey(); + if (!string.IsNullOrEmpty(linked) + && _wanOptions.Any(w => string.Equals(w.Key, linked, StringComparison.OrdinalIgnoreCase))) + { + await SelectWanAsync(linked!, persist: false); + return true; + } + + try + { + var stored = await JS.InvokeAsync("localStorage.getItem", SharedWanScopeKey); + if (!string.IsNullOrEmpty(stored) + && !string.Equals(stored, _selectedWanKey, StringComparison.OrdinalIgnoreCase) + && _wanOptions.Any(w => string.Equals(w.Key, stored, StringComparison.OrdinalIgnoreCase))) + { + await SelectWanAsync(stored, persist: false); + return true; + } + } + catch { } + return false; + } + + protected override async Task OnInitializedAsync() + { + _canOperate = AuthState is null + || (await Authz.AuthorizeAsync((await AuthState).User, SiteContext.Slug, Policies.SiteOperator)).Succeeded; + // Scheduled speed tests are a site administrator's to create (Alerts & Schedule gates the + // whole section on it), so only an administrator is pointed at them. + _canConfigureSchedules = AuthState is null + || (await Authz.AuthorizeAsync((await AuthState).User, SiteContext.Slug, Policies.SiteAdmin)).Succeeded; + + // Pull-to-refresh on this tab recomputes the scorecard (the Refresh button's action) + // instead of the layout's full-page-reload fallback. + PtrState.RefreshCallback = RefreshAsync; + PtrState.NotifyStateChanged = StateHasChanged; + + await LoadWanChoicesAsync(); + + // The report is NOT loaded here. Which WAN it should be for lives in localStorage, which + // needs interop, which is unavailable until after the first render - so loading now would + // compute the primary in the foreground, and on a site where the primary is due a + // recompute that is a long wait for a report the user did not ask for, followed by a + // switch to the one they did. It loads in OnAfterRenderAsync once the WAN is known. // Highlight the button for the effective (possibly auto-reduced) live window. if (_followLive) _selectedPreset = LiveWindowHours; @@ -1045,7 +1424,7 @@ else _autoReloading = true; try { - var latest = await IspHealth.GetReportAsync(); + var latest = await Svc.GetReportAsync(); if (latest != null && (_report == null || latest.ComputedAt != _report.ComputedAt)) { _report = latest; @@ -1099,6 +1478,8 @@ else // cached report, which is what the tab is showing. if (_windowStart.HasValue && _windowEnd.HasValue) url += $"?from={Uri.EscapeDataString(_windowStart.Value.ToString("o"))}&to={Uri.EscapeDataString(_windowEnd.Value.ToString("o"))}"; + if (_wanSvc != null && !string.IsNullOrEmpty(_selectedWanKey)) + url += (url.Contains('?') ? "&" : "?") + $"wan={Uri.EscapeDataString(_selectedWanKey)}"; if (!SiteContext.IsDefault) url = SiteContextService.WithSiteParam(url, SiteContext.Slug); @@ -1147,7 +1528,7 @@ else { if (IsLiveWindow) { - _report = await IspHealth.GetReportAsync(forceRefresh: true); + _report = await Svc.GetReportAsync(forceRefresh: true); // The chart fetches its own series from the freshly cached report. try { await JS.InvokeVoidAsync("eval", "window.__ispHealthCharts?.reload?.();"); } catch { /* chart not mounted yet */ } @@ -1156,7 +1537,7 @@ else { // Recompute the current window, bypassing the custom-window cache; the chart's // follow-up fetch then hits the freshly recomputed result for the same window. - _report = await IspHealth.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); + _report = await Svc.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); await ChartSetWindowAsync(_windowStart, _windowEnd); } } @@ -1174,11 +1555,11 @@ else private async Task OnPhysicalLinkSourceChanged(ChangeEventArgs e) { var key = e.Value?.ToString(); - await IspHealth.SetPhysicalLinkSourceAsync(string.IsNullOrEmpty(key) ? null : key); + await Svc.SetPhysicalLinkSourceAsync(string.IsNullOrEmpty(key) ? null : key); if (IsLiveWindow) - _report = await IspHealth.GetReportAsync(); + _report = await Svc.GetReportAsync(); else if (_windowStart.HasValue && _windowEnd.HasValue) - _report = await IspHealth.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); + _report = await Svc.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); StateHasChanged(); } @@ -1222,9 +1603,9 @@ else try { if (acknowledged) - await IspHealth.AcknowledgeOutageAsync(outageStartUtc); + await Svc.AcknowledgeOutageAsync(outageStartUtc); else - await IspHealth.UnacknowledgeOutageAsync(outageStartUtc); + await Svc.UnacknowledgeOutageAsync(outageStartUtc); await ReloadAfterOutageAckAsync(); } finally @@ -1238,9 +1619,9 @@ else private async Task ReloadAfterOutageAckAsync() { if (IsLiveWindow) - _report = await IspHealth.GetReportAsync(); + _report = await Svc.GetReportAsync(); else if (_windowStart.HasValue && _windowEnd.HasValue) - _report = await IspHealth.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); + _report = await Svc.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); StateHasChanged(); } @@ -1264,11 +1645,11 @@ else StateHasChanged(); try { - await IspHealth.SetAccessTechnologyAsync((AccessTechnology)techValue); + await Svc.SetAccessTechnologyAsync((AccessTechnology)techValue); if (IsLiveWindow) - _report = await IspHealth.GetReportAsync(); + _report = await Svc.GetReportAsync(); else if (_windowStart.HasValue && _windowEnd.HasValue) - _report = await IspHealth.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); + _report = await Svc.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); } finally { @@ -1316,6 +1697,17 @@ else protected override async Task OnAfterRenderAsync(bool firstRender) { + // Interop is unavailable during prerender, so the remembered WAN can only be applied here - + // and the report waits for it. Restoring first means one compute, for the WAN the user + // actually left the tab on, and the toolbar stays hidden behind _loading until the right + // pill is the lit one, so the selection never visibly jumps. + if (firstRender) + { + var switched = await RestoreWanSelectionAsync(); + if (!switched) await LoadReportAsync(); + _loading = false; + StateHasChanged(); + } if (_scrollChartAfterRender) { _scrollChartAfterRender = false; @@ -1351,22 +1743,66 @@ else } catch { /* tooltip refresh is best-effort */ } } + // A WAN switch renders the spinner state while the new report is fetched, and the report + // body - the chart's element with it - is behind @if (!_loading), so during that render + // there is nothing to mount into. This after-render still fires (the old report is still + // in hand, and DropChartMountAsync just lowered the flag), and mounting here is exactly + // what broke WAN switching: mount() found no element and returned WITHOUT throwing, the + // optimistic flag below stayed raised, and when the body came back the "already mounted" + // guard skipped the real mount - an empty chart area on every WAN switched to in-page, + // permanent because the unmount had also stopped the poll timer. The element only exists + // when !_loading, so leave the mount to that render's own after-render pass. + if (_loading) return; if (_chartMounted) return; + // Raised before the awaits, not after: renders interleave with them, and a second + // after-render must not start a second mount while this one is in flight. _chartMounted = true; var fromArg = _windowStart.HasValue ? $"'{_windowStart.Value:o}'" : "null"; var toArg = _windowEnd.HasValue ? $"'{_windowEnd.Value:o}'" : "null"; try { _selfRef ??= DotNetObjectReference.Create(this); - await JS.InvokeVoidAsync("eval", + // mount reports whether it found the element and built the chart. A miss returns + // false rather than throwing, so it cannot be left to the catch below: the flag must + // come back down on a miss or no later render will ever retry the mount. + var mounted = await JS.InvokeAsync("eval", $"(async () => {{ const m = await import('{VersionedJs("/js/isp-health-charts.js")}'); " + - $"window.__ispHealthCharts = m; await m.mount('isp-health-asn-chart', {fromArg}, {toArg}, {HiddenChartTypesJson()}); }})();"); + $"window.__ispHealthCharts = m; return await m.mount('isp-health-asn-chart', {fromArg}, {toArg}, {HiddenChartTypesJson()}); }})();"); + if (!mounted) { _chartMounted = false; return; } // Hand the chart a callback so its drag-zoom can filter the events list to the visible window. await JS.InvokeVoidAsync("__ispHealthCharts.setDotNetRef", _selfRef); + await PushChartWanAsync(); } catch { _chartMounted = false; } } + /// + /// Tells the per-network chart which WAN to fetch. Null for the primary, matching how the + /// report itself is sourced - the primary's series are the unscoped ones. + /// + /// + /// Forgets the mounted chart so the next render builds a new one. Called whenever the report + /// content is about to be replaced, since the chart's element goes with it. + /// + private async Task DropChartMountAsync() + { + if (!_chartMounted) return; + _chartMounted = false; + try { await JS.InvokeVoidAsync("eval", "window.__ispHealthCharts?.unmount?.();"); } + catch { /* nothing mounted, or the circuit is going away */ } + } + + private async Task PushChartWanAsync() + { + // Deliberately NOT gated on _chartMounted: the WAN key is module-level JS state that + // survives an unmount, so pushing it while the chart is dropped mid-switch is how the + // upcoming re-mount fetches the right WAN on its first load. setWan itself no-ops the + // fetch while unmounted. + var wan = _wanSvc == null ? "null" : $"'{System.Text.Encodings.Web.JavaScriptEncoder.Default.Encode(_selectedWanKey)}'"; + try { await JS.InvokeVoidAsync("eval", $"window.__ispHealthCharts?.setWan({wan});"); } + catch { /* module not imported yet (first page load) - the post-mount push covers that */ } + } + private void ToggleCustomPopover() { _showCustomPopover = !_showCustomPopover; @@ -1401,7 +1837,11 @@ else // datetime-local binds local wall-clock (Kind=Unspecified); treat it as local -> UTC. var startUtc = _customFrom.ToUniversalTime(); var endUtc = _customTo.ToUniversalTime(); - if (endUtc <= startUtc) return; + // A start after the end is a typo, not a request for nothing. Collapsing it onto the end + // lets the minimum-window rule below open it back up, so the user gets the shortest real + // window instead of a popover that closes and leaves the previous one on screen with no + // sign the input was rejected. + if (startUtc > endUtc) startUtc = endUtc; if (endUtc - startUtc < TimeSpan.FromHours(MinRangeHours)) startUtc = endUtc.AddHours(-MinRangeHours); // enforce the minimum window if (endUtc - startUtc > TimeSpan.FromHours(MaxRangeHours)) @@ -1422,8 +1862,8 @@ else // Default (null window) serves the cached 48 h report; an explicit window computes // off-cache and never disturbs it. Stale report stays on screen until the swap. _report = fromUtc.HasValue && toUtc.HasValue - ? await IspHealth.GetReportForWindowAsync(fromUtc.Value, toUtc.Value) - : await IspHealth.GetReportAsync(); + ? await Svc.GetReportForWindowAsync(fromUtc.Value, toUtc.Value) + : await Svc.GetReportAsync(); } catch { _report = null; } finally { _windowComputing = false; } @@ -1522,10 +1962,14 @@ else return Math.Clamp(dark / span * 100, 0, 95); } - private static string? FactorInvestigateUrl(string factorName) => factorName switch + /// + /// Where a factor's name links to. The report's WAN rides along, so the charts open filtered to + /// the same WAN the factor was graded on rather than to whatever the last visit left selected. + /// + private string? FactorInvestigateUrl(string factorName) => factorName switch { - "Packet Loss" => "/monitoring?tab=performance&investigate=packet-loss", - "Loaded Loss" => "/monitoring?tab=performance&investigate=loaded-loss", + "Packet Loss" => $"/monitoring?tab=performance&investigate=packet-loss{WanLinkQuery()}", + "Loaded Loss" => $"/monitoring?tab=performance&investigate=loaded-loss{WanLinkQuery()}", _ => null }; diff --git a/src/NetworkOptimizer.Web/Components/Shared/Monitoring/MonitoringJumpButton.razor b/src/NetworkOptimizer.Web/Components/Shared/Monitoring/MonitoringJumpButton.razor new file mode 100644 index 0000000000..baece191b9 --- /dev/null +++ b/src/NetworkOptimizer.Web/Components/Shared/Monitoring/MonitoringJumpButton.razor @@ -0,0 +1,36 @@ +@* Cross-tab jump between watching and analyzing the same moment: the magnifier opens the + analysis view for what is on screen, the eye plays back the moment the analysis is framing. + One component so the two directions cannot drift apart in glyph, hit area, or tooltip mode. *@ + + +@code { + public enum JumpIcon { Analyze, Watch } + + [Parameter] public JumpIcon Icon { get; set; } + [Parameter] public string Tooltip { get; set; } = ""; + [Parameter] public EventCallback OnClick { get; set; } + + /// + /// Guided-tour anchor for this button, when a step spotlights it. Null on the instances no tour + /// points at, and Blazor omits the attribute entirely then. + /// + [Parameter] public string? Tour { get; set; } +} diff --git a/src/NetworkOptimizer.Web/Components/Shared/Monitoring/WanFilterResetButton.razor b/src/NetworkOptimizer.Web/Components/Shared/Monitoring/WanFilterResetButton.razor new file mode 100644 index 0000000000..6f40b30b39 --- /dev/null +++ b/src/NetworkOptimizer.Web/Components/Shared/Monitoring/WanFilterResetButton.razor @@ -0,0 +1,14 @@ +@* The same clear-filter control the chart chip rows render from chart-filter.js, for the WAN pill + bars that are built in Razor. One component so the glyph cannot drift between them. *@ + + +@code { + [Parameter] public EventCallback OnReset { get; set; } +} diff --git a/src/NetworkOptimizer.Web/Components/Shared/SiteSetupWizard.razor b/src/NetworkOptimizer.Web/Components/Shared/SiteSetupWizard.razor index 261aebe786..4b2d704d9d 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/SiteSetupWizard.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/SiteSetupWizard.razor @@ -1,4 +1,3 @@ -@using NetworkOptimizer.Core.Helpers @using NetworkOptimizer.Core.Interfaces @using NetworkOptimizer.Storage.Models @using NetworkOptimizer.Web.Services @@ -75,7 +74,7 @@
- @if (!string.IsNullOrWhiteSpace(_name)) + @if (!string.IsNullOrWhiteSpace(_slugPreview)) { - Site ID: @StringUtilities.ToSlug(_name) - permanent identifier used for the site's database file, InfluxDB buckets, and agent configuration. + Site ID: @_slugPreview - permanent identifier used for the site's database file, InfluxDB buckets, and agent configuration. }
} @@ -322,6 +321,7 @@ private bool _busy; private Site? _site; private string _name = ""; + private string _slugPreview = ""; private string _message = ""; private string _messageClass = ""; @@ -361,6 +361,28 @@ StateHasChanged(); } + /// + /// Asks the service what slug this name would actually get, rather than slugging the name + /// here: the answer depends on what is already taken, so a name that collides with an + /// existing site or with the reserved default slug gets its "-2" suffix shown up front + /// instead of surprising the user after the site is created. + /// + private async Task UpdateSlugPreviewAsync() + { + var name = _name; + if (string.IsNullOrWhiteSpace(name)) + { + _slugPreview = ""; + return; + } + + var preview = await SiteManagement.PreviewSlugAsync(name); + // Keystrokes can resolve out of order; a stale answer would show a slug for a + // name the field no longer holds. + if (name == _name) + _slugPreview = preview; + } + /// /// Enter creates the site, matching the button beside the field. Guarded on the same conditions /// the button is disabled by, so a stray Enter on an empty field or mid-create does nothing. @@ -640,6 +662,7 @@ _step = 1; _site = null; _name = ""; + _slugPreview = ""; _consoleUrl = ""; _username = ""; _password = ""; diff --git a/src/NetworkOptimizer.Web/Components/Shared/SiteSwitcher.razor b/src/NetworkOptimizer.Web/Components/Shared/SiteSwitcher.razor index 44a58961c9..27ba7509a3 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/SiteSwitcher.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/SiteSwitcher.razor @@ -152,12 +152,18 @@ || string.Equals(pathOnly, "denied", StringComparison.OrdinalIgnoreCase)) return NavigationManager.BaseUri; + // A ?wan= names one site's WAN, and every reader of it matches by key or index - so + // carrying one across a switch either filters the new site to a WAN it does not have or, + // worse, quietly matches a different connection that happens to share the number. Dropped + // on every page; the rest of the query and the #fragment carry as before. + var target = SiteContextService.RemoveQueryParam(NavigationManager.Uri, "wan"); + // Client Performance pins a specific client via ?ip= - that address // belongs to the site being left, so a switch drops it and lands on the // new site's own client view (tab/range params and the #fragment carry). if (string.Equals(pathOnly, "client-dashboard", StringComparison.OrdinalIgnoreCase)) - return SiteContextService.RemoveQueryParam(NavigationManager.Uri, "ip"); + return SiteContextService.RemoveQueryParam(target, "ip"); - return NavigationManager.Uri; + return target; } } diff --git a/src/NetworkOptimizer.Web/Components/Shared/SitesOverviewCard.razor b/src/NetworkOptimizer.Web/Components/Shared/SitesOverviewCard.razor index cda5c33ca0..7a4f054fab 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/SitesOverviewCard.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/SitesOverviewCard.razor @@ -59,8 +59,11 @@ private List _sites = new(); private List _agents = new(); - private int EnrolledAgents => _agents.Count(a => a.EnrolledAt != null); - private int OnlineAgents => _agents.Count(a => TunnelRegistry.IsAgentLive(a)); + // Both tiles skip disabled agents, for the same reason the per-site count does: a disabled + // agent inflates Agents while it can never appear in Agents Online, so the pair reads as an + // outage rather than as a deliberate choice. + private int EnrolledAgents => _agents.Count(a => a.Enabled && a.EnrolledAt != null); + private int OnlineAgents => _agents.Count(a => a.Enabled && TunnelRegistry.IsAgentLive(a)); private void HandleCardClick() { diff --git a/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor b/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor index 93d84facbc..b83199037b 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor @@ -122,7 +122,7 @@
@if (ShowLiveViewLink) { - @TestTime.ToLocalTime().ToString("g") @TimeFormatHelper.FormatRelativeTimeShort(TestTime) @@ -507,6 +507,25 @@ return stampMs - 2 * DurationSeconds * 1000L; } } + /// Live View at the moment of this test, on the WAN that ran it. + private string LiveViewHref => $"/monitoring?tab=live&at={LiveViewAtMs}{LiveViewWanQuery}"; + + /// + /// The WAN the result was measured on, as the live filter's interface key ("&wan=wan2"). + /// A timestamp alone lands on whichever WAN the filter was left on, which for a multi-WAN site + /// is usually not the one the result is describing - the spike the link exists to show is on + /// the WAN that ran the test. Empty for a result with no WAN (LAN and client tests), and + /// harmless on a single-WAN site: the key matches no option there, so the filter stands. + /// + private string LiveViewWanQuery + { + get + { + var wanIndex = GatewayWanHelper.WanIndexFromKey(Result?.WanNetworkGroup); + return wanIndex > 0 ? $"&wan={GatewayWanHelper.WanInterfaceKey(wanIndex)}" : ""; + } + } + [Parameter] public double? PingMs { get; set; } [Parameter] public double? JitterMs { get; set; } [Parameter] public double? DownloadLatencyMs { get; set; } diff --git a/src/NetworkOptimizer.Web/Components/Shared/TourHost.razor b/src/NetworkOptimizer.Web/Components/Shared/TourHost.razor index af5cb03ffb..38a8a883a4 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/TourHost.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/TourHost.razor @@ -20,7 +20,7 @@

@_offer.Summary

}
    - @foreach (var step in _offer.Steps.Take(6)) + @foreach (var step in _offer.Steps.Where(s => !s.Step.HideFromList).Take(6)) {
  • @(step.Step.ListLabel ?? step.Step.Title) @@ -30,9 +30,10 @@ }
  • } - @if (_offer.Steps.Count > 6) + @{ var listedSteps = _offer.Steps.Count(s => !s.Step.HideFromList); } + @if (listedSteps > 6) { -
  • and @(_offer.Steps.Count - 6) more
  • +
  • and @(listedSteps - 6) more
  • }

@@ -286,6 +287,7 @@ var result = await SafeJsAsync("noTour.showStep", _selfRef, new { selector = resolved.Step.Selector, + matchText = resolved.Step.MatchText, title = resolved.Step.Title, body = resolved.Step.Body, placement = resolved.Step.Placement, diff --git a/src/NetworkOptimizer.Web/Components/Shared/UpstreamTracerPanel.razor b/src/NetworkOptimizer.Web/Components/Shared/UpstreamTracerPanel.razor index 425399cd56..7ae78c3e46 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/UpstreamTracerPanel.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/UpstreamTracerPanel.razor @@ -3,6 +3,8 @@ @using NetworkOptimizer.Web.Services.Monitoring @implements IDisposable @inject UpstreamTracerService Tracer +@inject NetworkOptimizer.Web.Services.Monitoring.UpstreamTracerRegistry TracerRegistry +@inject NetworkOptimizer.Web.Services.Monitoring.MonitoringPathView PathView @inject NetworkOptimizer.Web.Services.IUpstreamDiscoveryService Discovery @inject ILogger Logger @inject NetworkOptimizer.Storage.Services.SiteDbContextFactory SiteDb @@ -16,7 +18,25 @@

-

Upstream path discovery

+

Upstream Path Discovery

+ @if (_wanChoices.Count > 1) + { +
+ @foreach (var w in _wanChoices) + { + + } +
+ }
@StateLabel(_state.Step) @(_collapsed ? "▼" : "▲") @@ -154,7 +174,7 @@ -
-
- - +
-
- - +
+ +
-
- - @foreach (var agent in _agents) { @@ -91,20 +187,88 @@ }
-
- -
+
+ + - - + + +
+ @if (SelectedAgentOutdated) + { +
+ This agent is on an older release. Update it from Settings - Multi-Site - + some of the options here only appear once it has.
+ } +
+

Help

+ @if (!SelectedAgentCanBindSource) + { +

+ Give a source IP for local probing (the gateway must policy-route it out this + WAN), or assign a probe-only agent bound behind the WAN - not both. Assigned + targets are probed only by that agent. +

+ } + else + { +

+ This agent binds this address for the WAN's probes. The address needs its own + interface and MAC - UniFi matches the policy-based route by Client Device - so + a second address on an existing NIC won't route differently. +

+ } + @if (SelectedAgentCanBindInterface) + { +

+ This agent runs on the gateway, so its probes can go out the WAN's own + interface. Selecting a WAN fills this in. A gateway agent has to bind it: + routing policy does not govern the gateway's own traffic. +

+ } + @if ((!string.IsNullOrWhiteSpace(_newSourceIp) && !SelectedAgentCanBindSource) || SelectedAgentNeedsPolicyRoute) + { +

+ This agent reaches @WanLabelFor(_newWanInterface) only if the gateway routes it there. In UniFi + Network, go to Settings - Policy Table and add a Policy-Based Route with + this WAN as the interface, this agent's Client Device as the source, and + Any as the destination. The source is matched by MAC, so the agent needs an + interface and MAC of its own - an LXC has one already, a VM or Docker + container can be given one - not a second address on a host that's already + on the network. +

+ }
-

- Give a source IP for local probing (the gateway must policy-route it out this - WAN), or assign a probe-only agent bound behind the WAN - not both. Assigned - targets are probed only by that agent. -

@if (!string.IsNullOrEmpty(_addError)) {

@_addError

@@ -115,7 +279,7 @@ { } @@ -124,6 +288,85 @@
+ + @code { [Parameter, EditorRequired] public List WanContexts { get; set; } = new(); @@ -131,26 +374,181 @@ [Parameter] public EventCallback OnChanged { get; set; } + /// + /// Raised to open the Latency Targets card. Not currently wired to a control - Assign targets + /// sends a vantage with no targets to discovery instead - but kept for the case where a + /// vantage that already HAS targets wants to jump to its list. + /// + [Parameter] + public EventCallback OnAssignTargets { get; set; } + + /// Raised by Assign targets on a vantage with no targets: run discovery for its WAN. + [Parameter] + public EventCallback OnDiscoverTargets { get; set; } + + /// + /// Opens the card when a link sent the user here. A WAN with no context cannot be discovered + /// at all, so ISP Health points at this card - and arriving to find it collapsed would hide + /// the very thing the link was about. + /// + [Parameter] + public bool ForceExpand { get; set; } + private bool _collapsed = true; - private bool _showAdd; - private bool _adding; + private bool _showForm; + private bool _revealForm; + private bool _saving; + private int? _editingId; private string _newName = ""; private string _newDescription = ""; private string _newSourceIp = ""; private string _newAgentId = ""; + private string _newWanInterface = ""; + private string _newInterfaceName = ""; + private bool _agentTouched; private string? _addError; private List _agents = new(); + private IReadOnlyList _wans = Array.Empty(); + // Null until a connected compute has recorded the site's WAN roles: neither sentence below is + // shown on a guess, because they prescribe opposite things. + private bool? _siteLoadBalances; + private string? _primaryWanLabel; + // Agents that both run on the gateway and told us they can bind a probe source. Interface + // binding is only offered for those: an agent elsewhere on the network has no WAN interface to + // bind, and one that cannot bind at all would fail every probe in the context. + private HashSet _bindCapableAgents = new(); + private HashSet _sourceBindAgents = new(); + private HashSet _connectedAgents = new(); private Dictionary _targetCounts = new(); + private bool SelectedAgentCanBindInterface => + int.TryParse(_newAgentId, out var agentId) && _bindCapableAgents.Contains(agentId); + + /// + /// Whether the selected agent binds probes to an ADDRESS rather than an interface: it reports + /// the capability but does not run on the gateway, so it has no WAN interface of its own to + /// leave by. One such agent with an interface per WAN covers several WANs on its own. + /// + private bool SelectedAgentCanBindSource => + int.TryParse(_newAgentId, out var agentId) && _sourceBindAgents.Contains(agentId); + + /// + /// Whether the selected agent binds nothing of its own, so the WHOLE box has to be routed out + /// the WAN for its probes to go anywhere near it. True for an agent that cannot bind at all - + /// an older binary - which is the setup that silently measures the primary WAN and files the + /// results under another one if the route is never built. + /// + /// + /// Whether THIS server still probes this site's paths, which is what a vantage with no agent + /// depends on: the server is the thing binding the address. False for every secondary site, and + /// false for the main site once its agent owns path measurement - the off-site-server case, + /// where a local address the gateway is meant to policy-route could not be bound anyway. + /// + private bool ServerProbesThisSite => + SiteCtx.IsDefault && !AgentCoverage.AgentOwnsPathMeasurement(SiteCtx.Slug); + + /// + /// A saved vantage that nothing probes: it has no agent, and this server has stood down from + /// probing this site. It keeps its targets and collects nothing, which looks identical to a + /// WAN that is simply quiet. + /// + private bool IsOrphaned(WanContext context) => context.AgentId == null && !ServerProbesThisSite; + + /// + /// A vantage whose agent CAN bind but which has nothing to bind to, so its probes leave by the + /// agent's default route and are filed under this WAN regardless. Reachable without anything + /// going wrong: save the vantage while the agent is too old to offer a binding, then update the + /// agent - the capability arrives, the empty configuration does not change, and nothing says so. + /// + private bool BindsNothing(WanContext context) => + context.AgentId is int id + && (_bindCapableAgents.Contains(id) || _sourceBindAgents.Contains(id)) + && string.IsNullOrEmpty(context.InterfaceName) + && string.IsNullOrEmpty(context.ProbeSourceIp); + + /// + /// Whether the selected agent reports a version older than the release currently expected of + /// agents. Separate from what it can BIND - an agent can be current and still not bind on a + /// platform that cannot - so this says only what it is: out of date, and worth updating before + /// concluding an option is missing. + /// + private bool SelectedAgentOutdated => + int.TryParse(_newAgentId, out var outdatedAgentId) + && _agents.FirstOrDefault(a => a.Id == outdatedAgentId) is SiteAgent selected + && NetworkOptimizer.Core.Helpers.VersionUtilities.IsOlderThan( + selected.LastVersion, NetworkOptimizer.Web.Services.AppVersionInfo.LatestAgentVersion); + + private bool SelectedAgentNeedsPolicyRoute => + !string.IsNullOrEmpty(_newAgentId) && !SelectedAgentCanBindInterface && !SelectedAgentCanBindSource; + // WAN contexts are per-site data: route through the current site's database. private NetworkOptimizerDbContext CreateDb() => SiteDb.CreateForSite(SiteCtx.Slug, SiteCtx.IsDefault); private void ToggleCollapse() => _collapsed = !_collapsed; - protected override async Task OnInitializedAsync() => await LoadAgentsAsync(); + protected override void OnParametersSet() + { + if (ForceExpand) _collapsed = false; + } + + protected override async Task OnInitializedAsync() + { + // Agents and WANs name the rows in the table, not just the form's pickers, so both load up + // front. Both are cached upstream, and the card only renders on a multi-WAN site at all. + await LoadAgentsAsync(); + await LoadWansAsync(); + LoadConnectedAgents(); + await LoadBindCapableAgentsAsync(); + } + + private void LoadConnectedAgents() + { + try { _connectedAgents = TunnelRegistry.GetForSite(SiteCtx.Slug).Select(c => c.AgentId).ToHashSet(); } + catch { _connectedAgents = new(); } + } + + /// + /// A vantage with no targets is at its starting state, and discovery is how that state is + /// left: sending someone to an empty target list asks them to hand-enter what a trace would + /// find. The WAN goes with it so discovery opens on the one they clicked. + /// + private async Task JumpToDiscoveryAsync(WanContext context) + { + if (OnDiscoverTargets.HasDelegate) + await OnDiscoverTargets.InvokeAsync(context.WanInterface); + } + + private async Task JumpToLatencyTargetsAsync() + { + // The page owns both cards, so it is the one that can open the other. Falls back to + // scrolling on its own if nothing is listening. + if (OnAssignTargets.HasDelegate) + { + await OnAssignTargets.InvokeAsync(); + return; + } + try { await JS.InvokeVoidAsync("noHighlightTarget", "latency-targets"); } + catch { } + } + + /// + /// Whether this user may add, change or remove a vantage on this site. The markup already hides + /// those controls from anyone below Site Admin; this is the same rule where the work actually + /// happens, so it no longer depends on which wrapper a button happens to sit inside. + /// + private bool _canConfigure = true; + + [CascadingParameter] private Task? AuthState { get; set; } protected override async Task OnParametersSetAsync() { + // The same policy against the same resource as the SiteAdminOnly wrapper around these + // controls, so it can only ever reject someone who had no button to press. Resolved on every + // parameter set because the site can change under the card. No cascading state means no + // authentication is in play at all, which is the one case that must not lock anyone out. + _canConfigure = AuthState is null + || (await Authz.AuthorizeAsync((await AuthState).User, SiteCtx.Slug, Policies.SiteAdmin)).Succeeded; + try { await using var db = CreateDb(); @@ -161,12 +559,75 @@ .ToDictionaryAsync(g => g.Key, g => g.Count); } catch { _targetCounts = new(); } + LoadConnectedAgents(); } + /// + /// Opens the add form and scrolls it into view. The card sits at the bottom of the page, so + /// the form can otherwise open entirely below the fold and the button look like it did nothing. + /// private async Task ShowAddAsync() + { + if (!_canConfigure) return; + ResetForm(); + await LoadPickerDataAsync(); + _showForm = true; + _revealForm = true; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // After the render that created it, not before - there is no element to scroll to until + // the form is actually in the DOM. Scroll only, no ring: the user pressed the button that + // opened this, so nothing needs pointing out to them. + if (!_revealForm) return; + _revealForm = false; + await JS.InvokeVoidAsync("noScrollTo", "wan-vantage-form", "center"); + } + + private async Task EditContextAsync(WanContext context) + { + if (!_canConfigure) return; + ResetForm(); + _editingId = context.Id; + _newName = context.Name; + _newDescription = context.Description ?? ""; + _newSourceIp = context.ProbeSourceIp ?? ""; + _newAgentId = context.AgentId?.ToString() ?? ""; + _newWanInterface = context.WanInterface ?? ""; + _newInterfaceName = context.InterfaceName ?? ""; + await LoadPickerDataAsync(); + // A vantage saved before its agent could bind has no interface, and the agent gaining the + // capability does not give it one - it would go on probing the default route forever. Offer + // the WAN's own interface; saving is still the user's move. + if (string.IsNullOrEmpty(_newInterfaceName)) FillInterfaceFromWan(); + _showForm = true; + } + + private void CancelForm() + { + _showForm = false; + ResetForm(); + } + + private void ResetForm() + { + _editingId = null; + _agentTouched = false; + _newName = ""; + _newDescription = ""; + _newSourceIp = ""; + _newAgentId = ""; + _newWanInterface = ""; + _newInterfaceName = ""; + _addError = null; + } + + private async Task LoadPickerDataAsync() { await LoadAgentsAsync(); - _showAdd = true; + await LoadWansAsync(); + await LoadBindCapableAgentsAsync(); } /// @@ -190,6 +651,151 @@ catch { _agents = new(); } } + /// + /// The site's real WANs, so the WAN a context measures is picked rather than typed. Empty when + /// the console is unreachable; an existing context keeps showing its stored WAN either way. + /// + private async Task LoadWansAsync() + { + try { _wans = await PathView.GetWansAsync(); } + catch { _wans = Array.Empty(); } + + // Which WAN holds the primary role, and whether the site load balances, decide which + // guidance applies - unpinned probing measures the primary honestly under failover, and + // nothing at all under load balancing. Prefer the live answer; fall back to what the last + // connected compute recorded, and say nothing when neither can answer. + var live = _wans.FirstOrDefault(w => w.IsPrimary); + _primaryWanLabel = live != null + ? GatewayWanHelper.FormatWanLabelInProse( + WanLabelFor(live.WanInterface), GatewayWanHelper.WanIndexFromKey(live.WanInterface)) + : null; + try + { + await using var db = SiteDb.CreateForSite(SiteCtx.Slug, SiteCtx.IsDefault); + var primary = await db.WanProfiles.AsNoTracking().FirstOrDefaultAsync(w => w.IsPrimary == true); + _siteLoadBalances = primary?.SiteLoadBalances; + if (_primaryWanLabel == null && primary != null) + { + var key = GatewayWanHelper.WanInterfaceKeyFromKey(primary.WanNetworkgroup); + _primaryWanLabel = GatewayWanHelper.FormatWanLabelInProse( + WanLabelFor(key), GatewayWanHelper.WanIndexFromKey(key)); + } + } + catch { _siteLoadBalances = null; } + } + + /// + /// Sorts the connected agents into the two ways of binding a probe. Both need the agent to say + /// in its hello that it can bind at all - an agent too old to say counts as no - and what + /// separates them is where it runs. On the gateway it has the WAN's own interface to leave by + /// (asked per agent address, since a site can have several agents). Anywhere else it has no WAN + /// interface, so it binds one of its own addresses instead and the gateway policy-routes that + /// address out the WAN. + /// + private async Task LoadBindCapableAgentsAsync() + { + var byInterface = new HashSet(); + var byAddress = new HashSet(); + try + { + foreach (var connection in TunnelRegistry.GetForSite(SiteCtx.Slug)) + { + if (connection.SupportsSourceBind != true) continue; + if (await OnGatewayDetector.MatchGatewayAddressAsync(SiteCtx.Slug, connection.HostAddresses) != null) + byInterface.Add(connection.AgentId); + else + byAddress.Add(connection.AgentId); + } + } + catch { } + _bindCapableAgents = byInterface; + _sourceBindAgents = byAddress; + } + + /// + /// Assigning an agent that cannot bind an address settles where the probe leaves from, so the + /// source IP field goes away rather than sitting there as a second answer the save would + /// reject. For an agent that CAN bind one, the address is the agent's own binding and stays. + /// + private void OnAgentSelected(ChangeEventArgs e) + { + _agentTouched = true; + _newAgentId = e.Value?.ToString() ?? ""; + // Clearing the address is right only where the agent cannot bind one - there it is the + // server's mechanism and the agent has just replaced it. An agent that binds addresses + // keeps what was typed, since that is now the agent's own binding. + if (!string.IsNullOrEmpty(_newAgentId) && !SelectedAgentCanBindSource) + _newSourceIp = ""; + if (!SelectedAgentCanBindInterface) + _newInterfaceName = ""; + else + FillInterfaceFromWan(); + } + + private void OnWanSelected(ChangeEventArgs e) + { + _newWanInterface = e.Value?.ToString() ?? ""; + // Probing a secondary WAN from the server is the exception, not the default: it needs a + // policy route built by hand, while an agent binds for itself. So picking a WAN offers the + // first agent rather than None. Only on a new vantage, and only until the agent field is + // touched - after that the choice is the user's, including a deliberate None. + if (!_agentTouched && _editingId == null && string.IsNullOrEmpty(_newAgentId) && _agents.Count > 0) + { + _newAgentId = _agents[0].Id.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (!SelectedAgentCanBindSource) _newSourceIp = ""; + } + FillInterfaceFromWan(); + } + + /// + /// Fills the bind interface from the selected WAN's data path (its uplink interface, falling + /// back to the physical port). Deliberately not the counter interface: throughput is read from + /// the physical port because VLAN sub-interface counters double, while a probe has to leave by + /// the logical uplink - a PPPoE WAN's traffic goes out ppp0, not eth6. + /// + private void FillInterfaceFromWan() + { + if (!SelectedAgentCanBindInterface) return; + if (KnownWanInterface is { Length: > 0 } dataPath) + _newInterfaceName = dataPath; + } + + /// + /// The selected WAN's data-path interface as the console reports it, or null when the console + /// has nothing to say about that WAN - a WAN that is down, or one the site has only through a + /// vantage. Not a guess when it has a value, which is why the field showing it is read-only: + /// an agent on the gateway leaves by that interface or it does not leave by the WAN at all. + /// + private string? KnownWanInterface + { + get + { + var wan = _wans.FirstOrDefault(w => + string.Equals(w.WanInterface, _newWanInterface, StringComparison.OrdinalIgnoreCase)); + return wan?.UplinkIfName ?? wan?.PhysicalIfName; + } + } + + /// + /// What this context's probes leave from - the bound interface for an on-gateway agent, the + /// policy-routed source IP otherwise. Null when it binds neither, which leaves probes on + /// whatever route the prober already has. + /// + private static string? ProbeSourceOf(WanContext context) + => !string.IsNullOrEmpty(context.InterfaceName) ? context.InterfaceName + : !string.IsNullOrEmpty(context.ProbeSourceIp) ? context.ProbeSourceIp + : null; + + /// + /// Network Tools, pointed at whatever probes this vantage - its agent, or this server when the + /// vantage is an address the server binds. The point of going there is the first-hop check: + /// probes that leave by the wrong WAN look identical here and only differ there. + /// + private static string VerifyUrl(WanContext context) => + context.AgentId is int id + ? $"/network-tools?from=agent:{id}" + : $"/network-tools?from={NetworkOptimizer.Web.Services.Monitoring.ProbeVantages.ServerKey}"; + private string AgentLabel(int? agentId) { if (agentId == null) return "-"; @@ -197,82 +803,219 @@ return agent?.Name ?? $"agent {agentId}"; } - private async Task AddContextAsync() + /// + /// WAN label with the group set apart from the name ("My ISP (WAN2)") rather than run together + /// as the pill form does. Everything in this card is plain text in a list or a cell, where the + /// qualifier reads as part of the name unless it is bracketed off. + /// + private static string WanOptionLabel(WanSummary wan) + => WanProseLabel(wan.FriendlyName, GatewayWanHelper.WanIndexFromKey(wan.WanInterface)); + + private static string WanProseLabel(string? friendlyName, int wanIndex) + => GatewayWanHelper.FormatWanLabelInProse( + GatewayWanHelper.FormatWanLabel(friendlyName, wanIndex, null, null), wanIndex); + + /// + /// The name to use when the user leaves the field empty: the WAN itself, in the parenthetical + /// form used everywhere else ("Acme Fiber (WAN2)"), or the bare token for a WAN with no name of + /// its own. Naming a context is a chore with one sensible answer nearly every time - a WAN is + /// what a context is for - so the field asks rather than demands. + /// + /// Empty until a WAN is chosen, so nothing is suggested before there is anything to suggest. + /// A suggestion that collides with an existing context fails the same duplicate-name check a + /// typed one would, which is the honest outcome: two contexts on one WAN need telling apart. + /// + /// + private string SuggestedName() + { + if (string.IsNullOrWhiteSpace(_newWanInterface)) return ""; + return WanLabelFor(_newWanInterface); + } + + private string NamePlaceholder() + { + var suggested = SuggestedName(); + return string.IsNullOrEmpty(suggested) ? "backup-wan" : suggested; + } + + /// + /// Label for a stored WAN key, preferring the live WAN's friendly name and falling back to the + /// group alone so a vantage whose WAN is down (or predates the column) still reads sensibly. + /// + private string WanLabelFor(string? wanInterface) + { + if (string.IsNullOrEmpty(wanInterface)) return "-"; + var wan = _wans.FirstOrDefault(w => + string.Equals(w.WanInterface, wanInterface, StringComparison.OrdinalIgnoreCase)); + return wan != null + ? WanOptionLabel(wan) + : WanProseLabel(null, GatewayWanHelper.WanIndexFromKey(wanInterface)); + } + + /// + /// The rules a context has to satisfy, in the order the user meets them. Returns the message to + /// show, or null when the context is valid. + /// + /// A context needs a WAN: without one there is nothing to say which WAN its measurements + /// describe, which is what the tag on its points and the report they belong to are keyed on. It + /// needs at most one bind mechanism, since a source IP and an agent are two different answers to + /// "where does the probe leave from" - UNLESS the agent is the thing doing the binding, which + /// is the multi-homed agent case. And an interface bind needs the agent: this server does not + /// sit on the gateway, so a name only it could resolve binds nothing here. + /// + /// Context name as typed. + /// Selected UniFi WAN key, empty when none was chosen. + /// Probe source IP as typed, empty when none. + /// Selected agent, null for none. + /// Bind interface as typed, empty when none. + /// Names of the site's OTHER contexts (excluding the one being edited). + /// Whether this server probes this site itself (the main site). + /// + /// Whether the selected agent binds probes to one of its own addresses. When it does, an + /// address alongside the agent is not two competing answers - it IS the agent's binding, and + /// it is what lets one multi-homed agent cover several WANs. + /// + internal static string? ValidateContext( + string name, + string? wanInterface, + string? sourceIp, + int? agentId, + string? interfaceName, + IEnumerable otherNames, + bool serverProbesThisSite = true, + bool agentCanBindSource = false) + { + if (string.IsNullOrEmpty(name) || name.Length > 100) + return "A name up to 100 characters is required."; + if (otherNames.Any(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase))) + return "A vantage with that name already exists."; + if (string.IsNullOrWhiteSpace(wanInterface)) + return "Choose the WAN this vantage measures."; + // The context name is written as the Influx wan tag alongside the stable wan key, so a + // name that IS a wan key ("wan2") would file this context's points under another WAN. + // Allowed only when it names the context's own WAN. + if (System.Text.RegularExpressions.Regex.IsMatch(name, @"^wan\d*$", System.Text.RegularExpressions.RegexOptions.IgnoreCase) + && !string.Equals(NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(name), + NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface!), StringComparison.OrdinalIgnoreCase)) + return "A name that looks like a WAN key must match the vantage's own WAN."; + if (!string.IsNullOrEmpty(sourceIp) && !System.Net.IPAddress.TryParse(sourceIp, out _)) + return "Probe source IP must be a valid IP address."; + if (agentId != null && !string.IsNullOrEmpty(sourceIp) && !agentCanBindSource) + return "Use either a probe source IP or an assigned agent, not both."; + if (!string.IsNullOrWhiteSpace(interfaceName) && agentId == null) + return "Interface binding runs on an agent - assign one first."; + // A source-IP context is probed by the SERVER binding that address, and the server only + // probes the main site. On any other site nothing would ever run these probes, so the + // context would sit there looking configured and collect nothing. + if (!serverProbesThisSite && agentId == null) + return "This site is probed by its agent, so assign one to this WAN."; + return null; + } + + private async Task SaveContextAsync() { + if (!_canConfigure) return; _addError = null; + // An empty field takes the suggestion the placeholder was showing, so what the user saw + // before saving is what gets saved. var name = _newName.Trim(); - if (string.IsNullOrEmpty(name) || name.Length > 100) - { - _addError = "A name up to 100 characters is required."; - return; - } - if (WanContexts.Any(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase))) - { - _addError = "A context with that name already exists."; - return; - } + if (name.Length == 0) name = SuggestedName(); var sourceIp = _newSourceIp.Trim(); - if (!string.IsNullOrEmpty(sourceIp) && !System.Net.IPAddress.TryParse(sourceIp, out _)) - { - _addError = "Probe source IP must be a valid IP address."; - return; - } + var wanInterface = _newWanInterface.Trim(); + var interfaceName = _newInterfaceName.Trim(); int? agentId = int.TryParse(_newAgentId, out var parsedAgent) ? parsedAgent : null; - if (agentId != null && !string.IsNullOrEmpty(sourceIp)) - { - _addError = "Use either a probe source IP or an assigned agent, not both."; - return; - } - _adding = true; + _addError = ValidateContext(name, wanInterface, sourceIp, agentId, interfaceName, + WanContexts.Where(c => c.Id != _editingId).Select(c => c.Name), + serverProbesThisSite: ServerProbesThisSite, + agentCanBindSource: SelectedAgentCanBindSource); + if (_addError != null) return; + + _saving = true; try { await using var db = CreateDb(); - db.WanContexts.Add(new WanContext + if (_editingId is int editingId) + { + var row = await db.WanContexts.FindAsync(editingId); + if (row == null) + { + _addError = "That vantage no longer exists."; + return; + } + var wanChanged = !string.Equals(row.WanInterface, wanInterface, StringComparison.OrdinalIgnoreCase); + row.Name = name; + row.Description = string.IsNullOrWhiteSpace(_newDescription) ? null : _newDescription.Trim(); + row.ProbeSourceIp = string.IsNullOrEmpty(sourceIp) ? null : sourceIp; + row.AgentId = agentId; + row.WanInterface = wanInterface; + row.InterfaceName = string.IsNullOrEmpty(interfaceName) ? null : interfaceName; + // The context's targets say which WAN their data describes; a context re-pointed + // at another WAN takes its targets' stamp with it, or the per-WAN readers would + // keep attributing their data to the old WAN. + if (wanChanged) + await NetworkOptimizer.Web.Services.Monitoring.WanContextTargetStamping + .RestampContextTargetsAsync(db, editingId, wanInterface); + } + else { - Name = name, - Description = string.IsNullOrWhiteSpace(_newDescription) ? null : _newDescription.Trim(), - ProbeSourceIp = string.IsNullOrEmpty(sourceIp) ? null : sourceIp, - AgentId = agentId, - CreatedAt = DateTime.UtcNow, - }); + db.WanContexts.Add(new WanContext + { + Name = name, + Description = string.IsNullOrWhiteSpace(_newDescription) ? null : _newDescription.Trim(), + ProbeSourceIp = string.IsNullOrEmpty(sourceIp) ? null : sourceIp, + AgentId = agentId, + WanInterface = wanInterface, + InterfaceName = string.IsNullOrEmpty(interfaceName) ? null : interfaceName, + CreatedAt = DateTime.UtcNow, + }); + } await db.SaveChangesAsync(); - _showAdd = false; - _newName = ""; - _newDescription = ""; - _newSourceIp = ""; - _newAgentId = ""; + await RepushProbeConfigAsync(); + _showForm = false; + ResetForm(); await OnChanged.InvokeAsync(); } catch (Exception ex) { - _addError = $"Failed to add context: {ex.Message}"; + _addError = $"Failed to save vantage: {ex.Message}"; } finally { - _adding = false; + _saving = false; } } private async Task DeleteContextAsync(int contextId) { + if (!_canConfigure) return; try { await using var db = CreateDb(); var row = await db.WanContexts.FindAsync(contextId); if (row == null) return; - // The reference is loose (no FK): move the context's targets back to - // the primary WAN before removing it. - var assigned = await db.MonitoringTargets - .Where(t => t.WanContextId == contextId) - .ToListAsync(); - foreach (var target in assigned) - target.WanContextId = null; + // The reference is loose (no FK): move the context's targets back to the primary + // WAN before removing it - BOTH keys, so no row stays stamped with a WAN nothing + // probes for it any more (see WanContextTargetStamping). + await NetworkOptimizer.Web.Services.Monitoring.WanContextTargetStamping + .ReleaseContextTargetsAsync(db, contextId); db.WanContexts.Remove(row); await db.SaveChangesAsync(); + await RepushProbeConfigAsync(); await OnChanged.InvokeAsync(); } catch { } } + + /// + /// Tells every connected agent on this site what it should be probing now. Both ends of a + /// reassignment need it: the agent that lost the context keeps probing targets it no longer + /// owns until it hears otherwise, and the one that gained it does not start until it does. + /// + private async Task RepushProbeConfigAsync() + { + try { await ProbeSink.PushProbeConfigToSiteAsync(SiteCtx.Slug); } + catch { } + } } diff --git a/src/NetworkOptimizer.Web/Endpoints/AlertEndpoints.cs b/src/NetworkOptimizer.Web/Endpoints/AlertEndpoints.cs index 4f54ccba40..2a1cf7258c 100644 --- a/src/NetworkOptimizer.Web/Endpoints/AlertEndpoints.cs +++ b/src/NetworkOptimizer.Web/Endpoints/AlertEndpoints.cs @@ -95,6 +95,11 @@ public static void MapAlertEndpoints(this WebApplication app) }); // --- Incidents --- + // TODO: takes the newest `limit` incidents whatever their status, so a caller wanting the + // unresolved ones cannot get at them once that many newer ones have been resolved - the + // trap the Incidents tab was in until it moved to GetUnresolvedIncidentsAsync. Give this a + // status filter applied in SQL. Left as-is for now because changing what the resource + // returns by default would change it for anyone already reading it. read.MapGet("/api/alerts/incidents", async (IAlertRepository repo, int limit = 50) => Results.Ok(await repo.GetIncidentsAsync(limit))); diff --git a/src/NetworkOptimizer.Web/Endpoints/IspHealthEndpoints.cs b/src/NetworkOptimizer.Web/Endpoints/IspHealthEndpoints.cs index ba4a15de4c..c3377095b7 100644 --- a/src/NetworkOptimizer.Web/Endpoints/IspHealthEndpoints.cs +++ b/src/NetworkOptimizer.Web/Endpoints/IspHealthEndpoints.cs @@ -26,11 +26,15 @@ public static void Map(WebApplication app) group.MapGet("/api/monitoring/isp-health/pdf", async ( DateTime? from, DateTime? to, - IspHealthService ispHealth, + string? wan, + IspHealthRegistry ispHealthRegistry, SiteContextService siteContext, SiteManagementService siteManagement, CancellationToken ct) => { + // wan (a UniFi wan key) exports a non-primary WAN's report; absent = primary, + // exactly as before. + var ispHealth = ispHealthRegistry.GetFor(siteContext.Slug, wan); var report = from.HasValue && to.HasValue ? await ispHealth.GetReportForWindowAsync(from.Value, to.Value, ct: ct) : await ispHealth.GetReportAsync(ct: ct); @@ -56,11 +60,15 @@ public static void Map(WebApplication app) group.MapGet("/api/monitoring/isp-health/asn-series", async ( DateTime? from, DateTime? to, - IspHealthService ispHealth, + string? wan, + IspHealthRegistry ispHealthRegistry, + SiteContextService siteContext, CancellationToken ct) => { // from/to (the tab's date/time filter) make the chart follow a custom window off - // the 48 h cache; absent, it serves the cached 48 h report. + // the 48 h cache; absent, it serves the cached 48 h report. wan (a UniFi wan key) + // serves a non-primary WAN's instance; absent = primary, exactly as before. + var ispHealth = ispHealthRegistry.GetFor(siteContext.Slug, wan); var (series, report) = await ispHealth.GetAsnChartDataAsync(from, to, ct); // Cap the chart payload only for long windows: bucket toward a target point count, diff --git a/src/NetworkOptimizer.Web/Endpoints/MonitoringChartEndpoints.cs b/src/NetworkOptimizer.Web/Endpoints/MonitoringChartEndpoints.cs index 4a0dd894d1..4edeba8b91 100644 --- a/src/NetworkOptimizer.Web/Endpoints/MonitoringChartEndpoints.cs +++ b/src/NetworkOptimizer.Web/Endpoints/MonitoringChartEndpoints.cs @@ -3,8 +3,8 @@ using NetworkOptimizer.Storage.Models; using NetworkOptimizer.Storage.Services; using NetworkOptimizer.Web.Services; -using NetworkOptimizer.Web.Services.Monitoring; using NetworkOptimizer.Web.Services.Authorization; +using NetworkOptimizer.Web.Services.Monitoring; namespace NetworkOptimizer.Web.Endpoints; @@ -20,6 +20,9 @@ public static void Map(WebApplication app) group.MapGet("/api/monitoring/live-stats", async ( MonitoringLiveStats liveStats, UniFiConnectionService connectionService, + NetworkOptimizer.Storage.Services.SiteDbContextFactory siteDb, + SiteContextService siteContext, + string? wan, CancellationToken ct) => { string? gatewayMac = null; @@ -34,6 +37,39 @@ public static void Map(WebApplication app) } catch { } + // The live tick has to answer for the same WAN the caller is charting. Without this it + // served the primary's counters to every caller, so a chart backfilled with one WAN's + // history then grew a live edge of the primary's traffic - the two halves of the same + // line describing different connections. Absent means the primary, exactly as before. + if (!string.IsNullOrEmpty(wan)) + { + var group2 = NetworkOptimizer.UniFi.GatewayWanHelper.WanNetworkGroupFromKey(wan); + string? scopedCounter = null; + try + { + scopedCounter = (await connectionService.GetWanInterfacesForGroupAsync(group2, ct))?.CounterIfName; + } + catch { } + if (string.IsNullOrEmpty(scopedCounter)) + { + try + { + await using var db = siteDb.CreateForSite(siteContext.Slug, siteContext.IsDefault); + var profile = await db.WanProfiles.AsNoTracking() + .FirstOrDefaultAsync(w => w.WanNetworkgroup == group2, ct); + scopedCounter = profile?.CounterInterface; + if (string.IsNullOrEmpty(gatewayMac) && profile?.GatewayMac != null) + gatewayMac = profile.GatewayMac.Replace("-", ":").ToLowerInvariant(); + } + catch { } + } + // Empty rather than the primary's: a WAN with no recorded counter has no live + // answer, and borrowing one would draw another WAN's traffic under its name. + wanIfNames = string.IsNullOrEmpty(scopedCounter) + ? new List() + : new List { scopedCounter! }; + } + double wanDown = 0, wanUp = 0; DateTime? sampleTime = null; if (gatewayMac != null && wanIfNames != null) @@ -49,7 +85,13 @@ public static void Map(WebApplication app) } } - var (meanRtt, meanLoss) = await liveStats.GetMeanIspTransitLiveAsync(ct); + // Scoped to the same WAN as the rates above, or the chart's RTT and loss lines would + // be the site's while its throughput was one WAN's - and a WAN with no targets of its + // own would show the primary's latency as if it were its own. + var isPrimaryWan = string.IsNullOrEmpty(wan) + || string.Equals(NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wan!), + NetworkOptimizer.UniFi.GatewayWanHelper.DefaultWanKey, StringComparison.OrdinalIgnoreCase); + var (meanRtt, meanLoss) = await liveStats.GetMeanIspTransitLiveAsync(ct, wan, isPrimaryWan); return Results.Ok(new { @@ -72,6 +114,7 @@ public static void Map(WebApplication app) ILoggerFactory loggerFactory, DateTime? from, DateTime? to, + string? wan, CancellationToken ct) => { DateTime queryFrom, queryTo; @@ -107,7 +150,35 @@ public static void Map(WebApplication app) // console that returns no gateway still yields an empty series as before, and eth0, // eth6.100 and ppp0 keep resolving live. CounterInterface, not the data path - a VLAN // sub-interface's counters double, which is why the two are stored apart. - if ((string.IsNullOrEmpty(gatewayMac) || wanIfNames is not { Count: > 0 }) + // A named WAN replaces the primary-only list with THAT WAN's counter interface: live + // from the console, else the WAN's own remembered profile. Never a fallback to another + // WAN - an empty series is the honest answer for a WAN nothing has recorded, where + // borrowing the primary's would draw someone else's traffic under this WAN's name. + if (!string.IsNullOrEmpty(wan)) + { + var group = NetworkOptimizer.UniFi.GatewayWanHelper.WanNetworkGroupFromKey(wan); + string? scopedCounter = null; + try + { + var ifaces = await connectionService.GetWanInterfacesForGroupAsync(group, ct); + scopedCounter = ifaces?.CounterIfName; + } + catch { } + try + { + await using var db = siteDb.CreateForSite(siteContext.Slug, siteContext.IsDefault); + var profile = await db.WanProfiles.AsNoTracking() + .FirstOrDefaultAsync(w => w.WanNetworkgroup == group, ct); + scopedCounter ??= profile?.CounterInterface; + if (string.IsNullOrEmpty(gatewayMac) && profile?.GatewayMac != null) + gatewayMac = profile.GatewayMac.Replace("-", ":").ToLowerInvariant(); + } + catch { } + wanIfNames = string.IsNullOrEmpty(scopedCounter) + ? new List() + : new List { scopedCounter! }; + } + else if ((string.IsNullOrEmpty(gatewayMac) || wanIfNames is not { Count: > 0 }) && !connectionService.IsConnected) { try @@ -153,9 +224,47 @@ public static void Map(WebApplication app) ? influx.QueryGatewayWanRatesAsync(gatewayMac, wanIfNames, queryFrom, queryTo, sampleIntervalSeconds: sampleIntervalSeconds, ct: ct) : Task.FromResult>(Array.Empty()); + // Scoped like the rates above: the backfilled RTT and loss have to belong to the WAN + // being charted, or a secondary WAN's history is drawn with the primary's latency - + // the same borrowing the live tick did, just arriving as history instead. var targets = await liveStats.GetIspTransitTargetsAsync(ct); + // Points are scoped as well as targets. Selecting the right target ids is not enough on + // its own: one host reachable from two WANs is probed under each, and a row that has + // moved between contexts keeps its older points under the tag they were written with - + // so a read by id alone returns another WAN's readings too, which is a speed test on one + // WAN showing up as a latency spike on another's chart. Same scope the ISP Health + // reports use, built by the same helper. + // Same rule as the live tick: no WAN named means the primary, never every WAN. + MonitoringInfluxClient.LatencyWanScope? latencyScope = null; + { + var wanKey = string.IsNullOrEmpty(wan) + ? NetworkOptimizer.UniFi.GatewayWanHelper.DefaultWanKey + : NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wan!); + var wanIsPrimary = string.Equals(wanKey, + NetworkOptimizer.UniFi.GatewayWanHelper.DefaultWanKey, StringComparison.OrdinalIgnoreCase); + targets = targets.Where(t => string.IsNullOrEmpty(t.WanInterface) + ? wanIsPrimary + : string.Equals(NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(t.WanInterface!), + wanKey, StringComparison.OrdinalIgnoreCase)) + .ToList(); + try + { + await using var scopeDb = siteDb.CreateForSite(siteContext.Slug, siteContext.IsDefault); + var contexts = await scopeDb.WanContexts.AsNoTracking().ToListAsync(ct); + latencyScope = NetworkOptimizer.Web.Services.Monitoring.IspHealth.IspHealthService + .BuildWanScope(contexts, wanKey, wanIsPrimary); + } + catch + { + // Unreadable contexts: fall back to the id-only read rather than an empty chart. + } + } var targetIds = targets.Select(t => t.TargetId).ToList(); - var rttTask = influx.QueryMeanIspTransitLatencyAsync(queryFrom, queryTo, targetIds, ct: ct); + // No targets on this WAN means no latency history for it - an empty query would read + // as the site's, so it is skipped and the series stays empty. + var rttTask = targetIds.Count > 0 + ? influx.QueryMeanIspTransitLatencyAsync(queryFrom, queryTo, targetIds, wanScope: latencyScope, ct: ct) + : Task.FromResult>(Array.Empty()); await Task.WhenAll(wanTask, rttTask); @@ -168,19 +277,61 @@ public static void Map(WebApplication app) // 5s window - and SNMP polls get delayed exactly under load, so loss // spikes vanished from the chart precisely when they mattered. var rttSorted = rttData.OrderBy(p => p.Time).ToList(); + var wanSorted = wanData.OrderBy(w => w.Time).ToList(); + + // Rows come from the throughput series, so a span with no throughput point had no row + // at all - and took its latency and loss down with it. The gateway's SNMP counters are + // collected by whoever collects for the site, and on a site that leaves collection to + // the server there is nothing to buffer them: a server restart leaves a real hole in + // throughput while the agent's probe results replay into it perfectly. The chart drew + // the hole across every series, so backfilled latency and loss were invisible. + // + // Latency points landing in such a hole get a row of their own, with no throughput on + // it. Only in a hole: a point with throughput either side of it within a couple of + // sample intervals still rides that throughput point, so ordinary operation keeps + // exactly the rows it had and the throughput line does not turn dotted. Both series + // are already in hand - this adds no query. + var holeTolerance = TimeSpan.FromSeconds(Math.Max(sampleIntervalSeconds * 2, 10)); + var orphanTimes = new List(); + if (wanSorted.Count == 0) + { + orphanTimes.AddRange(rttSorted.Select(p => p.Time)); + } + else + { + var wi = 0; + foreach (var p in rttSorted) + { + while (wi + 1 < wanSorted.Count && wanSorted[wi + 1].Time <= p.Time) wi++; + var nearest = (p.Time - wanSorted[wi].Time).Duration(); + if (wi + 1 < wanSorted.Count) + { + var next = (wanSorted[wi + 1].Time - p.Time).Duration(); + if (next < nearest) nearest = next; + } + if (nearest > holeTolerance) orphanTimes.Add(p.Time); + } + } + + var rows = wanSorted + .Select(w => (Time: w.Time, Down: (double?)w.DownloadBps, Up: (double?)w.UploadBps)) + .Concat(orphanTimes.Select(t => (Time: t, Down: (double?)null, Up: (double?)null))) + .OrderBy(r => r.Time) + .ToList(); + var ri = 0; MonitoringInfluxClient.LatencyPoint? lastRtt = null; - var points = wanData.OrderBy(w => w.Time).Select(w => + var points = rows.Select(r => { - while (ri < rttSorted.Count && rttSorted[ri].Time <= w.Time) + while (ri < rttSorted.Count && rttSorted[ri].Time <= r.Time) lastRtt = rttSorted[ri++]; return new { - time = w.Time.ToString("o"), - downloadBps = w.DownloadBps, - uploadBps = w.UploadBps, + time = r.Time.ToString("o"), + downloadBps = r.Down, + uploadBps = r.Up, rttMs = lastRtt?.RttAvgMs, lossPercent = lastRtt?.LossPercent, }; @@ -230,7 +381,7 @@ public static void Map(WebApplication app) .Where(t => t.TargetType == targetType && t.Enabled && (t.AsnNumber == null || !WellKnownAsns.NonTransitInfrastructure.Contains(t.AsnNumber.Value))) .OrderBy(t => t.Name) - .Select(t => new { t.TargetId, t.Name, t.AutoLabel }) + .Select(t => new { t.TargetId, t.Name, t.AutoLabel, t.WanInterface, t.Address }) .ToListAsync(ct); if (targets.Count == 0) @@ -249,6 +400,10 @@ public static void Map(WebApplication app) // Role label ("gateway"/"switch"/"ap"/...) so the LAN flaky detector can // identify the gateway target and mask out gateway-outage windows. autoLabel = t.AutoLabel, + // WAN ownership (null = unstamped = primary) and address, so the chart's WAN + // filter can scope series client-side and pair the same host's per-WAN twins. + wanInterface = t.WanInterface, + address = t.Address, rtt = pts.Select(p => new { time = p.Time.ToString("o"), value = p.RttAvgMs }), loss = pts.Select(p => new { time = p.Time.ToString("o"), value = p.LossPercent }), }; @@ -260,9 +415,12 @@ public static void Map(WebApplication app) group.MapGet("/api/monitoring/wan-rate-chart", async ( MonitoringInfluxClient influx, UniFiConnectionService connectionService, + SiteDbContextFactory siteDbFactory, + SiteContextService siteContext, int? rangeHours, DateTime? from, DateTime? to, + string? wan, CancellationToken ct) => { DateTime queryFrom, queryTo; @@ -291,6 +449,36 @@ public static void Map(WebApplication app) } catch { } + // Explicit WAN (a UniFi wan key like "wan2", from the chart's WAN filter): that WAN's + // own counter interface - live, then its remembered profile row - replaces the default + // primary/active-uplink resolution above. Never a cross-WAN fallback: an unresolvable + // WAN returns an empty series rather than another WAN's throughput. + if (!string.IsNullOrWhiteSpace(wan)) + { + var wanGroup = NetworkOptimizer.UniFi.GatewayWanHelper.WanNetworkGroupFromKey(wan.Trim()); + string? counter = null; + try + { + counter = (await connectionService.GetWanInterfacesForGroupAsync(wanGroup, ct))?.CounterIfName; + } + catch { } + if (string.IsNullOrEmpty(counter) || string.IsNullOrEmpty(gatewayMac)) + { + try + { + await using var wdb = siteDbFactory.CreateForSite(siteContext.Slug, siteContext.IsDefault); + var profile = await wdb.WanProfiles.AsNoTracking() + .Where(w => w.WanNetworkgroup == wanGroup) + .OrderByDescending(w => w.UpdatedAt) + .FirstOrDefaultAsync(ct); + counter ??= profile?.CounterInterface; + gatewayMac = string.IsNullOrEmpty(gatewayMac) ? profile?.GatewayMac : gatewayMac; + } + catch { } + } + wanIfNames = string.IsNullOrEmpty(counter) ? null : new List { counter! }; + } + if (string.IsNullOrEmpty(gatewayMac) || wanIfNames == null || wanIfNames.Count == 0) return Results.Ok(new { download = Array.Empty(), upload = Array.Empty() }); diff --git a/src/NetworkOptimizer.Web/Program.cs b/src/NetworkOptimizer.Web/Program.cs index 6d8fa27eb2..7e0cb1040c 100644 --- a/src/NetworkOptimizer.Web/Program.cs +++ b/src/NetworkOptimizer.Web/Program.cs @@ -318,7 +318,7 @@ builder.Services.AddSingleton(); // Whether a site's agent collects instead of this server. Singleton: consulted by the // per-site collection loops and the probe executor factory, and it caches per slug. -builder.Services.AddSingleton(); +builder.Services.AddSiteScopedRegistry(); builder.Services.AddSiteScopedRegistry(); builder.Services.AddScoped(sp => sp.GetRequiredService() .GetFor(sp.GetRequiredService().Slug)); @@ -601,6 +601,10 @@ // Scoped - forwards to the current site's Influx client and database. builder.Services.AddScoped(); builder.Services.AddScoped(); +// Transient: every live-tile surface keeps its own selection state and re-render callback. +builder.Services.AddTransient(); +// Per-user teaching hints that retire once seen (UiHintKeys). +builder.Services.AddScoped(); builder.Services.AddSingleton(); // Per-site monitoring alert evaluators (target offline / device health / SFP DDM): // in-memory state machines keyed by target id / MAC, which repeat across sites, so @@ -609,6 +613,9 @@ builder.Services.AddSiteScopedRegistry(); // (The cable modem, ONT, and cellular alert evaluators are per site via // MonitoringAlertRegistry.) +// Loads the per-site WAN context (roles, labels, trace map) the WAN outage evaluator +// classifies against; the evaluator instances themselves live in MonitoringAlertRegistry. +builder.Services.AddSingleton(); // Upstream tracer is per site (isolated discovery state in each site's DB, traceroute // from the site's own vantage). Scoped resolution forwards to the current site's tracer; // the background re-discovery iterates sites via the registry. @@ -664,6 +671,8 @@ // Running a scan and curating findings are gated separately from the audit read surface. builder.Services.AddMutatingService(sp => sp.GetRequiredService()); builder.Services.AddScoped(); // Scoped - network diagnostics (trunk consistency, AP lock, etc.) +// Scoped - reads the gateway's traffic control over SSH for the Smart Queues shaper check +builder.Services.AddScoped(); // Mutating product services go through the declarative gate (design doc 06, gate 9): the // interface is proxied by MethodSecurityInterceptor, which authorizes the ambient caller against // the method's [RequireRole] and writes its [AuditAction] envelope. @@ -1018,20 +1027,16 @@ ProductVersion TEXT NOT NULL // Seed the Alerts & Schedule defaults into each site's DB too, so secondary // sites match the main site instead of showing blank lists. The main-DB seed // below only covers the default site. - var siteMissingRules = NetworkOptimizer.Alerts.DefaultAlertRules.GetDefaults() - .Where(r => !siteDb.AlertRules.Select(x => x.EventTypePattern).Contains(r.EventTypePattern)) - .ToList(); - if (siteMissingRules.Count > 0) + var siteSeededPatterns = StartupHelpers.SeedAlertRules( + siteDb, NetworkOptimizer.Alerts.DefaultAlertRules.GetDefaults()); + if (siteSeededPatterns.Count > 0) { - siteDb.AlertRules.AddRange(siteMissingRules); - siteDb.SaveChanges(); - app.Logger.LogInformation("Seeded {Count} alert rule(s) for site {Slug}", siteMissingRules.Count, site.Slug); + app.Logger.LogInformation("Seeded {Count} alert rule(s) for site {Slug}", siteSeededPatterns.Count, site.Slug); // Enable any freshly seeded modem/ONT rules for a secondary site that already // has the matching monitoring configured (mirrors the main-site seed below, // which secondary sites otherwise never got - a new ONT rule landed disabled). - var siteSeededPatterns = siteMissingRules.Select(m => m.EventTypePattern).ToHashSet(); - + // // Same one-time Device Offline enable as the main site, so managed sites match. AlertRuleAutoEnable.EnableNowThatItHasAPublisher( siteDb, "device.offline", "device.recovered", siteSeededPatterns, app.Logger); @@ -1039,6 +1044,7 @@ ProductVersion TEXT NOT NULL AlertRuleAutoEnable.EnableFreshlySeeded(siteDb, "cable_modem", siteSeededPatterns, () => siteDb.CmConfigurations.Any()); AlertRuleAutoEnable.EnableFreshlySeeded(siteDb, "ont", siteSeededPatterns, () => siteDb.OntConfigurations.Any()); AlertRuleAutoEnable.EnableFreshlySeeded(siteDb, "cellular", siteSeededPatterns, () => siteDb.ModemConfigurations.Any()); + AlertRuleAutoEnable.EnableFreshlySeeded(siteDb, "starlink", siteSeededPatterns, () => siteDb.StarlinkConfigurations.Any()); } if (NetworkOptimizer.Core.FeatureFlags.SchedulingEnabled && !siteDb.ScheduledTasks.Any()) @@ -1082,7 +1088,7 @@ ProductVersion TEXT NOT NULL app.Logger.LogInformation("Database journal mode: WAL (filesystem: {FilesystemType})", detectedFsType); } - // Seed default alert rules - insert any missing rules by EventTypePattern + // Seed default alert rules - insert any rule this database has never been seeded before { var defaults = NetworkOptimizer.Alerts.DefaultAlertRules.GetDefaults(); @@ -1097,22 +1103,15 @@ ProductVersion TEXT NOT NULL if (defaultSiteId == null || !db.SiteAgents.Any(a => a.SiteId == defaultSiteId)) defaults = defaults.Where(d => d.Source != "agent").ToList(); - var existingPatterns = db.AlertRules.Select(r => r.EventTypePattern).ToHashSet(); - var missing = defaults.Where(d => !existingPatterns.Contains(d.EventTypePattern)).ToList(); - if (missing.Count > 0) - { - db.AlertRules.AddRange(missing); - db.SaveChanges(); - app.Logger.LogInformation("Seeded {Count} new alert rules", missing.Count); - } + var seededPatterns = StartupHelpers.SeedAlertRules(db, defaults); + if (seededPatterns.Count > 0) + app.Logger.LogInformation("Seeded {Count} new alert rules", seededPatterns.Count); // Auto-enable freshly seeded modem/ONT rules for users who already have // configs. Only touches rules we just inserted - never re-enables rules // the user has manually disabled. - if (missing.Count > 0) + if (seededPatterns.Count > 0) { - var seededPatterns = missing.Select(m => m.EventTypePattern).ToHashSet(); - // Device Offline shipped disabled because nothing published device.offline until this // release. Enable that ONE rule as its publisher lands - keyed off the paired // device.recovered rule arriving, so it happens once and overrides no later choice. @@ -1122,6 +1121,7 @@ ProductVersion TEXT NOT NULL AlertRuleAutoEnable.EnableFreshlySeeded(db, "cable_modem", seededPatterns, () => db.CmConfigurations.Any()); AlertRuleAutoEnable.EnableFreshlySeeded(db, "ont", seededPatterns, () => db.OntConfigurations.Any()); AlertRuleAutoEnable.EnableFreshlySeeded(db, "cellular", seededPatterns, () => db.ModemConfigurations.Any()); + AlertRuleAutoEnable.EnableFreshlySeeded(db, "starlink", seededPatterns, () => db.StarlinkConfigurations.Any()); } } @@ -1237,6 +1237,10 @@ ProductVersion TEXT NOT NULL var ieeeOuiDb = app.Services.GetRequiredService(); await ieeeOuiDb.InitializeAsync(); +// Warm the agent-coverage flags before collection starts, so no synchronous gate answers +// "not covered" for a site that is while the cache fills. +await app.Services.GetRequiredService().WarmAsync(); + // Log admin auth startup configuration using (var startupScope = app.Services.CreateScope()) { @@ -1598,6 +1602,55 @@ internal static System.Security.Cryptography.X509Certificates.X509Certificate2 C password: null); } + /// + /// Inserts the default alert rules a database has never been given, and records every default + /// pattern it holds in SeededAlertRules so each one is seeded at most once per database. The + /// record is what makes a deletion stick: seeding used to key off AlertRules alone, so a rule + /// the user deleted (or a whole source they cleared out) came straight back on the next start. + /// + /// Patterns already present in AlertRules but not yet recorded are backfilled, including when + /// nothing was inserted, so existing installs stop resurrecting rules from here on. + /// is the caller's already-filtered list and is the only source of + /// recorded patterns - a default held back because the site lacks its capability (agent rules) + /// stays unrecorded and can still seed once that capability arrives. + /// + /// Database to seed (main or a site's). + /// Default rules this database should have. + /// The patterns inserted by this pass, for the auto-enable helpers to act on. + internal static HashSet SeedAlertRules( + NetworkOptimizerDbContext db, List defaults) + { + var existingPatterns = db.AlertRules.Select(r => r.EventTypePattern).ToHashSet(); + var recordedPatterns = db.SeededAlertRules.Select(s => s.EventTypePattern).ToHashSet(); + + var missing = defaults + .Where(d => !existingPatterns.Contains(d.EventTypePattern) && !recordedPatterns.Contains(d.EventTypePattern)) + .ToList(); + if (missing.Count > 0) + { + db.AlertRules.AddRange(missing); + db.SaveChanges(); + } + + var seededPatterns = missing.Select(m => m.EventTypePattern).ToHashSet(); + + var toRecord = defaults + .Select(d => d.EventTypePattern) + .Distinct() + .Where(p => !recordedPatterns.Contains(p) && (existingPatterns.Contains(p) || seededPatterns.Contains(p))) + .ToList(); + if (toRecord.Count > 0) + { + db.SeededAlertRules.AddRange(toRecord.Select(p => new NetworkOptimizer.Alerts.Models.SeededAlertRule + { + EventTypePattern = p + })); + db.SaveChanges(); + } + + return seededPatterns; + } + internal static (bool isFuse, string filesystemType) DetectFilesystem(string filePath) { if (!OperatingSystem.IsLinux()) diff --git a/src/NetworkOptimizer.Web/Services/AdminAuthCache.cs b/src/NetworkOptimizer.Web/Services/AdminAuthCache.cs index c316a0333e..a431d1e1e5 100644 --- a/src/NetworkOptimizer.Web/Services/AdminAuthCache.cs +++ b/src/NetworkOptimizer.Web/Services/AdminAuthCache.cs @@ -38,4 +38,21 @@ public void Store(string? hash, AdminPasswordSource source) /// Forces the next access to refresh (e.g. after the password is changed). public void Invalidate() => _entry = _entry with { RefreshedAt = DateTime.MinValue }; + + private string? _firstRunPassword; + + /// + /// Hands the just-generated first-run password to the Identity bootstrap, which runs + /// immediately after the first resolve and needs the plaintext to reset the admin + /// account's own hash. Held in memory only, and only until it is read once. + /// + public void PublishFirstRunPassword(string password) + => Interlocked.Exchange(ref _firstRunPassword, password); + + /// + /// Takes the first-run password published during this boot, clearing it so it is never + /// handed out twice. Null when this boot did not generate one. + /// + public string? ConsumeFirstRunPassword() + => Interlocked.Exchange(ref _firstRunPassword, null); } diff --git a/src/NetworkOptimizer.Web/Services/AdminAuthService.cs b/src/NetworkOptimizer.Web/Services/AdminAuthService.cs index 069de94686..468631e459 100644 --- a/src/NetworkOptimizer.Web/Services/AdminAuthService.cs +++ b/src/NetworkOptimizer.Web/Services/AdminAuthService.cs @@ -308,6 +308,13 @@ private async Task RefreshCacheIfNeededAsync(CancellationToken cancellationToken await SaveAdminSettingsToMainAsync(settings, cancellationToken); _cache.Store(hashedPassword, AdminPasswordSource.AutoGenerated); + + // Reaching here means there was no stored password at all - a first run, or an + // operator who just ran scripts/reset-password.*, which clears the row precisely + // to force this. Either way the printed password has to become the real login, so + // hand it to the Identity bootstrap running next: on a migrated install the admin + // account already exists, and its own hash is what sign-in actually checks. + _cache.PublishFirstRunPassword(generatedPassword); } catch (Exception ex) { @@ -333,7 +340,7 @@ private void StoreSource(string? passwordHash, AdminPasswordSource source, strin } /// - /// Generates a secure random password (16 characters, alphanumeric) + /// Generates a secure random password (16 characters, alphanumeric, always containing a digit) /// private static string GenerateSecurePassword() { @@ -342,13 +349,28 @@ private static string GenerateSecurePassword() var password = new char[16]; using var rng = RandomNumberGenerator.Create(); var bytes = new byte[16]; - rng.GetBytes(bytes); - for (int i = 0; i < password.Length; i++) + // Only 8 of the 55 characters are digits, so roughly one draw in twelve comes out with + // none at all - which the account policy requires (RequireDigit). Redrawing until one + // lands keeps every password uniform over the passwords that are actually acceptable, + // where patching a digit in at a fixed position would not. Bounded so this can never + // spin: at ~8% per draw, exhausting the attempts means a broken RNG, not bad luck. + for (var attempt = 0; attempt < 100; attempt++) { - password[i] = chars[bytes[i] % chars.Length]; + rng.GetBytes(bytes); + + for (int i = 0; i < password.Length; i++) + { + password[i] = chars[bytes[i] % chars.Length]; + } + + if (Array.Exists(password, char.IsDigit)) + return new string(password); } + // Unreachable in practice, but place a digit rather than hand back a password that the + // account policy would reject - that would fail the very reset it is generated for. + password[^1] = "23456789"[bytes[0] % 8]; return new string(password); } } diff --git a/src/NetworkOptimizer.Web/Services/AgentEnrollmentService.cs b/src/NetworkOptimizer.Web/Services/AgentEnrollmentService.cs index 2b65d377bb..a75c8f4d7d 100644 --- a/src/NetworkOptimizer.Web/Services/AgentEnrollmentService.cs +++ b/src/NetworkOptimizer.Web/Services/AgentEnrollmentService.cs @@ -25,6 +25,8 @@ public class AgentEnrollmentService : IAgentEnrollmentService private readonly IDbContextFactory _mainDbFactory; private readonly AgentTunnelRegistry _tunnelRegistry; private readonly SiteAgentCoverage _agentCoverage; + private readonly IServiceProvider _serviceProvider; + private readonly SiteTunnelRouting _tunnelRouting; private readonly ILogger _logger; private readonly Authorization.ISiteAccessFilter _siteAccess; @@ -33,15 +35,47 @@ public AgentEnrollmentService( AgentTunnelRegistry tunnelRegistry, Authorization.ISiteAccessFilter siteAccess, SiteAgentCoverage agentCoverage, + IServiceProvider serviceProvider, + SiteTunnelRouting tunnelRouting, ILogger logger) { _siteAccess = siteAccess; _mainDbFactory = mainDbFactory; _tunnelRegistry = tunnelRegistry; _agentCoverage = agentCoverage; + _serviceProvider = serviceProvider; + _tunnelRouting = tunnelRouting; _logger = logger; } + /// + /// Clears the console and device tunnel routing flags for a site. Both name a tunnel, so once + /// the site has no agent they can only point at something that will never answer. + /// + private async Task ClearAgentRoutingAsync(string siteSlug) + { + try + { + using var scope = _serviceProvider.CreateScope(); + scope.ServiceProvider.GetRequiredService().OverrideSite(siteSlug); + var db = scope.ServiceProvider.GetRequiredService(); + foreach (var key in new[] { UniFiConnectionService.ConsoleViaAgentKey, SiteTunnelRouting.DevicesViaAgentKey }) + { + var setting = await db.SystemSettings.FindAsync(key); + if (setting == null || !bool.TryParse(setting.Value, out var on) || !on) continue; + setting.Value = bool.FalseString; + } + await db.SaveChangesAsync(); + _tunnelRouting.Invalidate(siteSlug); + _logger.LogInformation("Cleared agent routing for site {Slug} - its last agent was removed", siteSlug); + } + catch (Exception ex) + { + // The agent is already gone; failing to tidy the flags must not fail the removal. + _logger.LogWarning(ex, "Could not clear agent routing flags for site {Slug}", siteSlug); + } + } + /// Agents registered for a site, newest first. public async Task> GetAgentsForSiteAsync(int siteId) { @@ -165,6 +199,22 @@ public async Task DeleteAgentAsync(string siteSlug, int agentId) await db.SaveChangesAsync(); DropLiveTunnel(agent.Id, agent.Name, "removed"); _logger.LogInformation("Removed agent {Name} (id {Id}) for site {SiteId}", agent.Name, agent.Id, agent.SiteId); + + // Removing the last agent leaves the main site nothing to route through, so its console and + // device routing flags are cleared with it. They outlived the agent otherwise, and every + // console read and SSH command went on addressing a tunnel that could never come up again. + // + // The main site only. A secondary site is reached ONLY through an agent, so those flags + // describe its sole access path rather than an option it took: clearing them strands the + // site on direct routing it cannot use, and the replacement agent does not restore them - + // the setup wizard writes them only when its proxy checkbox is ticked, and that defaults + // off. It would also silence the waiting-for-the-agent messages, which need the flags set + // to fire, leaving an operator mid-swap with generic connection failures instead. + if (siteSlug == SiteManagementService.DefaultSiteSlug + && !await db.SiteAgents.AnyAsync(a => a.SiteId == agent.SiteId)) + { + await ClearAgentRoutingAsync(siteSlug); + } } /// diff --git a/src/NetworkOptimizer.Web/Services/AgentOnGatewayDetector.cs b/src/NetworkOptimizer.Web/Services/AgentOnGatewayDetector.cs index db4378a741..fa67839540 100644 --- a/src/NetworkOptimizer.Web/Services/AgentOnGatewayDetector.cs +++ b/src/NetworkOptimizer.Web/Services/AgentOnGatewayDetector.cs @@ -46,6 +46,12 @@ public class AgentOnGatewayDetector // work without a system scope. private readonly ConcurrentDictionary _agentIp = new(); private readonly ConcurrentDictionary _refreshing = new(); + // The site's gateway addresses from the last resolution, so the per-connection check below can + // answer for an agent the site-level verdict never considered. Cached and refreshed on the same + // TTL as the verdict itself; a site with 2+ agents has one gateway either way. + private readonly ConcurrentDictionary Ips, DateTime At)> _gatewayIps = new(); + private readonly ConcurrentDictionary Ips, DateTime At)> _gatewayHostIps = new(); + private readonly ConcurrentDictionary _gatewayIpRefreshing = new(); public AgentOnGatewayDetector( AgentEnrollmentService enrollment, @@ -115,6 +121,95 @@ public async Task IsAgentOnGatewayAsync(string siteSlug, CancellationToken public string? LastKnownAgentIp(string siteSlug) => _agentIp.TryGetValue(siteSlug, out var ip) ? ip : null; + /// + /// Whether a specific address is one of the site's gateway addresses - the per-connection + /// counterpart to , for the questions that are about ONE + /// agent rather than about the site. A site with several agents has one gateway, but only one + /// of those agents may be sitting on it, and the site-level verdict cannot tell them apart: it + /// correlates against whichever agent the enrollment registry answers with. + /// + /// Deliberately not gated on the site being non-default. The site-level verdict keeps its + /// existing "false for the default site" contract for its existing consumers; this one answers + /// from the gateway addresses alone, so a main-site agent running on the gateway is recognized + /// as such - which is exactly the deployment multi-WAN contexts target. + /// + public async Task IsIpOnGatewayAsync(string siteSlug, string? ip, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(siteSlug) || string.IsNullOrWhiteSpace(ip)) + return false; + + var hasCached = _gatewayIps.TryGetValue(siteSlug, out var cached); + if (!hasCached || DateTime.UtcNow - cached.At >= CacheTtl) + { + var refresh = StartOrJoinGatewayIpRefresh(siteSlug); + if (!hasCached) + { + try + { + await refresh.WaitAsync(ct); + } + catch (OperationCanceledException) + { + // Caller gave up - the refresh itself continues and fills the cache. + } + hasCached = _gatewayIps.TryGetValue(siteSlug, out cached); + } + } + + return hasCached && cached.Ips.Contains(ip!.Trim(), StringComparer.OrdinalIgnoreCase); + } + + /// + /// The first of that is one of this site's gateway addresses, or + /// null when none is. + /// + /// The gateway address set is unchanged - this only asks the same question of more candidates. + /// An agent picks ONE address to report itself by, and on a gateway that choice is whichever + /// Ethernet interface the kernel enumerates first, which can easily be an uplink the console + /// never lists as the gateway's own. Comparing every address the host holds answers "is this + /// that machine" instead of "did it happen to name the address we know". + /// + /// + /// Returns the MATCHING address rather than a bool because callers that skip the gateway's own + /// target need the address the site knows it by, not the one the agent named itself with. + /// + /// + public async Task MatchGatewayAddressAsync( + string siteSlug, IEnumerable candidates, CancellationToken ct = default) + { + var addresses = candidates.Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim()).ToList(); + if (addresses.Count == 0) return null; + + // Narrow set first, so a caller using the answer as an ADDRESS gets the one the site knows + // the gateway by rather than some other interface of the same box. + foreach (var candidate in addresses) + if (await IsIpOnGatewayAsync(siteSlug, candidate, ct)) return candidate; + + if (!_gatewayHostIps.TryGetValue(siteSlug, out var host)) return null; + return addresses.FirstOrDefault(c => host.Ips.Contains(c, StringComparer.OrdinalIgnoreCase)); + } + + /// One in-flight gateway-address resolution per site; the result lands in the cache. + private Task StartOrJoinGatewayIpRefresh(string siteSlug) => + _gatewayIpRefreshing.GetOrAdd(siteSlug, slug => Task.Run(async () => + { + try + { + using var cts = new CancellationTokenSource(RefreshTimeout); + var connection = _siteConnections.GetFor(slug); + if (connection.IsConnected && connection.Client != null) + await ResolveGatewayIpsAsync(slug, connection.Client, cts.Token); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Gateway address resolution failed for site {Slug}", slug); + } + finally + { + _gatewayIpRefreshing.TryRemove(slug, out _); + } + })); + /// One in-flight refresh per site; result lands in the cache, and first-time callers await the returned task. private Task StartOrJoinRefresh(string siteSlug) => _refreshing.GetOrAdd(siteSlug, slug => Task.Run(async () => @@ -154,14 +249,46 @@ private async Task RefreshAsync(string siteSlug, CancellationToken ct) return; } - var devices = await connection.Client.GetDevicesAsync(ct) ?? new(); + var gatewayIps = await ResolveGatewayIpsAsync(siteSlug, connection.Client, ct); + + var onGateway = gatewayIps.Contains(agentIp!, StringComparer.OrdinalIgnoreCase); + _cache[siteSlug] = (onGateway, DateTime.UtcNow); + _agentIp[siteSlug] = agentIp!; + await PersistAsync(siteSlug, onGateway); + } + + /// + /// The site's gateway addresses: every gateway device's reported IP (on a gateway agent that is + /// the WAN address) plus the LAN-side gateway IP, in case the agent's own detection landed + /// there instead. Caches what it found so the per-connection check can answer without its own + /// console round trip. + /// + private async Task> ResolveGatewayIpsAsync( + string siteSlug, UniFi.UniFiApiClient client, CancellationToken ct) + { + var devices = await client.GetDevicesAsync(ct) ?? new(); var gatewayIps = devices .Where(d => d.DeviceType == DeviceType.Gateway && !string.IsNullOrEmpty(d.Ip)) .Select(d => d.Ip!) .ToList(); + + // Superset, cached alongside and never mixed into the set above: EVERY address the console + // reports the gateway holding, for the one question that needs it - is an agent running on + // this box. A gateway holds a dozen addresses and an agent that reports only one may name + // any of them, so the narrow set answers that question with a false no. Deliberately built + // from the gateway's own interfaces only; inform_ip and connect_request_ip are the console's + // loopback and would match every host alive, so they are not read at all. + var hostIps = new List(gatewayIps); + foreach (var device in devices.Where(d => d.DeviceType == DeviceType.Gateway)) + { + AddHostIp(hostIps, device.LanIp); + AddHostIp(hostIps, device.ConfigNetwork?.Ip); + foreach (var port in device.PortTable ?? new()) + AddHostIp(hostIps, port.Ip); + } try { - var lanIp = await Monitoring.SnmpDeviceRules.ResolveGatewayLanIpAsync(connection.Client, ct); + var lanIp = await Monitoring.SnmpDeviceRules.ResolveGatewayLanIpAsync(client, ct); if (!string.IsNullOrEmpty(lanIp)) gatewayIps.Add(lanIp!); } @@ -170,10 +297,28 @@ private async Task RefreshAsync(string siteSlug, CancellationToken ct) _logger.LogDebug(ex, "Gateway LAN IP resolution failed for site {Slug} during on-gateway detection", siteSlug); } - var onGateway = gatewayIps.Contains(agentIp!, StringComparer.OrdinalIgnoreCase); - _cache[siteSlug] = (onGateway, DateTime.UtcNow); - _agentIp[siteSlug] = agentIp!; - await PersistAsync(siteSlug, onGateway); + if (gatewayIps.Count > 0) + { + _gatewayIps[siteSlug] = (gatewayIps, DateTime.UtcNow); + foreach (var ip in gatewayIps) AddHostIp(hostIps, ip); + _gatewayHostIps[siteSlug] = (hostIps, DateTime.UtcNow); + } + return gatewayIps; + } + + /// + /// Adds an address to the host set when it can identify a host: not empty, not a duplicate, and + /// neither loopback nor link-local - the two an unrelated machine could hold as readily as this + /// one, where a match would mean nothing. + /// + private static void AddHostIp(List hostIps, string? ip) + { + var value = ip?.Trim(); + if (string.IsNullOrEmpty(value)) return; + if (!System.Net.IPAddress.TryParse(value, out var parsed)) return; + if (System.Net.IPAddress.IsLoopback(parsed)) return; + if (value.StartsWith("169.254.", StringComparison.Ordinal)) return; + if (!hostIps.Contains(value, StringComparer.OrdinalIgnoreCase)) hostIps.Add(value); } /// diff --git a/src/NetworkOptimizer.Web/Services/AgentProbeResultSink.cs b/src/NetworkOptimizer.Web/Services/AgentProbeResultSink.cs index d02951365e..3f9d9c3633 100644 --- a/src/NetworkOptimizer.Web/Services/AgentProbeResultSink.cs +++ b/src/NetworkOptimizer.Web/Services/AgentProbeResultSink.cs @@ -30,6 +30,7 @@ public class AgentProbeResultSink private readonly Monitoring.DeviceTransitionTracker _deviceTransitions; private readonly MonitoringAlertRegistry _alertRegistry; private readonly ICredentialProtectionService _credentialProtection; + private readonly Monitoring.IspHealth.IspHealthRegistry _ispHealthRegistry; private readonly ILogger _logger; // Counter delta cache for agent-relayed interface samples. Key = @@ -112,8 +113,12 @@ public AgentProbeResultSink( SiteAgentCoverage agentCoverage, AgentOnGatewayDetector onGatewayDetector, IAgentEnrollmentService enrollment, + AgentTunnelRegistry tunnelRegistry, + Monitoring.IspHealth.IspHealthRegistry ispHealthRegistry, ILogger logger) { + _ispHealthRegistry = ispHealthRegistry; + _tunnelRegistry = tunnelRegistry; _siteDbFactory = siteDbFactory; _influxRegistry = influxRegistry; _liveStatsRegistry = liveStatsRegistry; @@ -133,6 +138,7 @@ public AgentProbeResultSink( private readonly SiteAgentCoverage _agentCoverage; private readonly AgentOnGatewayDetector _onGatewayDetector; private readonly IAgentEnrollmentService _enrollment; + private readonly AgentTunnelRegistry _tunnelRegistry; /// /// Called once per connection after the hello exchange, and again by the periodic refresh. @@ -245,7 +251,20 @@ private async Task ReconnectConsoleIfViaAgentAsync(AgentTunnelConnection connect await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None); if (siteConnection.IsConnected) + { await PushSnmpConfigAsync(connection, CancellationToken.None); + + // Both halves are up now, so anything computed before this point saw a partial + // site. A report produced between server start and this moment is missing whatever + // arrives through the console - SNMP above all, which is what classifies load, so + // an early compute finds no loaded windows and reports a different score for the + // same day. It is then cached and served until something evicts it, which is why a + // cold report and a warm one disagreed with nothing in between to reconcile them. + _ispHealthRegistry.InvalidateSite(connection.SiteSlug); + _logger.LogDebug( + "Agent and console both up for site {Slug}; dropping any ISP Health computed without them", + connection.SiteSlug); + } } catch (Exception ex) { @@ -284,8 +303,54 @@ public async Task PushProbeConfigAsync(AgentTunnelConnection connection, Cancell .AsNoTracking() .Where(t => t.Enabled) .ToListAsync(ct); + // Before anything reads a context's binding, give one back to any context that lost the + // chance to have one. Runs here because this is the push that follows an agent's hello, + // which is exactly when an upgraded agent first reports it can bind. + if (await HealUnboundGatewayContextsAsync(db, connection, ct)) + _ = await db.SaveChangesAsync(ct); var contextsById = await db.WanContexts.AsNoTracking().ToDictionaryAsync(c => c.Id, ct); + // An agent that owns a WAN context is there to measure that WAN and nothing else: it + // sits behind a policy-routed source or binds the WAN's own interface, so every probe + // it runs leaves by that WAN. Handing it the site's ordinary targets as well would + // measure the secondary WAN and file the result under the primary. Only true once a + // context names this agent, so a site with no contexts pushes exactly what it always + // has. + // Steered means the agent's OWN default route leaves by a WAN that is not the primary - + // a probe box the gateway policy-routes by MAC, or one running with agent.json's + // probeSourceIp. It is a vantage behind that WAN and nothing else, so it must not + // probe anything the primary owns. + // + // Two ways an agent is NOT steered even while serving a context. It binds per probe + // (its context names an interface - a gateway agent), so its own route is untouched. + // Or its context IS the primary's, which needs no steering to reach: on a failover-only + // site every unpinned box already leaves by the primary. Both keep the agent eligible + // as the site's collector, which on a gateway-only site it has to be. + var primaryWanKey = await ResolvePersistedPrimaryWanKeyAsync(db, ct); + var agentIsSteeredToWan = contextsById.Values.Any(c => + c.AgentId == connection.AgentId + && string.IsNullOrEmpty(c.InterfaceName) + && !IsPrimaryWanContext(c, primaryWanKey)); + + // Exactly one agent probes the unassigned (primary-WAN) targets. Several agents on a + // site used to each get the whole set as extra vantage points, which on a site running + // an agent per WAN means every primary target probed N times for one number. The owner + // is the lowest-id agent that is CONNECTED and not steered: deterministic, so a refresh + // does not move the pool around, and self-healing, because the next agent takes it over + // on the following push if the owner drops. Steered agents are never eligible - their + // probes leave by the wrong WAN. + // Only when an agent collects for this site at all. On the main site with collection + // left to the server, the server probes the unassigned pool itself, and the results of + // an agent probing it too are discarded on arrival by ShouldRecordResult - so pushing + // them means the agent runs a set of probes for nothing. The push has to ask the same + // question the record does, or the two disagree about whose numbers count. + var agentCoversPrimary = !isDefault || await _agentCoverage.CoversAsync(connection.SiteSlug); + var unassignedOwnerId = agentCoversPrimary + ? SelectCollectorAgentId( + _tunnelRegistry.GetForSite(connection.SiteSlug).Select(c => c.AgentId), + contextsById.Values, primaryWanKey, connection.AgentId) + : NoCollectorAgentId; + // An agent running ON the gateway cannot usefully probe it: the target is the box the // probe runs on, so every reply is loopback - 0 ms and no loss - which reads as a // perfectly healthy gateway precisely when it might not be. Skipped for this agent at @@ -296,9 +361,15 @@ public async Task PushProbeConfigAsync(AgentTunnelConnection connection, Cancell // this runs on the tunnel's background path with no caller context, and the gate threw - // taking the whole push with it, so the site got no targets at all and its monitoring // read as total loss. - var selfAddress = await _onGatewayDetector.IsAgentOnGatewayAsync(connection.SiteSlug) - ? _onGatewayDetector.LastKnownAgentIp(connection.SiteSlug) - : null; + // Asked per connection rather than per site: with several agents the site-level verdict + // correlates against whichever one the registry answers with, so it would skip the + // gateway target for an agent that is not on the gateway - and miss it for the one that + // is. + // The MATCHED address, not the agent's own reported one: the site's target for the + // gateway carries the address the console knows it by, which is not necessarily the + // address the agent named itself with. + var selfAddress = await _onGatewayDetector.MatchGatewayAddressAsync( + connection.SiteSlug, connection.HostAddresses, ct); var skippedSelf = 0; var config = new ProbeConfig(); @@ -310,13 +381,16 @@ public async Task PushProbeConfigAsync(AgentTunnelConnection connection, Cancell skippedSelf++; continue; } - // Targets in an agent-assigned WAN context go only to that agent - // (typically a probe-only instance bound behind the right WAN); - // unassigned targets go to every agent as extra vantage points. - if (target.WanContextId is int contextId - && contextsById.TryGetValue(contextId, out var context) - && context.AgentId is int assignedAgent - && assignedAgent != connection.AgentId) + // Context targets are that context's alone: its assigned agent, or no agent when + // the context is server-probed (the server's own prober binds the source IP). + // Only UNASSIGNED targets fan out to every ordinary agent as extra vantage + // points - except to an agent that owns a context, which measures only that. A + // WanContextId whose row is gone counts as a context with no agent (pushed + // nowhere) rather than as unassigned - conservative until the row is cleaned up. + var context = target.WanContextId is int contextId + && contextsById.TryGetValue(contextId, out var found) ? found : null; + if (!ShouldPushTargetToAgent(target.WanContextId != null, context?.AgentId, connection.AgentId, + agentIsSteeredToWan, unassignedOwnerId, IsFabricTarget(target.TargetType))) continue; config.Targets.Add(new ProbeTargetSpec { @@ -327,6 +401,11 @@ public async Task PushProbeConfigAsync(AgentTunnelConnection connection, Cancell PollIntervalSeconds = target.PollIntervalSeconds, PingCount = target.PingCount, TargetType = target.TargetType.ToString().ToLowerInvariant(), + // The context's bind rides the target: an interface name for an + // on-gateway agent, a source IP for a policy-routed one. The agent + // prefers this over its own agent.json default, so one agent can + // still serve a context while probing on its own route elsewhere. + SourceIp = ResolveSpecSourceIp(context, connection.AgentId), }); } @@ -342,6 +421,285 @@ public async Task PushProbeConfigAsync(AgentTunnelConnection connection, Cancell } } + /// + /// The one agent that collects for a site: its SNMP, its fabric targets, and the primary WAN's + /// targets. The lowest-id CONNECTED agent that is not steered behind a secondary WAN. + /// + /// Lowest-id makes it deterministic, so a refresh does not move the workload around; taking it + /// from the connected set makes it self-healing, because the next agent picks the work up on + /// the following push if the holder drops. Steered agents are never eligible - everything they + /// send leaves by the wrong WAN. is returned when nothing is + /// eligible, which keeps a lone steered agent collecting rather than leaving a site dark. + /// + /// + /// + /// Stands in for "no agent collects here", where the server does it. Never a real agent id, so + /// every ownership comparison simply fails. + /// + internal const int NoCollectorAgentId = -1; + + internal static int SelectCollectorAgentId( + IEnumerable connectedAgentIds, + IEnumerable contexts, + string? primaryWanKey, + int fallbackAgentId) + { + var contextList = contexts as IReadOnlyCollection ?? contexts.ToList(); + return connectedAgentIds + .Where(id => !contextList.Any(c => + c.AgentId == id + && string.IsNullOrEmpty(c.InterfaceName) + && !IsPrimaryWanContext(c, primaryWanKey))) + .DefaultIfEmpty(fallbackAgentId) + .Min(); + } + + /// + /// Which agent currently collects for a site, for display. Same answer the push path acts on, + /// asked from one place so the page cannot disagree with what is actually happening. Null when + /// no agent is connected. + /// + public async Task GetCollectorAgentIdAsync(string siteSlug, CancellationToken ct = default) + { + var connected = _tunnelRegistry.GetForSite(siteSlug).Select(c => c.AgentId).ToList(); + if (connected.Count == 0) return null; + try + { + var isDefault = siteSlug == SiteManagementService.DefaultSiteSlug; + await using var db = _siteDbFactory.CreateForSite(siteSlug, isDefault); + var contexts = await db.WanContexts.AsNoTracking().ToListAsync(ct); + var primaryWanKey = await ResolvePersistedPrimaryWanKeyAsync(db, ct); + return SelectCollectorAgentId(connected, contexts, primaryWanKey, connected.Min()); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not resolve the collector agent for site {Slug}", siteSlug); + return connected.Min(); + } + } + + /// + /// The primary WAN's key as the last connected compute recorded it, or null when none has. + /// Read from the site's WanProfiles because this path has no console to ask, and a WAN's name + /// says nothing about its role. Null means unknown: callers must not read it as "not primary". + /// + /// + /// Fills the bind interface for this agent's contexts that have none, when the agent runs on the + /// gateway and can bind. + /// + /// The state is reachable without any mistake: save a vantage while the agent is too old to + /// offer a binding, then update the agent. The capability arrives, the empty configuration does + /// not change, and the probes go on leaving by the gateway's default route while their results + /// are filed under the context's WAN - a wrong number that looks exactly like a right one. A + /// policy-based route cannot rescue it either, because routing policy does not govern the + /// gateway's OWN egress; binding the interface is the only mechanism there is. + /// + /// + /// Only ever fills an empty binding, so it cannot overwrite a choice. The interface comes from + /// the WAN's persisted data path - the logical uplink, ppp0 on PPPoE rather than the physical + /// port - so it needs no console call and works while the console is unreachable. + /// + /// + /// Whether anything changed and the caller should save. + private async Task HealUnboundGatewayContextsAsync( + NetworkOptimizerDbContext db, AgentTunnelConnection connection, CancellationToken ct) + { + if (connection.SupportsSourceBind != true) return false; + var unbound = await db.WanContexts + .Where(c => c.AgentId == connection.AgentId + && (c.InterfaceName == null || c.InterfaceName == "") + && (c.ProbeSourceIp == null || c.ProbeSourceIp == "") + && c.WanInterface != null && c.WanInterface != "") + .ToListAsync(ct); + if (unbound.Count == 0) return false; + + // Asked only when there is something to heal: it can await a console round trip. + if (await _onGatewayDetector.MatchGatewayAddressAsync( + connection.SiteSlug, connection.HostAddresses, ct) == null) + return false; + + var profiles = await db.WanProfiles.AsNoTracking().ToListAsync(ct); + var healed = false; + foreach (var context in unbound) + { + var key = GatewayWanHelper.WanInterfaceKeyFromKey(context.WanInterface!); + var dataPath = profiles.FirstOrDefault(p => + !string.IsNullOrEmpty(p.WanNetworkgroup) + && string.Equals(GatewayWanHelper.WanInterfaceKeyFromKey(p.WanNetworkgroup), key, + StringComparison.OrdinalIgnoreCase))?.DataPathInterface; + if (string.IsNullOrEmpty(dataPath)) continue; + context.InterfaceName = dataPath; + healed = true; + _logger.LogInformation( + "WAN vantage '{Name}' had no binding; bound it to {Interface} for agent {Id} (site {Slug})", + context.Name, dataPath, connection.AgentId, connection.SiteSlug); + } + return healed; + } + + private static async Task ResolvePersistedPrimaryWanKeyAsync( + NetworkOptimizerDbContext db, CancellationToken ct) + { + var group = (await db.WanProfiles.AsNoTracking() + .FirstOrDefaultAsync(w => w.IsPrimary == true, ct))?.WanNetworkgroup; + return string.IsNullOrEmpty(group) ? null : GatewayWanHelper.WanInterfaceKeyFromKey(group); + } + + /// + /// Whether a context measures the primary WAN. False when the primary is unknown: an agent is + /// only excused from being treated as steered on a positive answer, so an unresolved primary + /// leaves the conservative reading in place rather than handing it the site's targets. + /// + internal static bool IsPrimaryWanContext(WanContext context, string? primaryWanKey) => + !string.IsNullOrEmpty(primaryWanKey) + && !string.IsNullOrEmpty(context.WanInterface) + && string.Equals(GatewayWanHelper.WanInterfaceKeyFromKey(context.WanInterface!), + primaryWanKey, StringComparison.OrdinalIgnoreCase); + + /// + /// Whether a target belongs in one agent's pushed set. + /// + /// Every target has exactly one prober, and which one depends on what the target measures. + /// + /// FABRIC targets - the gateway, switches, APs, anything inside the LAN - never cross a WAN, so + /// no WAN owns them and a context could not mean anything for one. They go to the site's + /// collector, the same agent that polls SNMP: it is the one inside the network, and pairing the + /// two keeps a device's counters and its reachability measured from the same place. + /// + /// WAN targets belong to the WAN they leave by: a context's targets to that context's agent, + /// and the unassigned ones - the primary's - to ONE agent rather than all of them, so a site + /// running an agent per WAN does not probe every primary target once per agent for one number. + /// + /// A STEERED agent is probe-only for its context: everything it sends leaves by that WAN, so a + /// primary target probed from it would measure the wrong path and be recorded as the primary's. + /// An interface-bound (gateway) agent is not steered - it binds each context probe to that + /// WAN's interface while its own route stays the primary - so it can serve contexts AND be the + /// site's collector, which on a gateway-only site it has to be. + /// + /// Whether the target belongs to ANY WAN context. A context + /// target is that context's alone: its assigned agent when it has one, or - for a source-IP + /// (server-probed) context - NO agent at all, because an ordinary agent would probe it over + /// its own primary route while the result gets tagged with the secondary WAN's key, + /// corrupting that WAN's score now that the tag is read. + /// Agent assigned to the target's WAN context; null when the target has no context, or its context has no agent (server-probed). + /// The agent being pushed to. + /// Whether a context names this agent WITHOUT an interface + /// to bind - i.e. the whole box sits behind one WAN. + /// The one agent that collects for the site: fabric targets + /// and the primary WAN's. + /// Whether the target is inside the LAN, so no WAN owns it. + internal static bool ShouldPushTargetToAgent( + bool targetHasContext, int? contextAgentId, int agentId, bool agentIsSteeredToWan, + int unassignedOwnerId, bool targetIsFabric = false) + => targetIsFabric + ? !agentIsSteeredToWan && unassignedOwnerId == agentId + : targetHasContext + ? contextAgentId == agentId + : !agentIsSteeredToWan && unassignedOwnerId == agentId; + + /// + /// Whether a target sits inside the LAN, where no WAN is involved and a WAN context would mean + /// nothing. Fabric is the type the discovery tier gives the gateway, switches and APs. + /// + internal static bool IsFabricTarget(MonitoringTargetType targetType) => + targetType == MonitoringTargetType.Fabric; + + /// + /// The source an agent binds this target's probes to: the context's interface when it has one, + /// otherwise its source IP, and empty for anything the agent is not running on that context's + /// behalf. Empty leaves the agent on its own configured default, which is what every target + /// carried before contexts existed. + /// + internal static string ResolveSpecSourceIp(WanContext? context, int agentId) + => context != null && context.AgentId == agentId + ? context.InterfaceName ?? context.ProbeSourceIp ?? "" + : ""; + + /// + /// Whether a result an agent sent should be written. + /// + /// Coverage governs primary-path measurement: a main-site agent that is not covering the site + /// is a second prober for targets the server is already probing, and its results are dropped so + /// the two cadences don't saw across the same series. A context's targets are not that - the + /// server never probes them (it cannot reach the secondary WAN), so the assigned agent's + /// results are the only ones there are and coverage has no bearing on them. + /// + internal static bool ShouldRecordResult(bool agentCoversPrimary, int? contextAgentId, int agentId) + => agentCoversPrimary || contextAgentId == agentId; + + /// + /// Whether an agent should be sent the site's SNMP config and speed-test server list. + /// + /// A STEERED agent is a probe vantage behind one WAN, not a second collector: polling SNMP + /// from it would double every counter the site already collects, and it serves no speed tests. + /// An interface-bound (gateway) agent is a collector that also serves contexts, so it keeps + /// both - a site whose only agent is on the gateway must still get its SNMP from somewhere. + /// False only once a steered context names it, so a site with no contexts is unaffected. + /// + internal static bool ShouldPushSiteCollectionConfig(bool agentIsSteeredToWan) => !agentIsSteeredToWan; + + /// + /// Whether this agent sits ENTIRELY behind one WAN: a context names it and gives no interface + /// to bind, so the box itself is policy-routed out that WAN. An agent whose contexts all name + /// an interface binds per probe and still routes normally, so it is not steered. Answers false + /// when the site database cannot be read, which leaves every gate on this at the behavior it + /// has today rather than standing an agent down on a hiccup. + /// + private async Task IsSteeredToWanAgentAsync(AgentTunnelConnection connection, CancellationToken ct) + { + try + { + var isDefault = connection.SiteSlug == SiteManagementService.DefaultSiteSlug; + await using var db = _siteDbFactory.CreateForSite(connection.SiteSlug, isDefault); + var primaryWanKey = await ResolvePersistedPrimaryWanKeyAsync(db, ct); + var contexts = await db.WanContexts.AsNoTracking() + .Where(c => c.AgentId == connection.AgentId + && (c.InterfaceName == null || c.InterfaceName == "")) + .ToListAsync(ct); + return contexts.Any(c => !IsPrimaryWanContext(c, primaryWanKey)); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read WAN contexts for agent {Id} (site {Slug})", + connection.AgentId, connection.SiteSlug); + return false; + } + } + + /// + /// Whether this agent owns any WAN context on its site, however that context binds. The test for + /// "is there anything worth reading this agent's results for" - the per-result check below then + /// decides which of them to keep. + /// + private async Task AgentOwnsAnyContextAsync(AgentTunnelConnection connection, CancellationToken ct) + { + try + { + await using var db = _siteDbFactory.CreateForSite( + connection.SiteSlug, connection.SiteSlug == SiteManagementService.DefaultSiteSlug); + return await db.WanContexts.AsNoTracking().AnyAsync(c => c.AgentId == connection.AgentId, ct); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read WAN contexts for agent {Id} (site {Slug})", + connection.AgentId, connection.SiteSlug); + return false; + } + } + + /// + /// Re-pushes probe config to every connected agent of a site. Reassigning a WAN context moves + /// targets between agents, and both ends have to hear about it: the agent losing the context + /// keeps probing what it no longer owns until it is told otherwise, and the one gaining it does + /// not start until it is. The periodic refresh would settle both within a minute; this makes + /// the edit take effect when the user makes it. + /// + public async Task PushProbeConfigToSiteAsync(string siteSlug, CancellationToken ct = default) + { + foreach (var connection in _tunnelRegistry.GetForSite(siteSlug)) + await PushProbeConfigAsync(connection, ct); + } + /// /// Pushes the WAN speed-test server list (global, main database) so the /// agent can serve its /wan/ redirect without the external servers needing @@ -351,6 +709,10 @@ public async Task PushProbeConfigAsync(AgentTunnelConnection connection, Cancell /// public async Task PushWanSpeedTestConfigAsync(AgentTunnelConnection connection, CancellationToken ct) { + // A context-assigned agent serves no speed test page, so it has no /wan/ redirect to + // resolve and no reason to hold the server list. + if (!ShouldPushSiteCollectionConfig(await IsSteeredToWanAgentAsync(connection, ct))) + return; try { await using var db = _siteDbFactory.CreateForSite(SiteManagementService.DefaultSiteSlug, isDefault: true); @@ -384,12 +746,23 @@ public async Task PushWanSpeedTestConfigAsync(AgentTunnelConnection connection, /// connection, filtered and addressed by the same SnmpDeviceRules the /// local collection agent uses. A default-site agent gets SNMP config only when the site is /// configured for its agent to cover it - otherwise the server's own collection agent is still - /// polling those devices and pushing a second poller would double every sample. + /// polling those devices and pushing a second poller would double every sample. A + /// context-assigned agent gets an explicitly disabled config for the same reason: it is a probe + /// vantage behind one WAN, and the site already has a collector. /// public async Task PushSnmpConfigAsync(AgentTunnelConnection connection, CancellationToken ct) { var isDefault = connection.SiteSlug == SiteManagementService.DefaultSiteSlug; if (isDefault && !await _agentCoverage.CoversAsync(connection.SiteSlug)) return; + if (!ShouldPushSiteCollectionConfig(await IsSteeredToWanAgentAsync(connection, ct))) + { + // Disabled rather than absent: an agent that polled before being assigned a context + // keeps polling on its last config until a new one tells it to stop. + connection.TrySend(new ServerMessage { SnmpConfig = new SnmpConfig { Enabled = false } }); + _logger.LogDebug("Agent {Id} (site {Slug}) probes a WAN context; SNMP polling left to the site's collector", + connection.AgentId, connection.SiteSlug); + return; + } try { await using var db = _siteDbFactory.CreateForSite(connection.SiteSlug, isDefault); @@ -1239,6 +1612,37 @@ public async Task RecordBatchAsync(AgentTunnelConnection connection, ProbeResult if (batch.Results.Count == 0) return; var isDefault = connection.SiteSlug == SiteManagementService.DefaultSiteSlug; + + // A main-site agent that is not covering the site probes targets the server is probing too, + // and both write the same series at different cadences - which reads as a sawtooth on the + // charts rather than as duplicate points. So its results are dropped. (The push path does + // NOT refuse those targets, which an earlier comment here claimed: the agent is sent the + // site's targets as an extra vantage point, probes them, and everything it reports lands + // here to be discarded.) + // + // A WAN context's targets are the exception: the server cannot reach the secondary WAN, so + // it never probes them, and the assigned agent's results are the only measurement there is. + // Below, each result is judged against the target's own context rather than the whole batch + // being refused here. + var agentCoversPrimary = !isDefault || await _agentCoverage.CoversAsync(connection.SiteSlug); + // Nothing this agent sends can be kept, so drop the batch without loading the site's + // targets for it - which is what happened before contexts existed, and still happens on + // every site that has none. + // + // The question is whether the agent owns ANY context, not whether it is steered. Those are + // the same for an agent whose whole box is routed out a WAN, and different for one on the + // gateway that binds each probe: binding leaves its own route alone, so it is not steered, + // yet its context's results are still the only measurement that WAN has. Asking the steering + // question here threw away every result from a gateway vantage the moment it was given an + // interface to bind. + if (!agentCoversPrimary && !await AgentOwnsAnyContextAsync(connection, ct)) + { + _logger.LogDebug( + "Dropped a batch of {Count} result(s) from agent {Id}: the main site collects for itself and this agent owns no WAN context", + batch.Results.Count, connection.AgentId); + return; + } + await using var db = _siteDbFactory.CreateForSite(connection.SiteSlug, isDefault); var ids = batch.Results.Select(r => r.TargetId).Distinct().ToList(); var targets = await db.MonitoringTargets @@ -1254,7 +1658,17 @@ public async Task RecordBatchAsync(AgentTunnelConnection connection, ProbeResult // configures itself from that site's MonitoringSettings on first use. var influx = _influxRegistry.GetFor(connection.SiteSlug); if (!influx.IsConfigured) await influx.ReconfigureAsync(ct); + // The latency writes below no-op silently on an unconfigured client, so a batch arriving + // while the site's Influx settings are unreadable - the buffered backlog being the first + // thing an agent sends after a restart - is swallowed with nothing to show for it. Say so; + // the batch still runs, because the live caches and alerting do not depend on Influx and + // are worth having either way. + if (!influx.IsConfigured) + _logger.LogWarning( + "{Count} result(s) from agent {Id} (site {Slug}) will not be stored: the site's InfluxDB client is not configured", + batch.Results.Count, connection.AgentId, connection.SiteSlug); var liveStats = _liveStatsRegistry.GetFor(connection.SiteSlug); + var discarded = 0; foreach (var result in batch.Results) { @@ -1264,9 +1678,16 @@ public async Task RecordBatchAsync(AgentTunnelConnection connection, ProbeResult continue; } + var context = target.WanContextId is int contextId && contextsById.TryGetValue(contextId, out var found) + ? found : null; + if (!ShouldRecordResult(agentCoversPrimary, context?.AgentId, connection.AgentId)) + { + discarded++; + continue; + } + var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(result.TimestampUnixMs).UtcDateTime; - var wanContext = target.WanContextId is int contextId && contextsById.TryGetValue(contextId, out var context) - ? context.Name : null; + var wanContext = context?.InfluxWanTag; await influx.WriteLatencyAsync( targetId: target.TargetId, @@ -1338,6 +1759,11 @@ await influx.WriteLatencyAsync( target.LastVerified = timestamp; } + if (discarded > 0) + _logger.LogDebug( + "Dropped {Count} result(s) from agent {Id}: the main site is collecting for itself and these targets are not in a WAN context this agent owns", + discarded, connection.AgentId); + await db.SaveChangesAsync(ct); } diff --git a/src/NetworkOptimizer.Web/Services/AgentProbeService.cs b/src/NetworkOptimizer.Web/Services/AgentProbeService.cs index 0f6eeb4cd0..2e5b54d23c 100644 --- a/src/NetworkOptimizer.Web/Services/AgentProbeService.cs +++ b/src/NetworkOptimizer.Web/Services/AgentProbeService.cs @@ -33,11 +33,29 @@ public AgentProbeService(AgentTunnelRegistry registry, ILogger - public async Task RunAsync(string siteSlug, ProbeRequest request, TimeSpan timeout, CancellationToken ct) + /// Site whose agents may run the probe. + /// The probe to run; its SourceIp carries any WAN context bind. + /// How long to wait for the agent's response. + /// Cancellation. + /// + /// Which of the site's agents should run it. Null keeps the original behavior - the site's + /// first connected agent - which is what every caller that has no reason to care wants. A + /// caller that does care is asking for one WAN's vantage, and another agent sits behind a + /// different WAN, so an unavailable one is reported rather than quietly substituted. + /// + public async Task RunAsync( + string siteSlug, ProbeRequest request, TimeSpan timeout, CancellationToken ct, int? agentId = null) { - var agent = _registry.GetForSite(siteSlug).FirstOrDefault(); + var agent = SelectAgent(_registry.GetForSite(siteSlug), agentId); if (agent == null) + { + // No agent at all is null, which callers word as "no on-site agent". A NAMED agent + // that is not connected is a different thing to say, and substituting another one + // would silently measure a different WAN. + if (agentId != null) + return new ProbeResponse { Success = false, Error = "The agent this probe was aimed at isn't connected right now" }; return null; + } var id = Interlocked.Increment(ref _nextRequestId); request.RequestId = id; @@ -66,6 +84,18 @@ public AgentProbeService(AgentTunnelRegistry registry, ILogger + /// Which connected agent runs a probe: the one asked for, or - when nothing asked - the + /// site's first, exactly as before. Never falls back from a named agent to another one: + /// the whole point of naming it is that it sits behind a particular WAN. + /// + /// The site's live tunnel connections. + /// Agent the caller wants, or null for "any". + internal static AgentTunnelConnection? SelectAgent(IReadOnlyList connections, int? agentId) + => agentId is int wanted + ? connections.FirstOrDefault(c => c.AgentId == wanted) + : connections.FirstOrDefault(); + /// Completes the matching pending probe when an agent returns a response. public void OnResult(ProbeResponse response) { diff --git a/src/NetworkOptimizer.Web/Services/AgentTunnelRegistry.cs b/src/NetworkOptimizer.Web/Services/AgentTunnelRegistry.cs index 4502d98932..78b1fc3af1 100644 --- a/src/NetworkOptimizer.Web/Services/AgentTunnelRegistry.cs +++ b/src/NetworkOptimizer.Web/Services/AgentTunnelRegistry.cs @@ -151,6 +151,37 @@ internal AgentTunnelConnection(int agentId, string siteSlug, string agentName) /// public bool? ServesSpeedTest { get; internal set; } + /// + /// Whether this agent can bind a probe to a source address or interface, as it stated in its + /// hello. Null for an agent old enough not to say, which the interface-bind offer reads as no: + /// a bind an agent cannot honor fails every probe that depends on it. + /// + public bool? SupportsSourceBind { get; internal set; } + + /// + /// Every address this agent's host holds, as the agent reported it. Empty from an agent that + /// predates the field, where alone is all there is. + /// + public IReadOnlyList LocalIps { get; internal set; } = Array.Empty(); + + /// + /// Addresses to recognise this agent's host by: everything it reported, or the single address + /// it chose when it reported nothing. Never empty of meaning - a caller can compare all of + /// these without caring which agent version answered. + /// + public IReadOnlyList HostAddresses => + LocalIps.Count > 0 + ? LocalIps + : string.IsNullOrWhiteSpace(LanIp) ? Array.Empty() : new[] { LanIp! }; + + /// + /// The LAN address this agent announced in its hello. Per connection rather than per site, + /// which is what a multi-agent site needs: the enrollment registry answers with one agent's + /// address for the whole site, so anything deciding about THIS agent - where its probes leave + /// from, whether it is the box a target points at - has to ask the connection. + /// + public string? LanIp { get; internal set; } + public int AgentId { get; } public string SiteSlug { get; } public string AgentName { get; } diff --git a/src/NetworkOptimizer.Web/Services/AgentTunnelService.cs b/src/NetworkOptimizer.Web/Services/AgentTunnelService.cs index 590bc568d0..1cd120e425 100644 --- a/src/NetworkOptimizer.Web/Services/AgentTunnelService.cs +++ b/src/NetworkOptimizer.Web/Services/AgentTunnelService.cs @@ -101,6 +101,12 @@ public override async Task Connect( var connection = _registry.Register(agent.Id, siteSlug, agent.Name); connection.SpeedTestPort = hello.SpeedTestPort; connection.ServesSpeedTest = hello.HasServesSpeedTest ? hello.ServesSpeedTest : null; + connection.SupportsSourceBind = hello.HasSupportsSourceBind ? hello.SupportsSourceBind : null; + connection.LanIp = string.IsNullOrWhiteSpace(hello.LanIp) ? null : hello.LanIp.Trim(); + connection.LocalIps = hello.LocalIps + .Where(ip => !string.IsNullOrWhiteSpace(ip)) + .Select(ip => ip.Trim()) + .ToList(); _logger.LogInformation("Agent {Name} (id {Id}) opened tunnel for site {Slug}", agent.Name, agent.Id, siteSlug); // The pump and refresh loops must stop when the read loop ends for any diff --git a/src/NetworkOptimizer.Web/Services/AlertConfigService.cs b/src/NetworkOptimizer.Web/Services/AlertConfigService.cs index 8353e1c620..8907190080 100644 --- a/src/NetworkOptimizer.Web/Services/AlertConfigService.cs +++ b/src/NetworkOptimizer.Web/Services/AlertConfigService.cs @@ -65,11 +65,27 @@ public interface IAlertConfigService [AuditAction(AuditActions.AlertRuleChanged, TargetType = "alert")] Task UpdateAlertAsync(AlertHistoryEntry alert); + /// + /// Sets many alerts to one status in a single round trip. What the bulk buttons use: updating + /// them one at a time is a database commit each, and slows to a crawl on a few hundred alerts. + /// + [RequireRole(Roles.Operator)] + [AuditAction(AuditActions.AlertRuleChanged, TargetType = "alert")] + Task SetAlertStatusAsync(IReadOnlyCollection alertIds, AlertStatus status, DateTime timestamp); + /// Saves an incident's state, which the alert list edits alongside its alerts. [RequireRole(Roles.Operator)] [AuditAction(AuditActions.AlertRuleChanged, TargetType = "incident")] Task UpdateIncidentAsync(AlertIncident incident); + /// + /// Saves several incidents in one round trip. What the bulk incident buttons use: saving them + /// one at a time is a database commit each. + /// + [RequireRole(Roles.Operator)] + [AuditAction(AuditActions.AlertRuleChanged, TargetType = "incident")] + Task UpdateIncidentsAsync(IReadOnlyCollection incidents); + /// Runs a scheduled task immediately. Returns false when it could not be started. [RequireRole(Roles.Operator)] [AuditAction(AuditActions.ScheduleChanged, TargetType = "schedule")] @@ -120,6 +136,17 @@ public async Task UpdateAlertAsync(AlertHistoryEntry alert) _auditContext.SetDetails(new { alert.AcknowledgedAt, alert.ResolvedAt }); } + /// + public async Task SetAlertStatusAsync(IReadOnlyCollection alertIds, AlertStatus status, DateTime timestamp) + { + var changed = await _alerts.SetAlertStatusAsync(alertIds, status, timestamp); + // One audit entry for the action, not one per alert: the bulk buttons are a single + // deliberate act and the count is what makes it readable afterwards. + _auditContext.SetTarget($"{changed} alert(s)", status.ToString()); + _auditContext.SetDetails(new { Status = status.ToString(), Count = changed, Timestamp = timestamp }); + return changed; + } + /// public async Task UpdateIncidentAsync(AlertIncident incident) { @@ -127,6 +154,13 @@ public async Task UpdateIncidentAsync(AlertIncident incident) _auditContext.SetTarget(incident.Id.ToString(), incident.Title); } + /// + public async Task UpdateIncidentsAsync(IReadOnlyCollection incidents) + { + await _alerts.UpdateIncidentsAsync(incidents); + _auditContext.SetTarget($"{incidents.Count} incident(s)", "bulk"); + } + /// public async Task RunScheduleNowAsync(int id, string siteSlug) { @@ -292,20 +326,6 @@ public async Task DeleteChannelAsync(int id) return alert; } - private async Task RecalculateIncidentStatusAsync(AlertHistoryEntry alert) - { - if (!alert.IncidentId.HasValue) return; - - var incident = await _alerts.GetIncidentAsync(alert.IncidentId.Value); - if (incident == null) return; - - var incidentAlerts = await _alerts.GetAlertsByIncidentIdAsync(incident.Id); - var (newStatus, resolvedAt) = AlertCorrelationService.DeriveIncidentStatus(incidentAlerts); - - if (newStatus == incident.Status) return; - - incident.Status = newStatus; - incident.ResolvedAt = resolvedAt; - await _alerts.UpdateIncidentAsync(incident); - } + private Task RecalculateIncidentStatusAsync(AlertHistoryEntry alert) + => AlertCorrelationService.RecalculateIncidentStatusAsync(alert, _alerts); } diff --git a/src/NetworkOptimizer.Web/Services/AppVersionInfo.cs b/src/NetworkOptimizer.Web/Services/AppVersionInfo.cs index 62af561899..546e968c27 100644 --- a/src/NetworkOptimizer.Web/Services/AppVersionInfo.cs +++ b/src/NetworkOptimizer.Web/Services/AppVersionInfo.cs @@ -22,7 +22,7 @@ public static class AppVersionInfo /// "Update agent" callout for enrolled agents reporting an older version /// than this, and over-bumping nags agents into pointless upgrades. /// - public const string LatestAgentVersion = "2.5.3"; + public const string LatestAgentVersion = "2.6.0"; /// Full informational version (e.g. "1.4.2" or "0.0.0-alpha.0.12"). public static string Informational { get; } diff --git a/src/NetworkOptimizer.Web/Services/Auditing/AuditQueryService.cs b/src/NetworkOptimizer.Web/Services/Auditing/AuditQueryService.cs index f2e9ed2221..518bc9fd92 100644 --- a/src/NetworkOptimizer.Web/Services/Auditing/AuditQueryService.cs +++ b/src/NetworkOptimizer.Web/Services/Auditing/AuditQueryService.cs @@ -1,6 +1,7 @@ -using System.Text; +using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; +using NetworkOptimizer.Web.Services.Gates; using NetworkOptimizer.Storage.Models.Identity; namespace NetworkOptimizer.Web.Services.Auditing; @@ -18,12 +19,36 @@ public sealed record AuditFilter public int Take { get; init; } = 100; } -/// Read-only, filtered access to the audit log plus CSV/JSON export of the current filter. +/// +/// Read-only, filtered access to the audit log plus CSV/JSON export of the current filter. +/// +/// Gated even though every member is a read. The audit log is the record of who did what across the +/// whole install - actors, source addresses, target names, and now the site each action touched - so +/// it is closer to a credential store than to a status page, and reads of it are worth the same +/// service-tier check as writes elsewhere. +/// +/// Until this attribute, nothing here was checked at all. The export endpoints carry +/// RequireAuthorization(RequireAdmin) and the page sits behind an AuthorizeView, so the surface was +/// covered in practice - but by the endpoint and the page rather than by the service, which is the +/// arrangement the gate engine exists to replace. Any new caller reaching this interface (a component +/// on another page, a background job, a future endpoint) would have inherited nothing. +/// +/// No [AuditAction]: recording every read would write an entry for each page and each page-turn of +/// the log itself, which buries the actions the log is kept for. +/// +[MutatingService] public interface IAuditQueryService { + [RequireRole(Roles.Admin)] Task> QueryAsync(AuditFilter filter); + + [RequireRole(Roles.Admin)] Task CountAsync(AuditFilter filter); + + [RequireRole(Roles.Admin)] Task ExportJsonAsync(AuditFilter filter); + + [RequireRole(Roles.Admin)] Task ExportCsvAsync(AuditFilter filter); } diff --git a/src/NetworkOptimizer.Web/Services/DashboardService.cs b/src/NetworkOptimizer.Web/Services/DashboardService.cs index 9f1ba441fb..8dffea92a0 100644 --- a/src/NetworkOptimizer.Web/Services/DashboardService.cs +++ b/src/NetworkOptimizer.Web/Services/DashboardService.cs @@ -77,6 +77,10 @@ public async Task GetDashboardDataAsync() if (devices != null) { + // The instant the uptimes below were read, so a reboot reason can be checked + // against the boot the device is reporting rather than an older one. + var uptimeReadAt = DateTime.UtcNow; + data.DeviceCount = devices.Count; data.Devices = devices.Select(d => { @@ -94,7 +98,9 @@ public async Task GetDashboardDataAsync() SuricataUpgradeAvailable = d.SuricataUpgradeAvailable }; - var rebootReason = string.IsNullOrEmpty(d.Mac) ? null : _rebootTracker.GetReason(d.Mac); + var rebootReason = string.IsNullOrEmpty(d.Mac) + ? null + : _rebootTracker.GetReasonForReportedUptime(d.Mac, info.UptimeSeconds, uptimeReadAt); if (rebootReason != null) { info.RebootReason = rebootReason.Summary; diff --git a/src/NetworkOptimizer.Web/Services/DiagnosticsService.cs b/src/NetworkOptimizer.Web/Services/DiagnosticsService.cs index 481ead2650..01363da279 100644 --- a/src/NetworkOptimizer.Web/Services/DiagnosticsService.cs +++ b/src/NetworkOptimizer.Web/Services/DiagnosticsService.cs @@ -24,6 +24,7 @@ public class DiagnosticsService private readonly ILoggerFactory _loggerFactory; private readonly SiteContextService _siteContext; private readonly Licensing.LicenseStateService _licenseState; + private readonly Ssh.GatewayShaperProbeService _shaperProbe; public DiagnosticsService( ILogger logger, @@ -33,7 +34,8 @@ public DiagnosticsService( IMemoryCache cache, ILoggerFactory loggerFactory, SiteContextService siteContext, - Licensing.LicenseStateService licenseState) + Licensing.LicenseStateService licenseState, + Ssh.GatewayShaperProbeService shaperProbe) { _siteContext = siteContext; _licenseState = licenseState; @@ -43,6 +45,7 @@ public DiagnosticsService( _ieeeOuiDb = ieeeOuiDb; _cache = cache; _loggerFactory = loggerFactory; + _shaperProbe = shaperProbe; } /// @@ -108,8 +111,13 @@ public async Task RunDiagnosticsAsync(DiagnosticsOptions? opt var qosRulesTask = _connectionService.Client.GetQosRulesRawAsync(); var wanEnrichedTask = _connectionService.Client.GetWanEnrichedConfigRawAsync(); + // The gateway's traffic control, for WANs with Smart Queues enabled. Runs alongside + // the rest of the fetches and returns nothing at all when the gateway can't be read + // over SSH. + var shaperTask = ProbeWanShapersAsync(networksTask, options); + await Task.WhenAll(devicesTask, clientsTask, networksTask, portProfilesTask, clientHistoryTask, - settingsTask, qosRulesTask, wanEnrichedTask); + settingsTask, qosRulesTask, wanEnrichedTask, shaperTask); var devices = await devicesTask; var clients = await clientsTask; @@ -119,6 +127,7 @@ await Task.WhenAll(devicesTask, clientsTask, networksTask, portProfilesTask, cli using var settingsDoc = await settingsTask; using var qosRulesDoc = await qosRulesTask; using var wanEnrichedDoc = await wanEnrichedTask; + var wanShaperStates = await shaperTask; _logger.LogInformation( "Fetched data for diagnostics: {DeviceCount} devices, {ClientCount} clients, " + @@ -145,7 +154,7 @@ await Task.WhenAll(devicesTask, clientsTask, networksTask, portProfilesTask, cli performanceLogger: _loggerFactory.CreateLogger()); var result = engine.RunDiagnostics(clients, devices, portProfiles, networks, options, clientHistory, - settingsDoc, qosRulesDoc, wanEnrichedDoc); + settingsDoc, qosRulesDoc, wanEnrichedDoc, wanShaperStates); // Cache the result _cache.Set(CacheKeyLastResult, result); @@ -168,6 +177,36 @@ await Task.WhenAll(devicesTask, clientsTask, networksTask, portProfilesTask, cli } } + /// + /// The gateway's shaper state for WANs with Smart Queues enabled, or nothing when there is + /// no such WAN. + /// + /// Gated on the network configs this run already fetches: reading the shapers means asking the + /// controller for the WAN interface names, which costs a second device-list fetch, and an + /// install with Smart Queues off everywhere can never produce the finding that would pay for + /// it. Waiting on that one small call is what buys the skip - everything else stays parallel. + /// + private async Task> ProbeWanShapersAsync( + Task> networksTask, DiagnosticsOptions? options) + { + var none = new List(); + + if (!(options?.RunPerformanceAnalyzer ?? true)) + return none; + + var networks = await networksTask; + var smartQueuesAnywhere = networks.Any(n => + string.Equals(n.Purpose, "wan", StringComparison.OrdinalIgnoreCase) && n.WanSmartqEnabled); + + if (!smartQueuesAnywhere) + { + _logger.LogDebug("No WAN has Smart Queues enabled - skipping the gateway shaper read"); + return none; + } + + return await _shaperProbe.RunAsync(); + } + private static DiagnosticsResult CreateErrorResult(string title, string message) { return new DiagnosticsResult diff --git a/src/NetworkOptimizer.Web/Services/IMonitoringTargetService.cs b/src/NetworkOptimizer.Web/Services/IMonitoringTargetService.cs index dfea1b091f..18d9ca6150 100644 --- a/src/NetworkOptimizer.Web/Services/IMonitoringTargetService.cs +++ b/src/NetworkOptimizer.Web/Services/IMonitoringTargetService.cs @@ -59,6 +59,14 @@ public sealed record NewMonitoringTarget public ProbeMode ProbeMode { get; init; } = ProbeMode.Icmp; public int Port { get; init; } = 443; public int PollIntervalSeconds { get; init; } = 10; + + /// + /// Which WAN context probes this target, or null for the primary WAN. Set at creation so a + /// target added for a secondary WAN is never briefly probed from the primary - the alternative, + /// add-then-reassign, writes a burst of primary-WAN points that the WAN it was added for then + /// has to be read around. + /// + public int? WanContextId { get; init; } } /// Thrown when a new target fails validation, so the card can show the reason inline. diff --git a/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs b/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs index 29f596c934..c575afcd20 100644 --- a/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs +++ b/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs @@ -57,11 +57,43 @@ public sealed class SiteConfigurationService : ISiteConfigurationService { private readonly SiteDbContextFactory _siteDb; private readonly SiteAgentCoverage _agentCoverage; + private readonly SiteConnectionRegistry _siteConnections; + private readonly SiteTunnelRouting _tunnelRouting; + private readonly ILogger _logger; - public SiteConfigurationService(SiteDbContextFactory siteDb, SiteAgentCoverage agentCoverage) + public SiteConfigurationService(SiteDbContextFactory siteDb, SiteAgentCoverage agentCoverage, + SiteConnectionRegistry siteConnections, SiteTunnelRouting tunnelRouting, + ILogger logger) { _siteDb = siteDb; _agentCoverage = agentCoverage; + _siteConnections = siteConnections; + _tunnelRouting = tunnelRouting; + _logger = logger; + } + + /// + /// Rebuilds the site's console on whichever path it should now take. The client records how it + /// was built, so a setting that changes the path leaves the existing connection on the old one + /// until something reconnects it - and nothing else does, because every automatic reconnect is + /// gated on the console being disconnected. Not awaited: a reconnect takes seconds and every + /// caller here is a checkbox. + /// + private void ReconnectConsole(string siteSlug, string because) + { + var connection = _siteConnections.GetFor(siteSlug); + _ = Task.Run(async () => + { + try + { + await connection.ReconnectAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not reconnect the console for site {Slug} after {Because}", + siteSlug, because); + } + }); } /// @@ -83,12 +115,23 @@ public async Task GetAsync(string siteSlug) } /// - public Task SetConsoleViaAgentAsync(string siteSlug, bool enabled) - => WriteAsync(siteSlug, UniFiConnectionService.ConsoleViaAgentKey, enabled.ToString()); + public async Task SetConsoleViaAgentAsync(string siteSlug, bool enabled) + { + await WriteAsync(siteSlug, UniFiConnectionService.ConsoleViaAgentKey, enabled.ToString()); + // This checkbox only appears once coverage is on, so coverage is necessarily switched + // first: reconnecting there alone always ran against the console's OLD routing and left + // this choice unapplied until something else happened to reconnect. + ReconnectConsole(siteSlug, "its console routing changed"); + } /// - public Task SetDevicesViaAgentAsync(string siteSlug, bool enabled) - => WriteAsync(siteSlug, SiteTunnelRouting.DevicesViaAgentKey, enabled.ToString()); + public async Task SetDevicesViaAgentAsync(string siteSlug, bool enabled) + { + await WriteAsync(siteSlug, SiteTunnelRouting.DevicesViaAgentKey, enabled.ToString()); + // Consulted per SSH command and per modem poll through a one-minute cache, so without this + // the switch appears to do nothing for up to a minute. + _tunnelRouting.Invalidate(siteSlug); + } /// public async Task SetAgentCoversSiteAsync(string siteSlug, bool enabled) @@ -96,7 +139,13 @@ public async Task SetAgentCoversSiteAsync(string siteSlug, bool enabled) await WriteAsync(siteSlug, SiteAgentCoverage.AgentCoversSiteKey, enabled.ToString()); // The collection paths read this through a one-minute cache; a setting that decides whether // the server collects at all should not wait that long to take effect. - _agentCoverage.Invalidate(siteSlug); + // Recorded rather than invalidated: the reconnect below reads this immediately, and the + // synchronous reader answers false while an invalidated entry refills. + _agentCoverage.Set(siteSlug, enabled); + // Also drops the devices cache: that flag is gated on coverage for the default site, so + // coverage changing changes the answer without the flag itself being touched. + _tunnelRouting.Invalidate(siteSlug); + ReconnectConsole(siteSlug, "its agent coverage changed"); } /// diff --git a/src/NetworkOptimizer.Web/Services/IUpstreamDiscoveryService.cs b/src/NetworkOptimizer.Web/Services/IUpstreamDiscoveryService.cs index f486eec159..3ee557f288 100644 --- a/src/NetworkOptimizer.Web/Services/IUpstreamDiscoveryService.cs +++ b/src/NetworkOptimizer.Web/Services/IUpstreamDiscoveryService.cs @@ -16,13 +16,20 @@ namespace NetworkOptimizer.Web.Services; [MutatingService(SiteScoped = true)] public interface IUpstreamDiscoveryService { - /// Traces the upstream path and proposes targets for review. + /// + /// Traces the upstream path and proposes targets for review. + /// runs a specific tracer instance (a WAN context's own, from the panel's per-WAN view); + /// null runs the site's primary tracer, exactly as before. + /// [RequireRole(Roles.Operator)] [AuditAction(AuditActions.MonitoringSetupChanged, TargetType = "upstream_discovery")] - Task StartAsync(CancellationToken ct = default); + Task StartAsync(Monitoring.UpstreamTracerService? tracer = null, CancellationToken ct = default); - /// Commits the reviewed discovery, writing its hops as monitoring targets. + /// + /// Commits the reviewed discovery, writing its hops as monitoring targets. Same tracer + /// selection rule as . + /// [RequireRole(Roles.Operator)] [AuditAction(AuditActions.MonitoringSetupChanged, TargetType = "upstream_discovery")] - Task CommitAsync(CancellationToken ct = default); + Task CommitAsync(Monitoring.UpstreamTracerService? tracer = null, CancellationToken ct = default); } diff --git a/src/NetworkOptimizer.Web/Services/Identity/IdentityBootstrapService.cs b/src/NetworkOptimizer.Web/Services/Identity/IdentityBootstrapService.cs index a50e8ca3fc..dba7962179 100644 --- a/src/NetworkOptimizer.Web/Services/Identity/IdentityBootstrapService.cs +++ b/src/NetworkOptimizer.Web/Services/Identity/IdentityBootstrapService.cs @@ -29,6 +29,7 @@ public sealed class IdentityBootstrapService : IIdentityBootstrapService private readonly UserManager _userManager; private readonly RoleManager _roleManager; private readonly IAuditLogger _audit; + private readonly AdminAuthCache _adminAuthCache; private readonly ILogger _logger; public IdentityBootstrapService( @@ -37,6 +38,7 @@ public IdentityBootstrapService( UserManager userManager, RoleManager roleManager, IAuditLogger audit, + AdminAuthCache adminAuthCache, ILogger logger) { _authDbFactory = authDbFactory; @@ -44,6 +46,7 @@ public IdentityBootstrapService( _userManager = userManager; _roleManager = roleManager; _audit = audit; + _adminAuthCache = adminAuthCache; _logger = logger; } @@ -115,10 +118,12 @@ private async Task ReconcileAdminAsync(CancellationToken cancellationToken) if (BreakGlass.IsRecoveryMode && !admin.IsEnabled) await ReenableAdminForRecoveryAsync(admin); - // Existing admin: only the live APP_PASSWORD override re-syncs an already-migrated account, - // so a changed env var takes effect on restart. Transcoded DB/auto-gen hashes are one-time. - if (credential.Source == CredentialSource.Environment) - await ResyncEnvPasswordAsync(admin, credential.Plaintext!); + // Existing admin: re-sync only from a credential that is authoritative *this* boot, so a + // changed env var takes effect on restart and a cleared password row still resets the login. + // A transcoded DB/auto-gen hash that was merely read back is one-time and must not re-apply, + // or every boot would overwrite a password since set through Identity. + if (credential.Source is CredentialSource.Environment or CredentialSource.FirstRunReset) + await ResyncPlaintextPasswordAsync(admin, credential); } private async Task CreateAdminAsync(AdminCredential credential) @@ -133,10 +138,11 @@ private async Task CreateAdminAsync(AdminCredential credential) }; IdentityResult result; - if (credential.Source == CredentialSource.Environment) + if (credential.Plaintext is not null) { - // Plaintext available (env var) - let Identity hash it at full strength. - result = await _userManager.CreateAsync(admin, credential.Plaintext!); + // Plaintext available (env var, or a password generated this boot) - let Identity + // hash it at full strength. + result = await _userManager.CreateAsync(admin, credential.Plaintext); } else { @@ -195,25 +201,56 @@ private async Task ReenableAdminForRecoveryAsync(ApplicationUser admin) details: new { reenabled = true })); } - private async Task ResyncEnvPasswordAsync(ApplicationUser admin, string envPassword) + private async Task ResyncPlaintextPasswordAsync(ApplicationUser admin, AdminCredential credential) { - if (await _userManager.CheckPasswordAsync(admin, envPassword)) + var password = credential.Plaintext!; + var fromEnv = credential.Source == CredentialSource.Environment; + + if (await _userManager.CheckPasswordAsync(admin, password)) return; // already in sync var token = await _userManager.GeneratePasswordResetTokenAsync(admin); - var result = await _userManager.ResetPasswordAsync(admin, token, envPassword); - if (result.Succeeded) + var result = await _userManager.ResetPasswordAsync(admin, token, password); + if (!result.Succeeded) + { + _logger.LogError( + "Identity bootstrap: failed to re-sync the admin password from {Source}: {Errors}", + fromEnv ? "APP_PASSWORD" : "the regenerated first-run password", Describe(result)); + return; + } + + admin.PasswordIsTemporary = credential.IsTemporary; + + if (!fromEnv) + { + // A reset is worth nothing if the account is then refused for some other reason, and an + // operator who has lost the password has usually been failing sign-ins to find that out. + admin.LockoutEnd = null; + admin.AccessFailedCount = 0; + } + + // ResetPasswordAsync has already rotated the security stamp, so existing sessions are gone. + await _userManager.UpdateAsync(admin); + + if (fromEnv) { - admin.PasswordIsTemporary = false; - await _userManager.UpdateAsync(admin); _logger.LogWarning( "Identity bootstrap: APP_PASSWORD differs from the stored admin hash; the env var wins " + "and the admin password was reset to it. Unset APP_PASSWORD to manage the password in-app."); + return; } - else - { - _logger.LogError("Identity bootstrap: failed to re-sync admin password from APP_PASSWORD: {Errors}", Describe(result)); - } + + _logger.LogWarning( + "Identity bootstrap: the stored admin password was cleared, so the regenerated first-run " + + "password above was applied to the admin account and its lockout was cleared."); + + // Only the reset path is audited. The env var re-syncing on boot is existing behaviour and + // stays silent; auditing it here would be an unrelated change to a path nobody asked about. + _audit.Log(AuditEventBuilder.From( + CallerInfo.System("password-reset"), + AuditCategories.Auth, AuditActions.PasswordReset, AuditOutcomes.Success, + targetType: "user", targetId: admin.Id, targetName: admin.UserName, + details: new { source = credential.Source.ToString() })); } /// Resolves the effective local admin credential, or null when there is none to seed. @@ -224,6 +261,13 @@ private async Task ResyncEnvPasswordAsync(ApplicationUser admin, string envPassw if (!string.IsNullOrEmpty(envPassword)) return AdminCredential.FromEnvironment(envPassword); + // A password generated moments ago by AdminAuthService means the stored one was absent: + // a first run, or scripts/reset-password.* having cleared it. That password has just been + // printed to the log as the way back in, so it outranks the hash now sitting in the row. + var firstRunPassword = _adminAuthCache.ConsumeFirstRunPassword(); + if (!string.IsNullOrEmpty(firstRunPassword)) + return AdminCredential.FromFirstRunReset(firstRunPassword); + await using var mainDb = await _mainDbFactory.CreateDbContextAsync(cancellationToken); var settings = await mainDb.AdminSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); if (settings?.Password is null || !LegacyPasswordTranscoder.IsLegacyFormat(settings.Password)) @@ -243,6 +287,7 @@ private enum CredentialSource { Environment, LegacyHash, + FirstRunReset, } private sealed record AdminCredential( @@ -253,5 +298,9 @@ public static AdminCredential FromEnvironment(string plaintext) public static AdminCredential FromLegacyHash(string v3Hash, bool isTemporary) => new(CredentialSource.LegacyHash, null, v3Hash, isTemporary); + + /// A password auto-generated this boot because none was stored; always temporary. + public static AdminCredential FromFirstRunReset(string plaintext) + => new(CredentialSource.FirstRunReset, plaintext, null, IsTemporary: true); } } diff --git a/src/NetworkOptimizer.Web/Services/Identity/IdentityRegistration.cs b/src/NetworkOptimizer.Web/Services/Identity/IdentityRegistration.cs index 55d3fc56b0..e4893d6781 100644 --- a/src/NetworkOptimizer.Web/Services/Identity/IdentityRegistration.cs +++ b/src/NetworkOptimizer.Web/Services/Identity/IdentityRegistration.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -77,6 +77,11 @@ public static IServiceCollection AddNetOptIdentityCore(this IServiceCollection s // One-release belt-and-suspenders: verify any still-legacy-format hash and flag it for rehash. services.AddScoped, LegacyFallbackPasswordHasher>(); + // The bootstrap reads the first-run password AdminAuthService generates moments earlier, so + // the two must share one instance. The host registers it already; this is for leaner + // containers (tests) that pull in identity without the rest of the admin-auth wiring. + services.TryAddSingleton(); + services.AddScoped(); services.AddScoped(); // Reads ungated (the login page and every authorization check need them before, or without, @@ -126,7 +131,9 @@ public static IServiceCollection AddNetOptIdentityCore(this IServiceCollection s services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); - services.AddScoped(); + // Gated, so it goes through the proxy rather than being resolved raw - registering the + // implementation as its own service type would leave an ungated instance in the container. + services.AddMutatingService(); return services; } diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/AgentProbeExecutor.cs b/src/NetworkOptimizer.Web/Services/Monitoring/AgentProbeExecutor.cs index 5d16e4cc5b..6b58d3ba7f 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/AgentProbeExecutor.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/AgentProbeExecutor.cs @@ -19,15 +19,29 @@ public sealed class AgentProbeExecutor : IProbeExecutor private readonly AgentProbeService _agentProbe; private readonly string _siteSlug; private readonly ILogger _logger; + private readonly int? _agentId; - public AgentProbeExecutor(AgentProbeService agentProbe, string siteSlug, ILogger logger) + /// + /// Builds an executor for a site's agent vantage. + /// + /// Tunnel probe service. + /// Site whose agent runs the probes. + /// Logger. + /// + /// Which of the site's agents to run on. Null means the site's agent in the singular - the + /// original behavior, and what every path that just wants an on-site origin needs. A named + /// agent is a specific vantage (a WAN context's), so it is never quietly swapped for another. + /// + public AgentProbeExecutor(AgentProbeService agentProbe, string siteSlug, ILogger logger, int? agentId = null) { _agentProbe = agentProbe; _siteSlug = siteSlug; _logger = logger; + _agentId = agentId; + Vantage = agentId is int id ? new($"agent:{id}", VantageKind.Server) : new("agent", VantageKind.Server); } - public ProbeVantage Vantage { get; } = new("agent", VantageKind.Server); + public ProbeVantage Vantage { get; } public Task GetCapabilityAsync(CancellationToken ct = default) => Task.FromResult(new ProbeCapability @@ -43,14 +57,14 @@ public Task GetCapabilityAsync(CancellationToken ct = default) public async Task PingAsync(ProbeTarget target, int count = 10, TimeSpan? perPingTimeout = null, CancellationToken ct = default) { var request = BuildRequest(target, traceroute: false, count: count, maxHops: 0); - var resp = await _agentProbe.RunAsync(_siteSlug, request, TimeSpan.FromSeconds(count * 3 + 15), ct); + var resp = await _agentProbe.RunAsync(_siteSlug, request, TimeSpan.FromSeconds(count * 3 + 15), ct, _agentId); if (resp == null) return FailedPing(target, "No on-site agent is online to run the probe"); if (!resp.Success || string.IsNullOrEmpty(resp.ResultJson)) return FailedPing(target, string.IsNullOrEmpty(resp.Error) ? "Agent probe failed" : resp.Error); try { - return JsonSerializer.Deserialize(resp.ResultJson) - ?? FailedPing(target, "Agent returned an unreadable ping result"); + var parsed = JsonSerializer.Deserialize(resp.ResultJson); + return parsed == null ? FailedPing(target, "Agent returned an unreadable ping result") : Attribute(parsed); } catch (Exception ex) { @@ -62,14 +76,14 @@ public async Task PingAsync(ProbeTarget target, int count = 10, public async Task TracerouteAsync(ProbeTarget target, int maxHops = 30, TimeSpan? perHopTimeout = null, TimeSpan? totalDeadline = null, CancellationToken ct = default) { var request = BuildRequest(target, traceroute: true, count: 0, maxHops: maxHops); - var resp = await _agentProbe.RunAsync(_siteSlug, request, TimeSpan.FromSeconds(30), ct); + var resp = await _agentProbe.RunAsync(_siteSlug, request, TimeSpan.FromSeconds(30), ct, _agentId); if (resp == null) return FailedTrace(target, "No on-site agent is online to run the traceroute"); if (!resp.Success || string.IsNullOrEmpty(resp.ResultJson)) return FailedTrace(target, string.IsNullOrEmpty(resp.Error) ? "Agent traceroute failed" : resp.Error); try { - return JsonSerializer.Deserialize(resp.ResultJson) - ?? FailedTrace(target, "Agent returned an unreadable traceroute result"); + var parsed = JsonSerializer.Deserialize(resp.ResultJson); + return parsed == null ? FailedTrace(target, "Agent returned an unreadable traceroute result") : Attribute(parsed); } catch (Exception ex) { @@ -93,6 +107,21 @@ public async Task TcpProbeAsync(ProbeTarget target, TimeSpan? ti }; } + /// + /// Names the vantage a NAMED agent's result came from. The agent runs the same + /// LocalProbeExecutor the server does, so its result arrives calling itself the "server" + /// vantage - which reads as this server on a site where the server also probes, and a probe + /// picked out by agent has to say which agent ran it. Left untouched for the unnamed + /// executor, where "server" is exactly what the site's single agent vantage has always + /// reported. + /// + private PingProbeResult Attribute(PingProbeResult result) => + _agentId == null ? result : result with { Vantage = Vantage }; + + /// + private TracerouteResult Attribute(TracerouteResult result) => + _agentId == null ? result : result with { Vantage = Vantage }; + private ProbeRequest BuildRequest(ProbeTarget target, bool traceroute, int count, int maxHops) => new() { Address = target.Address, diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/AsnResolutionService.cs b/src/NetworkOptimizer.Web/Services/Monitoring/AsnResolutionService.cs index 0b38a8cc7e..029ac4d5d0 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/AsnResolutionService.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/AsnResolutionService.cs @@ -52,8 +52,8 @@ public AsnResolutionService(GeoEnrichmentService geo, ILogger {Result}", + ipAddress, + (int)System.Diagnostics.Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds, + result == null ? "no attribution" : $"AS{result.Asn}"); return result; } finally @@ -100,21 +122,29 @@ public AsnResolutionService(GeoEnrichmentService geo, ILogger 200 ? firstLine[..200] + "..." : firstLine); return null; } catch (Exception ex) diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/FlakyTargetService.cs b/src/NetworkOptimizer.Web/Services/Monitoring/FlakyTargetService.cs index 243efa126d..6fb5a5b009 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/FlakyTargetService.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/FlakyTargetService.cs @@ -88,7 +88,8 @@ public record FlakyTarget( double LossPct, double BaselinePct, int OverBins, - int TotalBins) + int TotalBins, + int? WanContextId) { public string Evidence => $"{LossPct:0.0}% loss vs {BaselinePct:0.0}% peer median"; } @@ -129,7 +130,7 @@ public async Task> DetectAsync(CancellationToken ct = Dictionary> series; try { - series = await _influx.QueryLatencyDetailByTargetTypeAsync(type, from, to, binSize, ct); + series = await _influx.QueryLatencyDetailByTargetTypeAsync(type, from, to, binSize, ct: ct); } catch (Exception ex) { @@ -259,7 +260,7 @@ internal static IReadOnlyList Analyze( if (!byId.TryGetValue(targetId, out var t)) continue; var over = survivors.Count(l => l >= threshold); flaky.Add(new FlakyTarget(targetId, t.Id, string.IsNullOrEmpty(t.Name) ? t.Address : t.Name, - t.TargetType, metric, baseline, over, survivors.Count)); + t.TargetType, metric, baseline, over, survivors.Count, t.WanContextId)); } logger?.LogDebug("Flaky-target detect: {Count} flagged, baseline {Base:0.00}%, threshold {Thr:0.00}%, {Bins} surviving bins", diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/ElevationVerdict.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/ElevationVerdict.cs new file mode 100644 index 0000000000..08cdf0ad6f --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/ElevationVerdict.cs @@ -0,0 +1,97 @@ +namespace NetworkOptimizer.Web.Services.Monitoring.IspHealth; + +/// +/// Whether a line's elevation under load is OVER - the operator fixed it - or still happening. +/// +/// Asked this way round because of what the noise floor does downstream. Most loaded samples sit +/// near zero even while a line misbehaves, so the floor keeps only the elevated minority and the +/// figure reported is the median OF THE BAD ONES. Comparing medians cannot see a fix there: the +/// median over everything is ~0 before and after. Whether elevation is still HAPPENING can be +/// seen, and that is the question an operator is really asking. +/// +/// +/// Pure and separate from the scorer so the rule can be tested directly. Every branch here was +/// found by being wrong about a real WAN first. +/// +/// +internal static class ElevationVerdict +{ + /// The newest episodes, all below the floor, ending at the first elevated one. + /// Episodes anywhere in the window that were elevated. + /// Whether the clean run covers the hour elevation appeared at. + /// The verdict: everything above agreeing that it stopped. + internal sealed record Verdict( + IReadOnlyList<(DateTime Time, double Value)> CleanRun, + int ElevatedCount, + bool ProblemHourReTested, + bool ElevationIsOver); + + /// One value per load episode, newest first. + /// Added delay below which an episode counts as clean. + /// Clean episodes in a row required to call the elevation over. + /// Whether a cyclical problem must be re-tested at its own hour. + /// How long one episode's window covers, for hour attribution. + /// Below this an older episode counts as clean when deciding + /// whether the history shows hour-dependence at all. + internal static Verdict For( + IReadOnlyList<(DateTime Time, double Value)> episodesNewestFirst, + double noiseFloor, + int staleEpisodes, + bool needsSameHour, + TimeSpan episodeSpan, + double hourDependenceFloor) + { + var cleanRun = episodesNewestFirst.TakeWhile(e => e.Value < noiseFloor).ToList(); + var elevated = episodesNewestFirst.Where(e => e.Value >= noiseFloor).ToList(); + if (elevated.Count == 0) + { + // Nothing was ever elevated, so there is nothing to declare over. A line that has + // always been clean takes the path it always took. + return new Verdict(cleanRun, 0, false, false); + } + + var older = episodesNewestFirst.Skip(cleanRun.Count).ToList(); + var hourReTested = !needsSameHour + || !ShowsHourDependence(older, hourDependenceFloor) + || CoversProblemHour(cleanRun, elevated, episodeSpan); + + var over = cleanRun.Count >= staleEpisodes && hourReTested; + return new Verdict(cleanRun, elevated.Count, hourReTested, over); + } + + /// + /// Whether the history before the clean run varied by hour at all. If EVERY earlier episode was + /// elevated, the line misbehaved whenever it was loaded - the hour was never the variable, so a + /// clean run at any hour disproves it. Requiring the same hour there would hold a fix hostage + /// to whenever the line is next busy, which on a WAN whose only regular load is a scheduled + /// speed test is the following day. + /// + private static bool ShowsHourDependence( + IReadOnlyList<(DateTime Time, double Value)> older, double floor) + => older.Any(e => e.Value < floor); + + /// + /// Whether the clean run covers the hour of day elevation appeared at - the hour with the most + /// elevated episodes. A nightly problem otherwise clears itself: a line that bufferbloats every + /// evening is clean all night, so a run computed at 3 AM finds clean episodes on top of + /// elevated ones and calls it fixed. "It has been fine since" means nothing if the since never + /// covered the hour it went wrong. + /// + private static bool CoversProblemHour( + IReadOnlyList<(DateTime Time, double Value)> cleanRun, + IReadOnlyList<(DateTime Time, double Value)> elevated, + TimeSpan episodeSpan) + { + IEnumerable HoursOf((DateTime Time, double Value) episode) => + UsageWeighting.LocalHoursSpanned(episode.Time, episode.Time + episodeSpan, TimeZoneInfo.Local); + + var problemHour = elevated + .SelectMany(HoursOf) + .GroupBy(h => h) + .OrderByDescending(g => g.Count()) + .ThenBy(g => g.Key) + .First().Key; + + return cleanRun.SelectMany(HoursOf).Contains(problemHour); + } +} diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthOptions.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthOptions.cs index c809ac116c..f7e340865e 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthOptions.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthOptions.cs @@ -63,6 +63,158 @@ public class IspHealthOptions /// Weight of loaded latency delta within the access dimension. public double LoadedLatencyWeight { get; set; } = 0.14875; + /// + /// Half-life, in hours, for how much a speed test still counts toward the loaded-latency + /// figure. A plain median over the window treats a test from an hour ago exactly like one from + /// six days ago, so a line fixed this afternoon went on reporting bufferbloat until the good + /// tests outnumbered the bad - which on a daily schedule takes a week. Two days of evidence + /// counts half, so three consecutive clean runs outweigh a week of bad ones. + /// + /// Not shorter than that on purpose. On the daily schedule most sites run, a 24-hour half-life + /// gives the newest test more weight than every earlier test combined - which is not a median + /// any more, it is "latest test wins", and one bad run would raise a finding on its own. + /// + /// Zero disables the decay and restores the plain median. + /// + public double LoadedLatencyRecencyHalfLifeHours { get; set; } = 48; + + /// + /// Consecutive newest speed tests that, if all materially better than what came before, are + /// read as the line having been FIXED rather than as it varying - and the older tests are then + /// describing a connection that no longer exists. + /// + /// Weighting by age alone cannot answer this. The window is short enough that a fix this + /// afternoon leaves three clean tests against four bad ones only hours older, where decay + /// barely separates them and the median still sits on the bad cluster. Three in a row is the + /// smallest run that is not a fluke; below that the weighted median decides as before. + /// + /// + public int LoadedLatencyRegimeSamples { get; set; } = 3; + + /// + /// The share of the plan a WAN speed test must have reached IN THAT DIRECTION before its + /// loaded latency may stand in for the measured delta. A test that never filled the pipe did + /// not load the buffers either, so its latency describes something other than this link at + /// saturation - and since the substitution only ever raises the figure, admitting those would + /// bias every matched episode upward with nothing able to correct it. Judged per direction: a + /// test that saturated the downstream and not the upstream still speaks for the downstream. + /// + /// + /// Plan speed at or below which the configured plan is treated as UNSET rather than as a real + /// plan. UniFi Network will not accept anything under 1 Mbps, so a link with no meaningful + /// figure to enter - a metered backup, a standby WAN - ends up pinned at the floor. Grading + /// against it turns an ordinary backup link into a failing one: 0.6 / 0.1 Mbps against a + /// "1 / 1 plan" scored 17. + /// + public double PlanFloorMbps { get; set; } = 1.0; + + public double LoadedLatencySpeedTestMinPlanFraction { get; set; } = 0.7; + + /// + /// How far from a load episode a WAN speed test may sit and still be taken as the measurement + /// OF that episode. Only wide enough to bridge the stored instant of a test and the span of + /// the load it caused - a test runs for tens of seconds, so anything past that is a different + /// event and must not speak for this one. + /// + public double LoadedLatencySpeedTestMatchSeconds { get; set; } = 30; + + /// + /// How close in time two hops' samples must be to count as the same instant for the + /// cross-hop agreement check. One second: close enough that the same queue state is being + /// reported by both, loose enough to catch probes that do not fire in lockstep. + /// + public double LoadedLatencyAgreementToleranceSeconds { get; set; } = 1; + + /// + /// How many distinct hops must report at one instant before their agreement is consulted. + /// Below this there is nothing to corroborate against and the samples pass through as they + /// are - see . + /// + public int LoadedLatencyAgreementMinCohort { get; set; } = 4; + + /// + /// How far below the older tests the recent run has to sit to count as a fix: at 0.5, every one + /// of them must be under half the older median. A line that merely had a good afternoon does + /// not clear this, and a plausible measurement floor is allowed for besides, so a connection + /// whose delta is already small cannot trip it on noise. + /// + public double LoadedLatencyRegimeDropFraction { get; set; } = 0.5; + + /// + /// Consecutive newest load episodes that must show no added delay before the elevation is + /// treated as OVER - the line was fixed, and the elevated episodes behind it describe a + /// connection that no longer exists. + /// + /// Asked this way round because of what the noise floor does downstream. Most loaded samples on + /// a healthy line sit near zero, so the floor keeps only the elevated ones and the figure + /// reported is the median OF THE BAD ONES. Comparing medians cannot see a fix there - the + /// median over everything is ~0 both before and after. Whether elevation is still HAPPENING + /// can be seen, and that is the question. + /// + /// + /// Nothing changes for a line that was not fixed: a still-bad line has elevated episodes among + /// its newest and never qualifies, and a line that was always clean has no elevated episodes to + /// go stale, so it takes the path it always took. + /// + /// + public int LoadedLatencyElevationStaleEpisodes { get; set; } = 3; + + /// + /// Whether the clean run must also cover the hour of day when the elevation used to appear. + /// + /// Without this a nightly problem clears itself: a line that bufferbloats every evening is + /// clean all night, so a run computed at 3 AM sees three clean episodes on top of elevated ones + /// and calls it fixed. Congestion is a time-of-day phenomenon, and "it has been fine since" + /// only means something if the "since" covers the hour it used to go wrong. + /// + /// + /// The cost is honest: a fix is confirmed once the line carries traffic during that hour again, + /// not the moment it stops misbehaving at 3 AM. Until then the figure keeps describing the + /// behavior actually observed at the hour in question, which is all that is known. + /// + /// + /// Only asked when the history shows hour-dependence at all. A line that was elevated in EVERY + /// episode before the clean run was not misbehaving at a time of day - it was misbehaving under + /// load, full stop - so any clean run disproves it. Requiring the same hour there would hold a + /// fix hostage to whenever the line is next busy, which on a WAN whose only regular load is a + /// scheduled speed test is the following day. + /// + /// + public bool LoadedLatencyElevationStaleNeedsSameHour { get; set; } = true; + + /// + /// Utilization band, as a fraction of plan speed, over which a load episode earns credibility: + /// weak at the bottom, full at the top. + /// + /// It starts ABOVE deliberately. Everything reaching this + /// code is already classified loaded at 50%, so a ramp from zero would score almost every + /// episode near the top and separate nothing. Queues do not really build until the pipe is + /// most of the way full, so 60% is where the evidence starts being worth something and 90% is + /// where it is worth all it can be. + /// + /// + public double LoadedCredibilityUtilizationStart { get; set; } = 0.60; + + /// Utilization at which an episode is fully credible. See the start of the band. + public double LoadedCredibilityUtilizationFull { get; set; } = 0.90; + + /// + /// Least weight any load episode keeps, however light. Never zero: a lightly loaded episode is + /// weak evidence, not absent evidence, and a line whose only load is light would otherwise + /// have nothing to score at all. + /// + public double LoadedLatencyMinLoadWeight { get; set; } = 0.15; + + /// + /// Sustained seconds of load after which an episode is fully credible. Duration is not just + /// more samples: a short burst is the case load CLASSIFICATION gets wrong most often, and it is + /// also too brief for buffers to fill, so its latency understates what the line does when the + /// pipe stays full. A long saturation is the best evidence there is - better than a speed test, + /// which is short and synthetic - so it carries full weight while a few seconds of traffic + /// carries a fraction. + /// + public int LoadedLatencyFullCredibilitySustainedSeconds { get; set; } = 60; + /// Weight of loaded packet loss within the access dimension. public double LoadedLossWeight { get; set; } = 0.14875; @@ -845,7 +997,15 @@ public static class IspHealthProfiles IdleRttIdealMs: 1.5, IdleRttNormalLowMs: 2.0, IdleRttNormalHighMs: 3.0, IdleRttPoorMs: 8.0, IdleLossIdealPct: 0.02, IdleLossAcceptablePct: 0.05, LoadedLossDownLowPct: 1.0, LoadedLossDownHighPct: 2.0, - LoadedLossUpLowPct: 0.5, LoadedLossUpHighPct: 1.0, + // Upstream high is 1.5 rather than 1.0 (raised 2026-08-05). GPON upstream is 1.244 Gbps + // shared on TDMA grants, and a gig plan alone is ~80% of it, so contention loss under a + // saturating upload is the medium behaving normally rather than a fault. It is also + // where SQM does its work: we recommend enabling it, and an AQM controls the queue BY + // dropping - at a 1.0 ceiling a correctly-shaping line was graded as a problem for it. + // A genuinely oversubscribed segment still fails (3% scores 50, 4.5% scores 32). + // Judgment, not measurement - unlike the RTT / jitter / stability bands below, the + // loaded-loss bands have no calibration set behind them. + LoadedLossUpLowPct: 0.5, LoadedLossUpHighPct: 1.5, LoadedDeltaExcellentMs: 2.0, LoadedDeltaAcceptableMs: 10.0, JitterIdealMs: 0.4, JitterTypicalMs: 0.7, JitterPoorMs: 3.0, StabilityMadIdealMs: 0.15, StabilityMadTypicalMs: 0.4, StabilityMadPoorMs: 1.5), @@ -881,11 +1041,27 @@ public static class IspHealthProfiles // (Local Priority) ~4.3 ms MAD, degraded backup ~9.2 ms. The several-ms steady-state wander // is inherent LEO (handovers ~every 15 s); MAD is robust to the obstruction tail. AccessTechnology.Satellite => new AccessProfile("Satellite (LEO)", - IdleRttIdealMs: 23.0, IdleRttNormalLowMs: 30.0, IdleRttNormalHighMs: 45.0, IdleRttPoorMs: 80.0, + // Anchored on measured plans rather than estimates. 23 ms is the best the medium does + // at all - months of it on a tier above Local Priority - so it is full marks, and 42 ms + // is the floor of good, which is where a Backup dish sits when nothing is wrong with + // it. Those two points set the rest: with the ladder's 85 at normal-high and 25 at + // poor, 40 and 64 put 42 exactly on 80 and drop 45 to about 72. + // + // Deliberately not tier-aware. A cheaper plan really is worse latency, and hiding that + // behind per-tier bands would score every dish against its own plan and never tell + // anyone their tier is the reason. + IdleRttIdealMs: 23.0, IdleRttNormalLowMs: 30.0, IdleRttNormalHighMs: 40.0, IdleRttPoorMs: 64.0, IdleLossIdealPct: 0.2, IdleLossAcceptablePct: 0.5, LoadedLossDownLowPct: 0.5, LoadedLossDownHighPct: 1.0, LoadedLossUpLowPct: 0.25, LoadedLossUpHighPct: 0.5, - LoadedDeltaExcellentMs: 5.0, LoadedDeltaAcceptableMs: 25.0, + // Set from 403 real Starlink tests. The old 25 ms ceiling sat above the 95th + // percentile of measured delta (17.3 down, 21.6 up), so 97% of load events passed and + // nothing could ever fail it. 12 ms sits near p88 and flags the worst sixth; 3 ms is + // about the median, so "excellent" still means better than this link's usual. + // Not pushed lower on purpose: idle RTT itself swings 16 to 77 ms with obstructions + // and handovers, and a quarter of measured deltas come out negative, so a tighter + // ceiling would be reading that movement rather than queueing. + LoadedDeltaExcellentMs: 3.0, LoadedDeltaAcceptableMs: 12.0, JitterIdealMs: 5.0, JitterTypicalMs: 6.5, JitterPoorMs: 15.0, StabilityMadIdealMs: 5.0, StabilityMadTypicalMs: 9.0, StabilityMadPoorMs: 22.0), diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthRegistry.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthRegistry.cs index 24d333eded..1d80e551ba 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthRegistry.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthRegistry.cs @@ -4,10 +4,13 @@ namespace NetworkOptimizer.Web.Services.Monitoring.IspHealth; /// /// Owns one (and its ) -/// per site. The report snapshot, compute lock, custom-window cache, and adaptive -/// window state are all per-site; a single instance pinned to the default site put -/// the main site's ISP Health score on every site's Monitoring page. Scoped -/// resolution forwards to the current site's instance, same pattern as +/// per (site, WAN). The report snapshot, compute lock, custom-window cache, and adaptive +/// window state are all per-instance; a single instance pinned to the default site put +/// the main site's ISP Health score on every site's Monitoring page. The WAN dimension +/// keys one report per graded WAN: the null/absent WAN is the configured-primary +/// instance every install has (single-WAN sites never create another), and the WAN +/// selectors resolve non-primary WANs by their UniFi wan key ("wan2"). Scoped +/// resolution forwards to the current site's primary instance, same pattern as /// MonitoringInfluxRegistry / MonitoringCollectionRegistry. /// public class IspHealthRegistry : ISiteScopedRegistry @@ -20,21 +23,75 @@ public IspHealthRegistry(IServiceProvider serviceProvider) _serviceProvider = serviceProvider; } - /// The ISP Health service for a site, created on first use. - public IspHealthService GetFor(string slug) => - _instances.GetOrAdd(slug, s => + // Composite key: "{slug}" for the primary instance (identical to the pre-multi-WAN key, so + // nothing about the primary path changes), "{slug}|{wanKey}" for a scoped WAN. The slug + // alphabet has no '|', so keys cannot collide, and EvictSite can sweep by prefix. + private static string Key(string slug, string? wanInterface) => + string.IsNullOrWhiteSpace(wanInterface) + ? slug + // Normalized ("wan1" == "wan") so a legacy alias can never mint a second instance + // grading the same WAN. + : $"{slug}|{NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface.Trim())}"; + + /// The site's primary-WAN ISP Health service, created on first use. + public IspHealthService GetFor(string slug) => GetFor(slug, null); + + /// + /// The ISP Health service grading one WAN of a site, created on first use. Null (or empty) + /// is the configured-primary instance; a UniFi wan key + /// ("wan2") grades that WAN alone. + /// + public IspHealthService GetFor(string slug, string? wanInterface) => + _instances.GetOrAdd(Key(slug, wanInterface), _ => { - var resolver = ActivatorUtilities.CreateInstance(_serviceProvider, s); - return ActivatorUtilities.CreateInstance(_serviceProvider, s, resolver); + var resolver = ActivatorUtilities.CreateInstance(_serviceProvider, slug); + return string.IsNullOrWhiteSpace(wanInterface) + ? ActivatorUtilities.CreateInstance(_serviceProvider, slug, resolver) + : ActivatorUtilities.CreateInstance(_serviceProvider, slug, resolver, + NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface.Trim())); }); - /// The default site's ISP Health service. + /// The default site's primary-WAN ISP Health service. public IspHealthService GetDefault() => GetFor(SiteManagementService.DefaultSiteSlug); + /// + /// Drops the cached report for EVERY WAN of a site, so the next read recomputes. + /// + /// Callers reach for this after the monitoring targets change, and a target belongs to one WAN + /// but the change is not knowable per-WAN from where they stand: pausing a flaky target, an + /// upstream discovery committing hops, a rediscovery replacing them. Invalidating only the + /// injected instance - which is always the primary - left every secondary WAN's report frozen + /// on inputs that no longer existed, for as long as the process lived. + /// + /// + /// Cheap: it marks the caches stale rather than computing anything, and a WAN nobody opens + /// never recomputes at all. + /// + /// + public void InvalidateSite(string slug) + { + foreach (var (key, instance) in _instances) + { + if (string.Equals(key, slug, StringComparison.OrdinalIgnoreCase) + || key.StartsWith(slug + "|", StringComparison.OrdinalIgnoreCase)) + { + instance.Invalidate(); + } + } + } + /// + /// Sweeps every WAN instance of the site, not just the primary. public Func? EvictSite(string slug) { - _instances.TryRemove(slug, out _); + foreach (var key in _instances.Keys) + { + if (string.Equals(key, slug, StringComparison.OrdinalIgnoreCase) + || key.StartsWith(slug + "|", StringComparison.OrdinalIgnoreCase)) + { + _instances.TryRemove(key, out _); + } + } return null; } } diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthScorer.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthScorer.cs index b8006ee65a..977b1bcbe6 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthScorer.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthScorer.cs @@ -85,7 +85,7 @@ public IspHealthReport Score(IspHealthInputs inputs, AccessProfile profile) var jitterFloor = ComputeJitterFloor(inputs); _logger?.LogDebug("ISP Health: path jitter floor {Floor} ms", FormatMsOrNull(jitterFloor)); var (loadedLatency, hasLoadedLatency) = ScoreLoadedLatency(loadedDeltas, profile); - var (loadedLoss, hasLoadedLoss) = ScoreLoadedLoss(inputs.LossPoolSeries, loadWindows, profile); + var (loadedLoss, hasLoadedLoss) = ScoreLoadedLoss(inputs, inputs.LossPoolSeries, loadWindows, profile); // Physical Link: the access medium's own physical layer (optical RX, DOCSIS RF/FEC, // cellular signal). Null factor (omitted, no penalty) when no source matched the WAN. @@ -408,36 +408,215 @@ internal LoadedDeltas ResolveLoadedDeltas( double? down = null, up = null; if (loadWindows.Count > 0) { - down = LoadedLatencyDelta(inputs, loadWindows, w => w.IsLoadedDown, w => w.IsLoadedUp); - up = LoadedLatencyDelta(inputs, loadWindows, w => w.IsLoadedUp, w => w.IsLoadedDown); + down = LoadedLatencyDelta(inputs, loadWindows, w => w.IsLoadedDown, w => w.IsLoadedUp, upstream: false); + up = LoadedLatencyDelta(inputs, loadWindows, w => w.IsLoadedUp, w => w.IsLoadedDown, upstream: true); } bool downFromSpeedTest = false, upFromSpeedTest = false; if (down == null || up == null) { var (tests, _) = SelectSpeedTests(inputs); - var downDeltas = tests - .Where(t => t.DownloadLatencyMs.HasValue && t.PingMs.HasValue) - .Select(t => Math.Max(0, t.DownloadLatencyMs!.Value - t.PingMs!.Value)) - .ToList(); - var upDeltas = tests - .Where(t => t.UploadLatencyMs.HasValue && t.PingMs.HasValue) - .Select(t => Math.Max(0, t.UploadLatencyMs!.Value - t.PingMs!.Value)) + // Recency-weighted, so a fix shows up in days rather than after the good tests + // outnumber the bad ones. Still a median: one clean run cannot clear a standing + // finding, and one bad run cannot create one. + List<(DateTime Time, double Value)> Deltas(Func loaded) => tests + .Where(t => loaded(t).HasValue && t.PingMs.HasValue) + .Select(t => (t.Time, Math.Max(0, loaded(t)!.Value - t.PingMs!.Value))) .ToList(); + + var downDeltas = Deltas(t => t.DownloadLatencyMs); + var upDeltas = Deltas(t => t.UploadLatencyMs); + // No load weighting here: a speed test saturates the line by definition, so every one + // of these is a fully loaded episode already. if (down == null && downDeltas.Count > 0) { - down = SeriesStats.Median(downDeltas); + down = RecentRegimeDelta(downDeltas) ?? RecencyWeightedDelta(downDeltas, inputs.WindowEnd); downFromSpeedTest = true; } if (up == null && upDeltas.Count > 0) { - up = SeriesStats.Median(upDeltas); + up = RecentRegimeDelta(upDeltas) ?? RecencyWeightedDelta(upDeltas, inputs.WindowEnd); upFromSpeedTest = true; } } return new LoadedDeltas(down, up, downFromSpeedTest, upFromSpeedTest); } + /// + /// The loaded delta the speed tests support, newest first. Normally the recency-weighted + /// median; but when the newest runs in a row all sit far below everything older, that is a + /// line someone FIXED, and the older tests describe a connection that no longer exists. + /// + /// Age-weighting alone cannot see this. The scoring window is short, so a fix this afternoon + /// leaves a handful of clean tests against a handful of bad ones only hours older - too close + /// in age for decay to separate, and the median stays on the bad cluster for days after the + /// line stopped misbehaving. + /// + /// + /// + /// The delta a run of the NEWEST measurements supports when they all sit far below everything + /// older - a line someone fixed, where the older measurements describe a connection that no + /// longer exists. Null when the evidence does not say that. + /// + /// Age-weighting alone cannot see this. The scoring window is short, so a fix this afternoon + /// leaves a few clean measurements against a few bad ones only hours older: too close in age + /// for decay to separate, and the median stays on the bad cluster for days after the line + /// stopped misbehaving. + /// + /// + /// Asked BEFORE the monitoring path's noise floor on purpose. That floor drops deltas under + /// half a millisecond, which is exactly what a fixed line produces - so the evidence of the fix + /// lives in the samples it throws away, and a rule running after it could never see one. + /// + /// + private double? RecentRegimeDelta(IReadOnlyList<(DateTime Time, double Value)> newestFirst) + { + var run = _options.LoadedLatencyRegimeSamples; + if (run <= 0 || newestFirst.Count <= run) return null; + + var recent = newestFirst.Take(run).Select(s => s.Value).ToList(); + var older = newestFirst.Skip(run).Select(s => s.Value).ToList(); + if (SeriesStats.Median(older) is not { } baseline || baseline <= LoadedLatencyRegimeFloorMs) + return null; + + // The floor keeps a connection whose delta is already small from tripping this on ordinary + // measurement noise - halving 1 ms proves nothing. + var threshold = Math.Max( + baseline * _options.LoadedLatencyRegimeDropFraction, + LoadedLatencyRegimeFloorMs); + return recent.All(v => v < threshold) ? SeriesStats.Median(recent) : null; + } + + /// + /// Weighs each measurement by age and, where the load behind it is known, by how hard the line + /// was working - then takes the median at half the total weight. + /// + /// + /// One episode's added delay, by the SAME statistic the pooled path has always used: every + /// access hop's samples together, those below the noise floor dropped, median of what remains. + /// + /// That statistic IS the attribution and is deliberately untouched. Pooling the hops and taking + /// a low-order statistic of the credible ones is what tells a hop that genuinely queues from + /// one that only deprioritizes ICMP - the throttled hop sits at the top of the distribution + /// where a low-order statistic ignores it, while a flat near hop falls below the floor and + /// cannot dilute an OLT that really did spike. Reaching for the worst hop instead would promote + /// the very noise this rejects. + /// + /// + /// Nothing above the floor is zero: the line was loaded and it stayed clean. That is a reading, + /// not a gap - and it was the reading being thrown away, which is how a WAN whose every episode + /// was clean still reported the median of a handful of stray samples. + /// + /// + /// + /// Raises one episode's delta to a WAN speed test's own loaded-vs-idle figure when a test ran + /// during it and read higher, in the SAME direction. + /// + /// A test carries its own idle reference () taken by the + /// same probe against the same endpoint seconds apart, so the difference needs no baseline of + /// ours and inherits none of its blind spots. + /// + /// + /// Deliberately one-directional, and deliberately per-episode. Taking the larger of two + /// estimates biases upward wherever both are about right, so it is confined to the episodes a + /// test actually overlapped rather than allowed to lift the whole factor. + /// + /// + private List<(DateTime Time, double Value)> QualifyingTests( + DateTime start, DateTime end, IspHealthInputs inputs, bool upstream) + { + var tolerance = TimeSpan.FromSeconds(Math.Max(0, _options.LoadedLatencySpeedTestMatchSeconds)); + var found = new List<(DateTime Time, double Value)>(); + foreach (var test in inputs.WanSpeedTests) + { + if (test.Time < start - tolerance || test.Time > end + tolerance) continue; + var underLoad = upstream ? test.UploadLatencyMs : test.DownloadLatencyMs; + if (!underLoad.HasValue || !test.PingMs.HasValue) continue; + + // Only a test that actually filled the pipe measured this link under load. Without + // this the lift is genuinely biased: a stalled or server-limited test reads high for + // reasons that are not your access queue, and nothing downstream can pull it back. + // Unknown plan speed means the question cannot be asked, so the test is not used. + var achieved = upstream ? test.UploadMbps : test.DownloadMbps; + var expected = upstream ? inputs.ExpectedUploadMbps : inputs.ExpectedDownloadMbps; + if (!(expected > 0) || achieved < expected * _options.LoadedLatencySpeedTestMinPlanFraction) + continue; + + found.Add((test.Time, Math.Max(0, underLoad.Value - test.PingMs.Value))); + } + return found; + } + + private static double EpisodeDelta(List deltas, double noiseFloor) + { + var credible = deltas.Where(d => d >= noiseFloor).ToList(); + return credible.Count == 0 ? 0 : SeriesStats.Median(credible)!.Value; + } + + private double? RecencyWeightedDelta( + IReadOnlyList<(DateTime Time, double Value)> samples, + DateTime windowEnd, + Func? loadWeight = null) + => SeriesStats.WeightedMedian(samples + .Select(s => ( + s.Value, + SeriesStats.RecencyWeight(windowEnd - s.Time, _options.LoadedLatencyRecencyHalfLifeHours) + * (loadWeight?.Invoke(s.Time) ?? 1))) + .ToList()); + + /// + /// How much a load episode's latency is worth as evidence, from how hard the line was actually + /// working during it. A window carrying a fifth of the plan barely loads the buffers, so what + /// it shows says little about behavior when the pipe is full; a window at or past + /// counts in full. Never + /// zero - light load is weak evidence, not none - and 1 throughout when the plan speed is + /// unknown, which leaves the figure exactly as it was before load was considered. + /// + private Func BuildLoadWeighting( + IspHealthInputs inputs, bool upstream, IReadOnlySet loaded) + { + var floor = _options.LoadedLatencyMinLoadWeight; + var windowSeconds = Math.Max(1, _options.LoadWindowSeconds); + var fullSeconds = Math.Max(windowSeconds, _options.LoadedLatencyFullCredibilitySustainedSeconds); + var episodeSeconds = SeriesStats.LoadEpisodeSeconds(loaded, windowSeconds); + + double DurationWeight(DateTime key) => + episodeSeconds.TryGetValue(key, out var seconds) + ? SeriesStats.Credibility(seconds, fullSeconds, floor) + : floor; + + // Utilization needs the plan speed. Without it there is nothing to measure "hard" against, + // so that half is left at 1 and duration alone decides - which is still an improvement and + // leaves nothing worse than before. + var planMbps = upstream ? inputs.ExpectedUploadMbps : inputs.ExpectedDownloadMbps; + if (planMbps is not > 0) return time => DurationWeight(FloorToWindow(time)); + + var planBps = planMbps.Value * 1_000_000; + var utilizationByWindow = new Dictionary(); + foreach (var rate in inputs.WanRates) + { + var bps = upstream ? rate.UploadBps : rate.DownloadBps; + if (bps is not > 0) continue; + var key = FloorToWindow(rate.Time); + utilizationByWindow[key] = Math.Max(utilizationByWindow.GetValueOrDefault(key), bps.Value / planBps); + } + + var start = _options.LoadedCredibilityUtilizationStart; + var full = _options.LoadedCredibilityUtilizationFull; + return time => + { + var key = FloorToWindow(time); + var duration = DurationWeight(key); + var utilization = utilizationByWindow.TryGetValue(key, out var u) + ? SeriesStats.CredibilityBetween(u, start, full, floor) + : floor; + return duration * utilization; + }; + } + + /// Below this a loaded delta is too small for a "it was fixed" call to mean anything. + private const double LoadedLatencyRegimeFloorMs = 3; + /// /// Median RTT of the first clean ISP hop during idle windows. Without load /// classification, falls back to the 10th percentile of all RTTs, which @@ -503,6 +682,21 @@ internal LoadedDeltas ResolveLoadedDeltas( }, null, null, null); } + // Both directions pinned at UniFi Network's 1 Mbps minimum is not a 1 Mbps plan - it is the + // lowest the field accepts from someone with nothing real to enter, most often a dish in + // standby or a metered backup held in reserve. A ratio against it is meaningless, but the + // link is not: what matters for a standby WAN is whether it carries usable traffic when + // called on, so it is graded on that instead. Only when BOTH sit at the floor; a real plan + // with a 1 Mbps upstream is unusual but expressible, and half a sentinel is still a plan. + // Corroborated where the dish says so outright, inferred otherwise. The reported tier is + // ground truth and stands on its own: a dish capped by its plan measured against a REAL + // configured plan is the same story told worse, since the shortfall is the tier and not + // the link either way. + var reportedTier = inputs.PhysicalLink?.ReducedSpeedTier == true; + var standby = reportedTier + || (inputs.ExpectedDownloadMbps <= _options.PlanFloorMbps + && inputs.ExpectedUploadMbps <= _options.PlanFloorMbps); + var (tests, stale) = SelectSpeedTests(inputs); if (tests.Count == 0) { @@ -514,8 +708,12 @@ internal LoadedDeltas ResolveLoadedDeltas( }, null, null, null); } - var down = ScoreDirection(tests.Select(t => t.DownloadMbps), inputs.ExpectedDownloadMbps); - var up = ScoreDirection(tests.Select(t => t.UploadMbps), inputs.ExpectedUploadMbps); + var down = standby + ? ScoreStandbyDirection(tests.Select(t => t.DownloadMbps)) + : ScoreDirection(tests.Select(t => t.DownloadMbps), inputs.ExpectedDownloadMbps); + var up = standby + ? ScoreStandbyDirection(tests.Select(t => t.UploadMbps)) + : ScoreDirection(tests.Select(t => t.UploadMbps), inputs.ExpectedUploadMbps); var scores = new[] { down?.Score, up?.Score }.Where(s => s.HasValue).Select(s => s!.Value).ToList(); if (scores.Count == 0) { @@ -536,6 +734,25 @@ internal LoadedDeltas ResolveLoadedDeltas( var typicalUp = up?.TypicalMbps ?? bestUp; var planText = $"{FormatMbps(inputs.ExpectedDownloadMbps ?? 0)} / {FormatMbps(inputs.ExpectedUploadMbps ?? 0)} Mbps plan"; var multi = tests.Count > 1; + if (standby) + { + var standbyNote = multi ? $" Fastest of {tests.Count} WAN tests." : ""; + return (new IspScoreFactor + { + Name = "Speed vs Plan", + Score = (int)Math.Round(scores.Average()), + Weight = _options.SpeedVsPlanWeight, + ValueText = $"{FormatMbps(bestDown)} / {FormatMbps(bestUp)} Mbps", + Description = (reportedTier + ? "The dish reports a reduced-speed plan tier (such as Standby), so throughput " + + "is capped by the plan rather than the link. Graded on whether it carries " + + "usable traffic." + : "Backup link with expected speeds at 1 / 1 Mbps, the lowest UniFi Network " + + "allows. Graded on whether it carries usable traffic, not against a plan speed.") + + standbyNote + staleNote + }, new SpeedTestSample(bestTest.Time, bestDown, bestUp), down?.TypicalMbps, up?.TypicalMbps); + } + var description = multi ? $"Fastest of {tests.Count} WAN tests vs your {planText}. Typical {FormatMbps(typicalDown)} / {FormatMbps(typicalUp)} Mbps (down / up).{staleNote}" : $"Your latest WAN speed test vs your {planText} (down / up).{staleNote}"; @@ -549,6 +766,41 @@ internal LoadedDeltas ResolveLoadedDeltas( }, new SpeedTestSample(bestTest.Time, bestDown, bestUp), down?.TypicalMbps, up?.TypicalMbps); } + /// + /// Grades a standby link on capability rather than ratio: is it carrying usable traffic. + /// + /// Reaching the nominal 1 Mbps IS meeting the stated plan, so that scores full. Below it the + /// taper is deliberately forgiving - a dish in standby delivering 0.6 / 0.1 Mbps is doing + /// exactly its job in the emergency it exists for, and the ratio scoring called that a 17. + /// Only a link carrying essentially nothing scores badly, because that is the only outcome + /// that would actually fail its owner. + /// + /// + private (double Score, double BestMbps, double TypicalMbps)? ScoreStandbyDirection( + IEnumerable resultsMbps) + { + var sorted = resultsMbps.OrderBy(v => v).ToList(); + if (sorted.Count == 0) return null; + var trim = (int)Math.Floor(sorted.Count * _options.SpeedTestOutlierTrimFraction); + var kept = sorted.Skip(Math.Min(trim, sorted.Count - 1)).ToList(); + + var best = kept[^1]; + var typical = SeriesStats.Median(kept)!.Value; + var totalWeight = _options.SpeedCapacityWeight + _options.SpeedTypicalWeight; + var score = (StandbyScore(best) * _options.SpeedCapacityWeight + + StandbyScore(typical) * _options.SpeedTypicalWeight) / totalWeight; + return (score, best, typical); + + static double StandbyScore(double mbps) => mbps switch + { + >= 1.0 => 100, // meets the nominal plan outright + >= 0.1 => 80 + 20 * (mbps - 0.1) / 0.9, // usable for messaging, mail, alarms + >= 0.01 => 40 + 40 * (mbps - 0.01) / 0.09, // reachable, barely + > 0 => 40 * mbps / 0.01, + _ => 0 + }; + } + /// /// Outlier-trims one direction's results and blends capacity (best) with typical /// delivery (median of the rest). Returns the score plus the best and typical for display. @@ -742,7 +994,8 @@ private double ScoreLoadedDelta(double delta, AccessProfile profile) IspHealthInputs inputs, Dictionary loadWindows, Func directionSelector, - Func oppositeSelector) + Func oppositeSelector, + bool upstream) { const double noiseFloor = 0.5; var loaded = DilateLoadedWindows(loadWindows, directionSelector, oppositeSelector); @@ -751,25 +1004,170 @@ private double ScoreLoadedDelta(double delta, AccessProfile profile) ? inputs.AccessHopSeries : new List> { inputs.FirstHopSeries }; - var pooledDeltas = new List(); - foreach (var hop in accessCohort) + // Everything monitored out this WAN, LAN targets excluded - transit, the internet + // destinations, and the user's own witness targets join the access hops. A queue on the + // access link is in front of ALL of them, so under real bufferbloat they rise together; + // one hop rising while the rest read clean at the same second is that responder, not the + // link. This is the absolution DestinationSeries already performs for jitter by ancestry, + // done here by simultaneity, which needs no proven route between the two. + var agreementCohort = accessCohort + .Concat(inputs.TransitAsnSeries.Select(a => a.Samples)) + .Concat(inputs.DestinationSeries.Select(a => a.Samples)) + .Concat(inputs.WitnessSeries.Select(a => a.Samples)) + .Where(series => series.Count > 0) + .ToList(); + + var perHop = new List<(DateTime Time, double Value, int Series)>(); + for (var h = 0; h < agreementCohort.Count; h++) { + var hop = agreementCohort[h]; var baseline = ComputeIdleBaseline(hop, loadWindows); if (baseline == null) continue; - var deltas = hop + var series = h; + perHop.AddRange(hop .Where(s => s.RttAvgMs.HasValue && loaded.Contains(FloorToWindow(s.Time))) - .Select(s => s.RttAvgMs!.Value - baseline.Value); + .Select(s => (s.Time, s.RttAvgMs!.Value - baseline.Value, series))); + } + + // Hops that reported at the same instant are collapsed to what they AGREED on before any + // of this looks at magnitudes. Pooling them flat and then keeping whatever cleared the + // noise floor asked "was any sample high", which one ICMP-deprioritized responder answers + // yes to on its own; the clean hops it was sitting next to were discarded by that same + // floor before the median ever saw them. Collapsing first asks "was the LINK high", which + // is the question the score is about, and a lone squealer loses the vote. + var pooled = SeriesStats.CommonModeByInstant( + perHop, + TimeSpan.FromSeconds(_options.LoadedLatencyAgreementToleranceSeconds), + _options.LoadedLatencyAgreementMinCohort, + noiseFloor); + + // Grouped by EPISODE - the run of consecutive loaded windows - not by window. A window is + // seven seconds, so "the newest three windows" is the last twenty seconds and any brief + // lull inside one bad evening would read as a line that was fixed. An episode is however + // long the line actually stayed loaded, which is the unit a person means by "a load event". + var episodeStarts = SeriesStats.LoadEpisodeStarts(loaded, Math.Max(1, _options.LoadWindowSeconds)); + var episodes = pooled + .Where(x => episodeStarts.ContainsKey(FloorToWindow(x.Time))) + .GroupBy(x => episodeStarts[FloorToWindow(x.Time)]) + .Select(g => (Time: g.Key, Value: EpisodeDelta(g.Select(x => x.Value).ToList(), noiseFloor))) + .OrderByDescending(e => e.Time) + .ToList(); - pooledDeltas.AddRange(deltas); + // Has the elevation STOPPED? Comparing medians cannot answer that here: most loaded samples + // sit near zero even while the line misbehaves, so the median over everything is ~0 before + // and after a fix, and the figure that gets reported comes from the elevated minority the + // noise floor keeps. Whether elevation is still happening IS visible - and a run of clean + // episodes after elevated ones is a line someone fixed. + // + // A line that was not fixed is untouched: still-bad lines have elevated episodes among + // their newest, and always-clean lines have no elevated episodes to go stale. + if (episodes.Count == 0 || pooled.Count < _options.MinLoadedSamples) return null; + + // Where a WAN speed test ran during an episode, it measured the same event on purpose and + // at full saturation, while these probes only sampled it on their own cadence - so a short + // event's peak queue can build and drain between two probes and never be seen. Taken only + // when it reads HIGHER: that is the direction passive sampling fails in. When the test + // reads lower, the series saw something the test's own window did not cover, and the + // measurement stands. + var episodeEnds = episodeStarts + .GroupBy(kv => kv.Value) + .ToDictionary(g => g.Key, g => g.Max(kv => kv.Key) + .AddSeconds(Math.Max(1, _options.LoadWindowSeconds))); + // What a speed test measured during each episode, where one qualified. Applied to the + // FINAL figure rather than to the episode it came from, because the figure is a median + // across episodes and a median cannot be moved by one member however it is weighted - + // lifting per-episode left the better instrument unable to change a reported number even + // once. But applied only over the episodes the answer is actually being drawn from: a + // test is evidence about the line AS IT WAS THEN, and a window-wide maximum would let a + // test from before a fix override the clean run that proves the fix. + var testsByEpisode = episodes.ToDictionary( + e => e.Time, + e => QualifyingTests( + e.Time, episodeEnds.TryGetValue(e.Time, out var end) ? end : e.Time, + inputs, upstream)); + + // Judged the same way the speed-test fallback judges its own pool: a recent run of clean + // tests is read as the line having been FIXED and the older ones as describing a + // connection that no longer exists, otherwise recency-weighted. A plain maximum threw all + // of that away - the worst test in the window won outright, so a line whose recent tests + // are all clean kept reporting its worst day from a week ago. + double SpeedTestOver(IEnumerable<(DateTime Time, double Value)> over) + { + var deltas = over + .SelectMany(e => testsByEpisode.TryGetValue(e.Time, out var t) ? t : []) + .DistinctBy(t => t.Time) + .OrderByDescending(t => t.Time) + .ToList(); + if (deltas.Count == 0) return double.NegativeInfinity; + return RecentRegimeDelta(deltas) + ?? RecencyWeightedDelta(deltas, inputs.WindowEnd) + ?? double.NegativeInfinity; } - var credible = pooledDeltas.Where(d => d >= noiseFloor).ToList(); - if (credible.Count < _options.MinLoadedSamples) return null; - return Math.Max(0, SeriesStats.Median(credible)!.Value); + var loadWeight = BuildLoadWeighting(inputs, upstream, loaded); + var stale = _options.LoadedLatencyElevationStaleEpisodes; + var elevatedEpisodes = episodes.Where(e => e.Value >= noiseFloor).ToList(); + + ElevationVerdict.Verdict? verdict = null; + if (stale > 0 && episodes.Count > stale) + { + verdict = ElevationVerdict.For( + episodes, noiseFloor, stale, + _options.LoadedLatencyElevationStaleNeedsSameHour, + TimeSpan.FromSeconds(Math.Max(1, _options.LoadWindowSeconds)), + LoadedLatencyRegimeFloorMs); + + } + + // Logged for EVERY report, verdict or not. Gating this behind the verdict's own condition + // meant a WAN with too few load episodes to judge - the case most worth looking at - was + // the one that said nothing at all. The newest elevated episode is named so the moment can + // be pulled up in the time series rather than inferred from what sits near it. + _logger?.LogDebug( + "ISP Health: loaded latency {Dir} - {Episodes} episode(s) from {Cohort} target(s), " + + "{Elevated} elevated (newest {NewestElevated}), clean run {CleanRun}, needs {Needed}, " + + "problem hour re-tested: {HourCovered} -> {Verdict}", + upstream ? "up" : "down", episodes.Count, agreementCohort.Count, elevatedEpisodes.Count, + elevatedEpisodes.Count > 0 + // Local, not UTC: this is read by someone about to go and look at that moment in + // the time series, and every other part of this reasons in their hours too. + ? $"{TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(elevatedEpisodes[0].Time, DateTimeKind.Utc), TimeZoneInfo.Local):yyyy-MM-dd HH:mm:ss} local at " + + elevatedEpisodes[0].Value.ToString("0.0", CultureInfo.InvariantCulture) + " ms" + : "none", + verdict?.CleanRun.Count.ToString(CultureInfo.InvariantCulture) ?? "n/a", stale, + verdict?.ProblemHourReTested.ToString() ?? "n/a", + verdict is null ? "too few episodes to judge" + : verdict.ElevationIsOver ? "elevation over" + : elevatedEpisodes.Count == 0 ? "clean - no elevated episodes" + : "still elevated"); + + // The line was fixed: the elevated episodes describe a connection that no longer exists, + // so only the clean run since speaks for it. + // Only tests taken DURING the clean run speak for a line that was fixed. The elevated + // episodes describe a connection that no longer exists, and so do the tests that ran in + // them - letting those back in through the lift would re-assert the very finding the + // clean run just cleared. + if (verdict is { ElevationIsOver: true }) + return Math.Max(0, Math.Max( + SeriesStats.Median(verdict.CleanRun.Select(e => e.Value).ToList())!.Value, + SpeedTestOver(verdict.CleanRun))); + + // The reported figure is the median ACROSS EPISODES - what this line typically does under + // load - weighted by recency and by how credible each episode's load was. + // + // It used to be the median of the SAMPLES above the noise floor, which is a different + // question: the worst of it. One elevated episode among five then set the whole number, + // and a WAN whose every episode was clean could still report tens of milliseconds off a + // handful of stray samples. The floor still decides what counts as elevated for the + // verdict above - it is not a filter on what gets reported. + return Math.Max(0, Math.Max( + RecencyWeightedDelta(episodes, inputs.WindowEnd, loadWeight) ?? 0, + SpeedTestOver(episodes))); } private (IspScoreFactor Factor, bool HasData) ScoreLoadedLoss( + IspHealthInputs inputs, List> lossPool, Dictionary loadWindows, AccessProfile profile) @@ -784,8 +1182,8 @@ private double ScoreLoadedDelta(double delta, AccessProfile profile) }, false); } - var downLoss = LoadedMeanLoss(lossPool, loadWindows, w => w.IsLoadedDown, w => w.IsLoadedUp); - var upLoss = LoadedMeanLoss(lossPool, loadWindows, w => w.IsLoadedUp, w => w.IsLoadedDown); + var downLoss = LoadedMeanLoss(inputs, lossPool, loadWindows, w => w.IsLoadedDown, w => w.IsLoadedUp, upstream: false); + var upLoss = LoadedMeanLoss(inputs, lossPool, loadWindows, w => w.IsLoadedUp, w => w.IsLoadedDown, upstream: true); var scores = new List(); if (downLoss.HasValue) scores.Add(ScoreLossBand(downLoss.Value, profile.LoadedLossDownLowPct, profile.LoadedLossDownHighPct)); @@ -831,17 +1229,20 @@ private double ScoreLossBand(double loss, double bandLow, double bandHigh) } private double? LoadedMeanLoss( + IspHealthInputs inputs, List> lossPool, Dictionary loadWindows, Func directionSelector, - Func oppositeSelector) + Func oppositeSelector, + bool upstream) { var loaded = DilateLoadedWindows(loadWindows, directionSelector, oppositeSelector); - var losses = lossPool.SelectMany(series => series) + var samples = lossPool.SelectMany(series => series) .Where(s => s.LossPercent.HasValue && !InOutage(s.Time) && loaded.Contains(FloorToWindow(s.Time))) - .Select(s => _gatewayFloor.Apply(s.LossPercent!.Value, s.Time)) + .Select(s => (s.Time, Value: _gatewayFloor.Apply(s.LossPercent!.Value, s.Time))) .ToList(); + var losses = samples.Select(s => s.Value).ToList(); // Loaded loss rests on however many samples happen to fall inside the loaded windows, and on // a long window the rate series is aggregated far coarser than LoadWindowSeconds, so that set // can be small enough for a few dark samples to set the whole figure. Log what it was built @@ -851,7 +1252,17 @@ private double ScoreLossBand(double loss, double bandLow, double bandHigh) losses.Count, loaded.Count, losses.Count(l => l >= 99.0), losses.Count > 0 ? losses.Average().ToString("0.##", CultureInfo.InvariantCulture) : "n/a"); if (losses.Count < _options.MinLoadedSamples) return null; - return losses.Average(); + // Same credibility rules as loaded latency: a sustained saturation says far more about + // behavior under load than a two-second burst that may not even have been load, and recent + // evidence outranks old evidence of the same kind. A weighted MEAN rather than a median, + // because loss is a rate - most samples are zero even on a bad line, and a median over + // them reports zero however bad the rest are. + var loadWeight = BuildLoadWeighting(inputs, upstream, loaded); + return SeriesStats.WeightedMean(samples + .Select(s => (s.Value, + SeriesStats.RecencyWeight(inputs.WindowEnd - s.Time, _options.LoadedLatencyRecencyHalfLifeHours) + * loadWeight(s.Time))) + .ToList()) ?? losses.Average(); } /// @@ -1983,24 +2394,34 @@ private List CollectIssues( // Queues. Loss under load while it shapes means the rate it holds isn't backing off // enough for the real-time capacity drop, so point at its own tuning knobs (Severity // deepens the time-of-day dips; nominal speeds set the ceiling everything scales from). + // One recommendation used to serve both findings below, worded for loss - so a + // bufferbloat finding was answered with advice about drops the user was not seeing. + // The Adaptive SQM branch now says which symptom it is talking about; the other two + // are symptom-neutral and stay one string. string recommendation; + string latencyRecommendation; if (inputs.AdaptiveSqmEnabled) { recommendation = "Adaptive SQM is already shaping this WAN, so loss under load means the rate it holds isn't backing off enough when the line congests. In your Adaptive SQM settings, raise the Severity so the peak-hour rate dips go deeper, or lower the nominal download/upload if the line consistently delivers less than its plan. If loss persists once the rate is pulled down, the drops are upstream and only your ISP can fix them."; + latencyRecommendation = "Adaptive SQM is already shaping this WAN, so latency under load means the rate it holds isn't backing off enough when the line congests. In your Adaptive SQM settings, raise the Severity so the peak-hour rate dips go deeper, or lower the nominal download/upload if the line consistently delivers less than its plan. If the loaded latency persists once the rate is pulled down, the queue is upstream and only your ISP can drain it."; } else if (inputs.SmartQueuesEnabled) { recommendation = "Smart Queues is enabled on this WAN but the line still degrades under load; check that its configured rates match what the line actually delivers."; + latencyRecommendation = recommendation; } else { recommendation = "Enable Smart Queues (SQM) on this WAN in UniFi Network (Settings, Internet, your WAN, Smart Queues)."; + latencyRecommendation = recommendation; } // Only pitch Adaptive SQM when the WAN isn't already running it. if (!inputs.AdaptiveSqmEnabled && inputs.CongestionEvents.Count(e => e.Disposition == CongestionDisposition.Confirmed) >= _options.SqmRecurringCongestionEvents) { - recommendation += " This connection also shows a recurring congestion pattern; consider Adaptive SQM, which tracks time-of-day capacity changes automatically."; + const string alsoConsider = " This connection also shows a recurring congestion pattern; consider Adaptive SQM, which tracks time-of-day capacity changes automatically."; + recommendation += alsoConsider; + latencyRecommendation += alsoConsider; } if (latencyTriggered) { @@ -2009,7 +2430,7 @@ private List CollectIssues( Severity = IspIssueSeverity.Warning, Title = "Bufferbloat under load", Description = "Latency rises well beyond the excellent range for this connection type when the line is loaded.", - Recommendation = recommendation, + Recommendation = latencyRecommendation, LinkUrl = "/sqm", LinkText = "Adaptive SQM" }); @@ -2113,8 +2534,8 @@ private List CollectIssues( var loss = false; if (loadWindows.Count > 0) { - var downLoss = LoadedMeanLoss(inputs.LossPoolSeries, loadWindows, w => w.IsLoadedDown, w => w.IsLoadedUp); - var upLoss = LoadedMeanLoss(inputs.LossPoolSeries, loadWindows, w => w.IsLoadedUp, w => w.IsLoadedDown); + var downLoss = LoadedMeanLoss(inputs, inputs.LossPoolSeries, loadWindows, w => w.IsLoadedDown, w => w.IsLoadedUp, upstream: false); + var upLoss = LoadedMeanLoss(inputs, inputs.LossPoolSeries, loadWindows, w => w.IsLoadedUp, w => w.IsLoadedDown, upstream: true); loss = downLoss > profile.LoadedLossDownHighPct || upLoss > profile.LoadedLossUpHighPct; } return (latency, loss); diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthService.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthService.cs index e55839e9fa..57c348465e 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthService.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthService.cs @@ -3,6 +3,7 @@ using NetworkOptimizer.Core.Helpers; using NetworkOptimizer.Storage.Models; using NetworkOptimizer.Storage.Services; +using NetworkOptimizer.UniFi; namespace NetworkOptimizer.Web.Services.Monitoring.IspHealth; @@ -30,6 +31,11 @@ public class IspHealthService private readonly ILogger _logger; private readonly string _siteSlug; private readonly bool _isDefault; + // The UniFi wan key ("wan2") this instance grades, or null for the configured-primary + // instance - which is every install's only instance until it has more than one WAN. + // The primary instance resolves its wan key per compute (today's behavior, unchanged); + // a scoped instance grades exactly the WAN it was created for. + private readonly string? _scopedWanKey; private readonly IspHealthOptions _options = new(); private const int MaxCustomWindowHours = 720; // 30-day cap on the date/time filter, matching the UI private readonly SemaphoreSlim _computeLock = new(1, 1); @@ -66,10 +72,13 @@ public IspHealthService( SiteConnectionRegistry siteConnections, PhysicalLinkResolver physicalLinkResolver, ILogger logger, - string siteSlug = SiteManagementService.DefaultSiteSlug) + string siteSlug = SiteManagementService.DefaultSiteSlug, + string? wanInterface = null) { _siteSlug = string.IsNullOrEmpty(siteSlug) ? SiteManagementService.DefaultSiteSlug : siteSlug; _isDefault = _siteSlug == SiteManagementService.DefaultSiteSlug; + _scopedWanKey = string.IsNullOrWhiteSpace(wanInterface) + ? null : GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface.Trim()); _influx = influxRegistry.GetFor(_siteSlug); _dbFactory = dbFactory; _siteDbFactory = siteDbFactory; @@ -78,6 +87,12 @@ public IspHealthService( _logger = logger; } + /// + /// The UniFi wan key this instance grades, or null for the configured-primary instance + /// (which resolves its WAN per compute). Registry key, and what the UI selectors route on. + /// + public string? ScopedWanInterface => _scopedWanKey; + /// /// Every site (the home site included) reads its expected ISP plan speeds from the UniFi /// Console, so computing ISP Health before that connection is up would cache a report with @@ -149,15 +164,21 @@ public async Task SetAccessTechnologyAsync(AccessTechnology technology, Cancella { await using (var db = await CreateSiteDbAsync(ct)) { - // Primary WAN context, wan-first like the reader's ordering - but NOT filtered to - // non-Unknown: setting it when it is currently unset is the whole point. Create it if - // the table is empty, matching Upstream Discovery's create-if-missing on commit. - var ctxRow = (await db.WanDiscoveryContexts.ToListAsync(ct)) - .OrderBy(c => string.Equals(c.WanInterface, "wan", StringComparison.OrdinalIgnoreCase) ? 0 : 1) - .FirstOrDefault(); + var rows = await db.WanDiscoveryContexts.ToListAsync(ct); + // The SCORED WAN's context row - a scoped instance writes its own WAN's technology, + // never the primary's. The primary resolves its key like the compute does (configured + // role first, "wan"-first guess offline) - and is NOT filtered to non-Unknown: + // setting it when it is currently unset is the whole point. Create it if missing, + // matching Upstream Discovery's create-if-missing on commit. + var writeKey = _scopedWanKey + ?? await ResolveConfiguredPrimaryWanKeyAsync(ct) + ?? ResolvePrimaryWanKey(rows); + var ctxRow = rows.FirstOrDefault(c => string.Equals( + GatewayWanHelper.WanInterfaceKeyFromKey(c.WanInterface ?? ""), + GatewayWanHelper.WanInterfaceKeyFromKey(writeKey), StringComparison.OrdinalIgnoreCase)); if (ctxRow == null) { - ctxRow = new WanDiscoveryContext { WanInterface = "wan" }; + ctxRow = new WanDiscoveryContext { WanInterface = writeKey }; db.WanDiscoveryContexts.Add(ctxRow); } ctxRow.AccessTechnology = technology; @@ -569,9 +590,17 @@ private async Task ComputeCoreAsync(DateTime windowStart, DateTi // below (tolerance-matched; a recompute can shift a boundary by a bucket). List ackedOutageStarts; // The WAN this report grades, in MonitoringTarget.WanInterface's namespace (the UniFi WAN - // name, "wan"/"wan2" - NOT the data-path ifname GetPrimaryWanInterfaceAsync returns). Used - // to keep another WAN's internet destinations out of the partial-loss breadth pool. + // name, "wan"/"wan2" - NOT the data-path ifname GetPrimaryWanInterfaceAsync returns). + // Every input below - targets, discoveries, latency series, counters, expected speeds - + // is scoped to this one WAN, so a second WAN's data can never leak into this report. string? primaryWanKey; + string scoredWanKey; + // True for the configured-primary instance (_scopedWanKey null): it additionally owns + // every row with no WAN stamped (hand-added and legacy targets), preserving single-WAN + // behavior exactly. A scoped instance owns only rows stamped with its own wan key. + var primaryScope = _scopedWanKey == null; + // The wan-tag scope the latency reads filter on (see MonitoringInfluxClient.LatencyWanScope). + MonitoringInfluxClient.LatencyWanScope? wanScope; await using (var db = await CreateSiteDbAsync(ct)) { var settings = await db.MonitoringSettings.AsNoTracking().FirstOrDefaultAsync(ct); @@ -579,20 +608,27 @@ private async Task ComputeCoreAsync(DateTime windowStart, DateTi return new ComputeOutcome(IspHealthStatus.NotConfigured, null, new List()); // Access technology lives per-WAN in WanDiscoveryContexts (the wizard's - // store, which replaced the global MonitoringSettings column); prefer the - // primary WAN's context and fall back to the legacy global value. - var wanContexts = await db.WanDiscoveryContexts.AsNoTracking().ToListAsync(ct); - var primaryContext = wanContexts - .OrderBy(c => string.Equals(c.WanInterface, "wan", StringComparison.OrdinalIgnoreCase) ? 0 : 1) - .FirstOrDefault(c => c.AccessTechnology != AccessTechnology.Unknown); - technology = primaryContext?.AccessTechnology ?? settings.AccessTechnology; - // Same wan-first ordering, but the interface NAME and without the access-technology + // store, which replaced the global MonitoringSettings column). Same wan-first + // ordering as before, but the interface NAME and without an access-technology // filter: a WAN whose technology was never set still owns its targets. Falls back to // "wan" so a site with no discovery context yet still scopes to the conventional primary. - primaryWanKey = wanContexts - .OrderBy(c => string.Equals(c.WanInterface, "wan", StringComparison.OrdinalIgnoreCase) ? 0 : 1) - .Select(c => c.WanInterface) - .FirstOrDefault(w => !string.IsNullOrEmpty(w)) ?? "wan"; + var wanContexts = await db.WanDiscoveryContexts.AsNoTracking().ToListAsync(ct); + // Primary is a ROLE: ask the console which group holds it (any wanN can); the + // name-ordered context guess is the offline fallback only. + primaryWanKey = await ResolveConfiguredPrimaryWanKeyAsync(ct) ?? ResolvePrimaryWanKey(wanContexts); + scoredWanKey = _scopedWanKey ?? primaryWanKey; + + // The scored WAN's OWN discovery context decides its technology; the legacy global + // MonitoringSettings value is the primary's fallback only (installs predating the + // per-WAN context). A scoped WAN with no technology set funnels to NeedsTechnology + // below rather than borrowing the primary's - grading LTE against fiber thresholds + // is exactly the mispairing per-WAN scoring exists to kill. + var scoredContext = wanContexts.FirstOrDefault(c => + string.Equals(GatewayWanHelper.WanInterfaceKeyFromKey(c.WanInterface ?? ""), + GatewayWanHelper.WanInterfaceKeyFromKey(scoredWanKey), StringComparison.OrdinalIgnoreCase)); + technology = scoredContext?.AccessTechnology is { } t && t != AccessTechnology.Unknown + ? t + : primaryScope ? settings.AccessTechnology : AccessTechnology.Unknown; targets = await db.MonitoringTargets.AsNoTracking() .Where(t => t.Enabled && (t.TargetType == MonitoringTargetType.AccessIsp @@ -604,19 +640,41 @@ private async Task ComputeCoreAsync(DateTime windowStart, DateTi // an Internet target. Not graded as an ISP/transit card themselves. || t.TargetType == MonitoringTargetType.Custom)) .ToListAsync(ct); + // Scope to the WAN being graded. In memory (case-insensitive like every other + // WanInterface comparison), and null-WanInterface rows go to the primary only - + // hand-added and legacy targets were always primary-path measurements. + targets = ScopeTargetsToWan(targets, scoredWanKey, includeUnassigned: primaryScope); + // Fabric targets stay unscoped: the LAN gateway is shared by every WAN, and its + // series only scopes outages (gateway-unreachable => LAN outage, not WAN). fabricTargets = await db.MonitoringTargets.AsNoTracking() .Where(t => t.Enabled && t.TargetType == MonitoringTargetType.Fabric && t.DeviceMac != null) .ToListAsync(ct); - // TODO (multi-WAN): discoveries are read across ALL WANs, not scoped to the WAN - // being scored. UpstreamDiscovery rows carry WanInterface, but ISP Health scores - // a single (primary) WAN and ancestry/hopOrderKnown here is global, so a second - // WAN's discovery data could flip the absolve gate for a WAN that has none of its - // own. Scope by WanInterface once ISP Health grades per-WAN. See TODO.md. - var discoveries = await db.UpstreamDiscoveries.AsNoTracking() + // Discoveries scoped like the targets: this WAN's rows, plus unstamped legacy rows + // for the primary only. Ancestry, hopOrderKnown, and the hop-number map all follow, + // so another WAN's trace data can never flip this WAN's jitter-absolve gate or + // hop ordering - and a scoped WAN with no discovery of its own conservatively + // reads as "no trace map" (hopOrderKnown false) instead of borrowing one. + var discoveries = (await db.UpstreamDiscoveries.AsNoTracking() .Where(d => d.IsActive && d.MonitoringTargetId != null) - .ToListAsync(ct); + .ToListAsync(ct)) + .Where(d => string.IsNullOrEmpty(d.WanInterface) + ? primaryScope + : string.Equals(GatewayWanHelper.WanInterfaceKeyFromKey(d.WanInterface), + GatewayWanHelper.WanInterfaceKeyFromKey(scoredWanKey), StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // Latency reads filter the Influx `wan` tag to this WAN's series: untagged points + // for the primary, a WAN's context tag values for a scoped WAN (see BuildWanScope). + var bindingContexts = await db.WanContexts.AsNoTracking().ToListAsync(ct); + // No contexts means nothing has ever written a wan tag here, so there is nothing to + // filter apart: the primary instance reads exactly the unfiltered query it always + // has. That keeps every single-WAN install on the query shape that is already proven + // in the field rather than on a tag-absence predicate for no gain. + wanScope = primaryScope && bindingContexts.Count == 0 + ? null + : BuildWanScope(bindingContexts, scoredWanKey, primaryScope); // TargetId -> ancestor hop IPs. Join discovery rows to the loaded targets by PK. var targetIdById = targets.ToDictionary(t => t.Id, t => t.TargetId); ancestorIpsByTargetId = discoveries @@ -676,8 +734,8 @@ private async Task ComputeCoreAsync(DateTime windowStart, DateTi // A PPPoE session costs latency and loaded loss on top of whatever the medium does, so it // is overlaid on the medium's profile rather than replacing it. Read from the gateway, not // from the user: the encapsulation and the medium are independent facts, and only the - // medium needs asking for. Scoped to the primary WAN, matching what ISP Health grades - // (see the multi-WAN TODO above). + // medium needs asking for. Read off the SCORED WAN's own data-path interface - a PPPoE + // secondary behind a plain-DHCP primary gets its overlay, and vice versa. // Null (couldn't tell) scores like false - there is nothing else it can do - but it is // logged as the unknown it is rather than passed off as a settled answer. var pppoeSession = await IsPppoeWanAsync(ct); @@ -719,10 +777,13 @@ private async Task ComputeCoreAsync(DateTime windowStart, DateTi // technology resolution, and console calls. The "fetch" figure lumped it in with the reads, // which measured the four latency queries at ~1s from the box while fetch showed ~6.8s. var setupMs = computeSw.ElapsedMilliseconds; - var ispSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.AccessIsp, outageQueryStart, windowEnd, aggregate, ct); - var transitSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.Transit, windowStart, windowEnd, aggregate, ct); - var internetSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.InternetService, outageQueryStart, windowEnd, aggregate, ct); - var customSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.Custom, windowStart, windowEnd, aggregate, ct); + // Every type-level read carries the wan-tag scope, so a second WAN's series never enter + // this report even where a target id joined both (reassignment history). The gateway + // (fabric) read below stays unscoped by design: the LAN gateway serves every WAN. + var ispSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.AccessIsp, outageQueryStart, windowEnd, aggregate, wanScope, ct); + var transitSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.Transit, windowStart, windowEnd, aggregate, wanScope, ct); + var internetSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.InternetService, outageQueryStart, windowEnd, aggregate, wanScope, ct); + var customSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.Custom, windowStart, windowEnd, aggregate, wanScope, ct); // Rates keep a fine interval whatever the window length. Thinning them with everything else // destroys the only property that separates sustained load from a spike - whether neighboring // samples are loaded too - because a minute-long transfer and a one-sample counter artifact @@ -840,10 +901,30 @@ static Dictionary> TrimFrom(Dictionary ispSeries.ContainsKey(t.TargetId)) + .SelectMany(t => TransitUnreachableDetector.Detect( + t.TargetId, t.AsnNumber ?? 0, AsnNameCleanup.Clean(t.AsnName), ispSeries[t.TargetId], _options) + .Concat(TransitUnreachableDetector.DetectMostlyDark( + t.TargetId, t.AsnNumber ?? 0, AsnNameCleanup.Clean(t.AsnName), ispSeries[t.TargetId], _options))) + .ToList(); + var darkWindows = transitDarkWindows.Concat(ispDarkWindows).ToList(); + var darkByTargetId = darkWindows .GroupBy(w => w.TargetId) .ToDictionary(g => g.Key, g => g.ToList()); + // Hops with a discovery row but HopNumber 0 answered pings yet never landed in a trace + // (OLT/CMTS ICMP-deprioritization); only meaningful once there is trace data at all. + var notTracedTargetIds = hopOrderKnown + ? hopNumberByTargetId.Where(kv => kv.Value == 0).Select(kv => kv.Key).ToHashSet(StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + // Loss pool: ALL enabled AccessIsp + Transit targets plus well-known anycast DNS. // Every probe crosses the access link before reaching its target, so loss on ANY // of these is a signal of access-layer loss - including under load, where the @@ -857,13 +938,44 @@ static Dictionary> TrimFrom(Dictionary(); - identifiedPool.AddRange(ispTargets.Where(t => ispSeries.ContainsKey(t.TargetId)) - .Select(t => new LossPoolFilter.PoolEntry(t.TargetId, ispSeries[t.TargetId]))); + // Access hops that answer pings but sit on no traced path are excluded outright. Nothing + // of yours crosses them, so their loss is not loss you suffered - it is a box beside the + // road dropping the probes aimed at it. Their jitter was already discounted for exactly + // this reason; the same logic was never carried over to loss, and one ICMP-deprioritized + // OLT answering badly could hold the pooled figure up on its own. + // ...unless they are ALL that this site has. An off-path OLT is weak evidence, but it is + // the only access-layer member available on a network with nothing else pingable in front + // of transit, and dropping it would leave access-layer loss measured entirely by hops + // beyond the access network. Weak evidence in the right place beats none. + var ispWithSeries = ispTargets.Where(t => ispSeries.ContainsKey(t.TargetId)).ToList(); + var onPathIsp = ispWithSeries.Where(t => !notTracedTargetIds.Contains(t.TargetId)).ToList(); + var ispForPool = onPathIsp.Count > 0 ? onPathIsp : ispWithSeries; + + var offPathIsp = ispWithSeries.Except(ispForPool).ToList(); + if (offPathIsp.Count > 0) + _logger.LogDebug( + "ISP Health: excluding {Count} off-path access hop(s) from the loss pool: {Targets}", + offPathIsp.Count, string.Join(", ", offPathIsp.Select(t => t.Address))); + else if (onPathIsp.Count == 0 && ispWithSeries.Count > 0) + _logger.LogDebug( + "ISP Health: keeping {Count} off-path access hop(s) in the loss pool - the site has no on-path access hop", + ispWithSeries.Count); + + identifiedPool.AddRange(ispForPool + .Select(t => new LossPoolFilter.PoolEntry(t.TargetId, + darkByTargetId.TryGetValue(t.TargetId, out var ispDark) + ? ispSeries[t.TargetId].Where(s => !ispDark.Any(w => s.Time >= w.Start && s.Time <= w.End)).ToList() + : ispSeries[t.TargetId]))); identifiedPool.AddRange(transitTargets.Where(t => transitSeries.ContainsKey(t.TargetId)).Select(t => new LossPoolFilter.PoolEntry(t.TargetId, darkByTargetId.TryGetValue(t.TargetId, out var dark) ? transitSeries[t.TargetId].Where(s => !dark.Any(w => s.Time >= w.Start && s.Time <= w.End)).ToList() : transitSeries[t.TargetId]))); + // Anycast DNS goes in RAW, deliberately - no unreachable carve-out. Those addresses are + // served from everywhere at once and effectively never have an outage of their own, so a + // resolver going dark is the ISP failing to reach it, which is exactly the loss this pool + // exists to catch. Carving it out for symmetry with the hops above would delete the + // clearest outage signal there is. identifiedPool.AddRange(targets .Where(t => t.TargetType == MonitoringTargetType.InternetService && AnycastDnsIps.Contains(t.Address) @@ -1049,17 +1161,13 @@ double MedianRtt(AsnSeries s) => SeriesStats.Median( .Take(2) .ToList(); // Every internet destination on the WAN being graded - the partial pass's breadth evidence. - // Null WanInterface is INCLUDED: the tracer stamps only what it discovers, so a hand-added - // destination has none, and dropping those would quietly shrink the pool on exactly the - // installs that curated it. Only a target explicitly bound to a DIFFERENT WAN is excluded, - // so a failover link's destinations can't manufacture breadth for the primary. (The rest of - // ISP Health is still primary-WAN-only by assumption rather than by filter - see TODO.md - // "Multi-WAN Support (ISP Health & NMS)".) + // The target list is already scoped to this WAN (ScopeTargetsToWan: this WAN's rows, plus + // null-WanInterface hand-added rows for the primary only), so no per-row WAN check remains - + // a failover link's destinations can't manufacture breadth here because they never enter + // `targets` at all. var breadthInternet = targets .Where(t => t.TargetType == MonitoringTargetType.InternetService - && internetSeriesExt.ContainsKey(t.TargetId) - && (string.IsNullOrEmpty(t.WanInterface) - || string.Equals(t.WanInterface, primaryWanKey, StringComparison.OrdinalIgnoreCase))) + && internetSeriesExt.ContainsKey(t.TargetId)) .Select(t => new AsnSeries { AsnNumber = t.AsnNumber ?? 0, @@ -1184,7 +1292,7 @@ string TransitLabel(AsnSeries s) var blackoutSpans = outages.Where(o => !o.IsPartial).Select(o => (o.Start, o.End)).ToList(); double OverlapSeconds(DateTime s, DateTime e) => blackoutSpans.Sum(b => Math.Max(0, (new DateTime(Math.Min(e.Ticks, b.End.Ticks)) - new DateTime(Math.Max(s.Ticks, b.Start.Ticks))).TotalSeconds)); - var unreachableEvents = TransitUnreachableDetector.MergeByAsn(transitDarkWindows, _options) + var unreachableEvents = TransitUnreachableDetector.MergeByAsn(darkWindows, _options) .Where(e => OverlapSeconds(e.Start, e.End) < (e.End - e.Start).TotalSeconds * 0.5) .Select(e => new PathShiftEvent { @@ -1228,9 +1336,11 @@ double OverlapSeconds(DateTime s, DateTime e) => blackoutSpans.Sum(b => // chartClusters (one line per cluster) is the chart view computed from the same // snapshot the detectors ran on, so deeper-cluster "+N ms hop" labels still match // event labels. It is published together with the report (see Snapshot). - var primaryWanInterface = await GetPrimaryWanInterfaceAsync(ct); - var loadExclusions = await BuildSqmProbeExclusionsAsync(windowStart, windowEnd, primaryWanInterface, ct); - var adaptiveSqmEnabled = await IsAdaptiveSqmEnabledAsync(primaryWanInterface, ct); + // SQM probe exclusions and the Adaptive SQM flag key off the SCORED WAN's own + // data-path interface (SqmWanConfigurations rows are per interface). + var scoredDataPathInterface = await GetScoredWanDataPathInterfaceAsync(ct); + var loadExclusions = await BuildSqmProbeExclusionsAsync(windowStart, windowEnd, scoredDataPathInterface, ct); + var adaptiveSqmEnabled = await IsAdaptiveSqmEnabledAsync(scoredDataPathInterface, ct); // Match the WAN's access technology to one monitored physical device (ONT/SFP, cable // modem, or cellular modem) and aggregate its window metrics for the Physical Link factor. @@ -1266,9 +1376,7 @@ double OverlapSeconds(DateTime s, DateTime e) => blackoutSpans.Sum(b => HopOrderKnown = hopOrderKnown, // Hops with a discovery row but HopNumber 0 answered pings yet never landed in a trace // (OLT/CMTS ICMP-deprioritization); only meaningful once we have trace data at all. - NotTracedTargetIds = hopOrderKnown - ? hopNumberByTargetId.Where(kv => kv.Value == 0).Select(kv => kv.Key).ToHashSet(StringComparer.OrdinalIgnoreCase) - : new HashSet(StringComparer.OrdinalIgnoreCase), + NotTracedTargetIds = notTracedTargetIds, LoadExclusionWindows = loadExclusions, PhysicalLink = physical.Input }; @@ -1302,7 +1410,7 @@ double OverlapSeconds(DateTime s, DateTime e) => blackoutSpans.Sum(b => } /// - /// Whether the primary WAN carries its traffic over a PPPoE session, read from the gateway's + /// Whether the scored WAN carries its traffic over a PPPoE session, read from that WAN's /// data-path interface name (uplink_ifname) - "ppp0" is a PPPoE session and nothing else. /// Cheap: the underlying device call is already cached. /// @@ -1319,28 +1427,157 @@ double OverlapSeconds(DateTime s, DateTime e) => blackoutSpans.Sum(b => // Through the resolver, not the console directly: PPPoE is read off the interface NAME, // and the remembered profile holds that name, so an offline site keeps its overlay // instead of silently grading a PPPoE line against its medium's raw thresholds. - var dataPath = await GetPrimaryWanInterfaceAsync(ct); + var dataPath = await GetScoredWanDataPathInterfaceAsync(ct); if (string.IsNullOrEmpty(dataPath)) { - _logger.LogWarning("ISP Health could not resolve the primary WAN's data-path interface; " + + _logger.LogWarning("ISP Health could not resolve the scored WAN's data-path interface; " + "scoring without the PPPoE overlay, so a PPPoE line will grade against its medium's " + "unadjusted thresholds until the next recompute"); return null; } var isPppoe = NetworkUtilities.IsPppoeInterface(dataPath); - _logger.LogDebug("ISP Health: primary WAN data-path interface is {Interface}; PPPoE overlay {Applied}", + _logger.LogDebug("ISP Health: scored WAN data-path interface is {Interface}; PPPoE overlay {Applied}", dataPath, isPppoe ? "applied" : "not applicable"); return isPppoe; } catch (Exception ex) { - _logger.LogWarning(ex, "ISP Health could not resolve the primary WAN's data-path interface; " + + _logger.LogWarning(ex, "ISP Health could not resolve the scored WAN's data-path interface; " + "scoring without the PPPoE overlay until the next recompute"); return null; } } + /// + /// Scoping helpers, static and internal so the single-WAN equivalence tests exercise the + /// exact predicates the compute uses. + /// + /// + /// Rows with no WAN stamped (hand-added targets, rows predating per-WAN discovery) belong to + /// the primary: they were always primary-path measurements, and dropping them would shrink + /// the pool on exactly the installs that curated it. A scoped WAN owns only rows stamped + /// with its own key. + /// + internal static List ScopeTargetsToWan( + List targets, string wanKey, bool includeUnassigned) => + // Keys normalized ("wan1" == "wan"): legacy installs stamped rows with the wan1 alias, + // and an unnormalized comparison would silently drop them from their own report. + targets.Where(t => string.IsNullOrEmpty(t.WanInterface) + ? includeUnassigned + : string.Equals(GatewayWanHelper.WanInterfaceKeyFromKey(t.WanInterface), + GatewayWanHelper.WanInterfaceKeyFromKey(wanKey), StringComparison.OrdinalIgnoreCase)) + .ToList(); + + /// + /// The configured primary WAN's key from a resolved networkconf row ("WAN2" -> "wan2"), or + /// null when there is none to read. Primary is a ROLE, not a name: any wanN group can be the + /// configured primary (failover priority / load-balance weight decide), so this - never a + /// name-ordered guess - is the authoritative answer while the console can be asked. + /// + internal static string? ConfiguredPrimaryWanKey(NetworkInfo? primary) => + string.IsNullOrEmpty(primary?.WanNetworkgroup) + ? null : GatewayWanHelper.WanInterfaceKeyFromKey(primary!.WanNetworkgroup!); + + /// Configured primary key from the console; null when it cannot be asked. + private async Task ResolveConfiguredPrimaryWanKeyAsync(CancellationToken ct) + { + try + { + return ConfiguredPrimaryWanKey(await _connectionService.GetPrimaryWanNetworkAsync(ct)); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health could not resolve the configured primary WAN from the console"); + return null; + } + } + + /// + /// LAST-RESORT GUESS at the primary's wan key, for when the console cannot say which WAN + /// holds the primary role: the conventional "wan"-group discovery row first, then any row, + /// defaulting to "wan" with no rows at all. This is wrong exactly on an offline multi-WAN + /// site whose configured primary is another group (WAN2-primary with a WAN1 failover) - + /// there is nothing better to ask offline, and the next connected compute corrects it. + /// Callers must prefer whenever the console answers. + /// + internal static string ResolvePrimaryWanKey(IEnumerable contexts) => + GatewayWanHelper.WanInterfaceKeyFromKey(contexts + .OrderBy(c => string.Equals( + GatewayWanHelper.WanInterfaceKeyFromKey(c.WanInterface ?? ""), "wan", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .Select(c => c.WanInterface) + .FirstOrDefault(w => !string.IsNullOrEmpty(w)) ?? "wan"); + + /// + /// The Influx wan-tag scope for the WAN being scored. Primary: untagged points (every point + /// the primary path has ever written), plus the tag values of any context bound to the + /// primary WAN - so a primary probed through an explicit context keeps those points too. + /// Scoped WAN: its stable wan key (what the writers tag new points with, + /// WanContext.InfluxWanTag) plus its contexts' display names, which tagged the points + /// written before the stable-key tagging landed. Never untagged - untagged is the primary's. + /// + internal static MonitoringInfluxClient.LatencyWanScope BuildWanScope( + IEnumerable contexts, string wanKey, bool primaryScope) + { + // Context match is key-normalized ("wan1" == "wan"), but the TAG VALUES stay raw: points + // were written with each context's literal InfluxWanTag, so a legacy wan1-keyed context + // contributes the "wan1" tag its points actually carry. The scoped WAN's own normalized + // key is added for points the writers tag going forward. Note the wanKey parameter is + // whatever key the caller RESOLVED (configured primary or scoped key) - never a literal. + var normalizedKey = GatewayWanHelper.WanInterfaceKeyFromKey(wanKey); + var tags = contexts + .Where(c => !string.IsNullOrEmpty(c.WanInterface) && string.Equals( + GatewayWanHelper.WanInterfaceKeyFromKey(c.WanInterface!), normalizedKey, StringComparison.OrdinalIgnoreCase)) + .SelectMany(c => new[] { c.InfluxWanTag, c.Name }) + .Where(v => !string.IsNullOrEmpty(v)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (primaryScope) + return MonitoringInfluxClient.LatencyWanScope.Primary(tags); + if (!tags.Contains(normalizedKey, StringComparer.Ordinal)) + tags.Insert(0, normalizedKey); + return MonitoringInfluxClient.LatencyWanScope.ForWan(tags); + } + + /// + /// The scored WAN's data-path interface: the primary resolver for the primary instance + /// (unchanged, incl. its remembered-profile offline fallback), the WAN's own uplink for a + /// scoped instance - live from the console when connected, from the WAN's remembered + /// profile row when not. + /// + private async Task GetScoredWanDataPathInterfaceAsync(CancellationToken ct) + { + if (_scopedWanKey == null) + return await GetPrimaryWanInterfaceAsync(ct); + + var group = GatewayWanHelper.WanNetworkGroupFromKey(_scopedWanKey); + try + { + var ifaces = await _connectionService.GetWanInterfacesForGroupAsync(group, ct); + var live = ifaces?.UplinkIfName ?? ifaces?.PhysicalIfName; + if (!string.IsNullOrEmpty(live)) return live; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read WAN {Group}'s data-path interface from the console", group); + } + if (_connectionService.IsConnected) return null; + try + { + await using var db = await CreateSiteDbAsync(ct); + return await db.WanProfiles.AsNoTracking() + .Where(w => w.WanNetworkgroup == group && w.DataPathInterface != null) + .OrderByDescending(w => w.UpdatedAt) + .Select(w => w.DataPathInterface) + .FirstOrDefaultAsync(ct); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read WAN {Group}'s remembered data-path interface", group); + return null; + } + } + /// /// Per-ASN RTT series for the tab chart (ISP + transit) plus the report's events for chart /// annotations. With no window it serves the cached 48 h report; with an explicit window @@ -1393,32 +1630,136 @@ private async Task> QueryWanRatesAsync(DateTime from, Dat } /// - /// Resolves the gateway MAC and the CONFIGURED primary WAN's SNMP counter interface(s) - the same - /// WAN as the expected speeds and SQM exclusion (e.g. "eth6" for a VLAN-tagged primary), not the - /// live active uplink. Falls back to the active uplink only if the config-primary can't be - /// resolved, so analysis still runs. Returns (null, null) when no gateway is discovered. + /// Resolves the gateway MAC and the SCORED WAN's SNMP counter interface(s) - the same WAN as + /// the expected speeds and SQM exclusion (e.g. "eth6" for a VLAN-tagged WAN). The pairing is + /// the point: these counters are divided by this WAN's plan speeds, and that load figure sets + /// the Packet Loss ceiling quadratically (ScorePacketLoss), splits loaded from idle samples + /// (LoadClassifier), and drives congestion load-coincidence (CongestionTopology.Load) - so + /// another WAN's counters here mis-grade all three at once. + /// + /// Primary instance: configured primary live, then the primary's remembered profile row + /// (same WAN, cached), then the live active uplink as the last resort so analysis still + /// runs - that last step is the one place bytes can come from a different WAN than the + /// plan speeds, and it is logged as such. Scoped instance: that WAN's own counter interface + /// (live, then its profile row) and NOTHING cross-WAN - no active-uplink, no WAN1 fallback. /// private async Task<(string? Mac, List? IfNames)> ResolveWanCounterAsync(CancellationToken ct) { var devices = await _connectionService.GetDiscoveredDevicesAsync(ct); var gw = devices?.FirstOrDefault(d => d.Type == DeviceType.Gateway || d.HardwareType == DeviceType.Gateway); + + if (_scopedWanKey != null) + { + var group = GatewayWanHelper.WanNetworkGroupFromKey(_scopedWanKey); + string? counter = null; + try + { + var ifaces = await _connectionService.GetWanInterfacesForGroupAsync(group, ct); + counter = ifaces?.CounterIfName; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health: could not resolve WAN {Group}'s counter interface from the console", group); + } + var mac = gw?.Mac; + if (string.IsNullOrEmpty(counter) || string.IsNullOrEmpty(mac)) + { + try + { + await using var db = await CreateSiteDbAsync(ct); + var profile = await db.WanProfiles.AsNoTracking() + .Where(w => w.WanNetworkgroup == group) + .OrderByDescending(w => w.UpdatedAt) + .FirstOrDefaultAsync(ct); + counter = string.IsNullOrEmpty(counter) ? profile?.CounterInterface : counter; + mac = string.IsNullOrEmpty(mac) ? profile?.GatewayMac : mac; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health: could not read WAN {Group}'s remembered counter interface", group); + } + } + if (string.IsNullOrEmpty(mac) || string.IsNullOrEmpty(counter)) + { + _logger.LogDebug("ISP Health: no counter interface resolved for WAN {Group}; load context is empty for this report", group); + return (mac, null); + } + return (mac, new List { counter! }); + } + if (gw?.Mac == null) return (null, null); var primaryIfaces = await _connectionService.GetPrimaryWanInterfacesAsync(ct); var wanCounterNames = !string.IsNullOrEmpty(primaryIfaces?.CounterIfName) ? new List { primaryIfaces!.CounterIfName! } - : gw.WanInterfaceNames; + : null; + // Config-primary unresolved: prefer the primary's own remembered counter interface (same + // WAN, merely cached) before the live active uplink - during a failover the active uplink + // is ANOTHER WAN, and its bytes against the primary's plan speeds understate load, which + // relaxes into the strictest idle loss ceiling. The active uplink stays as the very last + // resort so a site that never resolved a primary still gets load context. + if (wanCounterNames == null) + { + try + { + // Prefer the CONFIGURED primary group's remembered row when the console can + // still say which group holds the primary role; the first-by-group-name pick is + // the last resort and is a documented GUESS - on a WAN2-primary site with a WAN1 + // failover row it returns the failover's counter. Nothing better exists offline + // (WanProfile carries no primary marker); the next connected read corrects it. + var cfgGroup = await ResolveConfiguredPrimaryWanKeyAsync(ct) is { } cfgKey + ? GatewayWanHelper.WanNetworkGroupFromKey(cfgKey) : null; + await using var db = await CreateSiteDbAsync(ct); + var remembered = await db.WanProfiles.AsNoTracking() + .Where(w => w.CounterInterface != null && (cfgGroup == null || w.WanNetworkgroup == cfgGroup)) + .OrderBy(w => w.WanNetworkgroup) + .ThenByDescending(w => w.UpdatedAt) + .Select(w => w.CounterInterface) + .FirstOrDefaultAsync(ct); + if (!string.IsNullOrEmpty(remembered)) + { + _logger.LogDebug("ISP Health: primary WAN unresolved, using its remembered counter interface {Iface}", remembered); + wanCounterNames = new List { remembered! }; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health: could not read the remembered primary counter interface"); + } + } + wanCounterNames ??= gw.WanInterfaceNames; if (wanCounterNames == null || wanCounterNames.Count == 0) { _logger.LogDebug("ISP Health: no WAN counter interface resolved"); return (gw.Mac, null); } - if (primaryIfaces?.CounterIfName == null) - _logger.LogDebug("ISP Health: primary WAN unresolved, falling back to active uplink {Ifaces}", string.Join(",", wanCounterNames)); + if (primaryIfaces?.CounterIfName == null && ReferenceEquals(wanCounterNames, gw.WanInterfaceNames)) + _logger.LogDebug("ISP Health: primary WAN unresolved, falling back to active uplink {Ifaces} - " + + "during a failover these are another WAN's counters paired with the primary's plan speeds", + string.Join(",", wanCounterNames)); return (gw.Mac, wanCounterNames); } + /// + /// The scored WAN's counter pairing (gateway MAC + counter interface names) for callers + /// outside the scoring pipeline - the Investigate loaded-loss lookup and the WAN traffic + /// reference - so they classify loaded-vs-idle against the same WAN whose latency they show. + /// + public async Task<(string? GatewayMac, List CounterIfNames)> GetWanCounterInterfacesAsync(CancellationToken ct = default) + { + try + { + var (mac, ifNames) = await ResolveWanCounterAsync(ct); + return (mac, ifNames ?? new List()); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health could not resolve the scored WAN's counter interfaces"); + return (null, new List()); + } + } + /// /// Hour-of-day usage fingerprint from the WAN throughput we already record (no new measurement): /// per local hour-of-day, the fraction of sampled time the line was actively in use (DS/US above @@ -1433,13 +1774,21 @@ private async Task> QueryWanRatesAsync(DateTime from, Dat if (!_options.UsageWeightingEnabled) return null; try { - var (mac, ifNames) = await ResolveWanCounterAsync(ct); - if (mac == null || ifNames == null || ifNames.Count == 0) return null; + // ALL WANs, deliberately - the one input that widens across WANs. The fingerprint asks + // "was the user doing anything in this hour", not "how loaded is the link being graded": + // an hour carried by a secondary WAN is still an hour the user was active, so it must + // not read idle and soften that hour's outage weighting. Identical across the per-WAN + // instances by construction. The summed multi-interface read is opted into explicitly + // (see QueryGatewayWanRatesAsync's contract); with one WAN the list has one name and + // the query is byte-identical to before. + var (mac, ifNames) = await ResolveAllWanCounterInterfacesAsync(ct); + if (mac == null || ifNames.Count == 0) return null; var from = windowEnd.AddDays(-_options.UsageFingerprintLookbackDays); // Active usage is sustained (streaming, calls, uploads); a 5-min mean is plenty to catch // it and keeps the lookback series small. - var rates = await _influx.QueryGatewayWanRatesAsync(mac, ifNames, from, windowEnd, TimeSpan.FromMinutes(5), ct: ct); + var rates = await _influx.QueryGatewayWanRatesAsync(mac, ifNames, from, windowEnd, TimeSpan.FromMinutes(5), + sumAcrossInterfaces: true, ct: ct); if (rates.Count == 0) return null; var tz = TimeZoneInfo.Local; @@ -1473,6 +1822,45 @@ private async Task> QueryWanRatesAsync(DateTime from, Dat } } + /// + /// Gateway MAC plus EVERY WAN's counter interface, for the all-WAN usage fingerprint only + /// (see the summing contract on QueryGatewayWanRatesAsync). Live enumeration when the + /// console answers, augmented by the remembered per-WAN profile rows so WANs the console + /// currently omits (down, disabled) still contribute their recorded usage. + /// + private async Task<(string? Mac, List IfNames)> ResolveAllWanCounterInterfacesAsync(CancellationToken ct) + { + string? mac = null; + var names = new List(); + try + { + var devices = await _connectionService.GetDiscoveredDevicesAsync(ct); + mac = devices?.FirstOrDefault(d => d.Type == DeviceType.Gateway || d.HardwareType == DeviceType.Gateway)?.Mac; + foreach (var wan in await _connectionService.GetAllWanInterfacesAsync(ct)) + if (!string.IsNullOrEmpty(wan.CounterIfName)) + names.Add(wan.CounterIfName!); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health: could not enumerate WAN counter interfaces from the console"); + } + try + { + await using var db = await CreateSiteDbAsync(ct); + var profiles = await db.WanProfiles.AsNoTracking() + .Where(w => w.CounterInterface != null) + .ToListAsync(ct); + foreach (var p in profiles) + names.Add(p.CounterInterface!); + mac ??= profiles.Select(p => p.GatewayMac).FirstOrDefault(m => !string.IsNullOrEmpty(m)); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health: could not read the remembered WAN profiles for the usage fingerprint"); + } + return (mac, names.Distinct(StringComparer.OrdinalIgnoreCase).ToList()); + } + /// Expected plan speeds for callers outside the scoring pipeline (e.g. loaded-loss investigation). public async Task<(double? DownMbps, double? UpMbps)> GetExpectedWanSpeedsAsync(CancellationToken ct = default) { @@ -1496,6 +1884,13 @@ public async Task> GetLossPoolTargetIdsAsync(CancellationToken ct = || t.TargetType == MonitoringTargetType.Transit || t.TargetType == MonitoringTargetType.InternetService)) .ToListAsync(ct); + // Same WAN scope as the compute (ScopeTargetsToWan there), so the Investigate highlight + // averages exactly the pool this instance's score is graded on - configured primary + // first, name-ordered guess only offline, like the compute. + var scoredKey = _scopedWanKey + ?? await ResolveConfiguredPrimaryWanKeyAsync(ct) + ?? ResolvePrimaryWanKey(await db.WanDiscoveryContexts.AsNoTracking().ToListAsync(ct)); + targets = ScopeTargetsToWan(targets, scoredKey, includeUnassigned: _scopedWanKey == null); // Flat-lined targets the last computed report dropped come out here too. Subtracting from the // report rather than re-deriving it keeps this the single definition: the exclusion is a // measurement judgment and this method only reads the database, so it cannot make it itself. @@ -1525,17 +1920,25 @@ private record WanIdentity(string? Name, string? NetworkGroup, string? Interface string? source = null; var smartQueues = false; WanIdentity? wan = null; + // A scoped instance reads ITS WAN's networkconf row; the primary instance keeps the + // configured-primary resolution unchanged. There is deliberately no cross-WAN fallback + // anywhere below: a WAN whose plan the console never reported ends unscored on Speed vs + // Plan rather than graded against another WAN's plan. + var scopedGroup = _scopedWanKey == null ? null : GatewayWanHelper.WanNetworkGroupFromKey(_scopedWanKey); try { var networks = await _connectionService.GetNetworksAsync(ct); - var primary = UniFiConnectionService.ResolvePrimaryWanNetwork(networks, _logger); - if (primary != null) + var net = scopedGroup == null + ? UniFiConnectionService.ResolvePrimaryWanNetwork(networks, _logger) + : networks.FirstOrDefault(n => n.IsWan && n.Enabled + && string.Equals(n.WanNetworkgroup, scopedGroup, StringComparison.OrdinalIgnoreCase)); + if (net != null) { - if (primary.WanDownloadMbps > 0) down = primary.WanDownloadMbps; - if (primary.WanUploadMbps > 0) up = primary.WanUploadMbps; + if (net.WanDownloadMbps > 0) down = net.WanDownloadMbps; + if (net.WanUploadMbps > 0) up = net.WanUploadMbps; if (down != null || up != null) source = "UniFi Network"; - smartQueues = primary.WanSmartqEnabled; - wan = new WanIdentity(primary.Name, primary.WanNetworkgroup, primary.WanIfname); + smartQueues = net.WanSmartqEnabled; + wan = new WanIdentity(net.Name, net.WanNetworkgroup, net.WanIfname); } } catch (Exception ex) @@ -1545,8 +1948,7 @@ private record WanIdentity(string? Name, string? NetworkGroup, string? Interface // Remember what the console said, per WAN. This is what lets a site whose console has gone // away still be graded, and it is stored per WAN because plan speeds belong to a WAN: - // scoring reads the primary today, and multi-WAN scoring is planned, at which point each - // WAN's row is already here. + // every scored WAN writes its own row here, keyed by WanNetworkgroup. if (wan?.NetworkGroup is { Length: > 0 }) await RememberWanSpeedsAsync(wan, down, up, ct); @@ -1559,9 +1961,13 @@ private record WanIdentity(string? Name, string? NetworkGroup, string? Interface // where the SQM value is a shaping target someone typed in - what to rate-limit to, // not what the ISP confirmed the line does. // - // With no console we cannot ask which WAN is primary, so prefer the first WAN group and - // fall back to the most recently confirmed row. Multi-WAN scoring picks its own WAN here. + // Primary with no console: we cannot ask which WAN holds the primary ROLE (WanProfile + // carries no primary marker), so the first-by-group-name row is a documented GUESS - + // on a WAN2-primary site whose WAN1 failover also has a remembered row, it grades + // against the failover's plan until the console comes back. A scoped instance reads + // exactly its own WAN's row - another WAN's row is never an answer. var remembered = await db.WanProfiles.AsNoTracking() + .Where(w => scopedGroup == null || w.WanNetworkgroup == scopedGroup) .OrderBy(w => w.WanNetworkgroup) .ThenByDescending(w => w.UpdatedAt) .FirstOrDefaultAsync(ct); @@ -1575,9 +1981,13 @@ private record WanIdentity(string? Name, string? NetworkGroup, string? Interface } // Truly inferred, so it goes last: only reached when the console has never told us. + // The primary keeps the lowest-numbered row (unchanged); a scoped WAN matches its + // own WAN number and otherwise stays unscored. if (down == null || up == null) { + var scopedWanNumber = _scopedWanKey == null ? 0 : GatewayWanHelper.WanIndexFromKey(_scopedWanKey); var sqmWan = await db.SqmWanConfigurations.AsNoTracking() + .Where(c => scopedWanNumber == 0 || c.WanNumber == scopedWanNumber) .OrderBy(c => c.WanNumber) .FirstOrDefaultAsync(ct); if (sqmWan != null) @@ -1613,9 +2023,15 @@ private async Task RememberWanSpeedsAsync(WanIdentity wan, double? down, double? // Keep the previous data path when the device read comes back empty on an otherwise // successful console read: overwriting it with the physical port would make a later // offline PPPoE check grade the line without its overlay, which is what splitting these - // two columns exists to prevent. - var dataPath = await _connectionService.GetPrimaryWanDataPathInterfaceAsync(ct) - ?? row.DataPathInterface ?? wan.Interface; + // two columns exists to prevent. Scoped instances resolve THEIR WAN's data path; the + // primary keeps the primary resolver. + var liveDataPath = _scopedWanKey == null + ? await _connectionService.GetPrimaryWanDataPathInterfaceAsync(ct) + : (await _connectionService.GetWanInterfacesForGroupAsync( + GatewayWanHelper.WanNetworkGroupFromKey(_scopedWanKey), ct)) is { } scopedIfaces + ? scopedIfaces.UplinkIfName ?? scopedIfaces.PhysicalIfName + : null; + var dataPath = liveDataPath ?? row.DataPathInterface ?? wan.Interface; row.DataPathInterface = dataPath; row.CounterInterface = NetworkUtilities.PreferredWanCounterInterface(wan.Interface, dataPath); @@ -1631,6 +2047,26 @@ private async Task RememberWanSpeedsAsync(WanIdentity wan, double? down, double? row.DownloadMbps = down; row.UploadMbps = up; row.UpdatedAt = DateTime.UtcNow; + + // Record which WAN holds the primary role, and whether the site load balances, while + // a console is answering. Both are read where no console can be reached - the probe + // push path has none at all - and both are otherwise guessed from the WAN's NAME, + // which carries no role information. Exactly one row may claim primary, so the others + // are cleared in the same save rather than left to accumulate stale claims. + var networks = await _connectionService.GetNetworksAsync(ct); + var primaryGroup = UniFiConnectionService.ResolvePrimaryWanNetwork(networks)?.WanNetworkgroup; + if (!string.IsNullOrEmpty(primaryGroup)) + { + var loadBalances = UniFiConnectionService.ResolveSiteLoadBalances(networks); + foreach (var profile in await db.WanProfiles.ToListAsync(ct)) + { + profile.IsPrimary = string.Equals( + profile.WanNetworkgroup, primaryGroup, StringComparison.OrdinalIgnoreCase); + profile.SiteLoadBalances = loadBalances; + } + row.IsPrimary = string.Equals(row.WanNetworkgroup, primaryGroup, StringComparison.OrdinalIgnoreCase); + row.SiteLoadBalances = loadBalances; + } await db.SaveChangesAsync(ct); } catch (Exception ex) @@ -1646,8 +2082,10 @@ private async Task RememberWanSpeedsAsync(WanIdentity wan, double? down, double? /// offline site still resolves it - the interface is what the throughput series are keyed on, /// so without it a site with plenty of stored history reads as having none. /// - /// Multi-WAN planned: this returns the primary only, and picks the first WAN group when there - /// is no console to ask. Per-WAN scoring resolves its own WAN's interface from its own row. + /// The offline pick is first-by-group-name, a documented GUESS: primary is a role, so on an + /// offline WAN2-primary site with a WAN1 failover row this returns the failover's data path + /// (WanProfile carries no primary marker to prefer). Per-WAN scoring resolves its own WAN's + /// interface from its own row and never lands here. /// private async Task GetPrimaryWanInterfaceAsync(CancellationToken ct) { @@ -1765,7 +2203,25 @@ private async Task> LoadWanSpeedTestsAsync(DateTime window // yields a recent capacity number. Bounded above by windowEnd for historical windows. var fallbackStart = windowEnd.AddDays(-_options.SpeedTestFallbackDays); var since = windowStart < fallbackStart ? windowStart : fallbackStart; + // Tests are attributed to the scored WAN by their recorded WAN group. A scoped WAN + // takes only tests stamped with its own group, never unstamped ones - an unstamped + // test ran over the default route, which is the primary's. + var scopedGroupLower = _scopedWanKey == null + ? null + : GatewayWanHelper.WanNetworkGroupFromKey(_scopedWanKey).ToLowerInvariant(); await using var db = await CreateSiteDbAsync(ct); + + // The primary's own group, when a connected compute has recorded which WAN holds the + // role. Without it the predicate below falls back to the conventional first group, + // which is right on the sites that have one WAN or lead with WAN1 and wrong on a site + // whose primary is WAN2 - there it would miss every test stamped "WAN2" and count the + // FAILOVER link's tests as the primary's, grading a backup circuit against the fiber + // plan. Unstamped rows stay in either way: they predate stamping and ran over the + // default route, which is the primary's by definition. + var primaryGroupLower = scopedGroupLower != null + ? null + : (await db.WanProfiles.AsNoTracking() + .FirstOrDefaultAsync(w => w.IsPrimary == true, ct))?.WanNetworkgroup?.ToLowerInvariant(); var results = await db.Iperf3Results.AsNoTracking() .Where(r => r.Success && r.TestTime >= since @@ -1774,7 +2230,10 @@ private async Task> LoadWanSpeedTestsAsync(DateTime window || r.Direction == SpeedTestDirection.CloudflareWanGateway || r.Direction == SpeedTestDirection.UwnWan || r.Direction == SpeedTestDirection.UwnWanGateway) - && (r.WanNetworkGroup == null || r.WanNetworkGroup.ToLower() == "wan")) + && (scopedGroupLower == null + ? (r.WanNetworkGroup == null + || r.WanNetworkGroup.ToLower() == (primaryGroupLower ?? "wan")) + : r.WanNetworkGroup != null && r.WanNetworkGroup.ToLower() == scopedGroupLower)) .OrderByDescending(r => r.TestTime) .Select(r => new { r.TestTime, r.DownloadBitsPerSecond, r.UploadBitsPerSecond, r.PingMs, r.DownloadLatencyMs, r.UploadLatencyMs }) .ToListAsync(ct); diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/OutageDetector.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/OutageDetector.cs index fbb68de0bc..3ede6a6c23 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/OutageDetector.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/OutageDetector.cs @@ -196,7 +196,7 @@ bool BucketQualifies(DateTime t) /// regional endpoints of one provider, all reached over that provider's network, count once. /// An unattributed destination falls back to its name - its own network, the safe reading. /// - private static string NetworkKey(Hop h) => + internal static string NetworkKey(Hop h) => h.AsnLabel ?? (h.AsnNumber > 0 ? $"AS{h.AsnNumber}" : h.Name); private static OutageEvent BuildPartialEvent( @@ -315,7 +315,7 @@ private static OutageEvent BuildPartialEvent( /// real access-ISP outage), partials require a hop untouched in the window (intermittent /// 60%-loss buckets are not "reachable"). /// - private static (string? LastReachableHop, string? BrokenNetwork) AttributeBreak( + internal static (string? LastReachableHop, string? BrokenNetwork) AttributeBreak( IEnumerable wanHops, Func judged, Func isClean, Func isBroken) { var rows = wanHops.Where(judged).ToList(); diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkModels.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkModels.cs index aa7a6c9454..7441be01bd 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkModels.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkModels.cs @@ -131,6 +131,14 @@ public class PhysicalLinkInput public double? DishDropRateMax { get; init; } /// Dish-logged outage seconds over the window, normalized per day via . + /// + /// The dish reports its throughput capped by the PLAN rather than by the link - a + /// reduced-speed tier such as Standby. Ground truth for "this is slow on purpose", which + /// otherwise has to be inferred from expected speeds sitting at the lowest value UniFi + /// Network accepts. + /// + public bool? ReducedSpeedTier { get; init; } + public double? OutageSecondsTotal { get; init; } /// Dish-logged outage count over the window. diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkResolver.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkResolver.cs index 50dec45914..9ca161b10f 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkResolver.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkResolver.cs @@ -439,6 +439,13 @@ private static bool IsFresh(DateTime? lastPolled, int intervalSeconds) => CurrentlyObstructed = live?.CurrentlyObstructed, DishDropRateAvg = dropAvg, DishDropRateMax = dropMax > 0 ? dropMax : null, + // LowSpeedPolicyLimit is the plan itself being a reduced-speed tier, as the Starlink + // panel already reads it. Plain PolicyLimit is ordinary shaping on nearly every plan + // and says nothing, so it is deliberately not treated as one. + ReducedSpeedTier = live is null + ? null + : live.DownlinkRestrictedReason == "LowSpeedPolicyLimit" + || live.UplinkRestrictedReason == "LowSpeedPolicyLimit", OutageSecondsTotal = outageSeconds, OutageCountTotal = outageCount, SnrPersistentlyLow = live?.IsSnrPersistentlyLow, diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/SeriesStats.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/SeriesStats.cs index 665f4e4aeb..e8d2092ccd 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/SeriesStats.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/SeriesStats.cs @@ -19,6 +19,199 @@ internal static class SeriesStats return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo); } + /// + /// Median with each value weighted, taken at the point where half the total weight has + /// accumulated. Still a median - one wild value cannot drag it the way a weighted mean can - + /// but a heavier sample counts for more of the half. + /// + /// Used where recent evidence should outrank old evidence of the same kind: a plain median + /// over a week-long window treats a measurement from an hour ago exactly like one from six + /// days ago, so a line that was fixed this afternoon keeps reporting the fault until the good + /// samples outnumber the bad ones. + /// + /// + /// + /// Collapses simultaneous samples from different series into one value per instant, weighing + /// the elevation by how much of the cohort CORROBORATED it. + /// + /// Congestion on a link is in front of everything crossing it, so a real access-layer queue + /// lights up most of what reported in that second. One hop rising while the rest read clean at + /// the same instant is that hop's own responder, and the clean readings beside it are the + /// proof - proof a flat pool throws away, because the noise floor discards them before the + /// median ever sees them. + /// + /// + /// Magnitude comes from the series that actually saw it, and only the CREDENCE scales with the + /// cohort. Collapsing magnitude across the whole cohort instead made the number fall as more + /// targets were monitored - a WAN watching 28 targets diluted a genuine 8 ms to a third of a + /// millisecond, and far destinations swinging below their own baseline cancelled what was left. + /// Monitoring more would have scored better, which is backwards. + /// + /// + /// The denominator is what REPORTED in this instant, never the cohort's full size: targets do + /// not all probe on the same cadence, and one that said nothing has not said "clean". + /// + /// + public static List<(DateTime Time, double Value)> CommonModeByInstant( + IReadOnlyList<(DateTime Time, double Value, int Series)> samples, + TimeSpan tolerance, + int minCohort, + double elevationFloor) + { + var result = new List<(DateTime Time, double Value)>(); + if (samples.Count == 0) return result; + + var ordered = samples.OrderBy(s => s.Time).ToList(); + var i = 0; + while (i < ordered.Count) + { + var start = ordered[i].Time; + var j = i; + while (j < ordered.Count && ordered[j].Time - start <= tolerance) j++; + + var cluster = ordered.GetRange(i, j - i); + var reporting = cluster.Select(c => c.Series).Distinct().Count(); + if (reporting >= minCohort) + { + var elevated = cluster.Where(c => c.Value >= elevationFloor).ToList(); + // Nothing elevated is not "no reading" - it is every target that reported saying + // the link was fine, which is the strongest clean evidence there is. + var corroboration = (double)elevated.Select(c => c.Series).Distinct().Count() / reporting; + result.Add((start, elevated.Count == 0 ? 0 : elevated.Average(c => c.Value) * corroboration)); + } + else + { + // Nothing to corroborate against. A short event where one hop happened to be the + // only one probed is still evidence, just uncorroborated evidence. + result.AddRange(cluster.Select(c => (c.Time, c.Value))); + } + + i = j; + } + + return result; + } + + public static double? WeightedMedian(IReadOnlyList<(double Value, double Weight)> samples) + { + var usable = samples.Where(s => s.Weight > 0).OrderBy(s => s.Value).ToArray(); + if (usable.Length == 0) return null; + + var half = usable.Sum(s => s.Weight) / 2.0; + var running = 0.0; + foreach (var (value, weight) in usable) + { + running += weight; + if (running >= half) return value; + } + return usable[^1].Value; + } + + /// + /// Weighted arithmetic mean. Used where the quantity is naturally averaged - loss is a rate, + /// and a median over mostly-zero samples reports zero however bad the rest are. + /// + public static double? WeightedMean(IReadOnlyList<(double Value, double Weight)> samples) + { + var total = 0.0; + var weight = 0.0; + foreach (var (value, w) in samples) + { + if (w <= 0) continue; + total += value * w; + weight += w; + } + return weight > 0 ? total / weight : null; + } + + /// + /// How long the run of consecutive loaded windows containing each window lasted, in seconds. + /// + /// Duration is credibility, not just sample count. A short burst is where load classification + /// goes wrong most often, and it is too brief for buffers to fill, so its latency understates + /// what a full pipe does - weak evidence twice over. A long saturation is the best evidence + /// there is, better than a speed test, which is itself short and synthetic. + /// + /// + public static Dictionary LoadEpisodeSeconds( + IEnumerable loadedWindowKeys, int windowSeconds) + { + var size = Math.Max(1, windowSeconds); + var ordered = loadedWindowKeys.Distinct().OrderBy(t => t).ToList(); + var seconds = new Dictionary(); + for (var i = 0; i < ordered.Count;) + { + var run = 1; + while (i + run < ordered.Count + && (ordered[i + run] - ordered[i + run - 1]).TotalSeconds <= size + 0.001) + { + run++; + } + var episode = run * (double)size; + for (var j = 0; j < run; j++) seconds[ordered[i + j]] = episode; + i += run; + } + return seconds; + } + + /// + /// The start time of the run of consecutive loaded windows each window belongs to, so samples + /// can be grouped by EPISODE rather than by window. A window is seven seconds; an episode is + /// however long the line actually stayed loaded, which is the unit a person would call "a load + /// event" and the only one at which "the last three" means anything. + /// + public static Dictionary LoadEpisodeStarts( + IEnumerable loadedWindowKeys, int windowSeconds) + { + var size = Math.Max(1, windowSeconds); + var ordered = loadedWindowKeys.Distinct().OrderBy(t => t).ToList(); + var starts = new Dictionary(); + for (var i = 0; i < ordered.Count;) + { + var run = 1; + while (i + run < ordered.Count + && (ordered[i + run] - ordered[i + run - 1]).TotalSeconds <= size + 0.001) + { + run++; + } + for (var j = 0; j < run; j++) starts[ordered[i + j]] = ordered[i]; + i += run; + } + return starts; + } + + /// + /// A credibility multiplier that rises to 1 as a measure approaches the level at which it is + /// fully believable, and never falls below - weak evidence is not + /// absent evidence. A non-positive target means "cannot judge", which is 1 throughout. + /// + public static double Credibility(double measured, double fullAt, double floor) + => fullAt <= 0 ? 1 : Math.Clamp(measured / fullAt, floor, 1); + + /// + /// The same over a BAND: nothing earned below , everything earned at + /// . For measures whose interesting range does not begin at zero - a + /// ramp from zero would score every value near the top and separate nothing. + /// + public static double CredibilityBetween(double measured, double start, double fullAt, double floor) + { + var span = fullAt - start; + return span <= 0 + ? Credibility(measured, fullAt, floor) + : Math.Clamp((measured - start) / span, floor, 1); + } + + /// + /// Weight for a sample of a given age, halving every . Zero or + /// negative half-life means no decay at all, which is how a caller opts out. + /// + public static double RecencyWeight(TimeSpan age, double halfLifeHours) + { + if (halfLifeHours <= 0) return 1; + var hours = Math.Max(0, age.TotalHours); + return Math.Pow(0.5, hours / halfLifeHours); + } + /// /// Mean after winsorizing the upper tail: values above the given percentile are capped /// to it, then averaged. Keeps sustained elevation fully visible (those samples sit diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/LiveWanScope.cs b/src/NetworkOptimizer.Web/Services/Monitoring/LiveWanScope.cs new file mode 100644 index 0000000000..9bc0bb8a91 --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/LiveWanScope.cs @@ -0,0 +1,366 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.JSInterop; +using NetworkOptimizer.Core.Helpers; +using NetworkOptimizer.Storage.Services; +using NetworkOptimizer.UniFi; + +namespace NetworkOptimizer.Web.Services.Monitoring; + +/// +/// Which WAN the live throughput tiles are showing, for the surfaces that carry those tiles (the +/// Monitoring Live View tab and the dashboard's Live View panel). Both ask the same question and +/// answered it identically in their own code until this became one implementation. +/// +/// The selection is deliberately separate from the analysis selectors: it has its own per-site +/// storage key, so watching one WAN's live rate never moves the Network Performance or ISP Health +/// focus. It IS shared between the two live surfaces, which is the point of the shared key - the +/// dashboard and the Monitoring tab show the same WAN. +/// +/// +/// Transient: each component keeps its own instance and its own , so one +/// surface re-rendering never reaches into another's lifecycle. +/// +/// +public sealed class LiveWanScope +{ + private readonly MonitoringPathView _pathView; + private readonly SiteDbContextFactory _siteDb; + private readonly SiteContextService _siteContext; + private readonly IJSRuntime _js; + + private bool _loaded; + private bool _restored; + private bool _pinned; + + /// The ?wan= value meaning every WAN, for links from a view that spans them all. + public const string AllWansToken = "all"; + + /// + /// The ?wan= value meaning whichever WAN holds the primary role, for a link from + /// somewhere that shows the primary's figures without knowing which WAN that is. Named rather + /// than spelled "wan": primary is a ROLE in UniFi Network and any WAN group can hold it, so a + /// link that hardcoded WAN1 would open the wrong report on a site whose primary is not first. + /// + public const string PrimaryWanToken = "primary"; + + public LiveWanScope( + MonitoringPathView pathView, + SiteDbContextFactory siteDb, + SiteContextService siteContext, + IJSRuntime js) + { + _pathView = pathView; + _siteDb = siteDb; + _siteContext = siteContext; + _js = js; + } + + /// + /// A WAN the live tiles can show. is the interface whose + /// counters carry that WAN's throughput; null when nothing has ever recorded one, which the + /// tiles read as "no answer" rather than substituting another WAN's. + /// + /// Whether a WAN context names this WAN. A secondary WAN without one + /// is not probed at all, so anything offering to fix its monitoring has to send the user to + /// make the context first - discovery cannot help until there is one. + public sealed record Option(string Key, string Label, bool IsPrimary, string? CounterIfName, bool HasContext); + + /// + /// Raised after the selection changes. The surface owning this instance sets it: the scope + /// holds the selection but cannot re-render or reach JS interop, so re-rendering the tiles and + /// pointing the chart at the new WAN both happen here. Async because both of those are. + /// + public Func? OnChanged { get; set; } + + public IReadOnlyList public class MonitoringAlertEvaluator { @@ -31,6 +37,7 @@ public class MonitoringAlertEvaluator private readonly IAlertEventBus _eventBus; private readonly ILogger _logger; private readonly DeviceTransitionTracker _transitions; + private readonly WanOutageEvaluator _wanOutages; private readonly ConcurrentDictionary _states = new(); private readonly string _siteSuffix; private readonly string _siteSlug; @@ -42,12 +49,13 @@ public class MonitoringAlertEvaluator /// alert titles; the default site reads exactly as before. /// public MonitoringAlertEvaluator(IAlertEventBus eventBus, ILogger logger, - DeviceTransitionTracker transitions, + DeviceTransitionTracker transitions, WanOutageEvaluator wanOutages, string siteSlug = SiteManagementService.DefaultSiteSlug) { _eventBus = eventBus; _logger = logger; _transitions = transitions; + _wanOutages = wanOutages; _siteSlug = siteSlug ?? SiteManagementService.DefaultSiteSlug; _siteSuffix = string.IsNullOrEmpty(siteSlug) || siteSlug == SiteManagementService.DefaultSiteSlug ? "" : $" (site {siteSlug})"; @@ -56,7 +64,20 @@ public MonitoringAlertEvaluator(IAlertEventBus eventBus, ILogger new TargetAlertState()); + var publishPerTarget = !WanOutageEvaluator.CoversTargetType(target.TargetType); + + await EvaluatePerTargetAsync(target, result, state, publishPerTarget, ct); + if (!publishPerTarget) + { + _wanOutages.RecordTargetState(target, state.IsOffline, state.IsLossy, state.ConsecutiveFailures); + await _wanOutages.EvaluateAsync(ct); + } + } + + private async ValueTask EvaluatePerTargetAsync(MonitoringTarget target, PingProbeResult result, + TargetAlertState state, bool publishPerTarget, CancellationToken ct) + { if (result.Success) { state.ConsecutiveFailures = 0; @@ -68,7 +89,8 @@ public async ValueTask EvaluateAsync(MonitoringTarget target, PingProbeResult re if (state.IsOffline && state.ConsecutiveSuccesses >= SuccessesToDeclareRecovered) { state.IsOffline = false; - await _eventBus.PublishAsync(BuildRecoveredEvent(target, result), ct); + if (publishPerTarget) + await _eventBus.PublishAsync(BuildRecoveredEvent(target, result), ct); } // Sustained-loss detection only matters while the target is nominally up. @@ -78,7 +100,8 @@ public async ValueTask EvaluateAsync(MonitoringTarget target, PingProbeResult re if (!state.IsLossy && avgLoss >= SustainedLossThresholdPercent) { state.IsLossy = true; - await _eventBus.PublishAsync(BuildSustainedLossEvent(target, avgLoss), ct); + if (publishPerTarget) + await _eventBus.PublishAsync(BuildSustainedLossEvent(target, avgLoss), ct); } else if (state.IsLossy && avgLoss < SustainedLossThresholdPercent / 2) { @@ -116,7 +139,8 @@ public async ValueTask EvaluateAsync(MonitoringTarget target, PingProbeResult re state.IsLossy = false; // offline supersedes lossy state.LossWindow.Clear(); state.TransitionSuppressionLogged = false; - await _eventBus.PublishAsync(BuildOfflineEvent(target), ct); + if (publishPerTarget) + await _eventBus.PublishAsync(BuildOfflineEvent(target), ct); } } } @@ -131,7 +155,7 @@ public async ValueTask EvaluateAsync(MonitoringTarget target, PingProbeResult re DeviceId = target.DeviceMac, DeviceName = target.Name, DeviceIp = target.Address, - SourceUrl = "/monitoring?tab=performance", + SourceUrl = TargetSourceUrl(target, DateTime.UtcNow), Tags = ["monitoring", target.TargetType.ToString().ToLowerInvariant()], Context = new Dictionary { @@ -152,7 +176,7 @@ public async ValueTask EvaluateAsync(MonitoringTarget target, PingProbeResult re DeviceName = target.Name, DeviceIp = target.Address, MetricValue = result.RttAvgMs, - SourceUrl = "/monitoring?tab=performance", + SourceUrl = TargetSourceUrl(target, DateTime.UtcNow), Tags = ["monitoring", target.TargetType.ToString().ToLowerInvariant()], Context = new Dictionary { @@ -173,7 +197,7 @@ public async ValueTask EvaluateAsync(MonitoringTarget target, PingProbeResult re DeviceIp = target.Address, MetricValue = avgLossPercent, ThresholdValue = SustainedLossThresholdPercent, - SourceUrl = "/monitoring?tab=performance", + SourceUrl = TargetSourceUrl(target, DateTime.UtcNow), Tags = ["monitoring", "packet-loss", target.TargetType.ToString().ToLowerInvariant()], Context = new Dictionary { @@ -182,6 +206,33 @@ public async ValueTask EvaluateAsync(MonitoringTarget target, PingProbeResult re } }; + /// + /// Where the alert takes you: the Network Performance chart, on this target's own category + /// and parked at the moment the alert fired, rather than the tab's default view of now. The + /// analysis page reads all three from the link. WAN-scoped targets carry their WAN too, so a + /// secondary WAN's alert does not open on the primary's chart. + /// + private static string TargetSourceUrl(MonitoringTarget target, DateTime firedAt) + { + var category = target.TargetType switch + { + MonitoringTargetType.Fabric => "Fabric", + MonitoringTargetType.AccessIsp => "AccessIsp", + MonitoringTargetType.Transit => "Transit", + _ => "Custom" + }; + var at = new DateTimeOffset(DateTime.SpecifyKind(firedAt, DateTimeKind.Utc)).ToUnixTimeMilliseconds(); + var url = $"/monitoring?tab=performance&category={category}&at={at}"; + // A LAN target is not reached over any one WAN, so it asks for all of them rather than + // arriving narrowed to whichever WAN the analysis filter happened to be left on - the same + // choice the page's own LAN jump makes. A stamped target names its WAN. + if (target.TargetType == MonitoringTargetType.Fabric) + return $"{url}&wan={LiveWanScope.AllWansToken}"; + return string.IsNullOrEmpty(target.WanInterface) + ? url + : $"{url}&wan={Uri.EscapeDataString(target.WanInterface)}"; + } + /// /// WAN/access-ISP/transit failures are user-impacting and rate as Critical. Fabric /// targets overlap with existing device-down detection, so Warning. Custom user diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/MonitoringLinks.cs b/src/NetworkOptimizer.Web/Services/Monitoring/MonitoringLinks.cs new file mode 100644 index 0000000000..92d47f1395 --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/MonitoringLinks.cs @@ -0,0 +1,59 @@ +namespace NetworkOptimizer.Web.Services.Monitoring; + +/// +/// The links the Live surfaces build into the analysis views. +/// +/// One place because there are TWO Live surfaces showing the same tiles - the Monitoring Live View +/// tab and the dashboard's Live View panel - and while each built its own links they drifted. The +/// panel went on opening whichever WAN ISP Health was last left on for as long as it took someone +/// to notice, and its stat tiles never carried a WAN at all. A tile that means the same thing on +/// two pages has to land in the same place from both. +/// +/// +public static class MonitoringLinks +{ + /// Chart categories, as the Latency & Packet Loss module names them. + public const string FabricCategory = "Fabric"; + public const string AccessIspCategory = "AccessIsp"; + public const string TransitCategory = "Transit"; + public const string CustomCategory = "Custom"; + + /// + /// The ?at= value meaning the view was live rather than parked on an instant, which + /// lands the analysis on a trailing window instead of one frozen at the moment of the click. + /// + public const string LiveAtToken = "live"; + + /// + /// Latency and Packet Loss for a category, at a moment, scoped to the WANs on screen. + /// + /// LAN and Custom targets are not reached over any one WAN, so those views ask for all of them + /// rather than narrowing to whichever WAN the tiles happened to be showing. + /// + /// + /// Chart category to open. + /// A Unix-ms instant, or . + /// The WANs on screen. Empty on a single-WAN site. + /// Whether that selection is every WAN the site has. + public static string Analysis( + string category, string at, IReadOnlyCollection selectedWanKeys, bool allSelected) + { + var wan = category is FabricCategory or CustomCategory || allSelected + ? LiveWanScope.AllWansToken + : string.Join(",", selectedWanKeys); + + var wanQuery = selectedWanKeys.Count > 0 && wan.Length > 0 + ? $"&wan={Uri.EscapeDataString(wan)}" + : ""; + return $"/monitoring?tab=performance&category={category}&at={at}{wanQuery}"; + } + + /// + /// The ISP Health report for one WAN. The primary is named like any other: the destination + /// remembers the WAN it was last left on, so leaving it unnamed opened that one instead. + /// + public static string IspHealth(string? wanKey) => + string.IsNullOrEmpty(wanKey) + ? "/monitoring?tab=isp-health" + : $"/monitoring?tab=isp-health&wan={Uri.EscapeDataString(wanKey)}"; +} diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/MonitoringStatFormat.cs b/src/NetworkOptimizer.Web/Services/Monitoring/MonitoringStatFormat.cs new file mode 100644 index 0000000000..c88c61bb23 --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/MonitoringStatFormat.cs @@ -0,0 +1,27 @@ +namespace NetworkOptimizer.Web.Services.Monitoring; + +/// +/// The live stat tiles' number formats, in one place because the Monitoring page and the shared +/// Live View panel render the same tiles and had a private copy each. +/// +/// Fixed decimals rather than trimmed ones. These figures update every few seconds, and a width +/// that changes with the value makes a column of them twitch - "9.9" to "10" moves everything +/// beside it. Holding the decimals steady costs a character and buys a number you can read while +/// it changes, which is the whole point of a live tile. +/// +/// +public static class MonitoringStatFormat +{ + /// + /// Round-trip time as "1.00 ms", dropping to one decimal at 100 ms and above ("120.5 ms"), or + /// "-" when nothing has been measured. The step keeps the digit count steady rather than + /// breaking it: "99.99" and "100.0" are the same width, so the tile does not jump as a figure + /// crosses a hundred, and the second decimal stops being worth its space once the number is + /// that large. + /// + public static string Rtt(double? ms) => + ms.HasValue ? (ms.Value >= 100 ? $"{ms.Value:0.0} ms" : $"{ms.Value:0.00} ms") : "-"; + + /// Loss as "0.0%". Zero is shown to the same precision - it is a reading, not an absence. + public static string Loss(double percent) => $"{percent:0.0}%"; +} diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/ProbeExecutorFactory.cs b/src/NetworkOptimizer.Web/Services/Monitoring/ProbeExecutorFactory.cs index c9a7233037..fc332498ff 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/ProbeExecutorFactory.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/ProbeExecutorFactory.cs @@ -75,7 +75,18 @@ public IProbeExecutor GetServer() /// Whether the "server" vantage resolves to the on-site agent for the current site. public bool ServerVantageIsAgent => - _agentCoverage.AgentCovers(_siteContext.Slug, _agentProbe.HasAgentForSite(_siteContext.Slug)); + _agentCoverage.AgentOwnsPathMeasurement(_siteContext.Slug); + + /// + /// An executor that runs on ONE named agent of the current site, rather than on whichever + /// agent the site's "server" vantage happens to resolve to. A site can have several agents, + /// each behind a different WAN, so a caller that picked one means that one: this never + /// substitutes another. + /// + /// Registry id of the agent to run on. + public IProbeExecutor ForAgent(int agentId) => + new AgentProbeExecutor(_agentProbe, _siteContext.Slug, + _loggerFactory.CreateLogger(), agentId); /// /// Build an executor that runs probes from the chosen UniFi device via SSH. Returns diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/ProbeVantages.cs b/src/NetworkOptimizer.Web/Services/Monitoring/ProbeVantages.cs new file mode 100644 index 0000000000..f41cd61f49 --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/ProbeVantages.cs @@ -0,0 +1,110 @@ +namespace NetworkOptimizer.Web.Services.Monitoring; + +/// +/// One place a Network Tools probe can be run from, as offered in the vantage picker. +/// +/// Picker value: "server", "agent:{id}", or "agent:{id}:{vantageId}". +/// What the user reads. +/// The agent to run on, or null for the server vantage. +/// +/// What this vantage's probes bind to on the way out - its WAN vantage's interface or source IP. +/// Null when the vantage probes on its own route, which is every agent with no vantage. +/// +public sealed record ProbeVantageOption(string Key, string Label, int? AgentId, string? SourceBind); + +/// +/// One WAN vantage an agent probes for. An agent can hold several, and each one binds differently, +/// so each is its own place to probe from rather than a detail of the agent. +/// +/// Row id of the WAN vantage, which makes the picker key unique. +/// The vantage's name, used when the console cannot name its WAN. +/// Its WAN in UniFi's own wording ("Yelcot Cable WAN4"), if known. +/// The interface or source IP its probes leave by. +public sealed record ProbeVantageBinding(int VantageId, string Name, string? WanLabel, string? SourceBind); + +/// +/// What is known about one of a site's connected agents while the vantage list is built. +/// +/// Registry id of the agent. +/// Agent name as enrolled. +/// Whether this agent's own address is one of the gateway's. +/// The WAN vantages assigned to this agent, if any. +public sealed record ProbeVantageAgent( + int AgentId, + string Name, + bool OnGateway, + IReadOnlyList Vantages); + +/// +/// Builds the Network Tools vantage list: where a probe can originate on this site, and what +/// each origin binds its probes to. +/// +public static class ProbeVantages +{ + /// Picker value for the server vantage - the value Network Tools has always used. + public const string ServerKey = "server"; + + /// + /// The probe origins worth offering a choice between. Returns an EMPTY list when the site has + /// at most one, which is every site with a single probe origin: the page then shows the single + /// origin it always has, with no picker chrome and nothing new to read. + /// + /// An agent holding several WAN vantages contributes ONE ENTRY PER VANTAGE, because each binds + /// its probes to a different interface or address - they are different places to probe from, + /// not one place described several ways. Listed per agent instead, something had to choose one + /// of the bindings, and a probe run "from" that agent left by whichever vantage happened to + /// sort first. + /// + /// An agent running on the gateway is listed as its own origin even though the gateway is also + /// offered as an SSH vantage. That is deliberate: same box, two different execution paths + /// (UniFi SSH versus the agent binary), and a disagreement between them is what separates an + /// agent-side binding or environment problem from a network one. They are labeled so the + /// relationship is visible, never collapsed into one entry. + /// + /// Whether this server itself probes the site (false when its agent does). + /// Existing label for the server vantage, unchanged by this list. + /// The site's connected agents. + public static List ForPicker( + bool serverProbesSite, + string serverLabel, + IEnumerable agents) + { + var options = new List(); + if (serverProbesSite) + options.Add(new ProbeVantageOption(ServerKey, serverLabel, null, null)); + + foreach (var agent in agents.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)) + { + if (agent.Vantages.Count == 0) + { + options.Add(new ProbeVantageOption( + $"agent:{agent.AgentId}", LabelFor(agent, null), agent.AgentId, null)); + continue; + } + + foreach (var vantage in agent.Vantages.OrderBy(v => v.WanLabel ?? v.Name, StringComparer.OrdinalIgnoreCase)) + options.Add(new ProbeVantageOption( + $"agent:{agent.AgentId}:{vantage.VantageId}", + LabelFor(agent, vantage), + agent.AgentId, + vantage.SourceBind)); + } + + return options.Count > 1 ? options : new List(); + } + + /// + /// An agent vantage's label: the agent, the WAN it probes for, and whether it runs on the + /// gateway. The WAN's own name only - a vantage is named after its WAN, so printing the + /// vantage name as well put the same words on screen twice inside nested brackets. + /// + internal static string LabelFor(ProbeVantageAgent agent, ProbeVantageBinding? vantage) + { + var wan = vantage is null + ? null + : string.IsNullOrWhiteSpace(vantage.WanLabel) ? vantage.Name : vantage.WanLabel; + + var label = string.IsNullOrWhiteSpace(wan) ? agent.Name : $"{agent.Name} - {wan}"; + return agent.OnGateway ? $"{label} (gateway)" : label; + } +} diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/RebootReason/DeviceRebootTracker.cs b/src/NetworkOptimizer.Web/Services/Monitoring/RebootReason/DeviceRebootTracker.cs index 9542502b75..76d7402ebd 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/RebootReason/DeviceRebootTracker.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/RebootReason/DeviceRebootTracker.cs @@ -79,13 +79,35 @@ public DeviceRebootTracker( public record DeviceBootRecord(DateTime BootedAt, DeviceRebootReason? Reason, string? FirmwareVersion = null); /// - /// The reason a device is running its current boot, or null when nothing is known yet. - /// Served from memory so the dashboard costs nothing. + /// The reason behind the boot a device is reporting right now, or null while that boot has no + /// reason yet. Served from memory so the dashboard costs nothing. + /// + /// It takes the reported uptime rather than answering from the MAC alone, deliberately. A + /// record is only as fresh as the last uptime sample the tracker was fed, while a caller + /// showing live uptime is reading the console directly - so a device that restarted since that + /// sample would be handed the reason for its PREVIOUS run, which is how an AP that had been + /// power cycled came to be labeled with a firmware upgrade from days earlier. Holding the + /// reason back until the boot instants line up leaves the tooltip empty for as long as the new + /// reason takes to resolve, which is the honest answer. /// - public DeviceRebootReason? GetReason(string deviceMac) + /// Device MAC. + /// Uptime the caller is displaying for the device. + /// When that uptime was read. + public DeviceRebootReason? GetReasonForReportedUptime(string deviceMac, long? uptimeSeconds, DateTime observedAt) { if (string.IsNullOrWhiteSpace(deviceMac)) return null; - return _records.TryGetValue(Normalize(deviceMac), out var record) ? record.Reason : null; + if (!_records.TryGetValue(Normalize(deviceMac), out var record) || record.Reason == null) return null; + + // Nothing to check against - an offline device reports no uptime - so the record stands. + // That is an absence of evidence, not evidence of a restart we missed. + if (uptimeSeconds is null or <= 0) return record.Reason; + + // Only a boot LATER than the record's is a restart the tracker has yet to account for. An + // earlier one means the two uptime sources disagree (the monitoring tiers read + // system-stats.uptime, the console the device's own field), which is not news and must not + // silence a perfectly good reason. + var reportedBootAt = observedAt.ToUniversalTime().AddSeconds(-uptimeSeconds.Value); + return reportedBootAt - record.BootedAt > BootMatchTolerance ? null : record.Reason; } /// When the device's current boot started, as last observed. diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/StarlinkAlertEvaluator.cs b/src/NetworkOptimizer.Web/Services/Monitoring/StarlinkAlertEvaluator.cs new file mode 100644 index 0000000000..cdadbc3b61 --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/StarlinkAlertEvaluator.cs @@ -0,0 +1,1021 @@ +using System.Collections.Concurrent; +using System.Globalization; +using NetworkOptimizer.Alerts.Events; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Monitoring.Models; + +namespace NetworkOptimizer.Web.Services.Monitoring; + +/// +/// Turns a Starlink dish's own reporting into alerts. Everything here hangs off the dish poll +/// rather than off , because Starlink is usually a backup +/// WAN with no vantage, no agent and no monitored targets at all: the dish is the only sensor on +/// that link, so these must fire for a WAN nothing else watches. +/// +/// +/// Nothing here correlates with per-WAN outage alerting, deliberately. A dish outage that also +/// darkens monitored targets should raise both: they carry different evidence, and "the dish says +/// it is obstructed" alongside "the WAN is down" is a better story than either alone. +/// +/// +/// +/// Severity does NOT follow the per-WAN outage table, which rates a backup's troubles lower +/// because service is unaffected. The opposite applies to a dish: a degraded primary announces +/// itself, while a degraded backup is silent by construction and is discovered at the moment it +/// is needed. Knowing the backup is unhealthy before the primary drops is the point of watching +/// it, so backup dish problems keep real severity. +/// +/// +/// +/// Every rule is written against a VALUE, never against "the field is set". On the reference dish +/// (fixed tilt, permanently rate-restricted subscription) disablement_code reads +/// Okay, alerts carries install_pending continuously, hardware_self_test +/// reads Failed continuously, and both restriction reasons are permanently populated - +/// all while nothing is wrong. Any "has a value" test would fire on day one and never stop. +/// +/// +/// +/// State is in memory only, so a restart re-arms every rule: an already-open condition raises its +/// alert once more, and the windowed rules (alignment, outage burst) stay quiet until they have +/// gathered enough samples again. That is the accepted cost of never persisting a verdict that +/// could go stale against a dish that has since recovered. +/// +/// +public class StarlinkAlertEvaluator +{ + // --- Event types ------------------------------------------------------------------------- + + internal const string DishAlertEvent = "starlink.dish_alert"; + internal const string ObstructedEvent = "starlink.obstructed"; + internal const string AlignmentDriftEvent = "starlink.alignment_drift"; + internal const string EthSpeedDegradedEvent = "starlink.eth_speed_degraded"; + internal const string OutageBurstEvent = "starlink.outage_burst"; + internal const string ServiceRestrictedEvent = "starlink.service_restricted"; + internal const string RecoveredEvent = "starlink.recovered"; + + /// + /// Context key on a naming the event type it closes. Matched by + /// AlertProcessingService.StarlinkRecoveredTypeKey, which cannot reference this project; + /// the two must stay in step, exactly as the WAN outage family's rollup device id does. + /// + internal const string RecoveredTypeKey = "recovered_type"; + + /// Prefix of the AlertEvent.DeviceId these alerts carry, so one dish's alerts close only its own. + internal const string DeviceIdPrefix = "starlink:"; + + // --- Tuning ------------------------------------------------------------------------------ + + /// + /// How long a condition must hold before an obstruction alert opens, and how long its clear + /// condition must hold before one closes. Obstruction is momentary by design - the dish loses + /// a satellite behind a branch and picks up another - so a window is mandatory here, not a + /// nicety. It costs little on FractionObstructed, which is already a long rolling + /// average and cannot spike and recover inside the window, and does the real work on + /// IsSnrPersistentlyLow, which is a bare boolean that can. + /// + /// + /// The same rolling average means RECOVERY lags: when the branch finally comes down the + /// fraction decays over hours, so the alert closes long after the sky cleared. That is the + /// metric's nature rather than a bug, and shortening the window would not change it. + /// + /// + private static readonly TimeSpan ObstructionSustain = TimeSpan.FromMinutes(15); + + /// + /// Obstruction fraction an open alert has to fall back under to close. Set below the raise + /// bar so a dish sitting right at 2% does not alternate between alert and recovery. There is + /// room for both: the reference dish runs at a median 0.06% obstructed and never exceeded + /// 0.1% in 30 days, so the raise bar sits some twenty times above anything healthy. + /// + private const double ObstructionClearFraction = StarlinkHealthThresholds.ObstructionFractionPoor * 0.75; + + /// + /// How far the dish's current alignment may sit from its own baseline before it needs + /// re-aiming. Measured on the reference dish over 30 days, 98% of readings fall within 0.27 + /// degrees of the median, so 2 degrees is about seven times the healthy band: nothing but real + /// movement reaches it. This is the operator's bar and should not be moved to accommodate a + /// noisier install - lengthen instead. + /// + private const double AlignmentDriftDeg = 2.0; + + /// Drift an open alignment alert has to fall back under to close, below the raise bar so it cannot flap. + private const double AlignmentClearDeg = AlignmentDriftDeg * 0.75; + + /// How long the drift has to hold, both to open the alert and to close it. + private static readonly TimeSpan AlignmentSustain = TimeSpan.FromMinutes(30); + + /// + /// The current alignment is the median of this window rather than the latest sample: single + /// samples wander up to ~1.6 degrees from the median on a perfectly healthy dish, which would + /// put a spurious trigger within reach of the 2 degree bar. Comparing medians does not. + /// + private static readonly TimeSpan AlignmentSampleWindow = TimeSpan.FromHours(1); + + /// + /// Samples needed in the window before a median means anything. The reference dish lands about + /// 730 points a day, one every two minutes, so an hour holds around thirty and this floor is + /// only ever reached while the window is filling or after a gap in polling. + /// + private const int MinAlignmentSamples = 5; + + /// + /// Attitude uncertainty above which the dish does not know where it is pointing, so a computed + /// drift is measuring its confusion rather than its aim. + /// + /// + /// Set from the reference dish's own 30 day distribution, because the intuitive value is badly + /// wrong. A healthy dish is nowhere near certain of its attitude: p50 0.70, p95 1.49, p99 1.83, + /// max 2.71 degrees. A bar anywhere near the 2 degree drift trigger would gate out most healthy + /// samples, and since a gated poll stalls the sustain, the drift alert would never survive its + /// 30 minute window - it would be dead rather than quiet. Four degrees sits about 1.5x the + /// observed maximum, so ordinary operation never gates and only genuine confusion does. + /// + /// + /// + /// The gate earns its place: mean uncertainty rises monotonically with how far the offset has + /// strayed from its median (0.77 within 0.15 degrees, 0.90 to 0.3, 1.10 to 0.6, 1.19 beyond), + /// so the excursions this rule must not mistake for movement do come with the dish saying it is + /// less sure. Note that uncertainty is NOT the noise on the computed offset, which is far + /// tighter (98% of readings within 0.27 degrees of the median) - it is the dish's own stated + /// confidence, and it is conservative. Sustained high uncertainty is a GPS or IMU problem in + /// its own right; it is not alerted here. + /// + /// + private const double AttitudeUncertaintyMaxDeg = 4.0; + + /// + /// Rolling window the outage-burst rule sums the dish's own outage seconds over. The bar it is + /// summed against has room: the reference dish logged between 1 and 31 seconds of outage a day + /// over 30 days, a median of 13, against a 300 second bar. + /// + private static readonly TimeSpan OutageWindow = TimeSpan.FromDays(1); + + /// Outage seconds per day an open burst alert has to fall back under to close. + private const double OutageClearSecondsPerDay = StarlinkHealthThresholds.OutageSecondsPerDayPoor * 0.5; + + /// + /// How long a downshifted Ethernet link has to hold before it alerts. A renegotiation during a + /// reboot or a cable reseat settles well inside this, and the rule is about a link that stays + /// capped. + /// + private static readonly TimeSpan EthSpeedSustain = TimeSpan.FromMinutes(5); + + /// + /// Dish alert codes that describe a state rather than a fault, and so must never raise one. + /// + /// + /// install_pending - MEASURED. The only code the reference dish raised in 30 days, and + /// it raised it continuously while nothing was wrong. Without this entry the product would + /// alert on day one and never stop, which is the failure this whole list exists to prevent. + /// + /// is_heating - the dish heating itself in cold weather, which is it working. + /// is_power_save_idle - a power-save setting the owner chose. + /// roaming - a service mode, and mobility is deliberately never treated as a fault here. + /// obstruction_map_reset - housekeeping after a move or reboot, not damage. + /// + /// Only the first is measured; the other four are judged on what the code means, since nothing + /// has been observed raising them. Everything else the dish reports is passed through verbatim: + /// these are SpaceX's own judgment about its own hardware, and translating them would only lose + /// what a search for the exact string turns up. + /// + /// + /// The reference dish is fixed-tilt, motorless and permanently rate restricted, and raised + /// NOTHING else across 30 days - which is real evidence that codes like + /// mast_not_near_vertical, motors_stuck and low_motor_current are not + /// simply always-on for that class of install, and so belong outside this list. If some other + /// hardware or firmware does report a code continuously while healthy, the symptom is one + /// alert per app restart on that install, and the fix is an entry here. + /// + /// + private static readonly HashSet BenignDishAlerts = new(StringComparer.OrdinalIgnoreCase) + { + "install_pending", + "is_heating", + "is_power_save_idle", + "roaming", + "obstruction_map_reset", + }; + + private readonly IAlertEventBus _eventBus; + private readonly ILogger _logger; + private readonly TimeProvider _time; + private readonly ConcurrentDictionary _states = new(); + private readonly string _siteSuffix; + + /// + /// Site this instance evaluates for (one instance per site, owned by + /// - Starlink configuration ids are per-site database + /// sequences, so state must not be shared). Non-default sites get their slug appended to + /// alert titles. + /// + /// Injected in tests so the sustain windows can be driven without waiting. + public StarlinkAlertEvaluator(IAlertEventBus eventBus, ILogger logger, + string siteSlug = SiteManagementService.DefaultSiteSlug, + TimeProvider? timeProvider = null) + { + _eventBus = eventBus; + _logger = logger; + _time = timeProvider ?? TimeProvider.System; + _siteSuffix = string.IsNullOrEmpty(siteSlug) || siteSlug == SiteManagementService.DefaultSiteSlug + ? "" : $" (site {siteSlug})"; + } + + /// + /// Evaluates one poll of one dish and publishes whatever changed. + /// + /// Configuration id of the dish (per-site database sequence). + /// The dish's configured name, used when no WAN could be bound to it. + /// This poll's reading. + /// + /// This poll's boresight offset from desired, as + /// computes it. Null when the + /// dish did not report the geometry. + /// + /// + /// The dish's own long-run median offset. Alignment is judged against this rather than against + /// zero: a hand-aimed fixed dish sits wherever it was mounted, several degrees off ideal from + /// day one, and works perfectly there. Null when there is not enough history yet, which + /// disables the drift rule rather than guessing. + /// + /// + /// The fastest Ethernet speed this dish has been seen to negotiate, which is the only evidence + /// available for what it is capable of. Null disables the degraded-speed rule. + /// + /// + /// The WAN the dish was bound to, already formatted by GatewayWanHelper.FormatWanLabel. + /// Null when the binding is unknown, in which case alerts name the dish and still fire. + /// + public async ValueTask EvaluateAsync( + int starlinkId, + string dishName, + StarlinkStats stats, + double? alignmentOffsetDeg = null, + double? alignmentBaselineDeg = null, + int? ethCapableMbps = null, + string? wanLabel = null, + CancellationToken ct = default) + { + var state = _states.GetOrAdd(starlinkId, _ => new DishState()); + var now = _time.GetUtcNow().UtcDateTime; + var subject = new DishSubject(starlinkId, dishName, wanLabel); + + // One evaluation per dish at a time. The timer poll is single-flighted against itself, but + // the Starlink Stats panel calls PollStarlinkAsync directly (Refresh, moving between + // terminals, first paint on an empty cache) and that path has no such guard - so two polls + // of the SAME dish can be in flight together. This state is not thread-safe: the sample + // windows are plain Queues, whose concurrent Enqueue/Dequeue corrupts their internal + // indices rather than merely racing. A lock cannot span the awaits, hence the semaphore. + await state.Gate.WaitAsync(ct); + try + { + await CheckDishAlerts(state, subject, stats, ct); + await CheckObstruction(state, subject, stats, now, ct); + await CheckAlignmentDrift(state, subject, stats, alignmentOffsetDeg, alignmentBaselineDeg, now, ct); + await CheckEthSpeed(state, subject, stats, ethCapableMbps, now, ct); + await CheckOutageBurst(state, subject, stats, now, ct); + await CheckServiceRestriction(state, subject, stats, ct); + + // Logged after the checks so the open set reflects this poll. Built only when Debug is + // on, since it medians the alignment window and sums the outage window to do it. + if (_logger.IsEnabled(LogLevel.Debug)) + LogEvaluation(state, subject, stats, alignmentBaselineDeg, ethCapableMbps); + } + finally + { + state.Gate.Release(); + } + } + + /// + /// One line per poll describing everything the rules just judged, because a healthy dish is + /// silent by construction: no alert fires, so nothing otherwise proves the chain is live. This + /// is what confirms the baseline came back from Influx, the dish got bound to a WAN, and each + /// condition is being evaluated against real numbers - none of which can be told apart from a + /// broken evaluator by the absence of alerts. + /// + private void LogEvaluation(DishState state, DishSubject subject, StarlinkStats stats, + double? baselineDeg, int? capableMbps) + { + var samples = state.AlignmentSamples.Count; + double? current = samples > 0 ? Median(state.AlignmentSamples.Select(s => s.Offset)) : null; + double? drift = current is { } c && baselineDeg is { } b ? Math.Abs(c - b) : null; + var gated = stats.AttitudeUncertaintyDeg > AttitudeUncertaintyMaxDeg; + + var open = new List(); + if (state.OpenDishAlertCodes.Count > 0) open.Add("dish_alert"); + if (state.Obstruction.Open) open.Add("obstructed"); + if (state.Alignment.Open) open.Add("alignment_drift"); + if (state.EthSpeed.Open) open.Add("eth_speed_degraded"); + if (state.OutageBurstOpen) open.Add("outage_burst"); + + _logger.LogDebug( + "Starlink {Dish} alerting: wan={Wan} obstruction={Obstruction} snrLow={SnrLow} " + + "align={Current}/{Baseline}deg drift={Drift} samples={Samples} uncertainty={Uncertainty}{Gated} " + + "eth={Eth}/{Capable}Mbps outages24h={Outage}s restricted={Restricted} open=[{Open}]", + subject.ShortName, + subject.WanLabel ?? "unbound", + Show(stats.FractionObstructed), + stats.IsSnrPersistentlyLow?.ToString() ?? "n/a", + Show(current), + Show(baselineDeg), + Show(drift), + samples, + Show(stats.AttitudeUncertaintyDeg), + gated ? " (gated)" : "", + stats.EthSpeedMbps?.ToString(CultureInfo.InvariantCulture) ?? "n/a", + capableMbps?.ToString(CultureInfo.InvariantCulture) ?? "n/a", + Show(state.OutageSamples.Sum(s => s.Seconds)), + state.WasRestricted?.ToString() ?? "n/a", + open.Count == 0 ? "none" : string.Join(",", open)); + } + + /// + /// Drops a leading or trailing "Starlink" from a name, so wording that already says Starlink + /// does not say it twice. Both ends matter: our own templates put the word in front, so + /// "Starlink Roof" and "Roof Starlink" double up identically. + /// + /// + /// A name that is nothing BUT "Starlink" - a plausible thing to call your only dish - strips to + /// nothing, and falls back to a generic noun rather than leaving a hole in the sentence. + /// + /// + /// + /// The word is left alone anywhere else in the name. "My Starlink Dish" still reads a little + /// redundant, but cutting from the middle mangles names far more often than it tidies them. + /// + /// + private static string WithoutStarlinkMention(string name, string fallback) + { + const string word = "Starlink"; + var trimmed = name.Trim(); + + if (trimmed.StartsWith(word, StringComparison.OrdinalIgnoreCase)) + trimmed = trimmed[word.Length..].TrimStart(' ', '-', ':'); + else if (trimmed.EndsWith(word, StringComparison.OrdinalIgnoreCase)) + trimmed = trimmed[..^word.Length].TrimEnd(' ', '-', ':'); + + return trimmed.Length == 0 ? fallback : trimmed; + } + + /// A number for the diagnostic line, or "n/a" where the dish reported none. + private static string Show(double? value) => + value?.ToString("0.####", CultureInfo.InvariantCulture) ?? "n/a"; + + // --- starlink.dish_alert ----------------------------------------------------------------- + + /// + /// The dish's own verdict on itself, folded into one event: the alert codes it raises, a + /// self-test that has started failing, and a disablement code other than Okay. Three + /// separate types would all have meant the same thing to an operator ("go look at the dish"), + /// so they share one, and the alert names whichever of them is live. + /// + /// + /// The self-test is a TRANSITION from passing to failing, never a standing state. The + /// reference dish reports Failed continuously while entirely healthy, so what a healthy + /// dish of a given hardware revision and firmware reports is not knowable in general - only + /// that a dish which was passing and is now failing has changed. A dish that has always failed + /// its self-test therefore never raises this on that account alone. + /// + /// + private async ValueTask CheckDishAlerts(DishState state, DishSubject subject, StarlinkStats stats, + CancellationToken ct) + { + var selfTest = Normalize(stats.HardwareSelfTest); + if (selfTest == "passed") + { + state.SelfTestHasPassed = true; + state.SelfTestRegressed = false; + } + else if (selfTest == "failed" && state.SelfTestHasPassed) + { + state.SelfTestRegressed = true; + } + + var disablement = stats.DisablementCode; + var disabled = IsDisabled(disablement); + + // The dish's codes verbatim - they are SpaceX's own vocabulary for its own hardware, and + // paraphrasing them would only lose what a search for the exact string turns up. + var codes = new SortedSet(StringComparer.OrdinalIgnoreCase); + foreach (var code in stats.ActiveAlerts) + { + if (!string.IsNullOrWhiteSpace(code) && !BenignDishAlerts.Contains(code)) + codes.Add(code); + } + + // The signature the open alert was raised for. It carries the disablement code and the + // self-test regression alongside the dish's codes so that either one arriving counts as + // new evidence, but neither is presented to the reader as a dish alert code. + var signature = new SortedSet(codes, StringComparer.OrdinalIgnoreCase); + if (state.SelfTestRegressed) signature.Add("hardware_self_test:failed"); + if (disabled) signature.Add($"disablement:{disablement}"); + + if (signature.Count == 0) + { + if (state.OpenDishAlertCodes.Count > 0) + { + state.OpenDishAlertCodes.Clear(); + await PublishRecovered(subject, DishAlertEvent, "dish fault", + "The dish is no longer reporting a fault.", ct); + } + return; + } + + // Only new evidence republishes: a standing set stays as the one open alert it already + // raised. Something appearing on top of the open set, or the dish going from "complaining" + // to "out of service", is new evidence and supersedes it. + var severity = disabled ? AlertSeverity.Critical : AlertSeverity.Warning; + if (state.OpenDishAlertCodes.Count > 0 + && signature.IsSubsetOf(state.OpenDishAlertCodes) + && severity == state.OpenDishAlertSeverity) + { + return; + } + + state.OpenDishAlertCodes = new HashSet(signature, StringComparer.OrdinalIgnoreCase); + state.OpenDishAlertSeverity = severity; + + var codeList = string.Join(", ", codes); + var sentences = new List(); + if (disabled) + sentences.Add($"Starlink has taken {subject.ShortLabel} out of service (disablement code {disablement})."); + if (codes.Count > 0) + sentences.Add($"The dish reports: {codeList}."); + if (state.SelfTestRegressed) + sentences.Add("Its hardware self-test was passing and is now failing."); + var message = string.Join(" ", sentences); + + _logger.LogDebug("Starlink dish {Name} reporting {Codes} (disablement {Disablement})", + subject.ShortName, codeList, disablement); + + await _eventBus.PublishAsync(new AlertEvent + { + EventType = DishAlertEvent, + Source = "starlink", + Severity = severity, + Title = disabled + ? $"{subject.Label} is out of service{_siteSuffix}" + : $"{subject.Label} dish reports a fault{_siteSuffix}", + Message = message, + DeviceId = subject.DeviceId, + DeviceName = subject.Label, + SourceUrl = subject.SourceUrl, + Tags = ["starlink", "dish"], + Context = subject.Context(new Dictionary + { + ["dish_alerts"] = codeList, + ["disablement_code"] = disablement ?? "", + ["hardware_self_test"] = stats.HardwareSelfTest ?? "", + }) + }, ct); + } + + // --- starlink.obstructed ----------------------------------------------------------------- + + /// + /// One type for both ways the dish can lose its view of the sky, because the remedy is the + /// same either way: it cannot see enough of it. A sustained obstruction fraction is the + /// measured version; the dish's own persistently-low-SNR flag is its version. + /// + private async ValueTask CheckObstruction(DishState state, DishSubject subject, StarlinkStats stats, + DateTime now, CancellationToken ct) + { + var fraction = stats.FractionObstructed; + var snrLow = stats.IsSnrPersistentlyLow == true; + + // A poll that reported neither signal says nothing either way, so it neither advances a + // pending run nor counts against one - otherwise a run interrupted by a gap in reporting + // would confirm on the far side of it as if it had held throughout. + if (fraction is null && stats.IsSnrPersistentlyLow is null) + { + state.Obstruction.Stall(); + return; + } + + var raise = snrLow || fraction >= StarlinkHealthThresholds.ObstructionFractionPoor; + var clear = !snrLow && (fraction is null || fraction < ObstructionClearFraction); + var critical = fraction >= StarlinkHealthThresholds.ObstructionFractionCritical; + + var transition = state.Obstruction.Observe(raise, clear, now, ObstructionSustain); + var escalated = state.Obstruction.Open && critical && !state.ObstructionCritical; + + if (transition == GateTransition.Closed) + { + state.ObstructionCritical = false; + await PublishRecovered(subject, ObstructedEvent, "obstruction", + "The dish has a clear enough view of the sky again.", ct); + return; + } + + if (transition != GateTransition.Opened && !escalated) return; + + state.ObstructionCritical = critical; + + var fractionTripped = fraction >= StarlinkHealthThresholds.ObstructionFractionPoor; + var reason = snrLow && fractionTripped + ? $"The dish has been {FormatPercent(fraction)} obstructed and reports persistently low signal" + : snrLow + ? "The dish reports persistently low signal" + : $"The dish has been {FormatPercent(fraction)} obstructed"; + + _logger.LogDebug("Starlink dish {Name} obstructed: fraction={Fraction} snrLow={SnrLow}", + subject.ShortName, fraction, snrLow); + + await _eventBus.PublishAsync(new AlertEvent + { + EventType = ObstructedEvent, + Source = "starlink", + Severity = critical ? AlertSeverity.Critical : AlertSeverity.Warning, + Title = $"{subject.Label} dish is obstructed{_siteSuffix}", + Message = $"{reason} for at least {FormatDuration(ObstructionSustain)}. " + + "Something in its view of the sky is cutting satellites off; the obstruction map on Starlink Stats shows where.", + DeviceId = subject.DeviceId, + DeviceName = subject.Label, + // Only when the fraction is what tripped it. On an SNR-only alert the obstruction + // fraction is healthy, and pairing that number with the poor-obstruction threshold + // would render as "0.0006 against 0.02" beside a message about low signal. + MetricValue = fractionTripped ? fraction : null, + ThresholdValue = fractionTripped ? StarlinkHealthThresholds.ObstructionFractionPoor : null, + SourceUrl = subject.SourceUrl, + Tags = ["starlink", "obstruction"], + Context = subject.Context(new Dictionary + { + ["fraction_obstructed"] = Format(fraction), + ["snr_persistently_low"] = snrLow ? "true" : "false", + }) + }, ct); + } + + // --- starlink.alignment_drift ------------------------------------------------------------ + + /// + /// A phased array steers electronically well off boresight, so a misaligned dish does not fail + /// loudly - it loses margin, and drops concentrate at the times of day when satellites transit + /// the part of the corridor now beyond its steering range. Nobody diagnoses that, because at + /// any given moment everything looks fine. A fixed dish stays wrong until a human climbs up to + /// it, so this alert is the only way anyone learns the mount slipped after wind, snow load or + /// a knock. + /// + /// + /// Judged against the dish's OWN baseline, never against desired: the reference dish sits at a + /// steady 3.69 degrees off desired and works fine there, so an absolute threshold would flag a + /// healthy install permanently. + /// + /// + /// + /// Deliberately NOT gated on mobility class. The obvious design suppresses drift on a roaming + /// dish, but the reference dish is fixed-tilt, bolted down, and reports Mobile - that + /// gate would have silenced the alert on exactly the installation it was written for. A + /// genuinely moving dish is handled by the baseline instead: one whose attitude changes + /// constantly has a baseline that moves with it and never accumulates a sustained departure. + /// + /// + private async ValueTask CheckAlignmentDrift(DishState state, DishSubject subject, StarlinkStats stats, + double? offsetDeg, double? baselineDeg, DateTime now, CancellationToken ct) + { + if (offsetDeg is not double offset) + { + state.Alignment.Stall(); + return; + } + + // Above the uncertainty bar the dish does not know where it is pointing, so any drift + // computed from it measures its confusion. Neither open nor close while that holds: the + // sample is dropped and the sustain stalls, and an already-open alert stays open because + // uncertainty says nothing about whether the dish moved back. + if (stats.AttitudeUncertaintyDeg > AttitudeUncertaintyMaxDeg) + { + state.Alignment.Stall(); + return; + } + + state.AlignmentSamples.Enqueue((now, offset)); + while (state.AlignmentSamples.Count > 0 && now - state.AlignmentSamples.Peek().At > AlignmentSampleWindow) + state.AlignmentSamples.Dequeue(); + + // No baseline (too little history, or Influx unreachable) and too few samples in the + // window are both "cannot judge", not "judged fine": the pending run is discarded so it + // cannot confirm across the gap on the strength of readings taken before it. + if (baselineDeg is not double baseline || state.AlignmentSamples.Count < MinAlignmentSamples) + { + state.Alignment.Stall(); + return; + } + + var current = Median(state.AlignmentSamples.Select(s => s.Offset)); + var drift = Math.Abs(current - baseline); + + var transition = state.Alignment.Observe( + drift > AlignmentDriftDeg, drift <= AlignmentClearDeg, now, AlignmentSustain); + + if (transition == GateTransition.Closed) + { + await PublishRecovered(subject, AlignmentDriftEvent, "alignment drift", + $"The dish is pointing within {AlignmentClearDeg:0.#} degrees of where it normally sits again.", ct); + return; + } + + if (transition != GateTransition.Opened) return; + + _logger.LogDebug("Starlink dish {Name} alignment drifted: current={Current} baseline={Baseline}", + subject.ShortName, current, baseline); + + await _eventBus.PublishAsync(new AlertEvent + { + EventType = AlignmentDriftEvent, + Source = "starlink", + Severity = AlertSeverity.Warning, + Title = $"{subject.Label} dish alignment has drifted{_siteSuffix}", + Message = $"The dish is pointing {drift:0.#} degrees further from its ideal aim than it normally does " + + $"({current:0.#} degrees off ideal now, against a long-run {baseline:0.#}), and has held there " + + $"for {FormatDuration(AlignmentSustain)}. A fixed mount does not correct itself, so this needs re-aiming by hand.", + DeviceId = subject.DeviceId, + DeviceName = subject.Label, + MetricValue = drift, + ThresholdValue = AlignmentDriftDeg, + SourceUrl = subject.SourceUrl, + Tags = ["starlink", "alignment"], + Context = subject.Context(new Dictionary + { + ["alignment_offset_deg"] = Format(current), + ["alignment_baseline_deg"] = Format(baseline), + ["alignment_drift_deg"] = Format(drift), + }) + }, ct); + } + + // --- starlink.eth_speed_degraded --------------------------------------------------------- + + /// + /// A bad cable or a bad port silently capping the service. The dish negotiates a clean + /// constant (1000 on the reference dish), so a drop below what it has been seen to reach is + /// unambiguous - and it is the only evidence available for what the dish is capable of, since + /// nothing reports a nameplate rate. + /// + private async ValueTask CheckEthSpeed(DishState state, DishSubject subject, StarlinkStats stats, + int? capableMbps, DateTime now, CancellationToken ct) + { + // A poll with no negotiated speed, or no known capable rate to compare it against, is not + // evidence in either direction, so the pending run is discarded rather than carried over. + if (stats.EthSpeedMbps is not int current || capableMbps is not int capable || capable <= 0) + { + state.EthSpeed.Stall(); + return; + } + + var transition = state.EthSpeed.Observe(current < capable, current >= capable, now, EthSpeedSustain); + + if (transition == GateTransition.Closed) + { + await PublishRecovered(subject, EthSpeedDegradedEvent, "Ethernet speed", + $"The dish is negotiating {capable} Mbps again.", ct); + return; + } + + if (transition != GateTransition.Opened) return; + + _logger.LogDebug("Starlink dish {Name} Ethernet negotiated {Current} Mbps against {Capable} Mbps", + subject.ShortName, current, capable); + + await _eventBus.PublishAsync(new AlertEvent + { + EventType = EthSpeedDegradedEvent, + Source = "starlink", + Severity = AlertSeverity.Warning, + Title = $"{subject.Label} dish Ethernet link degraded{_siteSuffix}", + Message = $"The dish has negotiated {current} Mbps, where it normally reaches {capable} Mbps. " + + "A cable or a port is capping the connection below what the service can deliver.", + DeviceId = subject.DeviceId, + DeviceName = subject.Label, + MetricValue = current, + ThresholdValue = capable, + SourceUrl = subject.SourceUrl, + Tags = ["starlink", "ethernet"], + Context = subject.Context(new Dictionary + { + ["eth_speed_mbps"] = current.ToString(CultureInfo.InvariantCulture), + ["eth_capable_mbps"] = capable.ToString(CultureInfo.InvariantCulture), + }) + }, ct); + } + + // --- starlink.outage_burst --------------------------------------------------------------- + + /// + /// The dish's own outage log, summed over a rolling day. Individual dish outages are short and + /// routine; what an operator can act on is the day they stop being routine. The most recent + /// cause travels with the alert, which is what makes it self-explaining. + /// + private async ValueTask CheckOutageBurst(DishState state, DishSubject subject, StarlinkStats stats, + DateTime now, CancellationToken ct) + { + if (stats.OutageSecondsDelta > 0) + state.OutageSamples.Enqueue((now, stats.OutageSecondsDelta)); + while (state.OutageSamples.Count > 0 && now - state.OutageSamples.Peek().At > OutageWindow) + state.OutageSamples.Dequeue(); + + var total = state.OutageSamples.Sum(s => s.Seconds); + + if (state.OutageBurstOpen) + { + if (total >= OutageClearSecondsPerDay) return; + state.OutageBurstOpen = false; + await PublishRecovered(subject, OutageBurstEvent, "outages", + "The dish is back under a normal amount of downtime for the day.", ct); + return; + } + + if (total < StarlinkHealthThresholds.OutageSecondsPerDayPoor) return; + state.OutageBurstOpen = true; + + var cause = string.IsNullOrWhiteSpace(stats.LastOutageCause) ? null : stats.LastOutageCause; + _logger.LogDebug("Starlink dish {Name} outage burst: {Seconds}s in the last day (last cause {Cause})", + subject.ShortName, total, cause); + + await _eventBus.PublishAsync(new AlertEvent + { + EventType = OutageBurstEvent, + Source = "starlink", + Severity = AlertSeverity.Warning, + Title = $"{subject.Label} dish keeps dropping out{_siteSuffix}", + Message = $"The dish has logged {total:0} seconds of outage in the last day, past the " + + $"{StarlinkHealthThresholds.OutageSecondsPerDayPoor:0} second mark." + + (cause == null ? "" : $" It gave {cause} as the reason for the most recent one."), + DeviceId = subject.DeviceId, + DeviceName = subject.Label, + MetricValue = total, + ThresholdValue = StarlinkHealthThresholds.OutageSecondsPerDayPoor, + SourceUrl = subject.SourceUrl, + Tags = ["starlink", "outage"], + Context = subject.Context(new Dictionary + { + ["outage_seconds_per_day"] = Format(total), + ["last_outage_cause"] = cause ?? "", + }) + }, ct); + } + + // --- starlink.service_restricted --------------------------------------------------------- + + /// + /// Reported as a TRANSITION, never as a state. Some subscriptions are throttled by design and + /// report the restriction permanently - the reference dish sits at LowSpeedPolicyLimit + /// downstream and PolicyLimit upstream continuously, with nothing wrong - so an alert + /// on the standing condition would fire forever at someone who bought exactly that service. + /// Class of service does not separate the two cases either: that same permanently restricted + /// dish reads Consumer. + /// + /// + /// Watching the edge instead serves both. For anyone on a plan with a data allotment, crossing + /// from unrestricted into restricted is the moment the allotment ran out and everything slowed + /// down, which is exactly when they would want to know. For a dish that is always restricted, + /// nothing ever transitions and nothing is ever sent. + /// + /// + private async ValueTask CheckServiceRestriction(DishState state, DishSubject subject, StarlinkStats stats, + CancellationToken ct) + { + var down = RestrictionReason(stats.DownlinkRestrictedReason); + var up = RestrictionReason(stats.UplinkRestrictedReason); + var restricted = down != null || up != null; + + var previous = state.WasRestricted; + state.WasRestricted = restricted; + + // The first reading only establishes what normal looks like for this dish. Without it, a + // permanently restricted dish would announce its own subscription on every restart. + if (previous is null) return; + + if (restricted && previous == false) + { + var reasons = string.Join(", ", new[] + { + down == null ? null : $"downlink {down}", + up == null ? null : $"uplink {up}", + }.Where(r => r != null)); + + _logger.LogDebug("Starlink dish {Name} entered a restricted state: {Reasons}", subject.ShortName, reasons); + + await _eventBus.PublishAsync(new AlertEvent + { + EventType = ServiceRestrictedEvent, + Source = "starlink", + Severity = AlertSeverity.Info, + Title = $"{subject.Label} service is now rate limited{_siteSuffix}", + Message = $"Starlink started limiting this connection ({reasons}). On a plan with a data " + + "allotment this is the moment it ran out.", + DeviceId = subject.DeviceId, + DeviceName = subject.Label, + SourceUrl = subject.SourceUrl, + Tags = ["starlink", "restriction"], + Context = subject.Context(new Dictionary + { + ["dl_restricted_reason"] = stats.DownlinkRestrictedReason ?? "", + ["ul_restricted_reason"] = stats.UplinkRestrictedReason ?? "", + }) + }, ct); + } + else if (!restricted && previous == true) + { + await PublishRecovered(subject, ServiceRestrictedEvent, "rate limit", + "Starlink is no longer limiting this connection.", ct); + } + } + + // --- starlink.recovered ------------------------------------------------------------------ + + /// + /// Closes exactly one open alert, named in so the processor can + /// resolve that dish's alert of that type and leave its other conditions alone. + /// + private ValueTask PublishRecovered(DishSubject subject, string recoveredType, string what, string detail, + CancellationToken ct) + { + _logger.LogDebug("Starlink dish {Name} recovered from {Type}", subject.ShortName, recoveredType); + + return _eventBus.PublishAsync(new AlertEvent + { + EventType = RecoveredEvent, + Source = "starlink", + Severity = AlertSeverity.Info, + Title = $"{subject.Label} {what} cleared{_siteSuffix}", + Message = detail, + DeviceId = subject.DeviceId, + DeviceName = subject.Label, + SourceUrl = subject.SourceUrl, + Tags = ["starlink", "recovered"], + Context = subject.Context(new Dictionary + { + [RecoveredTypeKey] = recoveredType, + }) + }, ct); + } + + // --- Helpers --------------------------------------------------------------------------- + + /// + /// Whether the terminal has been taken out of service. Tested against the value, not against + /// "is set": a healthy dish reports Okay here on every poll. An unknown or absent code + /// is no signal at all rather than a fault. + /// + private static bool IsDisabled(string? disablementCode) + { + var value = Normalize(disablementCode); + return value.Length > 0 && value != "okay" && value != "unknownstate" && value != "unknown"; + } + + /// + /// The restriction reason when the dish is actually being limited, null when it is not. + /// NoLimit is the healthy value and an unknown reason carries no information, so + /// neither counts as restricted. + /// + private static string? RestrictionReason(string? reason) + { + var value = Normalize(reason); + return value.Length == 0 || value == "nolimit" || value == "unknown" ? null : reason; + } + + /// + /// Folds a protobuf enum name to a comparable form, so LOW_SPEED_POLICY_LIMIT and + /// LowSpeedPolicyLimit are the same value however a provider chose to render it. + /// + private static string Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? "" : value.Replace("_", "").Trim().ToLowerInvariant(); + + private static double Median(IEnumerable values) + { + var sorted = values.OrderBy(v => v).ToList(); + if (sorted.Count == 0) return 0; + var mid = sorted.Count / 2; + return sorted.Count % 2 == 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0; + } + + private static string Format(double? value) => + value?.ToString("0.####", CultureInfo.InvariantCulture) ?? ""; + + private static string FormatPercent(double? fraction) => + fraction is null ? "" : (fraction.Value * 100).ToString("0.##", CultureInfo.InvariantCulture) + "%"; + + private static string FormatDuration(TimeSpan span) => + span.TotalMinutes < 60 + ? $"{span.TotalMinutes:0} minutes" + : span.TotalHours == 1 ? "an hour" : $"{span.TotalHours:0} hours"; + + /// + /// How one dish is named and linked in its alerts. The WAN label wins when the dish could be + /// bound to one, because that is the name the rest of the product uses for a connection; the + /// dish's own name is the fallback, and the alert fires either way. + /// + private readonly record struct DishSubject(int Id, string DishName, string? WanLabel) + { + public string Label => string.IsNullOrWhiteSpace(WanLabel) ? DishName : WanLabel!; + + /// + /// for the few sentences whose own wording already says Starlink. + /// Dishes and Starlink WANs are commonly named "Starlink Roof" or just "Starlink", which + /// would otherwise render as "Starlink has taken Starlink Roof out of service". + /// + /// Only for those. Titles keep the full name: a title is often all that reaches a + /// notification channel, and trimming the service out of it would lose what the alert is + /// even about. + /// + /// + public string ShortLabel => WithoutStarlinkMention(Label, "the dish"); + + /// The dish's own name for log templates that already open with "Starlink". + public string ShortName => WithoutStarlinkMention(DishName, "dish"); + + public string DeviceId => $"{DeviceIdPrefix}{Id}"; + + public string SourceUrl => $"/monitoring?tab=starlink&starlink={Id}"; + + public Dictionary Context(Dictionary extra) + { + extra["starlink_id"] = Id.ToString(CultureInfo.InvariantCulture); + extra["dish_name"] = DishName; + if (!string.IsNullOrWhiteSpace(WanLabel)) extra["wan_label"] = WanLabel!; + return extra; + } + } + + private enum GateTransition { None, Opened, Closed } + + /// + /// Two-sided sustain. A condition must hold for the window to open an alert, and its clear + /// condition must hold just as long to close one, so a value hovering at the bar produces one + /// alert and one recovery rather than a stream of both. + /// + private sealed class SustainGate + { + private DateTime? _raiseSince; + private DateTime? _clearSince; + + public bool Open { get; private set; } + + public GateTransition Observe(bool raise, bool clear, DateTime now, TimeSpan window) + { + _raiseSince = raise ? _raiseSince ?? now : null; + _clearSince = clear ? _clearSince ?? now : null; + + if (!Open && _raiseSince is { } raiseSince && now - raiseSince >= window) + { + Open = true; + _clearSince = null; + return GateTransition.Opened; + } + + if (Open && _clearSince is { } clearSince && now - clearSince >= window) + { + Open = false; + _raiseSince = null; + return GateTransition.Closed; + } + + return GateTransition.None; + } + + /// + /// Discards both pending runs without changing whether the alert is open, for a poll whose + /// reading says nothing either way. + /// + public void Stall() + { + _raiseSince = null; + _clearSince = null; + } + } + + private sealed class DishState + { + /// + /// Serializes evaluation of this dish, since nothing below is thread-safe and a UI-driven + /// poll can overlap the timer's. Never disposed: a dish's state lives as long as the + /// evaluator, and the semaphore is uncontended in the ordinary single-poll case. + /// + public readonly SemaphoreSlim Gate = new(1, 1); + + /// Codes the currently open dish_alert was raised for, so a standing set does not republish. + public HashSet OpenDishAlertCodes = new(StringComparer.OrdinalIgnoreCase); + + public AlertSeverity OpenDishAlertSeverity; + + /// Whether this dish has ever been seen to pass its self-test, which is what makes a later failure a change. + public bool SelfTestHasPassed; + + public bool SelfTestRegressed; + + public readonly SustainGate Obstruction = new(); + public bool ObstructionCritical; + + public readonly SustainGate Alignment = new(); + public readonly Queue<(DateTime At, double Offset)> AlignmentSamples = new(); + + public readonly SustainGate EthSpeed = new(); + + public readonly Queue<(DateTime At, double Seconds)> OutageSamples = new(); + public bool OutageBurstOpen; + + /// Null until the first poll: the first reading only establishes what normal looks like. + public bool? WasRestricted; + } +} diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamRediscoveryService.cs b/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamRediscoveryService.cs index dbada4dca3..816a45dd9c 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamRediscoveryService.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamRediscoveryService.cs @@ -41,6 +41,7 @@ public class UpstreamRediscoveryService : BackgroundService private readonly IDbContextFactory _dbFactory; private readonly NetworkOptimizer.Storage.Services.SiteDbContextFactory _siteDbFactory; private readonly UpstreamTracerRegistry _tracerRegistry; + private readonly NetworkOptimizer.Web.Services.AgentTunnelRegistry _tunnelRegistry; private readonly ILogger _logger; private readonly NetworkOptimizer.Core.ISiteWorkGate _siteWorkGate; @@ -50,8 +51,10 @@ public UpstreamRediscoveryService( NetworkOptimizer.Storage.Services.SiteDbContextFactory siteDbFactory, UpstreamTracerRegistry tracerRegistry, NetworkOptimizer.Core.ISiteWorkGate siteWorkGate, + NetworkOptimizer.Web.Services.AgentTunnelRegistry tunnelRegistry, ILogger logger) { + _tunnelRegistry = tunnelRegistry; _dbFactory = dbFactory; _siteDbFactory = siteDbFactory; _tracerRegistry = tracerRegistry; @@ -123,6 +126,12 @@ private async Task TickSiteAsync(string slug, bool isDefault, CancellationToken // so this is what keeps them usable as routes-through witnesses. await tracer.BackfillWitnessAncestryAsync(ct); + // Secondary WANs discover on their own per-WAN cadence, before the primary's gates below: + // a primary WAN sitting in "needs review" must not stall the other WANs' discovery, and + // there is nothing to review for them anyway (they commit as they go). No contexts means + // this returns immediately, which is every single-WAN install. + await RunWanContextDiscoveriesAsync(slug, db, ct); + if (settings.UpstreamDiscoveryNeedsReview) return; // already flagged - waiting for user if (!settings.LastUpstreamDiscoveryAt.HasValue) return; // never committed - nothing to re-discover @@ -180,6 +189,132 @@ private async Task TickSiteAsync(string slug, bool isDefault, CancellationToken // when they open the Monitoring page and click the banner. } + /// + /// Discovers the upstream path of every WAN that has a context, one WAN at a time. + /// + /// Each context traces the WAN it names, bound the way its targets are probed, and its + /// access/transit targets are committed straight away rather than staged for review: the + /// review flow is the primary WAN's single global flag, and a secondary WAN's candidates + /// have nowhere to be reviewed until per-WAN review lands. Committing is what gives that + /// WAN targets to probe and hop ancestry to grade at all - the alternative is discovering + /// nothing for it. + /// + /// Cadence is per-WAN, off that WAN's own , + /// so the WANs don't all sweep on the same hour and a new context discovers on the next tick. + /// Best-effort per context: one WAN that can't be traced (its agent offline, the WAN gone) + /// doesn't stop the others. + /// + /// Site being ticked. + /// The site's database. + /// Cancellation. + private async Task RunWanContextDiscoveriesAsync(string slug, NetworkOptimizerDbContext db, CancellationToken ct) + { + List contexts; + try + { + contexts = await db.WanContexts.AsNoTracking().OrderBy(c => c.Id).ToListAsync(ct); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Couldn't read WAN contexts for site {Slug}; skipping per-WAN discovery", slug); + return; + } + if (contexts.Count == 0) return; + + var lastByWan = await db.WanDiscoveryContexts.AsNoTracking() + .ToDictionaryAsync(c => c.WanInterface, c => c.LastDiscoveryAt, StringComparer.OrdinalIgnoreCase, ct); + + foreach (var context in ContextsDueForDiscovery(contexts, lastByWan, DateTime.UtcNow, RediscoveryThreshold)) + { + if (ct.IsCancellationRequested) return; + if (!CanBindForContext(context, slug)) + { + _logger.LogWarning( + "Skipping upstream discovery for WAN context '{Context}' ({Wan}) on site {Slug}: it binds to " + + "interface {Interface}, and the assigned agent does not report source binding. Tracing anyway " + + "would leave by the agent's own route and record another WAN's hops as this one's. Update the agent.", + context.Name, context.WanInterface, slug, context.InterfaceName); + continue; + } + try + { + var tracer = _tracerRegistry.GetForContext(slug, context); + _logger.LogInformation("Running upstream discovery for WAN context '{Context}' ({Wan}) on site {Slug}", + context.Name, context.WanInterface, slug); + await tracer.StartDiscoveryAsync(ct); + await tracer.WaitForCompletionAsync(); + if (tracer.State.Step != TracerStep.ReviewingResults) + { + _logger.LogInformation("WAN context '{Context}' ({Wan}) discovery finished in state {Step}: {Reason}", + context.Name, context.WanInterface, tracer.State.Step, tracer.State.FailureMessage ?? "no candidates"); + continue; + } + await tracer.CommitResultsAsync(ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; } + catch (Exception ex) + { + _logger.LogWarning(ex, "Upstream discovery failed for WAN context '{Context}' ({Wan}) on site {Slug}", + context.Name, context.WanInterface, slug); + } + } + } + + + /// + /// Whether this context's discovery can actually be bound to its WAN by the agent that would + /// run it. + /// + /// Only contexts that bind PER PROBE need the capability - an interface name goes out as the + /// probe's source and the agent has to know what to do with it. A context whose agent sits + /// behind the WAN already (policy-routed by MAC, no interface named) needs no binding at all, + /// so any agent version traces it correctly. + /// + /// + /// The failure this prevents is silent: an agent too old to bind a traceroute runs it over its + /// own route and returns hops that look exactly like a successful bound trace, which then get + /// persisted as that WAN's upstream path. Skipping and saying so is the only honest option. + /// + /// + private bool CanBindForContext(WanContext context, string slug) + { + if (string.IsNullOrEmpty(context.InterfaceName)) return true; + if (context.AgentId is not int agentId) return true; // server-probed: the server binds + var connection = _tunnelRegistry.GetForSite(slug).FirstOrDefault(c => c.AgentId == agentId); + // Not connected: let the run start and fail on its own terms rather than blaming the agent + // version for an absence. + return connection == null || connection.SupportsSourceBind == true; + } + + /// + /// Which of a site's WAN contexts discover on this tick. A context with no WAN yet cannot be + /// discovered at all - there would be nothing to record the result under - and each WAN runs + /// off its OWN last discovery, so the WANs stagger themselves instead of all sweeping on the + /// same hour, and a context added today discovers on the next tick rather than in a week. + /// + /// The site's WAN contexts. + /// Last discovery time per WAN key, from WanDiscoveryContexts. + /// Current time. + /// How stale a WAN's discovery has to be before it re-runs. + internal static List ContextsDueForDiscovery( + IEnumerable contexts, + IReadOnlyDictionary lastDiscoveryByWan, + DateTime now, + TimeSpan threshold) + { + var due = new List(); + foreach (var context in contexts) + { + if (string.IsNullOrWhiteSpace(context.WanInterface)) continue; + if (lastDiscoveryByWan.TryGetValue(context.WanInterface!, out var last) + && last.HasValue + && now - last.Value < threshold) + continue; + due.Add(context); + } + return due; + } + /// Result of comparing a run's discovered ASNs against the committed views. internal sealed record ChangeEvaluation( List Added, diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamTracerRegistry.cs b/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamTracerRegistry.cs index dd6ecf54a6..c2565590c4 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamTracerRegistry.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamTracerRegistry.cs @@ -72,7 +72,7 @@ public UpstreamTracerService GetFor(string slug) => _instances.GetOrAdd(slug, s // the life of the process, so a flag changed afterwards would otherwise never be seen. var agentExecutor = new AgentProbeExecutor(_agentProbe, s, _loggerFactory.CreateLogger()); Func traceExecutor = () => - !isDefault || _agentCoverage.AgentCovers(s, _agentProbe.HasAgentForSite(s)) + !isDefault || _agentCoverage.AgentOwnsPathMeasurement(s) ? agentExecutor : _localProbe; return new UpstreamTracerService( @@ -81,6 +81,7 @@ public UpstreamTracerService GetFor(string slug) => _instances.GetOrAdd(slug, s _connections.GetFor(s), _gatewaySsh.GetFor(s), _ispHealth.GetFor(s), + _ispHealth, traceExecutor, _siteDbFactory, _dbFactory, @@ -93,6 +94,55 @@ public UpstreamTracerService GetFor(string slug) => _instances.GetOrAdd(slug, s /// The default site's tracer. public UpstreamTracerService GetDefault() => GetFor(SiteManagementService.DefaultSiteSlug); + /// + /// A tracer that discovers ONE WAN context's upstream: it traces the WAN the context names + /// rather than the configured primary, binds every probe the way that context's targets are + /// probed, and stamps what it commits with both the WAN and the context. + /// + /// Deliberately not cached, unlike the per-site tracers above. A context's agent or bind can + /// be changed in the card at any moment, and a cached instance would keep tracing the old + /// one for the life of the process; nothing polls a context tracer's state either, since the + /// re-discovery service starts, awaits, and commits each run in turn. + /// + /// Site the context belongs to. + /// The context to discover for; it must already name a WAN. + public UpstreamTracerService GetForContext(string slug, WanContext context) + { + ArgumentNullException.ThrowIfNull(context); + if (string.IsNullOrEmpty(context.WanInterface)) + throw new ArgumentException("A WAN context can only be discovered once it names the WAN it measures.", nameof(context)); + + var isDefault = slug == SiteManagementService.DefaultSiteSlug; + // The context's own agent runs its probes when it has one - that agent is the thing + // sitting behind the WAN being measured. With no agent, the context is a source-IP one + // the gateway policy-routes, so it runs from the same vantage the site's primary uses. + var executor = context.AgentId is int agentId + ? new AgentProbeExecutor(_agentProbe, slug, _loggerFactory.CreateLogger(), agentId) + : new AgentProbeExecutor(_agentProbe, slug, _loggerFactory.CreateLogger()); + Func traceExecutor = context.AgentId != null + ? () => executor + : () => !isDefault || _agentCoverage.AgentOwnsPathMeasurement(slug) ? executor : _localProbe; + + return new UpstreamTracerService( + slug, + isDefault, + _connections.GetFor(slug), + _gatewaySsh.GetFor(slug), + _ispHealth.GetFor(slug), + _ispHealth, + traceExecutor, + _siteDbFactory, + _dbFactory, + _asnResolution, + _scopeFactory, + _ouiDb, + _loggerFactory.CreateLogger(), + new UpstreamTracerService.WanProbeBinding( + context.Id, + context.WanInterface!, + context.InterfaceName ?? context.ProbeSourceIp)); + } + /// public Func? EvictSite(string slug) { diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamTracerService.cs b/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamTracerService.cs index 55f9d31cb8..09d66db6a4 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamTracerService.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamTracerService.cs @@ -42,12 +42,31 @@ public class UpstreamTracerService private IProbeExecutor _traceExecutor => _traceExecutorFactory(); private readonly IServiceScopeFactory _scopeFactory; private readonly IspHealth.IspHealthService _ispHealth; + private readonly IspHealth.IspHealthRegistry _ispHealthRegistry; private readonly NetworkOptimizer.Audit.Services.IeeeOuiDatabase _ouiDb; private readonly ILogger _logger; private readonly SemaphoreSlim _stateLock = new(1, 1); private Task? _runningTask; + // The WAN context this instance discovers for, or null for the site's primary run - which is + // every install with no contexts, and behaves exactly as it did before contexts existed. + private readonly WanProbeBinding? _binding; + + /// + /// Ties a discovery run to one WAN context: which UniFi WAN it measures, and what its probes + /// bind to on the way out (the context's interface for an on-gateway agent, its policy-routed + /// source IP otherwise). A run with a binding traces THAT WAN rather than the configured + /// primary, and everything it persists is stamped with the WAN and the context. + /// + /// The row this run belongs to. + /// The UniFi WAN key the context measures ("wan", "wan2"). + /// Interface name or source IP the probes bind to; null to leave them on the prober's own route. + public sealed record WanProbeBinding(int WanContextId, string WanInterface, string? Source); + + /// The WAN context this tracer discovers for, or null when it is the site's primary tracer. + public WanProbeBinding? Binding => _binding; + // IPs that belong to *our* gateway (LAN side, WAN side, management VLANs). // Used to keep our own gateway out of the access-ISP hop list when the // traceroute's first hop is a private/CGNAT address. Collected during @@ -260,19 +279,23 @@ public UpstreamTracerService( UniFiConnectionService connectionService, IGatewaySshService gatewaySsh, IspHealth.IspHealthService ispHealth, + IspHealth.IspHealthRegistry ispHealthRegistry, Func traceExecutor, NetworkOptimizer.Storage.Services.SiteDbContextFactory siteDbFactory, IDbContextFactory dbFactory, AsnResolutionService asnResolution, IServiceScopeFactory scopeFactory, NetworkOptimizer.Audit.Services.IeeeOuiDatabase ouiDb, - ILogger logger) + ILogger logger, + WanProbeBinding? binding = null) { + _binding = binding; _siteSlug = siteSlug; _isDefault = isDefault; _connectionService = connectionService; _gatewaySsh = gatewaySsh; _ispHealth = ispHealth; + _ispHealthRegistry = ispHealthRegistry; _traceExecutorFactory = traceExecutor; _siteDbFactory = siteDbFactory; _dbFactory = dbFactory; @@ -354,9 +377,25 @@ public async Task RehydrateFromDbAsync(CancellationToken ct = default) try { await using var db = await CreateDbAsync(ct); - var ctx = await db.WanDiscoveryContexts - .OrderByDescending(c => c.LastDiscoveryAt ?? c.UpdatedAt) - .FirstOrDefaultAsync(ct); + // The tracer rehydrates ITS OWN WAN's committed state. A context-bound tracer reads + // exactly its WAN's row; the primary tracer asks the console which WAN is the + // configured primary (primary is a ROLE - it can be any wanN group) and only guesses + // when the console cannot answer. The old newest-first pick alone would hand the + // primary panel whichever WAN discovered LAST (a context's nightly run), showing a + // secondary's hops as the primary's. Single-WAN sites have one row either way. + string? configuredPrimaryKey = null; + if (_binding == null) + { + try + { + var primaryNet = await _connectionService.GetPrimaryWanNetworkAsync(ct); + if (!string.IsNullOrEmpty(primaryNet?.WanNetworkgroup)) + configuredPrimaryKey = NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(primaryNet!.WanNetworkgroup!); + } + catch { /* console unreachable - fall through to the documented guess */ } + } + var contexts = await db.WanDiscoveryContexts.ToListAsync(ct); + var ctx = PickRehydrateContext(contexts, _binding?.WanInterface, configuredPrimaryKey); if (ctx == null) return; var targets = await db.MonitoringTargets.AsNoTracking() @@ -490,6 +529,12 @@ private async Task LoadPersistedAccessTechnologyAsync(string? if (own != null && own.AccessTechnology != AccessTechnology.Unknown) return own.AccessTechnology; + // A context run measures exactly one WAN, so another WAN's technology is not evidence + // about it: an LTE backup behind a fiber primary would inherit "GPON" and have its + // first-mile device labeled as an OLT. Unknown is the honest answer, and it is what + // the reachability gate and role inference already handle. + if (_binding != null) return AccessTechnology.Unknown; + var known = contexts .Where(c => c.AccessTechnology != AccessTechnology.Unknown) .OrderByDescending(c => c.LastDiscoveryAt ?? c.UpdatedAt) @@ -549,6 +594,25 @@ private async Task RunAsync(CancellationToken ct) _logger.LogWarning(ex, "Post-run off-path evaluation failed; absence counters not advanced this run"); } + // Metered WANs arrive with fewer candidates ticked. Done here rather than at commit so + // the review shows what will actually be probed, with the count the operator can change. + try + { + await using var planDb = await CreateDbAsync(ct); + var reviewPlan = await ResolveProbePlanAsync( + planDb, _binding?.WanInterface ?? State.WanInterface ?? "wan", ct); + ApplyAutoEnableBudget(State, reviewPlan.MaxAutoEnabled); + if (reviewPlan.Rung > 0) + _logger.LogInformation( + "Metered WAN {Wan} (rung {Rung}): {Max} target(s) pre-selected at {Interval}s", + State.WanInterface, reviewPlan.Rung, reviewPlan.MaxAutoEnabled, reviewPlan.PollIntervalSeconds); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not apply the metered probe budget; leaving candidates as discovered"); + } + State.Step = TracerStep.ReviewingResults; State.CurrentActivity = "Review the discovered upstream path. Confirm to commit."; State.CompletedAt = DateTime.UtcNow; @@ -600,15 +664,20 @@ private async Task DetectPublicIpAsync(CancellationToken ct) // ISP/transit tracing follows the CONFIGURED primary WAN (not whichever WAN // happens to be first), matching the rest of the monitoring umbrella. Resolve // its networkgroup so the wan-object loop can pick the matching connection. + // A context run skips that entirely: the context already names the WAN it + // measures, and picking the primary would trace the wrong one. string? primaryNg = null; - try + if (_binding == null) { - var networks = await _connectionService.GetNetworksAsync(ct); - primaryNg = UniFiConnectionService.ResolvePrimaryWanNetwork(networks, _logger)?.WanNetworkgroup; - } - catch (Exception ex) - { - _logger.LogDebug(ex, "UpstreamTracer: failed to resolve primary WAN networkgroup; falling back to first WAN"); + try + { + var networks = await _connectionService.GetNetworksAsync(ct); + primaryNg = UniFiConnectionService.ResolvePrimaryWanNetwork(networks, _logger)?.WanNetworkgroup; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "UpstreamTracer: failed to resolve primary WAN networkgroup; falling back to first WAN"); + } } string? wanInterfaceName = null; @@ -681,6 +750,19 @@ private async Task DetectPublicIpAsync(CancellationToken ct) firstWan ??= (interfaceKey, uplinkIfname, ip); + // A context run takes its own WAN and nothing else - no first-WAN fallback, + // since tracing a different WAN than the one being recorded would file this + // WAN's upstream under that one. + if (_binding != null) + { + if (!string.Equals(interfaceKey, _binding.WanInterface, StringComparison.OrdinalIgnoreCase)) + continue; + wanInterfaceName = interfaceKey; + wanUplinkIfName = uplinkIfname; + wanIp = ip; + break; + } + // Resolve this wan's networkgroup and prefer the configured primary. string? ng = null; if (!string.IsNullOrEmpty(wan.IfName)) @@ -697,7 +779,7 @@ private async Task DetectPublicIpAsync(CancellationToken ct) } // Primary unresolved or not matched: fall back to the first WAN found. - if (wanInterfaceName == null && firstWan != null) + if (wanInterfaceName == null && _binding == null && firstWan != null) { wanInterfaceName = firstWan.Value.Key; wanUplinkIfName = firstWan.Value.Uplink; @@ -709,13 +791,30 @@ private async Task DetectPublicIpAsync(CancellationToken ct) } if (wanInterfaceName == null) - return Fail("Couldn't identify the WAN port on the gateway."); + return Fail(_binding == null + ? "Couldn't identify the WAN port on the gateway." + : $"The gateway no longer reports {_binding.WanInterface}, so this context's WAN can't be traced."); State.WanInterface = wanInterfaceName; _wanUplinkIfName = wanUplinkIfName; State.WanIpAddress = wanIp; State.WanIpClass = NetworkUtilities.ClassifyPublicAddress(wanIp); + // A gre* uplink is a UniFi Cellular Modem attached to the gateway, and nothing else on the + // gateway presents a WAN that way, so this WAN's medium is known from the interface rather + // than guessed from whoever answered. It settles only this case: a third-party modem or a + // bridged carrier router is equally cellular and looks like an ordinary WAN, so those still + // rely on the inference below or on the user. Decided HERE rather than beside the vendor + // inference because a GRE tunnel has no ARP neighbor, so that step returns early on exactly + // these WANs and never reaches it. Still only fills an empty slot. + if (State.AccessTechnology is AccessTechnology.Unknown or AccessTechnology.PppoE + && NetworkUtilities.IsUniFiCellularModemTunnel(_wanUplinkIfName)) + { + State.AccessTechnology = AccessTechnology.Cellular; + State.AccessTechnologyInferred = true; + _logger.LogDebug("Tracer: access technology set to Cellular from the {Uplink} uplink", _wanUplinkIfName); + } + switch (State.WanIpClass) { case PublicAddressClass.PublicIPv4: @@ -1018,6 +1117,8 @@ public static bool IsInjectableAccessHopAddress(string? ip) => /// private record AttributedHop(int HopNumber, string Address, string? Hostname, ProbeMode RespondedTo, AsnLookup? Asn); private List _mergedHops = new(); + /// Best (lowest) RTT seen for a hop address across every trace, in ms. + private readonly Dictionary _minRttByIp = new(StringComparer.OrdinalIgnoreCase); private List _accessHopsResolved = new(); // The detected access ISP ASN from the last TraceAccessIspAsync. Kept as a field so @@ -1100,12 +1201,17 @@ private async Task TraceAccessIspAsync(CancellationToken ct) // Merge hops across all traces by (hop IP -> first mode that saw it). We don't // care which CDN trace surfaced the hop, only that we saw it; ASN attribution // is per-IP and dedupes naturally on its way out. + _minRttByIp.Clear(); var byIp = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (_, result) in results) { foreach (var hop in result.Hops) { if (!hop.Responded || string.IsNullOrEmpty(hop.Address)) continue; + var rtt = hop.RttMinMs ?? hop.RttAvgMs; + if (rtt is double seen + && (!_minRttByIp.TryGetValue(hop.Address, out var best) || seen < best)) + _minRttByIp[hop.Address] = seen; if (byIp.ContainsKey(hop.Address)) continue; // Resolve ASN; ResolveAsync returns null for private/CGNAT/unparseable. var asn = await _asnResolution.ResolveAsync(hop.Address, ct); @@ -1184,7 +1290,8 @@ private async Task TraceAccessIspAsync(CancellationToken ct) { var alreadyIncluded = new HashSet( _accessHopsResolved.Select(h => h.Address), StringComparer.OrdinalIgnoreCase); - var unannounced = CollectUnannouncedAccessAddresses(traceSequences, asnByIp, accessAsn.Value) + var unannounced = CollectUnannouncedAccessAddresses( + traceSequences, asnByIp, accessAsn.Value, _gatewayIps, _minRttByIp) .Where(a => !alreadyIncluded.Contains(a) && !_gatewayIps.Contains(a) && byIp.ContainsKey(a)) .Select(a => byIp[a]) .OrderBy(h => h.HopNumber) @@ -1192,6 +1299,27 @@ private async Task TraceAccessIspAsync(CancellationToken ct) _accessHopsResolved = unannounced.Concat(_accessHopsResolved).ToList(); } + // On a UniFi Cellular Modem WAN the gateway reaches the modem over a GRE tunnel, so hop 1 is + // the modem's own tunnel endpoint: a CGNAT address on our side of the radio, answering in a + // fraction of a millisecond, that says nothing about the carrier's first mile. Dropped here + // rather than in any single collector because three paths feed this pool - the access-ASN + // filter, the positional pass for unannounced CGNAT first-mile hops, and the border walk - + // and it arrives by the second, which by design admits exactly this shape of address. + // Confined to that one topology: a cellular WAN behind any other modem has no tunnel hop to + // drop, and on every other medium hop 1 IS the first-mile device. Carrier hops past it, CGNAT + // included, stay eligible. + if (NetworkUtilities.IsUniFiCellularModemTunnel(_wanUplinkIfName)) + { + var tunnelHops = _accessHopsResolved.Where(h => h.HopNumber <= 1).ToList(); + if (tunnelHops.Count > 0) + { + _accessHopsResolved = _accessHopsResolved.Where(h => h.HopNumber > 1).ToList(); + _logger.LogDebug( + "Tracer: dropped {Count} first hop(s) at the {Uplink} tunnel endpoint from the access pool: {Addresses}", + tunnelHops.Count, _wanUplinkIfName, string.Join(", ", tunnelHops.Select(h => h.Address))); + } + } + // Walk each individual trace to find border hops: an access-ASN hop // whose next responding hop is in a different ASN. Different traces // may exit through different border routers depending on the transit @@ -1226,31 +1354,85 @@ private async Task TraceAccessIspAsync(CancellationToken ct) ?? (accessAsn.HasValue && accessAsn.Value == wanIpAsn?.Asn ? wanIpAsn.Name : null); var orgName = CleanAsnName(accessAsnRawName); _accessAsnName = string.IsNullOrEmpty(orgName) ? null : orgName; + // Which hop the WAN-side vendor evidence can speak for: the box on the other end of the + // WAN, and nothing behind it. + // + // The L2 neighbor IS that box whenever we have one - it is read from the WAN's own neighbor + // table, not inferred from distance - so when it is going to be injected below, no traced + // hop is first mile. Only when the trace already surfaced it does a traced hop hold the + // slot; failing both, the nearest traced hop is the best we can say. + var l2FirstMile = !string.IsNullOrEmpty(State.WanNeighborIp) + && IsInjectableAccessHopAddress(State.WanNeighborIp); + var l2Traced = l2FirstMile && _accessHopsResolved.Any(h => + string.Equals(h.Address, State.WanNeighborIp, StringComparison.OrdinalIgnoreCase)); + var firstMileHopNumber = + l2Traced ? _accessHopsResolved.First(h => + string.Equals(h.Address, State.WanNeighborIp, StringComparison.OrdinalIgnoreCase)).HopNumber + : l2FirstMile ? -1 + : _accessHopsResolved.Count > 0 ? _accessHopsResolved.Min(h => h.HopNumber) + : -1; State.AccessHops = _accessHopsResolved.Select(h => new AccessHopCandidate { TargetId = $"access-{NormalizeMacForId(h.Address)}", Label = "", Address = h.Address, PtrHostname = h.Hostname, - AsnNumber = h.Asn?.Asn, - AsnName = h.Asn?.Name, + // A hop with no BGP attribution of its own is here BECAUSE it sits below the ISP's + // announced border - private first-mile gear, or CGNAT. It is the ISP's, so it is + // stored as the ISP's: left unattributed it reads as an unknown network on the path, + // and nothing downstream would grade it against the access ISP. + AsnNumber = h.Asn?.Asn ?? accessAsn, + AsnName = h.Asn?.Name ?? (h.Asn == null ? accessAsnRawName : null), Role = borderIps.Contains(h.Address) ? UpstreamRole.Border - : InferAccessRole(h, State.AccessTechnology, State.WanNeighborOuiVendor), + : InferAccessRole(h, State.AccessTechnology, State.WanNeighborOuiVendor, + h.HopNumber == firstMileHopNumber), HopNumber = h.HopNumber, RespondedTo = h.RespondedTo, Enabled = true }).ToList(); - // Generate " " labels, same format as transit targets. - var accessIdx = 0; - foreach (var hop in State.AccessHops) + // A Starlink hop whose PTR is the "undefined" placeholder is a SATELLITE, not ground + // infrastructure - overhead for a few minutes and then gone, so the row would flap between + // reachable and dark for as long as it existed. Dropped from the candidate set outright + // rather than proposed and left to fail: nothing on the ground answers that way, and the + // ground hops on the same ASN keep a real PTR to be found by. + State.AccessHops.RemoveAll(h => + IsPlaceholderPtrHostname(h.PtrHostname) + && IsStarlinkAsn(h.AsnNumber ?? accessAsn, h.AsnName ?? orgName)); + + // Generate " " labels, same format as transit targets. With no usable + // PTR the hop's own number names it, bare. Where several responders answer at the SAME hop + // - ECMP, which is how satellite first miles usually look - the number alone names them + // all identically, so those carry a -1, -2 suffix and nothing else does. + foreach (var hopGroup in State.AccessHops.GroupBy(h => h.HopNumber)) + { + var labeled = hopGroup + .Select(h => (Hop: h, Ptr: FormatTransitHopLabel(h.PtrHostname, h.Address))) + .ToList(); + var unnamedCount = labeled.Count(x => x.Ptr == null); + var suffix = 0; + foreach (var (hop, ptr) in labeled) + { + hop.Label = ptr != null + ? $"{orgName} {ptr}" + : unnamedCount > 1 + ? $"{orgName} {hop.HopNumber}-{++suffix}" + : $"{orgName} {hop.HopNumber}"; + } + } + + // The ISP itself can settle the technology where the L2 neighbor could not: a Starlink + // dish presents its own router to the gateway, so the OUI names the CPE rather than the + // medium. Same rule as the vendor inference - only into an empty slot, never over a + // choice someone made. + if (State.AccessTechnology is AccessTechnology.Unknown or AccessTechnology.PppoE + && TechnologyFromAccessAsn(accessAsn, orgName) is { } asnTech) { - var ptrLabel = FormatTransitHopLabel(hop.PtrHostname, hop.Address); - if (ptrLabel != null) - hop.Label = $"{orgName} {ptrLabel}"; - else - hop.Label = $"{orgName} {++accessIdx}"; + State.AccessTechnology = asnTech; + State.AccessTechnologyInferred = true; + _logger.LogDebug("Tracer: access technology inferred as {Tech} from access AS{Asn} ({Org})", + asnTech, accessAsn, orgName); } // Inject the L2 neighbor (from ip neigh) as the first access hop if it @@ -1287,7 +1469,7 @@ private async Task TraceAccessIspAsync(CancellationToken ct) private async Task<(string Label, TracerouteResult Result)> TraceOneAsync(TraceEndpoint endpoint, ProbeMode mode, CancellationToken ct) { - var target = new ProbeTarget(endpoint.Address, mode); + var target = new ProbeTarget(endpoint.Address, mode, Port: null, SourceInterface: _binding?.Source); try { var result = await _traceExecutor.TracerouteAsync(target, maxHops: 30, @@ -1701,9 +1883,13 @@ internal static void ApplyTransitClumpSelection(IEnumerable _ => 3 }; - /// Rapid ping burst used for reachability verification. + /// + /// Rapid ping burst used for reachability verification. Bound the same way the traces are on + /// a context run: a hop reachable on the primary WAN but not out this one must read as + /// unreachable here, which is the whole point of verifying per WAN. + /// private Task ProbeReachabilityAsync(string address, ProbeMode mode, CancellationToken ct) => - _traceExecutor.PingAsync(new ProbeTarget(address, mode), + _traceExecutor.PingAsync(new ProbeTarget(address, mode, Port: null, SourceInterface: _binding?.Source), count: ReachabilityPingCount, perPingTimeout: TimeSpan.FromSeconds(2), ct: ct); private async Task VerifyReachabilityAsync(CancellationToken ct) @@ -2103,7 +2289,14 @@ private static bool IsIpDerivedHostname(string[] hostnameParts, string ipAddress /// the BNG label off that, and the user is left picking only the medium PPPoE rides - the /// two are independent facts and both are then available to score on. /// - private static UpstreamRole InferAccessRole(AttributedHop hop, AccessTechnology tech, string? ouiVendor) + /// + /// Whether this is the nearest access hop we found. The vendor evidence describes the box on + /// the other end of the WAN and nothing beyond it, so it may only name that one. By nearest + /// rather than by TTL: the first-mile device is hop 1 from a gateway vantage and hop 2 or 3 + /// from a LAN one, and the same box should not change role with the vantage. + /// + private static UpstreamRole InferAccessRole( + AttributedHop hop, AccessTechnology tech, string? ouiVendor, bool isFirstMile) { var vendor = ouiVendor?.ToLowerInvariant() ?? string.Empty; // Known OLT/PON vendors. Adtran for tier-2/3 US telcos, Ubiquiti for UISP-Fiber @@ -2114,11 +2307,12 @@ private static UpstreamRole InferAccessRole(AttributedHop hop, AccessTechnology var isCmtsVendor = vendor.Contains("arris") || vendor.Contains("commscope") || vendor.Contains("casa") || vendor.Contains("cadant") || vendor.Contains("ubr"); - if ((tech == AccessTechnology.Gpon || tech == AccessTechnology.XgsPon) && isOltVendor && hop.HopNumber == 1) + if (!isFirstMile) return UpstreamRole.Aggregation; + if ((tech == AccessTechnology.Gpon || tech == AccessTechnology.XgsPon) && isOltVendor) return UpstreamRole.Bng; if (tech == AccessTechnology.Docsis && (isCmtsVendor || hop.HopNumber == 1)) return UpstreamRole.Cmts; - if (tech == AccessTechnology.PppoE && hop.HopNumber == 1) + if (tech == AccessTechnology.PppoE) return UpstreamRole.Bng; return UpstreamRole.Aggregation; } @@ -2218,6 +2412,90 @@ public void RecomputeL2NeighborLabel() /// After the user reviews and edits labels, commit the proposed targets into /// the MonitoringTargets table. Becomes the live source the latency tier probes. /// + /// + /// What this WAN's traffic costs, from its access technology and whether Data Usage has a cap + /// configured for it. Any cap above zero counts: setting one is the operator saying the link is + /// metered, whatever the toggle beside it is doing. + /// + private async Task ResolveProbePlanAsync( + NetworkOptimizerDbContext db, string wanInterface, CancellationToken ct) + { + var metered = false; + try + { + var key = GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface); + var configs = await db.WanDataUsageConfigs.AsNoTracking() + .Where(c => c.DataCapGb > 0) + .Select(c => c.WanKey) + .ToListAsync(ct); + metered = configs.Any(k => string.Equals( + GatewayWanHelper.WanInterfaceKeyFromKey(k), key, StringComparison.OrdinalIgnoreCase)); + } + catch (Exception ex) + { + // Unreadable config is not evidence of a cap: probe as normal rather than quietly + // throttling a link that may have none. + _logger.LogDebug(ex, "Could not read Data Usage config for {Wan}; probing unmetered", wanInterface); + } + return MeteredProbePolicy.For(State.AccessTechnology, metered); + } + + /// + /// Leaves only a metered WAN's budget of candidates ticked, nearest first. Access hops before + /// transit: they are the ISP's own first mile, the fewest, and the ones whose loss is the ISP's + /// to answer for. Nothing is removed and nothing is disabled that the operator ticked - this + /// only decides what arrives ticked, and the review is still theirs to change. + /// + internal static void ApplyAutoEnableBudget(UpstreamTracerState state, int? budget) + { + if (budget is not int max) return; + + // Three buckets, taken one at a time in rotation. Access hops used to be taken first and + // in full, which on a first mile that answers with a dozen ECMP addresses spent nearly the + // whole allowance before the other two were reached - one internet target survived, so the + // site could see its access cloud in detail and could not tell whether anything it + // actually reaches was up. Each bucket answers a different question: an access hop says + // whether the ISP's own first mile is at fault, a transit hop says which upstream is, and + // a path endpoint says whether any of it is reaching the things people use. A budget that + // buys depth in one of them measures a fraction of the path. + // Candidates the reachability gate already rejected are not in the running. This runs + // AFTER that gate, and switching one back on because it happened to fall inside the + // budget hands the operator a target that is known not to answer - and spends one of the + // few slots a metered WAN gets doing it. Only reachable candidates are considered, and + // the rejected ones keep the Enabled=false the gate gave them. + var buckets = new List>[] + { + state.AccessHops.Where(h => !h.Unreachable).OrderBy(h => h.HopNumber) + .Select(h => (Action)(on => h.Enabled = on)).ToList(), + state.TransitAsns.Where(t => t.Method != DiscoveryMethod.PathProxy && !t.Unreachable) + .Select(t => (Action)(on => t.Enabled = on)).ToList(), + state.TransitAsns.Where(t => t.Method == DiscoveryMethod.PathProxy && !t.Unreachable) + .Select(t => (Action)(on => t.Enabled = on)).ToList(), + }; + + var cursors = new int[buckets.Length]; + var remaining = max; + bool tookAny; + do + { + tookAny = false; + for (var b = 0; b < buckets.Length && remaining > 0; b++) + { + if (cursors[b] >= buckets[b].Count) continue; + buckets[b][cursors[b]++](true); + remaining--; + tookAny = true; + } + } + while (tookAny && remaining > 0); + + // Whatever the rotation did not reach is left off - within a bucket that is its own order, + // so the nearest access hops and the first-listed endpoints are the ones kept. + for (var b = 0; b < buckets.Length; b++) + for (var i = cursors[b]; i < buckets[b].Count; i++) + buckets[b][i](false); + } + public async Task CommitResultsAsync(CancellationToken ct = default) { if (State.Step != TracerStep.ReviewingResults) return; @@ -2227,7 +2505,16 @@ public async Task CommitResultsAsync(CancellationToken ct = default) // Scope all writes to the WAN this discovery ran against. Multi-WAN setups // get one row in MonitoringTargets per (target, wan) and one row in // WanDiscoveryContexts per WAN. - var wanInterface = State.WanInterface ?? "wan"; + var wanInterface = _binding?.WanInterface ?? State.WanInterface ?? "wan"; + // A context run's targets carry both keys: the WAN says where the data belongs, the + // context says who probes them. Setting them together is what closes the gap where a + // context's targets had a context but no WAN, so no per-WAN reader could find them. + var wanContextId = _binding?.WanContextId; + + // What this WAN's probing costs. Targets are created at the plan's cadence, and on a + // metered WAN the ones already here are slowed to match - a link that has just been + // declared metered is exactly the one whose existing targets are the problem. + var probePlan = await ResolveProbePlanAsync(db, wanInterface, ct); // A confirmed provider change resets the connection's upstream monitoring wholesale: // pause every enabled access/transit/path target - auto-discovered and hand-added alike @@ -2251,11 +2538,14 @@ public async Task CommitResultsAsync(CancellationToken ct = default) foreach (var hop in State.AccessHops.Where(h => h.Enabled)) { _logger.LogDebug("Commit access hop: id={TargetId} label='{Label}' addr={Address}", hop.TargetId, hop.Label, hop.Address); - await UpsertTargetAsync(db, hop, wanInterface, ct); + await UpsertTargetAsync(db, hop, wanInterface, wanContextId, ct, probePlan.PollIntervalSeconds); } foreach (var hop in State.AccessHops.Where(h => !h.Enabled)) { - var existing = await db.MonitoringTargets.FirstOrDefaultAsync(t => t.Address == hop.Address, ct); + // With per-WAN twin rows an address can have several rows; pause only the one this + // WAN owns (another WAN's row - and its measuring - is that WAN's to manage). + var existing = (await db.MonitoringTargets.Where(t => t.Address == hop.Address).ToListAsync(ct)) + .FirstOrDefault(t => OwnsTargetRow(t.WanInterface, wanInterface)); if (existing != null) { existing.Enabled = false; @@ -2267,7 +2557,8 @@ public async Task CommitResultsAsync(CancellationToken ct = default) { _logger.LogDebug("Commit transit: id={TargetId} label='{Label}' addr={Address} method={Method}", transit.TargetId, transit.Label, transit.HopAddress ?? transit.PathProxyTarget, transit.Method); - await UpsertTransitTargetAsync(db, transit, wanInterface, ct); + await UpsertTransitTargetAsync(db, transit, wanInterface, wanContextId, ct, + pollIntervalSeconds: probePlan.PollIntervalSeconds); } foreach (var transit in State.TransitAsns.Where(t => !t.Enabled)) { @@ -2277,12 +2568,15 @@ public async Task CommitResultsAsync(CancellationToken ct = default) // later. Transit ASNs stay on their off-path / miss-counter mechanism (update-only). if (transit.Method == DiscoveryMethod.PathProxy) { - await UpsertTransitTargetAsync(db, transit, wanInterface, ct, enabled: false); + await UpsertTransitTargetAsync(db, transit, wanInterface, wanContextId, ct, enabled: false, + pollIntervalSeconds: probePlan.PollIntervalSeconds); continue; } var addr = transit.HopAddress ?? transit.PathProxyTarget; if (string.IsNullOrEmpty(addr)) continue; - var existing = await db.MonitoringTargets.FirstOrDefaultAsync(t => t.Address == addr, ct); + // Same per-WAN row selection as the access-hop pause above. + var existing = (await db.MonitoringTargets.Where(t => t.Address == addr).ToListAsync(ct)) + .FirstOrDefault(t => OwnsTargetRow(t.WanInterface, wanInterface)); if (existing != null) { existing.Enabled = false; @@ -2340,7 +2634,10 @@ await UpstreamRediscoveryService.ClearMissCountKeysAsync(db, wanInterface, ctxRow.NeedsReview = false; ctxRow.UpdatedAt = DateTime.UtcNow; - var settings = await db.MonitoringSettings.FirstOrDefaultAsync(ct); + // MonitoringSettings holds the LEGACY single-WAN timestamp and review flag, which the + // primary run owns. A context run must leave them alone: clearing the review flag here + // would dismiss a pending review of the primary WAN that nobody has looked at. + var settings = _binding == null ? await db.MonitoringSettings.FirstOrDefaultAsync(ct) : null; if (settings != null) { settings.LastUpstreamDiscoveryAt = DateTime.UtcNow; @@ -2348,6 +2645,25 @@ await UpstreamRediscoveryService.ClearMissCountKeysAsync(db, wanInterface, settings.UpdatedAt = DateTime.UtcNow; } + + // Slow what is already here to match. A WAN only reaches a rung by being declared metered + // or by its technology, and in both cases the targets already probing it are the cost - + // creating new ones at the right cadence while the old ones keep running at 10s would fix + // nothing. Fabric targets never leave the WAN, so they are left alone; so is anything + // already slower than the plan, which is a deliberate choice of the operator's. + if (probePlan.Rung > 0) + { + var repaced = await db.MonitoringTargets + .Where(t => t.TargetType != MonitoringTargetType.Fabric + && t.PollIntervalSeconds < probePlan.PollIntervalSeconds) + .ToListAsync(ct); + repaced = repaced.Where(t => OwnsTargetRow(t.WanInterface, wanInterface)).ToList(); + foreach (var target in repaced) target.PollIntervalSeconds = probePlan.PollIntervalSeconds; + if (repaced.Count > 0) + _logger.LogInformation("Metered WAN {Wan}: slowed {Count} existing target(s) to {Interval}s", + wanInterface, repaced.Count, probePlan.PollIntervalSeconds); + } + await db.SaveChangesAsync(ct); // Persist same-path hop ordering so ISP Health can confirm a farther transit @@ -2356,7 +2672,11 @@ await UpstreamRediscoveryService.ClearMissCountKeysAsync(db, wanInterface, // Drop the ISP Health cache so the "re-run discovery" banner clears on the next tab // view without a manual refresh - the freshly committed ancestry is now in the DB. - _ispHealth.Invalidate(); + // + // Every WAN of the site: this run commits targets for the WAN it was bound to, which is + // usually NOT the primary, and the injected instance always is. Invalidating that alone + // left the report for the very WAN just discovered showing its pre-discovery state. + _ispHealthRegistry.InvalidateSite(_siteSlug); State.Step = TracerStep.Done; State.CurrentActivity = "Targets committed. The agent will start probing on the next latency-tier cycle."; @@ -2471,7 +2791,80 @@ private async Task PersistHopOrderAsync(NetworkOptimizerDbContext db, string wan written, wanInterface, _lastTraces.Count); } - private static async Task UpsertTargetAsync(NetworkOptimizerDbContext db, AccessHopCandidate hop, string wanInterface, CancellationToken ct) + /// + /// Whether this run may write to an existing target row. A row already homed on a DIFFERENT + /// WAN belongs to that WAN's discovery: letting each run re-home it would have the two + /// trading it back and forth every cycle - and would let one WAN's run pause a target the + /// other WAN is measuring. A run that finds an address claimed by another WAN creates its + /// OWN row for it instead (see ), so the same host is + /// probed from every WAN that discovers it and each WAN's series stay separable. A row with + /// no WAN yet is unclaimed and adoptable, which is how every pre-existing row behaves on a + /// single-WAN install: there, this is always true and nothing changes. + /// + /// The WAN currently stamped on the row, if any. + /// The WAN this discovery run is committing. + /// + /// The discovery-context row a tracer rehydrates from. A bound (context) tracer takes + /// exactly its own WAN's row. The primary tracer takes the CONFIGURED primary's row when + /// the console answered (primary is a role - any wanN group can hold it); with no console + /// answer it falls back to a documented GUESS: the conventional "wan" row first, then the + /// most recently discovered. That guess is wrong exactly on an offline site whose + /// configured primary is not the "wan" group - acceptable only because there is nothing + /// better to ask, and the next connected rehydrate corrects it. Keys normalized + /// ("wan1" == "wan"). + /// + internal static WanDiscoveryContext? PickRehydrateContext( + IReadOnlyList contexts, string? boundWanInterface, string? configuredPrimaryKey) + { + static string Norm(string? k) => string.IsNullOrEmpty(k) + ? "" : NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(k); + if (!string.IsNullOrEmpty(boundWanInterface)) + return contexts.FirstOrDefault(c => Norm(c.WanInterface) == Norm(boundWanInterface)); + if (!string.IsNullOrEmpty(configuredPrimaryKey)) + { + var configured = contexts.FirstOrDefault(c => Norm(c.WanInterface) == Norm(configuredPrimaryKey)); + if (configured != null) return configured; + } + return contexts + .OrderBy(c => Norm(c.WanInterface) == "wan" ? 0 : 1) + .ThenByDescending(c => c.LastDiscoveryAt ?? c.UpdatedAt) + .FirstOrDefault(); + } + + internal static bool OwnsTargetRow(string? rowWanInterface, string wanInterface) + => string.IsNullOrEmpty(rowWanInterface) + // Normalized ("wan1" == "wan"): legacy rows stamped with the wan1 alias are the SAME + // WAN as a "wan" run, not a rival - unnormalized, every re-run on such an install + // would twin its own targets. + || string.Equals( + NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(rowWanInterface), + NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface), + StringComparison.OrdinalIgnoreCase); + + /// + /// The WAN-qualified target id for this WAN's twin of a host another WAN's discovery already + /// claimed. MonitoringTarget.TargetId is unique (and is the Influx target_id tag), so a host + /// reached from several WANs - a core resolver, a shared ISP hop - gets one row PER WAN: the + /// first WAN keeps the base id (existing installs and their history unchanged), every later + /// WAN gets "{baseId}@{wanKey}". Distinct ids keep result routing and the per-target Influx + /// series unambiguous with zero read-side cost; cross-WAN "same host" linkage for comparison + /// views is by (twins share it). + /// + internal static string WanQualifiedTargetId(string baseTargetId, string wanInterface) + => $"{baseTargetId}@{NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface)}"; + + /// + /// Creates or re-validates the monitoring target for a discovered access hop, stamped with + /// the WAN it was discovered on and - on a context run - the context whose agent probes it. + /// Test-visible (internal, see InternalsVisibleTo) because the double stamping and the + /// leave-another-WAN's-row-alone rule are the whole of per-WAN discovery's write side. + /// + /// The site's database. + /// The discovered hop. + /// WAN this discovery ran against. + /// Context this run belongs to, or null for the primary run. + /// Cancellation. + internal static async Task UpsertTargetAsync(NetworkOptimizerDbContext db, AccessHopCandidate hop, string wanInterface, int? wanContextId, CancellationToken ct, int pollIntervalSeconds = MeteredProbePolicy.DefaultIntervalSeconds) { // UniFi's WAN SLA probe targets (1.1.1.1 / 8.8.8.8) are public DNS resolvers, not // ISP first-mile infrastructure. They never belong as an Access ISP target; drop any @@ -2485,13 +2878,23 @@ private static async Task UpsertTargetAsync(NetworkOptimizerDbContext db, Access return; } - var existing = await db.MonitoringTargets.FirstOrDefaultAsync(t => t.TargetId == hop.TargetId, ct); - existing ??= await db.MonitoringTargets.FirstOrDefaultAsync(t => t.Address == hop.Address, ct); + // This WAN's own row for the hop: the base id where this WAN owns it (or it is + // unclaimed), this WAN's twin, or any row for the address this WAN owns. When the + // address is claimed by ANOTHER WAN, this run creates its own WAN-qualified twin so + // the host is probed from both WANs with separable series (see WanQualifiedTargetId). + var twinId = WanQualifiedTargetId(hop.TargetId, wanInterface); + var rows = await db.MonitoringTargets + .Where(t => t.TargetId == hop.TargetId || t.TargetId == twinId || t.Address == hop.Address) + .ToListAsync(ct); + var existing = rows.FirstOrDefault(t => t.TargetId == hop.TargetId && OwnsTargetRow(t.WanInterface, wanInterface)) + ?? rows.FirstOrDefault(t => t.TargetId == twinId) + ?? rows.FirstOrDefault(t => OwnsTargetRow(t.WanInterface, wanInterface)); + var claimedByOtherWan = existing == null && rows.Count > 0; if (existing == null) { db.MonitoringTargets.Add(new MonitoringTarget { - TargetId = hop.TargetId, + TargetId = claimedByOtherWan ? twinId : hop.TargetId, Name = hop.Label, Address = hop.Address, ProbeMode = hop.RespondedTo, @@ -2500,12 +2903,13 @@ private static async Task UpsertTargetAsync(NetworkOptimizerDbContext db, Access AsnNumber = hop.AsnNumber, AsnName = CleanAsnName(hop.AsnName), VantagePoint = "server", - PollIntervalSeconds = 10, + PollIntervalSeconds = pollIntervalSeconds, PingCount = 5, Enabled = true, AutoDiscovered = true, DiscoveryMethod = hop.Method, WanInterface = wanInterface, + WanContextId = wanContextId, PtrHostname = hop.PtrHostname, AutoLabel = hop.Role.ToString(), CreatedAt = DateTime.UtcNow, @@ -2522,6 +2926,9 @@ private static async Task UpsertTargetAsync(NetworkOptimizerDbContext db, Access existing.Address = hop.Address; existing.ProbeMode = hop.RespondedTo; existing.WanInterface = wanInterface; + // Written, never cleared: a target the user assigned to a context by hand keeps + // that assignment when the primary run re-verifies it. + if (wanContextId != null) existing.WanContextId = wanContextId; existing.Name = hop.Label; if (hop.AsnNumber.HasValue) existing.AsnNumber = hop.AsnNumber; if (!string.IsNullOrEmpty(hop.AsnName)) existing.AsnName = CleanAsnName(hop.AsnName); @@ -2530,7 +2937,18 @@ private static async Task UpsertTargetAsync(NetworkOptimizerDbContext db, Access } } - private static async Task UpsertTransitTargetAsync(NetworkOptimizerDbContext db, TransitAsnCandidate transit, string wanInterface, CancellationToken ct, bool enabled = true) + /// + /// Creates or re-validates the monitoring target for a discovered transit ASN hop or path-end + /// host, with the same WAN + context stamping and same-WAN ownership rule as + /// . Test-visible for the same reason. + /// + /// The site's database. + /// The discovered transit candidate. + /// WAN this discovery ran against. + /// Context this run belongs to, or null for the primary run. + /// Cancellation. + /// Whether the target is committed enabled (a declined path-end is saved paused). + internal static async Task UpsertTransitTargetAsync(NetworkOptimizerDbContext db, TransitAsnCandidate transit, string wanInterface, int? wanContextId, CancellationToken ct, bool enabled = true, int pollIntervalSeconds = MeteredProbePolicy.DefaultIntervalSeconds) { if (transit.Method == DiscoveryMethod.Unresolved || string.IsNullOrEmpty(transit.TargetId)) return; @@ -2539,14 +2957,22 @@ private static async Task UpsertTransitTargetAsync(NetworkOptimizerDbContext db, : MonitoringTargetType.Transit; var address = transit.HopAddress ?? transit.PathProxyTarget; - var existing = await db.MonitoringTargets.FirstOrDefaultAsync(t => t.TargetId == transit.TargetId, ct); - if (existing == null && !string.IsNullOrEmpty(address)) - existing = await db.MonitoringTargets.FirstOrDefaultAsync(t => t.Address == address, ct); + // Same twin rule as UpsertTargetAsync: a host another WAN's discovery already claimed + // gets this WAN's own WAN-qualified row, so both WANs probe it with separable series. + var twinId = WanQualifiedTargetId(transit.TargetId, wanInterface); + var rows = await db.MonitoringTargets + .Where(t => t.TargetId == transit.TargetId || t.TargetId == twinId + || (address != null && t.Address == address)) + .ToListAsync(ct); + var existing = rows.FirstOrDefault(t => t.TargetId == transit.TargetId && OwnsTargetRow(t.WanInterface, wanInterface)) + ?? rows.FirstOrDefault(t => t.TargetId == twinId) + ?? rows.FirstOrDefault(t => OwnsTargetRow(t.WanInterface, wanInterface)); + var claimedByOtherWan = existing == null && rows.Count > 0; if (existing == null) { db.MonitoringTargets.Add(new MonitoringTarget { - TargetId = transit.TargetId, + TargetId = claimedByOtherWan ? twinId : transit.TargetId, Name = transit.Label ?? transit.AsnName, Address = transit.HopAddress ?? transit.PathProxyTarget ?? "0.0.0.0", ProbeMode = transit.RespondedTo ?? NetworkOptimizer.Core.Enums.ProbeMode.Icmp, @@ -2555,13 +2981,14 @@ private static async Task UpsertTransitTargetAsync(NetworkOptimizerDbContext db, AsnNumber = transit.AsnNumber, AsnName = transit.AsnName, VantagePoint = "server", - PollIntervalSeconds = 15, + PollIntervalSeconds = pollIntervalSeconds, PingCount = 5, Enabled = enabled, PtrHostname = transit.HopHostname, AutoDiscovered = true, DiscoveryMethod = transit.Method, WanInterface = wanInterface, + WanContextId = wanContextId, CreatedAt = DateTime.UtcNow, LastVerified = DateTime.UtcNow }); @@ -2575,6 +3002,7 @@ private static async Task UpsertTransitTargetAsync(NetworkOptimizerDbContext db, if (!string.IsNullOrEmpty(transit.HopHostname)) existing.PtrHostname = transit.HopHostname; existing.DiscoveryMethod = transit.Method; existing.WanInterface = wanInterface; + if (wanContextId != null) existing.WanContextId = wanContextId; // Refresh ASN bookkeeping in case the resolver picked up a name now // (legacy rows from before the GeoLite2 path landed had nulls). if (transit.AsnNumber > 0) existing.AsnNumber = transit.AsnNumber; @@ -2711,16 +3139,32 @@ private async Task ResolveDestinationAsnsAsync(CancellationToken ct) /// attribution but sit in public or shared/CGNAT (RFC 6598) space - Bell's 142.124.x /// aggregation hops (#984). Being upstream of us and downstream of the ISP's announced /// border makes them the ISP's access infrastructure even though no ASN maps to them. - /// RFC1918 hops are excluded: those can be a bridged CPE's LAN side or a double-NAT - /// middlebox. Traces whose first attributed hop is NOT the access ASN (e.g. a trace + /// RFC1918 hops count too: an ISP whose access network is numbered out of private space (a CMTS + /// or BNG on 10/8) leaves no other trace of its first mile, and dropping those hops is what left + /// such sites with no access targets at all. Our own gateway is excluded by address; nothing + /// else is, because there is no reliable way to tell a bridged CPE from the ISP's first device + /// here - the sequences carry only hops that RESPONDED, so position is not TTL distance, and a + /// probe running ON the gateway has no gateway hop to count from at all. A wrong one is + /// proposed, not applied: discovery review is where the operator unticks it. + /// Traces whose first attributed hop is NOT the access ASN (e.g. a trace /// that only ever surfaces the destination's edge) contribute nothing - we can't prove /// their prefix hops sit below the access border. Dedupes across traces, preserves /// first-seen order. /// + /// + /// A private hop this close is on our own side of the WAN - the gateway itself, a bridged CPE, + /// a middlebox. The ISP's first-mile gear is a WAN crossing away and answers in milliseconds, + /// not fractions of one, so distance separates the two where position cannot: non-responding + /// hops make position unreliable, and a probe running on the gateway has no gateway hop at all. + /// + internal const double LocalHopRttMs = 1.2; + internal static List CollectUnannouncedAccessAddresses( IEnumerable> traceAddressSequences, IReadOnlyDictionary asnByIp, - int accessAsn) + int accessAsn, + IReadOnlyCollection? gatewayIps = null, + IReadOnlyDictionary? minRttByIp = null) { var result = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -2729,6 +3173,7 @@ internal static List CollectUnannouncedAccessAddresses( var prefix = new List(); foreach (var address in trace) { + if (gatewayIps != null && gatewayIps.Contains(address)) continue; if (asnByIp.TryGetValue(address, out var asn)) { if (asn == accessAsn) @@ -2736,9 +3181,15 @@ internal static List CollectUnannouncedAccessAddresses( if (seen.Add(p)) result.Add(p); break; } - var cls = NetworkUtilities.ClassifyPublicAddress(address); - if (cls is PublicAddressClass.PublicIPv4 or PublicAddressClass.Cgnat) - prefix.Add(address); + // Only private space is judged on distance: public and CGNAT hops are carrier space + // whatever they measure. An address with no timing is kept - silence is not evidence. + if (minRttByIp != null + && NetworkUtilities.ClassifyPublicAddress(address) + is not (PublicAddressClass.PublicIPv4 or PublicAddressClass.Cgnat) + && minRttByIp.TryGetValue(address, out var hopRtt) + && hopRtt < LocalHopRttMs) + continue; + prefix.Add(address); } } return result; @@ -2837,7 +3288,72 @@ internal static HashSet ComputeExcludedTier1Asns( var parts = hostname.Split('.'); if (IsIpDerivedHostname(parts, ipAddress ?? string.Empty)) return null; if (parts.Length <= 2) return null; - return string.Join('.', parts.Take(parts.Length - 2)); + var label = string.Join('.', parts.Take(parts.Length - 2)); + return PlaceholderPtrLabels.Contains(label) ? null : label; + } + + /// + /// PTR labels that name nothing. Starlink answers most of its network with + /// "undefined.hostname.localhost", which parses as a perfectly good label and produced a row + /// called "<Org> undefined" - repeated for every hop, so they were not even distinguishable + /// from each other. A placeholder is treated as no PTR at all. + /// + private static readonly HashSet PlaceholderPtrLabels = + new(StringComparer.OrdinalIgnoreCase) { "undefined", "unknown", "none", "null", "localhost" }; + + /// + /// Access technology implied by the access ISP itself. Only satellite is safe to read this + /// way: a terrestrial ISP's AS carries fiber, cable and DSL customers behind the same number, + /// while SpaceX's carries one medium. Matched on the number AND the org name so a secondary + /// ASN, or a registry rename, still lands. + /// + internal static AccessTechnology? TechnologyFromAccessAsn(int? asn, string? orgName) + => IsStarlinkAsn(asn, orgName) ? AccessTechnology.Satellite : null; + + /// + /// Whether an ASN is SpaceX's. Matched on the number AND the org name so a secondary ASN, or a + /// registry rename, still lands. + /// + internal static bool IsStarlinkAsn(int? asn, string? orgName) + { + if (asn == 14593) return true; + if (string.IsNullOrWhiteSpace(orgName)) return false; + return orgName.Contains("starlink", StringComparison.OrdinalIgnoreCase) + || orgName.Contains("space exploration", StringComparison.OrdinalIgnoreCase) + || orgName.Contains("spacex", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Whether a PTR answers with a placeholder rather than a name - "undefined.hostname.localhost" + /// and the like. Distinct from having no PTR at all, which is a different and less telling + /// thing: a host that answers with a placeholder is saying something about what it is. + /// + internal static bool IsPlaceholderPtrHostname(string? hostname) + { + if (string.IsNullOrEmpty(hostname)) return false; + var first = hostname.Split('.')[0]; + return PlaceholderPtrLabels.Contains(first); + } + + /// + /// Re-applies the metered probe budget to the candidates on screen. The budget reads the + /// access technology, which is often only right once someone sets it in the review - a + /// satellite WAN identified after the run would otherwise keep the pre-selection an unmetered + /// run made, which is the whole allowance it was meant to save. + /// + public async Task ReapplyProbeBudgetAsync(CancellationToken ct = default) + { + try + { + await using var db = await CreateDbAsync(ct); + var plan = await ResolveProbePlanAsync( + db, _binding?.WanInterface ?? State.WanInterface ?? "wan", ct); + ApplyAutoEnableBudget(State, plan.MaxAutoEnabled); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Re-applying the probe budget after an access technology change failed"); + } } private bool Fail(string message) diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/WanContextTargetStamping.cs b/src/NetworkOptimizer.Web/Services/Monitoring/WanContextTargetStamping.cs new file mode 100644 index 0000000000..69a267a81f --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/WanContextTargetStamping.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using NetworkOptimizer.Storage; +using NetworkOptimizer.Storage.Models; + +namespace NetworkOptimizer.Web.Services.Monitoring; + +/// +/// Keeps (probe routing) and +/// (which WAN the data describes - the key every +/// per-WAN reader scopes on) moving together at runtime. The deploy-time backfill migration +/// only fixed rows that existed then; every later assignment, context edit, and context +/// deletion goes through here so the two keys can never drift apart again. +/// +public static class WanContextTargetStamping +{ + /// + /// The WanInterface a target should carry after a WAN-context (re)assignment: the context's + /// WAN, or null when the target moves back to the primary (an unstamped target IS a + /// primary-path measurement to every scoped reader). + /// + public static void ApplyAssignment(MonitoringTarget target, int? wanContextId, string? contextWanInterface) + { + target.WanContextId = wanContextId; + target.WanInterface = wanContextId == null ? null : contextWanInterface; + } + + /// + /// Re-stamps every target assigned to a context after the context's WAN changed, so their + /// data is attributed to the WAN the context now measures. Caller saves. + /// + public static async Task RestampContextTargetsAsync( + NetworkOptimizerDbContext db, int wanContextId, string? wanInterface, CancellationToken ct = default) + { + var targets = await db.MonitoringTargets.Where(t => t.WanContextId == wanContextId).ToListAsync(ct); + foreach (var target in targets) + target.WanInterface = wanInterface; + return targets.Count; + } + + /// + /// Moves a deleted context's targets back to the primary: both keys cleared, because a row + /// keeping the dead context's WAN stamp would stay invisible to the primary report while no + /// context probes it any more. Caller saves. + /// + public static async Task ReleaseContextTargetsAsync( + NetworkOptimizerDbContext db, int wanContextId, CancellationToken ct = default) + { + var targets = await db.MonitoringTargets.Where(t => t.WanContextId == wanContextId).ToListAsync(ct); + foreach (var target in targets) + ApplyAssignment(target, null, null); + return targets.Count; + } +} diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/WanOutageClassifier.cs b/src/NetworkOptimizer.Web/Services/Monitoring/WanOutageClassifier.cs new file mode 100644 index 0000000000..6eb7d51847 --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/WanOutageClassifier.cs @@ -0,0 +1,207 @@ +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; + +namespace NetworkOptimizer.Web.Services.Monitoring; + +/// +/// What one WAN's monitored targets currently say about that WAN, classified for alerting. +/// +internal enum WanVerdictKind +{ + /// Nothing alert-worthy: everything reachable, or too little evidence to say. + None, + + /// Part of the path beyond the access layer is out while the WAN still passes traffic. + Partial, + + /// The WAN's internet is down: every destination failing, with or without the first hop. + Total +} + +/// +/// One WAN-scoped monitoring target's current state, as the WAN outage classifier sees it. +/// is the per-target offline state machine's verdict (consecutive +/// failed probes); additionally includes sustained-loss targets, +/// and is what the partial pass keys on - a branch can be "out or degraded" without every +/// probe failing outright. is the trace-map hop number +/// ( when the trace map does not place the row, which also clears +/// ). are the proven-upstream +/// monitored hop addresses from . +/// +internal sealed record WanTargetSnapshot( + string TargetId, + MonitoringTargetType Type, + string Name, + string Address, + bool Failing, + bool Degraded, + int Depth, + bool KnownPosition, + bool IsInternet, + string? AsnLabel, + int AsnNumber, + IReadOnlySet AncestorIps); + +/// +/// The classifier's answer for one WAN on one evaluation pass. +/// distinguishes "access layer and out" from "upstream of the access layer" for a +/// verdict. / +/// carry 's +/// attribution for the upstream case; names the shared +/// ancestor (or shared network) for a branch-shaped partial, null for an independent one. +/// +internal sealed record WanVerdict( + WanVerdictKind Kind, + bool AccessDown, + string? LastReachableHop, + string? BrokenNetwork, + string? BranchLabel, + int FailingCount, + int TotalCount) +{ + public static readonly WanVerdict None = new(WanVerdictKind.None, false, null, null, null, 0, 0); +} + +/// +/// Classifies one WAN's current target states into an outage verdict. Pure and stateless: the +/// evaluator owns freshness, confirmation counting and publishing; this owns only "what shape +/// is the failure". The attribution rules are inherited from +/// rather than restated: break naming goes through +/// (which refuses to anchor on off-map or internet-endpoint rows), and network independence +/// uses so several regional endpoints of one provider +/// never read as several independent networks. +/// +internal static class WanOutageClassifier +{ + public static WanVerdict Classify(IReadOnlyList targets) + { + if (targets.Count == 0) return WanVerdict.None; + + var failing = targets.Where(t => t.Failing).ToList(); + var degraded = targets.Where(t => t.Degraded).ToList(); + var accessRows = targets.Where(t => t.Type == MonitoringTargetType.AccessIsp).ToList(); + + // Access layer and out: every target on the WAN is failing, first hop included. + if (failing.Count == targets.Count) + return new WanVerdict(WanVerdictKind.Total, AccessDown: accessRows.Count > 0, + null, null, null, failing.Count, targets.Count); + + // Upstream of the access layer: the first hop answers, everything beyond it is failing. + // At least one failing internet destination is required - a set that is all transit + // beyond the access hop can go probe-dark from rate limiting alone, and with no + // destination monitored there is no evidence anything the user reaches is down. + if (accessRows.Count > 0 + && accessRows.All(a => !a.Failing) + && targets.Where(t => t.Type != MonitoringTargetType.AccessIsp).All(t => t.Failing) + && targets.Any(t => t.IsInternet && t.Failing)) + { + var (lastReachable, brokenNetwork) = AttributeUpstreamBreak(targets); + return new WanVerdict(WanVerdictKind.Total, AccessDown: false, + lastReachable, brokenNetwork, null, failing.Count, targets.Count); + } + + // Partial pass, over the degraded set (offline or sustained loss). A partial only opens + // when at least one internet destination is affected: transit hops routinely stop + // answering probes with nothing wrong, so a transit-only picture with every destination + // reachable is a rate-limited router, not an outage. Corroboration for a transit failure + // is exactly a destination behind it also failing, which the branch pass below finds. + if (degraded.Count == 0 || !degraded.Any(t => t.IsInternet)) + return WanVerdict.None; + + // Single destination: one internet endpoint dark while everything else is fine is nearly + // always the endpoint's problem, and the flappy CDN endpoints would rebuild the noise + // this alert class exists to remove. Visible on the charts, never notified. + if (degraded.Count == 1) + return WanVerdict.None; + + var branch = FindBranchLabel(targets, degraded); + if (branch != null) + return new WanVerdict(WanVerdictKind.Partial, false, null, null, branch, + degraded.Count, targets.Count); + + // Independence gate, by network (ASN label / real ASN), inherited from the partial + // detector: several endpoints of one provider are one network, not several. + var networks = degraded.Select(NetworkKeyOf).Distinct().Count(); + if (networks >= 2) + return new WanVerdict(WanVerdictKind.Partial, false, null, null, null, + degraded.Count, targets.Count); + + // One network, no monitored shared ancestor: the network itself is the branch. + return new WanVerdict(WanVerdictKind.Partial, false, null, null, + NetworkKeyOf(degraded[0]), degraded.Count, targets.Count); + } + + /// + /// Break attribution for a total-with-first-hop-answering verdict, through + /// with the current failing states as the + /// cleanliness tests, so the alert path inherits its rules: only trace-map-anchored path + /// hops may name where the break sat, and a clean row deeper than a failing one (a sibling + /// branch) never anchors. + /// + private static (string? LastReachableHop, string? BrokenNetwork) AttributeUpstreamBreak( + IReadOnlyList targets) + { + var failingByHop = new Dictionary(); + foreach (var t in targets) + { + var hop = new OutageDetector.Hop(t.Name, t.Depth, Array.Empty(), + Groupable: false, AsnLabel: t.AsnLabel, IsGateway: false, + KnownPosition: t.KnownPosition, IsInternet: t.IsInternet, AsnNumber: t.AsnNumber); + failingByHop[hop] = t.Failing; + } + return OutageDetector.AttributeBreak(failingByHop.Keys, + judged: _ => true, + isClean: h => !failingByHop[h], + isBroken: h => failingByHop[h]); + } + + /// + /// The shared-ancestor label for a branch-shaped partial, or null when the degraded set has + /// no usable common ancestor. Two shapes count: a degraded path hop that every other + /// degraded target sits behind (the branch head itself went dark), and a still-reachable + /// hop that every degraded target - and no healthy one - sits behind (the break is just + /// past it). Candidates must be trace-map-anchored path rows, never internet endpoints, + /// mirroring the attribution rules. + /// + private static string? FindBranchLabel( + IReadOnlyList targets, List degraded) + { + // A degraded path hop all other degraded targets are behind: the branch head. A healthy + // target behind the same hop disqualifies it - the targets behind it must AGREE it is + // gone, or the hop is just deprioritizing probes while forwarding fine. + var head = degraded + .Where(c => c.KnownPosition && !c.IsInternet + && degraded.All(t => ReferenceEquals(t, c) + || t.AncestorIps.Contains(c.Address)) + && !targets.Any(t => !t.Degraded && t.AncestorIps.Contains(c.Address))) + .OrderByDescending(c => c.Depth) + .FirstOrDefault(); + if (head != null) return head.AsnLabel ?? head.Name; + + // A common ancestor of every degraded target that no healthy target is behind - an + // ancestor healthy traffic also crosses (the access hop, usually) cannot be where the + // break sits. Ancestors are raw hop IPs; only ones that map onto a trace-map-anchored + // monitored path row can be named. + var common = degraded + .Select(t => (IEnumerable)t.AncestorIps) + .Aggregate((a, b) => a.Intersect(b, StringComparer.OrdinalIgnoreCase)); + var healthyAncestors = targets.Where(t => !t.Degraded) + .SelectMany(t => t.AncestorIps) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var byAddress = targets + .Where(t => t.KnownPosition && !t.IsInternet) + .GroupBy(t => t.Address, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var sharedAncestor = common + .Where(ip => !healthyAncestors.Contains(ip) && byAddress.ContainsKey(ip)) + .Select(ip => byAddress[ip]) + .OrderByDescending(t => t.Depth) + .FirstOrDefault(); + return sharedAncestor == null ? null : sharedAncestor.AsnLabel ?? sharedAncestor.Name; + } + + /// Independence key, deferring to . + private static string NetworkKeyOf(WanTargetSnapshot t) => + OutageDetector.NetworkKey(new OutageDetector.Hop(t.Name, t.Depth, + Array.Empty(), AsnLabel: t.AsnLabel, AsnNumber: t.AsnNumber)); +} diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/WanOutageContextSource.cs b/src/NetworkOptimizer.Web/Services/Monitoring/WanOutageContextSource.cs new file mode 100644 index 0000000000..a417aacd9e --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/WanOutageContextSource.cs @@ -0,0 +1,166 @@ +using Microsoft.EntityFrameworkCore; +using NetworkOptimizer.Storage.Services; +using NetworkOptimizer.UniFi; + +namespace NetworkOptimizer.Web.Services.Monitoring; + +/// One WAN as the outage evaluator needs it: identity, label, role, and link state. +/// Normalized interface key ("wan", "wan2"). +/// Display label from ("Acme Fiber WAN2"). +/// +/// Whether outage severity should treat this WAN as the primary. True when the console said so +/// AND when nothing has ever said (unknown role must over-alert about the connection the site +/// actually uses, not stay quiet); false only when the console recorded another WAN as primary. +/// +/// +/// Whether this WAN is carrying user traffic right now, which is what outage severity turns on. +/// True for the primary, and true for every WAN on a load-balancing site: under load balancing +/// each WAN carries live sessions, so losing a non-primary one drops real traffic rather than +/// only redundancy. False only for an idle failover backup. +/// +/// The console's link state for the WAN, when a console was reachable; null when unknown. +internal sealed record WanOutageWanInfo(string WanKey, string Label, bool TreatAsPrimary, bool CarriesTraffic, bool? ConsoleUp); + +/// A target's place in the persisted trace map. +internal sealed record WanOutageHopInfo(int Depth, IReadOnlySet AncestorIps); + +/// +/// Everything the WAN outage evaluator needs from the site's database, loaded as one snapshot +/// and cached by the evaluator. is keyed by +/// MonitoringTarget.TargetId; targets absent from it have no trace-map position. +/// +internal sealed record WanOutageContext( + string PrimaryWanKey, + IReadOnlyDictionary Wans, + IReadOnlyDictionary HopsByTargetId, + IReadOnlyDictionary AccessNeighborIpByWan) +{ + public static readonly WanOutageContext Empty = new( + GatewayWanHelper.DefaultWanKey, + new Dictionary(), + new Dictionary(), + new Dictionary()); +} + +/// +/// Loads the per-site WAN context the outage evaluator classifies against: WAN roles and labels +/// from , trace-map positions from +/// , and each WAN's first-hop neighbor from +/// . Reads the owning site's database directly +/// (the evaluator lives outside the ambient site scope), and consults the console's WAN link +/// state only for the default site, where the console connection is local; a missing console +/// just leaves the link state unknown, it never blocks evaluation. +/// +public class WanOutageContextSource +{ + private readonly SiteDbContextFactory _dbFactory; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public WanOutageContextSource(SiteDbContextFactory dbFactory, IServiceScopeFactory scopeFactory, + ILogger logger) + { + _dbFactory = dbFactory; + _scopeFactory = scopeFactory; + _logger = logger; + } + + internal virtual async Task LoadAsync(string siteSlug, IReadOnlyCollection wanKeysInUse, + CancellationToken ct = default) + { + var isDefault = siteSlug == SiteManagementService.DefaultSiteSlug; + await using var db = _dbFactory.CreateForSite(siteSlug, isDefault); + + var profiles = await db.WanProfiles.AsNoTracking().ToListAsync(ct); + var discoveryContexts = await db.WanDiscoveryContexts.AsNoTracking().ToListAsync(ct); + var hopRows = await db.UpstreamDiscoveries.AsNoTracking() + .Where(u => u.IsActive && u.MonitoringTargetId != null) + .Select(u => new { u.MonitoringTargetId, u.HopNumber, u.AncestorHopIps }) + .ToListAsync(ct); + var targetIdByDbId = await db.MonitoringTargets.AsNoTracking() + .Select(t => new { t.Id, t.TargetId }) + .ToDictionaryAsync(t => t.Id, t => t.TargetId, ct); + + // The primary WAN owns every unstamped (legacy/hand-added) target. When no console has + // ever recorded the role, the conventional first group is the documented guess. + var primaryKey = profiles.FirstOrDefault(p => p.IsPrimary == true) is { } primary + ? KeyFromGroup(primary.WanNetworkgroup) + : GatewayWanHelper.DefaultWanKey; + + // Recorded per WAN but a site-wide fact, so any row that has an answer speaks for the site. + var loadBalances = profiles.Any(p => p.SiteLoadBalances == true); + + var consoleUp = isDefault ? await TryGetConsoleLinkStatesAsync(ct) : null; + + // Every WAN we know about, from any source: profiles, discovery contexts, and the WAN + // keys the live targets are stamped with (a WAN can have targets before its profile row). + var wans = new Dictionary(StringComparer.OrdinalIgnoreCase); + var allKeys = profiles.Select(p => KeyFromGroup(p.WanNetworkgroup)) + .Concat(discoveryContexts.Select(d => GatewayWanHelper.WanInterfaceKeyFromKey(d.WanInterface))) + .Concat(wanKeysInUse.Select(GatewayWanHelper.WanInterfaceKeyFromKey)) + .Distinct(StringComparer.OrdinalIgnoreCase); + foreach (var key in allKeys) + { + var profile = profiles.FirstOrDefault(p => + string.Equals(KeyFromGroup(p.WanNetworkgroup), key, StringComparison.OrdinalIgnoreCase)); + var index = GatewayWanHelper.WanIndexFromKey(key); + // Only an explicit "another WAN is primary" makes a WAN non-primary; an unknown + // role must over-alert about the connection the site may actually be using. + var treatAsPrimary = profile?.IsPrimary != false; + wans[key] = new WanOutageWanInfo( + key, + GatewayWanHelper.FormatWanLabel(profile?.Name, index, null, null), + treatAsPrimary, + // Load balancing puts traffic on every WAN, so a backup's outage is a real + // service loss there, not just lost redundancy. Unknown reads as failover: + // that is the conventional setup, and the primary is covered either way. + treatAsPrimary || loadBalances, + consoleUp != null && consoleUp.TryGetValue(key, out var up) ? up : null); + } + + var hops = new Dictionary(); + foreach (var group in hopRows.GroupBy(r => r.MonitoringTargetId!.Value)) + { + if (!targetIdByDbId.TryGetValue(group.Key, out var targetId)) continue; + var depths = group.Where(r => r.HopNumber > 0).Select(r => r.HopNumber).ToList(); + var ancestors = group + .SelectMany(r => (r.AncestorHopIps ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + hops[targetId] = new WanOutageHopInfo(depths.Count > 0 ? depths.Min() : int.MaxValue, ancestors); + } + + var accessNeighbors = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var dc in discoveryContexts.Where(d => !string.IsNullOrEmpty(d.L2NeighborIp))) + accessNeighbors[GatewayWanHelper.WanInterfaceKeyFromKey(dc.WanInterface)] = dc.L2NeighborIp!; + + return new WanOutageContext(primaryKey, wans, hops, accessNeighbors); + } + + /// + /// The console's per-WAN link state for the default site, via the cached + /// WAN summary. Null (unknown) whenever the console is not + /// connected or the read fails - the outage verdict never depends on it, it only lets the + /// notification say "the console reports the link down" instead of describing an outage. + /// + private async Task?> TryGetConsoleLinkStatesAsync(CancellationToken ct) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var pathView = scope.ServiceProvider.GetRequiredService(); + var wans = await pathView.GetWansAsync(ct); + return wans.ToDictionary( + w => GatewayWanHelper.WanInterfaceKeyFromKey(w.WanInterface), + w => w.Up, + StringComparer.OrdinalIgnoreCase); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "WAN link state unavailable for outage context; continuing without it"); + return null; + } + } + + private static string KeyFromGroup(string wanNetworkgroup) => + GatewayWanHelper.WanInterfaceKeyFromKey(wanNetworkgroup.ToLowerInvariant()); +} diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/WanOutageEvaluator.cs b/src/NetworkOptimizer.Web/Services/Monitoring/WanOutageEvaluator.cs new file mode 100644 index 0000000000..183b9eef8d --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/WanOutageEvaluator.cs @@ -0,0 +1,521 @@ +using System.Collections.Concurrent; +using System.Globalization; +using NetworkOptimizer.Alerts.Events; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.UniFi; + +namespace NetworkOptimizer.Web.Services.Monitoring; + +/// +/// Turns per-target failure states into per-WAN outage alerts. The WAN-facing target categories +/// (access ISP, transit, internet, legacy WAN) are not independent - one access-layer outage +/// takes them all out at once - so instead of a pile of per-target notifications this publishes +/// one alert per WAN, classified by shape: monitoring.wan_outage (the connection is down), +/// monitoring.wan_outage_partial (part of the path beyond the access layer, while the WAN +/// still passes traffic), and monitoring.wan_recovered (closes either). One open alert per +/// (WAN, kind); a partial that becomes total is superseded, never stacked; when every WAN of a +/// multi-WAN site is out in the same evaluation, one site-level rollup replaces the per-WAN pile. +/// +/// Fed by , whose per-target offline state machine keeps +/// running underneath for every category (it just stops publishing per-target events for the WAN +/// categories). Evaluation is a throttled whole-site pass over the current target states, and a +/// verdict must hold across consecutive passes to open - the per-target +/// FailuresToDeclareOffline idea applied to the WAN - and to close, so a flapping WAN produces +/// one alert and one recovery. State is in memory only, rebuilt from live probe results after a +/// restart; an outage spanning a restart re-opens its alert once, which is the accepted cost of +/// never persisting outage state that could go stale against a network that has recovered. +/// +public class WanOutageEvaluator +{ + /// AlertEvent.DeviceId of the site-level all-WANs rollup alert. + internal const string RollupDeviceId = "all-wans"; + + // Tuned to beat the console's own WAN-down push (~60-120 s): a target counts as failing + // for WAN verdicts after two failed probes (each probe is multi-ping, and no WAN verdict + // ever rests on one target - a total needs the whole cohort failing at once, which is the + // real flap suppression), passes run at probe cadence, and two held passes open. Closing + // stays at three so a flapping WAN still produces one alert and one recovery rather than + // a stream. Net budget: ~25-40 s from first lost packet to alert. The per-target machine's + // own 3-strikes threshold (Fabric/Custom alerts) is deliberately untouched. + private const int EvaluationIntervalSeconds = 10; + private const int ConfirmsToOpen = 2; + private const int ConfirmsToClose = 3; + private const int FailedProbesToCountFailing = 2; + private const int TargetStalenessSeconds = 180; + + /// How far apart the WANs' total-outage confirmations may sit and still read as one site outage. + private const int RollupWindowSeconds = 90; + private const int ContextTtlSeconds = 300; + + private readonly IAlertEventBus _eventBus; + private readonly ILogger _logger; + private readonly WanOutageContextSource _contextSource; + private readonly TimeProvider _time; + private readonly string _siteSlug; + private readonly string _siteSuffix; + + private readonly ConcurrentDictionary _targets = new(); + private readonly SemaphoreSlim _passGate = new(1, 1); + private readonly Dictionary _wanStates = new(StringComparer.OrdinalIgnoreCase); + private WanOutageContext _context = WanOutageContext.Empty; + private DateTime _contextLoadedUtc = DateTime.MinValue; + private DateTime _lastPassUtc = DateTime.MinValue; + private bool _rollupOpen; + private DateTime? _rollupSince; + + /// + /// Site this instance evaluates for (one instance per site, owned by + /// , same pattern as ). + /// + public WanOutageEvaluator(IAlertEventBus eventBus, ILogger logger, + WanOutageContextSource contextSource, + string siteSlug = SiteManagementService.DefaultSiteSlug, + TimeProvider? timeProvider = null) + { + _eventBus = eventBus; + _logger = logger; + _contextSource = contextSource; + _time = timeProvider ?? TimeProvider.System; + _siteSlug = string.IsNullOrEmpty(siteSlug) ? SiteManagementService.DefaultSiteSlug : siteSlug; + _siteSuffix = _siteSlug == SiteManagementService.DefaultSiteSlug ? "" : $" (site {_siteSlug})"; + } + + /// Whether a target type is alerted per WAN here rather than per target. + public static bool CoversTargetType(MonitoringTargetType type) => type is + MonitoringTargetType.Wan or + MonitoringTargetType.AccessIsp or + MonitoringTargetType.Transit or + MonitoringTargetType.InternetService; + + /// + /// Records a WAN-scoped target's current per-target state machine verdict. Called by + /// on every probe result for a covered target, + /// from both the local collection loop and the agent result sink. + /// + internal void RecordTargetState(MonitoringTarget target, bool isOffline, bool isLossy, int consecutiveFailures) + { + if (!CoversTargetType(target.TargetType)) return; + var state = _targets.GetOrAdd(target.TargetId, _ => new TargetLiveState()); + state.Target = target; + state.Offline = isOffline || consecutiveFailures >= FailedProbesToCountFailing; + state.Lossy = isLossy; + state.LastResultUtc = _time.GetUtcNow().UtcDateTime; + } + + /// + /// Runs one whole-site evaluation pass when due. Throttled to one pass per + /// and single-flight, so the per-probe call sites + /// can invoke it unconditionally. + /// + internal async ValueTask EvaluateAsync(CancellationToken ct = default) + { + var now = _time.GetUtcNow().UtcDateTime; + if (now - _lastPassUtc < TimeSpan.FromSeconds(EvaluationIntervalSeconds)) return; + if (!await _passGate.WaitAsync(0, ct)) return; + try + { + if (now - _lastPassUtc < TimeSpan.FromSeconds(EvaluationIntervalSeconds)) return; + _lastPassUtc = now; + await RunPassAsync(now, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "WAN outage evaluation pass failed for site {Site}", _siteSlug); + } + finally + { + _passGate.Release(); + } + } + + private async Task RunPassAsync(DateTime now, CancellationToken ct) + { + // Group fresh target states by WAN. A target with no recent result says nothing about + // the WAN: when probing stops entirely (agent disconnected, monitoring off), every + // target goes stale and no verdict is reached - a monitoring gap is not an outage, + // and not a recovery either. + var staleness = TimeSpan.FromSeconds(TargetStalenessSeconds); + var fresh = _targets.Values + .Where(t => now - t.LastResultUtc <= staleness) + .ToList(); + + await RefreshContextAsync(now, fresh, ct); + + var byWan = fresh + .GroupBy(t => string.IsNullOrEmpty(t.Target.WanInterface) + ? _context.PrimaryWanKey + : GatewayWanHelper.WanInterfaceKeyFromKey(t.Target.WanInterface!), + StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); + + // Verdict and confirmation counting per WAN. + var confirmedThisPass = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (wanKey, targets) in byWan) + { + var verdict = WanOutageClassifier.Classify(targets.Select(ToSnapshot).ToList()); + var state = GetWanState(wanKey); + if (verdict.Kind == state.PendingKind) state.PendingCount++; + else + { + state.PendingKind = verdict.Kind; + state.PendingCount = 1; + } + if (verdict.Kind != WanVerdictKind.None && state.EpisodeStart == null) + state.EpisodeStart = now; + state.LastVerdict = verdict; + + var needed = verdict.Kind == WanVerdictKind.None ? ConfirmsToClose : ConfirmsToOpen; + if (state.PendingCount >= needed) confirmedThisPass[wanKey] = verdict.Kind; + + // When this WAN's total outage was first confirmed, for the rollup's window. Cleared + // the moment it is no longer verdicted total, so a recovered WAN cannot hold a stale + // confirmation and let a later rollup claim the site was wholly down. + if (verdict.Kind == WanVerdictKind.Total && state.PendingCount >= ConfirmsToOpen) + state.TotalConfirmedAt ??= now; + else if (verdict.Kind != WanVerdictKind.Total) + state.TotalConfirmedAt = null; + } + + // Site rollup: every WAN of a multi-WAN site down together collapses into ONE site-level + // Critical - N notifications for one event is the spam this alert class exists to remove. + // "Together" is a window rather than a single evaluation: WANs are polled at their own + // intervals (10 s on one, 60 s on another is ordinary), so a site that loses everything + // at once still confirms its WANs a minute apart, and a same-pass test would almost never + // fire. A WAN that already opened its own alert is folded in - the rollup event resolves + // the per-WAN alerts it supersedes - so the worst case is one alert then the rollup, + // rather than one per WAN. + var totalEverywhere = byWan.Keys.All(k => GetWanState(k).TotalConfirmedAt != null); + if (!_rollupOpen + && byWan.Count >= 2 + && totalEverywhere + && now - byWan.Keys.Min(k => GetWanState(k).TotalConfirmedAt!.Value) + <= TimeSpan.FromSeconds(RollupWindowSeconds)) + { + _rollupOpen = true; + _rollupSince = byWan.Keys.Select(k => GetWanState(k).EpisodeStart).Min() ?? now; + foreach (var k in byWan.Keys) + { + var s = GetWanState(k); + s.CoveredByRollup = true; + s.OpenKind = WanVerdictKind.None; + } + await _eventBus.PublishAsync(BuildRollupEvent(byWan.Keys.ToList(), now), ct); + return; + } + + foreach (var (wanKey, kind) in confirmedThisPass) + { + var state = GetWanState(wanKey); + var info = WanInfo(wanKey); + switch (kind) + { + case WanVerdictKind.Total when !state.CoveredByRollup && state.OpenKind != WanVerdictKind.Total: + // Opens fresh, or supersedes an open partial: publishing the total closes + // the partial downstream (AlertProcessingService resolves it), so the two + // never stack. + state.OpenKind = WanVerdictKind.Total; + await _eventBus.PublishAsync(BuildOutageEvent(info, state, now), ct); + break; + + case WanVerdictKind.Partial when !state.CoveredByRollup && state.OpenKind == WanVerdictKind.None: + // A partial never downgrades an open total; the total stays open until + // recovery closes it. + state.OpenKind = WanVerdictKind.Partial; + await _eventBus.PublishAsync(BuildOutageEvent(info, state, now), ct); + break; + + case WanVerdictKind.None: + await HandleRecoveryAsync(wanKey, state, info, now, ct); + break; + } + } + } + + /// + /// Recovery confirmed for one WAN. Under an open rollup the first recovery closes the rollup + /// (its "every WAN is out" premise is gone) and any still-out WAN opens its own alert, so + /// the picture goes back to per-WAN as soon as the WANs differ again. + /// + private async Task HandleRecoveryAsync(string wanKey, WanState state, WanOutageWanInfo info, + DateTime now, CancellationToken ct) + { + if (state.CoveredByRollup && _rollupOpen) + { + _rollupOpen = false; + state.CoveredByRollup = false; + await _eventBus.PublishAsync(BuildRecoveredEvent(info, state, now), ct); + state.EpisodeStart = null; + foreach (var (otherKey, other) in _wanStates) + { + if (!other.CoveredByRollup) continue; + other.CoveredByRollup = false; + var otherKind = other.LastVerdict?.Kind ?? WanVerdictKind.None; + if (otherKind == WanVerdictKind.None) + { + // Recovering alongside this WAN (possibly in the very same pass): the + // rollup's close already says the site is back, so nothing reopens and + // its own None-confirmation has nothing left to announce. + other.OpenKind = WanVerdictKind.None; + other.EpisodeStart = null; + continue; + } + other.OpenKind = otherKind; + await _eventBus.PublishAsync(BuildOutageEvent(WanInfo(otherKey), other, now), ct); + } + _rollupSince = null; + return; + } + + if (state.OpenKind != WanVerdictKind.None) + { + state.OpenKind = WanVerdictKind.None; + await _eventBus.PublishAsync(BuildRecoveredEvent(info, state, now), ct); + } + state.EpisodeStart = null; + } + + private async Task RefreshContextAsync(DateTime now, List fresh, CancellationToken ct) + { + if (now - _contextLoadedUtc < TimeSpan.FromSeconds(ContextTtlSeconds)) return; + try + { + var wanKeys = fresh + .Select(t => t.Target.WanInterface) + .Where(w => !string.IsNullOrEmpty(w)) + .Select(w => w!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + _context = await _contextSource.LoadAsync(_siteSlug, wanKeys, ct); + _contextLoadedUtc = now; + } + catch (Exception ex) + { + // Keep the previous snapshot: classification still works from the targets alone, + // it just loses trace-map attribution until the next successful load. + _contextLoadedUtc = now; + _logger.LogDebug(ex, "WAN outage context load failed for site {Site}; keeping previous snapshot", _siteSlug); + } + } + + private WanTargetSnapshot ToSnapshot(TargetLiveState t) + { + var hop = _context.HopsByTargetId.TryGetValue(t.Target.TargetId, out var h) + ? h + : new WanOutageHopInfo(int.MaxValue, new HashSet()); + return new WanTargetSnapshot( + t.Target.TargetId, + t.Target.TargetType, + t.Target.Name, + t.Target.Address, + Failing: t.Offline, + Degraded: t.Offline || t.Lossy, + Depth: hop.Depth, + KnownPosition: hop.Depth != int.MaxValue, + IsInternet: t.Target.TargetType is MonitoringTargetType.InternetService or MonitoringTargetType.Wan, + AsnLabel: string.IsNullOrEmpty(t.Target.AsnName) ? null : t.Target.AsnName, + AsnNumber: t.Target.AsnNumber ?? 0, + AncestorIps: hop.AncestorIps); + } + + private WanState GetWanState(string wanKey) + { + if (!_wanStates.TryGetValue(wanKey, out var state)) + _wanStates[wanKey] = state = new WanState(); + return state; + } + + private WanOutageWanInfo WanInfo(string wanKey) => + _context.Wans.TryGetValue(wanKey, out var info) + ? info + : new WanOutageWanInfo(wanKey, + GatewayWanHelper.FormatWanLabel(null, GatewayWanHelper.WanIndexFromKey(wanKey), null, null), + TreatAsPrimary: true, CarriesTraffic: true, ConsoleUp: null); + + private AlertEvent BuildOutageEvent(WanOutageWanInfo info, WanState state, DateTime now) + { + var verdict = state.LastVerdict!; + var total = verdict.Kind == WanVerdictKind.Total; + var duration = Humanize(now - (state.EpisodeStart ?? now)); + var breakAt = total + ? verdict.LastReachableHop ?? verdict.BrokenNetwork + : verdict.BranchLabel; + + string message; + if (total && verdict.AccessDown && info.ConsoleUp == false) + message = $"The console reports the {info.Label} link down. All {verdict.TotalCount} monitored targets on it have been failing for {duration}."; + else if (total && verdict.AccessDown) + message = $"All {verdict.TotalCount} monitored targets on {info.Label} have been failing for {duration}, including your ISP's first hop. This looks like the connection itself."; + else if (total && verdict.LastReachableHop == null && verdict.BrokenNetwork == null) + message = $"All {verdict.TotalCount} monitored targets on {info.Label} have been failing for {duration}. This looks like the connection itself."; + else if (total) + { + message = $"Your ISP's first hop on {info.Label} still answers, but the {verdict.FailingCount} targets beyond it have been failing for {duration}."; + if (verdict.LastReachableHop != null) + message += $" The path is fine up to {verdict.LastReachableHop}; past that, nothing responds."; + else if (verdict.BrokenNetwork != null) + message += $" The break looks like it sits in {verdict.BrokenNetwork}."; + } + // No reassurance about the connection itself: a partial is often a total still arriving, + // with the rest of the targets a probe cycle behind. State the evidence, not a verdict + // on the WAN that the next evaluation may overturn. + else if (verdict.BranchLabel != null) + message = $"{verdict.FailingCount} of {verdict.TotalCount} monitored targets on {info.Label} have been failing or degraded for {duration}, all behind {verdict.BranchLabel}. Other destinations are still reachable."; + else + message = $"{verdict.FailingCount} of {verdict.TotalCount} monitored targets on {info.Label} have been failing or degraded for {duration}, across unrelated networks."; + + return new AlertEvent + { + EventType = total ? "monitoring.wan_outage" : "monitoring.wan_outage_partial", + Source = "monitoring", + // Severity tracks user impact, not which link failed: a WAN that is carrying traffic + // (the primary, or any WAN on a load-balancing site) taking a total outage is a real + // service loss, while an idle failover backup dropping costs redundancy only. + Severity = total + ? info.CarriesTraffic ? AlertSeverity.Critical : AlertSeverity.Warning + : info.CarriesTraffic ? AlertSeverity.Warning : AlertSeverity.Info, + Title = total + ? $"Internet down on {info.Label}{_siteSuffix}" + : $"Partial internet outage on {info.Label}{_siteSuffix}", + Message = message, + DeviceId = info.WanKey, + DeviceName = info.Label, + // Opens on this WAN's own chart at the moment the outage started, rather than the + // tab's default view of now: by the time anyone follows the link the window that + // shows what happened has usually scrolled off the live view. + SourceUrl = WanSourceUrl(info.WanKey, total ? "AccessIsp" : "InternetService", + state.EpisodeStart ?? now), + Tags = ["monitoring", "wan-outage"], + Context = BuildContext(info, verdict, state.EpisodeStart, breakAt) + }; + } + + private AlertEvent BuildRecoveredEvent(WanOutageWanInfo info, WanState state, DateTime now) + { + var duration = Humanize(now - (state.EpisodeStart ?? now)); + return new AlertEvent + { + EventType = "monitoring.wan_recovered", + Source = "monitoring", + Severity = AlertSeverity.Info, + Title = $"{info.Label} is back{_siteSuffix}", + Message = $"Targets on {info.Label} are answering again. The outage lasted {duration}.", + DeviceId = info.WanKey, + DeviceName = info.Label, + // Parked at the START of the outage, not the recovery: what someone following a + // recovery wants to see is the episode that just ended. + SourceUrl = WanSourceUrl(info.WanKey, "AccessIsp", state.EpisodeStart ?? now), + Tags = ["monitoring", "wan-outage"], + Context = new Dictionary + { + ["wan"] = info.WanKey, + ["wan_label"] = info.Label, + ["verdict"] = "recovered", + ["since"] = (state.EpisodeStart ?? now).ToString("o", CultureInfo.InvariantCulture) + } + }; + } + + private AlertEvent BuildRollupEvent(IReadOnlyList wanKeys, DateTime now) + { + var labels = wanKeys.Select(k => WanInfo(k).Label).ToList(); + var failing = wanKeys.Sum(k => GetWanState(k).LastVerdict?.FailingCount ?? 0); + var totalTargets = wanKeys.Sum(k => GetWanState(k).LastVerdict?.TotalCount ?? 0); + var duration = Humanize(now - (_rollupSince ?? now)); + return new AlertEvent + { + EventType = "monitoring.wan_outage", + Source = "monitoring", + Severity = AlertSeverity.Critical, + Title = $"Internet down on all WANs{_siteSuffix}", + Message = $"Every WAN ({string.Join(", ", labels)}) has been failing all of its monitored targets for {duration}. The site looks offline.", + DeviceId = RollupDeviceId, + DeviceName = "All WANs", + // Every WAN is out, so this one spans them all rather than naming one. + SourceUrl = $"/monitoring?tab=performance&category=AccessIsp&at={new DateTimeOffset(DateTime.SpecifyKind(_rollupSince ?? now, DateTimeKind.Utc)).ToUnixTimeMilliseconds()}&wan={Services.Monitoring.LiveWanScope.AllWansToken}", + Tags = ["monitoring", "wan-outage"], + Context = new Dictionary + { + ["verdict"] = "all_wans_down", + ["targets_failing"] = failing.ToString(CultureInfo.InvariantCulture), + ["targets_total"] = totalTargets.ToString(CultureInfo.InvariantCulture), + ["since"] = (_rollupSince ?? now).ToString("o", CultureInfo.InvariantCulture) + } + }; + } + + private Dictionary BuildContext(WanOutageWanInfo info, WanVerdict verdict, + DateTime? since, string? breakAt) + { + var context = new Dictionary + { + ["wan"] = info.WanKey, + ["wan_label"] = info.Label, + ["verdict"] = verdict.Kind == WanVerdictKind.Total + ? verdict.AccessDown ? "access_down" : "upstream" + : verdict.BranchLabel != null ? "partial_branch" : "partial_independent", + ["targets_failing"] = verdict.FailingCount.ToString(CultureInfo.InvariantCulture), + ["targets_total"] = verdict.TotalCount.ToString(CultureInfo.InvariantCulture) + }; + if (breakAt != null) context["break_at"] = breakAt; + if (since != null) context["since"] = since.Value.ToString("o", CultureInfo.InvariantCulture); + return context; + } + + /// + /// The Network Performance chart, scoped to one WAN and parked at an instant. The analysis + /// page reads the category, the WAN and the timestamp from the link, so following an alert + /// lands on the evidence rather than on whatever the tab happens to show now. + /// + private static string WanSourceUrl(string wanKey, string category, DateTime at) + { + var ms = new DateTimeOffset(DateTime.SpecifyKind(at, DateTimeKind.Utc)).ToUnixTimeMilliseconds(); + return $"/monitoring?tab=performance&category={category}&at={ms}&wan={Uri.EscapeDataString(wanKey)}"; + } + + private static string Humanize(TimeSpan duration) + { + if (duration.TotalMinutes < 1) return "under a minute"; + if (duration.TotalHours < 1) + { + var minutes = (int)duration.TotalMinutes; + return minutes == 1 ? "1 minute" : $"{minutes} minutes"; + } + var hours = (int)duration.TotalHours; + var rest = (int)duration.Subtract(TimeSpan.FromHours(hours)).TotalMinutes; + var hourPart = hours == 1 ? "1 hour" : $"{hours} hours"; + return rest == 0 ? hourPart : $"{hourPart} {rest} minutes"; + } + + private sealed class TargetLiveState + { + public MonitoringTarget Target = null!; + public bool Offline; + public bool Lossy; + public DateTime LastResultUtc; + } + + private sealed class WanState + { + /// Verdict kind observed on recent passes, awaiting confirmation. + public WanVerdictKind PendingKind; + + /// Consecutive passes the pending kind has held. + public int PendingCount; + + /// When the current outage episode was first observed (pre-confirmation), for the notification body. + public DateTime? EpisodeStart; + + /// Which alert is currently open for this WAN, so a partial is superseded rather than stacked. + public WanVerdictKind OpenKind; + + /// Whether this WAN's outage is represented by the site-level rollup alert. + public bool CoveredByRollup; + + /// When this WAN's total outage was confirmed, for the site rollup's window. + public DateTime? TotalConfirmedAt; + + /// The most recent classification, carried into the event bodies. + public WanVerdict? LastVerdict; + } +} diff --git a/src/NetworkOptimizer.Web/Services/MonitoringAlertRegistry.cs b/src/NetworkOptimizer.Web/Services/MonitoringAlertRegistry.cs index 491c5b0499..b77900a949 100644 --- a/src/NetworkOptimizer.Web/Services/MonitoringAlertRegistry.cs +++ b/src/NetworkOptimizer.Web/Services/MonitoringAlertRegistry.cs @@ -26,6 +26,7 @@ public sealed record SiteAlertEvaluators( CableModemAlertEvaluator CableModem, OntAlertEvaluator Ont, CellularAlertEvaluator Cellular, + StarlinkAlertEvaluator Starlink, DeviceRebootAlertEvaluator DeviceReboot, DeviceStateAlertEvaluator DeviceState); @@ -44,13 +45,18 @@ public SiteAlertEvaluators GetFor(string slug) => // Wrap the shared bus so every event these per-site evaluators publish is // stamped with this site's slug, routing it to the site's rules and channels. var bus = new SiteAlertEventBus(_serviceProvider.GetRequiredService(), s); + // The WAN outage evaluator is fed by the target evaluator (which suppresses + // per-target events for the WAN categories in its favor), so it is created first + // and handed in rather than exposed on the bundle. + var wanOutages = ActivatorUtilities.CreateInstance(_serviceProvider, s, bus); return new SiteAlertEvaluators( - ActivatorUtilities.CreateInstance(_serviceProvider, s, bus), + ActivatorUtilities.CreateInstance(_serviceProvider, s, bus, wanOutages), ActivatorUtilities.CreateInstance(_serviceProvider, s, bus), ActivatorUtilities.CreateInstance(_serviceProvider, s, bus), ActivatorUtilities.CreateInstance(_serviceProvider, s, bus), ActivatorUtilities.CreateInstance(_serviceProvider, s, bus), ActivatorUtilities.CreateInstance(_serviceProvider, s, bus), + ActivatorUtilities.CreateInstance(_serviceProvider, s, bus), ActivatorUtilities.CreateInstance(_serviceProvider, s, bus), ActivatorUtilities.CreateInstance(_serviceProvider, s, bus)); }); diff --git a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs index 27a5035c60..cd3254fbfc 100644 --- a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs +++ b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs @@ -71,6 +71,7 @@ public class MonitoringCollectionAgent : BackgroundService // Counter delta cache for server-side rate computation. Key = "deviceMac/ifName". private readonly ConcurrentDictionary _counterCache = new(); + private bool _fabricSeeded; // Per-target last-probed time, for per-target poll intervals on a shared loop. private readonly ConcurrentDictionary _targetLastProbed = new(); @@ -124,7 +125,7 @@ public class MonitoringCollectionAgent : BackgroundService /// from inside instead - and on the default site too once it is configured for its agent to /// cover it. A status display must not claim the server is collecting where it is not. /// - public bool ServerProbesThisSite => _isDefault && !AgentCoversCollection(); + public bool ServerProbesThisSite => _isDefault && !AgentOwnsProbing(); /// /// Lets the Setup page's interactive re-check override the cached self-heal sighting @@ -206,6 +207,14 @@ private async Task CreateSiteDbAsync(CancellationToke /// cover it: a default-site agent is an ADDITIONAL vantage point by default, not a replacement /// for local collection, and that is what installs using one today rely on. /// + /// + /// Whether the agent owns this site's probing. Configuration only - unlike + /// , an agent that is merely offline does NOT hand probing + /// back to this server, because a probe from here measures a different path and would be + /// recorded as this site's. + /// + private bool AgentOwnsProbing() => _agentCoverage.AgentOwnsPathMeasurement(_siteSlug); + private bool AgentCoversCollection() { var agentPresent = _tunnelRegistry.GetForSite(_siteSlug).Count > 0 || _siteAgentEnrolled; @@ -450,6 +459,19 @@ private async Task FastTierCollectAsync(MonitoringSettings settings, Cancellatio // port the AP is plugged into (spec 5.6). _fabric.UpdateUnifiPortRates(devices, DateTime.UtcNow); + // First cycle after a start: show the last figures we recorded rather than a dash. + // Rates are derived from consecutive SNMP counter reads, and that cache is in memory, so a + // restart cannot produce one until a device has been polled TWICE - and none of that + // begins until the console (on an agent site, the console THROUGH the tunnel) has named + // the devices. Until then the fabric tiles read "-" though the data is sitting in Influx. + // Seeding from it is the same trick the flow map already uses for per-port rates; the + // live path overwrites each device the moment its own second poll lands. + if (!_fabricSeeded) + { + _fabricSeeded = true; + await SeedFabricSumsAsync(devices, ct); + } + // Resolve the gateway LAN IP once per cycle so the SNMP poll targets the // LAN-side address (which actually answers) instead of UniFi's reported WAN // public IP for the gateway (which never will). @@ -740,6 +762,60 @@ private async Task MaybeSelfHealSnmpAsync( private readonly LanFabricAggregator _fabric = new(); + + /// + /// Fills the live fabric totals from the most recent readings in InfluxDB so a restart does + /// not blank them until two fresh SNMP polls have happened. Types match what the live path + /// records for - switches, gateways and cellular modems - so an AP cannot inflate the seeded + /// total any more than it can the live one. + /// + private async Task SeedFabricSumsAsync(IReadOnlyList devices, CancellationToken ct) + { + if (!_influx.IsConfigured) return; + var until = DateTime.UtcNow; + var from = until - TimeSpan.FromMinutes(2); + + foreach (var device in devices) + { + if (string.IsNullOrEmpty(device.Mac)) continue; + if (device.DeviceType != NetworkOptimizer.Core.Enums.DeviceType.Switch + && device.DeviceType != NetworkOptimizer.Core.Enums.DeviceType.Gateway + && device.DeviceType != NetworkOptimizer.Core.Enums.DeviceType.CellularModem) + continue; + try + { + using var queryCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + queryCts.CancelAfter(TimeSpan.FromSeconds(5)); + var points = await _influx.QueryInterfaceRatesAsync( + NormalizeMac(device.Mac), from, until, null, queryCts.Token); + if (points.Count == 0) continue; + + // One reading per interface - the newest - then summed, which is how the live + // path builds the same figure from a single poll's interfaces. + double inBps = 0, outBps = 0; + DateTime stamp = default; + foreach (var per in points.GroupBy(p => p.IfName, StringComparer.OrdinalIgnoreCase)) + { + var latest = per.OrderByDescending(p => p.Time).First(); + inBps += latest.RateInBps ?? 0; + outBps += latest.RateOutBps ?? 0; + if (latest.Time > stamp) stamp = latest.Time; + } + if (stamp == default) continue; + _liveStats.RecordFabricSum(NormalizeMac(device.Mac), inBps, outBps, stamp); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // A slow Influx must not hold up the poll cycle that is about to replace this. + _logger.LogDebug("Fabric seed timed out for {Device}", device.Mac); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Fabric seed failed for {Device}", device.Mac); + } + } + } + private async Task MediumTierCollectAsync(MonitoringSettings settings, CancellationToken ct) { // Before anything filtered: device-state alerting needs EVERY adopted device, offline ones @@ -1434,7 +1510,7 @@ private async Task LatencyTierCollectAsync(MonitoringSettings settings, Cancella // log its own anycast RTT as the site's ISP latency. The site's agent probes its enabled // targets from inside once deployed (AgentProbeResultSink). The default site keeps probing // locally unless it too is covered by its agent, which is the off-site-server case. - if (!_isDefault || AgentCoversCollection()) return; + if (!_isDefault || AgentOwnsProbing()) return; await using var db = await CreateSiteDbAsync(ct); var targets = await db.MonitoringTargets @@ -1510,7 +1586,7 @@ await _influx.WriteLatencyAsync( sent: ping.Sent, received: ping.Received, timestamp: ping.Timestamp, - wanContext: wanContext?.Name); + wanContext: wanContext?.InfluxWanTag); // Surface fabric probe results on the dashboard's device cards (5.6). Other // target types (WAN, transit) feed cloud nodes on the 3D map; the per-device diff --git a/src/NetworkOptimizer.Web/Services/MonitoringLiveStats.cs b/src/NetworkOptimizer.Web/Services/MonitoringLiveStats.cs index 28ba97975b..3f660df35b 100644 --- a/src/NetworkOptimizer.Web/Services/MonitoringLiveStats.cs +++ b/src/NetworkOptimizer.Web/Services/MonitoringLiveStats.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using NetworkOptimizer.Storage.Models; using NetworkOptimizer.Storage.Services; +using NetworkOptimizer.UniFi; using NetworkOptimizer.Web.Services.Monitoring; namespace NetworkOptimizer.Web.Services; @@ -20,9 +21,16 @@ public class MonitoringLiveStats private readonly ILogger _logger; private readonly IDbContextFactory _dbFactory; - private List<(string TargetId, MonitoringTargetType TargetType)>? _ispTransitTargets; + private List<(string TargetId, MonitoringTargetType TargetType, string? WanInterface)>? _ispTransitTargets; private DateTime _ispTransitTargetsCacheTime; private static readonly TimeSpan TargetCacheTtl = TimeSpan.FromSeconds(30); + + /// + /// How old a target's last probe reading may be and still be plotted as live. Half again the + /// slowest poll interval a target can be given (60 s), so one missed cycle rides through and + /// the next expires the reading rather than letting it stand in for a current one. + /// + public static readonly TimeSpan LiveReadingMaxAge = TimeSpan.FromSeconds(90); private readonly Lock _targetCacheLock = new(); private readonly SiteDbContextFactory? _siteDbFactory; @@ -332,7 +340,7 @@ public void RecordTargetProbe(string targetId, double? rttAvgMs, double lossPerc } /// Cached list of enabled ISP+Transit monitoring targets. Refreshed every 30s. - public async Task> GetIspTransitTargetsAsync( + public async Task> GetIspTransitTargetsAsync( CancellationToken ct = default) { lock (_targetCacheLock) @@ -347,10 +355,10 @@ public void RecordTargetProbe(string targetId, double? rttAvgMs, double lossPerc && (t.TargetType == MonitoringTargetType.AccessIsp || t.TargetType == MonitoringTargetType.Transit) && (t.AsnNumber == null || !WellKnownAsns.NonTransitInfrastructure.Contains(t.AsnNumber.Value))) - .Select(t => new { t.TargetId, t.TargetType }) + .Select(t => new { t.TargetId, t.TargetType, t.WanInterface }) .ToListAsync(ct); - var result = targets.Select(t => (t.TargetId, t.TargetType)).ToList(); + var result = targets.Select(t => (t.TargetId, t.TargetType, t.WanInterface)).ToList(); lock (_targetCacheLock) { _ispTransitTargets = result; @@ -368,20 +376,51 @@ public void RecordTargetProbe(string targetId, double? rttAvgMs, double lossPerc /// blanked the chart exactly when loss mattered most. Shared by the live-stats /// endpoint and the LAN flow map WAN globes so both always show the same number. /// - public async Task<(double? MeanRttMs, double MeanLossPercent)> GetMeanIspTransitLiveAsync( - CancellationToken ct = default) + /// + /// Scope to one WAN's targets. Null keeps the site-wide mean, which is what every caller meant + /// before there was more than one WAN to tell apart. An unstamped target belongs to the + /// primary - the same rule every per-WAN reader uses - so a secondary WAN with no targets of + /// its own returns nothing rather than borrowing the primary's numbers and presenting them as + /// its own. + /// + public async Task<(double? MeanRttMs, double? MeanLossPercent)> GetMeanIspTransitLiveAsync( + CancellationToken ct = default, + string? wanInterface = null, + bool isPrimary = false) { var targets = await GetIspTransitTargetsAsync(ct); + // No WAN named means the primary, not every WAN. A chart showing one WAN asks for it by + // omitting the parameter, and skipping the filter entirely averaged in the other WANs' + // targets - a speed test on a secondary WAN then appeared as a latency and loss spike on + // the primary's chart, from readings that were never on its path. Unchanged on a + // single-WAN site, where every target is the primary's already. + var key = string.IsNullOrEmpty(wanInterface) + ? GatewayWanHelper.DefaultWanKey + : GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface!); + var primaryScope = isPrimary || string.IsNullOrEmpty(wanInterface); + targets = targets.Where(t => string.IsNullOrEmpty(t.WanInterface) + ? primaryScope + : string.Equals(GatewayWanHelper.WanInterfaceKeyFromKey(t.WanInterface!), key, + StringComparison.OrdinalIgnoreCase)) + .ToList(); var ispRtts = new List(); var ispLosses = new List(); var transitRtts = new List(); var transitLosses = new List(); + // A reading is only evidence while it is current. A target that stops reporting - which is + // exactly what some failures look like, rather than a reported 100% loss - otherwise keeps + // presenting its last good reading forever, and the card reads healthy through an outage. + // Observed on a WAN whose ISP targets went quiet under a blackhole while its transit + // targets kept reporting: transit showed the true 100% loss, the ISP rows showed the RTT + // and 0% loss they had carried before it started. + var stale = DateTime.UtcNow - LiveReadingMaxAge; + foreach (var t in targets) { var st = GetTargetStats(t.TargetId); - if (st == null) continue; + if (st == null || st.LastUpdate < stale) continue; if (t.TargetType == MonitoringTargetType.AccessIsp) { @@ -396,20 +435,21 @@ public void RecordTargetProbe(string targetId, double? rttAvgMs, double lossPerc } var ispRtt = ispRtts.Count > 0 ? ispRtts.Average() : (double?)null; - var ispLoss = ispLosses.Count > 0 ? ispLosses.Average() : 0.0; + var ispLoss = ispLosses.Count > 0 ? ispLosses.Average() : (double?)null; var transitRtt = transitRtts.Count > 0 ? transitRtts.Average() : (double?)null; - var transitLoss = transitLosses.Count > 0 ? transitLosses.Average() : 0.0; + var transitLoss = transitLosses.Count > 0 ? transitLosses.Average() : (double?)null; double? meanRtt; if (ispRtt != null && transitRtt != null) meanRtt = (ispRtt.Value + transitRtt.Value) / 2; else meanRtt = ispRtt ?? transitRtt; - double meanLoss = 0; - if (ispLosses.Count > 0 && transitLosses.Count > 0) - meanLoss = (ispLoss + transitLoss) / 2; - else if (ispLosses.Count > 0) meanLoss = ispLoss; - else if (transitLosses.Count > 0) meanLoss = transitLoss; + // Null, never zero, when nothing fresh reported: no reading is not the same claim as no + // loss, and the zero read as a healthy connection during an outage. + double? meanLoss; + if (ispLoss != null && transitLoss != null) + meanLoss = (ispLoss.Value + transitLoss.Value) / 2; + else meanLoss = ispLoss ?? transitLoss; return (meanRtt, meanLoss); } diff --git a/src/NetworkOptimizer.Web/Services/MonitoringTargetService.cs b/src/NetworkOptimizer.Web/Services/MonitoringTargetService.cs index c1493ace9d..648dd14da4 100644 --- a/src/NetworkOptimizer.Web/Services/MonitoringTargetService.cs +++ b/src/NetworkOptimizer.Web/Services/MonitoringTargetService.cs @@ -81,6 +81,18 @@ public async Task AddAsync(NewMonitoringTarget spec, Cancellat AsnName = asnName }; + // Same stamping the reassign path uses, so a target created against a WAN context carries + // both keys from its first poll: the context that routes the probe and the WAN the readings + // are filed under. + if (spec.WanContextId is int newContextId) + { + await using var contextDb = CreateDb(); + var context = await contextDb.WanContexts.FindAsync(new object?[] { newContextId }, ct); + if (context == null) + throw new MonitoringTargetValidationException("That WAN context no longer exists."); + Monitoring.WanContextTargetStamping.ApplyAssignment(entity, newContextId, context.WanInterface); + } + await using (var db = CreateDb()) { db.MonitoringTargets.Add(entity); @@ -95,7 +107,8 @@ public async Task AddAsync(NewMonitoringTarget spec, Cancellat probeMode = entity.ProbeMode.ToString(), entity.Port, entity.PollIntervalSeconds, - entity.AsnNumber + entity.AsnNumber, + entity.WanContextId }); // Trace-on-save: an Internet/Custom target only absolves the ISP/transit hops it crosses @@ -159,14 +172,26 @@ public Task DismissLanFlakyHintAsync(int id, CancellationToken ct = defaul }); /// - public Task SetWanContextAsync(int id, int? wanContextId, CancellationToken ct = default) => - UpdateAsync(id, ct, row => + public async Task SetWanContextAsync(int id, int? wanContextId, CancellationToken ct = default) + { + // The context's WAN rides along with the assignment: WanContextId routes the probes and + // WanInterface says which WAN the data describes, and every per-WAN reader scopes on the + // latter - an assignment that moved only the routing would keep grading the data under + // the old WAN. Moving back to the primary clears both (see WanContextTargetStamping). + string? contextWanInterface = null; + if (wanContextId is int contextId) + { + await using var db = CreateDb(); + contextWanInterface = (await db.WanContexts.FindAsync(new object?[] { contextId }, ct))?.WanInterface; + } + return await UpdateAsync(id, ct, row => { if (row.WanContextId == wanContextId) return null; var before = row.WanContextId; - row.WanContextId = wanContextId; + Monitoring.WanContextTargetStamping.ApplyAssignment(row, wanContextId, contextWanInterface); return new { field = "WanContextId", from = before, to = wanContextId }; }); + } /// /// Applies a single-field edit and records what actually changed. A mutate that returns null diff --git a/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs b/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs index 98c360c284..d0513c7df6 100644 --- a/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs +++ b/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs @@ -21,17 +21,21 @@ namespace NetworkOptimizer.Web.Services; /// enrolled (), so a flag set on a site with no /// agent changes nothing. /// -public class SiteAgentCoverage +public class SiteAgentCoverage : ISiteScopedRegistry { /// Per-site setting key: this site's agent collects, this server stands down. public const string AgentCoversSiteKey = "site.agent_covers_collection"; - // Consulted on collection paths that run every few seconds, so cache it briefly rather than - // hitting SQLite each time - same treatment as the via-agent routing flag. - private static readonly TimeSpan FlagCacheExpiry = TimeSpan.FromMinutes(1); - + // Consulted on collection paths that run every few seconds, so it is cached rather than hitting + // SQLite each time. Deliberately WITHOUT an expiry: every writer calls Invalidate, so a timed + // expiry bought nothing and cost a cold-miss window in which the synchronous reader below + // answers "not covered" for a site that is. On an off-site server that window means probes run + // from the wrong network and device dials go to RFC1918 addresses on the hosting provider's + // network instead of through the tunnel. The cache is warmed at startup for the same reason. + // The one thing this gives up is noticing a value changed in the database behind the app's + // back, which only an operator editing SQLite directly can do, and a restart settles that. private readonly IServiceProvider _serviceProvider; - private readonly ConcurrentDictionary _flags = new(); + private readonly ConcurrentDictionary _flags = new(); public SiteAgentCoverage(IServiceProvider serviceProvider) { @@ -42,21 +46,20 @@ public SiteAgentCoverage(IServiceProvider serviceProvider) public async Task CoversAsync(string slug) { if (string.IsNullOrEmpty(slug)) return false; - if (_flags.TryGetValue(slug, out var cached) && DateTime.UtcNow - cached.At < FlagCacheExpiry) - return cached.Enabled; + if (_flags.TryGetValue(slug, out var cached)) return cached; return await ReadAsync(slug); } /// - /// The cached answer, for the callers that cannot await - the probe executor factory resolves - /// a vantage from a synchronous property. A cache miss reads false and refreshes in the - /// background, so the worst case is one pass of today's behavior before the flag takes hold. + /// The cached answer, for the callers that cannot await - the probe executor factory resolves a + /// vantage from a synchronous property. The cache is warmed at startup and never expires, so a + /// miss here means a site created since startup, which has no flag set anyway. It still kicks a + /// read so the answer is right from the next pass. /// public bool Covers(string slug) { if (string.IsNullOrEmpty(slug)) return false; - if (_flags.TryGetValue(slug, out var cached) && DateTime.UtcNow - cached.At < FlagCacheExpiry) - return cached.Enabled; + if (_flags.TryGetValue(slug, out var cached)) return cached; _ = Task.Run(() => ReadAsync(slug)); return false; } @@ -64,6 +67,58 @@ public bool Covers(string slug) /// Drops the cached answer for a site, so the next read sees a change immediately. public void Invalidate(string slug) => _flags.TryRemove(slug, out _); + /// + /// Records a value the caller already knows, for a writer that has just stored it. + /// + /// Use this rather than whenever the new value is in hand. Invalidate + /// leaves a hole, and the synchronous reader answers "not covered" while it refills - so + /// switching coverage on and immediately reconnecting the console read false and connected on + /// the wrong path, with no banner to say so. There is no window here at all. + /// + public void Set(string slug, bool enabled) + { + if (!string.IsNullOrEmpty(slug)) _flags[slug] = enabled; + } + + /// + /// Swept with the per-site registries when a site is removed or created. The cached answer now + /// outlives the site that set it - there is no expiry to heal it - so a slug deleted and + /// re-created would otherwise inherit the previous site's coverage until the next restart. + /// Nothing to tear down: the entry is a bool. + /// + public Func? EvictSite(string slug) + { + Invalidate(slug); + return null; + } + + /// + /// Reads every site's flag once at startup. Without this the first pass of any synchronous + /// caller answers "not covered" while the cache fills, and on an off-site server that pass + /// probes from the wrong network and dials site addresses directly. + /// + public async Task WarmAsync(CancellationToken ct = default) + { + try + { + List slugs; + using (var scope = _serviceProvider.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + slugs = db.Sites.Select(x => x.Slug).ToList(); + } + foreach (var slug in slugs) + { + if (ct.IsCancellationRequested) return; + await ReadAsync(slug); + } + } + catch + { + // Best effort: a failure here leaves the old lazy behavior, not a broken start. + } + } + /// /// The question every gate actually asks: does the agent do this site's work rather than this /// server? A secondary site needs only an agent, which is what having one has always meant @@ -76,6 +131,25 @@ public bool Covers(string slug) public bool AgentCovers(string slug, bool agentPresent) => agentPresent && (slug != SiteManagementService.DefaultSiteSlug || Covers(slug)); + /// + /// Whether the site's agent owns PATH measurement for this site - latency and loss probes, and + /// upstream traceroutes. Configuration alone, deliberately without asking whether the agent is + /// connected right now. + /// + /// A probe measures the path FROM whoever runs it. If this server runs one for a site its agent + /// covers, the result describes this server's route rather than the site's, and it is stored + /// under the site's name either way. On an off-site server that is a different network + /// entirely. A probe that does not run leaves a gap; a probe run from the wrong place leaves a + /// wrong number that looks exactly like data - so this stands down on the configuration and + /// lets the probe fail while the agent is away. + /// + /// Contrast , which is the right question for reading device counters: + /// SNMP returns the device's own numbers whoever asks, so the server continuing while the agent + /// is down is a genuine fallback rather than a different measurement. + /// + public bool AgentOwnsPathMeasurement(string slug) + => slug != SiteManagementService.DefaultSiteSlug || Covers(slug); + /// public async Task AgentCoversAsync(string slug, bool agentPresent) => agentPresent && (slug != SiteManagementService.DefaultSiteSlug || await CoversAsync(slug)); @@ -89,7 +163,7 @@ private async Task ReadAsync(string slug) var db = scope.ServiceProvider.GetRequiredService(); var setting = await db.SystemSettings.FindAsync(AgentCoversSiteKey); var enabled = bool.TryParse(setting?.Value, out var value) && value; - _flags[slug] = (enabled, DateTime.UtcNow); + _flags[slug] = enabled; return enabled; } catch diff --git a/src/NetworkOptimizer.Web/Services/SiteTunnelRouting.cs b/src/NetworkOptimizer.Web/Services/SiteTunnelRouting.cs index fa16b6df38..b5987bc933 100644 --- a/src/NetworkOptimizer.Web/Services/SiteTunnelRouting.cs +++ b/src/NetworkOptimizer.Web/Services/SiteTunnelRouting.cs @@ -36,6 +36,13 @@ public SiteTunnelRouting(IServiceProvider serviceProvider, SiteAgentCoverage age _logger = logger; } + /// + /// Forget the cached flag for a site. Called when the flag is cleared out from under the cache + /// - removing a site's last agent - so routing stops within the request rather than after the + /// cache expires. + /// + public void Invalidate(string slug) => _flags.TryRemove(slug, out _); + /// Whether the site's devices are configured to be reached through its agent tunnel. public async Task IsViaAgentAsync(string slug) { diff --git a/src/NetworkOptimizer.Web/Services/SqmDeploymentService.cs b/src/NetworkOptimizer.Web/Services/SqmDeploymentService.cs index c93439e3d0..1f4637e218 100644 --- a/src/NetworkOptimizer.Web/Services/SqmDeploymentService.cs +++ b/src/NetworkOptimizer.Web/Services/SqmDeploymentService.cs @@ -1,10 +1,10 @@ using System.Text; +using NetworkOptimizer.Core.Helpers; using NetworkOptimizer.Sqm; using NetworkOptimizer.Sqm.Models; using NetworkOptimizer.Storage.Models; using NetworkOptimizer.Web.Services.Ssh; using SqmConfig = NetworkOptimizer.Sqm.Models.SqmConfiguration; -using NetworkOptimizer.Core.Helpers; namespace NetworkOptimizer.Web.Services; @@ -25,6 +25,15 @@ public class SqmDeploymentService : ISqmDeploymentService private const string OnBootDir = "/data/on_boot.d"; private const string SqmDir = "/data/sqm"; + // The boot script installs its dependencies inline on a first deploy: it adds the + // Ookla packagecloud repo (which runs its own apt-get update and fetches a GPG key), + // then apt-get installs speedtest, bc and jq. On a cold apt cache or a slow WAN that + // runs well past the 30 second default, so give it room rather than tearing down a + // deployment that is still working. Re-deploys skip the whole block and finish fast. + // Five minutes is comfortably clear of a slow first install without leaving the page + // sitting on a boot script that has genuinely wedged. + private static readonly TimeSpan BootScriptTimeout = TimeSpan.FromMinutes(5); + public SqmDeploymentService( ILogger logger, IGatewaySshService gatewaySsh, @@ -372,7 +381,7 @@ public async Task DeployAsync(SqmConfig config, Dictionary< result.Success = false; result.Error = $"IFB device {ifbDevice} does not exist. " + "Smart Queues is enabled but UniFi didn't actually create the traffic control classes - this is a known UniFi bug. " + - "To fix it, add any QoS rule in UniFi Network (Settings > Policy Table > QoS Rules). " + + "To fix it, add any QoS rule in UniFi Network (Settings > Policy Engine > Policy Table > QoS Rules). " + "It doesn't matter what the rule targets. Wait 45 seconds, then deploy again."; result.Steps = steps; _logger.LogWarning("SQM deployment blocked: IFB device {Device} not found on gateway", ifbDevice); @@ -410,7 +419,8 @@ public async Task DeployAsync(SqmConfig config, Dictionary< // Step 4: Run the boot script to set up everything steps.Add("Running boot script (installs deps, creates scripts, configures cron)..."); var setupResult = await RunCommandAsync( - $"chmod +x {OnBootDir}/{bootScriptName} && {OnBootDir}/{bootScriptName}"); + $"chmod +x {OnBootDir}/{bootScriptName} && {OnBootDir}/{bootScriptName}", + BootScriptTimeout); if (!setupResult.success) { diff --git a/src/NetworkOptimizer.Web/Services/SqmService.cs b/src/NetworkOptimizer.Web/Services/SqmService.cs index a18835ded1..dde9752f20 100644 --- a/src/NetworkOptimizer.Web/Services/SqmService.cs +++ b/src/NetworkOptimizer.Web/Services/SqmService.cs @@ -266,6 +266,11 @@ public async Task> GetWanInterfacesFromControllerAsync() .Where(w => !string.IsNullOrEmpty(w.WanNetworkgroup) && w.WanSmartqDownRate.HasValue) .ToDictionary(w => w.WanNetworkgroup!, w => w.WanSmartqDownRate!.Value / 1000, StringComparer.OrdinalIgnoreCase); + // Build lookup by wan_networkgroup for SmartQ upload rate (kbps -> Mbps) + var networkGroupToSmartqUpRate = wanConfigs + .Where(w => !string.IsNullOrEmpty(w.WanNetworkgroup) && w.WanSmartqUpRate.HasValue) + .ToDictionary(w => w.WanNetworkgroup!, w => w.WanSmartqUpRate!.Value / 1000, StringComparer.OrdinalIgnoreCase); + // Build lookup by wan_networkgroup for friendly name var networkGroupToName = wanConfigs .Where(w => !string.IsNullOrEmpty(w.WanNetworkgroup)) @@ -286,7 +291,7 @@ public async Task> GetWanInterfacesFromControllerAsync() _logger.LogDebug("Enabled WAN network groups (used to filter device WANs): [{Groups}]", enabledNetworkGroups.Count > 0 ? string.Join(", ", enabledNetworkGroups) : "none"); - result = ExtractWanInterfacesFromDeviceData(deviceJson, ipToName, networkGroupToSmartq, networkGroupToSmartqDownRate, networkGroupToName, networkGroupToWanType, enabledNetworkGroups); + result = ExtractWanInterfacesFromDeviceData(deviceJson, ipToName, networkGroupToSmartq, networkGroupToSmartqDownRate, networkGroupToSmartqUpRate, networkGroupToName, networkGroupToWanType, enabledNetworkGroups); _logger.LogInformation("WAN interface detection complete: {Count} interface(s) available for Adaptive SQM", result.Count); } @@ -309,6 +314,7 @@ private List ExtractWanInterfacesFromDeviceData( Dictionary ipToName, Dictionary networkGroupToSmartq, Dictionary networkGroupToSmartqDownRate, + Dictionary networkGroupToSmartqUpRate, Dictionary networkGroupToName, Dictionary networkGroupToWanType, HashSet enabledNetworkGroups) @@ -521,6 +527,14 @@ private List ExtractWanInterfacesFromDeviceData( smartqDownRateMbps = downRate; } + // Get Smart Queue upload rate (Mbps) if configured + int? smartqUpRateMbps = null; + if (!string.IsNullOrEmpty(networkGroup) && + networkGroupToSmartqUpRate.TryGetValue(networkGroup, out var upRate)) + { + smartqUpRateMbps = upRate; + } + // Get the actual WAN type from network config (dhcp, static, pppoe) var wanType = "dhcp"; // default if (!string.IsNullOrEmpty(networkGroup) && @@ -557,6 +571,7 @@ private List ExtractWanInterfacesFromDeviceData( SuggestedPingIp = suggestedPingIp, SmartqEnabled = smartqEnabled, SmartqDownRateMbps = smartqDownRateMbps, + SmartqUpRateMbps = smartqUpRateMbps, LinkSpeedMbps = linkSpeedMbps, WanIndex = i, PhysicalIfName = physicalIfname, @@ -769,6 +784,9 @@ public class WanInterfaceInfo /// Smart Queue download rate in Mbps (from UniFi config, converted from kbps) public int? SmartqDownRateMbps { get; set; } + /// Smart Queue upload rate in Mbps (from UniFi config, converted from kbps) + public int? SmartqUpRateMbps { get; set; } + /// Physical WAN port link speed in Mbps (e.g., 1000 for 1GbE, 2500 for 2.5GbE). Null if unknown (GRE tunnels, etc.) public int? LinkSpeedMbps { get; set; } diff --git a/src/NetworkOptimizer.Web/Services/Ssh/GatewayShaperProbe.cs b/src/NetworkOptimizer.Web/Services/Ssh/GatewayShaperProbe.cs new file mode 100644 index 0000000000..cb43817c1e --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Ssh/GatewayShaperProbe.cs @@ -0,0 +1,153 @@ +using System.Text; +using System.Text.RegularExpressions; +using NetworkOptimizer.Diagnostics.Models; + +namespace NetworkOptimizer.Web.Services.Ssh; + +/// +/// One WAN to read traffic control for: the names of its two shaper devices plus the rates UniFi +/// says it should be shaping at, which is what tells a missing shaper apart from a direction +/// UniFi was never asked to shape. +/// +/// The WAN's display name in UniFi Network. +/// Data-path interface - "eth6", "eth6.100", "ppp0". +/// The ingress companion - "ifb" plus the data-path name. +/// Configured Smart Queue download rate, if any. +/// Configured Smart Queue upload rate, if any. +public record ShaperProbeTarget( + string WanName, + string Interface, + string IfbInterface, + int? DownRateMbps, + int? UpRateMbps); + +/// +/// Builds and reads the gateway's traffic control readout for WANs with Smart Queues enabled. +/// Pure string work with no I/O, so the parsing is unit-testable against real gateway output. +/// +/// Every interface is asked in a single marker-separated command, the same shape +/// uses: SSH session setup dominates the cost, +/// so one round trip covers every WAN whatever the count. +/// +public static partial class GatewayShaperProbe +{ + /// Prefix of the line that introduces one interface's section. + public const string Marker = "###TC"; + + /// + /// The command reading every interface in one trip. Each section is introduced by + /// "###TC <interface>", stderr is folded into stdout so "Cannot find device" arrives as + /// section text rather than vanishing, and the chain ends on `true` so a non-zero exit from + /// the last tc call isn't reported as a failed SSH run. + /// + public static string BuildCommand(IEnumerable interfaces) + { + var command = new StringBuilder(); + foreach (var name in interfaces) + { + command.Append($"echo '{Marker} {name}'; tc class show dev {name} 2>&1; "); + } + command.Append("true"); + return command.ToString(); + } + + /// + /// Reads the command output into one state per target. A target whose sections did not both + /// come back is dropped rather than guessed at - a truncated readout must not read as a + /// missing shaper. + /// + public static List Parse(string output, IEnumerable targets) + { + var sections = SplitSections(output); + var states = new List(); + + foreach (var target in targets) + { + if (!sections.TryGetValue(target.Interface, out var egress) || + !sections.TryGetValue(target.IfbInterface, out var ingress)) + { + continue; + } + + states.Add(new WanShaperState + { + WanName = target.WanName, + Interface = target.Interface, + IfbInterface = target.IfbInterface, + DownRateMbps = target.DownRateMbps, + UpRateMbps = target.UpRateMbps, + Egress = ReadDevice(egress), + Ingress = ReadDevice(ingress) + }); + } + + return states; + } + + /// + /// Guards an interface name before it reaches the command line. Interface names are the only + /// caller-supplied part of the command and they come from the controller, so they are checked + /// rather than escaped. + /// + public static bool IsValidInterfaceName(string? name) => + !string.IsNullOrWhiteSpace(name) && InterfaceNamePattern().IsMatch(name); + + /// + /// What one section says about its device. An empty section is a real answer: an interface + /// with no shaper and no classful qdisc lists nothing at all. + /// + private static TcDeviceState ReadDevice(string section) + { + if (DeviceMissingPattern().IsMatch(section)) + return new TcDeviceState { DeviceFound = false, HasRootHtb = false }; + + return new TcDeviceState + { + DeviceFound = true, + HasRootHtb = RootHtbPattern().IsMatch(section) + }; + } + + private static Dictionary SplitSections(string output) + { + var sections = new Dictionary(StringComparer.Ordinal); + string? current = null; + var buffer = new List(); + + void Flush() + { + if (current != null) + sections[current] = string.Join("\n", buffer); + buffer.Clear(); + } + + foreach (var raw in (output ?? string.Empty).Split('\n')) + { + var line = raw.TrimEnd('\r'); + var trimmed = line.Trim(); + if (trimmed.StartsWith(Marker + " ", StringComparison.Ordinal)) + { + Flush(); + current = trimmed[(Marker.Length + 1)..].Trim(); + continue; + } + if (current != null) buffer.Add(line); + } + Flush(); + return sections; + } + + /// + /// The shaper actually running: "class htb 1:1 root rate 550Mbit ...". An interface left to + /// the kernel's own multiqueue shows "class mq :1 root" and matches nothing here. + /// + [GeneratedRegex(@"^\s*class\s+htb\s+\S+\s+root\b", RegexOptions.Multiline)] + private static partial Regex RootHtbPattern(); + + /// iproute2's wording when the device does not exist on the box. + [GeneratedRegex(@"Cannot find device|does not exist", RegexOptions.IgnoreCase)] + private static partial Regex DeviceMissingPattern(); + + [GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._-]{0,30}$")] + private static partial Regex InterfaceNamePattern(); +} diff --git a/src/NetworkOptimizer.Web/Services/Ssh/GatewayShaperProbeService.cs b/src/NetworkOptimizer.Web/Services/Ssh/GatewayShaperProbeService.cs new file mode 100644 index 0000000000..3dcebd12e5 --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Ssh/GatewayShaperProbeService.cs @@ -0,0 +1,117 @@ +using NetworkOptimizer.Diagnostics.Models; + +namespace NetworkOptimizer.Web.Services.Ssh; + +/// +/// Reads, over SSH, whether the gateway is actually shaping the WANs that have UniFi Smart Queues +/// turned on. UniFi Network regularly accepts the Smart Queues toggle without provisioning the +/// queues, and the only place that shows is the gateway's own traffic control - the controller +/// keeps reporting the feature as enabled. +/// +/// Every command is a read, and everything is asked in one round trip. Anything that makes the +/// answer unavailable - SSH off, no credentials, an offline agent tunnel, a failed command - +/// returns no states at all rather than a guess, so a site we cannot see is never accused of a +/// misconfiguration. +/// +public class GatewayShaperProbeService +{ + private readonly ISqmService _sqmService; + private readonly IGatewaySshService _gatewaySsh; + private readonly ILogger _logger; + + public GatewayShaperProbeService( + ISqmService sqmService, + IGatewaySshService gatewaySsh, + ILogger logger) + { + _sqmService = sqmService; + _gatewaySsh = gatewaySsh; + _logger = logger; + } + + /// + /// The shaper state of every WAN with Smart Queues enabled. Empty when there are none, or + /// when the gateway cannot be read. + /// + public async Task> RunAsync(CancellationToken ct = default) + { + var empty = new List(); + + try + { + var targets = await BuildTargetsAsync(); + if (targets.Count == 0) + return empty; + + var settings = await _gatewaySsh.GetSettingsAsync(); + if (!settings.Enabled || string.IsNullOrEmpty(settings.Host) || !settings.HasCredentials) + { + _logger.LogDebug("Skipping Smart Queues shaper probe: gateway SSH not available"); + return empty; + } + + if (await _gatewaySsh.IsAwaitingAgentTunnelAsync()) + { + _logger.LogDebug("Skipping Smart Queues shaper probe: waiting for the site's agent"); + return empty; + } + + var interfaces = targets + .SelectMany(t => new[] { t.Interface, t.IfbInterface }) + .ToList(); + + var (success, output) = await _gatewaySsh.RunCommandAsync( + GatewayShaperProbe.BuildCommand(interfaces), TimeSpan.FromSeconds(20), ct); + + if (!success) + { + _logger.LogDebug("Smart Queues shaper probe command failed: {Output}", output); + return empty; + } + + var states = GatewayShaperProbe.Parse(output, targets); + _logger.LogDebug( + "Smart Queues shaper probe read {Count} of {Target} WAN(s) with Smart Queues enabled", + states.Count, targets.Count); + return states; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Smart Queues shaper probe failed"); + return empty; + } + } + + /// + /// The WANs worth reading: Smart Queues on, and interface names the controller gave us that + /// are safe to put on a command line. Interface resolution is the controller's - "eth6" plain, + /// "eth6.100" VLAN-tagged, "ppp0" for PPPoE - so this check looks at exactly the devices + /// Adaptive SQM and Monitoring do. + /// + private async Task> BuildTargetsAsync() + { + var wans = await _sqmService.GetWanInterfacesFromControllerAsync(); + var targets = new List(); + + foreach (var wan in wans.Where(w => w.SmartqEnabled)) + { + if (!GatewayShaperProbe.IsValidInterfaceName(wan.Interface) || + !GatewayShaperProbe.IsValidInterfaceName(wan.TcInterface)) + { + _logger.LogDebug( + "Skipping WAN {Name} in shaper probe: unusable interface name '{Interface}'", + wan.Name, wan.Interface); + continue; + } + + targets.Add(new ShaperProbeTarget( + wan.Name, + wan.Interface, + wan.TcInterface, + wan.SmartqDownRateMbps, + wan.SmartqUpRateMbps)); + } + + return targets; + } +} diff --git a/src/NetworkOptimizer.Web/Services/Ssh/GatewaySshService.cs b/src/NetworkOptimizer.Web/Services/Ssh/GatewaySshService.cs index fefb72ba6d..f838b82848 100644 --- a/src/NetworkOptimizer.Web/Services/Ssh/GatewaySshService.cs +++ b/src/NetworkOptimizer.Web/Services/Ssh/GatewaySshService.cs @@ -32,7 +32,7 @@ public class GatewaySshService : IGatewaySshService /// protocol error. Mirrors the console's awaiting-agent message. /// public const string AwaitingAgentMessage = - "Waiting for the on-site agent to connect. This site's gateway is reached through its agent; SQM will connect automatically once the agent is online."; + "Waiting for the on-site agent to connect. This site's gateway is reached through its agent, and will connect automatically once the agent is online."; public GatewaySshService( ILogger logger, diff --git a/src/NetworkOptimizer.Web/Services/StarlinkMonitorService.cs b/src/NetworkOptimizer.Web/Services/StarlinkMonitorService.cs index 205c1df199..a8c322f50a 100644 --- a/src/NetworkOptimizer.Web/Services/StarlinkMonitorService.cs +++ b/src/NetworkOptimizer.Web/Services/StarlinkMonitorService.cs @@ -1,9 +1,12 @@ using System.Collections.Concurrent; +using Microsoft.EntityFrameworkCore; using NetworkOptimizer.Monitoring.Models; using NetworkOptimizer.Monitoring.Providers; using NetworkOptimizer.Storage.Interfaces; using NetworkOptimizer.Storage.Models; using NetworkOptimizer.Storage.Services; +using NetworkOptimizer.UniFi; +using NetworkOptimizer.Web.Services.Monitoring; namespace NetworkOptimizer.Web.Services; @@ -21,9 +24,36 @@ public sealed class StarlinkMonitorService : IDisposable /// How often the obstruction sky map is refreshed; it changes slowly and is a ~60 KB payload. private static readonly TimeSpan ObstructionMapRefresh = TimeSpan.FromMinutes(5); + /// + /// How far back the alerting baselines look. Seven days is long enough that a re-aim or a + /// cable change is followed rather than flagged, and short enough that the median still + /// describes how the dish is behaving now. + /// + private static readonly TimeSpan BaselineWindow = TimeSpan.FromDays(7); + + /// How often the baselines are recomputed. They move over days, so this is a cheap once-per-poll-cycle read at worst. + private static readonly TimeSpan BaselineRefresh = TimeSpan.FromHours(6); + + /// + /// Aggregation the baseline query asks Influx for. Fifteen minutes gives ~670 points over the + /// window, plenty for a stable median without pulling every raw sample across the wire. + /// + private static readonly TimeSpan BaselineAggregate = TimeSpan.FromMinutes(15); + + /// + /// Alignment samples needed in the window before its median is worth comparing against. A + /// fresh install has none, and the drift alert stays disabled rather than baselining off three + /// readings taken while the dish was still settling. + /// + private const int MinBaselineSamples = 24; + + /// How long a resolved WAN binding is reused. It only changes when someone renames a WAN or adds a dish. + private static readonly TimeSpan WanBindingTtl = TimeSpan.FromMinutes(30); + private readonly IServiceScopeFactory _scopeFactory; private readonly SiteTunnelRouting _tunnelRouting; private readonly MonitoringInfluxClient _influx; + private readonly StarlinkAlertEvaluator _alertEvaluator; private readonly ILogger _logger; private readonly Dictionary _providers; private readonly Timer _pollingTimer; @@ -31,8 +61,12 @@ public sealed class StarlinkMonitorService : IDisposable private readonly ConcurrentDictionary _statsCache = new(); private readonly ConcurrentDictionary _obstructionMapCache = new(); + private readonly ConcurrentDictionary _baselines = new(); private volatile bool _hasPrimedOnce; + private string? _wanLabel; + private DateTime _wanLabelLoadedAt = DateTime.MinValue; + private bool _isPolling; /// @@ -47,6 +81,7 @@ public StarlinkMonitorService( IEnumerable providers, SiteTunnelRouting tunnelRouting, MonitoringInfluxRegistry influxRegistry, + MonitoringAlertRegistry alertRegistry, ILogger logger, string siteSlug = SiteManagementService.DefaultSiteSlug) { @@ -55,6 +90,7 @@ public StarlinkMonitorService( _siteSlug = string.IsNullOrEmpty(siteSlug) ? SiteManagementService.DefaultSiteSlug : siteSlug; Active = _siteSlug == SiteManagementService.DefaultSiteSlug; _influx = influxRegistry.GetFor(_siteSlug); + _alertEvaluator = alertRegistry.GetFor(_siteSlug).Starlink; _logger = logger; _providers = providers.ToDictionary(p => p.ProviderKey, StringComparer.OrdinalIgnoreCase); @@ -127,6 +163,11 @@ public async Task SaveStarlinkAsync(StarlinkConfiguration config) var repo = scope.ServiceProvider.GetRequiredService(); await repo.SaveStarlinkConfigurationAsync(config); + // Adding or disabling a dish changes whether the WAN binding is unambiguous, so the + // cached answer is dropped rather than left to age out and label a second dish with the + // first one's WAN. + InvalidateWanBinding(); + if (isNew) await AlertRuleAutoEnable.EnableBySourceAsync(scope, "starlink", _logger); } @@ -141,6 +182,8 @@ public async Task SetStarlinkEnabledAsync(int id, bool enabled) using var scope = CreateSiteScope(); var repo = scope.ServiceProvider.GetRequiredService(); await repo.SetStarlinkEnabledAsync(id, enabled); + + InvalidateWanBinding(); } /// @@ -164,8 +207,13 @@ public async Task DeleteStarlinkAsync(int id) _statsCache.TryRemove(id, out _); _obstructionMapCache.TryRemove(id, out _); + _baselines.TryRemove(id, out _); + InvalidateWanBinding(); } + /// Forces the next poll to re-resolve which WAN the dish sits behind. + private void InvalidateWanBinding() => _wanLabelLoadedAt = DateTime.MinValue; + /// /// Test connectivity to a terminal using the configured provider. /// @@ -253,6 +301,7 @@ private async Task PollSingleAsync(StarlinkConfiguration config) { _statsCache[config.Id] = stats; WriteToInflux(config, stats); + await EvaluateAlertsAsync(config, stats); await RefreshObstructionMapAsync(provider, context, config.Id); } } @@ -268,6 +317,139 @@ private async Task PollSingleAsync(StarlinkConfiguration config) } } + /// + /// Hands this poll to the alert evaluator along with the two things it cannot derive from a + /// single reading: the dish's own long-run baselines, and which WAN it serves. Failures here + /// are logged and swallowed - alerting must never cost a poll its stats, its chart point, or + /// its sky map. + /// + private async Task EvaluateAlertsAsync(StarlinkConfiguration config, StarlinkStats stats) + { + try + { + var baseline = await GetBaselineAsync(config.Id); + await _alertEvaluator.EvaluateAsync( + config.Id, + config.Name, + stats, + ComputeAlignmentOffsetDeg(stats), + baseline.AlignmentMedianDeg, + baseline.EthCapableMbps, + await ResolveWanLabelAsync()); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Starlink alert evaluation failed for {Name} ({Id})", config.Name, config.Id); + } + } + + /// + /// The dish's own long-run behavior, from the series already in Influx: the median boresight + /// offset it normally sits at, and the fastest Ethernet speed it has been seen to negotiate. + /// Both are self-calibrating by design - a hand-aimed fixed dish is several degrees off ideal + /// from day one and works perfectly there, so drift is judged against where this dish sits + /// rather than against where one ideally would. + /// + /// + /// Reads the LONGTERM bucket, which is where QueryStarlinkAsync looks. An install with + /// no Influx, or a dish with too little history, comes back empty and simply leaves the two + /// rules that need a baseline disabled. + /// + /// + private async Task GetBaselineAsync(int configId) + { + if (_baselines.TryGetValue(configId, out var cached) && + DateTime.UtcNow - cached.ComputedAt < BaselineRefresh) + { + return cached; + } + + var to = DateTime.UtcNow; + var series = await _influx.QueryStarlinkAsync( + to - BaselineWindow, to, configId.ToString(), BaselineAggregate); + var points = series.Values.FirstOrDefault() ?? new List(); + + var offsets = points + .Where(p => p.AlignmentOffsetDeg.HasValue) + .Select(p => p.AlignmentOffsetDeg!.Value) + .OrderBy(v => v) + .ToList(); + double? median = offsets.Count >= MinBaselineSamples + ? offsets.Count % 2 == 1 + ? offsets[offsets.Count / 2] + : (offsets[offsets.Count / 2 - 1] + offsets[offsets.Count / 2]) / 2.0 + : null; + + // The maximum is the right statistic: eth_speed_mbps is the NEGOTIATED rate, so it cannot + // read higher than the link actually reached, and a dish that has ever done 1000 is + // 1000-capable. A genuine permanent downgrade (the dish moved onto a 100 Mbps segment for + // good) alerts until the old speed ages out of the window, then stops on its own. + var speeds = points.Where(p => p.EthSpeedMbps > 0).Select(p => p.EthSpeedMbps!.Value).ToList(); + int? capable = speeds.Count > 0 ? speeds.Max() : null; + + var baseline = new DishBaseline(median, capable, to); + _baselines[configId] = baseline; + + // Says which of the two baseline-dependent rules are armed and why. A null median here is + // the difference between "alignment drift is watching" and "alignment drift is off", and + // without this line the two look identical from outside. + _logger.LogDebug( + "Starlink {Id} baseline over {Days}d: alignment={Median} from {Offsets} points, " + + "capable={Capable} Mbps from {Speeds} points", + configId, BaselineWindow.TotalDays, + median?.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture) ?? "none", + offsets.Count, capable?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "none", + speeds.Count); + + return baseline; + } + + /// + /// Best-effort binding of the dish to a WAN, so its alerts carry the same label everything + /// else uses for that connection. Binds only when the answer is unambiguous: exactly one WAN + /// that recognizes, and exactly one dish configured to sit + /// behind it. With two dishes, or two Starlink WANs, nothing in the data says which serves + /// which, and a confidently wrong WAN name on an alert is worse than none - the alerts then + /// name the dish instead and fire regardless. + /// + private async Task ResolveWanLabelAsync() + { + if (DateTime.UtcNow - _wanLabelLoadedAt < WanBindingTtl) return _wanLabel; + + try + { + using var scope = CreateSiteScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var dishCount = await db.StarlinkConfigurations.CountAsync(c => c.Enabled); + var matches = dishCount == 1 + ? await db.WanProfiles.AsNoTracking() + .Select(p => new { p.WanNetworkgroup, p.Name }) + .ToListAsync() + : []; + + var starlinkWans = matches + .Where(p => StarlinkWanDetector.IsStarlinkWan(p.Name)) + .ToList(); + + _wanLabel = starlinkWans.Count == 1 + ? GatewayWanHelper.FormatWanLabel( + starlinkWans[0].Name, + GatewayWanHelper.WanIndexFromKey( + GatewayWanHelper.WanInterfaceKeyFromKey(starlinkWans[0].WanNetworkgroup)), + null, null) + : null; + } + catch (Exception ex) + { + // Keep whatever was resolved last: a failed lookup should cost the label, not the alert. + _logger.LogDebug(ex, "Could not resolve the Starlink WAN binding for site {Site}", _siteSlug); + } + + _wanLabelLoadedAt = DateTime.UtcNow; + return _wanLabel; + } + private async Task RefreshObstructionMapAsync( IStarlinkProvider provider, StarlinkPollContext context, int configId) { @@ -447,4 +629,13 @@ internal void DisposeOwned() { _pollingTimer.Dispose(); } + + /// + /// One dish's long-run behavior, as the alert rules that cannot judge from a single reading + /// need it. Null members mean "not enough history", which disables the rule that reads them. + /// + /// Median boresight offset over the baseline window, degrees. + /// Fastest Ethernet speed seen over the baseline window, Mbps. + /// When this was computed, for the refresh interval. + private sealed record DishBaseline(double? AlignmentMedianDeg, int? EthCapableMbps, DateTime ComputedAt); } diff --git a/src/NetworkOptimizer.Web/Services/Tours/TourModels.cs b/src/NetworkOptimizer.Web/Services/Tours/TourModels.cs index 45a889eb7e..56ac1ac265 100644 --- a/src/NetworkOptimizer.Web/Services/Tours/TourModels.cs +++ b/src/NetworkOptimizer.Web/Services/Tours/TourModels.cs @@ -75,6 +75,23 @@ public class TourStep [JsonPropertyName("listLabel")] public string? ListLabel { get; set; } + /// + /// Keeps this step out of the offer modal's list while still walking the user through it. For + /// the second half of a feature that takes two stops to show: the pair is one idea, and giving + /// it two bullets spends twice the space of anything else on the list, which is capped at six. + /// + [JsonPropertyName("hideFromList")] + public bool HideFromList { get; set; } + + /// + /// Narrows the spotlight to the row inside whose text contains this, + /// and scrolls to it. For a list whose rows depend on the user's own configuration: the anchor + /// can only sit on the list, while the useful target is one row in it. Absent text is not a + /// failure - the step falls back to spotlighting the anchor itself. + /// + [JsonPropertyName("matchText")] + public string? MatchText { get; set; } + [JsonPropertyName("body")] public string Body { get; set; } = ""; diff --git a/src/NetworkOptimizer.Web/Services/Tours/TourPredicateResolver.cs b/src/NetworkOptimizer.Web/Services/Tours/TourPredicateResolver.cs index 7aca2b97a7..d6804cd44c 100644 --- a/src/NetworkOptimizer.Web/Services/Tours/TourPredicateResolver.cs +++ b/src/NetworkOptimizer.Web/Services/Tours/TourPredicateResolver.cs @@ -33,8 +33,30 @@ public class TourPredicateResolver /// public const string SqmEnabled = "sqm-enabled"; + /// + /// UniFi's own Smart Queues is on for at least one of the site's WANs. Not the same thing as + /// , which is our Adaptive SQM: a WAN can have UniFi's Smart Queues on + /// without Adaptive SQM ever being deployed, and that is exactly the case the Smart Queues + /// shaper check exists for. + /// + public const string SmartQueues = "smart-queues"; + + /// + /// The site has more than one enabled WAN, so the per-WAN filters and comparisons exist to be + /// shown. A single-WAN site renders no WAN selector at all, so a step spotlighting one has + /// nothing to point at and must be filtered out BEFORE the driver navigates. + /// + public const string MultiWan = "multi-wan"; + + /// + /// The site has a Starlink terminal configured. Without one the dish alerts describe hardware + /// the user does not own, which is worse than saying nothing. + /// + public const string Starlink = "starlink"; + private readonly SiteManagementService _siteManagement; private readonly GatewaySshRegistry _gatewaySshRegistry; + private readonly SiteConnectionRegistry _siteConnections; private readonly AgentEnrollmentService _agentEnrollment; private readonly SiteDbContextFactory _siteDbFactory; private readonly ILogger _logger; @@ -42,12 +64,14 @@ public class TourPredicateResolver public TourPredicateResolver( SiteManagementService siteManagement, GatewaySshRegistry gatewaySshRegistry, + SiteConnectionRegistry siteConnections, AgentEnrollmentService agentEnrollment, SiteDbContextFactory siteDbFactory, ILogger logger) { _siteManagement = siteManagement; _gatewaySshRegistry = gatewaySshRegistry; + _siteConnections = siteConnections; _agentEnrollment = agentEnrollment; _siteDbFactory = siteDbFactory; _logger = logger; @@ -119,6 +143,9 @@ public async Task ResolveAsync() var gatewaySshSites = new HashSet(StringComparer.OrdinalIgnoreCase); var ispHealthSites = new HashSet(StringComparer.OrdinalIgnoreCase); var sqmSites = new HashSet(StringComparer.OrdinalIgnoreCase); + var smartQueuesSites = new HashSet(StringComparer.OrdinalIgnoreCase); + var multiWanSites = new HashSet(StringComparer.OrdinalIgnoreCase); + var starlinkSites = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var site in sites) { try @@ -150,6 +177,36 @@ public async Task ResolveAsync() { _logger.LogDebug(ex, "Tour predicate {Predicate} evaluation failed for site {Slug}", SqmEnabled, site.Slug); } + + try + { + if (await HasSmartQueuesAsync(site.Slug)) + smartQueuesSites.Add(site.Slug); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Tour predicate {Predicate} evaluation failed for site {Slug}", SmartQueues, site.Slug); + } + + try + { + if (await HasMultipleWansAsync(site.Slug)) + multiWanSites.Add(site.Slug); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Tour predicate {Predicate} evaluation failed for site {Slug}", MultiWan, site.Slug); + } + + try + { + if (await HasStarlinkAsync(site.Slug, site.IsDefault)) + starlinkSites.Add(site.Slug); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Tour predicate {Predicate} evaluation failed for site {Slug}", Starlink, site.Slug); + } } if (gatewaySshSites.Count > 0) qualifying[GatewaySsh] = gatewaySshSites; @@ -157,6 +214,12 @@ public async Task ResolveAsync() qualifying[IspHealth] = ispHealthSites; if (sqmSites.Count > 0) qualifying[SqmEnabled] = sqmSites; + if (smartQueuesSites.Count > 0) + qualifying[SmartQueues] = smartQueuesSites; + if (multiWanSites.Count > 0) + qualifying[MultiWan] = multiWanSites; + if (starlinkSites.Count > 0) + qualifying[Starlink] = starlinkSites; return new PredicateContext { @@ -195,4 +258,49 @@ private async Task HasSqmEnabledAsync(string slug, bool isDefault) using var db = _siteDbFactory.CreateForSite(slug, isDefault); return await db.SqmWanConfigurations.AsNoTracking().AnyAsync(c => c.Enabled); } + + /// + /// Whether the site has more than one enabled WAN. Asked of the console, because that is what + /// populates the WAN filter bars this step points at - a predicate reading anything else can + /// disagree with what is on screen. WanProfiles in particular cannot answer it: rows are written + /// as a side effect of computing an ISP Health report, so a site whose second WAN has never been + /// graded has no row for it, while a WAN since removed keeps the one it had. + /// Affordable for the same reason the Smart Queues check is: predicates resolve only for a tour + /// that is actually due. A site that is not connected does not qualify. + /// + private async Task HasMultipleWansAsync(string slug) + { + var connection = _siteConnections.GetFor(slug); + if (!connection.IsConnected || connection.Client == null) + return false; + + var wans = await connection.Client.GetWanConfigsAsync(); + return wans.Count(w => w.Enabled) > 1; + } + + /// + /// Whether the site has an enabled Starlink terminal. A disabled one is a dish the user has + /// stopped monitoring, and its alerts would describe hardware they are no longer watching. + /// + private async Task HasStarlinkAsync(string slug, bool isDefault) + { + using var db = _siteDbFactory.CreateForSite(slug, isDefault); + return await db.StarlinkConfigurations.AsNoTracking().AnyAsync(c => c.Enabled); + } + + /// + /// Whether the site has UniFi's Smart Queues turned on for at least one enabled WAN. This one + /// has to ask the console - nothing stores UniFi's own toggle locally - which is affordable + /// only because predicates resolve just for a tour that is actually due, never on the ordinary + /// Dashboard visit. A site that isn't connected simply does not qualify. + /// + private async Task HasSmartQueuesAsync(string slug) + { + var connection = _siteConnections.GetFor(slug); + if (!connection.IsConnected || connection.Client == null) + return false; + + var wans = await connection.Client.GetWanConfigsAsync(); + return wans.Any(w => w.Enabled && w.WanSmartqEnabled); + } } diff --git a/src/NetworkOptimizer.Web/Services/UiHintService.cs b/src/NetworkOptimizer.Web/Services/UiHintService.cs new file mode 100644 index 0000000000..b33a895786 --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/UiHintService.cs @@ -0,0 +1,156 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.EntityFrameworkCore; +using NetworkOptimizer.Storage.Models.Identity; + +namespace NetworkOptimizer.Web.Services; + +/// +/// Teaching hints that retire once the user has plainly seen them. +/// +/// Some gestures cannot be discovered by looking - a modifier click is the obvious case - so the +/// UI has to say them out loud. Saying them forever is its own kind of noise: the hint is for the +/// first encounter, not the hundredth. This counts how many times a user has been shown one and +/// stops at . +/// +/// +/// Per user, not per site or per install: what someone has learned travels with them, and one +/// operator learning a gesture says nothing about their colleagues. A user we cannot identify +/// (no Identity session) always sees the hint and nothing is recorded - the hint is the safe +/// outcome, and there is nowhere honest to keep the count. +/// +/// +public class UiHintService +{ + /// How many times a hint is shown before it is treated as learned. + public const int ShowLimit = 2; + + private readonly IDbContextFactory _authDb; + private readonly AuthenticationStateProvider _authState; + private readonly ILogger _logger; + + public UiHintService( + IDbContextFactory authDb, + AuthenticationStateProvider authState, + ILogger logger) + { + _authDb = authDb; + _authState = authState; + _logger = logger; + } + + /// + /// Whether this user should still be shown the hint. Errs toward showing it: a hint one time + /// too many is a smaller cost than a gesture nobody ever discovers. + /// + public async Task ShouldShowAsync(string hintKey, CancellationToken ct = default) + { + var userId = await CurrentUserIdAsync(); + if (userId == null) return true; + try + { + await using var db = await _authDb.CreateDbContextAsync(ct); + var shown = await db.UserUiHints.AsNoTracking() + .Where(h => h.UserId == userId && h.HintKey == hintKey) + .Select(h => (int?)h.TimesShown) + .FirstOrDefaultAsync(ct); + return (shown ?? 0) < ShowLimit; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read hint state for {Hint}; showing it", hintKey); + return true; + } + } + + /// + /// Counts one showing. Call once per occasion the user could actually have read it - a page + /// visit - not once per render, or a component that re-renders on a timer would burn the + /// allowance in seconds. + /// + public async Task RecordShownAsync(string hintKey, CancellationToken ct = default) + { + var userId = await CurrentUserIdAsync(); + if (userId == null) return; + try + { + await using var db = await _authDb.CreateDbContextAsync(ct); + var row = await db.UserUiHints + .FirstOrDefaultAsync(h => h.UserId == userId && h.HintKey == hintKey, ct); + if (row == null) + { + row = new UserUiHint { UserId = userId, HintKey = hintKey }; + db.UserUiHints.Add(row); + } + // Stops climbing at the limit: the number past that point means nothing, and leaving it + // to grow forever would make a future "reset hints" read as absurd. + if (row.TimesShown < ShowLimit) row.TimesShown++; + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + } + catch (Exception ex) + { + // Losing a count costs one extra tooltip, so it is never worth failing a render over. + _logger.LogDebug(ex, "Could not record hint state for {Hint}", hintKey); + } + } + + /// + /// Retires a hint outright because the user said so. Some hints teach a gesture and can fade on + /// their own after showings; a card that occupies the page until it is + /// closed needs an explicit answer, and that answer is per user for the same reason the counts + /// are - one operator dismissing it says nothing about their colleagues. Recorded as the limit + /// rather than as a separate flag, so needs no second rule. + /// + public async Task DismissAsync(string hintKey, CancellationToken ct = default) + { + var userId = await CurrentUserIdAsync(); + if (userId == null) return; + try + { + await using var db = await _authDb.CreateDbContextAsync(ct); + var row = await db.UserUiHints + .FirstOrDefaultAsync(h => h.UserId == userId && h.HintKey == hintKey, ct); + if (row == null) + { + row = new UserUiHint { UserId = userId, HintKey = hintKey }; + db.UserUiHints.Add(row); + } + row.TimesShown = ShowLimit; + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + } + catch (Exception ex) + { + // The card is already gone from this page; failing here costs its return on the next + // visit, which is not worth throwing over. + _logger.LogDebug(ex, "Could not record dismissal for {Hint}", hintKey); + } + } + + private async Task CurrentUserIdAsync() + { + try + { + var user = (await _authState.GetAuthenticationStateAsync()).User; + return user.Identity?.IsAuthenticated == true + ? user.FindFirstValue(ClaimTypes.NameIdentifier) + : null; + } + catch { return null; } + } +} + +/// Keys for hints that retire. Kept together so the set is visible at a glance. +public static class UiHintKeys +{ + /// Ctrl/Cmd-click on the WAN filter builds a comparison - invisible without saying so. + public const string WanFilterCompare = "wan-filter-compare"; + + /// + /// Where to go to start monitoring a second WAN, shown on Settings - Multi-Site to a site that + /// has more than one WAN and no vantage for any of them. Dismissed rather than counted down: + /// it is a card on the page, not a passing tooltip. + /// + public const string MultiWanVantageSetup = "multi-wan-vantage-setup"; +} diff --git a/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs b/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs index ca62ad6348..a778ac5fc3 100644 --- a/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs +++ b/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs @@ -214,6 +214,14 @@ private void PublishConsoleAlert(string eventType, AlertSeverity severity, strin /// Per-site setting key: reach this site's console through its agent tunnel. public const string ConsoleViaAgentKey = "console.via_agent"; + // How the CURRENT client was built, not how the site is configured now. The teardown hooks + // below used to re-read the setting, which answers a different question: whether the console is + // meant to route through the agent from here on. Those diverge the moment coverage is switched + // off with a tunnel-routed console still connected - the hooks then declined to tear anything + // down, and the client sat "connected" against a loopback proxy whose tunnel had died, with no + // path back (every automatic reconnect is gated on !IsConnected). + private bool _clientViaAgent; + /// Shown while a site's agent-tunneled console waits for the agent to come online. private const string AwaitingAgentMessage = "This site's console connects through its on-site agent, which isn't online yet. It'll connect automatically as soon as the agent comes online."; @@ -275,6 +283,18 @@ public async Task IsConsoleViaAgentAsync() { try { + // The default site answers no unless it has been handed to its agent, matching + // SiteTunnelRouting.IsViaAgentAsync. The flag is deliberately kept rather than cleared + // when coverage is switched off, so re-enabling coverage restores the operator's + // choice - which is exactly why the flag on its own cannot be trusted here. Without + // this, unchecking coverage left the console still dialing an agent that is no longer + // meant to serve the site, and every console read failed. + if (SiteSlug == SiteManagementService.DefaultSiteSlug + && !_serviceProvider.GetRequiredService().Covers(SiteSlug)) + { + return false; + } + using var scope = CreateSiteScope(); var db = scope.ServiceProvider.GetRequiredService(); var setting = await db.SystemSettings.FindAsync(ConsoleViaAgentKey); @@ -327,7 +347,7 @@ public async Task OnAgentTunnelDroppedAsync() try { if (!_isConnected && _client == null) return; - if (!await IsConsoleViaAgentAsync()) return; + if (!_clientViaAgent) return; // Re-check after the await: a fast agent bounce can reconnect (and the // connected hook re-establish the console) while the DB read above was in @@ -373,7 +393,7 @@ public async Task NoteTunnelUnreachableAsync() try { if (!_isConnected && _client == null) return; // already down / awaiting - idempotent - if (!await IsConsoleViaAgentAsync()) return; // only agent-routed consoles ride the tunnel + if (!_clientViaAgent) return; // only agent-routed consoles ride the tunnel _logger.LogInformation( "Site {Slug}'s agent tunnel is unreachable; flipping its console to awaiting-agent ahead of the watchdog", SiteSlug); _client?.Dispose(); @@ -405,7 +425,7 @@ private async Task PreferAwaitingAgentOnDeadTunnelAsync() { try { - if (!await IsConsoleViaAgentAsync()) return; + if (!_clientViaAgent) return; var proxy = _serviceProvider.GetService(); if (proxy == null || !proxy.IsTunnelSuspect(SiteSlug)) return; _awaitingAgent = true; @@ -699,6 +719,7 @@ public async Task ConnectAsync(UniFiConnectionConfig config) } var consoleEndpoint = ResolveControllerEndpoint(config.ControllerUrl, viaAgent); var clientLogger = _loggerFactory.CreateLogger(); + _clientViaAgent = viaAgent; _client = new UniFiApiClient( clientLogger, consoleEndpoint.Url, @@ -840,6 +861,7 @@ private async Task ConnectWithSettingsAsync(UniFiConnectionSettings settin } var consoleEndpoint = ResolveControllerEndpoint(config.ControllerUrl, viaAgent); var clientLogger = _loggerFactory.CreateLogger(); + _clientViaAgent = viaAgent; _client = new UniFiApiClient( clientLogger, consoleEndpoint.Url, @@ -1435,6 +1457,22 @@ public async Task> GetNetworksAsync(CancellationToken cancella return primary; } + /// + /// Whether the site spreads traffic across WANs rather than running one primary with the rest + /// on failover. True when two or more enabled WANs are NOT marked failover-only, which is + /// UniFi's way of saying they share the load. + /// + /// It decides what an unpinned probe measures. Under failover-only, everything on the LAN + /// leaves by the primary, so an ordinary agent measures the primary honestly and needs no + /// policy route (during an actual failover it follows the backup - collateral we accept and + /// state). Under load balancing the same probe is spread across WANs and attributable to + /// none, so every probe source has to be pinned, the primary's included. + /// + /// + public static bool ResolveSiteLoadBalances(IReadOnlyList networks) => + networks.Count(n => n.IsWan && n.Enabled + && !string.Equals(n.WanLoadBalanceType, "failover-only", StringComparison.OrdinalIgnoreCase)) > 1; + /// /// Convenience: fetches networks and resolves the primary WAN in one call. /// @@ -1456,8 +1494,20 @@ public async Task> GetNetworksAsync(CancellationToken cancella { var primary = await GetPrimaryWanNetworkAsync(ct); if (primary?.WanNetworkgroup == null) return null; + return await GetWanInterfacesForGroupAsync(primary.WanNetworkgroup, ct); + } - if (_client == null) return null; + /// + /// Resolves the interface forms of ANY WAN by its network group ("WAN", "WAN2") from the + /// cached device call - the same walk performs for + /// the configured primary, generalized so per-WAN consumers (multi-WAN ISP Health, the WAN + /// throughput selectors) pair a WAN's counters and data path with that same WAN's plan + /// speeds instead of falling back to another WAN's. Returns null when the group's wan + /// object cannot be found. + /// + public async Task GetWanInterfacesForGroupAsync(string networkGroup, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(networkGroup) || _client == null) return null; var rawDevices = await _client.GetDevicesAsync(ct); var gw = rawDevices.FirstOrDefault(d => d.Type is "ugw" or "udm" or "uxg"); if (gw == null) return null; @@ -1470,7 +1520,7 @@ public async Task> GetNetworksAsync(CancellationToken cancella gw.AdditionalData != null && gw.AdditionalData.TryGetValue("ethernet_overrides", out var eoElem) ? eoElem : default); - // Find the wan object whose physical interface maps to the primary networkgroup + // Find the wan object whose physical interface maps to the requested networkgroup foreach (var wan in wanInterfaces) { string? ng = null; @@ -1478,11 +1528,11 @@ public async Task> GetNetworksAsync(CancellationToken cancella ifnameToNg.TryGetValue(wan.IfName, out ng); ng ??= GatewayWanHelper.WanNetworkGroupFromKey(wan.Key); - if (string.Equals(ng, primary.WanNetworkgroup, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(ng, networkGroup, StringComparison.OrdinalIgnoreCase)) { var counter = NetworkUtilities.PreferredWanCounterInterface(wan.IfName, wan.UplinkIfName); - _logger.LogDebug("Primary WAN interfaces: counter={Counter}, data-path={Uplink} (physical={Physical}, networkgroup={NG})", - counter, wan.UplinkIfName ?? wan.IfName, wan.IfName, ng); + _logger.LogDebug("WAN {NG} interfaces: counter={Counter}, data-path={Uplink} (physical={Physical})", + ng, counter, wan.UplinkIfName ?? wan.IfName, wan.IfName); return new PrimaryWanInterfaces(ng, wan.IfName, wan.UplinkIfName, counter); } } @@ -1490,6 +1540,38 @@ public async Task> GetNetworksAsync(CancellationToken cancella return null; } + /// + /// Every WAN's interface forms from the cached device call, one entry per wan1..wan6 object + /// with an uplink. The all-WAN usage fingerprint sums these counter interfaces; per-WAN load + /// callers must NOT use this list (see MonitoringInfluxClient.QueryGatewayWanRatesAsync's + /// summing contract) - they resolve their one WAN via + /// . + /// + public async Task> GetAllWanInterfacesAsync(CancellationToken ct = default) + { + var results = new List(); + if (_client == null) return results; + var rawDevices = await _client.GetDevicesAsync(ct); + var gw = rawDevices.FirstOrDefault(d => d.Type is "ugw" or "udm" or "uxg"); + if (gw == null) return results; + + var wanInterfaces = gw.GetWanInterfaces(); + var ifnameToNg = GatewayWanHelper.BuildNetworkGroupByIfname( + gw.AdditionalData != null && gw.AdditionalData.TryGetValue("ethernet_overrides", out var eoElem) + ? eoElem : default); + foreach (var wan in wanInterfaces) + { + if (string.IsNullOrEmpty(wan.UplinkIfName) && string.IsNullOrEmpty(wan.IfName)) continue; + string? ng = null; + if (!string.IsNullOrEmpty(wan.IfName)) + ifnameToNg.TryGetValue(wan.IfName, out ng); + ng ??= GatewayWanHelper.WanNetworkGroupFromKey(wan.Key); + var counter = NetworkUtilities.PreferredWanCounterInterface(wan.IfName, wan.UplinkIfName); + results.Add(new PrimaryWanInterfaces(ng, wan.IfName, wan.UplinkIfName, counter)); + } + return results; + } + /// /// Resolves the data-path interface name (e.g. "eth6.100", "ppp0") for the /// primary WAN - the Linux ifname SQM deploys on. Thin accessor over diff --git a/src/NetworkOptimizer.Web/Services/UniFiSshService.cs b/src/NetworkOptimizer.Web/Services/UniFiSshService.cs index 95972dd81f..978f6010d9 100644 --- a/src/NetworkOptimizer.Web/Services/UniFiSshService.cs +++ b/src/NetworkOptimizer.Web/Services/UniFiSshService.cs @@ -140,6 +140,22 @@ public async Task SaveSettingsAsync(UniFiSshSettings settings) /// /// Test SSH connection to a specific host using shared credentials /// + /// + /// Shown when this site's devices are reached through its on-site agent and that agent isn't + /// online. Dialing the loopback tunnel proxy now gets a closed socket, which SSH.NET reports as + /// a raw "no identification string" protocol error - true of the socket, useless to the reader. + /// Mirrors the gateway's message. + /// + public const string AwaitingAgentMessage = + "Waiting for the on-site agent to connect. This site's devices are reached through its agent, and will connect automatically once the agent is online."; + + private async Task IsAwaitingAgentAsync() + { + var routing = _serviceProvider.GetService(); + if (routing == null) return false; + return await routing.IsViaAgentAsync(_siteSlug) && !routing.IsAgentOnline(_siteSlug); + } + public async Task<(bool success, string message)> TestConnectionAsync(string host) { var settings = await GetSettingsAsync(); @@ -149,6 +165,11 @@ public async Task SaveSettingsAsync(UniFiSshSettings settings) return (false, "SSH credentials not configured"); } + if (await IsAwaitingAgentAsync()) + { + return (false, AwaitingAgentMessage); + } + try { // Use echo without quotes for cross-platform compatibility (Windows/Linux) @@ -192,6 +213,11 @@ public async Task SaveSettingsAsync(UniFiSshSettings settings) string? privateKeyPathOverride, CancellationToken cancellationToken = default) { + if (await IsAwaitingAgentAsync()) + { + return (false, AwaitingAgentMessage); + } + var settings = await GetSettingsAsync(); // Determine effective credentials (per-device overrides take precedence) diff --git a/src/NetworkOptimizer.Web/Services/UpstreamDiscoveryService.cs b/src/NetworkOptimizer.Web/Services/UpstreamDiscoveryService.cs index fd3eb5dcbb..63f59e37ad 100644 --- a/src/NetworkOptimizer.Web/Services/UpstreamDiscoveryService.cs +++ b/src/NetworkOptimizer.Web/Services/UpstreamDiscoveryService.cs @@ -16,14 +16,17 @@ public UpstreamDiscoveryService(UpstreamTracerService tracer, IAuditContext audi } /// - public async Task StartAsync(CancellationToken ct = default) + public async Task StartAsync(Monitoring.UpstreamTracerService? tracer = null, CancellationToken ct = default) { - await _tracer.StartDiscoveryAsync(ct); + // The audit gate wraps whichever tracer runs; a per-WAN run is the same operator action + // on another WAN's instance, not a different action. + var t = tracer ?? _tracer; + await t.StartDiscoveryAsync(ct); // Shape of the result, not its contents: how far it got and how much it found. The path // itself (WAN address, first-mile neighbor) is discovery output the panel already shows, // and is not what an audit trail is for. - var s = _tracer.State; + var s = t.State; _audit.SetDetails(new { step = s.Step.ToString(), @@ -35,11 +38,12 @@ public async Task StartAsync(CancellationToken ct = default) } /// - public async Task CommitAsync(CancellationToken ct = default) + public async Task CommitAsync(Monitoring.UpstreamTracerService? tracer = null, CancellationToken ct = default) { + var t = tracer ?? _tracer; // Counted before the commit: committing clears the review lists, so reading them // afterwards would report every run as having applied nothing. - var s = _tracer.State; + var s = t.State; var detail = new { accessHops = s.AccessHops.Count, @@ -48,7 +52,7 @@ public async Task CommitAsync(CancellationToken ct = default) addedAsns = s.DiscoveryAddedAsns.Count }; - await _tracer.CommitResultsAsync(ct); + await t.CommitResultsAsync(ct); _audit.SetDetails(detail); } } diff --git a/src/NetworkOptimizer.Web/wwwroot/css/app.css b/src/NetworkOptimizer.Web/wwwroot/css/app.css index 33233b0468..774f6eff62 100644 --- a/src/NetworkOptimizer.Web/wwwroot/css/app.css +++ b/src/NetworkOptimizer.Web/wwwroot/css/app.css @@ -209,6 +209,13 @@ a:hover { color: var(--text-primary); } +.agent-install .agent-flavor-note { + margin-left: 0.4rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--text-muted); +} + .nav-link.active { background: var(--bg-tertiary); color: var(--accent-color); @@ -475,8 +482,12 @@ h1:focus { align-items: center; gap: 0.4rem; } +.wan-chart-mode-cluster.is-comparing { + right: 2.25rem; +} @media (max-width: 768px) { - .wan-chart-mode-cluster { + .wan-chart-mode-cluster, + .wan-chart-mode-cluster.is-comparing { right: 0; } } @@ -513,6 +524,37 @@ h1:focus { gap: 0.75rem; } +.monitoring-chart-header .chart-header-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + margin-left: auto; + justify-content: flex-end; +} + +.isp-health-toolbar .wan-selector { + margin-right: auto; +} + +.monitoring-chart-header.chart-header-stacked .card-title { + flex-basis: 100%; +} + +@media (max-width: 768px) { + .monitoring-chart-header.chart-header-stacked .wan-selector, + .monitoring-chart-header .chart-header-controls { + margin-left: 0; + flex: 1 1 100%; + justify-content: flex-start; + } +} + +.wan-selector .wan-filter-reset { + position: static; + margin-left: 0.15rem; +} + @media (max-width: 768px) { .monitoring-chart-header:has(.time-range-selector):has(.settings-link) { position: relative; @@ -529,6 +571,17 @@ h1:focus { right: 0; margin: 0; } + + #latency-charts-container .monitoring-chart-header { + position: relative; + } + + #latency-charts-container .monitoring-chart-header > .monitoring-jump-btn { + position: absolute; + top: -0.2rem; + right: -0.2rem; + margin: 0; + } } .card-header-collapsible { @@ -936,6 +989,9 @@ a.stat-card-link:active { flex: 1 1 120px; min-width: 100px; text-align: center; + display: flex; + flex-direction: column; + justify-content: center; } .monitoring-stat-pair { display: flex; @@ -947,6 +1003,7 @@ a.stat-card-link:active { min-width: 0; } .monitoring-stat-card .stat-value { + font-variant-numeric: tabular-nums; font-size: 1.3rem; font-weight: 600; color: var(--text-primary); @@ -957,6 +1014,9 @@ a.stat-card-link:active { color: var(--text-muted); margin-top: 0.25rem; } +.monitoring-stat-card .stat-value .stat-unit { + margin-left: 0.25rem; +} .monitoring-stat-card .stat-danger { color: var(--danger-color); } @@ -986,6 +1046,10 @@ a.stat-card-link:active { .monitoring-stat-card .stat-value { font-size: 1rem; } + .monitoring-stat-card .stat-value .stat-unit { + font-size: 0.7em; + margin-left: 0.15rem; + } .monitoring-stat-pair { gap: 0.5rem; flex-basis: 170px; @@ -2046,6 +2110,18 @@ a.stat-card-link:active { align-items: flex-end; } +@media (min-width: 769px) { + .alert-actions .btn { + min-width: 6.75rem; + } +} + +/* Offers a poll's findings without moving the list underneath the reader. */ +.alert-pending-pill { + display: block; + margin: 0 auto 1rem; +} + .no-alerts { text-align: center; padding: 2rem; @@ -4468,6 +4544,37 @@ tr:hover .snmp-row-chevron { flex-shrink: 0; } +.multi-wan-hint { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.multi-wan-hint-body { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; +} + +.multi-wan-hint p { + flex: 1; + margin: 0; +} + +@media (max-width: 768px) { + .multi-wan-hint { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .multi-wan-hint .btn { + align-self: flex-end; + } +} + .connection-banner .btn-secondary { background: white; color: var(--warning-color) !important; @@ -6486,6 +6593,11 @@ a.path-hop.hop-clickable:hover { font-size: 0.875rem; } +/* Sits inside the history card rather than between sections, so it needs the room underneath. */ +.history-pagination { + margin-bottom: 1rem; +} + .pag-arrow { position: relative; top: -1px; @@ -9417,6 +9529,75 @@ a.path-hop.hop-clickable:hover { } } +.stat-value-stacked { + display: grid; + grid-template-columns: 2.6rem 5.2rem; + justify-content: center; + align-items: center; + column-gap: 0.6rem; + row-gap: 0.1rem; + font-size: 0.95rem; + line-height: 1.35; + font-variant-numeric: tabular-nums; +} + +.stat-stack-row { + display: contents; +} + +.stat-stack-wan { + color: var(--text-muted); + font-size: 0.7rem; + text-align: right; +} + +.stat-stack-value { + text-align: left; + font-size: 1rem; +} + +.wan-pill-token { + color: var(--text-muted); + margin-left: 0.3rem; +} + +@media (max-width: 768px) { + .wan-pill-token { + display: none; + } + + .stat-stack-wan { + font-size: 0.65rem; + } + + .stat-stack-value { + font-size: 0.78rem; + } +} + +.time-btn.active .wan-pill-token { + color: var(--text-secondary); +} + +.wan-selector-standalone { + width: max-content; + max-width: 100%; +} + +@media (max-width: 768px) { + .wan-selector-standalone { + width: auto; + } +} + +.wan-all-btn { + flex: 0.4; +} + +.time-range-selector.wan-selector-on-page { + background: var(--bg-tertiary); +} + .time-range-selector { display: flex; gap: 0.25rem; @@ -9815,6 +9996,15 @@ a.path-hop.hop-clickable:hover { padding: 0.5rem 0.25rem; } + .wan-all-btn { + flex: 0.4; + } + + .wan-selector.wan-selector-many .time-btn { + font-size: 0.7rem; + padding: 0.5rem 0.15rem; + } + .filter-group { flex-wrap: wrap; } @@ -15410,6 +15600,45 @@ a.affected-client:hover { color: var(--text-primary); } +.monitoring-jump-btn { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.35rem; + border: none; + background: transparent; + color: var(--text-muted); + line-height: 1; + cursor: pointer; +} + +.monitoring-jump-btn:hover { + color: var(--text-primary); +} + +.monitoring-chart-header > .monitoring-jump-btn { + padding-right: 0.1rem; +} + +.live-view-jump-row { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.live-view-jump-row .monitoring-jump-btn { + margin-left: auto; + margin-top: auto; +} + +@media (max-width: 768px) { + .live-view-jump-row .monitoring-jump-btn { + margin-top: auto; + margin-bottom: auto; + } +} + + .wan-filter-badge { display: inline-flex; align-items: center; @@ -17037,6 +17266,15 @@ tr.stats-row-filtered { min-width: 0; padding: 0 12px; } + + .isp-health-toolbar { + flex-wrap: wrap; + row-gap: 0.5rem; + } + + .isp-health-toolbar .wan-selector { + flex-basis: 100%; + } } .isp-health-toolbar .btn-label-narrow { @@ -19240,61 +19478,12 @@ body.kiosk-mode .status-indicators { overflow-x: auto; } -.inline-add-form { - margin-top: 1rem; - padding: 1rem; - background: var(--bg-tertiary); - border-radius: 8px; -} - -.inline-add-form-row { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - align-items: flex-end; -} - -.inline-add-form-field { - flex: 1 1 150px; - min-width: 0; -} - -.inline-add-form-field label { - display: block; - margin-bottom: 0.25rem; - font-size: 0.8rem; -} - -.inline-add-form-field input, -.inline-add-form-field select { - width: 100%; -} - -.inline-add-form-actions { - display: flex; - gap: 0.5rem; -} - .form-error { color: var(--danger-color); margin-top: 0.5rem; font-size: 0.85rem; } -@media (max-width: 768px) { - .inline-add-form-field { - flex-basis: 100%; - } - - .inline-add-form-actions { - flex-basis: 100%; - } - - .inline-add-form-actions .btn { - flex: 1; - } -} - .site-card-agents { margin-left: auto; color: var(--text-secondary); diff --git a/src/NetworkOptimizer.Web/wwwroot/data/tours/2.6.0.json b/src/NetworkOptimizer.Web/wwwroot/data/tours/2.6.0.json new file mode 100644 index 0000000000..6323aabb43 --- /dev/null +++ b/src/NetworkOptimizer.Web/wwwroot/data/tours/2.6.0.json @@ -0,0 +1,76 @@ +{ + "id": "2.6.0", + "kind": "whats-new", + "title": "What's new in v2.6.0", + "summary": "Every WAN gets its own health score, and its own alerts.", + "steps": [ + { + "id": "multi-wan-monitoring", + "level": "major", + "url": "/monitoring?tab=live", + "selector": "[data-tour=\"live-wan-filter\"]", + "title": "Multi-WAN monitoring", + "listLabel": "Every WAN watched, graded and probed on its own", + "body": "Your WANs are already here, each with its own live throughput. Give one a vantage and it gets its own targets, upstream characterization and ISP Health score.", + "placement": "bottom", + "optional": true, + "requires": ["multi-wan"] + }, + { + "id": "jump-to-analysis", + "level": "minor", + "url": "/monitoring?tab=live", + "selector": "[data-tour=\"jump-to-analysis\"]", + "title": "Any moment, either view", + "listLabel": "Jump from any moment on the timeline straight to the charts", + "body": "Park the timeline on the moment you care about, then hit the magnifier to open **Latency & Packet Loss** framed on that exact instant.", + "placement": "bottom" + }, + { + "id": "jump-to-live", + "level": "minor", + "url": "/monitoring?tab=performance", + "selector": "[data-tour=\"jump-to-live\"]", + "title": "And back again", + "hideFromList": true, + "body": "This icon does the reverse: it takes the moment you are analyzing and plays it back on **Live View**.", + "placement": "bottom" + }, + { + "id": "wan-outage-alerts", + "level": "major", + "url": "/alerts?tab=active", + "selector": "[data-tour=\"active-alerts\"]", + "title": "WAN outage alerts", + "listLabel": "Knows whether your line dropped or something upstream did", + "badge": "improved", + "body": "Every target on the WAN is weighed together, so an outage arrives as one confirmed alert that says whether the link itself went or only part of the path beyond it.", + "placement": "top", + "requires": ["isp-health"] + }, + { + "id": "starlink-alerts", + "level": "minor", + "url": "/alerts?tab=rules", + "selector": "[data-tour=\"alert-rules\"]", + "matchText": "Starlink", + "title": "Starlink alerts", + "listLabel": "Obstruction, aim drift and outages, straight from the dish", + "body": "Obstruction climbing, a dish no longer pointed where it needs to be, outages with the cause the dish reports, even on a backup nothing else is watching. Turn them on under **Rules**.", + "placement": "top", + "requires": ["starlink"] + }, + { + "id": "smart-queues-not-shaping", + "level": "minor", + "url": "/config-optimizer", + "selector": "[data-tour=\"performance-suggestions\"]", + "title": "Smart Queues check", + "listLabel": "Smart Queues (SQM) that UniFi Network never actually turned on", + "badge": "improved", + "body": "Smart Queues (SQM) can read as on in UniFi Network while no shaper is running on your gateway. **Analyze** now catches that and tells you how to fix it.", + "placement": "top", + "requires": ["smart-queues"] + } + ] +} diff --git a/src/NetworkOptimizer.Web/wwwroot/js/cellular-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/cellular-charts.js index f505566983..8f86722522 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/cellular-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/cellular-charts.js @@ -3,7 +3,7 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = window.Apex?.colors || ['#4269d0', '#efb118', '#ff725c', '#6cc5b0', '#3ca951', '#ff8ab7']; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/chart-colors.js b/src/NetworkOptimizer.Web/wwwroot/js/chart-colors.js new file mode 100644 index 0000000000..78ffdb42b3 --- /dev/null +++ b/src/NetworkOptimizer.Web/wwwroot/js/chart-colors.js @@ -0,0 +1,12 @@ +// Download / upload colors for charts, read from the same CSS custom properties +// the speed test results use so charts and stat cards never drift apart. +// ApexCharts writes colors into SVG presentation attributes, where var() does not +// resolve, so they are resolved to concrete values here. + +function resolve(name, fallback) { + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return /^(#|rgb|hsl)/i.test(value) ? value : fallback; +} + +export const downloadColor = () => resolve('--speed-download-color', '#2E79C4'); +export const uploadColor = () => resolve('--speed-upload-color', '#24bc70'); diff --git a/src/NetworkOptimizer.Web/wwwroot/js/chart-tooltip.js b/src/NetworkOptimizer.Web/wwwroot/js/chart-tooltip.js index 42a0ec0b64..e5fd19e215 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/chart-tooltip.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/chart-tooltip.js @@ -85,16 +85,31 @@ export function valueSortedTooltip({ series, dataPointIndex, w }, options = {}) // The axis formatter by default, since it is already right for the chart. An explicit one // is for charts whose axis deliberately omits a unit that the tooltip should still carry - // ISP Health's axis reads "12.4" under an "ms" title, but its tooltip says "12.4 ms". - const fmt = options.format ?? w.config.yaxis?.[0]?.labels?.formatter ?? (v => v); + // An ARRAY of formatters addresses series by index, for a chart whose series do not share a + // unit - throughput beside loss beside latency, where one formatter cannot be right for all. + const fmtOpt = options.format ?? w.config.yaxis?.[0]?.labels?.formatter ?? (v => v); + const fmtFor = i => Array.isArray(fmtOpt) ? (fmtOpt[i] ?? (v => v)) : fmtOpt; const rows = []; let ts = null; for (let i = 0; i < series.length; i++) { const v = series[i]?.[dataPointIndex]; if (v == null) continue; ts ??= w.globals.seriesX[i]?.[dataPointIndex]; - rows.push({ name: w.globals.seriesNames[i], color: w.globals.colors[i % w.globals.colors.length], v }); + rows.push({ name: w.globals.seriesNames[i], color: w.globals.colors[i % w.globals.colors.length], v, i }); + } + // Sorted by value only where the series share a SCALE - several WANs' throughput on one axis, + // several targets' latency on one axis - because then the number and the height on the chart + // rank the same way. Series on different axes must not be sorted: bits per second, a + // percentage and milliseconds have no common order, so ranking them by raw magnitude puts + // throughput on top forever and tells the reader nothing. Those pass sort: false and keep + // their fixed places, which is also where the eye expects to find them. + if (options.sort !== false) rows.sort((a, b) => b.v - a.v); + else if (Array.isArray(options.order)) { + // Reading order, not series order: the chart draws throughput first because it is the + // backdrop, but the pair a reader compares is RTT and loss, so those sit together. + const rank = new Map(options.order.map((n, i) => [n, i])); + rows.sort((a, b) => (rank.get(a.name) ?? 99) - (rank.get(b.name) ?? 99)); } - rows.sort((a, b) => b.v - a.v); // Seconds by default, because the Monitoring charts poll fast enough for them to mean // something. A chart on a slower cadence can drop them - ISP Health polls once a minute over a // 24 hour window, where a seconds field is noise and was never shown before this was shared. @@ -108,7 +123,7 @@ export function valueSortedTooltip({ series, dataPointIndex, w }, options = {}) + '' + '
' + '' + esc(r.name) + ': ' - + '' + esc(fmt(r.v)) + '' + + '' + esc(fmtFor(r.i)(r.v)) + '' + '
').join(''); } diff --git a/src/NetworkOptimizer.Web/wwwroot/js/cm-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/cm-charts.js index 8dd60acc4e..41ada747f9 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/cm-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/cm-charts.js @@ -3,7 +3,7 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = window.Apex?.colors || ['#4269d0', '#efb118', '#ff725c', '#6cc5b0', '#3ca951', '#ff8ab7']; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/collapse-reveal.js b/src/NetworkOptimizer.Web/wwwroot/js/collapse-reveal.js index 083769214c..28607510ab 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/collapse-reveal.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/collapse-reveal.js @@ -64,6 +64,16 @@ document.addEventListener('click', function (e) { var header = e.target.closest && e.target.closest('.card-header-collapsible'); if (!header) return; + // A control living in the header - a filter pill, a link, a badge - does its own thing, so + // on an already-open card there is nothing newly revealed to follow. On a CLOSED one the + // same click may well open it, and then following is the whole point: filtering a card you + // cannot see is the one case where the view should move. Decided from the target because + // this listener runs in the CAPTURE phase, where a component's own stopPropagation cannot + // reach it. + var control = e.target.closest('button, a, select, input, label'); + var opened = header.nextElementSibling; + var wasOpen = !!opened && opened.classList.contains('expanded'); + if (control && header.contains(control) && wasOpen) return; // Blazor re-renders before the transition starts, so begin on the next frame and run for a // little longer than the 0.25s expand to catch the final pixels. var until = performance.now() + FOLLOW_MS; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/device-health-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/device-health-charts.js index 23fc50e9e0..94fff60c72 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/device-health-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/device-health-charts.js @@ -3,7 +3,7 @@ // device-health-charts, and future chart sets share one implementation. import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; // A device answers SNMP but can still miss a single field on a poll - a temperature or diff --git a/src/NetworkOptimizer.Web/wwwroot/js/isp-health-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/isp-health-charts.js index 237a006fef..e88e7da6b7 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/isp-health-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/isp-health-charts.js @@ -3,7 +3,7 @@ // render as shaded x-axis ranges, path shifts as annotation lines. import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = ['#2ba89a', '#3b82f6', '#a78bfa', '#ef5858', '#f59e0b', '#10b981']; @@ -183,7 +183,12 @@ async function loadAndUpdate() { fetchController = new AbortController(); try { let url = '/api/monitoring/isp-health/asn-series'; - if (win) url += `?from=${encodeURIComponent(win.from)}&to=${encodeURIComponent(win.to)}`; + const params = []; + if (win) params.push(`from=${encodeURIComponent(win.from)}`, `to=${encodeURIComponent(win.to)}`); + // Selected WAN (null = primary): the panel's WAN selector routes the chart to the + // matching per-WAN report so lines and event annotations always agree with the score. + if (wanKey) params.push(`wan=${encodeURIComponent(wanKey)}`); + if (params.length) url += `?${params.join('&')}`; const resp = await fetch(url, { credentials: 'same-origin', signal: fetchController.signal }); if (!resp.ok) return; const json = await resp.json(); @@ -284,9 +289,14 @@ function renderBadges() { } } +// Returns whether it actually mounted. The panel renders the chart element only alongside a +// loaded report, so during a WAN switch (spinner up, report body out of the DOM) there is +// nothing to mount into - that case returns false, without throwing, so the caller can leave +// its mounted flag down and retry on a later render instead of recording a chart that was +// never built. export async function mount(elId, fromISO = null, toISO = null, hidden = null) { const el = document.getElementById(elId); - if (!el) return; + if (!el) return false; win = (fromISO && toISO) ? { from: fromISO, to: toISO } : null; hiddenTypes = new Set(hidden || []); @@ -311,6 +321,7 @@ export async function mount(elId, fromISO = null, toISO = null, hidden = null) { await loadAndUpdate(); // Guarded at the tick, not inside loadAndUpdate, so an explicit reload is never suppressed. pollTimer = setInterval(() => { if (!tooltipHeld(el)) loadAndUpdate(); }, POLL_MS); + return true; } export async function reload() { @@ -327,6 +338,20 @@ export async function setWindow(fromISO, toISO) { await loadAndUpdate(); } +// NOT reset by unmount, on purpose: the panel drops the chart while it switches WAN (the +// element leaves the DOM with the report body) and pushes the new key before the re-mount, +// so the fresh mount's first fetch reads it and loads the right WAN straight away. +let wanKey = null; + +export function setWan(w) { + const next = w || null; + // Same key is a no-op rather than a reload: the post-mount push repeats the key the mount + // just fetched with, and refetching it would only abort-and-redo an identical request. + if (next === wanKey) return; + wanKey = next; + loadAndUpdate(); +} + export function setDotNetRef(ref) { dotNetRef = ref; } diff --git a/src/NetworkOptimizer.Web/wwwroot/js/lan-flow-map.js b/src/NetworkOptimizer.Web/wwwroot/js/lan-flow-map.js index 33d45e7033..c54aca8d18 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/lan-flow-map.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/lan-flow-map.js @@ -3698,8 +3698,9 @@ export class LanFlowMap { if (g.userData?.cloud) { const cloud = g.userData.cloud; - // TODO: enable for all WANs once multi-WAN upstream tracing is implemented - if (cloud.kind === 0 && cloud.wanInterface === this._snapshot?.primaryWanInterface) { + // Every access-ISP globe, not just the primary's: upstream discovery runs per WAN now, + // and the menu carries the WAN so it opens on that globe's own discovery. + if (cloud.kind === 0) { this._showCloudContextMenu(e.clientX, e.clientY, cloud); } return; @@ -3742,7 +3743,9 @@ export class LanFlowMap { e.stopPropagation(); this._dismissContextMenu(); if (this._dotnetRef) { - this._dotnetRef.invokeMethodAsync('NavigateToUpstreamDiscovery'); + // The globe knows which WAN it is, so the panel opens on that WAN's discovery + // rather than on the primary's - which on a secondary globe is the wrong panel. + this._dotnetRef.invokeMethodAsync('NavigateToUpstreamDiscoveryForWan', cloud.wanInterface || null); } }); menu.appendChild(item); diff --git a/src/NetworkOptimizer.Web/wwwroot/js/latency-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/latency-charts.js index 1e33fd7436..edff00a60d 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/latency-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/latency-charts.js @@ -6,8 +6,9 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; +import { downloadColor, uploadColor } from './chart-colors.js?v=1'; const PALETTE = window.Apex?.colors || ['#7EB26D', '#EAB839', '#6ED0E0', '#EF843C', '#E24D42', '#1F78C1']; const _colorCache = {}; @@ -48,6 +49,41 @@ let visibilityObserver = null; let isInViewport = true; let lastFetchData = null; let savedState = null; +// Per-WAN scope, set by Blazor (which owns the WAN pill bar and its visibility gate). +// null = no scoping at all: single-WAN sites never reach this code path and render +// exactly as before. Shape: { primaryKey, selected: [wanKey...], tokens: {key: 'WAN1'} }; +// selecting every key is comparison mode (per-host color kept, per-WAN dash pattern). +let wanScope = null; +// Dash patterns by WAN order: primary solid, then visibly distinct patterns per extra WAN. +const WAN_DASH_PATTERNS = [0, 6, 2, 9]; + +function effectiveWanKey(t) { + // Unstamped targets are primary-path measurements (same rule as the server side). + return (t.wanInterface || wanScope?.primaryKey || 'wan').toLowerCase(); +} + +function wanComparisonActive() { + return !!wanScope && wanScope.selected.length > 1; +} + +function filterTargetsToWanScope(targets) { + if (!wanScope) return targets; + const sel = new Set(wanScope.selected.map(k => k.toLowerCase())); + return targets.filter(t => sel.has(effectiveWanKey(t))); +} + +function wanDisplayName(t) { + if (!wanComparisonActive()) return t.name; + const key = effectiveWanKey(t); + const token = wanScope.tokens?.[key] || key.toUpperCase(); + return `${t.name} (${token})`; +} + +function wanDashFor(t) { + if (!wanComparisonActive()) return 0; + const idx = wanScope.selected.map(k => k.toLowerCase()).indexOf(effectiveWanKey(t)); + return WAN_DASH_PATTERNS[Math.max(0, idx) % WAN_DASH_PATTERNS.length]; +} let investigateMarker = null; // { startMs, endMs, label, loaded } while investigating a loss event // Highlight the investigated loss event on the RTT and loss charts, mirroring the @@ -159,7 +195,7 @@ function buildWanRateOpts() { return baseChartOpts('area', 'Throughput', v => v != null ? formatBps(v) : '', { - colors: ['#3b82f6', '#10b981'], + colors: [downloadColor(), uploadColor()], fill: { type: 'gradient', gradient: { shadeIntensity: 0.3, opacityFrom: 0.3, opacityTo: 0.05 }, @@ -287,6 +323,15 @@ const SHARED_OUTAGE_MIN_TARGETS = 3; // live in Blazor (Monitoring.razor), which has the target metadata. Entirely best-effort: // wrapped so a failure here can never disturb chart rendering, and a no-op until Blazor has // handed us its DotNet reference via window.__netoptLatencyRef. +// A ?at= in the URL says where a link wanted this window. The moment the user moves it themselves +// that stops being true, so Blazor is told to drop the parameter - otherwise a reload or the back +// button drags them back to the linked instant. Called from the user's own handlers only, never +// from frameMoment/frameTrailing, which ARE the link landing. Best-effort, like the hints below. +function notifyTimelineMoved() { + try { window.__netoptLatencyRef?.invokeMethodAsync('OnTimelineMovedByUser'); } + catch { /* no ref yet, or the circuit is gone - the window still moved */ } +} + function notifyLanFlakyHints(data) { try { const ref = window.__netoptLatencyRef; @@ -340,32 +385,41 @@ async function loadAndUpdate() { const data = await fetchData(); if (!data || !data.targets) return; - targetMeta = data.targets.map(t => ({ + // WAN scoping is client-side over the full per-type payload: the fetch stays shared + // across WAN selections, and comparison mode simply keeps every WAN's rows. Twin rows + // of one host share a name (and therefore a color); the WAN suffix + dash pattern + // are what tells them apart in comparison mode. + const scopedTargets = filterTargetsToWanScope(data.targets); + + targetMeta = scopedTargets.map(t => ({ id: t.targetId, - name: t.name, + name: wanDisplayName(t), color: hashColor(t.name), })); - const rttSeries = data.targets.map(t => ({ - name: t.name, + const rttSeries = scopedTargets.map(t => ({ + name: wanDisplayName(t), color: hashColor(t.name), data: (t.rtt || []).map(p => ({ x: new Date(p.time).getTime(), y: p.value })), })); - const lossSeries = data.targets.map(t => ({ - name: t.name, + const lossSeries = scopedTargets.map(t => ({ + name: wanDisplayName(t), color: hashColor(t.name), data: (t.loss || []).map(p => ({ x: new Date(p.time).getTime(), y: p.value })), })); - lastFetchData = data; + lastFetchData = { ...data, targets: scopedTargets }; + const dashArray = scopedTargets.map(wanDashFor); if (rttChart) rttChart.updateSeries(rttSeries, false); if (lossChart) lossChart.updateSeries(lossSeries, false); const annotations = buildInvestigateAnnotations(); - if (rttChart) rttChart.updateOptions({ annotations }, false, false); - if (lossChart) lossChart.updateOptions({ annotations }, false, false); + if (rttChart) rttChart.updateOptions({ annotations, stroke: { curve: 'smooth', width: 2, dashArray } }, false, false); + // Same dashes as the RTT chart: twins of one host share its color, so the pattern is the only + // thing telling their WANs apart here too. + if (lossChart) lossChart.updateOptions({ annotations, stroke: { curve: 'smooth', width: 2, dashArray } }, false, false); updateChartVisibility(); @@ -383,7 +437,13 @@ async function loadAndUpdate() { if (wanCard) wanCard.style.display = showWanRate ? '' : 'none'; if (showWanRate && wanRateChart) { - const timeParams = buildQueryParams().replace(/category=[^&]*&?/, ''); + let timeParams = buildQueryParams().replace(/category=[^&]*&?/, ''); + // The throughput reference follows the WAN filter: the solo-selected WAN, or the + // primary while comparing (never a sum - Blazor labels the card accordingly). + if (wanScope) { + const focused = wanScope.selected.length === 1 ? wanScope.selected[0] : wanScope.primaryKey; + if (focused) timeParams += `${timeParams ? '&' : ''}wan=${encodeURIComponent(focused)}`; + } try { const resp = await fetch(`/api/monitoring/wan-rate-chart?${timeParams}`, { credentials: 'same-origin' }); if (resp.ok) { @@ -425,7 +485,7 @@ function renderStatsTable(container, showAll) { const rtt = computeStats(rttVals); const loss = computeStats(lossVals); const meta = targetMeta.find(m => m.id === t.targetId); - return { id: t.targetId, label: t.name, color: meta?.color || '#9ca3af', + return { id: t.targetId, label: meta?.name || t.name, color: meta?.color || '#9ca3af', visible: meta && visibility[meta.id] !== false, values: [rtt?.mean, rtt?.min, rtt?.max, rtt?.p95, rtt?.p99, loss?.mean, loss?.max] }; }); @@ -560,6 +620,7 @@ function updateCustomLabel(container) { function applyDragZoom(xaxis) { const container = document.getElementById(containerId); if (container && xaxis && Number.isFinite(xaxis.min) && Number.isFinite(xaxis.max) && xaxis.min < xaxis.max) { + notifyTimelineMoved(); customFrom = new Date(xaxis.min); customTo = new Date(xaxis.max); isCustomRange = true; @@ -589,15 +650,23 @@ function getEffectiveTo() { return null; } -export async function mount(elId) { +// initialWanScope arrives with the mount rather than in a call behind it: this module is imported +// asynchronously, so a separate push can land before the import resolves and be dropped silently. +// Taking it here also survives the unmount/remount of leaving the tab and returning. +export async function mount(elId, initialWanScope, initialCategory) { containerId = elId; const container = document.getElementById(elId); if (!container) return; - // Seed the category from whichever filter button the server rendered active (LAN by - // default, ISP when the site has no LAN targets), so the initial load matches the UI. - const activeCategoryBtn = container.querySelector('[data-category].active'); - if (activeCategoryBtn) currentCategory = activeCategoryBtn.dataset.category; + setWanScope(initialWanScope); + + // The opening category comes from the server, which knows whether the WANs on screen have any + // LAN targets. From here the module owns it: the buttons carry no server-rendered active class, + // so a re-render of the header cannot put a stale one back while this still holds another. + if (initialCategory) currentCategory = initialCategory; + container.querySelectorAll('[data-category]').forEach(b => { + b.classList.toggle('active', b.dataset.category === currentCategory); + }); const rttEl = container.querySelector('.latency-rtt-chart'); const lossEl = container.querySelector('.latency-loss-chart'); @@ -634,12 +703,12 @@ export async function mount(elId) { // Preset range buttons container.querySelectorAll('[data-range]').forEach(btn => { - btn.addEventListener('click', () => selectPresetRange(container, parseInt(btn.dataset.range))); + btn.addEventListener('click', () => { notifyTimelineMoved(); selectPresetRange(container, parseInt(btn.dataset.range)); }); }); // Shift arrows container.querySelectorAll('[data-shift]').forEach(btn => { - btn.addEventListener('click', () => shiftWindow(btn.dataset.shift)); + btn.addEventListener('click', () => { notifyTimelineMoved(); shiftWindow(btn.dataset.shift); }); }); // Custom range popover @@ -670,6 +739,7 @@ export async function mount(elId) { const from = fromInput?.value ? new Date(fromInput.value) : null; const to = toInput?.value ? new Date(toInput.value) : null; if (!from || !to || isNaN(from) || isNaN(to) || from >= to) return; + notifyTimelineMoved(); customFrom = from; customTo = to; isCustomRange = true; @@ -694,23 +764,19 @@ export async function mount(elId) { startPoll(); } -export function navigateToTime(isoTimestamp, category, label, loaded, eventStartIso, eventEndIso) { - if (!savedState) { - savedState = { category: currentCategory, rangeHours: currentRangeHours, - customFrom, customTo, isCustomRange, windowOffset, visibility: { ...visibility } }; - } - const ts = new Date(isoTimestamp).getTime(); - investigateMarker = label - ? { - startMs: eventStartIso ? new Date(eventStartIso).getTime() : ts, - endMs: eventEndIso ? new Date(eventEndIso).getTime() : ts, - label, - loaded: !!loaded, - } - : null; - const windowMs = 10 * 60000; // 10 min window centered on event - customFrom = new Date(ts - windowMs); - customTo = new Date(ts + windowMs); +// Frames a custom window centered on one instant and switches category, stashing the view it +// replaced so leaving can put the user's own filter back. Shared by the two ways in - the +// Investigate flow below and the jump from the Live tab - because centering, the range-button +// bookkeeping and the save-once rule are the same job for both; only the marker differs. +function stashView() { + if (savedState) return; + savedState = { category: currentCategory, rangeHours: currentRangeHours, + customFrom, customTo, isCustomRange, windowOffset, visibility: { ...visibility } }; +} + +function frameCustomWindow(ts, category, halfWindowMs) { + customFrom = new Date(ts - halfWindowMs); + customTo = new Date(ts + halfWindowMs); isCustomRange = true; windowOffset = 0; if (category) currentCategory = category; @@ -729,6 +795,69 @@ export function navigateToTime(isoTimestamp, category, label, loaded, eventStart startPoll(); } +export function navigateToTime(isoTimestamp, category, label, loaded, eventStartIso, eventEndIso) { + stashView(); + const ts = new Date(isoTimestamp).getTime(); + investigateMarker = label + ? { + startMs: eventStartIso ? new Date(eventStartIso).getTime() : ts, + endMs: eventEndIso ? new Date(eventEndIso).getTime() : ts, + label, + loaded: !!loaded, + } + : null; + frameCustomWindow(ts, category, 10 * 60000); // 10 min either side of the event +} + +/** + * Frames the window on a moment carried in from the Live tab while it was PARKED on that instant: + * 7.5 minutes either side, the same 15 minutes wide as the live jump below, so the two arrive at + * the same zoom and an event looks like itself whichever way you came in. + * Deliberately NOT navigateToTime - that is the Investigate flow, and it carries an event marker + * and label this has no business drawing. Same window machinery, no marker. + */ +export function frameMoment(isoTimestamp, category) { + investigateMarker = null; + frameCustomWindow(new Date(isoTimestamp).getTime(), category, 7.5 * 60000); +} + +/** + * Frames a trailing 15-minute window for a jump made while the Live tab was LIVE rather than + * parked. Centering on "now" would leave half the window in the future and freeze the chart at + * the instant of the click - and a frozen chart and a quiet network look identical, so someone + * who was watching would end up reading a still frame as the present. Someone who was watching + * carries on watching. 15m is also the shortest preset that keeps polling: startPoll stands down + * on custom ranges, so a trailing custom window would be the frozen chart this avoids. + */ +export function frameTrailing(category) { + investigateMarker = null; + if (category) currentCategory = category; + const container = document.getElementById(containerId); + if (!container) return; + container.querySelectorAll('[data-category]').forEach(b => { + b.classList.toggle('active', b.dataset.category === currentCategory); + }); + selectPresetRange(container, 0); +} + +/** + * The view the Live tab needs to reproduce this one: the instant at the CENTER of the window on + * screen, plus the category being charted. Center rather than either edge because the spike + * someone wants to watch play back is the thing they framed the window around, and a playback + * position at the edge puts it half a window away. A plain trailing range keeps no explicit + * bounds - getEffectiveFrom/To answer null for it - so its window is derived from the range. + */ +export function currentView() { + const from = getEffectiveFrom(); + const to = getEffectiveTo(); + const endMs = to ? to.getTime() : Date.now(); + const startMs = from ? from.getTime() : endMs - (RANGE_MS[currentRangeHours] || 3600000); + return { + atIso: new Date((startMs + endMs) / 2).toISOString(), + category: currentCategory, + }; +} + export function restoreState() { if (!savedState) return; investigateMarker = null; @@ -762,6 +891,22 @@ export function restoreState() { startPoll(); } +// Blazor pushes the WAN pill bar's state here. Passing null clears scoping entirely +// (the gate is closed - single WAN, no contexts). +export function setWanScope(scope) { + wanScope = scope && Array.isArray(scope.selected) && scope.selected.length > 0 ? scope : null; + visibility = {}; + // LAN targets belong to the site, not to a WAN, so a secondary WAN has none - staying on the + // LAN category there draws an empty chart. Only ever leave a category that has nothing to show; + // coming back to a WAN that does have LAN targets leaves the choice alone, because by then it + // may be the one the user made. + if (wanScope && wanScope.hasLan === false && currentCategory === 'Fabric') { + setCategory('AccessIsp'); + return; + } + loadAndUpdate(); +} + export function setCategory(cat) { currentCategory = cat; const container = document.getElementById(containerId); @@ -808,4 +953,5 @@ export function unmount() { savedState = null; investigateMarker = null; isInViewport = true; + wanScope = null; } diff --git a/src/NetworkOptimizer.Web/wwwroot/js/ont-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/ont-charts.js index 77662f9a50..7750335981 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/ont-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/ont-charts.js @@ -3,7 +3,7 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = window.Apex?.colors || ['#4269d0', '#efb118', '#ff725c', '#6cc5b0', '#3ca951', '#ff8ab7']; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/sfp-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/sfp-charts.js index bbce0e364e..0130c8ed44 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/sfp-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/sfp-charts.js @@ -3,7 +3,7 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = window.Apex?.colors || ['#7EB26D', '#EAB839', '#6ED0E0', '#EF843C', '#E24D42', '#1F78C1']; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/site-context.js b/src/NetworkOptimizer.Web/wwwroot/js/site-context.js index f9e58bf904..0785dd5d0e 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/site-context.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/site-context.js @@ -208,3 +208,13 @@ window.noHighlightTarget = function (id, block, radius) { noHighlight(id, block, // A table row: tinted, because an offset ring around a row collides with the rows either side. window.noHighlightRow = function (id, block) { noHighlight(id, block || 'center', 'nav-highlight-row'); }; + +// Scroll with no ring, for something the user just caused to appear. The ring answers "which of +// these is the one you were sent to" - a question that only exists when a link brought you from +// somewhere else. A form that opened under the button you pressed needs no such answer, and +// flagging it would say something arrived that the user already knows they asked for. +window.noScrollTo = function (id, block) { + var el = document.getElementById(id); + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: block || 'start' }); +}; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/starlink-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/starlink-charts.js index ddef49d303..fb6e4da056 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/starlink-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/starlink-charts.js @@ -4,7 +4,7 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = window.Apex?.colors || ['#2ba89a', '#3b82f6', '#a78bfa', '#ef5858', '#f59e0b', '#10b981']; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/tour.js b/src/NetworkOptimizer.Web/wwwroot/js/tour.js index 3093758d00..a8067c8fa0 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/tour.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/tour.js @@ -42,6 +42,34 @@ window.noTour = (function () { return clicked; } + // Narrows a spotlight from a list to the row that actually mentions something. An anchor can + // only be placed on markup that always exists, but the interesting target is often one row + // among many, and which rows exist depends on the user's own configuration. Falls back to the + // anchor whenever the text is absent, so a step never fails over wording it hoped to find. + function narrowToText(anchor, text) { + if (!anchor || !text) return anchor; + const needle = text.toLowerCase(); + const hits = Array.from(anchor.querySelectorAll('*')).filter(e => + e.offsetParent !== null && (e.textContent || '').toLowerCase().includes(needle)); + if (!hits.length) return anchor; + + // Ancestors precede their descendants in document order, so the hits that contain no + // other hit are the words themselves; the first of those is the earliest match on the + // page. Spotlighting the words alone reads too tightly, so climb to the row holding + // them, stopping at the anchor. + const innermost = hits.find(e => !hits.some(o => o !== e && e.contains(o))) || hits[0]; + let row = innermost; + while (row && row !== anchor) { + const tag = row.tagName.toLowerCase(); + const cls = (row.className || '').toString().toLowerCase(); + if (tag === 'tr' || tag === 'li' || cls.includes('row') || cls.includes('item')) return row; + row = row.parentElement; + } + return innermost.parentElement && innermost.parentElement !== anchor + ? innermost.parentElement + : innermost; + } + function waitFor(selector, timeoutMs, opened) { return new Promise(resolve => { const started = Date.now(); @@ -252,9 +280,11 @@ window.noTour = (function () { // Shared with position() so a section opened during the wait is not reopened after, // and so the once-per-step rule spans the whole step rather than just the wait. const opened = new Set(); - const el = await waitFor(opts.selector, opts.waitMs || 8000, opened); + const anchor = await waitFor(opts.selector, opts.waitMs || 8000, opened); if (gen !== generation) return 'stale'; - if (!el) return 'missing'; + if (!anchor) return 'missing'; + + const el = narrowToText(anchor, opts.matchText); await ensureInView(el); if (gen !== generation) return 'stale'; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/wan-live-chart.js b/src/NetworkOptimizer.Web/wwwroot/js/wan-live-chart.js index 8d06603b93..8e35ef1f68 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/wan-live-chart.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/wan-live-chart.js @@ -4,6 +4,8 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import * as flowData from './lan-flow-data.js?v=7'; +import { valueSortedTooltip } from './chart-tooltip.js?v=8'; +import { downloadColor, uploadColor } from './chart-colors.js?v=1'; const HISTORY_MINUTES = 5; // Poll at twice the site's SNMP sample rate so no sample is missed when the two @@ -23,8 +25,8 @@ const SCROLL_MS = 250; // foreground only, and the newest live samples are kept, so the smooth edge // never regresses. const BACKFILL_MS = 60000; -const COLOR_DL = '#3b82f6'; -const COLOR_UL = '#10b981'; +const COLOR_DL = downloadColor(); +const COLOR_UL = uploadColor(); const COLOR_LOSS = '#ef4444'; const COLOR_RTT = '#d946ef'; @@ -33,6 +35,12 @@ let pollTimer = null; let scrollTimer = null; let backfillTimer = null; let buffer = []; +// Comparison mode: [{ key, label }] for the WANs on screen, and one buffer per WAN keyed the same +// way. Empty for the ordinary single-WAN case, which never reads either. +let compareWans = []; +let compareBuffers = new Map(); +// Dash patterns by position, so a WAN keeps its pattern for as long as the selection holds. +const WAN_DASH = [0, 6, 2, 10, 4, 8]; let elId = null; let visHandler = null; let mountGen = 0; @@ -184,6 +192,9 @@ function removeMouseTracking() { lastMouse = null; } +/** True while several WANs are on screen together. */ +function comparing() { return compareWans.length > 1; } + function buildOpts() { return { chart: { @@ -219,28 +230,47 @@ function buildOpts() { }, }, }, - series: [ - { name: 'Download', type: 'area', data: [] }, - { name: 'Upload', type: 'area', data: [] }, - { name: 'Loss', type: 'area', data: [] }, - { name: 'RTT', type: 'line', data: [] }, - ], - colors: [COLOR_DL, COLOR_UL, COLOR_LOSS, COLOR_RTT], + series: comparing() + ? compareWans.flatMap(w => ([ + { name: `${w.label} down`, type: 'area', data: [] }, + { name: `${w.label} up`, type: 'area', data: [] }, + ])) + : [ + { name: 'Download', type: 'area', data: [] }, + { name: 'Upload', type: 'area', data: [] }, + { name: 'Loss', type: 'area', data: [] }, + { name: 'RTT', type: 'line', data: [] }, + ], + // Comparing: the COLOUR still says which direction a line is, because that is what the eye + // is sorting for, and the dash pattern says which WAN. Loss and RTT drop out of the chart + // in this mode - they are per-WAN figures in the stat cards above, and four lines per WAN + // is not a comparison anyone can read. + colors: comparing() + ? compareWans.flatMap(() => [COLOR_DL, COLOR_UL]) + : [COLOR_DL, COLOR_UL, COLOR_LOSS, COLOR_RTT], stroke: { curve: 'smooth', - width: [2, 2, 1, 1], - dashArray: [0, 0, 0, 6], + width: comparing() ? compareWans.flatMap(() => [2, 2]) : [2, 2, 1, 1], + dashArray: comparing() + ? compareWans.flatMap((_, i) => { const d = WAN_DASH[i % WAN_DASH.length]; return [d, d]; }) + : [0, 0, 0, 6], }, - fill: { - type: ['gradient', 'gradient', 'gradient', 'solid'], - opacity: [1, 1, 1, 0], - gradient: { - shadeIntensity: 0.4, - opacityFrom: [0.55, 0.45, 0.5, 0], - opacityTo: [0.1, 0.08, 0.05, 0], - stops: [0, 95], + fill: comparing() + ? { + // Flat translucent fills: several overlapping gradients turn the plot into mud. + type: compareWans.flatMap(() => ['solid', 'solid']), + opacity: compareWans.flatMap(() => [0.12, 0.10]), + } + : { + type: ['gradient', 'gradient', 'gradient', 'solid'], + opacity: [1, 1, 1, 0], + gradient: { + shadeIntensity: 0.4, + opacityFrom: [0.55, 0.45, 0.5, 0], + opacityTo: [0.1, 0.08, 0.05, 0], + stops: [0, 95], + }, }, - }, markers: { size: 0 }, dataLabels: { enabled: false }, xaxis: { @@ -256,7 +286,23 @@ function buildOpts() { axisBorder: { show: false }, axisTicks: { show: false }, }, - yaxis: [ + // ONE axis for every throughput series while comparing - not one per series. A yaxis + // ARRAY is laid out entry by entry even where show is false, so 2N entries stole the plot + // width and pushed the chart past its container. A single object is also what lets the + // WANs be read against the same scale. + yaxis: comparing() + ? { + min: 0, + max: v => v * 1.1, + labels: { + style: { colors: '#9ca3af', fontSize: '10px' }, + formatter: v => formatBps(v), + offsetX: -10, + }, + axisBorder: { show: false }, + axisTicks: { show: false }, + } + : [ { seriesName: 'Download', min: 0, @@ -296,33 +342,73 @@ function buildOpts() { borderColor: '#374151', strokeDashArray: 3, // Bottom padding holds the strip below the axis where the - // annotation time labels render. - padding: { left: 3, right: 0, top: -8, bottom: 12 }, + // annotation time labels render. Comparing has no opposite RTT axis holding the right + // edge open, so it pads its own or the newest sample sits on the container's edge. + padding: comparing() + ? { left: 3, right: 26, top: -8, bottom: 12 } + : { left: 3, right: 0, top: -8, bottom: 12 }, xaxis: { lines: { show: false } }, }, responsive: [{ breakpoint: 1024, options: { - yaxis: [ - { seriesName: 'Download', show: false, min: 0, max: v => v * 1.1 }, - { seriesName: 'Download', show: false, min: 0, max: v => v * 1.1 }, - { seriesName: 'Loss', opposite: true, show: false, min: 0, max: v => Math.max(v * 1.2, 10) }, - { seriesName: 'RTT', opposite: true, show: false, min: 0 }, - ], + // One entry per series in BOTH modes: a mismatched length leaves ApexCharts + // holding axes for series that do not exist, and the plot escapes its container. + yaxis: comparing() + ? { show: false, min: 0, max: v => v * 1.1 } + : [ + { seriesName: 'Download', show: false, min: 0, max: v => v * 1.1 }, + { seriesName: 'Download', show: false, min: 0, max: v => v * 1.1 }, + { seriesName: 'Loss', opposite: true, show: false, min: 0, max: v => Math.max(v * 1.2, 10) }, + { seriesName: 'RTT', opposite: true, show: false, min: 0 }, + ], grid: { padding: { left: -5, right: -5, top: -8, bottom: 12 } }, }, }], legend: { show: false }, tooltip: { theme: 'dark', + // Shared, so every line's value is stacked at the cursor's instant. There is no way to + // hover one line out of 2N overlapping ones, so a per-series tooltip would be unusable + // here - and the stack is how a WAN is told from its neighbour, since the chart has no + // legend and the series names carry the WAN. shared: true, x: { format: 'HH:mm:ss', formatter: (val) => new Date(val).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }, - y: [ - { formatter: v => formatBps(v) }, - { formatter: v => formatBps(v) }, - { formatter: v => v != null ? v.toFixed(2) + '%' : '-' }, - { formatter: v => v != null ? v.toFixed(1) + ' ms' : '-' }, - ], + // Comparing uses the same custom tooltip as the rest of the Monitoring charts: it + // stacks every series at the hovered instant sorted by value, and paints its own + // hover dots (the library's markers are the flaky ones, and any non-zero size puts a + // permanent dot on every sample). An explicit formatter because the throughput axis + // is an object here, not the array the helper reads by default. + // The same custom tooltip either way - it stacks every series at the hovered instant + // and paints its own small hover dots. What differs is the ordering: comparing puts + // the WANs on ONE axis, so their values rank and the biggest belongs on top; the + // single-WAN chart spreads four series across four axes, where bits per second, a + // percentage and milliseconds cannot be ranked against each other, so they keep a + // fixed order instead. + custom: comparing() + ? (ctx) => valueSortedTooltip(ctx, { format: v => formatBps(v) }) + : (ctx) => valueSortedTooltip(ctx, { + sort: false, + // order is how they READ; format is indexed by SERIES position (Loss is + // series 2, RTT series 3) - the two lists are deliberately not parallel. + order: ['Download', 'Upload', 'RTT', 'Loss'], + format: [ + v => formatBps(v), + v => formatBps(v), + v => v != null ? v.toFixed(2) + '%' : '-', + v => v != null ? v.toFixed(1) + ' ms' : '-', + ], + }), + // Positional, one per series: a short array leaves the rest of the series with no + // formatter at all, which renders raw bits per second. + y: comparing() + ? compareWans.flatMap(() => [{ formatter: v => formatBps(v) }, { formatter: v => formatBps(v) }]) + : [ + { formatter: v => formatBps(v) }, + { formatter: v => formatBps(v) }, + { formatter: v => v != null ? v.toFixed(2) + '%' : '-' }, + { formatter: v => v != null ? v.toFixed(1) + ' ms' : '-' }, + ], }, noData: { text: 'Loading...', style: { color: '#64748b', fontSize: '13px' } }, }; @@ -335,6 +421,49 @@ function buildOpts() { // the viewport, so half-view panels and mobile - which constrain the chart // below full width while the viewport stays wide - step out to a sparser // grid instead of colliding. Full width keeps the dense 20s grid. +// Comparison series on ONE time grid, the union of every WAN's timestamps, with a null wherever a +// WAN has no reading for an instant. +// +// Each WAN's history is fetched separately and comes back on its own timestamps, so series built +// straight from those buffers share no x values. ApexCharts addresses series by data-point INDEX, +// so a shared tooltip then has nothing to print for the WANs that lack a point at the hovered +// instant - the reading you want appears only when the pointer happens to find that WAN's own line, +// which is hunting rather than reading. A common grid gives every series the same indices. +// +// Nulls rather than dropped points, for the reason alignedPoints exists in chart-tooltip.js: a +// missing point makes its neighbours adjacent and the stroke spans a gap that is really there, +// while a null ends one segment and starts another. valueSortedTooltip skips nulls, so a WAN with +// no reading costs no row. +function compareSeries() { + const times = [...new Set(compareWans.flatMap(w => + (compareBuffers.get(w.key) || []).map(p => p.time)))].sort((a, b) => a - b); + return compareWans.flatMap(w => { + const pts = (compareBuffers.get(w.key) || []).slice().sort((a, b) => a.time - b.time); + // As-of, not exact. Live, one tick stamps every WAN with the same timestamp, so exact + // matching lined up; a historic window is fetched per WAN and each comes back on its own + // timestamps, so exact matching left every series null at the other WANs' instants. The + // tooltip still read correctly - it skips nulls - but each line became isolated points, + // and with markers.size 0 an isolated point draws nothing. Hence values without lines. + // + // Each grid instant takes that WAN's newest reading at or before it, within a tolerance + // scaled to the WAN's own cadence, so a genuine outage still breaks the line rather than + // carrying a stale value across it. + const gaps = pts.slice(1).map((p, i) => p.time - pts[i].time).sort((a, b) => a - b); + const median = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 0; + const tolerance = Math.max(median * 2.5, 15000); + let i = 0, last = null; + const rows = times.map(t => { + while (i < pts.length && pts[i].time <= t) last = pts[i++]; + return last && t - last.time <= tolerance ? last : null; + }); + const on = key => rows.map((p, idx) => ({ x: times[idx], y: p?.[key] ?? null })); + return [ + { name: `${w.label} down`, data: on('download') }, + { name: `${w.label} up`, data: on('upload') }, + ]; + }); +} + function buildTimeTicks(minMs, maxMs) { const width = document.getElementById(elId)?.clientWidth || 800; // An HH:mm:ss label is ~46px at 10px; budget 64px per slot for breathing @@ -370,11 +499,17 @@ function buildTimeTicks(minMs, maxMs) { return ticks; } +// The RTT axis ceiling. Headroom over the p95 keeps an ordinary chart from filling its pane +// edge to edge, but the p95 alone CLIPPED the thing most worth seeing: a spike is by definition +// above the 95th percentile, so the scale was set from the calm band and the peak was drawn off +// the top of the axis. Taking whichever is greater keeps the roomy scale when nothing is +// happening and lets the axis grow when something is. function rttYMax() { const rtts = buffer.map(p => p.rtt).filter(v => v != null && v > 0).sort((a, b) => a - b); if (rtts.length === 0) return 10; - const p95 = rtts[Math.floor(rtts.length * 0.95)]; - return Math.ceil((p95 * 1.5) / 10) * 10; + const p95 = rtts[Math.min(rtts.length - 1, Math.floor(rtts.length * 0.95))]; + const peak = rtts[rtts.length - 1]; + return Math.ceil(Math.max(p95 * 1.5, peak * 1.1) / 10) * 10; } function buildSeriesData() { @@ -388,15 +523,29 @@ function buildSeriesData() { } function updateChart() { - if (!chart || buffer.length === 0) return; + if (!chart) return; + if (!comparing() && buffer.length === 0) return; if (Date.now() > clickRenderUntil && tooltipShowing()) return; const now = Date.now(); const pts = buildSeriesData(); - chart.updateOptions({ - xaxis: { min: now - HISTORY_MINUTES * 60000, max: now }, - yaxis: [chart.opts.yaxis[0], chart.opts.yaxis[1], chart.opts.yaxis[2], { ...chart.opts.yaxis[3], max: rttYMax() }], - annotations: { xaxis: buildTimeTicks(now - HISTORY_MINUTES * 60000, now) }, - }, false, false, false); + // Rescaling the RTT axis is a single-WAN concern: there IS no fourth axis while comparing, + // and rebuilding the array to a fixed length of four truncated the axes for three or more + // WANs and stamped an RTT-sized ceiling (~10) onto a throughput axis for two - which is what + // clipped the taller WAN's line. Comparison axes are set at mount and left alone. + chart.updateOptions(comparing() + ? { + xaxis: { min: now - HISTORY_MINUTES * 60000, max: now }, + annotations: { xaxis: buildTimeTicks(now - HISTORY_MINUTES * 60000, now) }, + } + : { + xaxis: { min: now - HISTORY_MINUTES * 60000, max: now }, + yaxis: [chart.opts.yaxis[0], chart.opts.yaxis[1], chart.opts.yaxis[2], { ...chart.opts.yaxis[3], max: rttYMax() }], + annotations: { xaxis: buildTimeTicks(now - HISTORY_MINUTES * 60000, now) }, + }, false, false, false); + if (comparing()) { + chart.updateSeries(compareSeries(), false); + return; + } chart.updateSeries([ { name: 'Download', data: pts.map(p => ({ x: p.time, y: p.download })) }, { name: 'Upload', data: pts.map(p => ({ x: p.time, y: p.upload })) }, @@ -405,13 +554,21 @@ function updateChart() { ], false); } +// Which WAN's counters this chart is showing. Null is the primary, which is what every caller +// meant before the chart could be pointed at another WAN - so an absent scope has to keep +// producing the exact request it always did. +let wanScope = null; + +function historyUrl(from, to) { + const base = `/api/monitoring/wan-live-chart-data?from=${from.toISOString()}&to=${to.toISOString()}`; + return wanScope ? `${base}&wan=${encodeURIComponent(wanScope)}` : base; +} + async function loadHistory() { const to = new Date(); const from = new Date(to.getTime() - HISTORY_MINUTES * 60000); try { - const resp = await fetch( - `/api/monitoring/wan-live-chart-data?from=${from.toISOString()}&to=${to.toISOString()}`, - { credentials: 'same-origin' }); + const resp = await fetch(historyUrl(from, to), { credentials: 'same-origin' }); if (!resp.ok) return 0; const data = await resp.json(); applySampleInterval(data); @@ -420,7 +577,7 @@ async function loadHistory() { download: p.downloadBps, upload: p.uploadBps, rtt: p.rttMs, - loss: p.lossPercent ?? 0, + loss: p.lossPercent, })); // Advance the live-sample watermark past the reloaded history so the // next pollLive can't append a sample older than the last history @@ -433,9 +590,75 @@ async function loadHistory() { } catch { } } +/** Pulls each compared WAN's history into its own buffer. */ +/** + * Fills every compared WAN's buffer. `atMs` loads the window a seek is parked on instead of the + * live one - in comparison mode renderHistoric draws straight from these buffers, so without it a + * seek fetched only the single-WAN buffer and the chart kept showing the live window at a historic + * playhead. Same window arithmetic as seekTime, so both modes frame the instant identically. + */ +async function loadCompareHistory(atMs = null) { + const end = atMs ? Math.min(atMs + HISTORY_MINUTES * 60000 / 2, Date.now()) : Date.now(); + const to = new Date(end); + const from = new Date(end - HISTORY_MINUTES * 60000); + for (const w of compareWans) { + try { + const resp = await fetch( + `/api/monitoring/wan-live-chart-data?from=${from.toISOString()}&to=${to.toISOString()}&wan=${encodeURIComponent(w.key)}`, + { credentials: 'same-origin' }); + if (!resp.ok) { compareBuffers.set(w.key, compareBuffers.get(w.key) || []); continue; } + const data = await resp.json(); + applySampleInterval(data); + compareBuffers.set(w.key, (data.points || []).map(p => ({ + time: new Date(p.time).getTime(), + download: p.downloadBps, + upload: p.uploadBps, + }))); + } catch { compareBuffers.set(w.key, compareBuffers.get(w.key) || []); } + } +} + +/** One live tick per compared WAN, appended to that WAN's own buffer. */ +async function pollLiveCompare() { + const cutoff = Date.now() - HISTORY_MINUTES * 60000; + + // Every WAN is read for the same tick, then stamped with ONE timestamp. A shared tooltip + // stacks values that sit at the same x - and each WAN's own SNMP sample time is a few hundred + // milliseconds off its neighbour's, so stamping each with its own left every series on its own + // x and the stack showed one WAN at a time. The reading is still each WAN's own; only the + // instant they are filed under is common, which is what "at this moment" means on one chart. + const results = await Promise.all(compareWans.map(async w => { + try { + const resp = await fetch(`/api/monitoring/live-stats?wan=${encodeURIComponent(w.key)}`, + { credentials: 'same-origin' }); + if (!resp.ok) return null; + const d = await resp.json(); + return { key: w.key, d, sampled: d.sampleTime ? new Date(d.sampleTime).getTime() : 0 }; + } catch { return null; } + })); + + const live = results.filter(Boolean); + if (live.length === 0) return; + // The newest real sample time across the WANs, so the x still tracks the data rather than the + // browser's clock; falls back to now when no WAN reported one. + const tick = Math.max(...live.map(r => r.sampled), 0) || Date.now(); + if (tick <= lastSampleTime) return; // same dedupe as the single-WAN path + lastSampleTime = tick; + + for (const r of live) { + const b = compareBuffers.get(r.key) || []; + b.push({ time: tick, download: r.d.downloadBps, upload: r.d.uploadBps }); + compareBuffers.set(r.key, b.filter(p => p.time >= cutoff)); + } + updateChart(); +} + async function pollLive() { + if (comparing()) return await pollLiveCompare(); try { - const resp = await fetch('/api/monitoring/live-stats', { credentials: 'same-origin' }); + const resp = await fetch( + wanScope ? `/api/monitoring/live-stats?wan=${encodeURIComponent(wanScope)}` : '/api/monitoring/live-stats', + { credentials: 'same-origin' }); if (!resp.ok) return; const d = await resp.json(); // Stamp the point with the server-side SNMP sample time and skip polls @@ -470,7 +693,9 @@ async function pollLive() { time: sampleTime, download: d.downloadBps, upload: d.uploadBps, - loss: d.lossPercent ?? 0, + // Null, not zero: no fresh reading is a gap in the series, and plotting it as 0% + // drew a healthy connection through an outage that had stopped reporting. + loss: d.lossPercent, rtt: d.rttMs, }); buffer = buffer.filter(p => p.time >= cutoff); @@ -542,9 +767,7 @@ async function backfillHistory() { const from = new Date(to.getTime() - HISTORY_MINUTES * 60000); let points; try { - const resp = await fetch( - `/api/monitoring/wan-live-chart-data?from=${from.toISOString()}&to=${to.toISOString()}`, - { credentials: 'same-origin' }); + const resp = await fetch(historyUrl(from, to), { credentials: 'same-origin' }); if (!resp.ok) return; const data = await resp.json(); applySampleInterval(data); @@ -553,7 +776,7 @@ async function backfillHistory() { download: p.downloadBps, upload: p.uploadBps, rtt: p.rttMs, - loss: p.lossPercent ?? 0, + loss: p.lossPercent, })); } catch { return 0; } // Bail if the mount changed or we left live mode while fetching. @@ -631,18 +854,38 @@ function syncModeUi() { if (!modeCluster) return; const historic = flowData.getMode() === 'historic'; modeCluster.style.display = historic ? '' : 'none'; + // Comparison mode clears the corner the single-WAN chart keeps occupied, so the cluster sits + // further right. Follows comparing() rather than a second notion of multi-WAN. + modeCluster.classList.toggle('is-comparing', comparing()); if (!playBtn) return; const paused = flowData.isPaused(); playBtn.textContent = paused ? '▶' : '⏸'; playBtn.setAttribute('aria-label', paused ? 'Play' : 'Pause'); } -export async function mount(containerId, opts) { +async function doMount(containerId, opts) { + // Ride in with the mount rather than in a call behind it: this module is imported + // asynchronously, so a scope pushed separately can land before the import resolves. + if (opts && 'wan' in opts) wanScope = opts.wan || null; + // A selection of several arrives as wans: [{key,label}] and starts the chart in comparison + // mode, so the first paint is already right rather than flipping a moment later. + // + // Keyed on the property being PRESENT, not on it being an array. The primary alone is sent as + // null - it needs no scope - and testing for an array skipped the reset entirely, leaving + // compareWans from the previous mount: this module is imported once and survives leaving the + // tab, so the pills came back reading one WAN while the chart was still comparing every WAN + // it had last been given. + if (opts && 'wans' in opts) { + const list = Array.isArray(opts.wans) ? opts.wans.filter(w => w && w.key) : []; + compareWans = list.length > 1 ? list : []; + if (list.length >= 1) wanScope = list[0].key; + } if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (scrollTimer) { clearInterval(scrollTimer); scrollTimer = null; } if (chart) { chart.destroy(); chart = null; } removeMouseTracking(); buffer = []; + compareBuffers.clear(); lastSampleTime = 0; seenLiveSample = false; lastLiveAt = 0; @@ -674,7 +917,8 @@ export async function mount(containerId, opts) { // appended before it is wiped. ensureModeUi(el); - await loadHistory(); + if (comparing()) await loadCompareHistory(); + else await loadHistory(); if (gen !== mountGen) return; await pollLive(); if (gen !== mountGen) return; @@ -710,6 +954,108 @@ export async function mount(containerId, opts) { } } +// mount and setWans both rebuild the chart and reload its history, and they arrive from +// independent Blazor tasks: the mount chain (initial mount, then the settled-scope remount in +// Monitoring.razor) and the LiveWanScope restore's setWans push interleave arbitrarily. Left to +// overlap, a setWans landing mid-mount had its compareWans wiped by the mount's own opts reset, +// and each side destroys the ApexCharts instance the other is awaiting render() on - which +// strands that render promise and takes the caller's interop await (and the settled-scope +// remount behind it) down with it, leaving a comparison chart whose history load never ran. +// Serializing the two entry points makes every arrival order equivalent to a clean sequence, +// and every terminal order ends loaded: a mount running last loads its own window, a setWans +// running last either loads or finds the same WAN list already mounted and stands pat. +let scopeOpChain = Promise.resolve(); + +function queueScopeOp(fn) { + const run = scopeOpChain.then(fn, fn); + // Keep the chain alive past a failed op; the caller still sees its own rejection via run. + scopeOpChain = run.then(() => {}, () => {}); + return run; +} + +/** Queued front door for doMount, so a mount can never interleave with a setWans. */ +export function mount(containerId, opts) { + return queueScopeOp(() => doMount(containerId, opts)); +} + +/** Queued front door for doSetWans, so a scope push can never interleave with a mount. */ +export function setWans(wans) { + return queueScopeOp(() => doSetWans(wans)); +} + +/** + * Points the chart at another WAN and reloads its history. Deliberately does NOT touch the + * paused/scrubbed state: changing which WAN you are looking at should not drag you back to live, + * and a scrubbed position is still a valid position on the new WAN's series. The seek path shares + * historyUrl(), so scrubbing after a WAN change reads that WAN too. + */ +/** + * Shows several WANs together, or falls back to the single-WAN path for one. Rebuilds the chart: + * the series count, their axes and their fills all differ between the two modes, so updating in + * place would leave ApexCharts holding a config for the shape it no longer has. + */ +async function doSetWans(wans) { + const list = (Array.isArray(wans) ? wans : []).filter(w => w && w.key); + if (list.length <= 1) { + const wasComparing = comparing(); + compareWans = []; + compareBuffers.clear(); + if (wasComparing) await remountChart(); + await setWan(list[0]?.key ?? null); + // A swap made while parked has to be drawn at the parked instant too. + if (!pollTimer && histAt > 0) await seekTime(new Date(histAt).toISOString()); + syncModeUi(); + return; + } + if (list.map(w => w.key).join(",") === compareWans.map(w => w.key).join(",")) return; + compareWans = list; + compareBuffers.clear(); + wanScope = list[0].key; + await remountChart(); + await loadCompareHistory(); + await redrawForCurrentTime(); + // remountChart rebuilds the chart but not the cluster (it is parented outside), so the mode + // class has to be refreshed here rather than riding in on a mount. + syncModeUi(); +} + +/** + * Draws the newly loaded scope at whatever instant the chart is showing. + * + * Live, that is now, and updateChart is the whole job. Parked or playing back it is the instant on + * the playhead, and updateChart draws the live edge instead - which is why changing WANs during + * playback appeared to do nothing: the series were replaced correctly, then painted for a time the + * user was not looking at. A full seek rather than a redraw, because the new scope holds no data + * for that instant until it is fetched, which is what seekTime does. + */ +async function redrawForCurrentTime() { + // Historic means no live poller AND a parked instant - either alone is a half-state seen while + // switching modes, and seeking on one of those is what stopped the chart. + if (!pollTimer && histAt > 0) { + await seekTime(new Date(histAt).toISOString()); + return; + } + updateChart(); +} + +/** Rebuilds the chart in place with the current mode's options, keeping the mount and listeners. */ +async function remountChart() { + if (!chart || !elId) return; + const el = document.getElementById(elId); + if (!el) return; + chart.destroy(); + chart = new ApexCharts(el, buildOpts()); + await chart.render(); +} + +export async function setWan(wanKey) { + const next = wanKey || null; + if (next === wanScope) return; + wanScope = next; + await loadHistory(); + updateChart(); +} + export function pause() { stopHistInterpolation(); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } @@ -717,11 +1063,52 @@ export function pause() { if (backfillTimer) { clearInterval(backfillTimer); backfillTimer = null; } } -export function resume() { - if (!chart || pollTimer) return; - pollTimer = setInterval(pollLive, pollMsOverride || pollMs); - scrollTimer = setInterval(updateChart, SCROLL_MS); - startBackfillCatchUp(); +// Entering live mode is one indivisible job - reload the 5-minute window, THEN start the +// timers - but it has two independent doors: the map's time sync sends seekTime(null) and its +// playstate sync sends resume(), each a fire-and-forget interop call, so they arrive in +// whichever order the circuit delivers them. When resume() won that race it used to start +// pollTimer without loading anything, and seekTime(null) then bailed on "already live" before +// reaching its history load - in comparison mode nothing else ever refills compareBuffers +// (backfill feeds only the single-WAN buffer), so the window never came back and the chart +// crept forward one live tick at a time. One shared entry makes the order irrelevant: +// whichever door opens first does the whole job, and the other finds it done - or in flight, +// which the flag below turns into a no-op instead of a second set of timers polling forever. +let liveEntryInFlight = false; + +async function enterLive() { + if (!chart || pollTimer || liveEntryInFlight) return; + // Still parked on a historic instant: while returning to live the playstate sync (resume) + // can arrive before the time sync (seekTime(null)), and entering here would clobber the + // parked window with the live one. seekTime(null) clears histAt first, then comes back in. + if (histAt > 0) return; + liveEntryInFlight = true; + try { + const gen = mountGen; + buffer = []; + // Comparison mode draws from the per-WAN buffers, so refilling the single-WAN one leaves + // the chart on the window it was parked at - the live 5 minutes never arrived and the + // series only crept back as fresh ticks came in one at a time. + if (comparing()) await loadCompareHistory(); + else await loadHistory(); + // A remount, a historic seek, or a path that already started polling superseded this + // entry while it was fetching. Deliberately NOT keyed on seekGen: a second return-to-live + // during the fetch bumps that too, and this entry completing is exactly what the second + // return wants - bailing on it left the chart live with no timers running. + if (gen !== mountGen || histAt > 0 || pollTimer) return; + updateChart(); + pollTimer = setInterval(pollLive, pollMsOverride || pollMs); + scrollTimer = setInterval(updateChart, SCROLL_MS); + startBackfillCatchUp(); + } finally { + liveEntryInFlight = false; + } +} + +export async function resume() { + // The same door as returning from historic: any pause leaves a hole the live ticks alone + // cannot fill (and in comparison mode nothing else fills it, since backfill only feeds the + // single-WAN buffer), so resuming reloads history rather than just restarting the timers. + await enterLive(); } // Render the historic view at a given playhead time from the current buffer. @@ -736,7 +1123,9 @@ export function resume() { // hover, kicking the user out of tooltip inspection while the background timeline // advances - the exact behavior the hover-hold exists to preserve. function renderHistoric(at, force = false) { - if (!chart || buffer.length === 0) return; + // The empty-buffer hold is single-WAN only: comparison mode draws from compareBuffers + // and an empty single-WAN buffer must not abort its only paused draw. + if (!chart || (!comparing() && buffer.length === 0)) return; if (!force && Date.now() > clickRenderUntil && tooltipShowing()) return; const halfWindow = HISTORY_MINUTES * 60000 / 2; const maxTime = Math.min(at + halfWindow, Date.now()); @@ -754,11 +1143,25 @@ function renderHistoric(at, force = false) { offsetY: -5, } }; - chart.updateOptions({ + // Same window and playhead either way; only the RTT axis rescale is single-WAN, since there is + // no RTT axis to rescale while comparing. + const histWindow = { xaxis: { min: maxTime - HISTORY_MINUTES * 60000, max: maxTime }, - yaxis: [chart.opts.yaxis[0], chart.opts.yaxis[1], chart.opts.yaxis[2], { ...chart.opts.yaxis[3], max: rttYMax() }], annotations: { xaxis: [...buildTimeTicks(maxTime - HISTORY_MINUTES * 60000, maxTime), playhead] }, - }, false, false, false); + }; + chart.updateOptions(comparing() + ? histWindow + : { + ...histWindow, + yaxis: [chart.opts.yaxis[0], chart.opts.yaxis[1], chart.opts.yaxis[2], { ...chart.opts.yaxis[3], max: rttYMax() }], + }, false, false, false); + + // Scrubbing while comparing draws every WAN at the parked instant - the point of comparing is + // to read them against each other, and freezing the time only makes that easier. + if (comparing()) { + chart.updateSeries(compareSeries(), false); + return; + } chart.updateSeries([ { name: 'Download', data: buffer.map(p => ({ x: p.time, y: p.download })) }, { name: 'Upload', data: buffer.map(p => ({ x: p.time, y: p.upload })) }, @@ -794,15 +1197,15 @@ export async function seekTime(isoTimestamp) { // plain time grid, dropping the playhead. (Mode cluster visibility is // driven by the store's playstate events, not by seeks.) stopHistInterpolation(); - if (pollTimer) return; // already live - const liveGen = seekGen; - buffer = []; - await loadHistory(); - if (liveGen !== seekGen) return; // seeked again while loading - updateChart(); - pollTimer = setInterval(pollLive, pollMsOverride || pollMs); - scrollTimer = setInterval(updateChart, SCROLL_MS); - startBackfillCatchUp(); + // Forget the parked instant. Left set, anything that later asks "what time is the chart + // showing" is told a timestamp from a playback session that has ended - which sent a WAN + // filter change seeking back into history, stopping the live poll on the way, and left the + // chart empty on a window with no data and nothing running to refill it. + histAt = 0; + // enterLive owns the "already live" bail, the history reload (per-WAN buffers while + // comparing), and the timer start - shared with resume(), so the two return-to-live + // callbacks can no longer race each other into skipping the load. + await enterLive(); return; } // Historic mode: stop polling, fetch window centered on timestamp @@ -821,23 +1224,28 @@ export async function seekTime(isoTimestamp) { const maxTime = Math.min(at + halfWindow, Date.now()); const from = new Date(maxTime - HISTORY_MINUTES * 60000); const to = new Date(maxTime); - try { - const resp = await fetch( - `/api/monitoring/wan-live-chart-data?from=${from.toISOString()}&to=${to.toISOString()}`, - { credentials: 'same-origin' }); - if (!resp.ok) return; - const data = await resp.json(); - if (gen !== seekGen) return; // a newer seek (or return to live) superseded this one - applySampleInterval(data); - buffer = (data.points || []).map(p => ({ - time: new Date(p.time).getTime(), - download: p.downloadBps, - upload: p.uploadBps, - rtt: p.rttMs, - loss: p.lossPercent, - })); - } catch { return; } - if (buffer.length === 0) return; + // Comparison mode draws only from the per-WAN buffers, so the single-WAN fetch is + // skipped there - its failure returns were aborting the one draw a paused seek gets. + if (comparing()) { + await loadCompareHistory(at); + if (gen !== seekGen) return; + } else { + try { + const resp = await fetch(historyUrl(from, to), { credentials: 'same-origin' }); + if (!resp.ok) return; + const data = await resp.json(); + if (gen !== seekGen) return; // a newer seek (or return to live) superseded this one + applySampleInterval(data); + buffer = (data.points || []).map(p => ({ + time: new Date(p.time).getTime(), + download: p.downloadBps, + upload: p.uploadBps, + rtt: p.rttMs, + loss: p.lossPercent, + })); + } catch { return; } + if (buffer.length === 0) return; + } // Force the reposition draw only for a discrete/paused seek (deep-link, manual // scrub): it must land even under the cursor or it's never retried while paused. // During active playback leave it unforced so a hover still holds the redraw for @@ -867,6 +1275,8 @@ export async function seekTime(isoTimestamp) { } export function unmount() { + compareWans = []; + compareBuffers.clear(); mountGen++; stopHistInterpolation(); if (unsubFlow) { unsubFlow(); unsubFlow = null; } diff --git a/tests/NetworkOptimizer.AgentProtocol.Tests/AgentHelloCompatibilityTests.cs b/tests/NetworkOptimizer.AgentProtocol.Tests/AgentHelloCompatibilityTests.cs new file mode 100644 index 0000000000..c471d295f8 --- /dev/null +++ b/tests/NetworkOptimizer.AgentProtocol.Tests/AgentHelloCompatibilityTests.cs @@ -0,0 +1,82 @@ +using FluentAssertions; +using Google.Protobuf; +using Xunit; + +namespace NetworkOptimizer.AgentProtocol.Tests; + +/// +/// The hello has to stay readable in both directions across a rollout: agents and servers update +/// on their own schedules, and a capability the server guesses at is worse than one it never +/// offers. Every capability on it is an explicitly optional field so "no" and "did not say" stay +/// distinguishable, and no field number is ever reused. +/// +public class AgentHelloCompatibilityTests +{ + [Fact] + public void OldAgent_SaysNothingAboutSourceBinding() + { + // What an agent predating the field puts on the wire: the field simply is not there. + var oldHello = new AgentHello { AgentKey = "key", Version = "2.5.0", LanIp = "192.0.2.20" }; + + var parsed = AgentHello.Parser.ParseFrom(oldHello.ToByteArray()); + + parsed.HasSupportsSourceBind.Should().BeFalse(); + parsed.SupportsSourceBind.Should().BeFalse(); + } + + [Fact] + public void NewAgent_SayingNo_IsDistinguishableFromSayingNothing() + { + var windowsAgent = new AgentHello { AgentKey = "key", Version = "2.6.0", SupportsSourceBind = false }; + + var parsed = AgentHello.Parser.ParseFrom(windowsAgent.ToByteArray()); + + parsed.HasSupportsSourceBind.Should().BeTrue(); + parsed.SupportsSourceBind.Should().BeFalse(); + } + + [Fact] + public void NewAgent_SayingYes_RoundTrips() + { + var linuxAgent = new AgentHello { AgentKey = "key", Version = "2.6.0", SupportsSourceBind = true }; + + var parsed = AgentHello.Parser.ParseFrom(linuxAgent.ToByteArray()); + + parsed.HasSupportsSourceBind.Should().BeTrue(); + parsed.SupportsSourceBind.Should().BeTrue(); + } + + [Fact] + public void NewFieldDoesNotDisturbTheExistingOnes() + { + // An old SERVER parses a new agent's hello by skipping the unknown field, so everything it + // already reads has to survive alongside it. + var hello = new AgentHello + { + AgentKey = "key", + Version = "2.6.0", + LanIp = "192.0.2.20", + SpeedTestPort = 24443, + ServesSpeedTest = true, + SupportsSourceBind = true, + }; + + var parsed = AgentHello.Parser.ParseFrom(hello.ToByteArray()); + + parsed.AgentKey.Should().Be("key"); + parsed.LanIp.Should().Be("192.0.2.20"); + parsed.SpeedTestPort.Should().Be(24443); + parsed.HasServesSpeedTest.Should().BeTrue(); + parsed.ServesSpeedTest.Should().BeTrue(); + } + + [Fact] + public void ProbeTargetSpec_WithoutASource_LeavesTheAgentOnItsOwnDefault() + { + // Every target on a site with no WAN contexts carries an empty source, which ProbeRunner + // reads as "use the agent's configured default" - the behavior before contexts existed. + var spec = new ProbeTargetSpec { TargetId = "wan-1", Address = "192.0.2.1", ProbeMode = "icmp" }; + + AgentProtocol.ProbeTargetSpec.Parser.ParseFrom(spec.ToByteArray()).SourceIp.Should().BeEmpty(); + } +} diff --git a/tests/NetworkOptimizer.Alerts.Tests/StarlinkAlertResolutionTests.cs b/tests/NetworkOptimizer.Alerts.Tests/StarlinkAlertResolutionTests.cs new file mode 100644 index 0000000000..9b2f9547d7 --- /dev/null +++ b/tests/NetworkOptimizer.Alerts.Tests/StarlinkAlertResolutionTests.cs @@ -0,0 +1,168 @@ +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NetworkOptimizer.Alerts.Events; +using NetworkOptimizer.Alerts.Interfaces; +using NetworkOptimizer.Alerts.Models; +using NetworkOptimizer.Core.Enums; +using Xunit; + +namespace NetworkOptimizer.Alerts.Tests; + +/// +/// The open/close half of the Starlink dish alert family: which open alerts an incoming +/// starlink.* event closes. The promise is one open alert per (dish, condition) - a condition +/// that raises again supersedes its own open alert rather than stacking a second, a recovery +/// closes only the condition it names, and one dish's alerts never touch another's. +/// +public class StarlinkAlertResolutionTests +{ + private readonly AlertProcessingService _service; + private readonly Mock _repository = new(); + private readonly List<(string[] EventTypes, string DeviceId)> _resolveCalls = []; + + public StarlinkAlertResolutionTests() + { + _repository + .Setup(r => r.ResolveActiveAlertsAsync( + It.IsAny>(), It.IsAny(), It.IsAny())) + .Callback, string, CancellationToken>( + (types, deviceId, _) => _resolveCalls.Add((types.ToArray(), deviceId))) + .ReturnsAsync(new List()); + + var configuration = new Mock(); + configuration.Setup(c => c["HOST_NAME"]).Returns("host.example"); + + var cooldownTracker = new AlertCooldownTracker(); + _service = new AlertProcessingService( + NullLogger.Instance, + Mock.Of(), + Mock.Of(), + new AlertRuleEvaluator(cooldownTracker, NullLogger.Instance), + new AlertCorrelationService(NullLogger.Instance), + [], + cooldownTracker, + Mock.Of(), + configuration.Object); + } + + private static AlertEvent CreateEvent(string eventType, string? deviceId, + Dictionary? context = null) => new() + { + EventType = eventType, + Severity = AlertSeverity.Warning, + Source = "starlink", + Title = "Test alert", + DeviceId = deviceId, + Context = context ?? new Dictionary() + }; + + [Fact] + public void ConditionRaisingAgain_SupersedesItsOwnOpenAlert() + { + var targets = AlertProcessingService.GetStarlinkAlertsToResolve( + "starlink.obstructed", "starlink:3", null); + + targets.Should().ContainSingle(); + targets[0].DeviceId.Should().Be("starlink:3"); + targets[0].EventTypes.Should().Equal("starlink.obstructed"); + } + + /// A dish that clears its obstruction keeps any other alert it still has open. + [Fact] + public void Recovery_ClosesOnlyTheConditionItNames() + { + var targets = AlertProcessingService.GetStarlinkAlertsToResolve( + "starlink.recovered", "starlink:3", + new Dictionary { ["recovered_type"] = "starlink.obstructed" }); + + targets.Should().ContainSingle(); + targets[0].DeviceId.Should().Be("starlink:3"); + targets[0].EventTypes.Should().Equal("starlink.obstructed"); + } + + [Fact] + public void RecoveryNamingNoCondition_ClosesNothing() + { + AlertProcessingService.GetStarlinkAlertsToResolve("starlink.recovered", "starlink:3", null) + .Should().BeEmpty(); + AlertProcessingService.GetStarlinkAlertsToResolve("starlink.recovered", "starlink:3", + new Dictionary { ["recovered_type"] = "" }).Should().BeEmpty(); + } + + [Fact] + public void EventWithoutADish_ClosesNothing() + { + AlertProcessingService.GetStarlinkAlertsToResolve("starlink.obstructed", null, null).Should().BeEmpty(); + AlertProcessingService.GetStarlinkAlertsToResolve("starlink.obstructed", "", null).Should().BeEmpty(); + } + + [Theory] + [InlineData("monitoring.wan_outage")] + [InlineData("cellular.signal_poor")] + [InlineData("device.offline")] + public void EventOutsideTheStarlinkFamily_ClosesNothing(string eventType) + { + AlertProcessingService.GetStarlinkAlertsToResolve(eventType, "starlink:3", null).Should().BeEmpty(); + } + + [Fact] + public async Task ResolveSupersededAlertsAsync_Recovery_ResolvesTheNamedConditionOnThatDishOnly() + { + var evt = CreateEvent("starlink.recovered", "starlink:3", + new Dictionary { ["recovered_type"] = "starlink.alignment_drift" }); + + await _service.ResolveSupersededAlertsAsync(evt, _repository.Object, CancellationToken.None); + + _resolveCalls.Should().ContainSingle(); + _resolveCalls[0].DeviceId.Should().Be("starlink:3"); + _resolveCalls[0].EventTypes.Should().Equal("starlink.alignment_drift"); + } + + [Fact] + public async Task ResolveSupersededAlertsAsync_NeverTouchesAnotherDishsAlerts() + { + var evt = CreateEvent("starlink.obstructed", "starlink:3"); + + await _service.ResolveSupersededAlertsAsync(evt, _repository.Object, CancellationToken.None); + + _resolveCalls.Select(c => c.DeviceId).Should().Equal("starlink:3"); + } + + /// + /// Every Starlink rule must ship with no cooldown, and this is not a style preference. + /// A re-published condition supersedes its own open alert, and the resolution runs BEFORE + /// rules are consulted - while the cooldown key is per (site, rule, device), which a + /// replacement shares with the alert it just closed. Give any of these a cooldown and an + /// obstruction escalating Warning -> Critical inside it resolves the Warning and has the + /// Critical suppressed, leaving a critically obstructed dish with no open alert. + /// + [Fact] + public void EveryStarlinkRule_ShipsWithNoCooldown() + { + var starlink = DefaultAlertRules.GetDefaults() + .Where(r => r.Source == "starlink") + .ToList(); + + starlink.Should().NotBeEmpty(); + starlink.Should().OnlyContain(r => r.CooldownSeconds == 0); + } + + [Fact] + public async Task ResolveSupersededAlertsAsync_RepositoryThrows_DoesNotPropagate() + { + _repository + .Setup(r => r.ResolveActiveAlertsAsync( + It.IsAny>(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("DB error")); + + var evt = CreateEvent("starlink.recovered", "starlink:3", + new Dictionary { ["recovered_type"] = "starlink.obstructed" }); + + var act = async () => await _service.ResolveSupersededAlertsAsync(evt, _repository.Object, CancellationToken.None); + + await act.Should().NotThrowAsync(); + } +} diff --git a/tests/NetworkOptimizer.Alerts.Tests/WanOutageAlertResolutionTests.cs b/tests/NetworkOptimizer.Alerts.Tests/WanOutageAlertResolutionTests.cs new file mode 100644 index 0000000000..764ca7b0ef --- /dev/null +++ b/tests/NetworkOptimizer.Alerts.Tests/WanOutageAlertResolutionTests.cs @@ -0,0 +1,254 @@ +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NetworkOptimizer.Alerts.Events; +using NetworkOptimizer.Alerts.Interfaces; +using NetworkOptimizer.Alerts.Models; +using NetworkOptimizer.Core.Enums; +using Xunit; + +namespace NetworkOptimizer.Alerts.Tests; + +/// +/// The open/close half of the WAN outage alert family: which open alerts an incoming +/// monitoring.wan_* event closes, and what that does to the incident they belonged to. +/// +public class WanOutageAlertResolutionTests +{ + private readonly AlertProcessingService _service; + private readonly Mock _repository = new(); + private readonly List<(string[] EventTypes, string DeviceId)> _resolveCalls = []; + + public WanOutageAlertResolutionTests() + { + _repository + .Setup(r => r.ResolveActiveAlertsAsync( + It.IsAny>(), It.IsAny(), It.IsAny())) + .Callback, string, CancellationToken>( + (types, deviceId, _) => _resolveCalls.Add((types.ToArray(), deviceId))) + .ReturnsAsync(new List()); + + var configuration = new Mock(); + configuration.Setup(c => c["HOST_NAME"]).Returns("host.example"); + + var cooldownTracker = new AlertCooldownTracker(); + _service = new AlertProcessingService( + NullLogger.Instance, + Mock.Of(), + Mock.Of(), + new AlertRuleEvaluator(cooldownTracker, NullLogger.Instance), + new AlertCorrelationService(NullLogger.Instance), + [], + cooldownTracker, + Mock.Of(), + configuration.Object); + } + + private static AlertEvent CreateEvent(string eventType, string? deviceId) => new() + { + EventType = eventType, + Severity = AlertSeverity.Critical, + Source = "monitoring", + Title = "Test alert", + DeviceId = deviceId + }; + + private void SetupResolved(string eventType, string deviceId, params AlertHistoryEntry[] resolved) + { + _repository + .Setup(r => r.ResolveActiveAlertsAsync( + It.Is>(t => t.Contains(eventType)), + deviceId, + It.IsAny())) + .Callback, string, CancellationToken>( + (types, device, _) => _resolveCalls.Add((types.ToArray(), device))) + .ReturnsAsync(resolved.ToList()); + } + + #region GetWanAlertsToResolve + + [Fact] + public void GetWanAlertsToResolve_TotalOutage_SupersedesThePartialOnTheSameWan() + { + var targets = AlertProcessingService.GetWanAlertsToResolve("monitoring.wan_outage", "wan2"); + + targets.Should().ContainSingle(); + targets[0].DeviceId.Should().Be("wan2"); + targets[0].EventTypes.Should().Equal("monitoring.wan_outage_partial"); + } + + /// + /// The rollup says the whole site is down, which is the same outage every per-WAN alert was + /// describing a piece of - so it closes them all, whatever WAN they name. + /// + [Fact] + public void GetWanAlertsToResolve_SiteRollupOutage_ClosesEveryPerWanAlert() + { + var targets = AlertProcessingService.GetWanAlertsToResolve("monitoring.wan_outage", "all-wans"); + + targets.Should().ContainSingle(); + targets[0].DeviceId.Should().BeNull("a null device id means every device"); + targets[0].EventTypes.Should().Equal("monitoring.wan_outage", "monitoring.wan_outage_partial"); + } + + [Fact] + public void GetWanAlertsToResolve_OutageWithoutDeviceId_ClosesNothing() + { + AlertProcessingService.GetWanAlertsToResolve("monitoring.wan_outage", null).Should().BeEmpty(); + AlertProcessingService.GetWanAlertsToResolve("monitoring.wan_outage", "").Should().BeEmpty(); + } + + [Fact] + public void GetWanAlertsToResolve_Recovery_ClosesBothKindsOnTheWanAndTheRollup() + { + var targets = AlertProcessingService.GetWanAlertsToResolve("monitoring.wan_recovered", "wan"); + + targets.Should().HaveCount(2); + targets[0].DeviceId.Should().Be("wan"); + targets[0].EventTypes.Should().BeEquivalentTo(new[] { "monitoring.wan_outage", "monitoring.wan_outage_partial" }); + targets[1].DeviceId.Should().Be("all-wans"); + targets[1].EventTypes.Should().Equal("monitoring.wan_outage"); + } + + [Fact] + public void GetWanAlertsToResolve_RecoveryWithoutDeviceId_StillClosesTheRollup() + { + var targets = AlertProcessingService.GetWanAlertsToResolve("monitoring.wan_recovered", null); + + targets.Should().ContainSingle(); + targets[0].DeviceId.Should().Be("all-wans"); + targets[0].EventTypes.Should().Equal("monitoring.wan_outage"); + } + + [Theory] + [InlineData("monitoring.target_offline")] + [InlineData("monitoring.target_recovered")] + [InlineData("device.offline")] + public void GetWanAlertsToResolve_EventOutsideTheWanFamily_ClosesNothing(string eventType) + { + AlertProcessingService.GetWanAlertsToResolve(eventType, "wan").Should().BeEmpty(); + } + + #endregion + + #region ResolveSupersededAlertsAsync + + [Fact] + public async Task ResolveSupersededAlertsAsync_TotalOutage_ResolvesOnlyThatWansPartial() + { + var evt = CreateEvent("monitoring.wan_outage", "wan2"); + + await _service.ResolveSupersededAlertsAsync(evt, _repository.Object, CancellationToken.None); + + _resolveCalls.Should().ContainSingle(); + _resolveCalls[0].DeviceId.Should().Be("wan2"); + _resolveCalls[0].EventTypes.Should().Equal("monitoring.wan_outage_partial"); + } + + [Fact] + public async Task ResolveSupersededAlertsAsync_Recovery_ResolvesOutagePartialAndRollup() + { + var evt = CreateEvent("monitoring.wan_recovered", "wan2"); + + await _service.ResolveSupersededAlertsAsync(evt, _repository.Object, CancellationToken.None); + + _resolveCalls.Should().HaveCount(2); + _resolveCalls.Select(c => c.DeviceId).Should().Equal("wan2", "all-wans"); + _resolveCalls[0].EventTypes.Should().BeEquivalentTo(new[] { "monitoring.wan_outage", "monitoring.wan_outage_partial" }); + _resolveCalls[1].EventTypes.Should().Equal("monitoring.wan_outage"); + } + + [Fact] + public async Task ResolveSupersededAlertsAsync_NeverTouchesAnotherWansAlerts() + { + var evt = CreateEvent("monitoring.wan_recovered", "wan2"); + + await _service.ResolveSupersededAlertsAsync(evt, _repository.Object, CancellationToken.None); + + // Only this WAN and the site rollup - "wan" and "wan3" keep their open alerts, and the + // repository handed in is already pinned to the event's site, so other sites are untouched. + _resolveCalls.Select(c => c.DeviceId).Should().NotContain("wan").And.NotContain("wan3"); + } + + [Fact] + public async Task ResolveSupersededAlertsAsync_EventOutsideTheWanFamily_ResolvesNothing() + { + var evt = CreateEvent("monitoring.target_offline", "wan2"); + + await _service.ResolveSupersededAlertsAsync(evt, _repository.Object, CancellationToken.None); + + _resolveCalls.Should().BeEmpty(); + _repository.Verify(r => r.ResolveActiveAlertsAsync( + It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ResolveSupersededAlertsAsync_ResolvedAlertInIncident_RecalculatesIncidentStatus() + { + var resolved = new AlertHistoryEntry + { + Id = 5, + EventType = "monitoring.wan_outage_partial", + DeviceId = "wan2", + IncidentId = 7, + Status = AlertStatus.Resolved + }; + SetupResolved("monitoring.wan_outage_partial", "wan2", resolved); + + var incident = new AlertIncident { Id = 7, Status = AlertStatus.Active }; + _repository.Setup(r => r.GetIncidentAsync(7, It.IsAny())).ReturnsAsync(incident); + _repository.Setup(r => r.GetAlertsByIncidentIdAsync(7, It.IsAny())) + .ReturnsAsync(new List { resolved }); + + var evt = CreateEvent("monitoring.wan_outage", "wan2"); + await _service.ResolveSupersededAlertsAsync(evt, _repository.Object, CancellationToken.None); + + incident.Status.Should().Be(AlertStatus.Resolved); + incident.ResolvedAt.Should().NotBeNull(); + _repository.Verify(r => r.UpdateIncidentAsync(incident, It.IsAny()), Times.Once); + } + + [Fact] + public async Task ResolveSupersededAlertsAsync_IncidentStillHasActiveAlerts_LeavesIncidentOpen() + { + var resolved = new AlertHistoryEntry + { + Id = 5, + EventType = "monitoring.wan_outage_partial", + DeviceId = "wan2", + IncidentId = 7, + Status = AlertStatus.Resolved + }; + SetupResolved("monitoring.wan_outage_partial", "wan2", resolved); + + var incident = new AlertIncident { Id = 7, Status = AlertStatus.Active }; + _repository.Setup(r => r.GetIncidentAsync(7, It.IsAny())).ReturnsAsync(incident); + _repository.Setup(r => r.GetAlertsByIncidentIdAsync(7, It.IsAny())) + .ReturnsAsync(new List { resolved, new() { Id = 6, Status = AlertStatus.Active } }); + + var evt = CreateEvent("monitoring.wan_outage", "wan2"); + await _service.ResolveSupersededAlertsAsync(evt, _repository.Object, CancellationToken.None); + + incident.Status.Should().Be(AlertStatus.Active); + _repository.Verify(r => r.UpdateIncidentAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ResolveSupersededAlertsAsync_RepositoryThrows_DoesNotPropagate() + { + _repository + .Setup(r => r.ResolveActiveAlertsAsync( + It.IsAny>(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("DB error")); + + var evt = CreateEvent("monitoring.wan_recovered", "wan2"); + + var act = async () => await _service.ResolveSupersededAlertsAsync(evt, _repository.Object, CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + #endregion +} diff --git a/tests/NetworkOptimizer.Diagnostics.Tests/Analyzers/PerformanceAnalyzerTests.cs b/tests/NetworkOptimizer.Diagnostics.Tests/Analyzers/PerformanceAnalyzerTests.cs index a3635662f6..924b1168ce 100644 --- a/tests/NetworkOptimizer.Diagnostics.Tests/Analyzers/PerformanceAnalyzerTests.cs +++ b/tests/NetworkOptimizer.Diagnostics.Tests/Analyzers/PerformanceAnalyzerTests.cs @@ -1980,4 +1980,132 @@ public void CheckSqmFirmwareRegression_UnaffectedGatewayModel_ReturnsEmpty() } #endregion + + #region SQM Not Shaping + + [Fact] + public void CheckSqmNotShaping_NoStates_ReturnsEmpty() + { + _analyzer.CheckSqmNotShaping(new List { CreateGateway() }, null) + .Should().BeEmpty(); + + _analyzer.CheckSqmNotShaping(new List { CreateGateway() }, new List()) + .Should().BeEmpty(); + } + + [Fact] + public void CheckSqmNotShaping_BothDirectionsShaped_ReturnsEmpty() + { + var states = new List { CreateShaperState(egressHtb: true, ingressHtb: true) }; + + var result = _analyzer.CheckSqmNotShaping(new List { CreateGateway() }, states); + + result.Should().BeEmpty(); + } + + [Fact] + public void CheckSqmNotShaping_NeitherDirectionShaped_ReportsBothInterfaces() + { + var states = new List { CreateShaperState(egressHtb: false, ingressHtb: false) }; + + var result = _analyzer.CheckSqmNotShaping(new List { CreateGateway() }, states); + + var issue = result.Should().ContainSingle().Subject; + issue.Title.Should().Be("Smart Queues Not Shaping on Fiber"); + issue.Severity.Should().Be(PerformanceSeverity.Recommendation); + issue.Category.Should().Be(PerformanceCategory.Performance); + issue.DeviceName.Should().Be("Test Gateway"); + issue.Description.Should().Contain("no shaper on ppp0 or ifbppp0"); + issue.Description.Should().Contain("running unshaped"); + issue.Recommendation.Should().Contain("QoS rule"); + } + + [Fact] + public void CheckSqmNotShaping_IfbDeviceMissing_ReportsDownloadUnshaped() + { + var states = new List { CreateShaperState(egressHtb: true, ingressFound: false) }; + + var result = _analyzer.CheckSqmNotShaping(new List { CreateGateway() }, states); + + var issue = result.Should().ContainSingle().Subject; + issue.Description.Should().Contain("only shaping upload"); + issue.Description.Should().Contain("ifbppp0 has no shaper"); + issue.Description.Should().Contain("download traffic is running unshaped"); + } + + [Fact] + public void CheckSqmNotShaping_EgressUnshaped_ReportsUploadUnshaped() + { + var states = new List { CreateShaperState(egressHtb: false, ingressHtb: true) }; + + var result = _analyzer.CheckSqmNotShaping(new List { CreateGateway() }, states); + + var issue = result.Should().ContainSingle().Subject; + issue.Description.Should().Contain("only shaping download"); + issue.Description.Should().Contain("ppp0 has no shaper"); + issue.Description.Should().Contain("upload traffic is running unshaped"); + } + + [Fact] + public void CheckSqmNotShaping_DirectionRatedZero_IsNotExpectedToShape() + { + var states = new List + { + CreateShaperState(egressHtb: false, ingressHtb: true, upRateMbps: 0) + }; + + var result = _analyzer.CheckSqmNotShaping(new List { CreateGateway() }, states); + + result.Should().BeEmpty(); + } + + [Fact] + public void CheckSqmNotShaping_WanInterfaceNotOnGateway_ReturnsEmpty() + { + // We resolved a device name the box doesn't have - that says nothing about UniFi's + // provisioning, so it must not surface as a finding. + var states = new List { CreateShaperState(egressFound: false, ingressFound: false) }; + + var result = _analyzer.CheckSqmNotShaping(new List { CreateGateway() }, states); + + result.Should().BeEmpty(); + } + + [Fact] + public void CheckSqmNotShaping_MultipleWans_ReportsOnlyTheUnshapedOne() + { + var states = new List + { + CreateShaperState(egressHtb: true, ingressHtb: true), + CreateShaperState(egressHtb: false, ingressHtb: false, wanName: "Cable", ifName: "eth7") + }; + + var result = _analyzer.CheckSqmNotShaping(new List { CreateGateway() }, states); + + result.Should().ContainSingle().Which.Title.Should().Be("Smart Queues Not Shaping on Cable"); + } + + private static WanShaperState CreateShaperState( + bool egressHtb = false, + bool ingressHtb = false, + bool egressFound = true, + bool ingressFound = true, + int? downRateMbps = 900, + int? upRateMbps = 500, + string wanName = "Fiber", + string ifName = "ppp0") + { + return new WanShaperState + { + WanName = wanName, + Interface = ifName, + IfbInterface = $"ifb{ifName}", + DownRateMbps = downRateMbps, + UpRateMbps = upRateMbps, + Egress = new TcDeviceState { DeviceFound = egressFound, HasRootHtb = egressHtb }, + Ingress = new TcDeviceState { DeviceFound = ingressFound, HasRootHtb = ingressHtb } + }; + } + + #endregion } diff --git a/tests/NetworkOptimizer.Monitoring.Tests/Probes/TcpBindAddressTests.cs b/tests/NetworkOptimizer.Monitoring.Tests/Probes/TcpBindAddressTests.cs new file mode 100644 index 0000000000..22deea820e --- /dev/null +++ b/tests/NetworkOptimizer.Monitoring.Tests/Probes/TcpBindAddressTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using FluentAssertions; +using NetworkOptimizer.Monitoring.Probes; +using Xunit; + +namespace NetworkOptimizer.Monitoring.Tests.Probes; + +/// +/// A TCP probe binds an address, so a WAN context that names an interface has to be resolved to +/// that interface's current address at probe time. Doing it at probe time rather than at push time +/// is what keeps a DHCP or PPPoE WAN working: its address moves, and a stale one binds nothing. +/// +public class TcpBindAddressTests +{ + private static IReadOnlyList NoAddresses(string _) => Array.Empty(); + + [Fact] + public void IpLiteral_IsUsedDirectly() + { + var (address, error) = LocalProbeExecutor.ResolveTcpBindAddress("192.0.2.10", NoAddresses); + + address.Should().Be(IPAddress.Parse("192.0.2.10")); + error.Should().BeNull(); + } + + [Fact] + public void InterfaceName_ResolvesToItsCurrentIPv4Address() + { + var (address, error) = LocalProbeExecutor.ResolveTcpBindAddress( + "eth8", _ => new[] { IPAddress.Parse("198.51.100.7") }); + + address.Should().Be(IPAddress.Parse("198.51.100.7")); + error.Should().BeNull(); + } + + [Fact] + public void InterfaceName_SkipsIPv6AndTakesTheIPv4Address() + { + var (address, error) = LocalProbeExecutor.ResolveTcpBindAddress( + "ppp0", _ => new[] { IPAddress.Parse("2001:db8::1"), IPAddress.Parse("198.51.100.7") }); + + address.Should().Be(IPAddress.Parse("198.51.100.7")); + error.Should().BeNull(); + } + + [Fact] + public void InterfaceWithNoIPv4Address_FailsLoudlyRatherThanProbingUnbound() + { + // An unbound probe leaves by the default route and records another WAN's latency under + // this one's name, which reads as data rather than as a failure. + var (address, error) = LocalProbeExecutor.ResolveTcpBindAddress( + "ppp0", _ => new[] { IPAddress.Parse("2001:db8::1") }); + + address.Should().BeNull(); + error.Should().Contain("ppp0").And.Contain("IPv4"); + } + + [Fact] + public void UnknownInterface_Fails() + { + var (address, error) = LocalProbeExecutor.ResolveTcpBindAddress("eth9", NoAddresses); + + address.Should().BeNull(); + error.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void UnsafeSourceValue_IsRejectedBeforeAnyLookup() + { + var looked = false; + var (address, error) = LocalProbeExecutor.ResolveTcpBindAddress( + "eth0; rm -rf /", _ => { looked = true; return Array.Empty(); }); + + address.Should().BeNull(); + error.Should().Contain("Invalid probe source"); + looked.Should().BeFalse(); + } +} diff --git a/tests/NetworkOptimizer.Monitoring.Tests/Probes/TracerouteCommandTests.cs b/tests/NetworkOptimizer.Monitoring.Tests/Probes/TracerouteCommandTests.cs new file mode 100644 index 0000000000..275b02ce26 --- /dev/null +++ b/tests/NetworkOptimizer.Monitoring.Tests/Probes/TracerouteCommandTests.cs @@ -0,0 +1,158 @@ +using FluentAssertions; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Monitoring.Probes; +using Xunit; + +namespace NetworkOptimizer.Monitoring.Tests.Probes; + +/// +/// Traceroute is what discovers a WAN's upstream path, so on a multi-WAN install it has to leave +/// by the WAN being discovered. These cover the source binding it grew: an interface name becomes +/// -i, an IP becomes -s, and anything that can't be bound - a hostile value, a binary without the +/// options, the Windows managed path - fails instead of tracing out the default route and filing +/// another WAN's upstream under this one. +/// +public class TracerouteCommandTests +{ + private static readonly LocalProbeExecutor.TracerouteBinaryTraits Gnu = + LocalProbeExecutor.TracerouteBinaryTraits.FullyBindable; + + [Fact] + public void NoSource_BuildsTheSameCommandItAlwaysHas() + { + // The single-WAN case: nothing about the command changes. + var (exe, args, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp), maxHops: 30, perHopTimeout: TimeSpan.FromSeconds(2), Gnu, isWindows: false); + + error.Should().BeNull(); + exe.Should().Be("traceroute"); + args.Should().Be("-m 30 -q 2 -w 2 -I 192.0.2.1"); + } + + [Fact] + public void InterfaceName_BindsWithDashI() + { + var (_, args, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp, null, "eth8"), 30, TimeSpan.FromSeconds(2), Gnu, isWindows: false); + + error.Should().BeNull(); + args.Should().Be("-m 30 -q 2 -w 2 -I -i eth8 192.0.2.1"); + } + + [Fact] + public void IpLiteral_BindsWithDashS() + { + var (_, args, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Udp, null, "198.51.100.7"), 30, TimeSpan.FromSeconds(2), Gnu, isWindows: false); + + error.Should().BeNull(); + args.Should().Contain("-s 198.51.100.7").And.NotContain("-i "); + } + + [Fact] + public void UnsafeSourceValue_FailsInsteadOfReachingTheCommandLine() + { + var (_, args, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp, null, "eth0; rm -rf /"), 30, TimeSpan.FromSeconds(2), Gnu, isWindows: false); + + error.Should().Contain("Invalid probe source"); + args.Should().BeEmpty(); + } + + [Fact] + public void BusyBoxWithoutTheOptions_FailsRatherThanTracingUnbound() + { + var stripped = new LocalProbeExecutor.TracerouteBinaryTraits( + IsBusyBox: true, CanBindAddress: false, CanBindInterface: false); + + var iface = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp, null, "eth8"), 30, TimeSpan.FromSeconds(2), stripped, isWindows: false); + var address = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp, null, "198.51.100.7"), 30, TimeSpan.FromSeconds(2), stripped, isWindows: false); + + iface.Error.Should().Contain("source interface").And.Contain("eth8"); + address.Error.Should().Contain("source address"); + } + + [Fact] + public void BusyBoxWithoutTheOptions_StillTracesWhenNothingAskedForABind() + { + var stripped = new LocalProbeExecutor.TracerouteBinaryTraits( + IsBusyBox: true, CanBindAddress: false, CanBindInterface: false); + + var (_, args, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp), 30, TimeSpan.FromSeconds(2), stripped, isWindows: false); + + error.Should().BeNull(); + args.Should().Be("-m 30 -q 2 -w 2 -I 192.0.2.1"); + } + + [Fact] + public void Windows_CannotBindAtAllAndSaysSo() + { + // tracert.exe has no source option, and the Windows managed path can't bind either - + // the same loud failure the managed ping path gives rather than a wrong-WAN reading. + var (_, _, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp, null, "198.51.100.7"), 30, TimeSpan.FromSeconds(2), + Gnu, isWindows: true); + + error.Should().Contain("native traceroute binary"); + } + + [Fact] + public void Windows_WithoutASourceBuildsTheTracertCommandItAlwaysHas() + { + var (exe, args, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp), 30, TimeSpan.FromSeconds(2), Gnu, isWindows: true); + + error.Should().BeNull(); + exe.Should().Be("tracert.exe"); + args.Should().Be("-h 30 -w 2000 192.0.2.1"); + } + + [Fact] + public void BusyBoxUsageListingBothOptions_ReadsAsBindable() + { + const string usage = + "BusyBox v1.36.1 (2024-01-01) multi-call binary.\n" + + "Usage: traceroute [-46FIlnrv] [-f 1ST_TTL] [-m MAXTTL] [-q PROBES] [-s SRC_IP]\n" + + " [-t TOS] [-w WAIT_SEC] [-G GATEWAY] [-i IFACE] HOST [BYTES]"; + + var traits = LocalProbeExecutor.InterpretTracerouteBanner(usage); + + traits.IsBusyBox.Should().BeTrue(); + traits.CanBindAddress.Should().BeTrue(); + traits.CanBindInterface.Should().BeTrue(); + } + + [Fact] + public void BusyBoxUsageWithoutSourceOptions_ReadsAsUnbindable() + { + const string usage = + "BusyBox v1.36.1 multi-call binary.\n" + + "Usage: traceroute [-46Fln] [-m MAXTTL] [-q PROBES] [-w WAIT_SEC] HOST [BYTES]"; + + var traits = LocalProbeExecutor.InterpretTracerouteBanner(usage); + + traits.IsBusyBox.Should().BeTrue(); + traits.CanBindAddress.Should().BeFalse(); + traits.CanBindInterface.Should().BeFalse(); + } + + [Theory] + [InlineData("Modern traceroute for Linux, version 2.1.0")] + [InlineData("Version 1.4a12")] + [InlineData("")] + [InlineData(null)] + public void AnythingButBusyBox_ReadsAsFullyBindable(string? banner) + { + // GNU traceroute and BSD traceroute both document -s and -i, and a binary that answered + // nothing gets the same benefit of the doubt: an option it doesn't have makes the command + // fail loudly, which is still not a silently unbound probe. + var traits = LocalProbeExecutor.InterpretTracerouteBanner(banner); + + traits.IsBusyBox.Should().BeFalse(); + traits.CanBindAddress.Should().BeTrue(); + traits.CanBindInterface.Should().BeTrue(); + } +} diff --git a/tests/NetworkOptimizer.Storage.Tests/AlertRepositoryTests.cs b/tests/NetworkOptimizer.Storage.Tests/AlertRepositoryTests.cs new file mode 100644 index 0000000000..4bd9fc3288 --- /dev/null +++ b/tests/NetworkOptimizer.Storage.Tests/AlertRepositoryTests.cs @@ -0,0 +1,148 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using NetworkOptimizer.Alerts.Models; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Storage.Repositories; +using Xunit; + +namespace NetworkOptimizer.Storage.Tests; + +public class AlertRepositoryTests : IDisposable +{ + private readonly string _databaseName = Guid.NewGuid().ToString(); + private readonly NetworkOptimizerDbContext _context; + private readonly AlertRepository _repository; + + public AlertRepositoryTests() + { + _context = CreateContext(); + _repository = new AlertRepository(_context, new Mock>().Object); + } + + private NetworkOptimizerDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: _databaseName) + .Options; + return new NetworkOptimizerDbContext(options); + } + + public void Dispose() + { + _context.Dispose(); + } + + private async Task SeedAlertAsync( + string eventType, + string? deviceId, + AlertStatus status = AlertStatus.Active) + { + var alert = new AlertHistoryEntry + { + EventType = eventType, + Severity = AlertSeverity.Critical, + Status = status, + Source = "monitoring", + Title = "Test alert", + DeviceId = deviceId, + TriggeredAt = DateTime.UtcNow + }; + + _context.AlertHistory.Add(alert); + await _context.SaveChangesAsync(); + return alert; + } + + #region ResolveActiveAlertsAsync + + [Fact] + public async Task ResolveActiveAlertsAsync_ResolvesOnlyMatchingEventTypeAndDevice() + { + var partialOnWan2 = await SeedAlertAsync("monitoring.wan_outage_partial", "wan2"); + var partialOnWan = await SeedAlertAsync("monitoring.wan_outage_partial", "wan"); + var outageOnWan2 = await SeedAlertAsync("monitoring.wan_outage", "wan2"); + var rollup = await SeedAlertAsync("monitoring.wan_outage", "all-wans"); + + var resolved = await _repository.ResolveActiveAlertsAsync(["monitoring.wan_outage_partial"], "wan2"); + + resolved.Should().ContainSingle(); + resolved[0].Id.Should().Be(partialOnWan2.Id); + + using var verify = CreateContext(); + var stored = await verify.AlertHistory.AsNoTracking().ToDictionaryAsync(a => a.Id, a => a.Status); + stored[partialOnWan2.Id].Should().Be(AlertStatus.Resolved); + stored[partialOnWan.Id].Should().Be(AlertStatus.Active); + stored[outageOnWan2.Id].Should().Be(AlertStatus.Active); + stored[rollup.Id].Should().Be(AlertStatus.Active); + } + + [Fact] + public async Task ResolveActiveAlertsAsync_ResolvesEveryListedEventTypeOnTheDevice() + { + var outage = await SeedAlertAsync("monitoring.wan_outage", "wan2"); + var partial = await SeedAlertAsync("monitoring.wan_outage_partial", "wan2"); + + var resolved = await _repository.ResolveActiveAlertsAsync( + ["monitoring.wan_outage", "monitoring.wan_outage_partial"], "wan2"); + + resolved.Select(a => a.Id).Should().BeEquivalentTo(new[] { outage.Id, partial.Id }); + resolved.Should().OnlyContain(a => a.Status == AlertStatus.Resolved); + } + + [Fact] + public async Task ResolveActiveAlertsAsync_LeavesAcknowledgedAndAlreadyResolvedEntriesAlone() + { + var acknowledged = await SeedAlertAsync("monitoring.wan_outage", "wan2", AlertStatus.Acknowledged); + var alreadyResolved = await SeedAlertAsync("monitoring.wan_outage", "wan2", AlertStatus.Resolved); + + var resolved = await _repository.ResolveActiveAlertsAsync(["monitoring.wan_outage"], "wan2"); + + resolved.Should().BeEmpty(); + + using var verify = CreateContext(); + var stored = await verify.AlertHistory.AsNoTracking().ToDictionaryAsync(a => a.Id, a => a.Status); + stored[acknowledged.Id].Should().Be(AlertStatus.Acknowledged); + stored[alreadyResolved.Id].Should().Be(AlertStatus.Resolved); + } + + [Fact] + public async Task ResolveActiveAlertsAsync_StampsResolvedAt() + { + var before = DateTime.UtcNow; + await SeedAlertAsync("monitoring.wan_outage", "wan2"); + + var resolved = await _repository.ResolveActiveAlertsAsync(["monitoring.wan_outage"], "wan2"); + + resolved.Should().ContainSingle(); + resolved[0].ResolvedAt.Should().NotBeNull(); + resolved[0].ResolvedAt!.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow); + } + + [Fact] + public async Task ResolveActiveAlertsAsync_NoMatches_ReturnsEmpty() + { + await SeedAlertAsync("monitoring.wan_outage", "wan"); + + var resolved = await _repository.ResolveActiveAlertsAsync(["monitoring.wan_outage"], "wan2"); + + resolved.Should().BeEmpty(); + } + + [Fact] + public async Task ResolveActiveAlertsAsync_NoEventTypesOrNoDevice_ReturnsEmpty() + { + var alert = await SeedAlertAsync("monitoring.wan_outage", "wan2"); + + (await _repository.ResolveActiveAlertsAsync([], "wan2")).Should().BeEmpty(); + (await _repository.ResolveActiveAlertsAsync(["monitoring.wan_outage"], "")).Should().BeEmpty(); + + using var verify = CreateContext(); + var stored = await verify.AlertHistory.AsNoTracking().FirstAsync(a => a.Id == alert.Id); + stored.Status.Should().Be(AlertStatus.Active); + } + + #endregion +} diff --git a/tests/NetworkOptimizer.Storage.Tests/LegacyWan1KeyNormalizationTests.cs b/tests/NetworkOptimizer.Storage.Tests/LegacyWan1KeyNormalizationTests.cs new file mode 100644 index 0000000000..60aff79893 --- /dev/null +++ b/tests/NetworkOptimizer.Storage.Tests/LegacyWan1KeyNormalizationTests.cs @@ -0,0 +1,185 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Storage; +using NetworkOptimizer.Storage.Models; +using Xunit; + +namespace NetworkOptimizer.Storage.Tests; + +/// +/// Migration 20260521500000 stamped the rows it found 'wan1'; every writer since uses 'wan'. The +/// two spell the same WAN, and nothing minded until per-WAN reading arrived - at which point a +/// 'wan' discovery run stops recognizing 'wan1' rows as its own and duplicates them, and the 'wan' +/// report stops counting them. NormalizeLegacyWan1Key folds the legacy spelling into the current +/// one, here against a real SQLite database through the real migration pipeline. +/// +public class LegacyWan1KeyNormalizationTests : IDisposable +{ + // The migration applied immediately before the normalization. + private const string PreNormalizeMigration = "20260803210000_BackfillWanContextTargetWan"; + + private readonly string _dbPath; + + public LegacyWan1KeyNormalizationTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"no-wan1-normalize-test-{Guid.NewGuid():N}.db"); + } + + public void Dispose() + { + foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" }) + { + var path = _dbPath + suffix; + if (File.Exists(path)) + { + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + } + } + + private NetworkOptimizerDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"DataSource={_dbPath}") + .Options; + return new NetworkOptimizerDbContext(options); + } + + private static MonitoringTarget Target(string targetId, string? wanInterface) => new() + { + TargetId = targetId, + Name = targetId, + Address = "203.0.113.10", + TargetType = MonitoringTargetType.AccessIsp, + ProbeMode = ProbeMode.Icmp, + WanInterface = wanInterface, + }; + + [Fact] + public void Normalize_RenamesTheLegacySpellingEverywhereItIsStored() + { + using (var context = CreateContext()) + { + context.GetService().Migrate(PreNormalizeMigration); + + context.WanDiscoveryContexts.Add(new WanDiscoveryContext { WanInterface = "wan1", AccessTechnology = AccessTechnology.Gpon }); + context.MonitoringTargets.Add(Target("access-legacy", "wan1")); + context.UpstreamDiscoveries.Add(new UpstreamDiscovery { HopIp = "192.0.2.30", HopNumber = 1, WanInterface = "wan1" }); + context.WanContexts.Add(new WanContext { Id = 1, Name = "legacy-context", WanInterface = "wan1", ProbeSourceIp = "198.51.100.9" }); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.WanDiscoveryContexts.Single().WanInterface.Should().Be("wan"); + context.WanDiscoveryContexts.Single().AccessTechnology.Should().Be(AccessTechnology.Gpon); + context.MonitoringTargets.Single().WanInterface.Should().Be("wan"); + context.UpstreamDiscoveries.Single().WanInterface.Should().Be("wan"); + context.WanContexts.Single().WanInterface.Should().Be("wan"); + } + } + + [Fact] + public void Normalize_KeepsTheNewerDiscoveryContextWhenBothSpellingsExist() + { + // WanDiscoveryContexts is keyed by the WAN, so the two rows cannot both survive the + // rename. The row describing the more recent discovery is the one worth keeping. + using (var context = CreateContext()) + { + context.GetService().Migrate(PreNormalizeMigration); + + context.WanDiscoveryContexts.Add(new WanDiscoveryContext + { + WanInterface = "wan1", + AccessTechnology = AccessTechnology.Docsis, + LastDiscoveryAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }); + context.WanDiscoveryContexts.Add(new WanDiscoveryContext + { + WanInterface = "wan", + AccessTechnology = AccessTechnology.XgsPon, + LastDiscoveryAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), + }); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.WanDiscoveryContexts.Single().Should().Match( + c => c.WanInterface == "wan" && c.AccessTechnology == AccessTechnology.XgsPon); + } + } + + [Fact] + public void Normalize_KeepsTheLegacyRowWhenItIsTheNewerOne() + { + using (var context = CreateContext()) + { + context.GetService().Migrate(PreNormalizeMigration); + + context.WanDiscoveryContexts.Add(new WanDiscoveryContext + { + WanInterface = "wan1", + AccessTechnology = AccessTechnology.XgsPon, + LastDiscoveryAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), + }); + context.WanDiscoveryContexts.Add(new WanDiscoveryContext + { + WanInterface = "wan", + AccessTechnology = AccessTechnology.Docsis, + LastDiscoveryAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.WanDiscoveryContexts.Single().Should().Match( + c => c.WanInterface == "wan" && c.AccessTechnology == AccessTechnology.XgsPon); + } + } + + [Fact] + public void Normalize_LeavesEveryOtherWanAlone() + { + using (var context = CreateContext()) + { + context.GetService().Migrate(PreNormalizeMigration); + + context.WanDiscoveryContexts.Add(new WanDiscoveryContext { WanInterface = "wan2" }); + context.MonitoringTargets.Add(Target("access-wan2", "wan2")); + context.MonitoringTargets.Add(Target("access-unstamped", null)); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.WanDiscoveryContexts.Single().WanInterface.Should().Be("wan2"); + context.MonitoringTargets.Single(t => t.TargetId == "access-wan2").WanInterface.Should().Be("wan2"); + context.MonitoringTargets.Single(t => t.TargetId == "access-unstamped").WanInterface.Should().BeNull(); + } + } + + [Fact] + public void Normalize_OnACleanDatabaseDoesNothingAndDoesNotThrow() + { + using var context = CreateContext(); + + var act = () => MigrationSafety.MigrateWithFriendlyErrors(context); + + act.Should().NotThrow(); + context.Database.GetPendingMigrations().Should().BeEmpty(); + context.WanDiscoveryContexts.Should().BeEmpty(); + } +} diff --git a/tests/NetworkOptimizer.Storage.Tests/WanContextTargetWanBackfillTests.cs b/tests/NetworkOptimizer.Storage.Tests/WanContextTargetWanBackfillTests.cs new file mode 100644 index 0000000000..d808743800 --- /dev/null +++ b/tests/NetworkOptimizer.Storage.Tests/WanContextTargetWanBackfillTests.cs @@ -0,0 +1,142 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Storage; +using NetworkOptimizer.Storage.Models; +using Xunit; + +namespace NetworkOptimizer.Storage.Tests; + +/// +/// A monitoring target carries two WAN keys: WanContextId (who probes it, assigned by hand) and +/// WanInterface (which WAN its data describes, written by upstream discovery). Contexts predate +/// the WAN column on WanContext, so a target assigned to a secondary WAN's context has been +/// carrying no WAN at all, or the primary's - which puts its data under the wrong WAN for every +/// per-WAN reader. The BackfillWanContextTargetWan migration reconciles the two against a real +/// SQLite database, through the real migration pipeline. +/// +public class WanContextTargetWanBackfillTests : IDisposable +{ + // The migration applied immediately before the backfill. + private const string PreBackfillMigration = "20260803193154_AddWanContextInterfaceBinding"; + + private readonly string _dbPath; + + public WanContextTargetWanBackfillTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"no-wan-backfill-test-{Guid.NewGuid():N}.db"); + } + + public void Dispose() + { + foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" }) + { + var path = _dbPath + suffix; + if (File.Exists(path)) + { + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + } + } + + private NetworkOptimizerDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"DataSource={_dbPath}") + .Options; + return new NetworkOptimizerDbContext(options); + } + + private static MonitoringTarget Target(string targetId, string address, int? contextId, string? wanInterface) => new() + { + TargetId = targetId, + Name = targetId, + Address = address, + TargetType = MonitoringTargetType.Custom, + ProbeMode = ProbeMode.Icmp, + WanContextId = contextId, + WanInterface = wanInterface, + }; + + [Fact] + public void Backfill_GivesAContextsTargetsTheContextsWan() + { + using (var context = CreateContext()) + { + context.GetService().Migrate(PreBackfillMigration); + + context.WanContexts.Add(new WanContext { Id = 1, Name = "backup-wan", WanInterface = "wan2", ProbeSourceIp = "198.51.100.7" }); + // Assigned to the context but never given a WAN, and assigned but stamped with the + // primary's WAN by a discovery that predates per-WAN contexts. + context.MonitoringTargets.Add(Target("t-unstamped", "203.0.113.1", contextId: 1, wanInterface: null)); + context.MonitoringTargets.Add(Target("t-wrong-wan", "203.0.113.2", contextId: 1, wanInterface: "wan")); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.MonitoringTargets.OrderBy(t => t.TargetId) + .Select(t => t.WanInterface).ToList() + .Should().Equal("wan2", "wan2"); + } + } + + [Fact] + public void Backfill_LeavesTargetsWithNoContextAlone() + { + // Every target on a single-WAN install: no context, so nothing to reconcile against. + using (var context = CreateContext()) + { + context.GetService().Migrate(PreBackfillMigration); + + context.MonitoringTargets.Add(Target("t-primary", "203.0.113.3", contextId: null, wanInterface: "wan")); + context.MonitoringTargets.Add(Target("t-manual", "203.0.113.4", contextId: null, wanInterface: null)); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.MonitoringTargets.Single(t => t.TargetId == "t-primary").WanInterface.Should().Be("wan"); + context.MonitoringTargets.Single(t => t.TargetId == "t-manual").WanInterface.Should().BeNull(); + } + } + + [Fact] + public void Backfill_LeavesTargetsOfAContextThatNamesNoWanAlone() + { + // A context created before the WAN column exists has nothing to copy down. + using (var context = CreateContext()) + { + context.GetService().Migrate(PreBackfillMigration); + + context.WanContexts.Add(new WanContext { Id = 2, Name = "legacy", ProbeSourceIp = "198.51.100.8" }); + context.MonitoringTargets.Add(Target("t-legacy", "203.0.113.5", contextId: 2, wanInterface: "wan")); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.MonitoringTargets.Single().WanInterface.Should().Be("wan"); + } + } + + [Fact] + public void Backfill_OnACleanDatabaseDoesNothingAndDoesNotThrow() + { + using var context = CreateContext(); + + var act = () => MigrationSafety.MigrateWithFriendlyErrors(context); + + act.Should().NotThrow(); + context.Database.GetPendingMigrations().Should().BeEmpty(); + context.MonitoringTargets.Should().BeEmpty(); + } +} diff --git a/tests/NetworkOptimizer.Storage.Tests/WanScopeFilterTests.cs b/tests/NetworkOptimizer.Storage.Tests/WanScopeFilterTests.cs new file mode 100644 index 0000000000..0d857b031c --- /dev/null +++ b/tests/NetworkOptimizer.Storage.Tests/WanScopeFilterTests.cs @@ -0,0 +1,77 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Services; +using Xunit; + +namespace NetworkOptimizer.Storage.Tests; + +/// +/// The Flux filter stage a latency wan-scope emits. The shapes are a correctness AND +/// performance contract (see BuildWanScopeFilter's remarks): tag ABSENCE for the primary - +/// never an empty-string equality, which matches nothing against a series that has no wan +/// column - and plain pushdown-safe tag equality for a scoped WAN. +/// +public class WanScopeFilterTests +{ + [Fact] + public void NoScope_EmitsNoFilterStage() + { + MonitoringInfluxClient.BuildWanScopeFilter(null).Should().BeEmpty(); + } + + [Fact] + public void PrimaryWithNoContexts_FiltersOnTagAbsenceOnly() + { + var filter = MonitoringInfluxClient.BuildWanScopeFilter( + MonitoringInfluxClient.LatencyWanScope.Primary()); + + filter.Should().Be("\n |> filter(fn: (r) => not exists r.wan)"); + } + + [Fact] + public void PrimaryWithAPrimaryBoundContext_KeepsBothShapesInOnePredicate() + { + var filter = MonitoringInfluxClient.BuildWanScopeFilter( + MonitoringInfluxClient.LatencyWanScope.Primary(new[] { "wan" })); + + // Explicit \n, never a verbatim string spanning a source newline: the emitted filter + // always uses \n, while a verbatim literal follows the checkout's line endings and + // fails on a CRLF working copy. + filter.Should().Be("\n |> filter(fn: (r) => not exists r.wan or r.wan == \"wan\")"); + } + + [Fact] + public void ScopedWan_IsAPlainTagEqualityChain() + { + var filter = MonitoringInfluxClient.BuildWanScopeFilter( + MonitoringInfluxClient.LatencyWanScope.ForWan(new[] { "wan2", "starlink-backup" })); + + filter.Should().Be("\n |> filter(fn: (r) => r.wan == \"wan2\" or r.wan == \"starlink-backup\")"); + } + + [Fact] + public void ScopedWan_DeduplicatesTagValues() + { + var filter = MonitoringInfluxClient.BuildWanScopeFilter( + MonitoringInfluxClient.LatencyWanScope.ForWan(new[] { "wan2", "wan2" })); + + filter.Should().Be("\n |> filter(fn: (r) => r.wan == \"wan2\")"); + } + + [Fact] + public void ScopedWanWithNoUsableTags_MatchesNothingRatherThanEveryWan() + { + var filter = MonitoringInfluxClient.BuildWanScopeFilter( + MonitoringInfluxClient.LatencyWanScope.ForWan(new[] { "" })); + + filter.Should().Contain("exists r.wan and not exists r.wan"); + } + + [Fact] + public void TagValues_AreFluxSanitized() + { + var filter = MonitoringInfluxClient.BuildWanScopeFilter( + MonitoringInfluxClient.LatencyWanScope.ForWan(new[] { "a\"b" })); + + filter.Should().NotContain("a\"b"); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/AgentEnrollmentServiceTests.cs b/tests/NetworkOptimizer.Web.Tests/AgentEnrollmentServiceTests.cs index 02f824894d..9c787fccc3 100644 --- a/tests/NetworkOptimizer.Web.Tests/AgentEnrollmentServiceTests.cs +++ b/tests/NetworkOptimizer.Web.Tests/AgentEnrollmentServiceTests.cs @@ -38,9 +38,15 @@ public AgentEnrollmentServiceTests() .Options; _factory = new TestDbFactory(options); // No service provider behind it: every coverage read fails closed to "the server still - // collects", which is what these tests assert against. + // collects", which is what these tests assert against. The same empty provider backs the + // routing cleanup on agent removal, so it logs and moves on rather than clearing anything - + // these tests are about enrollment, and the removal must not depend on that tidying working. + var emptyProvider = new ServiceCollection().BuildServiceProvider(); _service = new AgentEnrollmentService(_factory, _tunnelRegistry, new UnfilteredSiteAccess(), - new SiteAgentCoverage(new ServiceCollection().BuildServiceProvider()), + new SiteAgentCoverage(emptyProvider), + emptyProvider, + new SiteTunnelRouting(emptyProvider, new SiteAgentCoverage(emptyProvider), + new Mock>().Object), new Mock>().Object); } diff --git a/tests/NetworkOptimizer.Web.Tests/AutoEnableBudgetTests.cs b/tests/NetworkOptimizer.Web.Tests/AutoEnableBudgetTests.cs new file mode 100644 index 0000000000..29e850f269 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/AutoEnableBudgetTests.cs @@ -0,0 +1,112 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests; + +public class AutoEnableBudgetTests +{ + private static UpstreamTracerState BuildState(int accessHops, int transitRouters, int pathEndpoints) + { + var state = new UpstreamTracerState(); + for (var i = 1; i <= accessHops; i++) + state.AccessHops.Add(new AccessHopCandidate + { + TargetId = $"access-{i}", + Label = $"Access {i}", + Address = $"192.0.2.{i}", + HopNumber = i, + Enabled = true, + }); + for (var i = 1; i <= transitRouters; i++) + state.TransitAsns.Add(new TransitAsnCandidate + { + AsnNumber = 64500 + i, + AsnName = $"Transit{i}", + Method = DiscoveryMethod.DirectRouter, + HopAddress = $"198.51.100.{i}", + Enabled = true, + }); + for (var i = 1; i <= pathEndpoints; i++) + state.TransitAsns.Add(new TransitAsnCandidate + { + AsnNumber = 64600 + i, + AsnName = $"Endpoint{i}", + Method = DiscoveryMethod.PathProxy, + PathProxyTarget = $"203.0.113.{i}", + Enabled = true, + }); + return state; + } + + private static int EnabledOf(UpstreamTracerState state, DiscoveryMethod method) => + state.TransitAsns.Count(t => t.Method == method && t.Enabled); + + [Fact] + public void No_budget_leaves_every_candidate_ticked() + { + var state = BuildState(accessHops: 6, transitRouters: 6, pathEndpoints: 6); + + UpstreamTracerService.ApplyAutoEnableBudget(state, null); + + state.AccessHops.Should().OnlyContain(h => h.Enabled); + state.TransitAsns.Should().OnlyContain(t => t.Enabled); + } + + [Fact] + public void Every_bucket_keeps_a_share_of_a_tight_budget() + { + // The failure this guards: access hops taken first and in full left one endpoint ticked, + // so the site could see its first mile and not whether anything it reaches was up. + var state = BuildState(accessHops: 12, transitRouters: 6, pathEndpoints: 9); + + UpstreamTracerService.ApplyAutoEnableBudget(state, 8); + + state.AccessHops.Count(h => h.Enabled).Should().Be(3); + EnabledOf(state, DiscoveryMethod.DirectRouter).Should().Be(3); + EnabledOf(state, DiscoveryMethod.PathProxy).Should().Be(2); + } + + [Fact] + public void A_bucket_that_runs_out_hands_its_share_to_the_others() + { + var state = BuildState(accessHops: 1, transitRouters: 6, pathEndpoints: 6); + + UpstreamTracerService.ApplyAutoEnableBudget(state, 7); + + state.AccessHops.Count(h => h.Enabled).Should().Be(1); + EnabledOf(state, DiscoveryMethod.DirectRouter).Should().Be(3); + EnabledOf(state, DiscoveryMethod.PathProxy).Should().Be(3); + } + + [Fact] + public void Unreachable_candidates_are_neither_ticked_nor_charged_to_the_budget() + { + // The reachability gate runs BEFORE this and turns them off. Switching one back on because + // it fell inside the budget hands over a target known not to answer, and spends one of the + // few slots a metered WAN gets doing it. + var state = BuildState(accessHops: 4, transitRouters: 0, pathEndpoints: 0); + foreach (var hop in state.AccessHops.Take(2)) + { + hop.Unreachable = true; + hop.Enabled = false; + } + + UpstreamTracerService.ApplyAutoEnableBudget(state, 2); + + state.AccessHops.Where(h => h.Unreachable).Should().OnlyContain(h => !h.Enabled); + state.AccessHops.Where(h => !h.Unreachable).Should().OnlyContain(h => h.Enabled); + } + + [Fact] + public void Candidates_beyond_the_budget_are_turned_off() + { + var state = BuildState(accessHops: 4, transitRouters: 0, pathEndpoints: 0); + + UpstreamTracerService.ApplyAutoEnableBudget(state, 2); + + state.AccessHops.Count(h => h.Enabled).Should().Be(2); + state.AccessHops.Where(h => h.Enabled).Should().OnlyContain(h => h.HopNumber <= 2); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Identity/AuditQueryGateTests.cs b/tests/NetworkOptimizer.Web.Tests/Identity/AuditQueryGateTests.cs new file mode 100644 index 0000000000..bd875c9fba --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Identity/AuditQueryGateTests.cs @@ -0,0 +1,110 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NetworkOptimizer.Storage.Models.Identity; +using NetworkOptimizer.Web.Services.Auditing; +using NetworkOptimizer.Web.Services.Gates; +using NetworkOptimizer.Web.Services.Identity; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Identity; + +/// +/// The audit log is the record of who did what across the whole install - actors, source addresses, +/// target names, and the site each action touched. Reading it is closer to reading a credential store +/// than a status page, so it earns a service-tier check rather than relying on the page and the export +/// endpoint that happen to sit in front of it today. +/// +public sealed class AuditQueryGateTests +{ + private static ServiceProvider Build() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContextFactory(o => + o.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddScoped(); + services.AddSingleton(new NoOpAudit()); + // Non-site-scoped gate, so the interceptor ranks the global role and never asks this - it is + // here because SiteRoleHandler takes it as a dependency. + services.AddScoped(); + services.AddGatePlumbing(); + services.AddMutatingService(); + return services.BuildServiceProvider(); + } + + private sealed class NoOpAudit : IAuditLogger + { + public void Log(AuditEvent auditEvent) { } + } + + private sealed class UnusedResolver : NetworkOptimizer.Web.Services.Authorization.IEffectiveSiteRoleResolver + { + public void Invalidate(string userId) { } + public void InvalidateAll() { } + public Task FirstAdministeredSlugAsync(System.Security.Claims.ClaimsPrincipal user) + => Task.FromResult(null); + public Task GetEffectiveRoleAsync(System.Security.Claims.ClaimsPrincipal user, string slug) + => Task.FromResult(null); + public Task> GetAuthorizedSlugsAsync(System.Security.Claims.ClaimsPrincipal user) + => Task.FromResult>(new HashSet()); + } + + [Fact] + public async Task An_admin_may_read_the_audit_log() + { + await using var provider = Build(); + using var scope = provider.ScopeAs("admin-1", Roles.Admin); + + var act = async () => await scope.ServiceProvider + .GetRequiredService().QueryAsync(new AuditFilter()); + + await act.Should().NotThrowAsync(); + } + + [Theory] + [InlineData(Roles.Viewer)] + [InlineData(Roles.Operator)] + public async Task Anyone_below_Admin_is_refused(string role) + { + await using var provider = Build(); + using var scope = provider.ScopeAs("someone", role); + + var act = async () => await scope.ServiceProvider + .GetRequiredService().QueryAsync(new AuditFilter()); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Export_is_gated_the_same_way_as_the_page_read() + { + // The two exports leave the app as files and were reachable through their own endpoint, so a + // gate that covered only the interactive read would have missed the larger disclosure. + await using var provider = Build(); + using var scope = provider.ScopeAs("viewer-1", Roles.Viewer); + var query = scope.ServiceProvider.GetRequiredService(); + + var json = async () => await query.ExportJsonAsync(new AuditFilter()); + var csv = async () => await query.ExportCsvAsync(new AuditFilter()); + + await json.Should().ThrowAsync(); + await csv.Should().ThrowAsync(); + } + + /// + /// The gate has to stay declared on the interface. Losing the attribute puts the reads back + /// behind nothing but the page and the endpoint, which is where they started. + /// + [Fact] + public void Every_member_carries_a_role_gate() + { + typeof(IAuditQueryService).Should().BeDecoratedWith(); + + foreach (var method in typeof(IAuditQueryService).GetMethods()) + { + method.Should().BeDecoratedWith( + $"{method.Name} reads the audit log and must be gated"); + } + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Identity/IdentityBootstrapServiceTests.cs b/tests/NetworkOptimizer.Web.Tests/Identity/IdentityBootstrapServiceTests.cs index 6784707403..9db4c3a516 100644 --- a/tests/NetworkOptimizer.Web.Tests/Identity/IdentityBootstrapServiceTests.cs +++ b/tests/NetworkOptimizer.Web.Tests/Identity/IdentityBootstrapServiceTests.cs @@ -133,6 +133,154 @@ public async Task NoLocalCredential_SkipsSeed() (await roleManager.RoleExistsAsync(Roles.Admin)).Should().BeTrue(); } + /// + /// The first run on a brand-new install: nothing stored, so the password is generated and the + /// admin account is created straight from the plaintext. This one goes through Identity's + /// password validators, so a generated password that broke the policy would leave the install + /// with no admin account at all - the worst outcome available on this path. + /// + [Fact] + public async Task FirstRun_WithNoStoredCredential_CreatesAWorkingAdminFromTheGeneratedPassword() + { + // No AdminSettings row at all, exactly like a fresh install. + await using var provider = BuildProvider(); + + const string generated = "Generated-First-Run-7"; + provider.GetRequiredService().PublishFirstRunPassword(generated); + await RunBootstrapAsync(provider); + + using var scope = provider.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + var admin = await users.FindByNameAsync(IdentityBootstrapService.AdminUserName); + + admin.Should().NotBeNull("a failed create would leave a fresh install with no way in"); + (await users.CheckPasswordAsync(admin!, generated)).Should().BeTrue(); + (await users.IsInRoleAsync(admin!, Roles.Admin)).Should().BeTrue(); + admin!.PasswordIsTemporary.Should().BeTrue(); + admin.IsEnabled.Should().BeTrue(); + } + + /// + /// An upgrading install brings whatever password it already had, which predates the account + /// policy and need not satisfy it. The seed copies the transcoded hash and calls the overload + /// that runs only the user validators, so the policy is never evaluated and nobody is locked + /// out by the cutover. If this ever starts validating, short legacy passwords stop migrating. + /// + [Fact] + public async Task LegacyPasswordThatBreaksThePolicy_StillMigratesAndSignsIn() + { + // Too short and no digit: rejected outright if it were ever put through the validators. + const string weakPassword = "abc"; + await SeedLegacyAdminSettingsAsync(new PasswordHasher().HashPassword(weakPassword), enabled: true); + + await using var provider = BuildProvider(); + await RunBootstrapAsync(provider); + + using var scope = provider.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + var admin = await users.FindByNameAsync(IdentityBootstrapService.AdminUserName); + + admin.Should().NotBeNull("an upgrade must never lock the operator out over password strength"); + (await users.CheckPasswordAsync(admin!, weakPassword)).Should().BeTrue(); + (await users.IsInRoleAsync(admin!, Roles.Admin)).Should().BeTrue(); + } + + /// + /// The reset scripts clear the stored password so startup regenerates one and prints it. Once an + /// admin account exists, only re-applying that password to the account makes the printed one the + /// real login - without it the scripts hand out a password that is silently refused. + /// + [Fact] + public async Task RegeneratedFirstRunPassword_ResetsAnAlreadySeededAdmin() + { + const string oldPassword = "Original-Pass-42"; + await SeedLegacyAdminSettingsAsync(new PasswordHasher().HashPassword(oldPassword), enabled: true); + + await using var provider = BuildProvider(); + await RunBootstrapAsync(provider); + + // The operator has been failing sign-ins, so the account is locked out too. + using (var scope = provider.CreateScope()) + { + var users = scope.ServiceProvider.GetRequiredService>(); + var locked = await users.FindByNameAsync(IdentityBootstrapService.AdminUserName); + locked!.LockoutEnd = DateTimeOffset.UtcNow.AddMinutes(5); + locked.AccessFailedCount = 5; + await users.UpdateAsync(locked); + } + + // Act: reset-password.* cleared the row, so AdminAuthService generated and printed this one. + const string resetPassword = "Regenerated-Pass-99"; + provider.GetRequiredService().PublishFirstRunPassword(resetPassword); + await RunBootstrapAsync(provider); + + using (var scope = provider.CreateScope()) + { + var users = scope.ServiceProvider.GetRequiredService>(); + var admin = await users.FindByNameAsync(IdentityBootstrapService.AdminUserName); + + (await users.CheckPasswordAsync(admin!, resetPassword)).Should().BeTrue( + "the password the reset printed is the one that must now work"); + (await users.CheckPasswordAsync(admin!, oldPassword)).Should().BeFalse( + "the password that was reset away must stop working"); + admin!.PasswordIsTemporary.Should().BeTrue("a generated password still needs replacing"); + admin.LockoutEnd.Should().BeNull("a reset is worthless if the account stays locked out"); + admin.AccessFailedCount.Should().Be(0); + } + } + + /// + /// A stored hash that was only read back must not re-apply on later boots, or every restart + /// would overwrite whatever password the user has since set through Identity. + /// + [Fact] + public async Task StoredLegacyHash_DoesNotOverwriteALaterPasswordOnReboot() + { + await SeedLegacyAdminSettingsAsync(new PasswordHasher().HashPassword("Seeded-Pass-42"), enabled: true); + + await using var provider = BuildProvider(); + await RunBootstrapAsync(provider); + + const string chosenPassword = "Chosen-In-App-77"; + using (var scope = provider.CreateScope()) + { + var users = scope.ServiceProvider.GetRequiredService>(); + var admin = await users.FindByNameAsync(IdentityBootstrapService.AdminUserName); + var token = await users.GeneratePasswordResetTokenAsync(admin!); + (await users.ResetPasswordAsync(admin!, token, chosenPassword)).Succeeded.Should().BeTrue(); + } + + await RunBootstrapAsync(provider); // reboot, legacy row untouched + + using (var scope = provider.CreateScope()) + { + var users = scope.ServiceProvider.GetRequiredService>(); + var admin = await users.FindByNameAsync(IdentityBootstrapService.AdminUserName); + (await users.CheckPasswordAsync(admin!, chosenPassword)).Should().BeTrue(); + } + } + + /// + /// The generated password is applied through Identity now, which enforces the account policy - + /// so it has to satisfy RequireDigit. Drawing uniformly from the alphabet leaves roughly one in + /// twelve with no digit at all, which would fail the reset it is meant to perform. + /// + [Fact] + public void GeneratedFirstRunPassword_AlwaysSatisfiesThePasswordPolicy() + { + var generate = typeof(AdminAuthService).GetMethod( + "GenerateSecurePassword", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + generate.Should().NotBeNull("the first-run password generator is expected on AdminAuthService"); + + for (var i = 0; i < 500; i++) + { + var password = (string)generate!.Invoke(null, null)!; + password.Length.Should().BeGreaterThanOrEqualTo(8); + password.Any(char.IsDigit).Should().BeTrue("Identity's RequireDigit rejects the rest"); + } + } + private static async Task RunBootstrapAsync(ServiceProvider provider) { using var scope = provider.CreateScope(); diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/CrossHopAgreementTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/CrossHopAgreementTests.cs new file mode 100644 index 0000000000..75d5e78d85 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/CrossHopAgreementTests.cs @@ -0,0 +1,136 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.IspHealth; + +/// +/// Congestion on a link is common to everything crossing it. One hop rising while the hops beside +/// it stay flat AT THE SAME SECOND is that hop's own responder deprioritizing ICMP, and the flat +/// readings taken alongside it are the proof - proof the old flat pooling threw away, because the +/// noise floor discarded the clean samples before the median ever saw them. +/// +public class CrossHopAgreementTests +{ + private static readonly DateTime T0 = new(2026, 8, 5, 16, 23, 0, DateTimeKind.Utc); + private static readonly TimeSpan Tolerance = TimeSpan.FromSeconds(1); + private const double Floor = 0.5; + + private static (DateTime, double, int) S(double atSecond, double value, int hop) => + (T0.AddSeconds(atSecond), value, hop); + + [Fact] + public void One_squealing_hop_is_diluted_by_its_clean_neighbors() + { + var samples = new[] + { + S(0, 0.1, 0), S(0.2, 8.0, 1), S(0.4, 0.2, 2), S(0.6, 0.1, 3), S(0.8, 0.0, 4), + }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 2, elevationFloor: Floor); + + // Not discarded - the hop that saw it sets the magnitude (8.0), scaled by how alone it + // was in seeing it (1 of 5). Collapsing magnitude across the cohort instead would have + // answered a different question: what the AVERAGE target saw, which nothing experiences. + agreed.Should().HaveCount(1); + agreed[0].Value.Should().BeApproximately(8.0 / 5, 0.001); + } + + [Fact] + public void A_link_that_is_genuinely_loaded_carries_every_hop_up_together() + { + var samples = new[] + { + S(0, 21.0, 0), S(0.2, 24.0, 1), S(0.4, 22.0, 2), S(0.6, 23.0, 3), + }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 2, elevationFloor: Floor); + + agreed.Should().HaveCount(1); + agreed[0].Value.Should().BeApproximately(22.5, 0.001); + } + + [Fact] + public void The_figure_does_not_shrink_just_because_more_targets_are_monitored() + { + // The bug this replaced: dilution scaled with cohort size, so a WAN watching 28 targets + // reported a third of a millisecond for a genuine 8 ms. Monitoring more scored better. + static IEnumerable<(DateTime, double, int)> Bloat(int targets) => + Enumerable.Range(0, targets).Select(t => S(t * 0.02, 8.0, t)); + + var small = SeriesStats.CommonModeByInstant(Bloat(5).ToList(), Tolerance, 4, Floor); + var large = SeriesStats.CommonModeByInstant(Bloat(28).ToList(), Tolerance, 4, Floor); + + small.Should().ContainSingle().Which.Value.Should().BeApproximately(8.0, 0.001); + large.Should().ContainSingle().Which.Value.Should().BeApproximately(8.0, 0.001); + } + + [Fact] + public void A_target_that_said_nothing_this_instant_does_not_count_as_clean() + { + // Denominator is what reported, not the cohort's size - targets do not share a cadence. + var samples = new[] { S(0, 8.0, 0), S(0.2, 8.0, 1), S(0.4, 8.0, 2), S(0.6, 8.0, 3) }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 4, Floor); + + agreed.Should().ContainSingle().Which.Value.Should().BeApproximately(8.0, 0.001); + } + + [Fact] + public void Every_target_reading_clean_reports_nothing_happened() + { + var samples = new[] { S(0, 0.1, 0), S(0.2, 0.0, 1), S(0.4, 0.2, 2), S(0.6, 0.1, 3) }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 4, Floor); + + agreed.Should().ContainSingle().Which.Value.Should().Be(0); + } + + [Fact] + public void A_hop_with_nothing_beside_it_is_passed_through_untouched() + { + // Short events where only one hop happened to be probed are still evidence. Uncorroborated + // evidence is not the same as refuted evidence, and dropping it would blind the score to + // exactly the brief spikes it is supposed to notice. + var samples = new[] { S(0, 9.0, 0) }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 2, elevationFloor: Floor); + + agreed.Should().ContainSingle().Which.Value.Should().Be(9.0); + } + + [Fact] + public void Two_readings_from_the_SAME_hop_do_not_corroborate_each_other() + { + var samples = new[] { S(0, 9.0, 0), S(0.3, 9.2, 0) }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 2, elevationFloor: Floor); + + agreed.Should().HaveCount(2); + agreed.Select(a => a.Value).Should().BeEquivalentTo(new[] { 9.0, 9.2 }); + } + + [Fact] + public void Samples_further_apart_than_the_tolerance_are_separate_instants() + { + // Not simultaneous, so they say nothing about each other: a hop that was clean five + // seconds later does not testify about the second the spike happened. + var samples = new[] { S(0, 8.0, 0), S(5, 0.1, 1) }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 2, elevationFloor: Floor); + + agreed.Should().HaveCount(2); + agreed.Select(a => a.Value).Should().BeEquivalentTo(new[] { 8.0, 0.1 }); + } + + [Fact] + public void Instants_are_reported_in_time_order_regardless_of_input_order() + { + var samples = new[] { S(10, 1.0, 0), S(10.2, 1.2, 1), S(0, 5.0, 0), S(0.2, 5.4, 1) }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 2, elevationFloor: Floor); + + agreed.Should().HaveCount(2); + agreed[0].Time.Should().BeBefore(agreed[1].Time); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/ElevationVerdictTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/ElevationVerdictTests.cs new file mode 100644 index 0000000000..66424cdbe0 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/ElevationVerdictTests.cs @@ -0,0 +1,119 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.IspHealth; + +/// +/// "Has the elevation stopped" rather than "what is the median", because the noise floor +/// downstream keeps only the elevated samples - so the reported figure is the median of the bad +/// ones, and comparing medians cannot see a fix at all. +/// +/// The bar for calling it over: a run of clean load episodes, and - where the history shows the +/// problem was tied to a time of day - one of them at that hour. Every case here came from being +/// wrong about a real WAN first. +/// +/// +public class ElevationVerdictTests +{ + private const double NoiseFloor = 0.5; + private const double HourDependenceFloor = 3.0; + private const int StaleEpisodes = 3; + private static readonly TimeSpan EpisodeSpan = TimeSpan.FromSeconds(7); + + // Local time, because the rule reasons about the operator's hours. + private static DateTime At(int dayOffset, int hour, int minute = 0) => + TimeZoneInfo.ConvertTimeToUtc( + new DateTime(2026, 8, 5, hour, minute, 0, DateTimeKind.Unspecified).AddDays(-dayOffset), + TimeZoneInfo.Local); + + private static ElevationVerdict.Verdict Judge( + params (DateTime Time, double Value)[] newestFirst) + => ElevationVerdict.For(newestFirst, NoiseFloor, StaleEpisodes, true, EpisodeSpan, HourDependenceFloor); + + [Fact] + public void A_line_still_misbehaving_is_not_over() + { + // Newest episodes are elevated: nothing to declare. + var verdict = Judge( + (At(0, 22), 23), (At(0, 21), 0), (At(0, 20), 0), (At(0, 8), 24), (At(1, 8), 23)); + + verdict.ElevationIsOver.Should().BeFalse(); + verdict.CleanRun.Should().BeEmpty(); + } + + [Fact] + public void A_line_that_was_never_elevated_has_no_elevation_to_declare_over() + { + // Not "cleared" of a problem it never had - but the caller reads ElevatedCount 0 as its own + // answer: every load episode was clean, which is the strongest statement available and the + // reason this line no longer falls through to the median of whichever samples crossed the + // noise floor. That path reported 23 ms on a WAN whose every episode read under 0.5. + var verdict = Judge((At(0, 22), 0.1), (At(0, 21), 0), (At(0, 20), 0.2), (At(1, 8), 0.1)); + + verdict.ElevatedCount.Should().Be(0); + verdict.ElevationIsOver.Should().BeFalse(); + } + + [Fact] + public void A_constant_problem_clears_from_any_hour() + { + // The WAN4 case. Elevated in EVERY episode before the fix, so the hour was never the + // variable - the line misbehaved whenever it was loaded. Three clean saturations at 22:00 + // disprove it without waiting for the 08:00 scheduled test to come round again. + var verdict = Judge( + (At(0, 22, 30), 0.0), (At(0, 22), 0.1), (At(0, 21, 55), 0.0), + (At(0, 20, 37), 23.9), (At(0, 8), 24.4), (At(1, 8), 23.1), (At(2, 8), 24.0)); + + verdict.ProblemHourReTested.Should().BeTrue(); + verdict.ElevationIsOver.Should().BeTrue(); + } + + [Fact] + public void A_nightly_problem_does_not_clear_itself_at_3am() + { + // Bad every evening, clean every night. A run computed at 3 AM sees three clean episodes on + // top of elevated ones - and must NOT call that fixed. + var verdict = Judge( + (At(0, 3), 0.0), (At(0, 2), 0.1), (At(0, 1), 0.0), + (At(1, 20), 22.0), (At(1, 14), 0.2), (At(2, 20), 21.0), (At(2, 14), 0.1)); + + verdict.CleanRun.Should().HaveCount(3); + verdict.ProblemHourReTested.Should().BeFalse(); + verdict.ElevationIsOver.Should().BeFalse(); + } + + [Fact] + public void A_nightly_problem_clears_once_its_own_hour_comes_back_clean() + { + // Same line, but the evening has now been re-tested and was fine. + var verdict = Judge( + (At(0, 20), 0.1), (At(0, 19), 0.0), (At(0, 14), 0.0), + (At(1, 20), 22.0), (At(1, 14), 0.2), (At(2, 20), 21.0)); + + verdict.ProblemHourReTested.Should().BeTrue(); + verdict.ElevationIsOver.Should().BeTrue(); + } + + [Fact] + public void A_short_clean_run_is_not_enough() + { + var verdict = Judge((At(0, 22), 0.0), (At(0, 21), 0.1), (At(0, 8), 24.0), (At(1, 8), 23.0)); + + verdict.CleanRun.Should().HaveCount(2); + verdict.ElevationIsOver.Should().BeFalse(); + } + + [Fact] + public void The_hour_rule_can_be_turned_off() + { + var episodes = new[] + { + (At(0, 3), 0.0), (At(0, 2), 0.1), (At(0, 1), 0.0), + (At(1, 20), 22.0), (At(1, 14), 0.2), (At(2, 20), 21.0), + }; + + ElevationVerdict.For(episodes, NoiseFloor, StaleEpisodes, false, EpisodeSpan, HourDependenceFloor) + .ElevationIsOver.Should().BeTrue(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/IspHealthScorerTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/IspHealthScorerTests.cs index c73869dbce..96dbc7384e 100644 --- a/tests/NetworkOptimizer.Web.Tests/IspHealth/IspHealthScorerTests.cs +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/IspHealthScorerTests.cs @@ -41,7 +41,10 @@ private static IspHealthInputs BuildInputs( bool hopOrderKnown = false, List? outages = null, TimeSpan? scoreWindow = null, - HashSet? notTracedTargetIds = null) + HashSet? notTracedTargetIds = null, + double? expectedDownMbps = null, + double? expectedUpMbps = null, + PhysicalLinkInput? physicalLink = null) { // lineIdle: a near-zero, flat WAN with no load bursts (~0% average load), for // exercising the load-calibrated packet-loss ceiling at the idle end. @@ -73,8 +76,9 @@ private static IspHealthInputs BuildInputs( DestinationSeries = destinations ?? new List(), WanRates = rates, InternetMedianDeltaMs = internetDeltaMs, - ExpectedDownloadMbps = withExpectedSpeeds ? 1000 : null, - ExpectedUploadMbps = withExpectedSpeeds ? 500 : null, + PhysicalLink = physicalLink, + ExpectedDownloadMbps = withExpectedSpeeds ? expectedDownMbps ?? 1000 : null, + ExpectedUploadMbps = withExpectedSpeeds ? expectedUpMbps ?? 500 : null, ExpectedSpeedSource = withExpectedSpeeds ? "UniFi Network" : null, WanSpeedTests = speedTests ?? new List { @@ -484,6 +488,140 @@ public void Loaded_latency_surfaces_spiky_far_hop_not_hidden_by_flat_near_hop() withOlt.ValueText.Should().Contain("6.0 ms down"); } + [Fact] + public void A_hop_squealing_while_the_rest_of_the_WAN_reads_clean_is_outvoted() + { + // Same shape as the OLT case above, but this WAN monitors enough targets to have an + // opinion. A queue on the access link sits in front of every one of them, so a single hop + // rising while transit and the internet destinations stay flat AT THE SAME SECOND is that + // responder deprioritizing ICMP - the reading the old flat pooling reported in full, + // because the noise floor discarded the clean samples before the median saw them. + var rates = TestSeries.Throughput(TestSeries.Start, Day, 50, 5) + .Select(r => r.Time >= LoadedDownStart && r.Time < LoadedDownEnd + ? r with { DownloadBps = 800_000_000 } + : r) + .ToList(); + + var nearHop = TestSeries.Flat(TestSeries.Start, Day, 2.0, 0.3); + var squealer = TestSeries.Flat(TestSeries.Start, Day, 2.0, 0.3) + .WithSegment(LoadedDownStart, LoadedDownEnd, 8.0, 0.3); + + AsnSeries Clean(string name, double rtt) => new() + { + AsnNumber = 0, + AsnName = name, + Samples = TestSeries.Flat(TestSeries.Start, Day, rtt, 0.3) + }; + + var inputs = new IspHealthInputs + { + WindowStart = TestSeries.Start, + WindowEnd = TestSeries.Start + Day, + FirstHopSeries = nearHop, + AccessHopSeries = new List> { nearHop, squealer }, + TransitAsnSeries = new List { Clean("Transit", 9.0) }, + DestinationSeries = new List { Clean("DNS", 14.0), Clean("CDN", 16.0) }, + LossPoolSeries = new List> { nearHop }, + WanRates = rates, + ExpectedDownloadMbps = 1000, + ExpectedUploadMbps = 500, + ExpectedSpeedSource = "UniFi Network", + WanSpeedTests = new List { new(TestSeries.Start.AddHours(6), 980, 490) } + }; + + var factor = new IspHealthScorer(Options).Score(inputs, Gpon) + .AccessDimension.Factors.Single(f => f.Name == "Loaded Latency"); + + factor.ValueText.Should().NotContain("6.0 ms down"); + factor.Score.Should().Be(100); + } + + [Fact] + public void A_standby_link_is_graded_on_carrying_traffic_not_on_ratio() + { + // 1 / 1 is the lowest expected speed UniFi Network accepts, so a dish held in standby ends + // up there with nothing real to enter. Scored as a ratio it read 17 - a link doing exactly + // its job in the emergency it exists for, marked as failing. + var inputs = BuildInputs( + expectedDownMbps: 1, expectedUpMbps: 1, + speedTests: new List { new(TestSeries.Start.AddHours(6), 0.6, 0.1) }); + + var factor = new IspHealthScorer(Options).Score(inputs, Gpon) + .AccessDimension.Factors.Single(f => f.Name == "Speed vs Plan"); + + factor.Score.Should().BeGreaterThan(80); + factor.ValueText.Should().Contain("0.6"); + factor.Description.Should().Contain("lowest UniFi Network allows"); + } + + [Fact] + public void A_dish_reporting_a_reduced_speed_tier_is_graded_that_way_against_a_real_plan() + { + // Ground truth beats the inference: the dish says its throughput is capped by the plan + // tier, so the shortfall is not the link - even though a real 1000 / 500 plan is + // configured and the ratio against it would read as a near-total failure. + var inputs = BuildInputs( + speedTests: new List { new(TestSeries.Start.AddHours(6), 0.6, 0.1) }, + physicalLink: new PhysicalLinkInput + { + Medium = PhysicalMedium.Satellite, + SourceName = "Dish", + ReducedSpeedTier = true + }); + + var factor = new IspHealthScorer(Options).Score(inputs, Gpon) + .AccessDimension.Factors.Single(f => f.Name == "Speed vs Plan"); + + factor.Score.Should().BeGreaterThan(80); + factor.Description.Should().Contain("reduced-speed plan tier"); + } + + [Fact] + public void Satellite_idle_latency_is_anchored_on_measured_plans() + { + // Both ends come from real dishes: 23 ms is the best the medium does at all, and 42 ms is + // where a healthy Backup dish sits - the floor of good rather than a fault. + var satellite = IspHealthProfiles.GetProfile(AccessTechnology.Satellite)!; + + int Idle(double rtt) => new IspHealthScorer(Options) + .Score(BuildInputs(idleRtt: rtt), satellite) + .AccessDimension.Factors.Single(f => f.Name == "Idle Latency").Score!.Value; + + Idle(23).Should().Be(100); + Idle(42).Should().Be(80); + Idle(45).Should().BeInRange(70, 75); + } + + [Fact] + public void A_standby_link_carrying_nothing_still_fails() + { + // Forgiving is not blind: the one outcome that would actually fail its owner is a backup + // that carries nothing when called on. + var inputs = BuildInputs( + expectedDownMbps: 1, expectedUpMbps: 1, + speedTests: new List { new(TestSeries.Start.AddHours(6), 0.001, 0) }); + + var factor = new IspHealthScorer(Options).Score(inputs, Gpon) + .AccessDimension.Factors.Single(f => f.Name == "Speed vs Plan"); + + factor.Score.Should().BeLessThan(30); + } + + [Fact] + public void A_real_plan_with_a_1_Mbps_upstream_is_still_graded() + { + // Half a sentinel is still a plan: 100 Mbps down cannot have been typed by someone with + // nothing to enter, so the link keeps its grade. + var inputs = BuildInputs( + expectedDownMbps: 100, expectedUpMbps: 1, + speedTests: new List { new(TestSeries.Start.AddHours(6), 95, 1) }); + + var factor = new IspHealthScorer(Options).Score(inputs, Gpon) + .AccessDimension.Factors.Single(f => f.Name == "Speed vs Plan"); + + factor.Score.Should().NotBeNull(); + } + [Fact] public void Below_band_idle_latency_scores_higher_than_above_band() { @@ -585,6 +723,27 @@ public void No_sqm_recommendation_when_loaded_behavior_is_excellent() report.Issues.Should().NotContain(i => i.Title == "Bufferbloat under load"); } + [Fact] + public void Upstream_loaded_loss_inside_the_gpon_band_is_not_flagged() + { + // GPON upstream tolerates 1.5%: that upstream is shared on TDMA grants, a gig plan is most + // of it, and an AQM controls the queue BY dropping - so 1.2% under a saturating upload is + // the medium working, not a fault to report. + var report = new IspHealthScorer(Options).Score(BuildInputs(lossPct: 1.2), Gpon); + + report.Issues.Should().NotContain(i => i.Title == "Packet loss under load"); + } + + [Fact] + public void Upstream_loaded_loss_past_the_gpon_band_is_flagged() + { + // The far side of the same breakpoint. 1.8% clears upstream's 1.5% while still sitting + // under downstream's untouched 2.0%, so the upstream band is what fires here. + var report = new IspHealthScorer(Options).Score(BuildInputs(lossPct: 1.8), Gpon); + + report.Issues.Should().Contain(i => i.Title == "Packet loss under load"); + } + [Fact] public void Overall_is_equal_thirds_of_dimensions() { @@ -1369,7 +1528,8 @@ public void Transit_health_weights_asns_by_internet_host_involvement() }; var dest = new AsnSeries { - AsnNumber = 64512, AsnName = "Destination", + AsnNumber = 64512, + AsnName = "Destination", TargetIds = { "dest-clean" }, Samples = TestSeries.Flat(TestSeries.Start, Day, 13, 0.4), HopIps = { "30.0.0.1" }, @@ -1399,13 +1559,21 @@ public void Off_path_jittery_isp_hop_is_flagged_for_disable() // with high jitter is flagged SuggestDisable. The graded on-path hop never is. var graded = new AsnSeries { - AsnNumber = 64496, AsnName = "ISP", TargetIds = { "isp-near" }, RoleTargetIds = { "isp-near" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 2.0, 0.3), HopIps = { "10.0.0.1" } + AsnNumber = 64496, + AsnName = "ISP", + TargetIds = { "isp-near" }, + RoleTargetIds = { "isp-near" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 2.0, 0.3), + HopIps = { "10.0.0.1" } }; var offPathJittery = new AsnSeries { - AsnNumber = 64496, AsnName = "ISP", TargetIds = { "isp-olt" }, RoleTargetIds = { "isp-olt" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 4.0, 6.0), HopIps = { "10.0.0.9" } + AsnNumber = 64496, + AsnName = "ISP", + TargetIds = { "isp-olt" }, + RoleTargetIds = { "isp-olt" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 4.0, 6.0), + HopIps = { "10.0.0.9" } }; var hops = new List { graded, offPathJittery }; @@ -1436,8 +1604,11 @@ public void Transit_asns_with_no_attributable_hosts_are_floored_and_labeled() }; var peeredDest = new AsnSeries { - AsnNumber = 64512, AsnName = "Destination", TargetIds = { "dest" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 8, 0.4), HopIps = { "30.0.0.1" }, + AsnNumber = 64512, + AsnName = "Destination", + TargetIds = { "dest" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 8, 0.4), + HopIps = { "30.0.0.1" }, AncestorIps = { "9.9.9.9" } // routes through neither transit (peered) }; @@ -1469,20 +1640,29 @@ public void Ix_peering_entry_requires_both_low_delta_and_no_transit_on_path() }; var peered = new AsnSeries { - AsnNumber = 13335, AsnName = "Peered", TargetIds = { "peered" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 4, 0.3), HopIps = { "30.0.0.1" }, + AsnNumber = 13335, + AsnName = "Peered", + TargetIds = { "peered" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 4, 0.3), + HopIps = { "30.0.0.1" }, AncestorIps = { "10.0.0.1" } // access ISP hop only - crosses no transit }; var viaTransit = new AsnSeries { - AsnNumber = 15169, AsnName = "ViaTransit", TargetIds = { "via" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 4, 0.3), HopIps = { "31.0.0.1" }, + AsnNumber = 15169, + AsnName = "ViaTransit", + TargetIds = { "via" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 4, 0.3), + HopIps = { "31.0.0.1" }, AncestorIps = { "10.0.0.1", "20.0.0.1" } // low RTT but routes through the transit ASN }; var farPeer = new AsnSeries { - AsnNumber = 54113, AsnName = "FarPeer", TargetIds = { "far" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 20, 0.3), HopIps = { "32.0.0.1" }, + AsnNumber = 54113, + AsnName = "FarPeer", + TargetIds = { "far" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 20, 0.3), + HopIps = { "32.0.0.1" }, AncestorIps = { "10.0.0.1" } // crosses no transit, but ~18 ms beyond the access hop }; @@ -1508,8 +1688,11 @@ public void Ix_peering_entry_is_absent_when_no_destination_is_directly_peered() }; var viaTransit = new AsnSeries { - AsnNumber = 15169, AsnName = "ViaTransit", TargetIds = { "via" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 12, 0.3), HopIps = { "31.0.0.1" }, + AsnNumber = 15169, + AsnName = "ViaTransit", + TargetIds = { "via" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 12, 0.3), + HopIps = { "31.0.0.1" }, AncestorIps = { "10.0.0.1", "20.0.0.1" } }; @@ -1762,14 +1945,19 @@ public void Loaded_latency_uses_thin_single_hop_data() } [Fact] - public void Loaded_latency_filters_sub_half_ms_deltas() + public void Loaded_latency_reports_a_line_that_stays_clean_under_load() { - // Access hops show sub-0.5 ms delta under load (no meaningful bufferbloat). - // All samples filtered out, returns null (falls back to speed tests). + // Access hops show sub-0.5 ms delta under load - no meaningful bufferbloat. + // + // This used to return null and fall through to the speed tests, on the reasoning that the + // noise floor had filtered everything and nothing was left to say. It is the opposite: no + // episode elevated means every time this line was loaded it stayed clean, which is the + // strongest statement the data can make. Returning null here is what left a real WAN + // reporting +23 ms from the median of whichever stray samples crossed the floor. var inputs = BuildInputs( accessHops: new() { LoadedDownHop(2, 0.1), LoadedDownHop(3, 0.2) }); - ResolvedDownDelta(inputs).Should().BeNull(); + ResolvedDownDelta(inputs).Should().BeInRange(0, 0.5); } [Fact] @@ -1956,9 +2144,12 @@ public void Pppoe_overlay_widens_loaded_loss_additively() pppoe.LoadedLossDownLowPct.Should().BeApproximately(1.5, 0.001); pppoe.LoadedLossDownHighPct.Should().BeApproximately(3.0, 0.001); pppoe.LoadedLossUpLowPct.Should().BeApproximately(1.0, 0.001); - pppoe.LoadedLossUpHighPct.Should().BeApproximately(2.0, 0.001); + // 1.5 baseline + the same 1.0 the downstream high gets. The overlay is unchanged; the + // GPON upstream high it widens moved from 1.0 to 1.5 on 2026-08-05. + pppoe.LoadedLossUpHighPct.Should().BeApproximately(2.5, 0.001); } + [Fact] public void Pppoe_overlay_never_tightens_a_band() { diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/LoadCredibilityTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/LoadCredibilityTests.cs new file mode 100644 index 0000000000..532db1f29c --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/LoadCredibilityTests.cs @@ -0,0 +1,110 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.IspHealth; + +/// +/// Not every loaded moment is equally good evidence about behavior under load. A brief burst is +/// where load CLASSIFICATION goes wrong most often and is too short for buffers to fill; a +/// sustained saturation near plan speed is the best evidence available, better than a speed test, +/// which is itself short and synthetic. +/// +public class LoadCredibilityTests +{ + private const int WindowSeconds = 7; + + private static DateTime W(int index) => new DateTime(2026, 8, 5, 0, 0, 0, DateTimeKind.Utc) + .AddSeconds(index * WindowSeconds); + + [Fact] + public void Consecutive_windows_are_one_episode_and_carry_its_full_length() + { + // Three back-to-back windows are one 21-second episode, not three 7-second ones. + var seconds = SeriesStats.LoadEpisodeSeconds(new[] { W(0), W(1), W(2) }, WindowSeconds); + + seconds.Values.Should().AllBeEquivalentTo(21.0); + } + + [Fact] + public void A_gap_starts_a_new_episode() + { + // W(0..1) then a hole then W(5): two episodes, measured separately. + var seconds = SeriesStats.LoadEpisodeSeconds(new[] { W(0), W(1), W(5) }, WindowSeconds); + + seconds[W(0)].Should().Be(14); + seconds[W(1)].Should().Be(14); + seconds[W(5)].Should().Be(7); + } + + [Fact] + public void Order_and_duplicates_do_not_change_an_episode() + { + var seconds = SeriesStats.LoadEpisodeSeconds(new[] { W(2), W(0), W(1), W(1) }, WindowSeconds); + + seconds.Should().HaveCount(3); + seconds.Values.Should().AllBeEquivalentTo(21.0); + } + + [Fact] + public void A_short_burst_counts_for_less_than_a_sustained_saturation() + { + const double fullAt = 60, floor = 0.15; + + var burst = SeriesStats.Credibility(7, fullAt, floor); + var sustained = SeriesStats.Credibility(120, fullAt, floor); + + burst.Should().BeLessThan(sustained); + sustained.Should().Be(1); + // Weak evidence, never absent evidence. + burst.Should().BeGreaterThanOrEqualTo(floor); + } + + [Fact] + public void Utilization_is_judged_across_the_band_where_it_can_discriminate() + { + // Everything here is already classified loaded at 50% of plan, so a ramp from zero would + // score every episode near the top. The band starts above that threshold instead. + const double start = 0.60, full = 0.90, floor = 0.15; + + SeriesStats.CredibilityBetween(0.55, start, full, floor).Should().Be(floor); + SeriesStats.CredibilityBetween(0.75, start, full, floor).Should().BeApproximately(0.5, 0.001); + SeriesStats.CredibilityBetween(0.90, start, full, floor).Should().Be(1); + SeriesStats.CredibilityBetween(1.20, start, full, floor).Should().Be(1); + + // The naive ramp for comparison: 55% and 75% are nearly indistinguishable, which is the + // failure this band exists to avoid. + SeriesStats.Credibility(0.55, full, floor).Should().BeApproximately(0.61, 0.01); + SeriesStats.Credibility(0.75, full, floor).Should().BeApproximately(0.83, 0.01); + } + + [Fact] + public void A_weighted_mean_is_used_for_loss_because_a_median_of_mostly_zeros_is_zero() + { + // Loss is a rate: most samples are zero even on a line dropping traffic under load, so a + // median reports zero however bad the rest are. The mean carries them. + var samples = new[] { (0.0, 1.0), (0.0, 1.0), (0.0, 1.0), (8.0, 1.0), (8.0, 1.0) }; + + SeriesStats.WeightedMedian(samples).Should().Be(0); + SeriesStats.WeightedMean(samples).Should().BeApproximately(3.2, 0.001); + } + + [Fact] + public void Credible_load_outweighs_doubtful_load_in_the_reported_loss() + { + // Same two readings, one from a long saturation and one from a two-second blip: the + // sustained one decides the number. + var trusted = new[] { (6.0, 1.0), (0.0, 0.15) }; + var doubted = new[] { (6.0, 0.15), (0.0, 1.0) }; + + SeriesStats.WeightedMean(trusted).Should().BeApproximately(5.22, 0.01); + SeriesStats.WeightedMean(doubted).Should().BeApproximately(0.78, 0.01); + } + + [Fact] + public void Nothing_credible_is_null_rather_than_zero() + { + SeriesStats.WeightedMean(new[] { (5.0, 0.0) }).Should().BeNull(); + SeriesStats.WeightedMean(Array.Empty<(double, double)>()).Should().BeNull(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/LoadEpisodeTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/LoadEpisodeTests.cs new file mode 100644 index 0000000000..ba0a2ced4f --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/LoadEpisodeTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.IspHealth; + +/// +/// A load window is seven seconds; an episode is however long the line actually stayed loaded. +/// Grouping by window made "the newest three" mean the last twenty seconds, so any brief lull +/// inside one bad evening read as a line that had been fixed. +/// +public class LoadEpisodeTests +{ + private const int WindowSeconds = 7; + + private static DateTime W(int index) => new DateTime(2026, 8, 5, 0, 0, 0, DateTimeKind.Utc) + .AddSeconds(index * WindowSeconds); + + [Fact] + public void Consecutive_windows_share_one_episode_start() + { + var starts = SeriesStats.LoadEpisodeStarts(new[] { W(0), W(1), W(2) }, WindowSeconds); + + starts.Values.Should().AllBeEquivalentTo(W(0)); + } + + [Fact] + public void A_gap_begins_a_new_episode() + { + var starts = SeriesStats.LoadEpisodeStarts(new[] { W(0), W(1), W(9), W(10) }, WindowSeconds); + + starts[W(0)].Should().Be(W(0)); + starts[W(1)].Should().Be(W(0)); + starts[W(9)].Should().Be(W(9)); + starts[W(10)].Should().Be(W(9)); + starts.Values.Distinct().Should().HaveCount(2); + } + + [Fact] + public void A_long_saturation_is_one_episode_not_many() + { + // Five minutes of continuous load: one event, however many windows it spans. + var windows = Enumerable.Range(0, 43).Select(W).ToArray(); + + var starts = SeriesStats.LoadEpisodeStarts(windows, WindowSeconds); + + starts.Values.Distinct().Should().ContainSingle(); + SeriesStats.LoadEpisodeSeconds(windows, WindowSeconds).Values.Should().AllBeEquivalentTo(43 * 7.0); + } + + [Fact] + public void Unordered_input_still_groups_correctly() + { + var starts = SeriesStats.LoadEpisodeStarts(new[] { W(10), W(1), W(9), W(0) }, WindowSeconds); + + starts[W(1)].Should().Be(W(0)); + starts[W(10)].Should().Be(W(9)); + } + + [Fact] + public void Nothing_loaded_is_an_empty_map_rather_than_a_throw() + { + SeriesStats.LoadEpisodeStarts(Array.Empty(), WindowSeconds).Should().BeEmpty(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/RecencyWeightedMedianTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/RecencyWeightedMedianTests.cs new file mode 100644 index 0000000000..bc403131c3 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/RecencyWeightedMedianTests.cs @@ -0,0 +1,83 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.IspHealth; + +/// +/// Loaded latency is read from WAN speed tests, and a plain median over the window treated a test +/// from an hour ago exactly like one from six days ago - so a line fixed this afternoon went on +/// reporting bufferbloat until the good tests outnumbered the bad, which on a daily schedule takes +/// a week. Weighting by recency answers "is it fixed NOW" without giving up the median's refusal to +/// swing on one sample. +/// +public class RecencyWeightedMedianTests +{ + // The shipped default. Shorter and the newest sample outweighs everything before it on a + // daily test schedule, which stops being a median at all - the last test in this file is what + // pins that down. + private const double HalfLifeHours = 48; + + private static (double Value, double Weight) Sample(double value, double ageHours) => + (value, SeriesStats.RecencyWeight(TimeSpan.FromHours(ageHours), HalfLifeHours)); + + [Fact] + public void With_no_decay_it_is_the_plain_median() + { + var samples = new[] { (1.0, 1.0), (5.0, 1.0), (30.0, 1.0) }; + + SeriesStats.WeightedMedian(samples).Should().Be(5.0); + } + + [Fact] + public void RecencyWeight_halves_every_half_life() + { + SeriesStats.RecencyWeight(TimeSpan.Zero, HalfLifeHours).Should().Be(1); + SeriesStats.RecencyWeight(TimeSpan.FromHours(48), HalfLifeHours).Should().BeApproximately(0.5, 0.001); + SeriesStats.RecencyWeight(TimeSpan.FromHours(96), HalfLifeHours).Should().BeApproximately(0.25, 0.001); + // Opting out restores equal weighting. + SeriesStats.RecencyWeight(TimeSpan.FromDays(30), 0).Should().Be(1); + } + + [Fact] + public void Three_clean_runs_outweigh_a_week_of_bad_ones() + { + // The WAN4 case: ~+23 ms every morning for a week, then the line is fixed and the last + // three runs come back clean. The plain median still reads ~23 and keeps the finding up. + var samples = new List<(double, double)> + { + Sample(0, 1), Sample(0, 5), Sample(0, 7), + }; + for (var day = 1; day <= 7; day++) samples.Add(Sample(23, day * 24)); + + SeriesStats.Median(samples.Select(s => s.Item1).ToList()).Should().Be(23); + SeriesStats.WeightedMedian(samples).Should().Be(0); + } + + [Fact] + public void One_clean_run_does_not_clear_a_standing_finding() + { + // The other half of the bargain: it is still a median, so a single good test among bad + // ones cannot call the fault fixed. + var samples = new List<(double, double)> { Sample(0, 1) }; + for (var day = 1; day <= 7; day++) samples.Add(Sample(23, day * 24)); + + SeriesStats.WeightedMedian(samples).Should().Be(23); + } + + [Fact] + public void One_bad_run_does_not_raise_a_finding_on_its_own() + { + var samples = new List<(double, double)> { Sample(40, 1) }; + for (var day = 1; day <= 5; day++) samples.Add(Sample(2, day * 24)); + + SeriesStats.WeightedMedian(samples).Should().Be(2); + } + + [Fact] + public void Nothing_to_weigh_is_null() + { + SeriesStats.WeightedMedian(Array.Empty<(double, double)>()).Should().BeNull(); + SeriesStats.WeightedMedian(new[] { (5.0, 0.0) }).Should().BeNull(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/SpeedTestLiftTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/SpeedTestLiftTests.cs new file mode 100644 index 0000000000..13ae0d30b1 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/SpeedTestLiftTests.cs @@ -0,0 +1,158 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.IspHealth; + +/// +/// A WAN speed test measures the same event on purpose and at full saturation, while the latency +/// probes only sample it on their own cadence - so a short event's peak queue can build and drain +/// between two probes unseen. The test stands in only where it read HIGHER, which is the one +/// direction passive sampling fails in. +/// +/// That asymmetry is only fair while neither instrument can over-read, so a test that never filled +/// the pipe is refused: it did not load the buffers, and since the substitution only ever raises +/// the figure there is nothing downstream able to correct it. +/// +/// +/// Distinct from the older wholesale fallback, which takes the speed tests' own deltas when the +/// series yields no loaded figure AT ALL - a path that is no longer reachable while there are +/// loaded windows, since a line whose every episode read clean now answers 0 rather than nothing. +/// +/// +public class SpeedTestLiftTests +{ + private static readonly TimeSpan Day = TimeSpan.FromHours(24); + private static readonly DateTime LoadedStart = TestSeries.Start.AddHours(12); + private static readonly DateTime LoadedEnd = TestSeries.Start.AddHours(18); + private static readonly AccessProfile Gpon = IspHealthProfiles.GetProfile(AccessTechnology.Gpon)!; + private static readonly IspHealthOptions Options = new(); + + /// + /// What the probes themselves saw under load. The idle floor is 2.0, so 3.0 is a measured + /// delta of about 1 ms; passing 2.0 leaves the series flat, which now reads as a clean line + /// rather than as an absent measurement. + /// + private static double? LoadedDown(double loadedHopRtt, params SpeedTestSample[] tests) + { + var rates = TestSeries.Throughput(TestSeries.Start, Day, 50, 5) + .Select(r => r.Time >= LoadedStart && r.Time < LoadedEnd + ? r with { DownloadBps = 800_000_000 } + : r) + .ToList(); + + var hop = TestSeries.Flat(TestSeries.Start, Day, 2.0, 0.3) + .WithSegment(LoadedStart, LoadedEnd, loadedHopRtt, 0.3); + + var inputs = new IspHealthInputs + { + WindowStart = TestSeries.Start, + WindowEnd = TestSeries.Start + Day, + FirstHopSeries = hop, + AccessHopSeries = new List> { hop }, + LossPoolSeries = new List> { hop }, + WanRates = rates, + ExpectedDownloadMbps = 1000, + ExpectedUploadMbps = 500, + ExpectedSpeedSource = "UniFi Network", + WanSpeedTests = tests.ToList() + }; + + var text = new IspHealthScorer(Options).Score(inputs, Gpon) + .AccessDimension.Factors.Single(f => f.Name == "Loaded Latency").ValueText; + + return double.TryParse(text?.Split(" ms down")[0], out var v) ? v : null; + } + + private static SpeedTestSample Test(DateTime at, double downMbps, double loadedMs, double? idleMs = 6) => + new(at, downMbps, 490, PingMs: idleMs, DownloadLatencyMs: loadedMs, UploadLatencyMs: 8); + + [Fact] + public void A_saturating_test_that_saw_more_queue_than_the_probes_did_sets_the_figure() + { + // 980 of a 1000 plan, 31 ms under load against its own 6 ms idle: it filled the pipe and + // measured 25 ms of queue the probes, reading about 1 ms, never sampled. + var measured = LoadedDown(3.0); + var lifted = LoadedDown(3.0, Test(LoadedStart.AddHours(1), 980, 31)); + + // Higher, not 25: the lift is confined to the ONE episode the test overlapped, and the + // factor is the median across every episode in the window. A single test moving the whole + // figure to its own reading would be exactly the unconfined bias this avoids. + lifted.Should().BeGreaterThan(measured!.Value); + } + + [Fact] + public void A_test_that_never_filled_the_pipe_is_refused() + { + // Same 25 ms at a fifth of plan - it never loaded the buffers, so whatever it measured was + // not this link at saturation. This is the case that would otherwise bias every matched + // episode upward with nothing able to pull it back. + var measured = LoadedDown(3.0); + var lifted = LoadedDown(3.0, Test(LoadedStart.AddHours(1), 200, 31)); + + lifted.Should().Be(measured); + } + + [Fact] + public void A_test_reading_lower_than_the_probes_does_not_pull_the_figure_down() + { + var measured = LoadedDown(3.0); + var clean = LoadedDown(3.0, Test(LoadedStart.AddHours(1), 980, 6.1)); + + clean.Should().Be(measured); + } + + [Fact] + public void A_test_from_outside_the_episode_is_not_its_measurement() + { + var measured = LoadedDown(3.0); + var far = LoadedDown(3.0, Test(TestSeries.Start.AddHours(2), 980, 31)); + + far.Should().Be(measured); + } + + [Fact] + public void A_test_without_its_own_idle_reference_is_unusable() + { + // The delta is loaded-minus-idle from the SAME probe seconds apart. With no idle figure + // there is nothing to subtract, and borrowing our baseline would reintroduce every blind + // spot the substitution exists to avoid. + var measured = LoadedDown(3.0); + var noIdle = LoadedDown(3.0, Test(LoadedStart.AddHours(1), 980, 31, idleMs: null)); + + noIdle.Should().Be(measured); + } + + [Fact] + public void Recent_clean_tests_outrank_an_older_bad_one() + { + // The regression this exists for. Taking the highest qualifying test in the window meant + // one bad day outranked every clean test since, so a line whose recent tests are all clean + // kept reporting its worst reading from a week ago - and it walked straight past the + // clean-run verdict that had already decided the line was fixed. + var oldBad = Test(LoadedStart.AddMinutes(10), 980, 31); + var recentClean = new[] + { + Test(LoadedStart.AddHours(3), 980, 6.2), + Test(LoadedStart.AddHours(4), 980, 6.1), + Test(LoadedStart.AddHours(5), 980, 6.3), + }; + + var withHistory = LoadedDown(3.0, new[] { oldBad }.Concat(recentClean).ToArray()); + + withHistory.Should().BeLessThan(10); + } + + [Fact] + public void A_site_whose_probes_saw_nothing_still_gets_what_its_test_measured() + { + // Since load episodes that all read clean became a real answer rather than no-answer, a + // flat series returns 0 instead of null and never reaches the older wholesale fallback. + // The lift covers that hole from the other side: the site is not left blind just because + // its probes never sampled the queue its own test measured. + var flat = LoadedDown(2.0, Test(LoadedStart.AddHours(1), 980, 31)); + + flat.Should().BeApproximately(25, 1); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/MeteredProbePolicyTests.cs b/tests/NetworkOptimizer.Web.Tests/MeteredProbePolicyTests.cs new file mode 100644 index 0000000000..e89844ea1c --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/MeteredProbePolicyTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests; + +public class MeteredProbePolicyTests +{ + [Theory] + [InlineData(AccessTechnology.Gpon)] + [InlineData(AccessTechnology.XgsPon)] + [InlineData(AccessTechnology.Docsis)] + [InlineData(AccessTechnology.DirectEthernet)] + [InlineData(AccessTechnology.PppoE)] + [InlineData(AccessTechnology.Dsl)] + [InlineData(AccessTechnology.Unknown)] + [InlineData(AccessTechnology.Other)] + public void Wireline_and_unknown_technologies_probe_as_before(AccessTechnology technology) + { + var plan = MeteredProbePolicy.For(technology, dataUsageEnabled: false); + + plan.Rung.Should().Be(0); + plan.MaxAutoEnabled.Should().BeNull(); + plan.PollIntervalSeconds.Should().Be(MeteredProbePolicy.DefaultIntervalSeconds); + } + + [Theory] + [InlineData(AccessTechnology.Satellite)] + [InlineData(AccessTechnology.Cellular)] + [InlineData(AccessTechnology.FixedWireless)] + public void Usually_metered_technologies_drop_a_rung(AccessTechnology technology) + { + var plan = MeteredProbePolicy.For(technology, dataUsageEnabled: false); + + plan.Rung.Should().Be(1); + plan.MaxAutoEnabled.Should().Be(15); + plan.PollIntervalSeconds.Should().Be(30); + } + + [Fact] + public void A_declared_cap_drops_a_rung_on_its_own() + { + // Cable with a cap costs the same per byte as satellite without one. + var plan = MeteredProbePolicy.For(AccessTechnology.Docsis, dataUsageEnabled: true); + + plan.Rung.Should().Be(1); + plan.MaxAutoEnabled.Should().Be(15); + } + + [Fact] + public void The_two_signals_stack() + { + var plan = MeteredProbePolicy.For(AccessTechnology.Satellite, dataUsageEnabled: true); + + plan.Rung.Should().Be(2); + plan.MaxAutoEnabled.Should().Be(8); + plan.PollIntervalSeconds.Should().Be(60); + } + + [Fact] + public void Rungs_land_where_the_traffic_estimate_says_they_should() + { + // The numbers the ladder was chosen against, both directions, 30 days. + MeteredProbePolicy.EstimatedMonthlyGb(25, 10).Should().BeApproximately(5.44, 0.05); + MeteredProbePolicy.EstimatedMonthlyGb(15, 30).Should().BeApproximately(1.09, 0.05); + MeteredProbePolicy.EstimatedMonthlyGb(8, 60).Should().BeApproximately(0.29, 0.02); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanDeepLinkTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanDeepLinkTests.cs new file mode 100644 index 0000000000..13095f2a08 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanDeepLinkTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using Microsoft.AspNetCore.WebUtilities; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// Clicking a WAN's live score has to open THAT WAN's report. The live tiles and the analysis +/// pages keep their selections apart on purpose, so the link carries the WAN explicitly rather +/// than the two sharing state. +/// +public class IspHealthWanDeepLinkTests +{ + private static string? LinkedWanKey(string uri) + { + var value = QueryHelpers.ParseQuery(new Uri(uri).Query) + .TryGetValue("wan", out var v) ? v.ToString() : null; + return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + } + + [Theory] + [InlineData("https://x/monitoring?tab=isp-health&wan=wan2", "wan2")] + [InlineData("https://x/monitoring?tab=isp-health&wan=WAN2", "wan2")] + [InlineData("https://x/monitoring?tab=isp-health", null)] + [InlineData("https://x/monitoring?tab=isp-health&wan=", null)] + [InlineData("https://x/monitoring", null)] + public void TheLinkedWanIsReadFromTheQuery(string uri, string? expected) + { + LinkedWanKey(uri).Should().Be(expected); + } + + [Fact] + public void APrimarySelectionAddsNoParameter() + { + // The primary's report is what the page opens on anyway; a parameter would only be noise + // in the address bar. + var query = (IsPrimary: true, Key: "wan") is { IsPrimary: false } w + ? $"&wan={Uri.EscapeDataString(w.Key)}" : ""; + + query.Should().BeEmpty(); + } + + [Fact] + public void ANonPrimarySelectionTravels() + { + var sel = (IsPrimary: false, Key: "wan2"); + var query = !sel.IsPrimary ? $"&wan={Uri.EscapeDataString(sel.Key)}" : ""; + + query.Should().Be("&wan=wan2"); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanScopingTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanScopingTests.cs new file mode 100644 index 0000000000..c5053547ce --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanScopingTests.cs @@ -0,0 +1,159 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Storage.Services; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// ISP Health now scopes every input to the WAN it grades. These pin the scoping predicates +/// themselves - which targets a WAN owns, which Influx wan-tag filter each scope emits, and +/// how the primary's wan key resolves - plus the single-WAN equivalence bar: with one WAN and +/// no contexts, the scoped selection must be exactly what the old unscoped queries returned. +/// +public class IspHealthWanScopingTests +{ + private static MonitoringTarget Target(string id, string? wan) => new() + { + TargetId = id, + Name = id, + Address = "192.0.2.1", + WanInterface = wan, + }; + + // ─── Target scoping ─── + + [Fact] + public void PrimaryScope_KeepsItsOwnAndUnstampedRows() + { + var targets = new List + { + Target("a", null), // hand-added / legacy - always a primary-path measurement + Target("b", ""), + Target("c", "wan"), + Target("d", "WAN"), // key case is not a different WAN + Target("e", "wan2"), // another WAN's row must never grade the primary + }; + + IspHealthService.ScopeTargetsToWan(targets, "wan", includeUnassigned: true) + .Select(t => t.TargetId).Should().Equal("a", "b", "c", "d"); + } + + [Fact] + public void ScopedWan_OwnsOnlyRowsStampedWithItsKey() + { + var targets = new List + { + Target("a", null), // unstamped belongs to the primary, not to wan2 + Target("b", "wan"), + Target("c", "wan2"), + Target("d", "WAN2"), + Target("e", "wan2"), + }; + + IspHealthService.ScopeTargetsToWan(targets, "wan2", includeUnassigned: false) + .Select(t => t.TargetId).Should().Equal("c", "d", "e"); + } + + [Fact] + public void SingleWanSite_ScopedSelectionIsExactlyTheOldUnscopedOne() + { + // The equivalence bar: a single-WAN site's rows are unstamped (legacy/hand-added) or + // stamped with its one wan key, so the primary scope selects every row the old + // unfiltered query returned - same rows, same order. + var targets = new List + { + Target("legacy", null), + Target("hop", "wan"), + Target("transit", "wan"), + Target("dns", null), + }; + + var scoped = IspHealthService.ScopeTargetsToWan(targets, "wan", includeUnassigned: true); + + scoped.Should().Equal(targets); + } + + // ─── Primary wan key resolution ─── + + [Fact] + public void PrimaryWanKey_FallsBackToTheConventionalWanWithNoContexts() + { + IspHealthService.ResolvePrimaryWanKey(Array.Empty()).Should().Be("wan"); + } + + [Fact] + public void PrimaryWanKey_PrefersTheWanRowOverOthers() + { + var contexts = new[] + { + new WanDiscoveryContext { WanInterface = "wan2" }, + new WanDiscoveryContext { WanInterface = "wan" }, + }; + IspHealthService.ResolvePrimaryWanKey(contexts).Should().Be("wan"); + } + + [Fact] + public void PrimaryWanKey_TakesTheOnlyRowWhenWanIsAbsent() + { + var contexts = new[] { new WanDiscoveryContext { WanInterface = "wan2" } }; + IspHealthService.ResolvePrimaryWanKey(contexts).Should().Be("wan2"); + } + + // ─── Influx wan-tag scope ─── + + [Fact] + public void PrimaryScope_WithNoContextsReadsOnlyUntaggedSeries() + { + var scope = IspHealthService.BuildWanScope(Array.Empty(), "wan", primaryScope: true); + + scope.IncludeUntagged.Should().BeTrue(); + scope.WanTags.Should().BeEmpty(); + } + + [Fact] + public void PrimaryScope_IgnoresContextsBoundToOtherWans() + { + var contexts = new[] { new WanContext { Name = "backup", WanInterface = "wan2" } }; + + var scope = IspHealthService.BuildWanScope(contexts, "wan", primaryScope: true); + + scope.WanTags.Should().BeEmpty(); + } + + [Fact] + public void PrimaryScope_KeepsAPrimaryBoundContextsTaggedPoints() + { + var contexts = new[] { new WanContext { Name = "gw-bound", WanInterface = "wan" } }; + + var scope = IspHealthService.BuildWanScope(contexts, "wan", primaryScope: true); + + scope.IncludeUntagged.Should().BeTrue(); + scope.WanTags.Should().BeEquivalentTo("wan", "gw-bound"); + } + + [Fact] + public void ScopedWan_ReadsItsKeyAndItsContextsNames_NeverUntagged() + { + var contexts = new[] + { + new WanContext { Name = "starlink-backup", WanInterface = "wan2" }, + new WanContext { Name = "other", WanInterface = "wan3" }, + }; + + var scope = IspHealthService.BuildWanScope(contexts, "wan2", primaryScope: false); + + scope.IncludeUntagged.Should().BeFalse(); + scope.WanTags.Should().BeEquivalentTo("wan2", "starlink-backup"); + } + + [Fact] + public void ScopedWan_WithNoContextRowStillFiltersOnItsStableKey() + { + var scope = IspHealthService.BuildWanScope(Array.Empty(), "wan2", primaryScope: false); + + scope.IncludeUntagged.Should().BeFalse(); + scope.WanTags.Should().Equal("wan2"); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/OldAgentCompatibilityTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/OldAgentCompatibilityTests.cs new file mode 100644 index 0000000000..4465ae0027 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/OldAgentCompatibilityTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using Google.Protobuf; +using NetworkOptimizer.AgentProtocol; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// The new server has to keep working against agent binaries that predate this branch, because +/// that is what every deployed site is running until the agents are rolled out. The rule is that +/// an old agent behaves exactly as it did, and is never handed work it cannot do correctly. +/// +public class OldAgentCompatibilityTests +{ + [Fact] + public void AnOldAgentsHello_ReadsAsDidNotSay_NotAsNo() + { + // No supports_source_bind on the wire at all. Absent has to stay distinguishable from an + // explicit false, because "cannot bind" and "did not say" get treated the same only by + // accident - and the field is what gates offering an interface bind. + var hello = new AgentHello { AgentKey = "k", Version = "2.5.3", LanIp = "192.0.2.10" }; + + hello.HasSupportsSourceBind.Should().BeFalse(); + var stored = hello.HasSupportsSourceBind ? hello.SupportsSourceBind : (bool?)null; + stored.Should().BeNull(); + } + + [Fact] + public void ANewAgentCanSayNo_Distinctly() + { + var hello = new AgentHello { AgentKey = "k", SupportsSourceBind = false }; + + hello.HasSupportsSourceBind.Should().BeTrue(); + var stored = hello.HasSupportsSourceBind ? hello.SupportsSourceBind : (bool?)null; + stored.Should().Be(false); + } + + [Fact] + public void AnOldAgentRoundTripsThroughTheNewProto() + { + // Field 6 is new; nothing else moved. An old agent's bytes still parse, and a new server's + // extra field does not disturb the fields an old agent reads. + var hello = new AgentHello { AgentKey = "k", Version = "2.5.3", LanIp = "192.0.2.10", SpeedTestPort = 3000 }; + + var parsed = AgentHello.Parser.ParseFrom(hello.ToByteArray()); + + parsed.AgentKey.Should().Be("k"); + parsed.LanIp.Should().Be("192.0.2.10"); + parsed.SpeedTestPort.Should().Be(3000); + parsed.HasSupportsSourceBind.Should().BeFalse(); + } + + [Fact] + public void ProbeTargetSpecSourceIp_IsNotNewOnThisBranch() + { + // The field the server now populates predates this work, and old agents already prefer it + // over their own default - which is why per-probe PING binding works before any rollout. + var spec = new ProbeTargetSpec { TargetId = "t", Address = "192.0.2.1", SourceIp = "198.51.100.7" }; + + spec.SourceIp.Should().Be("198.51.100.7"); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/PerWanDiscoveryTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/PerWanDiscoveryTests.cs new file mode 100644 index 0000000000..34dd6f1ee5 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/PerWanDiscoveryTests.cs @@ -0,0 +1,311 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Storage; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// Upstream discovery used to run for one WAN - the configured primary - so a secondary WAN's +/// context had targets nobody discovered and no hop ancestry to grade. It now runs per context, +/// which puts two things on every target it writes: the WAN the data describes and the context +/// whose agent probes it. These cover that double stamping, the rule that keeps two WANs' runs +/// from fighting over one shared row, and the per-WAN cadence that decides who runs when - each +/// with its no-contexts counterpart, since that is every single-WAN install. +/// +public class PerWanDiscoveryTests +{ + private static NetworkOptimizerDbContext NewDb() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static AccessHopCandidate Hop(string address) => new() + { + TargetId = $"access-{address}", + Label = "First hop", + Address = address, + AsnNumber = 64500, + AsnName = "Example ISP", + Role = UpstreamRole.AccessHop, + HopNumber = 1, + RespondedTo = ProbeMode.Icmp, + Method = DiscoveryMethod.DirectRouter, + Enabled = true, + }; + + private static TransitAsnCandidate Transit(string address) => new() + { + AsnNumber = 64501, + AsnName = "Example Transit", + Method = DiscoveryMethod.DirectRouter, + TargetId = $"transit-as64501-{address}", + HopAddress = address, + RespondedTo = ProbeMode.Icmp, + Enabled = true, + }; + + [Fact] + public async Task ContextRun_StampsBothTheWanAndTheContextOnANewAccessTarget() + { + await using var db = NewDb(); + + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + + var target = await db.MonitoringTargets.SingleAsync(); + target.WanInterface.Should().Be("wan2"); + target.WanContextId.Should().Be(4); + } + + [Fact] + public async Task ContextRun_StampsBothOnANewTransitTarget() + { + await using var db = NewDb(); + + await UpstreamTracerService.UpsertTransitTargetAsync(db, Transit("203.0.113.9"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + + var target = await db.MonitoringTargets.SingleAsync(); + target.WanInterface.Should().Be("wan2"); + target.WanContextId.Should().Be(4); + } + + [Fact] + public async Task PrimaryRun_LeavesTheContextAloneJustAsItAlwaysHas() + { + // No contexts means no context id, and nothing about the written row changes. + await using var db = NewDb(); + + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan", wanContextId: null, default); + await db.SaveChangesAsync(); + + var target = await db.MonitoringTargets.SingleAsync(); + target.WanInterface.Should().Be("wan"); + target.WanContextId.Should().BeNull(); + } + + [Fact] + public async Task PrimaryRun_KeepsAHandAssignedContextOnRevalidation() + { + // The per-target WAN dropdown is the user's own statement about who probes a target; a + // primary re-validation that cleared it would silently move the target back. + await using var db = NewDb(); + db.MonitoringTargets.Add(new MonitoringTarget + { + TargetId = "access-198.51.100.1", + Name = "First hop", + Address = "198.51.100.1", + TargetType = MonitoringTargetType.AccessIsp, + WanInterface = "wan", + WanContextId = 9, + }); + await db.SaveChangesAsync(); + + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan", wanContextId: null, default); + await db.SaveChangesAsync(); + + (await db.MonitoringTargets.SingleAsync()).WanContextId.Should().Be(9); + } + + [Fact] + public async Task ContextRun_CreatesItsOwnTwinForAHostAnotherWanAlreadyClaimed() + { + // A host both WANs reach - a core resolver, a shared ISP hop - is probed from BOTH: + // the claiming WAN keeps the base row untouched (never re-homed, never re-enabled by + // the other run), and the second WAN gets its own WAN-qualified row so the two series + // stay separable and comparable by Address. + await using var db = NewDb(); + db.MonitoringTargets.Add(new MonitoringTarget + { + TargetId = "access-198.51.100.1", + Name = "First hop", + Address = "198.51.100.1", + TargetType = MonitoringTargetType.AccessIsp, + WanInterface = "wan", + Enabled = false, + }); + await db.SaveChangesAsync(); + + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + + var original = await db.MonitoringTargets.SingleAsync(t => t.TargetId == "access-198.51.100.1"); + original.WanInterface.Should().Be("wan"); + original.WanContextId.Should().BeNull(); + original.Enabled.Should().BeFalse(); + + var twin = await db.MonitoringTargets.SingleAsync(t => t.TargetId == "access-198.51.100.1@wan2"); + twin.WanInterface.Should().Be("wan2"); + twin.WanContextId.Should().Be(4); + twin.Enabled.Should().BeTrue(); + twin.Address.Should().Be(original.Address); + } + + [Fact] + public async Task ContextRun_RevalidatesItsTwinInsteadOfStackingAnother() + { + await using var db = NewDb(); + db.MonitoringTargets.Add(new MonitoringTarget + { + TargetId = "access-198.51.100.1", + Name = "First hop", + Address = "198.51.100.1", + TargetType = MonitoringTargetType.AccessIsp, + WanInterface = "wan", + }); + await db.SaveChangesAsync(); + + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + + (await db.MonitoringTargets.CountAsync()).Should().Be(2); + (await db.MonitoringTargets.CountAsync(t => t.WanInterface == "wan2")).Should().Be(1); + } + + [Fact] + public async Task ContextRun_CreatesATransitTwinTheSameWay() + { + await using var db = NewDb(); + db.MonitoringTargets.Add(new MonitoringTarget + { + TargetId = "transit-as64501-203.0.113.9", + Name = "Example Transit", + Address = "203.0.113.9", + TargetType = MonitoringTargetType.Transit, + WanInterface = "wan", + }); + await db.SaveChangesAsync(); + + await UpstreamTracerService.UpsertTransitTargetAsync(db, Transit("203.0.113.9"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + + var twin = await db.MonitoringTargets.SingleAsync(t => t.TargetId == "transit-as64501-203.0.113.9@wan2"); + twin.WanInterface.Should().Be("wan2"); + (await db.MonitoringTargets.SingleAsync(t => t.TargetId == "transit-as64501-203.0.113.9")) + .WanInterface.Should().Be("wan"); + } + + [Fact] + public void WanQualifiedTargetId_SuffixesTheWanKeyStably() + { + UpstreamTracerService.WanQualifiedTargetId("access-198.51.100.1", "WAN2") + .Should().Be("access-198.51.100.1@wan2"); + } + + [Fact] + public async Task ContextRun_AdoptsARowThatHasNoWanYet() + { + await using var db = NewDb(); + db.MonitoringTargets.Add(new MonitoringTarget + { + TargetId = "access-198.51.100.1", + Name = "First hop", + Address = "198.51.100.1", + TargetType = MonitoringTargetType.AccessIsp, + }); + await db.SaveChangesAsync(); + + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + + var target = await db.MonitoringTargets.SingleAsync(); + target.WanInterface.Should().Be("wan2"); + target.WanContextId.Should().Be(4); + } + + [Theory] + [InlineData(null, "wan", true)] // never stamped - adoptable, which is every legacy row + [InlineData("", "wan", true)] + [InlineData("wan", "wan", true)] + [InlineData("WAN", "wan", true)] // the WAN key's case is not a different WAN + [InlineData("wan2", "wan", false)] + public void OwnsTargetRow_LetsARunWriteOnlyItsOwnWansRows(string? rowWan, string runWan, bool expected) + { + UpstreamTracerService.OwnsTargetRow(rowWan, runWan).Should().Be(expected); + } + + [Fact] + public void ContextsDueForDiscovery_SkipsAContextThatHasNoWanYet() + { + var contexts = new[] { new WanContext { Id = 1, Name = "backup-wan" } }; + + UpstreamRediscoveryService.ContextsDueForDiscovery( + contexts, new Dictionary(), DateTime.UtcNow, TimeSpan.FromDays(7)) + .Should().BeEmpty(); + } + + [Fact] + public void ContextsDueForDiscovery_RunsAWanThatHasNeverDiscovered() + { + var contexts = new[] { new WanContext { Id = 1, Name = "backup-wan", WanInterface = "wan2" } }; + + UpstreamRediscoveryService.ContextsDueForDiscovery( + contexts, new Dictionary(), DateTime.UtcNow, TimeSpan.FromDays(7)) + .Should().ContainSingle().Which.WanInterface.Should().Be("wan2"); + } + + [Fact] + public void ContextsDueForDiscovery_HoldsAWanDiscoveredRecentlyAndRunsAStaleOne() + { + var now = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc); + var contexts = new[] + { + new WanContext { Id = 1, Name = "backup-wan", WanInterface = "wan2" }, + new WanContext { Id = 2, Name = "lte", WanInterface = "wan3" }, + }; + var last = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["wan2"] = now.AddDays(-1), + ["wan3"] = now.AddDays(-9), + }; + + UpstreamRediscoveryService.ContextsDueForDiscovery(contexts, last, now, TimeSpan.FromDays(7)) + .Select(c => c.WanInterface).Should().Equal("wan3"); + } + + [Fact] + public void ContextsDueForDiscovery_WithNoContextsRunsNothing() + { + UpstreamRediscoveryService.ContextsDueForDiscovery( + Array.Empty(), new Dictionary(), DateTime.UtcNow, TimeSpan.FromDays(7)) + .Should().BeEmpty(); + } + + [Fact] + public void SelectAgent_WithNoAgentAskedForTakesTheSitesFirst() + { + var connections = Connections(1, 2); + + AgentProbeService.SelectAgent(connections, null)!.AgentId.Should().Be(1); + } + + [Fact] + public void SelectAgent_TakesTheAgentAskedFor() + { + var connections = Connections(1, 2); + + AgentProbeService.SelectAgent(connections, 2)!.AgentId.Should().Be(2); + } + + [Fact] + public void SelectAgent_NeverSubstitutesAnotherAgentForTheOneAskedFor() + { + // The named agent sits behind a particular WAN; another one measures a different path. + var connections = Connections(1, 2); + + AgentProbeService.SelectAgent(connections, 99).Should().BeNull(); + } + + private static List Connections(params int[] agentIds) + { + var registry = new AgentTunnelRegistry(new AgentTunnelOptions(Enabled: true, Port: 0)); + return agentIds.Select(id => registry.Register(id, "site1", $"Agent{id}")).ToList(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/ProbeVantagesTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/ProbeVantagesTests.cs new file mode 100644 index 0000000000..d75654c1e7 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/ProbeVantagesTests.cs @@ -0,0 +1,125 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// Network Tools offers a choice of where a probe runs from only when there is a choice to make. +/// One origin - which is every single-WAN, single-agent site - leaves the page exactly as it was, +/// and an agent that runs on the gateway stays a separate entry from the gateway's own SSH +/// vantage on purpose: same box, different execution paths, and telling them apart is what +/// separates an agent-side binding problem from a network one. +/// +public class ProbeVantagesTests +{ + private static ProbeVantageAgent Agent( + int id, string name, bool onGateway = false, params ProbeVantageBinding[] vantages) + => new(id, name, onGateway, vantages); + + private static ProbeVantageBinding Vantage( + int id, string name, string? wanLabel = null, string? bind = null) + => new(id, name, wanLabel, bind); + + [Fact] + public void ServerOnly_OffersNoPicker() + { + var options = ProbeVantages.ForPicker(true, "Network Optimizer server", Array.Empty()); + + options.Should().BeEmpty(); + } + + [Fact] + public void SingleAgentSiteWhereTheAgentIsTheServerVantage_OffersNoPicker() + { + // A secondary site with one agent: the "server" vantage already means that agent, so + // listing it twice would be the only thing a picker added. + var options = ProbeVantages.ForPicker(false, "On-site agent", new[] { Agent(1, "Agent1") }); + + options.Should().BeEmpty(); + } + + [Fact] + public void ServerPlusAContextAgent_OffersBoth() + { + var options = ProbeVantages.ForPicker(true, "Network Optimizer server", new[] + { + Agent(7, "Agent1", false, Vantage(4, "backup-wan", "Backup ISP WAN2", "198.51.100.7")) + }); + + options.Select(o => o.Key).Should().Equal("server", "agent:7:4"); + options[0].AgentId.Should().BeNull(); + options[1].Label.Should().Be("Agent1 - Backup ISP WAN2"); + options[1].AgentId.Should().Be(7); + options[1].SourceBind.Should().Be("198.51.100.7"); + } + + [Fact] + public void OnGatewayAgent_IsListedSeparatelyAndSaysSo() + { + // Deliberate: the gateway is also offered as its own SSH vantage elsewhere on the page, + // and these two are never collapsed into one entry. + var options = ProbeVantages.ForPicker(true, "Network Optimizer server", new[] + { + Agent(3, "Agent1", true, Vantage(9, "wan2-context", "Backup ISP WAN2", "eth8")) + }); + + options.Should().HaveCount(2); + options[1].Label.Should().Be("Agent1 - Backup ISP WAN2 (gateway)"); + options[1].SourceBind.Should().Be("eth8"); + } + + [Fact] + public void OnGatewayAgentWithNoContext_StillCarriesTheMarker() + { + var label = ProbeVantages.LabelFor(Agent(4, "Agent2", onGateway: true), null); + + label.Should().Be("Agent2 (gateway)"); + } + + [Fact] + public void PlainAgent_IsJustItsName() + { + ProbeVantages.LabelFor(Agent(5, "Agent3"), null).Should().Be("Agent3"); + } + + [Fact] + public void ContextWithNoKnownWan_LabelsTheContextAlone() + { + // The console can be unreachable when the list is built; the vantage still names itself. + ProbeVantages.LabelFor(Agent(6, "Agent4"), Vantage(2, "backup-wan")) + .Should().Be("Agent4 - backup-wan"); + } + + [Fact] + public void AnAgentWithSeveralVantages_OffersOneEntryEach() + { + // Each vantage binds differently, so each is its own place to probe from. Offered as one + // entry per agent, the picker had to choose a binding and probes left by whichever + // vantage sorted first. + var options = ProbeVantages.ForPicker(true, "Network Optimizer server", new[] + { + Agent(67, "Agent 2", true, + Vantage(11, "Yelcot Cable (WAN4)", "Yelcot Cable WAN4", "eth1"), + Vantage(12, "Starlink (WAN2)", "Starlink WAN2", "eth0")) + }); + + options.Select(o => o.Key).Should().Equal("server", "agent:67:12", "agent:67:11"); + options[1].Label.Should().Be("Agent 2 - Starlink WAN2 (gateway)"); + options[1].SourceBind.Should().Be("eth0"); + options[2].Label.Should().Be("Agent 2 - Yelcot Cable WAN4 (gateway)"); + options[2].SourceBind.Should().Be("eth1"); + } + + [Fact] + public void TwoAgentsWithNoServerVantage_AreBothOffered() + { + var options = ProbeVantages.ForPicker(false, "On-site agent", new[] + { + Agent(2, "Zulu"), Agent(1, "Alpha", false, Vantage(3, "backup-wan")) + }); + + options.Select(o => o.Key).Should().Equal("agent:1:3", "agent:2"); + options.Should().NotContain(o => o.Key == ProbeVantages.ServerKey); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/SiteLoadBalanceDetectionTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/SiteLoadBalanceDetectionTests.cs new file mode 100644 index 0000000000..c25104d497 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/SiteLoadBalanceDetectionTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using NetworkOptimizer.UniFi; +using NetworkOptimizer.Web.Services; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// Whether the site spreads traffic across WANs decides what an unpinned probe is worth: under +/// failover-only every unpinned box leaves by the primary and measures it honestly, while under +/// load balancing the same probe is spread across WANs and attributable to none of them. +/// +public class SiteLoadBalanceDetectionTests +{ + private static NetworkInfo Wan(string group, string? lbType, bool enabled = true) => new() + { + Name = group, + Purpose = "wan", + Enabled = enabled, + WanNetworkgroup = group, + WanLoadBalanceType = lbType, + }; + + [Fact] + public void OneWan_IsNotLoadBalancing() + { + UniFiConnectionService.ResolveSiteLoadBalances(new[] { Wan("WAN", null) }).Should().BeFalse(); + } + + [Fact] + public void APrimaryWithAFailoverOnlyBackup_IsNotLoadBalancing() + { + UniFiConnectionService.ResolveSiteLoadBalances(new[] + { + Wan("WAN", null), + Wan("WAN2", "failover-only"), + }).Should().BeFalse(); + } + + [Fact] + public void TwoWeightedWans_AreLoadBalancing() + { + UniFiConnectionService.ResolveSiteLoadBalances(new[] + { + Wan("WAN", null), + Wan("WAN2", "weighted"), + }).Should().BeTrue(); + } + + [Fact] + public void ADisabledSecondWan_DoesNotCount() + { + UniFiConnectionService.ResolveSiteLoadBalances(new[] + { + Wan("WAN", null), + Wan("WAN2", "weighted", enabled: false), + }).Should().BeFalse(); + } + + [Fact] + public void ThreeWansWithOneOnFailover_StillLoadBalanceTheOtherTwo() + { + UniFiConnectionService.ResolveSiteLoadBalances(new[] + { + Wan("WAN", null), + Wan("WAN2", "weighted"), + Wan("WAN3", "failover-only"), + }).Should().BeTrue(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/StarlinkAlertEvaluatorTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/StarlinkAlertEvaluatorTests.cs new file mode 100644 index 0000000000..91dc39c7c4 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/StarlinkAlertEvaluatorTests.cs @@ -0,0 +1,771 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NetworkOptimizer.Alerts.Events; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Monitoring.Models; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// The Starlink alert rules, driven the way the dish poll drives them: one +/// reading at a time, with time advanced between them so the sustain windows are exercised rather +/// than waited out. +/// +/// +/// The load-bearing cases are the ones that must stay SILENT. Every "problem" field on the +/// reference dish is populated while nothing is wrong - it reports install_pending continuously, +/// fails its hardware self-test continuously, sits at a permanent rate limit, and points a steady +/// 3.69 degrees off desired - so a rule written as "the field has a value" would fire on day one +/// and never stop. Each of those has a test here that asserts nothing is published. +/// +/// +public class StarlinkAlertEvaluatorTests +{ + private const int DishId = 1; + private const string DishName = "Starlink Roof"; + + private static readonly DateTime Start = new(2026, 8, 5, 12, 0, 0, DateTimeKind.Utc); + + private readonly FakeTimeProvider _time = new(Start); + private readonly CapturingBus _bus = new(); + private readonly StarlinkAlertEvaluator _evaluator; + + public StarlinkAlertEvaluatorTests() + { + _evaluator = new StarlinkAlertEvaluator( + _bus, NullLogger.Instance, timeProvider: _time); + } + + /// + /// A reading from a dish with nothing wrong, matching what the reference dish actually + /// reports over 30 days: a benign standing alert code and no other, a self-test that has + /// always failed, a permanent rate limit on both directions, mobility class Mobile on a + /// bolted-down dish, a constant gigabit Ethernet link, a median 0.06% obstructed, and the + /// median 0.70 degrees of attitude uncertainty a healthy dish carries. + /// + private static StarlinkStats Healthy() => new() + { + ActiveAlerts = ["install_pending"], + HardwareSelfTest = "Failed", + DisablementCode = "Okay", + DownlinkRestrictedReason = "LowSpeedPolicyLimit", + UplinkRestrictedReason = "PolicyLimit", + ClassOfService = "Consumer", + MobilityClass = "Mobile", + SoftwareUpdateState = "Idle", + EthSpeedMbps = 1000, + FractionObstructed = 0.0006, + IsSnrPersistentlyLow = false, + AttitudeUncertaintyDeg = 0.70, + }; + + private ValueTask Feed(StarlinkStats stats, double? alignmentOffsetDeg = null, + double? baselineDeg = null, int? ethCapableMbps = 1000, string? wanLabel = null) => + _evaluator.EvaluateAsync(DishId, DishName, stats, + alignmentOffsetDeg, baselineDeg, ethCapableMbps, wanLabel); + + private List Of(string eventType) => + _bus.Published.Where(e => e.EventType == eventType).ToList(); + + private List RecoveriesOf(string recoveredType) => + _bus.Published + .Where(e => e.EventType == "starlink.recovered" + && e.Context.TryGetValue("recovered_type", out var t) && t == recoveredType) + .ToList(); + + // --- The silence cases --------------------------------------------------------------- + + [Fact] + public async Task HealthyDish_PublishesNothing() + { + for (var i = 0; i < 200; i++) + { + await Feed(Healthy(), alignmentOffsetDeg: 3.69, baselineDeg: 3.69); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + _bus.Published.Should().BeEmpty(); + } + + [Fact] + public async Task PermanentlyRestrictedSubscription_NeverAlerts() + { + // The reference dish is rate limited on both directions continuously and always has been. + for (var i = 0; i < 10; i++) + { + await Feed(Healthy()); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + Of("starlink.service_restricted").Should().BeEmpty(); + } + + [Fact] + public async Task SelfTestThatHasAlwaysFailed_IsNotAFault() + { + for (var i = 0; i < 10; i++) + { + await Feed(Healthy()); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + Of("starlink.dish_alert").Should().BeEmpty(); + } + + [Fact] + public async Task DishSittingAtASteadyNonZeroOffset_NeverAlerts() + { + // 30 days of the reference dish: median 3.69, with the measured p1/p99 spread and the two + // isolated outliers that are exactly why the current value is a median and not a sample. + double[] wander = [3.69, 3.42, 3.96, 3.53, 3.84, 2.04, 3.69, 4.64, 3.61, 3.75]; + + for (var i = 0; i < 300; i++) + { + await Feed(Healthy(), alignmentOffsetDeg: wander[i % wander.Length], baselineDeg: 3.69); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + Of("starlink.alignment_drift").Should().BeEmpty(); + } + + [Fact] + public async Task BenignDishAlertCodes_AreIgnored() + { + var stats = Healthy(); + stats.ActiveAlerts = ["install_pending", "is_heating", "is_power_save_idle", "roaming", "obstruction_map_reset"]; + + await Feed(stats); + + Of("starlink.dish_alert").Should().BeEmpty(); + } + + // --- starlink.dish_alert ------------------------------------------------------------- + + [Fact] + public async Task NonBenignDishAlertCode_PublishesOnceAndCarriesTheCodeVerbatim() + { + var stats = Healthy(); + stats.ActiveAlerts = ["install_pending", "thermal_shutdown"]; + + await Feed(stats); + _time.Advance(TimeSpan.FromMinutes(1)); + await Feed(stats); + + var events = Of("starlink.dish_alert"); + events.Should().ContainSingle("a standing set of codes is one open alert, not one per poll"); + events[0].Severity.Should().Be(AlertSeverity.Warning); + events[0].Source.Should().Be("starlink"); + events[0].Message.Should().Contain("thermal_shutdown").And.NotContain("install_pending"); + events[0].Context["dish_alerts"].Should().Be("thermal_shutdown"); + } + + [Fact] + public async Task ANewCodeOnTopOfAnOpenDishAlert_Republishes() + { + var stats = Healthy(); + stats.ActiveAlerts = ["thermal_shutdown"]; + await Feed(stats); + + _time.Advance(TimeSpan.FromMinutes(1)); + var worse = Healthy(); + worse.ActiveAlerts = ["thermal_shutdown", "motors_stuck"]; + await Feed(worse); + + Of("starlink.dish_alert").Should().HaveCount(2); + } + + [Fact] + public async Task DisablementCodeOtherThanOkay_IsCritical() + { + var stats = Healthy(); + stats.DisablementCode = "NoActiveAccount"; + + await Feed(stats); + + var events = Of("starlink.dish_alert"); + events.Should().ContainSingle(); + events[0].Severity.Should().Be(AlertSeverity.Critical); + events[0].Message.Should().Contain("NoActiveAccount"); + events[0].Context["disablement_code"].Should().Be("NoActiveAccount"); + } + + [Fact] + public async Task SelfTestGoingFromPassingToFailing_IsAFault() + { + var passing = Healthy(); + passing.HardwareSelfTest = "Passed"; + await Feed(passing); + + _time.Advance(TimeSpan.FromMinutes(1)); + await Feed(Healthy()); // back to "Failed" + + var events = Of("starlink.dish_alert"); + events.Should().ContainSingle(); + events[0].Message.Should().Contain("self-test"); + } + + [Fact] + public async Task DishAlertClearing_PublishesRecoveryAndReArms() + { + var faulted = Healthy(); + faulted.ActiveAlerts = ["thermal_shutdown"]; + await Feed(faulted); + + _time.Advance(TimeSpan.FromMinutes(1)); + await Feed(Healthy()); + + _time.Advance(TimeSpan.FromMinutes(1)); + await Feed(faulted); + + RecoveriesOf("starlink.dish_alert").Should().ContainSingle(); + Of("starlink.dish_alert").Should().HaveCount(2, "the condition cleared, so it can raise again"); + } + + // --- starlink.obstructed ------------------------------------------------------------- + + [Fact] + public async Task ObstructionBelowTheSustainWindow_DoesNotAlert() + { + var stats = Healthy(); + stats.FractionObstructed = 0.05; + + for (var i = 0; i < 10; i++) + { + await Feed(stats); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + Of("starlink.obstructed").Should().BeEmpty("obstruction is momentary by design"); + } + + [Fact] + public async Task SustainedObstruction_AlertsOnce() + { + var stats = Healthy(); + stats.FractionObstructed = 0.05; + + for (var i = 0; i < 40; i++) + { + await Feed(stats); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + var events = Of("starlink.obstructed"); + events.Should().ContainSingle(); + events[0].Severity.Should().Be(AlertSeverity.Warning); + events[0].MetricValue.Should().Be(0.05); + } + + [Fact] + public async Task ObstructionPastTheCriticalBar_PublishesCritical() + { + var stats = Healthy(); + stats.FractionObstructed = 0.2; + + for (var i = 0; i < 40; i++) + { + await Feed(stats); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + Of("starlink.obstructed").Should().ContainSingle() + .Which.Severity.Should().Be(AlertSeverity.Critical); + } + + [Fact] + public async Task ObstructionEscalatingFromPoorToCritical_Republishes() + { + var poor = Healthy(); + poor.FractionObstructed = 0.05; + for (var i = 0; i < 20; i++) + { + await Feed(poor); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + var critical = Healthy(); + critical.FractionObstructed = 0.2; + await Feed(critical); + + var events = Of("starlink.obstructed"); + events.Should().HaveCount(2); + events[0].Severity.Should().Be(AlertSeverity.Warning); + events[1].Severity.Should().Be(AlertSeverity.Critical); + } + + [Fact] + public async Task PersistentlyLowSnr_AlertsAsObstruction() + { + var stats = Healthy(); + stats.IsSnrPersistentlyLow = true; + + for (var i = 0; i < 20; i++) + { + await Feed(stats); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + var evt = Of("starlink.obstructed").Should().ContainSingle().Subject; + evt.Context["snr_persistently_low"].Should().Be("true"); + // The obstruction fraction is healthy here, so quoting it against the poor-obstruction + // threshold would read as "0.0006 against 0.02" beside a message about low signal. + evt.MetricValue.Should().BeNull(); + evt.ThresholdValue.Should().BeNull(); + } + + [Fact] + public async Task ObstructionClearing_PublishesRecovery() + { + var stats = Healthy(); + stats.FractionObstructed = 0.05; + for (var i = 0; i < 20; i++) + { + await Feed(stats); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + for (var i = 0; i < 20; i++) + { + await Feed(Healthy()); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + RecoveriesOf("starlink.obstructed").Should().ContainSingle(); + } + + // --- starlink.alignment_drift --------------------------------------------------------- + + [Fact] + public async Task SustainedStepBeyondTwoDegrees_AlertsOnce() + { + await Settle(offset: 3.69, baseline: 3.69); + + for (var i = 0; i < 120; i++) + { + await Feed(Healthy(), alignmentOffsetDeg: 6.5, baselineDeg: 3.69); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + var events = Of("starlink.alignment_drift"); + events.Should().ContainSingle(); + events[0].Severity.Should().Be(AlertSeverity.Warning); + events[0].ThresholdValue.Should().Be(2.0); + } + + [Fact] + public async Task StepBeyondTwoDegreesThatDoesNotHold_DoesNotAlert() + { + await Settle(offset: 3.69, baseline: 3.69); + + // Ten minutes of departure, well inside the 30 minute window. + for (var i = 0; i < 10; i++) + { + await Feed(Healthy(), alignmentOffsetDeg: 6.5, baselineDeg: 3.69); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + Of("starlink.alignment_drift").Should().BeEmpty(); + } + + /// + /// A healthy dish is nowhere near certain of its attitude - the reference dish runs p50 0.70, + /// p95 1.49, p99 1.83 degrees of uncertainty over 30 days - so the gate must sit above that + /// whole range. An earlier 1 degree bar gated out most healthy polls, and because a gated poll + /// stalls the sustain, the drift alert could never hold its 30 minute window: the rule was + /// dead rather than quiet. This pins the gate open across the real healthy distribution. + /// + [Theory] + [InlineData(0.70)] + [InlineData(1.49)] + [InlineData(1.83)] + [InlineData(2.71)] + public async Task RealDriftAtHealthyAttitudeUncertainty_StillAlerts(double uncertaintyDeg) + { + var dish = Healthy(); + dish.AttitudeUncertaintyDeg = uncertaintyDeg; + + for (var i = 0; i < 60; i++) + { + await Feed(dish, alignmentOffsetDeg: 3.69, baselineDeg: 3.69); + _time.Advance(TimeSpan.FromMinutes(1)); + } + for (var i = 0; i < 120; i++) + { + await Feed(dish, alignmentOffsetDeg: 6.5, baselineDeg: 3.69); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + Of("starlink.alignment_drift").Should().ContainSingle(); + } + + [Fact] + public async Task StepBeyondTwoDegreesWithHighAttitudeUncertainty_DoesNotAlert() + { + await Settle(offset: 3.69, baseline: 3.69); + + var confused = Healthy(); + confused.AttitudeUncertaintyDeg = 5; + for (var i = 0; i < 120; i++) + { + await Feed(confused, alignmentOffsetDeg: 6.5, baselineDeg: 3.69); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + Of("starlink.alignment_drift").Should().BeEmpty( + "above the uncertainty bar the dish does not know where it is pointing"); + } + + [Fact] + public async Task DriftReturningToBaseline_ClosesTheAlert() + { + await Settle(offset: 3.69, baseline: 3.69); + + for (var i = 0; i < 120; i++) + { + await Feed(Healthy(), alignmentOffsetDeg: 6.5, baselineDeg: 3.69); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + Of("starlink.alignment_drift").Should().ContainSingle(); + + for (var i = 0; i < 180; i++) + { + await Feed(Healthy(), alignmentOffsetDeg: 3.69, baselineDeg: 3.69); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + RecoveriesOf("starlink.alignment_drift").Should().ContainSingle(); + } + + /// + /// A run of drift interrupted by polls that could not be judged must start over, not confirm + /// on the far side of the gap as if it had held throughout. + /// + [Fact] + public async Task DriftRunInterruptedByUnjudgeablePolls_StartsOver() + { + await Settle(offset: 3.69, baseline: 3.69); + + for (var i = 0; i < 40; i++) + { + await Feed(Healthy(), alignmentOffsetDeg: 6.5, baselineDeg: 3.69); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + // The dish stops reporting its geometry for a while, then comes straight back drifted. + for (var i = 0; i < 5; i++) + { + await Feed(Healthy(), alignmentOffsetDeg: null, baselineDeg: 3.69); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + await Feed(Healthy(), alignmentOffsetDeg: 6.5, baselineDeg: 3.69); + + Of("starlink.alignment_drift").Should().BeEmpty(); + } + + [Fact] + public async Task NoBaselineYet_LeavesTheDriftRuleDisabled() + { + for (var i = 0; i < 120; i++) + { + await Feed(Healthy(), alignmentOffsetDeg: 40, baselineDeg: null); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + Of("starlink.alignment_drift").Should().BeEmpty(); + } + + // --- starlink.eth_speed_degraded ------------------------------------------------------ + + [Fact] + public async Task EthernetNegotiatedBelowWhatTheDishReaches_AlertsOnce() + { + var stats = Healthy(); + stats.EthSpeedMbps = 100; + + for (var i = 0; i < 20; i++) + { + await Feed(stats, ethCapableMbps: 1000); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + var events = Of("starlink.eth_speed_degraded"); + events.Should().ContainSingle(); + events[0].MetricValue.Should().Be(100); + events[0].ThresholdValue.Should().Be(1000); + } + + [Fact] + public async Task BriefEthernetRenegotiation_DoesNotAlert() + { + var stats = Healthy(); + stats.EthSpeedMbps = 100; + + await Feed(stats, ethCapableMbps: 1000); + _time.Advance(TimeSpan.FromMinutes(1)); + await Feed(Healthy(), ethCapableMbps: 1000); + + Of("starlink.eth_speed_degraded").Should().BeEmpty(); + } + + [Fact] + public async Task NoKnownCapableRate_LeavesTheEthernetRuleDisabled() + { + var stats = Healthy(); + stats.EthSpeedMbps = 100; + + for (var i = 0; i < 20; i++) + { + await Feed(stats, ethCapableMbps: null); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + Of("starlink.eth_speed_degraded").Should().BeEmpty(); + } + + [Fact] + public async Task EthernetComingBackUpToSpeed_PublishesRecovery() + { + var slow = Healthy(); + slow.EthSpeedMbps = 100; + for (var i = 0; i < 20; i++) + { + await Feed(slow, ethCapableMbps: 1000); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + for (var i = 0; i < 20; i++) + { + await Feed(Healthy(), ethCapableMbps: 1000); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + RecoveriesOf("starlink.eth_speed_degraded").Should().ContainSingle(); + } + + // --- starlink.outage_burst ------------------------------------------------------------ + + [Fact] + public async Task OutageSecondsPassingTheDailyBar_AlertsOnceWithTheCause() + { + for (var i = 0; i < 12; i++) + { + var stats = Healthy(); + stats.OutageSecondsDelta = 30; + stats.LastOutageCause = "OBSTRUCTED"; + await Feed(stats); + _time.Advance(TimeSpan.FromMinutes(10)); + } + + var events = Of("starlink.outage_burst"); + events.Should().ContainSingle(); + events[0].MetricValue.Should().Be(300, "it fires on the poll that crosses the bar, not on the last one fed"); + events[0].Message.Should().Contain("OBSTRUCTED"); + } + + [Fact] + public async Task OutageSecondsUnderTheDailyBar_DoesNotAlert() + { + for (var i = 0; i < 9; i++) + { + var stats = Healthy(); + stats.OutageSecondsDelta = 30; + await Feed(stats); + _time.Advance(TimeSpan.FromMinutes(10)); + } + + Of("starlink.outage_burst").Should().BeEmpty(); + } + + [Fact] + public async Task OutagesAgingOutOfTheWindow_PublishRecovery() + { + for (var i = 0; i < 12; i++) + { + var stats = Healthy(); + stats.OutageSecondsDelta = 30; + await Feed(stats); + _time.Advance(TimeSpan.FromMinutes(10)); + } + + Of("starlink.outage_burst").Should().ContainSingle(); + + _time.Advance(TimeSpan.FromDays(2)); + await Feed(Healthy()); + + RecoveriesOf("starlink.outage_burst").Should().ContainSingle(); + } + + // --- starlink.service_restricted ------------------------------------------------------- + + [Fact] + public async Task CrossingFromUnrestrictedIntoRestricted_PublishesInfo() + { + var free = Healthy(); + free.DownlinkRestrictedReason = "NoLimit"; + free.UplinkRestrictedReason = "NoLimit"; + await Feed(free); + + _time.Advance(TimeSpan.FromMinutes(1)); + await Feed(Healthy()); // permanently-restricted reference values + + var events = Of("starlink.service_restricted"); + events.Should().ContainSingle(); + events[0].Severity.Should().Be(AlertSeverity.Info); + events[0].Context["dl_restricted_reason"].Should().Be("LowSpeedPolicyLimit"); + } + + [Fact] + public async Task RestrictionLifting_PublishesRecovery() + { + var free = Healthy(); + free.DownlinkRestrictedReason = "NoLimit"; + free.UplinkRestrictedReason = "NoLimit"; + + await Feed(free); + _time.Advance(TimeSpan.FromMinutes(1)); + await Feed(Healthy()); + _time.Advance(TimeSpan.FromMinutes(1)); + await Feed(free); + + RecoveriesOf("starlink.service_restricted").Should().ContainSingle(); + } + + [Fact] + public async Task RestrictionReportedInScreamingSnakeCase_ReadsTheSameValues() + { + var free = Healthy(); + free.DownlinkRestrictedReason = "NO_LIMIT"; + free.UplinkRestrictedReason = "NO_LIMIT"; + await Feed(free); + + _time.Advance(TimeSpan.FromMinutes(1)); + var limited = Healthy(); + limited.DownlinkRestrictedReason = "LOW_SPEED_POLICY_LIMIT"; + limited.UplinkRestrictedReason = "NO_LIMIT"; + await Feed(limited); + + Of("starlink.service_restricted").Should().ContainSingle(); + } + + // --- Labelling ------------------------------------------------------------------------ + + [Fact] + public async Task WithNoWanBinding_TheAlertNamesTheDish() + { + var stats = Healthy(); + stats.ActiveAlerts = ["thermal_shutdown"]; + + await Feed(stats, wanLabel: null); + + var evt = Of("starlink.dish_alert").Should().ContainSingle().Subject; + evt.Title.Should().StartWith(DishName); + evt.DeviceName.Should().Be(DishName); + evt.DeviceId.Should().Be("starlink:1"); + evt.SourceUrl.Should().Be("/monitoring?tab=starlink&starlink=1"); + evt.Context["dish_name"].Should().Be(DishName); + } + + /// + /// Dishes get called "Starlink Roof", "Roof Starlink", or just "Starlink", and the + /// out-of-service sentence opens with the word itself - so the name is trimmed there and only + /// there. Titles keep the full name: a title is often all that reaches a notification channel. + /// + [Theory] + [InlineData("Starlink Roof", "Starlink has taken Roof out of service")] + [InlineData("Roof Starlink", "Starlink has taken Roof out of service")] + [InlineData("Starlink", "Starlink has taken the dish out of service")] + [InlineData("Dishy McFlatface", "Starlink has taken Dishy McFlatface out of service")] + public async Task OutOfServiceSentence_DoesNotSayStarlinkTwice(string dishName, string expected) + { + var stats = Healthy(); + stats.DisablementCode = "NoActiveAccount"; + + await _evaluator.EvaluateAsync(DishId, dishName, stats); + + var evt = Of("starlink.dish_alert").Should().ContainSingle().Subject; + evt.Message.Should().Contain(expected); + evt.Title.Should().StartWith(dishName, "the title keeps the name whole"); + } + + [Fact] + public async Task WithAWanBinding_TheAlertNamesTheWan() + { + var stats = Healthy(); + stats.ActiveAlerts = ["thermal_shutdown"]; + + await Feed(stats, wanLabel: "Starlink WAN2"); + + var evt = Of("starlink.dish_alert").Should().ContainSingle().Subject; + evt.Title.Should().StartWith("Starlink WAN2"); + evt.Context["wan_label"].Should().Be("Starlink WAN2"); + evt.Context["dish_name"].Should().Be(DishName, "the dish is still identified in the context"); + } + + /// + /// A dish on a WAN with nothing else watching it is the PRIMARY case, not an edge case: no + /// vantage, no agent, no monitored targets, and the evaluator is fed by the dish poll alone. + /// This test is that shape - nothing but readings goes in - and it still alerts. + /// + [Fact] + public async Task DishOnAWanWithNoMonitoredTargets_AlertsNormally() + { + var stats = Healthy(); + stats.FractionObstructed = 0.05; + + for (var i = 0; i < 40; i++) + { + await Feed(stats, wanLabel: null); + _time.Advance(TimeSpan.FromMinutes(1)); + } + + Of("starlink.obstructed").Should().ContainSingle(); + } + + // --- Fixtures ------------------------------------------------------------------------- + + /// + /// Fills the alignment sample window at a steady offset, so a following step change is + /// measured against a settled median rather than against a half-empty window. + /// + private async Task Settle(double offset, double baseline) + { + for (var i = 0; i < 60; i++) + { + await Feed(Healthy(), alignmentOffsetDeg: offset, baselineDeg: baseline); + _time.Advance(TimeSpan.FromMinutes(1)); + } + } + + private sealed class FakeTimeProvider : TimeProvider + { + private DateTimeOffset _utcNow; + + public FakeTimeProvider(DateTime start) => _utcNow = new DateTimeOffset(start); + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan by) => _utcNow = _utcNow.Add(by); + } + + private sealed class CapturingBus : IAlertEventBus + { + public List Published { get; } = new(); + + public ValueTask PublishAsync(AlertEvent alertEvent, CancellationToken ct = default) + { + Published.Add(alertEvent); + return ValueTask.CompletedTask; + } + + public async IAsyncEnumerable ConsumeAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield break; + } + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/Wan2PrimarySiteTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/Wan2PrimarySiteTests.cs new file mode 100644 index 0000000000..642c915244 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/Wan2PrimarySiteTests.cs @@ -0,0 +1,208 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.UniFi; +using NetworkOptimizer.Web.Services.Monitoring; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// Primary is a ROLE, not a name. WAN1/WAN2/WAN3 are arbitrary labels in UniFi Network and any of +/// them can hold the primary role - a site whose WAN2 is primary and WAN1 is the failover is an +/// ordinary configuration, not an exotic one. Every "which WAN is primary" answer therefore has to +/// come from the configured primary network group, never from the conventional "wan"-first +/// ordering. This fixture is that site: WAN2 primary, WAN1 failover, with a context on each. +/// +public class Wan2PrimarySiteTests +{ + private static NetworkInfo Wan(string group) => new() + { + Id = group, + Name = group, + Purpose = "wan", + Enabled = true, + WanNetworkgroup = group, + }; + + private static MonitoringTarget Target(string id, string? wan) => new() + { + TargetId = id, + Name = id, + Address = "192.0.2.1", + WanInterface = wan, + }; + + // ─── The scope key the primary report resolves ─── + + [Theory] + [InlineData("WAN", "wan")] + [InlineData("WAN2", "wan2")] + [InlineData("WAN3", "wan3")] + [InlineData("wan2", "wan2")] + public void ConfiguredPrimaryWanKey_TakesWhicheverGroupHoldsTheRole(string group, string expected) + { + IspHealthService.ConfiguredPrimaryWanKey(Wan(group)).Should().Be(expected); + } + + [Fact] + public void ConfiguredPrimaryWanKey_IsNullWhenTheConsoleCannotSay() + { + // Null is the signal to fall through to the documented offline guess, not "it is wan". + IspHealthService.ConfiguredPrimaryWanKey(null).Should().BeNull(); + IspHealthService.ConfiguredPrimaryWanKey(new NetworkInfo { Purpose = "wan" }).Should().BeNull(); + } + + // ─── Target scoping on that site ─── + + [Fact] + public void PrimaryScope_OnAWan2PrimarySite_KeepsWan2RowsAndTheUnstampedOnes() + { + // Unstamped rows are primary-path measurements wherever the role sits; wan1's rows are + // the FAILOVER's here and must never grade the primary. + var targets = new List + { + Target("legacy", null), + Target("hop-wan2", "wan2"), + Target("hop-wan2-upper", "WAN2"), + Target("hop-wan", "wan"), + Target("hop-wan1", "wan1"), + }; + + IspHealthService.ScopeTargetsToWan(targets, "wan2", includeUnassigned: true) + .Select(t => t.TargetId).Should().Equal("legacy", "hop-wan2", "hop-wan2-upper"); + } + + [Fact] + public void FailoverScope_OnAWan2PrimarySite_OwnsTheWanRowsAndNoUnstampedOnes() + { + var targets = new List + { + Target("legacy", null), + Target("hop-wan", "wan"), + Target("hop-wan1", "wan1"), // the legacy alias is the same WAN as "wan" + Target("hop-wan2", "wan2"), + }; + + IspHealthService.ScopeTargetsToWan(targets, "wan", includeUnassigned: false) + .Select(t => t.TargetId).Should().Equal("hop-wan", "hop-wan1"); + } + + [Fact] + public void PrimaryScope_OnAWan2PrimarySite_ReadsWan2sTagsNeverWan1s() + { + var contexts = new[] + { + new WanContext { Name = "fiber", WanInterface = "wan2" }, + new WanContext { Name = "cable-failover", WanInterface = "wan" }, + }; + + var scope = IspHealthService.BuildWanScope(contexts, "wan2", primaryScope: true); + + scope.IncludeUntagged.Should().BeTrue(); + scope.WanTags.Should().BeEquivalentTo("wan2", "fiber"); + } + + // ─── Upstream discovery rehydrate ─── + + [Fact] + public void PickRehydrateContext_TakesTheConfiguredPrimarysRowNotTheWanOne() + { + // The bug this pins: a WAN2-primary site rehydrating the primary panel from WAN1's row + // presents the failover's hops as the primary's upstream path. + var contexts = new List + { + new() { WanInterface = "wan", LastDiscoveryAt = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc) }, + new() { WanInterface = "wan2", LastDiscoveryAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) }, + }; + + UpstreamTracerService.PickRehydrateContext(contexts, boundWanInterface: null, configuredPrimaryKey: "wan2") + !.WanInterface.Should().Be("wan2"); + } + + [Fact] + public void PickRehydrateContext_StillLetsABoundTracerReadItsOwnWan() + { + var contexts = new List + { + new() { WanInterface = "wan" }, + new() { WanInterface = "wan2" }, + }; + + UpstreamTracerService.PickRehydrateContext(contexts, boundWanInterface: "wan", configuredPrimaryKey: "wan2") + !.WanInterface.Should().Be("wan"); + } + + [Fact] + public void PickRehydrateContext_FallsBackToTheDocumentedGuessOnlyWhenTheConsoleIsSilent() + { + // Offline last resort: the conventional "wan" row, then recency. Wrong on exactly this + // site - which is why the configured key is asked for first and this is a documented guess. + var contexts = new List + { + new() { WanInterface = "wan2", LastDiscoveryAt = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc) }, + new() { WanInterface = "wan", LastDiscoveryAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) }, + }; + + UpstreamTracerService.PickRehydrateContext(contexts, boundWanInterface: null, configuredPrimaryKey: null) + !.WanInterface.Should().Be("wan"); + } + + [Fact] + public void PickRehydrateContext_TakesTheOnlyRowWhenTheConfiguredPrimaryHasNoneYet() + { + var contexts = new List { new() { WanInterface = "wan" } }; + + UpstreamTracerService.PickRehydrateContext(contexts, boundWanInterface: null, configuredPrimaryKey: "wan2") + !.WanInterface.Should().Be("wan"); + } + + // ─── The offline guess, stated as a guess ─── + + [Fact] + public void ResolvePrimaryWanKey_IsTheWanFirstGuessAndSaysSoOnAWan2PrimarySite() + { + // Pinned deliberately: with the console silent there is nothing better to ask, so the + // offline answer on a WAN2-primary site is "wan" - wrong, self-correcting on the next + // connected compute, and never reached while ConfiguredPrimaryWanKey can answer. + var contexts = new[] + { + new WanDiscoveryContext { WanInterface = "wan2" }, + new WanDiscoveryContext { WanInterface = "wan" }, + }; + + IspHealthService.ResolvePrimaryWanKey(contexts).Should().Be("wan"); + } + + // ─── WAN speed tests follow the role, not the name ─── + + [Theory] + // A recorded primary is what the primary report matches on. + [InlineData("WAN2", null, false)] // WAN1's test on a WAN2-primary site: the FAILOVER's + [InlineData("WAN2", "WAN2", true)] // the primary's own test + [InlineData("WAN2", "WAN", false)] + // No recorded primary: fall back to the conventional first group, as before. + [InlineData(null, "WAN", true)] + [InlineData(null, "WAN2", false)] + public void PrimarySpeedTestPredicate_MatchesTheWanHoldingTheRole( + string? recordedPrimaryGroup, string? testGroup, bool expected) + { + var primaryGroupLower = recordedPrimaryGroup?.ToLowerInvariant(); + + // The predicate the primary instance applies (unstamped rows are covered separately). + var matches = testGroup != null + && testGroup.ToLowerInvariant() == (primaryGroupLower ?? "wan"); + + matches.Should().Be(expected); + } + + [Fact] + public void UnstampedSpeedTests_StayWithThePrimaryWhicheverWanHoldsIt() + { + // They predate stamping and ran over the default route, which is the primary's. + string? testGroup = null; + var matchesPrimary = testGroup == null; + + matchesPrimary.Should().BeTrue(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextRoutingTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextRoutingTests.cs new file mode 100644 index 0000000000..9d0464152e --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextRoutingTests.cs @@ -0,0 +1,416 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// The routing decisions behind multi-WAN contexts: which agent is pushed which targets, what +/// source each target is bound to, and whose results are written. The three deployment shapes +/// these have to hold for are: +/// +/// A. Main site collecting for itself (no coverage flag), plus a context agent. +/// B. Main site covered by its primary agent, plus a context agent. +/// C. Managed site with a primary agent, plus a context agent. +/// +/// Every case also gets its no-context counterpart: a site with no WAN contexts must behave +/// exactly as it did before contexts existed. +/// +public class WanContextRoutingTests +{ + private const int PrimaryAgent = 1; + private const int ContextAgent = 2; + + // ---- Push composition ------------------------------------------------- + + [Fact] + public void Push_NoContexts_UnassignedTargetsStillGoToEveryAgent() + { + AgentProbeResultSink.ShouldPushTargetToAgent(false, null, PrimaryAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent(false, null, ContextAgent, agentIsSteeredToWan: false, unassignedOwnerId: ContextAgent) + .Should().BeTrue(); + } + + [Fact] + public void Push_ContextAgent_GetsOnlyItsOwnContextTargets() + { + // Shapes A, B and C alike: everything this agent probes leaves by its WAN, so the site's + // ordinary targets would be measured on the wrong path and filed under the primary. + AgentProbeResultSink.ShouldPushTargetToAgent(true, ContextAgent, ContextAgent, agentIsSteeredToWan: true, unassignedOwnerId: ContextAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent(false, null, ContextAgent, agentIsSteeredToWan: true, unassignedOwnerId: ContextAgent) + .Should().BeFalse(); + } + + [Fact] + public void Push_PrimaryAgent_KeepsUnassignedTargetsAndNeverAnotherContexts() + { + AgentProbeResultSink.ShouldPushTargetToAgent(false, null, PrimaryAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent(true, ContextAgent, PrimaryAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeFalse(); + } + + [Fact] + public void Push_ServerBoundContextTargets_GoToNoAgent() + { + // A source-IP context is probed by the server itself, whose prober binds the source IP + // the gateway policy-routes. An ordinary agent would probe the same target over its OWN + // primary route while the result gets tagged with the secondary WAN's key - corrupting + // that WAN's score now that the tag is read - so a context target with no assigned agent + // reaches NO agent at all, on any shape. + AgentProbeResultSink.ShouldPushTargetToAgent(true, null, PrimaryAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeFalse(); + AgentProbeResultSink.ShouldPushTargetToAgent(true, null, ContextAgent, agentIsSteeredToWan: true, unassignedOwnerId: ContextAgent) + .Should().BeFalse(); + } + + [Fact] + public void Push_ContextWhoseRowIsGone_ReachesNoAgentRatherThanFanningOut() + { + // A stale WanContextId (row deleted out from under it) is conservative: pushed nowhere + // until the assignment is cleaned up, never broadcast as if unassigned. + AgentProbeResultSink.ShouldPushTargetToAgent(true, null, PrimaryAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeFalse(); + } + + // ---- Source binding on the wire --------------------------------------- + + [Fact] + public void SourceIp_NoContext_IsEmptySoTheAgentKeepsItsOwnDefault() + { + AgentProbeResultSink.ResolveSpecSourceIp(null, ContextAgent).Should().BeEmpty(); + } + + [Fact] + public void SourceIp_InterfaceContext_SendsTheInterfaceName() + { + var context = new WanContext { Id = 5, Name = "backup", AgentId = ContextAgent, InterfaceName = "eth8", WanInterface = "wan2" }; + + AgentProbeResultSink.ResolveSpecSourceIp(context, ContextAgent).Should().Be("eth8"); + } + + [Fact] + public void SourceIp_InterfaceWins_OverAStaleSourceIp() + { + var context = new WanContext + { + Id = 5, + Name = "backup", + AgentId = ContextAgent, + InterfaceName = "ppp0", + ProbeSourceIp = "192.0.2.10", + WanInterface = "wan2" + }; + + AgentProbeResultSink.ResolveSpecSourceIp(context, ContextAgent).Should().Be("ppp0"); + } + + [Fact] + public void SourceIp_AnotherAgentsContext_SendsNothing() + { + // The receiving agent is not behind that WAN, so binding it to that context's source would + // either fail or measure the wrong path. + var context = new WanContext { Id = 5, Name = "backup", AgentId = ContextAgent, InterfaceName = "eth8", WanInterface = "wan2" }; + + AgentProbeResultSink.ResolveSpecSourceIp(context, PrimaryAgent).Should().BeEmpty(); + } + + [Fact] + public void SourceIp_ServerBoundContext_SendsNothingToAgents() + { + // The source IP belongs to the server's own host and is policy-routed there; an agent + // binding it would fail. + var context = new WanContext { Id = 5, Name = "backup", ProbeSourceIp = "192.0.2.10", WanInterface = "wan2" }; + + AgentProbeResultSink.ResolveSpecSourceIp(context, ContextAgent).Should().BeEmpty(); + } + + // ---- Result acceptance ------------------------------------------------ + + [Fact] + public void Results_ShapeC_ManagedSite_AllAccepted() + { + // A managed site's agent always covers it: nothing here is conditional on contexts. + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: true, contextAgentId: null, agentId: PrimaryAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: true, contextAgentId: ContextAgent, agentId: ContextAgent) + .Should().BeTrue(); + } + + [Fact] + public void Results_ShapeB_CoveredMainSite_AllAccepted() + { + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: true, contextAgentId: null, agentId: PrimaryAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: true, contextAgentId: ContextAgent, agentId: ContextAgent) + .Should().BeTrue(); + } + + [Fact] + public void Results_ShapeA_NonCoveringMainSite_ContextResultsAccepted() + { + // The server cannot probe the secondary WAN, so this agent's results are the only + // measurement of it - coverage governs the primary path, not this. + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: false, contextAgentId: ContextAgent, agentId: ContextAgent) + .Should().BeTrue(); + } + + [Fact] + public void Results_ShapeA_NonCoveringMainSite_NonContextResultsStillDiscarded() + { + // The sawtooth protection: the server is probing these targets too. + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: false, contextAgentId: null, agentId: PrimaryAgent) + .Should().BeFalse(); + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: false, contextAgentId: null, agentId: ContextAgent) + .Should().BeFalse(); + } + + [Fact] + public void Results_ShapeA_AnotherAgentsContext_StillDiscarded() + { + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: false, contextAgentId: PrimaryAgent, agentId: ContextAgent) + .Should().BeFalse(); + } + + // ---- SNMP and speed-test recipients ----------------------------------- + + [Fact] + public void SiteCollectionConfig_NoContexts_EveryAgentStillGetsIt() + { + AgentProbeResultSink.ShouldPushSiteCollectionConfig(agentIsSteeredToWan: false).Should().BeTrue(); + } + + [Fact] + public void SiteCollectionConfig_ContextAgent_IsExcluded() + { + // Otherwise a context agent polls every device a second time on a managed or covered site. + AgentProbeResultSink.ShouldPushSiteCollectionConfig(agentIsSteeredToWan: true).Should().BeFalse(); + } + + // ---- Influx wan tag --------------------------------------------------- + + [Fact] + public void WanTag_PrefersTheStableWanKey() + { + var context = new WanContext { Id = 1, Name = "Backup circuit", WanInterface = "wan2" }; + + context.InfluxWanTag.Should().Be("wan2"); + } + + [Fact] + public void WanTag_LegacyContextWithoutAWan_FallsBackToItsName() + { + var context = new WanContext { Id = 1, Name = "backup-wan" }; + + context.InfluxWanTag.Should().Be("backup-wan"); + } + + private const int GatewayAgent = 77; + + // ---- A gateway agent can serve contexts AND collect for the site ------- + + [Fact] + public void GatewayAgent_ServingEveryExtraWan_StillCollectsForTheSite() + { + // Its contexts name an interface, so each probe binds to that WAN while the box itself + // still routes out the primary. It is the site's collector as well - on a site whose only + // agent is the one on the gateway, nothing else can be. + AgentProbeResultSink.ShouldPushTargetToAgent(false, null, GatewayAgent, agentIsSteeredToWan: false, unassignedOwnerId: GatewayAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldPushSiteCollectionConfig(agentIsSteeredToWan: false).Should().BeTrue(); + } + + [Fact] + public void GatewayAgent_StillTakesEveryContextItOwnsAndNoOtherAgents() + { + AgentProbeResultSink.ShouldPushTargetToAgent(true, GatewayAgent, GatewayAgent, agentIsSteeredToWan: false, unassignedOwnerId: GatewayAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent(true, ContextAgent, GatewayAgent, agentIsSteeredToWan: false, unassignedOwnerId: GatewayAgent) + .Should().BeFalse(); + } + + [Fact] + public void SteeredProbeBox_TakesItsOwnWanAndNothingElse() + { + // No interface to bind, so the gateway policy-routes the whole box: a primary target + // probed from here would leave by the secondary WAN and be recorded as the primary's. + AgentProbeResultSink.ShouldPushTargetToAgent(false, null, ContextAgent, agentIsSteeredToWan: true, unassignedOwnerId: ContextAgent) + .Should().BeFalse(); + AgentProbeResultSink.ShouldPushSiteCollectionConfig(agentIsSteeredToWan: true).Should().BeFalse(); + } + + [Theory] + [InlineData("eth8", false)] // gateway agent: binds per probe, routes normally + [InlineData(null, true)] // probe box: the whole box sits behind the WAN + [InlineData("", true)] + public void SteeredIsDecidedByWhetherTheContextNamesAnInterface(string? interfaceName, bool expectedSteered) + { + var contexts = new[] { new WanContext { Id = 1, AgentId = ContextAgent, InterfaceName = interfaceName } }; + + var steered = contexts.Any(c => c.AgentId == ContextAgent && string.IsNullOrEmpty(c.InterfaceName)); + + steered.Should().Be(expectedSteered); + } + + // ---- One prober per target ------------------------------------------- + + [Fact] + public void UnassignedTargets_GoToOneAgentOnly() + { + // Two collectors on a site: the primary-WAN targets belong to whichever one owns the + // pool, not to both. Probing them twice produces two series for one number and doubles + // the load on every target the site monitors. + AgentProbeResultSink.ShouldPushTargetToAgent( + false, null, PrimaryAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent( + false, null, GatewayAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeFalse(); + } + + [Fact] + public void AGatewayAgentOwningTheWansStillTakesThemWhenAnotherAgentHoldsThePool() + { + // Losing the unassigned pool costs it nothing of its own: its contexts are still its. + AgentProbeResultSink.ShouldPushTargetToAgent( + true, GatewayAgent, GatewayAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeTrue(); + } + + // ---- Fabric targets follow the collector, not a WAN -------------------- + + [Fact] + public void FabricTargets_GoToTheCollector_WhicheverWansExist() + { + // Nothing inside the LAN crosses a WAN, so a context cannot own it: the agent that polls + // the site's SNMP probes it, and only that one. + AgentProbeResultSink.IsFabricTarget(MonitoringTargetType.Fabric).Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent( + false, null, PrimaryAgent, agentIsSteeredToWan: false, + unassignedOwnerId: PrimaryAgent, targetIsFabric: true).Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent( + false, null, GatewayAgent, agentIsSteeredToWan: false, + unassignedOwnerId: PrimaryAgent, targetIsFabric: true).Should().BeFalse(); + } + + [Fact] + public void FabricTarget_InAContext_StillGoesToTheCollector() + { + // A fabric target that somehow carries a context is still a LAN measurement: the context + // says nothing about it, so ownership does not move. + AgentProbeResultSink.ShouldPushTargetToAgent( + true, ContextAgent, ContextAgent, agentIsSteeredToWan: true, + unassignedOwnerId: PrimaryAgent, targetIsFabric: true).Should().BeFalse(); + } + + [Theory] + [InlineData(MonitoringTargetType.AccessIsp)] + [InlineData(MonitoringTargetType.Transit)] + [InlineData(MonitoringTargetType.InternetService)] + public void WanTargets_AreNotFabric(MonitoringTargetType type) + { + AgentProbeResultSink.IsFabricTarget(type).Should().BeFalse(); + } + + // ---- Steering is about the WAN, not the interface field ---------------- + + [Fact] + public void PrimaryWansContext_DoesNotMakeAnAgentSteered() + { + // Reaching the primary needs no steering: on a failover-only site every unpinned box + // already leaves by it. An agent named on the primary's context is still the collector. + var context = new WanContext { AgentId = PrimaryAgent, WanInterface = "wan2" }; + + AgentProbeResultSink.IsPrimaryWanContext(context, primaryWanKey: "wan2").Should().BeTrue(); + } + + [Fact] + public void SecondaryWansContext_MeansTheAgentIsSteered() + { + var context = new WanContext { AgentId = ContextAgent, WanInterface = "wan3" }; + + AgentProbeResultSink.IsPrimaryWanContext(context, primaryWanKey: "wan2").Should().BeFalse(); + } + + [Fact] + public void UnknownPrimary_LeavesTheConservativeReading() + { + // No connected compute has recorded the role yet. Guessing the agent is on the primary + // would hand it the site's targets; the safe reading is that it is not. + var context = new WanContext { AgentId = ContextAgent, WanInterface = "wan" }; + + AgentProbeResultSink.IsPrimaryWanContext(context, primaryWanKey: null).Should().BeFalse(); + } + + [Fact] + public void LegacyWan1Context_MatchesAPrimaryRecordedAsWan() + { + var context = new WanContext { AgentId = PrimaryAgent, WanInterface = "wan1" }; + + AgentProbeResultSink.IsPrimaryWanContext(context, primaryWanKey: "wan").Should().BeTrue(); + } + + // ---- Which agent collects for the site -------------------------------- + + [Fact] + public void TheCollectorIsTheLowestIdConnectedAgent() + { + AgentProbeResultSink.SelectCollectorAgentId( + new[] { GatewayAgent, PrimaryAgent }, Array.Empty(), + primaryWanKey: "wan", fallbackAgentId: 0).Should().Be(PrimaryAgent); + } + + [Fact] + public void ASteeredAgentIsNeverTheCollector() + { + // Even as the lowest id: everything it sends leaves by its own WAN. + var contexts = new[] { new WanContext { AgentId = PrimaryAgent, WanInterface = "wan2" } }; + + AgentProbeResultSink.SelectCollectorAgentId( + new[] { PrimaryAgent, GatewayAgent }, contexts, + primaryWanKey: "wan", fallbackAgentId: 0).Should().Be(GatewayAgent); + } + + [Fact] + public void AGatewayAgentServingWansCanStillCollect() + { + // Its context names an interface, so it binds per probe and routes normally. + var contexts = new[] { new WanContext { AgentId = GatewayAgent, WanInterface = "wan2", InterfaceName = "eth8" } }; + + AgentProbeResultSink.SelectCollectorAgentId( + new[] { GatewayAgent }, contexts, + primaryWanKey: "wan", fallbackAgentId: 0).Should().Be(GatewayAgent); + } + + [Fact] + public void AnAgentOnThePrimarysContextCanStillCollect() + { + var contexts = new[] { new WanContext { AgentId = PrimaryAgent, WanInterface = "wan2" } }; + + AgentProbeResultSink.SelectCollectorAgentId( + new[] { PrimaryAgent }, contexts, + primaryWanKey: "wan2", fallbackAgentId: 0).Should().Be(PrimaryAgent); + } + + [Fact] + public void ALoneSteeredAgentStillCollects_RatherThanLeavingTheSiteDark() + { + var contexts = new[] { new WanContext { AgentId = ContextAgent, WanInterface = "wan2" } }; + + AgentProbeResultSink.SelectCollectorAgentId( + new[] { ContextAgent }, contexts, + primaryWanKey: "wan", fallbackAgentId: ContextAgent).Should().Be(ContextAgent); + } + + [Fact] + public void TheCollectorMovesOnWhenItsAgentDrops() + { + // Taken from the CONNECTED set, so the next push hands the work to whoever is left. + AgentProbeResultSink.SelectCollectorAgentId( + new[] { GatewayAgent }, Array.Empty(), + primaryWanKey: "wan", fallbackAgentId: 0).Should().Be(GatewayAgent); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextTargetStampingTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextTargetStampingTests.cs new file mode 100644 index 0000000000..423e47b4cb --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextTargetStampingTests.cs @@ -0,0 +1,259 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Storage.Services; +using NetworkOptimizer.Web.Services; +using NetworkOptimizer.Web.Services.Gates; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// A monitoring target carries two WAN keys that must never drift apart: WanContextId routes the +/// probe, WanInterface says which WAN the resulting data describes - and every per-WAN reader +/// scopes on the latter. The deploy-time backfill only fixed the rows that existed then, so the +/// three runtime paths that can move one key have to move the other: assigning a target to a +/// context, re-pointing a context at another WAN, and deleting a context. A target that kept a +/// dead or stale WAN stamp reads as flatlined in the primary's report and invisible in its own. +/// +public class WanContextTargetStampingTests : IDisposable +{ + private readonly string _dir; + private readonly SiteDbContextFactory _factory; + private readonly SiteContextService _siteContext; + private readonly AuditContext _audit = new(); + + public WanContextTargetStampingTests() + { + _dir = Path.Combine(Path.GetTempPath(), "no-wan-stamping-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_dir); + var paths = new SiteDatabasePaths(Path.Combine(_dir, "network_optimizer.db")); + _factory = new SiteDbContextFactory(paths); + _siteContext = new SiteContextService(new HttpContextAccessor(), paths); + + using var db = Db(); + db.Database.Migrate(); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* temp dir; a leftover is harmless */ } + GC.SuppressFinalize(this); + } + + private NetworkOptimizerDbContext Db() => _factory.CreateForSite(_siteContext.Slug, _siteContext.IsDefault); + + private MonitoringTargetService Targets() => new( + _factory, _siteContext, asnResolution: null!, executorFactory: null!, _audit, + NullLogger.Instance); + + private async Task SeedContextAsync(string name, string? wanInterface) + { + await using var db = Db(); + var context = new WanContext + { + Name = name, + WanInterface = wanInterface, + ProbeSourceIp = "198.51.100.7", + CreatedAt = DateTime.UtcNow, + }; + db.WanContexts.Add(context); + await db.SaveChangesAsync(); + return context.Id; + } + + private async Task SeedTargetAsync(string targetId, int? contextId = null, string? wanInterface = null) + { + await using var db = Db(); + var target = new MonitoringTarget + { + TargetId = targetId, + Name = targetId, + Address = "203.0.113.10", + TargetType = MonitoringTargetType.Custom, + ProbeMode = ProbeMode.Icmp, + WanContextId = contextId, + WanInterface = wanInterface, + CreatedAt = DateTime.UtcNow, + }; + db.MonitoringTargets.Add(target); + await db.SaveChangesAsync(); + return target.Id; + } + + private async Task ReadAsync(int id) + { + await using var db = Db(); + return (await db.MonitoringTargets.FindAsync(id))!; + } + + // ─── Path 1: assigning a target to a context ─── + + [Fact] + public async Task Assigning_a_target_to_a_context_stamps_the_contexts_wan() + { + var contextId = await SeedContextAsync("backup", "wan2"); + var targetId = await SeedTargetAsync("custom-hop"); + + (await Targets().SetWanContextAsync(targetId, contextId)).Should().BeTrue(); + + var row = await ReadAsync(targetId); + row.WanContextId.Should().Be(contextId); + row.WanInterface.Should().Be("wan2"); + } + + [Fact] + public async Task Reassigning_a_target_to_another_wans_context_moves_its_stamp_too() + { + var backup = await SeedContextAsync("backup", "wan2"); + var lte = await SeedContextAsync("lte", "wan3"); + var targetId = await SeedTargetAsync("custom-hop", backup, "wan2"); + + (await Targets().SetWanContextAsync(targetId, lte)).Should().BeTrue(); + + var row = await ReadAsync(targetId); + row.WanContextId.Should().Be(lte); + row.WanInterface.Should().Be("wan3"); + } + + [Fact] + public async Task Moving_a_target_back_to_the_primary_clears_both_keys() + { + // An unstamped row IS a primary-path measurement to every scoped reader, so the WAN + // stamp has to go with the routing - a row left saying "wan2" would keep grading the + // secondary's report with data nothing probes over the secondary any more. + var contextId = await SeedContextAsync("backup", "wan2"); + var targetId = await SeedTargetAsync("custom-hop", contextId, "wan2"); + + (await Targets().SetWanContextAsync(targetId, null)).Should().BeTrue(); + + var row = await ReadAsync(targetId); + row.WanContextId.Should().BeNull(); + row.WanInterface.Should().BeNull(); + } + + [Fact] + public async Task Assigning_to_a_context_that_names_no_wan_leaves_the_stamp_empty() + { + // A context created before the WAN column existed has nothing to copy down. + var contextId = await SeedContextAsync("legacy", null); + var targetId = await SeedTargetAsync("custom-hop"); + + await Targets().SetWanContextAsync(targetId, contextId); + + var row = await ReadAsync(targetId); + row.WanContextId.Should().Be(contextId); + row.WanInterface.Should().BeNull(); + } + + [Fact] + public async Task A_single_wan_target_that_was_never_assigned_stays_untouched() + { + // Every row on a single-WAN install: no context to move to, nothing to stamp, and the + // no-change path must not write an audit event either. + var targetId = await SeedTargetAsync("custom-hop"); + + (await Targets().SetWanContextAsync(targetId, null)).Should().BeTrue(); + + var row = await ReadAsync(targetId); + row.WanContextId.Should().BeNull(); + row.WanInterface.Should().BeNull(); + _audit.Drain().Suppressed.Should().BeTrue(); + } + + // ─── Path 2: a context re-pointed at another WAN ─── + + [Fact] + public async Task Repointing_a_context_restamps_every_target_it_owns() + { + var contextId = await SeedContextAsync("backup", "wan2"); + await SeedTargetAsync("hop-a", contextId, "wan2"); + await SeedTargetAsync("hop-b", contextId, "wan2"); + + await using (var db = Db()) + { + (await WanContextTargetStamping.RestampContextTargetsAsync(db, contextId, "wan3")).Should().Be(2); + await db.SaveChangesAsync(); + } + + await using var read = Db(); + read.MonitoringTargets.Select(t => t.WanInterface).ToList().Should().Equal("wan3", "wan3"); + } + + [Fact] + public async Task Repointing_a_context_leaves_another_contexts_targets_alone() + { + var backup = await SeedContextAsync("backup", "wan2"); + var lte = await SeedContextAsync("lte", "wan3"); + await SeedTargetAsync("hop-a", backup, "wan2"); + await SeedTargetAsync("hop-b", lte, "wan3"); + await SeedTargetAsync("hop-primary"); + + await using (var db = Db()) + { + await WanContextTargetStamping.RestampContextTargetsAsync(db, backup, "wan4"); + await db.SaveChangesAsync(); + } + + await using var read = Db(); + (await read.MonitoringTargets.SingleAsync(t => t.TargetId == "hop-a")).WanInterface.Should().Be("wan4"); + (await read.MonitoringTargets.SingleAsync(t => t.TargetId == "hop-b")).WanInterface.Should().Be("wan3"); + (await read.MonitoringTargets.SingleAsync(t => t.TargetId == "hop-primary")).WanInterface.Should().BeNull(); + } + + // ─── Path 3: deleting a context ─── + + [Fact] + public async Task Deleting_a_context_releases_both_keys_on_its_targets() + { + var contextId = await SeedContextAsync("backup", "wan2"); + await SeedTargetAsync("hop-a", contextId, "wan2"); + + await using (var db = Db()) + { + (await WanContextTargetStamping.ReleaseContextTargetsAsync(db, contextId)).Should().Be(1); + await db.SaveChangesAsync(); + } + + await using var read = Db(); + var row = await read.MonitoringTargets.SingleAsync(); + row.WanContextId.Should().BeNull(); + row.WanInterface.Should().BeNull(); + } + + [Fact] + public async Task Deleting_a_context_touches_nothing_on_a_site_that_has_no_targets_on_it() + { + await SeedTargetAsync("hop-primary", contextId: null, wanInterface: "wan"); + + await using (var db = Db()) + { + (await WanContextTargetStamping.ReleaseContextTargetsAsync(db, 404)).Should().Be(0); + await db.SaveChangesAsync(); + } + + await using var read = Db(); + (await read.MonitoringTargets.SingleAsync()).WanInterface.Should().Be("wan"); + } + + // ─── The rule itself ─── + + [Fact] + public void ApplyAssignment_carries_the_contexts_wan_and_clears_it_on_the_way_back() + { + var target = new MonitoringTarget { TargetId = "t", Name = "t", Address = "203.0.113.10" }; + + WanContextTargetStamping.ApplyAssignment(target, 7, "wan2"); + target.WanContextId.Should().Be(7); + target.WanInterface.Should().Be("wan2"); + + // The context's WAN is irrelevant on the way back to the primary: both keys clear. + WanContextTargetStamping.ApplyAssignment(target, null, "wan2"); + target.WanContextId.Should().BeNull(); + target.WanInterface.Should().BeNull(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/WanDeepLinkTargetTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanDeepLinkTargetTests.cs new file mode 100644 index 0000000000..17bbaba67d --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanDeepLinkTargetTests.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// Where a WAN-scoped report should send someone who has nothing to look at yet. Discovery is not +/// always the answer: a secondary WAN is traced THROUGH its context, so a WAN without one cannot +/// be discovered however many times you run it, and pointing there wastes the trip. +/// +public class WanDeepLinkTargetTests +{ + private static bool NeedsContextFirst(bool isPrimary, bool hasContext) => !isPrimary && !hasContext; + + [Theory] + [InlineData(true, false, false)] // the primary needs no context - discovery is the answer + [InlineData(true, true, false)] + [InlineData(false, true, false)] // secondary WITH a context - discovery is the answer + [InlineData(false, false, true)] // secondary with none - the context comes first + public void ASecondaryWanWithoutAContextIsSentToMakeOne(bool isPrimary, bool hasContext, bool expected) + { + NeedsContextFirst(isPrimary, hasContext).Should().Be(expected); + } + + private static string DiscoveryWanQuery(string? wanKey, int wanCount) => + string.IsNullOrEmpty(wanKey) || wanCount <= 1 ? "" : $"&wan={System.Uri.EscapeDataString(wanKey)}"; + + [Fact] + public void ADiscoveryLinkCarriesTheWanTheReportIsAbout() + { + DiscoveryWanQuery("wan2", 2).Should().Be("&wan=wan2"); + } + + [Fact] + public void ASingleWanSiteAddsNothing() + { + // One WAN means one discovery; a parameter would only be noise in the address bar. + DiscoveryWanQuery("wan", 1).Should().BeEmpty(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/WanOutageClassifierTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanOutageClassifierTests.cs new file mode 100644 index 0000000000..2b79faf0f6 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanOutageClassifierTests.cs @@ -0,0 +1,291 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// The shape half of the WAN outage alert family: given one WAN's current target states, which +/// outage the picture is. Pure and stateless, so these cover every branch directly - the two +/// total shapes (access layer down, and the first hop answering while everything beyond it is +/// dark), the branch-shaped and independent partials, and the deliberate silences that keep the +/// alert class quiet: one dark destination, a transit hop nobody sits behind, and too little +/// evidence to say anything at all. +/// +public class WanOutageClassifierTests +{ + private const string AccessIp = "192.0.2.1"; + private const string TransitIp = "198.51.100.1"; + private const string SecondTransitIp = "198.51.100.2"; + + private static WanTargetSnapshot Access(bool failing = false, bool degraded = false) => + new("wan-access", MonitoringTargetType.AccessIsp, "Acme Fiber first hop", AccessIp, + Failing: failing, Degraded: failing || degraded, Depth: 1, KnownPosition: true, + IsInternet: false, AsnLabel: "Acme Fiber", AsnNumber: 64500, + AncestorIps: Ancestors(null)); + + private static WanTargetSnapshot Transit(string id, string address, int depth, + bool failing = false, bool degraded = false, string? asnLabel = "TransitNet", + int asnNumber = 64501, string[]? ancestors = null) => + new(id, MonitoringTargetType.Transit, id, address, + Failing: failing, Degraded: failing || degraded, Depth: depth, KnownPosition: true, + IsInternet: false, AsnLabel: asnLabel, AsnNumber: asnNumber, + AncestorIps: Ancestors(ancestors)); + + private static WanTargetSnapshot Internet(string id, string address, + bool failing = false, bool degraded = false, string? asnLabel = "Alpha Cloud", + int asnNumber = 64510, string[]? ancestors = null) => + new(id, MonitoringTargetType.InternetService, id, address, + Failing: failing, Degraded: failing || degraded, Depth: 6, KnownPosition: true, + IsInternet: true, AsnLabel: asnLabel, AsnNumber: asnNumber, + AncestorIps: Ancestors(ancestors)); + + private static IReadOnlySet Ancestors(string[]? ips) => + new HashSet(ips ?? [], StringComparer.OrdinalIgnoreCase); + + [Fact] + public void Classify_NoTargets_ReturnsNone() + { + var verdict = WanOutageClassifier.Classify([]); + + verdict.Kind.Should().Be(WanVerdictKind.None); + verdict.Should().BeSameAs(WanVerdict.None); + } + + [Fact] + public void Classify_EveryTargetFailingWithAnAccessHop_IsTotalWithTheAccessLayerDown() + { + var verdict = WanOutageClassifier.Classify([ + Access(failing: true), + Transit("transit-a", TransitIp, 3, failing: true, ancestors: [AccessIp]), + Internet("resolver-a", "203.0.113.10", failing: true, ancestors: [AccessIp, TransitIp]) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.Total); + verdict.AccessDown.Should().BeTrue(); + verdict.FailingCount.Should().Be(3); + verdict.TotalCount.Should().Be(3); + } + + /// + /// Same picture without a monitored first hop: still the connection, but nothing to say the + /// access layer itself is the part that went - so no attribution is claimed. + /// + [Fact] + public void Classify_EveryTargetFailingWithNoAccessHopMonitored_IsTotalWithoutAttribution() + { + var verdict = WanOutageClassifier.Classify([ + Transit("transit-a", TransitIp, 3, failing: true), + Internet("resolver-a", "203.0.113.10", failing: true, ancestors: [TransitIp]), + Internet("resolver-b", "203.0.113.20", failing: true, asnLabel: "Beta Cloud", + asnNumber: 64520, ancestors: [TransitIp]) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.Total); + verdict.AccessDown.Should().BeFalse(); + verdict.LastReachableHop.Should().BeNull(); + verdict.BrokenNetwork.Should().BeNull(); + } + + [Fact] + public void Classify_FirstHopAnswersAndEverythingBeyondFails_IsTotalAttributedToTheFirstHop() + { + var verdict = WanOutageClassifier.Classify([ + Access(), + Transit("transit-a", TransitIp, 3, failing: true, ancestors: [AccessIp]), + Internet("resolver-a", "203.0.113.10", failing: true, ancestors: [AccessIp, TransitIp]), + Internet("resolver-b", "203.0.113.20", failing: true, asnLabel: "Beta Cloud", + asnNumber: 64520, ancestors: [AccessIp, TransitIp]) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.Total); + verdict.AccessDown.Should().BeFalse(); + verdict.LastReachableHop.Should().Be("Acme Fiber"); + verdict.FailingCount.Should().Be(3); + verdict.TotalCount.Should().Be(4); + } + + /// + /// A transit hop dark WITH a destination behind it also dark is the corroborated branch: the + /// hop is the branch head, and it names the partial. + /// + [Fact] + public void Classify_TransitHopAndTheDestinationBehindItFailing_IsPartialNamingTheTransit() + { + var verdict = WanOutageClassifier.Classify([ + Access(), + Transit("transit-a", TransitIp, 3, failing: true, ancestors: [AccessIp]), + Internet("resolver-a", "203.0.113.10", failing: true, ancestors: [AccessIp, TransitIp]), + Internet("resolver-b", "203.0.113.20", asnLabel: "Beta Cloud", asnNumber: 64520, + ancestors: [AccessIp]) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.Partial); + verdict.BranchLabel.Should().Be("TransitNet"); + verdict.FailingCount.Should().Be(2); + verdict.TotalCount.Should().Be(4); + } + + /// + /// The break just past a hop that still answers: every failing destination sits behind the + /// transit and no reachable one does, so the transit is where the picture narrows. + /// + [Fact] + public void Classify_DestinationsSharingAReachableAncestor_IsPartialNamingThatAncestor() + { + var verdict = WanOutageClassifier.Classify([ + Access(), + Transit("transit-a", TransitIp, 3, ancestors: [AccessIp]), + Internet("resolver-a", "203.0.113.10", failing: true, ancestors: [AccessIp, TransitIp]), + Internet("resolver-b", "203.0.113.20", failing: true, asnLabel: "Beta Cloud", + asnNumber: 64520, ancestors: [AccessIp, TransitIp]) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.Partial); + verdict.BranchLabel.Should().Be("TransitNet"); + } + + /// + /// The ancestor healthy traffic also crosses cannot be the branch - naming the first hop + /// while other destinations behind it are perfectly reachable would blame the wrong network. + /// + [Fact] + public void Classify_AncestorHealthyTrafficAlsoCrosses_IsPartialWithoutABranch() + { + var verdict = WanOutageClassifier.Classify([ + Access(), + Internet("resolver-a", "203.0.113.10", failing: true, ancestors: [AccessIp]), + Internet("resolver-b", "203.0.113.20", failing: true, asnLabel: "Beta Cloud", + asnNumber: 64520, ancestors: [AccessIp]), + Internet("resolver-c", "203.0.113.30", asnLabel: "Gamma Cloud", asnNumber: 64530, + ancestors: [AccessIp]) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.Partial); + verdict.BranchLabel.Should().BeNull(); + verdict.FailingCount.Should().Be(2); + } + + [Fact] + public void Classify_UnrelatedDestinationsFailing_IsPartialWithoutABranch() + { + var verdict = WanOutageClassifier.Classify([ + Access(), + Internet("resolver-a", "203.0.113.10", failing: true, ancestors: [TransitIp]), + Internet("resolver-b", "203.0.113.20", failing: true, asnLabel: "Beta Cloud", + asnNumber: 64520, ancestors: [SecondTransitIp]), + Internet("resolver-c", "203.0.113.30", asnLabel: "Gamma Cloud", asnNumber: 64530) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.Partial); + verdict.BranchLabel.Should().BeNull(); + } + + /// + /// Several endpoints of one provider are one network, not several independent ones, so with + /// no monitored hop to anchor on the network itself is what the partial is named after. + /// + [Fact] + public void Classify_TwoDestinationsOfOneUnlabeledAsn_IsPartialNamingTheAsn() + { + var verdict = WanOutageClassifier.Classify([ + Access(), + Internet("resolver-a", "203.0.113.10", failing: true, asnLabel: null, asnNumber: 64540), + Internet("resolver-b", "203.0.113.20", failing: true, asnLabel: null, asnNumber: 64540), + Internet("resolver-c", "203.0.113.30", asnLabel: "Gamma Cloud", asnNumber: 64530) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.Partial); + verdict.BranchLabel.Should().Be("AS64540"); + } + + /// + /// One dark destination is nearly always the destination's own problem, and alerting on it + /// would rebuild exactly the per-target noise this alert class exists to remove. + /// + [Fact] + public void Classify_OneDestinationFailing_ReturnsNone() + { + var verdict = WanOutageClassifier.Classify([ + Access(), + Transit("transit-a", TransitIp, 3, ancestors: [AccessIp]), + Internet("resolver-a", "203.0.113.10", failing: true, ancestors: [AccessIp, TransitIp]), + Internet("resolver-b", "203.0.113.20", asnLabel: "Beta Cloud", asnNumber: 64520, + ancestors: [AccessIp, TransitIp]) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.None); + } + + [Fact] + public void Classify_OneTransitHopFailingAlone_ReturnsNone() + { + var verdict = WanOutageClassifier.Classify([ + Access(), + Transit("transit-a", TransitIp, 3, failing: true, ancestors: [AccessIp]), + Internet("resolver-a", "203.0.113.10", ancestors: [AccessIp, TransitIp]) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.None); + } + + /// + /// Transit routers rate-limit ICMP with nothing wrong, so a transit-only picture with every + /// destination still reachable is never an outage however many hops stop answering. + /// + [Fact] + public void Classify_TransitHopsDarkWithEveryDestinationReachable_ReturnsNone() + { + var verdict = WanOutageClassifier.Classify([ + Access(), + Transit("transit-a", TransitIp, 3, failing: true, ancestors: [AccessIp]), + Transit("transit-b", SecondTransitIp, 4, failing: true, asnLabel: "Delta Transit", + asnNumber: 64502, ancestors: [AccessIp]), + Internet("resolver-a", "203.0.113.10", ancestors: [AccessIp, TransitIp]), + Internet("resolver-b", "203.0.113.20", asnLabel: "Beta Cloud", asnNumber: 64520, + ancestors: [AccessIp, SecondTransitIp]) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.None); + } + + /// + /// Sustained loss counts toward a partial without the target ever going fully dark - a branch + /// can be out or degraded without every probe failing outright. + /// + [Fact] + public void Classify_DegradedButNotOfflineDestinations_IsPartial() + { + var verdict = WanOutageClassifier.Classify([ + Access(), + Internet("resolver-a", "203.0.113.10", degraded: true, ancestors: [TransitIp]), + Internet("resolver-b", "203.0.113.20", degraded: true, asnLabel: "Beta Cloud", + asnNumber: 64520, ancestors: [SecondTransitIp]), + Internet("resolver-c", "203.0.113.30", asnLabel: "Gamma Cloud", asnNumber: 64530) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.Partial); + verdict.BranchLabel.Should().BeNull(); + verdict.FailingCount.Should().Be(2); + } + + /// + /// Degraded is not offline: a WAN losing packets everywhere still passes traffic, so however + /// wide the degradation it must not read as the connection being down. + /// + [Fact] + public void Classify_EveryTargetDegradedButNoneFailing_IsNeverTotal() + { + var verdict = WanOutageClassifier.Classify([ + Access(degraded: true), + Transit("transit-a", TransitIp, 3, degraded: true, ancestors: [AccessIp]), + Internet("resolver-a", "203.0.113.10", degraded: true, ancestors: [AccessIp, TransitIp]), + Internet("resolver-b", "203.0.113.20", degraded: true, asnLabel: "Beta Cloud", + asnNumber: 64520, ancestors: [AccessIp, TransitIp]) + ]); + + verdict.Kind.Should().Be(WanVerdictKind.Partial); + verdict.AccessDown.Should().BeFalse(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/WanOutageEvaluatorTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanOutageEvaluatorTests.cs new file mode 100644 index 0000000000..b2ea016766 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanOutageEvaluatorTests.cs @@ -0,0 +1,421 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NetworkOptimizer.Alerts.Events; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Monitoring.Probes; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// The state-machine half of the WAN outage alert family, driven the way production drives it: +/// probe results into , which runs the per-target machines +/// and hands the WAN-facing ones to . What these pin down is the +/// promise the feature makes - one notification per event instead of one per target: an outage +/// publishes once, a partial that becomes total is superseded rather than stacked, a whole site +/// going dark collapses into a single rollup that releases back to per-WAN alerts as soon as the +/// WANs differ again, and both a flap and a monitoring gap publish nothing at all. Fabric and +/// custom targets keep their per-target events throughout. +/// +/// Time is faked because both machines are cadence-driven: a target needs three consecutive +/// failed probes, and the WAN verdict then has to hold three evaluation passes 30 seconds apart. +/// Probes arrive faster than passes run, exactly as they do in production (10 s polling against +/// the 30 s pass throttle), which is what lets the flap test move a verdict in and out inside a +/// single evaluation window. +/// +public class WanOutageEvaluatorTests +{ + /// + /// Probe rounds comfortably past what it takes to open or close an alert. Opening needs two + /// failed probes per target and two confirming passes; closing needs three successes to clear + /// each per-target machine and three passes, so this is sized for the slower of the two. Extra + /// rounds are harmless - the state machine only publishes on transitions. + /// + private const int RoundsToConfirm = 8; + + /// A hair over the evaluator's pass interval, so each round runs exactly one pass. + private const int SecondsPerPass = 11; + + private static readonly DateTime Start = new(2026, 7, 25, 12, 0, 0, DateTimeKind.Utc); + + private static readonly MonitoringTarget WanAccess = + Target("wan-access", "Acme Fiber first hop", "192.0.2.1", MonitoringTargetType.AccessIsp, + "wan", 64500, "Acme Fiber"); + private static readonly MonitoringTarget WanTransit = + Target("wan-transit", "TransitNet", "198.51.100.1", MonitoringTargetType.Transit, + "wan", 64501, "TransitNet"); + private static readonly MonitoringTarget WanResolverA = + Target("wan-resolver-a", "resolver-a", "203.0.113.10", MonitoringTargetType.InternetService, + "wan", 64510, "Alpha Cloud"); + private static readonly MonitoringTarget WanResolverB = + Target("wan-resolver-b", "resolver-b", "203.0.113.20", MonitoringTargetType.InternetService, + "wan", 64520, "Beta Cloud"); + + private static readonly MonitoringTarget Wan2Access = + Target("wan2-access", "Beta Cable first hop", "192.0.2.65", MonitoringTargetType.AccessIsp, + "wan2", 64600, "Beta Cable"); + private static readonly MonitoringTarget Wan2Transit = + Target("wan2-transit", "TransitNet via Beta Cable", "198.51.100.65", MonitoringTargetType.Transit, + "wan2", 64501, "TransitNet"); + private static readonly MonitoringTarget Wan2ResolverA = + Target("wan2-resolver-a", "resolver-c", "203.0.113.65", MonitoringTargetType.InternetService, + "wan2", 64510, "Alpha Cloud"); + private static readonly MonitoringTarget Wan2ResolverB = + Target("wan2-resolver-b", "resolver-d", "203.0.113.75", MonitoringTargetType.InternetService, + "wan2", 64520, "Beta Cloud"); + + private static readonly MonitoringTarget[] WanTargets = + [WanAccess, WanTransit, WanResolverA, WanResolverB]; + private static readonly MonitoringTarget[] Wan2Targets = + [Wan2Access, Wan2Transit, Wan2ResolverA, Wan2ResolverB]; + private static readonly MonitoringTarget[] WanDestinations = [WanResolverA, WanResolverB]; + private static readonly MonitoringTarget[] WanPath = [WanAccess, WanTransit]; + private static readonly MonitoringTarget[] NoTargets = []; + + private readonly CapturingAlertEventBus _bus = new(); + private readonly FakeTimeProvider _time = new(Start); + private readonly MonitoringAlertEvaluator _evaluator; + + public WanOutageEvaluatorTests() + { + _evaluator = BuildEvaluator(BuildContext()); + } + + private MonitoringAlertEvaluator BuildEvaluator(WanOutageContext context) + { + var wanOutages = new WanOutageEvaluator(_bus, NullLogger.Instance, + new FakeContextSource(context), timeProvider: _time); + return new MonitoringAlertEvaluator(_bus, NullLogger.Instance, + new DeviceTransitionTracker(), wanOutages); + } + + #region Per-WAN outages + + [Fact] + public async Task EveryTargetOnOneWanFailing_PublishesOneOutageAndNoPerTargetEvents() + { + await RoundsAsync(RoundsToConfirm, failing: WanTargets, passing: Wan2Targets); + + _bus.Published.Should().ContainSingle(); + var evt = _bus.Published.Single(); + evt.EventType.Should().Be("monitoring.wan_outage"); + evt.Severity.Should().Be(AlertSeverity.Critical); + evt.DeviceId.Should().Be("wan"); + evt.Title.Should().StartWith("Internet down on Acme Fiber WAN1"); + _bus.Published.Should().NotContain(e => e.EventType == "monitoring.target_offline"); + } + + [Fact] + public async Task DestinationsFailingWhileThePathAnswers_PublishesOnePartialOutage() + { + await RoundsAsync(RoundsToConfirm, failing: WanDestinations, + passing: [.. WanPath, .. Wan2Targets]); + + _bus.Published.Should().ContainSingle(); + var evt = _bus.Published.Single(); + evt.EventType.Should().Be("monitoring.wan_outage_partial"); + evt.Severity.Should().Be(AlertSeverity.Warning); + evt.DeviceId.Should().Be("wan"); + } + + /// + /// A partial that grows into the whole connection is superseded, never stacked: the total + /// follows once, and the partial is not repeated as the picture worsens. + /// + [Fact] + public async Task PartialThatBecomesTotal_IsFollowedByExactlyOneOutage() + { + await RoundsAsync(RoundsToConfirm, failing: WanDestinations, + passing: [.. WanPath, .. Wan2Targets]); + await RoundsAsync(RoundsToConfirm, failing: WanTargets, passing: Wan2Targets); + + _bus.Published.Select(e => e.EventType).Should() + .Equal("monitoring.wan_outage_partial", "monitoring.wan_outage"); + _bus.Published[1].DeviceId.Should().Be("wan"); + } + + [Fact] + public async Task WanThatComesBack_PublishesOneRecoveryAndThenStaysQuiet() + { + await RoundsAsync(RoundsToConfirm, failing: WanTargets, passing: Wan2Targets); + await RoundsAsync(RoundsToConfirm, failing: NoTargets, passing: [.. WanTargets, .. Wan2Targets]); + + _bus.Published.Select(e => e.EventType).Should() + .Equal("monitoring.wan_outage", "monitoring.wan_recovered"); + var recovered = _bus.Published[1]; + recovered.Severity.Should().Be(AlertSeverity.Info); + recovered.DeviceId.Should().Be("wan"); + + await RoundsAsync(RoundsToConfirm, failing: NoTargets, passing: [.. WanTargets, .. Wan2Targets]); + + _bus.Published.Should().HaveCount(2); + } + + /// + /// A backup WAN going dark matters, but not at the severity of the connection the site is + /// actually using - and it says nothing about the WAN that is still passing traffic. + /// + [Fact] + public async Task NonPrimaryWanDown_AlertsOnThatWanOnlyAndAtWarning() + { + await RoundsAsync(RoundsToConfirm, failing: Wan2Targets, passing: WanTargets); + + _bus.Published.Should().ContainSingle(); + var evt = _bus.Published.Single(); + evt.EventType.Should().Be("monitoring.wan_outage"); + evt.Severity.Should().Be(AlertSeverity.Warning); + evt.DeviceId.Should().Be("wan2"); + evt.Title.Should().StartWith("Internet down on Beta Cable WAN2"); + } + + #endregion + + #region Site rollup + + [Fact] + public async Task EveryWanDownTogether_PublishesOneSiteRollupInsteadOfPerWanOutages() + { + await RoundsAsync(RoundsToConfirm, failing: [.. WanTargets, .. Wan2Targets], passing: NoTargets); + + _bus.Published.Should().ContainSingle(); + var evt = _bus.Published.Single(); + evt.EventType.Should().Be("monitoring.wan_outage"); + evt.Severity.Should().Be(AlertSeverity.Critical); + evt.DeviceId.Should().Be("all-wans"); + _bus.Published.Should().NotContain(e => e.DeviceId == "wan" || e.DeviceId == "wan2"); + } + + /// + /// The rollup's premise is that every WAN is out. The moment one comes back the picture goes + /// back to per-WAN: the rollup closes and the WAN still dark opens its own alert. + /// + [Fact] + public async Task OneWanRecoveringUnderTheRollup_ClosesItAndOpensTheWanStillDown() + { + await RoundsAsync(RoundsToConfirm, failing: [.. WanTargets, .. Wan2Targets], passing: NoTargets); + await RoundsAsync(RoundsToConfirm, failing: Wan2Targets, passing: WanTargets); + + _bus.Published.Should().HaveCount(3); + _bus.Published[0].DeviceId.Should().Be("all-wans"); + _bus.Published.Single(e => e.EventType == "monitoring.wan_recovered") + .DeviceId.Should().Be("wan"); + _bus.Published.Skip(1).Single(e => e.EventType == "monitoring.wan_outage") + .DeviceId.Should().Be("wan2"); + } + + #endregion + + #region What the WAN alerts must not change + + [Theory] + [InlineData(MonitoringTargetType.Fabric)] + [InlineData(MonitoringTargetType.Custom)] + public async Task TargetOutsideTheWanCategories_StillPublishesPerTarget(MonitoringTargetType type) + { + var target = Target("lan-switch", "Switch 1", "192.0.2.10", type, deviceMac: "aabbccddeeff"); + + await RoundsAsync(3, failing: [target], passing: NoTargets); + + _bus.Published.Should().ContainSingle(); + var evt = _bus.Published.Single(); + evt.EventType.Should().Be("monitoring.target_offline"); + evt.Severity.Should().Be(AlertSeverity.Warning); + evt.Title.Should().Be("Switch 1 is offline"); + evt.DeviceId.Should().Be("aabbccddeeff"); + } + + /// + /// Under load balancing every WAN carries live sessions, so a backup going dark is a real + /// service loss rather than lost redundancy - it grades the same as the primary would. + /// + [Fact] + public async Task NonPrimaryWanDownOnALoadBalancingSite_IsCritical() + { + var evaluator = BuildEvaluator(BuildContext(loadBalances: true)); + + for (var round = 0; round < RoundsToConfirm; round++) + { + foreach (var target in Wan2Targets) + await evaluator.EvaluateAsync(target, Probe(target, success: false)); + foreach (var target in WanTargets) + await evaluator.EvaluateAsync(target, Probe(target, success: true)); + _time.Advance(TimeSpan.FromSeconds(SecondsPerPass)); + } + + var evt = _bus.Published.Should().ContainSingle().Subject; + evt.EventType.Should().Be("monitoring.wan_outage"); + evt.DeviceId.Should().Be("wan2"); + evt.Severity.Should().Be(AlertSeverity.Critical); + } + + #endregion + + #region Silences + + /// + /// A verdict that does not hold long enough is not an outage. Probes run faster than passes, + /// so a WAN can go dark and come back inside a couple of evaluation windows - which is what + /// the confirmation count exists to swallow. + /// + [Fact] + public async Task VerdictThatDoesNotHoldLongEnough_PublishesNothing() + { + // First failed probe: the pass it triggers has nothing recorded to judge yet. + await ProbeAsync(WanTargets, success: false); + + // Second failed probe puts every target over the failing threshold, and the pass that + // comes with it reaches a Total verdict - one confirming pass, one short of opening. + _time.Advance(TimeSpan.FromSeconds(SecondsPerPass)); + await ProbeAsync(WanTargets, success: false); + + // Back up before the next pass. A success resets the failure count immediately (the + // targets never reached the per-target offline threshold), so that pass sees a healthy + // WAN and the pending Total never gets its second confirmation. + _time.Advance(TimeSpan.FromSeconds(SecondsPerPass)); + await ProbeAsync(WanTargets, success: true); + + _bus.Published.Should().BeEmpty(); + } + + /// + /// Targets that stop reporting say nothing about the WAN: a monitoring gap (agent gone, + /// collection stopped) must not confirm an outage out of stale states. + /// + [Fact] + public async Task WanWhoseTargetsStopReporting_PublishesNothing() + { + // One confirming pass on a failing WAN, then wan stops reporting entirely. + await ProbeAsync(WanTargets, success: false); + _time.Advance(TimeSpan.FromSeconds(SecondsPerPass)); + await ProbeAsync(WanTargets, success: false); + + // The other WAN keeps reporting, so passes keep running with wan's states long stale. + _time.Advance(TimeSpan.FromMinutes(10)); + await RoundsAsync(RoundsToConfirm, failing: NoTargets, passing: Wan2Targets); + + _bus.Published.Should().BeEmpty(); + } + + #endregion + + #region Harness + + private async Task RoundsAsync(int rounds, IReadOnlyList failing, + IReadOnlyList passing) + { + for (var round = 0; round < rounds; round++) + { + await ProbeAsync(failing, success: false); + await ProbeAsync(passing, success: true); + _time.Advance(TimeSpan.FromSeconds(SecondsPerPass)); + } + } + + private async Task ProbeAsync(IReadOnlyList targets, bool success) + { + foreach (var target in targets) + await _evaluator.EvaluateAsync(target, Probe(target, success)); + } + + private PingProbeResult Probe(MonitoringTarget target, bool success) => new() + { + Target = new ProbeTarget(target.Address, ProbeMode.Icmp), + Vantage = ProbeVantage.Server, + Sent = 10, + Received = success ? 10 : 0, + Timestamp = _time.GetUtcNow().UtcDateTime, + RttAvgMs = success ? 12.5 : (double?)null + }; + + private static MonitoringTarget Target(string targetId, string name, string address, + MonitoringTargetType type, string? wanInterface = null, int? asnNumber = null, + string? asnName = null, string? deviceMac = null) => new() + { + TargetId = targetId, + Name = name, + Address = address, + TargetType = type, + WanInterface = wanInterface, + AsnNumber = asnNumber, + AsnName = asnName, + DeviceMac = deviceMac + }; + + /// + /// Two WANs with a first hop, a transit hop and two destinations each. resolver-a sits behind + /// the monitored transit; resolver-b reaches the internet another way, so a pair of dark + /// destinations has no shared branch to be named after. + /// + private static WanOutageContext BuildContext(bool loadBalances = false) => new( + PrimaryWanKey: "wan", + Wans: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["wan"] = new("wan", "Acme Fiber WAN1", TreatAsPrimary: true, CarriesTraffic: true, ConsoleUp: null), + ["wan2"] = new("wan2", "Beta Cable WAN2", TreatAsPrimary: false, + CarriesTraffic: loadBalances, ConsoleUp: null) + }, + HopsByTargetId: new Dictionary + { + ["wan-access"] = Hop(1), + ["wan-transit"] = Hop(3, "192.0.2.1"), + ["wan-resolver-a"] = Hop(6, "192.0.2.1", "198.51.100.1"), + ["wan-resolver-b"] = Hop(6, "192.0.2.1"), + ["wan2-access"] = Hop(1), + ["wan2-transit"] = Hop(3, "192.0.2.65"), + ["wan2-resolver-a"] = Hop(6, "192.0.2.65", "198.51.100.65"), + ["wan2-resolver-b"] = Hop(6, "192.0.2.65") + }, + AccessNeighborIpByWan: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["wan"] = "192.0.2.1", + ["wan2"] = "192.0.2.65" + }); + + private static WanOutageHopInfo Hop(int depth, params string[] ancestors) => + new(depth, new HashSet(ancestors, StringComparer.OrdinalIgnoreCase)); + + private sealed class FakeContextSource : WanOutageContextSource + { + private readonly WanOutageContext _context; + + public FakeContextSource(WanOutageContext context) + : base(null!, null!, NullLogger.Instance) => _context = context; + + internal override Task LoadAsync(string siteSlug, + IReadOnlyCollection wanKeysInUse, CancellationToken ct = default) => + Task.FromResult(_context); + } + + private sealed class FakeTimeProvider : TimeProvider + { + private DateTimeOffset _utcNow; + + public FakeTimeProvider(DateTime start) => _utcNow = new DateTimeOffset(start); + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan by) => _utcNow = _utcNow.Add(by); + } + + private sealed class CapturingAlertEventBus : IAlertEventBus + { + public List Published { get; } = new(); + + public ValueTask PublishAsync(AlertEvent alertEvent, CancellationToken ct = default) + { + Published.Add(alertEvent); + return ValueTask.CompletedTask; + } + + public async IAsyncEnumerable ConsumeAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield break; + } + } + + #endregion +} diff --git a/tests/NetworkOptimizer.Web.Tests/RegistryConstructionTests.cs b/tests/NetworkOptimizer.Web.Tests/RegistryConstructionTests.cs index f04c804431..b2cf4412cf 100644 --- a/tests/NetworkOptimizer.Web.Tests/RegistryConstructionTests.cs +++ b/tests/NetworkOptimizer.Web.Tests/RegistryConstructionTests.cs @@ -41,6 +41,7 @@ public class RegistryConstructionTests { typeof(CableModemMonitorService), new[] { typeof(string) } }, { typeof(OntMonitorService), new[] { typeof(string) } }, { typeof(CellularModemService), new[] { typeof(string), typeof(UniFiSshService), typeof(List) } }, + { typeof(StarlinkMonitorService), new[] { typeof(string), typeof(List) } }, // IspHealthRegistry { typeof(PhysicalLinkResolver), new[] { typeof(string) } }, { typeof(IspHealthService), new[] { typeof(string), typeof(PhysicalLinkResolver) } }, @@ -51,6 +52,7 @@ public class RegistryConstructionTests { typeof(CableModemAlertEvaluator), new[] { typeof(string), typeof(SiteAlertEventBus) } }, { typeof(OntAlertEvaluator), new[] { typeof(string), typeof(SiteAlertEventBus) } }, { typeof(CellularAlertEvaluator), new[] { typeof(string), typeof(SiteAlertEventBus) } }, + { typeof(StarlinkAlertEvaluator), new[] { typeof(string), typeof(SiteAlertEventBus) } }, // MonitoringCollectionRegistry { typeof(MonitoringCollectionAgent), new[] { typeof(string) } }, // MonitoringInfluxRegistry / MonitoringLiveStatsRegistry diff --git a/tests/NetworkOptimizer.Web.Tests/Ssh/GatewayShaperProbeServiceTests.cs b/tests/NetworkOptimizer.Web.Tests/Ssh/GatewayShaperProbeServiceTests.cs new file mode 100644 index 0000000000..a858dd9fb0 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Ssh/GatewayShaperProbeServiceTests.cs @@ -0,0 +1,159 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services; +using NetworkOptimizer.Web.Services.Ssh; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Ssh; + +/// +/// The preconditions around the Smart Queues shaper read. Everything here is about NOT asking: +/// a site with nothing to check, or a gateway we cannot see, must cost no SSH and produce no +/// state - a finding raised from a failed read would accuse a healthy install. +/// +public class GatewayShaperProbeServiceTests +{ + private readonly Mock _sqm = new(); + private readonly Mock _ssh = new(); + + private GatewayShaperProbeService CreateService() => + new(_sqm.Object, _ssh.Object, NullLogger.Instance); + + [Fact] + public async Task RunAsync_NoWanWithSmartQueues_NeverTouchesSsh() + { + SetWans(CreateWan("Fiber", "eth6", smartqEnabled: false)); + + var states = await CreateService().RunAsync(); + + states.Should().BeEmpty(); + _ssh.Verify(s => s.GetSettingsAsync(It.IsAny()), Times.Never); + _ssh.Verify(s => s.RunCommandAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task RunAsync_GatewaySshDisabled_ReturnsNothing() + { + SetWans(CreateWan("Fiber", "eth6")); + SetSshSettings(enabled: false); + + var states = await CreateService().RunAsync(); + + states.Should().BeEmpty(); + _ssh.Verify(s => s.RunCommandAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task RunAsync_NoCredentials_ReturnsNothing() + { + SetWans(CreateWan("Fiber", "eth6")); + SetSshSettings(password: null); + + var states = await CreateService().RunAsync(); + + states.Should().BeEmpty(); + _ssh.Verify(s => s.RunCommandAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task RunAsync_AgentTunnelNotUp_ReturnsNothing() + { + SetWans(CreateWan("Fiber", "eth6")); + SetSshSettings(); + _ssh.Setup(s => s.IsAwaitingAgentTunnelAsync()).ReturnsAsync(true); + + var states = await CreateService().RunAsync(); + + states.Should().BeEmpty(); + _ssh.Verify(s => s.RunCommandAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task RunAsync_CommandFails_ReturnsNothing() + { + SetWans(CreateWan("Fiber", "eth6")); + SetSshSettings(); + SetCommandResult(success: false, output: "Connection refused"); + + var states = await CreateService().RunAsync(); + + states.Should().BeEmpty(); + } + + [Fact] + public async Task RunAsync_ReadsEveryEnabledWanInOneCommand() + { + SetWans( + CreateWan("Fiber", "ppp0"), + CreateWan("Cable", "eth7"), + CreateWan("Backup", "eth8", smartqEnabled: false)); + SetSshSettings(); + + string? issued = null; + _ssh.Setup(s => s.RunCommandAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((cmd, _, _) => issued = cmd) + .ReturnsAsync(() => (true, """ + ###TC ppp0 + class htb 1:1 root rate 550Mbit ceil 550Mbit + ###TC ifbppp0 + class htb 1:1 root rate 894Mbit ceil 894Mbit + ###TC eth7 + class mq :1 root + ###TC ifbeth7 + Cannot find device "ifbeth7" + """)); + + var states = await CreateService().RunAsync(); + + _ssh.Verify(s => s.RunCommandAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + issued.Should().Contain("ppp0").And.Contain("ifbppp0").And.Contain("eth7").And.Contain("ifbeth7"); + issued.Should().NotContain("eth8"); + + states.Should().HaveCount(2); + states[0].WanName.Should().Be("Fiber"); + states[0].Egress.HasRootHtb.Should().BeTrue(); + states[1].WanName.Should().Be("Cable"); + states[1].Ingress.DeviceFound.Should().BeFalse(); + } + + [Fact] + public async Task RunAsync_UnusableInterfaceName_SkipsThatWan() + { + SetWans(CreateWan("Odd", "eth6; reboot")); + SetSshSettings(); + + var states = await CreateService().RunAsync(); + + states.Should().BeEmpty(); + _ssh.Verify(s => s.RunCommandAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + private void SetWans(params WanInterfaceInfo[] wans) => + _sqm.Setup(s => s.GetWanInterfacesFromControllerAsync()).ReturnsAsync(wans.ToList()); + + private void SetSshSettings(bool enabled = true, string? host = "192.0.2.1", string? password = "secret") => + _ssh.Setup(s => s.GetSettingsAsync(It.IsAny())).ReturnsAsync(new GatewaySshSettings + { + Enabled = enabled, + Host = host, + Username = "root", + Password = password + }); + + private void SetCommandResult(bool success, string output) => + _ssh.Setup(s => s.RunCommandAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((success, output)); + + private static WanInterfaceInfo CreateWan(string name, string ifName, bool smartqEnabled = true) => + new() + { + Name = name, + Interface = ifName, + TcInterface = $"ifb{ifName}", + SmartqEnabled = smartqEnabled, + SmartqDownRateMbps = 900, + SmartqUpRateMbps = 500 + }; +} diff --git a/tests/NetworkOptimizer.Web.Tests/Ssh/GatewayShaperProbeTests.cs b/tests/NetworkOptimizer.Web.Tests/Ssh/GatewayShaperProbeTests.cs new file mode 100644 index 0000000000..dd754db8f4 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Ssh/GatewayShaperProbeTests.cs @@ -0,0 +1,157 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Ssh; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Ssh; + +/// +/// Parser coverage for the Smart Queues shaper probe. The samples are what a UniFi gateway +/// actually emits: an htb root class when the shaper is running, the kernel's own multiqueue +/// classes when it is not (the exact output from the report in #1083), and iproute2's message +/// when UniFi never created the ingress device at all. +/// +public class GatewayShaperProbeTests +{ + private static readonly ShaperProbeTarget PppoeWan = + new("Fiber", "ppp0", "ifbppp0", DownRateMbps: 894, UpRateMbps: 550); + + [Fact] + public void BuildCommand_AsksEveryInterfaceInOneTrip() + { + var command = GatewayShaperProbe.BuildCommand(new[] { "ppp0", "ifbppp0", "eth7" }); + + command.Should().Contain("###TC ppp0"); + command.Should().Contain("tc class show dev ppp0 2>&1"); + command.Should().Contain("###TC ifbppp0"); + command.Should().Contain("tc class show dev eth7 2>&1"); + command.Should().EndWith("true"); + } + + [Fact] + public void Parse_ShapedWan_ReadsBothDirections() + { + const string output = """ + ###TC ppp0 + class htb 1:1 root rate 550Mbit ceil 550Mbit burst 2750b cburst 2750b + ###TC ifbppp0 + class htb 1:1 root rate 894Mbit ceil 894Mbit burst 111750b cburst 111750b + """; + + var state = GatewayShaperProbe.Parse(output, new[] { PppoeWan }).Should().ContainSingle().Subject; + + state.WanName.Should().Be("Fiber"); + state.Egress.DeviceFound.Should().BeTrue(); + state.Egress.HasRootHtb.Should().BeTrue(); + state.Ingress.DeviceFound.Should().BeTrue(); + state.Ingress.HasRootHtb.Should().BeTrue(); + state.DownRateMbps.Should().Be(894); + state.UpRateMbps.Should().Be(550); + } + + [Fact] + public void Parse_MultiqueueOnly_IsNotShaped() + { + // A physical WAN port left unshaped: the kernel's own mq classes and nothing else. + const string output = """ + ###TC eth6 + class mq :1 root + class mq :2 root + class mq :3 root + class mq :4 root + ###TC ifbeth6 + class mq :1 root + """; + + var target = new ShaperProbeTarget("Fiber", "eth6", "ifbeth6", 900, 500); + + var state = GatewayShaperProbe.Parse(output, new[] { target }).Should().ContainSingle().Subject; + + state.Egress.DeviceFound.Should().BeTrue(); + state.Egress.HasRootHtb.Should().BeFalse(); + state.Ingress.HasRootHtb.Should().BeFalse(); + } + + [Fact] + public void Parse_EmptySection_IsAFoundButUnshapedDevice() + { + // An interface with no classful qdisc lists nothing at all - that is an answer, not a gap. + const string output = """ + ###TC ppp0 + ###TC ifbppp0 + """; + + var state = GatewayShaperProbe.Parse(output, new[] { PppoeWan }).Should().ContainSingle().Subject; + + state.Egress.DeviceFound.Should().BeTrue(); + state.Egress.HasRootHtb.Should().BeFalse(); + state.Ingress.DeviceFound.Should().BeTrue(); + state.Ingress.HasRootHtb.Should().BeFalse(); + } + + [Fact] + public void Parse_MissingIfbDevice_IsReportedAsNotFound() + { + const string output = """ + ###TC ppp0 + class htb 1:1 root rate 550Mbit ceil 550Mbit + ###TC ifbppp0 + Cannot find device "ifbppp0" + """; + + var state = GatewayShaperProbe.Parse(output, new[] { PppoeWan }).Should().ContainSingle().Subject; + + state.Egress.HasRootHtb.Should().BeTrue(); + state.Ingress.DeviceFound.Should().BeFalse(); + state.Ingress.HasRootHtb.Should().BeFalse(); + } + + [Fact] + public void Parse_SeveralWansInOneOutput_KeepsThemApart() + { + const string output = """ + ###TC ppp0 + class htb 1:1 root rate 550Mbit ceil 550Mbit + ###TC ifbppp0 + class htb 1:1 root rate 894Mbit ceil 894Mbit + ###TC eth7 + class mq :1 root + ###TC ifbeth7 + Cannot find device "ifbeth7" + """; + + var second = new ShaperProbeTarget("Cable", "eth7", "ifbeth7", 500, 20); + + var states = GatewayShaperProbe.Parse(output, new[] { PppoeWan, second }); + + states.Should().HaveCount(2); + states[0].Egress.HasRootHtb.Should().BeTrue(); + states[1].Egress.HasRootHtb.Should().BeFalse(); + states[1].Ingress.DeviceFound.Should().BeFalse(); + } + + [Fact] + public void Parse_TruncatedOutput_DropsTheWanRatherThanGuessing() + { + // Only the egress section came back. Treating the absent ifb section as "no shaper" + // would turn a truncated read into a finding. + const string output = """ + ###TC ppp0 + class htb 1:1 root rate 550Mbit ceil 550Mbit + """; + + GatewayShaperProbe.Parse(output, new[] { PppoeWan }).Should().BeEmpty(); + } + + [Theory] + [InlineData("eth6", true)] + [InlineData("eth6.100", true)] + [InlineData("ifbppp0", true)] + [InlineData("", false)] + [InlineData("eth6; rm -rf /", false)] + [InlineData("$(reboot)", false)] + [InlineData("eth6 && reboot", false)] + public void IsValidInterfaceName_RejectsAnythingWithShellMeaning(string name, bool expected) + { + GatewayShaperProbe.IsValidInterfaceName(name).Should().Be(expected); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Tours/TourDefinitionFileTests.cs b/tests/NetworkOptimizer.Web.Tests/Tours/TourDefinitionFileTests.cs new file mode 100644 index 0000000000..577c81c5d4 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Tours/TourDefinitionFileTests.cs @@ -0,0 +1,58 @@ +using System.Reflection; +using System.Text.Json; +using FluentAssertions; +using NetworkOptimizer.Web.Services.Tours; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Tours; + +/// +/// Guards the shipped tour JSON against the mistake it cannot report: a step whose "requires" +/// names a predicate that does not exist resolves to "no site qualifies", so the step is silently +/// dropped from every install forever, with nothing in the logs to say a tour lost a step. +/// +public class TourDefinitionFileTests +{ + private static readonly HashSet KnownPredicates = typeof(TourPredicateResolver) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(f => f.IsLiteral && f.FieldType == typeof(string)) + .Select(f => (string)f.GetRawConstantValue()!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + public static TheoryData TourFiles() + { + var data = new TheoryData(); + foreach (var file in Directory.GetFiles(ToursDirectory(), "*.json")) + data.Add(Path.GetFileName(file)); + return data; + } + + [Theory] + [MemberData(nameof(TourFiles))] + public void EveryStepRequiresAKnownPredicate(string fileName) + { + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(ToursDirectory(), fileName))); + + foreach (var step in doc.RootElement.GetProperty("steps").EnumerateArray()) + { + if (!step.TryGetProperty("requires", out var requires)) + continue; + + foreach (var predicate in requires.EnumerateArray()) + { + KnownPredicates.Should().Contain(predicate.GetString()!, + $"step '{step.GetProperty("id").GetString()}' in {fileName} would never be shown otherwise"); + } + } + } + + private static string ToursDirectory() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "NetworkOptimizer.sln"))) + directory = directory.Parent; + + directory.Should().NotBeNull("the test must run from inside a NetworkOptimizer checkout"); + return Path.Combine(directory!.FullName, "src", "NetworkOptimizer.Web", "wwwroot", "data", "tours"); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/UiHintServiceTests.cs b/tests/NetworkOptimizer.Web.Tests/UiHintServiceTests.cs new file mode 100644 index 0000000000..08f784f490 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/UiHintServiceTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services; +using Xunit; + +namespace NetworkOptimizer.Web.Tests; + +/// +/// A hint that exists to reveal a gesture is for the first encounter, not the hundredth. These pin +/// the arithmetic of "shown enough"; the storage round-trip needs a full Identity graph and is +/// exercised on a test site instead. +/// +public class UiHintServiceTests +{ + private static bool StillOwed(int timesShown) => timesShown < UiHintService.ShowLimit; + + [Theory] + [InlineData(0, true)] + [InlineData(1, true)] + [InlineData(2, false)] + [InlineData(3, false)] + public void AHintRetiresOnceItHasBeenShownItsAllowance(int timesShown, bool expected) + { + StillOwed(timesShown).Should().Be(expected); + } + + [Fact] + public void TheAllowanceIsTwoOccasions() + { + // Twice: once to notice it exists, once to remember what it said. A single showing is + // easily missed and a third is nagging. + UiHintService.ShowLimit.Should().Be(2); + } + + [Fact] + public void TheCountStopsClimbingAtTheLimit() + { + // Left to grow, a "shown 400 times" would make any future reset read as absurd - and the + // number past the limit answers no question anyone has. + var shown = 0; + for (var visit = 0; visit < 10; visit++) + if (shown < UiHintService.ShowLimit) shown++; + + shown.Should().Be(UiHintService.ShowLimit); + } + + [Fact] + public void HintKeysAreStableStrings() + { + // Renaming one starts its count over, which is harmless - but it should be a decision, + // not a typo, so the keys live in one place. + UiHintKeys.WanFilterCompare.Should().Be("wan-filter-compare"); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/UpstreamTracerServiceTests.cs b/tests/NetworkOptimizer.Web.Tests/UpstreamTracerServiceTests.cs index f4fe65def7..2a739ed079 100644 --- a/tests/NetworkOptimizer.Web.Tests/UpstreamTracerServiceTests.cs +++ b/tests/NetworkOptimizer.Web.Tests/UpstreamTracerServiceTests.cs @@ -1139,7 +1139,7 @@ private static Dictionary Map(params (string Ip, int Asn)[] e) public void Unannounced_public_hops_before_the_access_border_are_attributed() { // The #984 shape: RFC1918, then unannounced public space, then the announced - // access-ASN border. The public hops are kept; the RFC1918 hop is not. + // access-ASN border. Everything below the border is kept, private included. var traces = new IReadOnlyList[] { new[] { "10.0.0.2", "203.0.113.10", "203.0.113.11", "192.0.2.60" } @@ -1147,7 +1147,7 @@ public void Unannounced_public_hops_before_the_access_border_are_attributed() var map = Map(("192.0.2.60", Bell)); UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell) - .Should().Equal("203.0.113.10", "203.0.113.11"); + .Should().Equal("10.0.0.2", "203.0.113.10", "203.0.113.11"); } [Fact] @@ -1161,13 +1161,102 @@ public void Cgnat_prefix_hops_are_attributed() } [Fact] - public void Rfc1918_hops_are_never_attributed() + public void Rfc1918_hops_are_attributed() { + // An ISP numbering its access network out of private space leaves no other trace of its + // first mile, so every private hop below the ISP's border is a candidate. var traces = new IReadOnlyList[] { new[] { "10.0.0.2", "172.16.0.2", "192.168.1.2", "192.0.2.60" } }; var map = Map(("192.0.2.60", Bell)); UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell) - .Should().BeEmpty(); + .Should().Equal("10.0.0.2", "172.16.0.2", "192.168.1.2"); + } + + [Fact] + public void Our_own_gateway_is_never_attributed() + { + var traces = new IReadOnlyList[] { new[] { "192.168.1.1", "192.168.100.1", "10.99.2.5", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + var gateways = new HashSet(new[] { "192.168.1.1" }, StringComparer.OrdinalIgnoreCase); + + UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell, gateways) + .Should().Equal("192.168.100.1", "10.99.2.5"); + } + + [Fact] + public void A_private_hop_answering_from_our_own_side_is_not_attributed() + { + // 192.168.100.1 at 0.3 ms is a bridged CPE on our side of the WAN; the CMTS at 11 ms is a + // WAN crossing away. Distance separates them where position cannot. + var traces = new IReadOnlyList[] { new[] { "192.168.100.1", "10.99.2.5", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + var rtt = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["192.168.100.1"] = 0.3, + ["10.99.2.5"] = 11.087, + }; + + UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell, null, rtt) + .Should().Equal("10.99.2.5"); + } + + [Fact] + public void A_private_hop_with_no_timing_is_still_attributed() + { + var traces = new IReadOnlyList[] { new[] { "10.99.2.5", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + + UpstreamTracerService.CollectUnannouncedAccessAddresses( + traces, map, Bell, null, new Dictionary()) + .Should().Equal("10.99.2.5"); + } + + [Fact] + public void A_close_public_hop_is_still_attributed() + { + // The distance test is for private space only - carrier space is carrier space. + var traces = new IReadOnlyList[] { new[] { "198.51.100.9", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + var rtt = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["198.51.100.9"] = 0.4 }; + + UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell, null, rtt) + .Should().Equal("198.51.100.9"); + } + + [Fact] + public void The_first_responding_hop_is_attributed_when_the_gateway_is_the_vantage() + { + // Probing from the gateway itself: there is no gateway hop to skip, and the first responder + // is already ISP-side. + var traces = new IReadOnlyList[] { new[] { "10.99.2.5", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + + UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell) + .Should().Equal("10.99.2.5"); + } + + [Fact] + public void A_cgnat_first_hop_past_our_gateway_is_still_attributed() + { + // The hold-back is for RFC1918 only. On a CGNAT provider the first hop past the gateway is + // the carrier's own first-mile device, and it is usually the only one that answers at all. + var traces = new IReadOnlyList[] { new[] { "192.168.1.1", "100.64.0.1", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + var gateways = new HashSet(new[] { "192.168.1.1" }, StringComparer.OrdinalIgnoreCase); + + UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell, gateways) + .Should().Equal("100.64.0.1"); + } + + [Fact] + public void A_public_first_hop_past_our_gateway_is_still_attributed() + { + var traces = new IReadOnlyList[] { new[] { "192.168.1.1", "198.51.100.9", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + var gateways = new HashSet(new[] { "192.168.1.1" }, StringComparer.OrdinalIgnoreCase); + + UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell, gateways) + .Should().Equal("198.51.100.9"); } [Fact] diff --git a/tests/NetworkOptimizer.Web.Tests/WanContextsCardTests.cs b/tests/NetworkOptimizer.Web.Tests/WanContextsCardTests.cs new file mode 100644 index 0000000000..ba540cea84 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/WanContextsCardTests.cs @@ -0,0 +1,236 @@ +using FluentAssertions; +using NetworkOptimizer.UniFi; +using NetworkOptimizer.Web.Components.Shared; +using Xunit; + +namespace NetworkOptimizer.Web.Tests; + +/// +/// This project has no Blazor component-test harness (no bunit), so the WAN context form's rules +/// are covered here through the pure validation function the component calls. The wiring around it +/// - which fields are shown, the interface auto-fill from the selected WAN - still needs manual +/// verification. ValidateContext is exposed internal (see NetworkOptimizer.Web.csproj +/// InternalsVisibleTo). +/// +public class WanContextsCardTests +{ + private static readonly string[] NoOtherContexts = Array.Empty(); + + [Fact] + public void Validate_SourceIpContext_IsAccepted() + { + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "192.0.2.10", + agentId: null, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().BeNull(); + } + + [Fact] + public void Validate_AgentWithInterfaceBind_IsAccepted() + { + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "", + agentId: 2, interfaceName: "eth8", otherNames: NoOtherContexts); + + error.Should().BeNull(); + } + + [Fact] + public void Validate_MissingWan_IsRejected() + { + // A context with no WAN cannot say which WAN its measurements describe. + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "", sourceIp: "192.0.2.10", + agentId: null, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().Contain("WAN"); + } + + [Fact] + public void Validate_SourceIpAndAgentTogether_IsRejected() + { + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "192.0.2.10", + agentId: 2, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().Contain("not both"); + } + + [Fact] + public void Validate_SourceIpAndAgentTogether_IsAllowed_WhenTheAgentBindsTheAddress() + { + // A multi-homed agent, one interface per WAN: the address is not a competing answer to + // "where does the probe leave from", it IS the agent's binding. + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "192.0.2.10", + agentId: 2, interfaceName: "", otherNames: NoOtherContexts, + agentCanBindSource: true); + + error.Should().BeNull(); + } + + [Fact] + public void Validate_AgentBindingAnAddress_SatisfiesASiteTheServerCannotProbe() + { + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "192.0.2.10", + agentId: 2, interfaceName: "", otherNames: NoOtherContexts, + serverProbesThisSite: false, agentCanBindSource: true); + + error.Should().BeNull(); + } + + [Fact] + public void Validate_InterfaceWithoutAgent_IsRejected() + { + // Nothing on this server can bind a name only the gateway resolves. + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "", + agentId: null, interfaceName: "eth8", otherNames: NoOtherContexts); + + error.Should().Contain("agent"); + } + + [Fact] + public void Validate_MalformedSourceIp_IsRejected() + { + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "not-an-ip", + agentId: null, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().Contain("valid IP address"); + } + + [Fact] + public void Validate_DuplicateName_IsRejected_CaseInsensitively() + { + var error = WanContextsCard.ValidateContext( + name: "Backup", wanInterface: "wan2", sourceIp: "", + agentId: 2, interfaceName: "", otherNames: new[] { "backup" }); + + error.Should().Contain("already exists"); + } + + [Fact] + public void Validate_EditingAContextKeepingItsOwnName_IsAccepted() + { + // The caller passes the OTHER contexts' names, so a rename to itself is not a clash. + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "", + agentId: 2, interfaceName: "eth8", otherNames: new[] { "starlink" }); + + error.Should().BeNull(); + } + + [Fact] + public void Validate_EmptyName_IsRejected() + { + var error = WanContextsCard.ValidateContext( + name: "", wanInterface: "wan2", sourceIp: "", + agentId: 2, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().Contain("name"); + } + + [Theory] + [InlineData("wan2")] + [InlineData("WAN2")] + [InlineData("wan")] + [InlineData("wan1")] + public void Validate_NameThatIsAnotherWansKey_IsRejected(string name) + { + // The context's name is written as an Influx wan tag alongside the stable wan key, so a + // context on wan3 named "wan2" would file its points under WAN2's report and swallow that + // WAN's measurements. + var error = WanContextsCard.ValidateContext( + name: name, wanInterface: "wan3", sourceIp: "192.0.2.10", + agentId: null, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().Be("A name that looks like a WAN key must match the vantage's own WAN."); + } + + [Theory] + [InlineData("wan2", "wan2")] + [InlineData("WAN2", "wan2")] + [InlineData("wan", "wan")] + [InlineData("wan1", "wan")] // the wan1 alias IS the primary's key, not a rival WAN + [InlineData("wan", "wan1")] + public void Validate_NameThatIsItsOwnWansKey_IsAccepted(string name, string wanInterface) + { + var error = WanContextsCard.ValidateContext( + name: name, wanInterface: wanInterface, sourceIp: "192.0.2.10", + agentId: null, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().BeNull(); + } + + [Theory] + [InlineData("starlink")] + [InlineData("wan backup")] + [InlineData("wan2-backup")] + [InlineData("lte-wan2")] + public void Validate_NameThatMerelyMentionsAWan_IsAccepted(string name) + { + // Only a name that IS a bare wan key can be mistaken for one in the tag chain. + var error = WanContextsCard.ValidateContext( + name: name, wanInterface: "wan3", sourceIp: "192.0.2.10", + agentId: null, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().BeNull(); + } + + [Theory] + [InlineData("wan", 1)] + [InlineData("wan1", 1)] + [InlineData("wan2", 2)] + [InlineData("WAN3", 3)] + [InlineData("", 0)] + [InlineData("eth8", 0)] + public void WanIndexFromKey_FollowsUniFisConvention(string key, int expected) + { + GatewayWanHelper.WanIndexFromKey(key).Should().Be(expected); + } + + [Fact] + public void WanLabel_EchoesUniFisFriendlyNamePlusGroupConvention() + { + // The WAN picker has to read like the one in UniFi Network's policy table so the user can + // match them up: "Internet 1 WAN1" for a default name, "My ISP WAN2" for a renamed one. + GatewayWanHelper.FormatWanLabel("Internet 1", GatewayWanHelper.WanIndexFromKey("wan"), null, null) + .Should().Be("Internet 1 WAN1"); + GatewayWanHelper.FormatWanLabel("My ISP", GatewayWanHelper.WanIndexFromKey("wan2"), null, null) + .Should().Be("My ISP WAN2"); + GatewayWanHelper.FormatWanLabel(null, GatewayWanHelper.WanIndexFromKey("wan2"), null, null) + .Should().Be("WAN2"); + } + + [Fact] + public void ASourceIpContextIsRejectedOnASiteTheServerDoesNotProbe() + { + // Source-IP contexts are probed by the server binding that address, and the server only + // probes the main site. On any other site this would look configured and collect nothing. + WanContextsCard.ValidateContext( + "backup", "wan2", "198.51.100.7", agentId: null, interfaceName: null, + otherNames: Array.Empty(), serverProbesThisSite: false) + .Should().Be("This site is probed by its agent, so assign one to this WAN."); + } + + [Fact] + public void ASourceIpContextIsFineOnTheMainSite() + { + WanContextsCard.ValidateContext( + "backup", "wan2", "198.51.100.7", agentId: null, interfaceName: null, + otherNames: Array.Empty(), serverProbesThisSite: true) + .Should().BeNull(); + } + + [Fact] + public void AnAgentAssignedContextIsFineOnAnySite() + { + WanContextsCard.ValidateContext( + "backup", "wan2", sourceIp: null, agentId: 4, interfaceName: null, + otherNames: Array.Empty(), serverProbesThisSite: false) + .Should().BeNull(); + } +}