feat: improve mobile layout responsiveness
This commit is contained in:
parent
9d1b9a78c4
commit
4ba5dcc785
7 changed files with 178 additions and 54 deletions
|
|
@ -21,6 +21,7 @@ import Settings from './components/Settings/Settings';
|
||||||
import { Alerts } from './pages/Alerts';
|
import { Alerts } from './pages/Alerts';
|
||||||
import { DockerHosts } from './components/Docker/DockerHosts';
|
import { DockerHosts } from './components/Docker/DockerHosts';
|
||||||
import { ToastContainer } from './components/Toast/Toast';
|
import { ToastContainer } from './components/Toast/Toast';
|
||||||
|
import NotificationContainer from './components/NotificationContainer';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import { SecurityWarning } from './components/SecurityWarning';
|
import { SecurityWarning } from './components/SecurityWarning';
|
||||||
import { Login } from './components/Login';
|
import { Login } from './components/Login';
|
||||||
|
|
@ -584,6 +585,7 @@ function App() {
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</div>
|
</div>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
<NotificationContainer />
|
||||||
<TokenRevealDialog />
|
<TokenRevealDialog />
|
||||||
<TooltipRoot />
|
<TooltipRoot />
|
||||||
</DarkModeContext.Provider>
|
</DarkModeContext.Provider>
|
||||||
|
|
@ -784,12 +786,34 @@ function AppLayout(props: {
|
||||||
navigate(tab.route);
|
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 (
|
return (
|
||||||
<div class="pulse-shell">
|
<div class="pulse-shell">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div class="header flex flex-row justify-between items-center mb-2">
|
<div class="header mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="hidden md:block md:flex-1"></div>
|
<div class="flex items-center justify-between gap-2 sm:gap-3">
|
||||||
<div class="flex items-center gap-1 flex-none">
|
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
height="20"
|
height="20"
|
||||||
|
|
@ -824,8 +848,8 @@ function AppLayout(props: {
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-controls flex justify-end items-center gap-4 md:flex-1">
|
<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 items-center gap-2">
|
<div class="flex w-full flex-wrap items-center gap-2 justify-start sm:w-auto sm:justify-end">
|
||||||
<div
|
<div
|
||||||
class={`group status text-xs rounded-full flex items-center justify-center transition-all duration-500 ease-in-out px-1.5 ${
|
class={`group status text-xs rounded-full flex items-center justify-center transition-all duration-500 ease-in-out px-1.5 ${
|
||||||
props.connected()
|
props.connected()
|
||||||
|
|
@ -904,9 +928,41 @@ function AppLayout(props: {
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Tabs */}
|
||||||
<div
|
<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"
|
role="tablist"
|
||||||
aria-label="Primary navigation"
|
aria-label="Primary navigation"
|
||||||
>
|
>
|
||||||
|
|
@ -1008,7 +1064,10 @@ function AppLayout(props: {
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<Show when={tab.badge === 'update'}>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1353,7 +1353,7 @@ const UnifiedBackups: Component = () => {
|
||||||
{/* Available Backups chart - keep visible while backups exist or a date filter is active */}
|
{/* Available Backups chart - keep visible while backups exist or a date filter is active */}
|
||||||
<Show when={hasAnyBackups() || selectedDateRange() !== null}>
|
<Show when={hasAnyBackups() || selectedDateRange() !== null}>
|
||||||
<Card padding="md">
|
<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
|
<SectionHeader
|
||||||
title={
|
title={
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
|
|
@ -1416,7 +1416,7 @@ const UnifiedBackups: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</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">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1463,7 +1463,7 @@ const UnifiedBackups: Component = () => {
|
||||||
1y
|
1y
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<span class="text-gray-500 dark:text-gray-400">Last {chartTimeRange()} days</span>
|
||||||
<Show when={selectedDateRange()}>
|
<Show when={selectedDateRange()}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -1480,7 +1480,7 @@ const UnifiedBackups: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<Show
|
||||||
when={chartData().data.length > 0}
|
when={chartData().data.length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
|
|
@ -1511,7 +1511,9 @@ const UnifiedBackups: Component = () => {
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="available-backups-svg w-full h-full"
|
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) => {
|
ref={(el) => {
|
||||||
// Use createEffect to reactively update the chart
|
// Use createEffect to reactively update the chart
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Card } from '@/components/shared/Card';
|
||||||
import { MetricBar } from '@/components/Dashboard/MetricBar';
|
import { MetricBar } from '@/components/Dashboard/MetricBar';
|
||||||
import { renderDockerStatusBadge } from './DockerStatusBadge';
|
import { renderDockerStatusBadge } from './DockerStatusBadge';
|
||||||
import { formatUptime } from '@/utils/format';
|
import { formatUptime } from '@/utils/format';
|
||||||
|
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
||||||
|
|
||||||
export interface DockerHostSummary {
|
export interface DockerHostSummary {
|
||||||
host: DockerHost;
|
host: DockerHost;
|
||||||
|
|
@ -114,15 +115,15 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="none" class="mb-4 overflow-hidden">
|
<Card padding="none" class="mb-4 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<ScrollableTable minWidth="720px">
|
||||||
<table class="w-full min-w-[720px] border-collapse">
|
<table class="w-full border-collapse">
|
||||||
<thead>
|
<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">
|
<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
|
<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"
|
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')}
|
onClick={() => handleSort('name')}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
||||||
tabindex="0"
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={`Sort by host ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
|
aria-label={`Sort by host ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
|
||||||
>
|
>
|
||||||
|
|
@ -150,19 +151,19 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
Containers {renderSortIndicator('running')}
|
Containers {renderSortIndicator('running')}
|
||||||
</th>
|
</th>
|
||||||
<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')}
|
onClick={() => handleSort('uptime')}
|
||||||
>
|
>
|
||||||
Uptime {renderSortIndicator('uptime')}
|
Uptime {renderSortIndicator('uptime')}
|
||||||
</th>
|
</th>
|
||||||
<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')}
|
onClick={() => handleSort('lastSeen')}
|
||||||
>
|
>
|
||||||
Last Update {renderSortIndicator('lastSeen')}
|
Last Update {renderSortIndicator('lastSeen')}
|
||||||
</th>
|
</th>
|
||||||
<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')}
|
onClick={() => handleSort('agent')}
|
||||||
>
|
>
|
||||||
Agent {renderSortIndicator('agent')}
|
Agent {renderSortIndicator('agent')}
|
||||||
|
|
@ -174,6 +175,8 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
{(summary) => {
|
{(summary) => {
|
||||||
const selected = props.selectedHostId() === summary.host.id;
|
const selected = props.selectedHostId() === summary.host.id;
|
||||||
const online = isHostOnline(summary.host);
|
const online = isHostOnline(summary.host);
|
||||||
|
const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—';
|
||||||
|
const agentLabel = summary.host.agentVersion ? `v${summary.host.agentVersion}` : '—';
|
||||||
|
|
||||||
const rowStyle = () => {
|
const rowStyle = () => {
|
||||||
const styles: Record<string, string> = {};
|
const styles: Record<string, string> = {};
|
||||||
|
|
@ -206,14 +209,16 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
return className;
|
return className;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const agentOutdated = isAgentOutdated(summary.host.agentVersion);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
class={rowClass()}
|
class={rowClass()}
|
||||||
style={rowStyle()}
|
style={rowStyle()}
|
||||||
onClick={() => props.onSelect(summary.host.id)}
|
onClick={() => props.onSelect(summary.host.id)}
|
||||||
>
|
>
|
||||||
<td class="pr-2 py-0.5 pl-3 whitespace-nowrap">
|
<td class="pr-2 py-1 pl-3 align-top">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100">
|
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100">
|
||||||
{summary.host.displayName}
|
{summary.host.displayName}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -228,11 +233,51 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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>
|
||||||
<td class="px-2 py-0.5">
|
<td class="px-2 py-1 align-top">
|
||||||
{renderDockerStatusBadge(summary.host.status)}
|
{renderDockerStatusBadge(summary.host.status)}
|
||||||
</td>
|
</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>}>
|
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||||
<MetricBar
|
<MetricBar
|
||||||
value={summary.cpuPercent}
|
value={summary.cpuPercent}
|
||||||
|
|
@ -241,7 +286,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</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>}>
|
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||||
<MetricBar
|
<MetricBar
|
||||||
value={summary.memoryPercent}
|
value={summary.memoryPercent}
|
||||||
|
|
@ -251,8 +296,8 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5">
|
<td class="px-2 py-1 align-top">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-start sm:justify-center">
|
||||||
<MetricBar
|
<MetricBar
|
||||||
value={summary.runningPercent}
|
value={summary.runningPercent}
|
||||||
label={`${summary.runningCount}/${summary.totalCount}`}
|
label={`${summary.runningCount}/${summary.totalCount}`}
|
||||||
|
|
@ -260,38 +305,40 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
{summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—'}
|
{uptimeLabel}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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">
|
<div class="text-sm text-gray-900 dark:text-gray-100">
|
||||||
{summary.lastSeenRelative}
|
{summary.lastSeenRelative}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<Show when={summary.lastSeenAbsolute}>
|
||||||
{summary.lastSeenAbsolute}
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
{summary.lastSeenAbsolute}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</td>
|
</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">
|
<div class="flex flex-col gap-0.5">
|
||||||
<Show when={summary.host.agentVersion}>
|
<Show when={summary.host.agentVersion}>
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
<span
|
<span
|
||||||
class={
|
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-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'
|
: '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={
|
title={
|
||||||
isAgentOutdated(summary.host.agentVersion)
|
agentOutdated
|
||||||
? 'Outdated - Update recommended'
|
? 'Outdated - Update recommended'
|
||||||
: 'Up to date - Auto-update enabled'
|
: 'Up to date - Auto-update enabled'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
v{summary.host.agentVersion}
|
v{summary.host.agentVersion}
|
||||||
</span>
|
</span>
|
||||||
<Show when={isAgentOutdated(summary.host.agentVersion)}>
|
<Show when={agentOutdated}>
|
||||||
<span class="text-[9px] text-yellow-600 dark:text-yellow-500 font-medium">
|
<span class="text-[9px] text-yellow-600 dark:text-yellow-500 font-medium">
|
||||||
⚠ Update available
|
⚠ Update available
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -311,7 +358,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
</For>
|
</For>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</ScrollableTable>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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';
|
import { Show } from 'solid-js';
|
||||||
|
|
||||||
interface ScrollableTableProps {
|
interface ScrollableTableProps {
|
||||||
|
|
@ -23,6 +23,9 @@ export const ScrollableTable: Component<ScrollableTableProps> = (props) => {
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
checkScroll();
|
checkScroll();
|
||||||
window.addEventListener('resize', checkScroll);
|
window.addEventListener('resize', checkScroll);
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener('resize', checkScroll);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -43,7 +46,7 @@ export const ScrollableTable: Component<ScrollableTableProps> = (props) => {
|
||||||
<div
|
<div
|
||||||
ref={scrollContainer}
|
ref={scrollContainer}
|
||||||
class="overflow-x-auto"
|
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>{`
|
<style>{`
|
||||||
.overflow-x-auto::-webkit-scrollbar { display: none; }
|
.overflow-x-auto::-webkit-scrollbar { display: none; }
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,11 @@ export function SectionHeader(props: SectionHeaderProps) {
|
||||||
const sizeClass = () => {
|
const sizeClass = () => {
|
||||||
switch (local.size) {
|
switch (local.size) {
|
||||||
case 'sm':
|
case 'sm':
|
||||||
return 'text-base';
|
return 'text-base sm:text-lg';
|
||||||
case 'lg':
|
case 'lg':
|
||||||
return 'text-2xl';
|
return 'text-xl sm:text-2xl lg:text-3xl';
|
||||||
default:
|
default:
|
||||||
return 'text-lg';
|
return 'text-lg sm:text-xl';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -47,13 +47,13 @@ export function SectionHeader(props: SectionHeaderProps) {
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<h2
|
<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}
|
{local.title}
|
||||||
</h2>
|
</h2>
|
||||||
<Show when={local.description}>
|
<Show when={local.description}>
|
||||||
<p
|
<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}
|
{local.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -119,15 +119,27 @@
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.pulse-shell {
|
.pulse-shell {
|
||||||
width: min(95.5vw, 95rem);
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 95rem;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
padding-inline: clamp(1.25rem, 3vw, 3.25rem);
|
padding-inline: clamp(1rem, 3vw, 3.25rem);
|
||||||
transition: padding-inline 0.3s ease;
|
transition: padding-inline 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pulse-shell {
|
||||||
|
padding-inline: clamp(0.75rem, 6vw, 1.25rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.pulse-panel {
|
.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 */
|
/* Card styles */
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
import { SettingsPanel } from '@/components/shared/SettingsPanel';
|
import { SettingsPanel } from '@/components/shared/SettingsPanel';
|
||||||
import { Toggle } from '@/components/shared/Toggle';
|
import { Toggle } from '@/components/shared/Toggle';
|
||||||
import { formField, formControl, formHelpText, labelClass, controlClass } from '@/components/shared/Form';
|
import { formField, formControl, formHelpText, labelClass, controlClass } from '@/components/shared/Form';
|
||||||
|
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
||||||
import { useWebSocket } from '@/App';
|
import { useWebSocket } from '@/App';
|
||||||
import { showSuccess, showError } from '@/utils/toast';
|
import { showSuccess, showError } from '@/utils/toast';
|
||||||
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
|
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
|
||||||
|
|
@ -1795,7 +1796,7 @@ function OverviewTab(props: {
|
||||||
return (
|
return (
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{/* Stats Cards */}
|
{/* 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">
|
<Card padding="sm" class="sm:p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -3073,7 +3074,7 @@ function ScheduleTab(props: ScheduleTabProps) {
|
||||||
<span class={`${labelClass('text-xs uppercase tracking-[0.08em]')} mb-2 block`}>
|
<span class={`${labelClass('text-xs uppercase tracking-[0.08em]')} mb-2 block`}>
|
||||||
Grouping strategy
|
Grouping strategy
|
||||||
</span>
|
</span>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<label
|
<label
|
||||||
class={`relative flex items-center gap-2 rounded-lg border-2 p-3 transition-all ${
|
class={`relative flex items-center gap-2 rounded-lg border-2 p-3 transition-all ${
|
||||||
grouping().byNode
|
grouping().byNode
|
||||||
|
|
@ -4221,7 +4222,7 @@ function HistoryTab() {
|
||||||
<select
|
<select
|
||||||
value={timeFilter()}
|
value={timeFilter()}
|
||||||
onChange={(e) => setTimeFilter(e.currentTarget.value as '24h' | '7d' | '30d' | 'all')}
|
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="24h">Last 24h</option>
|
||||||
<option value="7d">Last 7d</option>
|
<option value="7d">Last 7d</option>
|
||||||
|
|
@ -4232,14 +4233,14 @@ function HistoryTab() {
|
||||||
<select
|
<select
|
||||||
value={severityFilter()}
|
value={severityFilter()}
|
||||||
onChange={(e) => setSeverityFilter(e.currentTarget.value as 'warning' | 'critical' | 'all')}
|
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="all">All Levels</option>
|
||||||
<option value="critical">Critical Only</option>
|
<option value="critical">Critical Only</option>
|
||||||
<option value="warning">Warning Only</option>
|
<option value="warning">Warning Only</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div class="flex-1 max-w-xs">
|
<div class="w-full sm:flex-1 sm:max-w-xs">
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -4273,7 +4274,7 @@ function HistoryTab() {
|
||||||
>
|
>
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div class="mb-2 border border-gray-200 dark:border-gray-700 rounded overflow-hidden">
|
<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">
|
<table class="w-full min-w-[900px] text-xs sm:text-sm">
|
||||||
<thead>
|
<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">
|
<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>
|
</For>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</ScrollableTable>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue