live device polling
This commit is contained in:
parent
6bdd90fd97
commit
7232dab601
7 changed files with 464 additions and 70 deletions
176
assets/js/app.ts
176
assets/js/app.ts
|
|
@ -47,20 +47,40 @@ function getTimeRangeMs(range: string): number {
|
|||
// Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.)
|
||||
const SensorChart: SensorChartHook = {
|
||||
mounted() {
|
||||
this.isLiveMode = this.el?.dataset.range === 'live'
|
||||
this.createChart()
|
||||
|
||||
// Listen for live updates
|
||||
this.handleEvent?.("live_data_update", (data: any) => {
|
||||
this.handleLiveDataUpdate(data)
|
||||
})
|
||||
},
|
||||
|
||||
updated() {
|
||||
// Destroy and recreate chart to properly handle scale changes
|
||||
if (this.chart) {
|
||||
this.chart.destroy()
|
||||
const newRange = this.el?.dataset.range
|
||||
const wasLiveMode = this.isLiveMode
|
||||
this.isLiveMode = newRange === 'live'
|
||||
|
||||
// Mode switch - recreate chart
|
||||
if (wasLiveMode !== this.isLiveMode) {
|
||||
if (this.chart) {
|
||||
this.chart.destroy()
|
||||
}
|
||||
this.createChart()
|
||||
} else if (!this.isLiveMode) {
|
||||
// Historical mode - recreate chart (existing behavior)
|
||||
if (this.chart) {
|
||||
this.chart.destroy()
|
||||
}
|
||||
this.createChart()
|
||||
}
|
||||
this.createChart()
|
||||
// Live mode - updates handled by event, don't recreate
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
if (this.chart) {
|
||||
this.chart.destroy()
|
||||
this.chart = null
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -224,8 +244,62 @@ const SensorChart: SensorChartHook = {
|
|||
|
||||
// Calculate time range based on selected range
|
||||
const now = Date.now()
|
||||
const timeRangeMs = getTimeRangeMs(range)
|
||||
const rangeStart = now - timeRangeMs
|
||||
let xAxisConfig: any
|
||||
|
||||
if (this.isLiveMode) {
|
||||
// Live mode - 5 minute window
|
||||
xAxisConfig = {
|
||||
type: 'linear',
|
||||
min: now - (5 * 60 * 1000),
|
||||
max: now,
|
||||
ticks: {
|
||||
callback: function(value: number) {
|
||||
const date = new Date(value)
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
},
|
||||
maxTicksLimit: 10
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Historical mode - existing configuration
|
||||
const timeRangeMs = getTimeRangeMs(range)
|
||||
const rangeStart = now - timeRangeMs
|
||||
|
||||
xAxisConfig = {
|
||||
type: 'linear',
|
||||
min: rangeStart,
|
||||
max: now,
|
||||
ticks: {
|
||||
callback: function(value: number) {
|
||||
const date = new Date(value)
|
||||
// For ranges > 24h, show date + time; otherwise just time
|
||||
if (timeRangeMs > 24 * 60 * 60 * 1000) {
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
} else {
|
||||
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
},
|
||||
maxTicksLimit: 12
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
|
|
@ -269,36 +343,74 @@ const SensorChart: SensorChartHook = {
|
|||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
min: rangeStart,
|
||||
max: now,
|
||||
ticks: {
|
||||
callback: function(value: number) {
|
||||
const date = new Date(value)
|
||||
// For ranges > 24h, show date + time; otherwise just time
|
||||
if (timeRangeMs > 24 * 60 * 60 * 1000) {
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
} else {
|
||||
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
},
|
||||
maxTicksLimit: 12
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
x: xAxisConfig,
|
||||
y: yAxisConfig
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
handleLiveDataUpdate(event: any) {
|
||||
if (!this.chart || !this.isLiveMode) return
|
||||
|
||||
const { points } = event
|
||||
const now = Date.now()
|
||||
|
||||
// Update each dataset with new point
|
||||
points.forEach((point: any) => {
|
||||
const dataset = this.chart!.data.datasets.find(
|
||||
(ds: any) => ds.label === point.label
|
||||
)
|
||||
|
||||
if (dataset && dataset.data) {
|
||||
// Add new point
|
||||
(dataset.data as any[]).push({
|
||||
x: point.timestamp,
|
||||
y: point.value
|
||||
})
|
||||
|
||||
// Remove points older than 5 minutes
|
||||
const cutoff = now - (5 * 60 * 1000)
|
||||
dataset.data = (dataset.data as any[]).filter(
|
||||
(p: any) => p.x >= cutoff
|
||||
)
|
||||
} else if (!dataset) {
|
||||
// New sensor - add dataset
|
||||
const colors = [
|
||||
'rgb(59, 130, 246)', // blue
|
||||
'rgb(239, 68, 68)', // red
|
||||
'rgb(34, 197, 94)', // green
|
||||
'rgb(234, 179, 8)', // yellow
|
||||
'rgb(168, 85, 247)', // purple
|
||||
'rgb(236, 72, 153)', // pink
|
||||
'rgb(14, 165, 233)', // sky
|
||||
'rgb(249, 115, 22)', // orange
|
||||
]
|
||||
const index = this.chart!.data.datasets.length
|
||||
const color = colors[index % colors.length]
|
||||
|
||||
this.chart!.data.datasets.push({
|
||||
label: point.label,
|
||||
data: [{x: point.timestamp, y: point.value}],
|
||||
borderColor: color,
|
||||
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.2)'),
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
fill: false
|
||||
} as any)
|
||||
}
|
||||
})
|
||||
|
||||
// Update x-axis range to keep 5-minute window
|
||||
if (this.chart.options.scales?.x) {
|
||||
this.chart.options.scales.x.min = now - (5 * 60 * 1000)
|
||||
this.chart.options.scales.x.max = now
|
||||
}
|
||||
|
||||
// Update chart without animation
|
||||
this.chart.update('none')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
2
assets/js/types/liveview.d.ts
vendored
2
assets/js/types/liveview.d.ts
vendored
|
|
@ -13,5 +13,7 @@ export interface LiveViewHook {
|
|||
|
||||
export interface SensorChartHook extends LiveViewHook {
|
||||
chart?: any;
|
||||
isLiveMode?: boolean;
|
||||
createChart(): void;
|
||||
handleLiveDataUpdate(event: any): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,18 +40,7 @@ defmodule ToweropsWeb.Api.V1.MibController do
|
|||
with :ok <- require_superuser(conn) do
|
||||
case params["file"] do
|
||||
%Plug.Upload{} = upload ->
|
||||
vendor = params["vendor"] || "custom"
|
||||
|
||||
# Validate vendor name to prevent directory traversal
|
||||
case validate_vendor_name(vendor) do
|
||||
:ok ->
|
||||
handle_upload(conn, upload, vendor)
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: reason})
|
||||
end
|
||||
handle_upload_with_vendor(conn, upload, params)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|
|
@ -61,6 +50,21 @@ defmodule ToweropsWeb.Api.V1.MibController do
|
|||
end
|
||||
end
|
||||
|
||||
defp handle_upload_with_vendor(conn, upload, params) do
|
||||
vendor = params["vendor"] || "custom"
|
||||
|
||||
# Validate vendor name to prevent directory traversal
|
||||
case validate_vendor_name(vendor) do
|
||||
:ok ->
|
||||
handle_upload(conn, upload, vendor)
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: reason})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
List all available MIB vendors.
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,7 @@
|
|||
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
<.icon name="hero-queue-list" class="w-5 h-5 inline mr-1" />
|
||||
Oban Dashboard
|
||||
<.icon name="hero-queue-list" class="w-5 h-5 inline mr-1" /> Oban Dashboard
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Monitor background jobs and queues
|
||||
|
|
@ -46,8 +45,7 @@
|
|||
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
<.icon name="hero-chart-bar" class="w-5 h-5 inline mr-1" />
|
||||
LiveDashboard
|
||||
<.icon name="hero-chart-bar" class="w-5 h-5 inline mr-1" /> LiveDashboard
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
System metrics and performance
|
||||
|
|
|
|||
|
|
@ -277,7 +277,9 @@
|
|||
<div class="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<span class="font-medium">Current:</span>
|
||||
<span class="font-mono ml-1">{@snmp_device.firmware_version}</span>
|
||||
<span class="font-mono ml-1">
|
||||
{@snmp_device.firmware_version}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Latest:</span>
|
||||
|
|
@ -286,7 +288,9 @@
|
|||
</span>
|
||||
<%= if @available_firmware.release_date do %>
|
||||
<span class="text-gray-500 dark:text-gray-500">
|
||||
(released {format_date(@available_firmware.release_date)})
|
||||
(released {format_date(
|
||||
@available_firmware.release_date
|
||||
)})
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -299,7 +303,10 @@
|
|||
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
Download
|
||||
<.icon name="hero-arrow-top-right-on-square" class="h-3 w-3" />
|
||||
<.icon
|
||||
name="hero-arrow-top-right-on-square"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
</a>
|
||||
<%= if @available_firmware.changelog_url do %>
|
||||
<a
|
||||
|
|
@ -309,7 +316,10 @@
|
|||
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20"
|
||||
>
|
||||
Release Notes
|
||||
<.icon name="hero-arrow-top-right-on-square" class="h-3 w-3" />
|
||||
<.icon
|
||||
name="hero-arrow-top-right-on-square"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
alias Towerops.Repo
|
||||
alias Towerops.Snmp
|
||||
|
||||
# Maximum live data points (5 minutes at 1 second intervals)
|
||||
@max_live_points 300
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
|
|
@ -44,6 +47,8 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
|> assign(:interface_id, interface_id)
|
||||
|> assign(:sensor_id, sensor_id)
|
||||
|> assign(:storage_id, storage_id)
|
||||
|> assign(:is_live_mode, range == "live")
|
||||
|> maybe_start_live_polling()
|
||||
|> load_graph_data()
|
||||
|
||||
{:noreply, socket}
|
||||
|
|
@ -102,30 +107,65 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
# Private functions
|
||||
|
||||
defp load_graph_data(socket) do
|
||||
if socket.assigns.is_live_mode do
|
||||
load_live_mode_graph_data(socket)
|
||||
else
|
||||
# Historical mode - existing logic
|
||||
device_id = socket.assigns.device_id
|
||||
sensor_type = socket.assigns.sensor_type
|
||||
range = socket.assigns.range
|
||||
|
||||
device = Devices.get_device!(device_id)
|
||||
{chart_data, title_suffix} = load_chart_data_for_type(socket.assigns, range)
|
||||
|
||||
{title, unit, auto_scale} = get_chart_config(sensor_type)
|
||||
chart_title = if title_suffix, do: "#{title} - #{title_suffix}", else: title
|
||||
show_zero_line = sensor_type == "traffic"
|
||||
|
||||
# Calculate max/min values from chart data
|
||||
{max_value, min_value} = calculate_chart_stats(chart_data)
|
||||
|
||||
socket
|
||||
|> assign(:device, device)
|
||||
|> assign(:page_title, "#{device.name} - #{chart_title}")
|
||||
|> assign(:chart_title, chart_title)
|
||||
|> assign(:chart_data, chart_data)
|
||||
|> assign(:unit, unit)
|
||||
|> assign(:auto_scale, auto_scale)
|
||||
|> assign(:show_zero_line, show_zero_line)
|
||||
|> assign(:max_value, max_value)
|
||||
|> assign(:min_value, min_value)
|
||||
end
|
||||
end
|
||||
|
||||
defp load_live_mode_graph_data(socket) do
|
||||
device_id = socket.assigns.device_id
|
||||
sensor_type = socket.assigns.sensor_type
|
||||
range = socket.assigns.range
|
||||
|
||||
device = Devices.get_device!(device_id)
|
||||
{chart_data, title_suffix} = load_chart_data_for_type(socket.assigns, range)
|
||||
|
||||
{title, unit, auto_scale} = get_chart_config(sensor_type)
|
||||
chart_title = if title_suffix, do: "#{title} - #{title_suffix}", else: title
|
||||
show_zero_line = sensor_type == "traffic"
|
||||
|
||||
# Calculate max/min values from chart data
|
||||
{max_value, min_value} = calculate_chart_stats(chart_data)
|
||||
title_suffix = get_title_suffix(socket.assigns)
|
||||
chart_title = if title_suffix, do: "#{title} - #{title_suffix}", else: title
|
||||
|
||||
socket
|
||||
|> assign(:device, device)
|
||||
|> assign(:page_title, "#{device.name} - #{chart_title}")
|
||||
|> assign(:page_title, "#{device.name} - #{chart_title} (Live)")
|
||||
|> assign(:chart_title, chart_title)
|
||||
|> assign(:chart_data, chart_data)
|
||||
|> assign(:chart_data, Jason.encode!(%{datasets: []}))
|
||||
|> assign(:unit, unit)
|
||||
|> assign(:auto_scale, auto_scale)
|
||||
|> assign(:show_zero_line, show_zero_line)
|
||||
|> assign(:max_value, max_value)
|
||||
|> assign(:min_value, min_value)
|
||||
|> assign(:show_zero_line, sensor_type == "traffic")
|
||||
|> assign(:max_value, nil)
|
||||
|> assign(:min_value, nil)
|
||||
end
|
||||
|
||||
defp get_title_suffix(assigns) do
|
||||
cond do
|
||||
assigns[:sensor_id] -> get_sensor_name(assigns[:sensor_id])
|
||||
assigns[:interface_id] && assigns[:sensor_type] == "traffic" -> get_interface_name(assigns[:interface_id])
|
||||
assigns[:storage_id] && assigns[:sensor_type] == "storage_volume" -> get_storage_name(assigns[:storage_id])
|
||||
true -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp load_chart_data_for_type(%{sensor_type: "latency", device_id: device_id}, range) do
|
||||
|
|
@ -521,4 +561,213 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
defp format_value(value, unit) do
|
||||
"#{Float.round(value, 1)}#{unit}"
|
||||
end
|
||||
|
||||
# Live polling functions
|
||||
|
||||
# Start/stop live polling based on mode
|
||||
defp maybe_start_live_polling(socket) do
|
||||
if socket.assigns[:is_live_mode] && connected?(socket) do
|
||||
cancel_live_polling_timer(socket)
|
||||
|
||||
socket
|
||||
|> assign(:live_data_buffer, %{})
|
||||
|> assign(:live_data_points, 0)
|
||||
|> schedule_live_poll()
|
||||
else
|
||||
cancel_live_polling_timer(socket)
|
||||
end
|
||||
end
|
||||
|
||||
# Schedule next poll in 1 second
|
||||
defp schedule_live_poll(socket) do
|
||||
timer_ref = Process.send_after(self(), :live_poll, 1_000)
|
||||
assign(socket, :live_poll_timer, timer_ref)
|
||||
end
|
||||
|
||||
# Cancel existing timer
|
||||
defp cancel_live_polling_timer(socket) do
|
||||
case Map.get(socket.assigns, :live_poll_timer) do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
timer_ref ->
|
||||
Process.cancel_timer(timer_ref)
|
||||
assign(socket, :live_poll_timer, nil)
|
||||
end
|
||||
end
|
||||
|
||||
# Handle live poll event
|
||||
@impl true
|
||||
def handle_info(:live_poll, socket) do
|
||||
if socket.assigns[:is_live_mode] do
|
||||
socket = perform_live_poll(socket)
|
||||
{:noreply, schedule_live_poll(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# Perform SNMP polling and update buffer
|
||||
defp perform_live_poll(socket) do
|
||||
device_id = socket.assigns.device_id
|
||||
|
||||
# Verify device still exists and access is valid
|
||||
case Devices.get_device(device_id) do
|
||||
nil ->
|
||||
socket
|
||||
|> put_flash(:error, "Device no longer exists")
|
||||
|> push_navigate(to: ~p"/devices")
|
||||
|
||||
device ->
|
||||
device = Repo.preload(device, site: :organization)
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
if device.site.organization_id == organization.id do
|
||||
poll_and_update_buffer(socket, device)
|
||||
else
|
||||
socket
|
||||
|> put_flash(:error, "Access to device revoked")
|
||||
|> push_navigate(to: ~p"/devices")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Poll sensors and update buffer
|
||||
defp poll_and_update_buffer(socket, device) do
|
||||
client_opts = build_snmp_client_opts(device)
|
||||
now = DateTime.utc_now()
|
||||
timestamp_ms = DateTime.to_unix(now, :millisecond)
|
||||
|
||||
# Poll sensors based on graph type
|
||||
new_points = poll_sensors_for_live_mode(socket.assigns, client_opts, timestamp_ms)
|
||||
|
||||
# Update buffer with new points (rolling window)
|
||||
{updated_buffer, point_count} =
|
||||
update_live_data_buffer(
|
||||
socket.assigns.live_data_buffer,
|
||||
socket.assigns.live_data_points,
|
||||
new_points
|
||||
)
|
||||
|
||||
# Push update to client
|
||||
socket
|
||||
|> assign(:live_data_buffer, updated_buffer)
|
||||
|> assign(:live_data_points, point_count)
|
||||
|> push_event("live_data_update", %{
|
||||
timestamp: timestamp_ms,
|
||||
points: new_points
|
||||
})
|
||||
end
|
||||
|
||||
# Build SNMP client options
|
||||
defp build_snmp_client_opts(device) do
|
||||
snmp_config = Devices.get_snmp_config(device)
|
||||
|
||||
[
|
||||
ip: device.ip_address,
|
||||
community: snmp_config.community,
|
||||
version: snmp_config.version,
|
||||
port: device.snmp_port || 161,
|
||||
# Increased for live polling - devices may be slow to respond
|
||||
timeout: 3000
|
||||
]
|
||||
end
|
||||
|
||||
# Poll sensors in parallel
|
||||
defp poll_sensors_for_live_mode(assigns, client_opts, timestamp_ms) do
|
||||
sensors = get_sensors_for_live_mode(assigns)
|
||||
|
||||
sensors
|
||||
|> Task.async_stream(
|
||||
fn sensor ->
|
||||
case poll_single_sensor(sensor, client_opts) do
|
||||
{:ok, value} ->
|
||||
%{
|
||||
sensor_id: sensor.id,
|
||||
label: sensor.sensor_descr,
|
||||
value: Float.round(value, 1),
|
||||
timestamp: timestamp_ms
|
||||
}
|
||||
|
||||
{:error, _reason} ->
|
||||
# Skip failed polls - chart will show gap
|
||||
nil
|
||||
end
|
||||
end,
|
||||
max_concurrency: 10,
|
||||
# Give SNMP client (3000ms timeout) enough time plus overhead
|
||||
timeout: 4000,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Enum.reduce([], fn
|
||||
{:ok, nil}, acc -> acc
|
||||
{:ok, point}, acc -> [point | acc]
|
||||
{:exit, _reason}, acc -> acc
|
||||
end)
|
||||
end
|
||||
|
||||
# Get sensors based on graph type
|
||||
defp get_sensors_for_live_mode(%{sensor_id: sensor_id}) when not is_nil(sensor_id) do
|
||||
case Snmp.get_sensor(sensor_id) do
|
||||
nil -> []
|
||||
sensor -> [sensor]
|
||||
end
|
||||
end
|
||||
|
||||
defp get_sensors_for_live_mode(%{sensor_type: sensor_type, device_id: device_id}) do
|
||||
sensor_types = get_sensor_types_for_chart(sensor_type)
|
||||
device = Snmp.get_device_with_associations(device_id)
|
||||
|
||||
device.sensors
|
||||
|> Enum.filter(&(&1.sensor_type in sensor_types))
|
||||
|> Enum.sort_by(& &1.sensor_index)
|
||||
end
|
||||
|
||||
# Poll single sensor via SNMP
|
||||
defp poll_single_sensor(sensor, client_opts) do
|
||||
case Snmp.Client.get(client_opts, sensor.sensor_oid) do
|
||||
{:ok, raw_value} when is_number(raw_value) ->
|
||||
value = raw_value / sensor.sensor_divisor
|
||||
{:ok, value}
|
||||
|
||||
{:ok, _non_numeric} ->
|
||||
{:error, :non_numeric}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# Update live data buffer with rolling window
|
||||
defp update_live_data_buffer(buffer, current_count, new_points) do
|
||||
updated_buffer =
|
||||
Enum.reduce(new_points, buffer, fn point, acc ->
|
||||
sensor_buffer = Map.get(acc, point.sensor_id, [])
|
||||
|
||||
# Add new point at the front
|
||||
new_buffer = [
|
||||
%{x: point.timestamp, y: point.value} | sensor_buffer
|
||||
]
|
||||
|
||||
# Trim to max size
|
||||
trimmed_buffer =
|
||||
if current_count >= @max_live_points do
|
||||
Enum.take(new_buffer, @max_live_points)
|
||||
else
|
||||
new_buffer
|
||||
end
|
||||
|
||||
Map.put(acc, point.sensor_id, trimmed_buffer)
|
||||
end)
|
||||
|
||||
new_count = min(current_count + 1, @max_live_points)
|
||||
{updated_buffer, new_count}
|
||||
end
|
||||
|
||||
# Cleanup on LiveView termination
|
||||
@impl true
|
||||
def terminate(_reason, socket) do
|
||||
cancel_live_polling_timer(socket)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,16 +23,24 @@
|
|||
<div class="flex items-center gap-2 mb-6">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Time Range:</span>
|
||||
<div class="flex gap-1">
|
||||
<%= for {label, value} <- [{"1 Hour", "1h"}, {"6 Hours", "6h"}, {"12 Hours", "12h"}, {"24 Hours", "24h"}, {"7 Days", "7d"}, {"30 Days", "30d"}] do %>
|
||||
<%= for {label, value} <- [{"Live", "live"}, {"1 Hour", "1h"}, {"6 Hours", "6h"}, {"12 Hours", "12h"}, {"24 Hours", "24h"}, {"7 Days", "7d"}, {"30 Days", "30d"}] do %>
|
||||
<button
|
||||
phx-click="change_range"
|
||||
phx-value-range={value}
|
||||
class={[
|
||||
"px-3 py-1.5 text-sm font-medium rounded border transition-colors",
|
||||
if @range == value do
|
||||
"bg-blue-500 text-white border-blue-500"
|
||||
"px-3 py-1.5 text-sm font-medium rounded border transition-all duration-200",
|
||||
if value == "live" do
|
||||
if @range == "live" do
|
||||
"bg-green-500 text-white border-green-500 shadow-lg shadow-green-500/50"
|
||||
else
|
||||
"bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 text-green-700 dark:text-green-400 border-green-300 dark:border-green-700 hover:from-green-100 hover:to-emerald-100 dark:hover:from-green-900/30 dark:hover:to-emerald-900/30 hover:border-green-400 dark:hover:border-green-600 hover:shadow-md hover:shadow-green-500/20 font-semibold"
|
||||
end
|
||||
else
|
||||
"bg-white dark:bg-gray-800/50 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
if @range == value do
|
||||
"bg-blue-500 text-white border-blue-500"
|
||||
else
|
||||
"bg-white dark:bg-gray-800/50 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
end
|
||||
end
|
||||
]}
|
||||
>
|
||||
|
|
@ -41,6 +49,17 @@
|
|||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Live Mode Indicator -->
|
||||
<%= if @is_live_mode do %>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
<span class="relative flex h-3 w-3">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75">
|
||||
</span>
|
||||
<span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
||||
</span>
|
||||
<span>Live polling (1 second interval, 5 minute window)</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<!-- Large Chart -->
|
||||
<%= if @chart_data do %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue