feat(ui): optimize mobile view for Alerts, Storage, and Navigation

- Implement compact mobile navigation with label truncation on xs screens
- Optimize Alerts Overview with tighter spacing and better description truncation
- Enhance Storage table mobile view: consolidate Shared column, use StatusDots, and increase bar thickness
- Increase Node Summary table row height and column min-widths for readability
- Add xs (400px) breakpoint for granular mobile styling
This commit is contained in:
rcourtman 2025-12-26 13:26:21 +00:00
parent 3eedbff6e6
commit 03d680365c
18 changed files with 1614 additions and 1586 deletions

View file

@ -860,7 +860,7 @@ function App() {
<button
type="button"
onClick={() => aiChatStore.toggle()}
class="fixed right-0 top-1/2 -translate-y-1/2 z-40 flex items-center gap-1.5 pl-2 pr-1.5 py-3 rounded-l-xl bg-gradient-to-r from-purple-600 to-purple-700 text-white shadow-lg hover:from-purple-700 hover:to-purple-800 transition-all duration-200 group"
class="fixed right-0 top-1/2 -translate-y-1/2 z-40 flex items-center gap-1.5 pl-2 pr-1.5 py-3 rounded-l-xl bg-gradient-to-r from-purple-600 to-purple-700 text-white shadow-lg hover:from-purple-700 hover:to-purple-800 transition-all duration-200 group sm:top-1/2 sm:translate-y-[-50%] top-auto bottom-20 translate-y-0"
title={aiChatStore.context.context?.name ? `AI Assistant - ${aiChatStore.context.context.name}` : 'AI Assistant (⌘K)'}
aria-label="Expand AI Assistant"
>
@ -1285,7 +1285,7 @@ function AppLayout(props: {
const isActive = () => getActiveTab() === platform.id;
const disabled = () => !platform.enabled;
const baseClasses =
'tab relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer';
'tab relative px-1.5 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer';
const className = () => {
if (isActive()) {
@ -1311,19 +1311,20 @@ function AppLayout(props: {
title={title()}
>
{platform.icon}
<span>{platform.label}</span>
<span class="hidden xs:inline">{platform.label}</span>
<span class="xs:hidden">{platform.label.charAt(0)}</span>
</div>
);
}}
</For>
</div>
<div class="flex items-end gap-2 ml-auto" role="group" aria-label="System">
<div class="flex items-end gap-1 pl-3 sm:pl-4">
<div class="flex items-end gap-1 ml-auto" role="group" aria-label="System">
<div class="flex items-end gap-1 pl-1 sm:pl-4">
<For each={utilityTabs()}>
{(tab) => {
const isActive = () => getActiveTab() === tab.id;
const baseClasses =
'tab relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer';
'tab relative px-1.5 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer';
const className = () => {
if (isActive()) {
@ -1341,8 +1342,9 @@ function AppLayout(props: {
title={tab.tooltip}
>
{tab.icon}
<span class="flex items-center gap-1.5">
<span>{tab.label}</span>
<span class="flex items-center gap-1">
<span class="hidden xs:inline">{tab.label}</span>
<span class="xs:hidden">{tab.label.charAt(0)}</span>
{tab.id === 'alerts' && (() => {
const total = tab.count ?? 0;
if (total <= 0) {

View file

@ -146,13 +146,13 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
commitSearchToHistory(e.currentTarget.value);
}
}}
class="w-full pl-9 pr-20 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
class="w-full pl-8 sm:pl-9 pr-14 sm:pr-20 py-1.5 sm:py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search backups by name, VMID, or filter by node"
/>
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 dark:text-gray-500"
class="absolute left-2.5 sm:left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@ -167,7 +167,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
<Show when={props.search()}>
<button
type="button"
class="absolute right-14 top-1/2 -translate-y-1/2 transform p-1 rounded-full
class="absolute right-12 sm:right-14 top-1/2 -translate-y-1/2 transform p-1 rounded-full
bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-300
hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/50 dark:hover:text-red-400
transition-all duration-150 active:scale-90"
@ -297,7 +297,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
</div>
{/* Row 2: Filters - Compact horizontal layout */}
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-1.5 sm:gap-2">
{/* Source Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button

View file

@ -171,9 +171,9 @@ const UnifiedBackups: Component = () => {
{ id: 'node', label: 'Node', priority: 'essential' },
{ id: 'owner', label: 'Owner', priority: 'secondary', toggleable: true },
{ id: 'backupTime', label: 'Time', priority: 'essential' },
{ id: 'size', label: 'Size', priority: 'secondary', toggleable: true },
{ id: 'size', label: 'Size', priority: 'primary', toggleable: true },
{ id: 'backupType', label: 'Backup Type', priority: 'essential' },
{ id: 'storage', label: 'Location', priority: 'secondary', toggleable: true },
{ id: 'storage', label: 'Location', priority: 'primary', toggleable: true },
{ id: 'verified', label: 'Verified', priority: 'secondary', toggleable: true },
{ id: 'comment', label: 'Comment', priority: 'supplementary', toggleable: true },
{ id: 'details', label: 'Details', priority: 'essential' },
@ -2100,58 +2100,58 @@ const UnifiedBackups: Component = () => {
{/* Mobile Card View removed in favor of scrollable table */}
{/* Desktop Table View */}
<table class="backup-table" style={{ "min-width": "1200px" }}>
<table class="backup-table" style={{ "min-width": "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-600">
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-1.5 sm:px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('vmid')}
>
{hasHostBackups() ? 'ID' : 'VMID'}{' '}
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-1.5 sm:px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('type')}
>
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-1.5 sm:px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('name')}
>
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-1.5 sm:px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('node')}
>
Node {sortKey() === 'node' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<Show when={isColumnVisible('owner') && (backupTypeFilter() === 'all' || backupTypeFilter() === 'remote')}>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-1.5 sm:px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('owner')}
>
Owner {sortKey() === 'owner' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
</Show>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-1.5 sm:px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('backupTime')}
>
Time {sortKey() === 'backupTime' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<Show when={isColumnVisible('size') && backupTypeFilter() !== 'snapshot'}>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-1.5 sm:px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('size')}
>
Size {sortKey() === 'size' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
</Show>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-1.5 sm:px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('backupType')}
>
Backup{' '}
@ -2159,7 +2159,7 @@ const UnifiedBackups: Component = () => {
</th>
<Show when={isColumnVisible('storage') && backupTypeFilter() !== 'snapshot'}>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-1.5 sm:px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('storage')}
>
Location{' '}
@ -2209,8 +2209,8 @@ const UnifiedBackups: Component = () => {
<For each={group.items}>
{(item) => (
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="p-0.5 pl-5 pr-1.5 text-sm align-middle">{item.vmid}</td>
<td class="p-0.5 px-1.5 align-middle">
<td class="p-0.5 pl-3 sm:pl-5 pr-1 sm:pr-1.5 text-xs sm:text-sm align-middle">{item.vmid}</td>
<td class="p-0.5 px-1 sm:px-1.5 align-middle">
<span
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${item.type === 'VM'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
@ -2247,7 +2247,7 @@ const UnifiedBackups: Component = () => {
{item.size ? formatBytes(item.size) : '-'}
</td>
</Show>
<td class="p-0.5 px-1.5 align-middle">
<td class="p-0.5 px-1 sm:px-1.5 align-middle">
<div class="flex items-center gap-1">
<span
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${item.backupType === 'snapshot'

View file

@ -1098,7 +1098,7 @@ export function Dashboard(props: DashboardProps) {
<ComponentErrorBoundary name="Guest Table">
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": isMobile() ? "100%" : "900px" }}>
<table class="w-full border-collapse whitespace-nowrap" style={{ "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">
<For each={visibleColumns()}>

View file

@ -138,7 +138,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
commitSearchToHistory(e.currentTarget.value);
}
}}
class={`w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
class={`w-full pl-8 sm:pl-9 pr-14 sm:pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all`}
/>
@ -158,7 +158,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<Show when={props.search()}>
<button
type="button"
class="absolute right-[4.5rem] top-1/2 -translate-y-1/2 transform p-1 rounded-full
class="absolute right-[3.5rem] sm:right-[4.5rem] top-1/2 -translate-y-1/2 transform p-1 rounded-full
bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-300
hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/50 dark:hover:text-red-400
transition-all duration-150 active:scale-90"
@ -301,7 +301,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
</div>
{/* Row 2: Filters - grouped for logical wrapping */}
<div class="flex flex-wrap items-center gap-x-2 gap-y-2">
<div class="flex flex-wrap items-center gap-x-1.5 sm:gap-x-2 gap-y-2">
{/* Problems Toggle - Prominent "Show me issues" button */}
<button
type="button"
@ -427,7 +427,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
{/* End Primary Filters Group */}
{/* Secondary Controls Group: Grouping, View, Columns, Reset */}
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-x-1.5 sm:gap-x-2 gap-y-2">
{/* Grouping Mode Toggle */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">

View file

@ -2806,7 +2806,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
>
<Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": isMobile() ? "100%" : "800px" }}>
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": "800px" }}>
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20">
<For each={DOCKER_COLUMNS}>

View file

@ -856,7 +856,7 @@ export const HostsOverview: Component = () => {
<Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;">
<style>{`.overflow-x-auto::-webkit-scrollbar { display: none; }`}</style>
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": isMobile() ? "100%" : "900px" }}>
<table class="w-full border-collapse whitespace-nowrap" 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-700">
{/* Essential columns */}
@ -1168,7 +1168,7 @@ const HostRow: Component<HostRowProps> = (props) => {
<EnhancedCPUBar
usage={cpuPercent}
loadAverage={host.loadAverage?.[0]}
cores={host.cpuCount}
cores={props.isMobile() ? undefined : host.cpuCount}
/>
</Show>
</td>

View file

@ -60,17 +60,19 @@ export const ProxmoxSectionNav: Component<ProxmoxSectionNavProps> = (props) => {
const hasCeph = state.cephClusters && state.cephClusters.length > 0;
const hasReplication = state.replicationJobs && state.replicationJobs.length > 0;
return allSections.filter((section) =>
(section.id !== 'mail' || hasPMG) &&
(section.id !== 'ceph' || hasCeph) &&
(section.id !== 'replication' || hasReplication)
section.id === props.current || (
(section.id !== 'mail' || hasPMG) &&
(section.id !== 'ceph' || hasCeph) &&
(section.id !== 'replication' || hasReplication)
)
);
});
const baseClasses =
'inline-flex items-center px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900';
'inline-flex items-center px-1.5 sm:px-3 py-1 text-[11px] sm:text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900';
return (
<div class={`flex flex-wrap items-center gap-3 sm:gap-4 ${props.class ?? ''}`} aria-label="Proxmox sections">
<div class={`flex flex-wrap items-center gap-1.5 sm:gap-4 ${props.class ?? ''}`} aria-label="Proxmox sections">
<For each={sections()}>{(section) => {
const isActive = section.id === props.current;
const classes = isActive

View file

@ -292,7 +292,7 @@ const findMatchingHostAgent = (
export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
return (
<Card padding="none" tone="glass" class="overflow-x-auto rounded-lg">
<table class="min-w-[800px] divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<table class="min-w-[900px] divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/70">
<tr>
<th scope="col" class="py-2 pl-4 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
@ -594,7 +594,7 @@ const resolvePbsStatusMeta = (
export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
return (
<Card padding="none" tone="glass" class="overflow-x-auto rounded-lg">
<table class="min-w-[800px] divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<table class="min-w-[900px] divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/70">
<tr>
<th scope="col" class="py-2 pl-4 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
@ -790,7 +790,7 @@ const resolvePmgStatusMeta = (
export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
return (
<Card padding="none" tone="glass" class="overflow-x-auto rounded-lg">
<table class="min-w-[800px] divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<table class="min-w-[900px] divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/70">
<tr>
<th scope="col" class="py-2 pl-4 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">

View file

@ -2406,7 +2406,7 @@ const Settings: Component<SettingsProps> = (props) => {
<Show when={flatTabs.length > 0}>
<div class="lg:hidden border-b border-gray-200 dark:border-gray-700">
<div
class="flex gap-1 px-2 py-1 w-full overflow-x-auto scrollbar-hide"
class="flex gap-1 px-2 py-1 w-full overflow-x-auto"
style="-webkit-overflow-scrolling: touch;"
>
<For each={flatTabs}>
@ -2489,9 +2489,9 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</Show>
<Show when={initialLoadComplete()}>
<Card padding="lg">
<div class="space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<Card padding="none" tone="glass">
<div class="px-3 py-4 sm:px-6 sm:py-6 space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">
Proxmox VE nodes
</h4>
@ -2779,9 +2779,9 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</Show>
<Show when={initialLoadComplete()}>
<Card padding="lg">
<div class="space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<Card padding="none" tone="glass">
<div class="px-3 py-4 sm:px-6 sm:py-6 space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">
Proxmox Backup Server nodes
</h4>
@ -2909,7 +2909,7 @@ const Settings: Component<SettingsProps> = (props) => {
No PBS nodes configured
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
Add a Proxmox Backup Server to monitor your backup infrastructure
Add a Proxmox Backup Server to monitor your backups
</p>
</div>
</Show>
@ -3066,9 +3066,9 @@ const Settings: Component<SettingsProps> = (props) => {
</Show>
<Show when={initialLoadComplete()}>
<Card padding="lg">
<div class="space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<Card padding="none" tone="glass">
<div class="px-3 py-4 sm:px-6 sm:py-6 space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">
Proxmox Mail Gateway nodes
</h4>
@ -3174,7 +3174,7 @@ const Settings: Component<SettingsProps> = (props) => {
globalTemperatureMonitoringEnabled={temperatureMonitoringEnabled()}
onTestConnection={testNodeConnection}
onEdit={(node) => {
setEditingNode(nodes().find((n) => n.id === node.id) ?? null);
setEditingNode(node);
setCurrentNodeType('pmg');
setModalResetKey((prev) => prev + 1);
setShowNodeModal(true);
@ -3192,8 +3192,7 @@ const Settings: Component<SettingsProps> = (props) => {
No PMG nodes configured
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
Add a Proxmox Mail Gateway to monitor mail queue and quarantine
metrics
Add a Proxmox Mail Gateway node to start monitoring
</p>
</div>
</Show>
@ -3515,7 +3514,7 @@ const Settings: Component<SettingsProps> = (props) => {
<DiagnosticsPanel />
</Show>
</div>
</div>
</div >
</Card >
</div >
@ -3793,7 +3792,7 @@ const Settings: Component<SettingsProps> = (props) => {
</Show >
{/* Update Confirmation Modal */}
<UpdateConfirmationModal
< UpdateConfirmationModal
isOpen={showUpdateConfirmation()}
onClose={() => setShowUpdateConfirmation(false)}
onConfirm={handleConfirmUpdate}

View file

@ -17,29 +17,29 @@ const allSections: Array<{
label: string;
icon: typeof Server;
}> = [
{
id: 'pve',
label: 'Virtual Environment',
icon: Server,
},
{
id: 'pbs',
label: 'Backup Server',
icon: HardDrive,
},
{
id: 'pmg',
label: 'Mail Gateway',
icon: Mail,
},
];
{
id: 'pve',
label: 'Virtual Environment',
icon: Server,
},
{
id: 'pbs',
label: 'Backup Server',
icon: HardDrive,
},
{
id: 'pmg',
label: 'Mail Gateway',
icon: Mail,
},
];
export const SettingsSectionNav: Component<SettingsSectionNavProps> = (props) => {
const baseClasses =
'inline-flex items-center gap-2 px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900';
return (
<div class={`flex flex-wrap items-center gap-3 sm:gap-4 ${props.class ?? ''}`} aria-label="Settings sections">
<div class={`flex flex-wrap items-center gap-2 sm:gap-4 ${props.class ?? ''}`} aria-label="Settings sections">
<For each={allSections}>
{(section) => {
const isActive = section.id === props.current;
@ -56,8 +56,8 @@ export const SettingsSectionNav: Component<SettingsSectionNavProps> = (props) =>
onClick={() => props.onSelect(section.id)}
aria-pressed={isActive}
>
<Icon size={16} stroke-width={2} />
<span>{section.label}</span>
<Icon size={14} stroke-width={2} class="sm:w-4 sm:h-4" />
<span class="whitespace-nowrap">{section.label}</span>
</button>
);
}}

View file

@ -55,7 +55,7 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
};
return (
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div ref={containerRef} class="metric-text w-full h-5 flex items-center justify-center">
<div
class="relative w-full max-w-[150px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
onMouseEnter={handleMouseEnter}

View file

@ -4,6 +4,7 @@ import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { formatBytes, formatPercent } from '@/utils/format';
import type { Storage as StorageType, CephCluster } from '@/types/api';
import { StatusDot } from '@/components/shared/StatusDot';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
import { StorageFilter } from './StorageFilter';
@ -809,7 +810,7 @@ const Storage: Component = () => {
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }
`}</style>
<table class="w-full" style={{ "min-width": "750px" }}>
<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">
<th class="px-1.5 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-auto">
@ -826,16 +827,16 @@ const Storage: Component = () => {
</th>
</Show>
<Show when={isColumnVisible('status')}>
<th class="px-1.5 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
<th class="px-1.5 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%] min-w-[70px]">
Status
</th>
</Show>
<Show when={viewMode() === 'node' && isColumnVisible('shared')}>
<th class="px-1.5 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[6%]">
<th class="hidden sm:table-cell px-1.5 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[6%]">
Shared
</th>
</Show>
<th class="px-1.5 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[25%] min-w-[120px]">
<th class="px-1.5 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[25%] min-w-[130px]">
Usage
</th>
<Show when={isColumnVisible('free')}>
@ -1187,9 +1188,16 @@ const Storage: Component = () => {
</td>
<Show when={isColumnVisible('type')}>
<td class="p-0.5 px-1.5">
<span class="inline-block px-1.5 py-0.5 text-[10px] font-medium rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{storage.type}
</span>
<div class="flex items-center gap-1.5">
<span class="inline-block px-1.5 py-0.5 text-[10px] font-medium rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{storage.type}
</span>
<Show when={storage.shared}>
<svg class="h-3 w-3 text-blue-500 sm:hidden" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 6a3 3 0 013-3h10a1 1 0 01.8 1.6L14.25 8l2.55 3.4A1 1 0 0116 13H6a1 1 0 00-1 1v3a1 1 0 11-2 0V6z" clip-rule="evenodd" />
</svg>
</Show>
</div>
</td>
</Show>
<Show when={isColumnVisible('content')}>
@ -1204,18 +1212,24 @@ const Storage: Component = () => {
</Show>
<Show when={isColumnVisible('status')}>
<td class="p-0.5 px-1.5 text-xs whitespace-nowrap">
<span
class={`${storage.status === 'available'
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{storage.status || 'unknown'}
</span>
<div class="flex items-center gap-1.5">
<StatusDot
variant={storage.status === 'available' ? 'success' : 'danger'}
size="xs"
/>
<span
class={`${storage.status === 'available'
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{storage.status || 'unknown'}
</span>
</div>
</td>
</Show>
<Show when={viewMode() === 'node' && isColumnVisible('shared')}>
<td class="p-0.5 px-1.5">
<td class="hidden sm:table-cell p-0.5 px-1.5">
<span class="text-xs text-gray-600 dark:text-gray-400">
{storage.shared ? '✓' : '-'}
</span>

View file

@ -146,7 +146,7 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
commitSearchToHistory(e.currentTarget.value);
}
}}
class="w-full pl-9 pr-20 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
class="w-full pl-8 sm:pl-9 pr-14 sm:pr-20 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search storage by name or filter by node"
@ -167,7 +167,7 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
<Show when={props.search()}>
<button
type="button"
class="absolute right-14 top-1/2 -translate-y-1/2 transform p-1 rounded-full
class="absolute right-[3.5rem] sm:right-14 top-1/2 -translate-y-1/2 transform p-1 rounded-full
bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-300
hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/50 dark:hover:text-red-400
transition-all duration-150 active:scale-90"
@ -297,7 +297,7 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
</div>
{/* Row 2: Filters - Compact horizontal layout */}
<div class="flex flex-wrap items-center gap-x-2 gap-y-2">
<div class="flex flex-wrap items-center gap-x-1.5 sm:gap-x-2 gap-y-2">
{/* Group By Filter */}
<Show when={props.groupBy && props.setGroupBy}>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">

View file

@ -348,7 +348,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (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={{ "min-width": isMobile() ? "100%" : "800px" }}>
<table class="w-full border-collapse whitespace-nowrap" 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-700">
<th
@ -360,13 +360,13 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
<th class={thClass} style={{ "min-width": '80px' }} onClick={() => handleSort('uptime')}>
Uptime {renderSortIndicator('uptime')}
</th>
<th class={thClass} style={isMobile() ? { "min-width": "60px" } : { width: "200px", "min-width": "200px", "max-width": "200px" }} onClick={() => handleSort('cpu')}>
<th class={thClass} style={isMobile() ? { "min-width": "80px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }} onClick={() => handleSort('cpu')}>
CPU {renderSortIndicator('cpu')}
</th>
<th class={thClass} style={isMobile() ? { "min-width": "60px" } : { width: "200px", "min-width": "200px", "max-width": "200px" }} onClick={() => handleSort('memory')}>
<th class={thClass} style={isMobile() ? { "min-width": "80px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }} onClick={() => handleSort('memory')}>
Memory {renderSortIndicator('memory')}
</th>
<th class={thClass} style={isMobile() ? { "min-width": "60px" } : { width: "200px", "min-width": "200px", "max-width": "200px" }} onClick={() => handleSort('disk')}>
<th class={thClass} style={isMobile() ? { "min-width": "80px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }} onClick={() => handleSort('disk')}>
Disk {renderSortIndicator('disk')}
</th>
<Show when={hasAnyTemperatureData()}>
@ -483,7 +483,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return (
<tr
class={rowClass()}
style={{ ...rowStyle(), height: '29px', 'max-height': '29px' }}
style={{ ...rowStyle(), 'min-height': '36px' }}
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
>
{/* Name */}
@ -575,8 +575,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* CPU */}
<td class={tdClass} style={isMobile() ? { "min-width": "60px" } : metricColumnStyle}>
<div class="h-4">
<td class={tdClass} style={isMobile() ? { "min-width": "80px" } : metricColumnStyle}>
<div class="h-5">
<EnhancedCPUBar
usage={cpuPercentValue}
loadAverage={isPVEItem ? node!.loadAverage?.[0] : undefined}
@ -588,8 +588,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* Memory */}
<td class={tdClass} style={isMobile() ? { "min-width": "60px" } : metricColumnStyle}>
<div class="h-4">
<td class={tdClass} style={isMobile() ? { "min-width": "80px" } : metricColumnStyle}>
<div class="h-5">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={memoryPercentValue}
@ -626,8 +626,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* Disk */}
<td class={tdClass} style={isMobile() ? { "min-width": "60px" } : metricColumnStyle}>
<div class="h-4">
<td class={tdClass} style={isMobile() ? { "min-width": "80px" } : metricColumnStyle}>
<div class="h-5">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={diskPercentValue}

View file

@ -5,7 +5,7 @@ import { createSignal, onMount, onCleanup, createMemo, Accessor } from 'solid-js
* These match Tailwind's default breakpoints
*/
export const BREAKPOINTS = {
xs: 0,
xs: 400,
sm: 640,
md: 768,
lg: 1024,

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,14 @@ export default {
],
darkMode: 'class',
theme: {
screens: {
'xs': '400px',
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
'2xl': '1536px',
},
extend: {
colors: {
gray: {