From 928a3a8343a557a0ecc3cf346df8974456ec2ce2 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 7 Jun 2026 01:15:16 -0400 Subject: [PATCH] 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. --- .gitignore | 1 + frontend/src/components/EditorLayout.tsx | 145 +++++-- .../components/EditorLayout/EditorView.tsx | 3 + .../EditorLayout/MobileStackDetail.tsx | 22 +- frontend/src/components/MobileMoreMenu.tsx | 77 ++++ frontend/src/components/MobileTabBar.test.tsx | 21 +- frontend/src/components/MobileTabBar.tsx | 34 +- .../components/dashboard/HealthStatusBar.tsx | 28 +- .../components/dashboard/deriveHealth.test.ts | 67 ++++ .../src/components/dashboard/deriveHealth.ts | 31 ++ .../src/components/mobile/MobileDashboard.tsx | 239 ++++++++++++ .../src/components/mobile/MobileFleet.tsx | 358 ++++++++++++++++++ .../src/components/mobile/MobileSchedules.tsx | 169 +++++++++ .../src/components/mobile/MobileSettings.tsx | 100 +++++ frontend/src/components/mobile/mobile-ui.tsx | 188 +++++++++ .../src/components/settings/SettingsPage.tsx | 132 +------ .../settings/SettingsSectionContent.tsx | 158 ++++++++ 17 files changed, 1559 insertions(+), 214 deletions(-) create mode 100644 frontend/src/components/MobileMoreMenu.tsx create mode 100644 frontend/src/components/dashboard/deriveHealth.test.ts create mode 100644 frontend/src/components/dashboard/deriveHealth.ts create mode 100644 frontend/src/components/mobile/MobileDashboard.tsx create mode 100644 frontend/src/components/mobile/MobileFleet.tsx create mode 100644 frontend/src/components/mobile/MobileSchedules.tsx create mode 100644 frontend/src/components/mobile/MobileSettings.tsx create mode 100644 frontend/src/components/mobile/mobile-ui.tsx create mode 100644 frontend/src/components/settings/SettingsSectionContent.tsx diff --git a/.gitignore b/.gitignore index eb5e019b..40f37b16 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 2be254a8..3d24ea64 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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(['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) => ( ); + const notificationsEl = ( + + ); + const themeSwitchEl = ; + const userMenuEl = openSettings('account')} />; + const topBarEl = ( } - themeSwitch={} - notifications={ - - } - userMenu={ - 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 = ( +
+ {notificationsEl} + {themeSwitchEl}{userMenuEl}} + /> +
+ ); + const workspaceEl = (
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 ( + + ); + case 'fleet': + return ( + + ); + case 'scheduled-ops': + return ; + case 'settings': + return ; + default: + return workspaceEl; + } + }; + if (isMobile) { return (
{commandPaletteEl} - {mobileSurface !== 'detail' && topBarEl} + {mobileSurface !== 'detail' && !bespokeContent && topBarEl}
{mobileSurface === 'list' && sidebarEl} - {mobileSurface === 'content' && workspaceEl} + {mobileSurface === 'content' && (bespokeContent ? renderMobileBespoke() : workspaceEl)} {mobileSurface === 'detail' && ( detailReady ? ( -
{renderEditor()}
+
{renderEditor(mobileMastheadActions)}
) : ( - + ) )}
@@ -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 (
@@ -670,9 +744,10 @@ function MobileDetailLoading({ name, onBack }: { name: string; onBack: () => voi Stacks - + {name.replace(/\.(ya?ml)$/, '')} + {headerActions ?
{headerActions}
: null}
diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 68e7727e..5dfd3ff1 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -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) { diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index 19339e68..9908af38 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -54,6 +54,7 @@ export function MobileStackDetail(props: EditorViewProps) { setCopiedDigest, requestDeleteStack, onMobileBack, + headerActions, } = props; const [segment, setSegment] = useState('logs'); @@ -67,15 +68,18 @@ export function MobileStackDetail(props: EditorViewProps) {
{/* Detail header: back to list + identity + action bar */}
- +
+ + {headerActions ?
{headerActions}
: null} +
(['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 ( + + + + + +
+

Navigate

+
+ + {footer ? ( +
+ {footer} +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/components/MobileTabBar.test.tsx b/frontend/src/components/MobileTabBar.test.tsx index e7019796..ad917026 100644 --- a/frontend/src/components/MobileTabBar.test.tsx +++ b/frontend/src/components/MobileTabBar.test.tsx @@ -16,6 +16,7 @@ function renderBar(over: Partial> = {} 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> = {} } 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'); diff --git a/frontend/src/components/MobileTabBar.tsx b/frontend/src/components/MobileTabBar.tsx index 143567b4..462f290f 100644 --- a/frontend/src/components/MobileTabBar.tsx +++ b/frontend/src/components/MobileTabBar.tsx @@ -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', )} > - - + + {tab.label} diff --git a/frontend/src/components/dashboard/HealthStatusBar.tsx b/frontend/src/components/dashboard/HealthStatusBar.tsx index 58c48a48..aa203347 100644 --- a/frontend/src/components/dashboard/HealthStatusBar.tsx +++ b/frontend/src/components/dashboard/HealthStatusBar.tsx @@ -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 = { healthy: { label: 'Healthy', diff --git a/frontend/src/components/dashboard/deriveHealth.test.ts b/frontend/src/components/dashboard/deriveHealth.test.ts new file mode 100644 index 00000000..3aeca12d --- /dev/null +++ b/frontend/src/components/dashboard/deriveHealth.test.ts @@ -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 => ({ + 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 => ({ + 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'); + }); +}); diff --git a/frontend/src/components/dashboard/deriveHealth.ts b/frontend/src/components/dashboard/deriveHealth.ts new file mode 100644 index 00000000..9c4c32b8 --- /dev/null +++ b/frontend/src/components/dashboard/deriveHealth.ts @@ -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'] }; +} diff --git a/frontend/src/components/mobile/MobileDashboard.tsx b/frontend/src/components/mobile/MobileDashboard.tsx new file mode 100644 index 00000000..e208daa2 --- /dev/null +++ b/frontend/src/components/mobile/MobileDashboard.tsx @@ -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 = { healthy: 'Healthy', degraded: 'Degraded', critical: 'Critical' }; +const LEVEL_TONE: Record = { + 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 = { + healthy: '', + warn: 'bg-warning-muted', + error: 'bg-destructive-muted', +}; +const ROW_TONE: Record = { + healthy: 'success', + warn: 'warning', + error: 'destructive', +}; +const ROW_STROKE: Record = { + 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 ( +
+ {label} +
{value}
+ {bar} +
+ ); +} + +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 = { 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 ( +
+ + {/* 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). */} + {`${stackCount} stacks · ${upCount} up · ${downCount} dn · ${data.metricsStale ? 'metrics paused' : syncLabel}`} + {data.metricsStale ? ( + + metrics stale + + ) : null} + + } + right={headerActions} + /> + +
+ {/* CPU hero */} +
+
+ {`cpu${data.systemStats ? ` · ${data.systemStats.cpu.cores} cores` : ''}`} + + {data.systemStats ? `${cpuVal.toFixed(0)}%` : '--'} + +
+
+ +
+
{cpuSub}
+
+ + {/* mem / disk / net 3-up */} +
+ : undefined} + /> + : undefined} + /> + +
+ + {/* stack health */} +
+ view all →}> + stack health + + {visibleRows.length === 0 ? ( +

No stacks yet.

+ ) : ( +
+ {visibleRows.map(row => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/mobile/MobileFleet.tsx b/frontend/src/components/mobile/MobileFleet.tsx new file mode 100644 index 00000000..d170840c --- /dev/null +++ b/frontend/src/components/mobile/MobileFleet.tsx @@ -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; + +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([]); + const [loading, setLoading] = useState(true); + const [lastSyncAt, setLastSyncAt] = useState(null); + const abortRef = useRef(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 ( +
+
{value}
+
{label}
+
+ ); +} + +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 ( + + ); +} + +// One labeled resource bar in the node detail. +function ResourceRow({ label, pct, detail }: { label: string; pct: number; detail: string }) { + return ( +
+
+ {label} + {detail} +
+ +
+ ); +} + +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 ( +
+
+ +
+
+ +
{`fleet › node › ${node.name}`}
+
+ {node.name} + {node.cordoned ? 'cordoned' : online ? (isCritical(node) ? 'degraded' : 'online') : 'offline'} +
+
+ {`${node.type} · ${stacks.length} stacks${lastSeen ? ` · last seen ${formatAgo(now - lastSeen)}` : ''}`} +
+
+ +
+
+ onInspectNode(node.id)}>Inspect + {canCordon ? ( + setConfirmOpen(true)}> + {node.cordoned ? 'Uncordon' : 'Drain'} + + ) : null} +
+ + {online && node.systemStats ? ( +
+ resources + + + {node.systemStats.disk ? ( + + ) : null} +
+ ) : null} + +
+ stacks on node + {stacks.length === 0 ? ( +

{online ? 'No stacks on this node.' : 'Node unreachable.'}

+ ) : ( +
+ {stacks.map(stack => ( + + ))} +
+ )} +
+
+ +
+ {`${lastSeen ? `last seen ${formatAgo(now - lastSeen)} ago · ` : ''}auto-refreshes every 30s`} +
+ + { 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} + /> +
+ ); +} + +export function MobileFleet({ headerActions, onInspectNode, onInspectStack }: MobileFleetProps) { + const { nodes, loading, lastSyncAt, refetch } = useMobileFleet(); + const { activeNode } = useNodes(); + const [selectedId, setSelectedId] = useState(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 ( + 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 ( +
+ + +
+
+ + 0 ? `${avgCpu.toFixed(0)}%` : '--'} /> + 0 ? `${memPct.toFixed(0)}%` : '--'} /> +
+ + {loading && nodes.length === 0 ? ( +
+ +
+ ) : nodes.length === 0 ? ( +

No nodes configured.

+ ) : ( +
+ {nodes.map(node => ( + setSelectedId(node.id)} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/mobile/MobileSchedules.tsx b/frontend/src/components/mobile/MobileSchedules.tsx new file mode 100644 index 00000000..ae6d4fac --- /dev/null +++ b/frontend/src/components/mobile/MobileSchedules.tsx @@ -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 = { + 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 = { + 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([]); + const [loading, setLoading] = useState(true); + const [now, setNow] = useState(() => Date.now()); + const abortRef = useRef(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 ( +
+ + +
+ {loading && tasks.length === 0 ? ( +
+ +
+ ) : upcoming.length === 0 ? ( +

+ Nothing scheduled. Create a schedule on desktop to automate recurring operations. +

+ ) : ( + 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 ( +
+ {day !== prevDay ? {day} : null} +
+ {hhmm(run.runAt)} + + + {ACTION_LABEL[run.task.action]}{` ${targetLabel(run.task)}`} + + {relative(run.runAt, now)} +
+
+ ); + }) + )} +
+
+ ); +} diff --git a/frontend/src/components/mobile/MobileSettings.tsx b/frontend/src/components/mobile/MobileSettings.tsx new file mode 100644 index 00000000..42bceb24 --- /dev/null +++ b/frontend/src/components/mobile/MobileSettings.tsx @@ -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(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 ( +
+
+ setSelected(null)} /> +
+
+ +
{`settings · ${group?.label ?? ''} · ${item.label}`}
+ {item.label} +
+
+ +
+
+ ); + } + + return ( +
+ +
+ {groups.map(group => ( +
+
+ {group.kicker ? `${group.label} · ${group.kicker}` : group.label} +
+ {group.items.map((it, idx) => ( + + ))} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/mobile/mobile-ui.tsx b/frontend/src/components/mobile/mobile-ui.tsx new file mode 100644 index 00000000..e217495a --- /dev/null +++ b/frontend/src/components/mobile/mobile-ui.tsx @@ -0,0 +1,188 @@ +// Shared building blocks for the bespoke mobile ( = { + success: 'text-success', + warning: 'text-warning', + destructive: 'text-destructive', + brand: 'text-brand', +}; +const TONE_BG: Record = { + success: 'bg-success', + warning: 'bg-warning', + destructive: 'bg-destructive', + brand: 'bg-brand', +}; +const TONE_MUTED: Record = { + success: 'bg-success/[0.14]', + warning: 'bg-warning/[0.14]', + destructive: 'bg-destructive/[0.14]', + brand: 'bg-brand/[0.09]', +}; +const TONE_VAR: Record = { + 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 ( + + {children} + + ); +} + +// Section header on a thin top rule. +export function SectionHead({ children, right }: { children: ReactNode; right?: ReactNode }) { + return ( +
+ {children} + {right ? {right} : null} +
+ ); +} + +export function StateDot({ tone = 'success', size = 7, glow = false, pulse = false }: { tone?: Tone; size?: number; glow?: boolean; pulse?: boolean }) { + return ( + + ); +} + +// Horizontal gauge bar (5px, recessed well track, threshold fill). +export function Bar({ pct }: { pct: number }) { + return ( +
+
+
+ ); +} + +// 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 ( +
+ +
+ ); +} + +// State pill (online, degraded, etc.). +export function StatePill({ tone = 'success', live = false, children }: { tone?: Tone; live?: boolean; children: ReactNode }) { + return ( + + + {children} + + ); +} + +// 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 ( + + ); +} + +// Back chevron chip for drill-in detail screens. +export function BackChip({ label, onClick }: { label: string; onClick?: () => void }) { + return ( + + ); +} + +// 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 ( +
+ +
+
+
{kicker}
+
+ + {state} +
+
+ {right ?
{right}
: null} +
+ {meta ?
{meta}
: null} +
+ ); +} diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 8a6548d6..b04609c6 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -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 ( -
- - - - -
- ); -} - 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 ; - case 'appearance': return ; - case 'license': return ; - case 'users': return ; - case 'sso': return ; - case 'api-tokens': return ; - case 'registries': return ; - case 'labels': return ; - case 'host-alerts': return handleDirtyChange('host-alerts', d)} />; - case 'docker-storage': return handleDirtyChange('docker-storage', d)} />; - case 'fleet-mesh': return handleDirtyChange('fleet-mesh', d)} />; - case 'notifications': return ; - case 'notification-routing': return ; - case 'webhooks': return ; - case 'security': return ; - case 'cloud-backup': return ; - case 'developer': return handleDirtyChange('developer', d)} />; - case 'data-retention': return handleDirtyChange('data-retention', d)} />; - case 'nodes': return ; - case 'app-store': return ; - case 'recovery': return ; - case 'support': return ; - case 'about': return ; - // 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} >
- {activeItem?.description ? ( -

- {activeItem.description} -

- ) : 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. */} - - }> - - {sectionElement} - - - +
@@ -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 diff --git a/frontend/src/components/settings/SettingsSectionContent.tsx b/frontend/src/components/settings/SettingsSectionContent.tsx new file mode 100644 index 00000000..77cc7ca2 --- /dev/null +++ b/frontend/src/components/settings/SettingsSectionContent.tsx @@ -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 ( +
+ + + + +
+ ); +} + +function renderSection( + sectionId: SectionId, + isPaid: boolean, + onDirtyChange: (section: SectionId, dirty: boolean) => void, +) { + switch (sectionId) { + case 'account': return ; + case 'appearance': return ; + case 'license': return ; + case 'users': return ; + case 'sso': return ; + case 'api-tokens': return ; + case 'registries': return ; + case 'labels': return ; + case 'host-alerts': return onDirtyChange('host-alerts', d)} />; + case 'docker-storage': return onDirtyChange('docker-storage', d)} />; + case 'fleet-mesh': return onDirtyChange('fleet-mesh', d)} />; + case 'notifications': return ; + case 'notification-routing': return ; + case 'webhooks': return ; + case 'security': return ; + case 'cloud-backup': return ; + case 'developer': return onDirtyChange('developer', d)} />; + case 'data-retention': return onDirtyChange('data-retention', d)} />; + case 'nodes': return ; + case 'app-store': return ; + case 'recovery': return ; + case 'support': return ; + case 'about': return ; + // 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 ? ( +

+ {item.description} +

+ ) : 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. */} + + }> + + {element} + + + + + ); +} + +// 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; +}