diff --git a/assets/js/app.ts b/assets/js/app.ts index b43a432a..7f9ad907 100644 --- a/assets/js/app.ts +++ b/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') } } diff --git a/assets/js/types/liveview.d.ts b/assets/js/types/liveview.d.ts index b13bfd8c..80a2fa83 100644 --- a/assets/js/types/liveview.d.ts +++ b/assets/js/types/liveview.d.ts @@ -13,5 +13,7 @@ export interface LiveViewHook { export interface SensorChartHook extends LiveViewHook { chart?: any; + isLiveMode?: boolean; createChart(): void; + handleLiveDataUpdate(event: any): void; } diff --git a/lib/towerops_web/controllers/api/v1/mib_controller.ex b/lib/towerops_web/controllers/api/v1/mib_controller.ex index 46e075e4..d8d0beba 100644 --- a/lib/towerops_web/controllers/api/v1/mib_controller.ex +++ b/lib/towerops_web/controllers/api/v1/mib_controller.ex @@ -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. diff --git a/lib/towerops_web/live/admin/dashboard_live.html.heex b/lib/towerops_web/live/admin/dashboard_live.html.heex index 307f7d22..ef28de37 100644 --- a/lib/towerops_web/live/admin/dashboard_live.html.heex +++ b/lib/towerops_web/live/admin/dashboard_live.html.heex @@ -30,8 +30,7 @@
Monitor background jobs and queues @@ -46,8 +45,7 @@
System metrics and performance diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index 00972421..ef1d4ab7 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -277,7 +277,9 @@