mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
Improve table column widths and sparkline visibility
This commit is contained in:
@@ -907,12 +907,12 @@ export function Dashboard(props: DashboardProps) {
|
||||
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
|
||||
<ComponentErrorBoundary name="Guest Table">
|
||||
<Card padding="none" class="mb-4 overflow-hidden">
|
||||
<ScrollableTable minWidth="760px">
|
||||
<table class="w-full min-w-[760px] md:min-w-[900px] table-fixed border-collapse">
|
||||
<ScrollableTable minWidth="750px">
|
||||
<table class="w-full min-w-[750px] md:min-w-[840px] lg:min-w-[970px] table-fixed border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
||||
<th
|
||||
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[160px] sm:w-[200px] lg:w-[240px] xl:w-[280px] 2xl:w-[380px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[140px] sm:w-[160px] lg:w-[180px] xl:w-[240px] 2xl:w-[380px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||
onClick={() => 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' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[48px] sm:w-[56px] lg:w-[60px] xl:w-[64px] 2xl:w-[87px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[52px] sm:w-[58px] lg:w-[64px] xl:w-[70px] 2xl:w-[87px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={() => handleSort('type')}
|
||||
title="Type"
|
||||
>
|
||||
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
<span class="hidden xl:inline">Type</span>
|
||||
<span class="xl:hidden" aria-label="Type">Typ</span>{' '}
|
||||
{sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-1.5 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wide w-[44px] sm:w-[52px] lg:w-[60px] xl:w-[68px] 2xl:w-[92px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
class="px-1.5 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wide w-[50px] sm:w-[56px] lg:w-[62px] xl:w-[70px] 2xl:w-[92px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={() => handleSort('vmid')}
|
||||
title="VM/Container ID"
|
||||
>
|
||||
VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
<span class="hidden xl:inline">VMID</span>
|
||||
<span class="xl:hidden" aria-label="VM ID">ID</span>{' '}
|
||||
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-1.5 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wide w-[60px] sm:w-[70px] lg:w-[80px] xl:w-[92px] 2xl:w-[125px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
class="px-1.5 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wide w-[56px] sm:w-[64px] lg:w-[72px] xl:w-[84px] 2xl:w-[125px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={() => handleSort('uptime')}
|
||||
title="Uptime"
|
||||
>
|
||||
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
<span class="hidden lg:inline">Uptime</span>
|
||||
<span class="lg:hidden" aria-label="Uptime">Up</span>{' '}
|
||||
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[160px] sm:w-[170px] lg:w-[180px] xl:w-[190px] 2xl:w-[204px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[80px] sm:w-[90px] lg:w-[110px] xl:w-[160px] 2xl:w-[204px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={() => handleSort('cpu')}
|
||||
>
|
||||
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[160px] sm:w-[170px] lg:w-[180px] xl:w-[190px] 2xl:w-[204px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[80px] sm:w-[90px] lg:w-[110px] xl:w-[160px] 2xl:w-[204px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={() => handleSort('memory')}
|
||||
title="Memory"
|
||||
>
|
||||
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
<span class="hidden lg:inline">Memory</span>
|
||||
<span class="lg:hidden" aria-label="Memory">Mem</span>{' '}
|
||||
{sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[160px] sm:w-[170px] lg:w-[180px] xl:w-[190px] 2xl:w-[204px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[80px] sm:w-[90px] lg:w-[110px] xl:w-[160px] 2xl:w-[204px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={() => handleSort('disk')}
|
||||
>
|
||||
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
@@ -960,28 +972,37 @@ export function Dashboard(props: DashboardProps) {
|
||||
<th
|
||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[56px] sm:w-[62px] lg:w-[70px] xl:w-[78px] 2xl:w-[106px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={() => handleSort('diskRead')}
|
||||
title="Disk Read"
|
||||
>
|
||||
Disk Read{' '}
|
||||
<span class="hidden lg:inline">Disk Read</span>
|
||||
<span class="lg:hidden" aria-label="Disk Read">D Rd</span>{' '}
|
||||
{sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[56px] sm:w-[62px] lg:w-[70px] xl:w-[78px] 2xl:w-[106px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={() => handleSort('diskWrite')}
|
||||
title="Disk Write"
|
||||
>
|
||||
Disk Write{' '}
|
||||
<span class="hidden lg:inline">Disk Write</span>
|
||||
<span class="lg:hidden" aria-label="Disk Write">D Wr</span>{' '}
|
||||
{sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[56px] sm:w-[62px] lg:w-[70px] xl:w-[78px] 2xl:w-[106px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={() => handleSort('networkIn')}
|
||||
title="Network In"
|
||||
>
|
||||
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
<span class="hidden lg:inline">Net In</span>
|
||||
<span class="lg:hidden" aria-label="Network In">N In</span>{' '}
|
||||
{sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[56px] sm:w-[62px] lg:w-[70px] xl:w-[78px] 2xl:w-[106px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={() => handleSort('networkOut')}
|
||||
title="Network Out"
|
||||
>
|
||||
Net Out{' '}
|
||||
<span class="hidden lg:inline">Net Out</span>
|
||||
<span class="lg:hidden" aria-label="Network Out">N Out</span>{' '}
|
||||
{sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@@ -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={
|
||||
<div class="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span
|
||||
class="text-sm font-medium text-gray-900 dark:text-gray-100 cursor-text select-none"
|
||||
class="text-sm font-medium text-gray-900 dark:text-gray-100 cursor-text select-none overflow-hidden text-ellipsis"
|
||||
style="cursor: text;"
|
||||
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
|
||||
onClick={startEditingUrl}
|
||||
@@ -541,7 +541,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||
</td>
|
||||
|
||||
{/* Type */}
|
||||
<td class="py-0.5 px-2 whitespace-nowrap w-[48px] sm:w-[56px] lg:w-[60px] xl:w-[64px] 2xl:w-[87px]">
|
||||
<td class="py-0.5 px-2 whitespace-nowrap w-[52px] sm:w-[58px] lg:w-[64px] xl:w-[70px] 2xl:w-[87px]">
|
||||
<div class="flex h-[24px] items-center">
|
||||
<span
|
||||
class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${
|
||||
@@ -556,13 +556,13 @@ export function GuestRow(props: GuestRowProps) {
|
||||
</td>
|
||||
|
||||
{/* VMID */}
|
||||
<td class="py-0.5 px-1.5 whitespace-nowrap w-[44px] sm:w-[52px] lg:w-[60px] xl:w-[68px] 2xl:w-[92px] text-sm text-gray-600 dark:text-gray-400 align-middle">
|
||||
<td class="py-0.5 px-1.5 whitespace-nowrap w-[50px] sm:w-[56px] lg:w-[62px] xl:w-[70px] 2xl:w-[92px] text-sm text-gray-600 dark:text-gray-400 align-middle">
|
||||
{props.guest.vmid}
|
||||
</td>
|
||||
|
||||
{/* Uptime */}
|
||||
<td
|
||||
class={`py-0.5 px-1.5 w-[60px] sm:w-[70px] lg:w-[80px] xl:w-[92px] 2xl:w-[125px] text-sm whitespace-nowrap align-middle ${
|
||||
class={`py-0.5 px-1.5 w-[56px] sm:w-[64px] lg:w-[72px] xl:w-[84px] 2xl:w-[125px] text-sm whitespace-nowrap align-middle ${
|
||||
props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
@@ -572,7 +572,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||
</td>
|
||||
|
||||
{/* CPU */}
|
||||
<td class="py-0.5 px-2 w-[160px] sm:w-[170px] lg:w-[180px] xl:w-[190px] 2xl:w-[204px]">
|
||||
<td class="py-0.5 px-2 w-[80px] sm:w-[90px] lg:w-[110px] xl:w-[160px] 2xl:w-[204px]">
|
||||
<Show when={isRunning()} fallback={<span class="text-sm text-gray-400">-</span>}>
|
||||
<MetricBar
|
||||
value={cpuPercent()}
|
||||
@@ -589,7 +589,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||
</td>
|
||||
|
||||
{/* Memory */}
|
||||
<td class="py-0.5 px-2 w-[160px] sm:w-[170px] lg:w-[180px] xl:w-[190px] 2xl:w-[204px]">
|
||||
<td class="py-0.5 px-2 w-[80px] sm:w-[90px] lg:w-[110px] xl:w-[160px] 2xl:w-[204px]">
|
||||
<div title={memoryTooltip() ?? undefined}>
|
||||
<Show when={isRunning()} fallback={<span class="text-sm text-gray-400">-</span>}>
|
||||
<MetricBar
|
||||
@@ -604,7 +604,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||
</td>
|
||||
|
||||
{/* Disk – surface usage even if guest is currently stopped so users can see last reported values */}
|
||||
<td class="py-0.5 px-2 w-[160px] sm:w-[170px] lg:w-[180px] xl:w-[190px] 2xl:w-[204px]">
|
||||
<td class="py-0.5 px-2 w-[80px] sm:w-[90px] lg:w-[110px] xl:w-[160px] 2xl:w-[204px]">
|
||||
<Show
|
||||
when={hasDiskUsage()}
|
||||
fallback={
|
||||
|
||||
@@ -68,7 +68,20 @@ export function MetricBar(props: MetricBarProps) {
|
||||
fallback={
|
||||
// Original progress bar mode
|
||||
<div class="metric-text w-full h-6 flex items-center">
|
||||
<div class="relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
|
||||
{/* On very small screens (< lg), show compact percentage with color indicator */}
|
||||
<div class="lg:hidden relative w-full h-3.5 flex items-center justify-center rounded overflow-hidden bg-gray-100 dark:bg-gray-700">
|
||||
{/* Slim color indicator bar */}
|
||||
<div class={`absolute bottom-0 left-0 h-1 w-full ${progressColorClass()}`} />
|
||||
<span class={`text-[10px] font-medium z-10 ${
|
||||
getColor() === 'red' ? 'text-red-700 dark:text-red-300' :
|
||||
getColor() === 'yellow' ? 'text-yellow-700 dark:text-yellow-300' :
|
||||
'text-gray-800 dark:text-gray-100'
|
||||
}`}>
|
||||
{props.label}
|
||||
</span>
|
||||
</div>
|
||||
{/* On larger screens (>= lg), show full progress bar */}
|
||||
<div class="hidden lg:block relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
|
||||
<div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} />
|
||||
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
|
||||
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
|
||||
|
||||
@@ -144,7 +144,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
||||
<th
|
||||
class="pl-3 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-1/4 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
|
||||
class="pl-3 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[18%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
|
||||
onClick={() => handleSort('name')}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
||||
tabIndex={0}
|
||||
@@ -153,47 +153,47 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||
>
|
||||
Host {renderSortIndicator('name')}
|
||||
</th>
|
||||
<th class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap">
|
||||
<th class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[7%] whitespace-nowrap">
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-[140px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
onClick={() => handleSort('cpu')}
|
||||
>
|
||||
CPU {renderSortIndicator('cpu')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-[140px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
onClick={() => handleSort('memory')}
|
||||
>
|
||||
Memory {renderSortIndicator('memory')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-[140px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
onClick={() => handleSort('disk')}
|
||||
>
|
||||
Disk {renderSortIndicator('disk')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[11%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
onClick={() => handleSort('running')}
|
||||
>
|
||||
Containers {renderSortIndicator('running')}
|
||||
</th>
|
||||
<th
|
||||
class="hidden px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
||||
class="hidden px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[9%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
||||
onClick={() => handleSort('uptime')}
|
||||
>
|
||||
Uptime {renderSortIndicator('uptime')}
|
||||
</th>
|
||||
<th
|
||||
class="hidden px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
||||
class="hidden px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[12%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
||||
onClick={() => handleSort('lastSeen')}
|
||||
>
|
||||
Last Update {renderSortIndicator('lastSeen')}
|
||||
</th>
|
||||
<th
|
||||
class="hidden px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
||||
class="hidden px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[9%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
||||
onClick={() => handleSort('agent')}
|
||||
>
|
||||
Agent {renderSortIndicator('agent')}
|
||||
@@ -319,42 +319,36 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-1 align-middle">
|
||||
<div class="flex justify-center items-center h-full w-full min-w-[180px] max-w-[220px] whitespace-nowrap">
|
||||
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||
<MetricBar
|
||||
value={summary.cpuPercent}
|
||||
label={formatPercent(summary.cpuPercent)}
|
||||
type="cpu"
|
||||
resourceId={metricsKey}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||
<MetricBar
|
||||
value={summary.cpuPercent}
|
||||
label={formatPercent(summary.cpuPercent)}
|
||||
type="cpu"
|
||||
resourceId={metricsKey}
|
||||
/>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-2 py-1 align-middle">
|
||||
<div class="flex justify-center items-center h-full w-full min-w-[180px] max-w-[220px] whitespace-nowrap">
|
||||
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||
<MetricBar
|
||||
value={summary.memoryPercent}
|
||||
label={formatPercent(summary.memoryPercent)}
|
||||
sublabel={summary.memoryLabel}
|
||||
type="memory"
|
||||
resourceId={metricsKey}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||
<MetricBar
|
||||
value={summary.memoryPercent}
|
||||
label={formatPercent(summary.memoryPercent)}
|
||||
sublabel={summary.memoryLabel}
|
||||
type="memory"
|
||||
resourceId={metricsKey}
|
||||
/>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-2 py-1 align-middle">
|
||||
<div class="flex justify-center items-center h-full min-w-[180px] max-w-[220px] whitespace-nowrap">
|
||||
<Show when={summary.diskLabel} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||
<MetricBar
|
||||
value={summary.diskPercent}
|
||||
label={formatPercent(summary.diskPercent)}
|
||||
sublabel={summary.diskLabel}
|
||||
type="disk"
|
||||
resourceId={metricsKey}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={summary.diskLabel} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||
<MetricBar
|
||||
value={summary.diskPercent}
|
||||
label={formatPercent(summary.diskPercent)}
|
||||
sublabel={summary.diskLabel}
|
||||
type="disk"
|
||||
resourceId={metricsKey}
|
||||
/>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-2 py-1 align-middle">
|
||||
<div class="flex justify-center items-center h-full whitespace-nowrap">
|
||||
|
||||
@@ -358,7 +358,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
||||
<th
|
||||
class="pl-3 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-1/4 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||
class="pl-3 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[18%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
|
||||
onClick={() => handleSort('name')}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
||||
tabindex="0"
|
||||
@@ -371,25 +371,25 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||
{sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
onClick={() => handleSort('uptime')}
|
||||
>
|
||||
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
onClick={() => handleSort('cpu')}
|
||||
>
|
||||
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
onClick={() => handleSort('memory')}
|
||||
>
|
||||
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
onClick={() => handleSort('disk')}
|
||||
>
|
||||
{props.currentTab === 'backups' && props.pbsInstances ? 'Storage / Disk' : 'Disk'}{' '}
|
||||
@@ -397,7 +397,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||
</th>
|
||||
<Show when={hasAnyTemperatureData()}>
|
||||
<th
|
||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-20 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
onClick={() => handleSort('temperature')}
|
||||
>
|
||||
Temp{' '}
|
||||
@@ -407,7 +407,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||
<For each={countColumns()}>
|
||||
{(column) => (
|
||||
<th
|
||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-16 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||
onClick={() => handleSort(column.key)}
|
||||
>
|
||||
{column.header}{' '}
|
||||
|
||||
@@ -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<SparklineProps> = (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<SparklineProps> = (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<SparklineProps> = (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<SparklineProps> = (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<SparklineProps> = (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<SparklineProps> = (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<SparklineProps> = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative block w-full">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
class="block cursor-crosshair"
|
||||
style={{
|
||||
width: `${width()}px`,
|
||||
height: `${height()}px`,
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
<Show when={hoveredPoint()}>
|
||||
{(point) => (
|
||||
<div
|
||||
class="absolute z-50 pointer-events-none bg-gray-900 dark:bg-gray-800 text-white text-xs rounded px-2 py-1 shadow-lg border border-gray-700"
|
||||
style={{
|
||||
left: `${point().x}px`,
|
||||
top: '-32px',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<div class="font-medium">{point().value.toFixed(1)}%</div>
|
||||
<div class="text-gray-400 text-[10px]">{formatTime(point().timestamp)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<>
|
||||
<div class="relative block w-full">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
class="block cursor-crosshair transition-opacity duration-150"
|
||||
style={{
|
||||
width: `${width()}px`,
|
||||
height: `${height()}px`,
|
||||
opacity: hoveredPoint() ? '1' : '0.7',
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
<Portal>
|
||||
<Show when={hoveredPoint()}>
|
||||
{(point) => (
|
||||
<div
|
||||
class="fixed pointer-events-none bg-gray-900 dark:bg-gray-800 text-white text-xs rounded px-2 py-1 shadow-lg border border-gray-700"
|
||||
style={{
|
||||
left: `${point().x}px`,
|
||||
top: `${point().y}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
'z-index': '9999',
|
||||
}}
|
||||
>
|
||||
<div class="font-medium">{point().value.toFixed(1)}%</div>
|
||||
<div class="text-gray-400 text-[10px]">{formatTime(point().timestamp)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user