refactor: polish unified resource model UI components

- Refined UnifiedResourceTable and Infrastructure page layouts
- Improved ResourceDetailDrawer with cleaner design
- Enhanced MobileNavBar responsiveness
- Updated metric components (MetricBar, StackedDiskBar, StackedMemoryBar)
- Polished ResponsiveMetricCell for better mobile experience
This commit is contained in:
rcourtman
2026-02-05 17:57:33 +00:00
parent 8262b813db
commit 9d7cfa5922
19 changed files with 725 additions and 788 deletions

View File

@@ -712,7 +712,7 @@ export function ResourceTable(props: ResourceTableProps) {
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<SectionHeader title={props.title} size="sm" />
</div>
<div class="overflow-x-auto">
<div class="overflow-x-auto" style={{ '-webkit-overflow-scrolling': 'touch' }}>
<table class="w-full">
<thead>
<tr class="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">

View File

@@ -2042,7 +2042,7 @@ const UnifiedBackups: Component = () => {
{/* Table */}
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;">
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch;">
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }
.backup-table {

View File

@@ -243,7 +243,7 @@ export function Dashboard(props: DashboardProps) {
setSelectedGuestId(resourceId);
const [instance, node, vmid] = resourceId.split(':');
if (instance && node && vmid) {
const knownNode = props.nodes.find((item) => item.id === instance || item.node === node || item.name === node);
const knownNode = props.nodes.find((item) => item.id === instance || item.name === node);
if (knownNode) {
setSelectedNode(knownNode.id);
}
@@ -297,8 +297,8 @@ export function Dashboard(props: DashboardProps) {
);
const legacyGuests = createMemo<WorkloadGuest[]>(() => [
...props.vms.map((vm) => ({ ...vm, workloadType: 'vm', displayId: String(vm.vmid) })),
...props.containers.map((ct) => ({ ...ct, workloadType: 'lxc', displayId: String(ct.vmid) })),
...props.vms.map((vm) => ({ ...vm, workloadType: 'vm' as const, displayId: String(vm.vmid) })),
...props.containers.map((ct) => ({ ...ct, workloadType: 'lxc' as const, displayId: String(ct.vmid) })),
]);
// Combine workloads into a single list for filtering, preferring v2 workloads when enabled
@@ -1124,7 +1124,11 @@ export function Dashboard(props: DashboardProps) {
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
<ComponentErrorBoundary name="Guest Table">
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div ref={tableRef} class="overflow-x-auto">
<div
ref={tableRef}
class="overflow-x-auto"
style={{ '-webkit-overflow-scrolling': 'touch' }}
>
<table class="w-full border-collapse whitespace-nowrap" style={{ "table-layout": "fixed", "min-width": isMobile() ? "800px" : "900px" }}>
<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-700">
@@ -1144,7 +1148,7 @@ export function Dashboard(props: DashboardProps) {
...((['cpu', 'memory', 'disk'].includes(col.id))
? { "width": isMobile() ? "60px" : "140px" }
: (col.width ? { "width": col.width } : {})),
"vertical-align": 'middle'
"vertical-align": 'middle',
}}
onClick={() => isSortable && handleSort(sortKeyForCol!)}
title={col.icon ? col.label : undefined}

View File

@@ -173,340 +173,340 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
{/* Use CSS hidden instead of Show to avoid mount/unmount which causes scroll jumps.
overflow-anchor: none prevents browser scroll anchoring from jumping when display toggles. */}
<div class={activeTab() === 'overview' ? '' : 'hidden'} style={{ "overflow-anchor": "none" }}>
{/* Flex layout - items grow to fill space, max ~4 per row */}
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full [&>*]:overflow-hidden">
{/* System Info - always show */}
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">System</div>
<div class="space-y-1.5 text-[11px]">
<Show when={props.guest.cpus}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">CPUs</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.cpus}</span>
</div>
</Show>
<Show when={props.guest.uptime > 0}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Uptime</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{formatUptime(props.guest.uptime)}</span>
</div>
</Show>
<Show when={props.guest.node}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Node</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.node}</span>
</div>
</Show>
<Show when={hasAgentInfo()}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Agent</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate ml-2" title={isVM(props.guest) ? `QEMU guest agent ${agentVersion()}` : agentVersion()}>
{isVM(props.guest) ? `QEMU ${agentVersion()}` : agentVersion()}
</span>
</div>
</Show>
</div>
</div>
{/* Guest Info - OS and IPs */}
<Show when={hasOsInfo() || ipAddresses().length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Guest Info</div>
<div class="space-y-2">
<Show when={hasOsInfo()}>
<div class="text-[11px] text-gray-600 dark:text-gray-300 truncate" title={`${osName()} ${osVersion()}`.trim()}>
<Show when={osName().length > 0}>
<span class="font-medium">{osName()}</span>
</Show>
<Show when={osName().length > 0 && osVersion().length > 0}>
<span class="text-gray-400 dark:text-gray-500 mx-1"></span>
</Show>
<Show when={osVersion().length > 0}>
<span>{osVersion()}</span>
</Show>
</div>
</Show>
<Show when={ipAddresses().length > 0}>
<div class="flex flex-wrap gap-1">
<For each={ipAddresses()}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200 max-w-full truncate" title={ip}>
{ip}
</span>
)}
</For>
</div>
</Show>
{/* Flex layout - items grow to fill space, max ~4 per row */}
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full [&>*]:overflow-hidden">
{/* System Info - always show */}
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">System</div>
<div class="space-y-1.5 text-[11px]">
<Show when={props.guest.cpus}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">CPUs</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.cpus}</span>
</div>
</div>
</Show>
{/* Memory Details */}
<Show when={memoryExtraLines() && memoryExtraLines()!.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Memory</div>
<div class="space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
</Show>
<Show when={props.guest.uptime > 0}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Uptime</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{formatUptime(props.guest.uptime)}</span>
</div>
</div>
</Show>
{/* Backup Info */}
<Show when={props.guest.lastBackup}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Backup</div>
<div class="space-y-1 text-[11px]">
{(() => {
const backupDate = new Date(props.guest.lastBackup);
const now = new Date();
const daysSince = Math.floor((now.getTime() - backupDate.getTime()) / (1000 * 60 * 60 * 24));
const isOld = daysSince > 7;
const isCritical = daysSince > 30;
return (
<>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Last Backup</span>
<span class={`font-medium ${isCritical ? 'text-red-600 dark:text-red-400' : isOld ? 'text-amber-600 dark:text-amber-400' : 'text-green-600 dark:text-green-400'}`}>
{daysSince === 0 ? 'Today' : daysSince === 1 ? 'Yesterday' : `${daysSince}d ago`}
</span>
</div>
<div class="text-[10px] text-gray-400 dark:text-gray-500">
{backupDate.toLocaleDateString()}
</div>
</>
);
})()}
</Show>
<Show when={props.guest.node}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Node</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.node}</span>
</div>
</div>
</Show>
{/* Tags */}
<Show when={props.guest.tags && (Array.isArray(props.guest.tags) ? props.guest.tags.length > 0 : props.guest.tags.length > 0)}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Tags</div>
<div class="flex flex-wrap gap-1">
<For each={Array.isArray(props.guest.tags) ? props.guest.tags : (props.guest.tags?.split(',') || [])}>
{(tag) => (
<span class="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{tag.trim()}
</span>
)}
</For>
</div>
</div>
</Show>
{/* Filesystems */}
<Show when={hasFilesystemDetails() && props.guest.disks && props.guest.disks.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Filesystems</div>
<div class="text-[11px] text-gray-600 dark:text-gray-300">
<DiskList
disks={props.guest.disks || []}
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
/>
</div>
</div>
</Show>
{/* Network Interfaces */}
<Show when={hasNetworkInterfaces()}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Network</div>
<div class="space-y-2">
<For each={networkInterfaces().slice(0, 4)}>
{(iface) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
return (
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700 overflow-hidden">
<div class="flex items-center gap-2 text-[11px] font-medium text-gray-700 dark:text-gray-200 min-w-0">
<span class="truncate min-w-0">{iface.name || 'interface'}</span>
<Show when={iface.mac}>
<span class="text-[9px] text-gray-400 dark:text-gray-500 font-normal truncate shrink-0 max-w-[100px]" title={iface.mac}>{iface.mac}</span>
</Show>
</div>
<Show when={addresses.length > 0}>
<div class="flex flex-wrap gap-1 mt-1">
<For each={addresses}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200 max-w-full truncate" title={ip}>
{ip}
</span>
)}
</For>
</div>
</Show>
<Show when={hasTraffic}>
<div class="flex gap-3 mt-1 text-[10px] text-gray-500 dark:text-gray-400">
<span>RX {formatBytes(iface.rxBytes ?? 0)}</span>
<span>TX {formatBytes(iface.txBytes ?? 0)}</span>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
</div>
{/* Performance History */}
<div class="mt-3 space-y-3">
{/* Shared range selector */}
<div class="flex items-center gap-2">
<svg class="w-3.5 h-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path stroke-linecap="round" d="M12 6v6l4 2" />
</svg>
<select
value={historyRange()}
onChange={(e) => setHistoryRange(e.currentTarget.value as HistoryTimeRange)}
class="text-[11px] font-medium pl-2 pr-6 py-1 rounded-md border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 cursor-pointer focus:ring-1 focus:ring-blue-500 focus:border-blue-500 appearance-none"
style={{ "background-image": "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E\")", "background-repeat": "no-repeat", "background-position": "right 6px center" }}
>
<option value="1h">Last 1 hour</option>
<option value="6h">Last 6 hours</option>
<option value="12h">Last 12 hours</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
</div>
<div class="relative">
<div class="space-y-3">
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(33.333%-0.5rem)] [&>*]:min-w-[250px]">
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="cpu"
height={120}
color="#8b5cf6"
label="CPU"
unit="%"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="memory"
height={120}
color="#f59e0b"
label="Memory"
unit="%"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="disk"
height={120}
color="#10b981"
label="Disk"
unit="%"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
</div>
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(50%-0.375rem)] [&>*]:min-w-[250px]">
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="netin"
height={120}
color="#3b82f6"
label="Net In"
unit="B/s"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="netout"
height={120}
color="#6366f1"
label="Net Out"
unit="B/s"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
</div>
</div>
{/* Single shared Pro lock overlay */}
<Show when={isHistoryLocked()}>
<div class="absolute inset-0 z-10 flex flex-col items-center justify-center backdrop-blur-sm bg-white/60 dark:bg-gray-900/60 rounded-lg">
<div class="bg-indigo-500 rounded-full p-3 shadow-lg mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-1">{historyRange() === '30d' ? '30' : '90'}-Day History</h3>
<p class="text-sm text-gray-600 dark:text-gray-300 text-center max-w-[260px] mb-4">
Upgrade to Pulse Pro to unlock {historyRange() === '30d' ? '30' : '90'} days of historical data retention.
</p>
<a
href="https://pulserelay.pro/pricing"
target="_blank"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-md shadow-sm transition-colors"
>
Unlock Pro Features
</a>
</Show>
<Show when={hasAgentInfo()}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Agent</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate ml-2" title={isVM(props.guest) ? `QEMU guest agent ${agentVersion()}` : agentVersion()}>
{isVM(props.guest) ? `QEMU ${agentVersion()}` : agentVersion()}
</span>
</div>
</Show>
</div>
</div>
{/* Guest Info - OS and IPs */}
<Show when={hasOsInfo() || ipAddresses().length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Guest Info</div>
<div class="space-y-2">
<Show when={hasOsInfo()}>
<div class="text-[11px] text-gray-600 dark:text-gray-300 truncate" title={`${osName()} ${osVersion()}`.trim()}>
<Show when={osName().length > 0}>
<span class="font-medium">{osName()}</span>
</Show>
<Show when={osName().length > 0 && osVersion().length > 0}>
<span class="text-gray-400 dark:text-gray-500 mx-1"></span>
</Show>
<Show when={osVersion().length > 0}>
<span>{osVersion()}</span>
</Show>
</div>
</Show>
<Show when={ipAddresses().length > 0}>
<div class="flex flex-wrap gap-1">
<For each={ipAddresses()}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200 max-w-full truncate" title={ip}>
{ip}
</span>
)}
</For>
</div>
</Show>
</div>
</div>
</Show>
{/* Memory Details */}
<Show when={memoryExtraLines() && memoryExtraLines()!.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Memory</div>
<div class="space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
</div>
</div>
</Show>
{/* Backup Info */}
<Show when={props.guest.lastBackup}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Backup</div>
<div class="space-y-1 text-[11px]">
{(() => {
const backupDate = new Date(props.guest.lastBackup);
const now = new Date();
const daysSince = Math.floor((now.getTime() - backupDate.getTime()) / (1000 * 60 * 60 * 24));
const isOld = daysSince > 7;
const isCritical = daysSince > 30;
return (
<>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Last Backup</span>
<span class={`font-medium ${isCritical ? 'text-red-600 dark:text-red-400' : isOld ? 'text-amber-600 dark:text-amber-400' : 'text-green-600 dark:text-green-400'}`}>
{daysSince === 0 ? 'Today' : daysSince === 1 ? 'Yesterday' : `${daysSince}d ago`}
</span>
</div>
<div class="text-[10px] text-gray-400 dark:text-gray-500">
{backupDate.toLocaleDateString()}
</div>
</>
);
})()}
</div>
</div>
</Show>
{/* Tags */}
<Show when={props.guest.tags && (Array.isArray(props.guest.tags) ? props.guest.tags.length > 0 : props.guest.tags.length > 0)}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Tags</div>
<div class="flex flex-wrap gap-1">
<For each={Array.isArray(props.guest.tags) ? props.guest.tags : (props.guest.tags?.split(',') || [])}>
{(tag) => (
<span class="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{tag.trim()}
</span>
)}
</For>
</div>
</div>
</Show>
{/* Filesystems */}
<Show when={hasFilesystemDetails() && props.guest.disks && props.guest.disks.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Filesystems</div>
<div class="text-[11px] text-gray-600 dark:text-gray-300">
<DiskList
disks={props.guest.disks || []}
diskStatusReason={isVM(props.guest) ? (props.guest as any).diskStatusReason : undefined}
/>
</div>
</div>
</Show>
{/* Network Interfaces */}
<Show when={hasNetworkInterfaces()}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Network</div>
<div class="space-y-2">
<For each={networkInterfaces().slice(0, 4)}>
{(iface) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
return (
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700 overflow-hidden">
<div class="flex items-center gap-2 text-[11px] font-medium text-gray-700 dark:text-gray-200 min-w-0">
<span class="truncate min-w-0">{iface.name || 'interface'}</span>
<Show when={iface.mac}>
<span class="text-[9px] text-gray-400 dark:text-gray-500 font-normal truncate shrink-0 max-w-[100px]" title={iface.mac}>{iface.mac}</span>
</Show>
</div>
<Show when={addresses.length > 0}>
<div class="flex flex-wrap gap-1 mt-1">
<For each={addresses}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200 max-w-full truncate" title={ip}>
{ip}
</span>
)}
</For>
</div>
</Show>
<Show when={hasTraffic}>
<div class="flex gap-3 mt-1 text-[10px] text-gray-500 dark:text-gray-400">
<span>RX {formatBytes(iface.rxBytes ?? 0)}</span>
<span>TX {formatBytes(iface.txBytes ?? 0)}</span>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
</div>
{/* Always rendered, hidden via CSS. Wrapped in a local Suspense
{/* Performance History */}
<div class="mt-3 space-y-3">
{/* Shared range selector */}
<div class="flex items-center gap-2">
<svg class="w-3.5 h-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path stroke-linecap="round" d="M12 6v6l4 2" />
</svg>
<select
value={historyRange()}
onChange={(e) => setHistoryRange(e.currentTarget.value as HistoryTimeRange)}
class="text-[11px] font-medium pl-2 pr-6 py-1 rounded-md border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 cursor-pointer focus:ring-1 focus:ring-blue-500 focus:border-blue-500 appearance-none"
style={{ "background-image": "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E\")", "background-repeat": "no-repeat", "background-position": "right 6px center" }}
>
<option value="1h">Last 1 hour</option>
<option value="6h">Last 6 hours</option>
<option value="12h">Last 12 hours</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
</div>
<div class="relative">
<div class="space-y-3">
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(33.333%-0.5rem)] [&>*]:min-w-[250px]">
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="cpu"
height={120}
color="#8b5cf6"
label="CPU"
unit="%"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="memory"
height={120}
color="#f59e0b"
label="Memory"
unit="%"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="disk"
height={120}
color="#10b981"
label="Disk"
unit="%"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
</div>
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(50%-0.375rem)] [&>*]:min-w-[250px]">
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="netin"
height={120}
color="#3b82f6"
label="Net In"
unit="B/s"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="netout"
height={120}
color="#6366f1"
label="Net Out"
unit="B/s"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
</div>
</div>
{/* Single shared Pro lock overlay */}
<Show when={isHistoryLocked()}>
<div class="absolute inset-0 z-10 flex flex-col items-center justify-center backdrop-blur-sm bg-white/60 dark:bg-gray-900/60 rounded-lg">
<div class="bg-indigo-500 rounded-full p-3 shadow-lg mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-1">{historyRange() === '30d' ? '30' : '90'}-Day History</h3>
<p class="text-sm text-gray-600 dark:text-gray-300 text-center max-w-[260px] mb-4">
Upgrade to Pulse Pro to unlock {historyRange() === '30d' ? '30' : '90'} days of historical data retention.
</p>
<a
href="https://pulserelay.pro/pricing"
target="_blank"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-md shadow-sm transition-colors"
>
Unlock Pro Features
</a>
</div>
</Show>
</div>
</div>
</div>
{/* Always rendered, hidden via CSS. Wrapped in a local Suspense
so DiscoveryTab's createResource loading state doesn't bubble
up to the app-level Suspense and replace the entire page. */}
<div class={activeTab() === 'discovery' ? '' : 'hidden'} style={{ "overflow-anchor": "none" }}>
<Suspense fallback={
<div class="flex items-center justify-center py-8">
<div class="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" />
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading discovery...</span>
</div>
}>
<DiscoveryTab
resourceType={discoveryResourceType()}
hostId={props.guest.node}
resourceId={String(props.guest.vmid)}
hostname={props.guest.name}
guestId={guestId()}
customUrl={props.customUrl}
onCustomUrlChange={(url) => props.onCustomUrlChange?.(guestId(), url)}
/>
</Suspense>
</div>
<div class={activeTab() === 'discovery' ? '' : 'hidden'} style={{ "overflow-anchor": "none" }}>
<Suspense fallback={
<div class="flex items-center justify-center py-8">
<div class="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" />
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading discovery...</span>
</div>
}>
<DiscoveryTab
resourceType={discoveryResourceType()}
hostId={props.guest.node}
resourceId={String(props.guest.vmid)}
hostname={props.guest.name}
guestId={guestId()}
customUrl={props.customUrl}
onCustomUrlChange={(url) => props.onCustomUrlChange?.(guestId(), url)}
/>
</Suspense>
</div>
</div>
);
};

View File

@@ -775,7 +775,9 @@ export function GuestRow(props: GuestRowProps) {
onClick={props.onClick}
>
{/* Name - always visible */}
<td class={`pr-2 py-1 align-middle whitespace-nowrap ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
<td
class={`pr-2 py-1 align-middle whitespace-nowrap ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}
>
<div class="flex items-center gap-2 min-w-0">
<div class={`transition-transform duration-200 ${props.isExpanded ? 'rotate-90' : ''}`}>
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">

View File

@@ -7,6 +7,7 @@ interface MetricBarProps {
value: number;
label: string;
sublabel?: string;
showLabel?: boolean;
type?: 'cpu' | 'memory' | 'disk' | 'generic';
resourceId?: string; // Required for sparkline mode to fetch history
class?: string;
@@ -45,12 +46,15 @@ export function MetricBar(props: MetricBarProps) {
// Determine if sublabel fits based on estimated text width
const showSublabel = createMemo(() => {
if (props.showLabel === false) return false;
if (!props.sublabel) return false;
const fullText = `${props.label} (${props.sublabel})`;
const estimatedWidth = estimateTextWidth(fullText);
return containerWidth() >= estimatedWidth;
});
const showLabel = createMemo(() => props.showLabel !== false && props.label.trim().length > 0);
// Get color based on percentage and metric type (matching original)
const getColor = createMemo(() => {
const percentage = props.value;
@@ -106,19 +110,21 @@ export function MetricBar(props: MetricBarProps) {
when={viewMode() === 'sparklines' && props.resourceId}
fallback={
// Progress bar mode - full width, flex centered like stacked bars
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center min-w-0">
<div class={`relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded ${props.class || ''}`}>
<div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} />
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span>{props.label}</span>
<Show when={showSublabel()}>
<span class="metric-sublabel font-normal text-gray-500 dark:text-gray-300">
({props.sublabel})
</span>
</Show>
<Show when={showLabel()}>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none min-w-0 overflow-hidden">
<span class="max-w-full min-w-0 whitespace-nowrap overflow-hidden text-ellipsis px-0.5 text-center">
<span>{props.label}</span>
<Show when={showSublabel()}>
<span class="metric-sublabel font-normal text-gray-500 dark:text-gray-300">
{' '}({props.sublabel})
</span>
</Show>
</span>
</span>
</span>
</Show>
</div>
</div>
}

View File

@@ -304,25 +304,25 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
</Show>
{/* Label overlay */}
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none min-w-0 overflow-hidden">
<span class="max-w-full min-w-0 whitespace-nowrap overflow-hidden text-ellipsis px-0.5 text-center">
<span>{displayLabel()}</span>
<Show when={showMaxLabel()}>
<span
class="text-[8px] font-normal text-gray-500 dark:text-gray-400"
title={maxLabelFull()}
>
{maxLabelShort()}
{' '}{maxLabelShort()}
</span>
</Show>
<Show when={showSublabel()}>
<span class="metric-sublabel font-normal text-gray-500 dark:text-gray-300">
({displaySublabel()})
{' '}({displaySublabel()})
</span>
</Show>
<Show when={useStackedSegments()}>
<span class="text-[8px] font-normal text-gray-500 dark:text-gray-400">
[{props.disks?.length}]
{' '}[{props.disks?.length}]
</span>
</Show>
{/* Anomaly indicator */}

View File

@@ -27,7 +27,7 @@ const anomalySeverityClass: Record<string, string> = {
// Colors for memory segments
const MEMORY_COLORS = {
active: 'rgba(34, 197, 94, 0.6)', // green (base, overridden by threshold)
balloon: 'rgba(59, 130, 246, 0.6)', // yellow to blue
balloon: 'rgba(59, 130, 246, 0.6)', // blue
swap: 'rgba(168, 85, 247, 0.6)', // purple
};
@@ -206,12 +206,12 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
</Show>
{/* Label overlay */}
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none pointer-events-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none pointer-events-none min-w-0 overflow-hidden">
<span class="max-w-full min-w-0 whitespace-nowrap overflow-hidden text-ellipsis px-0.5 text-center">
<span>{displayLabel()}</span>
<Show when={showSublabel()}>
<span class="metric-sublabel font-normal text-gray-500 dark:text-gray-300">
({displaySublabel()})
{' '}({displaySublabel()})
</span>
</Show>
{/* Anomaly indicator */}

View File

@@ -1,13 +1,11 @@
import { Component, Show, createMemo, For, createSignal, createEffect } from 'solid-js';
import type { Disk, Host, HostNetworkInterface, HostSensorSummary, Memory, Node } from '@/types/api';
import type { Resource, ResourceMetric } from '@/types/resource';
import { getDisplayName, getCpuPercent, getMemoryPercent, getDiskPercent } from '@/types/resource';
import { formatBytes, formatUptime, formatRelativeTime, formatAbsoluteTime, formatPercent } from '@/utils/format';
import { getDisplayName } from '@/types/resource';
import { formatUptime, formatRelativeTime, formatAbsoluteTime } from '@/utils/format';
import { formatTemperature } from '@/utils/temperature';
import { MetricBar } from '@/components/Dashboard/MetricBar';
import { StatusDot } from '@/components/shared/StatusDot';
import { TagBadges } from '@/components/Dashboard/TagBadges';
import { buildMetricKey } from '@/utils/metricsKeys';
import { getHostStatusIndicator } from '@/utils/status';
import { getPlatformBadge, getSourceBadge, getTypeBadge, getUnifiedSourceBadges } from './resourceBadges';
import { SystemInfoCard } from '@/components/shared/cards/SystemInfoCard';
@@ -74,10 +72,7 @@ type PlatformData = {
matches?: unknown;
};
const metricSublabel = (metric?: ResourceMetric) => {
if (!metric || typeof metric.used !== 'number' || typeof metric.total !== 'number') return undefined;
return `${formatBytes(metric.used)}/${formatBytes(metric.total)}`;
};
const buildMemory = (metric?: ResourceMetric, fallback?: Partial<Memory>): Memory => {
const total = metric?.total ?? fallback?.total ?? 0;
@@ -155,6 +150,7 @@ const toNodeFromProxmox = (resource: Resource): Node | null => {
model: proxmox.cpuInfo?.model ?? 'Unknown',
cores: proxmox.cpuInfo?.cores ?? 0,
sockets: proxmox.cpuInfo?.sockets ?? 0,
mhz: '0',
},
lastSeen,
connectionHealth: resource.status ?? 'unknown',
@@ -232,7 +228,7 @@ const buildTemperatureRows = (sensors?: HostSensorSummary) => {
};
export const ResourceDetailDrawer: Component<ResourceDetailDrawerProps> = (props) => {
type DrawerTab = 'overview' | 'discovery' | 'metrics' | 'debug';
type DrawerTab = 'overview' | 'discovery' | 'debug';
const [activeTab, setActiveTab] = createSignal<DrawerTab>('overview');
const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false);
const [copied, setCopied] = createSignal(false);
@@ -242,11 +238,6 @@ export const ResourceDetailDrawer: Component<ResourceDetailDrawerProps> = (props
const statusIndicator = createMemo(() => getHostStatusIndicator({ status: props.resource.status }));
const lastSeen = createMemo(() => formatRelativeTime(props.resource.lastSeen));
const lastSeenAbsolute = createMemo(() => formatAbsoluteTime(props.resource.lastSeen));
const metricKey = createMemo(() => buildMetricKey('host', props.resource.id));
const cpuPercent = createMemo(() => (props.resource.cpu ? Math.round(getCpuPercent(props.resource)) : null));
const memoryPercent = createMemo(() => (props.resource.memory ? Math.round(getMemoryPercent(props.resource)) : null));
const diskPercent = createMemo(() => (props.resource.disk ? Math.round(getDiskPercent(props.resource)) : null));
const platformBadge = createMemo(() => getPlatformBadge(props.resource.platformType));
const sourceBadge = createMemo(() => getSourceBadge(props.resource.sourceType));
@@ -316,7 +307,6 @@ export const ResourceDetailDrawer: Component<ResourceDetailDrawerProps> = (props
const base = [
{ id: 'overview' as DrawerTab, label: 'Overview' },
{ id: 'discovery' as DrawerTab, label: 'Discovery' },
{ id: 'metrics' as DrawerTab, label: 'Metrics' },
];
if (debugEnabled()) {
base.push({ id: 'debug' as DrawerTab, label: 'Debug' });
@@ -575,52 +565,6 @@ export const ResourceDetailDrawer: Component<ResourceDetailDrawerProps> = (props
</div>
</div>
{/* Metrics Tab */}
<div class={activeTab() === 'metrics' ? '' : 'hidden'} style={{ "overflow-anchor": "none" }}>
<div class="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Metrics</div>
<div class="space-y-2">
<div class="space-y-1">
<div class="text-[10px] text-gray-500 dark:text-gray-400">CPU</div>
<Show when={cpuPercent() !== null} fallback={<div class="text-xs text-gray-400"></div>}>
<MetricBar
value={cpuPercent() ?? 0}
label={formatPercent(cpuPercent() ?? 0)}
type="cpu"
resourceId={metricKey()}
/>
</Show>
</div>
<div class="space-y-1">
<div class="text-[10px] text-gray-500 dark:text-gray-400">Memory</div>
<Show when={memoryPercent() !== null} fallback={<div class="text-xs text-gray-400"></div>}>
<MetricBar
value={memoryPercent() ?? 0}
label={formatPercent(memoryPercent() ?? 0)}
sublabel={metricSublabel(props.resource.memory)}
type="memory"
resourceId={metricKey()}
/>
</Show>
</div>
<div class="space-y-1">
<div class="text-[10px] text-gray-500 dark:text-gray-400">Disk</div>
<Show when={diskPercent() !== null} fallback={<div class="text-xs text-gray-400"></div>}>
<MetricBar
value={diskPercent() ?? 0}
label={formatPercent(diskPercent() ?? 0)}
sublabel={metricSublabel(props.resource.disk)}
type="disk"
resourceId={metricKey()}
/>
</Show>
</div>
</div>
</div>
</div>
</div>
{/* Debug Tab */}
<Show when={debugEnabled()}>
<div class={activeTab() === 'debug' ? '' : 'hidden'} style={{ "overflow-anchor": "none" }}>

View File

@@ -139,9 +139,29 @@ export const UnifiedResourceTable: Component<UnifiedResourceTableProps> = (props
setExpandedResourceId(props.expandedResourceId === resourceId ? null : resourceId);
};
const thClassBase = 'px-2 py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap';
const thClassBase = 'px-1.5 sm:px-2 py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap';
const thClass = `${thClassBase} text-center`;
const tdClass = 'px-2 py-1 align-middle';
const tdClass = 'px-1.5 sm:px-2 py-1 align-middle';
const resourceColumnStyle = createMemo(() =>
isMobile()
? { width: '110px', 'min-width': '110px', 'max-width': '150px' }
: { 'min-width': '220px' }
);
const metricColumnStyle = createMemo(() =>
isMobile()
? { width: '70px', 'min-width': '70px', 'max-width': '90px' }
: { 'min-width': '140px', 'max-width': '180px' }
);
const sourceColumnStyle = createMemo(() =>
isMobile()
? { width: '100px', 'min-width': '100px', 'max-width': '120px' }
: { width: '140px', 'min-width': '140px', 'max-width': '160px' }
);
const uptimeColumnStyle = createMemo(() =>
isMobile()
? { width: '70px', 'min-width': '70px', 'max-width': '80px' }
: { width: '80px', 'min-width': '80px', 'max-width': '80px' }
);
const getUnifiedSources = (resource: Resource): string[] => {
const platformData = resource.platformData as { sources?: string[] } | undefined;
@@ -150,28 +170,31 @@ export const UnifiedResourceTable: Component<UnifiedResourceTableProps> = (props
return (
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full border-collapse whitespace-nowrap" style={{ 'table-layout': 'fixed', 'min-width': '840px' }}>
<div
class="overflow-x-auto"
style={{ '-webkit-overflow-scrolling': 'touch' }}
>
<table class="w-full border-collapse whitespace-nowrap" style={{ 'table-layout': 'fixed', 'min-width': '600px' }}>
<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-700">
<th class={`${thClassBase} text-left pl-3`} onClick={() => handleSort('name')}>
<th class={`${thClassBase} text-left pl-2 sm:pl-3`} style={resourceColumnStyle()} onClick={() => handleSort('name')}>
Resource {renderSortIndicator('name')}
</th>
<th class={thClass} style={{ width: '80px', 'min-width': '80px', 'max-width': '80px' }} onClick={() => handleSort('uptime')}>
Uptime {renderSortIndicator('uptime')}
</th>
<th class={thClass} style={isMobile() ? { 'min-width': '80px' } : { 'min-width': '140px', 'max-width': '180px' }} onClick={() => handleSort('cpu')}>
<th class={thClass} style={metricColumnStyle()} onClick={() => handleSort('cpu')}>
CPU {renderSortIndicator('cpu')}
</th>
<th class={thClass} style={isMobile() ? { 'min-width': '80px' } : { 'min-width': '140px', 'max-width': '180px' }} onClick={() => handleSort('memory')}>
<th class={thClass} style={metricColumnStyle()} onClick={() => handleSort('memory')}>
Memory {renderSortIndicator('memory')}
</th>
<th class={thClass} style={isMobile() ? { 'min-width': '80px' } : { 'min-width': '140px', 'max-width': '180px' }} onClick={() => handleSort('disk')}>
<th class={thClass} style={metricColumnStyle()} onClick={() => handleSort('disk')}>
Disk {renderSortIndicator('disk')}
</th>
<th class={thClass} style={{ width: '140px', 'min-width': '140px', 'max-width': '160px' }} onClick={() => handleSort('source')}>
<th class={thClass} style={sourceColumnStyle()} onClick={() => handleSort('source')}>
Source {renderSortIndicator('source')}
</th>
<th class={thClass} style={uptimeColumnStyle()} onClick={() => handleSort('uptime')}>
Uptime {renderSortIndicator('uptime')}
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800">
@@ -214,7 +237,6 @@ export const UnifiedResourceTable: Component<UnifiedResourceTableProps> = (props
return className;
});
const platformBadge = createMemo(() => getPlatformBadge(resource.platformType));
const sourceBadge = createMemo(() => getSourceBadge(resource.sourceType));
const unifiedSourceBadges = createMemo(() =>
@@ -236,7 +258,7 @@ export const UnifiedResourceTable: Component<UnifiedResourceTableProps> = (props
style={{ 'min-height': '36px' }}
onClick={() => toggleExpand(resource.id)}
>
<td class="pr-2 py-1 align-middle overflow-hidden pl-3">
<td class="pr-1.5 sm:pr-2 py-1 align-middle overflow-hidden pl-2 sm:pl-3">
<div class="flex items-center gap-1.5 min-w-0">
<div
class={`transition-transform duration-200 ${isExpanded() ? 'rotate-90' : ''}`}
@@ -266,57 +288,44 @@ export const UnifiedResourceTable: Component<UnifiedResourceTableProps> = (props
</td>
<td class={tdClass}>
<div class="flex justify-center">
<Show when={resource.uptime} fallback={<span class="text-xs text-gray-400"></span>}>
<span class="text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
{formatUptime(resource.uptime ?? 0)}
</span>
</Show>
</div>
<Show when={cpuPercentValue() !== null} fallback={<div class="flex justify-center"><span class="text-xs text-gray-400"></span></div>}>
<ResponsiveMetricCell
class="w-full"
value={cpuPercentValue() ?? 0}
type="cpu"
resourceId={isMobile() ? undefined : metricsKey()}
isRunning={isResourceOnline(resource)}
showMobile={false}
/>
</Show>
</td>
<td class={tdClass}>
<div class="flex justify-center">
<Show when={cpuPercentValue() !== null} fallback={<span class="text-xs text-gray-400"></span>}>
<ResponsiveMetricCell
value={cpuPercentValue() ?? 0}
type="cpu"
resourceId={metricsKey()}
isRunning={isResourceOnline(resource)}
showMobile={false}
/>
</Show>
</div>
<Show when={memoryPercentValue() !== null} fallback={<div class="flex justify-center"><span class="text-xs text-gray-400"></span></div>}>
<ResponsiveMetricCell
class="w-full"
value={memoryPercentValue() ?? 0}
type="memory"
sublabel={memorySublabel()}
resourceId={isMobile() ? undefined : metricsKey()}
isRunning={isResourceOnline(resource)}
showMobile={false}
/>
</Show>
</td>
<td class={tdClass}>
<div class="flex justify-center">
<Show when={memoryPercentValue() !== null} fallback={<span class="text-xs text-gray-400"></span>}>
<ResponsiveMetricCell
value={memoryPercentValue() ?? 0}
type="memory"
sublabel={memorySublabel()}
resourceId={metricsKey()}
isRunning={isResourceOnline(resource)}
showMobile={false}
/>
</Show>
</div>
</td>
<td class={tdClass}>
<div class="flex justify-center">
<Show when={diskPercentValue() !== null} fallback={<span class="text-xs text-gray-400"></span>}>
<ResponsiveMetricCell
value={diskPercentValue() ?? 0}
type="disk"
sublabel={diskSublabel()}
resourceId={metricsKey()}
isRunning={isResourceOnline(resource)}
showMobile={false}
/>
</Show>
</div>
<Show when={diskPercentValue() !== null} fallback={<div class="flex justify-center"><span class="text-xs text-gray-400"></span></div>}>
<ResponsiveMetricCell
class="w-full"
value={diskPercentValue() ?? 0}
type="disk"
sublabel={diskSublabel()}
resourceId={isMobile() ? undefined : metricsKey()}
isRunning={isResourceOnline(resource)}
showMobile={false}
/>
</Show>
</td>
<td class={tdClass}>
@@ -352,6 +361,16 @@ export const UnifiedResourceTable: Component<UnifiedResourceTableProps> = (props
</Show>
</div>
</td>
<td class={tdClass}>
<div class="flex justify-center">
<Show when={resource.uptime} fallback={<span class="text-xs text-gray-400"></span>}>
<span class="text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
{formatUptime(resource.uptime ?? 0)}
</span>
</Show>
</div>
</td>
</tr>
<Show when={isExpanded()}>
<tr>

View File

@@ -183,7 +183,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
<Show when={filteredDisks().length > 0}>
<Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto">
<div class="overflow-x-auto" style={{ '-webkit-overflow-scrolling': 'touch' }}>
<table class="w-full" style={{ "min-width": "800px" }}>
<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">

View File

@@ -55,7 +55,7 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
};
return (
<div ref={containerRef} class="metric-text w-full h-5 flex items-center">
<div ref={containerRef} class="metric-text w-full h-5 flex items-center min-w-0">
<div
class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
onMouseEnter={handleMouseEnter}
@@ -78,8 +78,8 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
</Show>
{/* Label */}
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none pointer-events-none">
<span class="whitespace-nowrap px-0.5">
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none pointer-events-none min-w-0 overflow-hidden">
<span class="max-w-full min-w-0 whitespace-nowrap overflow-hidden text-ellipsis px-0.5 text-center">
{formatPercent(usagePercent())} (
{formatBytes(props.used)}/
{formatBytes(props.total)})

View File

@@ -992,7 +992,7 @@ const Storage: Component = () => {
<Show when={connected() && initialDataReceived() && sortedStorage().length > 0}>
<ComponentErrorBoundary name="Storage Table">
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;">
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch;">
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }
`}</style>

View File

@@ -22,9 +22,9 @@ const toneClassMap: Record<Tone, string> = {
const paddingClassMap: Record<Padding, string> = {
none: 'p-0',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
sm: 'p-2 sm:p-3',
md: 'p-3 sm:p-4',
lg: 'p-4 sm:p-6',
};
export function Card(props: CardProps) {

View File

@@ -1,11 +1,5 @@
import { createEffect, createMemo, createSignal, For, Show, onCleanup } from 'solid-js';
import type { JSX } from 'solid-js';
import ServerIcon from 'lucide-solid/icons/server';
import BoxesIcon from 'lucide-solid/icons/boxes';
import BellIcon from 'lucide-solid/icons/bell';
import SettingsIcon from 'lucide-solid/icons/settings';
import MoreHorizontalIcon from 'lucide-solid/icons/more-horizontal';
import XIcon from 'lucide-solid/icons/x';
type PlatformTab = {
id: string;
@@ -14,7 +8,9 @@ type PlatformTab = {
settingsRoute: string;
tooltip: string;
enabled: boolean;
live: boolean;
icon: JSX.Element;
alwaysShow: boolean;
badge?: string;
};
@@ -38,72 +34,84 @@ type MobileNavBarProps = {
};
export function MobileNavBar(props: MobileNavBarProps) {
const [drawerOpen, setDrawerOpen] = createSignal(false);
const [touchStartX, setTouchStartX] = createSignal<number | null>(null);
const [touchStartY, setTouchStartY] = createSignal<number | null>(null);
let navRef: HTMLDivElement | undefined;
const [showFade, setShowFade] = createSignal(false);
const alertsTab = createMemo(() => props.utilityTabs().find((tab) => tab.id === 'alerts'));
const settingsTab = createMemo(() => props.utilityTabs().find((tab) => tab.id === 'settings'));
const infrastructureTab = createMemo(() =>
props.platformTabs().find((tab) => tab.id === 'infrastructure'),
);
const workloadsTab = createMemo(() =>
props.platformTabs().find((tab) => tab.id === 'workloads'),
);
const orderedPlatformTabs = createMemo(() => {
const tabs = props.platformTabs();
const priority = ['infrastructure', 'workloads', 'storage', 'backups'];
const prioritySet = new Set(priority);
const byId = new Map(tabs.map((tab) => [tab.id, tab]));
const ordered: PlatformTab[] = [];
priority.forEach((id) => {
const tab = byId.get(id);
if (tab) ordered.push(tab);
});
tabs.forEach((tab) => {
if (!prioritySet.has(tab.id)) ordered.push(tab);
});
return ordered;
});
const morePlatformTabs = createMemo(() =>
props.platformTabs().filter((tab) => !['infrastructure', 'workloads'].includes(tab.id)),
);
const moreUtilityTabs = createMemo(() =>
props.utilityTabs().filter((tab) => !['alerts', 'settings'].includes(tab.id)),
);
const orderedUtilityTabs = createMemo(() => {
const tabs = props.utilityTabs();
const priority = ['alerts', 'settings', 'ai'];
const prioritySet = new Set(priority);
const byId = new Map(tabs.map((tab) => [tab.id, tab]));
const ordered: UtilityTab[] = [];
priority.forEach((id) => {
const tab = byId.get(id as UtilityTab['id']);
if (tab) ordered.push(tab);
});
tabs.forEach((tab) => {
if (!prioritySet.has(tab.id)) ordered.push(tab);
});
return ordered;
});
const updateFadeIndicator = () => {
if (!navRef) {
setShowFade(false);
return;
}
const maxScrollLeft = navRef.scrollWidth - navRef.clientWidth;
setShowFade(maxScrollLeft > 1 && navRef.scrollLeft < maxScrollLeft - 1);
};
createEffect(() => {
if (!drawerOpen()) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setDrawerOpen(false);
}
};
document.addEventListener('keydown', handleKeyDown);
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
if (!navRef) return;
updateFadeIndicator();
const handleScroll = () => updateFadeIndicator();
navRef.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll);
onCleanup(() => {
navRef?.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
});
});
createEffect(() => {
if (!navRef) return;
const activeId = props.activeTab();
if (!activeId) return;
const activeEl = navRef.querySelector<HTMLElement>(`[data-tab-id="${activeId}"]`);
if (!activeEl) return;
requestAnimationFrame(() => {
activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
updateFadeIndicator();
});
});
const handlePlatformClick = (platform: PlatformTab) => {
props.onPlatformClick(platform);
setDrawerOpen(false);
};
const handleUtilityClick = (tab: UtilityTab) => {
props.onUtilityClick(tab);
setDrawerOpen(false);
};
const handleTouchStart: JSX.EventHandlerUnion<HTMLDivElement, TouchEvent> = (event) => {
const touch = event.touches[0];
if (!touch) return;
setTouchStartX(touch.clientX);
setTouchStartY(touch.clientY);
};
const handleTouchEnd: JSX.EventHandlerUnion<HTMLDivElement, TouchEvent> = (event) => {
const touch = event.changedTouches[0];
if (!touch || touchStartX() === null || touchStartY() === null) {
setTouchStartX(null);
setTouchStartY(null);
return;
}
const deltaX = touch.clientX - touchStartX()!;
const deltaY = touch.clientY - touchStartY()!;
if (deltaX > 60 && Math.abs(deltaY) < 40) {
setDrawerOpen(false);
}
setTouchStartX(null);
setTouchStartY(null);
};
const renderAlertsBadge = () => {
const tab = alertsTab();
const renderAlertsBadge = (tab: UtilityTab) => {
if (tab.id !== 'alerts') return null;
if (!tab || !tab.count || tab.count <= 0) return null;
const critical = tab.breakdown?.critical ?? 0;
const warning = tab.breakdown?.warning ?? 0;
@@ -127,181 +135,78 @@ export function MobileNavBar(props: MobileNavBarProps) {
<>
{/* Bottom navigation bar */}
<nav class="fixed inset-x-0 bottom-0 z-40 border-t border-gray-200 bg-white/95 backdrop-blur dark:border-gray-700 dark:bg-gray-900/95 md:hidden">
<div class="flex items-center justify-around px-2 py-2">
<button
type="button"
onClick={() => infrastructureTab() && handlePlatformClick(infrastructureTab()!)}
class={`flex flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors ${props.activeTab() === 'infrastructure'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
}`}
<div class="relative">
<div
ref={(el) => (navRef = el)}
class="flex items-center gap-1 overflow-x-auto scrollbar-hide px-2 py-2"
role="tablist"
aria-label="Mobile navigation"
>
<ServerIcon class="h-5 w-5" />
<span>Infra</span>
</button>
<For each={orderedPlatformTabs()}>
{(platform) => (
<button
type="button"
data-tab-id={platform.id}
onClick={() => handlePlatformClick(platform)}
title={platform.tooltip}
class={`relative flex shrink-0 flex-col items-center gap-1 rounded-lg px-2 py-1 text-[10px] font-medium transition-colors ${props.activeTab() === platform.id
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
} ${platform.enabled ? '' : 'opacity-70'}`}
>
<span class="relative flex items-center justify-center">
{platform.icon}
</span>
<span class="whitespace-nowrap">{platform.label}</span>
<Show when={!platform.enabled}>
<span class="rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-200">
Setup
</span>
</Show>
<Show when={platform.badge}>
<span class="rounded-full bg-gray-200 px-1.5 py-0.5 text-[9px] font-semibold text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{platform.badge}
</span>
</Show>
</button>
)}
</For>
<button
type="button"
onClick={() => workloadsTab() && handlePlatformClick(workloadsTab()!)}
class={`flex flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors ${props.activeTab() === 'workloads'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
<BoxesIcon class="h-5 w-5" />
<span>Workloads</span>
</button>
<For each={orderedUtilityTabs()}>
{(tab) => (
<button
type="button"
data-tab-id={tab.id}
onClick={() => handleUtilityClick(tab)}
title={tab.tooltip}
class={`relative flex shrink-0 flex-col items-center gap-1 rounded-lg px-2 py-1 text-[10px] font-medium transition-colors ${props.activeTab() === tab.id
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
<span class="relative flex items-center justify-center">
{tab.icon}
{renderAlertsBadge(tab)}
</span>
<span class="whitespace-nowrap">{tab.label}</span>
<Show when={tab.badge === 'update'}>
<span class="mt-0.5 h-1.5 w-1.5 rounded-full bg-red-500"></span>
</Show>
<Show when={tab.badge === 'pro'}>
<span class="rounded-full bg-blue-100 px-1.5 py-0.5 text-[9px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Pro
</span>
</Show>
</button>
)}
</For>
</div>
<button
type="button"
onClick={() => alertsTab() && handleUtilityClick(alertsTab()!)}
class={`relative flex flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors ${props.activeTab() === 'alerts'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
<span class="relative">
<BellIcon class="h-5 w-5" />
{renderAlertsBadge()}
</span>
<span>Alerts</span>
</button>
<Show when={settingsTab()}>
<button
type="button"
onClick={() => settingsTab() && handleUtilityClick(settingsTab()!)}
class={`flex flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors ${props.activeTab() === 'settings'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
<SettingsIcon class="h-5 w-5" />
<span>Settings</span>
</button>
<Show when={showFade()}>
<div class="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-white via-white/80 to-transparent dark:from-gray-900/95 dark:via-gray-900/70"></div>
</Show>
<button
type="button"
onClick={() => setDrawerOpen((prev) => !prev)}
class={`flex flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors ${drawerOpen() || !['infrastructure', 'workloads', 'alerts', 'settings'].includes(props.activeTab())
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
<MoreHorizontalIcon class="h-5 w-5" />
<span>More</span>
</button>
</div>
</nav>
{/* Drawer overlay */}
<div
class={`fixed inset-0 z-50 md:hidden ${drawerOpen() ? 'pointer-events-auto' : 'pointer-events-none'}`}
aria-hidden={!drawerOpen()}
>
<div
class={`absolute inset-0 bg-black/50 transition-opacity duration-200 ${drawerOpen() ? 'opacity-100' : 'opacity-0'}`}
onClick={() => setDrawerOpen(false)}
/>
<div
class={`absolute inset-y-0 right-0 w-80 max-w-[90vw] bg-white shadow-xl transition-transform duration-200 dark:bg-gray-900 ${drawerOpen() ? 'translate-x-0' : 'translate-x-full'}`}
role="dialog"
aria-label="Mobile navigation menu"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-4 dark:border-gray-700">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Menu</div>
<button
type="button"
onClick={() => setDrawerOpen(false)}
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
aria-label="Close menu"
>
<XIcon class="h-5 w-5" />
</button>
</div>
<div class="h-full overflow-y-auto px-4 pb-20 pt-4">
<Show when={morePlatformTabs().length > 0}>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
More Destinations
</div>
<div class="mt-3 space-y-2">
<For each={morePlatformTabs()}>
{(platform) => (
<button
type="button"
onClick={() => handlePlatformClick(platform)}
title={platform.tooltip}
class={`flex w-full items-center justify-between rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium transition-colors dark:border-gray-700 ${platform.enabled
? 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800'
: 'text-gray-400 hover:bg-gray-100 dark:text-gray-500 dark:hover:bg-gray-800'
}`}
>
<span class="flex items-center gap-2">
{platform.icon}
<span>{platform.label}</span>
</span>
<span class="flex items-center gap-2">
<Show when={!platform.enabled}>
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-200">
Setup
</span>
</Show>
<Show when={platform.badge}>
<span class="rounded-full bg-gray-200 px-2 py-0.5 text-[10px] font-semibold text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{platform.badge}
</span>
</Show>
</span>
</button>
)}
</For>
</div>
</Show>
<Show when={moreUtilityTabs().length > 0}>
<div class="mt-6 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
System
</div>
<div class="mt-3 space-y-2">
<For each={moreUtilityTabs()}>
{(tab) => (
<button
type="button"
onClick={() => handleUtilityClick(tab)}
title={tab.tooltip}
class="flex w-full items-center justify-between rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
>
<span class="flex items-center gap-2">
{tab.icon}
<span>{tab.label}</span>
</span>
<span class="flex items-center gap-2">
<Show when={tab.id === 'alerts' && tab.count && tab.count > 0}>
<span class="rounded-full bg-red-600 px-2 py-0.5 text-[10px] font-semibold text-white">
{tab.count}
</span>
</Show>
<Show when={tab.badge === 'update'}>
<span class="h-2 w-2 rounded-full bg-red-500"></span>
</Show>
<Show when={tab.badge === 'pro'}>
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Pro
</span>
</Show>
</span>
</button>
)}
</For>
</div>
</Show>
</div>
</div>
</div>
</>
);
}

View File

@@ -33,10 +33,10 @@ export function SettingsPanel(props: SettingsPanelProps) {
border={false}
{...rest}
>
<div class="bg-blue-50 dark:bg-blue-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="bg-blue-50 dark:bg-blue-900/20 px-3 py-3 sm:px-6 sm:py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<Show when={local.icon}>
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg text-blue-600 dark:text-blue-300">
<div class="p-1.5 sm:p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg text-blue-600 dark:text-blue-300">
{local.icon}
</div>
</Show>
@@ -51,7 +51,7 @@ export function SettingsPanel(props: SettingsPanelProps) {
</Show>
</div>
</div>
<div class={`p-6 ${local.bodyClass ?? 'space-y-6'}`}>{local.children}</div>
<div class={`p-3 sm:p-6 ${local.bodyClass ?? 'space-y-6'}`}>{local.children}</div>
</Card>
);
}

View File

@@ -1,5 +1,6 @@
import { Component, Show, createMemo, JSX } from 'solid-js';
import { MetricBar } from '@/components/Dashboard/MetricBar';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { formatPercent } from '@/utils/format';
export interface ResponsiveMetricCellProps {
@@ -57,6 +58,34 @@ function getMetricColorClass(value: number, type: 'cpu' | 'memory' | 'disk'): st
return 'text-gray-600 dark:text-gray-400';
}
function compactCapacityLabel(sublabel?: string): string | undefined {
if (!sublabel) return undefined;
const raw = sublabel.trim();
const parts = raw.split('/');
if (parts.length < 2) return raw;
const leftRaw = parts[0]?.trim();
const rightRaw = parts.slice(1).join('/').trim();
if (!leftRaw || !rightRaw) return raw;
const rightUnitMatch = rightRaw.match(/[A-Za-z]+$/);
const leftUnitMatch = leftRaw.match(/[A-Za-z]+$/);
const rightUnit = rightUnitMatch?.[0];
const leftUnit = leftUnitMatch?.[0];
let normalizedLeft = leftRaw;
if (rightUnit && leftUnit && rightUnit === leftUnit) {
normalizedLeft = leftRaw.slice(0, Math.max(0, leftRaw.length - rightUnit.length)).trim();
}
const compactLeft = normalizedLeft.replace(/\s+/g, '');
const compactRight = rightRaw.replace(/\s+/g, '');
if (!compactLeft || !compactRight) return raw;
return `${compactLeft}/${compactRight}`;
}
/**
* A responsive metric cell that shows a simple colored percentage on mobile
* and a full MetricBar (with progress bar or sparkline) on desktop.
@@ -73,10 +102,25 @@ function getMetricColorClass(value: number, type: 'cpu' | 'memory' | 'disk'): st
* ```
*/
export const ResponsiveMetricCell: Component<ResponsiveMetricCellProps> = (props) => {
const { isAtLeast, isBelow } = useBreakpoint();
const displayLabel = createMemo(() => props.label ?? formatPercent(props.value));
const colorClass = createMemo(() => getMetricColorClass(props.value, props.type));
const isRunning = () => props.isRunning !== false; // Default to true if not specified
const isVeryNarrow = createMemo(() => isBelow('xs'));
const isMedium = createMemo(() => isAtLeast('md') && isBelow('lg'));
const isWide = createMemo(() => isAtLeast('lg'));
const compactSublabel = createMemo(() => compactCapacityLabel(props.sublabel));
const resolvedSublabel = createMemo(() => {
if (isWide()) return props.sublabel;
if (isMedium()) return compactSublabel();
return undefined;
});
const showLabel = createMemo(() => !isVeryNarrow());
const showMobileText = createMemo(() => Boolean(props.showMobile) && !isVeryNarrow());
const showMetricBar = createMemo(() => !props.showMobile || isVeryNarrow());
const defaultFallback = (
<div class="h-4 flex items-center justify-center">
<span class="text-xs text-gray-400 dark:text-gray-500"></span>
@@ -87,18 +131,19 @@ export const ResponsiveMetricCell: Component<ResponsiveMetricCellProps> = (props
<Show when={isRunning()} fallback={props.fallback ?? defaultFallback}>
<div class={props.class}>
{/* Mobile: Colored percentage text */}
<Show when={props.showMobile}>
<div class={`md:hidden text-xs text-center ${colorClass()}`}>
<Show when={showMobileText()}>
<div class={`md:hidden text-xs text-center ${colorClass()} whitespace-nowrap overflow-hidden text-ellipsis`}>
{displayLabel()}
</div>
</Show>
{/* Desktop: Full MetricBar with sparkline support */}
<div class={props.showMobile ? 'hidden md:block' : ''}>
<div class={showMetricBar() ? '' : 'hidden md:block'}>
<MetricBar
value={props.value}
label={displayLabel()}
sublabel={props.sublabel}
sublabel={resolvedSublabel()}
showLabel={showLabel()}
type={props.type}
resourceId={props.resourceId}
/>

View File

@@ -144,9 +144,9 @@ const toResource = (v2: V2Resource): Resource => {
network:
v2.metrics?.netIn || v2.metrics?.netOut
? {
rxBytes: v2.metrics?.netIn?.value ?? 0,
txBytes: v2.metrics?.netOut?.value ?? 0,
}
rxBytes: v2.metrics?.netIn?.value ?? 0,
txBytes: v2.metrics?.netOut?.value ?? 0,
}
: undefined,
uptime: v2.agent?.uptimeSeconds ?? v2.proxmox?.uptime,
tags: v2.tags,
@@ -193,7 +193,7 @@ export function useUnifiedResources() {
initialValue: [],
});
const wsStore = getGlobalWebSocketStore();
let refreshHandle: number | undefined;
let refreshHandle: ReturnType<typeof setTimeout> | undefined;
const scheduleRefetch = () => {
if (refreshHandle !== undefined) {

View File

@@ -12,7 +12,18 @@ import type { Resource } from '@/types/resource';
export function Infrastructure() {
const { resources, loading, error, refetch } = useUnifiedResources();
const location = useLocation();
// Track if we've completed initial load to prevent flash of empty state
const [initialLoadComplete, setInitialLoadComplete] = createSignal(false);
createEffect(() => {
if (!loading() && !initialLoadComplete()) {
setInitialLoadComplete(true);
}
});
const hasResources = createMemo(() => resources().length > 0);
// Only show "no resources" after initial load completes with zero results
const showNoResources = createMemo(() => initialLoadComplete() && !hasResources() && !error());
const [selectedSources, setSelectedSources] = createSignal<Set<string>>(new Set());
const [selectedStatuses, setSelectedStatuses] = createSignal<Set<string>>(new Set());
const [expandedResourceId, setExpandedResourceId] = createSignal<string | null>(null);
@@ -156,8 +167,19 @@ export function Infrastructure() {
};
const clearFilters = () => {
setSelectedSources(new Set());
setSelectedStatuses(new Set());
setSelectedSources(new Set<string>());
setSelectedStatuses(new Set<string>());
};
const segmentedButtonClass = (selected: boolean, disabled: boolean) => {
const base = 'px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95';
if (disabled) {
return `${base} text-gray-400 dark:text-gray-600 cursor-not-allowed`;
}
if (selected) {
return `${base} bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600`;
}
return `${base} text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50`;
};
const filteredResources = createMemo(() => {
@@ -186,7 +208,7 @@ export function Infrastructure() {
const hasFilteredResources = createMemo(() => filteredResources().length > 0);
return (
<div class="space-y-4 px-4">
<div class="space-y-4">
<SectionHeader
title="Infrastructure"
description="Unified host inventory across monitored platforms."
@@ -221,7 +243,7 @@ export function Infrastructure() {
}
>
<Show
when={hasResources()}
when={!showNoResources()}
fallback={
<Card class="p-6">
<EmptyState
@@ -233,79 +255,69 @@ export function Infrastructure() {
}
>
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-3 rounded-md border border-gray-200 bg-white/70 px-3 py-2 text-[10px] text-gray-500 shadow-sm dark:border-gray-700 dark:bg-gray-800/40 dark:text-gray-400">
<div class="flex items-center gap-2">
<span class="uppercase tracking-wide text-[9px] text-gray-400 dark:text-gray-500">Source</span>
<div class="flex flex-wrap gap-1.5">
<For each={sourceOptions}>
{(source) => {
const isSelected = () => selectedSources().has(source.key);
const isDisabled = () =>
!availableSources().has(source.key) && !selectedSources().has(source.key);
return (
<button
type="button"
disabled={isDisabled()}
aria-pressed={isSelected()}
onClick={() => toggleSource(source.key)}
class={`px-2 py-0.5 rounded border text-[10px] font-medium transition-colors ${
isSelected()
? 'border-blue-300 bg-blue-100 text-blue-700 dark:border-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
: isDisabled()
? 'border-gray-200 text-gray-300 dark:border-gray-700 dark:text-gray-600 cursor-not-allowed'
: 'border-gray-300 text-gray-500 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700/50'
}`}
>
{source.label}
</button>
);
}}
</For>
<Card padding="sm" class="mb-4">
<div class="flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-2">
<span class="uppercase tracking-wide text-[9px] text-gray-400 dark:text-gray-500">Source</span>
<div class="inline-flex flex-wrap rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<For each={sourceOptions}>
{(source) => {
const isSelected = () => selectedSources().has(source.key);
const isDisabled = () =>
!availableSources().has(source.key) && !selectedSources().has(source.key);
return (
<button
type="button"
disabled={isDisabled()}
aria-pressed={isSelected()}
onClick={() => toggleSource(source.key)}
class={segmentedButtonClass(isSelected(), isDisabled())}
>
{source.label}
</button>
);
}}
</For>
</div>
</div>
</div>
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700" />
<div class="h-5 w-px bg-gray-200 dark:bg-gray-700 hidden sm:block" />
<div class="flex items-center gap-2">
<span class="uppercase tracking-wide text-[9px] text-gray-400 dark:text-gray-500">Status</span>
<div class="flex flex-wrap gap-1.5">
<For each={statusOptions()}>
{(status) => {
const isSelected = () => selectedStatuses().has(status.key);
const isDisabled = () =>
!availableStatuses().has(status.key) && !selectedStatuses().has(status.key);
return (
<button
type="button"
disabled={isDisabled()}
aria-pressed={isSelected()}
onClick={() => toggleStatus(status.key)}
class={`px-2 py-0.5 rounded border text-[10px] font-medium transition-colors ${
isSelected()
? 'border-blue-300 bg-blue-100 text-blue-700 dark:border-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
: isDisabled()
? 'border-gray-200 text-gray-300 dark:border-gray-700 dark:text-gray-600 cursor-not-allowed'
: 'border-gray-300 text-gray-500 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700/50'
}`}
>
{status.label}
</button>
);
}}
</For>
<div class="flex items-center gap-2">
<span class="uppercase tracking-wide text-[9px] text-gray-400 dark:text-gray-500">Status</span>
<div class="inline-flex flex-wrap rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<For each={statusOptions()}>
{(status) => {
const isSelected = () => selectedStatuses().has(status.key);
const isDisabled = () =>
!availableStatuses().has(status.key) && !selectedStatuses().has(status.key);
return (
<button
type="button"
disabled={isDisabled()}
aria-pressed={isSelected()}
onClick={() => toggleStatus(status.key)}
class={segmentedButtonClass(isSelected(), isDisabled())}
>
{status.label}
</button>
);
}}
</For>
</div>
</div>
</div>
<Show when={hasActiveFilters()}>
<button
type="button"
onClick={clearFilters}
class="ml-auto text-[10px] font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear
</button>
</Show>
</div>
<Show when={hasActiveFilters()}>
<button
type="button"
onClick={clearFilters}
class="ml-auto text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear
</button>
</Show>
</div>
</Card>
<Show
when={hasFilteredResources()}