feat: improve mobile layout responsiveness

This commit is contained in:
rcourtman 2025-10-22 12:39:53 +00:00
parent 9d1b9a78c4
commit 4ba5dcc785
7 changed files with 178 additions and 54 deletions

View file

@ -21,6 +21,7 @@ import Settings from './components/Settings/Settings';
import { Alerts } from './pages/Alerts';
import { DockerHosts } from './components/Docker/DockerHosts';
import { ToastContainer } from './components/Toast/Toast';
import NotificationContainer from './components/NotificationContainer';
import { ErrorBoundary } from './components/ErrorBoundary';
import { SecurityWarning } from './components/SecurityWarning';
import { Login } from './components/Login';
@ -584,6 +585,7 @@ function App() {
</AppLayout>
</div>
<ToastContainer />
<NotificationContainer />
<TokenRevealDialog />
<TooltipRoot />
</DarkModeContext.Provider>
@ -784,12 +786,34 @@ function AppLayout(props: {
navigate(tab.route);
};
type PlatformTab = ReturnType<typeof platformTabs>[number];
type UtilityTab = ReturnType<typeof utilityTabs>[number];
const mobileTabs = createMemo<
Array<(PlatformTab & { type: 'platform' }) | (UtilityTab & { type: 'utility' })>
>(() => {
const platformEntries = platformTabs().map((tab) => ({ ...tab, type: 'platform' as const }));
const utilityEntries = utilityTabs().map((tab) => ({ ...tab, type: 'utility' as const }));
return [...platformEntries, ...utilityEntries];
});
const handleMobileTabChange = (tabId: PlatformTab['id'] | UtilityTab['id']) => {
const platform = platformTabs().find((tab) => tab.id === tabId);
if (platform) {
handlePlatformClick(platform);
return;
}
const utility = utilityTabs().find((tab) => tab.id === tabId);
if (utility) {
handleUtilityClick(utility);
}
};
return (
<div class="pulse-shell">
{/* Header */}
<div class="header flex flex-row justify-between items-center mb-2">
<div class="hidden md:block md:flex-1"></div>
<div class="flex items-center gap-1 flex-none">
<div class="header mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center justify-between gap-2 sm:gap-3">
<svg
width="20"
height="20"
@ -824,8 +848,8 @@ function AppLayout(props: {
</span>
</Show>
</div>
<div class="header-controls flex justify-end items-center gap-4 md:flex-1">
<div class="flex items-center gap-2">
<div class="header-controls flex w-full flex-wrap items-center gap-3 justify-start sm:w-auto sm:flex-1 sm:justify-end">
<div class="flex w-full flex-wrap items-center gap-2 justify-start sm:w-auto sm:justify-end">
<div
class={`group status text-xs rounded-full flex items-center justify-center transition-all duration-500 ease-in-out px-1.5 ${
props.connected()
@ -904,9 +928,41 @@ function AppLayout(props: {
</div>
</div>
{/* Mobile Tab Switcher */}
<div class="sm:hidden mb-3">
<label for="mobile-tab-switcher" class="sr-only">
Navigate between tabs
</label>
<div class="relative">
<select
id="mobile-tab-switcher"
value={getActiveTab()}
onChange={(event) => handleMobileTabChange(event.currentTarget.value as PlatformTab['id'] | UtilityTab['id'])}
class="w-full appearance-none rounded-lg border border-gray-300 bg-white py-2 pl-3 pr-9 text-sm font-medium text-gray-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:focus:border-blue-400 dark:focus:ring-blue-500/40"
>
<For each={mobileTabs()}>
{(tab) => (
<option value={tab.id}>
{tab.type === 'platform' && !tab.enabled ? `${tab.label} (Configure)` : tab.label}
</option>
)}
</For>
</select>
<div class="pointer-events-none absolute inset-y-0 right-3 flex items-center text-gray-400 dark:text-gray-500">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 10.94l3.71-3.71a.75.75 0 1 1 1.06 1.06l-4.24 4.24a.75.75 0 0 1-1.06 0L5.21 8.29a.75.75 0 0 1 .02-1.08z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
{/* Tabs */}
<div
class="tabs flex items-end gap-2 mb-2 border-b border-gray-300 dark:border-gray-700 overflow-x-auto overflow-y-hidden whitespace-nowrap scrollbar-hide"
class="tabs mb-2 flex items-end gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap border-b border-gray-300 dark:border-gray-700 scrollbar-hide"
role="tablist"
aria-label="Primary navigation"
>
@ -1008,7 +1064,10 @@ function AppLayout(props: {
})()}
</span>
<Show when={tab.badge === 'update'}>
<span class="ml-1 w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
<span class="ml-1 flex items-center">
<span class="sr-only">Update available</span>
<span aria-hidden="true" class="block h-2 w-2 rounded-full bg-red-500 animate-pulse"></span>
</span>
</Show>
</div>
);

View file

@ -1353,7 +1353,7 @@ const UnifiedBackups: Component = () => {
{/* Available Backups chart - keep visible while backups exist or a date filter is active */}
<Show when={hasAnyBackups() || selectedDateRange() !== null}>
<Card padding="md">
<div class="mb-3 flex items-start justify-between gap-3">
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<SectionHeader
title={
<div class="flex items-center gap-1.5">
@ -1416,7 +1416,7 @@ const UnifiedBackups: Component = () => {
</div>
)}
</Show>
<div class="flex flex-wrap items-center justify-end gap-2">
<div class="flex flex-wrap items-center justify-start gap-2 sm:justify-end">
<div class="flex items-center gap-1">
<button
type="button"
@ -1463,7 +1463,7 @@ const UnifiedBackups: Component = () => {
1y
</button>
</div>
<div class="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
<div class="hidden h-4 w-px bg-gray-300 dark:bg-gray-600 sm:block"></div>
<span class="text-gray-500 dark:text-gray-400">Last {chartTimeRange()} days</span>
<Show when={selectedDateRange()}>
<button
@ -1480,7 +1480,7 @@ const UnifiedBackups: Component = () => {
</div>
</div>
</div>
<div class="h-32 relative bg-gray-100 dark:bg-gray-800 rounded overflow-hidden">
<div class="relative min-h-[14rem] sm:min-h-[12rem] bg-gray-100 dark:bg-gray-800 rounded overflow-hidden">
<Show
when={chartData().data.length > 0}
fallback={
@ -1511,7 +1511,9 @@ const UnifiedBackups: Component = () => {
>
<svg
class="available-backups-svg w-full h-full"
style="cursor: pointer"
role="img"
aria-label="Available backups chart"
style="cursor: pointer; touch-action: pan-y;"
ref={(el) => {
// Use createEffect to reactively update the chart
createEffect(() => {

View file

@ -4,6 +4,7 @@ import { Card } from '@/components/shared/Card';
import { MetricBar } from '@/components/Dashboard/MetricBar';
import { renderDockerStatusBadge } from './DockerStatusBadge';
import { formatUptime } from '@/utils/format';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
export interface DockerHostSummary {
host: DockerHost;
@ -114,15 +115,15 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
return (
<Card padding="none" class="mb-4 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full min-w-[720px] border-collapse">
<ScrollableTable minWidth="720px">
<table class="w-full border-collapse">
<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="pl-3 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-1/4 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => handleSort('name')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
tabindex="0"
tabIndex={0}
role="button"
aria-label={`Sort by host ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
>
@ -150,19 +151,19 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
Containers {renderSortIndicator('running')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="hidden px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 sm:table-cell"
onClick={() => handleSort('uptime')}
>
Uptime {renderSortIndicator('uptime')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="hidden px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 sm:table-cell"
onClick={() => handleSort('lastSeen')}
>
Last Update {renderSortIndicator('lastSeen')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="hidden px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 sm:table-cell"
onClick={() => handleSort('agent')}
>
Agent {renderSortIndicator('agent')}
@ -174,6 +175,8 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
{(summary) => {
const selected = props.selectedHostId() === summary.host.id;
const online = isHostOnline(summary.host);
const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—';
const agentLabel = summary.host.agentVersion ? `v${summary.host.agentVersion}` : '—';
const rowStyle = () => {
const styles: Record<string, string> = {};
@ -206,14 +209,16 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
return className;
};
const agentOutdated = isAgentOutdated(summary.host.agentVersion);
return (
<tr
class={rowClass()}
style={rowStyle()}
onClick={() => props.onSelect(summary.host.id)}
>
<td class="pr-2 py-0.5 pl-3 whitespace-nowrap">
<div class="flex items-center gap-1">
<td class="pr-2 py-1 pl-3 align-top">
<div class="flex flex-wrap items-center gap-1">
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100">
{summary.host.displayName}
</span>
@ -228,11 +233,51 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
</span>
</Show>
</div>
<div class="mt-2 grid grid-cols-1 gap-1 text-[10px] text-gray-500 dark:text-gray-400 sm:hidden">
<div class="flex items-center gap-1">
<span class="font-semibold text-gray-600 dark:text-gray-300">Uptime:</span>
<span>{uptimeLabel}</span>
</div>
<div class="flex items-start gap-1">
<span class="font-semibold text-gray-600 dark:text-gray-300">Last:</span>
<span class="flex-1">
<span>{summary.lastSeenRelative}</span>
<Show when={summary.lastSeenAbsolute}>
<span class="block text-[9px] text-gray-400 dark:text-gray-500">
{summary.lastSeenAbsolute}
</span>
</Show>
</span>
</div>
<div class="flex flex-wrap items-center gap-1">
<span class="font-semibold text-gray-600 dark:text-gray-300">Agent:</span>
<span
class={
agentOutdated
? 'rounded px-1 py-0.5 bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 font-medium'
: 'rounded px-1 py-0.5 bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-400 font-medium'
}
>
{agentLabel}
</span>
<Show when={agentOutdated}>
<span class="text-[9px] font-medium text-yellow-600 dark:text-yellow-500">
Update recommended
</span>
</Show>
</div>
<Show when={summary.host.intervalSeconds}>
<div class="flex items-center gap-1">
<span class="font-semibold text-gray-600 dark:text-gray-300">Interval:</span>
<span>{summary.host.intervalSeconds}s</span>
</div>
</Show>
</div>
</td>
<td class="px-2 py-0.5">
<td class="px-2 py-1 align-top">
{renderDockerStatusBadge(summary.host.status)}
</td>
<td class="px-2 py-0.5">
<td class="px-2 py-1 align-top">
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}>
<MetricBar
value={summary.cpuPercent}
@ -241,7 +286,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
/>
</Show>
</td>
<td class="px-2 py-0.5">
<td class="px-2 py-1 align-top">
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}>
<MetricBar
value={summary.memoryPercent}
@ -251,8 +296,8 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
/>
</Show>
</td>
<td class="px-2 py-0.5">
<div class="flex justify-center">
<td class="px-2 py-1 align-top">
<div class="flex justify-start sm:justify-center">
<MetricBar
value={summary.runningPercent}
label={`${summary.runningCount}/${summary.totalCount}`}
@ -260,38 +305,40 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
/>
</div>
</td>
<td class="px-2 py-0.5 whitespace-nowrap">
<td class="hidden px-2 py-1 align-top whitespace-nowrap sm:table-cell">
<span class="text-xs text-gray-600 dark:text-gray-400">
{summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—'}
{uptimeLabel}
</span>
</td>
<td class="px-2 py-0.5">
<td class="hidden px-2 py-1 align-top sm:table-cell">
<div class="text-sm text-gray-900 dark:text-gray-100">
{summary.lastSeenRelative}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{summary.lastSeenAbsolute}
</div>
<Show when={summary.lastSeenAbsolute}>
<div class="text-xs text-gray-500 dark:text-gray-400">
{summary.lastSeenAbsolute}
</div>
</Show>
</td>
<td class="px-2 py-0.5">
<td class="hidden px-2 py-1 align-top sm:table-cell">
<div class="flex flex-col gap-0.5">
<Show when={summary.host.agentVersion}>
<div class="flex flex-col gap-0.5">
<span
class={
isAgentOutdated(summary.host.agentVersion)
agentOutdated
? 'text-[10px] px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium inline-block w-fit'
: 'text-[10px] px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium inline-block w-fit'
}
title={
isAgentOutdated(summary.host.agentVersion)
agentOutdated
? 'Outdated - Update recommended'
: 'Up to date - Auto-update enabled'
}
>
v{summary.host.agentVersion}
</span>
<Show when={isAgentOutdated(summary.host.agentVersion)}>
<Show when={agentOutdated}>
<span class="text-[9px] text-yellow-600 dark:text-yellow-500 font-medium">
Update available
</span>
@ -311,7 +358,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
</For>
</tbody>
</table>
</div>
</ScrollableTable>
</Card>
);
};

View file

@ -1,4 +1,4 @@
import { Component, JSX, createSignal, createEffect, onMount } from 'solid-js';
import { Component, JSX, createSignal, createEffect, onMount, onCleanup } from 'solid-js';
import { Show } from 'solid-js';
interface ScrollableTableProps {
@ -23,6 +23,9 @@ export const ScrollableTable: Component<ScrollableTableProps> = (props) => {
onMount(() => {
checkScroll();
window.addEventListener('resize', checkScroll);
onCleanup(() => {
window.removeEventListener('resize', checkScroll);
});
});
createEffect(() => {
@ -43,7 +46,7 @@ export const ScrollableTable: Component<ScrollableTableProps> = (props) => {
<div
ref={scrollContainer}
class="overflow-x-auto"
style="scrollbar-width: none; -ms-overflow-style: none;"
style="scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain;"
>
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }

View file

@ -31,11 +31,11 @@ export function SectionHeader(props: SectionHeaderProps) {
const sizeClass = () => {
switch (local.size) {
case 'sm':
return 'text-base';
return 'text-base sm:text-lg';
case 'lg':
return 'text-2xl';
return 'text-xl sm:text-2xl lg:text-3xl';
default:
return 'text-lg';
return 'text-lg sm:text-xl';
}
};
@ -47,13 +47,13 @@ export function SectionHeader(props: SectionHeaderProps) {
</span>
</Show>
<h2
class={`${sizeClass()} font-semibold text-gray-900 dark:text-gray-100 ${local.titleClass ?? ''}`.trim()}
class={`${sizeClass()} font-semibold leading-snug text-gray-900 dark:text-gray-100 ${local.titleClass ?? ''}`.trim()}
>
{local.title}
</h2>
<Show when={local.description}>
<p
class={`text-sm text-gray-600 dark:text-gray-400 ${local.descriptionClass ?? ''}`.trim()}
class={`text-sm sm:text-base text-gray-600 dark:text-gray-400 ${local.descriptionClass ?? ''}`.trim()}
>
{local.description}
</p>

View file

@ -119,15 +119,27 @@
@layer components {
.pulse-shell {
width: min(95.5vw, 95rem);
max-width: 100%;
width: 100%;
max-width: 95rem;
margin-inline: auto;
padding-inline: clamp(1.25rem, 3vw, 3.25rem);
padding-inline: clamp(1rem, 3vw, 3.25rem);
transition: padding-inline 0.3s ease;
}
@media (max-width: 640px) {
.pulse-shell {
padding-inline: clamp(0.75rem, 6vw, 1.25rem);
}
}
.pulse-panel {
padding: clamp(0.75rem, 1.6vw, 1.5rem);
padding: clamp(0.75rem, 1.8vw, 1.5rem);
}
@media (max-width: 640px) {
.pulse-panel {
padding: clamp(0.75rem, 5vw, 1.25rem);
}
}
/* Card styles */

View file

@ -10,6 +10,7 @@ import { SectionHeader } from '@/components/shared/SectionHeader';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import { Toggle } from '@/components/shared/Toggle';
import { formField, formControl, formHelpText, labelClass, controlClass } from '@/components/shared/Form';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { useWebSocket } from '@/App';
import { showSuccess, showError } from '@/utils/toast';
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
@ -1795,7 +1796,7 @@ function OverviewTab(props: {
return (
<div class="space-y-6">
{/* Stats Cards */}
<div class="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-4">
<Card padding="sm" class="sm:p-4">
<div class="flex items-center justify-between">
<div>
@ -3073,7 +3074,7 @@ function ScheduleTab(props: ScheduleTabProps) {
<span class={`${labelClass('text-xs uppercase tracking-[0.08em]')} mb-2 block`}>
Grouping strategy
</span>
<div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label
class={`relative flex items-center gap-2 rounded-lg border-2 p-3 transition-all ${
grouping().byNode
@ -4221,7 +4222,7 @@ function HistoryTab() {
<select
value={timeFilter()}
onChange={(e) => setTimeFilter(e.currentTarget.value as '24h' | '7d' | '30d' | 'all')}
class="px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
class="w-full sm:w-auto px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
>
<option value="24h">Last 24h</option>
<option value="7d">Last 7d</option>
@ -4232,14 +4233,14 @@ function HistoryTab() {
<select
value={severityFilter()}
onChange={(e) => setSeverityFilter(e.currentTarget.value as 'warning' | 'critical' | 'all')}
class="px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
class="w-full sm:w-auto px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
>
<option value="all">All Levels</option>
<option value="critical">Critical Only</option>
<option value="warning">Warning Only</option>
</select>
<div class="flex-1 max-w-xs">
<div class="w-full sm:flex-1 sm:max-w-xs">
<input
ref={searchInputRef}
type="text"
@ -4273,7 +4274,7 @@ function HistoryTab() {
>
{/* Table */}
<div class="mb-2 border border-gray-200 dark:border-gray-700 rounded overflow-hidden">
<div class="overflow-x-auto">
<ScrollableTable minWidth="900px">
<table class="w-full min-w-[900px] text-xs sm:text-sm">
<thead>
<tr class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
@ -4418,7 +4419,7 @@ function HistoryTab() {
</For>
</tbody>
</table>
</div>
</ScrollableTable>
</div>
</Show>
}