diff --git a/flake.nix b/flake.nix index f6a4e5d..3fa0155 100644 --- a/flake.nix +++ b/flake.nix @@ -43,6 +43,9 @@ # Mosquito for MQTT support mosquitto + + # Network monitoring + iftop ]; shellHook = '' diff --git a/nix/package.nix b/nix/package.nix index e68690c..f665be1 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -2,6 +2,7 @@ lib, beamPackages, src, + iftop, }: beamPackages.mixRelease rec { @@ -10,6 +11,9 @@ beamPackages.mixRelease rec { inherit src; + # Runtime dependencies + buildInputs = [ iftop ]; + # Disable distributed Erlang to avoid COOKIE requirement postInstall = '' # Create wrapper script that sets proper environment including COOKIE diff --git a/server/lib/systant/ha_discovery.ex b/server/lib/systant/ha_discovery.ex index 32c6733..64803f0 100644 --- a/server/lib/systant/ha_discovery.ex +++ b/server/lib/systant/ha_discovery.ex @@ -134,9 +134,9 @@ defmodule Systant.HaDiscovery do {"sensor", "disk_root_usage", build_sensor_config("Root Disk Usage", "#{base_topic}", "disk.disks", "%", "mdi:harddisk", "{{ (value_json.disk.disks | selectattr('mounted_on', 'equalto', '/') | list | first).use_percent if value_json.disk.disks else 0 }}")}, {"sensor", "disk_home_usage", build_sensor_config("Home Disk Usage", "#{base_topic}", "disk.disks", "%", "mdi:harddisk", "{{ (value_json.disk.disks | selectattr('mounted_on', 'equalto', '/home') | list | first).use_percent if (value_json.disk.disks | selectattr('mounted_on', 'equalto', '/home') | list) else 0 }}")}, - # Network Sensors - Primary interface - {"sensor", "network_rx_bytes", build_sensor_config("Network RX Bytes", "#{base_topic}", "network", "bytes", "mdi:download-network", "{{ value_json.network[0].rx_bytes if value_json.network and value_json.network|length > 0 else 0 }}")}, - {"sensor", "network_tx_bytes", build_sensor_config("Network TX Bytes", "#{base_topic}", "network", "bytes", "mdi:upload-network", "{{ value_json.network[0].tx_bytes if value_json.network and value_json.network|length > 0 else 0 }}")}, + # Network Sensors - Primary interface throughput + {"sensor", "network_rx_throughput", build_sensor_config("Network RX Throughput", "#{base_topic}", "network", "B/s", "mdi:download-network", "{{ value_json.network[0].rx_throughput_bps if value_json.network and value_json.network|length > 0 else 0 }}")}, + {"sensor", "network_tx_throughput", build_sensor_config("Network TX Throughput", "#{base_topic}", "network", "B/s", "mdi:upload-network", "{{ value_json.network[0].tx_throughput_bps if value_json.network and value_json.network|length > 0 else 0 }}")}, # Binary Sensors for status {"binary_sensor", "system_online", build_binary_sensor_config("System Online", "#{base_topic}", "mdi:server", "connectivity")} diff --git a/server/lib/systant/mqtt_client.ex b/server/lib/systant/mqtt_client.ex index ad35963..3b09b9b 100644 --- a/server/lib/systant/mqtt_client.ex +++ b/server/lib/systant/mqtt_client.ex @@ -20,7 +20,8 @@ defmodule Systant.MqttClient do # Store both configs for later use state_config = %{ app_config: app_config, - mqtt_config: mqtt_config + mqtt_config: mqtt_config, + previous_network_stats: nil } connection_opts = [ @@ -64,14 +65,14 @@ defmodule Systant.MqttClient do def handle_info(:publish_startup_stats, state) do Logger.info("Publishing initial system metrics") - publish_stats(state.app_config, state.mqtt_config) - {:noreply, state} + {_stats, updated_state} = collect_and_publish_stats(state) + {:noreply, updated_state} end def handle_info(:publish_stats, state) do - publish_stats(state.app_config, state.mqtt_config) + {_stats, updated_state} = collect_and_publish_stats(state) schedule_stats_publish(state.mqtt_config.publish_interval) - {:noreply, state} + {:noreply, updated_state} end def handle_info(_msg, state) do @@ -83,6 +84,36 @@ defmodule Systant.MqttClient do :ok end + defp collect_and_publish_stats(state) do + # Collect metrics with previous network stats for throughput calculation + stats = Systant.SystemMetrics.collect_metrics(state.app_config, state.previous_network_stats) + + # Store current network stats for next iteration + current_network_stats = case Map.get(stats, :network) do + network_data when is_list(network_data) -> + %{ + interfaces: network_data, + timestamp: System.monotonic_time(:second) + } + _ -> nil + end + + updated_state = Map.put(state, :previous_network_stats, current_network_stats) + + # Publish the stats + payload = Jason.encode!(stats) + + case Tortoise.publish(state.mqtt_config.client_id, state.mqtt_config.stats_topic, payload, qos: state.mqtt_config.qos) do + :ok -> + Logger.info("Published system metrics for #{stats.hostname}") + {:error, reason} -> + Logger.error("Failed to publish stats: #{inspect(reason)}") + end + + {stats, updated_state} + end + + # Legacy function for compatibility if needed defp publish_stats(app_config, mqtt_config) do stats = Systant.SystemMetrics.collect_metrics(app_config) diff --git a/server/lib/systant/system_metrics.ex b/server/lib/systant/system_metrics.ex index 233847d..9b42e71 100644 --- a/server/lib/systant/system_metrics.ex +++ b/server/lib/systant/system_metrics.ex @@ -9,7 +9,7 @@ defmodule Systant.SystemMetrics do @doc """ Collect system metrics based on configuration """ - def collect_metrics(config \\ nil) do + def collect_metrics(config \\ nil, previous_network_stats \\ nil) do config = config || Systant.Config.load_config() base_metrics = %{ @@ -27,7 +27,7 @@ defmodule Systant.SystemMetrics do "memory" -> Map.put(acc, :memory, collect_memory_metrics(config)) "disk" -> Map.put(acc, :disk, collect_disk_metrics(config)) "gpu" -> Map.put(acc, :gpu, collect_gpu_metrics(config)) - "network" -> Map.put(acc, :network, collect_network_metrics(config)) + "network" -> Map.put(acc, :network, collect_network_metrics(config, previous_network_stats)) "temperature" -> Map.put(acc, :temperature, collect_temperature_metrics(config)) "processes" -> Map.put(acc, :processes, collect_process_metrics(config)) "system" -> Map.put(acc, :system, collect_system_info(config)) @@ -73,10 +73,10 @@ defmodule Systant.SystemMetrics do end @doc """ - Collect network interface statistics + Collect network interface statistics with throughput calculation """ - def collect_network_metrics(config) do - get_network_stats(config) + def collect_network_metrics(config, previous_stats \\ nil) do + get_network_stats(config, previous_stats) end @doc """ @@ -681,19 +681,23 @@ defmodule Systant.SystemMetrics do end end - defp get_network_stats(config) do + defp get_network_stats(config, previous_stats \\ nil) do network_config = Systant.Config.get(config, ["network"]) || %{} + current_time = System.monotonic_time(:second) try do case File.read("/proc/net/dev") do {:ok, content} -> - content + current_interfaces = content |> String.split("\n") |> Enum.drop(2) |> Enum.filter(&(String.trim(&1) != "")) |> Enum.map(&parse_network_interface/1) |> Enum.filter(&(&1 != nil)) |> filter_network_interfaces(network_config) + |> Enum.map(&calculate_throughput(&1, previous_stats, current_time)) + + current_interfaces _ -> [] end rescue @@ -794,4 +798,44 @@ defmodule Systant.SystemMetrics do end end + defp calculate_throughput(current_interface, previous_stats, current_time) do + case previous_stats do + %{interfaces: prev_interfaces, timestamp: prev_time} -> + # Find matching interface in previous data + prev_interface = Enum.find(prev_interfaces, &(&1.interface == current_interface.interface)) + + if prev_interface && prev_time do + time_diff = current_time - prev_time + + if time_diff > 0 do + rx_bytes_diff = current_interface.rx_bytes - prev_interface.rx_bytes + tx_bytes_diff = current_interface.tx_bytes - prev_interface.tx_bytes + + # Calculate bytes per second + rx_throughput = max(0, rx_bytes_diff / time_diff) + tx_throughput = max(0, tx_bytes_diff / time_diff) + + current_interface + |> Map.put(:rx_throughput_bps, Float.round(rx_throughput, 2)) + |> Map.put(:tx_throughput_bps, Float.round(tx_throughput, 2)) + else + # First measurement or time error + current_interface + |> Map.put(:rx_throughput_bps, 0.0) + |> Map.put(:tx_throughput_bps, 0.0) + end + else + # No previous data for this interface + current_interface + |> Map.put(:rx_throughput_bps, 0.0) + |> Map.put(:tx_throughput_bps, 0.0) + end + _ -> + # No previous data at all + current_interface + |> Map.put(:rx_throughput_bps, 0.0) + |> Map.put(:tx_throughput_bps, 0.0) + end + end + end \ No newline at end of file