feat(mobile): bespoke phone layouts for dashboard, fleet, schedules, and settings (#1330)

* feat(mobile): masthead-led dashboard and 5-tab bottom nav on phones

On phones (below the md breakpoint) the dashboard now renders a bespoke,
masthead-led layout instead of the reflowed desktop workspace:

- A status masthead leads with the overall system-health verdict, the node,
  and a live summary (stack counts, last sync, a "metrics stale" marker when
  polling stops).
- A CPU hero card with a sparkline, then a memory / disk / network strip with
  threshold-colored bars, then a tappable stack-health list.
- The bottom tab bar gains a Home tab (Home / Stacks / Fleet / Sched /
  Settings); the global top bar is dropped on this screen, with notifications
  and a "more" menu rehomed into the masthead.

The health-verdict logic is extracted into a shared helper so the phone
masthead and the desktop health bar read from one source, with unit tests.

All changes are scoped below the md breakpoint or rendered only on the mobile
shell; desktop layout is unchanged (verified against the desktop snapshot gate).

* feat(mobile): bespoke fleet glance and node detail on phones

On phones (below the md breakpoint) the Fleet view now renders a bespoke,
masthead-led layout instead of the reflowed desktop workspace:

- A fleet masthead leads with the overall fleet-health verdict and a running /
  cpu / mem summary band, then a list of node cards. The local node is marked
  with a cyan rail and a "you are here" tag; offline nodes are dimmed.
- Tapping a node opens a full-screen node detail: state pill, resource bars
  (cpu / mem / disk), the stacks running on that node, and an Inspect action
  that switches to the node. Operators with the right permissions also get a
  Drain (cordon) action.
- The screen polls the fleet overview every 30 seconds; the global top bar is
  dropped here, with notifications and a "more" menu in the masthead.

All changes are scoped below the md breakpoint or rendered only on the mobile
shell; desktop layout is unchanged.

* feat(mobile): bespoke schedules and settings screens on phones

On phones (below the md breakpoint) Schedules and Settings now render bespoke,
masthead-led layouts instead of the reflowed desktop workspace:

- Schedules: a "next up" glance leading with the next run time and countdown,
  then upcoming runs grouped by day with a per-action status dot and target.
  It is read-only on mobile; creating and editing schedules stays on desktop.
- Settings: a grouped-card list of every reachable section; tapping one opens
  it full-screen with a back affordance and a section masthead. The section
  content itself is the same as on desktop.

The settings section switch, lazy-loaded section chunks, and tier gating are
moved into a shared component so the desktop and mobile screens render the same
section content from one place. The global top bar is dropped on both screens,
with notifications and a "more" menu in the masthead.

All changes are scoped below the md breakpoint or rendered only on the mobile
shell; desktop layout is unchanged.

* fix(mobile): show notifications and more-menu on the stack detail header

The full-screen stack detail on phones drops the global top bar, but its
header was missing the notifications bell and the "more" navigation menu that
the other mobile screens carry in their masthead, leaving no way to reach
notifications or other destinations while viewing a stack. Render the same
header-actions cluster in the detail header (and the loading placeholder),
next to the back affordance. Desktop is unaffected.
This commit is contained in:
Anso
2026-06-07 01:15:16 -04:00
committed by GitHub
parent 2072378396
commit 928a3a8343
17 changed files with 1559 additions and 214 deletions
@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState, useCallback, useMemo, useEffect, lazy, Suspense } from 'react';
import { useLayoutEffect, useRef, useState, useCallback, useMemo, useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useIsMobile } from '@/hooks/use-is-mobile';
@@ -11,26 +11,10 @@ import {
CommandList,
} from '@/components/ui/command';
import { PageMasthead, type MastheadMetadataItem } from '@/components/ui/PageMasthead';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { NodeManager } from '../NodeManager';
import { SSOSection } from '../SSOSection';
import {
AccountSection,
AppearanceSection,
LicenseSection,
HostAlertsSection,
DockerStorageSection,
FleetMeshSection,
NotificationsSection,
DeveloperSection,
DataRetentionSection,
AppStoreSection,
SupportSection,
AboutSection,
RecoverySection,
SETTINGS_ITEMS,
SETTINGS_GROUPS,
getSettingsItem,
@@ -39,58 +23,10 @@ import {
isItemLocked,
} from './index';
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
import LazyBoundary from '../LazyBoundary';
import { SectionGate } from './SectionGate';
import { SettingsSidebar } from './SettingsSidebar';
import { SettingsSectionContent } from './SettingsSectionContent';
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
// Paid-tier sections are loaded on demand. SectionGate short-circuits to a
// TierLockedCard for Community / wrong-variant operators before reaching the
// JSX that would mount these components, so the chunks are never fetched on
// those installs and the JSX, copy, and prop shapes never enter the bundle a
// Community user downloads. Bypassing the ./index barrel keeps each component
// in its own chunk; importing through the barrel would pull every named
// export into the same chunk and defeat the split.
const UsersSection = lazy(() =>
import('./UsersSection').then(m => ({ default: m.UsersSection })),
);
const WebhooksSection = lazy(() =>
import('./WebhooksSection').then(m => ({ default: m.WebhooksSection })),
);
const SecuritySection = lazy(() =>
import('./SecuritySection').then(m => ({ default: m.SecuritySection })),
);
const LabelsSection = lazy(() =>
import('./LabelsSection').then(m => ({ default: m.LabelsSection })),
);
const NotificationRoutingSection = lazy(() =>
import('./NotificationRoutingSection').then(m => ({ default: m.NotificationRoutingSection })),
);
const CloudBackupSection = lazy(() =>
import('./CloudBackupSection').then(m => ({ default: m.CloudBackupSection })),
);
const ApiTokensSection = lazy(() =>
import('../ApiTokensSection').then(m => ({ default: m.ApiTokensSection })),
);
const RegistriesSection = lazy(() =>
import('../RegistriesSection').then(m => ({ default: m.RegistriesSection })),
);
// Approximation of a settings section's first-paint shape: a header strip and
// a couple of field rows. Visible only on the brief window between an unlocked
// section's chunk request and its first render. SectionGate's TierLockedCard
// path never mounts the lazy children, so this never flashes for locked tiers.
function SectionSkeleton() {
return (
<div className="flex flex-col gap-4" aria-busy="true">
<Skeleton className="h-8 w-1/3 rounded-md" />
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
);
}
interface SettingsPageProps {
currentSection: SectionId;
onSectionChange: (section: SectionId) => void;
@@ -199,38 +135,6 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
}
}, []);
const sectionElement = useMemo(() => {
switch (safeSection) {
case 'account': return <AccountSection />;
case 'appearance': return <AppearanceSection />;
case 'license': return <LicenseSection />;
case 'users': return <UsersSection />;
case 'sso': return <SSOSection />;
case 'api-tokens': return <ApiTokensSection />;
case 'registries': return <RegistriesSection />;
case 'labels': return <LabelsSection />;
case 'host-alerts': return <HostAlertsSection onDirtyChange={(d) => handleDirtyChange('host-alerts', d)} />;
case 'docker-storage': return <DockerStorageSection onDirtyChange={(d) => handleDirtyChange('docker-storage', d)} />;
case 'fleet-mesh': return <FleetMeshSection onDirtyChange={(d) => handleDirtyChange('fleet-mesh', d)} />;
case 'notifications': return <NotificationsSection />;
case 'notification-routing': return <NotificationRoutingSection />;
case 'webhooks': return <WebhooksSection />;
case 'security': return <SecuritySection isPaid={isPaid} />;
case 'cloud-backup': return <CloudBackupSection />;
case 'developer': return <DeveloperSection onDirtyChange={(d) => handleDirtyChange('developer', d)} />;
case 'data-retention': return <DataRetentionSection onDirtyChange={(d) => handleDirtyChange('data-retention', d)} />;
case 'nodes': return <NodeManager />;
case 'app-store': return <AppStoreSection />;
case 'recovery': return <RecoverySection />;
case 'support': return <SupportSection />;
case 'about': return <AboutSection />;
// Exhaustiveness guard: a new SectionId without a case above fails tsc here.
default: return assertExhaustiveSection(safeSection);
}
// Section components close over isPaid for tier-gated branches; handleDirtyChange is stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [safeSection, isPaid]);
const kicker = activeItem && activeGroup
? `Settings · ${activeGroup.label} · ${activeItem.label}`
: 'Settings';
@@ -288,24 +192,12 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
onScrollCapture={saveScrollPosition}
>
<div className="px-7 pt-6 pb-8 flex flex-col gap-6 min-w-0">
{activeItem?.description ? (
<p className="text-sm text-stat-subtitle/90 leading-relaxed max-w-3xl">
{activeItem.description}
</p>
) : null}
{/* Suspense outside SectionGate so the locked-tier
path (which never mounts the lazy children)
does not see a fallback flash. LazyBoundary
outside Suspense catches chunk-fetch failures
so a stale tab spans-deploy mismatch shows a
Reload card instead of crashing the workspace. */}
<LazyBoundary>
<Suspense fallback={<SectionSkeleton />}>
<SectionGate sectionId={safeSection}>
{sectionElement}
</SectionGate>
</Suspense>
</LazyBoundary>
<SettingsSectionContent
sectionId={safeSection}
isPaid={isPaid}
onDirtyChange={handleDirtyChange}
showDescription
/>
</div>
</ScrollArea>
</div>
@@ -337,14 +229,6 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
);
}
// Compile-time check that the section switch covers every SectionId. If the
// switch is ever reached at runtime (it should not be, since safeSection is a
// validated registry id), log the unhandled id and render nothing rather than crash.
function assertExhaustiveSection(section: never): null {
console.error('Unhandled settings section', section);
return null;
}
function scopeLabel(item: SettingsItemMeta): string {
// Personal sections (account, appearance) apply to the signed-in operator or
// this browser. Access sections (license, users, sso, api-tokens) are
@@ -0,0 +1,158 @@
import { lazy, Suspense, useMemo } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { NodeManager } from '../NodeManager';
import { SSOSection } from '../SSOSection';
import {
AccountSection,
AppearanceSection,
LicenseSection,
HostAlertsSection,
DockerStorageSection,
FleetMeshSection,
NotificationsSection,
DeveloperSection,
DataRetentionSection,
AppStoreSection,
SupportSection,
AboutSection,
RecoverySection,
getSettingsItem,
} from './index';
import type { SectionId } from './index';
import LazyBoundary from '../LazyBoundary';
import { SectionGate } from './SectionGate';
// Paid-tier sections are loaded on demand. SectionGate returns null for
// Community / unentitled operators before reaching the JSX that would mount
// these components, so the chunks are never fetched on those installs and the
// JSX, copy, and prop shapes never enter the bundle a Community user downloads.
// Bypassing the ./index barrel keeps each component in its own chunk; importing
// through the barrel would pull every named export into the same chunk and
// defeat the split.
const UsersSection = lazy(() =>
import('./UsersSection').then(m => ({ default: m.UsersSection })),
);
const WebhooksSection = lazy(() =>
import('./WebhooksSection').then(m => ({ default: m.WebhooksSection })),
);
const SecuritySection = lazy(() =>
import('./SecuritySection').then(m => ({ default: m.SecuritySection })),
);
const LabelsSection = lazy(() =>
import('./LabelsSection').then(m => ({ default: m.LabelsSection })),
);
const NotificationRoutingSection = lazy(() =>
import('./NotificationRoutingSection').then(m => ({ default: m.NotificationRoutingSection })),
);
const CloudBackupSection = lazy(() =>
import('./CloudBackupSection').then(m => ({ default: m.CloudBackupSection })),
);
const ApiTokensSection = lazy(() =>
import('../ApiTokensSection').then(m => ({ default: m.ApiTokensSection })),
);
const RegistriesSection = lazy(() =>
import('../RegistriesSection').then(m => ({ default: m.RegistriesSection })),
);
// Approximation of a settings section's first-paint shape: a header strip and
// a couple of field rows. Visible only on the brief window between an unlocked
// section's chunk request and its first render. SectionGate returns null for
// locked tiers and never mounts the lazy children, so this never flashes for them.
function SectionSkeleton() {
return (
<div className="flex flex-col gap-4" aria-busy="true">
<Skeleton className="h-8 w-1/3 rounded-md" />
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
);
}
function renderSection(
sectionId: SectionId,
isPaid: boolean,
onDirtyChange: (section: SectionId, dirty: boolean) => void,
) {
switch (sectionId) {
case 'account': return <AccountSection />;
case 'appearance': return <AppearanceSection />;
case 'license': return <LicenseSection />;
case 'users': return <UsersSection />;
case 'sso': return <SSOSection />;
case 'api-tokens': return <ApiTokensSection />;
case 'registries': return <RegistriesSection />;
case 'labels': return <LabelsSection />;
case 'host-alerts': return <HostAlertsSection onDirtyChange={(d) => onDirtyChange('host-alerts', d)} />;
case 'docker-storage': return <DockerStorageSection onDirtyChange={(d) => onDirtyChange('docker-storage', d)} />;
case 'fleet-mesh': return <FleetMeshSection onDirtyChange={(d) => onDirtyChange('fleet-mesh', d)} />;
case 'notifications': return <NotificationsSection />;
case 'notification-routing': return <NotificationRoutingSection />;
case 'webhooks': return <WebhooksSection />;
case 'security': return <SecuritySection isPaid={isPaid} />;
case 'cloud-backup': return <CloudBackupSection />;
case 'developer': return <DeveloperSection onDirtyChange={(d) => onDirtyChange('developer', d)} />;
case 'data-retention': return <DataRetentionSection onDirtyChange={(d) => onDirtyChange('data-retention', d)} />;
case 'nodes': return <NodeManager />;
case 'app-store': return <AppStoreSection />;
case 'recovery': return <RecoverySection />;
case 'support': return <SupportSection />;
case 'about': return <AboutSection />;
// Exhaustiveness guard: a new SectionId without a case above fails tsc here.
default: return assertExhaustiveSection(sectionId);
}
}
interface SettingsSectionContentProps {
sectionId: SectionId;
isPaid: boolean;
onDirtyChange: (section: SectionId, dirty: boolean) => void;
/** Render the section's lead description paragraph above the content. */
showDescription?: boolean;
}
/**
* Renders a single settings section: its optional description, then the section
* component behind the tier gate and a lazy-chunk Suspense boundary. Shared by
* the desktop SettingsPage and the mobile settings screen so the section switch,
* lazy splitting, and gating live in exactly one place.
*/
export function SettingsSectionContent({ sectionId, isPaid, onDirtyChange, showDescription }: SettingsSectionContentProps) {
const item = getSettingsItem(sectionId);
// Memoize the section element so unrelated re-renders of the host page (the
// command palette opening, a dirty-flag toggle) do not re-render the active
// section. onDirtyChange is stable from both call sites.
const element = useMemo(
() => renderSection(sectionId, isPaid, onDirtyChange),
[sectionId, isPaid, onDirtyChange],
);
return (
<>
{showDescription && item?.description ? (
<p className="text-sm text-stat-subtitle/90 leading-relaxed max-w-3xl">
{item.description}
</p>
) : null}
{/* Suspense outside SectionGate so the locked-tier path (which never
mounts the lazy children) does not see a fallback flash.
LazyBoundary outside Suspense catches chunk-fetch failures so a
tab left open across a deploy shows a Reload card instead of
crashing the workspace. */}
<LazyBoundary>
<Suspense fallback={<SectionSkeleton />}>
<SectionGate sectionId={sectionId}>
{element}
</SectionGate>
</Suspense>
</LazyBoundary>
</>
);
}
// Compile-time check that the section switch covers every SectionId. If the
// switch is ever reached at runtime (it should not be, since the section id is a
// validated registry id), log the unhandled id and render nothing rather than crash.
function assertExhaustiveSection(section: never): null {
console.error('Unhandled settings section', section);
return null;
}