From a82a345cd6eb9ea40ffb738d5165d2313d2b8962 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 9 Nov 2025 23:36:52 +0000 Subject: [PATCH] Improve table column widths and sparkline visibility --- .../src/components/Dashboard/Dashboard.tsx | 55 ++++++++---- .../src/components/Dashboard/GuestRow.tsx | 16 ++-- .../src/components/Dashboard/MetricBar.tsx | 15 +++- .../Docker/DockerHostSummaryTable.tsx | 76 ++++++++-------- .../components/shared/NodeSummaryTable.tsx | 14 +-- .../src/components/shared/Sparkline.tsx | 86 +++++++++++-------- internal/notifications/webhook_enhanced.go | 10 +++ 7 files changed, 164 insertions(+), 108 deletions(-) diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 23a2bfaf4..89c9ea0db 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -907,12 +907,12 @@ export function Dashboard(props: DashboardProps) { 0}> - - + +
diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 2006b4590..5b0686256 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -405,7 +405,7 @@ export function GuestRow(props: GuestRowProps) { // Get first cell styling const firstCellClass = createMemo(() => { const base = - 'py-0.5 pr-2 whitespace-nowrap relative w-[160px] sm:w-[200px] lg:w-[240px] xl:w-[280px] 2xl:w-[380px]'; + 'py-0.5 pr-2 whitespace-nowrap relative w-[140px] sm:w-[160px] lg:w-[180px] xl:w-[240px] 2xl:w-[380px]'; const indent = props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT; return `${base} ${indent}`; }); @@ -432,7 +432,7 @@ export function GuestRow(props: GuestRowProps) { fallback={
{/* Type */} -
{/* Uptime */} {/* CPU */} - -
handleSort('name')} onKeyDown={(e) => e.key === 'Enter' && handleSort('name')} tabindex="0" @@ -922,37 +922,49 @@ export function Dashboard(props: DashboardProps) { Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('type')} + title="Type" > - Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')} + + Typ{' '} + {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('vmid')} + title="VM/Container ID" > - VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')} + + ID{' '} + {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('uptime')} + title="Uptime" > - Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')} + + Up{' '} + {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('cpu')} > CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('memory')} + title="Memory" > - Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')} + + Mem{' '} + {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('disk')} > Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')} @@ -960,28 +972,37 @@ export function Dashboard(props: DashboardProps) { handleSort('diskRead')} + title="Disk Read" > - Disk Read{' '} + + D Rd{' '} {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('diskWrite')} + title="Disk Write" > - Disk Write{' '} + + D Wr{' '} {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('networkIn')} + title="Network In" > - Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')} + + N In{' '} + {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('networkOut')} + title="Network Out" > - Net Out{' '} + + N Out{' '} {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
+
{/* VMID */} -
+ {props.guest.vmid} @@ -572,7 +572,7 @@ export function GuestRow(props: GuestRowProps) { + -}> {/* Memory */} - +
-}> {/* Disk – surface usage even if guest is currently stopped so users can see last reported values */} -
+ -
+ {/* On very small screens (< lg), show compact percentage with color indicator */} +
+ {/* Slim color indicator bar */} +
+ + {props.label} + +
+ {/* On larger screens (>= lg), show full progress bar */} +
handleSort('name')} onKeyDown={(e) => e.key === 'Enter' && handleSort('name')} tabIndex={0} @@ -153,47 +153,47 @@ export const DockerHostSummaryTable: Component = (p > Host {renderSortIndicator('name')} + Status handleSort('cpu')} > CPU {renderSortIndicator('cpu')} handleSort('memory')} > Memory {renderSortIndicator('memory')} handleSort('disk')} > Disk {renderSortIndicator('disk')} handleSort('running')} > Containers {renderSortIndicator('running')} -
- —}> - - -
+ —}> + +
-
- —}> - - -
+ —}> + +
-
- —}> - - -
+ —}> + +
diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx index aed209b10..ca0d2a1b9 100644 --- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx +++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx @@ -358,7 +358,7 @@ export const NodeSummaryTable: Component = (props) => {
handleSort('name')} onKeyDown={(e) => e.key === 'Enter' && handleSort('name')} tabindex="0" @@ -371,25 +371,25 @@ export const NodeSummaryTable: Component = (props) => { {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('uptime')} > Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('cpu')} > CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('memory')} > Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('disk')} > {props.currentTab === 'backups' && props.pbsInstances ? 'Storage / Disk' : 'Disk'}{' '} @@ -397,7 +397,7 @@ export const NodeSummaryTable: Component = (props) => { handleSort('temperature')} > Temp{' '} @@ -407,7 +407,7 @@ export const NodeSummaryTable: Component = (props) => { {(column) => ( handleSort(column.key)} > {column.header}{' '} diff --git a/frontend-modern/src/components/shared/Sparkline.tsx b/frontend-modern/src/components/shared/Sparkline.tsx index 3dd7b1d5e..239bfa618 100644 --- a/frontend-modern/src/components/shared/Sparkline.tsx +++ b/frontend-modern/src/components/shared/Sparkline.tsx @@ -6,6 +6,7 @@ */ import { onMount, onCleanup, createEffect, createSignal, Component, Show } from 'solid-js'; +import { Portal } from 'solid-js/web'; import type { MetricSnapshot } from '@/stores/metricsHistory'; import { scheduleSparkline } from '@/utils/canvasRenderQueue'; @@ -68,6 +69,16 @@ export const Sparkline: Component = (props) => { return '#22c55e'; // green-500 }; + // Get color with opacity matching progress bars (60% for consistency) + const getColorWithOpacity = (value: number): string => { + const baseColor = getColor(value); + // Convert hex to rgba with 0.6 opacity (matching progress bar 60%) + const r = parseInt(baseColor.slice(1, 3), 16); + const g = parseInt(baseColor.slice(3, 5), 16); + const b = parseInt(baseColor.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, 0.85)`; // Slightly more opaque for visibility + }; + const drawSparkline = () => { if (!canvasRef) return; @@ -110,6 +121,7 @@ export const Sparkline: Component = (props) => { // Get latest value for color const latestValue = values[values.length - 1] || 0; const color = getColor(latestValue); + const colorWithOpacity = getColorWithOpacity(latestValue); // Find min/max for scaling const minValue = 0; // Always anchor at 0 @@ -163,8 +175,8 @@ export const Sparkline: Component = (props) => { ctx.closePath(); ctx.fill(); - // Draw line - ctx.strokeStyle = color; + // Draw line with opacity matching progress bars + ctx.strokeStyle = colorWithOpacity; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; @@ -178,10 +190,10 @@ export const Sparkline: Component = (props) => { }); ctx.stroke(); - // Draw current value dot + // Draw current value dot with opacity if (points.length > 0) { const lastPoint = points[points.length - 1]; - ctx.fillStyle = color; + ctx.fillStyle = colorWithOpacity; ctx.beginPath(); ctx.arc(lastPoint.x, lastPoint.y, 2, 0, Math.PI * 2); ctx.fill(); @@ -231,7 +243,7 @@ export const Sparkline: Component = (props) => { const value = values[nearestIndex]; const timestamp = data[nearestIndex].timestamp; - // Calculate y position for visual indicator + // Calculate absolute viewport position for portal const minValue = 0; const maxValue = Math.max(100, ...values); const y = h - ((value - minValue) / (maxValue - minValue)) * h; @@ -239,8 +251,8 @@ export const Sparkline: Component = (props) => { setHoveredPoint({ value, timestamp, - x: nearestIndex * xStep, - y: rect.top + y, + x: rect.left + nearestIndex * xStep, // Absolute x position + y: rect.top - 45, // Position above the sparkline (45px above top edge) }); }; @@ -255,32 +267,38 @@ export const Sparkline: Component = (props) => { }; return ( -
- - - {(point) => ( -
-
{point().value.toFixed(1)}%
-
{formatTime(point().timestamp)}
-
- )} -
-
+ <> +
+ +
+ + + {(point) => ( +
+
{point().value.toFixed(1)}%
+
{formatTime(point().timestamp)}
+
+ )} +
+
+ ); }; diff --git a/internal/notifications/webhook_enhanced.go b/internal/notifications/webhook_enhanced.go index 36b9e73c7..0211ff5b9 100644 --- a/internal/notifications/webhook_enhanced.go +++ b/internal/notifications/webhook_enhanced.go @@ -487,6 +487,16 @@ func (n *NotificationManager) TestEnhancedWebhook(webhook EnhancedWebhookConfig) } webhook.URL = renderedURL + // Validate webhook URL to prevent SSRF/DNS rebinding attacks (same validation as live sends) + if err := n.ValidateWebhookURL(webhook.URL); err != nil { + log.Error(). + Err(err). + Str("webhook", webhook.Name). + Str("url", webhook.URL). + Msg("Webhook URL validation failed for test request") + return 0, "", fmt.Errorf("webhook URL validation failed: %w", err) + } + // For Telegram, extract chat_id from URL if present if webhook.Service == "telegram" { if chatID, err := extractTelegramChatID(webhook.URL); err == nil && chatID != "" {