diff --git a/frontend-modern/src/components/Alerts/ResourceTable.tsx b/frontend-modern/src/components/Alerts/ResourceTable.tsx index 3121e739d..b14b99d4b 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.tsx @@ -712,7 +712,7 @@ export function ResourceTable(props: ResourceTableProps) {
-
+
diff --git a/frontend-modern/src/components/Backups/UnifiedBackups.tsx b/frontend-modern/src/components/Backups/UnifiedBackups.tsx index 27549881b..347fc5886 100644 --- a/frontend-modern/src/components/Backups/UnifiedBackups.tsx +++ b/frontend-modern/src/components/Backups/UnifiedBackups.tsx @@ -2042,7 +2042,7 @@ const UnifiedBackups: Component = () => { {/* Table */} -
+
diff --git a/frontend-modern/src/components/shared/Card.tsx b/frontend-modern/src/components/shared/Card.tsx index 89e043b1c..8921a23ae 100644 --- a/frontend-modern/src/components/shared/Card.tsx +++ b/frontend-modern/src/components/shared/Card.tsx @@ -22,9 +22,9 @@ const toneClassMap: Record = { const paddingClassMap: Record = { 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) { diff --git a/frontend-modern/src/components/shared/MobileNavBar.tsx b/frontend-modern/src/components/shared/MobileNavBar.tsx index c301378f7..2ee7238a8 100644 --- a/frontend-modern/src/components/shared/MobileNavBar.tsx +++ b/frontend-modern/src/components/shared/MobileNavBar.tsx @@ -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(null); - const [touchStartY, setTouchStartY] = createSignal(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(`[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 = (event) => { - const touch = event.touches[0]; - if (!touch) return; - setTouchStartX(touch.clientX); - setTouchStartY(touch.clientY); - }; - - const handleTouchEnd: JSX.EventHandlerUnion = (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 */}
- - {/* Drawer overlay */} -
-
setDrawerOpen(false)} - /> - -
); } diff --git a/frontend-modern/src/components/shared/SettingsPanel.tsx b/frontend-modern/src/components/shared/SettingsPanel.tsx index 6bb7b4484..e40b84664 100644 --- a/frontend-modern/src/components/shared/SettingsPanel.tsx +++ b/frontend-modern/src/components/shared/SettingsPanel.tsx @@ -33,10 +33,10 @@ export function SettingsPanel(props: SettingsPanelProps) { border={false} {...rest} > -
+
-
+
{local.icon}
@@ -51,7 +51,7 @@ export function SettingsPanel(props: SettingsPanelProps) {
-
{local.children}
+
{local.children}
); } diff --git a/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx b/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx index c5f65c6e2..09f461260 100644 --- a/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx +++ b/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx @@ -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 = (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 = (
@@ -87,18 +131,19 @@ export const ResponsiveMetricCell: Component = (props
{/* Mobile: Colored percentage text */} - -
+ +
{displayLabel()}
{/* Desktop: Full MetricBar with sparkline support */} -
+
diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index adbbcac47..39d07e5a9 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -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 | undefined; const scheduleRefetch = () => { if (refreshHandle !== undefined) { diff --git a/frontend-modern/src/pages/Infrastructure.tsx b/frontend-modern/src/pages/Infrastructure.tsx index bbf4d62cb..8b1419f3b 100644 --- a/frontend-modern/src/pages/Infrastructure.tsx +++ b/frontend-modern/src/pages/Infrastructure.tsx @@ -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>(new Set()); const [selectedStatuses, setSelectedStatuses] = createSignal>(new Set()); const [expandedResourceId, setExpandedResourceId] = createSignal(null); @@ -156,8 +167,19 @@ export function Infrastructure() { }; const clearFilters = () => { - setSelectedSources(new Set()); - setSelectedStatuses(new Set()); + setSelectedSources(new Set()); + setSelectedStatuses(new Set()); + }; + + 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 ( -
+
-
-
- Source -
- - {(source) => { - const isSelected = () => selectedSources().has(source.key); - const isDisabled = () => - !availableSources().has(source.key) && !selectedSources().has(source.key); - return ( - - ); - }} - + +
+
+ Source +
+ + {(source) => { + const isSelected = () => selectedSources().has(source.key); + const isDisabled = () => + !availableSources().has(source.key) && !selectedSources().has(source.key); + return ( + + ); + }} + +
-
-
+ +