mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
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:
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user