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
View File
@@ -59,6 +59,7 @@ CLAUDE.md
# Design system (local-only authoritative reference)
frontend/DESIGN.md
.design-bundle/
docs/design/
# Local-only projects (separate repos)
demo-video/
+110 -35
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { Button } from './ui/button';
import { Plus, Loader2, ChevronLeft } from 'lucide-react';
import { UserProfileDropdown } from './UserProfileDropdown';
@@ -40,9 +40,19 @@ import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-e
import { toast } from '@/components/ui/toast-store';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { MobileTabBar } from './MobileTabBar';
import { MobileMoreMenu } from './MobileMoreMenu';
import { MobileDashboard } from './mobile/MobileDashboard';
import { MobileFleet } from './mobile/MobileFleet';
import { MobileSchedules } from './mobile/MobileSchedules';
import { MobileSettings } from './mobile/MobileSettings';
import { deriveMobileSurface, type MobileView } from './EditorLayout/mobile-surface';
import type { SectionId } from './settings/types';
// Content views that render a bespoke, masthead-led mobile screen instead of the
// reflowed desktop workspace. For these the global TopBar is dropped on mobile
// (each screen's masthead leads). The set grows as screens are re-skinned.
const BESPOKE_MOBILE_VIEWS = new Set<string>(['dashboard', 'fleet', 'scheduled-ops', 'settings']);
export default function EditorLayout() {
const { isAdmin, can } = useAuth();
const { status: trivy } = useTrivyStatus();
@@ -261,6 +271,31 @@ export default function EditorLayout() {
void stackActions.loadFile(file);
};
// Open a specific stack on a node (from Fleet): load it directly if that node
// is already active, else stash it and switch nodes (the node-switch effect
// loads the pending stack once the registry settles). Mobile shows the
// optimistic detail surface immediately.
const handleFleetNavigateToNode = (nodeId: number, stackName: string) => {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
if (isMobile) setPendingDetailStack(stackName);
if (activeNode?.id === nodeId) {
void stackActions.loadFile(stackName);
} else {
pendingStackLoadRef.current = stackName;
setActiveNode(node);
}
};
// "Inspect" a node from the mobile Fleet screen: switch to it and land on its
// stack list.
const handleInspectNode = (nodeId: number) => {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
if (activeNode?.id !== nodeId) setActiveNode(node);
goToMobileList();
};
// Hamburger / command-palette navigation is mobile-aware so it collapses the
// current surface and honors the unsaved-changes guard; desktop is untouched.
const navHandler = isMobile ? navigateMobileAware : handleNavigate;
@@ -286,8 +321,9 @@ export default function EditorLayout() {
}
}, [stackActions, setActiveView, isMobile, navigateMobileAware]);
const renderEditor = () => (
const renderEditor = (headerActions?: ReactNode) => (
<EditorView
headerActions={headerActions}
stackName={stackName}
isDarkMode={isDarkMode}
containers={containers}
@@ -525,6 +561,19 @@ export default function EditorLayout() {
/>
);
const notificationsEl = (
<NotificationPanel
notifications={notifications}
nodes={nodes}
onMarkAllRead={markAllRead}
onClearAll={clearAllNotifications}
onDelete={deleteNotification}
onNavigate={stackActions.navigateToNotification}
/>
);
const themeSwitchEl = <ThemeQuickSwitch />;
const userMenuEl = <UserProfileDropdown onOpenSettings={() => openSettings('account')} />;
const topBarEl = (
<TopBar
activeView={activeView}
@@ -533,25 +582,27 @@ export default function EditorLayout() {
mobileNavOpen={mobileNavOpen}
onMobileNavOpenChange={setMobileNavOpen}
search={<GlobalCommandPaletteTrigger />}
themeSwitch={<ThemeQuickSwitch />}
notifications={
<NotificationPanel
notifications={notifications}
nodes={nodes}
onMarkAllRead={markAllRead}
onClearAll={clearAllNotifications}
onDelete={deleteNotification}
onNavigate={stackActions.navigateToNotification}
/>
}
userMenu={
<UserProfileDropdown
onOpenSettings={() => openSettings('account')}
/>
}
themeSwitch={themeSwitchEl}
notifications={notificationsEl}
userMenu={userMenuEl}
/>
);
// On the bespoke mobile screens the TopBar is dropped, so notifications and
// the secondary-destinations menu are rehomed into each screen's masthead
// right slot.
const mobileMastheadActions = (
<div className="flex items-center gap-0.5">
{notificationsEl}
<MobileMoreMenu
navItems={navItems}
activeView={activeView}
onNavigate={navigateMobileAware}
footer={<>{themeSwitchEl}{userMenuEl}</>}
/>
</div>
);
const workspaceEl = (
<div key={activeView} className="flex-1 overflow-y-auto p-6 max-md:p-4 animate-fade-up">
<ViewRouter
@@ -565,17 +616,7 @@ export default function EditorLayout() {
void stackActions.loadFile(sName);
}}
onHostConsoleClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')}
onFleetNavigateToNode={(nodeId, sName) => {
const node = nodes.find(n => n.id === nodeId);
if (node) {
if (activeNode?.id === nodeId) {
void stackActions.loadFile(sName);
} else {
pendingStackLoadRef.current = sName;
setActiveNode(node);
}
}
}}
onFleetNavigateToNode={handleFleetNavigateToNode}
filterNodeId={filterNodeId}
onClearScheduledOpsFilter={() => setFilterNodeId(null)}
schedulePrefill={schedulePrefill}
@@ -605,19 +646,51 @@ export default function EditorLayout() {
/>
);
// Bespoke, masthead-led mobile screens. When showing one, the TopBar is
// dropped and the screen renders its own masthead (with notifications +
// more-menu rehomed into the right slot).
const bespokeContent = mobileSurface === 'content' && BESPOKE_MOBILE_VIEWS.has(activeView);
const renderMobileBespoke = () => {
switch (activeView) {
case 'dashboard':
return (
<MobileDashboard
notifications={notifications}
headerActions={mobileMastheadActions}
onNavigateToStack={handleSelectStack}
onViewAllStacks={goToMobileList}
/>
);
case 'fleet':
return (
<MobileFleet
headerActions={mobileMastheadActions}
onInspectNode={handleInspectNode}
onInspectStack={handleFleetNavigateToNode}
/>
);
case 'scheduled-ops':
return <MobileSchedules headerActions={mobileMastheadActions} />;
case 'settings':
return <MobileSettings headerActions={mobileMastheadActions} />;
default:
return workspaceEl;
}
};
if (isMobile) {
return (
<div className="flex h-screen w-screen flex-col overflow-hidden app-canvas text-foreground">
{commandPaletteEl}
{mobileSurface !== 'detail' && topBarEl}
{mobileSurface !== 'detail' && !bespokeContent && topBarEl}
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{mobileSurface === 'list' && sidebarEl}
{mobileSurface === 'content' && workspaceEl}
{mobileSurface === 'content' && (bespokeContent ? renderMobileBespoke() : workspaceEl)}
{mobileSurface === 'detail' && (
detailReady ? (
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">{renderEditor()}</div>
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">{renderEditor(mobileMastheadActions)}</div>
) : (
<MobileDetailLoading name={pendingDetailStack ?? ''} onBack={goToMobileList} />
<MobileDetailLoading name={pendingDetailStack ?? ''} onBack={goToMobileList} headerActions={mobileMastheadActions} />
)
)}
</div>
@@ -626,6 +699,7 @@ export default function EditorLayout() {
activeView={activeView}
mobileView={mobileView}
detailOpen={detailOpen}
onHome={() => navigateMobileAware('dashboard')}
onStacks={goToMobileList}
onNavigate={navigateMobileAware}
onSettings={openSettingsMobileAware}
@@ -657,7 +731,7 @@ export default function EditorLayout() {
// Optimistic stack-detail placeholder shown on mobile the instant a row is
// tapped, until loadFile resolves and the real EditorView mounts. Keeps the tap
// feeling immediate on slow networks.
function MobileDetailLoading({ name, onBack }: { name: string; onBack: () => void }) {
function MobileDetailLoading({ name, onBack, headerActions }: { name: string; onBack: () => void; headerActions?: ReactNode }) {
return (
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex items-center gap-1 border-b border-hairline px-3 py-2">
@@ -670,9 +744,10 @@ function MobileDetailLoading({ name, onBack }: { name: string; onBack: () => voi
<ChevronLeft className="h-4 w-4" strokeWidth={1.6} />
Stacks
</button>
<span className="truncate font-display text-2xl italic text-stat-value">
<span className="min-w-0 flex-1 truncate font-display text-2xl italic text-stat-value">
{name.replace(/\.(ya?ml)$/, '')}
</span>
{headerActions ? <div className="shrink-0">{headerActions}</div> : null}
</div>
<div className="flex flex-1 items-center justify-center text-stat-subtitle">
<Loader2 className="h-5 w-5 animate-spin" strokeWidth={1.5} />
@@ -145,6 +145,9 @@ export interface EditorViewProps {
// Mobile-only: back affordance in the detail header returns to the stack list.
onMobileBack?: () => void;
// Mobile-only: notifications + more-menu cluster for the detail header right
// slot (the global TopBar is dropped on the full-screen detail surface).
headerActions?: React.ReactNode;
}
export function EditorView(props: EditorViewProps) {
@@ -54,6 +54,7 @@ export function MobileStackDetail(props: EditorViewProps) {
setCopiedDigest,
requestDeleteStack,
onMobileBack,
headerActions,
} = props;
const [segment, setSegment] = useState<Segment>('logs');
@@ -67,15 +68,18 @@ export function MobileStackDetail(props: EditorViewProps) {
<div className="flex h-full min-h-0 flex-col">
{/* Detail header: back to list + identity + action bar */}
<div className="shrink-0 border-b border-hairline px-4 pb-3 pt-3">
<button
type="button"
onClick={onMobileBack}
aria-label="Back to stacks"
className="mb-1 inline-flex min-h-11 items-center gap-1 pr-3 font-mono text-xs text-brand"
>
<ChevronLeft className="h-4 w-4" strokeWidth={1.6} />
Stacks
</button>
<div className="mb-1 flex items-center justify-between gap-2">
<button
type="button"
onClick={onMobileBack}
aria-label="Back to stacks"
className="inline-flex min-h-11 items-center gap-1 pr-3 font-mono text-xs text-brand"
>
<ChevronLeft className="h-4 w-4" strokeWidth={1.6} />
Stacks
</button>
{headerActions ? <div className="shrink-0">{headerActions}</div> : null}
</div>
<StackIdentityHeader
stackName={stackName}
activeNode={activeNode}
@@ -0,0 +1,77 @@
import { useState, type ReactNode } from 'react';
import { Menu } from 'lucide-react';
import { Button } from './ui/button';
import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
import { cn } from '@/lib/utils';
import type { NavItem, ActiveView } from './EditorLayout/hooks/useViewNavigationState';
// Destinations the bottom tab bar already exposes; the "more" menu omits these
// so it lists only the secondary destinations (auto-updates, app store,
// observability, etc.). Kept in sync with MobileTabBar's tab set.
const TAB_BAR_VIEWS = new Set<string>(['dashboard', 'fleet', 'scheduled-ops', 'settings']);
interface MobileMoreMenuProps {
/** The already-gated nav items (admin / remote / paid filtering applied). */
navItems: NavItem[];
activeView: ActiveView;
onNavigate: (value: ActiveView) => void;
/** Theme switch + account controls, pinned to the bottom of the sheet. */
footer?: ReactNode;
}
/**
* The "more destinations" affordance for the mobile content screens. On a phone
* the global TopBar is dropped and each screen's masthead leads, so this hosts
* every secondary destination the bottom tab bar does not (auto-updates, app
* store, observability, etc.) plus the theme and account controls.
*/
export function MobileMoreMenu({ navItems, activeView, onNavigate, footer }: MobileMoreMenuProps) {
const [open, setOpen] = useState(false);
const secondaryItems = navItems.filter(item => !TAB_BAR_VIEWS.has(item.value));
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="More destinations"
className="h-9 w-9 rounded-lg text-stat-icon"
>
<Menu className="h-[18px] w-[18px]" strokeWidth={1.6} />
</Button>
</SheetTrigger>
<SheetContent side="right" className="flex w-72 flex-col p-0">
<div className="border-b border-hairline px-4 py-3">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">Navigate</p>
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
{secondaryItems.map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => {
onNavigate(value);
setOpen(false);
}}
className={cn(
'flex min-h-11 items-center gap-3 rounded-lg px-3 text-sm transition-colors',
activeView === value
? 'bg-glass-highlight font-medium text-foreground'
: 'text-muted-foreground hover:bg-glass-highlight hover:text-foreground',
)}
>
<Icon className="h-4 w-4" strokeWidth={1.5} />
{label}
</button>
))}
</nav>
{footer ? (
<div className="flex items-center justify-between gap-2 border-t border-hairline px-4 py-3">
{footer}
</div>
) : null}
</SheetContent>
</Sheet>
);
}
+16 -5
View File
@@ -16,6 +16,7 @@ function renderBar(over: Partial<React.ComponentProps<typeof MobileTabBar>> = {}
activeView: 'dashboard',
mobileView: 'list',
detailOpen: false,
onHome: vi.fn(),
onStacks: vi.fn(),
onNavigate: vi.fn(),
onSettings: vi.fn(),
@@ -26,28 +27,32 @@ function renderBar(over: Partial<React.ComponentProps<typeof MobileTabBar>> = {}
}
describe('MobileTabBar', () => {
it('always renders Stacks and Settings', () => {
it('always renders Home, Stacks and Settings', () => {
renderBar();
expect(screen.getByRole('button', { name: 'Home' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Stacks' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
});
it('renders Fleet and Schedules only when present in the gated nav items', () => {
it('renders Fleet and Sched only when present in the gated nav items', () => {
renderBar({ navItems: [{ value: 'dashboard', label: 'Home', icon: Home }] });
expect(screen.queryByRole('button', { name: 'Fleet' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Schedules' })).not.toBeInTheDocument();
// Stacks + Settings remain.
expect(screen.queryByRole('button', { name: 'Sched' })).not.toBeInTheDocument();
// Home + Stacks + Settings remain.
expect(screen.getByRole('button', { name: 'Home' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Stacks' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
});
it('routes each tab to its handler', () => {
const props = renderBar();
fireEvent.click(screen.getByRole('button', { name: 'Home' }));
expect(props.onHome).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Stacks' }));
expect(props.onStacks).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Fleet' }));
expect(props.onNavigate).toHaveBeenCalledWith('fleet');
fireEvent.click(screen.getByRole('button', { name: 'Schedules' }));
fireEvent.click(screen.getByRole('button', { name: 'Sched' }));
expect(props.onNavigate).toHaveBeenCalledWith('scheduled-ops');
fireEvent.click(screen.getByRole('button', { name: 'Settings' }));
expect(props.onSettings).toHaveBeenCalledTimes(1);
@@ -59,6 +64,12 @@ describe('MobileTabBar', () => {
expect(screen.getByRole('button', { name: 'Fleet' })).not.toHaveAttribute('aria-current');
});
it('marks Home as current on the dashboard content surface', () => {
renderBar({ mobileView: 'content', activeView: 'dashboard' });
expect(screen.getByRole('button', { name: 'Home' })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('button', { name: 'Stacks' })).not.toHaveAttribute('aria-current');
});
it('marks the active content view as current', () => {
renderBar({ mobileView: 'content', activeView: 'fleet' });
expect(screen.getByRole('button', { name: 'Fleet' })).toHaveAttribute('aria-current', 'page');
+20 -14
View File
@@ -1,10 +1,10 @@
import { Layers, Radar, Clock, Settings as SettingsIcon } from 'lucide-react';
import { Home, Layers, Radar, Clock, Settings as SettingsIcon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { NavItem, ActiveView } from './EditorLayout/hooks/useViewNavigationState';
import type { MobileView } from './EditorLayout/mobile-surface';
type TabId = 'stacks' | 'fleet' | 'schedules' | 'settings';
type TabId = 'home' | 'stacks' | 'fleet' | 'schedules' | 'settings';
interface MobileTabBarProps {
/** The already-gated nav items (admin / remote / paid filtering applied). */
@@ -14,6 +14,7 @@ interface MobileTabBarProps {
mobileView: MobileView;
/** True while a stack detail is open (keeps the Stacks tab marked current). */
detailOpen: boolean;
onHome: () => void;
onStacks: () => void;
onNavigate: (view: ActiveView) => void;
onSettings: () => void;
@@ -27,17 +28,19 @@ interface Tab {
}
/**
* Bottom tab bar for the mobile shell (hidden at md+). The four primary
* destinations; everything else stays reachable through the TopBar hamburger.
* Fleet and Schedules only appear when present in the gated `navItems`, so the
* bar never exposes a flow the desktop nav would hide (admin-only Schedules,
* hub-only views on a remote node).
* Bottom tab bar for the mobile shell (hidden at md+). Five primary
* destinations: Home (dashboard), Stacks (the list), Fleet, Sched, Settings.
* Fleet and Sched only appear when present in the gated `navItems`, so the bar
* never exposes a flow the desktop nav would hide (admin-only schedules,
* hub-only views on a remote node). Everything else stays reachable through the
* masthead "more" menu on bespoke screens, or the TopBar nav sheet elsewhere.
*/
export function MobileTabBar({
navItems,
activeView,
mobileView,
detailOpen,
onHome,
onStacks,
onNavigate,
onSettings,
@@ -45,16 +48,18 @@ export function MobileTabBar({
const has = (value: ActiveView) => navItems.some(i => i.value === value);
const tabs: Tab[] = [
{ id: 'home', label: 'Home', icon: Home },
{ id: 'stacks', label: 'Stacks', icon: Layers },
...(has('fleet') ? [{ id: 'fleet' as const, label: 'Fleet', icon: Radar, view: 'fleet' as const }] : []),
...(has('scheduled-ops')
? [{ id: 'schedules' as const, label: 'Schedules', icon: Clock, view: 'scheduled-ops' as const }]
? [{ id: 'schedules' as const, label: 'Sched', icon: Clock, view: 'scheduled-ops' as const }]
: []),
{ id: 'settings', label: 'Settings', icon: SettingsIcon },
];
const currentTab = (): TabId | null => {
if (detailOpen || mobileView === 'list') return 'stacks';
if (activeView === 'dashboard') return 'home';
if (activeView === 'fleet') return 'fleet';
if (activeView === 'scheduled-ops') return 'schedules';
if (activeView === 'settings') return 'settings';
@@ -63,7 +68,8 @@ export function MobileTabBar({
const current = currentTab();
const select = (tab: Tab) => {
if (tab.id === 'stacks') onStacks();
if (tab.id === 'home') onHome();
else if (tab.id === 'stacks') onStacks();
else if (tab.id === 'settings') onSettings();
else if (tab.view) onNavigate(tab.view);
};
@@ -74,8 +80,8 @@ export function MobileTabBar({
className={cn(
'md:hidden flex shrink-0 items-stretch',
'border-t border-hairline',
'bg-[color-mix(in_oklch,var(--card)_72%,transparent)] backdrop-blur-md backdrop-saturate-150',
'pb-[env(safe-area-inset-bottom)]',
'bg-[color-mix(in_oklch,var(--card)_70%,transparent)] backdrop-blur-md backdrop-saturate-150',
'pb-[max(8px,env(safe-area-inset-bottom))]',
)}
>
{tabs.map(tab => {
@@ -89,13 +95,13 @@ export function MobileTabBar({
aria-current={on ? 'page' : undefined}
aria-label={tab.label}
className={cn(
'flex flex-1 min-h-14 flex-col items-center justify-center gap-1 py-2',
'flex flex-1 min-h-14 flex-col items-center justify-center gap-1 pt-2',
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
on ? 'text-brand' : 'text-stat-icon hover:text-foreground',
)}
>
<Icon className="h-5 w-5" strokeWidth={1.5} />
<span className={cn('font-mono text-[9px] uppercase tracking-[0.14em]', on && 'font-medium')}>
<Icon className="h-5 w-5" strokeWidth={1.6} />
<span className={cn('font-mono text-[9px] uppercase tracking-[0.08em]', on && 'font-medium')}>
{tab.label}
</span>
</button>
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Bell } from 'lucide-react';
import type { Stats, SystemStats, NotificationItem, HealthLevel } from './types';
import { deriveHealth } from './deriveHealth';
interface HealthStatusBarProps {
stats: Stats;
@@ -13,33 +14,6 @@ interface HealthStatusBarProps {
metricsStale?: boolean;
}
interface HealthResult {
level: HealthLevel;
reasons: string[];
}
function deriveHealth(stats: Stats, systemStats: SystemStats | null, notifications: NotificationItem[]): HealthResult {
const cpu = parseFloat(systemStats?.cpu.usage || '0');
const ram = parseFloat(systemStats?.memory.usagePercent || '0');
const disk = parseFloat(systemStats?.disk?.usagePercent || '0');
const unreadErrors = notifications.filter(n => !n.is_read && n.level === 'error').length;
const reasons: string[] = [];
if (cpu >= 80) reasons.push(`CPU ${cpu.toFixed(0)}%`);
if (ram >= 80) reasons.push(`RAM ${ram.toFixed(0)}%`);
if (disk >= 80) reasons.push(`Disk ${disk.toFixed(0)}%`);
if (stats.exited > 0) reasons.push(`${stats.exited} exited`);
if (unreadErrors > 0) reasons.push(`${unreadErrors} unread ${unreadErrors === 1 ? 'error' : 'errors'}`);
if (cpu >= 90 || ram >= 90 || disk >= 90 || (stats.exited > 0 && unreadErrors > 0)) {
return { level: 'critical', reasons };
}
if (cpu >= 80 || ram >= 80 || disk >= 80 || stats.exited > 0 || unreadErrors > 0) {
return { level: 'degraded', reasons };
}
return { level: 'healthy', reasons: ['All systems nominal'] };
}
const healthConfig: Record<HealthLevel, { label: string; dotClass: string; textClass: string; railClass: string; tintClass: string }> = {
healthy: {
label: 'Healthy',
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { deriveHealth } from './deriveHealth';
import type { Stats, SystemStats, NotificationItem } from './types';
const stats = (over: Partial<Stats> = {}): Stats => ({
active: 5, managed: 5, unmanaged: 0, exited: 0, total: 5, ...over,
});
// deriveHealth only reads cpu.usage, memory.usagePercent and disk?.usagePercent.
const sys = (cpu: string, ram: string, disk: string | null): SystemStats => ({
cpu: { usage: cpu, cores: 8 },
memory: { total: 100, used: 50, free: 50, usagePercent: ram },
disk: disk === null ? null : { fs: '/', mount: '/', total: 100, used: 50, free: 50, usagePercent: disk },
});
const note = (over: Partial<NotificationItem> = {}): NotificationItem => ({
id: 1, level: 'error', message: 'x', timestamp: 0, is_read: 0, ...over,
});
describe('deriveHealth', () => {
it('reports healthy when all metrics are low and nothing is wrong', () => {
const r = deriveHealth(stats(), sys('10', '20', '30'), []);
expect(r.level).toBe('healthy');
expect(r.reasons).toEqual(['All systems nominal']);
});
it('escalates to degraded at the 80 boundary but not at 79', () => {
expect(deriveHealth(stats(), sys('80', '20', '30'), []).level).toBe('degraded');
expect(deriveHealth(stats(), sys('79', '20', '30'), []).level).toBe('healthy');
});
it('escalates to critical at the 90 boundary', () => {
expect(deriveHealth(stats(), sys('20', '90', '30'), []).level).toBe('critical');
});
it('treats exited containers as degraded with a reason', () => {
const r = deriveHealth(stats({ exited: 2 }), sys('10', '20', '30'), []);
expect(r.level).toBe('degraded');
expect(r.reasons).toContain('2 exited');
});
it('escalates to critical when exits AND unread errors coincide below 90', () => {
const r = deriveHealth(stats({ exited: 1 }), sys('10', '20', '30'), [note()]);
expect(r.level).toBe('critical');
});
it('counts only unread error-level notifications, with pluralization', () => {
// A read error and an unread warning must both be ignored.
const ignored = deriveHealth(stats(), sys('10', '20', '30'), [
note({ is_read: 1 }),
note({ level: 'warning' }),
]);
expect(ignored.level).toBe('healthy');
const single = deriveHealth(stats(), sys('10', '20', '30'), [note()]);
expect(single.level).toBe('degraded');
expect(single.reasons).toContain('1 unread error');
const many = deriveHealth(stats(), sys('10', '20', '30'), [note(), note({ id: 2 })]);
expect(many.reasons).toContain('2 unread errors');
});
it('treats missing system stats and disk as zero usage', () => {
expect(deriveHealth(stats(), null, []).level).toBe('healthy');
expect(deriveHealth(stats(), sys('10', '20', null), []).level).toBe('healthy');
});
});
@@ -0,0 +1,31 @@
import type { Stats, SystemStats, NotificationItem, HealthLevel } from './types';
export interface HealthResult {
level: HealthLevel;
reasons: string[];
}
// Single source of truth for the overall-health verdict, shared by the desktop
// HealthStatusBar and the mobile dashboard masthead so their thresholds never
// drift apart.
export function deriveHealth(stats: Stats, systemStats: SystemStats | null, notifications: NotificationItem[]): HealthResult {
const cpu = parseFloat(systemStats?.cpu.usage || '0');
const ram = parseFloat(systemStats?.memory.usagePercent || '0');
const disk = parseFloat(systemStats?.disk?.usagePercent || '0');
const unreadErrors = notifications.filter(n => !n.is_read && n.level === 'error').length;
const reasons: string[] = [];
if (cpu >= 80) reasons.push(`CPU ${cpu.toFixed(0)}%`);
if (ram >= 80) reasons.push(`RAM ${ram.toFixed(0)}%`);
if (disk >= 80) reasons.push(`Disk ${disk.toFixed(0)}%`);
if (stats.exited > 0) reasons.push(`${stats.exited} exited`);
if (unreadErrors > 0) reasons.push(`${unreadErrors} unread ${unreadErrors === 1 ? 'error' : 'errors'}`);
if (cpu >= 90 || ram >= 90 || disk >= 90 || (stats.exited > 0 && unreadErrors > 0)) {
return { level: 'critical', reasons };
}
if (cpu >= 80 || ram >= 80 || disk >= 80 || stats.exited > 0 || unreadErrors > 0) {
return { level: 'degraded', reasons };
}
return { level: 'healthy', reasons: ['All systems nominal'] };
}
@@ -0,0 +1,239 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useNodes } from '@/context/NodeContext';
import { useDashboardData } from '@/components/dashboard';
import { deriveHealth } from '@/components/dashboard/deriveHealth';
import type { HealthLevel, NotificationItem, StackCpuSeries, StackStatusEntry } from '@/components/dashboard/types';
import { Bar, Kicker, Masthead, MSparkline, SectionHead, StateDot } from './mobile-ui';
interface MobileDashboardProps {
notifications: NotificationItem[];
/** Notification bell + more-menu cluster for the masthead right slot. */
headerActions: ReactNode;
onNavigateToStack: (stackFile: string) => void;
onViewAllStacks: () => void;
}
const SPARK_WINDOW_MS = 10 * 60 * 1000;
const LEVEL_LABEL: Record<HealthLevel, string> = { healthy: 'Healthy', degraded: 'Degraded', critical: 'Critical' };
const LEVEL_TONE: Record<HealthLevel, 'success' | 'warning' | 'destructive'> = {
healthy: 'success',
degraded: 'warning',
critical: 'destructive',
};
function formatBytes(bytes: number): string {
if (!bytes || bytes <= 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function formatAgo(ms: number): string {
const c = Math.max(0, ms);
if (c < 60_000) return `${Math.round(c / 1000)}s`;
if (c < 3_600_000) return `${Math.round(c / 60_000)}m`;
return `${Math.round(c / 3_600_000)}h`;
}
type RowState = 'healthy' | 'warn' | 'error';
function classifyRow(status: StackStatusEntry['status'], peakCpu: number): RowState {
if (status === 'exited') return 'error';
if (peakCpu >= 90) return 'error';
if (peakCpu >= 80) return 'warn';
return 'healthy';
}
const ROW_TINT: Record<RowState, string> = {
healthy: '',
warn: 'bg-warning-muted',
error: 'bg-destructive-muted',
};
const ROW_TONE: Record<RowState, 'success' | 'warning' | 'destructive'> = {
healthy: 'success',
warn: 'warning',
error: 'destructive',
};
const ROW_STROKE: Record<RowState, string> = {
healthy: 'var(--brand)',
warn: 'var(--warning)',
error: 'var(--destructive)',
};
// One labeled metric cell inside the 3-up strip (memory / disk / network).
function StripCell({ label, value, bar }: { label: string; value: string; bar?: ReactNode }) {
return (
<div className="min-w-0 flex-1 px-[13px] py-3">
<Kicker>{label}</Kicker>
<div className="mt-1.5 font-mono tabular-nums text-[17px] leading-none text-stat-value truncate">{value}</div>
{bar}
</div>
);
}
export function MobileDashboard({ notifications, headerActions, onNavigateToStack, onViewAllStacks }: MobileDashboardProps) {
const { activeNode } = useNodes();
const data = useDashboardData();
const activeNodeName = activeNode?.name || 'Local';
const locality = activeNode?.type === 'remote' ? 'remote' : 'local';
// Re-render every few seconds so the "sync Xs" freshness label advances
// without a parent refetch, mirroring the desktop HealthStatusBar ticker.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 5000);
return () => clearInterval(id);
}, []);
const { level } = useMemo(
() => deriveHealth(data.stats, data.systemStats, notifications),
[data.stats, data.systemStats, notifications],
);
const cpuVal = parseFloat(data.systemStats?.cpu.usage || '0');
const ramVal = parseFloat(data.systemStats?.memory.usagePercent || '0');
const diskVal = parseFloat(data.systemStats?.disk?.usagePercent || '0');
const netPerSec = (data.systemStats?.network?.rxSec ?? 0) + (data.systemStats?.network?.txSec ?? 0);
const cpuHistory = data.cpuHistory;
const cpuPeak = cpuHistory.length > 0 ? Math.max(...cpuHistory) : 0;
const cpuAvg = cpuHistory.length > 0 ? cpuHistory.reduce((s, v) => s + v, 0) / cpuHistory.length : 0;
const cpuPeakLabel = useMemo(() => {
if (cpuHistory.length === 0 || data.historyEndAt === null) return null;
const peakIndex = cpuHistory.indexOf(cpuPeak);
if (peakIndex < 0) return null;
const bucketMs = SPARK_WINDOW_MS / cpuHistory.length;
const ts = data.historyEndAt - (cpuHistory.length - 1 - peakIndex) * bucketMs;
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}, [cpuHistory, cpuPeak, data.historyEndAt]);
const healthRows = useMemo(() => {
const list = Object.entries(data.stackStatuses).map(([file, entry]) => {
const name = file.replace(/\.(ya?ml)$/, '');
const series: StackCpuSeries | undefined = data.stackCpuSeries[name];
const peakCpu = series?.peakValue ?? 0;
return {
file,
name,
status: entry.status,
cpu: series?.latestValue ?? 0,
points: series?.points ?? [],
peakCpu,
state: classifyRow(entry.status, peakCpu),
};
});
const order: Record<RowState, number> = { error: 0, warn: 1, healthy: 2 };
list.sort((a, b) => order[a.state] - order[b.state] || b.peakCpu - a.peakCpu);
return list;
}, [data.stackStatuses, data.stackCpuSeries]);
const visibleRows = healthRows.slice(0, 6);
const stackCount = healthRows.length;
const upCount = healthRows.filter(r => r.status === 'running').length;
const downCount = healthRows.filter(r => r.status === 'exited').length;
const syncLabel = data.lastSyncAt ? `sync ${formatAgo(now - data.lastSyncAt)}` : 'connecting…';
const cpuSub = cpuHistory.length > 0
? `avg ${cpuAvg.toFixed(0)}% last 10m · peak ${cpuPeak.toFixed(0)}%${cpuPeakLabel ? ` @ ${cpuPeakLabel}` : ''}`
: 'collecting metrics…';
return (
<div className="flex h-full min-h-0 flex-col">
<Masthead
kicker={`${activeNodeName} · ${locality}`}
state={LEVEL_LABEL[level]}
stateTone={LEVEL_TONE[level]}
live={level !== 'healthy' || data.metricsStale}
meta={
<span className="inline-flex flex-wrap items-center gap-x-1.5 gap-y-1">
{/* When metrics polling has died, "sync Xs" would falsely imply
liveness, so swap it for a stale marker and a warning chip
(mirrors the desktop HealthStatusBar badge). */}
<span>{`${stackCount} stacks · ${upCount} up · ${downCount} dn · ${data.metricsStale ? 'metrics paused' : syncLabel}`}</span>
{data.metricsStale ? (
<span className="rounded-sm border border-warning/30 bg-warning/10 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-warning">
metrics stale
</span>
) : null}
</span>
}
right={headerActions}
/>
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px] [&>*+*]:mt-[14px]">
{/* CPU hero */}
<div className="rounded-[12px] border border-card-border border-t-card-border-top bg-card px-[15px] pt-[13px] pb-2 shadow-card-bevel">
<div className="flex items-start justify-between gap-3">
<Kicker>{`cpu${data.systemStats ? ` · ${data.systemStats.cpu.cores} cores` : ''}`}</Kicker>
<span className="font-mono tabular-nums text-[30px] leading-none tracking-[-0.02em] text-stat-value">
{data.systemStats ? `${cpuVal.toFixed(0)}%` : '--'}
</span>
</div>
<div className="mx-[-3px] mb-1 mt-1.5">
<MSparkline values={cpuHistory} height={46} peak={false} />
</div>
<div className="font-mono text-[10.5px] text-stat-subtitle">{cpuSub}</div>
</div>
{/* mem / disk / net 3-up */}
<div className="flex items-stretch divide-x divide-hairline overflow-hidden rounded-[12px] border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
<StripCell
label="mem"
value={data.systemStats ? `${ramVal.toFixed(0)}%` : '--'}
bar={data.systemStats ? <Bar pct={ramVal} /> : undefined}
/>
<StripCell
label="disk"
value={data.systemStats?.disk ? `${diskVal.toFixed(0)}%` : '--'}
bar={data.systemStats?.disk ? <Bar pct={diskVal} /> : undefined}
/>
<StripCell
label="net"
value={data.systemStats?.network ? `${formatBytes(netPerSec)}/s` : '--'}
/>
</div>
{/* stack health */}
<div>
<SectionHead right={<button type="button" onClick={onViewAllStacks} className="text-brand">view all </button>}>
stack health
</SectionHead>
{visibleRows.length === 0 ? (
<p className="px-1 py-4 font-mono text-[12px] text-stat-subtitle">No stacks yet.</p>
) : (
<div className="flex flex-col gap-px">
{visibleRows.map(row => (
<button
key={row.file}
type="button"
onClick={() => onNavigateToStack(row.file)}
className={`flex min-h-11 items-center gap-2.5 rounded-[7px] px-2.5 py-[9px] text-left ${ROW_TINT[row.state]}`}
>
<StateDot tone={ROW_TONE[row.state]} size={7} glow={row.state !== 'healthy'} />
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-[13px] text-stat-value">{row.name}</span>
<span className="block truncate font-mono text-[10px] text-stat-icon">{activeNodeName}</span>
</span>
<span className="block h-[18px] w-[60px] shrink-0">
{row.points.length > 1 ? (
<MSparkline values={row.points} height={18} color={ROW_STROKE[row.state]} peak={false} />
) : (
<span className="block h-full w-full border-b border-dashed border-hairline" />
)}
</span>
<span
className={`w-[34px] shrink-0 text-right font-mono tabular-nums text-[12px] ${row.cpu >= 80 ? 'text-warning' : 'text-stat-subtitle'}`}
>
{`${row.cpu.toFixed(0)}%`}
</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,358 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { ChevronRight, Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
import { toast } from '@/components/ui/toast-store';
import { ConfirmModal } from '@/components/ui/modal';
import { formatBytes } from '@/lib/utils';
import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils';
import type { FleetNode } from '@/components/FleetView/types';
import { Bar, BackChip, Kicker, Masthead, MBtn, SectionHead, StateDot, StatePill } from './mobile-ui';
import type { Tone as UiTone } from './mobile-ui';
interface MobileFleetProps {
headerActions: ReactNode;
/** Switch the active node and drop to its stack list. */
onInspectNode: (nodeId: number) => void;
/** Switch the active node and open a specific stack on it. */
onInspectStack: (nodeId: number, stackName: string) => void;
}
// Node health is a strict subset of the primitive tones (never brand-colored).
// Deriving it keeps the subset compiler-enforced if mobile-ui's tones change.
type Tone = Exclude<UiTone, 'brand'>;
function nodeTone(node: FleetNode): Tone {
if (node.status !== 'online') return 'destructive';
if (isCritical(node)) return 'warning';
return 'success';
}
function formatAgo(ms: number): string {
const c = Math.max(0, ms);
if (c < 60_000) return `${Math.round(c / 1000)}s`;
if (c < 3_600_000) return `${Math.round(c / 60_000)}m`;
return `${Math.round(c / 3_600_000)}h`;
}
// Fetch + poll the fleet overview (same endpoint the desktop fleet uses).
function useMobileFleet() {
const [nodes, setNodes] = useState<FleetNode[]>([]);
const [loading, setLoading] = useState(true);
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchOverview = useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await apiFetch('/fleet/overview', { localOnly: true, signal: controller.signal });
if (res.ok) {
setNodes(await res.json() as FleetNode[]);
setLastSyncAt(Date.now());
} else {
// Leave the stale data and let the masthead "last sync" age visibly
// rather than toast on every failed background poll, but log so a
// wedged poll is traceable.
console.error('Fleet overview poll failed:', res.status);
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
console.error('Failed to fetch fleet overview:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// fetchOverview sets state only after an await, in a later tick, so it does
// not cause the synchronous cascading render this rule guards against; the
// rule can't follow the call through the async boundary and flags it here.
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchOverview();
const id = setInterval(() => void fetchOverview(), 30_000);
return () => {
clearInterval(id);
abortRef.current?.abort();
};
}, [fetchOverview]);
return { nodes, loading, lastSyncAt, refetch: fetchOverview };
}
// One labeled metric cell in the masthead band / node card.
function StatCell({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 flex-1 px-3 py-2.5 text-center">
<div className="font-mono tabular-nums text-[17px] leading-none text-stat-value truncate">{value}</div>
<div className="mt-1"><Kicker>{label}</Kicker></div>
</div>
);
}
function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boolean; onOpen: () => void }) {
const tone = nodeTone(node);
const local = node.type === 'local';
const stateLabel = node.status !== 'online' ? 'offline' : isCritical(node) ? 'critical' : 'online';
return (
<button
type="button"
onClick={onOpen}
className={`relative w-full overflow-hidden rounded-[12px] border border-card-border border-t-card-border-top bg-card text-left shadow-card-bevel ${node.status === 'online' ? '' : 'opacity-60'}`}
>
{local ? <span aria-hidden className="absolute inset-y-0 left-0 w-[2px] bg-brand" /> : null}
<div className="flex items-center gap-2.5 px-[13px] pb-2.5 pt-3">
<StateDot tone={tone} size={7} glow pulse={local} />
<span className="min-w-0 flex-1 truncate font-mono text-[14px] text-stat-value">{node.name}</span>
{local ? (
<span className="rounded-[5px] bg-brand/[0.09] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.14em] text-brand">
you are here
</span>
) : null}
{isActive && !local ? (
<span className="rounded-[5px] bg-brand/[0.09] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.14em] text-brand">
active
</span>
) : null}
<Kicker className={tone === 'destructive' ? 'text-destructive' : tone === 'warning' ? 'text-warning' : 'text-stat-subtitle'}>
{node.cordoned ? 'cordoned' : stateLabel}
</Kicker>
</div>
<div className="flex items-stretch divide-x divide-hairline border-t border-hairline">
<StatCell label="stacks" value={`${node.stacks?.length ?? 0}`} />
<StatCell label="cpu" value={node.status === 'online' && node.systemStats ? `${getNodeCpu(node).toFixed(0)}%` : '--'} />
<StatCell label="mem" value={node.status === 'online' && node.systemStats ? `${getNodeMem(node).toFixed(0)}%` : '--'} />
</div>
</button>
);
}
// One labeled resource bar in the node detail.
function ResourceRow({ label, pct, detail }: { label: string; pct: number; detail: string }) {
return (
<div className="py-2">
<div className="flex items-baseline justify-between">
<Kicker>{label}</Kicker>
<span className="font-mono tabular-nums text-[12px] text-stat-subtitle">{detail}</span>
</div>
<Bar pct={pct} />
</div>
);
}
function NodeDetail({
node,
now,
onBack,
onInspectNode,
onInspectStack,
onCordonChange,
}: {
node: FleetNode;
now: number;
onBack: () => void;
onInspectNode: (nodeId: number) => void;
onInspectStack: (nodeId: number, stackName: string) => void;
onCordonChange: () => void;
}) {
const { isPaid } = useLicense();
const { can } = useAuth();
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
const [confirmOpen, setConfirmOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const tone = nodeTone(node);
const online = node.status === 'online';
const lastSeen = node.last_successful_contact ?? node.pilot_last_seen ?? null;
const stacks = node.stacks ?? [];
const handleCordon = async () => {
setSubmitting(true);
try {
if (node.cordoned) {
await uncordonNode(node.id);
toast.success(`Uncordoned ${node.name}`);
} else {
await cordonNode(node.id, null);
toast.success(`Cordoned ${node.name}`);
}
setConfirmOpen(false);
onCordonChange();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update cordon state');
} finally {
setSubmitting(false);
}
};
return (
<div className="flex h-full min-h-0 flex-col">
<div className="px-2 pt-1">
<BackChip label="Fleet" onClick={onBack} />
</div>
<div className="relative border-b border-hairline px-4 pb-[15px] pt-1">
<span aria-hidden className={`absolute left-0 top-1 bottom-[15px] w-[3px] ${tone === 'destructive' ? 'bg-destructive' : tone === 'warning' ? 'bg-warning' : 'bg-brand'}`} />
<div className="mb-1"><Kicker>{`fleet node ${node.name}`}</Kicker></div>
<div className="flex items-center gap-2.5">
<span className="min-w-0 truncate font-display italic text-[30px] leading-[34px] text-stat-value">{node.name}</span>
<StatePill tone={tone} live={!online}>{node.cordoned ? 'cordoned' : online ? (isCritical(node) ? 'degraded' : 'online') : 'offline'}</StatePill>
</div>
<div className="mt-[7px] font-mono text-[12px] text-stat-subtitle">
{`${node.type} · ${stacks.length} stacks${lastSeen ? ` · last seen ${formatAgo(now - lastSeen)}` : ''}`}
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px]">
<div className="flex gap-2">
<MBtn kind="primary" full onClick={() => onInspectNode(node.id)}>Inspect</MBtn>
{canCordon ? (
<MBtn kind="outline" full onClick={() => setConfirmOpen(true)}>
{node.cordoned ? 'Uncordon' : 'Drain'}
</MBtn>
) : null}
</div>
{online && node.systemStats ? (
<div className="mt-[14px]">
<SectionHead right="cpu · mem · disk">resources</SectionHead>
<ResourceRow label="cpu" pct={getNodeCpu(node)} detail={`${getNodeCpu(node).toFixed(0)}%`} />
<ResourceRow
label="mem"
pct={getNodeMem(node)}
detail={`${formatBytes(node.systemStats.memory.used, 1)} / ${formatBytes(node.systemStats.memory.total, 1)}`}
/>
{node.systemStats.disk ? (
<ResourceRow
label="disk"
pct={getNodeDisk(node)}
detail={`${formatBytes(node.systemStats.disk.used, 1)} / ${formatBytes(node.systemStats.disk.total, 1)}`}
/>
) : null}
</div>
) : null}
<div className="mt-[14px]">
<SectionHead right={`${stacks.length}`}>stacks on node</SectionHead>
{stacks.length === 0 ? (
<p className="px-1 py-3 font-mono text-[12px] text-stat-subtitle">{online ? 'No stacks on this node.' : 'Node unreachable.'}</p>
) : (
<div className="flex flex-col">
{stacks.map(stack => (
<button
key={stack}
type="button"
onClick={() => onInspectStack(node.id, stack)}
className="flex min-h-11 items-center gap-2 border-b border-hairline py-2 text-left last:border-b-0"
>
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-stat-value">{stack.replace(/\.(ya?ml)$/, '')}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-stat-icon" strokeWidth={1.6} />
</button>
))}
</div>
)}
</div>
</div>
<div className="border-t border-hairline bg-band px-4 py-2.5">
<Kicker>{`${lastSeen ? `last seen ${formatAgo(now - lastSeen)} ago · ` : ''}auto-refreshes every 30s`}</Kicker>
</div>
<ConfirmModal
open={confirmOpen}
onOpenChange={(open) => { if (!submitting) setConfirmOpen(open); }}
kicker="Federation"
title={node.cordoned ? `Uncordon ${node.name}` : `Cordon ${node.name}`}
description={node.cordoned
? 'Re-enable this node for new blueprint placements. Existing deployments are unchanged.'
: 'Mark this node as unschedulable. New blueprint deployments will skip it. Existing deployments remain in place.'}
confirmLabel={node.cordoned ? 'Uncordon node' : 'Cordon node'}
confirming={submitting}
onConfirm={handleCordon}
/>
</div>
);
}
export function MobileFleet({ headerActions, onInspectNode, onInspectStack }: MobileFleetProps) {
const { nodes, loading, lastSyncAt, refetch } = useMobileFleet();
const { activeNode } = useNodes();
const [selectedId, setSelectedId] = useState<number | null>(null);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 5000);
return () => clearInterval(id);
}, []);
const selected = selectedId !== null ? nodes.find(n => n.id === selectedId) ?? null : null;
if (selected) {
return (
<NodeDetail
node={selected}
now={now}
onBack={() => setSelectedId(null)}
onInspectNode={onInspectNode}
onInspectStack={onInspectStack}
onCordonChange={() => void refetch()}
/>
);
}
const onlineNodes = nodes.filter(n => n.status === 'online');
const criticalCount = onlineNodes.filter(isCritical).length;
const offlineCount = nodes.length - onlineNodes.length;
const level = criticalCount > 0 ? 'critical' : offlineCount > 0 ? 'degraded' : 'healthy';
const label = level === 'critical' ? 'Critical' : level === 'degraded' ? 'Degraded' : 'Healthy';
const tone: Tone = level === 'critical' ? 'destructive' : level === 'degraded' ? 'warning' : 'success';
const totalStacks = nodes.reduce((sum, n) => sum + (n.stacks?.length ?? 0), 0);
const running = nodes.reduce((sum, n) => sum + (n.stats?.active ?? 0), 0);
const avgCpu = onlineNodes.length > 0 ? onlineNodes.reduce((s, n) => s + getNodeCpu(n), 0) / onlineNodes.length : 0;
const memUsed = onlineNodes.reduce((s, n) => s + (n.systemStats?.memory.used ?? 0), 0);
const memTotal = onlineNodes.reduce((s, n) => s + (n.systemStats?.memory.total ?? 0), 0);
const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : 0;
const syncLabel = lastSyncAt ? `last sync ${formatAgo(now - lastSyncAt)}` : 'connecting…';
return (
<div className="flex h-full min-h-0 flex-col">
<Masthead
kicker="fleet · overview"
state={label}
stateTone={tone}
live={level !== 'healthy'}
meta={`${nodes.length} ${nodes.length === 1 ? 'node' : 'nodes'} · ${totalStacks} stacks · ${syncLabel}`}
right={headerActions}
/>
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px] [&>*+*]:mt-[14px]">
<div className="flex items-stretch divide-x divide-hairline overflow-hidden rounded-[12px] border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
<StatCell label="running" value={`${running}`} />
<StatCell label="cpu" value={onlineNodes.length > 0 ? `${avgCpu.toFixed(0)}%` : '--'} />
<StatCell label="mem" value={memTotal > 0 ? `${memPct.toFixed(0)}%` : '--'} />
</div>
{loading && nodes.length === 0 ? (
<div className="flex items-center justify-center py-10 text-stat-subtitle">
<Loader2 className="h-5 w-5 animate-spin" strokeWidth={1.5} />
</div>
) : nodes.length === 0 ? (
<p className="px-1 py-4 font-mono text-[12px] text-stat-subtitle">No nodes configured.</p>
) : (
<div className="flex flex-col gap-2.5">
{nodes.map(node => (
<NodeCard
key={node.id}
node={node}
isActive={activeNode?.id === node.id}
onOpen={() => setSelectedId(node.id)}
/>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,169 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import type { ScheduledTask } from '@/types/scheduling';
import { Masthead, SectionHead, StateDot } from './mobile-ui';
interface MobileSchedulesProps {
headerActions: ReactNode;
}
type Tone = 'success' | 'warning' | 'destructive' | 'brand';
const ACTION_TONE: Record<ScheduledTask['action'], Tone> = {
restart: 'brand',
update: 'success',
scan: 'success',
prune: 'warning',
snapshot: 'warning',
auto_backup: 'brand',
auto_stop: 'warning',
auto_down: 'destructive',
auto_start: 'success',
};
const ACTION_LABEL: Record<ScheduledTask['action'], string> = {
restart: 'restart',
update: 'update',
scan: 'scan',
prune: 'prune',
snapshot: 'snapshot',
auto_backup: 'backup',
auto_stop: 'stop',
auto_down: 'down',
auto_start: 'start',
};
function hhmm(ts: number): string {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function relative(ts: number, now: number): string {
const diff = ts - now;
if (diff <= 0) return 'now';
const mins = Math.round(diff / 60_000);
if (mins < 60) return `in ${mins}m`;
const hours = Math.floor(mins / 60);
const rem = mins % 60;
return rem === 0 ? `in ${hours}h` : `in ${hours}h ${rem}m`;
}
function dayLabel(ts: number, now: number): string {
const d = new Date(ts);
const n = new Date(now);
const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const dayDiff = Math.round((startOf(d) - startOf(n)) / 86_400_000);
if (dayDiff <= 0) return 'Today';
if (dayDiff === 1) return 'Tomorrow';
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
function targetLabel(task: ScheduledTask): string {
if (task.target_type === 'stack') return (task.target_id ?? task.name).replace(/\.(ya?ml)$/, '');
if (task.target_type === 'fleet') return 'fleet';
return task.target_type;
}
interface UpcomingRun {
task: ScheduledTask;
runAt: number;
}
export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(true);
const [now, setNow] = useState(() => Date.now());
const abortRef = useRef<AbortController | null>(null);
const fetchTasks = useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await apiFetch('/scheduled-tasks', { localOnly: true, signal: controller.signal });
if (res.ok) {
setTasks(await res.json() as ScheduledTask[]);
} else {
console.error('Scheduled tasks poll failed:', res.status);
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
console.error('Failed to fetch scheduled tasks:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// fetchTasks sets state only after an await, so it does not cause the
// synchronous cascading render this rule guards against; the rule flags the
// call conservatively because it can't follow the async boundary.
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchTasks();
const id = setInterval(() => void fetchTasks(), 60_000);
return () => {
clearInterval(id);
abortRef.current?.abort();
};
}, [fetchTasks]);
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 30_000);
return () => clearInterval(id);
}, []);
const enabledCount = tasks.filter(t => t.enabled === 1).length;
const upcoming: UpcomingRun[] = tasks
.filter(t => t.enabled === 1 && t.next_runs && t.next_runs.length > 0)
.flatMap(task => (task.next_runs ?? []).map(runAt => ({ task, runAt })))
.filter(p => p.runAt >= now)
.sort((a, b) => a.runAt - b.runAt)
.slice(0, 60);
const next = upcoming[0] ?? null;
return (
<div className="flex h-full min-h-0 flex-col">
<Masthead
kicker={`schedules · ${enabledCount} active`}
state={next ? hhmm(next.runAt) : '--:--'}
stateTone="brand"
live={false}
meta={next ? `${relative(next.runAt, now)} · ${ACTION_LABEL[next.task.action]} ${targetLabel(next.task)}` : 'nothing scheduled'}
right={headerActions}
/>
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px]">
{loading && tasks.length === 0 ? (
<div className="flex items-center justify-center py-10 text-stat-subtitle">
<Loader2 className="h-5 w-5 animate-spin" strokeWidth={1.5} />
</div>
) : upcoming.length === 0 ? (
<p className="px-1 py-4 font-mono text-[12px] text-stat-subtitle">
Nothing scheduled. Create a schedule on desktop to automate recurring operations.
</p>
) : (
upcoming.map((run, i) => {
const prevDay = i > 0 ? dayLabel(upcoming[i - 1].runAt, now) : null;
const day = dayLabel(run.runAt, now);
const tone = ACTION_TONE[run.task.action];
return (
<div key={`${run.task.id}-${run.runAt}`}>
{day !== prevDay ? <SectionHead>{day}</SectionHead> : null}
<div className="flex items-center gap-2.5 py-2">
<span className="w-[46px] shrink-0 font-mono tabular-nums text-[13px] text-stat-value">{hhmm(run.runAt)}</span>
<StateDot tone={tone} size={7} glow />
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-stat-subtitle">
<span className="text-stat-value">{ACTION_LABEL[run.task.action]}</span>{` ${targetLabel(run.task)}`}
</span>
<span className="shrink-0 font-mono text-[11px] text-stat-icon">{relative(run.runAt, now)}</span>
</div>
</div>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,100 @@
import { useState, type ReactNode } from 'react';
import { ChevronRight } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import {
SETTINGS_GROUPS,
SETTINGS_ITEMS,
getSettingsItem,
getSettingsGroup,
isItemVisible,
isItemLocked,
} from '@/components/settings';
import type { SectionId } from '@/components/settings';
import { SettingsSectionContent } from '@/components/settings/SettingsSectionContent';
import { BackChip, Kicker, Masthead } from './mobile-ui';
interface MobileSettingsProps {
headerActions: ReactNode;
}
const NOOP = () => {};
export function MobileSettings({ headerActions }: MobileSettingsProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const nodeName = activeNode?.name ?? 'local';
const visibility = { isRemote, isAdmin, isPaid };
const visibleItems = SETTINGS_ITEMS.filter(
item => isItemVisible(item, visibility) && !isItemLocked(item, visibility),
);
const groups = SETTINGS_GROUPS
.map(group => ({ ...group, items: visibleItems.filter(item => item.group === group.id) }))
.filter(group => group.items.length > 0);
const [selected, setSelected] = useState<SectionId | null>(null);
// If the active node changes and hides the open section, fall back to the list.
const activeSection = selected && visibleItems.some(i => i.id === selected) ? selected : null;
const item = activeSection ? getSettingsItem(activeSection) : null;
if (activeSection && item) {
const group = getSettingsGroup(item.group);
return (
<div className="flex h-full min-h-0 flex-col">
<div className="px-2 pt-1">
<BackChip label="Settings" onClick={() => setSelected(null)} />
</div>
<div className="relative border-b border-hairline px-4 pb-[15px] pt-1">
<span aria-hidden className="absolute left-0 top-1 bottom-[15px] w-[3px] bg-brand" />
<div className="mb-1"><Kicker>{`settings · ${group?.label ?? ''} · ${item.label}`}</Kicker></div>
<span className="font-display italic text-[30px] leading-[34px] text-stat-value">{item.label}</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 pb-8 pt-4 flex flex-col gap-6">
<SettingsSectionContent sectionId={activeSection} isPaid={isPaid} onDirtyChange={NOOP} showDescription />
</div>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<Masthead
kicker={`settings · ${nodeName}`}
state="Settings"
stateTone="brand"
live={false}
right={headerActions}
/>
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px] [&>*+*]:mt-[14px]">
{groups.map(group => (
<div
key={group.id}
className="overflow-hidden rounded-[12px] border border-card-border border-t-card-border-top bg-card shadow-card-bevel"
>
<div className="border-b border-hairline px-[13px] py-2.5">
<Kicker>{group.kicker ? `${group.label} · ${group.kicker}` : group.label}</Kicker>
</div>
{group.items.map((it, idx) => (
<button
key={it.id}
type="button"
onClick={() => setSelected(it.id)}
className={`flex min-h-11 w-full items-center gap-3 px-[13px] py-3 text-left ${idx > 0 ? 'border-t border-hairline' : ''}`}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[15px] text-stat-value">{it.label}</span>
<span className="block truncate font-mono text-[11px] text-stat-icon">{it.description}</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-stat-icon" strokeWidth={1.6} />
</button>
))}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,188 @@
// Shared building blocks for the bespoke mobile (<md) screens. These translate
// the approved prototype's inline-styled primitives (docs/design/mobile-reference)
// onto our design tokens. No new colors or fonts. Rendered only on the mobile
// shell surfaces.
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Sparkline } from '@/components/ui/sparkline';
export type Tone = 'success' | 'warning' | 'destructive' | 'brand';
const TONE_TEXT: Record<Tone, string> = {
success: 'text-success',
warning: 'text-warning',
destructive: 'text-destructive',
brand: 'text-brand',
};
const TONE_BG: Record<Tone, string> = {
success: 'bg-success',
warning: 'bg-warning',
destructive: 'bg-destructive',
brand: 'bg-brand',
};
const TONE_MUTED: Record<Tone, string> = {
success: 'bg-success/[0.14]',
warning: 'bg-warning/[0.14]',
destructive: 'bg-destructive/[0.14]',
brand: 'bg-brand/[0.09]',
};
const TONE_VAR: Record<Tone, string> = {
success: 'var(--success)',
warning: 'var(--warning)',
destructive: 'var(--destructive)',
brand: 'var(--brand)',
};
// Bar fill color by threshold: >=90 destructive, >=80 warning, else brand.
function barColor(pct: number): string {
if (pct >= 90) return TONE_VAR.destructive;
if (pct >= 80) return TONE_VAR.warning;
return TONE_VAR.brand;
}
// Tracked-mono uppercase label.
export function Kicker({ children, className }: { children: ReactNode; className?: string }) {
return (
<span className={cn('font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-icon', className)}>
{children}
</span>
);
}
// Section header on a thin top rule.
export function SectionHead({ children, right }: { children: ReactNode; right?: ReactNode }) {
return (
<div className="flex items-center justify-between border-t border-hairline pt-[9px] mb-[9px]">
<Kicker>{children}</Kicker>
{right ? <Kicker className="text-stat-subtitle">{right}</Kicker> : null}
</div>
);
}
export function StateDot({ tone = 'success', size = 7, glow = false, pulse = false }: { tone?: Tone; size?: number; glow?: boolean; pulse?: boolean }) {
return (
<span
className={cn('inline-block shrink-0 rounded-full', TONE_BG[tone], pulse && 'animate-pulse')}
style={{
width: size,
height: size,
boxShadow: glow || pulse ? `0 0 6px 0 ${TONE_VAR[tone]}` : undefined,
}}
/>
);
}
// Horizontal gauge bar (5px, recessed well track, threshold fill).
export function Bar({ pct }: { pct: number }) {
return (
<div className="mt-[7px] h-[5px] overflow-hidden rounded-[3px] bg-well">
<div className="h-full rounded-[3px]" style={{ width: `${Math.max(0, Math.min(100, pct))}%`, background: barColor(pct) }} />
</div>
);
}
// Sparkline wrapper matching the reference (gradient fill + amber peak dot).
export function MSparkline({ values, height = 30, color = 'var(--brand)', peak = true }: { values: number[]; height?: number; color?: string; peak?: boolean }) {
return (
<div style={{ height }}>
<Sparkline points={values} stroke={color} fill={color} peakColor="var(--warning)" showPeak={peak} className="h-full w-full" />
</div>
);
}
// State pill (online, degraded, etc.).
export function StatePill({ tone = 'success', live = false, children }: { tone?: Tone; live?: boolean; children: ReactNode }) {
return (
<span
className={cn('inline-flex items-center gap-1.5 rounded-[7px] border px-[9px] py-[3px] font-mono text-[11px] tracking-[0.04em]', TONE_TEXT[tone], TONE_MUTED[tone])}
style={{ borderColor: `color-mix(in oklch, ${TONE_VAR[tone]} 30%, transparent)` }}
>
<StateDot tone={tone} size={6} pulse={live} glow={!live} />
{children}
</span>
);
}
// Mono button: primary (cyan) / outline / ghost. >=44px tall.
export function MBtn({
kind = 'outline',
children,
full = false,
onClick,
disabled = false,
className,
}: { kind?: 'primary' | 'outline' | 'ghost'; children: ReactNode; full?: boolean; onClick?: () => void; disabled?: boolean; className?: string }) {
const variant =
kind === 'primary' ? 'bg-brand text-brand-foreground shadow-btn-glow'
: kind === 'outline' ? 'bg-card text-stat-title border border-card-border border-t-card-border-top shadow-btn-glow'
: 'bg-transparent text-stat-subtitle';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
'inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg border border-transparent px-[14px] font-mono text-[11px] font-medium uppercase tracking-[0.12em] whitespace-nowrap transition-colors disabled:opacity-50',
full && 'w-full',
variant,
className,
)}
>
{children}
</button>
);
}
// Back chevron chip for drill-in detail screens.
export function BackChip({ label, onClick }: { label: string; onClick?: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex min-h-11 items-center gap-1 rounded-[7px] pl-0.5 pr-2 font-mono text-xs text-brand"
>
<svg width="8" height="13" viewBox="0 0 8 13" aria-hidden="true">
<path d="M6.5 1L1 6.5 6.5 12" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{label}
</button>
);
}
// Status masthead for the bespoke mobile content leads (Home and Fleet today;
// Schedules as it is re-skinned): 3px cyan left rail, kicker, serif-italic
// state word, meta, optional right slot.
export function Masthead({
kicker,
state,
stateTone = 'success',
meta,
right,
live = true,
stateClassName,
}: {
kicker: ReactNode;
state: ReactNode;
stateTone?: Tone;
meta?: ReactNode;
right?: ReactNode;
live?: boolean;
stateClassName?: string;
}) {
return (
<div className="relative border-b border-hairline px-4 pb-[15px] pt-2">
<span aria-hidden className="absolute left-0 top-2 bottom-[15px] w-[3px] bg-brand" />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="mb-1"><Kicker>{kicker}</Kicker></div>
<div className="flex items-center gap-[11px]">
<StateDot tone={stateTone} size={11} pulse={live} />
<span className={cn('font-display italic text-[38px] leading-[40px] text-stat-value', stateClassName)}>{state}</span>
</div>
</div>
{right ? <div className="shrink-0 text-right">{right}</div> : null}
</div>
{meta ? <div className="mt-[7px] font-mono text-[12px] text-stat-subtitle">{meta}</div> : null}
</div>
);
}
@@ -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;
}