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; +}