Enhance UI responsiveness for tables: apply percentage-based widths and selective column hiding
This commit is contained in:
parent
809676cb4f
commit
ac747ddf11
7 changed files with 738 additions and 756 deletions
|
|
@ -1353,8 +1353,8 @@ const UnifiedBackups: Component = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setChartTimeRange(7)}
|
onClick={() => setChartTimeRange(7)}
|
||||||
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 7
|
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 7
|
||||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
|
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
|
||||||
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
7d
|
7d
|
||||||
|
|
@ -1363,8 +1363,8 @@ const UnifiedBackups: Component = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setChartTimeRange(30)}
|
onClick={() => setChartTimeRange(30)}
|
||||||
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 30
|
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 30
|
||||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
|
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
|
||||||
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
30d
|
30d
|
||||||
|
|
@ -1373,8 +1373,8 @@ const UnifiedBackups: Component = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setChartTimeRange(90)}
|
onClick={() => setChartTimeRange(90)}
|
||||||
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 90
|
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 90
|
||||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
|
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
|
||||||
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
90d
|
90d
|
||||||
|
|
@ -1383,8 +1383,8 @@ const UnifiedBackups: Component = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setChartTimeRange(365)}
|
onClick={() => setChartTimeRange(365)}
|
||||||
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 365
|
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 365
|
||||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
|
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
|
||||||
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
1y
|
1y
|
||||||
|
|
@ -1871,7 +1871,6 @@ const UnifiedBackups: Component = () => {
|
||||||
.backup-table {
|
.backup-table {
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 1200px;
|
|
||||||
}
|
}
|
||||||
.backup-table th,
|
.backup-table th,
|
||||||
.backup-table td {
|
.backup-table td {
|
||||||
|
|
@ -1954,8 +1953,8 @@ const UnifiedBackups: Component = () => {
|
||||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 ${item.type === 'VM'
|
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 ${item.type === 'VM'
|
||||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||||
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.type}
|
{item.type}
|
||||||
|
|
@ -1968,10 +1967,10 @@ const UnifiedBackups: Component = () => {
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${item.backupType === 'snapshot'
|
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${item.backupType === 'snapshot'
|
||||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||||
: item.backupType === 'local'
|
: item.backupType === 'local'
|
||||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||||
: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
|
: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.backupType === 'snapshot'
|
{item.backupType === 'snapshot'
|
||||||
|
|
@ -2036,76 +2035,67 @@ const UnifiedBackups: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Table View */}
|
{/* Desktop Table View */}
|
||||||
<table class="backup-table hidden lg:table">
|
<table class="backup-table hidden md:table">
|
||||||
<thead>
|
<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">
|
<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
|
<th
|
||||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider 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 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 w-[8%] min-w-[60px]"
|
||||||
onClick={() => handleSort('vmid')}
|
onClick={() => handleSort('vmid')}
|
||||||
style="width: 60px;"
|
|
||||||
>
|
>
|
||||||
{hasHostBackups() ? 'VMID/Host' : 'VMID'}{' '}
|
{hasHostBackups() ? 'VMID/Host' : 'VMID'}{' '}
|
||||||
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider 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 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 w-[8%] min-w-[60px]"
|
||||||
onClick={() => handleSort('type')}
|
onClick={() => handleSort('type')}
|
||||||
style="width: 60px;"
|
|
||||||
>
|
>
|
||||||
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider 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 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 w-auto"
|
||||||
onClick={() => handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
style="width: 150px;"
|
|
||||||
>
|
>
|
||||||
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
class="hidden xl:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 w-[8%]"
|
||||||
onClick={() => handleSort('node')}
|
onClick={() => handleSort('node')}
|
||||||
style="width: 100px;"
|
|
||||||
>
|
>
|
||||||
Node {sortKey() === 'node' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
Node {sortKey() === 'node' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<Show when={backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'}>
|
<Show when={backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'}>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
class="hidden 2xl:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 w-[8%]"
|
||||||
onClick={() => handleSort('owner')}
|
onClick={() => handleSort('owner')}
|
||||||
style="width: 80px;"
|
|
||||||
>
|
>
|
||||||
Owner {sortKey() === 'owner' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
Owner {sortKey() === 'owner' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
</Show>
|
</Show>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider 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 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 w-[14%] min-w-[120px]"
|
||||||
onClick={() => handleSort('backupTime')}
|
onClick={() => handleSort('backupTime')}
|
||||||
style="width: 140px;"
|
|
||||||
>
|
>
|
||||||
Time {sortKey() === 'backupTime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
Time {sortKey() === 'backupTime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<Show when={backupTypeFilter() !== 'snapshot'}>
|
<Show when={backupTypeFilter() !== 'snapshot'}>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
class="hidden lg:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 w-[8%]"
|
||||||
onClick={() => handleSort('size')}
|
onClick={() => handleSort('size')}
|
||||||
style="width: 80px;"
|
|
||||||
>
|
>
|
||||||
Size {sortKey() === 'size' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
Size {sortKey() === 'size' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
</Show>
|
</Show>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
class="hidden lg:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 w-[8%]"
|
||||||
onClick={() => handleSort('backupType')}
|
onClick={() => handleSort('backupType')}
|
||||||
style="width: 80px;"
|
|
||||||
>
|
>
|
||||||
Backup{' '}
|
Backup{' '}
|
||||||
{sortKey() === 'backupType' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'backupType' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<Show when={backupTypeFilter() !== 'snapshot'}>
|
<Show when={backupTypeFilter() !== 'snapshot'}>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
class="hidden xl:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 w-[12%]"
|
||||||
onClick={() => handleSort('storage')}
|
onClick={() => handleSort('storage')}
|
||||||
style="width: 150px;"
|
|
||||||
>
|
>
|
||||||
Location{' '}
|
Location{' '}
|
||||||
{sortKey() === 'storage' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'storage' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
|
@ -2113,17 +2103,15 @@ const UnifiedBackups: Component = () => {
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'}>
|
<Show when={backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'}>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
class="hidden lg:table-cell px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 w-[6%]"
|
||||||
onClick={() => handleSort('verified')}
|
onClick={() => handleSort('verified')}
|
||||||
style="width: 60px;"
|
|
||||||
>
|
>
|
||||||
Verified{' '}
|
Verified{' '}
|
||||||
{sortKey() === 'verified' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'verified' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
</Show>
|
</Show>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider"
|
class="hidden md:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]"
|
||||||
style="width: 200px;"
|
|
||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</th>
|
</th>
|
||||||
|
|
@ -2136,14 +2124,17 @@ const UnifiedBackups: Component = () => {
|
||||||
<tr class="bg-gray-50 dark:bg-gray-900/40">
|
<tr class="bg-gray-50 dark:bg-gray-900/40">
|
||||||
<td
|
<td
|
||||||
colspan={(() => {
|
colspan={(() => {
|
||||||
let cols = 7; // Base columns: VMID, Type, Name, Node, Time, Backup, Details
|
let cols = 4; // Base: VMID, Type, Name, Time
|
||||||
if (backupTypeFilter() === 'all' || backupTypeFilter() === 'remote')
|
if (backupTypeFilter() === 'all' || backupTypeFilter() === 'remote') {
|
||||||
cols++; // Add Owner column
|
// Owner - hidden on 2xl
|
||||||
if (backupTypeFilter() !== 'snapshot') cols++; // Add Size column
|
// We can't easily adjust colspan based on media query in JS without a hook.
|
||||||
if (backupTypeFilter() === 'all' || backupTypeFilter() === 'remote')
|
// For now, let's just set a high colspan or use a simpler approach.
|
||||||
cols++; // Add Verified column
|
// A safer bet for responsive tables with colspans is to just set it to a large number like 100
|
||||||
if (backupTypeFilter() !== 'snapshot') cols++; // Add Location column
|
// provided the table layout handles it, or try to be accurate.
|
||||||
return cols;
|
// Given the complexity of responsive hiding, colspan=100 is often the easiest hack for full-width rows.
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
return 20;
|
||||||
})()}
|
})()}
|
||||||
class="py-1 pr-2 pl-3 text-[12px] sm:text-sm font-semibold text-slate-700 dark:text-slate-100"
|
class="py-1 pr-2 pl-3 text-[12px] sm:text-sm font-semibold text-slate-700 dark:text-slate-100"
|
||||||
>
|
>
|
||||||
|
|
@ -2164,10 +2155,10 @@ const UnifiedBackups: Component = () => {
|
||||||
<td class="p-0.5 px-1.5 align-middle">
|
<td class="p-0.5 px-1.5 align-middle">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${item.type === 'VM'
|
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${item.type === 'VM'
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
||||||
: item.type === 'Host'
|
: item.type === 'Host'
|
||||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300'
|
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300'
|
||||||
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.type}
|
{item.type}
|
||||||
|
|
@ -2176,13 +2167,13 @@ const UnifiedBackups: Component = () => {
|
||||||
<td class="p-0.5 px-1.5 text-sm align-middle">
|
<td class="p-0.5 px-1.5 text-sm align-middle">
|
||||||
{item.name || '-'}
|
{item.name || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-0.5 px-1.5 text-sm align-middle">{item.node}</td>
|
<td class="hidden xl:table-cell p-0.5 px-1.5 text-sm align-middle">{item.node}</td>
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'
|
backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<td class="p-0.5 px-1.5 text-xs align-middle text-gray-500 dark:text-gray-400">
|
<td class="hidden 2xl:table-cell p-0.5 px-1.5 text-xs align-middle text-gray-500 dark:text-gray-400">
|
||||||
{item.owner ? item.owner.split('@')[0] : '-'}
|
{item.owner ? item.owner.split('@')[0] : '-'}
|
||||||
</td>
|
</td>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -2193,19 +2184,19 @@ const UnifiedBackups: Component = () => {
|
||||||
</td>
|
</td>
|
||||||
<Show when={backupTypeFilter() !== 'snapshot'}>
|
<Show when={backupTypeFilter() !== 'snapshot'}>
|
||||||
<td
|
<td
|
||||||
class={`p-0.5 px-1.5 align-middle ${getSizeColor(item.size)}`}
|
class={`hidden lg:table-cell p-0.5 px-1.5 align-middle ${getSizeColor(item.size)}`}
|
||||||
>
|
>
|
||||||
{item.size ? formatBytes(item.size) : '-'}
|
{item.size ? formatBytes(item.size) : '-'}
|
||||||
</td>
|
</td>
|
||||||
</Show>
|
</Show>
|
||||||
<td class="p-0.5 px-1.5 align-middle">
|
<td class="hidden lg:table-cell p-0.5 px-1.5 align-middle">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${item.backupType === 'snapshot'
|
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${item.backupType === 'snapshot'
|
||||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300'
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300'
|
||||||
: item.backupType === 'local'
|
: item.backupType === 'local'
|
||||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300'
|
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300'
|
||||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
|
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.backupType === 'snapshot'
|
{item.backupType === 'snapshot'
|
||||||
|
|
@ -2255,7 +2246,7 @@ const UnifiedBackups: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<Show when={backupTypeFilter() !== 'snapshot'}>
|
<Show when={backupTypeFilter() !== 'snapshot'}>
|
||||||
<td class="p-0.5 px-1.5 text-sm align-middle">
|
<td class="hidden xl:table-cell p-0.5 px-1.5 text-sm align-middle">
|
||||||
{item.storage ||
|
{item.storage ||
|
||||||
(item.datastore &&
|
(item.datastore &&
|
||||||
(item.namespace && item.namespace !== 'root'
|
(item.namespace && item.namespace !== 'root'
|
||||||
|
|
@ -2269,7 +2260,7 @@ const UnifiedBackups: Component = () => {
|
||||||
backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'
|
backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<td class="p-0.5 px-1.5 text-center align-middle">
|
<td class="hidden lg:table-cell p-0.5 px-1.5 text-center align-middle">
|
||||||
{item.backupType === 'remote' ? (
|
{item.backupType === 'remote' ? (
|
||||||
item.verified ? (
|
item.verified ? (
|
||||||
<span title="PBS backup verified">
|
<span title="PBS backup verified">
|
||||||
|
|
@ -2315,7 +2306,7 @@ const UnifiedBackups: Component = () => {
|
||||||
</td>
|
</td>
|
||||||
</Show>
|
</Show>
|
||||||
<td
|
<td
|
||||||
class="p-0.5 px-1.5 cursor-help align-middle"
|
class="hidden md:table-cell p-0.5 px-1.5 cursor-help align-middle"
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
const details = [];
|
const details = [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -927,12 +927,12 @@ export function Dashboard(props: DashboardProps) {
|
||||||
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
|
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
|
||||||
<ComponentErrorBoundary name="Guest Table">
|
<ComponentErrorBoundary name="Guest Table">
|
||||||
<Card padding="none" class="mb-4 overflow-hidden">
|
<Card padding="none" class="mb-4 overflow-hidden">
|
||||||
<ScrollableTable minWidth="750px">
|
<ScrollableTable minWidth="600px">
|
||||||
<table class="w-full min-w-[750px] md:min-w-[840px] lg:min-w-[970px] table-fixed border-collapse">
|
<table class="w-full min-w-[600px] table-fixed border-collapse">
|
||||||
<thead>
|
<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">
|
<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
|
<th
|
||||||
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider 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 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 w-auto"
|
||||||
onClick={() => handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
|
@ -942,7 +942,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
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"
|
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[5%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||||
onClick={() => handleSort('type')}
|
onClick={() => handleSort('type')}
|
||||||
title="Type"
|
title="Type"
|
||||||
>
|
>
|
||||||
|
|
@ -951,7 +951,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
{sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
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"
|
class="hidden lg:table-cell px-1.5 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wide w-[5%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||||
onClick={() => handleSort('vmid')}
|
onClick={() => handleSort('vmid')}
|
||||||
title="VM/Container ID"
|
title="VM/Container ID"
|
||||||
>
|
>
|
||||||
|
|
@ -960,7 +960,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
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"
|
class="hidden sm:table-cell px-1.5 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wide w-[8%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||||
onClick={() => handleSort('uptime')}
|
onClick={() => handleSort('uptime')}
|
||||||
title="Uptime"
|
title="Uptime"
|
||||||
>
|
>
|
||||||
|
|
@ -969,13 +969,13 @@ export function Dashboard(props: DashboardProps) {
|
||||||
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
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"
|
class="px-2 py-1 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"
|
||||||
onClick={() => handleSort('cpu')}
|
onClick={() => handleSort('cpu')}
|
||||||
>
|
>
|
||||||
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
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"
|
class="px-2 py-1 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"
|
||||||
onClick={() => handleSort('memory')}
|
onClick={() => handleSort('memory')}
|
||||||
title="Memory"
|
title="Memory"
|
||||||
>
|
>
|
||||||
|
|
@ -984,13 +984,13 @@ export function Dashboard(props: DashboardProps) {
|
||||||
{sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
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"
|
class="hidden sm:table-cell px-2 py-1 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"
|
||||||
onClick={() => handleSort('disk')}
|
onClick={() => handleSort('disk')}
|
||||||
>
|
>
|
||||||
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<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"
|
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[6%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||||
onClick={() => handleSort('diskRead')}
|
onClick={() => handleSort('diskRead')}
|
||||||
title="Disk Read"
|
title="Disk Read"
|
||||||
>
|
>
|
||||||
|
|
@ -999,7 +999,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
{sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<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"
|
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[6%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||||
onClick={() => handleSort('diskWrite')}
|
onClick={() => handleSort('diskWrite')}
|
||||||
title="Disk Write"
|
title="Disk Write"
|
||||||
>
|
>
|
||||||
|
|
@ -1008,7 +1008,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
{sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<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"
|
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[6%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||||
onClick={() => handleSort('networkIn')}
|
onClick={() => handleSort('networkIn')}
|
||||||
title="Network In"
|
title="Network In"
|
||||||
>
|
>
|
||||||
|
|
@ -1017,7 +1017,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
{sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<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"
|
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[6%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||||
onClick={() => handleSort('networkOut')}
|
onClick={() => handleSort('networkOut')}
|
||||||
title="Network Out"
|
title="Network Out"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -406,7 +406,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
// Get first cell styling
|
// Get first cell styling
|
||||||
const firstCellClass = createMemo(() => {
|
const firstCellClass = createMemo(() => {
|
||||||
const base =
|
const base =
|
||||||
'py-0.5 pr-2 whitespace-nowrap relative w-[140px] sm:w-[160px] lg:w-[180px] xl:w-[240px] 2xl:w-[380px]';
|
'py-0.5 pr-2 whitespace-nowrap relative w-auto';
|
||||||
const indent = props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT;
|
const indent = props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT;
|
||||||
return `${base} ${indent}`;
|
return `${base} ${indent}`;
|
||||||
});
|
});
|
||||||
|
|
@ -424,258 +424,254 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr class={rowClass()} style={rowStyle()} onClick={toggleDrawer} aria-expanded={drawerOpen()}>
|
<tr class={rowClass()} style={rowStyle()} onClick={toggleDrawer} aria-expanded={drawerOpen()}>
|
||||||
{/* Name - Sticky column */}
|
{/* Name - Sticky column */}
|
||||||
<td class={firstCellClass()}>
|
<td class={firstCellClass()}>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-1.5 flex-1 min-w-0">
|
<div class="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
<StatusDot
|
<StatusDot
|
||||||
variant={guestStatus().variant}
|
variant={guestStatus().variant}
|
||||||
title={guestStatus().label}
|
title={guestStatus().label}
|
||||||
ariaLabel={guestStatus().label}
|
ariaLabel={guestStatus().label}
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
{/* Name - show input when editing, otherwise show name with optional link */}
|
|
||||||
<Show
|
|
||||||
when={isEditingUrl()}
|
|
||||||
fallback={
|
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
|
||||||
<span
|
|
||||||
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}
|
|
||||||
data-guest-name-editable
|
|
||||||
>
|
|
||||||
{props.guest.name}
|
|
||||||
</span>
|
|
||||||
<Show when={customUrl()}>
|
|
||||||
<a
|
|
||||||
href={customUrl()}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
|
|
||||||
title="Open in new tab"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-3.5 h-3.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
|
|
||||||
<input
|
|
||||||
ref={urlInputRef}
|
|
||||||
type="text"
|
|
||||||
value={editingUrlValue()}
|
|
||||||
data-guest-id={guestId()}
|
|
||||||
onInput={(e) => {
|
|
||||||
editingValues.set(guestId(), e.currentTarget.value);
|
|
||||||
setEditingValuesVersion(v => v + 1);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
saveUrl();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
cancelEditingUrl();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
placeholder="https://192.168.1.100:8006"
|
|
||||||
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-url-editor-button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
saveUrl();
|
|
||||||
}}
|
|
||||||
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
|
||||||
title="Save (or press Enter)"
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-url-editor-button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteUrl();
|
|
||||||
}}
|
|
||||||
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
|
||||||
title="Delete URL"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tag badges - hide when editing URL to save space */}
|
|
||||||
<Show when={!isEditingUrl()}>
|
|
||||||
<div class="flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
|
|
||||||
<TagBadges
|
|
||||||
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
|
||||||
maxVisible={3}
|
|
||||||
onTagClick={props.onTagClick}
|
|
||||||
activeSearch={props.activeSearch}
|
|
||||||
/>
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
{/* Name - show input when editing, otherwise show name with optional link */}
|
||||||
|
<Show
|
||||||
|
when={isEditingUrl()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span
|
||||||
|
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}
|
||||||
|
data-guest-name-editable
|
||||||
|
>
|
||||||
|
{props.guest.name}
|
||||||
|
</span>
|
||||||
|
<Show when={customUrl()}>
|
||||||
|
<a
|
||||||
|
href={customUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
|
||||||
|
title="Open in new tab"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
|
||||||
|
<input
|
||||||
|
ref={urlInputRef}
|
||||||
|
type="text"
|
||||||
|
value={editingUrlValue()}
|
||||||
|
data-guest-id={guestId()}
|
||||||
|
onInput={(e) => {
|
||||||
|
editingValues.set(guestId(), e.currentTarget.value);
|
||||||
|
setEditingValuesVersion(v => v + 1);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveUrl();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEditingUrl();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
placeholder="https://192.168.1.100:8006"
|
||||||
|
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-url-editor-button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
saveUrl();
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||||
|
title="Save (or press Enter)"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-url-editor-button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteUrl();
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
|
title="Delete URL"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={lockLabel()}>
|
{/* Tag badges - hide when editing URL to save space */}
|
||||||
|
<Show when={!isEditingUrl()}>
|
||||||
|
<div class="flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
|
||||||
|
<TagBadges
|
||||||
|
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
||||||
|
maxVisible={3}
|
||||||
|
onTagClick={props.onTagClick}
|
||||||
|
activeSearch={props.activeSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={lockLabel()}>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
||||||
|
title={`Guest is locked (${lockLabel()})`}
|
||||||
|
>
|
||||||
|
Lock: {lockLabel()}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<td class="hidden lg:table-cell py-0.5 px-2 whitespace-nowrap w-[5%]">
|
||||||
|
<div class="flex h-[24px] items-center">
|
||||||
<span
|
<span
|
||||||
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${props.guest.type === 'qemu'
|
||||||
title={`Guest is locked (${lockLabel()})`}
|
|
||||||
>
|
|
||||||
Lock: {lockLabel()}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Type */}
|
|
||||||
<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 ${
|
|
||||||
props.guest.type === 'qemu'
|
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
||||||
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isVM(props.guest) ? 'VM' : 'LXC'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* VMID */}
|
||||||
|
<td class="hidden lg:table-cell py-0.5 px-1.5 whitespace-nowrap w-[5%] text-sm text-gray-600 dark:text-gray-400 align-middle">
|
||||||
|
{props.guest.vmid}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Uptime */}
|
||||||
|
<td
|
||||||
|
class={`hidden sm:table-cell py-0.5 px-1.5 w-[8%] text-sm whitespace-nowrap align-middle ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isVM(props.guest) ? 'VM' : 'LXC'}
|
<Show when={isRunning()} fallback="-">
|
||||||
</span>
|
{formatUptime(props.guest.uptime)}
|
||||||
</div>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* VMID */}
|
{/* CPU */}
|
||||||
<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">
|
<td class="py-0.5 px-2 w-[10%]">
|
||||||
{props.guest.vmid}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Uptime */}
|
|
||||||
<td
|
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Show when={isRunning()} fallback="-">
|
|
||||||
{formatUptime(props.guest.uptime)}
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* CPU */}
|
|
||||||
<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()}
|
|
||||||
label={formatPercent(cpuPercent())}
|
|
||||||
sublabel={
|
|
||||||
props.guest.cpus
|
|
||||||
? `${props.guest.cpus} ${props.guest.cpus === 1 ? 'core' : 'cores'}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
type="cpu"
|
|
||||||
resourceId={metricsKey()}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Memory */}
|
|
||||||
<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>}>
|
<Show when={isRunning()} fallback={<span class="text-sm text-gray-400">-</span>}>
|
||||||
<MetricBar
|
<MetricBar
|
||||||
value={memPercent()}
|
value={cpuPercent()}
|
||||||
label={formatPercent(memPercent())}
|
label={formatPercent(cpuPercent())}
|
||||||
sublabel={memoryUsageLabel()}
|
sublabel={
|
||||||
type="memory"
|
props.guest.cpus
|
||||||
|
? `${props.guest.cpus} ${props.guest.cpus === 1 ? 'core' : 'cores'}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
type="cpu"
|
||||||
resourceId={metricsKey()}
|
resourceId={metricsKey()}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Disk – surface usage even if guest is currently stopped so users can see last reported values */}
|
{/* Memory */}
|
||||||
<td class="py-0.5 px-2 w-[80px] sm:w-[90px] lg:w-[110px] xl:w-[160px] 2xl:w-[204px]">
|
<td class="py-0.5 px-2 w-[10%]">
|
||||||
<Show
|
<div title={memoryTooltip() ?? undefined}>
|
||||||
when={hasDiskUsage()}
|
<Show when={isRunning()} fallback={<span class="text-sm text-gray-400">-</span>}>
|
||||||
fallback={
|
<MetricBar
|
||||||
<span class="text-gray-400 text-sm cursor-help" title={getDiskStatusTooltip()}>
|
value={memPercent()}
|
||||||
-
|
label={formatPercent(memPercent())}
|
||||||
</span>
|
sublabel={memoryUsageLabel()}
|
||||||
}
|
type="memory"
|
||||||
>
|
resourceId={metricsKey()}
|
||||||
<MetricBar
|
/>
|
||||||
value={diskPercent()}
|
</Show>
|
||||||
label={formatPercent(diskPercent())}
|
</div>
|
||||||
sublabel={
|
</td>
|
||||||
props.guest.disk
|
|
||||||
? `${formatBytes(props.guest.disk.used, 0)}/${formatBytes(props.guest.disk.total, 0)}`
|
{/* Disk – surface usage even if guest is currently stopped so users can see last reported values */}
|
||||||
: undefined
|
<td class="hidden sm:table-cell py-0.5 px-2 w-[10%]">
|
||||||
|
<Show
|
||||||
|
when={hasDiskUsage()}
|
||||||
|
fallback={
|
||||||
|
<span class="text-gray-400 text-sm cursor-help" title={getDiskStatusTooltip()}>
|
||||||
|
-
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
type="disk"
|
>
|
||||||
resourceId={metricsKey()}
|
<MetricBar
|
||||||
/>
|
value={diskPercent()}
|
||||||
</Show>
|
label={formatPercent(diskPercent())}
|
||||||
</td>
|
sublabel={
|
||||||
|
props.guest.disk
|
||||||
|
? `${formatBytes(props.guest.disk.used, 0)}/${formatBytes(props.guest.disk.total, 0)}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
type="disk"
|
||||||
|
resourceId={metricsKey()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
|
||||||
{/* Disk I/O */}
|
{/* Disk I/O */}
|
||||||
<td class="py-0.5 px-2 w-[56px] sm:w-[62px] lg:w-[70px] xl:w-[78px] 2xl:w-[106px]">
|
<td class="hidden lg:table-cell py-0.5 px-2 w-[6%]">
|
||||||
<div class="flex h-[24px] items-center">
|
<div class="flex h-[24px] items-center">
|
||||||
<IOMetric value={props.guest.diskRead} disabled={!isRunning()} />
|
<IOMetric value={props.guest.diskRead} disabled={!isRunning()} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-0.5 px-2 w-[56px] sm:w-[62px] lg:w-[70px] xl:w-[78px] 2xl:w-[106px]">
|
<td class="hidden lg:table-cell py-0.5 px-2 w-[6%]">
|
||||||
<div class="flex h-[24px] items-center">
|
<div class="flex h-[24px] items-center">
|
||||||
<IOMetric value={props.guest.diskWrite} disabled={!isRunning()} />
|
<IOMetric value={props.guest.diskWrite} disabled={!isRunning()} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Network I/O */}
|
{/* Network I/O */}
|
||||||
<td class="py-0.5 px-2 w-[56px] sm:w-[62px] lg:w-[70px] xl:w-[78px] 2xl:w-[106px]">
|
<td class="hidden lg:table-cell py-0.5 px-2 w-[6%]">
|
||||||
<div class="flex h-[24px] items-center">
|
<div class="flex h-[24px] items-center">
|
||||||
<IOMetric value={props.guest.networkIn} disabled={!isRunning()} />
|
<IOMetric value={props.guest.networkIn} disabled={!isRunning()} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-0.5 px-2 w-[56px] sm:w-[62px] lg:w-[70px] xl:w-[78px] 2xl:w-[106px]">
|
<td class="hidden lg:table-cell py-0.5 px-2 w-[6%]">
|
||||||
<div class="flex h-[24px] items-center">
|
<div class="flex h-[24px] items-center">
|
||||||
<IOMetric value={props.guest.networkOut} disabled={!isRunning()} />
|
<IOMetric value={props.guest.networkOut} disabled={!isRunning()} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<Show when={drawerOpen() && canShowDrawer()}>
|
<Show when={drawerOpen() && canShowDrawer()}>
|
||||||
<tr
|
<tr
|
||||||
class={`text-[11px] ${
|
class={`text-[11px] ${isRunning() && props.parentNodeOnline !== false
|
||||||
isRunning() && props.parentNodeOnline !== false
|
? 'bg-gray-50/60 text-gray-600 dark:bg-gray-800/40 dark:text-gray-300'
|
||||||
? 'bg-gray-50/60 text-gray-600 dark:bg-gray-800/40 dark:text-gray-300'
|
: 'bg-gray-100/70 text-gray-400 dark:bg-gray-900/30 dark:text-gray-500'
|
||||||
: 'bg-gray-100/70 text-gray-400 dark:bg-gray-900/30 dark:text-gray-500'
|
}`}
|
||||||
}`}
|
|
||||||
aria-hidden={!isRunning() || props.parentNodeOnline === false}
|
aria-hidden={!isRunning() || props.parentNodeOnline === false}
|
||||||
>
|
>
|
||||||
<td class="px-4 py-2" colSpan={11}>
|
<td class="px-4 py-2" colSpan={11}>
|
||||||
<div
|
<div
|
||||||
class={`flex flex-wrap gap-3 justify-start ${
|
class={`flex flex-wrap gap-3 justify-start ${drawerDisabled() ? 'opacity-50 saturate-75 pointer-events-none' : ''
|
||||||
drawerDisabled() ? 'opacity-50 saturate-75 pointer-events-none' : ''
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={hasDrawerContent()}
|
when={hasDrawerContent()}
|
||||||
|
|
|
||||||
|
|
@ -1182,7 +1182,7 @@ const DockerContainerRow: Component<{
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5">
|
<td class="hidden sm:table-cell px-2 py-0.5">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`}
|
class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`}
|
||||||
title={
|
title={
|
||||||
|
|
@ -1194,7 +1194,7 @@ const DockerContainerRow: Component<{
|
||||||
{runtimeInfo.label}
|
{runtimeInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
|
<td class="hidden lg:table-cell px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
|
||||||
<span title={container.image}>
|
<span title={container.image}>
|
||||||
{container.image || '—'}
|
{container.image || '—'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1204,7 +1204,7 @@ const DockerContainerRow: Component<{
|
||||||
{statusLabel()}
|
{statusLabel()}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5 min-w-[180px]">
|
<td class="hidden md:table-cell px-2 py-0.5">
|
||||||
<Show
|
<Show
|
||||||
when={isRunning() && container.cpuPercent && container.cpuPercent > 0}
|
when={isRunning() && container.cpuPercent && container.cpuPercent > 0}
|
||||||
fallback={<span class="text-xs text-gray-400">—</span>}
|
fallback={<span class="text-xs text-gray-400">—</span>}
|
||||||
|
|
@ -1217,7 +1217,7 @@ const DockerContainerRow: Component<{
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5 min-w-[240px]">
|
<td class="hidden md:table-cell px-2 py-0.5">
|
||||||
<Show
|
<Show
|
||||||
when={isRunning() && container.memoryUsageBytes && container.memoryUsageBytes > 0}
|
when={isRunning() && container.memoryUsageBytes && container.memoryUsageBytes > 0}
|
||||||
fallback={<span class="text-xs text-gray-400">—</span>}
|
fallback={<span class="text-xs text-gray-400">—</span>}
|
||||||
|
|
@ -1231,7 +1231,7 @@ const DockerContainerRow: Component<{
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5 min-w-[220px]">
|
<td class="hidden xl:table-cell px-2 py-0.5">
|
||||||
<Show when={hasDiskStats()} fallback={<span class="text-xs text-gray-400">—</span>}>
|
<Show when={hasDiskStats()} fallback={<span class="text-xs text-gray-400">—</span>}>
|
||||||
<Show
|
<Show
|
||||||
when={diskPercent() !== null}
|
when={diskPercent() !== null}
|
||||||
|
|
@ -1247,13 +1247,13 @@ const DockerContainerRow: Component<{
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
|
<td class="hidden lg:table-cell px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
|
||||||
<Show when={isRunning()} fallback={<span class="text-gray-400">—</span>}>
|
<Show when={isRunning()} fallback={<span class="text-gray-400">—</span>}>
|
||||||
{restarts()}
|
{restarts()}
|
||||||
<span class="text-[10px] text-gray-500 dark:text-gray-400 ml-1">restarts</span>
|
<span class="text-[10px] text-gray-500 dark:text-gray-400 ml-1">restarts</span>
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
|
<td class="hidden sm:table-cell px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
|
||||||
<Show when={isRunning()} fallback={<span class="text-gray-400">—</span>}>
|
<Show when={isRunning()} fallback={<span class="text-gray-400">—</span>}>
|
||||||
{uptime()}
|
{uptime()}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -1539,8 +1539,8 @@ const DockerContainerRow: Component<{
|
||||||
<div class="mt-1 flex flex-wrap gap-1 text-[10px] text-gray-500 dark:text-gray-400">
|
<div class="mt-1 flex flex-wrap gap-1 text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
<span
|
<span
|
||||||
class={`rounded px-1.5 py-0.5 ${mount.rw === false
|
class={`rounded px-1.5 py-0.5 ${mount.rw === false
|
||||||
? 'bg-gray-200 text-gray-700 dark:bg-gray-700/60 dark:text-gray-200'
|
? 'bg-gray-200 text-gray-700 dark:bg-gray-700/60 dark:text-gray-200'
|
||||||
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{rw}
|
{rw}
|
||||||
|
|
@ -1946,12 +1946,12 @@ const DockerServiceRow: Component<{
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5">
|
<td class="hidden sm:table-cell px-2 py-0.5">
|
||||||
<span class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${typeBadgeClass('service')}`}>
|
<span class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${typeBadgeClass('service')}`}>
|
||||||
Service
|
Service
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
|
<td class="hidden lg:table-cell px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
|
||||||
<span title={service.image}>
|
<span title={service.image}>
|
||||||
{service.image || '—'}
|
{service.image || '—'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1961,16 +1961,16 @@ const DockerServiceRow: Component<{
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500 min-w-[150px]">—</td>
|
<td class="hidden md:table-cell px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500">—</td>
|
||||||
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500 min-w-[210px]">—</td>
|
<td class="hidden md:table-cell px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500">—</td>
|
||||||
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500 min-w-[200px]">—</td>
|
<td class="hidden xl:table-cell px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500">—</td>
|
||||||
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
<td class="hidden lg:table-cell px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0}
|
{(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0}
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-1 text-gray-500 dark:text-gray-400">tasks</span>
|
<span class="ml-1 text-gray-500 dark:text-gray-400">tasks</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
<td class="hidden sm:table-cell px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
<Show when={updatedAt} fallback="—">
|
<Show when={updatedAt} fallback="—">
|
||||||
{(timestamp) => (
|
{(timestamp) => (
|
||||||
<span title={new Date(timestamp()).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}>
|
<span title={new Date(timestamp()).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}>
|
||||||
|
|
@ -2425,12 +2425,12 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Card padding="none" class="overflow-hidden">
|
<Card padding="none" class="overflow-hidden">
|
||||||
<ScrollableTable minWidth="1080px">
|
<ScrollableTable>
|
||||||
<table class="w-full min-w-[1080px] table-fixed border-collapse whitespace-nowrap">
|
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
||||||
<thead>
|
<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">
|
<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
|
<th
|
||||||
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[22%] cursor-pointer select-none 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-auto cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||||
onClick={() => handleSort('resource')}
|
onClick={() => handleSort('resource')}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('resource')}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('resource')}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -2459,7 +2459,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
class="hidden sm:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||||
onClick={() => handleSort('type')}
|
onClick={() => handleSort('type')}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('type')}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('type')}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -2473,7 +2473,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||||
onClick={() => handleSort('image')}
|
onClick={() => handleSort('image')}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('image')}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('image')}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -2501,7 +2501,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] min-w-[150px] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
class="hidden md:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||||
onClick={() => handleSort('cpu')}
|
onClick={() => handleSort('cpu')}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('cpu')}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('cpu')}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -2515,7 +2515,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%] min-w-[210px] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
class="hidden md:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||||
onClick={() => handleSort('memory')}
|
onClick={() => handleSort('memory')}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('memory')}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('memory')}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -2529,7 +2529,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] min-w-[200px] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
class="hidden xl:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||||
onClick={() => handleSort('disk')}
|
onClick={() => handleSort('disk')}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('disk')}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('disk')}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -2543,7 +2543,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[9%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[9%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||||
onClick={() => handleSort('tasks')}
|
onClick={() => handleSort('tasks')}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('tasks')}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('tasks')}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -2557,7 +2557,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
class="hidden sm:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||||
onClick={() => handleSort('updated')}
|
onClick={() => handleSort('updated')}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('updated')}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('updated')}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
|
||||||
|
|
@ -154,31 +154,31 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead>
|
<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">
|
<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="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
|
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||||||
Node
|
Node
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
|
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||||||
Device
|
Device
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
|
<th class="hidden md:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[20%]">
|
||||||
Model
|
Model
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
|
<th class="hidden sm:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%]">
|
||||||
Type
|
Type
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
|
<th class="hidden lg:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%]">
|
||||||
FS
|
FS
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
|
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||||||
Health
|
Health
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
|
<th class="hidden md:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]">
|
||||||
SSD Life
|
SSD Life
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider hidden sm:table-cell">
|
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider hidden sm:table-cell w-[8%]">
|
||||||
Temp
|
Temp
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
|
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||||||
Size
|
Size
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 w-8"></th>
|
<th class="px-2 py-1.5 w-8"></th>
|
||||||
|
|
@ -202,19 +202,19 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
{disk.devPath}
|
{disk.devPath}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1.5 text-xs">
|
<td class="hidden md:table-cell px-2 py-1.5 text-xs">
|
||||||
<span class="text-gray-700 dark:text-gray-300">
|
<span class="text-gray-700 dark:text-gray-300">
|
||||||
{disk.model || 'Unknown'}
|
{disk.model || 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1.5 text-xs">
|
<td class="hidden sm:table-cell px-2 py-1.5 text-xs">
|
||||||
<span
|
<span
|
||||||
class={`inline-block px-1.5 py-0.5 text-[10px] font-medium rounded ${getDiskTypeBadge(disk.type)}`}
|
class={`inline-block px-1.5 py-0.5 text-[10px] font-medium rounded ${getDiskTypeBadge(disk.type)}`}
|
||||||
>
|
>
|
||||||
{disk.type.toUpperCase()}
|
{disk.type.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1.5 text-xs">
|
<td class="hidden lg:table-cell px-2 py-1.5 text-xs">
|
||||||
<Show
|
<Show
|
||||||
when={disk.used && disk.used !== 'unknown'}
|
when={disk.used && disk.used !== 'unknown'}
|
||||||
fallback={<span class="text-gray-400">-</span>}
|
fallback={<span class="text-gray-400">-</span>}
|
||||||
|
|
@ -231,22 +231,21 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
{health.text}
|
{health.text}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1.5 text-xs">
|
<td class="hidden md:table-cell px-2 py-1.5 text-xs">
|
||||||
<Show
|
<Show
|
||||||
when={disk.wearout > 0}
|
when={disk.wearout > 0}
|
||||||
fallback={<span class="text-gray-400">-</span>}
|
fallback={<span class="text-gray-400">-</span>}
|
||||||
>
|
>
|
||||||
<div class="relative w-24 h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
|
<div class="relative w-24 h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
|
||||||
<div
|
<div
|
||||||
class={`absolute top-0 left-0 h-full ${
|
class={`absolute top-0 left-0 h-full ${disk.wearout >= 50
|
||||||
disk.wearout >= 50
|
? 'bg-green-500/60 dark:bg-green-500/50'
|
||||||
? 'bg-green-500/60 dark:bg-green-500/50'
|
: disk.wearout >= 20
|
||||||
: disk.wearout >= 20
|
? 'bg-yellow-500/60 dark:bg-yellow-500/50'
|
||||||
? 'bg-yellow-500/60 dark:bg-yellow-500/50'
|
: disk.wearout >= 10
|
||||||
: disk.wearout >= 10
|
? 'bg-orange-500/60 dark:bg-orange-500/50'
|
||||||
? 'bg-orange-500/60 dark:bg-orange-500/50'
|
: 'bg-red-500/60 dark:bg-red-500/50'
|
||||||
: 'bg-red-500/60 dark:bg-red-500/50'
|
}`}
|
||||||
}`}
|
|
||||||
style={{ width: `${disk.wearout}%` }}
|
style={{ width: `${disk.wearout}%` }}
|
||||||
/>
|
/>
|
||||||
<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="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
|
||||||
|
|
@ -261,13 +260,12 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
fallback={<span class="font-medium text-gray-400">-</span>}
|
fallback={<span class="font-medium text-gray-400">-</span>}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={`font-medium ${
|
class={`font-medium ${disk.temperature > 70
|
||||||
disk.temperature > 70
|
? 'text-red-600 dark:text-red-400'
|
||||||
? 'text-red-600 dark:text-red-400'
|
: disk.temperature > 60
|
||||||
: disk.temperature > 60
|
? 'text-yellow-600 dark:text-yellow-400'
|
||||||
? 'text-yellow-600 dark:text-yellow-400'
|
: 'text-green-600 dark:text-green-400'
|
||||||
: 'text-green-600 dark:text-green-400'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{disk.temperature}°C
|
{disk.temperature}°C
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -566,8 +566,8 @@ const Storage: Component = () => {
|
||||||
onClick={() => setTabView('pools')}
|
onClick={() => setTabView('pools')}
|
||||||
type="button"
|
type="button"
|
||||||
class={`inline-flex items-center px-2 sm:px-3 py-1 text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900 ${tabView() === 'pools'
|
class={`inline-flex items-center px-2 sm:px-3 py-1 text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900 ${tabView() === 'pools'
|
||||||
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
|
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
|
||||||
: 'hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50'
|
: 'hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Storage Pools
|
Storage Pools
|
||||||
|
|
@ -576,8 +576,8 @@ const Storage: Component = () => {
|
||||||
onClick={() => setTabView('disks')}
|
onClick={() => setTabView('disks')}
|
||||||
type="button"
|
type="button"
|
||||||
class={`inline-flex items-center px-2 sm:px-3 py-1 text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900 ${tabView() === 'disks'
|
class={`inline-flex items-center px-2 sm:px-3 py-1 text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900 ${tabView() === 'disks'
|
||||||
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
|
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
|
||||||
: 'hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50'
|
: 'hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Physical Disks
|
Physical Disks
|
||||||
|
|
@ -750,30 +750,30 @@ const Storage: Component = () => {
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead>
|
<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">
|
<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="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
|
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-auto">
|
||||||
Storage
|
Storage
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider hidden md:table-cell">
|
<th class="hidden md:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||||||
Type
|
Type
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider hidden lg:table-cell">
|
<th class="hidden lg:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]">
|
||||||
Content
|
Content
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider hidden sm:table-cell">
|
<th class="hidden sm:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<Show when={viewMode() === 'node'}>
|
<Show when={viewMode() === 'node'}>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider hidden lg:table-cell">
|
<th class="hidden lg:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%]">
|
||||||
Shared
|
Shared
|
||||||
</th>
|
</th>
|
||||||
</Show>
|
</Show>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-[200px]">
|
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[25%] min-w-[150px]">
|
||||||
Usage
|
Usage
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider hidden sm:table-cell">
|
<th class="hidden sm:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||||||
Free
|
Free
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
|
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||||||
Total
|
Total
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 w-8"></th>
|
<th class="px-2 py-1.5 w-8"></th>
|
||||||
|
|
@ -1061,8 +1061,8 @@ const Storage: Component = () => {
|
||||||
<Show when={zfsPool && zfsPool.state !== 'ONLINE'}>
|
<Show when={zfsPool && zfsPool.state !== 'ONLINE'}>
|
||||||
<span
|
<span
|
||||||
class={`px-1.5 py-0.5 rounded text-[10px] font-medium ${zfsPool?.state === 'DEGRADED'
|
class={`px-1.5 py-0.5 rounded text-[10px] font-medium ${zfsPool?.state === 'DEGRADED'
|
||||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||||
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{zfsPool?.state}
|
{zfsPool?.state}
|
||||||
|
|
@ -1109,12 +1109,12 @@ const Storage: Component = () => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-0.5 px-1.5 hidden md:table-cell">
|
<td class="hidden md:table-cell p-0.5 px-1.5">
|
||||||
<span class="inline-block px-1.5 py-0.5 text-[10px] font-medium rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
<span class="inline-block px-1.5 py-0.5 text-[10px] font-medium rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||||
{storage.type}
|
{storage.type}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-0.5 px-1.5 hidden lg:table-cell">
|
<td class="hidden lg:table-cell p-0.5 px-1.5">
|
||||||
<span
|
<span
|
||||||
class="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap truncate max-w-[220px]"
|
class="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap truncate max-w-[220px]"
|
||||||
title={storage.content || '-'}
|
title={storage.content || '-'}
|
||||||
|
|
@ -1122,18 +1122,18 @@ const Storage: Component = () => {
|
||||||
{storage.content || '-'}
|
{storage.content || '-'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-0.5 px-1.5 text-xs hidden sm:table-cell whitespace-nowrap">
|
<td class="hidden sm:table-cell p-0.5 px-1.5 text-xs whitespace-nowrap">
|
||||||
<span
|
<span
|
||||||
class={`${storage.status === 'available'
|
class={`${storage.status === 'available'
|
||||||
? 'text-green-600 dark:text-green-400'
|
? 'text-green-600 dark:text-green-400'
|
||||||
: 'text-red-600 dark:text-red-400'
|
: 'text-red-600 dark:text-red-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{storage.status || 'unknown'}
|
{storage.status || 'unknown'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<Show when={viewMode() === 'node'}>
|
<Show when={viewMode() === 'node'}>
|
||||||
<td class="p-0.5 px-1.5 hidden lg:table-cell">
|
<td class="hidden lg:table-cell p-0.5 px-1.5">
|
||||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
{storage.shared ? '✓' : '-'}
|
{storage.shared ? '✓' : '-'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1141,7 +1141,7 @@ const Storage: Component = () => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<td class="p-0.5 px-1.5">
|
<td class="p-0.5 px-1.5">
|
||||||
<div class="relative min-w-[200px] h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
|
<div class="relative min-w-[150px] h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
|
||||||
<div
|
<div
|
||||||
class={`absolute top-0 left-0 h-full ${getProgressBarColor(usagePercent)}`}
|
class={`absolute top-0 left-0 h-full ${getProgressBarColor(usagePercent)}`}
|
||||||
style={{ width: `${usagePercent}%` }}
|
style={{ width: `${usagePercent}%` }}
|
||||||
|
|
@ -1155,7 +1155,7 @@ const Storage: Component = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-0.5 px-1.5 text-xs hidden sm:table-cell whitespace-nowrap">
|
<td class="hidden sm:table-cell p-0.5 px-1.5 text-xs whitespace-nowrap">
|
||||||
{formatBytes(storage.free || 0, 0)}
|
{formatBytes(storage.free || 0, 0)}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-0.5 px-1.5 text-xs whitespace-nowrap">
|
<td class="p-0.5 px-1.5 text-xs whitespace-nowrap">
|
||||||
|
|
@ -1166,8 +1166,8 @@ const Storage: Component = () => {
|
||||||
<Show when={isCephStorage() && isExpanded()}>
|
<Show when={isCephStorage() && isExpanded()}>
|
||||||
<tr
|
<tr
|
||||||
class={`text-[11px] border-t border-gray-200 dark:border-gray-700 ${drawerDisabled()
|
class={`text-[11px] border-t border-gray-200 dark:border-gray-700 ${drawerDisabled()
|
||||||
? 'bg-gray-100/70 text-gray-400 dark:bg-gray-900/30 dark:text-gray-500'
|
? 'bg-gray-100/70 text-gray-400 dark:bg-gray-900/30 dark:text-gray-500'
|
||||||
: 'bg-gray-50/60 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300'
|
: 'bg-gray-50/60 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td colSpan={9} class="px-4 py-3">
|
<td colSpan={9} class="px-4 py-3">
|
||||||
|
|
@ -1233,10 +1233,10 @@ const Storage: Component = () => {
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class={`px-1.5 py-0.5 rounded text-xs font-medium ${storage.zfsPool!.state === 'ONLINE'
|
class={`px-1.5 py-0.5 rounded text-xs font-medium ${storage.zfsPool!.state === 'ONLINE'
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||||
: storage.zfsPool!.state === 'DEGRADED'
|
: storage.zfsPool!.state === 'DEGRADED'
|
||||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||||
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{storage.zfsPool!.state}
|
{storage.zfsPool!.state}
|
||||||
|
|
|
||||||
|
|
@ -355,387 +355,384 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card padding="none" class="mb-4 overflow-hidden">
|
<Card padding="none" class="mb-4 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full min-w-[600px] table-fixed border-collapse">
|
<table class="w-full table-fixed border-collapse">
|
||||||
<thead>
|
<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">
|
<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-[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"
|
|
||||||
role="button"
|
|
||||||
aria-label={`Sort by name ${
|
|
||||||
sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'}{' '}
|
|
||||||
{sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
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 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 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 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'}{' '}
|
|
||||||
{sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
<Show when={hasAnyTemperatureData()}>
|
|
||||||
<th
|
<th
|
||||||
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"
|
class="pl-3 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-auto 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('temperature')}
|
onClick={() => handleSort('name')}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label={`Sort by name ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Temp{' '}
|
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'}{' '}
|
||||||
{sortKey() === 'temperature' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
</Show>
|
<th
|
||||||
<For each={countColumns()}>
|
class="hidden sm:table-cell px-2 py-1.5 text-left 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"
|
||||||
{(column) => (
|
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 w-[25%] sm:w-[15%] 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 w-[25%] sm:w-[15%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
|
onClick={() => handleSort('memory')}
|
||||||
|
>
|
||||||
|
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="hidden md:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
|
onClick={() => handleSort('disk')}
|
||||||
|
>
|
||||||
|
{props.currentTab === 'backups' && props.pbsInstances ? 'Storage / Disk' : 'Disk'}{' '}
|
||||||
|
{sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</th>
|
||||||
|
<Show when={hasAnyTemperatureData()}>
|
||||||
<th
|
<th
|
||||||
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"
|
class="hidden lg:table-cell 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(column.key)}
|
onClick={() => handleSort('temperature')}
|
||||||
>
|
>
|
||||||
{column.header}{' '}
|
Temp{' '}
|
||||||
{sortKey() === column.key && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'temperature' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
)}
|
</Show>
|
||||||
</For>
|
<For each={countColumns()}>
|
||||||
</tr>
|
{(column) => (
|
||||||
</thead>
|
<th
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
class="hidden lg:table-cell 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"
|
||||||
<For each={sortedItems()}>
|
onClick={() => handleSort(column.key)}
|
||||||
{(item) => {
|
|
||||||
const isPVE = item.type === 'pve';
|
|
||||||
const isPBS = item.type === 'pbs';
|
|
||||||
const node = isPVE ? (item.data as Node) : null;
|
|
||||||
const pbs = isPBS ? (item.data as PBSInstance) : null;
|
|
||||||
|
|
||||||
const online = isItemOnline(item);
|
|
||||||
const statusIndicator = createMemo(() =>
|
|
||||||
isPVE ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
|
|
||||||
);
|
|
||||||
const cpuPercentValue = getCpuPercent(item);
|
|
||||||
const memoryPercentValue = getMemoryPercent(item);
|
|
||||||
const diskPercentValue = getDiskPercent(item);
|
|
||||||
const diskSublabel = getDiskSublabel(item);
|
|
||||||
const cpuTemperatureValue = getCpuTemperatureValue(item);
|
|
||||||
const uptimeValue = isPVE ? node?.uptime ?? 0 : isPBS ? pbs?.uptime ?? 0 : 0;
|
|
||||||
const displayName = () => {
|
|
||||||
if (isPVE) return getNodeDisplayName(node as Node);
|
|
||||||
return (pbs as PBSInstance).name;
|
|
||||||
};
|
|
||||||
const showActualName = () => isPVE && hasAlternateDisplayName(node as Node);
|
|
||||||
|
|
||||||
// Use unique node ID (not hostname) to handle duplicate node names
|
|
||||||
const nodeId = isPVE ? node!.id : pbs!.name;
|
|
||||||
const isSelected = () => props.selectedNode === nodeId;
|
|
||||||
// Use the full resource ID for alert matching
|
|
||||||
const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name;
|
|
||||||
// Use namespaced metric key for sparklines
|
|
||||||
const metricsKey = buildMetricKey('node', resourceId);
|
|
||||||
const alertStyles = createMemo(() =>
|
|
||||||
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
|
|
||||||
);
|
|
||||||
const showAlertHighlight = createMemo(
|
|
||||||
() => alertStyles().hasUnacknowledgedAlert && online,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rowStyle = createMemo(() => {
|
|
||||||
const styles: Record<string, string> = {};
|
|
||||||
const shadows: string[] = [];
|
|
||||||
|
|
||||||
if (showAlertHighlight()) {
|
|
||||||
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
|
|
||||||
shadows.push(`inset 4px 0 0 0 ${color}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSelected()) {
|
|
||||||
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
|
|
||||||
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shadows.length > 0) {
|
|
||||||
styles['box-shadow'] = shadows.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
return styles;
|
|
||||||
});
|
|
||||||
|
|
||||||
const rowClass = createMemo(() => {
|
|
||||||
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm';
|
|
||||||
|
|
||||||
if (isSelected()) {
|
|
||||||
return `cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAlertHighlight()) {
|
|
||||||
return alertStyles().severity === 'critical'
|
|
||||||
? 'cursor-pointer transition-all duration-200 relative bg-red-50 dark:bg-red-950/30 hover:bg-red-100 dark:hover:bg-red-950/40 hover:shadow-sm'
|
|
||||||
: 'cursor-pointer transition-all duration-200 relative bg-yellow-50 dark:bg-yellow-950/20 hover:bg-yellow-100 dark:hover:bg-yellow-950/30 hover:shadow-sm';
|
|
||||||
}
|
|
||||||
|
|
||||||
let className = baseHover;
|
|
||||||
|
|
||||||
if (props.selectedNode && props.selectedNode !== nodeId) {
|
|
||||||
className += ' opacity-50 hover:opacity-80';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!online) {
|
|
||||||
className += ' opacity-60';
|
|
||||||
}
|
|
||||||
|
|
||||||
return className;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
class={rowClass()}
|
|
||||||
style={rowStyle()}
|
|
||||||
onClick={() => props.onNodeClick(nodeId, item.type)}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class={`pr-2 py-0.5 whitespace-nowrap ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
{column.header}{' '}
|
||||||
<StatusDot
|
{sortKey() === column.key && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
variant={statusIndicator().variant}
|
</th>
|
||||||
title={statusIndicator().label}
|
)}
|
||||||
ariaLabel={statusIndicator().label}
|
</For>
|
||||||
size="xs"
|
</tr>
|
||||||
/>
|
</thead>
|
||||||
<a
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
href={
|
<For each={sortedItems()}>
|
||||||
isPVE
|
{(item) => {
|
||||||
? node!.guestURL || node!.host || `https://${node!.name}:8006`
|
const isPVE = item.type === 'pve';
|
||||||
: pbs!.host || `https://${pbs!.name}:8007`
|
const isPBS = item.type === 'pbs';
|
||||||
}
|
const node = isPVE ? (item.data as Node) : null;
|
||||||
target="_blank"
|
const pbs = isPBS ? (item.data as PBSInstance) : null;
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400"
|
const online = isItemOnline(item);
|
||||||
>
|
const statusIndicator = createMemo(() =>
|
||||||
{displayName()}
|
isPVE ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
|
||||||
</a>
|
);
|
||||||
<Show when={showActualName()}>
|
const cpuPercentValue = getCpuPercent(item);
|
||||||
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
const memoryPercentValue = getMemoryPercent(item);
|
||||||
({(node as Node).name})
|
const diskPercentValue = getDiskPercent(item);
|
||||||
</span>
|
const diskSublabel = getDiskSublabel(item);
|
||||||
</Show>
|
const cpuTemperatureValue = getCpuTemperatureValue(item);
|
||||||
<Show when={isPVE}>
|
const uptimeValue = isPVE ? node?.uptime ?? 0 : isPBS ? pbs?.uptime ?? 0 : 0;
|
||||||
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
|
const displayName = () => {
|
||||||
PVE
|
if (isPVE) return getNodeDisplayName(node as Node);
|
||||||
</span>
|
return (pbs as PBSInstance).name;
|
||||||
</Show>
|
};
|
||||||
<Show when={isPVE && node!.pveVersion}>
|
const showActualName = () => isPVE && hasAlternateDisplayName(node as Node);
|
||||||
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
|
||||||
v{node!.pveVersion.split('/')[1] || node!.pveVersion}
|
// Use unique node ID (not hostname) to handle duplicate node names
|
||||||
</span>
|
const nodeId = isPVE ? node!.id : pbs!.name;
|
||||||
</Show>
|
const isSelected = () => props.selectedNode === nodeId;
|
||||||
<Show when={isPVE && node!.isClusterMember !== undefined}>
|
// Use the full resource ID for alert matching
|
||||||
<span
|
const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name;
|
||||||
class={`text-[9px] px-1 py-0 rounded text-[8px] font-medium whitespace-nowrap ${
|
// Use namespaced metric key for sparklines
|
||||||
node!.isClusterMember
|
const metricsKey = buildMetricKey('node', resourceId);
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
const alertStyles = createMemo(() =>
|
||||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
|
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
|
||||||
}`}
|
);
|
||||||
>
|
const showAlertHighlight = createMemo(
|
||||||
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
|
() => alertStyles().hasUnacknowledgedAlert && online,
|
||||||
</span>
|
);
|
||||||
</Show>
|
|
||||||
<Show when={isPBS}>
|
const rowStyle = createMemo(() => {
|
||||||
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
const styles: Record<string, string> = {};
|
||||||
PBS
|
const shadows: string[] = [];
|
||||||
</span>
|
|
||||||
</Show>
|
if (showAlertHighlight()) {
|
||||||
<Show when={isPBS && pbs!.version}>
|
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
|
||||||
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
shadows.push(`inset 4px 0 0 0 ${color}`);
|
||||||
v{pbs!.version}
|
}
|
||||||
</span>
|
|
||||||
</Show>
|
if (isSelected()) {
|
||||||
</div>
|
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
|
||||||
</td>
|
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
|
||||||
<td class="px-2 py-0.5 whitespace-nowrap">
|
}
|
||||||
<span
|
|
||||||
class={`text-xs ${
|
if (shadows.length > 0) {
|
||||||
isPVE && (node?.uptime ?? 0) < 3600
|
styles['box-shadow'] = shadows.join(', ');
|
||||||
? 'text-orange-500'
|
}
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
|
||||||
}`}
|
return styles;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowClass = createMemo(() => {
|
||||||
|
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm';
|
||||||
|
|
||||||
|
if (isSelected()) {
|
||||||
|
return `cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAlertHighlight()) {
|
||||||
|
return alertStyles().severity === 'critical'
|
||||||
|
? 'cursor-pointer transition-all duration-200 relative bg-red-50 dark:bg-red-950/30 hover:bg-red-100 dark:hover:bg-red-950/40 hover:shadow-sm'
|
||||||
|
: 'cursor-pointer transition-all duration-200 relative bg-yellow-50 dark:bg-yellow-950/20 hover:bg-yellow-100 dark:hover:bg-yellow-950/30 hover:shadow-sm';
|
||||||
|
}
|
||||||
|
|
||||||
|
let className = baseHover;
|
||||||
|
|
||||||
|
if (props.selectedNode && props.selectedNode !== nodeId) {
|
||||||
|
className += ' opacity-50 hover:opacity-80';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!online) {
|
||||||
|
className += ' opacity-60';
|
||||||
|
}
|
||||||
|
|
||||||
|
return className;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
class={rowClass()}
|
||||||
|
style={rowStyle()}
|
||||||
|
onClick={() => props.onNodeClick(nodeId, item.type)}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class={`pr-2 py-0.5 whitespace-nowrap ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}
|
||||||
>
|
>
|
||||||
<Show
|
<div class="flex items-center gap-1.5">
|
||||||
when={online && uptimeValue}
|
<StatusDot
|
||||||
fallback="-"
|
variant={statusIndicator().variant}
|
||||||
>
|
title={statusIndicator().label}
|
||||||
{formatUptime(uptimeValue)}
|
ariaLabel={statusIndicator().label}
|
||||||
</Show>
|
size="xs"
|
||||||
</span>
|
/>
|
||||||
</td>
|
<a
|
||||||
<td class="px-2 py-0.5">
|
href={
|
||||||
<Show
|
isPVE
|
||||||
when={online && cpuPercentValue !== null}
|
? node!.guestURL || node!.host || `https://${node!.name}:8006`
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
: pbs!.host || `https://${pbs!.name}:8007`
|
||||||
>
|
|
||||||
<MetricBar
|
|
||||||
value={cpuPercentValue ?? 0}
|
|
||||||
label={formatPercent(cpuPercentValue ?? 0)}
|
|
||||||
sublabel={
|
|
||||||
isPVE && node!.cpuInfo?.cores
|
|
||||||
? `${node!.cpuInfo.cores} cores`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
type="cpu"
|
|
||||||
resourceId={metricsKey}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-0.5">
|
|
||||||
<Show
|
|
||||||
when={online && memoryPercentValue !== null}
|
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
|
||||||
>
|
|
||||||
<MetricBar
|
|
||||||
value={memoryPercentValue ?? 0}
|
|
||||||
label={formatPercent(memoryPercentValue ?? 0)}
|
|
||||||
sublabel={
|
|
||||||
isPVE && node!.memory
|
|
||||||
? `${formatBytes(node!.memory.used, 0)}/${formatBytes(node!.memory.total, 0)}`
|
|
||||||
: isPBS && pbs!.memoryTotal
|
|
||||||
? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
type="memory"
|
|
||||||
resourceId={metricsKey}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-0.5">
|
|
||||||
<Show
|
|
||||||
when={online && diskPercentValue !== null}
|
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
|
||||||
>
|
|
||||||
<MetricBar
|
|
||||||
value={diskPercentValue ?? 0}
|
|
||||||
label={formatPercent(diskPercentValue ?? 0)}
|
|
||||||
sublabel={diskSublabel}
|
|
||||||
type="disk"
|
|
||||||
resourceId={metricsKey}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
<Show when={hasAnyTemperatureData()}>
|
|
||||||
<td class="px-2 py-0.5 whitespace-nowrap text-center">
|
|
||||||
<Show
|
|
||||||
when={
|
|
||||||
online &&
|
|
||||||
isPVE &&
|
|
||||||
cpuTemperatureValue !== null &&
|
|
||||||
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
|
|
||||||
isTemperatureMonitoringEnabled(node!)
|
|
||||||
}
|
|
||||||
fallback={
|
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500">-</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const value = cpuTemperatureValue as number;
|
|
||||||
const severityClass =
|
|
||||||
value >= 80
|
|
||||||
? 'text-red-600 dark:text-red-400'
|
|
||||||
: value >= 70
|
|
||||||
? 'text-yellow-600 dark:text-yellow-400'
|
|
||||||
: 'text-green-600 dark:text-green-400';
|
|
||||||
|
|
||||||
const temp = node!.temperature;
|
|
||||||
const cpuMinValue =
|
|
||||||
typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null;
|
|
||||||
const cpuMaxValue =
|
|
||||||
typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0
|
|
||||||
? temp.cpuMaxRecord
|
|
||||||
: null;
|
|
||||||
const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null;
|
|
||||||
|
|
||||||
const gpus = temp?.gpu ?? [];
|
|
||||||
const hasGPU = gpus.length > 0;
|
|
||||||
|
|
||||||
if (hasMinMax || hasGPU) {
|
|
||||||
const min = Math.round(cpuMinValue!);
|
|
||||||
const max = Math.round(cpuMaxValue!);
|
|
||||||
|
|
||||||
const getTooltipColor = (temp: number) => {
|
|
||||||
if (temp >= 80) return 'text-red-400';
|
|
||||||
if (temp >= 70) return 'text-yellow-400';
|
|
||||||
return 'text-green-400';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span class="relative inline-block group">
|
|
||||||
<span class={`text-xs font-medium ${severityClass} cursor-help`}>
|
|
||||||
{value}°C
|
|
||||||
</span>
|
|
||||||
<span class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs whitespace-nowrap bg-gray-900 dark:bg-gray-700 text-white rounded shadow-lg z-50 pointer-events-none">
|
|
||||||
{hasMinMax && (
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-300">CPU:</span> <span class={getTooltipColor(min)}>{min}</span>-<span class={getTooltipColor(max)}>{max}</span>°C
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasGPU && gpus.map((gpu) => {
|
|
||||||
const gpuTemp = gpu.edge ?? gpu.junction ?? gpu.mem ?? 0;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-300">GPU:</span> <span class={getTooltipColor(gpuTemp)}>{Math.round(gpuTemp)}</span>°C
|
|
||||||
{gpu.edge && ` E:${Math.round(gpu.edge)}`}
|
|
||||||
{gpu.junction && ` J:${Math.round(gpu.junction)}`}
|
|
||||||
{gpu.mem && ` M:${Math.round(gpu.mem)}`}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
target="_blank"
|
||||||
return <span class={`text-xs font-medium ${severityClass}`}>{value}°C</span>;
|
onClick={(e) => e.stopPropagation()}
|
||||||
})()}
|
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
{displayName()}
|
||||||
|
</a>
|
||||||
|
<Show when={showActualName()}>
|
||||||
|
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
||||||
|
({(node as Node).name})
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={isPVE}>
|
||||||
|
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
|
||||||
|
PVE
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={isPVE && node!.pveVersion}>
|
||||||
|
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
||||||
|
v{node!.pveVersion.split('/')[1] || node!.pveVersion}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={isPVE && node!.isClusterMember !== undefined}>
|
||||||
|
<span
|
||||||
|
class={`text-[9px] px-1 py-0 rounded text-[8px] font-medium whitespace-nowrap ${node!.isClusterMember
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={isPBS}>
|
||||||
|
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||||
|
PBS
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={isPBS && pbs!.version}>
|
||||||
|
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
||||||
|
v{pbs!.version}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="hidden sm:table-cell px-2 py-0.5 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class={`text-xs ${isPVE && (node?.uptime ?? 0) < 3600
|
||||||
|
? 'text-orange-500'
|
||||||
|
: 'text-gray-600 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={online && uptimeValue}
|
||||||
|
fallback="-"
|
||||||
|
>
|
||||||
|
{formatUptime(uptimeValue)}
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-0.5">
|
||||||
|
<Show
|
||||||
|
when={online && cpuPercentValue !== null}
|
||||||
|
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
||||||
|
>
|
||||||
|
<MetricBar
|
||||||
|
value={cpuPercentValue ?? 0}
|
||||||
|
label={formatPercent(cpuPercentValue ?? 0)}
|
||||||
|
sublabel={
|
||||||
|
isPVE && node!.cpuInfo?.cores
|
||||||
|
? `${node!.cpuInfo.cores} cores`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
type="cpu"
|
||||||
|
resourceId={metricsKey}
|
||||||
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
</Show>
|
<td class="px-2 py-0.5">
|
||||||
<For each={countColumns()}>
|
<Show
|
||||||
{(column) => {
|
when={online && memoryPercentValue !== null}
|
||||||
const value = getCountValue(item, column.key);
|
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
||||||
const display = online ? value ?? '-' : '-';
|
>
|
||||||
const textClass = online
|
<MetricBar
|
||||||
? 'text-xs text-gray-700 dark:text-gray-300'
|
value={memoryPercentValue ?? 0}
|
||||||
: 'text-xs text-gray-400 dark:text-gray-500';
|
label={formatPercent(memoryPercentValue ?? 0)}
|
||||||
return (
|
sublabel={
|
||||||
<td class="px-2 py-0.5 whitespace-nowrap text-center">
|
isPVE && node!.memory
|
||||||
<span class={textClass}>{display}</span>
|
? `${formatBytes(node!.memory.used, 0)}/${formatBytes(node!.memory.total, 0)}`
|
||||||
</td>
|
: isPBS && pbs!.memoryTotal
|
||||||
);
|
? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}`
|
||||||
}}
|
: undefined
|
||||||
</For>
|
}
|
||||||
</tr>
|
type="memory"
|
||||||
);
|
resourceId={metricsKey}
|
||||||
}}
|
/>
|
||||||
</For>
|
</Show>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
<td class="hidden md:table-cell px-2 py-0.5">
|
||||||
</div>
|
<Show
|
||||||
|
when={online && diskPercentValue !== null}
|
||||||
|
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
||||||
|
>
|
||||||
|
<MetricBar
|
||||||
|
value={diskPercentValue ?? 0}
|
||||||
|
label={formatPercent(diskPercentValue ?? 0)}
|
||||||
|
sublabel={diskSublabel}
|
||||||
|
type="disk"
|
||||||
|
resourceId={metricsKey}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<Show when={hasAnyTemperatureData()}>
|
||||||
|
<td class="hidden lg:table-cell px-2 py-0.5 whitespace-nowrap text-center">
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
online &&
|
||||||
|
isPVE &&
|
||||||
|
cpuTemperatureValue !== null &&
|
||||||
|
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
|
||||||
|
isTemperatureMonitoringEnabled(node!)
|
||||||
|
}
|
||||||
|
fallback={
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">-</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const value = cpuTemperatureValue as number;
|
||||||
|
const severityClass =
|
||||||
|
value >= 80
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: value >= 70
|
||||||
|
? 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
: 'text-green-600 dark:text-green-400';
|
||||||
|
|
||||||
|
const temp = node!.temperature;
|
||||||
|
const cpuMinValue =
|
||||||
|
typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null;
|
||||||
|
const cpuMaxValue =
|
||||||
|
typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0
|
||||||
|
? temp.cpuMaxRecord
|
||||||
|
: null;
|
||||||
|
const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null;
|
||||||
|
|
||||||
|
const gpus = temp?.gpu ?? [];
|
||||||
|
const hasGPU = gpus.length > 0;
|
||||||
|
|
||||||
|
if (hasMinMax || hasGPU) {
|
||||||
|
const min = Math.round(cpuMinValue!);
|
||||||
|
const max = Math.round(cpuMaxValue!);
|
||||||
|
|
||||||
|
const getTooltipColor = (temp: number) => {
|
||||||
|
if (temp >= 80) return 'text-red-400';
|
||||||
|
if (temp >= 70) return 'text-yellow-400';
|
||||||
|
return 'text-green-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span class="relative inline-block group">
|
||||||
|
<span class={`text-xs font-medium ${severityClass} cursor-help`}>
|
||||||
|
{value}°C
|
||||||
|
</span>
|
||||||
|
<span class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs whitespace-nowrap bg-gray-900 dark:bg-gray-700 text-white rounded shadow-lg z-50 pointer-events-none">
|
||||||
|
{hasMinMax && (
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-300">CPU:</span> <span class={getTooltipColor(min)}>{min}</span>-<span class={getTooltipColor(max)}>{max}</span>°C
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasGPU && gpus.map((gpu) => {
|
||||||
|
const gpuTemp = gpu.edge ?? gpu.junction ?? gpu.mem ?? 0;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-300">GPU:</span> <span class={getTooltipColor(gpuTemp)}>{Math.round(gpuTemp)}</span>°C
|
||||||
|
{gpu.edge && ` E:${Math.round(gpu.edge)}`}
|
||||||
|
{gpu.junction && ` J:${Math.round(gpu.junction)}`}
|
||||||
|
{gpu.mem && ` M:${Math.round(gpu.mem)}`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span class={`text-xs font-medium ${severityClass}`}>{value}°C</span>;
|
||||||
|
})()}
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
<For each={countColumns()}>
|
||||||
|
{(column) => {
|
||||||
|
const value = getCountValue(item, column.key);
|
||||||
|
const display = online ? value ?? '-' : '-';
|
||||||
|
const textClass = online
|
||||||
|
? 'text-xs text-gray-700 dark:text-gray-300'
|
||||||
|
: 'text-xs text-gray-400 dark:text-gray-500';
|
||||||
|
return (
|
||||||
|
<td class="hidden lg:table-cell px-2 py-0.5 whitespace-nowrap text-center">
|
||||||
|
<span class={textClass}>{display}</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue